React 简单而美好:Scheduler 的内外双循环设计

1,011 阅读11分钟

宏大叙事能力的重要性

内循环,外循环。以内循环为主的国内国际双循环baike.baidu.com/item/%E5%9B…

最近看了两本书 《解构现代化:温铁军演讲录》《结构性改革 中国经济的问题与对策(黄奇帆 著)》。双循环,“范式设计”,“结构设计”,宏大叙事能力等这些词总是挥之不去。看到大佬们把一些复杂的事情,抛开了意识形态的限制,研究得很通透,讲得很明白......总之就是觉得很牛叉。

突然有一天猛想起来 react 中的 Scheduler 也有 双循环 的设计。我就想再把 Scheduler 仔细撸一遍。

那为什么宏大叙事能力这么重要呢?因为可以帮助你研究问题,讲好故事。

任务

任务的数据结构如下:

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

任务在数据结构上就确保了 时间线 的特性,下面我们逐个对每个属性三连问: “是什么”,“为什么”,“怎么用” 。

怎么构建任务

id

id 是通过全局 taskIdCounter 在创建任务的时候自增实现。taskIdCounter 是从 1 开始递增的整数,不仅确保了唯一性,还表示了任务创建的顺序。任务是有优先级的,如果两个任务在通过其他的途径都判别优先级是相同时,id 就可以发挥作用了。此时就可以认为先创建的任务的优先级高,因为同级任务先创建的需要先执行。

callback

这是任务里面的具体工作内容。

priorityLevel

任务的优先级分别是: ImmediatePriority , UserBlockingPriority , NormalPriority , LowPriority , IdlePriority 。
每一种优先级,都定义了不同 超时时间(timeout) 。超时时间就是任务可以启动后,经过了多长时间后任务会到期。
任务优先级和超时时间的对应关系如下:

任务优先级超时时间(ms)描述
ImmediatePriority-1超时时间是 -1,过期时间比  启动时间都还短。
这种优先级的任务只要启动了,就会立即被调度执行。
UserBlockingPriority250为什么是这些值呢?一直没挖到权威的解释。用一句“经验值”来强行解释。如果您知道更合适的解释,请联系我。
NormalPriority5000
LowPriority10000
IdlePriority1073741823Max 31 bit integer. The max integer size in V8 for 32-bit systems.
Math.pow(2, 30) - 1 --> 0b111111111111111111111111111111

这种任务,永远都不会超时,没有别的任务能比这种任务的过期时间长了。

startTime

创建任务的时候可以选择任务需不需要被延迟启动。
需要延迟启动:startTime = currentTime + delay(delay > 0)。这种任务被称为 延迟任务 
需要立即启动:startTime = currentTime + 0                          。这种任务被称为 普通任务 

expirationTime

任务的过期时间 expirationTime = startTime + timeout。任务在启动后经过了多久会到期。这时候需要被执行。

sortIndex

这是用于任务排序字段,我们将在下节结合任务存储的数据结构介绍。

怎么存储任务

创建任务的时候有两种不同的任务,分别是 延迟任务 和 普通任务 。
任务存储的时候有两种不同的结构,分别是 timerQueue 和 taskQueue 。timerQueuetaskQueue 都是最小二叉堆的结构。
react 16 中早期的版本存储任务使用的是单向链表。因为堆是优先级队列的业内的标准实现,中后期版本开始换成了最小二叉堆。更多信息参考之前发的沸点:juejin.cn/pin/6877006…
这里不具体讲最小二叉堆,如果对此不熟悉的话,这里推荐一本“雅佳达”的算法书:《小灰的算法之旅

如果任务是延迟任务,那需要存储在 timerQueue 中。在 timerQueue 中任务的排序字段 sortIndex 设置为 startTime。也就是根据延迟任务的启动时间从小到大排序。

如果任务是普通任务,那需要存储在 taskQueue 中。在 taskQueue 中任务的排序字段 sortIndex 设置为 expirationTime。也就是根据普通任务的过期时间从小到大排序。

