React 源码系列 - 深入理解 Scheduler

412 阅读13分钟

前言

在学习 Scheduler 之前,首先要明白 Scheduler 到底做了什么事:多个任务管理、单个任务执行控制(中断和恢复)

学习过程中应该带着这两个疑问去发现、去查找,看源码更是如此,如果只专注细节,不先理清整体脉络,很容易就会迷失在源码的汪洋大海中

概念

首先,先来认识下几个重要概念,React 中的各种概念和数据结构实在是太多了,更要命的是有些函数名和数据字段还重名,这就导致了读着读着容易读岔,进而是 emo、自我怀疑 -- 诶?我真的适合学前端吗?那该死的热情哪去了?没关系,vo50%,保管教会你各个重要函数名,的拼写

一上来需要先知道几个重要主体:

  • React 中的任务:performConcurrentWorkOnRoot,其中包括了我们熟悉的 render 阶段和 commit 阶段,因此该任务也是 React 用于构建和渲染 Fiber 的任务

  • 发起调度的入口:scheduleCallback

  • 两个任务队列

    • taskQueue:任务队列
    • timerQueue:延时任务队列

    这里插一嘴,在 Scheduler 中是以 task 作为执行单位,而 task 涉及到两个重要概念:优先级(任务执行顺序)与时间片(任务执行时间)

    我们都知道,React 有自己的优先级系统 lane,而 Scheduler 由于被拆分出来独立发包,因此也有自己独自的优先级体系。所以在发起调度时,React 会先进行优先级的转换,也就是将 React 的优先级 Lane 转换为 Scheduler 优先级(lane 和 Scheduler 的优先级这里就不详细介绍了,大家可以自己去学学 React 中的三种优先级:lane、事件优先级和 Scheduler 优先级)

    好的,简单了解了优先级之后,我们来看看任务队列中的任务 task 到底是怎样的,以及优先级到底是怎样去决定任务的执行顺序和执行时间的

    // 优先级需要转换,同样的,任务也需要转换,React 任务进入调度时会被转换为 task
    // React 中的任务 performConcurrentWorkOnRoot 会被转换为 Scheduler 中的任务 Task
    var newTask: Task = {
      id: taskIdCounter++,
      // callback 函数即 performConcurrentWorkOnRoot 函数
      callback,
      // 调度优先级
      priorityLevel,
      // 开始时间 startTime = currentTime + delay || currentTime
      startTime,
      // 过期时间 expirationTime = startTime + timeout
      expirationTime,
      // 在任务队列中排序的依据,由开始时间和过期时间决定
      // 而 timeout 又根据优先级生成,这也就是为什么说优先级决定了任务的执行顺序和执行时间
      sortIndex: startTime || expirationTime,
    };
    
  • 调度者:requestHostCallback(本质是 schedulePerformWorkUntilDeadline),通过 MessageChannel(重点!!!是一个宏任务)调用 port.postMessage 来安排调度

  • 调度者和执行者之间的中介:否则进行双方间的消息传送

  • 执行者:flushWork,调用 workLoop 去执行完 taskQueue 中的所有任务

好了,主角悉数登场,上面有一个重点:调度者发起的调度并不是一个同步任务

举一个例子来说明调度的本质:

浏览器同样是以任务队列的方式来执行事件,假设有一个耗时 10s 的计算事件,首先这个事件会先进入队列,然后浏览器再把他取出来执行,如果用户同时间触发了滚动事件,该滚动事件也会进入队列,但由于前面已经有任务在执行了,所以这个滚动事件不会立马执行

问题来了,当用户触发滚动事件后,不可能等待 10s 之后再得到响应,所以为了优化用户体验,解决掉帧问题,需要进行调度

这时就有了时间片的概念

