Nodejs 系列二:Child Process 子进程

前言

本文是笔者所总结的有关 Nodejs 基础系列之一,

笔者目前使用的是 Nodejs LTS 8.9.4 版本,因此将以此版本作为蓝本对 Nodejs 的相关概念和技术进行剖析;本文将对 Nodejs 的底层核心技术之一的 Child Process 进行剖析,从操作系统进程的角度去了解 Nodejs 的同步/异步无阻塞( Asynchronous non-blocking )的实现机制;

注明:本文为作者的原创作品,转载需注明出处;

术语

  • Spawn
    在英文的官方文档中数词出现这个单词 Spawn,如果直译为“孵化”的意思,从英文的角度非常的形象,但是用在中文的解释上就非常的怪;综合中文的表述习惯,笔者将其翻译为创建

原理

Nodejs 运行时刻的主进程既是 Event Loop 进程,通过该进程可以创建出任意多个子进程( Child Process ),并且子进程和父进程维护了三个特殊的管道,分别是 stdinstdout 以及 stderr 这样的三个管道,父进程和子进程可以通过这些管道实现无阻塞( non-blocking) 的通讯方式;英文原文是

It is possible to stream data through these pipes in a non-blocking way.

翻译过来就是,可以依赖于这些管道实现无阻塞的通讯方式;不过笔者要提醒的是,这个条件只是其中的一个必要条件,要在应用层面实现无阻塞的机制,那么还需要必要的回调机制,父进程通过该回调机制获取子进行运行的状态以便做出对应的操作,这样才算做一个完成的异步无阻塞的执行机制,归纳起来必要条件总共有两点;

  1. 管道
    使得子进程的数据可以直接的与父进程之间进行通讯,而无需通过文件或者磁盘等媒介,这样就无需额外的中断而是直接与父进程进行通讯,其实就是实现了 I/O 无阻塞的通讯机制,因此也才能实现真正意义上的无阻塞( non-blocking )通讯;因此它是其中一个必要的条件;
  2. 回调机制
    在大多数的应用场景,子进程都是为主进程(或称作父进程)服务的,所以子进程的执行的状态务必有效的反馈给父进程,而要实现异步的通讯机制,那么必然需要一个有效的“回调机制”,父进程可以在子进程上注册相应的事件,当子进程完成必要的声明周期的操作以后,通过该“回调机制”立刻反馈给父进程,这样父进程就能够时刻的子进程的保持有效的通讯;在后续的章节中,可以知道,这种“回调机制”是通过 EventEmitter 实现的;( 当然,如果你的应用场景是,父进程只要创建出一个异步的子进程,你运行你的,我运行我的,我们的运行过程互相不干涉,那么笔者也没什么好说的,当然这种情况下也就无需必要的回调机制了 ),

因此,一旦有了上述两点充要条件,那么我们的应用都可以构建在异步非阻塞的机制之上了,但是,Nodejs 考虑到极少数可能出现的同步场景,有些步骤或许必须等待子进程完成以后才能够执行,因此增加了同步子进程的机制,也就是说,主进程 Event Loop 必须等待子进程完成以后,才能继续执行,不过说实话,笔者绞尽脑汁也没有想到一定要使用同步的机制,倒是觉得,任何同步的实现方式都可以改造成为异步的方式,只是实现上要稍微麻烦一些,但是性能上却有质的飞跃;那么 Nodejs 是如何实现其同步的机制的呢?既然父子进程是通过管道的方式通讯,因此在 I/O 通讯上一定是无阻塞的,因此可以断定的是,父进程一直在等待子进程的完成的这种等待机制,无非就是在父进程中开启一个循环判断,或者是开启一个中断来实现的;不过正如笔者所论述的那样,任何同步的调用机制都可以使用异步来实现,那么笔者认为这种等待其实是不必要的;

