likes
comments
collection
share

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

作者站长头像
站长
· 阅读数 25

什么是 scheduler?

我们这里所说的「scheduler」就是 react github 仓库中的 scheduler npm 包。翻看一下这个包的 README.md,下面的这段文字映入眼帘:

This is a package for cooperative scheduling in a browser environment. It is currently used internally by React, but we plan to make it more generic.

简单来说,这个包主要是用于在浏览器环境去实现协作式的调度(cooperative scheduling)。当前,它主要被应用于 react 项目。未来,react 团队将会彻底让它成为一个平台无关的 npm 包,使得它更通用。这就是为什么它的 npm 包名称没有冠以一个 react-* 的前缀的原因。

在继续深入之前,我们不妨搞清楚什么是协作式调度。

什么是协作式调度?

了解操作系统的人都知道,其实这个概念主要是来自于操作系统领域。在计算机中,由于 CPU 资源极其宝贵,为了提高它的利用率,操作系统需要把 CPU 的使用权在同一时间段内分配给多个进程或者任务去使用。实现多个进程或者任务对 CPU 的使用权所采用的策略就是称之为「调度策略」。而协作式调度就是其中的一种调度策略。

协作式调度主要是有以下行为特征:

  • 在协作式调度中,任务或进程自愿释放CPU控制权。这意味着任务只有在主动让出CPU的情况下,其他任务才能获得执行机会;
  • 协作式调度需要任务之间具有一定的合作性和自觉性,因为如果一个任务不合作或者无限期地占用CPU,会导致系统其他任务无法执行;
  • 由于任务必须自行管理CPU时间,协作式调度通常不够稳定,容易出现问题,例如任务之间的竞争和死锁。

说到底,协作式调度策略中,系统的良好运行完全靠对方自觉。每个任务在其他方任务占用 CPU 的时候,内心的 OS 估计是这样的:「王八蛋,你用的时间差不多了,要见好即收哈,赶紧把 CPU 让出来」。

跟「协作式调度」一并提起的还有一个调度策略「抢占式调度(Preemptive Scheduling)」。它有以下的行为特征:

  • 在抢占式调度中,操作系统具有管理任务执行的权限,可以随时中断正在执行的任务,将CPU分配给其他任务。这意味着操作系统可以强制性地剥夺任务的CPU时间;
  • 抢占式调度通常会使用优先级、时间片等策略来确定任务执行的顺序,高优先级任务会优先执行,而时间片用于控制任务在CPU上执行的时间;
  • 抢占式调度可以更好地保证系统的响应性和稳定性,因为它不依赖于任务的合作性,即使某个任务陷入无限循环或其他问题,操作系统仍然可以确保其他任务能够获得执行机会;

可以看得出,在抢占式调度中,存在一个处于更高层面的审判官。它一旦判定某个进程后者任务占用 CPU 时间过久之后,它就会强行中断该任务或者进程的执行,把 CPU 使用权分配给后面排队的,更高优先级的任务或者进程。

通过对比协作式调度跟抢占式调度的行为特征以及不同点,我们可以反过来巩固对「什么是协作式调度?」这个问题的理解。现代操作系统中,一般会混合使用这两种调度策略。而以 event loop 为核心的单线程编程语言,比如像 python 和 javascript 就采用了协作式的调度方式。

其实浏览器已经原生提供了 Scheduler 这个 API。只不过这个 API 在现代主流浏览器上遭遇了些兼容性问题。

(npm 包)scheduler 是如何实现协作式调度呢?

跟操作系统语境不一样的是,当「协作式调度」的概念落地到浏览器环境的时候,参与的多方并不是指「进程」了,而是 「js代码」和「浏览器」;需要占用的资源也不是 CPU 了,而是主线程(UI 线程)的控制权。

其实所谓的「协作式」,我们在上一小节也指出来了:「当前占用主线程控制权的一方要自觉,自愿释放控制权。」 这就好像两个人本着公平公正的原则,共同约定怎样公共地使用一样东西 - 「你用一会儿了,差不多了就给我用一会儿。我用一会儿了,差不多了就给你用一会儿......如此往复循环」。

所以,scheduler 包核心要实现的就是「让 js 执行一段时间后,主动把主线程的控制权让渡给浏览器」。这句话里面有两个要素要考究:

  • 如何把主动地把控制权让渡出去呢?
  • 执行一段时间?到底执行多久呢?

如何把主动地把控制权让渡出去呢?

其实上面已经提到了,当前大部分浏览器已经原生地实现了 Scheduler 这个 API。但是鉴于它还有兼容性问题,react 团队使用了下面这几个异步代码调度 API 来实现主线程控制权的主动让渡的:

  • setImmediate()
  • MessageChannel
  • setTimeout()

源码片段 1

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;

曾经 react 团队是使用 requestIdleCallback() 来实现主线程控制权的让渡的,后来因为发现浏览器对它的实现十分地不满足当前需求:

一句话,requestIdleCallback()在浏览器行为十分地不稳定,因而是不可控。听说后面又尝试了 requestAnimationFrame(),但是最终弃用,采用了当前的方案。

以上的三个调度 API 都是把 callback 入队到 macrotask 队列里面。react 采用了降级机制:

  • 优先考虑使用 setImmediate()
  • 其次考虑使用 MessageChannel
  • 最后无可奈何才考虑使用 setTimeout()

至于为什么是这样降级,源码注释上也写得很清楚了,这里就不赘述了。

为什么 react 团队不把 callback 推入到 microtask 队列呢? 这是因为 microtask 会在当前的同步代码后面紧跟着执行。且,它还有一个叫做「饿死特性」,一旦 microtask 出现嵌套入队,则 event loop 的 call stack 一直被占用。基于上面的两个原因,把 callback 入队 microtask 队列并不能实现我们把主线程的控制权马上让渡给浏览器的需求。

使用了异步代码调度 API 就可以了吗?还不行的,我们还需要一个前置动作。那就是 js 方需要主动地退出事件循环 call stack 的占用。在 js 代码中,最占用 call stack 的无非就是一些循环类的代码,比如:for 循环while 循环等或者递归调用。

从上面给出的「源码片段 1」我们可以知道,浏览器在执行完自己的任务(layout/paint/composite)之后它会让 event loop 执行的我们的 callback 函数是 performWorkUntilDeadline()。我稍微对performWorkUntilDeadline()的调用栈进行追踪:

performWorkUntilDeadline()
scheduledHostCallback(其实是 flushWork 的引用)
workLoop()

workLoop() 的函数体里面,我们可以看到我们正在寻找的 while 循环:

function workLoop(hasTimeRemaining, initialTime) {
  // ......
   while (currentTask !== null && !enableSchedulerDebugging) {
      if (
        currentTask.expirationTime > currentTime &&
        (!hasTimeRemaining || shouldYieldToHost())
      ) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
  }
  // .......
}

当 work loop 在进入下一个迭代前,它会进行条件判断。如果当前同时满足以下的两个条件:

  1. 当前的任务还没有触达它的过期时间;
  2. 当前可用时间没有剩余了或者 shouldYieldToHost() 告诉我们是时候去让渡主线程的控制权

的时候,work loop 就会跳出while 循环,从而退出了事件循环 call stack 的占用。

