NodeJs的进程操作

NodeJs的进程操作

背景

我们之前有一起了解过 NodeJs 中的进程的底层原理(详情移步:探秘NodeJs·NodeJs进程之谜),那么,在 NodeJs 当中,究竟有哪些 API 可以操作进程呢?又是如何操作的呢?这些 API 之间又有什么联系呢?我们今天就一起来梳理一下这一块内容

环境准备

由于后续学习和实验过程中,主要使用 ts 编写 nodejs 程序,因此,我们需要先准备一下环境。首先,我们创建一个新目录,如:NodeJS,然后再该目录下运行:

# 初始化 typescript 配置文件
tsc --init
# 安装 @types/node,用于编辑器的语法提示
npm i @types/node -D
# 全局安装 ts-node,用于后续全局运行 ts 文件
npm i ts-node

child_process

在 NodeJs 当中,进程的操作都封装在 child_process 包中,如果我们想使用其中的方法可以使用:

const {} = require('child_process');
// 或使用 ESM
import {} from 'child_process';

相关的 API 文档详见:child_process

进程的创建

fork

当我们想要让某一个指定路径下的模块在子进程中执行的时候,就可以使用这个 api。

// 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

那么,我们怎么知道输出的结果真的是在子进程里面产生的呢?我们可以改造一下我们的程序:

// 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();

上述程序运行后,将输出如下结果:

NodeJs的进程操作

上面我们是执行一个模块文件:child.js,那么,如果我只是想在子进程执行一个指令行不行呢?

import { fork } from "child_process";

fork('ls');// Error.

不出意料的,使用 fork 需要我们传入的是一个模块文件,不能直接使用指令。但是,在实际开发过程中,我们又确实需要在子进程中执行一些指令,如果把所有的指令都写入模块文件未免太过于冗余了,是否有更好的方式呢?答案当然是有的,我们需要使用:exec 方法。

exec

exec 是异步调用的 API,与之对应的,还有 execSync 这个同步 API,我们来分别看看这两个 API 的调用方式和执行结果:

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);

NodeJs的进程操作

那么,fork 不能执行指令,那 exec 反过来能不能执行一个文件模块呢?我们来尝试一下:

// 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);

NodeJs的进程操作

实时证明是可以的,那么,我们是不是可以把 fork API 当做是 execSync 的一个特例呢?也就是说,fork 底层可以看成就是用 execSync 实现的。

那么,为什么 exec 能够直接执行指令呢?实际上,NodeJs 在执行 exec的时候,相当于是将提供的指令放到了一个 shell 环境当中执行。

spawn

从上面的实验当中,我们可以理解为 fork 的爸爸就是 execSync,那么,execSync 有没有爸爸呢?如果有,他的爸爸又是谁呢?其实,在 NodeJs 当中所有的进程操作底层都是基于 spawn,可以认为 spawn 是所有进程操作的共同祖先,就像我们华夏儿女都是炎黄子孙,我们的共同祖先就是炎帝和黄帝部落的先人。

import { spawnSync } from "child_process";

const res = spawnSync("ls", {
    encoding: "utf-8",
});

console.log(res);

NodeJs的进程操作

我们观察 spawnSync 的输出可以发现,相较于 execSync 多了很多额外的信息,如:statussignalpid以及标准输入输出流等信息,而我们的 execSync 实际上是在 spawnSync 的基础上做的一层封装。

我们再来看一下 spawn 这个 API 的输出:

NodeJs的进程操作

NodeJs的进程操作

我们可以看到,spawn 返回的类型是一个流,而exec则是返回一个子进程对象。返回流的好处就是可以完成一个任务就交付一个任务,一遍工作一边交付,因此,spawn 是能够最早拿到输出信息的。而exec拿到的输出信息则是一段一段的,原因是因为 exec 在实现的时候,做了缓存 Buffer 操作,类似厨师做好菜了,spawn是做好一道就上一道,这样可以保证没一道菜都在最快的时间送上餐桌,保证食材的新鲜度。而 exec则是等厨师做好了几道菜后,再一起端上餐桌,这样,服务员的工作效率会更高。两种方式各有优缺点,取决于使用场景。

如果我们先要从 spawn 的里面获取数据可以这样:

NodeJs的进程操作

进程间通信(IPC)

现在我们已经了解了进程应该如何创建了,也了解了多种创建进程的方式以及各自适合的场景。在实际开发过程中,肯定不可避免的会遇到各种进程之间相互通信的场景,那么,我们进程之间的通信究竟是如何完成的呢?我们来一探究竟。

// 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!");
});

NodeJs的进程操作

通过上述方式,我们就完成了主进程跟三个子进程之间的相互通信了,需要注意的是:process.send 仅作为子进程时才可以调用,如果我们在主进程中调用是会报错的。

我们可以发现,上述的代码执行完毕后,进程并没有自动结束,而是一直处于待操作的状态。原因是因为:process.on 方法可能频繁的接受来自父进程的消息,因此,NodeJs 对于调用了这个 API 的进程不会自动结束,如果我们想要接受到消息之后结束进程,可以这样做:


// sendChild.ts
process.on("message", (msg) => {
    console.log("Message from parent:", msg);
    process.send?.("Revived!");
  	process.exit();
});

刚刚我们说过,可以把 fork 看成是 exec 的一种运行特例,那么,既然 fork 可以实现进程间的通信,那么,通过 exec 执行的子进程是否也同样具备进程间通信的能力呢?我们来做个实验看看。

NodeJs的进程操作

经过实验我们可以发现,这么玩就直接崩了,提示说 child.send is not a function。由此可见,exec 本身并没有实现进程间通信的能力,这个能力是 fork 自己实现的。

那么,如果我们一定要使用 exec 实现进程间的通信改怎么办呢?我们其实可以采用管道的方式来实现,例如主进程需要把一个消息通知给子进程,我们可以将主进程的信息写入到某个指定文件当中,子进程运行时去读取,而子进程与父进程之间的通信也是类似。但这终归不是很好的方式,毕竟 io 操作始终都是低效的。所以我们如果真的需要进行进程间通信的话,还是老老实实使用 fork 比较好。

结语

上面这些示例和实验都是非常简单的示例,主要是为了梳理和了解 NodeJs 当中进程的一些操作,并初步了解进程间的通信。在实际开发过程中,会遇到更多更加复杂的场景,但万变不离其宗,无论是哪一种方式,基本都是通过这上述的方式封装而来的。

原文链接:https://juejin.cn/post/7327570230774497314 作者:kinertang

(0)
上一篇 2024年1月25日 上午11:04
下一篇 2024年1月25日 上午11:15

相关推荐

发表回复

登录后才能评论