React 18 追踪:Concurrent 工作循环与时间切片

5 分钟阅读
·

系列前三篇分别看了 Fiber 数据结构和 Lane 模型的上下两篇,回答的都是「更新怎么表示、怎么选」的问题。这一篇回答最后一个问题:选出来的 lane 怎么被执行。也就是工作循环(work loop)这一层,对应的源码是 packages/react-reconciler/src/ReactFiberWorkLoop.new.js 和独立的 packages/scheduler/ 包。读完这一篇,从 setState 到上屏的整条链路就闭环了,Fiber、Lane、工作循环三篇收束。参考代码状态是 master 分支 2021-06-24 的 73ffce1b6f,老规矩,并发相关实现都在 .new fork 里。

系列目录

日期 标题
06-08 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构
06-10 React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime
06-17 React 18 追踪:Lane 模型(下):调度决策与饥饿保护
06-24 React 18 追踪:Concurrent 工作循环与时间切片(本篇)

ensureRootIsScheduled:每次更新都要过的闸口

scheduleUpdateOnFiber 把 lane 挂到 root 之后,最后一步固定是调 ensureRootIsScheduled(root, currentTime)。这个函数是 Sync 和 Concurrent 两条路径的分岔口,值得逐段看。

它先调 markStarvedLanesAsExpired 做饥饿检查(第 3 篇的内容),然后 getNextLanes 算出本次该处理的 nextLanes。如果 nextLanes === NoLanes,说明没活干,把已有的调度任务取消掉直接返回。有活干时,用 getHighestPriorityLane(nextLanes) 拿到代表优先级 newCallbackPriority,和 root 上记录的 callbackPriority 比较:相同说明已有一个同等优先级的任务在排队,直接复用返回,什么都不做;不同才取消旧任务、按新优先级重新调度。连续多次 setState 只调度一次、高优先级更新能插掉低优先级任务,靠的就是这一段比较。拿输入框举例:一次按键产生一个 Default lane 的更新,调度进 Scheduler;同一事件里业务代码又 setState 了一次,lane 合并进 root 后 getNextLanes 的结果不变,newCallbackPriority 与已有任务相同,函数在比较处就返回了,不会重复调度。随后用户点击按钮,点击属于 discrete 事件,取到的 lane 是 SyncLane,newCallbackPriority 变了,旧的 Scheduler 任务被 cancelCallback 取消,改走下面要说的同步路径。

真正的分野在最后一段:

if (newCallbackPriority === SyncLane) {
  // 同步回调进一个专门的内部队列
  scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  if (supportsMicrotasks) {
    scheduleMicrotask(flushSyncCallbacks);
  } else {
    scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
  }
  newCallbackNode = null;
} else {
  // 按 lane 映射出 Scheduler 优先级
  switch (lanesToEventPriority(nextLanes)) {
    case DiscreteEventPriority:
      schedulerPriorityLevel = ImmediateSchedulerPriority; break;
    case ContinuousEventPriority:
      schedulerPriorityLevel = UserBlockingSchedulerPriority; break;
    // Default -> Normal, Idle -> Idle
  }
  newCallbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root),
  );
}

SyncLane 不经过 Scheduler,走一个内部同步队列加微任务;其他 lane 全部交给 Scheduler。两条路径下面分别说。

Sync 路径:同步队列加微任务

同步路径的入口是 performSyncWorkOnRoot。它先 flushPassiveEffects,再 getNextLanes 确认还有 SyncLane 的活,然后 renderRootSync(root, lanes) 一口气渲染完整棵树。渲染循环是 workLoopSync

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

while 循环里没有任何让出检查,workInProgress 指针不为空就一直跑。这条路径的行为和 React 17 的同步渲染一致:阻塞主线程直到算完,然后 commitRoot 提交。

调度的时机值得说一下。同步任务进队列之后,用 scheduleMicrotask(flushSyncCallbacks) 安排一个微任务来清空队列。微任务在当前宏任务结束后立刻执行,早于浏览器的绘制,所以同一个事件处理器里连续调三次 setState,实际只渲染一次,这就是批量更新在当时的新实现。supportsMicrotasks 不成立的环境降级成 scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks),改走 Scheduler 的最高优先级任务。注意即便走 Scheduler,后续的渲染本身仍然是 workLoopSync 式的不可中断循环,「不可中断」和「什么时候被调度」是两件事。