到了这里,我们集齐了 scheduler 包实现「把主动地把控制权让渡出去」的两个步骤:

  1. 当符合某种条件的情况下,主动跳出 work loop 的 while 循环;
  2. 调用异步代码调度 API 来通知协作方「我暂时把主线程的控制权让给你,但是你稍后要让回给我」。

用一个示意图来表示如下:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

执行一段时间?到底执行多久呢?

这个问题就比较简单了。众所周知,为了在视觉上达到流畅的用户体验,我们屏幕的渲染帧率至少需要达到 60 FPS。react 内部也是采用这个标准。在这个标准之下,一帧所耗费的时间为 = 1000ms / 60 ≈ 16ms。这 16 ms 内要做的事情包括:

解释执行 js -> layout -> paint -> composite

react 团队根据自己的实践所获得的经验,觉得了预留 11ms 给浏览器所取得的界面更新效果比较理想。所以,留给 js 解释执行的时间就是 16ms - 11ms = 5ms

综上所述,在 scheduler 中,单次任务的最长执行时间5ms(注意这里的措辞,是「单次任务」而不是「单个任务」)。时间一到,scheduler 就会把主线程的控制权让渡给浏览器。这就是上面提到的 shouldYieldToHost() 函数要做的事情。下面我们看看 scheduler 具体是怎么实现的:

  const frameYieldMs = 5; // 5ms
  // ......
  let frameInterval = frameYieldMs;
  let startTime = -1;

  function shouldYieldToHost() {
    const timeElapsed = getCurrentTime() - startTime;

    if (timeElapsed < frameInterval) {
      // The main thread has only been blocked for a really short amount of time;
      // smaller than a single frame. Don't yield yet.
      return false;
    } // The main thread has been blocked for a non-negligible amount of time. We

    return true;
  }

可以看出,只要相对于开始时间,过去的时间大于 5ms, scheduler 就会中断 work loop,把主线程的控制权让渡给浏览器。这里留给我们一个问题,scheduler 是在哪里开始计时的呢?从代码的角度来问就是:“startTime 变量是哪里赋值的呢?”。一番搜索,我们不难发现它是在performWorkUntilDeadline() 的函数体里面:

    const performWorkUntilDeadline = () => {
    const currentTime = getCurrentTime();
    // startTime 是一个 module scope 变量。在这里,startTime 被赋值了
    startTime = currentTime; 

    try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      // ......
    }
}

上面也指出了,scheduledHostCallback 其实只是flushWork() 的引用,而 flushWork() 的核心只是调用 workLoop() 函数。也就是说,scheduler 会在 work loop 开始之前记录了一个开始时间。而 work loop 只会在开始下一次任务执行之前才会做超时检查。鉴于函数调用的「run-to-completion」的特性,那么是不是如果某次任务执行超过 5ms,scheduler 是不是对此无可奈何呢?

是的,scheduler 还真的无可奈何。因为从 scheduler 的实现源码来看,scheduler 能够中断的最小工作单元是「一次任务的执行」。用示意图表示如下:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

这样的能力似乎不能实现 Dan 在js开发者大会上面提出的 time slicing 特性,是吗?这里先留个悬念。答案会在后面的章节去揭晓。

scheduler 的用途是什么?

源码中的 README.md 已经直言不讳地告诉我们,scheduler 主要是用于在浏览器环境实现一个协作式的调度系统。结合我们上面已经探索出来的东西,更具体地说,scheduler 实现了一个以 5ms 为时间片的 time slicing 功能。但是,我们上面也指出了一点,目前从源码的角度来看,scheduler 似乎没有做到这一点。

好吧,我也不想继续卖关子了。要想实现完整的 time slicing 能力,scheduler 需要调用方的配合。在本文的语境下,这个调用方就是「react」。在探究 react 是如何配合 scheduler 来完成 time slicing 能力之前,我们先来感受一下 time slicing 之美,或者说什么是 time slicing?上图:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

从我们对主线程的 profile 结果 - 火焰图可以直观地体会到什么是「time slicing」。与此同时,我们把鼠标 hover 在某个「Function Call」上面,你可以看到一个 function call 的总耗时大概是为 5ms。对的,这个就是我们上面提到的时间片的值。

我们还可以沿着火焰图的每个「小火山」的边缘去描一条边,得到如下的示意图:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

可以看出,浏览器和js程序交替占用主线程的控制权的这种现象就称之为「time slicing」

也许,你会继续追问:“那 time slicing 有什么用呢?”。一句话:「time slicing 能提高界面的可响应性,从而提高用户的交互体验」。

这里面的原理是:浏览器采用了单线程模型去线性地处理多个任务,这里主要包括,处理用户的交互动作,解释/执行 js和渲染界面。而且,js 的解释/执行和界面的渲染是两个相互阻塞的任务。虽然在 js 的执行期间浏览器是能捕获用户的交互动作,但是因为 js 还在执行,界面没有得到机会去根据用户的交互动作去做界面渲染。只有 js 执行完毕之后,界面才会被更新。这就导致了从「用户做出动作」(比如,「点击按钮」,「输入框输入文字」)到「用户看到正确的界面效果」之间出现了明显的时间间隔。这段时间间隔就带来了所谓的「卡顿,滞后的用户交互体验」问题

通过把一段 long task 型 js 代码的执行分散到无限多的渲染帧里面,我们保证了在一帧里面有足够多的时间去让浏览器完整界面渲染任务,从而解决了「卡顿,滞后的用户交互体验」问题。

以上就是「time slicing 能提高界面的可响应性,从而提高用户的交互体验」的原理。

scheduler 完整的工作流程

前置知识

最小堆(min heap)

scheduler 是用最小堆算法实现了优先级队列(priority queue)。什么是「最小堆」?最小堆就是一种特殊结构的二叉堆。众所周知,二叉堆本质上就是二叉树。只不过相比于普通的二叉树,它有两个特性:

  • 二叉堆是一棵完全二叉树。也就说,除了叶子节点之外,所有的节点必须要有左右两个节点。
  • 对于所有的父节点,它的值必须「大于等于」或者「小于等于」它的每个的子节点。

从第二个特性我们知道,二叉堆不是最大堆就是最小堆。在 scheduler 中,它是选择了最小堆来实现了优先级队列。为什么不是最大堆?那是因为 scheduler 选择了用「过期时间(expirationTime)」代表任务的优先级。如此一来:

  • expirationTime 的值越小,就代表着该任务的过期时间越短;
  • 过期时间越是短,则代表着这个任务越快触达它的任务失效点(再不执行,就要失信于调用方了);
  • 越是快触达它的任务失效点,则意味着越是紧急;
  • 越是紧急,代表任务的优先级越高;

综上所述,我们只需要关注expirationTime值最小的那个任务。而最小堆所具备的能力-「高效的排序能力,快速的最小值访问能力」刚好符合我们的需求 - 我们只需要关注那个优先级最高的任务。所以,react 团队就采用了最小堆来实现 scheduler 中的优先级队列。

如果采用冒泡排序等其他排序算法也能实现这个需求。但是鉴于我们只需要关注队列中的最小值的元素,那么这些算法就显得不那么高效了。

二叉堆的数据操作