如果分别在 timerQueue 和 taskQueue 中排序时遇到有相等的任务的时候,就按照两个任务的 id 排序。这也就是前面介绍的 id 的实现机制是全局的 taskIdCounter 自增。
taskIdCounter 自增会不会达到最大值的极限?react 没考虑这种情况。为什么呢?
javascript 能处理的最大的整数是 Number.MAX_SAFE_INTEGER 是 9007199254740991 (亿万亿量级)。实际的工作场景中 react 不会有这么多的任务去更新吧

延迟任务转移机

任务分为了普通任务和延迟任务,真正被消费的只有普通任务。那延迟任务就需要通过一定的机制变成普通任务。这个机制就是:延迟任务的延迟时间到了或者已经过了(startTime <= currentTime)。那就让它的 sortIndex 属性的值从 startTime 变成 expirationTime,满足 taskQueue 中普通任务的排序依据;从 timerQueue 转移到 taskQueue 中等待被消费。
这种过程涉及到 timerQueue 的循环和删除,taskQueue 的增加,这时候就可以体现出最小二叉堆比单向链表的更快的优势了。

内外双循环概述

Scheduler 中有内外双循环的设计。内外双循环以内循环为主,两个内循环完成了一次外循环。

什么是外循环

外循环循环的是内循环,在两个不同的内循环流程中,完成一次外循环。外循环使用的场景是:
场景 1 :新创建了普通任务,但内外双循环都已经停止了。此时需要拉动新的内循环去执行任务
场景 2 :内循环提前退出了,任务还有残留。此时需要拉动新的内循环执行残留的任务

什么是内循环

内循环循环的是 taskQueue 中的普通任务 ,执行任务中的 callback。内循环有三个主要特征:吃得多,吐得少,可以多吐几次。

外循环

react 16 中早期的版本使用的是 window.postMessage + requestAnimationFrame,让任务的执行跟 frame 对齐。在浏览器下,任务的执行时间最多可以是 16.7ms(1000/ 60hz)。当时还设计成了可以从 33ms 开始自动调整 frame 时间,让适配的环境更多。

react 16 中后期版本开始换成了 MessageChannel。为什么要从 requestAnimationFrame 换成 MessageChannel,来看下官方的解释:


与 MessageChannel 相关的更多的参考信息:


简而言之换成 MessageChannel 是因为大多数的任务其实都不需要跟 frame 对齐。如果在某些场景下需要跟 frame 对其的时候,可以再换成 requestAnimationFrame 。

外循环的周期

不使用 requestAnimationFrame 自然就没有了 frame 的 16.7 ms 的周期限制,设计 yieldInterval 作为周期的时常限制。yieldInterval 的默认值是 5ms,可以使用提供的 forceFrameRate 更新。

forceFrameRate = function(fps) {
  if (fps < 0 || fps > 125) {
    // Using console['error'] to evade Babel and ESLint
    console['error'](
      'forceFrameRate takes a positive int between 0 and 125, ' +
      'forcing framerates higher than 125 fps is not unsupported',
    );
    return;
  }
  if (fps > 0) {
    yieldInterval = Math.floor(1000 / fps);
    // 浏览器是   1000 / 60 = 16.7ms
    // ??125??   1000 / 125 = 8ms
    // 默认的情况  1000 / 200 = 5ms
  } else {
    // reset the framerate
    yieldInterval = 5;
  }
};

可以看出 fps 的合法范围是 (0, 125)。浏览器的 60 hz 属于这个范围内。

vsync 的问题:
vsync 是什么?www.digitaltrends.com/computing/w… 
yieldInterval 达到后就会释放对主线程的控制,没有考虑渲染是不是正处在 vsync 中。

怎么实现外循环

MessageChannel 有两个端口,只要一个端口绑定了回调函数,当另一个端口发信息后,回调函数就可以被触发。这是一种宏事件。

为了让发消息时减少序列化的消耗,使用 port.postMessage(null) 。一旦发送了消息,就把发消息的开关闭合,可以防止外循环被频繁的触发,确保每次外循环都只会拉动新的内循环。

