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

709 阅读4分钟

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

一、前情回顾

上一篇小作文讨论了 workerPool.poolQueueworker —— WorkerPool.distributeJob 方法的详细逻辑:

  1. 找到最合适的 worker(子进程),标准是活跃任务数最少的;
  2. 如果没有超过 this.numberOfWorkers 的限制就新建 workerworkerPoolWorker 的实例,可以理解成子进程;

本篇我们详细讲解 PoolWorker 这个类是如何创建子进程的以及父子进程间的通信方式。平时不敢在公司吐槽(就属我吐槽声音最大)最近真的是越来越卷了,恨不得所有人都去搞 V8 优化了,关键是人家那头没啥业务啊,我们这边业务多的要命了,再这么卷下去,摸鱼专家恐怕要被卷成寿司专家(也许是润专家)了。。。。

二、PoolWorker 类

  1. 类的位置:thread-loader/src/WorkerPool.js -> class PoolWorker

  2. 构造函数参数:

    • 2.1 options:初始化 PoolWorker 所需配置项,上文 WorkerPool.prototype.createWorker 时传递的进程参数、最大并发任务数就是了。
    • 2.2 onJobDonePoolWorker 实例完成任务时需要触发的 onJobDone 回调(汇报工作用的)
  3. 构造函数具体工作:

    • 3.1 初始化实例的 disposed/nextJobId/jobs/id 等属性,并将 onJobDone 挂载到实例上;其中 id 表示的是 PoolWorker 这个 workerid
    • 3.2 对接收到的 nodeArgs 进行脱敏处理,防止不合法的参数导致子进程崩溃
    • 3.3 通过 child_process.spawn 衍生子进程,并将子进程对象挂载到 PoolWorker.worker 属性;
    • 3.4 通过 unref 使得子进程不再阻塞父进程的退出,其目的也是让子进程可以脱离父进程独立运行;
    • 3.5 做防止命中操作系统内核打开文件数目达到限制的处理;
    • 3.6 获取子进程对象的除 标注输入、标准输出、标准错误 外的另两个管道,这两个额外的管道就是父子进程间通信的关键,将管道也挂载到 PoolWorker 实例的 readPipe/writePipe 上;
    • 3.7 调用 this.listenStdOutAndErrFromWorker 方法监听子进程的标准输出和标准错误;
    • 3.8 调用 thie.readNextMessge 开始读取子进程写入管道的数据;
class PoolWorker {
  constructor(options, onJobDone) {
    this.disposed = false;
    this.nextJobId = 0;
    this.jobs = Object.create(null);
    this.activeJobs = 0;
    this.onJobDone = onJobDone; // 完成任务时要调用的方法
    this.id = workerId; // worker 自己的 id

    workerId += 1;
    // Empty or invalid node args would break the child process
    const sanitizedNodeArgs = (options.nodeArgs || []).filter((opt) => !!opt);

    this.worker = childProcess.spawn(
      process.execPath,
      [].concat(sanitizedNodeArgs).concat(workerPath, options.parallelJobs),
      {
        detached: true,
        stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
      }
    );

    // 此方法使 IPC 通道不会保持进程的事件循环运行,并且即使在通道打开时也让它完成。
    this.worker.unref();

   
    // 防止当内核命中打开文件限制时 worker.stdios 为 undefined 的情况发生
    if (!this.worker.stdio) {
      throw new Error(
        `Failed to create the worker pool with workerId: ${workerId} and ${''}configuration: ${JSON.stringify(
          options
        )}. Please verify if you hit the OS open files limit.`
      );
    }

    // 获取 readPipe, writePipe 这两个通道并挂载到实例
    const [, , , readPipe, writePipe] = this.worker.stdio;
    this.readPipe = readPipe;
    this.writePipe = writePipe;
    
    // 监听子进程的标准输出、错误
    this.listenStdOutAndErrFromWorker(this.worker.stdout, this.worker.stderr);
    
    // 开始读取消息
    this.readNextMessage();
  }
}

2.1 childProcess.spawn

childProcess 就是 node.js 的原生模块 child_process 模块,child_process.spawn 用于创建子进程,其用法如下:

import childProcess from 'child_process';