Scheduler:一个独立的小调度器

Concurrent 路径的任务全部进 packages/scheduler/,这是一个独立的包,设计上不绑 React,谁都可以拿去做任务切片。核心文件是 src/forks/Scheduler.js

它维护两个小顶堆:taskQueueexpirationTime 排序,存可以立刻执行的任务;timerQueuestartTime 排序,存 setTimeout 语义的延迟任务。unstable_scheduleCallback 根据优先级算超时时间:Immediate 是 -1(立刻过期),UserBlocking 250ms,Normal 5000ms,Low 10000ms,Idle 是 31 位整数最大值(永不过期)。expirationTime = currentTime + timeout,过期意味着不再参与时间切片,必须马上执行完。advanceTimers 在每一轮循环前后把到期的延迟任务从 timerQueue 搬进 taskQueue。

主循环 workLoop 的逻辑:peek taskQueue 堆顶,只要当前任务没过期且 shouldYieldToHost() 返回 true,就 break 让出;否则取出任务的 callback 执行。这里有一个关键设计,continuation:

const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
  currentTask.callback = continuationCallback;  // 没干完,挂回去下次接着跑
} else {
  if (currentTask === peek(taskQueue)) {
    pop(taskQueue);  // 干完了,任务出堆
  }
}

任务的 callback 返回一个函数,表示工作没做完,这个函数就是续体,被挂回 currentTask.callback,下一轮宏任务从断点继续;返回非函数(React 里是 null)才算任务结束。workLoop 退出时如果堆里还有任务,返回 true,外层会再安排一次宏任务。

顺带看 unstable_cancelCallback 的实现,就一行有效的:task.callback = null。注释解释了为什么不能直接从堆里删:小顶堆是数组实现的,只能删堆顶,删不了任意节点。所以取消是惰性的,被取消的任务留在堆里,等它浮到堆顶、workLoop 发现 callback 不是函数时直接 pop 丢弃。前面 ensureRootIsScheduledcancelCallback 取消旧任务就是走的这条路,旧任务节点本身并不会立刻消失。

shouldYieldToHost 与 5ms 切片

让出的判断在 shouldYieldToHost。先交代一个核实结果:规划这个系列时我记的是 Scheduler 里有个 frameYieldMs 常量,翻 2021-06-24 的 SchedulerFeatureFlags.js 才发现里面只有 enableSchedulerDebuggingenableIsInputPendingenableProfiling 三个开关,没有 frameYieldMs。真正的切片间隔直接写在 Scheduler.js 里:

let yieldInterval = 5;
let deadline = 0;
const maxYieldInterval = 300;

每轮进入 performWorkUntilDeadline 时设 deadline = currentTime + yieldInterval,也就是给本轮工作 5ms 预算。enableIsInputPending 默认关闭,所以 shouldYieldToHost 实际生效的是 fallback 分支,一行:

return getCurrentTime() >= deadline;

干满 5ms 就让出主线程。5ms 明显小于一帧的 16.6ms 预算,浏览器在两段 React 工作之间有机会处理输入和绘制。isInputPending 那套更精细的逻辑(有待处理输入或绘制才让出,否则最长连续跑 300ms)当时已经被写出来了,但包在 enableIsInputPending 开关后面,是个还没启用的实验。

回到 reconciler 一侧,workLoopConcurrent 就是在 fiber 层面消费这个让出信号:

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

workLoopSync 的差别只有循环条件里多了 !shouldYield()。每处理完一个 fiber 节点问一次 Scheduler:到点了吗?到点就停,指针留在 workInProgress 上,下一段从原地继续。

为什么用 MessageChannel 而不是 setTimeout

Scheduler 让出之后怎么把自己再调度回来,候选方案有三个,代码里按优先级排列:Node 和旧 IE 用 setImmediate,浏览器用 MessageChannel,都不行才退回 setTimeout(0)。源码注释把选 MessageChannel 的原因写得很直白:

We prefer MessageChannel because of the 4ms setTimeout clamping.

HTML 规范规定 setTimeout 嵌套超过 5 层后最小间隔被钳制到 4ms。Scheduler 的工作模式恰好是反复嵌套调度(干 5ms、让出、再调度、再让出),用 setTimeout 意味着每轮至少白等 4ms,切片收益直接砍掉近一半。MessageChannel 的 postMessage 没有这个钳制,消息在下一个宏任务就派发,「让出主线程」的成本只是让浏览器插一次队处理输入和绘制,然后立刻回来继续干活。setImmediate 语义更好但只有两个平台有,浏览器环境的标准答案因此是 MessageChannel。

