教你如何实现react Scheduler(二)

在上一篇文章中 “反应调度程序(1)” 中,我们使用 MessageChannel 来实现一个简单的任务调度功能。但是这个功能目前还是比较简单的,现在我们来改进一下,给任务增加优先级和过期时间。

定义任务优先级:

 常量 NoPriority = 0; // 没有优先级  
 常量立即优先级 = 1; // 最高优先级  
 常量用户阻塞优先级 = 2;  
 常量 NormalPriority = 3;  
 常量低优先级 = 4;  
 常量空闲优先级 = 5; // 最低优先级  
 复制代码

首先要明确一点,在任务队列taskQueue中,任务的排序并不是直接根据这个优先级,而是根据过期时间。较早到期的任务将较早地放入队列中。所以我们还需要定义任务延迟时间:

 常量 maxSigned31BitInt = 1073741823; // 最大的 31 位整数  
 常量 IMMEDIATE_PRIORITY_TIMEOUT = - 1; // 立即过期,对应最高优先级的任务  
 常量 USER_BLOCKING_PRIORITY_TIMEOUT = 250;  
 常量 NORMAL_PRIORITY_TIMEOUT = 5000;  
 常量 LOW_PRIORITY_TIMEOUT = 10000;  
 常量 IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; //永不过期  
 复制代码

任务开始时间+延迟时间=任务到期时间。在执行任务时,我们只会在任务到期时执行该任务,否则我们将交出主线程的控制权。

既然我们已经有了任务优先级,那我们就不能直接按照任务优先级来排序吗?为什么我们需要设置过期时间?假设这样一个场景:我们将一个优先级为 NormalPriority(切片为 A1、A2、A3)的任务 A 添加到队列中。在调度A1的时候,我们在队列中加入了一个优先级为USER_BLOCKING_PRIORITY_TIMEOUT的任务B,然后在任务B结束后,继续加入任务B,这样队列就被切掉了。如果只根据优先级决定先执行哪个任务,那么任务A永远不会执行。但是现在我们使用过期时间来按过期时间排序。一开始有任务B来切队。因为你的任务B的过期时间只有250,expirationTime比较小,ok没问题,可以切队列先执行。随着你切队列的次数越来越多,你后面插入的任务的expirationTime肯定会比任务A的要大。这个时候,你应该等待任务A先执行。这就是 expireTime 所做的:避免低优先级的任务永远不会被执行。

现在任务不仅仅是一个简单的函数,我们使用函数 createTask 来创建任务:

 让 taskIdCounter = 1; // 全局变量,自增任务id  
 函数创建任务(优先级,回调){  
 const currentTime = 性能。现在();  
 常量开始时间 = 当前时间;  
 让超时 = 0;  
 开关(优先级){  
 案例立即优先级:  
 超时 = IMMEDIATE_PRIORITY_TIMEOUT;  
 休息;  
 案例用户阻塞优先级:  
 超时 = USER_BLOCKING_PRIORITY_TIMEOUT;  
 休息;  
 情况空闲优先级:  
 超时 = IDLE_PRIORITY_TIMEOUT;  
 休息;  
 案例低优先级:  
 超时 = LOW_PRIORITY_TIMEOUT;  
 休息;  
 案例 NormalPriority:  
 默认:  
 超时 = NORMAL_PRIORITY_TIMEOUT;  
 休息;  
 }  
 常量过期时间 = 开始时间 + 超时; // 计算不同优先级任务对应的过期时间  
 常量新任务 = {  
 id: taskIdCounter++,  
 callback, // 待执行的任务  
 优先级,  
 开始时间,  
 过期时间,  
 sortIndex: expirationTime, // 使用 expireTime 排序  
 }  
 返回新任务;  
 }  
 复制代码

任务已经创建,我们需要有一个优先级队列来存储任务。优先级任务队列是最小堆数据结构。这里简单介绍一下最小堆,不熟悉的读者可以自行理解。

堆是一棵完全二叉树,其中每个节点都小于或等于其子节点。数组可用于在 js 中实现最小堆。对于数组索引为index的节点,其父节点索引为 Math.floor(index - 1) / 2 , 左右子节点的索引分别为 2 * 索引 + 1 2 * 索引 + 2 .最小堆的顶部元素是最小值,因此获取最小值的时间复杂度为 O(1)。对应我们的任务队列,堆顶是过期时间最短的任务(需要最早执行)。

