Node.js 如何与子进程进行通信

在 Node.js 官方文档中有这样一段描述:

nodejs后端调用java模块 nodejs调用go_javascript

在子进程中,可以通过 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: pipeipc: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]