任务续体:一次渲染怎么被切成很多段

Scheduler 侧的 continuation 机制有了,reconciler 侧谁来返回续体?看 performConcurrentWorkOnRoot 的结尾:

ensureRootIsScheduled(root, now());
if (root.callbackNode === originalCallbackNode) {
  // 当前执行的任务没被取消也没被替换,返回续体
  return performConcurrentWorkOnRoot.bind(null, root);
}
return null;

每段切片跑完(渲染没完成,exitStatus 是 RootIncomplete),先调 ensureRootIsScheduled 重新评估。如果 root 上挂的 callbackNode 还是函数开头记下的那个,说明调度状态没变,于是返回一个 bind 好的自己作为续体,Scheduler 把它挂回 currentTask.callback,下一轮宏任务接着渲染。如果渲染期间有新更新进来(比如 render phase update)导致 ensureRootIsScheduled 取消旧任务换上了新的,callbackNode 对不上,这里返回 null,旧任务就此终结,已算出的部分被丢弃,新任务从头开始。

恢复渲染不需要保存调用栈。渲染进度本来就外化在 Fiber 树上:workInProgress 指针指到哪、哪些子树已经 complete,都记在节点上。这是第 1 篇说的「把递归栈显式化为链表结构」在工作循环层的直接收益,Stack Reconciler 时代递归到一半想中断是做不到的。

performConcurrentWorkOnRoot 开头还有两个动作值得知道。一是先 flushPassiveEffects,把上一次 commit 挂起的 useEffect 先清掉,因为 effect 里可能又安排了更新,不清掉的话本次 getNextLanes 的输入就是脏的;如果清的过程中当前任务被取消了(root.callbackNode 变了),直接返回 null 退出。二是错误处理:渲染抛错(RootErrored)时,会把本次涉及的 lane 重新用 renderRootSync 同步重试一遍,同步渲染能挡住并发的数据修改,再失败才带着错误进 commit。时间切片是正常路径的待遇,出错路径一律退回同步。

还有一个容易看漏的细节:进了 Scheduler 不等于一定切片。performConcurrentWorkOnRoot 里用 shouldTimeSlice(在 ReactFiberLane.new.js)决定这次渲染调 renderRootConcurrent 还是 renderRootSync:lanes 里有过期 lane 则同步渲染到底;sync-by-default 模式下 InputContinuous 和 Default 这一组「SyncDefaultLanes」也同步渲染;只有 transition、retry 这类 lane 才真正走 workLoopConcurrent。所以调度层有两道闸:ensureRootIsScheduled 决定进不进 Scheduler,shouldTimeSlice 决定进去之后切不切片。第 3 篇讲的饥饿保护在这里闭环:lane 一旦过期,下一次调度到它就直接同步渲染,不再让出。

一个场景走到底:transition 渲染到一半,用户点了按钮

