多进程打包:用 worker_threads 改写 thread-loader(1)

1,821 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的23天,点击查看活动详情

码字不易,感谢阅读,你的点赞让人振奋!
如文章有误请移步评论区告知,万分感谢!
未经授权不得转载!

一、前情回顾

上一篇小作文是 thread-loader 源码部分的最后一篇,现在回顾一下 thread-loader 的整个工作流程:

  1. thread-loader 的 pitch 方法拦截它后面的所有 loader;
  2. 创建 WorkerPool 实例 workerPool,它是个进程池子,用以调度进程;调度工作依赖使用 neo-async/queue.js 创建的 poolQueue 队列;
  3. poolQueue.push(data, callback);
  4. poolQueue.push 后会执行 poolQueue 的 worker 函数 —— distributeJob 创建子进程;
  5. distributeJob 创建子进程,通过自定义管道通信,利用 readPipe 接收子进程消息,利用 writePipe 向子进程发送消息,通信的数据载体是 JSON 格式字符串;
  6. 子进程接收来自父进程发送过来的消息运行 loader,碍于进程间通信限制,子进程自己构造了一个 loaderContext 对象,当用到父进程 loaderContext 中的方法时,构造的 loaderContext 对象会通过进程间通信委托父进程实现;
  7. 当子进程完成 runLoaders 工作后,在回调中利用管道向父进程发送结果;
  8. 父进程收到消息后,找到本次运行 loader 时对应的回调函数,在回调函数中把这些结果 —— 各种类型的依赖,添加到构建中;

我们之所以要这么细致的阅读这个 loader 的源码,是因为我的任务除了在框架层面接入 thread-loader 之外还需要尝试用多线程改写 thread-loader。

经过源码部分可知:

  1. thread-loader 虽然叫 thread loader,但实现确实名不副实的 child_process 即子进程;
  2. 系统开一个子进程的开销比新开一个线程(worker_threads)大的多;

基于上述内容,我们准备用多线程实现一个多线程打包。这个任务最终效果如何,没有人知道,总之是个探索性的任务!

给年轻人一个忠告:不建议认领这种尝试性的 KPI 工作,很可能最后没有产出,只有个行不通的结论!

二、worker_threads —— 工作线程

既然准备用要用多线程,先来认识一下我们后半场的主角 worker_threads

2.1 进程 vs 线程

  • 进程 进程是操作系统进行资源分配最小单位,同一时间内,同时执行的进程不会超过 CPU 的核心数(这个就是大家电脑上的4核、6核、12核)。可以粗陋的理解一个程序运行运行就是一个进程,每个进程都拥有单独的硬件资源,之所以这么设计,是为了满足切换程序时可以快速的回到刚刚的状态。

比如你的电脑上聊天(微X)同时看视频(爱P艺),这就是两个程序,是两个进程。

  • 线程

线程隶属于某个进程,线程是程序执行过程的最小单元,他们共享进程的硬件资源。一个程序内包含多种任务,还是拿视频播放为例,视频播放时有图像、有音频两种任务,为了保证音画同步,就需要他俩同时开工。否则,当要拖动进度条时就会出现音画不同步的诡异场景。

  • 主要区别:

线程和进程的最主要区别:进程间天然隔离,他们的分配到的硬件资源是隔离的,而线程间是贡献它所属进程的资源;

2.2 Node.js 中的 worker_threads 模块

worker_threads 模块用于创建并执行 JavaScript 的线程,用法:

const worker = require('worker_threads');

2.2.1 举个例子

  • wt.js
const {
  Worker,
  isMainThread,
  parentPort,
  workerData
} = require('worker_threads');

if (isMainThread) {
  module.exports = function parseJSAsync (script) {
    return new Promise((resolve, reject) => {
      console.log(__filename);
      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 {
  const { parse } = require('./sub');
  const script = workerData;
  parentPort.postMessage(parse(script))
}
  • ./sub.js
console.log('hahahhahahahah');

exports.parse = (s) => {
  console.log('sub parse')
  console.log(s);
  return 'sub - parse'
}

2.2.2 worker_threads 的一些属性作用

  1. Worker, 主线程用于创建 worker 线程的对象类型,包含 MessagePort 操作以及一些特有的子线程元数据( meta data)

  2. isMainThread, false 标识当前代码作为子线程运行,true 则表示主线程

  3. parentPort, 在 worker 线程里表示父进程的 MessagePort 类型的对象,在主线里为 null,这个 parentPort 是用于父子通信的;

  4. workerData, 在 worker 线程里是父进程创建 worker 线程实例时初始化的数据,在主线程中为 undefined;

  5. threadId, 当前 worker 线程的线程 ID,在父进程里是 0;

  6. MessageChannel, 包含一对儿能够互相跨线程通信的 MessagePort 类型的对象,可用于创建自定义的通信频道,用于线程间通信;

  7. MessagePort, 用于线程间通信的句柄,继承自 EventEmitter 类,有 close 等事件;

三、改写 thread-loader 需要 worker_threads 哪些能力?

在改写之前我们首先要斟酌哪些能力是必须具备的,这些就是需要前期调研的工作,如果这些必要能力都具备在开工,否则容易干到一半儿才发现有致命缺陷导致这条路走不通,这就是磨刀不费砍柴工!关于对 woker_threads 的要求能力如下:

3.1 创建子线程执行 js 模块

对应 thread-loader 中调用 child_process.spawn 启用新的进程运行 js 模块,这就要求 worker_threads 也必须可以创建线程并且无限制能力的执行 js 模块;

通过上面的例子可以发现,new worker_threads.Worker() 对应了这一能力!

const { Worker } = require('worker_threads');
new Worker('dist/worker.js');

3.2 通信

这方面的要求主要来自两方面:

  1. 对应 thread-loader 运行于子进程的 loader 在执行结束之后需要把 loader 的结果给到主进程,以便继续 webpack 的打包工作流;

  2. 运行在子进程的 loader 并没有获取原来的 loaderContext 的能力,子进程有些工作需要委托给主进程完成,待完成后再将结果发送给子进程;

thread-loader 使用 child_process 时的解决方案为自定义管道完成 IPC(进程间通信),stdio 的第四个、第五个 'pipe' 便是文件描述符为 3、4 的自定义管道,一个用于发送,一个用于读取,如下:

improt child_process from 'child_process';

const worker = child_process.spawn('node', 'dist/worker.js', {
  stdio: ['pipe', 'pipe', 'pipe', 'pipe', 'pipe']
});

// 自定义管道
cosnt [,,, readPipe, writePipe] = worker.stdio;

这期间并不需要子线程与子线程间通信,只需要子线程与主线程进行通信,worker_threads 对应的解决方案为:worker.postMessage & parentPort.postMessage;

在子线程中访问 parentPort,通过他把内容发送给主线程。在主线程中通过 worker.postMessage 即可把内容发送给子线程;

四、总结

本篇小作文开始上手鼓捣 thread-loader,准备把由 child_process 实现的 thread-loader 变成 worker_threads 的 thread-loader,这才是名副其实的多线程(multi-threads),主要盘点了以下内容:

  1. 回顾 thread-loader 的工作原理;
  2. 简单介绍进程、线程区别和联系;
  3. 简单了解 worker_threads 模块的能力;
  4. 分析了 worker_threads 在 js 模块执行和通信方面满足 thread-loader 的要求;

从下一篇开始,我们手把手的改写这代码,来吧加入我们!