scheduleCallback与调度任务
# 目录
# unstable_scheduleCallback
在调和器的章节中我们知道了同步渲染由 scheduleSyncCallback 调度,异步渲染由 scheduleCallback 调度,最终都是由 unstable_scheduleCllback 来管理调度任务。unstable_scheduleCallback 是调度器中暴露出来的,在独立的 scheduler/Scheduler.js 文件中。
unstable_scheduleCallback
方法代码如下:
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 获取当前时间
var currentTime = getCurrentTime();
// 计算startTime 和 timeout
var startTime;
var timeout;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
timeout =
typeof options.timeout === 'number' ?
options.timeout :
timeoutForPriorityLevel(priorityLevel);
} else {
timeout = timeoutForPriorityLevel(priorityLevel);
startTime = currentTime;
}
// 计算 expirationTime
var expirationTime = startTime + timeout;
// 创建新的 task
var newTask = {
// 任务 id
id: taskIdCounter++,
// 任务执行完之后的回调
callback,
// 任务的优先级
priorityLevel,
// 开始时间
startTime,
// 到期时间:经过多长时间没执行的话必须执行
expirationTime,
// 任务排序的索引
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}
if (startTime > currentTime) {
// This is a delayed task.
// startTime 大于 currentTime 则 task 被 delay
// 延迟任务
// 延迟的任务队列将以 startTime 进行排序
newTask.sortIndex = startTime;
// 将新建的 task 添加至队列,延时任务加入到
// 延迟任务由队列 timerQueue 维护
push(timerQueue, newTask);
// 如果当前没有即时任务,且 newTask 为最早的延时任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
// 如果现在有延迟任务预约,就将这个延迟任务的预约取消,因为现在有优先级更高的了
cancelHostTimeout();
} else {
// 如果没有延迟任务的预约,就预约任务的回调
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
// 设置延时, 主线程延时回调,传入延迟时长和回调函数
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// 即时任务,将以 expirationTime 进行排序
newTask.sortIndex = expirationTime;
// 即时任务加入到 taskQueue
// 即时任务由队列 taskQueue 维护
push(taskQueue, newTask);
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
//如果当前并没被其他即时任务预约,也没有正在回调某个任务
if (!isHostCallbackScheduled && !isPerformingWork) {
// 预约当前任务
isHostCallbackScheduled = true;
// 请求主线程回调
requestHostCallback(flushWork);
}
}
return newTask;
}
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
这个函数的作用是:创建调度任务请求主线程回调。具体来看:
- 将回调包装成任务,并且由相对的任务队列来管理。
- 区分即时任务和延时任务,即时任务由 taskQueue 管理,延时任务由 timerQueue 管理。
- 如果是即时任务,则请求主线程回调,如果是延时任务,则请求主线程延时回调。
- 如何计算 startTime 和 expirationTime?
- 如果 options 中传了 delay,则
startTime = currentTime + delay
,否则startTime = currentTime
。 - 如果 options 中传了 timeout,则 timeout 为
options.timeout
, 否则会跟根据优先级计算 timeout,即timeout = timeoutForPriorityLevel(priorityLevel)
。 expirationTime = startTime + timeout
即超时时间为currentTime + delay + timeout
。
- 如何判断是即时任务还是延时任务?
将 startTime
和 currentTime
进行比较,如果 startTime > currentTime
,则认为是延时任务,否则就认为是即时任务。
结合 currentTime
的计算方法可知,只有 options 中 delay 存在且大于 0 时,才会被认为是延时任务。
- callback 是如何处理的?
callback 被挂载到到 newTask 上,newTask 最终由 unstable_scheduleCallback
返回。
- 即时任务和延时任务分别是如何处理的?
- 即时任务
即时任务会被加入到 taskQueue
队列中,由 requestHostCallback
调度,直接请求主线程回调。
- 延时任务
延时任务会被加入到 timerQueue
队列中,由 requestHostTimeout
调度,请求主线程延时回调。
taskQueue
和timerQueue
的区别?
// Tasks are stored on a min heap
var taskQueue = [];
var timerQueue = [];
2
3
- 这两个队列都是小顶堆,初始化为
[]
。 taskQueue
队列管理即时任务,timerQueue
队列管理延时任务,只有taskQueue
中的任务才会被主线程立即回调。
- 任务的排序:
- 即时任务:以 expirationTime 排序,expirationTime 越小优先级越高。
- 延时任务:以 startTime 排序,startTime 越小优先级越高,startTime = currentTime + delay。
- 关于小顶堆
参考:
- 下面这三个函数由 SchedulerHostConfig 实现,前面我们已经知道 requestAnimationFrame 和 requestIdleCallback 这两个函数可以实现浏览器中任务执行的优先级,但是由于 API 兼容性(requestIdleCallback)的问题,react 内部进行了实现,用 requestAnimationFrame 和 setTimeout 模拟实现 requestIdleCallback。这部分我们将在 SchedulerHostConfig 中分析。
- requestHostTimeout:请求主线程延时回调
- cancelHostTimeout:取消主线程延迟回调
- requestHostCallback:请求主线程回调
# flushWork 和 workLoop
这里我们知道 unstable_scheduleCallback 实际上就是调度器的入口,针对外部传入的 callback,调度器将之包装成即时任务和延时任务,按照 delay 区分不同的优先级执行回调。requestHostTimeout 和 requestHostCallback 分别回使传入的任务延时执行和立即执行。调度器最重要的功能就是任务队列的管理、任务执行和任务的中断与恢复。我们先来看下同步任务列表时如何处理的。具体内部是如何即时执行和延时执行的,后文详述。
# 在一个任务被加入到同步队列时发生了什么?
push(taskQueue, newTask);
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
// 请求主线程回调
requestHostCallback(flushWork);
}
2
3
4
5
6
在 unstable_scheduleCallback 这段代码中可以看出,如果在加入这个任务到同步队列时,如果当前主线程并没有请求即时回调也没有执行同步任务队列,这是就主动请求一次主线程即时回调。
# flushWork
// performWorkUntilDeadline 回调次函数时hasTimeRemaining=true,initialTime=currentTime
function flushWork(hasTimeRemaining, initialTime) {
if (enableProfiling) {
markSchedulerUnsuspended(initialTime);
}
// We'll need a host callback the next time work is scheduled.
// 当前属于 isPerformingWork 阶段(回调执行阶段),这是允许其他的任务继续请求主线程回调
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
// We scheduled a timeout but it's no longer needed. Cancel it.
// 如果同步任务队列已经被回调执行了,那么延迟任务队列的延时回调就不需要了
// 为什么不需要了?因为延时回调 handleTimeout 的功能在 workLoop 中已经被包含了
// 将延时任务队列中到期的任务放入即时任务队列,视情况请求延时回调或者即时回调,后文详述
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
// isPerformingWork 表示当前处于同步任务回调执行阶段
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
if (enableProfiling) {
try {
// 继续调用 workLoop
return workLoop(hasTimeRemaining, initialTime);
} catch (error) {
if (currentTask !== null) {
const currentTime = getCurrentTime();
markTaskErrored(currentTask, currentTime);
currentTask.isQueued = false;
}
throw error;
}
} else {
// No catch in prod codepath.
return workLoop(hasTimeRemaining, initialTime);
}
} finally {
// 执行回调阶段后的清理工作
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
if (enableProfiling) {
const currentTime = getCurrentTime();
markSchedulerSuspended(currentTime);
}
}
}
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
这个函数的实际上是将 workLoop 函数包装了一层,做了一些优化、清理等工作,主要的逻辑还在 workLoop 中。
# advanceTimers
在进入 workLoop 函数的分析之前,先来看下 advanceTimers 这个函数。我们已经知道所有的任务已经被分成即时任务(同步任务)和延时任务来管理。其实在执行同步任务队列之前,我们需要对同步任务队列做更新,因为程序执行到这里可能会有延时任务已经到期了,这时这个已经到期的延时任务需要转移到同步任务队列之中,而这份工作正在 advanceTimers 函数实现的。
advanceTimers 源码如下:
function advanceTimers(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
// 从延时任务中取出最早的任务
let timer = peek(timerQueue);
while (timer !== null) {
// callback 为 null,表示该任务已经被取消了,所以删除之。
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
// startTime 已经过了时间了,这说明这个任务已经成为了到期的任务
// 将之从延时任务队列中取出转移到即时任务队列,并改变其排序的索引
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
if (enableProfiling) {
markTaskStart(timer, currentTime);
timer.isQueued = true;
}
} else {
// Remaining timers are pending.
// 未到期则直接结束循环,因为整个队列是有序的,后面的一定未到期,不必再判断了
return;
}
// 指针移动到下一个任务
timer = peek(timerQueue);
}
}
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
可以看到,advanceTimers 的主要作用是:更新即时任务队列。
# workLoop
这个函数包含了即时任务队列在回调中处理的核心逻辑。
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
// 更新同步任务队列
advanceTimers(currentTime);
// 去除最早的即时任务
currentTask = peek(taskQueue);
while (
currentTask !== null &&
// isSchedulerPaused 表示调度器被中断
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// 如果任务并没有过期,或者没有剩余的时间直接终止执行
// 一般来说这里并不会发生
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
// 取出当前任务的原始的回调函数
const callback = currentTask.callback;
// 这个任务是新任务或者是没有执行完的任务,需要继续执行。
if (callback !== null) {
// 将 callback 置空是因为我们现在已经要执行他了
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
// didUserCallbackTimeout 恒为 true
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
markTaskRun(currentTask, currentTime);
// 执行回调函数,并且获得了回调函数的返回值。
// 为什么要有返回值?我们要通过这个返回值来判断这个任务到底有没有执行完
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
// 如果回调函数是一个函数,一般会返回回调函数本身,说明任务并没有执行完。只是执行了部分,可能是被中断了。
if (typeof continuationCallback === 'function') {
// 把这个回调结果放在callback上。这时任务并没有从队列中移除,只是 callback 改变了。任务的优先级不变。
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
// 回调函数如果不是返回函数说明任务已经执行完了,可以将任务从队列中移除了
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
// 在继续循环之前先更新下即时任务队列。
advanceTimers(currentTime);
} else {
// 已经取消的任务删之。
pop(taskQueue);
}
// 指针移动到队列中的首个任务继续执行。
currentTask = peek(taskQueue);
}
// Return whether there's additional work
if (currentTask !== null) {
// 队列的任务并没有执行完,返回 true。
// 这里会告知任务的执行者,采取措施。
// 实际上 performWorkUntilDeadline 会通过 port.postMessage 再发出一个消息
return true;
} else {
// 如果延时任务队列还有任务,通过最早的任务在请求一次延时回调。
let firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
// 任务队列已经执行完,返回 false 表示可以结束本次调度。
return false;
}
}
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
- 看这里跳出循环的条件可能有些疑问,为什么同步任务队列里要判断任务没有过期?同步任务队列里的任务并不一定都是过期的,也有些是没有过期的,从前文中计算任务的 expiration = start + time 可知,这里的 timeout 会影响 expiration,表示这个任务最多可以推迟的时间,已经过期的任务必须立即执行掉,但是未过期的任务可以有一定的推迟,但是这个推迟的条件比较苛刻,可以看到在!hasTimeRemaining 即没有剩余执行时间时,或者 shouldYieldToHost () 即需要向主线程让渡执行权的时候(后文详述),才可以推迟。
核心理解
- 任务队列的中断和恢复机制
为什么要中断任务队列?
从上面的代码中,我们已经知道了在剩余执行时间不够时或者有更高优先级的任务需要让渡执行权给主线程时,需要中断任务队列。综合来看中断任务队列是因为:
- 防止任务队列过大造成主线程阻塞、用户交互迟缓、页面阻塞(相对于其他任务,用户交互任务一般都是优先级比较高的任务);
- 并非所有的即时任务都需要立即执行的,在一定情况下是可以有一定的延迟度的,只是相对于延时任务而言的 “立即执行”。
怎么中断任务队列?怎么恢复?
跳出任务队列执行的循环,并且在 workLoop 函数中返回 true。告知执行者,本次调度是被中断的,执行者会在发出一次消息回调一次。回调时任务列表会在执行一次。
- 任务的中断和恢复机制
为什么要中断任务?
- 防止过大的任务阻塞主线程。
- 根据时间片将任务进行切分,提高任务执行效率。
任务的中断和恢复?
workLoop 是根据 callback 的返回值来判断是否需要中断的。在 callback 返回 'function' 类型的值时即表示请求中断,这时 workLoop 就会保存现场,不删除此任务,先执行下一个任务,下次执行任务队列时对于中断的任务可以恢复现场,直到 callback 返回了 null,表示任务执行完毕,此时 workLoop 将删除此任务,继续执行下一个任务。
- 为什么要有中断?
中断很重要,中断可以把代码分到不同的帧去执行,我们知道调度器的在回调时正是考虑到帧的问题,防止在同一帧做过多的事情,造成页面掉帧,这点将在后文详述。
- 执行者是如何感知任务队列的终端状态?
这里所说的执行者是指 performWorkUntilDeadline 函数,执行者是通过此函数返回值来判断终端的具体状态的,函数返回 true 表示存在任务队列执行过程中有中断,需要再派一个执行者继续处理,返回 false 表示任务队列执行完毕,此次回调到此结束。
任务中断与恢复示意图:
# handleTimeout
在上文中,我们讲述了同步任务队列是如何执行的,以及任务中断和恢复问题,下面我们再来看一下延时任务是如何调度执行的。需要说明的是,上文我们解释了 advanceTimers 函数的作用,他可以更新同步任务队列,经过思考我们可能已经意思到了,延时任务是通过跳跃到同步任务队列来执行的。没错,我们将在下面详细说明这一点:
function handleTimeout(currentTime) {
isHostTimeoutScheduled = false;
// 更新同步任务队列,此时延时任务队列中的任务应该都没有过期。
advanceTimers(currentTime);
// 如果已经请求了主线程回调就没有必要再请求回调了
if (!isHostCallbackScheduled) {
// 如果即时任务队列里有任务可以消费了但是没有请求回调,就主动请求一个主线程回调
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
// 如果没有即时任务需要去回调执行,就以延时任务队列中最小的 timeout 继续请求延时回调。
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
从这里我们可以看出:
- handleTimeout 只会在没有请求主线程回调时继续请求主线程即时回调或者主线程延时回调,这取决于更新之后的即时任务队列到底还有没有值可以被消费。
- 因为即时任务需要被立即执行的,所以这个优先去请求主线程即时回调了,其实在 workLoop 的最后也会检查还有没有延时任务来请求主线程延时回调。
- 因为延迟任务随时都有可能过期成为即时任务,所以需要执行者不断检查,不管是主线程延时回调还是即时回调都会在请求一次延时回调。
# 小结
这篇文章介绍了任务队列的维护、执行、中断与恢复,以及任务的执行、中断与恢复,下片文章将具体介绍调度器是如何即时回调和延时回调的。