react任务调度(二)

lxf2023-04-21 21:06:01

什么是时间切片

回答以前,先说一下浏览器相关的东西。我们知道浏览器会定期刷新我们的页面,页面上的内容是一帧一帧绘制出来的。并且大多数刷新页面的频率为60HZ,也就是一帧大概16.6ms的时间。

那浏览器一帧做了哪些事情呢?

react任务调度(二)

【1】接受用户输入

【2】执行事件回调 -- 点击,键盘等

【3】执行定时器

【4】开始一帧

【5】执行RAF(requestAnimationFrame)

【6】页面布局,样式计算

【7】 绘制渲染

【8】 空闲时间,执行 RIC (RequestIdelCallback) --- 如果一帧没有多余时间则不会执行,有时间则会执行该回调。

当遇到一个long task 的时候,如果需要大于100ms的时间去执行,这时候就会明显感觉卡顿的效果。

时间切片就是利用浏览器上面的原理把长任务分割成一个个小的任务去执行。

为什么是宏任务?

上一节我们提出了疑问?为什么要用宏任务去执行一个task,而不是直接调用flushwork去执行任务呢?

是的,答案就是宏任务能够起到分割任务的作用。 这里有2个点:

  1. js主线程 和 渲染交互的GUI线程是互斥的

  2. 宏任务能够交出主线程的使用权

    关于第一点不是主要阐述对象,大致原因可能是GUI线程的产物本身就是一种宏任务,需要和主线任务同时执行。

    关于宏任务能交出主线程使用权,我们来回顾一下事件队列:

    react任务调度(二)

事件队列:异步代码的执行,不会等待它的执行结果,而是将这个时间挂起,继续执行栈其他任务。当异步任务执行完成(一般是其他线程去处理这些异步任务),并不会立即执行,会将异步回调函数和结果放进事件队列中。等待执行栈中的所有任务处理完成,主线程处于空闲状态的时候。则会取出事件队列中第一个回调函数放进栈中执行。

所以我们可以回答上面的疑问:我们如果使用一个宏任务去执行flushwork,那么当我们能控制flushwork的中断,则能控制主线程的权利,让GUI线程得到资源,从何让用户可以交互,避免卡顿。

Tips:不用微任务迭代原因是,微任务将在页面更新前全部执行完,达不到将主线程还给浏览器的目的

为什么用 MessageChannel ,而不首选 setTimeout/requestAnimationFrame?

原因如下:

  1. setTimeout(fn,0) 所创建的宏任务,会有至少 4ms 的执行时差,setInterval 同理
  2. MessageChannel 总会在 setTimeout 任务之前执行,且执行消耗的时间总会小于 setTimeout
  3. requestAnimationFrame会出现执行2次的问题

具体可以参考: Admin.net/post/695380…

至此,我们简单梳理一下react时间切片,以及相关的疑惑点。

workLoop

接着第一篇,我们继续看下workLoop是如何执行任务的

// 在当前时间切片内循环执行任务
function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime;
	
  advanceTimers(currentTime); // 检测timerQueue中任务是否过期
  currentTask = peek(taskQueue) as Task; // 取出优先级最高的

  while (currentTask !== null) {
    const should = shouldYieldToHost(); // 执行持续时间是否超时
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || should)
    ) {
      // 当前任务还没有过期,并且没有剩余时间了
      break;
    }

    const callback = currentTask.callback; // 调度任务
    currentPriorityLevel = currentTask.priorityLevel;
    if (isFn(callback)) {
      currentTask.callback = null;

      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;

      const continuationCallback = callback(didUserCallbackTimeout);

      currentTime = getCurrentTime();
      if (isFn(continuationCallback)) {
        // 任务没有执行完
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      // currentTask不是有效任务
      pop(taskQueue);
    }
    currentTask = peek(taskQueue) as Task;
  }

在开始执行任务前,先调用advanceTimers函数,来更新timerQueue的任务到taskQueue,实现代码如下:

