跳到主要内容

认识

一、认识


Scheduler 包含两个功能:

  • 时间切片

  • 优先级调度

Scheduler 调度器表现如下:

  1. 优先级高的任务会打断低优先级任务的执行, 每次执行的任务是所有任务中优先级最高的

  2. 如果两个任务的优先级相同, 不会开启新的调度

  3. 低优先级的任务存在过期时间, 等到失效后, 以同步任务高优执行。防止低优先级任务一直得不到执行, 导致饥饿问题的出现

二、工作流


2.1 时间切片工作流

1. 在 Reactrender 阶段,开启 Concurrent Mode 时,每次遍历前,都会通过 Scheduler 提供的 shouldYield 方法判断是否需要中断遍历,使浏览器有时间渲染

function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}

2. Scheduler 为任务分配的初始剩余时间为 5ms, 是否要中断遍历的依据就是每个任务的剩余时间是否用完。

const yieldInterval = 5;
const perf = window.performance;

const getCurrentTime: () => DOMHighResTimeStamp = perf.now.bind(perf);

export function unstable_shouldYield(): boolean {
return getCurrentTime() >= deadline;
}

function runTask(){
deadline = getCurrentTime() + yieldInterval;
}

3. Scheduler 随着应用的运行, 会通过 fps 动态调整分配给任务的可执行时间

function forceFrameRate(fps: number) {
if (fps < 0 || fps > 125) {
// Using console['error'] to evade Babel and ESLint
console['error'](
'forceFrameRate takes a positive int between 0 and 125, ' +
'forcing frame rates higher than 125 fps is not supported',
);
return;
}
if (fps > 0) {
frameInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
frameInterval = frameYieldMs;
}
}

2.2 优先级调度工作流

Schedule 中分别提供了 unstable_runWithPriorityunstable_scheduleCallback 方法, 用于支持调度。其中:

  • unstable_runWithPriority: 接受一个优先级与一个回调函数。在函数中, 执行回调函数之前, 记录当前优先级为传入的优先级, 开始执行回调函数并返回函数执行结果, 最后记录当前优先级为之前的优先级。

  • unstable_scheduleCallback: 用于以某个优先级注册回调函数。在函数中, 不同的优先级意味着不同时长的任务过期时间。同时会比较 startTimecurrentTime , 如果 startTime > currentTime, 表示当前任务未就绪, 存入 timerQueue。并根据开始时间重新排列 timerQueue 中任务的顺序。当 timerQueue 中有任务就绪,即startTime <= currentTime,我们将其取出并加入taskQueuetimerQueuetaskQueue 两个队列为了能在 O(1) 的时间复杂度里找到两个队列中时间最早的那个任务, 采用的是最小堆的数据结构, 每次存入任务的过程就是最小堆自动调整的过程

三、时间切片


时间切片的本质是模拟实现requestIdleCallback

3.1 requestIdleCallback 背景

当项目变得庞大、组件数量繁多时,就容易遇到CPU的瓶颈。同时,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了,造成页面卡顿、掉帧问题。在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件(可以看到,在源码中,预留的初始时间是5ms),当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UIReact则等待下一帧时间到来继续被中断的工作。因此,将这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片time slice)。

所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是: 将同步的更新变为可中断的异步更新。这个可中断的异步更新被拆分到浏览器每一帧的空闲时间中,一旦当前帧还有空闲时间,那么就继续执行中断的异步任务。

既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React放弃使用:

  • 浏览器兼容性: requestIdleCallback 目前只有谷歌浏览器支持

  • 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的 requestIdleCallback 触发的频率会变得很低

基于以上原因, React 实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

3.2 模拟实现 requestIdleCallback

浏览器的每一帧(每一次事件循环), 都会做如下事情:

  • 1.宏任务

  • 2.队列中的微任务

  • 3. requestAnimationFrame

  • 4. 浏览器重排/重绘

  • 5. requestIdleCallback

