0%

react18.2高优先级任务插队和饥饿任务问题分析

时间切片是 Concurrent Mode 的核心机制之一。它的主要目的是将长任务分解为小的任务单元,每个单元只执行一小段时间,然后让出控制权,避免长时间阻塞主线程。

react 的并发模式在 render 阶段 每处理一个 fiber 就会根据以下两点判断一次是否应该打断并把控制权交换给主线程:

  1. 是否超过分片时间(5ms)
  2. 根据 isInputPending 判断是否有用户输入

但是并不会根据优先级来打断,优先级只会影响任务队列的任务排序,所以按照按优先级排序好的任务顺序来执行,也就能让高优先级任务得到及时处理。

当一个任务开始执行时, Scheduler 会为其分配一个时间片 (通常为 5ms)。在这个时间片内,任务可以连续执行。

  • 如果任务在时间片内完成,则直接结束
  • 如果任务执行时间超过了时间片,则 Scheduler 会中断任务的执行,保存当前的进度,并让出控制权给浏览器,以便响应用户交互或执行其他高优先级的任务
  • 如果任务执行中,遇到用户输入等需要浏览器及时反馈的操作,会通过判断 isInputPending,会中断当前任务,并让出控制权给浏览器,以便响应用户交互或执行其他高优先级的任务
1
2
3
4
5
6
7
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}

通过 shouldYield 判断,具体方法就详细说明了,可以去看《react调度器scheduler源码分析》

scheduleTaskForRootDuringMicrotask

scheduleTaskForRootDuringMicrotask 每次执行都会判断上一次中断任务优先级和root下的最高优先级是否一样。

  • 如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;
  • 如果不一样,说明有更高优先级的更新进来,要清空之前已开始的协调过程,从根节点开始重新协调。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// packages/react-reconciler/src/ReactFiberRootScheduler.js
function scheduleTaskForRootDuringMicrotask(
root: FiberRoot,
currentTime: number,
): Lane {
// 这个函数总是在microtask中被调用,或者在渲染任务的最后,在我们将控制权交还给主线程之前被调用。它绝对不应该被同步调用。

// 检查是否有任何lanes被其他work饿死。如果是,将它们标记为过期,这样我们就知道下一个要处理的是它们。
markStarvedLanesAsExpired(root, currentTime);

// Determine the next lanes to work on, and their priority.
const workInProgressRoot = getWorkInProgressRoot();
const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();

// 获取当前所有优先级中最高的优先级
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);

const existingCallbackNode = root.callbackNode;
if (
// Check if there's nothing to work on
nextLanes === NoLanes ||
// If this root is currently suspended and waiting for data to resolve, don't
// schedule a task to render it. We'll either wait for a ping, or wait to
// receive an update.
//
// Suspended render phase
(root === workInProgressRoot && isWorkLoopSuspendedOnData()) ||
// Suspended commit phase
root.cancelPendingCommit !== null
) {
// Fast path: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackNode = null;
root.callbackPriority = NoLane;
return NoLane;
}

// Schedule a new callback in the host environment.
if (includesSyncLane(nextLanes)) {
// sy- setState
// sy-no 初次渲染
// 同步工作始终在微任务结束时刷新,因此我们不需要安排额外的任务。
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackPriority = SyncLane;
root.callbackNode = null;
return SyncLane;
} else {

// We use the highest priority lane to represent the priority of the callback.
const existingCallbackPriority = root.callbackPriority;
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (
newCallbackPriority === existingCallbackPriority &&
// Special case related to `act`. If the currently scheduled task is a
// Scheduler task, rather than an `act` task, cancel it and re-schedule
// on the `act` queue.
!(
__DEV__ &&
ReactCurrentActQueue.current !== null &&
existingCallbackNode !== fakeActCallbackNode
)
) {
// The priority hasn't changed. We can reuse the existing task.
return newCallbackPriority;
} else {
// Cancel the existing callback. We'll schedule a new one below.
cancelCallback(existingCallbackNode);
}

let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority: // 32
// ? sy 页面初次渲染、transition(128)
schedulerPriorityLevel = NormalSchedulerPriority; // 3
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}

// ? sy 页面初次渲染
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);

root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
return newCallbackPriority;
}
}

1. 高优先级打断低优先级

cancelCallback

1
2
3
4
5
function unstable_cancelCallback(task: Task) {
// ...

task.callback = null;
}

cancelCallback(existingCallbackNode)cancelCallback 函数就是将 root.callbackNode 赋值为 null

