在 Node.js 中如何通过子进程与其他语言(Go)进行 IPC 通信

文章详细阐述了在Node.js中如何通过`child_process.spawn`创建子进程并利用`stdio`中的`ipc`选项建立与子进程的通信通道。通过`NODE_CHANNEL_FD`环境变量,子进程可以找到与父进程通信的socket文件描述符,进而使用`recvmsg`和`sendmsg`进行数据交换。在Golang中,可以通过读写这个文件描述符来实现与Node.js的交互。
摘要由CSDN通过智能技术生成

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

在 Node.js 官方文档中有这样一段描述:0f465c1f86abb25602d08dc3e4b844c9.png在子进程中,可以通过 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包中,提供了对应的 RecvmsgSendmsg 这两个方法,所以通信就很简单了。发送数据:

// 发送数据
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]

c1c19fcbe5b4d1b42a92e33c52e1ed43.png

往期推荐

10 个可以副业赚钱的网站,总有一个适合你

ba1776b2da2e0fa6d92e0ccd673218da.png

用 Three.js 做个兔吉宝箱给大家拜个年

a412f53d2ffac818db9dd652e09fa3bb.png

推荐20个开源的前端低代码项目

acd0abbb64edafe9dd54e6fef73aab60.png


最后

  • 欢迎加我微信,拉你进技术群,长期交流学习...

  • 欢迎关注「前端Q」,认真学前端,做个专业的技术人...

4337e038a8bf5511b3593bcc2c3596b1.jpeg

caa80aa8c286cad0b86423325f9a6959.png

点个在看支持我吧

797ef405325d05ba7a757fcd1813fbb5.gif

参考资料

[1]

lib/internal/child_process.js#L380: https://github.com/nodejs/node/blob/main/lib/internal/child_process.js#L380

[2]

lib/internal/child_process.js#L395: https://github.com/nodejs/node/blob/81ab00d913a878a47510f8ea3ccf8dadb2971a7d/lib/internal/child_process.js#L395

[3]

lib/internal/child_process.js#L366: https://github.com/nodejs/node/blob/81ab00d913a878a47510f8ea3ccf8dadb2971a7d/lib/internal/child_process.js#L366

[4]

src/process_wrap.cc#LL233C5-L233C22: https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/src/process_wrap.cc#LL233C5-L233C22

[5]

src/process_wrap.cc#L264: https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/src/process_wrap.cc#L264

[6]

src/unix/process.c#L991: https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/deps/uv/src/unix/process.c#L991

[7]

deps/uv/src/unix/process.c#L382: https://github.com/nodejs/node/blob/f313b39d8f282f16d36fe99372e919c37864721c/deps/uv/src/unix/process.c#L382

[8]

https://linux.die.net/man/2/recvmsg: https://linux.die.net/man/2/recvmsg

[9]

https://linux.die.net/man/2/sendmsg: https://linux.die.net/man/2/sendmsg

[10]

https://github.com/midwayjs/lb: https://github.com/midwayjs/lb

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值