二叉堆的数据操作很少,无非就下面这三种(最大堆和最小堆都一样):

  • 向堆中插入一个值(insert
  • 访问堆中的最大或者最小值,但是不要移除这个节点(findMini)
  • 访问堆中的最大或者最小值,但是同时要移除这个节点(extract)

以上的这种数据操作往往会伴随着三个 util 类型的操作 :

  • 上移(siftUp)
  • 下移(siftDown)
  • 交换(swap)

之所以称之为「util 类型的操作」是因为这些不是向外暴露给使用者的 API,而是服务于内部实现的操作。它们的目的是为了在每一个 mutable 类型的数据操作(插入或者移除一个元素)发生之后使得二叉堆仍然是符合最大堆或者最小堆的定义的。换句话说,mutable 类型的数据操作后,二叉堆还是「合法的」。

在 js 中实现最小堆

在 js 这门语言中,往往是采用数组(Array)这种数据结构来实现二叉堆。react 团队也不例外。用 js 数组来实现了 scheduler 里面的这个优先级队列。不过,这里面存在一个空间结构的转换: 将存在树结构的元素存储在数组中。这个转换遵循的规则是:按照「按层遍历」的方式来将二叉堆的元素入队到数组中。假如,我们有下面的最小堆:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler 那么,按照「按层遍历」的方式存储到数组中,我们得到这样的结果:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

通过观察,我们可以发现这样的一个规律:将一个合法的二叉堆存储在数组中,给定任意一个父节点,如果它的数组索引值为 i,那么它的左右子节点在数组中的索引值必定遵循下面的计算公式:

  • 左子节点的数组索引值 = 2i + 1;
  • 右子节点的数组索引值 = 2i + 2;

以上这个对应关系,是用数组实现二叉堆的关键是所在,请切记。这对研究 scheduler 中最小堆的实现源码十分有帮助。

scheduler 中的最小堆实现

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict
 */

type Heap = Array<Node>;
type Node = {|
  id: number,
  sortIndex: number,
|};

export function push(heap: Heap, node: Node): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

export function peek(heap: Heap): Node | null {
  return heap.length === 0 ? null : heap[0];
}

export function pop(heap: Heap): Node | 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(heap, node, i) {
  let index = i;
  while (index > 0) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  while (index < halfLength) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (compare(left, node) < 0) {
      if (rightIndex < length && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

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;
}

以上就是 react 在 scheduler 中对最小堆的实现。在这里,不会讲述这里面的实现原理和细节,因为这里面用到的算法思想都是通用的。在这里想强调的是,react 团队在对数据操作方法命名上的不同:

  • pop() 其实就是我们上面的 extract(),用于访问堆顶元素后并把它从堆中移除掉;
  • peek() 其实就是我们上面的findMini(),用于只访问堆顶元素而不移除它;
  • push() 其实就是我们上面的insert(),用于把新元素插入到堆中;

与此同时,我们可以看到,在小顶堆中插入或者移除一个元素后,二叉堆会马上调用 siftUp()或者siftDown()这两个方法来动态调整二叉堆,使得刚被破坏结构的二叉堆马上恢复为合法的最小堆状态。理解最小堆(最大堆也一样)这种动态调整能力对于理解 scheduler 的调度系统十分重要。

小结

以上就是关于 scheduler 所用到的最小堆的简单介绍。上面我们并没有具体讲述最小堆在数据结构与算法层面的实现原理,我们的侧重点在于帮助大家来理解「react 是如何利用它来实现优先级队列的」。总而言之,我们只需要记住下面的二段话即可:

  • 通过 peek(),我们总是能访问到task queue里面优先级最高的那个任务;
  • 一旦我们进行 push() 或者 pop()操作之后,二叉堆会自动再次构建一个合法的最小堆,等待着我们下一次的 peek()

两个 priority queue

scheduler 中用到的数据结构不多,就两个:

  • task queue
  • timer queue

从用途的角度来看,它们都是「优先级队列」;从数据结构与算法的角度来看,它们都是「最小堆」;从 js 数据类型来看,它们都是「普通的 js 数组」;从数组元素的角度来看,它们的数组元素的含义都是「任务」,且使用的数据结构是一模一样的:

type Task = {
      id: number, // 任务 id,一个唯一标识符
      callback: ()=> any, // callback 就是我们要执行的真正的任务,它的返回值是有特殊含义的
      priorityLevel: number, // scheduler 里面有五个优先级级别,稍后再讨论
      startTime: number, // 任务入队到 task queue 时候的时间戳
      expirationTime: number // 任务过期的时间戳
      sortIndex: number, // 用于在最小堆中排序的字段。
    }

唯一的不同点是,这两个数组中任务的含义不一样:

  • task queue 里面装的是普通任务;
  • timer queue里面装的是调用方主动要求延迟的任务;

什么是「调用方主动要求延迟的任务」呢?关于这个问题的答案,我们可以在 unstable_scheduleCallback() 函数体中找到答案:

function unstable_scheduleCallback(priorityLevel, callback, options) {
    var currentTime = getCurrentTime();
    var startTime;

    if (typeof options === "object" && options !== null) {
      var delay = options.delay;

      if (typeof delay === "number" && delay > 0) {
        startTime = currentTime + delay;
      } else {
        startTime = currentTime;
      }
    } else {
      startTime = currentTime;
    }
    
   // ......
   if (startTime > currentTime) {
      // ......
      push(timerQueue, newTask);
      // ......
    } else {
      // ......
      push(taskQueue, newTask);
      // wait until the next time we yield.

      // ......
    }
}

从上面摘抄出来的源码中,我们可以看得一清二楚。当调用方调用unstable_scheduleCallback()时,通过第三个参数 options对象的 delay属性指明延时时间的时候,我们就会把这个任务入队到 timer queue;否则的话,就入队到 task queue

这两个任务队列并不是完全独立,它们之间存在一种转换关系timer queue 里面的延时任务一旦到达了它的延时时间点,它就会被推入到 task queue 中,等待被执行。

要记住,延时任务永远不会在 timer queue 中被执行,而是最终会被转移到 task queue之后才会被执行。

在 scheduler 的源码中,有一个专门的函数来负责将延时到期的任务转移到 task queue中 - advanceTimers():

  function advanceTimers(currentTime) {
    // Check for tasks that are no longer delayed and add them to the queue.
    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);
    }
  }

从上面的源码,我们可以看出,advanceTimers() 的任务就是遍历 timer queue 所有的延时任务,逐个逐个地检查当前时间点是否已经超过了它所设定的延时时间点:

// ......
else if (timer.startTime <= currentTime) {
// .......
}

如果是,就做下面的三件事:

  • 把当前延迟任务 A 从 timer queue 中移除;
  • 把 A 插入到task queue中;
  • 修改 A 的堆排依据字段为 expirationTime(在timer queue,堆排的依据字段为 startTime)。

延时任务转普通任务的时机

在 scheduler 中,存在三个时机去做「延时任务转普通任务」的这件事情:

  1. 开始 task queue 的 work loop 之前:

    function workLoop(hasTimeRemaining, initialTime) {
        let currentTime = initialTime;
        advanceTimers(currentTime);
        // ......
    
        while (currentTask !== null && !enableSchedulerDebugging) {
            // ......
        }
    }
    
  2. 在 work loop 的每一个迭代中,当执行完一次任务之后:

    function workLoop(hasTimeRemaining, initialTime) {
        // ......
    
        while (currentTask !== null && !enableSchedulerDebugging) {
           if (typeof callback === "function") {
              // .....
            const continuationCallback = callback(didUserCallbackTimeout);
              // ......
             advanceTimers(currentTime);
            } else {
            // ......
            }
        }
    }
    
  3. 当 work loop 被中断后且 task queue 已经被清空 ,scheduler 就会尝试去检查 timer queue,看看是否有延时到期的任务可以转移到 task queue 中去:

    function handleTimeout(currentTime) {
        isHostTimeoutScheduled = false;
        advanceTimers(currentTime);
        // ......
      }
    

小结

其实,「延时任务转普通任务的时机」并不是 scheduler 工作的主流程。它只是 scheduler 向外提供的一个增强型特性 - 支持延迟任务。到目前为止, react 的源码里面并没有怎么使用这种能力。所以,在后续的 scheduler 工作的主流程讲解中,我不会过多探讨「延时任务」相关的东西。 scheduler 工作的主流程是以普通任务的调度为主。

时间要素

在 scheduler 的内部实现里面,「时间」是最重要的要素之一。毫不夸张地说,「时间」就是 scheduler 的命门。它的相关的操作贯穿在 scheduler 工作主流程中,最终影响了某个任务是否应该执行以及何时执行。下面将会从两个维度来梳理「时间」这个要素在 scheduler 里面的表现:

  • 时间的统计方式
  • 跟时间相关的变量

时间的统计方式

首先,我们得来看看,scheduler 所采用的计时方式是什么。

 let getCurrentTime;
  const hasPerformanceNow =
    typeof performance === "object" && typeof performance.now === "function";

  if (hasPerformanceNow) {
    const localPerformance = performance;

    getCurrentTime = () => localPerformance.now();
  } else {
    const localDate = Date;
    const initialTime = localDate.now();

    getCurrentTime = () => localDate.now() - initialTime;
  }

上面的源码将这一点展现无疑:

  • 优先考虑使用 performance.now() 这个 API 来作为计时的工具;
  • 只有在不支持这个 API 的环境里面,才会考虑使用 Date.now() 这个 API。

无论是 performance.now() 还是 Date.now(),这两个方法的返回值都是一个正数,所代表的含义都是「时间戳」。而时间戳的定义是:“相比某个参考点(time origin),过去了多少毫秒”。

也许有好奇的读者可能会想,为什么不优先考虑 Date.now() 呢? 这是因为,performance.now() 相比 Date.now() 有两个优势:

  • 能表示的时间精度更高 - 相比于Date.now() 只能精确到 1msperformance.now() 能精确到微秒级别。
  • 更可靠和安全 - Date.now() 会受到操作系统时钟或者用户时间调整的影响。而 performance.now() 不会。因为它的 time origin 是「用户打开当前浏览器标签页的那一刻的时间戳」。用户对操作系统时间的调整不会影响到performance.now()的返回值。

尤其是第二点优势特别重要。设想一下,如果是使用Date.now() 作为计时工具的话。那么,如果在两次任务的执行的间隙中,用户把系统时间调小了,那么 shouldYieldToHost() 内部实现的的 timeElapsed(const timeElapsed = getCurrentTime() - startTime;) 就是一个负数,也就是说shouldYieldToHost()的返回值将是false。这会导致 scheduler 永远都不会把主线程的控制权让渡出去,任务永远都是以同步的方式去执行。time slicing 就形同虚设了。

跟时间相关的变量

work loop 的开始时间 startTime

它是一个全局变量, 初始值为 -1。scheduler 会在每一个 work loop 进行之前对它进行赋值:

let startTime = -1
const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime(); // Keep track of the start time so we can measure how long the main thread
      // has been blocked.
      startTime = currentTime;
     
      // ......

      try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      } finally {
      // ......
      }
    } else {
      // ......
    } 
  }

