系列目录
| 日期 | 标题 |
|---|---|
| 06-08 | 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构 |
| 06-10 | React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime |
| 06-17 | React 18 追踪:Lane 模型(下):调度决策与饥饿保护(本篇) |
上一篇把 31 个 lane 位怎么分组、位运算怎么读讲完了。分组只是静态布局,真正做决定的是运行期读这些位的代码。这篇读三块:getNextLanes 怎么从待处理的 lane 里选出本次渲染要处理的集合,markStarvedLanesAsExpired 怎么防止低优先级 lane 被无限插队,claimNextTransitionLane 怎么在 16 个 transition 坑位里轮转。代码以 master 分支 2021-06-17 的 568dc3532e 为准,集中在 packages/react-reconciler/src/ReactFiberLane.new.js,调度入口在 packages/react-reconciler/src/ReactFiberWorkLoop.new.js。这两周 master 上并发相关的提交依然密集,Lane 这套机制本身已经稳定下来,最近一个月的改动多在 Fizz 和 createRoot API 上,正好适合把 Lane 的调度侧读完。
场景:transition 渲染到一半,用户点了按钮
设一个场景,后面每个分支都拿它过一遍。
页面有个长列表,筛选框的 onChange 里调了 startTransition 更新列表。React 开始一次 transition 渲染,假设分到 TransitionLane1。这次渲染走时间切片,中间让出过几次主线程,做了一半。
这时用户点了页面上的按钮。点击属于 discrete event,packages/react-reconciler/src/ReactEventPriorities.new.js 里 DiscreteEventPriority 直接定义为 SyncLane,于是一个 SyncLane 更新挂到 root 上:markRootUpdated 把 pendingLanes 改成 TransitionLane1 | SyncLane,顺手清掉 suspendedLanes 和 pingedLanes。清零这个动作有讲究:新更新的落点可能正好挂在某个挂起中的 Suspense 边界的上游,把它一并渲染出来,之前挂起的 transition 就有了解除条件,所以 React 选择全部清零重来,宁可多试一次。旁边挂着一条 TODO,说精确做法应该是只清更新 fiber 的 subtreeLanes 和 return 路径上的 lane,无关兄弟子树的挂起不该被碰,当时还没做这个收窄。
每次更新进来,以及任务退出前,React 都会跑 ensureRootIsScheduled(ReactFiberWorkLoop.new.js)。它的前两个动作是:markStarvedLanesAsExpired(root, currentTime),然后 getNextLanes(root, wipLanes)。wipLanes 是正在渲染中的 lane 集合,这里就是 TransitionLane1。
getNextLanes:从 pendingLanes 到本次渲染集合
整个过程是四层筛选加一步收尾,如下图。
第一层,non-idle 优先。NonIdleLanes 是低 28 位全 1 的掩码,把 Idle、IdleHydration、Offscreen 三个 lane 排除在外。只要 pending 里还有任何非 idle 的工作,idle 工作连候选资格都没有,哪怕它没挂起、哪怕它等了很久。idle 的语义就是「别的都干完了才轮到我」。
第二层,排除 suspended。非 idle 的 pending 再扣掉 suspendedLanes。渲染撞上没就绪的 Suspense 边界时,markRootSuspended 把对应 lane 挪进 suspendedLanes,同时清掉它的过期时间,因为它已经不占 CPU 了,不该再计时。被挂起的 lane 要等 ping:数据就绪后 markRootPinged 把它放进 pingedLanes,getNextLanes 在「非 idle 工作全部挂起」的情况下退而求其次,从 pingedLanes 里选。如果既没有可渲染的也没有被 ping 的,返回 NoLanes,这个 root 本次没有可调度的渲染,已排期的任务会被取消。
场景里此时 pending 是 TransitionLane1 | SyncLane,都没挂起,前两层原样通过。
第三层,选优先级最高的组。getHighestPriorityLanes 先用上一篇讲的 lanes & -lanes 取出最高优先级 lane,再按它所属的分组返回整组:Sync 组只返回 SyncLane 自己,transition 组返回 lanes & TransitionLanes,也就是所有挂起的 transition lane 整组一起渲染,自动合批。场景里选出 SyncLane。
第四层,和进行中的渲染比较,决定打不打断。这是最容易被忽略的分支。wipLanes 非空且不等于 nextLanes 时,React 要回答一个问题:新选出的工作值不值得扔掉手头做了一半的渲染?判断依据是两边最高优先级 lane 的位值。位值越小优先级越高,所以 nextLane >= wipLane 意味着新工作不更紧急,直接返回 wipLanes,继续老渲染。位值能直接比较,靠的就是上一篇说的分组约定:越靠左的位优先级越低。
场景里 SyncLane 位值 1,TransitionLane1 位值 64,新工作更紧急,打断。ensureRootIsScheduled 发现新的 callbackPriority 是 SyncLane,取消已排期的并发任务,改走 scheduleSyncCallback,并用微任务 flushSyncCallbacks,最终在 performSyncWorkOnRoot 里同步渲染 SyncLane 这棵更新。transition 渲染做了一半的 work-in-progress 被丢弃,因为 lane 不同,没法接着用。
同步渲染提交后,markRootFinished 把 SyncLane 从 pending 里清掉,TransitionLane1 还在。ensureRootIsScheduled 再跑一轮,getNextLanes 选出 TransitionLane1,重新排一个并发任务,transition 渲染从新的已提交树上从头开始。用户看到的是:点击立即响应,列表筛选的渲染稍后重做。
这个分支还有三个特例值得说。
一是 Default 不打断 transition。如果场景里的按钮换成「fetch 回调里的一次普通 setState」,进来的是 DefaultLane。DefaultLane 位值 16,比 transition 的位值小,按位值比较应该打断,但代码里有一条专门判断:nextLane 是 DefaultLane 且 wipLane 属于 TransitionLanes 时,继续渲染 wip。注释给的理由是:default 更新和 transition 更新的唯一差别是 default 不支持 refresh transition,为这点差别扔掉半棵渲染中的树不划算。普通 setState 会等手头的 transition 渲染做完再排。
二是同优先级不打断。transition 渲染进行中用户又敲了一个键,新 transition 分到 TransitionLane2。getHighestPriorityLanes 会返回两个 transition lane 的合集,和 wipLanes(只有 TransitionLane1)不相等,但两边最高优先级 lane 都是 TransitionLane1,nextLane >= wipLane 取等号成立,不打断。新 transition 等下一轮一起渲染。
三是进行中的渲染自己挂起过,打断没有成本。wipLanes 比较外层还有一个条件:wipLanes 与 suspendedLanes 没有交集才需要保护。如果手头这次渲染已经因为「带延迟的挂起」进过 suspendedLanes,说明它展示的本来就是过渡态,直接打断换新工作,注释原话是 interrupting is fine,不用等它完成。
打断决定做出之后,调度动作本身也值得一提。ensureRootIsScheduled 用 getHighestPriorityLane(nextLanes) 作为回调优先级,和 root.callbackPriority 比较:相等说明已排期的任务优先级没变,直接复用,什么都不做;不等才取消旧任务、排新任务。场景里从 TransitionLane1 换成 SyncLane,优先级变了,走的就是取消重排这条路;如果只是又一个 Default 更新进来而当前排的就是 Default,调度器一趟空转就返回了。
收尾,entangled lane 并入。选出 nextLanes 后还有一步:扫 root.entangledLanes,把和 nextLanes 纠缠的 lane 全部并入。纠缠的意思是「这些 lane 不允许分开渲染」。哪里会产生纠缠?最典型的一处在 ReactFiberHooks.new.js 的 dispatchAction:一个 hook 的 state queue 上挂着来自不同 transition 的更新时,新 lane 会和 queue 里已有的 lane 纠缠,保证对同一个 state 的多次更新要么一起出现要么都不出现,界面不会呈现只应用了一半的中间态。markRootEntangled 还算传递闭包:C 已经和 A 纠缠,再把 A 和 B 纠缠,C 也就和 B 纠缠。注意纠缠并入发生在 wipLanes 比较之后,进行中的渲染不会因为事后追加的纠缠被打断。注释里明确说这是有意的:纠缠是尽力而为,不值得为它扔掉做完一半的工作。
纠缠并入之前还有一个合批:非并发默认模式下,nextLanes 含 InputContinuousLane 时会把 pending 的 DefaultLane 并进来。注释解释了原因:continuous 和 default 分用两个 lane 的唯一理由是 continuous 要能打断 transition 而 default 不行,渲染时它们仍应同批。这个细节和饥饿保护有关,下面会回到。
饥饿保护:每个 lane 一张过期时间表
上一篇对比过 React 17 的 expirationTime 模型。有意思的是,Lane 模型自己也留了一套过期时间,只是角色变了:17 拿过期时间当优先级本身,Lane 模型里位才是优先级,过期时间退化成一个兜底保险,专门处理「低优先级 lane 一直被插队,永远轮不到」的情况。
机制在 markStarvedLanesAsExpired,每次 ensureRootIsScheduled 都跑,函数开头的 TODO 还提到并发任务每次让出主线程时也会跑,并抱怨这样调用太勤、可以在 root 上缓存最早过期时间做快速退出,这个优化当时还没做。root 上有一个 31 格的 expirationTimes 数组,每个 lane 一格。函数遍历 pendingLanes:某格还没有过期时间,且该 lane 没被挂起(或者已被 ping),认为它是 CPU-bound 的,调 computeExpirationTime 记一个死线;某格已有死线且已到期,把这个 lane 记进 root.expiredLanes。
把场景延伸一下,看这套机制什么时候真的被触发。transition 渲染每次快做完时用户又点了一下按钮,SyncLane 进来打断,transition 的半成品被丢弃,从头再渲染,再被打断。只要点击间隔小于渲染耗时,这个 transition 理论上永远完不成。但它第一次被看到 pending 且未挂起时就被记下了 currentTime + 5000 的死线,被打断不会改变 pending 状态,死线一直在走。五秒后 markStarvedLanesAsExpired 把它记进 expiredLanes,下一次它被 getNextLanes 选中(高优先级工作排空的间隙),渲染就不再让出了。
computeExpirationTime 的数值,以当前代码为准:
- Sync、InputContinuousHydration、InputContinuous:currentTime + 250 毫秒
- DefaultHydration、Default、TransitionHydration、Transition 全部 16 个 lane:currentTime + 5000 毫秒
- Retry 5 个 lane:不过期
- SelectiveHydration、IdleHydration、Idle、Offscreen:不过期
用户交互只有 250 毫秒,这个分支上方有一段注释记录了来历:他们曾把这个值调大,www 上的一个产品指标随即回退,迹象是某个用户交互被一连串同步更新饿死了。注释说理论上的正确修法是解决那个饿死本身,但这件事反过来证明了过期时间作为兜底确实在起作用。Retry 不过期也有一段注释:作者试过让 CPU-bound 的 retry 过期,结果浏览器崩溃率飙升,没有复现样本,只有线上指标,只能先回退,留了个 TODO。这两段注释值得读原文,它们是「这个数值是线上事故换的」的直接证据。
过期之后发生什么?这里要纠正一个容易想当然的理解:过期的 lane 不会在 getNextLanes 里插队,选 lane 永远只看位优先级。expiredLanes 的消费点在 shouldTimeSlice:本次渲染的 lane 集合里只要有任何一个过期,它返回 false,performConcurrentWorkOnRoot 就走 renderRootSync,不让出、不可打断,一口气渲染完。饥饿保护的承诺因而是:一旦轮到它,保证做完,不会再被时间切片切碎,也不会被新进来的高优先级工作半路扔掉。配合前面「InputContinuous 渲染时并入 Default」的合批,被饿的 Default lane 经常能搭 continuous 的顺风车进同一个批次,250 毫秒那条线兜住的则是反方向:交互本身被同步更新的级联饿死。
shouldTimeSlice 里还有另外两条规则,顺带交代。一条是 sync-by-default 模式下 InputContinuous 和 Default 本来就不做时间切片,它们从诞生起就走同步渲染,「过期后不让出」对它们没有额外效果,过期机制真正改变行为的是 transition 这批默认切片的工作。另一条是 allowConcurrentByDefault 这个 flag 打开且 root 带 ConcurrentUpdatesByDefaultMode 时,一切更新默认并发、永远切片,这条路径当时还是实验性质,默认关闭。
挂起的 lane 不在这套机制覆盖范围内。markRootSuspended 清掉过期时间,挂起期间不计时,数据就绪 ping 回来后才重新起算。这个设计说得通:等数据的日子不算被饿死。
Transition lane 的轮转分配
最后一块:一个 transition 更新进来时,16 个 transition lane 里选哪个?
requestUpdateLane(ReactFiberWorkLoop.new.js)里,判断当前更新是 transition 后调 claimNextTransitionLane。这个函数就是一个循环指针:模块级的 nextTransitionLane 从 TransitionLane1 开始,每次左移一位,移出 TransitionLanes 掩码范围就绕回 TransitionLane1。retry lane 同理,claimNextRetryLane 在 5 个 retry 坑位里轮转。
为什么同组要 16 个坑位,所有 transition 共享一个不就行了?因为每个 lane 在 root 上有独立的 eventTime、过期时间、suspended 与 pinged 状态。不同 transition 各占一个坑位,它们的挂起、过期、完成就能分开记账,互不连坐。而选 lane 时 getHighestPriorityLanes 又能把整组 transition 一把取出来合批渲染,独立记账和合并执行两头都占。
另一个细节在同一个函数里:同一事件中的所有 transition 更新共享同一个 lane。模块级缓存 currentEventTransitionLane,事件里第一个 transition 更新时 claim 一次,后续更新复用;缓存的复位点在 performConcurrentWorkOnRoot 入口,进入并发工作循环说明事件已经结束了。注释解释了动机:同一事件、同一优先级的更新,lane 分配结果必须稳定,所以算法的输入必须在事件期间保持不变,手段就是把首个输入缓存到事件结束。
retry lane 顺带说一句。Suspense 边界从挂起恢复后的重试渲染用 claimNextRetryLane 分配,逻辑和 transition 一样是循环左移,只是坑位只有 5 个。retry 和触发它的 transition 分开记账,重试挂起时不拖累原 transition 的状态。
收个尾
到这里 lane 的生命周期闭环了:更新进来时 requestUpdateLane 分 lane,markRootUpdated 记账;调度时 getNextLanes 选批,markStarvedLanesAsExpired 兜底;渲染完 markRootFinished 清账。下一篇读 ensureRootIsScheduled 之后的事:Sync 路径和 Concurrent 路径怎么分,时间切片怎么让出主线程,以及 Scheduler 选 MessageChannel 做宏任务的原因。