当回调函数被触发后,就定义主线程忙碌的截止时间: deadline = currentTime + yieldInterval ,然后拉动新的 内循环 。这里 deadline 是用于内循环判断是否应该提前退出释放主线程的控制权。

如果内循环被提前退出了,还有任务没完成,会继续使用 port.postMessage(null),再次触发 MessageChannel 端口绑定的回调韩式,这样就实现了外循环。

内循环

内循环循环的就是存储普通任务的 taskQueue。内循环的作用是更多,更及时地执行普通任务下挂载的 callback,还不让浏览器卡顿。

饥渴的 taskQueue 很健康

一旦在某个时刻进入了内循环,那内循环会依次做下面这些事情:

首先执行延迟任务转移机,让尽可能多的合适的延迟任务变成普通任务,并把 taskQueue 中所有的普通任务排好序。
然后依次取出普通任务定义作为当前任务,放到全局。期望一直循环到 taskQueue 为空,但可以提前退出。

饥渴的表现一:想吃得多

只要当前任务的 callback 执行完,都会及时地再次执行延迟任务转移机,尽可能让 taskQueue 装上可以 timerQueue 中启动的延迟任务。

饥渴的表现二:吃了不想吐

如果当前任务的 callback 执行完,可能还会产生衍生的工作,也就是 callback 发生了更新。这样的任务就不能删除,否则需要及时删除这个任务。

饥渴的表现三:吐了没几个就想闭嘴

如果当前任务是没有到期的普通任务,那可以提前跳出 taskQueue。因为 taskQueue 是按照 expirationTime 或 taskIdCounter 从小到大排序的。如果当前任务还没到期,那后面的任务也是没有到期的。

饥渴的表现四:吐的时间有限制

如果尝试做当前任务的具体工作的时候,判断出应该放弃对主线程的控制权,以便浏览器可以执行高优先级任务(比如:用户输入),需要提前跳出 taskQueue。
那怎么判断应该放弃对主线程的控制权 ?答案是:根据外循环中的定义的当前主线程忙碌的截止时间来判断。
也就是说,在主线程空闲的时间内,内循环才会工作。

饥渴的表现五:吐完了想再次开始

如果 taskQueue 没有提前退出,taskQueue 被刷空了,所有普通任务都在主线程空闲时间内彻底完成。那就在 timerQueue 找到最早的延迟任务。因为 timerQueue 中的延迟任务是按照 startTime 从小到大排序的。最早的延迟任务就是 timerQueue 中的第一个任务。

如果这个延迟任务存在,那就在延迟到期后, 先执行延迟任务转移机,再尝试继续外循环 。把这个过程称为 吐完了想再次开始 。如果尝试继续外循环失败,那就再来一遍这个过程。

健康的表现一:可以多吐几次

如果 taskQueue 提前退出了,内循环抛到全局的应该要完成的任务还在。那就继续外循环开启新的内循环。

总之

以上这些饥渴和健康的表现,是为了更多,更及时地消费任务,同时还不长时间的阻塞主线程。 

万一从 timerQueue 开始

如果 taskQueue 被刷空了,刚创建的任务还是属于 timerQueue 的延迟任务,当前只有这个延迟任务可以做。
所以需要安排在这个特殊的延迟任务延迟结束后,进入【taskQueue 饥渴的表现五:吐完了想再次开始】的这个过程。用 setTimeout 来实现这个安排。

但是在延迟时间之前,可能因为创建了一个普通任务,那立即会打开外循环,从而开启内循环,上面的这个 安排 就不需要了。所以需要在在开始内循环之前把这个安排取消。

同样的,也可能在延迟时间之前,又创建了一个新的延迟任务,那就取消 老的安排  ,在 timerQueue 取第一个延迟任务重新用 setTimeout 来实现一个 新的安排 。

未来的 isInputPending

这是检测用户是否输入的 api, navigator.scheduling.isInputPending


前面介绍了 yieldInterval 可以限定 deadline ,从而实现内循环的提前退出,释放对主线程的控制权。
这个 api 目前还没有浏览支持,根据这种设计,很方便就能知道用户是不是用输入,可以更方便,更及时的释放对主线程的控制权。