NodeJs的进程操作
背景
我们之前有一起了解过 NodeJs 中的进程的底层原理(详情移步:探秘NodeJs·NodeJs进程之谜),那么,在 NodeJs 当中,究竟有哪些 API 可以操作进程呢?又是如何操作的呢?这些 API 之间又有什么联系呢?我们今天就一起来梳理一下这一块内容
环境准备
由于后续学习和实验过程中,主要使用 ts 编写 nodejs 程序,因此,我们需要先准备一下环境。首先,我们创建一个新目录,如:NodeJS,然后再该目录下运行:
bash
# 初始化 typescript 配置文件
tsc --init
# 安装 @types/node,用于编辑器的语法提示
npm i @types/node -D
# 全局安装 ts-node,用于后续全局运行 ts 文件
npm i ts-node
child_process
在 NodeJs 当中,进程的操作都封装在 child_process
包中,如果我们想使用其中的方法可以使用:
js
const {} = require('child_process');
// 或使用 ESM
import {} from 'child_process';
相关的 API 文档详见:child_process。
进程的创建
fork
当我们想要让某一个指定路径下的模块在子进程中执行的时候,就可以使用这个 api。
js
// child.ts
console.log('child process');
// index.ts
import { resolve } from "path";
import { fork } from "child_process";
const child = fork(resolve(__dirname, "child.ts"));
// output:
// child process
那么,我们怎么知道输出的结果真的是在子进程里面产生的呢?我们可以改造一下我们的程序:
typescript
// child.ts
console.log('child');
async function wait(delay: number = 1000) {
return new Promise((resolve) => {
setTimeout(resolve, delay);
})
}
async function main() {
while (true) {
await wait();
console.log("Child process is running...");
}
}
main();
// index.ts
import { resolve } from "path";
import { fork } from "child_process";
const child = fork(resolve(__dirname, "child.js"));
async function wait(delay: number = 1000) {
return new Promise((resolve) => {
setTimeout(resolve, delay);
})
}
async function main() {
while (true) {
await wait();
console.log("Main process is running...");
}
}
main();
上述程序运行后,将输出如下结果:
上面我们是执行一个模块文件:child.js
,那么,如果我只是想在子进程执行一个指令行不行呢?
typescript
import { fork } from "child_process";
fork('ls');// Error.
不出意料的,使用 fork
需要我们传入的是一个模块文件,不能直接使用指令。但是,在实际开发过程中,我们又确实需要在子进程中执行一些指令,如果把所有的指令都写入模块文件未免太过于冗余了,是否有更好的方式呢?答案当然是有的,我们需要使用:exec
方法。
exec
exec
是异步调用的 API,与之对应的,还有 execSync
这个同步 API,我们来分别看看这两个 API 的调用方式和执行结果:
typescript
import { exec, execSync } from "child_process";
exec("node -v", (err, stdout, stderr) => {
if (err) {
console.error(err);
return;
}
console.log("============== exec ======================")
console.log(stdout);
});
console.log("============== execSync ======================")
const res = execSync("ls", {
encoding: "utf-8",
});
console.log(res);
那么,fork
不能执行指令,那 exec
反过来能不能执行一个文件模块呢?我们来尝试一下:
typescript
// fork/child.ts
console.log("child");
// exec.ts
import { exec, execSync } from "child_process";
import { resolve } from "path";
exec("node -v", (err, stdout, stderr) => {
if (err) {
console.error(err);
return;
}
console.log("============== exec ======================")
console.log(stdout);
});
console.log("============== execSync ======================")
const res = execSync("ls", {
encoding: "utf-8",
});
console.log(res);
console.log("============== module file ======================")
const res2 = execSync(`ts-node ${resolve(__dirname, 'fork', 'child.ts')}`, {
encoding: "utf-8",
});
console.log(res2);
实时证明是可以的,那么,我们是不是可以把 fork
API 当做是 execSync
的一个特例呢?也就是说,fork
底层可以看成就是用 execSync
实现的。
那么,为什么 exec
能够直接执行指令呢?实际上,NodeJs 在执行 exec
的时候,相当于是将提供的指令放到了一个 shell
环境当中执行。
spawn
从上面的实验当中,我们可以理解为 fork
的爸爸就是 execSync
,那么,execSync
有没有爸爸呢?如果有,他的爸爸又是谁呢?其实,在 NodeJs 当中所有的进程操作底层都是基于 spawn
,可以认为 spawn
是所有进程操作的共同祖先,就像我们华夏儿女都是炎黄子孙,我们的共同祖先就是炎帝和黄帝部落的先人。
typescript
import { spawnSync } from "child_process";
const res = spawnSync("ls", {
encoding: "utf-8",
});
console.log(res);
我们观察 spawnSync
的输出可以发现,相较于 execSync
多了很多额外的信息,如:status
、signal
、pid
以及标准输入输出流等信息,而我们的 execSync
实际上是在 spawnSync
的基础上做的一层封装。
我们再来看一下 spawn
这个 API 的输出:
我们可以看到,spawn
返回的类型是一个流,而exec
则是返回一个子进程对象。返回流的好处就是可以完成一个任务就交付一个任务,一遍工作一边交付,因此,spawn
是能够最早拿到输出信息的。而exec
拿到的输出信息则是一段一段的,原因是因为 exec
在实现的时候,做了缓存 Buffer 操作,类似厨师做好菜了,spawn
是做好一道就上一道,这样可以保证没一道菜都在最快的时间送上餐桌,保证食材的新鲜度。而 exec
则是等厨师做好了几道菜后,再一起端上餐桌,这样,服务员的工作效率会更高。两种方式各有优缺点,取决于使用场景。
如果我们先要从 spawn
的里面获取数据可以这样:
进程间通信(IPC)
现在我们已经了解了进程应该如何创建了,也了解了多种创建进程的方式以及各自适合的场景。在实际开发过程中,肯定不可避免的会遇到各种进程之间相互通信的场景,那么,我们进程之间的通信究竟是如何完成的呢?我们来一探究竟。
typescript
// send.ts
import { fork } from "child_process";
import { resolve } from "path";
const child1 = fork(resolve(__dirname, "sendChild.ts"));
const child2 = fork(resolve(__dirname, "sendChild.ts"));
const child3 = fork(resolve(__dirname, "sendChild.ts"));
const children = [child1, child2, child3];
children.forEach((child) => {
child.send({ hello: "world" });
child.on("message", (msg) => {
console.log("Message from child: ", msg);
});
});
// sendChild.ts
process.on("message", (msg) => {
console.log("Message from parent:", msg);
process.send?.("Revived!");
});
通过上述方式,我们就完成了主进程跟三个子进程之间的相互通信了,需要注意的是:process.send
仅作为子进程时才可以调用,如果我们在主进程中调用是会报错的。
我们可以发现,上述的代码执行完毕后,进程并没有自动结束,而是一直处于待操作的状态。原因是因为:process.on
方法可能频繁的接受来自父进程的消息,因此,NodeJs 对于调用了这个 API 的进程不会自动结束,如果我们想要接受到消息之后结束进程,可以这样做:
typescript
// sendChild.ts
process.on("message", (msg) => {
console.log("Message from parent:", msg);
process.send?.("Revived!");
process.exit();
});
刚刚我们说过,可以把 fork
看成是 exec
的一种运行特例,那么,既然 fork
可以实现进程间的通信,那么,通过 exec
执行的子进程是否也同样具备进程间通信的能力呢?我们来做个实验看看。
经过实验我们可以发现,这么玩就直接崩了,提示说 child.send is not a function
。由此可见,exec
本身并没有实现进程间通信的能力,这个能力是 fork
自己实现的。
那么,如果我们一定要使用 exec
实现进程间的通信改怎么办呢?我们其实可以采用管道的方式来实现,例如主进程需要把一个消息通知给子进程,我们可以将主进程的信息写入到某个指定文件当中,子进程运行时去读取,而子进程与父进程之间的通信也是类似。但这终归不是很好的方式,毕竟 io 操作始终都是低效的。所以我们如果真的需要进行进程间通信的话,还是老老实实使用 fork
比较好。
结语
上面这些示例和实验都是非常简单的示例,主要是为了梳理和了解 NodeJs 当中进程的一些操作,并初步了解进程间的通信。在实际开发过程中,会遇到更多更加复杂的场景,但万变不离其宗,无论是哪一种方式,基本都是通过这上述的方式封装而来的。