Node.js 如何与子进程进行通信
在 Node.js 官方文档中有这样一段描述:
在子进程中,可以通过 NODE_CHANNEL_FD
这个环境变量来获取到一个文件描述符来与父进程进行通信,那这个 NODE_CHANNEL_FD
是从哪里来的?又该如何使用呢?首先,我们从 child_process.spawn
这个创建子进程的方法开始说起,下面是一段在 Node.js 中启动一个子进程,执行 go run main.go
这样命令的代码:
const { spawn } = require('child_process');
const { join } = require('path');
const childProcess = spawn('go', ['run', 'main.go'], {
stdio: [0, 1, 2, 'ipc']
});
可以看到,我们在 stdio
数组中包含了 ipc
这样一个字符串,在 Node.js 中是这样处理这个参数的:
// https://github.com/nodejs/node/blob/7b1e15353062feaa3f29f4fe53e11a1bc644e63c/lib/internal/child_process.js#L1025-L1043
stdio = ArrayPrototypeReduce(stdio, (acc, stdio, i) => {
if (stdio === 'ignore') {
// 忽略里面的 N 行代码
} else if (stdio === 'ipc') {
ipc = new Pipe(PipeConstants.IPC);
ipcFd = i;
ArrayPrototypePush(acc, {
type: 'pipe',
handle: ipc,
ipc: true
});
} else if (stdio === 'inherit') {
// 忽略里面的 N 行代码
}
return acc;
}, []);
可以看出,这里会迭代 stdio,如果其中包含 ipc
那就往 acc
上面添加属性 type: pipe
、ipc:true
等,同时赋值 ipcFd = i
,根据我们之前调用 spawn
的参数,ipc
这个字符串所在的索引位置 i 为 3
,那么 ipcFd 的值就是 3,在 child_process.spawn
的实现中可以看到会把 ipcFd 赋值到 NODE_CHANNEL_FD
上(lib/internal/child_process.js#L380[1])。在文件描述符表中,0/1/2 分别代表标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr),3 代表的是第一个文件描述符,接下来继续看这个文件描述符是怎么来的,为什么 NODE_CHANNEL_FD
会是第一个文件描述符?在 child_process.spawn
中调用了 _handler.spawn
方法(lib/internal/child_process.js#L395[2]),这个 _handler
来源于实例化 process_wrap.cc
导出的 Process,同时 spawn 执行时的参数中的 stdio
属性,来源于上面迭代 stdio
之后的返回值 (lib/internal/child_process.js#L366[3] )。在 ProcessWrap::Spawn
的实现中(src/process_wrap.cc#LL233C5-L233C22[4]),会调用 ParseStdioOptions 来处理 stdio
参数,将 type:pipe
处理成对应的 flag
,然后调用 uv_spawn
(src/process_wrap.cc#L264[5])。
在 uv_spwan
中有一个很关键的步骤,在其中调用uv__process_init_stdio
,在其中根据之前处理的 flag
,调用了 uv_socketpair
,在这个方法内部调用了 socketpair
来创建一对相互连接的 socket 用于之后再父子进程之间进行通信,同时将这个 socket 的文件描述符存储起来,以用于在后面传递给子进程。然后再在 uv_spwan
中通过 uv__spawn_and_init_child
(src/unix/process.c#L991[6])来调用 uv__spawn_and_init_child_fork
方法,在其中 fork 子进程。
// https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/deps/uv/src/unix/process.c#L789
static int uv__spawn_and_init_child_fork(const uv_process_options_t* options,
int stdio_count,
int (*pipes)[2],
int error_fd,
pid_t* pid) {
// 忽略 N 行代码
*pid = fork();
if (*pid == 0) {
/* Fork succeeded, in the child process */
uv__process_child_init(options, stdio_count, pipes, error_fd);
abort();
}
if (pthread_sigmask(SIG_SETMASK, &sigoldset, NULL) != 0)
abort();
if (*pid == -1)
/* Failed to fork */
return UV__ERR(errno);
/* Fork succeeded, in the parent process */
return 0;
}
众所周知,在执行 fork
函数创建一个子进程时,会同时有两个进程运行,在父进程中,fock
函数会返回子进程的进程 id,在子进程中,会返回 0,所以判断如果返回 0,那就执行子进程中的一些初始化逻辑。在子进程中调用 uv__process_child_init
中,通过 dup2
让子进程中 3
(也就是父进程中创建的环境变量 NODE_CHANNEL_FD
)这个文件描述符执行的文件重定向到父进程通过 socketpair
打开的文件描述符指向的文件:
// stdio_count 的值为 4,对应了 spawn 的 stdio 参数 [0, 1, 2, 'ipc']
for (fd = 0; fd < stdio_count; fd++) {
close_fd = -1;
// 当 fd 为3的时候,对应了 socketpair 创建的用来通信的文件描述符,假设是 24
// 也就是说 fd = 3、use_fd = 24
use_fd = pipes[fd][1];
if (use_fd < 0) {
}
if (fd == use_fd) {
}
else {
// dep2(24, 3);
fd = dup2(use_fd, fd);
}
// 忽略 N 行代码
}
最后通过调用 execvp
来加载要执行的子进程程序(deps/uv/src/unix/process.c#L382[7]) 对于父进程在 fork 之前打开的文件,比如 socket 等,由于在 uv__cloexec
中通过 fcntl
函数设置了 FD_CLOEXEC
,那么在 execcp
的时候都会自动进行关闭,而通过 socketpair
创建的这个文件,会被保留,这也就给后续再 Golang 里面与 Node.js 进行通信创造了条件。
Golang 进程如何与 Node.js 父进程进行通信
由于 NODE_CHANNEL_FD
这个环境变量指向了与父进程进行通信的 socket 文件,那么在 Go 里面,我们就可以通过对 socket 进行数据的写入和读取,来实现与父进程进行通信:
nodeChannelFD := os.Getenv(NODE_CHANNEL_FD)
nodeChannelFDInt, _ := strconv.Atoi(nodeChannelFD)
fd := os.NewFile(uintptr(int(nodeChannelFDInt)), "lbipc"+nodeChannelFD)
通过 Linux 文档,可以发现有 recvmsg
(https://linux.die.net/man/2/recvmsg[8]) 和 sendmsg
(https://linux.die.net/man/2/sendmsg[9])这两个函数,分别来实现对一个 socket 进行数据读取和发送操作,同时在 Go 的官方提供的 syscall
包中,提供了对应的 Recvmsg
和 Sendmsg
这两个方法,所以通信就很简单了。发送数据:
// 发送数据
type Message struct {
Id string `json:"id"`
MsgType string `json:"type"`
Data string `json:"data"`
}
fdHandler := int(fd.Fd())
responseMsg := Message{
Id: "id:1",
Data: "hello world",
MsgType: "test",
}
jsonData, _ := json.Marshal(responseMsg)
syscall.Sendmsg(fdHandler, append(jsonData, '\n'), nil, nil, 0)
接受数据:
// 接受数据
fdHandler := int(fd.Fd())
syscall.Recvmsg(fdHandler, dataBuf, attachedDataBuf, 0)
相关代码实现可以查看 https://github.com/midwayjs/lb[10]