多进程打包:thread-loader 源码(10)

1,000 阅读6分钟

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

码字不易,喜欢点赞,不喜请移步评论区告知,未经授权不得转载

一、前情回顾

上一篇小作文梳理了一遍从开始到 PoolWorker.prototype.run 方法的调用链路,然后分析得到 PoolWorker 实例后调用实例的 run 方法向 writePipe 写入数据的过程;

把数据写到 writePipe 标志着把任务数据发送给子进程。此时主进程中前半部分已经结束,这其中包含初始化管控 worker 进程的 WorkerPool 类实例、负责具体处理通信和创建子进程的 PoolWorker 类,这期间还穿插了 neo-async 和进程间通信的知识。

从本篇开始我们正式进入子进程的相关细节部分,如果前面的部分还有疑问,不推荐继续向后看,这回让你感到困惑和痛苦,请详细阅读前面九篇的基础(反正我不觉得基础,甚至有点高能)内容;

二、回顾创建子进程

创建子进程的逻辑是在 PoolWoker 类的构造函数中进行,所谓创建子进程就是调用 childProcess.spawn 创建一个执行 thread-loader/dist/woker.js 文件的子进程:

class PoolWorker {
  constructor(options, onJobDone) {
       
    const sanitizedNodeArgs = (options.nodeArgs || []).filter((opt) => !!opt);
    
    // 创建子进程
    this.worker = childProcess.spawn(
      process.execPath, // node.js 在系统中的可执行文件的路径
      [].concat(sanitizedNodeArgs).concat(
          workerPath, // thread-loader 中的 worker.js 路径
          options.parallelJobs // 传递给 worker.js 的进程参数 process.argv
      ),
      {
        detached: true, // 子进程独立运行需要此配置项
        stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], // 进程间通信的管道
      }
    );
  }
}

上面的示例代码说这么多其实等效于调用下面的命令:

# process.execPath 就是 node 命令
# workerPath 就是 thread-loader/dist/worker.js
# options.parallelJobs 就是 20,子进程并发任务数

$ node thread-loader/dist/worker.js 20

三、woker.js 文件代码结构

  • 文件位置:thread-loader/src/worker.js

这个 worker.js 是上面子进程运行的文件,loader 在这个文件中被调用。这里要说一下,thread-loaderwebpack 引用的是被打包后输出到 dist 目录下的文件,这些文件被编译成 CommonJS 模块,这写文件不利于阅读,其源码都在 src 目录下,源码是遵循 ESModule 规范的模块和代码风格。

按照我们过往的经验先简化这些代码,下面的代码只保留函数名,函数体中的代码被省略了,这可以快速从宏观上先看看这个 js 文件都做了哪些工作、入口是哪里。

// import modules ignored ...

// 创建 fd:3/4 的流,他们作为进程间通信的基础
// fd:3 的可写流,子进程写入,父进程读取
const writePipe = fs.createWriteStream(null, { fd: 3 });
// fd: 4 的可读流,父进程写入,子进程读取
const readPipe = fs.createReadStream(null, { fd: 4 });

// 监听结束、关闭、错误事件,
writePipe.on('finish', onTerminateWrite);
writePipe.on('close', onTerminateWrite);

readPipe.on('end', onTerminateRead);
readPipe.on('close', onTerminateRead);

readPipe.on('error', onError);
writePipe.on('error', onError);

// 从进程参数获取并发任务数,默认 20
const PARALLEL_JOBS = +process.argv[2] || 20;

// 标识符,进程是否终止
let terminated = false;

// 问询 id,自增的
let nextQuestionId = 0;

// 回调函数对象,key 是对应的 nextQuestionId
const callbackMap = Object.create(null);

// 声明一堆方法
function onError(error) {}

function onTerminateRead() {}

function onTerminateWrite() {}

function writePipeWrite(...args) {}

function writePipeCork() {}

function writePipeUncork() {}

function terminateRead() {}

function terminateWrite() {}