最小堆的js实现:

 类 SchedulerMinHeap {  
 构造函数(){  
 这个。堆 = [];  
 }  
 推(节点){  
 这个。堆。推(节点);  
 这个。 siftUp(node, this.heap.length - 1);  
 }  
 窥视(){  
 返回堆。长度 ?堆[0]:空;  
 }  
 流行音乐(){  
 if(this.heap.length === 0) 返回空值;  
 if(this.heap.length === 1) 返回这个。堆。流行音乐();  
 const first = heap[0];  
 常量最后 = 这个。堆。流行音乐();  
 堆[0] = 最后一个; // 将最后一个节点放在堆顶  
 这个。 siftDown(堆[0], 0); // 开始向下移动  
 先返回; // 返回堆的顶部节点  
 }  
 // 向上移动节点直到到达堆顶或父节点小于自身  
 筛选(节点,idx){  
 让索引 = idx;  
 而(索引> 0){  
 常量父索引 = 数学。地板(索引 - 1);  
 常量父 = 这个。堆[父索引];  
 如果(这个。比较(节点,父)<0){  
 // 小于父节点,交换位置  
 头[父索引] = 节点;  
 头部 [索引] = 父级;  
 索引 = 父索引;  
 } 别的 {  
 返回;  
 }  
 }  
 }  
 // 向下移动节点,直到到达堆底部或子节点大于自身。  
 // 调用场景:删除堆顶节点后,将堆底元素放在堆顶,然后向下移动  
 siftDown(节点,idx){  
 让索引 = i;  
 常量长度 = 这个。堆。长度;  
 常量一半 = 数学。地板(长度/2); // 在react源码中使用移位操作:index >>> 1  
 而(索引<一半){  
 // 还没有到达二叉树的底部  
 const leftIndex = (index + 1) * 2 - 1;  
 const rightIndex = leftIndex + 1;  
 // 左子节点必须存在。如果不存在,说明已经到了二叉树的底部,不会进入这个循环。  
 常量左 = 这个。堆[leftIndex];  
 // 右子节点不一定存在  
 const 对 = 这个。堆[rightIndex];  
 如果(这个。比较(左,节点)<0){  
 // 左子节点小于当前节点。需要下移,交换左子节点还是右子节点?  
 if(rightIndex < length && compare(right, left) < 0) {  
 // 有右子节点且较小,交换右子节点  
 这个。堆[索引] = 对;  
 这个。堆[rightIndex] = 节点;  
 索引 = 右索引;  
 } 别的{  
 // 左孩子变小,交换左孩子  
 这个。堆[索引] = 左;  
 这个。堆[leftIndex] = 节点;  
 索引 = 左索引;  
 }  
 } else if(rightIndex < length && compare(right, node) < 0) {  
 // 左子节点比较大,看右子节点情况:右子节点存在且较小,交换  
 这个。堆[索引] = 对;  
 这个。堆[rightIndex] = 节点;  
 索引 = 右索引;  
 } 别的 {  
 // 左右子节点都比节点大  
 返回;  
 }  
 }  
 }  
 // a小于b,则返回负数;  
 比较(一,乙){  
 // 先用sortIndex比较,再用id比较  
 常量差异 = a。排序索引 - b。排序索引;  
 返回差异!== 0?差异:一个。身份证 - b。 ID;  
 }  
 }  
 复制代码

实例化一个全局任务队列:

 const taskQueue = new SchedulerMinHeap();  
 复制代码

任务和任务队列已经实现。接下来要做的是创建一个任务,将其推送到任务队列中,然后postMessage开始任务调度。

 函数 scheduleCallback(优先级,回调){  
 const task = createTask(priorityLevel, callback); // 创建任务  
 任务队列。推(任务); //加入任务队列  
 请求主机回调(); // 开始调度任务  
 }  
 复制代码

接下来是requestHostCallback的实现。需要注意的是,scheduleCallback 可能会被多次调用,不可能每次调用都postMessage。所以我们将使用一个全局变量来控制它:

 常量频道 = 新的 MessageChannel();  
 常量端口 2 = 通道。端口2;  
 常量端口 1 = 通道。端口1;  
 端口 1。 onmessage = performWorkUntilDeadline;  
  
 让 isMessageLoopRunning = false; // 主动调度任务时,如果该值为true,则不能postMessage  
 函数请求主机回调(){  
 如果(!isMessageLoopRunning){  
 端口 2。 postMessage(空);  
 }  
 }  
  
 功能 performWorkUntilDeadline() {  
 让 hasMoreWork = true;  
 const currentTime = 性能。现在();  
 开始时间 = 当前时间; //更新任务开始时间  
 尝试 {  
 hasMoreWork = flushWork(currentTime);  
 } 最后 {  
 如果(有更多工作){  
 端口 2。 postMessage(空);  
 } 别的 {  
 // 没有任务了,等待下一次创建新任务  
 isMessageLoopRunning = 假;  
 }  
 }  
 }  
 复制代码

