Skip to content

第4章 调度器:React 的 CPU 调度算法

本章要点

  • Scheduler 的设计哲学:为什么 React 要自己实现一个任务调度器
  • 优先级模型:5 级优先级系统的设计与 Lane 模型的映射关系
  • 时间切片(Time Slicing)的实现:shouldYield 与 5ms 时间窗口
  • 任务队列的数据结构:小顶堆(Min Heap)的选择与实现
  • MessageChannel vs requestIdleCallback:React 为何放弃了浏览器原生 API
  • scheduleCallbackperformWorkUntilDeadline:一个任务的完整生命周期
  • 延迟任务与过期机制:饥饿问题的解决方案

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,但很快发现了它的几个致命问题:

  1. 调用频率不可控:rIC 的调用时机完全由浏览器决定。在高负载场景下,rIC 可能被延迟到几百毫秒甚至更久才执行
  2. 最大超时只有 50ms:即使在完全空闲的情况下,timeRemaining() 最多返回 50ms,这个限制是硬编码在规范中的
  3. 兼容性问题:Safari 直到 2024 年仍未支持 rIC
  4. 没有优先级概念: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 → 空闲
}

基于 VitePress 构建