childProcess.spawn(command[, args][, options])`

2.2 childProcess.spawn 的参数

  1. command: 要运行的命令;
  2. [, args]: 传递给上面的 command 的字符串参数列表;
  3. options: 配置子进程的选项对象;

了解了 childProcess.spawn 参数意义,我们接着看看 PoolWorker 创建子进程时传递的参数:

class PoolWorker {
  constructor(options, onJobDone) {
  
    const sanitizedNodeArgs = (options.nodeArgs || []).filter((opt) => !!opt);

    this.worker = childProcess.spawn(
      process.execPath, 
      [].concat(sanitizedNodeArgs).concat(workerPath, options.parallelJobs),
      {
        detached: true,
        stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
      }
    );

  }
}

从上面的代码可以看出,PoolWorker 类创建子进程的时候传递了三个参数:

  1. process.execPatch,这个参数对应的 command 形参;process.execPath 属性对应的是启动系统 Node.js 进程的可执行文件的绝对路径,如果是符号链接(软链)会被解析,在本例子中是 process.execPath: "/usr/local/bin/node" 。啥意思呢?说人话就是找到全局安装的 node.js 安装的 node 命令,每个命令在安装的时候都对应了一个可执行文件(bin),我们平时在任意位置可以执行 node ./some.js ,是因为 node 是全局安装的,系统会自动去找到它对应的可执行文件。啥是可执行文件(rwx 权限中,x 对应可执行);所以这就能看出,传递给 spawn 的参数就是 node 这个命令;上 Node.js 官方文档

  2. [].concat(sanitizedNodeArgs).concat(workerPath, options.parallelJobs);这个第二个参数对应上面的 [, args],这个参数都是传递给前面的 command 的参数。数组 concat 最后肯定是拼接成一个数组;我们看看里面都是啥?

    • 2.1 sanitizedNodeArgs: options.nodeArgs 是创建 thread-loader 时传递的,在本例中没传所以就是空数组 []
    • 2.2 workerPath: "/Users/xx/Documents/some-dir/node_modules/thread-loader/dist/worker.js" 你会发现这个对应了一个 js 文件,这个 js 文件就是要在子进程中执行处理 loader 具体逻辑的 worker 了;
    • 2.3 options.parallelJobs:这个同样是配置的,在我们的例子中没有额外配置,所以是默认值 20; 所以最后的结果:

[ "/Users/xx/Documents/some-dir/node_modules/thread-loader/dist/worker.js" , 20]

image.png

  1. { detached: true, stdio: [....] },这个参数对应了 spawn 的参数 options 选项对象,在本例中只传入了很多参数,它的参数还有很多,暂时只讨论这两个:
    • 3.1 detached: 配置这个参数为 true,可以让子进程在父进程退出后继续运行,当然这里我说的比较简单,具体行为还和系统平台有关。当然,仅仅配置这个还不够,还需要调用子进程的 unref() 方法,即上文的 this.worker.unref();还需要在 spawn 配置 sdioignore
    • 3.2 stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']options.stdio 选项用于配置在父进程和子进程之间建立的管道,这些管道用于通信。这里传递了一个数组,每个索引位置的值对应不同类型;
      • 3.2.1 第一个是标准输入
      • 3.2.2 第二个是标准输出
      • 3.2.3 第三个是标准错误
      • 3.2.4 除了前三个以后的就是自定义的管道了,在 thread-loader 中就是靠后面两个自定义管道进行进程间通信

总结起来,childProcess.spawn 衍生子进程就是调用 node 命令执行 thread-loader 下的一个 worker.js 文件:

$ node /Users/xx/Documents/some-dir/node_modules/thread-loader/dist/worker.js 
# node 命令等效于 process.execPath 对应的 /usr/local/bin/node 

2.3 获取与子进程通信的管道

讨论过上面的 childProcess.spawn 创建子进程时传的 options.stdio,其中 options.stdio[3]options.stdio[4] 是自定义的管道。这两个管道是由子进程创建的。

通过 子进程对象.stdio[自定义管道索引] 的方式可以获取到这些管道,在这里我们需要把两个自定义管道挂载到 PoolWorker 实例上;

这两个自定义管道是 Stream 对象,其中索引为 3 的是可读流,索引为 4 的是可写流;

class PoolWorker {
  constructor(options, onJobDone) {
  
    this.worker = childProcess.spawn(
      process.execPath,
      [].concat(sanitizedNodeArgs).concat(workerPath, options.parallelJobs),
      {
        detached: true,
        stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
      }
    );

    // readPipe 索引为 3,可读流对象
    // writePipe 索引为 4 可写流对象
    const [, , , readPipe, writePipe] = this.worker.stdio;
    
    //这两个通道并挂载到实例
    this.readPipe = readPipe;
    this.writePipe = writePipe;

  }
}

三、总结

本文接上文的 从 workerPool.run(data, cb) 后到 this.poolQueue.push(data, cb)poolQueue 得到 data 经过一系列格式化操作,最后调用创建 poolQueue 传递的 worker 方法即 this.distributeJob 方法处理 data 并传入 done 方法;

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

PoolWorker 构造函数主要作用是通过 child_process.spawn 创建子进程运行 thread-loader/dist/worker.js 这个文件,这个文件在子进程被运行,处理 data

在创建子进程的时候通过传递给 spawnoptions.stdio 自定义管道实现进程间通信,这里使用的自定义管道是 Stream 对象。