performConcurrentWorkOnRoot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {
// ...

// Flush any pending passive effects before deciding which lanes to work on,
// in case they schedule additional work.
const originalCallbackNode = root.callbackNode;
// ...

// 在某些情况下,我们会禁用时间片切片:如果work过长时间做计算(为了防止饥饿而将其视为“过期”的work),或者我们处于默认启用同步更新模式。
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) && // 参考这个例子,UseDeferredValuePage
(disableSchedulerTimeoutInWorkLoop || !didTimeout);

// ! 1. render
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes) // 参考这个例子,UseDeferredValuePage
: renderRootSync(root, lanes); // ? sy, 不用时间切片

if (exitStatus !== RootInProgress) {
let renderWasConcurrent = shouldTimeSlice;
do {
if (exitStatus === RootDidNotComplete) {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
markRootSuspended(root, lanes, NoLane);
} else {
// ! 2. render结束,做一些检查

// ...

// !3. commit
// 我们现在有了一个一致的树。下一步要么是commit,要么是,如果有什么被暂停了,就等待一段时间后再commit。
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
}
break;
} while (true);
}
ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function getContinuationForRoot(
root: FiberRoot,
originalCallbackNode: mixed,
): RenderTaskFn | null {
// This is called at the end of `performConcurrentWorkOnRoot` to determine
// if we need to schedule a continuation task.
//
// Usually `scheduleTaskForRootDuringMicrotask` only runs inside a microtask;
// however, since most of the logic for determining if we need a continuation
// versus a new task is the same, we cheat a bit and call it here. This is
// only safe to do because we know we're at the end of the browser task.
// So although it's not an actual microtask, it might as well be.
scheduleTaskForRootDuringMicrotask(root, now());
if (root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}

performConcurrentWorkOnRoot 函数是每个并发任务的入口,下面简要分析以下它的运行流程:

  1. 会先把 root.callbackNode 缓存起来,存在 originalCallbackNode 变量中
  2. 并发模式下会使用 renderRootConcurrent 执行
    1. render 阶段中也有可能在代码执行中,触发更高优先级事件,例如点击事件,高优先级事件又触发了 setState 就是相对高优先级的
    2. setState 执行 scheduleUpdateOnFiberprepareFreshStackmarkRootUpdated 函数,就已经把 update 添加到 fiber 上并且将更新优先级标记到 rootpendingLanes 上了
  3. 如果render阶段结束,做一些检查,commit阶段,如果render阶段中断,会直接跳过上述操作,走到函数的末尾
  4. 在函数末尾会调用 getContinuationForRoot 函数,先执行 scheduleTaskForRootDuringMicrotask 就是把 render 中目前最高优先级事件和当前任务优先级作对比,进行 cancelCallback 或者 scheduleCallback,同时 root.callbackNode 也可能被改掉了
  5. 再判断 root.callbackNode 和开始缓存起来的值 originalCallbackNode 是否一样,如果不一样,就代表 root.callbackNode 被赋值为 null 或者其他的task 值,代表有更高优先级任务进来。
  6. 此时 performConcurrentWorkOnRoot 返回值为 null

pop(taskQueue)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const callback = currentTask.callback;
if (typeof callback === 'function') {
// ...
const continuationCallback = callback(didUserCallbackTimeout);
// 返回值continuationCallback是函数,也就是performConcurrentWorkOnRoot执行后的返回值performConcurrentWorkOnRoot.bind(null, root)或null,如果是函数就继续,不是的话,如果判断如果currentTask === peek(taskQueue),currentTask 出队
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.callback = continuationCallback;
if (enableProfiling) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
markTaskYield(currentTask, currentTime);
}
advanceTimers(currentTime);
return true;
} else {
if (enableProfiling) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
markTaskCompleted(currentTask, currentTime);
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);

上面是 Scheduler 模块里面 workLoop 函数的代码片段,currentTask.callback 就是 scheduleCallback 的第二个参数,也就是performConcurrentWorkOnRoot 函数。

承接上个主题,如果 performConcurrentWorkOnRoot 函数返回了 nullworkLoop 内部就会执行 pop(taskQueue),将当前的任务从 taskQueue中弹出。

低优先级任务重启

上一步中说道一个低优先级任务从 taskQueue 中被弹出。那高优先级任务执行完毕之后,如何重启回之前的低优先级任务呢?

1
2
3
4
5
6
7
8
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
markRootFinished(root, remainingLanes, spawnedLane);

// ...