可以看到,从任务队列中取出并执行的任务都是在flushWork中完成的,它返回任务队列中是否还有任务需要处理。

 let startTime = - 1; 任务开始时间  
 常量 frameYieldMs = 5; // 任务的连续执行时间不能超过5ms  
 让 currentTask = null; // 用于保存当前任务  
  
 函数flushWork(初始时间){  
 尝试 {  
 返回工作循环(初始时间);  
 } 最后 {  
 当前任务=空;  
 }  
 }  
  
 函数工作循环(初始时间){  
 让当前时间 = 初始时间;  
 // 取出队列中的第一个任务,注意这里用的是peek,不是pop,任务还在队列中  
 当前任务 = 任务队列。窥视();  
 而(当前任务!== null){  
 if(currentTask.expirationTime > currentTime && shouldYieldToHost()) {  
 // 过期时间未到,调度已超过5ms  
 休息;  
 }  
 // 可以安排任务  
 常量回调 = 当前任务。打回来;  
 if (typeof callback === 'function') {  
 当前任务。回调=空;  
 常量 continuationCallback = 回调(); // 做真正的任务  
 if ( typeof continuationCallback === 'function') {  
 // 注意这很关键:如果我们的任务返回一个函数,这意味着该任务是一个分片任务  
 // 第一个shard任务执行后,不会再接下一个任务,而是重新分配task.callback,  
 // 然后再经过while循环,执行下一个分片任务。每个分片任务执行前,都要判断是否超过5ms。  
 // 如果超过了,就会中断,等待下一个事件循环被取出并再次执行。  
 当前任务。回调 = 延续回调;  
 } 别的 {  
 // 任务不分片,执行后可删除  
 if(currentTask === taskQueue.peek()) {  
 任务队列。流行音乐();  
 }  
 }  
 } 别的 {  
 // 对于任务分片的情况,所有分片的任务都已经执行完毕,  
 // 表示当前任务已经完成,可以删除  
 任务队列。流行音乐();  
 }  
 currentTask = peek(taskQueue); // 取下一个任务执行  
 }  
 如果(当前任务!== null){  
 // 在下一个事件循环中还有任务需要处理  
 返回真;  
 }  
 }  
  
 函数应该YieldToHost() {  
 // 任务是否应该被挂起  
 const currentTime = 性能。现在();  
 如果(当前时间 - 开始时间 < frameYieldMs){  
 返回假;  
 }  
 返回真;  
 }  
  
 复制代码

如果你理解了workLoop的逻辑,你会发现我们实际上已经实现了高优先级任务的队列切割功能。需要注意的是,定时任务回调一定要有好的设计,要么是短时间,要么是一个shard任务,每个shard耗时很短。分片任务设计的关键是在每次执行前使用 shouldYieldToHost 函数判断任务调度是否已经达到 5ms。如果达到 5ms,则返回一个函数,调度器将在下一个事件循环中继续执行它。同时,这个任务还必须记住执行的进度,否则每次从头执行都会陷入死循环。

以下是高优先级任务如何切队列的解释:

如果当前正在调度一个分片任务A(分为A1、A2、A3),​​当A1正在执行时,调用scheduleCallback调度一个高优先级任务B,该任务将被推入任务队列(最小堆,根据优先级排序)。此时需要等待A1执行完毕,判断是否超时。如果没有超时,继续执行A2。 A2执行完后发现已经超时,跳出循环。当进入下一个事件循环并重新进入workLoop函数时,任务再次从任务队列中取出。此时取出任务B,将任务B切入队列。 B执行完后,继续取出A执行。再次强调,A 任务必须有正确的分片设计,取出时会从 A3 开始执行。

经过分析,我们知道任务插入不是随机的,最早只能在下一个事件循环之后执行。

至此,react scheduler的核心功能已经实现。其实在react源码中,不仅有一个任务队列taskQueue,还有一个timerQueue,用来处理一些延迟的任务。在某些场景下,我们在创建任务时,并不想立即将其添加到taskQueue中,而是延迟一段时间再添加到taskQueue中,然后再将其添加到timerQueue中。实现这个会使得调度器的逻辑很复杂,影响我们对最核心原理的把握,所以暂时不实现。

版权声明:本文为博主原创文章,遵循CC 4.0 BY-SA版权协议。转载请附上原文出处链接和本声明。

这篇文章的链接: https://homecpp.art/0522/10141/1134

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明

本文链接:https://www.qanswer.top/38696/47482212

posted @ 2022-09-22 12:48  哈哈哈来了啊啊啊  阅读(87)  评论(0编辑  收藏  举报