function terminate() {}

function toErrorObj(err) {}

function toNativeError(obj) {}

function writeJson(data) {}

const queue = asyncQueue(({ id, data }, taskCallback) => {}, PARALLEL_JOBS);

function dispose() {}

function onMessage(message) {}

// 入口方法
function readNextMessage() {}

// 调用入口方法,开始从主进程读取消息(父进程写入到管道中的数据)
readNextMessage();

3.1 进程通信之子进程部分

结合父进程执行 childProcess.spawn(... { stdio: [, , , , 3, 4] }) 创建子进程时指定的文件描述符 3/4 作为父子进程间通信的自定义管道,子进程需要创建指定文件描述为 3的可写流(父进程的 eadPipe)文件描述为 4 的可读流(父进程的 writePipe)。

结合下面的代码你会发现一个有趣的事情,子进程的 writePipe 是父进程的 readPipe,而子进程的 readPipe 是父进程的 writePipe

// 父进程从子进程的 stdio 获取通信管道
// 数组索引位置对应文件描述符 fd
const [, , , readPipe, writePipe] = this.worker.stdio

这是一种 Node.js 处理进程间通信的方式,子进程向 fd3 的流写入,父进程读取,父进程向 fd4 的流写入,子进程读取,这就能实现父子进程间的通信。

这类似某种接头方式:双方前往约定地点(指定fd),组织的人(父进程)发出(写入)暗号(数据),接头(子进程)人接收(读取)暗号(数据)。接头人(子进程)报出(写入)另一段暗号(数据),组织的人(父进程)核对(读取)这段暗号(数据)。。。。

3.2 监听事件

writePipe/readPipe 的相关结束、关闭、错误事件,事件 handler 是 onTerminatWrite/onError 方法;

3.3 获取并发数

从子进程的进程参数获取并发数量:process.argv 就是进程参数,如果你不记得了,去前文看看吧。这个数字也是在 childProcess.spawn('/usr/bin/node', ['dist/worker.js', '20']) 调用中传递的,20 就是并发数了;

3.4 声明标识符terminated

声明标识符 terminated 标识符,标识进程是否终止;

3.5 声明 nextQuestionId

声明 nextQuestionId 这个问询 id,后面用一次就会累加 1。这个 id 是子进程有些工作需要交给父进程查询时要用用到,主要是调用一些 loaderContext 或者 webpack api 时候要用。你是不是很好奇为啥子进程不自己调用?这是因为进程间通信传递的是序列化后的 JSON 数据,即便是正则都会丢失,所以方法不能全部传过来,所以只能穿数据,方法还是留给父进程,子进程如果需要的时候让父进程调用,父进程得到结果后再通过管道发送给子进程;

3.6 声明 callbackMap 常量

存储回调函数,key 就是上面的 nextQuestionId

3.7 声明一堆方法

接着就是声明一堆方法,包含入口方法 readNextMessage(呵!这文章写的真简(偷)洁(懒),这些方法现在说也没用,用到一个讲一个,然后再梳理调用栈)

3.8 调用入口方法

入口方法 readNextMessage 被调用,开启子进程打工人的职业生涯。

四、总结

本篇小作文接前文的主进程后续,开始了子进程 worker.js 的代码详解,主要讨论了以下内容:

  1. 父进程创建子进程的过程分析,包含:
    • 1.1 解析 node 路径;
    • 1.2 分析 thread-loader 下的 worker.js 路径;
    • 1.3 传递并发数配置;
    • 1.3 设置 stdio:[, , , pipe, pipe] 自定义管道的方式处理进程间通信;
  2. 分析了 worker.js 的文件结构,期间分析了自定义管道在子进程中的创建 fd3/4 的流负责通信,得出 readNextMessage 这个入口方法的声明和调用;

下一篇我们真是进入到 readNextMessage 方法的细节,前方高能,请好好阅读已有的文章啊!

感谢阅读,你的点赞是我更文最大的动力!