// Always call this before exiting `commitRoot`, to ensure that any
// additional work on this root is scheduled.
ensureRootIsScheduled(root, now());

markRootFinished 函数刚刚上面说了是释放已完成任务的 Lane,那也就是说未完成任务依然会存在 lanes 中,所以我们可以重新调用 ensureRootIsScheduled 发起一次新的调度,去重启低优先级任务的执行。我们可以看下重启部分的判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const workInProgressRoot = getWorkInProgressRoot();
const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();

const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
// 如果 nextLanes 为 NoLanes,就证明所有任务都执行完毕了
if (nextLanes === NoLanes) {
// ...
root.callbackNode = null;
root.callbackPriority = NoLane;
// 只要 nextLanes 为 NoLanes,就可以结束调度了
return;
}
// 如果 nextLanes 不为 NoLanes,就代表还有任务未执行完,也就是那些被打断的低优先级任务
// ...

commit 的最后还是会 ensureRootIsScheduled,高优先级插队后低优先级任务能重启的原因:taskQueue中被打断的低优先级的任务已经 pop了,但是 root 上还有 pendingLanes,通过 ensureRootIsScheduled 重新把低优先级的任务加入 taskQueue 中。

流程总结

  1. 任务通过 unstable_scheduleCallback 进行调度,将任务添加到 taskQueue 中,如果是首次加载通过 requestHostCallback 调度宏任务
  2. Scheduler 里执行 flush 后执行 workLoop 方法,在 workLoop 方法中取出第一个任务,判断是否超出切片时间或者有更需要及时反馈的用户操作,如果没有,那当执行任务的 callback 时,也就是 performConcurrentWorkOnRoot 方法,执行流程可以看上面。所以我们知道 performConcurrentWorkOnRoot 方法可能会返回null,也可能会返回 performConcurrentWorkOnRoot
  3. 当返回 null 时候,Scheduler 会执行 pop(taskQueue),将当前的任务从 taskQueue中弹出
  4. 低优先级任务重启,commitRootImpl 最后会再次调用 ensureRootIsScheduled(root, now()),判断如果 nextLanesNoLanes,就证明所有任务都执行完毕了,如果 nextLanes 不为 NoLanes,就代表还有任务未执行完,也就是那些被打断的低优先级任务,会重新进行调度

任务插队情况具体总结分析

结合 eventLoop, 用户 click 是一个宏任务,会把回调 push 到宏任务队列,等待下一次执行。react 的并发,也是分成了 5ms 的宏任务来执行。

情况1 - 用户手动触发高优先级任务

例如正在执行一个并发任务的时候,用户点击了按钮准备触发 setState(可能是一个 lane2 的同步任务,也可能是比现在优先级高的其它任务),会把这个回调加入到宏任务队列,等到并发任务执行结束,( flushWork 的返回值如果是 true,也会 schedulePerformWorkUntilDeadline 再次 push 一个任务到宏任务队列,click 的回调在这个任务的前面)

下次进入宏任务队列,取出一个任务也就是 click 的回调,执行 dispatchSetStateisRenderPhaseUpdatefalse(还没进入到 render 阶段),scheduleUpdateOnFiber 也会向 root 上标记待执行任务的优先级,ensureRootIsScheduled 调度了微任务scheduleImmediateTask,宏任务执行完毕

进入微任务执行 processRootScheduleInMicrotask,执行 scheduleTaskForRootDuringMicrotask

  1. 这里会进行高优先级打断低优先级的 cancelCallback 操作、通过 scheduleCallback 将任务加入到 taskQueue 中(taskQueue 是最小堆,也会根据 lane 排序)
  2. 上面如果是同步任务,就不会执行 scheduleCallback,会在 microtask 结束时,flushSyncWorkOnAllRoots();( flush 任何pending 的同步 work。这必须放在最后,因为它执行实际的可能会抛出异常的渲染工作。)
  3. 最后执行完微任务,等到下次执行宏任务的时候,高优先级的就会先执行,以后再执行到低优先级时,其实低优先级没有 callback 了,就会跳过,后面commit 最后再重新发起调度低优先级任务

情况2 - 代码里触发了高优先级任务

还有另一种情况是在某个并发任务的 performConcurrentWorkOnRoot 里的 render 过程中加入了可能是一个 lane2 的同步任务,也可能是比现在优先级高的其它任务,会在此次任务切片(5ms)结束后,来进行判断(切片结束是通过 workLoopConcurrent 里面判断)