上面已经提到了,scheduledHostCallback() 最终调用的是 workLoop()

在进入 work loop 的下一次迭代前,scheduler 都会检查是否应该中断 work loop。而中断 work loop 有几个条件,检查当前 js 执行所阻塞主线程的时间就是其中的一个条件之一。负责这个检查工作的就是 shouldYieldToHost() 函数:

function shouldYieldToHost() {
    const timeElapsed = getCurrentTime() - startTime;

    if (timeElapsed < frameInterval) {
      // The main thread has only been blocked for a really short amount of time;
      // smaller than a single frame. Don't yield yet.
      return false;
    } // The main thread has been blocked for a non-negligible amount of time. We

    return true;
  }

这里面的 startTime 就是上面的全局变量 startTime 。可以看出,timeElapsed 统计的就是「从 work loop 开始到执行完某次任务之后,进入 work loop 下一次迭代之前所经过的时间(单位是毫秒)」

任务的开始时间 startTime

这里另外一个「startTime」。

  • 对于普通任务而言,它就是任务对象生成的时间戳
task.startTime = getCurrentTime();
  • 对于延迟任务而言,它就是任务对象生成的时间戳 + 调用方主动要求的延迟时间
task.startTime = getCurrentTime() + delay;

以上的实现细节藏在 unstable_scheduleCallback() 函数里面。

任务的过期时间 expirationTime

expirationTime 可以说是跟时间相关的最重要的一个变量。之所以说它是最重要的,因为它代表的就是任务的优先级。哪个任务的优先级高,哪个任务就先执行。早执行就早超生。几乎可以说,它就是任务的「命门」。

那它的值是怎么来的呢?答曰:“计算出来的”。它的计算公式是:

任务的过期时间戳 = 任务的开始时间戳 +  timeout 时间;

任务的开始时间戳,上面已经说了,它就是任务对象生成的时间。任务对象会在调用方调用 unstable_scheduleCallback() 来请求调度式地去执行某个 callback(也就是我们这里说的「任务」)的时候生成。

那「timeout 时间」是怎么来呢?timeout 时间是根据调用方传递给我们的「优先级」(priorityLevel)来转换得出的。简单来说,就是每一个优先级都会对应一个内定的 timeout 时间。关于这一点,unstable_scheduleCallback() 函数的这段源码已经表达得很清楚了:

var maxSigned31BitInt = 1073741823;

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

function unstable_scheduleCallback(priorityLevel, callback, options) {
 // .......
    var timeout;

    switch (priorityLevel) {
      case ImmediatePriority:
        timeout = IMMEDIATE_PRIORITY_TIMEOUT;
        break;

      case UserBlockingPriority:
        timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
        break;

      case IdlePriority:
        timeout = IDLE_PRIORITY_TIMEOUT;
        break;

      case LowPriority:
        timeout = LOW_PRIORITY_TIMEOUT;
        break;

      case NormalPriority:
      default:
        timeout = NORMAL_PRIORITY_TIMEOUT;
        break;
    }
    // ......
}

到这里,我们知道了 expirationTime 「来龙」,那它的「去脉」是怎么的呢?也就是说它有什么用?expirationTime 有两种用途:

  • 赋值给任务对象的 sortIndex 属性。而 sortIndex 属性就是最小堆在做排序的时候做大小比较的值。也就是 expirationTime 用于确定任务的优先级的。相关的源码就是最小堆的 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;
      }
    
  • 用于确定某个任务是否已经到期了。scheduler 无法中断到期任务的执行。到期任务将会以同步的方式去执行。如果判断某个任务到期呢? 很简单,就是那任务对象的 expirationTime 跟当前时间戳比。如果当前时间戳大于 expirationTime的值,则表示该任务已经到期了;否则的话,就是没有到期。

