React中的任务饥饿行为

nero

发布于 2021-02-09 11:02:18

点击进入React源码调试仓库。

本文是在React中的高优先级任务插队机制基础上的后续延伸,先通过阅读这篇文章了解任务调度执行的整体流程,有助于更快地理解本文所讲的内容。

饥饿问题说到底就是高优先级任务不能毫无底线地打断低优先级任务,一旦低优先级任务过期了,那么他就会被提升到同步优先级去立即执行。如下面的例子: 我点击左面的开始按钮,开始渲染大量DOM节点,完成一次正常的高优先级插队任务:

而一旦左侧更新的时候去拖动右侧的元素,并在拖动事件中调用setState记录坐标,介入更高优先级的任务,这个时候,左侧的DOM更新过程会被暂停,不过当我拖动到一定时间的时候,左侧的任务过期了,那它就会提升到同步优先级去立即调度,完成DOM的更新(低优先级任务的lane优先级并没有变,只是任务优先级提高了)。

要做到这样,React就必须用一个数据结构去存储pendingLanes中有效的lane它对应的过期时间。另外,还要不断地检查这个lane是否过期。

这就涉及到了任务过期时间的记录 以及 过期任务的检查

lane模型过期时间的数据结构

完整的pendingLanes有31个二进制位,为了方便举例,我们缩减位数,但道理一样。

例如现在有一个lanes:

0  b  0  0  1  1  0  0  0

那么它对应的过期时间的数据结构就是这样一个数组:

[ -1,  -1, 4395.2254, 3586.2245, -1,  -1, -1 ]

在React过期时间的机制中,-1 为 NoTimestamp

即pendingLanes中每一个1的位对应过期时间数组中一个有意义的时间,过期时间数组会被存到root.expirationTimes字段。这个计算和存取以及判断是否过期的逻辑 是在markStarvedLanesAsExpired函数中,每次有任务要被调度的时候都会调用一次。

记录并检查任务过期时间

React中的高优先级任务插队机制那篇文章中提到过,ensureRootIsScheduled函数作为统一协调任务调度的角色,它会调用markStarvedLanesAsExpired函数,目的是把当前进来的这个任务的过期时间记录到root.expirationTimes,并检查这个任务是否已经过期,若过期则将它的lane放到root.expiredLanes中。

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // 获取旧任务
  const existingCallbackNode = root.callbackNode;

  // 记录任务的过期时间,检查是否有过期任务,有则立即将它放到root.expiredLanes,
  // 便于接下来将这个任务以同步模式立即调度
  markStarvedLanesAsExpired(root, currentTime);

  ...

}

markStarvedLanesAsExpired函数的实现如下:

暂时不需要关注suspendedLanes和pingedLanes

export function markStarvedLanesAsExpired(
  root: FiberRoot,
  currentTime: number,
): void {
  // 获取root.pendingLanes
  const pendingLanes = root.pendingLanes;
  // suspense相关
  const suspendedLanes = root.suspendedLanes;
  // suspense的任务被恢复的lanes
  const pingedLanes = root.pingedLanes;

  // 获取root上已有的过期时间
  const expirationTimes = root.expirationTimes;

  // 遍历待处理的lanes,检查是否到了过期时间,如果过期,
  // 这个更新被视为饥饿状态,并把它的lane放到expiredLanes

  let lanes = pendingLanes;
  while (lanes > 0) {

    /*
     pickArbitraryLaneIndex是找到lanes中最靠左的那个1在lanes中的index
     也就是获取到当前这个lane在expirationTimes中对应的index
     比如 0b0010,得出的index就是2,就可以去expirationTimes中获取index为2
     位置上的过期时间
    */

    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;
    // 上边两行的计算过程举例如下:
    //   lanes = 0b0000000000000000000000000011100
    //   index = 4

    //       1 = 0b0000000000000000000000000000001
    //  1 << 4 = 0b0000000000000000000000000001000

    //    lane = 0b0000000000000000000000000001000

    const expirationTime = expirationTimes[index];
    if (expirationTime === NoTimestamp) {
      // Found a pending lane with no expiration time. If it"s not suspended, or
      // if it"s pinged, assume it"s CPU-bound. Compute a new expiration time
      // using the current time.
      // 发现一个没有过期时间并且待处理的lane,如果它没被挂起,
      // 或者被触发了,那么去计算过期时间
      if (
        (lane & suspendedLanes) === NoLanes ||
        (lane & pingedLanes) !== NoLanes
      ) {

        expirationTimes[index] = computeExpirationTime(lane, currentTime);
      }
    } else if (expirationTime <= currentTime) {
      // This lane expired
      // 已经过期,将lane并入到expiredLanes中,实现了将lanes标记为过期
      root.expiredLanes |= lane;
    }
    // 将lane从lanes中删除,每循环一次删除一个,直到lanes清空成0,结束循环
    lanes &= ~lane;
  }
}

