简介

调度器的概念,对于了解操作系统的同学比较熟悉,React 的调度实现与之非常类似。

JavaScript 是运行在单线程环境的。像用户对页面的交互事件更新回调rAF 动画更新回调,都执行在单一线程(主线程)上。因此,整个系统运行是时间共享 (Time Sharing) 的,所有任务将共享一条时间线。

React 通过时间分片 (time slicing) 技术将处理时间划分为小段,并轮流分配给各个任务,每个任务有自己不同的优先级。来代替浏览器事件循环的默认调度规则。

React 的调度是一种多级调度队列算法。下面逐一介绍一些关键概念,来完成对调度器的完整认识。

独立包 scheduler

在 React 中,调度器是单独用一个子包来管理。

一方面调度器的角色和执行什么任务是可以解耦的,它本职用于管理异步任务。在操作系统上调度器是管理进程任务、网络任务和 IO 任务等在时间共享系统上的分配,而用于 React 就是管理用户事件、React 渲染任务的分配。

另一方面调度器后续会开放更多目前未公开的 untable API,它们会给用户带来灵活的异步任务调度方式,提高其使用价值。

优先级 (Priority Level)

在 React 的 scheduler 子包定义了任务的优先级:

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

// TODO: Use symbols?
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
  • NoPriority (0): 不需要执行。
  • ImmediatePriority (1): 对应于几乎立即执行的更新,如处理用户的输入点击事件等。
  • UserBlockingPriority (2): 对应于那些需要快速响应以避免打断用户流的更新,如动画
  • NormalPriority (3): 是大部分更新的默认优先级。
  • LowPriority (4): 可以用于那些不那么紧急的更新,比如数据的预加载
  • IdlePriority (5): 用于完全不急的任务,比如在浏览器空闲时执行

React 在进行更新时,会根据不同类型的工作赋予不同的优先级。这些优先级常量用于内部调度逻辑,帮助 React 决定应该先执行哪些更新,以及哪些更新可以延后。

在 React 的并发模式下,这些优先级非常关键,因为它们允许 React 暂停和恢复工作,优先响应用户的交互,同时保证了低优先级的更新最终也能完成。

任务 (Task)

type Task = {
  id: number,
  callback: Callback | null,
  priorityLevel: PriorityLevel,
  startTime: number,
  expirationTime: number,
  sortIndex: number,
  isQueued?: boolean,
};
  • callback:即当任务到达执行时间时应该调用的函数。用于 React 里就是渲染任务的函数
  • priorityLevel:任务优先级。
  • startTime:任务开始的时间。表示任务应当开始处理的时间。
  • expirationTime:任务的过期时间。这通常用于确定任务是否应被立即执行推迟
  • sortIndex:排序索引。它的值是时间戳,默认值 -1
  • isQueued (可选):一个布尔值,表示任务是否已经被添加到队列中。这有助于避免重复队列任务

sortIndex 的值是实际任务执行先后顺序的判断依据。

如何确定时间

源码里可以看到不同优先级类型任务对应的超时时长设定都不同,优先级和超时时长对应关系在如下:

ImmediatePriority -> IMMEDIATE_PRIORITY_TIMEOUT = -1 // 超时即过期
UserBlockingPriority -> IDLE_PRIORITY_TIMEOUT = 250 // 超时 250 ms 过期
IdlePriority -> IDLE_PRIORITY_TIMEOUT = 1073741823 // 取值为有符号 32 位最大正整数,表示永不过期。
LowPriority ->  LOW_PRIORITY_TIMEOUT = 10000 // 超时 10s 过期
NormalPriority (default) -> NORMAL_PRIORITY_TIMEOUT = 5000 // 超时 5s 过期

如果创建任务没有提供 delay 选项来设置任务延时,那么 startTime当前时间。而 expirationTime 就是 startTime + 超时时长

其中,startTimeexpirationTimesortIndex 三个属性有重要的关联性。

我们在介绍 Fiber 聊过像用户交互事件都可以阻断 React 普通更新渲染任务,也就是说即便创建任务了,也不一定立即能够执行。

任务队列

React 的调度器中有两个数组实现的任务队列:执行任务队列 (taskQueue) 和待执行队列 (timerQueue),元素都是 Task 类型,并都采用了最小堆数据结构。

最小堆是通常是由数组实现的具备完全二叉树形状特性的数据结构,它经常应用于优先级队列这样的抽象数据类型 (ADT)。每次执行 poppush 操作时都会对数组进行堆化,让最小 Key 的元素保持在数组的第一个,维持其堆属性。因此,你总能在数组的第一个元素获取优先级最高的任务。

源码中,它的 Key 大小是 Task 的 sortIndexid 先后决定的。

react-scheduler-task-queue.svg

调度器是事件驱动的,因此 workLoop() 的执行通常由用户触发或者其他更新事件触发的。当 workLoop 开始工作时,会尝试从 taskQueue 弹出优先级最高的任务来执行。

控制权转让

WorkLoop 循环的任务工作是连续的,这会导致用户事件绘制任务可能没有机会立即插入到执行队列里执行。为了解决这个问题,WorkLoop 每过一小段时间就会把主线程控制权让出给主机(浏览器)。

在源码里可以看到调度器设定的检查间隔变量 frameInterval,在没有主动指定 FPS 情况下,默认值为:5ms。也就表示从 workloop 执行任务队列开始超过 5ms 执行队列里的任务还没执行完,那么考虑暂停执行,并重新开始计划执行。该控制决策,可以在 shouldYieldToHost 函数中找到。

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    return false;
  }
  if (enableIsInputPending) {
    if (needsPaint) {
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      return true;
    }
  }
  return true;
}

除了每次单个任务执行前检查总执行时长是否超过 5ms 的固定间隔检查外,也会根据当用户是否处于持续输入事件时,来决策是否交出控制权。例如:用户的 mousemove 事件、input 事件等等。

不过目前该功能需要实验性质isInputPending Web API 的支持,这是一种优化。也就是说 5ms 过后,是否交出控制权取决于用户输入状态,这样可以让任务连续的执行完,而不需要中断再次安排计划任务。

在源码中,可以看到 enableIsInputPending 常量目前处于 false。因此,目前实际情况任然是固定间隔 5ms 让出控制权。

React 团队后续可能会考虑自己实现用户输出事件状态功能,目前继续保持观望。

API

调度器提供了一些 API,让外部可以使用调度服务,后续应该会暴露更多接口。

scheduleCallback

scheduleCallback 是调度器对外开放的核心函数接口,它是 React Fiber 执行并发工作的任务入口

function scheduleCallback(priorityLevel: PriorityLevel, callback: Callback, options?: {delay: number}): {}

options可选的,React 安排调度任务并没有传任何参数。因此,每次创建 Task 时,sortIndex 的值都是 expirationTime,并且直接加入到执行任务队列 (taskQueue) 中去。

调度器 Push 任务到队列后,请求 Host (浏览器)执行异步执行任务,在源码里使用 requestHostCallback 函数来触发 WorkLoop。

在源码中设置了三个梯度 Web APIs 来执行:setImmediateMessageChannelsetTimeout

if (typeof localSetImmediate === 'function') {
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

我们通过 Performance 调试可以发现 performWorkUntilDeadline 的后续执行:

scheduler-performworkuntildealine.png

也可以设置断点,在调用栈看到调用过程:

call-stack.png

shouldYieldToHost

控制权转让策略函数,用于判断是否应该转移控制权给浏览器。

forceFrameRate

设置帧率函数,可以用于修改 frameInterval 参数,来改变控制权转让时间间隔。

参考资料:

> https://wicg.github.io/scheduling-apis/