function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
// ......
while (currentTask !== null && !enableSchedulerDebugging) {
      if (
        currentTask.expirationTime > currentTime &&
        (!hasTimeRemaining || shouldYieldToHost())
      ) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
      
      const callback = currentTask.callback;

      if (typeof callback === "function") {
         const didUserCallbackTimeout =
          currentTask.expirationTime <= currentTime;

          const continuationCallback = callback(didUserCallbackTimeout);
          // ......
      }
 }
 // ......
 
}

如果,如果当前时间戳大于 expirationTime的值,代码就无法进入 break 语句内部,也就是无法跳出 while(){} 循环,只能硬着头皮去执行这个过期任务。与此同时,scheduler 还有通过 didUserCallbackTimeout 来把「任务过期了」这个信息告知调用方,让调用方自己看着办。就 react 这个调用方而言,它将会用「同步不可中断」的方式去这个任务。

这里请不要误解「任务过期」的意思。因为从字面意思来看,「任务过期」就好像在表达「任务就像食物一样变坏了」,已经不重要了,甚至说不需要执行(因为物理世界中,如果别人跟我们说某样食品过期了,我们得第一反应应该是该食品不能吃了,要丢掉它)。而恰恰相反,「过期任务」的意思是这个任务十分紧急,十分重要。因为,再不执行这个任务的话,我(scheduler)就会失信于调用方的委托。

scheduler 的优先级系统

上一小节已经提到了『优先级』这个术语了。在 scheduler 有自己的优先级系统。该系统里面有五个等级:

  // TODO: Use symbols?
  const ImmediatePriority = 1;
  const UserBlockingPriority = 2;
  const NormalPriority = 3;
  const LowPriority = 4;
  const IdlePriority = 5;

优先级的数值越小,就代表它的优先级越高,所关联的任务就越紧急。越是紧急的任务,就会越早被 scheduler 执行。

上面的优先级的值本身是不会参与算术运算的。上面的 comment 也写得很清楚 - 它的值也可以用 ES6 的 symbol 来代表。所以,优先级变量的职责是一个常量而已。

优先级变量的用处其实在上一小节中已经提到过了 - 不同的优先级最终会被映射成一个 timeout 数值,最终参与到任务过期时间 expirationTime 的计算中来。

优先级来自于调用方调用unstable_scheduleCallback() 函数时传进来的实参。从上面的源码,我们可以得知,调用方最好是使用 scheduler 内置的这五个优先级等级。否则的话,scheduler 只能 fallback 到 NormalPriority 这个等级(这个等级所对应的 timeout 时间为 5000ms)。

流程解析

有了上面的前置知识作为铺垫,相信我们能很容易就能了解 scheduler 工作的主体流程。下面,让我们开始进入流程解析的内容吧。

主体流程

人狠话不多,直接祭出主题流程的时序图:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

从上面的时序图中我们可以看出,react 内部也自带了一个简单的调度系统。因为这个小系统比较简单,所以本文先不讨论它。本文讨论的是交由 scheduler 来调度的那一部分内容。所以,看图的话,只需要关注「scheduler」和「浏览器」这两个角色即可。

下面,我们会沿着主流程,按次序来讲讲每一个步骤里面的细节。

理论上说, scheduler 是一个平台无关的调度系统。但是本系列志在讨论 react 的底层原理,所以下文中,我们把 scheduler 的调用方锁定为「react」。

1. 请求调度

目前而言,scheduler 包是通过 unstable_scheduleCallback(priorityLevel, callback, options) 这个 API 来向外提供调度能力的。react 内部就是通过调用这个方法来请求调度一次更新。

由用户发起的每一个更新请求最终,必须经过一个关卡 - ensureRootIsScheduled()。在这个函数里面,我们可以看到它对 unstable_scheduleCallback() 调用:

// 源码路径:react@18.2.0/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
function ensureRootIsScheduled(){
    // ......
    if (newCallbackPriority === SyncLane) {
        // ......
    }else {
        // ......
         newCallbackNode = scheduleCallback(
              schedulerPriorityLevel,
              performConcurrentWorkOnRoot.bind(null, root),
         );
    }

从上面的逻辑可以看到,非同步 lane 的更新请求最终会进入 scheduler 的调度系统。

也许你会说,你有没有搞错?上面 ensureRootIsScheduled() 函数中调用的方法是 scheduleCallback() 而不是 unstable_scheduleCallback()。好吧,你可以到 react 的源码里面追溯一下,实际上,scheduleCallback()最终指向的就是 scheduler 包里面的 unstable_scheduleCallback() 函数。

现在我们假设当前是用户发出的第一个非同步的更新请求,那么我们现在正式向 scheduler 发起一次调度请求。

调度,调度,调度谁啊?从上面的代码中,我们可以看出,调度的对象是 performConcurrentWorkOnRoot()。从unstable_scheduleCallback() 函数的签名来看,它是被称之为 callback

2. 创建任务对象

我们在上面的 「两个 priority queue」小节中讲过,在 scheduler 中,有两种类型的任务:

  • 普通任务
  • 延时任务

由于,我们在调用调度接口的时候没有传递延时任务所要求的配置对象 options。显而易见,我们将会生成的是一个普通任务。创建任务对象发生在unstable_scheduleCallback() 函数里面。

任务对象是长什么样的?上面也提到过了,不过这次我们直接上源码来看看:

function unstable_scheduleCallback(priorityLevel, callback, options) {
    var currentTime = getCurrentTime();
    var startTime;

    if (typeof options === "object" && options !== null) {
      var delay = options.delay;

      if (typeof delay === "number" && delay > 0) {
        startTime = currentTime + delay;
      } else {
        startTime = currentTime;
      }
    } else {
      startTime = currentTime;
    }

    var timeout;

    switch (priorityLevel) {
      case ImmediatePriority:
        timeout = IMMEDIATE_PRIORITY_TIMEOUT;
        break;

      case UserBlockingPriority:
        timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
        break;

      case IdlePriority:
        timeout = IDLE_PRIORITY_TIMEOUT;
        break;

      case LowPriority:
        timeout = LOW_PRIORITY_TIMEOUT;
        break;

      case NormalPriority:
      default:
        timeout = NORMAL_PRIORITY_TIMEOUT;
        break;
    }

    var expirationTime = startTime + timeout;
    var newTask = {
      id: taskIdCounter++,
      callback,
      priorityLevel,
      startTime,
      expirationTime,
      sortIndex: -1,
    };

    if (startTime > currentTime) {
      // This is a delayed task.
      newTask.sortIndex = startTime;
      push(timerQueue, newTask);

      if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
        // All tasks are delayed, and this is the task with the earliest delay.
        if (isHostTimeoutScheduled) {
          // Cancel an existing timeout.
          cancelHostTimeout();
        } else {
          isHostTimeoutScheduled = true;
        } 
        // Schedule a timeout.
        requestHostTimeout(handleTimeout, startTime - currentTime);
      }
    } else {
      newTask.sortIndex = expirationTime;
      push(taskQueue, newTask);
      
      // wait until the next time we yield.
      if (!isHostCallbackScheduled && !isPerformingWork) {
        isHostCallbackScheduled = true;
        requestHostCallback(flushWork);
      }
    }

    return newTask;
  }