概念图
childprocess-concept.png

  • 主进程 Event Loop 通过 child_process.spawn() 创建出来的是异步子进程;为了简化使用,Nodejs 从 child_process.spawn() 衍生出了多个创建异步子进程的方法,分别是

    1. child_process.exec()
    2. child_process.execFile()
    3. child_process.fork()
      这里需要注意的是,它创建出来的是一个独立于主进程且拥有独立 V8 引擎的进程,详情参考 child_process.fork()
  • 主进程 Event Loop 通过 child_process.spawnSync() 创建出来的是同步子进程;同样,为了简化使用,Nodejs 从 child_process.spawnSync() 衍生出了多个创建异步子进程的方法,分别是

    1. child_process.execSync()
    2. child_process.execFileSync()

创建异步子进程

官网上对该过程进行了详细的阐述,这里不打算照本宣科,而是简要的对其进行总结性和补充性的描述;

child_process.spawn()child_process.exec()child_process.execFile()child_process.fork() 四个方法均会创建出异步非阻塞的子进程,并且都会返回一个 ChildProcess 实例,而 ChildProcess 实例实现了 EventEmitter 接口,这样父进程就可以通过 EventEmitter 所提供的接口来注册父进程的回调方法,这样子进程在各个生命周期或者在任何需要的时候,调用父类的回调方法进行反馈;

child_process.spawn()

child_process.spawn(command[, args][, options]),相关参数说明如下,

childprocess-spawn-parameters.png

例子

windows

在 windows 上模拟如何通过 child_process.spawn() 创建一个新的异步子进程,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// On Windows Only ...
const { spawn } = require('child_process');
const bat = spawn('cmd.exe', ['/c', 'my.bat']);

bat.stdout.on('data', (data) => {
console.log(data.toString());
});

bat.stderr.on('data', (data) => {
console.log(data.toString());
});

bat.on('exit', (code) => {
console.log(`Child exited with code ${code}`);
});

下面笔者试着来解读一下上面的代码

  1. 代码第二行,const { spawn } = require('child_process');
    注意该写法必须使用 ECMAScript 6,否则编译报错,这句话实际上就是引用的 child_process.js 中的 spawn 模块,源码如下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    var spawn = exports.spawn = function(/*file, args, options*/) {
    var opts = normalizeSpawnArguments.apply(null, arguments);
    var options = opts.options;
    var child = new ChildProcess();

    debug('spawn', opts.args, options);

    child.spawn({
    file: opts.file,
    args: opts.args,
    cwd: options.cwd,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!options.windowsVerbatimArguments,
    detached: !!options.detached,
    envPairs: opts.envPairs,
    stdio: options.stdio,
    uid: options.uid,
    gid: options.gid
    });

    return child;
    };

    可以看到,spawn 模块代表的就是一个 function,当调用该 spawn 方法以后,会根据传入的参数初始化了一个 ChildProcess,然后返回;

  2. 代码第二行,const bat = spawn('cmd.exe', ['/c', 'my.bat']);
    这行代码既是开始调用 request.js 中的 spawn 模块并返回一个 ChildProcess
  3. 代码第 13 到 15 行

    1
    2
    3
    bat.on('exit', (code) => {
    console.log(`Child exited with code ${code}`);
    });

    首先,bat 就是一个 ChildProcess 实例,而 ChildProcess 实现了 EventEmitter 的接口,所以这里可以通过接口方法 on() 注入父进程的回调方法

    1
    2
    3
    (code) => {
    console.log(`Child exited with code ${code}`);
    }

    这样当子进程退出的时候,主进程便会通过该回调方法得知子进程的反馈;

Linux

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { spawn } = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
console.log(`stderr: ${data}`);
});

ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});

options.detached

该属性可以让子进程脱离父进程独立运行,不过在 Windows 和 Linux 系统上的实现方式不同,

  • Linux
    所创建的子进程实际上是一个新的进程,也就是说,该子进程与原来的父进程之间是平级的了;
  • Windows
    子进程可以在父进程结束之后依然执行,不过子进程和父进程之间的关系依然是存在的;

那么试问,为什么需要这样的场景呢?为什么要让一个子进程脱离父进程执行呢?其实就是当某个子进程需要长时间执行的时候,比如 batch process,这个时候,如果让子进程脱离父进程执行,那么会因为主进程的释放而节约大量的系统资源;不过即便是设置了 options.detached = true 还不够,我们还需要额外的措施来保证子进程完全脱离父进程,来看下面的步骤,

  1. 不过即便是设置了 options.detached = true ,父进程依然会试图等待子进程的结束,因为父进程始终会通过 reference count 保持与子进程的关联关系;因此我们还需要让子进程显式的调用 subprocess.unref() 方法来使得父进程不再通过 reference count 来记录自己了;
  2. 不过,要想子进程完全脱离父进程,光设置 options.detached = true 以及子进程调用 subprocess.unref() 还是不够的,因为还有一层关系需要斩断,那就是父进程与子进程通讯的 IPC 通道,那么这个通道又该如何斩断呢?首先,父进程和子进程是通过 stdio 管道来进行通讯的,stdin、stdout 和 stderr 都是继承自它;有两种方式可以斩断它:一是通过在设置 stdio 属性的时候将其设置为 false 明确的告诉父进程,该子进程 stdio 不与父进程通讯,因而可以斩断这种关系;二是可以将子进程的输出重定向到其它的文件中;

下面我们看看完整的用例,

  • 不使用父进程的 stdio 对象

    1
    2
    3
    4
    5
    6
    7
    8
    const { spawn } = require('child_process');

    const subprocess = spawn(process.argv[0], ['child_program.js'], {
    detached: true,
    stdio: 'ignore'
    });

    subprocess.unref();

    这样做虽然便捷但是最大的问题是,子进程的输出没啦;

  • 重定向子进程的 stdio 到文件中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const fs = require('fs');
    const { spawn } = require('child_process');
    const out = fs.openSync('./out.log', 'a');
    const err = fs.openSync('./err.log', 'a');

    const subprocess = spawn('prg', [], {
    detached: true,
    stdio: [ 'ignore', out, err ]
    });

    subprocess.unref();

options.stdio

该参数是一个由三个元素组成的数组,看一个例子,

1
2
3
4
5
6
7
8
9
10
const fs = require('fs');
const { spawn } = require('child_process');
const ins = fs.openSync('./in.log', 'a');
const out = fs.openSync('./out.log', 'a');
const err = fs.openSync('./err.log', 'a');

const subprocess = spawn('prg', [], {
detached: true,
stdio: [ ins, out, err ]
});

由此可知,options.stdio 数组中的三个元素分别表示对管道 stdin、stdout 和 stderr 进行设置,通常情况下,使用编号 0, 1, 2 来分别表示 stdin、stdout 和 stderr,可以通过 subprocess.stdio[0]、subprocess.stdio[1] 和 subprocess.stdio[2] 分别访问到这三个管道对象 FD;那么这三个值分别可以设置为什么样的类型呢?

  1. ‘pipe’
    一个字符串参数,这是默认值;设置它相当于为 subprocess 创建了自己的管道,不过该管道是子进程和父进程之间的管道,两者之间是有关联关系的,这种关联关系是通过将子进程的管道暴露给父进程的管道得以实现的;如果我们将该属性设置为 [‘pipe’, ‘pipe’, ‘pipe’],那么就等价于子进程的 stdin、stdout 和 stderr 使用的是自己的管道 subprocess.stdin, subprocess.stdout and subprocess.stderr;看一个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const assert = require('assert');
    const fs = require('fs');
    const child_process = require('child_process');

    const subprocess = child_process.spawn('ls', {
    stdio: [
    0, // Use parent's stdin for child
    'pipe', // Pipe child's stdout to parent
    fs.openSync('err.out', 'w') // Direct child's stderr to a file
    ]
    });

    assert.strictEqual(subprocess.stdio[0], null);
    assert.strictEqual(subprocess.stdio[0], subprocess.stdin);

    assert(subprocess.stdout);
    assert.strictEqual(subprocess.stdio[1], subprocess.stdout); // true

    assert.strictEqual(subprocess.stdio[2], null);
    assert.strictEqual(subprocess.stdio[2], subprocess.stderr); // false

    可以看到 subprocess.stdio[1] 的类型就是 subprocess.stdout,也就是说,子进程的 stdout 将会使用自己的管道;

  2. ‘ipc’
    字符串参数,设置该值以后便是创建一个 IPC 管道,通过该管道可以父进程之间进行消息消息传递;一个 ChildProcess 最多只能有一个 IPC stdio;特点是,

    1. 一旦设置为 ipc,那么就为父进程激活了 subprocess.send() 方法,那么父进程就可以直接通过这个方法给子进程“实时的”发送消息了,看一个例子

      1
      2
      3
      4
      5
      6
      7
      8
      9
      const cp = require('child_process');
      const n = cp.fork(`${__dirname}/sub.js`);

      n.on('message', (m) => {
      console.log('PARENT got message:', m);
      });

      // Causes the child to print: CHILD got message: { hello: 'world' }
      n.send({ hello: 'world' });
    2. 如果该子进程是 Node.js 进程,还会分别在子进程中激活 process.send()process.disconnect()process.on(‘disconnect’)process.on(‘message’) 四个方法;process.send() 允许子进程给父进程发送消息,而 process.disconnect() 则子进程通知父进程关闭 IPC 管道,因此,子进程可以优雅的进行关闭,这样便没有任何的残留;

  3. ‘ignore’
    字符串参数,该参数告诉子进程的各个管道不再使用 fd,而是为各个管道开启 /dev/null;因为 ‘pipe’ 是默认值,如果某个管道不使用 fd 必须显式的对它进行设置;
  4. <Stream> 对象
    Stream 对象参数,一个由 tty、file、socket 或者一个 pipe 所构成可读或者可写的 Stream 对象;就像本小节开始的时候的那个例子,使用的正好就是 file Stream 对象;
  5. ‘inherit’
    表示使用父进程的管道;相当于设置为 process.stdin、process.stdout 和 process.stderr;
  6. 正整数
    该正整数表示父进程一个开启的文件编号;
  7. null, undefined
    使用默认值,也就是 ‘pipe’

为了定义上的简便,可以使用下述的方式进行参数设定,

  • ‘pipe’
    等价于设置 [‘pipe’, ‘pipe’, ‘pipe’] 也是默认的配置;
  • ‘ignore’
    等价于设置 [‘ignore’, ‘ignore’, ‘ignore’]
  • ‘inherit’
    等价于设置 [process.stdin, process.stdout, process.stderr]

看一个例子,

1
2
3
4
5
6
7
8
9
10
11
const { spawn } = require('child_process');

// Child will use parent's stdios
spawn('prg', [], { stdio: 'inherit' });

// Spawn child sharing only stderr
spawn('prg', [], { stdio: ['pipe', 'pipe', process.stderr] });

// Open an extra fd=4, to interact with programs presenting a
// startd-style interface.
spawn('prg', [], { stdio: ['pipe', null, null, null, 'pipe'] });

最后要注意的是,如果子进程使用的是 IPC 管道,那么需要显示的调用 process.on(‘disconnet’) 来关闭与父进程之间的 IPC 管道,这样子进程才能优雅的关闭;

child_process.exec()

child_process.exec(command[, options][, callback])

该方法主要是用来调度 shell 命令的,来看一个例子,

1
2
3
4
5
6
7
8
9
const { exec } = require('child_process');
exec('cat *.js bad_file | wc -l', (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
});

更多详情参考官网

child_process.execFile()

更多详情参考官网

child_process.fork()

childprocess-fork-parameters.png

child_process.fork() 方法是专门用来创建 Node.js 的进程的;

It is important to keep in mind that spawned Node.js child processes are independent of the parent with exception of the IPC communication channel that is established between the two. Each process has its own memory, with their own V8 instances. Because of the additional resource allocations required, spawning a large number of child Node.js processes is not recommended.

特别注意的是,通过 fork 方法创建出来的 Node.js 子进程是与父进程相互独立的,父进程和子进程之间通过 IPC 管道进行通讯;正是因为这种相互独立性,使得子进程也同样拥有自己独立的内存,以及自己独立的 V8 实例;因此,不推荐大量的创建 Node.js 进程;

By default, child_process.fork() will spawn new Node.js instances using the process.execPath of the parent process. The execPath property in the options object allows for an alternative execution path to be used.

默认情况下,通过 child_process.fork() 创建的 Node.js 的子进程会使用父类 process.execPath;

Node.js processes launched with a custom execPath will communicate with the parent process using the file descriptor (fd) identified using the environment variable NODE_CHANNEL_FD on the child process.

如果不想使用父类的 execPath,可以通过子进程的环境变量 NODE_CHANNEL_FD 来进行设置;

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

Note: The shell option available in child_process.spawn() is not supported by child_process.fork() and will be ignored if set.

更多详情参考官网

来看一个例子

通过 sendHandler 将 socket 的句柄如何从父进程传递到子进程,下面的这个例子分别通过 subprocess.js 文件创建了两个子进程 normal 和 special 来获得不同的 socket 句柄;

  1. subprocess.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    process.on('message', (m, socket) => {
    if (m === 'socket') {
    if (socket) {
    // Check that the client socket exists.
    // It is possible for the socket to be closed between the time it is
    // sent and the time it is received in the child process.
    socket.end(`Request handled with ${process.argv[2]} priority`);
    }
    }
    });

    子进程,通过在 message 频道上对父进程进行监听,一旦有消息传递过来便判断是否是 socket 类型,然后进行处理;

  2. 主进程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const { fork } = require('child_process');
    const normal = fork('subprocess.js', ['normal']);
    const special = fork('subprocess.js', ['special']);

    // Open up the server and send sockets to child. Use pauseOnConnect to prevent
    // the sockets from being read before they are sent to the child process.
    const server = require('net').createServer({ pauseOnConnect: true });
    server.on('connection', (socket) => {

    // If this is special priority
    if (socket.remoteAddress === '74.125.127.100') {
    special.send('socket', socket);
    return;
    }
    // This is normal priority
    normal.send('socket', socket);
    });
    server.listen(1337);

    可见,主进程通过用户自定义的 javascript Nodejs 脚本 fork 出了相应的子进程,并且当 server 的事件 connection 成功以后开始回调 (socket) => {} 方法,然后将 socket 对象传递给 child process;

笔者由此非常的好奇,Express 是如何实现它的子进程逻辑的?在分析完此小节之前,笔者认为,Express 最有可能使用的是 fork 创建出一个 Nodejs 子进程来处理相关的子流程逻辑,但是现在看来,Nodejs 进程因为是一个单独的进程而且拥有独立的 V8 引擎,所以在资源的消耗上来看 Express 肯定不会选择使用它;笔者的这部分猜测将来在分析 Express 的时候再来印证一下;

创建同步子进程

参考官网教程,不再赘述;

Class ChildProcess

ChildProcess 实现了 EventEmitters 接口,因此 ChildProcess 就是一个 EventEmitters 对象;ChildProcess 不能被显式的创建,而必须通过 child_process.spawn(), child_process.exec(), child_process.execFile(), or child_process.fork() 等方法进行创建;

Event: ‘close’

pass,更多详情参考 Class: ChildProcess,以下内容不再赘述,

Event: ‘disconnect’

pass

Event: ‘error’

pass

Event: ‘exit’

pass

Event: ‘message’

pass

subprocess.channel

pass

subprocess.connected

pass

subprocess.disconnect()

pass

subprocess.kill([signal])

pass

subprocess.killed

pass

subprocess.pid

pass

subprocess.send(message[, sendHandle[, options]][, callback])

pass

Example: sending a server object

pass

Reference

https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html