title | date | summary | tags | draft | authors | ||
---|---|---|---|---|---|---|---|
任务调度的基本原理 |
2023-08-01 |
本文从案例出发结合 chrome performance 工具一步步剖析`任务调度`的原理 |
|
false |
|
React fiber 架构中引入了任务调度
的概念并实现了scheduler
,但是任务调度是框架无关的
很多文章分析的时候都会搬来计算机中操作系统调度,其他语言中的进程管理等概念,对于前端来说知识密度比较高不易理解。因此本文打算从案例出发结合 chrome performance 工具一步步剖析任务调度
的原理。
本文尝试解答以下问题:
- 浏览器的渲染流程与长任务的影响
- 为什么需要任务调度,与什么是合作任务调度
- 为什么需要使用宏任务而不是微任务进行调度
- 使用 setTimeout 调度的问题
- message Channel 作为调度方案
- 什么是任务切片?什么是时间切片
我们知道,浏览器是一个中的 JS 线程和 UI 渲染线程是互斥的。长时间的 JS 同步任务会阻塞浏览器渲染,导致页面卡顿(动画执行卡顿)甚至失去相应(用户输入不响应).
这里通过一个 Demo 进行效果演示。当点击点击模拟任务
的按钮时,我们执行一段 JS 代码,这段代码会动态创建 3000 个任务区执行,每个任务的执行时间为 2ms, 总体的任务时间为 6000ms(可能有误差,但整体不大)
// ----------任务创建----------
const createWorks = () => {
const taskDuration = 2;
let works = [];
for (let i = 0; i < 3000; i++) {
const work = () => {
let startTime = Date.now();
while (Date.now() - startTime < taskDuration) {
// 模拟任务执行时间
}
};
works.push(work);
}
return works;
};
const works = createWorks();
// -------更新页面------
const flushWorks = () => {
works.forEach((work) => work());
};
通过观察可以发现,在点击了 button 之后 JS 执行的过程中,整个页面是处于 freeze 状态,这必然是很影响用户体验的。
通过 Performance 面板我们可以看到,整个 JS 的任务执行时间非常长,因此我们得想办法优化这部分的逻辑。
👉 在线效果预览, 或者下载源码后打开(为避免浏览器插件影响,请在无痕模式下打开)
既然我们的同步的长任务不可接受,那么我们就要想办法将任务变成可中断的。同时使用宏任务 API setTimeout 进行异步更新(当然在实际过程中我们要考虑更多的因素,比如执行上下文的保存以及中断之后的恢复, 如 React Fiber 架构)
注意此处不可使用微任务 API 如
Promise
,因为微任务会在一个宏任务执行完成之后全部执行,实际效果就和同步执行差不多了,关于宏任务微任务的相关知识点可以看这里的介绍
// --------任务调度--------
const workLoop = () => {
const work = works.shift();
if (work) {
work();
setTimeout(workLoop, 0);
} else {
updateView(Date.now() - startTime);
}
};
const flushWorks = () => {
setTimeout(workLoop, 0);
};
点击按钮观察页面可以发现,经过 setTimeout 进行异步更新后,现在任务执行的时候已经不会阻塞页面动画渲染了。但是这里存在一个问题就是 setTimeout 的调度时机问题,虽然我们在代码里面写了setTimeout(workLoop, 0)
, 但是实际上浏览器每次调度的时候的都会有一个 4ms 的延迟,从图中我们可以看出在一帧的时间内仅执行了 3 个任务,这也导致了总任务执行时长变为了 18s,那么有没有什么办法可以提高调度的频率呢?
上面提到了我们需要提高浏览器的调度频率,这里我们使用 messageChannel 来实现对应的功能。实际上如果我们不考虑任务执行的时间, 单纯测试 messageChannel 的调用次数的话,其表现效果还是很亮眼的,1s 内的调用次数可以达到 16 万次(取决于机器的性能。)
有了上面的数据,我们使用 messageChannel 来改造我们的示例,这里为了方便不熟悉 messageChannel 的朋友理解,我们模拟 setTimeout 的使用方式来使用 messageChannel 进行任务调度,其余代码保持不变。
const _setTimeout = ((workLoop) => {
let channel = null;
return (onMessageCb) => {
if (!channel) {
channel = new MessageChannel();
channel.port1.onmessage = onMessageCb;
}
channel.port2.postMessage(null);
};
})();
const workLoop = () => {
const work = works.shift();
if (work) {
work();
_setTimeout(workLoop);
} else {
updateView(Date.now() - startTime);
}
};
通过 Performance 面板可以发现,在每个任务执行时间为 2ms 的情况下,一帧内可以执行的任务数为 8。但即便如此,依然存在一定的调度耗时。
在上面的基础上,我们很容易有这样的想法:“尽量减少任务调度的次数”不就可以了吗?我们可以改造下 workLoop 函数,让尽可能多的任务在一帧内执行。
const workLoop = () => {
let workExecutedCount = 0;
while (workExecutedCount < 7) {
const work = works.shift();
if (work) {
work();
workExecutedCount++;
} else {
updateView(Date.now() - startTime);
break;
}
}
if (works.length) {
_setTimeout(workLoop);
}
};
通过 Performance 面板可以看到,性能略微有提升。
上述的“任务切片”方案是基于我们已知单个任务执行时间的前提下,但是在实际业务中每个任务的执行时间是不确定的。因此我们可能会想到另外一种切片的方式:“时间切片”。 给定一个预定的时间比如 5ms, 每次任务执行完成之后都会去判断一下是否超出了时间分片的限制,如果是的话就让出执行权交给浏览器去渲染。
这里我们来改造下 createWorks 方法,每次生成一个在 0-1 毫秒之内的任务,然后执行,查看效果。
const createWorks = () => {
const taskDuration = Math.random(); // 随机生成 0-1
let works = [];
for (let i = 0; i < 3000; i++) {
works.push(() => {
const start = performance.now();
const time = Math.random();
while (performance.now() - start < time) {}
});
}
return works;
};
const works = createWorks();
至此,我们已经初步了解了调度相关的知识,下一篇会讲讲优先级调度相关的东西。
本文首发于个人博客前端开发笔记,由于笔者能力有限,文章难免有疏漏之处,欢迎指正