最近在维护微信文档这块内容,遇到一个问题,文档数量多起来编译时间会变慢,而且有时候会越来越慢。后面,发现文档的编译一直走的是单线程的,只用到了一个核,顿时感觉有套路可以走了。node 在 v10 过后提出了 worker_threads 模块,它是在一个单独的 node v8 实例进程里面,可以创建多个线程来进行搞 CPU 任务。
tl;dr
下文主要阐述了一下几点:
- worker_threads 的基本使用和了解
- 使用线程池模式,来提高 node 进程的计算速度
- 用 worker_threads 模块,来优化 vuepress 编译速度
- worker_threads 模块和 cluster、child_process 之间的用法和区别
worker_threads 简介
Nodejs 核心执行是基于单线程 + event_loop ,底层是基于 libuv 库,在每次循环中,执行一次完整的 event_loop。所以为了实现 thread_worker 的方式,只有脱离于 node 单线程,单独提供 worker_threads
模块来实现。当然,nodejs 还有其他方式实现高性能并发,比如 cluster 和 child_process,不过,这两者在使用和场景上,与 worker_threads 区别还是挺大的。这里我们后面会了解一下。
worker_threads 的应用主要聚焦在 高 CPU 计算,低 I/O 的场景上,比如像现在比较火热的 AI,挖矿计算,或者朴实点的文件编译上。
Note: worker_threads 是在 10.x 版本提出的,但是在使用时,还需要加上
--experimental-worker
flag,不过不想加 flag 的话,把 node 版本切到 11.7 以上就行。
worker_threads 抽象上提供 mainThread 和 worker。其中:
- mainThread 相当于就是 nodejs 的主线程
- worker 是单独吊起的 worker 子线程
mainThread 通过 new Worker
去实例化子线程,然后通过 MessageChannel 来和 worker 通信。
这里参考一下官网例子,顺道先解释一下。下面的 demoCode,描述的是一个文件即作为 mainThread,也作为 worker 的执行。
const {
Worker, isMainThread, parentPort, workerData
} = require('worker_threads');
if (isMainThread) {
// 通过 isMainThread 判断,是否是 worker 还是 mainthread
module.exports = async function parseJSAsync(script) {
return new Promise((resolve, reject) => {
// 通过 __dirname 引用自身文件创建 worker
const worker = new Worker(__filename, {
workerData: script
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0)
reject(new Error(`Worker stopped with exit code ${code}`));
});
});
};
} else {
// 执行高 CPU 计算
const { parse } = require('some-js-parsing-library');
const script = workerData;
parentPort.postMessage(parse(script));
}
初始化 worker
官方库提供了 Worker 类,用来进行 Worker 的初始化工作。基本格式为:
new Worker(filename[, options])
这里,可以通过两种方式来写一个 worker 内容,一种是文件、另外一种 eval 代码。
使用文件初始化 worker
现在你已经写好了 worker.js,文件路径为 /abs/to/worker.js
。那么,在 mainthread 就可以初始化一个 worker.js。
let worker = new Worker("/abs/to/worker.js")
使用 eval 初始化 worker
使用 eval 执行的话,需要设置一下 new Worker 的 eval 参数,将其手动设置为 true.
new Worker(code,{
eval:true
})
可以看一下实例代码:
// 设置好处
let code = `
let fib(8);
function fib(n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib (n - 2);
}`
// 使用 eval 代码执行
let worekr = new Worker(code,{
eval:true
})
有时候在进行初始化时,worker 其实还依赖于 mainthread 传入的一些常用变量。nodejs 提供了 workerData 来帮助 coder 完成这件事。
传递给 worker 的初始数据
workerData 的传递,只需要将对应的数据,塞给 new Worker 的初始化 workerData
参数。
new Worker(path,{
workerData:data
})
需要注意的是,workerData 遵循的是 HTML structured clone algorithm,传递给 worker 时,会 deep-clone 一份,防止 数据的循环引用和保证两个线程之间的数据独立性。也就是说,该 workerData 中的数据只能包含一些基础类型:
- 不能传函数,保证两个线程的独立性
- 可以传 Object, Array, Buffer 之类的
更多的,可以参考 https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
那么,在 worker 中,如何调用 workData 的具体数据呢?
在 worker.js 里面,通过 worker_threads 模块提供的 workerData 来获取。这么说有点抽象,用伪代码模拟下。
// mainthread.js
new Worker("worker.js",{
workerData:{
website:"villainhr.com"
}
})
// worker.js
const {
workerData
} = require('worker_threads');
// 直接通过 workerData 来获取
let {website} = workerData;
worker 的通信
Worker 的通信主要是 IPC 模式,和 webWorker 一样,也是通过 MessagePort 来互传消息。
Mainthread 向 threadWorker 发消息
主要利用 worker 实例上挂载的 postMessage 方法来实现。
let worker = new Worker("worker.js");
worker.postMessage("欢迎关注 零度的田 公众号")
Worker 上接受 mainthread 传递的消息,利用 worker_threads
模块提供的 parentPort
成员对象来。
const {
parentPort
} = require('worker_threads');
parentPort.on("message",msg=>{
console.log(msg); // 欢迎关注 零度的田 公众号
})
这里有个很重要的点需要注意下,如果你通过
parentPort
监听了message
事件,那么该 worker 是不会自动中断的,除非你手动terminate
掉它。
threadWorker 向 mainthread 发消息
那么返回来,在 worker 中,怎么给 mainThread 传递消息?还是需要利用 parentPort
对象上,挂载的 postMessage 方法。
// worker.js
const {
parentPort
} = require('worker_threads');
// 向 mainthread 传递信息
parentPort.postMessage("欢迎关注 零度的田 公众号")
worker_threads 最佳实践
在使用 worker 的过程中,通常是将高 cpu 的计算放在 worker 中运行。根据通信的模式,可以分为两种:
- 每次接收任务时,单独创建一个原始的 worker 任务,使用完毕后销毁
- 预先根据 cpu 核数,创建线程池,去执行所有任务
上面两种模式的选取主要是根据业务的模式,不过,一般情况下使用 线程池 会更高效些,因为,重复创建相同的 worker 的话,每次都需要经过一遍 js code 的解码、编译、执行的过程,还是有一定的性能损耗的。
所以,官方推荐是 能用线程池,就不要每次创建 worker。线程池的实现,主要在于 worker_pool 的算法,里面重要功能是需要实现 worker 的调度。
这里推荐一个 worker_pool repo node-worker-threads-pool,这个库在判断 worker 是否 空闲有个取巧的办法,就是当 worker 调用 parentPort.postMessage("xxx")
API ,返回结果时,就认为该 worker 已经处于空闲状态了。
为了防止这篇内容过于空洞、浮夸,为了证明 我真的不是在吹水。最近在做微信文档构建的时候,使用到 worker_pool 来进行优化。
vuepress 编译实践
前段时间在维护 微信开放文档, 发现每次统一编译需要时间在 110s ~ 200s 之间,差不多 2~3 min 中,有时候如果编译文件过多的话,可能达 5min 左右。后续,随着文件数量的增加,该编译时间可能会拖慢编译的整个流程。
现在的文档的编译是基于 webpack + vue.renderToString 来做的整体编译。webpack 是前端的一个打包工具库,里面的生态已经很成熟。vue.rednerToString 是用来进行 html 的 prerender 方法,作为静态文档的输出部分。
主要的编译过程主要就在上面两个部分当中,为了分析该过程,选择使用小程序开发部分的文件编译,来作为基准。该部分具有的特性为:
- 所有 md 文件有 1454, 量级比较大
在 node 版本 8.6,48 核的机器条件下进行编译,总体耗时为:157s
拆分看:
- webpack 的编译耗时为:57s,占比 36%
- vue.renderToString 的耗时为:100s,占比 64%
所以,这里的主要问题聚焦于,主要减少 vue.renderToString 的时间,尽量减少 webpack 的编译时间。
vue.renderToString 没有提供任何接口来进行性能优化和提升,只是单纯的作为一个模板拼接函数。所以,只能 node 线程入手,即,通过 node 多线程编程充分利用机器性能加快编译速率。
接下来就是 threads_worker 的重点内容了。
其中,vue.renderToString 有一个任务队列,主要是将所有的 pages,按照路径输出模板。通过 worker 的调度器来实现多线程的 renderToString 方案。
initWorker(){
// 初始化 workerPool 调度器
this.pool = new StaticPool({
size: workerCount,
task: this.workerPath,
workerData: this.workerData
})
}
// 执行 vue.renderToString 的任务队列
async renderPages(pages){
let jobs = pages.map(async page=>{
let {html,filePath} = await this.pool.exec(page)
await fs.outputFile(filePath,html)
})
await Promise.all(jobs)
this.pool.destroy()
}
经过多线程的优化,整体的编译时间有挺大的优化。
总体编译耗费时间
优化前:157s
优化后: 84s
优化比例为:46.156%
worker_threads vs cluster vs child_process
说道压榨 CPU 性能的点,nodejs 中,除了使用 worker_threads 之外,还有两个模块也能做到, 一个是 cluster
、一个是 child_process
。
cluster
cluster 是在一个 master process 中,通过 cluster.fork() 来实例化多个 node v8 实例。可以说 cluster 是多进程的模块,常常用来处理多进程的 node 服务,比如像 pm2。
它的使用方式比较重,每次都需要创建一个进程,并初始化自身的 node 实例,像 event-loop,每个进程都是独立的,所以单个进程发生失败,并不会影响到主进程的稳定性。
具体使用,可以参考 node 文档:https://nodejs.org/dist/latest-v10.x/docs/api/cluster.html#cluster_how_it_works
child_process
child_process 模块你可以只理解为,它就是一个进程吊起的模块。比如,常常用到的:
- fork
- exec
- spawn
它的执行并不仅仅只限于 nodejs,你用其他语言实现也可以,比如说 python, cpp 二进制文件等。而在 child_process 里面就不存在所谓的通信,父进程通过获得子进程的 stderr、stdout、stdio、stdin 来输出。它进程之间传输数据比较难用,没有所谓的 structure clone 的防止去传递一些对象数据之类的。
worker_threads
worker_threads 和上面两者其实都不同,它并没有脱离当前 v8 的进程实例,而是在其中,创建线程,而这些线程和进程类似,都有自己独立的 OS-level API,并且可以使用绝大多数 node 模块。较上面来说,worker_trheads 有以下优势:
- 单进程,多线程
- 线程间通信方便,通过 MessageChannel 模式,实现基于事件的跨 线程通信。
- 可以使用 SharedArrayBuffer,实现多个 worker 共用高效内存
- 使用简单,在一个 node v8 实例中,共用同一个 event-loop 队列。
打扰了 我就是收了你红包来点赞的
大佬大佬
此条2元,欢迎下次光临
举报里网赚的哈!
高,实在是高~