可以看出,任务对象就是一个普通字面量对象,无他。创建任务对象的最重要的两个子项是:

  1. 计算任务开始时间 startTime 的值;
  2. 计算任务过期时间 expirationTime 的值;
计算任务开始时间 startTime 的值

对于普通任务而言,startTime就等于任务对象生成的时间戳,即 currentTime;而对于延时任务而言,它的startTime 就等于 「currentTime + 延时的时间段」。

计算任务过期时间 expirationTime 的值

无论对于普通任务还是延时任务,过期时间的计算公式都是一样的:

expirationTime = startTime + timeout;

timeout 怎么来?在「时间要素」的那一小节都已经讲解清楚了,在此就不再赘述。

3. 入队到对应的队列

任务对象一旦创建完毕后,scheduler 就会开始着手将它入队到相应的优先级队列中 - task queue 或者 timer queue。对于这两个队列,我们已经在「前置知识 - 两个 priority queue」中讲过,此处就不再赘述。

因为对于延时任务而言,它的 startTime 的值是叠加了一个延时时间段。除非异常情况(调用方传递进来的delay 值为负数或者0),否则,startTime 一定是大于 currentTime。这就是源码里面,if (startTime > currentTime) 这个判断条件的意思。

如果是普通任务,那么 scheduler 就会入队到 task queue;否则,就是延时任务,那么就入队了 timer queue里面。

这两种情况下的入队,伴随的还有一个重要的动作要做:修正任务对象sortIndex 的值。从上面的源码可以知道,在 task queue 中,scheduler 是使用 expirationTime 字段来指示任务的优先级;而在 timer queue,scheduler 是使用 startTime 字段来指示任务的优先级。

为什么timer queue不使用 expirationTime 字段来指示任务的优先级呢?其实,对于timer queue中的任务而言,startTime 的真正含义并不是优先级相关的,而是在告知 scheduler「最快是什么时候得到timer queue队列中去检查一下,看看有没有延时到期的,如果有,则把它们转移到timer queue中去」

4. 真正地去调度

当前我们讨论的是主流程,所以我们只关注普通任务。同时我们也假设当前是以用户的第一个更新请求。所以,代码最终会执行到这里:

function unstable_scheduleCallback(priorityLevel, callback, options) {
    // ......
    requestHostCallback(flushWork);
    // ......
}

这里,scheduler 正式向浏览器发成调度请求。

用什么方式来发出呢?用浏览器提供给我们的异步代码调度 web API。关于这一点,我在上面的「如何把主动地把控制权让渡出去呢?」小节已经阐述过。requestHostCallback() 的调用栈如下:

requestHostCallback()
schedulePerformWorkUntilDeadline()
setImmediate()/MessageChannel/setTimeout()

可以看出,真正发挥调度功能的还是浏览器原生的 web API。

调度的对象是谁?从下面的源码中,我们可以找到这个问题的答案:

let schedulePerformWorkUntilDeadline;

  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);
    };
  }

可以看到,调度的对象是 scheduler 的 performWorkUntilDeadline() 函数。也就是说,浏览器一旦完成了自己的任务,让渡对主线程的控制权之后,event loop 会马上执行的函数是 performWorkUntilDeadline() 函数。

5. 开始 work loop

实际上,调用 performWorkUntilDeadline() 函数并没有直接进入 work loop。workLoop()作为一个函数,它被层层包裹住了。这么做,是为了在 workLoop() 的前后去设置诸多标志位变量。这些标志位变量有什么用呢?关于这一点,我会在「边缘用例 - 防止重复调度」中讲解。现在且看,workLoop()函数是如何被包裹住的:

  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      startTime = currentTime;
      const hasTimeRemaining = true; 
      
      // If a scheduler task throws, exit the current browser task so the
      // error can be observed.
      //
      // Intentionally not using a try-catch, since that makes some debugging
      // techniques harder. Instead, if `scheduledHostCallback` errors, then
      // `hasMoreWork` will remain true, and we'll continue the work loop.
      let hasMoreWork = true;

      try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      } finally {
        if (hasMoreWork) {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          schedulePerformWorkUntilDeadline();
        } else {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        }
      }
    } else {
      isMessageLoopRunning = false;
    } 
  };

scheduledHostCallback 是一个全局变量。它的赋值发生在 requestHostCallback() 函数里:

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

// 在 scheduler 中,对 `requestHostCallback` 的调用都是这样传参:
requestHostCallback(flushWork)

最终表明,scheduledHostCallback指向的就是 flushWork() 函数。到这里,我们就可以得到下面三者

  • performWorkUntilDeadline()
  • flushWork()
  • workLoop()

的关系心智图:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

6. 执行任务

work loop 其实就是一个可退出的 while(){} 循环,无他。在没有退出 work loop 之前,work loop 会通过 peek() 方法到 task queue 中取出那个优先级最高的(或者说最紧急的)任务来执行。每一个 work loop 迭代里面只处理一次任务

细心的读者可能发现了,我多次强调了这么一个事实:work loop 的一个迭代里面,只执行「一次任务」。我强调的是「一次」而不是「一个」,这是为什么呢?

这是因为: 『一个任务可能被执行若干次』。翻译为代码的语言就是:『代表任务的 callback 函数有可能被调用多次才能完成一个完成的任务』。

联系到 react 这个上下文,我们把 performConcurrentWorkOnRoot() 代入成这个 callback,事情就很好理解了。

上面的这些逻辑所相关的代码如下:

function workLoop(hasTimeRemaining, initialTime) {
    // ......
    const continuationCallback = callback(didUserCallbackTimeout);
    currentTime = getCurrentTime();

    if (typeof continuationCallback === "function") {
      currentTask.callback = continuationCallback;
    } else {
      if (currentTask === peek(taskQueue)) {
        pop(taskQueue);
      }
    }
   // ......
}

可以看出,scheduler 是在乎 callback 的返回值的。如果返回值是函数类型的话,这表明当前这个任务还没有完成。调用方需要 scheduler 的 work loop 在下一个迭代继续去执行;否则的话,就代表这个任务已经完成了,可以从 task queue 队列中移除了。

从 scheduler 在 react 中的应用来看。这个 callback 主要是指 performConcurrentWorkOnRoot() 函数。我们可以到源码中看看该函数的返回值:

function performConcurrentWorkOnRoot(root, didTimeout) {
    // ......
    if (root.callbackNode === originalCallbackNode) {
      // The task node scheduled for this root is the same one that's
      // currently executed. Need to return a continuation.
      return performConcurrentWorkOnRoot.bind(null, root);
    }

    return null;
}

可以看出,正常执行的情况下(因为还有各种主动 throw 错误的情况),performConcurrentWorkOnRoot()只有两种返回值:

  • 返回函数自身;
  • 返回 null

所以,同一个时间片(5ms的时间内)里面,如果前一个任务执行后返回返回值是函数类型的话,那么下一个迭代执行的还是相同的任务。

7. 中断 work loop

我们把「break while(){}循环」称之为「中断 work loop」。

