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

673 阅读7分钟

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

一、前情回顾

上一篇小作文接着 workerPool.run(data, cb) 内部通过调用创建 poolQueue 传递的 worker 方法即 this.distributeJob 方法处理 data 的位置继续讨论了:

  1. this.distributeJob 方法寻找最合适的 worker 或者创建 worker 处理 data,创建 worker 就来到了 PoolWorker 构造函数;

  2. PoolWorker 构造函数主要作用是通过 child_process.spawn 创建子进程运行 thread-loader/dist/worker.js 这个文件处理 data,将 spawn 返回的子进程对象挂载到 PoolWorker 实例的 worker 属性上,即 this.worker

  3. 在创建子进程的时候通过传递给 spawnoptions.stdio 自定义管道实现进程间通信,这里使用的自定义管道是 Stream 对象。这些自定义的管道可以通过 子进程对象.stdio[索引] 的方式访问到,即 this.worker.stdio[索引],这里的索引 0,1,2分别是 标准输入、标准输出、标准错误3及以后的是自定义管道。

本文接上文 PoolWorker 构造函数将自定义管道挂载到 PoolWorker 实例——this.readPipe/wirtePipe 挂载完成后的内容。

二、PoolWorker.prototype.listenStdOutAndErrFromWorker

  1. 方法位置:thread-loader/src/WorkerPool.js -> PoolWorker.prototype.listenStdOutAndErrFromWorker

  2. 方法参数:

    • 2.1 workerStdout:子进程的 stdout
    • 2.2 workerStderr:子进程的 stderr
  3. 方法作用:监听子进程的 stdout/stderrdata 事件,把收到的输入输出到父进程的标准输出、标准错误

class PoolWorker {
  constructor(options, onJobDone) {
    // 监听子进程的 stdout 和 stderr 的 data 事件
    // 把 stdout 和 stderr 输出的内容输出到父进程的 shell
    this.listenStdOutAndErrFromWorker(this.worker.stdout, this.worker.stderr);
  }
}


listenStdOutAndErrFromWorker(workerStdout, workerStderr) {
  if (workerStdout) {
    workerStdout.on('data', this.writeToStdout);
  }

  if (workerStderr) {
    workerStderr.on('data', this.writeToStderr);
  }
}

// 写入父进程的 stdout
writeToStdout(data) {
  if (!this.disposed) {
    process.stdout.write(data);
  }
}

// 写入父进程的 stderr
writeToStderr(data) {
  if (!this.disposed) {
    process.stderr.write(data);
  }
}

三、PoolWorker.prototype.readNextMessage

在执行 PoolWorker 的构造函数中被调用;

  1. 方法位置:thread-loader/src/WorkerPool.js -> PoolWorker.prototype.readNextMessage

  2. 方法参数:暂无

  3. 方法作用:

    • 3.1 更新 this.state 值为 'read length' 表示开始读取本次 message 的长度,这个是 buffer 长度;
    • 3.2 调用 this.readBuffer 方法先读取长度,传入 4,标识 4 个字节,因为存储 message 长度的是一个32位整数,从其回调的 readIn32BE 方法调用也可佐证这一点。
class PoolWorker {
  constructor(options, onJobDone) {
    // 读取消息,消息哪里来的呢?
    // 很显然是子进程抛过来的,至于抛出消息这是后话了
    this.readNextMessage();
  }
  
  // 读取消息
  readNextMessage() {
    this.state = 'read length';
    
    // 读取长度
    this.readBuffer(4, (lengthReadError, lengthBuffer) => {
       // ... 读取长度后的操作
    });
  });   
 }
}

3.1 PoolWorker.prototype.readBuffer

  1. 方法位置:thread-loader/src/WorkerPool.js -> PoolWorker.prototype.readBuffer

  2. 方法参数:

    • 2.1 length: 要读取的 buffer 长度
    • 2.2 callback: 取出完成后要执行的回调函数
  3. 方法作用:科里化 readBuffer 方法,绑定可读流对象为 this.readPipe,从前文可知,这个 readPipe 是父子进程通信的管道。事实上,readPipe 是一个可读流对象,由 thread-loader 下的 worker.js 文件在子进程中运行时创建。

class PoolWorker {
  readBuffer(length, callback) {
    // 科里化 readBuffer 绑定 readPipe 
    readBuffer(this.readPipe, length, callback);
  }
}

3.2 readBuffer 方法

  1. 方法位置:thread-loader/src/readBuffer.js -> function readBuffer

  2. 方法参数:

    • 2.1 pipe:数据来源管道对象,在 thread-loader 中就是用于父子进程通信的 this.readPipe 这个管道
    • 2.2 length:本次要读取的长度,是 buffer 长度;你有没有想过,我怎么知道要读取多长?每次放回的结果长度肯定不一致的?记好这个问题,后面会有答案的;
    • 2.3 callback:读取成功后要执行的回调含糊
  3. 方法作用:下面的代码时经过简化过得,结构很清晰

    • 3.1 判断如果 length0,则直接分配一个 0 字节的 buffer,然后调用 callback,因为不需要读取;
    • 3.2 调用 readChunk 私有方法,这个方法内部实现就是给 pipe 绑定 data 事件,给可读流绑定 data 事件会触发缓存中的数据转移出来,这样就能接收到缓存区的数据了,把接收到的数据交给 onChunk 回调处理
export default function readBuffer(pipe, length, callback) {
  if (length === 0) {
    callback(null, Buffer.alloc(0));
    return;
  }

  let remainingLength = length;

  const buffers = [];

  const readChunk = () => {
    const onChunk = () => { /* 具体读取 buffer 数据的逻辑 */ };
    pipe.on('data', onChunk);
    pipe.resume();
  };
  
  // 调用 readChunk
  readChunk();
}