第 3 篇结尾留了这个问题,现在有全部零件了,可以完整回答。假设一个并发 root 里,startTransition 包着的更新触发了一次大列表重渲染:

  1. requestUpdateLanerequestCurrentTransition() 非空,claimNextTransitionLane 分到一个 Transition lane,markRootUpdated 挂上 root。
  2. ensureRootIsScheduled 算出 newCallbackPriority 是这个 Transition lane,走 else 分支,lanesToEventPriority 把它映射成 Normal 优先级,scheduleCallback 进 Scheduler 的 taskQueue,expirationTime 是 5000ms 后。
  3. Scheduler 用 MessageChannel 发出宏任务,performWorkUntilDeadline 设下 5ms 的 deadline,workLoop 取出任务执行 performConcurrentWorkOnRootshouldTimeSlice 检查通过,进 renderRootConcurrentworkLoopConcurrent 一个 fiber 一个 fiber 往下算。
  4. 算到一半用户点了按钮。浏览器的事件循环插不进来,点击事件排在队列里,要等当前宏任务结束才能派发。这一段 5ms 切片跑完,workLoopConcurrent 退出,Scheduler 通过 MessageChannel 约了下一个宏任务继续。
  5. 主线程空出来,点击事件先派发(它排在 MessageChannel 消息前面)。事件处理器里的 setState 是 discrete 事件,当时 DiscreteEventPriority 就直接定义为 SyncLane(ReactEventPriorities.new.js)。scheduleUpdateOnFiber 把 SyncLane 挂上 root,ensureRootIsScheduled 发现 newCallbackPriority 变成了 SyncLane,cancelCallback 取消 Scheduler 里那个还没跑完的任务,改走同步队列加微任务。
  6. 点击事件的宏任务结束,微任务里 flushSyncCallbacks 执行 performSyncWorkOnRootworkLoopSync 一口气渲染完。transition 的更新还挂在 root 上,但这次 getNextLanes 只返回 SyncLane,同步渲染只处理按钮的更新,很快 commit,界面立刻响应点击。同步渲染结束的 ensureRootIsScheduled 发现 Transition lane 还挂着,重新 scheduleCallback 进 Scheduler。
  7. 之后 Scheduler 的消息宏任务派发,taskQueue 堆顶是那个已被取消的任务(callback 为 null),直接出堆丢弃;再下一轮才轮到新安排的 transition 任务,整棵树的 transition 渲染从头再来一遍。

被丢弃的那半棵 workInProgress 树没有任何清理动作,下次 renderRootConcurrent 开头 prepareFreshStack 直接重置。双缓冲保证 current 树从头到尾没动过,用户看到的是旧界面多停留了一会,然后点击结果先上屏,列表更新后上屏。

commit 之后还有一环:passive effects

链路图里画到浏览器绘制为止,其实 commit 之后还有一环和 Scheduler 有关。commitRootImpl 里如果本次提交的树上挂着 passive effects(useEffect 的回调),会 scheduleCallback(NormalSchedulerPriority, () => { flushPassiveEffects(); return null; }),把 effect 的 flush 也作为一个 Normal 优先级的任务交给 Scheduler。也就是说 useEffect 的异步执行复用的正是本篇讲的这套机制:不和渲染抢主线程,排在 transition 渲染同级的队列里,可以被切片让位。layout effects(useLayoutEffect)则相反,在 commit 的 layout 阶段同步执行完,浏览器绘制之前一定结束。同一个 Scheduler,三种用法:并发渲染的宿主、useEffect 的异步队列、以及不支持微任务环境下 sync 队列的降级通道。

收束:一次 setState 的完整链路

把前三篇和本篇串起来,一次 setState 从触发到上屏的完整链路如下:

一次 setState 从触发到上屏的完整链路

沿图走一遍。requestUpdateLane 按更新来源取 lane:非并发模式直接 SyncLane,transition 更新走 claimNextTransitionLane 轮转分配(第 3 篇),事件更新由 getCurrentEventPriority 给出对应 lane。scheduleUpdateOnFiber 把 lane 一路标到 root,markRootUpdated 记下 eventTime,这是饥饿保护的输入。ensureRootIsScheduledgetNextLanes 做决策,SyncLane 进同步队列加微任务,其余 lane 映射成 Scheduler 优先级进 taskQueue。Scheduler 用 MessageChannel 宏任务驱动 workLoop,每 5ms 让出一次;渲染在 Fiber 双缓冲树上进行(第 1 篇),commit 之前页面上的 current 树不受影响,中断丢弃也没有副作用。渲染完成或过期后 commitRoot 一次性提交 mutation 和 layout effects,浏览器绘制,用户看到新界面。

回头看这个系列前四篇的分工:第 1 篇的 Fiber 结构回答「中断后现场在哪」,第 2 篇的 31 个 lane 回答「更新怎么表示和合并」,第 3 篇的 getNextLanes 回答「这次该做哪批」,本篇的工作循环和 Scheduler 回答「选出来之后怎么执行、怎么让出」。四个问题串起来,concurrent 渲染在 2021 年 6 月的 master 上已经是一个能讲圆的整体了。

这一篇落笔时 master 上调度层已经稳定,入口 API 层正在动:06-09 ReactDOM.render 被标记废弃,06-15 createRoot(..., {hydrate: true}) 换成了独立的 hydrateRoot,06-17 内部构建里移除了 unstable_createRoot。下一篇就看这些新入口怎么接到这套调度上。


851 字 · 52 段落
xi ming

Written by xi ming You should follow him on Github