// 检查timerQueue中的任务,是否有任务到期了呢,到期了就把当前有效任务移动到taskQueue
function advanceTimers(currentTime: number) {
  let timer: Task = peek(timerQueue) as Task;
  while (timer !== null) {
    if (timer.callback === null) {
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
    } else {
      return;
    }
    timer = peek(timerQueue) as Task;
  }
}

拿出timerQueue中一个任务,如果任务开始时间小于当前时间,说明任务可以开始执行。会把该任务从timerQueue中弹出,并推入taskQueue中。

这里需要提一下的是:如果判断timerQueue弹出来的是任务是优先级最高的,也就是最先需要被执行的任务,push进入taskQueue以后,该任务是排在第几个被执行呢? 这里就是设计到了算法:最小堆,也就是小顶堆。根据sortIndex也就是expirationTime来进行排序。expirationTime过期越近的排在靠堆顶的位置。 这是算法知识不展开讲述,可以参考:leetcode.cn/problems/kt…

调整完2个任务池中的任务以后,取出taskQueue中优先级最高的任务

currentTask = peek(taskQueue) as Task; // 取出优先级最高的

然后开始react 2大while循环中的一种,调度任务循环中来:

 while (currentTask !== null) {
    const should = shouldYieldToHost();
     if (
       currentTask.expirationTime > currentTime &&
       (!hasTimeRemaining || should)
     ) {
       // 当前任务还没有过期,并且没有剩余时间了
       break;
     }
			...
      const callback = currentTask.callback; // 调度任务

      if (isFn(callback)) {
        currentTask.callback = null;
        const continuationCallback = callback(didUserCallbackTimeout);
        // 任务没有执行完
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      }
      ...
  }

进入while前,先判断执是否还有剩余时间:

...
let frameInterval = 5; //frameYieldMs;
...
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;
  }

  return true;
}

任务开始时间和当前时间进行差值和5ms对比来判断

如果没有过期则会执行callback函数。从中可以看出 callback会返回一个函数,来表示当前任务是否执行完成。 意思是如果该任务由于没有剩余时间或者被高优先级打断的话,会返回一个函数表示。并且会重新推入taskQueue中。等待下次的调用。

反之,currentTask.callback = null;来表示任务执行完成,被pop相应出任务队列。

performSyncWorkOnRoot

到这里,其实调度一个单线流程差不多完成。延迟任务就是用一个倒计时,当倒计时完成,放入任务队列。这里不做分析了。

其实到这里,会有一个问题,上面说调度任务是执行callback,如果这个callback 的执行时间大于了5ms怎么办呢? 这时候你是不是和我一样会好奇这个callback 到底是什么?

在第一篇中我们知道,scheduleCallback作为调度的入口

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

我们找到调用scheduleCallback函数的地方得知:

// concurrent模式
scheduleCallback(
  schedulerPriorityLevel,
  performConcurrentWorkOnRoot.bind(null, root),
);

// 同步渲染模式
scheduleSyncCallback(
  performSyncWorkOnRoot.bind(null, root),
)

在concurrent模式和同步模式下都是指,performSyncWorkOnRoot这个函数。所以最终调用了performSyncWorkOnRoot这个函数。

tips: concurrent模式是最新模式,大部分react项目还没有应用,故大部分项目其实还是一撸到底,并不会利用时间切片去处理长任务。

看到performSyncWorkOnRoot这个函数,我此刻是比较熟悉了。你们如果不了解可以看看我之前分析的2篇文章。 react源码系列 -- render阶段:Admin.net/post/709491… react源码系列 -- commit阶段: Admin.net/post/709944…

简单总结就是当调用performSyncWorkOnRoot函数,就开始了react的2大过程,render阶段和commit阶段。 render阶段会通过jsx构建出fiber,通过fiber找出effect节点。这个过程是可以中断的。

commit阶段则是根据上面的effect链,来渲染真实的dom节点。由于节点的不可逆性,改过程不能中断。

至此完成了这样一个大的流程图: react任务调度(二)

附上一张Scheduler 任务调度的流程图

react任务调度(二)

参考链接:www.cnblogs.com/cczlovexw/p…