1
2
3
4
5
6
7
8
9
10
export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {

// ...

ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
}
  1. 如果是比现在优先级高的任务但又不是同步任务,getContinuationForRootscheduleTaskForRootDuringMicrotask 会进行打断低优先级任务并对新的且不是同步任务的发起 scheduleCallback 调度加入到 taskQueue 中,最终 performConcurrentWorkOnRoot 的返回值 return null,在 workLoopcontinuationCallbacknull, 会把当前的低优先级任务移除。取出 taskQueue 中的目前最高优先级的任务进行新的调度。
  2. 如果是添加同步任务是在 dispatchSetState 里的 ensureRootIsScheduled 发起的微任务调度,或者在 performConcurrentWorkOnRoot 的最后 ensureRootIsScheduled 发起的调度,执行完本次任务切片的宏任务接着执行微任务的时候同步任务就会被执行了

所以所有同步任务也都是在 microtask 结束时执行的,已保证最高的优先级且尽可能早的执行

2. 饥饿任务问题

上面说到,在高优先级任务执行完毕之后,低优先级任务就会被重启,但假设如果持续有高优先级任务持续进来,那低优先级任务岂不是没有重启之日?

所以 react 为了处理解决饥饿任务问题,在 ensureRootIsScheduled 函数开始的时候做了以下处理:(参考markStarvedLanesAsExpired函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 在 调度更新的过程中会被调用
// 检查是有lanes挨饿,如果有,则标记他们过期,即提升优先级(以便下次执行)。
export function markStarvedLanesAsExpired(
root: FiberRoot,
currentTime: number,
): void {
// TODO: This gets called every time we yield. We can optimize by storing
// the earliest expiration time on the root. Then use that to quickly bail out
// of this function.

const pendingLanes = root.pendingLanes;
const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
const expirationTimes = root.expirationTimes; // 数组,初始化的时候,值都是-1

// Iterate through the pending lanes and check if we've reached their
// expiration time. If so, we'll assume the update is being starved and mark
// it as expired to force it to finish.
// 遍历pending lanes,并检查是否已经达到它们的过期时间。
// 如果是,我们就认为这个update挨饿了,并将其标记为已过期,以强制其完成。
// TODO: We should be able to replace this with upgradePendingLanesToSync
//
// We exclude retry lanes because those must always be time sliced, in order
// to unwrap uncached promises.
// TODO: Write a test for this
let lanes = enableRetryLaneExpiration
? pendingLanes // ? sy
: pendingLanes & ~RetryLanes;

while (lanes > 0) {
// 下面两行代码的作用是找到lanes中最低位的1,即优先级最
const index = pickArbitraryLaneIndex(lanes);
// 把1左移index位,即得到一个只有第index位为1的子掩码
const lane = 1 << index;

const expirationTime = expirationTimes[index];
if (expirationTime === NoTimestamp) {
// sy- console.log('%c [ ]-1469', 'font-size:13px; background:pink; color:#bf2c9f;', lane)
// 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.
// 如果这个 pending lane 没有过期时间
// 如果它没有被挂起且需要更新,我们就认为它是CPU密集型操作。
// 用当前时间计算出一个新的过期时间给它。
// CPU bound / IO Bound
if (
(lane & suspendedLanes) === NoLanes ||
(lane & pingedLanes) !== NoLanes
) {
// sy- console.log('%c [ 饿死 ]-482', 'font-size:13px; background:pink; color:#bf2c9f;', lane)
// Assumes timestamps are monotonically increasing.
// 假设timestamps(时间戳)是单调递增的
expirationTimes[index] = computeExpirationTime(lane, currentTime);
}
} else if (expirationTime <= currentTime) {
// 这个 pending lane 已经过期了
// This lane expired
root.expiredLanes |= lane;
}
// 把lane从lanes中移除,计算下一个lane
lanes &= ~lane;
}
}

可以参考 render 阶段执行的函数 performConcurrentWorkOnRoot 中的代码片段

1
2
3
4
// ! 1. render
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes); // 不用时间切片

可以看到只要 shouldTimeSlice 只要返回 false,就会执行 renderRootSync,也就是以同步优先级进入 render 阶段。而 shouldTimeSlice 的逻辑也就是刚刚的 expiredLanes 属性相关

1
2
3
4
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
1
2
3
4
5
6
// 检查root是否包含过期的lane
export function includesExpiredLane(root: FiberRoot, lanes: Lanes): boolean {
// This is a separate check from includesBlockingLane because a lane can
// expire after a render has already started.
return (lanes & root.expiredLanes) !== NoLanes;
}