vux 编译越来越慢_node 线程池技术让文档编译起飞

最近在维护微信文档这块内容,遇到一个问题,文档数量多起来编译时间会变慢,而且有时候会越来越慢。后面,发现文档的编译一直走的是单线程的,只用到了一个核,顿时感觉有套路可以走了。node 在 v10 过后提出了 worker_threads 模块,它是在一个单独的 node v8 实例进程里面,可以创建多个线程来搞 CPU 任务。

tl;dr

下文主要阐述了一下几点:

  • worker_threads 的基本使用和了解

  • 使用线程池模式,来提高 node 进程的计算速度

  • 用 worker_threads 模块,来优化 vuepress 编译速度

  • workerthreads 模块和 cluster、childprocess 之间的用法和区别

worker_threads 简介

Nodejs 核心执行是基于单线程 + eventloop ,底层是基于 libuv 库,在每次循环中,执行一次完整的 eventloop。所以为了实现 threadworker 的方式,只有脱离于 node 单线程,单独提供 worker_threads 模块来实现。当然,nodejs 还有其他方式实现高性能并发,比如 cluster 和 childprocess,不过,这两者在使用和场景上,与 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 通过 newWorker 去实例化子线程,然后通过 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/WebWorkersAPI/Structuredclonealgorithm

那么,在 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 方法,作为静态文档的输出部分。

主要的编译过程主要就在上面两个部分当中,为了分析该过程,选择使用小程序开发部分的文件编译,来作为基准。该部分具有的特性为:

  1. 所有 md 文件有 1454, 量级比较大

b87b36be64b75bc7736511de06b1b08f.png

在 node 版本 8.6,48 核的机器条件下进行编译,总体耗时为:157s

3c376f4c757634b102804b09e27a564e.png

拆分看:

  1. webpack 的编译耗时为:57s,占比 36%

  2. 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%

workerthreads vs cluster vs childprocess

说道压榨 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#clusterhowit_works

child_process

child_process 模块你可以只理解为,它就是一个进程调起的模块。比如,常常用到的:

  • fork

  • exec

  • spawn

它的执行并不仅仅只限于 nodejs,你用其他语言实现也可以,比如说 python, cpp 二进制文件等。而在 child_process 里面就不存在所谓的通信,父进程通过获得子进程的 stderr、stdout、stdio、stdin 来输出。它进程之间传输数据比较难用,没有所谓的 structure clone 的方式去传递一些对象数据之类的。

worker_threads

workerthreads 和上面两者其实都不同,它并没有脱离当前 v8 的进程实例,而是在其中,创建线程,而这些线程和进程类似,都有自己独立的 OS-level API,并且可以使用绝大多数 node 模块。较上面来说,workertrheads 有以下优势:

  • 单进程,多线程

  • 线程间通信方便,通过 MessageChannel 模式,实现基于事件的跨 线程通信。

  • 可以使用 SharedArrayBuffer,实现多个 worker 共用高效内存

  • 使用简单,在一个 node v8 实例中,共用同一个 event-loop 队列。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值