比如有一个 1s 的时间片(实际上时间片的时间是不固定的,由设备 fps 决定),当 React 的调度器在经过 1 秒之后,发现当前这个计算任务还未完成,说明他已经超出了时间片限制了,此时就会把剩余的 9s 的计算内容再作为一个宏任务添加进任务队列末尾,然后先执行任务队列中的首个任务也就是用户的滚动事件(滚动任务的优先级比计算任务高,所以才会在时间片到期时让计算任务中断,如果把滚动任务换成一个无关紧要的任务,此时即使时间片到期,React 的调度器会重新分配时间片,然后继续执行计算任务,所以这是优先级的另一个作用:实现任务排序,也就是任务插队)

这就是调度的本质:控制任务的中断与恢复,防止单个任务执行一直占据那仅有的一个线程

这里可能会有疑问:怎么实现的任务中断与恢复?恢复时怎么只去执行这个任务中还未执行的那部分?

别急,我们先跑下整体流程,再在源码中去找答案

流程

  1. 首先,触发事件产生了React 任务,此时会通过调度入口发起调度,并做了两件事:

    • 将「React 任务」转换为「task 任务」

    • 将 React 任务的优先级「Lane」转换为 「task 任务的优先级」

  2. 之后,将该任务添加进任务队列

    • 如果当前时间 >= 任务开始时间,说明过期,放入 taskQueue(放入之后,根据过期时间重新排序 taskQueue);否则放入 timerQueue(放入之后,根据开始时间重新排序 timerQueue)

    • 两个任务队列本质都是一个数组形式的小顶堆

  3. 接着,通过调度者发起调度

    调度者会通过 postMessage 去通知中介,再让中介通知执行者开始循环执行 taskQueue 中的所有任务

  4. 然后执行者会开始执行 taskQueue 中的所有任务

    • 每一个任务都有对应的时间片,当某个任务超过时间片限制还未执行完成时,则会中断该任务,并且返回true中介检测到执行者返回true,说明中断的原因不是因为所有任务都执行完成,而是由于任务中断

      这里说一下:返回true说明是任务中断,返回false说明是任务执行完成

    • 此时中介会告知调度者当前任务中断,调度者收到该消息后,会再次告知中介去调度一个新的执行者继续执行未完成的部分

      继续执行未完成的部分是指作为一个宏任务去执行,可以联系上文说的调度的本质

  5. 最后,当某一个任务执行完成时,这个任务就会被从 taskQueue 中弹出

以上就是主要流程,也许你还会有很多疑问,没关系,有疑问才是好事

下面就带着疑问,以及对主要流程的了解,让我们手写一个 Sceduler 吧

问题

任务的中断与恢复是如何实现的

你可能会有以下疑问:任务是如何中断与恢复的呢?多个任务的场景下是如何进行的?一个大任务又是怎么处理划分成多个小任务的?任务中又产生了新任务该怎么办?

没关系,我们直接来手写一个调度器,其中就会有问题的答案

// 创建一个 React 任务,由 dispatchEvent 函数触发产生
const performConcurrentWorkOnRoot = () => {
  // render 阶段,包含了 beginWork、completeWork 阶段
  renderRootConcurrent();
  // commit 阶段,包含了 beforeMutation、Mutation 以及 Layout 等阶段
  finishConcurrentRender();
}

// 创建两个任务队列
const taskQueue = [];
const timerQueue = [];
// 初始化开始时间
let startTime;

// 1.首先,发起调度,将 React 任务和优先级转换为 Scheduler 中的任务和优先级
const newCallbackNode = scheduleCallback(
  // 优先级
  schedulerPriorityLevel,
  // React 任务,即构建 Fiber 和渲染:render 阶段和 commit 阶段
  performConcurrentWorkOnRoot.bind(null, root),
);

// 该函数用于检查 timerQueue 中第一个任务是否过期
// 过期则加入到 taskQueue 并发起调度
function advanceTimers(currentTime) {
  // 取出 timerQueue 中的第一个任务,判断是否过期
  let timer = timerQueue[0];
  while(timer !== null) {
    if(timer.startTime <= currentTime) {
      // 过期则放入 taskQueue
      const timerTask = timerQueue.shift();
      taskQueue.push(timerTask);
      // 排序
      taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);
      timerQueue.sort((a, b) => a.startTime - b.startTime);
      // 发起调度
      requestHostCallback();
    } else {
      // 未过期则继续观察
      return;
    }
    timer = timerQueue[0];
  }
}

