node-interview [IV] : Child process & Cluster

Child process

一些基础性的介绍在这里。child_process有两个核心方法,spawnfork,二者唯一的区别是后者会在创立子进程的同时建立一个IPC通道,因此就分析fork好了。本文将只讨论unix下的实现。

fork

unix下有一个fork函数可以用来创建子进程。文档里有这么一句话:

Note: Unlike the fork(2) POSIX system call, child_process.fork() does not clone the current process.

这句话给人一种child_process和unix的fork没关系的错觉,像是libuv自己实现了一套fork方法一样。实际上,child_process.fork最终调用的就是libuv里的deps/uv/src/unix/process.c: uv_spawn(),最终还是调用了系统的fork. 而文档里说的没有克隆原始进程,意思应该是copy-on-write机制。

IPC channel

IPC在unix下有很多种实现方式,node使用的是unix domain socket方案。相较针对网络通讯设计的socket,效率更高。

unix下创建unix domain socket的系统函数是socketpair,它将返回一对文件描述符fd[0] fd[1]。对其中任意一个执行写操作之后,对这个文件描述符的读取操作会阻塞,只可以在另一个上读出数据。它实际上是一个全双工的IPC。而使用管道的话是单工的,双工通信需要创建两次,比较繁琐。

node在调用fork操作的时候,会通过环境变量NODE_CHANNEL_FD传递给子进程一个值作为IPC频道的暗号。我之前有参考过CNODE论坛上的帖子,看完这个帖子我有一个最大的疑问:fork操作后,子进程是会完整的继承原始父进程打开的文件描述符的,所以如果在fork之前调用了socketpair,子进程是有这个文件描述符的,又何必要用NODE_CHANNEL_FD折腾一大圈呢?

child_process.fork最终调用的是libuv里的uv_spawn, 来仔细分析一下uv_spawn这个函数吧。第一个关键函数是uv__process_init_stdio,它内部调用了uv__make_socketpair,而该函数又调用了系统层面的socketpair生成一对fd,这里没什么疑问。在生成socketpair后,有这样一段代码:

if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds))
    return -errno;
uv__cloexec(fds[0], 1);
uv__cloexec(fds[1], 1);

这个uv__cloexec将新生成的两个fd配置为exec调用后即关闭,即close on exec. 这里是个关键点。

随后的操作是fork。此时程序开始分支,子进程接受到的fork返回值为0,即执行if (pid == 0)内的代码,即:

  if (pid == 0) {
    uv__process_child_init(options, stdio_count, pipes, signal_pipe[1]);
    abort();
  }

uv__process_child_init内做了一个操作:调用dup2NODE_CHANNEL_FD传入的索引值重定向到之前socketpair生成的fd中的一个,另一个同样执行cloexec操作:

    if (fd == use_fd)
      uv__cloexec_fcntl(use_fd, 0);
    else
      fd = dup2(use_fd, fd);

函数的尾部则调用了execvp(options->file, options->args);,使用exec重新执行。exec只会替换当前进程的代码段、数据段和堆栈段,没有配置关闭操作的文件描述符则会保留下来。由于之前的配置,socketpair创建的两个原始fd都会被关闭,留下的只有NODE_CHANNEL_FD重定向后的fd。至此,这个fd在子进程里终于可以访问了。随后的操作就是node初始化的时候执行的setupChannel操作,打开管道。

Cluster

官方文档里描述了Cluster工作的一些要点:

The worker processes are spawned using the child_process.fork() method, so that they can communicate with the parent via IPC and pass server handles back and forth.
The first one (and the default one on all platforms except Windows), is the round-robin approach, where the master process listens on a port, accepts new connections and distributes them across the workers in a round-robin fashion, with some built-in smarts to avoid overloading a worker process.
The second approach is where the master process creates the listen socket and sends it to interested workers. The workers then accept incoming connections directly.

NODE_UNIQUE_ID

由于cluster本身是基于child_process.fork实现的,根据前面的分析,由于在fork后调用了exec,因此子进程是会从头开始完整的重新执行一遍代码的,这点和unix的fork有本质区别。区分父子进程的方法是利用cluster.isMaster()标识,那些父进程需要执行的代码放在if (cluster.isMaster)内,子进程执行的代码放在else里。

而实际上,父子进程的区别从require("cluster")就开始了:

const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';
module.exports = require(`internal/cluster/${childOrMaster}`);

父进程require到的是internal/cluster/master.js,子进程require到的是internal/cluster/child.js。父子进程的区别通过NODE_UNIQUE_ID这个环境变量来体现,这个环境变量是父进程在fork操作的时候放进去的:

workerEnv.NODE_UNIQUE_ID = '' + id;

bind, listen, accept

Cluster模式下有个重要的特点就是,父子进程可以同时监听一个端口而不会扔EADDRINUSE,这背后的原因要从监听端口的步骤说起。

bind操作将已有的socket绑定到一个端口和地址上,listen操作则开始监听这个socket上的连接,accept操作从socket已建立的连接队列里取出一个进行操作。在没有ESTABLISHED状态的连接时,accept会一直阻塞,直到有连接进来才返回。

那么这里就会衍生出两种编程模型,一种是多个进程共享listen返回的fd,即多个进程同时执行accept操作,对应下文调度策略里的SCHED_NONE。另一种是主进程负责accept,将fd分发给其他进程来执行操作,对应下文调度策略里的SCHED_RR

调度策略

SCHED_NONE对应node文档里的第二种策略,SCHED_RR对应第一种。

SCHED_NONE

这种模型的c代码描述如下(完整代码见这里):

    int pid;
    pid = fork();
    if (pid < 0)    {
        printf("fork error");
        return -1;
    } else if (pid == 0)    {
        //  Parent process.
        while (1)   {
            len = sizeof(cliaddr);
            connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &len);
            printf("Master: connection from %s, port %d\n",
                inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
                ntohs(cliaddr.sin_port));
            close(connfd);
        }
    } else {
        //  Child process
        while (1)   {
            len = sizeof(cliaddr);
            connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &len);
            printf("Child: connection from %s, port %d\n",
                inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
                ntohs(cliaddr.sin_port));
            close(connfd);
        }
    }

这个模型的关键在于父子进程间共享同一个listenfd,父子进程同时进行accept操作。由于fork时共享了父进程的空间,因此父子进程访问的listenfd是一个东西。在node里,由于exec操作,父子进程不会有共享的变量,listenfd通过IPC来在进程之间传递。这种方式实际上是把调度扔给了操作系统,在早期的linux内核时存在所谓的惊群效应,现在的系统上已经不会存在这个问题,多个进程同时accept只会唤醒一个进程,其他的继续阻塞。如官方文档所述,从实践来看,这个方式的调度不够均衡。

SCHED_RR

与这种模型类似的c代码描述如下(完整代码见这里):

    while (1)   {
        connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &len);
        if ((pid = fork()) == 0)    {
            //  Child process
            printf("PID %d: connection from %s, port %d\n", getpid(),
                inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
                ntohs(cliaddr.sin_port));
            close(connfd);
            exit(0);
        }
        close(connfd);
    }

这个模型的关键在于父子进程之间共享connfd,同时父进程不负责处理连接,只是把连接扔给子进程去处理,父进程里直接关闭connfd再循环accept。这里的代码是一个连接就fork一个子进程,node里则是先创建与CPU核心数量相等的进程,再按round-robin策略分发。

Server.prototype.listen

无论如何调度,都绕不开一个问题,那就是多个node进程必然会依次执行listen操作,如果每个进程都按bind listen accept的步骤走下去,第二个进程必然会出错。因此,来看下node里面listen操作究竟是如何处理的。

http.createServer方法返回的对象的listen方法实际上是从其父类Server里继承来的,位于lib/net.js。看Server.prototype.listenlisten方法本身可以接收多个、多种不同类型的参数,因此先调用了normalizeArgs()对入参进行了格式化,然后再根据入参的情况进行相应的处理。这里略去normalizeArgs()的执行过程分析,感兴趣的朋友可以把这个函数拿出来单独运行一下就可以得到结果了。

listen(8000)这样指定端口号的调用为例,最终将匹配到这个if else: if (typeof options.port === 'number' || typeof options.port === 'string')并执行:listenInCluster(this, null, options.port | 0, 4,backlog, undefined, options.exclusive);。再看listenInCluster,先对cluster的角色进行了区分。如果是master,直接新建fd,这没什么好说的。如果是child,那么会调用cluster._getServer方法尝试获取master的fd。

再回到lib/internal/cluster/child.jscluster._getServer方法,子进程调用send方法向master进程发送了queryServer请求。再看lib/internal/cluster/master.js里master进程是如何处理的,处理函数最终调用的是queryServer,以ROUND-ROBIN即SCHED_RR为例,先会检查尝试监听的端口是否已经存在,如果已存在则直接返回,如果不存在,则会调用lib/internal/cluster/round_robin_handle.js中的构造函数,由master进程监听端口,再把和监听有关的信息带回给子进程。父进程accept到新连接之后,通过发送message.act === "newconn"的消息给子进程来分发fd。至此,可以看出,在cluster的round-robin模式下,始终是由master来监听端口,接受连接。

你可能感兴趣的:(node-interview [IV] : Child process & Cluster)