如何通过WebWorker与时间分片优化JS长任务?

如何通过WebWorker与时间分片优化JS长任务?

Tags
JavaScript
多线程编程
协程
CreatedTime
Aug 18, 2022 11:51 AM
Slug
2021-10-07-js-cpu
UpdatedTime
Last updated August 18, 2022

前端长任务

Js 是单线程的语言。如果一段 js 代码的逻辑占用了大量 CPU,那么就会造成「阻塞」,从而导致后面的渲染逻辑迟迟无法执行。 一般来说,超过 50ms 的任务,就是「长任务」。

长任务优化 1: 使用 Web Worker

对于长任务,在浏览器环境下,可以使用 Worker 规范,来开启子线程专门用来计算,主线程只负责发起计算任务和读取计算结果。 在 nodejs 中,可以通过 child_processworker_thread 来开启多进程或者工作线程,达到类似的效果。
假设现在要实现一个长任务计算,主线程的整体流程是:
  1. 主线程加载 worker 线程
  1. 主线程向 worker 线程发送数据,告诉 worker 线程开始计算任务
  1. 主线程监听 worker 线程返回的数据,然后根据具体业务逻辑使用它
主线程代码如下:
/* * @Author: dongyuanxin * @Date: 2021-01-11 23:29:13 * @Github: <https://github.com/dongyuanxin/blog> * @Blog: <https://xin-tan.com/> * @Description: 长任务优化-worker */ import ReactDom from "react-dom"; import React, { useEffect } from "react"; const App = () => { useEffect(() => { // 开启一个worker线程 const worker = new Worker("./6.worker.js"); // 监听worker线程传来的消息 worker.addEventListener("message", (e) => { console.log(`>>> [main.js] worker's data is:`, e.data); }); // 监听worker线程的报错 worker.addEventListener("messageerror", (e) => { console.log(`>>> [main.js] worker's error is:`, e.data); }); // 将任务交给worker线程执行 console.log(">>> [main.js] 开始进行任务计算"); worker.postMessage(1000000n); console.log(">>> [main.js] 交给Worker处理"); }, []); return <span>App</span>; }; const rootElement = document.getElementById("root"); ReactDom.render(<App />, rootElement);
worker 线程的整体流程是:
  1. 监听主线程发送数据,收到数据时,启动计算任务
  1. 计算完后,将任务结果发送给主线程
worker 线程代码如下:
// 在worker线程中,self的作用类似window // 监听主线程传来的消息 self.addEventListener("message", (e) => { // 执行计算任务 console.log(`>>> [worker.js] main's data is:`, e.data); for (let i = 0n; i < e.data; i = i + 1n) { // 模拟浪费CPU的计算 } // 将计算结果返回给主线程 self.postMessage(e.data * 2n); });

长任务优化 2: 使用 Generator Function

对于长任务来说,WebWorker 本质上是使用多线程。还有其它方法吗?有,可以尝试将长任务分解成短任务。
这里「分解成短任务」,不是说将其拆分为多个子函数,因为这样还是一次性都要执行,由于函数堆栈调用,时间甚至更慢。而是说,让长任务运行到某个部分,然后暂停,空出 CPU 去执行其它任务,例如渲染界面、响应用户交互。之后,再拿回 CPU,继续上次的计算。
这种处理思路,有点像早期操作系统,只有一个进程。那怎么让用户看起来所有的任务都在执行呢?只能来回切换任务,但同一时刻,其实只有一个任务在执行;而多进程,是同一时刻,多个任务同时进行。也就是并发和并行的区别。
那么,想要做到这个效果,需要满足 2 个条件:
  1. 能够暂停函数,让出 CPU 控制权
  1. 能够回到函数,拿到 CPU 控制台权继续执行
这时,就要使用 es6 提供的 generator 函数,也就是协程在 js 语言规范中的实现。

实现 1: 利用 generator 实现长任务切分

function ts(gen) { if (typeof gen !== "function") { return; } let generator = gen(); return function next() { const res = generator.next(); if (res.done) { return; } // 在下一次js的事件循环中,继续执行gen函数中的逻辑 setTimeout(next); }; }
Ts 函数在当前 js 事件循环中,执行传入的 generator 函数的部分逻辑;然后让出 cpu 控制权,并且在下次事件循环中,继续执行剩余部分逻辑;循环往复,直到完成。
下面是使用ts()来对长任务进行切分的效果:
function* run() { yield; for (let times = 0; times < 10; ++times) { for (let i = 0n; i < 1000000n; i += 1n) {} console.log(`>>> finish duty: ${times + 1} / 10`); yield; } } const runGen = ts(run); runGen();
这个任务总共大概 300ms(系统是 Mac 2020 16 寸)。通过 yiled,将长任务切分成 10 个短任务。从而避免一直占用 js 线程,阻塞当前的事件循环,导致之后的渲染出错。

实现 2: 更好地切分长任务

进一步考虑下一种情况,假设通过 yield 关键词切分的子任务,执行时间太短(比如就循环了 100 次,可能就几毫秒)。这样长任务会被切的非常碎,分布在很多次事件循环中执行。整体的执行时间会增加。
优化的思路:通过「批量执行」子任务。统计子任务执行时间,如果执行时间太短(小于 25ms),那么继续执行,保证每次执行的 1 个或者 n 个子任务的总时间,总时间大于等于 25ms。
代码实现如下:
function ts2(gen) { if (typeof gen !== "function") { return; } let generator = gen(); return function next() { const startTime = Date.now(); let res; do { res = generator.next(); } while (!res.done && Date.now() - startTime < 25); if (res.done) { return; } // 在下一次js的事件循环中,继续执行gen函数中的逻辑 setTimeout(next); }; } const runGen2 = ts2(run); runGen2();

参考链接

 
#JavaScript/浏览器开发 #面试题目/JavaScript题目 #JavaScript/ES6 #算法/并发编程/长任务优化