// 调度的入口
function scheduleCallback(priorityLevel, callback) {
  // 记录当前时间
  const currentTime = performance.now();
  // 记录调度开始时间,该开始时间也是任务的开始时间
  const startTime = currentTime

  // 在 scheduleCallback 转换优先级和任务
  var task = {
    // 详细细节看上文,这里主要是将 performConcurrentWorkOnRoot 挂载到 callback 上
    callback,
    startTime,
    // ...
  }
          
  // 2.将任务放入队列中
  if (startTime > currentTime) {
    // 未过期任务,先将其放入 timerQueue 并根据开始时间排序
    timerQueue.push(task);
    // 在一定时间后检查是否过期
    setTimeout(() => {
      advanceTimers(currentTime);
    }, startTime - currentTime);
  } else {
    // 任务已过期,放入 taskQueue 并开启调度  
    taskQueue.push(task);
    // 发起调度
    requestHostCallback();
  }
}

// 3.开始调度
const channel = new MessageChannel();
const port = channel.port2;
// 注册「中介 performWorkUntilDeadline」,通过「中介」去通知「执行者」
channel.port1.onmessage = performWorkUntilDeadline;
// 注册「调度者 schedulePerformWorkUntilDeadline」
let schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};

// 调度循环的状态记录,防止重复开启调度循环
let isMessageLoopRunning = false;

function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    // 开始调度
    schedulePerformWorkUntilDeadline();
  }
}

// 4.「中介」通知「执行者」执行
// 调用 schedulePerformWorkUntilDeadline 会触发 port.postMessage
// 使 channel.port1.onmessage 上挂载的函数执行
// 所以此时会执行 performWorkUntilDeadline
const performWorkUntilDeadline = () => {
  if (isMessageLoopRunning) {
    const currentTime = performance.now();
    // 更新开始时间
    startTime = currentTime;
    // 判断 taskQueue 是否还有任务未执行
    let hasMoreWork = true;
    try {
      // 开始执行任务
      hasMoreWork = flshWork(currentTime);
    } finally {
      // 「执行者 flshWork」返回 true,说明是由于任务中断终止执行
      // 这时会继续调度任务
      // 也就是通知「调度者」,「调度者」会通知 「中介 performWorkUntilDeadline」
      // 「中介」重新执行 flshWork
      // 所以本质上来说,当有任务中断时,会重新在下一个宏任务中执行未完成的部分
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        // 否则说明任务都已经完成,停止调度
        isMessageLoopRunning = false;
      }
    }
  }
};

// 5.开始执行任务
function flshWork(initialTime) {
  // 核心是执行 workLoop,其他细节这里可以不用考虑
  return workLoop(initialTime);
}

function workLoop(initialTime) {
  let currentTime = initialTime;
  // 检查 timerQueue 中是否有任务过期
  advanceTimers(currentTime);
  // 取出 taskQueue 中的任务
  const currentTask = taskQueue[0];
  while (currentTask !== null) {
    // 如果任务还未过期但是当前时间片没有剩余时间,则中断任务执行
    // 注意,这里的 5ms 是剩余时间,会根据设备分辨率动态决定
    if (currentTask.expirationTime > currentTime && performance.now() - startTime < 5) {
      break;
    }
    // 获取回调函数,也就是 「React 任务 performConcurrentWorkOnRoot」
    const callback = currentTask.callback;
    if (typeof callback === "function") {
      // 获取之后将其置空,取消调度的关键
      currentTask.callback = null;
      // 判断任务是否过期
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // 执行任务,也就是开始了 React 的 render 和 commit 阶段
      const continuationCallback = callback(didUserCallbackTimeout);
      // 任务执行完成之后再重新更新当前时间
      currentTime = performance.now();
      // 如果任务返回其本身,说明是由于任务中断造成的
      // 此时重新将其赋值给 callback,之后在下一个宏任务中完成
      if (typeof continuationCallback === "function") {
        currentTask.callback = continuationCallback;
        // 重新检查 timerQueue
        advanceTimers(currentTime);
        // 退出循环,返回 true 去告知「中介」当前任务未执行完成
        return true;
      } else {
        // 如果任务不返回本身,说明任务执行完成,将任务出列
        taskQueue.shift();
        advanceTimers(currentTime);
      }
    } else {
      // 说明 callback 不是函数,则将该任务出列
      taskQueue.shift();
    }
    // 取出下一个任务
    currentTask = taskQueue[0];
  }
  if (currentTask !== null) {
    // 说明此时任务还未到期,但是由于时间片不足导致的任务中断
    // 此时返回 true 告知「中介」继续执行任务
    return true;
  } else {
    // currentTask 为空说明 taskTask 任务都已经执行完成
    // 此时会去 timerQueue 中寻找是否有过期任务
    const firstTimer = timerQueue[0];
    setTimeout(() => {
      advanceTimers(currentTime);
    }, firstTimer.startTime - currentTime);
    // 返回 false 告知结束当前调度
    return false;
  }
}

