React 19 内核探秘
第4章 调度器:React 的 CPU 调度算法
第4章 调度器:React 的 CPU 调度算法
本章要点
- Scheduler 的设计哲学:为什么 React 要自己实现一个任务调度器
- 优先级模型:5 级优先级系统的设计与 Lane 模型的映射关系
- 时间切片(Time Slicing)的实现:
shouldYield与 5ms 时间窗口- 任务队列的数据结构:小顶堆(Min Heap)的选择与实现
MessageChannelvsrequestIdleCallback:React 为何放弃了浏览器原生 API- 从
scheduleCallback到performWorkUntilDeadline:一个任务的完整生命周期- 延迟任务与过期机制:饥饿问题的解决方案
2018 年,React 团队做了一个在当时看来颇为大胆的决定——他们要在 JavaScript 中实现一个任务调度器。
这不是一个普通的任务队列。React 团队要实现的,是一个具备优先级抢占、时间切片、过期淘汰能力的调度器,它在概念上与操作系统内核中的 CPU 调度器极其相似。这个调度器最终以一个独立的 npm 包发布,名字简单直白:scheduler。
为什么 React 需要自己的调度器?答案藏在浏览器的运行模型里。
4.1 浏览器的单线程困境
一个线程,所有的事
浏览器的主线程是一个极其繁忙的执行环境。JavaScript 执行、DOM 操作、样式计算、布局、绘制、垃圾回收——所有这些任务都在同一个线程上顺序执行。浏览器通过事件循环(Event Loop)来协调这些工作:
// 浏览器事件循环的简化模型
function eventLoop() {
while (true) {
// 1. 从宏任务队列取出一个任务执行
const task = macroTaskQueue.dequeue();
if (task) task.run();
// 2. 执行所有微任务
while (microTaskQueue.length > 0) {
microTaskQueue.dequeue().run();
}
// 3. 如果到了渲染时机(通常 16.67ms 一次)
if (shouldRender()) {
// 执行 requestAnimationFrame 回调
runRAFCallbacks();
// 样式计算 → 布局 → 绘制
style();
layout();
paint();
}
// 4. 如果有空闲时间
if (hasIdleTime()) {
runIdleCallbacks(); // requestIdleCallback
}
}
}
问题在于:如果步骤 1 中的任务执行时间过长,步骤 3 的渲染就会被延迟,用户感知到的就是卡顿。而 React 的协调过程——对比新旧虚拟 DOM 树,计算需要更新的节点——恰恰是一个可能非常耗时的 JavaScript 任务。
requestIdleCallback:一个美好但不够用的 API
浏览器其实提供了一个看起来完美的 API——requestIdleCallback(rIC)。它允许开发者在浏览器空闲时执行低优先级的工作:
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回当前帧剩余的空闲时间
while (deadline.timeRemaining() > 0 && hasWork()) {
doWork();
}
});
React 团队最初确实考虑过使用 rIC,但很快发现了它的几个致命问题:
- 调用频率不可控:rIC 的调用时机完全由浏览器决定。在高负载场景下,rIC 可能被延迟到几百毫秒甚至更久才执行
- 最大超时只有 50ms:即使在完全空闲的情况下,
timeRemaining()最多返回 50ms,这个限制是硬编码在规范中的 - 兼容性问题:Safari 直到 2024 年仍未支持 rIC
- 没有优先级概念:rIC 只有"空闲时执行"一种语义,无法区分"紧急更新"和"普通更新"
// React 早期基于 rIC 的原型(已废弃)
function workLoop(deadline: IdleDeadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// 还有工作没做完,请求下一次空闲回调
requestIdleCallback(workLoop);
// ⚠️ 问题:不知道下次什么时候会被调用
// ⚠️ 可能是 16ms 后,也可能是 200ms 后
}
}
React 需要的是更精确、更可控的调度能力。于是,他们决定自己造一个。
4.2 Scheduler 的架构设计
独立包的设计决策
React 的调度器以 scheduler 这个独立 npm 包的形式存在,这个设计决策本身就值得讨论。为什么不把调度逻辑直接写在 react-reconciler 里?
react
├── react // 核心 API(createElement, hooks 等)
├── react-dom // DOM 渲染器
├── react-reconciler // 协调器(Fiber 工作循环)
└── scheduler // 调度器(独立包)
答案是通用性。React 团队最初的愿景是让 Scheduler 成为一个通用的浏览器任务调度库——不仅 React 可以用,任何需要在主线程上做任务调度的库都可以用。虽然这个愿景至今没有完全实现(Scheduler 仍然主要服务于 React 生态),但独立包的设计让它的边界非常清晰:
- Scheduler 只负责"何时执行",不关心"执行什么"
- React Reconciler 负责"执行什么",不关心"何时执行"
这种关注点分离(Separation of Concerns)让两个模块可以独立演进。
核心数据结构
Scheduler 内部维护两个任务队列:
// packages/scheduler/src/forks/Scheduler.js 的核心结构
// 存放已就绪的任务(按 sortIndex 排序,sortIndex = expirationTime)
var taskQueue: Array<Task> = [];
// 存放延迟任务(按 startTime 排序)
var timerQueue: Array<Task> = [];
// 任务节点的定义
interface Task {
id: number; // 自增 ID,用于在 sortIndex 相同时保持插入顺序
callback: SchedulerCallback; // 要执行的工作函数
priorityLevel: PriorityLevel; // 优先级
startTime: number; // 开始时间(当前时间 + delay)
expirationTime: number; // 过期时间(startTime + timeout)
sortIndex: number; // 排序索引(taskQueue 中为 expirationTime,timerQueue 中为 startTime)
}
两个队列都使用小顶堆(Min Heap) 实现。为什么是小顶堆而不是普通数组?
// 如果用数组 + sort:
// 每次插入后排序:O(n log n)
// 取最小值:O(1)
// 总复杂度:O(n log n)
// 使用小顶堆:
// 插入:O(log n) ✓ 更优
// 取最小值:O(1)
// 删除最小值:O(log n)
// 总复杂度:O(log n) ✓ 更优
React 实现了一个简洁的小顶堆:
// packages/scheduler/src/SchedulerMinHeap.js
type Heap<T extends Node> = Array<T>;
type Node = { id: number; sortIndex: number };
// 插入:将节点放到末尾,然后上浮
export function push<T extends Node>(heap: Heap<T>, node: T): void {
const index = heap.length;
heap.push(node);
siftUp(heap, node, index);
}
// 查看堆顶(最小值)
export function peek<T extends Node>(heap: Heap<T>): T | null {
return heap.length === 0 ? null : heap[0];
}
// 弹出堆顶,将末尾元素移到堆顶,然后下沉
export function pop<T extends Node>(heap: Heap<T>): T | null {
if (heap.length === 0) return null;
const first = heap[0];
const last = heap.pop()!;
if (last !== first) {
heap[0] = last;
siftDown(heap, last, 0);
}
return first;
}
function siftUp<T extends Node>(heap: Heap<T>, node: T, i: number): void {
let index = i;
while (index > 0) {
const parentIndex = (index - 1) >>> 1; // 位运算取父节点
const parent = heap[parentIndex];
if (compare(parent, node) > 0) {
// parent 比 node 大,交换
heap[parentIndex] = node;
heap[index] = parent;
index = parentIndex;
} else {
return; // 已满足堆性质
}
}
}
function compare(a: Node, b: Node): number {
// 先比较 sortIndex,相同则比较 id(保持插入顺序)
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
graph TD
subgraph "小顶堆 - taskQueue"
A["Task A<br/>expirationTime: 100<br/>ImmediatePriority"] --> B["Task C<br/>expirationTime: 350<br/>NormalPriority"]
A --> C["Task B<br/>expirationTime: 200<br/>UserBlockingPriority"]
B --> D["Task D<br/>expirationTime: 600<br/>LowPriority"]
B --> E["Task E<br/>expirationTime: 500<br/>NormalPriority"]
end
style A fill:#ff6b6b,stroke:#333,color:#fff
style C fill:#ffa94d,stroke:#333,color:#fff
style B fill:#69db7c,stroke:#333
style E fill:#69db7c,stroke:#333
style D fill:#74c0fc,stroke:#333
图 4-1:Scheduler 的小顶堆结构(按 expirationTime 排序)
4.3 优先级模型
五级优先级
Scheduler 定义了 5 个优先级级别,每个级别对应不同的超时时间:
// packages/scheduler/src/SchedulerPriorities.js
export const NoPriority = 0; // 无优先级
export const ImmediatePriority = 1; // 立即执行
export const UserBlockingPriority = 2; // 用户交互阻塞
export const NormalPriority = 3; // 普通优先级
export const LowPriority = 4; // 低优先级
export const IdlePriority = 5; // 空闲优先级
// 每个优先级对应的超时时间
var IMMEDIATE_PRIORITY_TIMEOUT = -1; // 立即过期
var USER_BLOCKING_PRIORITY_TIMEOUT = 250; // 250ms
var NORMAL_PRIORITY_TIMEOUT = 5000; // 5s
var LOW_PRIORITY_TIMEOUT = 10000; // 10s
var IDLE_PRIORITY_TIMEOUT = 1073741823; // maxSigned31BitInt ≈ 永不过期
超时时间的设计非常巧妙。它不是简单地决定"什么时候执行",而是决定"什么时候过期"。任务的 expirationTime = startTime + timeout:
- ImmediatePriority:timeout = -1,意味着
expirationTime < currentTime,任务"生下来就已经过期",必须立即执行 - UserBlockingPriority:250ms 后过期。用户点击、输入等交互响应通常需要在 100-300ms 内完成
- NormalPriority:5 秒后过期。大多数状态更新(如数据获取后的渲染)使用这个优先级
- LowPriority:10 秒后过期。不那么紧急的更新
- IdlePriority:实际上永不过期(
maxSigned31BitInt= 1073741823ms ≈ 12.4 天;源码注释写的是 31 位有符号整数上限Math.pow(2, 30) - 1,12.4 天是换算结果而非注释原文)。只在浏览器完全空闲时执行
4.3.1 compare 函数的两层排序:sortIndex 主、id 副
仔细看 SchedulerMinHeap.js 的 compare 函数——它有两个比较维度:
function compare(a, b) {
// Compare sort index first, then task id.
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
a.sortIndex - b.sortIndex 是主排序——按过期时间(taskQueue)或开始时间(timerQueue)排。
a.id - b.id 是副排序——当两个任务的 sortIndex 相同时、按创建顺序(id 自增)排。
为什么副排序用 id 而不是任意 tiebreaker?——因为要保证 "相同优先级的任务按插入顺序执行"。如果没有这个次序、两个同优先级任务的执行顺序不确定(取决于堆的内部结构)——这在某些用户可见的场景里会破坏语义。
注意场景要选对:React 18+ 的 SyncLane 更新不走 Scheduler 的 taskQueue(走 microtask 的 flushSyncWorkOnAllRoots),且每个 root 同时只会挂一个 Scheduler task(ensureRootIsScheduled 会复用或取消)——所以"两次点击、两个 SyncLane 任务比 id"并不会发生。真实场景是:同一毫秒内多次 scheduleCallback——比如页面上有多个 root 同时被调度、或 Scheduler 的其他调用方连续提交同优先级任务——它们的 sortIndex(同一毫秒的 expirationTime)相同,靠 id 保证 FIFO。
这种 "主排序 + 次排序保插入顺序" 的模式在所有需要稳定性的排序场景都出现——Python 的 sorted() 是 stable sort、Java 的 Arrays.sort 对象版本也 stable。Scheduler 的小顶堆天然不是 stable(堆操作会打乱)、但通过 tiebreaker 显式地 restore stability——一行代码换回稳定性保证。
4.3.2 >>> 1 位运算取父节点——性能细节
看 siftUp / siftDown 里的 (index - 1) >>> 1——无符号右移 1 位、等价于 Math.floor((index - 1) / 2):
const parentIndex = (index - 1) >>> 1;
为什么不用 / 2 或 Math.floor?——因为在 V8 里、位运算比 / 快(整数除法 vs 单周期 shift)、比 Math.floor 更快(省函数调用)。对 sift 这种频繁调用的函数、微小差异累积显著。
>>> 而不是 >>——无符号右移确保 index 是非负整数时结果也是非负整数。>> 是算术右移、对负数保留符号位——但 index 不可能负、所以两者在这里等价。选 >>> 是因为它表达 "这里处理的是数组下标、一定非负"——类型语义更清晰。
这种 "用位运算替代算术运算" 的微优化是系统编程的通用手法——移位在指令级比通用除法便宜是通识。对每次插入触发 log n 次 sift的热路径来说,这是零成本就能拿到的确定性收益,也符合 Scheduler 全文件"避免一切不必要开销"的编码风格。
4.3.3 从 React Lane 到 Scheduler Priority 的映射
在第3章我们提到了 React 的 Lane 优先级系统。Lane 是 React 内部的优先级模型,而 Scheduler 有自己的优先级模型。v19 没有 lanesToSchedulerPriority——真实路径是两步:
lanesToEventPriority(ReactEventPriorities.js)把 lane 收成事件优先级;scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js)里再switch到 Scheduler 优先级。
// packages/react-reconciler/src/ReactEventPriorities.js(v19.0.0)
function lanesToEventPriority(lanes: Lanes): EventPriority {
const lane = getHighestPriorityLane(lanes);
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return DiscreteEventPriority; // ≈ SyncLane
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return ContinuousEventPriority; // ≈ InputContinuousLane
}
if (includesNonIdleWork(lane)) {
return DefaultEventPriority;
}
return IdleEventPriority;
}
// packages/react-reconciler/src/ReactFiberRootScheduler.js
// scheduleTaskForRootDuringMicrotask 内(示意)
if (includesSyncLane(nextLanes)) {
// Sync 工作走 microtask 同步 flush,不进 Scheduler taskQueue
// → 因此不再映射到 ImmediatePriority
root.callbackNode = null;
return SyncLane;
}
switch (lanesToEventPriority(nextLanes)) {
// 注释原话:Scheduler 虽有 ImmediatePriority,
// 但 sync 已改走 microtask,这条路径不再用它。
case DiscreteEventPriority:
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
}
scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
graph LR
subgraph "React Lane(v19 位值)"
L1["SyncLane<br/>(0b...00010)"]
L2["InputContinuousLane<br/>(0b...01000)"]
L3["DefaultLane<br/>(0b...100000)"]
L4["IdleLane<br/>(0b...100000000000000)"]
end
subgraph "调度去向"
S0["microtask<br/>flushSyncWork*<br/>不进 Scheduler"]
S2["UserBlockingPriority<br/>timeout = 250ms"]
S3["NormalPriority<br/>timeout = 5000ms"]
S4["IdlePriority<br/>timeout = ∞"]
end
L1 --> S0
L2 --> S2
L3 --> S3
L4 --> S4
style L1 fill:#ff6b6b,stroke:#333,color:#fff
style L2 fill:#ffa94d,stroke:#333,color:#fff
style L3 fill:#69db7c,stroke:#333
style L4 fill:#74c0fc,stroke:#333
style S0 fill:#ff6b6b,stroke:#333,color:#fff
style S2 fill:#ffa94d,stroke:#333,color:#fff
style S3 fill:#69db7c,stroke:#333
style S4 fill:#74c0fc,stroke:#333
图 4-2:React Lane 到调度路径的映射(v19.0.0)
要点:SyncLane 工作走 microtask,不进 Scheduler 的 ImmediatePriority;能走到 scheduleCallback 的 Discrete/Continuous 一律绑 UserBlocking。Scheduler 的 ImmediatePriority 常量仍在包内,但 React reconciler 生产排程不再用它。
饥饿问题与过期机制
在任何优先级调度系统中,都存在一个经典问题:饥饿(Starvation)。如果高优先级任务源源不断地到来,低优先级任务可能永远得不到执行。
React 的解决方案是过期时间。每个任务在创建时就计算好了过期时间。当任务过期后,它会被当作最高优先级处理——因为在小顶堆中,过期时间越小的排在越前面,而已经过期的任务的 expirationTime 一定小于当前时间。
// 饥饿场景示例
function demonstrateStarvation() {
// 时间线:
// T=0: 创建 NormalPriority 任务 A,expirationTime = 5000
// T=100: 创建 UserBlockingPriority 任务 B,expirationTime = 350
// T=200: 创建 UserBlockingPriority 任务 C,expirationTime = 450
// T=300: 创建 UserBlockingPriority 任务 D,expirationTime = 550
// ...
// T=5000: 任务 A 过期!
// 此时 A.expirationTime (5000) < currentTime (5001)
// A 必须在下一个工作循环中被处理
// 在 workLoop 中:
// if (currentTask.expirationTime <= currentTime) {
// // 任务已过期,不会被 shouldYield() 中断
// // 即使时间片用完也继续执行
// }
}
这个设计保证了:任何任务最终都会被执行,只是执行的时机取决于它的优先级。高优先级任务优先执行,但低优先级任务不会被无限推迟。
4.3.4 生产路径没有 needsPaint——requestPaint 是空函数
v19 生产 Scheduler(packages/scheduler/src/forks/Scheduler.js)里:
// 生产路径真实形态
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
function requestPaint() {} // 空函数,无 needsPaint 状态
没有 needsPaint / continuousYieldMs / maxYieldMs 三段式分支——那些只出现在 SchedulerMock.js(测试 mock)或更早历史构建里,不是 v19 浏览器生产路径。
unstable_requestPaint 仍导出,但实现是 no-op:既然每 ~frameYieldMs(默认 5ms)都会 yield,显式 paint 信号没有额外作用。读源码时若在 mock/测试文件里看到 needsPaint,勿当成生产实现。
4.4 时间切片的实现
5ms 的时间窗口
时间切片是 Scheduler 最核心的能力。它的实现原理出奇地简单:
// packages/scheduler/src/forks/Scheduler.js
// 每个时间切片的长度:5ms(frameYieldMs)
let frameInterval = frameYieldMs;
// performWorkUntilDeadline 入口处写入:本段工作的起始时刻
let startTime = -1;
// 是否应该让出主线程(v19.0.0 生产路径)
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// 主线程阻塞时间还不到一个切片,继续干
return false;
}
// 直接让出——效果就是每 ~frameInterval(默认 5ms)yield 一次。
return true;
}
为什么是 5ms 而不是更长或更短?
- 16.67ms(一帧)太长:如果 React 用满一整帧,浏览器就没有时间做渲染
- 1ms 太短:频繁切换任务的开销(调度开销、函数调用开销)会超过实际工作的时间
- 5ms 是一个平衡点:给浏览器留下约 11ms 来完成渲染工作(样式计算 + 布局 + 绘制),同时也能完成足够多的 React 工作
gantt
title 一帧(16.67ms)内的时间分配
dateFormat X
axisFormat %Lms
section React 工作
Scheduler 时间切片 :active, 0, 5
section 浏览器渲染
样式计算 :crit, 5, 7
布局 :crit, 7, 10
绘制 :crit, 10, 13
section 空闲
剩余空闲时间 :done, 13, 17
图 4-3:5ms 时间切片在一帧中的位置
4.4.1 生产路径只有 frameYieldMs——勿把 mock/历史三段式当现行
v19 生产 SchedulerFeatureFlags.js 导出的与切片相关的核心常量是 frameYieldMs = 5(另有 priority timeout 等)。shouldYieldToHost 只比较 timeElapsed 与 frameInterval(默认即 frameYieldMs)。
continuousYieldMs / maxYieldMs / needsPaint / enableIsInputPending 三段式不在 v19 生产 Scheduler.js 里——若在 SchedulerMock.js 或旧版源码笔记里见到 5/50/300,那是测试 mock 或历史路径,不要写成「18.2+/19 真实生产实现」。
生产行为可以一句话记住:每约 5ms 无条件让出主线程(forceFrameRate 可改 frameInterval,默认仍是 5ms 语义)。
4.4.2 MessageChannel:调度的引擎
确定了时间切片的长度后,下一个问题是:React 如何在让出主线程后重新获得控制权?
React 的选择是 MessageChannel:
// packages/scheduler/src/forks/Scheduler.js
const channel = new MessageChannel();
const port = channel.port2;
// port1 的 onmessage 回调会在下一个宏任务中执行
channel.port1.onmessage = performWorkUntilDeadline;
// 请求调度:向 port2 发送消息
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
}
为什么是 MessageChannel 而不是 setTimeout?
// setTimeout 的问题:
setTimeout(callback, 0);
// 非嵌套时延迟约 1ms;但按 HTML 规范,嵌套超过 5 层后
// 会被强制 clamp 到至少 4ms
// Scheduler 的调度恰恰是递归的——每个切片结束后再调度下一个,
// 必然触发嵌套 clamp,调度开销吃掉 4ms,几乎和时间切片本身(5ms)一样长
// MessageChannel 的优势:
port.postMessage(null);
// 消息会在微任务之后、下一个渲染之前作为宏任务执行
// 延迟通常 < 1ms
// 没有嵌套惩罚
sequenceDiagram
participant Scheduler as Scheduler
participant ML as 消息循环
participant Browser as 浏览器
Scheduler->>ML: port.postMessage(null)
Note over ML: 当前宏任务执行完毕
ML->>Scheduler: onmessage → performWorkUntilDeadline
Scheduler->>Scheduler: startTime = currentTime
loop 工作循环
Scheduler->>Scheduler: 执行一个工作单元
alt shouldYield() === true(timeElapsed ≥ frameInterval)
Scheduler->>ML: port.postMessage(null) 再次调度
ML->>Browser: 让出主线程
Browser->>Browser: 渲染、处理输入
ML->>Scheduler: onmessage → 继续工作
else 所有工作完成
Scheduler->>Scheduler: isMessageLoopRunning = false
end
end
图 4-4:MessageChannel 驱动的调度循环
4.4.3 schedulePerformWorkUntilDeadline 的三选一 fallback
React Scheduler 真实代码(Scheduler.js:600-631)里、schedulePerformWorkUntilDeadline 根据环境三选一:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
三种实现优先级:
① setImmediate(Node.js 和老 IE)——Node.js 里 setImmediate 比 setTimeout 更早触发、且不阻止进程退出(MessageChannel 会阻止 Node.js 进程退出——这是个经典坑)。
② MessageChannel(主流浏览器)——avoid setTimeout 的 4ms clamping、调度更精确。
③ setTimeout(..., 0)(兜底)——最坏情况下的 fallback、只在非 DOM 非 Node.js 环境(比如某些 WebWorker 的特殊子集)用。
三个选择按 "最优 → 次优 → 兜底" 的降级顺序选——让 Scheduler 能在任何 JS 环境运行、同时在每个环境用最好的调度原语。
setImmediate 优先于 MessageChannel 的原因是 GitHub issue #20756 里描述的 Node.js 进程退出 bug——如果 Scheduler 在 Node.js 里用 MessageChannel、进程会因为 "还有活跃的 MessageChannel" 拒绝退出、导致测试 hang 住。用 setImmediate 绕开这个问题。
这就是基础库作者必须掌握的"环境适配"——同一个代码在 V8 浏览器、V8 Node、JSC、Edge 里行为都不同、必须 pre-emptively 处理。Scheduler 用这种"多 backend + 选择最优"的模式、在各种环境都能运行良好。
4.4.4 performWorkUntilDeadline:调度入口
现在我们来看 Scheduler 的实际调度入口(v19.0.0 生产路径 packages/scheduler/src/forks/Scheduler.js):
// packages/scheduler/src/forks/Scheduler.js(v19.0.0)
const performWorkUntilDeadline = () => {
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
// 记录本段工作的起始时刻——shouldYieldToHost 用
// getCurrentTime() - startTime 与 frameInterval 比较,而不是 deadline
startTime = currentTime;
// 故意不用 try/catch 包住业务错误路径:若 flushWork 抛错,
// hasMoreWork 保持 true,finally 里会继续 schedule
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};
关键点(相对旧笔记常见误读):
- 入口条件是
isMessageLoopRunning,不是scheduledHostCallback !== null requestHostCallback()无参,生产路径没有scheduledHostCallback槽(那是旧形态 / 部分历史笔记)flushWork(initialTime)/workLoop(initialTime)无hasTimeRemaining参数- 切片边界靠
startTime = currentTime,没有deadline = currentTime + frameInterval(那是 React 16/17 旧模型)
若在
SchedulerMock.js里看到scheduledHostCallback+hasTimeRemaining,那是仅测试 mock,不要写成 19 生产实现。
4.4.5 isMessageLoopRunning 标志的作用——避免重复调度
isMessageLoopRunning 既是 performWorkUntilDeadline 的入口条件,也在 requestHostCallback 里护栏:
// v19.0.0 生产路径:无参
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
isMessageLoopRunning 标志核心作用——防止同一时刻有多个 MessageChannel 消息排队。
具体场景:
- Scheduler 收到 scheduleCallback(task1)——调
requestHostCallback()→port.postMessage(null) - 第一个 postMessage 还没触发 onmessage、用户又调了 scheduleCallback(task2)
- 如果不检查 isMessageLoopRunning、第二个 scheduleCallback 又调
port.postMessage(null)——两个 MessageChannel 消息排队 - 接下来 onmessage 触发两次、
performWorkUntilDeadline跑两次——第二次往往多余
isMessageLoopRunning = true + postMessage(null) 的组合让"消息在途"期间不再重复发——保证 MessageChannel 队列里最多一个 pending 消息。消息 onmessage 完成后,要么根据 hasMoreWork 重新调度,要么把 isMessageLoopRunning 置回 false。
这就是 "标志位 + 重入防护" 的经典模式——和 ReactFiberWorkLoop 模块变量 rootDoesHavePassiveEffects、LangGraph 的 patch_execution_info 的不变式都是同类。JavaScript 的 concurrency 是合作式的——但异步消息的重入依然要手动防护。
4.4.6 performWorkOnRootViaSchedulerTask.bind(null, root) 的 bind 开销
React Reconciler 调用 Scheduler 的代码在 ReactFiberRootScheduler.js。v19 入口名是 performWorkOnRootViaSchedulerTask(内部再调统一的 performWorkOnRoot):
// v19.0.0
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root), // ← bind
);
每次 scheduleCallback 都 bind 一次——创建一个新的 bound function 对象。这是一笔真实但很小的分配开销。
为什么不提前 bind?——因为 root 参数每次不同(多 root 场景)——必须每次绑定。
理论上让 scheduleCallback 接收 (callback, arg)、内部直接 callback(arg) 可以省掉这个 bound function,但那要改 Scheduler 的公开 API 签名。相对于一次并发渲染动辄毫秒级的工作量,一个 bound function 的分配可以忽略——API 稳定优先于微观性能,这类 trade-off 在大型库里常见。
4.5 任务的生命周期
scheduleCallback:任务的诞生
当 React 需要调度一个更新时,它会调用 scheduleCallback:
// packages/scheduler/src/forks/Scheduler.js
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: SchedulerCallback,
options?: { delay?: number }
): Task {
const currentTime = getCurrentTime();
// 1. 确定开始时间
let startTime: number;
if (typeof options === 'object' && options !== null && typeof options.delay === 'number') {
startTime = currentTime + options.delay; // 延迟任务
} else {
startTime = currentTime; // 立即任务
}
// 2. 根据优先级确定超时时间
let timeout: number;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT; // 10000
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT; // 5000
break;
}
// 3. 计算过期时间
const expirationTime = startTime + timeout;
// 4. 创建任务节点
const newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// 5. 放入合适的队列
if (startTime > currentTime) {
// 延迟任务:放入 timerQueue
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// 如果 taskQueue 为空,且这是 timerQueue 中最早的任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// 设置一个定时器,在 startTime 到达时将任务转移到 taskQueue
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// 立即任务:放入 taskQueue
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// 如果没有正在执行的调度,启动调度
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}
return newTask;
}
flowchart TD
A["scheduleCallback(priority, callback, options)"] --> B{是否有 delay?}
B -->|有| C["startTime = now + delay"]
B -->|无| D["startTime = now"]
C --> E["expirationTime = startTime + timeout"]
D --> E
E --> F["创建 Task 节点"]
F --> G{startTime > now?}
G -->|是: 延迟任务| H["push(timerQueue, task)"]
G -->|否: 立即任务| I["push(taskQueue, task)"]
H --> J{taskQueue 为空<br/>且是最早的延迟任务?}
J -->|是| K["requestHostTimeout()<br/>等待 startTime 到达"]
J -->|否| L["等待现有 timeout"]
I --> M{当前没有调度在进行?}
M -->|是| N["requestHostCallback()<br/>port.postMessage(null)"]
M -->|否| O["等待当前调度处理"]
style A fill:#e7f5ff,stroke:#333
style N fill:#ff6b6b,stroke:#333,color:#fff
style K fill:#ffa94d,stroke:#333,color:#fff
图 4-5:scheduleCallback 的完整流程
flushWork 和 workLoop:任务的执行
当 performWorkUntilDeadline 被触发时,它会调用 flushWork,进而调用 workLoop:
// packages/scheduler/src/forks/Scheduler.js(v19.0.0 生产路径)
function flushWork(initialTime: number): boolean {
isHostCallbackScheduled = false;
// 检查是否有延迟任务到期了
if (isHostTimeoutScheduled) {
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
return workLoop(initialTime);
} finally {
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}
}
function workLoop(initialTime: number): boolean {
let currentTime = initialTime;
// 将到期的延迟任务从 timerQueue 转移到 taskQueue
advanceTimers(currentTime);
// 取出优先级最高的任务
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (
currentTask.expirationTime > currentTime &&
shouldYieldToHost()
) {
// 任务未过期 且 时间片已用完 → 中断,让出主线程
break;
}
// 取出任务的回调
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
// 判断任务是否已过期
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 执行回调,传入是否超时的信息
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 回调返回了一个函数 → 任务还没完成,更新 callback 继续
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
return true; // 还有工作
} else {
// 任务完成,从队列中移除
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
// callback 为 null(任务已被取消)
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// 检查是否还有工作
if (currentTask !== null) {
return true; // taskQueue 还有任务
} else {
// taskQueue 为空,检查 timerQueue
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false; // 所有工作完成
}
}
这里有一个关键的设计:continuation callback。当任务回调返回一个函数时,Scheduler 认为任务还没有完成,会保留这个任务在队列中,下次继续执行。这正是 React Reconciler 实现可中断渲染的基础——v19 的 Scheduler 入口在时间片用完时会返回自身:
// packages/react-reconciler/src/ReactFiberRootScheduler.js(v19.0.0)
function performWorkOnRootViaSchedulerTask(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {
// ... flushPassiveEffects、getNextLanes、performWorkOnRoot ...
scheduleTaskForRootDuringMicrotask(root, now());
if (root.callbackNode != null && root.callbackNode === originalCallbackNode) {
// 任务还没完成(被时间切片中断了)→ 返回自身作为 continuation
return performWorkOnRootViaSchedulerTask.bind(null, root);
}
return null; // 任务完成 / 已改挂新 callback
}
延迟任务的转移:advanceTimers
延迟任务在 startTime 到达后需要从 timerQueue 转移到 taskQueue:
function advanceTimers(currentTime: number) {
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// 任务已被取消
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// 到期了,转移到 taskQueue
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
// 最早的任务还没到期,后面的更不可能到期
return;
}
timer = peek(timerQueue);
}
}
任务的取消
取消任务的方式非常巧妙——不是从堆中移除(那样需要 O(n) 查找),而是将 callback 设为 null:
function unstable_cancelCallback(task: Task) {
// 不从队列中删除,而是将 callback 设为 null
// workLoop 遇到 callback 为 null 的任务时会跳过
task.callback = null;
}
这个设计的妙处在于:删除堆中任意一个元素需要先找到它(O(n)),然后重新调整堆(O(log n)),总共 O(n)。而 lazy deletion 只需要 O(1) 的标记操作,被标记的任务在 workLoop 取出时自然跳过。代价是堆中会暂时保留无效节点,但这些节点最终都会在堆顶被清理。
4.5.1 advanceTimers 遇到 callback=null 的 early pop
看 advanceTimers(Scheduler.js:124-148)的真实实现:
function advanceTimers(currentTime) {
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
}
三种分支:
① callback === null(任务被取消)——直接 pop、不搬到 taskQueue。这和 §4.5 讲的 lazy deletion 配合——cancelled 的延迟任务在 advanceTimers 自然被清除、不需要主动扫描整个 timerQueue。
② startTime <= currentTime(到期)——pop + 更新 sortIndex + push 到 taskQueue。注意 sortIndex 从 startTime 切换到 expirationTime——两个队列的排序依据不同、转移时必须改 sortIndex。
③ startTime > currentTime(未到期)——立即 return。因为 timerQueue 是最小堆、堆顶都没到期、下面的更不可能到期——提前退出、O(k) 而不是 O(n)。
这三个分支在一个 while 循环里统一处理——优雅且高效。每次 workLoop 开始都调一次 advanceTimers、把到期的延迟任务批量搬家——分摊的代价最小化。
advanceTimers 是 Scheduler 里少数几个 O(k log n) 的操作(k = 到期数)——但 k 通常很小(per tick 到期数 <= 1-5)——实际上近似 O(log n)。
4.5.2 生产路径:直接 flushWork,无 scheduledHostCallback 槽
v19 生产 Scheduler.js 不再维护 scheduledHostCallback 变量。消息循环与工作函数的关系是固定的:
// v19.0.0 生产路径(示意)
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline(); // MessageChannel / setImmediate / setTimeout
}
}
const performWorkUntilDeadline = () => {
if (isMessageLoopRunning) {
startTime = getCurrentTime();
let hasMoreWork = true;
try {
hasMoreWork = flushWork(startTime); // 直接调 flushWork,无 callback 槽
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};
为什么 schedulePerformWorkUntilDeadline 不接参?——MessageChannel / setImmediate 的 host 回调签名本就不便传动态业务参数;生产路径索性把「跑哪段工作」定死为 flushWork,状态靠 taskQueue / isMessageLoopRunning / startTime 等模块变量携带。
scheduledHostCallback+ 有参requestHostCallback(callback)若出现在笔记里,应标为历史形态或SchedulerMock,不是 v19 生产路径。
4.6 与 React Reconciler 的协作
ensureRootIsScheduled:只保证 root 进表 + 挂 microtask
React 的每次状态更新最终都会走到 ensureRootIsScheduled。v19 里它本身几乎不做「选 lane / 绑 Scheduler task」——真正排程在 microtask 里的 scheduleTaskForRootDuringMicrotask:
// packages/react-reconciler/src/ReactFiberRootScheduler.js(v19.0.0,示意)
function ensureRootIsScheduled(root: FiberRoot): void {
// 1) 把 root 链入 firstScheduledRoot … lastScheduledRoot(若尚未在表中)
// 2) mightHavePendingSyncWork = true
// 3) 若本事件循环还没挂过 microtask → scheduleImmediateTask(processRootScheduleInMicrotask)
// 真正的 nextLanes / Sync vs scheduleCallback 在 microtask 里做
}
function scheduleTaskForRootDuringMicrotask(root, currentTime): Lane {
markStarvedLanesAsExpired(root, currentTime);
const nextLanes = getNextLanes(/* ... */);
if (nextLanes === NoLanes /* 或 suspended 等 */) {
// 取消已有 callback,清空 callbackNode / callbackPriority
return NoLane;
}
if (includesSyncLane(nextLanes)) {
// Sync:不 scheduleCallback;靠 microtask 末尾 flushSyncWork*
if (existingCallbackNode !== null) cancelCallback(existingCallbackNode);
root.callbackPriority = SyncLane;
root.callbackNode = null;
return SyncLane;
}
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === root.callbackPriority) {
return newCallbackPriority; // 同优先级复用
}
cancelCallback(existingCallbackNode);
// lanesToEventPriority → UserBlocking / Normal / Idle(见 §4.3.3)
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
return newCallbackPriority;
}
flowchart TD
A["setState() / dispatch()"] --> B["scheduleUpdateOnFiber"]
B --> C["ensureRootIsScheduled(root)"]
C --> D["root 入 schedule 链表<br/>挂 processRootScheduleInMicrotask"]
D --> E["microtask: scheduleTaskForRootDuringMicrotask"]
E --> F{"nextLanes"}
F -->|NoLanes| G["取消现有 callback"]
F -->|SyncLane| H["callbackNode=null<br/>microtask 内 sync flush"]
F -->|并发 Lane| I{"与现有 callbackPriority 比较"}
I -->|相同| J["复用现有 Scheduler task"]
I -->|不同| K["cancel + scheduleCallback"]
K --> L["performWorkOnRootViaSchedulerTask"]
L --> M{"时间切片用完?"}
M -->|是| N["返回 continuation bind"]
M -->|否| O["完成协调 / commit"]
style A fill:#e7f5ff,stroke:#333
style H fill:#ff6b6b,stroke:#333,color:#fff
style K fill:#ffa94d,stroke:#333,color:#fff
style N fill:#69db7c,stroke:#333
图 4-6:从 setState 到 Scheduler 的完整调度链路(v19)
优先级抢占
当一个高优先级更新在低优先级更新执行过程中到来时,会发生优先级抢占:
// 场景:用户输入触发高优先级更新,打断正在进行的数据加载渲染
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
// 用户输入 → SyncLane(最高优先级)
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
// 数据加载 → DefaultLane(普通优先级)
useEffect(() => {
fetchResults(query).then(data => {
startTransition(() => {
setResults(data); // TransitionLane,可被中断
});
});
}, [query]);
return (
<div>
<input value={query} onChange={handleInput} />
<ResultList items={results} /> {/* 渲染 10000 条结果 */}
</div>
);
}
抢占的过程如下:
- 用户输入 "a",触发
setQuery('a')(SyncLane) - 数据加载完成,触发
setResults(data)(TransitionLane),Scheduler 开始执行performWorkOnRootViaSchedulerTask→performWorkOnRoot - 在
setResults的渲染过程中,用户又输入了 "ab",触发新的setQuery('ab')(SyncLane) - 更新入队后
ensureRootIsScheduled保证 microtask 在表;真正在scheduleTaskForRootDuringMicrotask里看到更高优先级 Sync,取消当前 Scheduler task setQuery('ab')作为同步更新在 microtask 路径 flush- 同步更新完成后,再次
scheduleTaskForRootDuringMicrotask重新挂上被中断的 transition 任务
// 在 workLoop(React Reconciler 的)中检查是否被抢占
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// ...
outer: do {
try {
if (workInProgressSuspendedReason !== NotSuspended) {
// 处理 Suspense 相关逻辑
} else {
workLoopConcurrent();
}
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
// ...
}
function workLoopConcurrent() {
// 并发模式下,每处理一个 Fiber 就检查是否需要让出
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
4.6.1 existingCallbackPriority === newCallbackPriority 短路复用
v19 里这个短路在 scheduleTaskForRootDuringMicrotask(不是 ensureRootIsScheduled 本体):
const existingCallbackPriority = root.callbackPriority;
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === existingCallbackPriority) {
// 优先级没变、复用现有 Scheduler task
return newCallbackPriority;
}
如果新的优先级和当前 scheduled 的一样——直接 return、不重新调度。
为什么?——因为 Scheduler 已经有一个 pending callback 在那等着、它最终会 pick up 所有 lanes 的工作(getNextLanes 读出待处理 lane)。不需要 cancel 旧的、再 schedule 新的——那是多余的工作。
这个优化极其关键:ensureRootIsScheduled 会被每次 setState 调用,但真正的 cancel/schedule 决策 defer 到 microtask;同优先级连续 setState 在 microtask 里只会看到一次「优先级未变」——Scheduler 不会 thrash(堆里反复 push/pop)。
=== 短路保证 "同优先级连续 setState 只挂一个 Scheduler task"——几十次 setState 总调度成本和一次一样。
4.6.2 cancelCallback(existingCallbackNode) 的惰性实现
当 scheduleTaskForRootDuringMicrotask 发现 existingCallbackPriority !== newCallbackPriority 时——只要优先级不同就取消重调,无论新的更高还是更低——会 cancelCallback:
if (existingCallbackNode != null) {
cancelCallback(existingCallbackNode);
}
cancelCallback 的实现(Scheduler.js:448-457):
function unstable_cancelCallback(task) {
// Null out the callback to indicate the task has been cancelled.
// (Can't remove from the queue because you can't remove arbitrary
// nodes from an array based heap, only the first one.)
task.callback = null;
}
一行代码——把 task.callback 设为 null。不从堆里删除。
为什么不直接删?——因为任意位置删除 array-based heap 是 O(n)(要找到节点再调整)、而 Scheduler 只支持 O(log n) 的 pop 堆顶。为了 O(1) 的 cancel 性能、接受 "队列里保留 dead node" 的代价。
dead node 的代价:
- 内存——直到 dead node 浮到堆顶被 pop、才释放——短暂的 memory overhead
- workLoop 的 check——每次 peek 可能看到 dead node(callback=null)、需要 pop 跳过——少量 CPU 开销
但这些代价都是常数级——而O(1) 的 cancel 比 O(n) 的 cancel 快几个数量级——在高频 setState 触发 cancel 的场景里差异巨大。
这就是设计决策——"廉价 cancel + 少量 dead node" 胜过 "昂贵 cancel + 零 dead node"。Scheduler 的所有设计都服务于 React 的真实工作负载——用户交互触发的大量 setState——在这个 workload 下、前者完胜。
4.7 Scheduler 的性能特征
时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 调度任务(scheduleCallback) | O(log n) | 小顶堆 push |
| 取消任务(cancelCallback) | O(1) | lazy deletion |
| 获取最高优先级任务 | O(1) | 堆顶 peek |
| 完成/移除任务 | O(log n) | 小顶堆 pop |
| 检查是否让出(shouldYield) | O(1) | 时间戳比较 |
| 延迟任务转移(advanceTimers) | O(k log n) | k 为到期的延迟任务数 |
与操作系统调度的对比
graph TD
subgraph "操作系统 CPU 调度"
OS1["进程/线程"] --> OS2["优先级队列<br/>(多级反馈队列)"]
OS2 --> OS3["时间片轮转<br/>(通常 10-100ms)"]
OS3 --> OS4["上下文切换<br/>(保存/恢复寄存器)"]
OS4 --> OS5["抢占式调度<br/>(定时器中断)"]
end
subgraph "React Scheduler"
R1["任务(Task)"] --> R2["小顶堆<br/>(按 expirationTime)"]
R2 --> R3["时间切片<br/>(5ms)"]
R3 --> R4["让出主线程<br/>(保存 Fiber 进度)"]
R4 --> R5["协作式 + 过期强制<br/>(MessageChannel)"]
end
OS1 -.->|"类比"| R1
OS2 -.->|"类比"| R2
OS3 -.->|"类比"| R3
OS4 -.->|"类比"| R4
OS5 -.->|"类比"| R5
图 4-7:操作系统 CPU 调度 vs React Scheduler 的概念映射
核心区别在于:操作系统的调度是抢占式的(通过硬件定时器中断强制切换),而 React Scheduler 本质上是协作式的(任务需要主动检查 shouldYield() 来让出)。但 React 通过过期机制为协作式调度增加了强制性——过期的任务不会被 shouldYield() 中断,这在效果上接近了抢占。
isInputPending:历史/实验路径,非 v19 生产
navigator.scheduling.isInputPending() 曾被讨论用于「切片用完后若无输入可继续干一会儿」。v19 生产 Scheduler.js 的 shouldYieldToHost 并不调用它——只比较 timeElapsed 与 frameInterval(§4.3.4 / §4.4.1)。
// v19.0.0 生产路径
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
若在旧笔记、SchedulerMock 或内部 www 实验构建里看到 isInputPending / 5·50·300 三段式,按非生产路径理解即可。开源 v19 实际行为:每约 5ms 无条件让出。
4.7.1 Scheduler 与其他语言的协作式调度器对比
Scheduler 的"协作式 + 过期兜底"模式在不同语言生态里有各自的 echo:
| 运行时 | 调度器 | 协作 vs 抢占 | 优先级 | 时间切片 |
|---|---|---|---|---|
| Go | goroutine runtime | 协作(runtime.Gosched())+ preempt-safepoint |
无 | 10ms |
| Erlang | BEAM | 软抢占(reduction counter) | 4 级 | 4000 reductions(CONTEXT_REDS) |
| Tokio | runtime | 协作(.await) |
无(手动) | 无 |
| Node.js | event loop | 协作(return 事件) | 无(FIFO) | 无 |
| React | Scheduler | 协作(shouldYield)+ expirationTime |
5 级 | 5ms |
React 最独特的地方是——既有多级优先级、又有时间切片、又有过期兜底。其他运行时最多有两项。这不是说 React 比它们强——而是它服务的场景(浏览器主线程)决定了这个复杂度:
- 浏览器主线程不能被 preempt(没有系统线程机制)——必须协作
- 用户交互和后台工作必须区分优先级——否则 UX 崩
- 必须有时间切片——不让浏览器渲染用户会感知卡顿
- 必须有过期兜底——不然 low priority 任务永远执行不到
React 是把 OS 级调度器的复杂度搬到用户态的典型——核心实现不过数百行 JS(Scheduler.js 主文件约 700 行,加上 MinHeap / Profiling / FeatureFlags 仍远不到千行量级)就能支撑生产级调度、这在其他语言生态里都很罕见。
4.7.2 Scheduler 的 feature flag 矩阵
v19.0.0 生产 packages/scheduler/src/SchedulerFeatureFlags.js 大致是:
export const enableSchedulerDebugging = false;
export const enableProfiling = false;
export const frameYieldMs = 5;
export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;
没有 enableIsInputPending / continuousYieldMs / maxYieldMs 这组生产导出(与 §4.4.1 一致)。切片行为由 frameYieldMs(及 forceFrameRate 改写的 frameInterval)决定。
frameYieldMs = 5 是硬编码默认值——改它的后果是系统级的,React 没有把它暴露成公开配置。priority timeout 常量则对应 §4.3 各优先级的 expiration 计算。
4.8 调试与观察
使用 Performance 面板观察调度
在 Chrome DevTools 的 Performance 面板中,你可以清晰地观察到 Scheduler 的工作模式:
// 在 React 应用中添加性能标记,便于在 Performance 面板中观察
function App() {
const [count, setCount] = useState(0);
const [items, setItems] = useState<number[]>([]);
const handleClick = () => {
// 高优先级:直接更新计数
setCount(c => c + 1);
// 低优先级:通过 transition 更新大列表
startTransition(() => {
setItems(Array.from({ length: 10000 }, (_, i) => i));
});
};
return (
<div>
<button onClick={handleClick}>Count: {count}</button>
<ul>
{items.map(i => (
<li key={i}>{i}</li>
))}
</ul>
</div>
);
}
在 Performance 面板中你会看到:
- 点击事件处理:
setCount同步执行,立即更新 - 多个 5ms 左右的
performWorkUntilDeadline调用块 - 每两个调用块之间有渲染帧的间隔
- 最终所有工作完成,
commit阶段一次性更新 DOM
Scheduler Profiler
React DevTools 的 Profiler 面板提供了更高层次的视图:
// 使用 React Profiler API 观察调度行为
function ProfiledApp() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<App />
</Profiler>
);
}
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number, // 本次渲染花费的时间
baseDuration: number, // 不使用 memoization 时的估计时间
startTime: number, // React 开始渲染的时间
commitTime: number // React 提交的时间
) {
console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}ms`);
// 如果 actualDuration 远小于 baseDuration,
// 说明 memoization(React.memo, useMemo)起了作用
}
4.8.1 SchedulerProfiling 模块的 event marker
Scheduler 有一个独立模块 SchedulerProfiling.js——专门处理 profiling 事件标记:
export function markTaskStart(task, ms) { /* ... */ }
export function markTaskCompleted(task, ms) { /* ... */ }
export function markTaskErrored(task, ms) { /* ... */ }
export function markTaskCanceled(task, ms) { /* ... */ }
export function markTaskYield(task, ms) { /* ... */ }
export function markTaskRun(task, ms) { /* ... */ }
export function markSchedulerSuspended(ms) { /* ... */ }
export function markSchedulerUnsuspended(ms) { /* ... */ }
这些 mark* 函数并不直接把事件发到 DevTools / Performance Timeline。真实路径是:在 enableProfiling === true 且已调用 startLoggingProfilingEvents() 时,把事件写入内部 Int32Array 事件日志(logEvent → eventLog.set(...)),再通过 unstable_Profiling API(startLoggingProfilingEvents / stopLoggingProfilingEvents,后者返回 ArrayBuffer)暴露给调用方。
开源构建里 enableProfiling 默认为 false——unstable_Profiling 直接是 null,上述 mark* 路径是死代码,生产包里通常被 DCE 掉。你在 React DevTools Timeline / Performance 面板里看到的调度可视化,实际走的是 reconciler 侧的 scheduling profiler(enableSchedulingProfiler + ReactFiberDevToolsHook 等),不是 Scheduler 这个内部 Int32Array 日志。
核心路径在非 Profiling 构建下不进入这些分支——零开销。这和 §6.6.1 讲的 React enableProfilerTimer feature flag 是同一个模式——observability 是开关的、开销可控。
4.9 本章小结
React Scheduler 是一个精心设计的用户态任务调度器。它的核心思想可以用一句话概括:用最小的开销,在正确的时机,执行最重要的工作。
让我们回顾 Scheduler 的关键设计决策:
- 独立包:关注点分离,让调度逻辑与渲染逻辑解耦
- 小顶堆:O(log n) 的插入和取出,高效管理优先级队列
- 5ms 时间切片:在响应性和吞吐量之间取得平衡
- MessageChannel:避免 setTimeout 的 4ms 最小延迟惩罚
- 过期机制:用绝对时间而非相对优先级来解决饥饿问题
- Lazy Deletion:O(1) 的任务取消操作
- Continuation Callback:支持任务的中断与恢复
Scheduler 不仅是 React 并发模式的基石,也是理解现代前端框架如何在单线程环境中实现复杂调度逻辑的绝佳范例。在下一章中,我们将看到 Scheduler 调度的工作具体是什么——Reconciliation,React 的 Diff 算法。
思考题
-
为什么 Scheduler 选择协作式调度而非抢占式调度? 如果 JavaScript 提供了抢占式线程能力(如 Web Workers),React 的调度策略会如何变化?
-
如果一个 NormalPriority 的任务在执行过程中耗时超过了它的 expirationTime(5000ms),会发生什么? 与一个刚创建就已过期的 ImmediatePriority 任务有什么不同?
-
考虑以下场景:一个页面同时发生了三个更新——用户输入(SyncLane)、动画(TransitionLane)、后台数据加载(IdleLane)。请描述 Scheduler 如何编排这三个更新的执行顺序,以及每个更新可能被中断几次。
-
Scheduler 的小顶堆为什么不用
Map或Set替代? 尝试分析在 React 典型使用场景下(任务数 < 100),不同数据结构的实际性能差异。