3.3 onChunk 回调

  1. 方法位置:thread-loader/src/readBuffer.js → function readBuffer → readChunk → onChunk

  2. 方法参数:arg,从可读流对象得到的数据块

  3. 方法作用:从 pipe 缓存的 buffer 读取指定长度的内容,如果超出长度把超过的内容退回 pipe 的缓存区;详细如下:

    • 3.1 设置 overflow,用以承载超过 readBuffer 指定的 length 长度的数据,以备后面退回缓冲区
    • 3.2 判断 chunk.length > reamainingLengthremainingLmengthreadBuffer 接收到参数 lenght,表示要读取多长的 buffer。如果 chunk.lenght 大于 remaingLength,说明超了,这个时候就直接读取 remaingLength 长度,剩下的复制到到 overflow。否则说明读取的不超过 remainingLenght,读取然后扣除已经读取的长度,重新计算剩余可读取长度。
    • 3.3 把本次读取的 buffer 和之前的读取的 buffer 拼接;之所以要和之前的拼接,是因为读取 buffer 这个事儿可能无法一次性读取够,每次 onChunk 触发都不再有之前的数据,所以要自己把前面已经读取过的保存好。
    • 3.4 当 remainingLength0,说明已经读取够了 readBuffer 指定的 lenght 长度,此时
      • 3.4.1 移除 pipedata 监听器,这么做可以让 pipe 暂停转移缓冲区的数据出来;
      • 3.4.2 调用 pipe.pause() 也是暂缓转移缓冲区数据;
      • 3.4.3 如果有 overflow 说明有超过 length 的数据,调用 pipe.unshift 退回 pipe 的缓冲区;
      • 3.4.4 调用 readBuffercallback 并传入长度为 lengthbuffer 数据;
// readBuffer 接收到指定长度,
// 表示还可以再读取多少长度的 buffer
let reaminingLength = length; 
const buffers = [];
const readChunk = () => {
  // ...
  
  // 接收 pipe 缓冲的 buffer
  const onChunk = (arg) => {
    let chunk = arg;
    
    // 设置 是否超出标识符
    let overflow;
    
    // chunk.length > reaminingLnegth 说明超过指定长度了
    if (chunk.length > remainingLength) {
      // 把超超出长度的 buffer 复制出来
      overflow = chunk.slice(remainingLength);
      
      // 复制 remaingingLength 长度的的 buffer
      chunk = chunk.slice(0, remainingLength);
      
      // 上面两步已经复制 reaminingLength,说明第一次就读取够 readBuffer 指定的 length 长度
      remainingLength = 0;
    } else {
      // 说明不够 remainingLength
      remainingLength -= chunk.length;
    }

    buffers.push(chunk);

    if (remainingLength === 0) {
      pipe.removeListener('data', onChunk);
      pipe.pause();

      if (overflow) {
        pipe.unshift(overflow);
      }

      callback(null, Buffer.concat(buffers, length));
    }
  };

  pipe.on('data', onChunk);
  pipe.resume();
};

3.4 this.readNextMessage 读取长度后的回调

前面我们分析了 readBuffer 的工作,它会从 readPipe 中读取指定长度的 buffer 数据,然后调用回调;现在我们看看在 this.readNextMessage 中调用 this.readBuffer 后的回调中的工作;

  1. 更新 this.state 为 长度读取成功 —— lenght read
  2. lengthBuffer.readInt32BE 从指定的 offset 处的 bufer 读取有符号的大端序 32 位整数,说人话就是子进程写入管道的数据总长度,告诉后面处理消息的方法要读取多少长度就能取到本次需要的数据;
  3. 更新状态为 read message,表示已经知道子进程写入的数据(message 所谓消息,是为了父子通信这个场景更应景儿的叫法,就是子进程跑完 loader 以后的数据)
  4. 再次调用 this.readBuffer 读取数据,并且传入上一次 readBuffer 得到的长度;最后在本次 readBuffer 回调中处理消息编码、转成 JSON,最后触发 this.onWorkerMessage 方法处理消息;
class PoolWorker {  
  // 读取消息
  readNextMessage() {
    this.state = 'read length';
    
    // 读取长度
    this.readBuffer(4, (lengthReadError, lengthBuffer) => {
      // 这个回调就是 readBuffer 后要执行的,以忽略错误处理
      
      // 更新 this.state 为 length 读取完成
      this.state = 'length read';
      
      // 获取 length
      const length = lengthBuffer.readInt32BE(0);

      this.state = 'read message';
      this.readBuffer(length, (messageError, messageBuffer) => {
         // 处理读取数据的回调
         // 后文专门开篇讨论
      });
  });   
 }
}

四、总结

本篇小作文详细讨论了 Pool.prototype.readNextMessage 的逻辑,它的核心就是先从进程通信管道 readPipe 中通过 this.readBuffer 先读取数据长度,读取到长度之后再调用 this.readBuffer 读取数据,最后交给处理数据的方法;

此外,我们还详细介绍了 readBuffer 方法的工作原理:监听 readPipedata 事件,读取指定长度的 buffer;在进程通信中,通信的消息由两部分构成:数据长度 + 数据;这个设计很 nice,数据长度是个 32 位正数,已知 4 个字节长度。

所以每次先读取 4 个字节,得到后面的数据长度,这样就解决了每次读取数据不一样长的。不一样长好办啊,子进程会告诉我们数据长度,我们只需要获取一下这个长度就可以了。