function workLoop(hasTimeRemaining, initialTime) {
  // ......
  while (currentTask !== null && !enableSchedulerDebugging) {
      if (
        currentTask.expirationTime > currentTime &&
        (!hasTimeRemaining || shouldYieldToHost())
      ) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
      
      // ......
  }
}

从源码找那个我们可以看出中断 work loop 的条件有三个。当前条件必须同时满足这三个条件,scheduler 才会中断 work loop:

  • 当前任务还没有过期;
  • hasTimeRemaining 的值为 false;
  • shouldYieldToHost() 的返回值为 true

实际上,因为 hasTimeRemaining 这个变量的值是从 performWorkUntilDeadline() 函数体内一直透传给 workLoop() 的,而且是硬编码为 true

react 团队为什么这么干,一时半会还搞不清楚

所以,实际上能够中断 work loop 的条件只有两个:

  • 当前任务还没有过期;
  • shouldYieldToHost() 告诉我们是时候要让渡控制权给浏览器了;

我在上面的小节中「执行一段时间?到底执行多久呢?」已经解释了「work loop 的时间片为 5ms」的事实以及实现细节,这里就不再赘述了。

在这里想强调的一点,从上面的条件我们可以看出,如果一个任务到期了,那么 work loop 是不会中断的,而是把这个过期任务以同步的方式执行完毕

当然,让任务以同步的方式去执行,这一步其实不是 scheduler 来决定的,而是调用方(react)来决定的。scheduler 只是负责告知调用方当前这个任务已经过期了。

综上所述,time slicing 并不是总是能奏效的。它能奏效的前提是在一个时间片的时间内所执行的任务没有过期任务。

8. 继续 work loop

work loop 最理想的情况是什么?那就是用一个时间片的时间把 task queue 中的所有任务都执行完成。倘若不能的话,那 scheduler 就会发起另外一个调度请求。在事件循环的 next tick 中去再跑一次 work loop。

scheduler 是如何知道是否需要调度下一次的 work loop 呢?好吧,是通过 workLoop()函数的返回值:

  • 返回值为 true 的时候表示还有任务需要执行;
  • 返回值为 false 的时候表示所有的任务都完成了;
var currentTask = null;
function workLoop(hasTimeRemaining, initialTime) {
    // ......
    currentTask = peek(taskQueue);
    while (currentTask !== null && !enableSchedulerDebugging) {
        // ......
        currentTask = peek(taskQueue);
    }
    
    if (currentTask !== null) {
      return true;
    } else {
      // ......
      return false;
    }
}

那什么时候 currentTasknull呢?

function peek(heap) {
    return heap.length === 0 ? null : heap[0];
}

答案显而易见: task queue 队列为空的时候 - 「task queue 队列为空」也就是意味着队列中的所有任务都执行完成,都从队列中移除了。

从上面这个心智图,我们可以知道,workLoop()函数的返回值会层层返回给 performWorkUntilDeadline() 的。在performWorkUntilDeadline()函数内部用一个hasMoreWork来承接这个返回值。如果hasMoreWorktrue,则代表中此时task queue 队列不为空,还有任务要执行(正如这个变量名所表达的意思那样)。这个时候,scheduler 就会重新发起一个调度:

  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
     // ......

      try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      } finally {
        if (hasMoreWork) {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          schedulePerformWorkUntilDeadline();
        } else {
           // ......
        }
      }
    } else {
       // ......
    }
  };

聪明的人可能看出了,这是一个间接的循环调用:schedulePerformWorkUntilDeadline() 最终会导致 performWorkUntilDeadline()被调用。倘若,在 scheduler 让渡主线程的控制权给浏览器期间,不断地有任务入队,那么这个循环调用就会一直进行下去。

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

9. work loop 空闲

某一次 work loop 完成之后,task queue 清空了。此时 hasMoreWork 的值为 false。work loop 就处于空闲状态。与此同时,上面所提的这个performWorkUntilDeadline() 的间接调用就终止了。

实现真正的 time slicing

上面在「scheduler 的用途是什么」已经提到了「scheduler 需要调用方(react)的配合才能实现 time slicing 能力」。因为,从 scheduler 的 work loop 这边看,work loop 只会执行下一个任务之前去检查时间片是否已经用完了。假如上一个任务(即任务对象的 callback 函数)一旦开始调用了,由于 js 函数的 「run-to-completion」特性,函数是无法从外部去结束调用的。

举个例子。task queue有三个任务,第一个用时 3ms,第二个用时 4ms,第三个用时5ms,那么这个 work loop 就会占用主线程 7ms 才会退出。为什么会这样?归根到底的原因是,scheduler 中,对 work loop 占用时间的检查只会发生在「任务执行之前」

在执行第二个任务之前,我们检查了占用时间,此时 3ms < 5ms。所以,work loop 会继续 - 第二个任务马上被执行。一个任务的执行就是一个函数的调用,鉴于函数调用的「run-to-completion」的特性,正常情况下,这个函数会执行完,没有什么东西能从外部去打断这个函数的执行。所以,第二个任务会占用 4ms执行完。直到准备执行三个任务之前,时间片检查不通过((3ms + 4ms) > 5ms), work loop 才会退出。所以说,单靠 scheduler 是无法准确实现 time slicing 的。

那怎么办?方法只有一个,从 callback 内部去结束调用。 这就是在 react 侧的 performConcurrentWorkOnRoot() 调用栈上的 workLoopConcurrent() 函数也有一个时间片检查的原因:因为它需要配合 scheduler 一起来完成 time slicing 的能力:

  function workLoopConcurrent() {
    // Perform work until Scheduler asks us to yield
    while (workInProgress !== null && !shouldYield()) {
      performUnitOfWork(workInProgress);
    }
  }

shouldYield() 其实引用的就是 scheduler 里面的 shouldYieldToHost() 函数。在shouldYieldToHost() 函数里面有两个时间变量:

  • startTime
  • getCurrentTime()

上面讲到了 startTime5ms 时间片的计时起点,它的赋值是发生在 scheduler 的 work loop 开始之前。而 workLoopConcurrent() 用的跟 scheduler 的 work loop 同一个的时间片检查工具函数。也就是说,这两个 work loop 时间片起始时间点是同一个。

技术上,通过对 startTime 变量进行闭包来实现的。

换句话说,react 是通过在两个时间节点上相对同一个起始时间进行时间片检查来保证了 time slicing 的相对准确实现(注意,我强调了「相对准确」)

  • 第一个时间节点是在代表任务的 callback 函数外面 - scheduler 的 work loop 执行下一个任务之前;
  • 第二个时间节点是在代表任务的 callback 函数里面 - react fiber 的 work loop 在对下一个 fiber node 执行 work 之前。

上面的阐述一张示意图来表示就是:

2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler

为什么我上面强调了强调了「相对准确」这种说辞呢?这个问题可以换成另外一个问题:“有了这两次的时间片检查就一定能保证我们是 5ms 让渡一次吗?”

我们不妨往深入再思考一下一个命题:「如果把所有的任务都执行完视为正向的事情,那么 time slicing 模式,最理想的情况是什么?最糟糕的情况又是什么?」