通过markStarvedLanesAsExpired的标记,过期任务得以被放到root.expiredLanes中在随后获取任务优先级时,会优先从root.expiredLanes中取值去计算优先级,这时得出的优先级是同步级别,因此走到下面会以同步优先级调度。实现过期任务被立即执行。

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // 获取旧任务
  const existingCallbackNode = root.callbackNode;

  // 记录任务的过期时间,检查是否有过期任务,有则立即将它放到root.expiredLanes,
  // 便于接下来将这个任务以同步模式立即调度
  markStarvedLanesAsExpired(root, currentTime);

  ...

  // 若有任务过期,这里获取到的会是同步优先级
  const newCallbackPriority = returnNextLanesPriority();

  ...

  // 调度一个新任务
  let newCallbackNode;
  if (newCallbackPriority === SyncLanePriority) {
    // 过期任务以同步优先级被调度
    newCallbackNode = scheduleSyncCallback(
      performSyncWorkOnRoot.bind(null, root),
    );
  }
}

何时记录并检查任务是否过期

concurrent模式下的任务执行会有时间片的体现,检查并记录任务是否过期就发生在每个时间片结束交还主线程的时候。可以理解成在整个(高优先级)任务的执行期间, 持续调用ensureRootIsScheduled去做这件事,这样一旦发现有过期任务,可以立马调度。

执行任务的函数是performConcurrentWorkOnRoot,一旦因为时间片中断了任务,就会调用ensureRootIsScheduled

function performConcurrentWorkOnRoot(root) {

  ...

  // 去执行更新任务的工作循环,一旦超出时间片,则会退出renderRootConcurrent
  // 去执行下面的逻辑
  let exitStatus = renderRootConcurrent(root, lanes);

  ...

  // 调用ensureRootIsScheduled去检查有无过期任务,是否需要调度过期任务
  ensureRootIsScheduled(root, now());

  // 更新任务未完成,return自己,方便Scheduler判断任务完成状态
  if (root.callbackNode === originalCallbackNode) {
    return performConcurrentWorkOnRoot.bind(null, root);
  }
  // 否则retutn null,表示任务已经完成,通知Scheduler停止调度
  return null;
}

performConcurrentWorkOnRoot是被Scheduler持续执行的,这与Scheduler的原理相关,可以移步到我写的一篇长文帮你彻底搞懂React的调度机制原理这篇文章去了解一下,如果暂时不了解也没关系,你只需要知道它会被Scheduler在每一个时间片内都调用一次即可。

一旦时间片中断了任务,那么就会走到下面调用ensureRootIsScheduled。我们可以追问一下时间片下的fiber树构建机制,更深入的理解ensureRootIsScheduled 为什么会在时间片结束的时候调用。

这一切都要从renderRootConcurrent函数说起:

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {

  // workLoopConcurrent中判断超出时间片了,
  // 那workLoopConcurrent就会从调用栈弹出,
  // 走到下面的break,终止循环

  // 然后走到循环下面的代码
  // 就说明是被时间片打断任务了,或者fiber树直接构建完了
  // 依据情况return不同的status
  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);


  if (workInProgress !== null) {
      // workInProgress 不为null,说明是被时间片打断的
      // return RootIncomplete说明还没完成任务
    return RootIncomplete;
  } else {

    // 否则说明任务完成了
    // return最终的status
    return workInProgressRootExitStatus;
  }
}

renderRootConcurrent中写了一个do…while(true)的循环,目的是如果任务执行的时间未超出时间片限制(一般未5ms),那就一直执行, 直到workLoopConcurrent调用完成出栈,brake掉循环。

workLoopConcurrent中依据时间片去深度优先构建fiber树

function workLoopConcurrent() {
  // 调用shouldYield判断如果超出时间片限制,那么结束循环
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

所以整个持续检查过期任务过程是:一个更新任务被调度,Scheduler调用performConcurrentWorkOnRoot去执行任务,后面的步骤:

  1. performConcurrentWorkOnRoot调用renderRootConcurrentrenderRootConcurrent去调用workLoopConcurrent执行fiber的构建任务,也就是update引起的更新任务。
  2. 当执行时间超出时间片限制之后,首先workLoopConcurrent会弹出调用栈,然后renderRootConcurrent中的do…while(true)被break掉,使得它也弹出调用栈,因此回到performConcurrentWorkOnRoot中。
  3. performConcurrentWorkOnRoot继续往下执行,调用ensureRootIsScheduled检查有无过期任务需要被调度。
  4. 本次时间片跳出后的逻辑完成,Scheduler会再次调用performConcurrentWorkOnRoot执行任务,重复1到3的过程,也就实现了持续检查过期任务。

总结

低优先级任务的饥饿问题其实本质上还是高优先级任务插队,但是低优先级任务在被长时间的打断之后,它的优先级并没有提高,提高的根本原因是markStarvedLanesAsExpired 将过期任务的优先级放入root.expiredLanes,之后优先从expiredLanes获取任务优先级以及渲染优先级,即使pendingLanes中有更高优先级的任务,但也无法从pendingLanes中 获取到高优任务对应的任务优先级。

欢迎扫码关注公众号,发现更多技术文章