持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的10天,点击查看活动详情
一、前情回顾
上一篇小作文讨论了 workerPool.poolQueue 的 worker —— WorkerPool.distributeJob 方法的详细逻辑:
- 找到最合适的
worker(子进程),标准是活跃任务数最少的; - 如果没有超过
this.numberOfWorkers的限制就新建worker,worker是PoolWorker的实例,可以理解成子进程;
本篇我们详细讲解 PoolWorker 这个类是如何创建子进程的以及父子进程间的通信方式。平时不敢在公司吐槽(就属我吐槽声音最大)最近真的是越来越卷了,恨不得所有人都去搞 V8 优化了,关键是人家那头没啥业务啊,我们这边业务多的要命了,再这么卷下去,摸鱼专家恐怕要被卷成寿司专家(也许是润专家)了。。。。
二、PoolWorker 类
-
类的位置:
thread-loader/src/WorkerPool.js -> class PoolWorker -
构造函数参数:
- 2.1
options:初始化PoolWorker所需配置项,上文WorkerPool.prototype.createWorker时传递的进程参数、最大并发任务数就是了。 - 2.2
onJobDone:PoolWorker实例完成任务时需要触发的onJobDone回调(汇报工作用的)
- 2.1
-
构造函数具体工作:
- 3.1 初始化实例的
disposed/nextJobId/jobs/id等属性,并将onJobDone挂载到实例上;其中id表示的是PoolWorker这个worker的id; - 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开始读取子进程写入管道的数据;
- 3.1 初始化实例的
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 的参数
command: 要运行的命令;[, args]: 传递给上面的command的字符串参数列表;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 类创建子进程的时候传递了三个参数:
-
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 官方文档 -
[].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; 所以最后的结果:
- 2.1
[ "/Users/xx/Documents/some-dir/node_modules/thread-loader/dist/worker.js" , 20]
{ detached: true, stdio: [....] },这个参数对应了spawn的参数options选项对象,在本例中只传入了很多参数,它的参数还有很多,暂时只讨论这两个:- 3.1 detached: 配置这个参数为
true,可以让子进程在父进程退出后继续运行,当然这里我说的比较简单,具体行为还和系统平台有关。当然,仅仅配置这个还不够,还需要调用子进程的unref()方法,即上文的this.worker.unref();还需要在spawn配置sdio为ignore; - 3.2 stdio:
['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],options.stdio选项用于配置在父进程和子进程之间建立的管道,这些管道用于通信。这里传递了一个数组,每个索引位置的值对应不同类型;- 3.2.1 第一个是标准输入
- 3.2.2 第二个是标准输出
- 3.2.3 第三个是标准错误
- 3.2.4 除了前三个以后的就是自定义的管道了,在
thread-loader中就是靠后面两个自定义管道进行进程间通信
- 3.1 detached: 配置这个参数为
总结起来,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。
在创建子进程的时候通过传递给 spawn 的 options.stdio 自定义管道实现进程间通信,这里使用的自定义管道是 Stream 对象。