答曰:

  • 最理想的情况是,task queue 中所有的任务的执行耗时加起来少于 5ms。这种情况下,所有的任务会在 scheduler 的一个 work loop 里面被执行完;
  • 最糟糕的情况是 task queue 中优先级最高的那个任务的执行耗时多于 5ms。这种情况下,scheduler 的一个 work loop 只执行了一次任务(而这个任务也没有执行完成,还要执行若干次)就退出了。

边缘用例

上面讨论的是主流程功能,下面我们讨论完了主流程之外的一些 edge case。

防止重复调度

主流程里面,我们仅仅是研究了首个任务入队的情况。那假如随后有第二个,第三个......第N个任务入队,scheduler 会怎么应对呢?scheduler 采用的措施是「防止重复调度」。这样做的好处至少有两点:

  • 通过保持唯一一个重复调度入口,保证了调度系统的稳定性和可靠性;
  • 不会对 performWorkUntilDeadline()产生多余的调用(所谓「多余调用」就是 task queue为空了,我们还是调度执行了performWorkUntilDeadline()

上面讲过 schedulePerformWorkUntilDeadline() 就是 scheduler 发起调度请求的工具函数。那么,我们把对它调用的调用栈之前代码合并到一块,就能清晰地看到三个标志位变量:

function unstable_scheduleCallback(priorityLevel, callback, options) {
    // ......
    if (!isHostCallbackScheduled && !isPerformingWork) {
        isHostCallbackScheduled = true;
        // 把“requestHostCallback(flushWork)”替代为下面的代码;
        scheduledHostCallback = flushWork;

        if (!isMessageLoopRunning) {
          isMessageLoopRunning = true;
          schedulePerformWorkUntilDeadline();
        }
      }
     // ......
}

可以看出,scheduler 是通过三个全局的标志位变量来防止调用方对 performWorkUntilDeadline() 进行重复调度。

  • isHostCallbackScheduled - 已经发出了调度请求,协作方是否已经回调我方 callback。值为 true,表示还在等待回调;值为 false,表示已经被回调了;
  • isPerformingWork - work loop 是否正在运行中(这对在多线程环境使用 scheduler 有用);
  • isMessageLoopRunning - task queue 是否已经被清空(即所有的普通任务都已经执行完毕)。

只有上面三个标志位变量同时为 false, scheduler 才会发起一次调度请求。因为这三个标志位变量的初始值都是 false,这就是第一个任务能够产生一次调度请求的原因。其实这三个标志位代表的是调度系统的三种状态:

  • isHostCallbackScheduled - 正在等待 callback 被回调;
  • isPerformingWork - callback 被回调了,正在运行 work loop;
  • isMessageLoopRunning - 无数个 work loop 之后, task queue 终于被清空。
isHostCallbackScheduled

该标志位是在发起调度请求的时候设置为true:

function unstable_scheduleCallback(priorityLevel, callback, options) {
    // ......
    if (!isHostCallbackScheduled && !isPerformingWork) {
        isHostCallbackScheduled = true;
        requestHostCallback(flushWork);
      }
     // ......
}

flushWork() 中又被设置为false

function flushWork(hasTimeRemaining, initialTime) {
    isHostCallbackScheduled = false;
    // ......
}
isPerformingWork

该标志位是在 work loop 开始的时候设置为true,结束之后设置为false :

function flushWork(hasTimeRemaining, initialTime) {
    // ......
    isPerformingWork = true;
    // ......

    try {
      if (enableProfiling) {
        // ......
      } else {
        // No catch in prod code path.
        return workLoop(hasTimeRemaining, initialTime);
      }
    } finally {
      // 在 JavaScript 中,当 return 语句出现在 finally 块之前时,finally 块里的语句仍然会执行。
      // 所以说,下面的 cleanup 工作始终都会执行
      // ......
      isPerformingWork = false;
    }
  }
isMessageLoopRunning

该标志位是在发起调度请求的时候设置为true:

  function requestHostCallback(callback) {
    // ......

    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      schedulePerformWorkUntilDeadline();
    }
  }

在经历若干个 work loop,task queue 被清空,所有任务都执行完之后被设置为false

  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      // ......
      try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      } finally {
        if (hasMoreWork) {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          schedulePerformWorkUntilDeadline();
        } else {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        }
      }
    } else {
      isMessageLoopRunning = false;
    } 
  };

综上所述,scheduler 通过三个标志位变量,设立了三个关卡来防止重复调度:

  • 如果被 performWorkUntilDeadline() 还在等待 event loop 被调用的状态下,scheduler 拒绝在unstable_scheduleCallback函数里面发出新的调度请求;
  • 如果 work loop 还在运行中,scheduler 拒绝在unstable_scheduleCallback函数里面发出新的调度请求;
  • 如果当前 task queue 还没有清空,scheduler 拒绝在unstable_scheduleCallback函数里面发出新的调度请求。那还没执行完的任务怎么办?performWorkUntilDeadline() 自身有循环调用,你还记得吗?

因为,task queue 被清空了,必定意味着 isHostCallbackScheduledisPerformingWork两个标志位变量的值为 false。所以,换个角度总结就是:只有 task queue 被清空了(即当前所有的任务有被执行完),scheduler 才会重新发起一次调度请求。

最后额外提醒第一点是,调用方调用unstable_scheduleCallback函数不一定会导致 scheduler 发起一次调度请求。但是任务的入队是板上钉钉的,要不是入队task queue就是入队timer queue

延时任务的执行

我们在上面的章节已经讲过,延时任务最终是先转移到 timer queue中,然后才会被 work loop 执行。延时任务如何转移到 timer queue 中,我在「延时任务转普通任务的时机」小节就讲过了,在此就不赘述了。

任务的取消

任务的取消其实很简单:

function unstable_cancelCallback(task) {
    // remove from the queue because you can't remove arbitrary nodes from an
    // array based heap, only the first one.)
    task.callback = null;
}

就是把任务对象的 callback 属性设置为 null。然后,在 work loop 中会做一个检查,如果当前的任务对象的 callback 为 null,那么就把这个任务对象从task queue 中移除:

function workLoop(hasTimeRemaining, initialTime) {
    // ......
    while (currentTask !== null && !enableSchedulerDebugging) {
        // ......
        if (typeof callback === "function") {
            // ......
        }else {
            pop(taskQueue);
        }
        
    }
    // ......
}

总结

整篇下来,我们可以把我们的所学所思和所得总结成下面几个重要的认知:

1. scheduler 让渡主线程的控制权的精髓是「中断 work loop,然后再发出一次调度请求」;

2. 中断 work loop 的不是什么神秘的东西,而是基于时间要素的 5ms 时间片;

3. scheduler 需要调用方(react)的配合才能实现相对准确的 time slicing ;

4. 到期任务最紧急

退出当前 work loop 要同时满足两个条件:

  • 当前执行的任务还没有到期;
  • 5ms 时间片使用完了 如果是到期任务,那么 work loop 会继续把这个任务执行完毕,即使当前的 5ms 已经用完了。所以,我们说「到期任务最紧急」。

实际上,到期任务到了 react 这边会以「同步」的方法去执行,这也呼应了我们「到期任务最紧急」的认知。

5. 中断单次任务执行的不是 scheduler,而是任务本身(react 内部的 concurrent work loop);

6. scheduler 通过加上三把锁来防止重复调度

换个角度总结就是:只有 task queue 被清空了(即当前所有的任务有被执行完),scheduler 才会重新发起一次调度请求。”