根据浏览器的一帧可以得出, requestIdleCallback 是在 浏览器重排/重绘 后如果当前帧还有空余时间被调用的,但是浏览器并没有提送其他API能够在同样的时机requestIdleCallback调用以模拟其实现,所以ReactScheduler模拟的 时间切片 是通过 宏任务(task 实现的。

最常见的宏任务当属setTimeout了。 React 模拟 requestIdleCallback 的策略为: setImmediateMessageChannelsetTimeout。 由于setTimeout嵌套过深会有>= 4ms的延迟, 所以如果当前宿主环境不支持 setImmediateMessageChannel, React 会优雅降级,选择setTimeout 来模拟

3.3 MessageChannel 模拟实现 requestIdleCallback

function requestIdleCallbackOfMessageChannel() {
return (cb, { timeout } = {}) => {
const start = performance.now();
const channel = new MessageChannel();
channel.port2.onmessage = () => {
const now = performance.now();
const didTimeout = timeout && now - start >= timeout;
if (didTimeout || now - start < 50) {
cb({
didTimeout,
timeRemaining() { return Math.max(0, 50 - (now - start)); },
});
} else {
channel.port1.postMessage(undefined);
}
};
channel.port1.postMessage(undefined);
};
}

3.4 setTimeout 模拟实现 requestIdleCallback

function requestIdleCallbackOfSetTimeout() {
return (cb, { timeout } = {}) => {
const start = performance.now();

const callbackWrapper = () => {
const now = performance.now();
const didTimeout = timeout && now - start >= timeout;
if (didTimeout || now - start < 50) {
cb({
didTimeout,
timeRemaining() { return Math.max(0, 50 - (now - start)); },
});
} else {
window.setTimeout(callbackWrapper);
}
};

window.setTimeout(callbackWrapper);
};
}

四、批量更新


React.js 18.x 之后, 几乎所有更新, 包括 Promise.thensetTimeout 或者原生事件处理程序等, 都将自动批处理。批量处理逻辑为: 在同一环境中, 无论同步调用还是异步调用, 多次更新函数任务的优先级是相同的, 属于同一批任务。这一批任务都通过 scheduleUpdateOnFiber 标记相同的优先级之后, 开始通过 ensureRootIsScheduled 进行调度更新, 对比上次等待的更新和本次更新的优先级, 如果相等, 则会终止这个这任务的调度流程, 复用已有的调度任务。因此, 多次触发更新只有第一次会进入到调度中

五、优先级调度


5.1 Priority

Scheduler 存在五种优先级:

  1. unstable_ImmediatePriority: 同步更新

  2. unstable_UserBlockingPriority: 比如点击事件

  3. unstable_NormalPriority:

  4. unstable_LowPriority

  5. unstable_IdlePriority: 空闲时优先级

5.2 Schedule Queue

React 中, 任务有自己的优先级, 对应不同的过期时间, 因此, 任务可以被延迟。 所以我们可以将这些任务按是否被延迟分为:

  • 已就绪任务

  • 未就绪任务

所以,Scheduler存在两个队列:

  • timerQueue: 保存未就绪任务, timerQueue 是一个最小堆, 便于能在 O(1) 的时间内找到时间最早的任务

  • taskQueue: 保存已就绪任务, taskQueue 是一个最小堆, 便于能在 O(1) 的时间内找到时间最早的任务

unstable_scheduleCallback 中, 每当有新的未就绪的任务被注册, 即 startTime > currentTime, 我们将其插入 timerQueue 并根据开始时间重新排列 timerQueue 中任务的顺序。其实就是重新调整最小堆的过程。当 timerQueue 中有任务就绪,即 startTime <= currentTime,我们将其取出并加入 taskQueue。随后, 取出 taskQueue 中最早过期的任务并执行。

if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, 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 {
newTask.sortIndex = expirationTime;
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();
}
}

5.3 unstable_runWithPriority

Scheduler 对外暴露了一个方法 unstable_runWithPriority。这个方法接受一个优先级与一个回调函数,在回调函数内部调用获取优先级的方法都会取得第一个参数对应的优先级:

function unstable_runWithPriority<T>(
priorityLevel: PriorityLevel,
eventHandler: () => T,
): T {
switch (priorityLevel) {
case ImmediatePriority:
case UserBlockingPriority:
case NormalPriority:
case LowPriority:
case IdlePriority:
break;
default:
priorityLevel = NormalPriority;
}

var previousPriorityLevel = currentPriorityLevel;
currentPriorityLevel = priorityLevel;

try {
return eventHandler();
} finally {
currentPriorityLevel = previousPriorityLevel;
}
}

React 内部凡是涉及到优先级调度的地方,都会使用 unstable_runWithPriority

5.4 unstable_scheduleCallback

Scheduler 对外暴露最重要的方法便是unstable_scheduleCallback。该方法用于以某个优先级注册回调函数。unstable_scheduleCallback 中, 不同的优先级意味着不同时长的任务过期时间。可以看到,如果一个任务的优先级是 ImmediatePriority,对应IMMEDIATE_PRIORITY_TIMEOUT-1 ,那么该任务的过期时间比当前时间还短,表示他已经过期了,需要立即被执行。

function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
var currentTime = getCurrentTime();

var startTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}

var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
timeout = normalPriorityTimeout;
break;
}


var expirationTime = startTime + timeout;

var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}

if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, 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 {
newTask.sortIndex = expirationTime;
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();
}
}

return newTask;
}