评论

Node 多核编译实践

最近在维护微信文档这块内容,遇到一个问题,文档数量多起来编译时间会变慢,而且有时候会越来越慢。后面,发现文档的编译一直走的是单线程的,只用到了一个核,顿时感觉有套路可以走了

最近在维护微信文档这块内容,遇到一个问题,文档数量多起来编译时间会变慢,而且有时候会越来越慢。后面,发现文档的编译一直走的是单线程的,只用到了一个核,顿时感觉有套路可以走了。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 方法,作为静态文档的输出部分。

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

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

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

拆分看:

  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%

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 队列。
最后一次编辑于  08-30  
点赞 7
收藏
评论

4 个评论

  • 仙森ღ₅₂₀¹³¹⁴
    仙森ღ₅₂₀¹³¹⁴
    08-30

    打扰了 我就是收了你红包来点赞的

    08-30
    赞同 1
    回复
  • var 友原
    var 友原
    08-30

    大佬大佬

    08-30
    赞同
    回复
  • 卢霄霄
    卢霄霄
    08-30

    此条2元,欢迎下次光临

    08-30
    赞同
    回复 2
  • aholy.cium
    aholy.cium
    08-30

    高,实在是高~

    08-30
    赞同
    回复