如果还未解答你的疑问,可能是嵌入了一个误区:怎么实现地从函数体中断部分继续执行呢?其实这是做不到的,中断与恢复并不是说从函数体中的某个部分恢复执行

  • 首先需要明确一点,React 中的任务都是对 Fiber 对象的处理,在处理的过程中会打上对应的标记,在下一次执行时,还是会执行相同的任务函数,并不会省略函数体内容,但由于处理过的 Fiber 部分已经打上了标记,所以 React 可以直接跳过这部分,从没有标记的地方继续处理

  • 同理,这个问题也说明了在「一个大任务」的场景下,React 可能会分段执行,也就是所谓的「将大任务划分为多个小任务」,原理就是借助了调度器的中断与恢复

  • 还是同理,借助中断与恢复,在「任务中又产生新任务」的场景下,如果这个新任务「task2」的优先级比当前任务「task1」高,则会中断该任务「task1」,先去执行新任务「task2」,之后再恢复执行还未完成的任务「task1」

饥饿问题

什么是饥饿问题

从上文得知,任务在任务队列里是以优先级作为依据来进行排序的,如果一个任务的优先级很低,且在执行前面任务的过程中又会源源不断的产生新的高优先级任务,那么这个低优先级任务可能就永远都不会执行,这就是饥饿问题

Scheduler 如何解决饥饿问题?

回顾一下一开始给的 task 的数据结构:

// 优先级需要转换,同样的,任务也需要转换,React 任务进入调度时会被转换为 task
// React 中的任务 performConcurrentWorkOnRoot 会被转换为 Scheduler 中的任务 Task
var newTask: Task = {
  id: taskIdCounter++,
  // callback 函数即 performConcurrentWorkOnRoot 函数
  callback,
  // 调度优先级
  priorityLevel,
  // 开始时间 startTime = currentTime + delay || currentTime
  startTime,
  // 过期时间 expirationTime = startTime + timeout
  expirationTime,
  // 在任务队列中排序的依据,由开始时间和过期时间决定
  // 而 timeout 又根据优先级生成,这也就是为什么说优先级决定了任务的执行顺序和执行时间
  sortIndex: startTime || expirationTime,
};

任务在小顶堆中实际上是根据task.sortIndex属性去排序的,而 sortIndex 其实就是由 expirationTime 赋值得到(taskQueue 中),而 expirationTime 又是由startTime + timeout得来的,关键就是这个 startTime,优先级只决定了 timeout,但排序的根据除了这个 timeout 还依据了 startTime,这也就是说,尽管某个任务的优先级很小,但是随着时间推移,startTime 会逐渐成为队列中最小的,因此这个任务的排列顺序会随着时间越来越靠前,饥饿问题就能解决啦

最后留下两个问题:

  • React 为什么要从 requestAnimationFrame 改用成 MessageChannel 呢?

  • React 的 Scheduler 和 Vue3 的 Scheduler 有什么区别?

最后声明,以上内容纯属个人瞎编,如有任何问题或编写错误,欢迎在评论区友好交流

参考: