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

457 阅读6分钟

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

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

一、前情回顾

上一篇小作文动手改造 thread-loader 了,这个改造目前主要分为以下三方面:

  1. 改用 worker_threads 创建 Worker 线程的 woker;
  2. 将原有的自定义管道的通信方式改为 message 事件和 postMessage 方法的方式;
  3. 将原有的对 loader 运行结果的改造的部分代码移除,直接发送 loader 运行结果;

其中第二点在本篇小作文用一个个专门的篇幅讲述!

二、 通信方式及改造

关于通信方式估计是要费点笔墨的,为了让前面没有看过 thread-loader 源码系列的看官老爷们也能看得懂,我只能把这个通信方式再介绍一下,然后再写怎么改:

2.1 进程间自定义管道通信

自定义管道,听起来很高大上。还记得前面我们说 child_process.spawn 衍生子进程时传递的第三个参数 options.stdio 吗?这个东东就是自定义管道的核心了,自定义管道一共分这么几步:

2.1.1 开启自定义管道

使用 child_process.spawn 衍生子进程时传递 options.stdio 选项,该选项陈传一个数组,这个数组的前三项是系统管道,从索引为0的第一项按顺序依次为: stdin(标准输入)、stdout(标准输出)、stderr(标准错误);除这三个之外,再额外的传递的,也就是从索引为 3 (第四项)之后的值传递 'pipe' 时,就相当于开启了自定义管道;

示例代码如下:

this.worker = childProcess.spawn(
       process.execPath,
       [],
       {
         detached: true,
         // stdio 索引 3、4 即为自定义管道
         stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']
       }
    );
  }
}

2.1.2 父进程获取自定义管道并分工

创建子进程时开启了自定义管道,这两个管道的作用并不相同,而是各司其职的,其中一个负责读取,另一个负责写入。在 thread_loader 中,stdio[3] 管道负责从子进程读取,stdio[4] 负责向子进程写入。代码如下:

// this.worker.stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']
const [, , , readPipe, writePipe] = this.worker.stdio;
this.readPipe = readPipe;
this.writePipe = writePipe;

这里有必要说一下,所谓管道只是一种称呼,管道的本质是 Stream 对象,也就是大家所有的 流 。所谓读取管道则是 父进程 从流中读取数据,而 写入管道 就是父进程向流中写入数据。

  1. readPipe —— 负责读取的管道

分工完成以后,开始源源不断地从读取管道中读取数据,读取数据也就是子进程通过管道传递给父进程的数据,这就是进程通信的一段,子 到 父 通信过程,对照代码也就是 this.readNextMessage 方法的作用;

另外还要提一点 thread-loader 设计的这个数据格式也很有趣。因为写入管道中的数据都是二进制的数据,所以必须要要知道本次读取多少才能得到完整的数据,所以他设计了一种数据格式,它由两部分组成:第一部分是数据长度,第二部分才是真正的数据;

所以读取时要先读取一次长度,这个长度是个 4 字节的数字,也就是 32 位的整数。得到真实数据长度之后,在读取长度个字节得到真实数据:

readNextMessage() {
  // 读取长度
  this.readBuffer(4, (lengthReadError, lengthBuffer) => {
    
    // 得到长度后开始读取数据
    this.readBuffer(length, (messageError, messageBuffer) => {
         // 这里得到最终的数据
       
        // 下个事件循环接着读,实现源源不断的读取
        setImmediate(() => this.readNextMessage());
      });
    });

  });
}

// readBuffer 方法,从 readPipe 读取数据
readBuffer(length, callback) {
  readBuffer(this.readPipe, length, callback);
}
  1. writePipe —— 负责写入的管道

前面的 readPipe 完成了 子 到 父 的通信,那么 父 到 子 该怎么搞呢?也就是写入管道的工作了。写入的工作就比较简单了,就是向 writePipe 这个流中写入数据,数据格式和上面的读取格式一致:数据长度+数据 的格式写入,对照代码就是 writeJson 方法的实现:

writeJson(data) {
  // 创建长度 buffer,4个字节,能装下一个 32 位的整数
  const lengthBuffer = Buffer.alloc(4);
  
  // 计算数据长额
  const messageBuffer = Buffer.from(JSON.stringify(data, replacer), 'utf-8');
 
  lengthBuffer.writeInt32BE(messageBuffer.length, 0);
  
  // 先写入数据长度
  this.writePipe.write(lengthBuffer);
  
  // 紧接着写入数据本身
  this.writePipe.write(messageBuffer);
}

2.1.3 子进程获取自定义管道

虽然开启子进程的时候已经传递了 stdio[3] stdio[4] 这两个自定义管道,但是子进程如果要使用的话需要再次获取,而获取的方式则是——创建 指定文件描述 的流对象,而自定义管道需要的文件描述符也就是父进程在创建子进程时创建自定义管道的索引,也就是 stdio 的所以,所以这里对应的就是 3 和 4 这两个数字:

import fs from 'fs';
const writePipe = fs.createWriteStream(null, { fd: 3 });
const readPipe = fs.createReadStream(null, { fd: 4 });

这里还不算完,不知道你发现没有,有个有趣的事实:父进程的 stdio[3] 是 readPipe,而子进程的 fd: 3 却是个可写流,并且变量名字是 wripePipe,而父进程的 stdio[4] 是 writePipe,子进程的 fd:4 是 readPipe,为什么会这样?

这其实也不难理解,所谓管道就 Stream 对象,通信时双方的,所以肯定需要一端写入另一端读取,这也就解释了为什么父进程的 readPipe 对应的流是子进程的 writePipe,因为父进程读取的恰好是子进程写入的。对于父进程的 writePipe 来说亦是如此,父进程负责写入,子进程负责读取,所以父进程的 writePipe 到了子进程这里就是 readPipe 了;

2.2 改造成线程间通信方式

对于改造来说就很简单了,因为 worker_threads 创建的线程自带与父进程通信的 buff 的 —— postMessage 方法和 messsage 事件,改造分以下四个步骤步:

  1. 改造父进程读取 buffer 为事件监听线程的 message 事件:
readNextMessage() {

  this.worker.on('message', message => {
    // message 即为子进程发来的数据
    message = JSON.parse(message, reviver);
  })
}
  1. 改造父进程写入为 postMessage 方法:
writeJson(data) {
  this.worker.postMessage(JSON.stringify(data, replacer));
}
  1. 改造子进程读取为事件监听 parentPort 的 message 事件

如果此线程是 Worker,则 parentPort 对象是允许与父线程通信的 MessagePort

通过监听 parentPort 的 message 事件即可接收父进程发送来的数据:

function readNextMessage() {
  parentPort.on('message', (msg) => {
    msg = JSON.parse(msg, reviver);
  })
}
  1. 改造子进程写入为 parentPort.postMessage 方法 通过 parentPort.postMessage 即可把数据发送到父进程,也就对应第一步的 message 事件监听;
function writeJson(data) {
  parentPort.postMessage(JSON.stringify(data, replacer))
}

三、总结

本文详细讨论了 thread-loader 中如何实现的自定义管道完成进程间通信,另外还讨论了如何利用 parentPort 和 message 完成对通信方式的改造:

  1. 父进程读取 buffer 为事件监听线程的 message 事件;
  2. 父进程写入为 postMessage 方法;
  3. 子进程读取为事件监听 parentPort 的 message 事件;
  4. 子进程写入为 parentPort.postMessage 方法;