Appearance
第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)来协调这些工作:
typescript
// 浏览器事件循环的简化模型
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)。它允许开发者在浏览器空闲时执行低优先级的工作:
typescript
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回当前帧剩余的空闲时间
while (deadline.timeRemaining() > 0 && hasWork()) {
doWork();
}
});React 团队最初确实考虑过使用 rIC,但很快发现了它的几个致命问题:
- 调用频率不可控:rIC 的调用时机完全由浏览器决定。在高负载场景下,rIC 可能被延迟到几百毫秒甚至更久才执行
- 最大超时只有 50ms:即使在完全空闲的情况下,
timeRemaining()最多返回 50ms,这个限制是硬编码在规范中的 - 兼容性问题:Safari 直到 2024 年仍未支持 rIC
- 没有优先级概念:rIC 只有"空闲时执行"一种语义,无法区分"紧急更新"和"普通更新"
typescript
// 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 内部维护两个任务队列:
typescript
// 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) 实现。为什么是小顶堆而不是普通数组?
typescript
// 如果用数组 + sort:
// 每次插入后排序:O(n log n)
// 取最小值:O(1)
// 总复杂度:O(n log n)
// 使用小顶堆:
// 插入:O(log n) ✓ 更优
// 取最小值:O(1)
// 删除最小值:O(log n)
// 总复杂度:O(log n) ✓ 更优React 实现了一个简洁的小顶堆:
typescript
// 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;
}图 4-1:Scheduler 的小顶堆结构(按 expirationTime 排序)
4.3 优先级模型
五级优先级
Scheduler 定义了 5 个优先级级别,每个级别对应不同的超时时间:
typescript
// 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:实际上永不过期(~24.8 天)。只在浏览器完全空闲时执行
从 React Lane 到 Scheduler Priority 的映射
在第3章我们提到了 React 的 Lane 优先级系统。Lane 是 React 内部的优先级模型,而 Scheduler 有自己的优先级模型。React 通过一层映射将两者连接:
typescript
// packages/react-reconciler/src/ReactFiberRootScheduler.js
function lanesToSchedulerPriority(lanes: Lanes): PriorityLevel {
const lane = getHighestPriorityLane(lanes);
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return ImmediatePriority; // SyncLane → 立即执行
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return UserBlockingPriority; // InputContinuousLane → 用户阻塞
}
if (includesNonIdleWork(lane)) {
return NormalPriority; // DefaultLane 等 → 普通
}
return IdlePriority; // IdleLane → 空闲
}