进程和线程

“进程” 是计算机系统进行资源分配和调度的基本单位,我们可以理解为计算机每开启一个任务就会创建至少一个进程来处理,有时会创建多个,如 Chrome 浏览器的选项卡,其目的是为了防止一个进程挂掉而应用停止工作,而 “线程” 是程序执行流的最小单元,Node.js 默认是单进程、单线程的,我们将这个进程称为主进程,也可以通过 child_process 模块创建子进程实现多进程,我们称这些子进程为 “工作进程”,并且归主进程管理,进程之间默认是不能通信的,且所有子进程执行任务都是异步的。

spawn 实现多进程

spawn 创建子进程

Node.js 中执行一个 JS 文件,如果想在这个文件中再同时(异步)执行另一个 JS 文件,可以使用 child_process 模块中的 spawn 来实现,spawn 可以帮助我们创建一个子进程,用法如下。

/* 文件:process.js */
const { spawn } = require('child_process');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js', '--port', '3000'], {
  cwd: path.join(__dirname, 'test') // 指定子进程的当前工作目录
});

// 出现错误触发
child.on('error', err => console.log(err));

// 子进程退出触发
child.on('exit', () => console.log('exit'));

// 子进程关闭触发
child.on('close', () => console.log('close'));

// exit
// close

spawn 方法可以帮助我们创建一个子进程,这个方法的返回值就是子进程 idspawn 接收以下几个参数:

  • command:要运行的命令;
  • args:类型为数组,数组内第一项为文件名,后面项依次为执行文件的命令参数和值;
  • options:选项,类型为对象,用于指定子进程的当前工作目录和主进程、子进程的通信规则等,具体可查看 官方文档

error 事件在子进程出错时触发,exit 事件在子进程退出时触发,close 事件在子进程关闭后触发,在子进程任务结束后 exit 一定会触发,close 不一定触发。

/* 文件:~test/sub_process.js */
// 打印子进程执行 sub_process.js 文件的参数
console.log(process.argv);

通过上面代码打印了子进程执行时的参数,但是我们发现主进程窗口并没有打印,我们希望的是子进程的信息可以反馈给主进程,要实现通信需要在创建子进程时在第三个参数 options 中配置 stdio 属性定义。

spawn 定义输入、输出

/* 文件:process.js */
const { spawn } = require('child_process');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js', '--port', '3000'], {
  cwd: path.join(__dirname, 'test') // 指定子进程的当前工作目录
  // stdin: [process.stdin, process.stdout, process.stderr]
  stdio: [0, 1, 2] // 配置标准输入、标准输出、错误输出
});

// C:\Program Files\Node.js\node.exe,g:\process\test\sub_process.js,--port,3000
/* 文件:~test/sub_process.js */
// 使用主进程的标准输出,输出 sub_process.js 文件执行的参数
process.stdout.write(process.argv.toString());

通过上面配置 optionsstdio 值为数组,上面的两种写法作用相同,都表示子进程和主进程共用了主进程的标准输入、标准输出、和错误输出,实际上并没有实现主进程与子进程的通信,其中 0stdin 代表标准输入,1stdout 代表标准输出,2stderr 代表错误输出。

上面这样的方式只要子进程执行 sub_process.js 就会在窗口输出,如果我们希望是否输出在主进程里面控制,即实现子进程与主进程的通信,看下面用法。

/* 文件:process.js */
const { spawn } = require('child_process');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: ['pipe']
});

child.stdout.on('data', data => console.log(data.toString()));

// hello world
/* 文件:~test/sub_process.js */
// 子进程执行 sub_process.js
process.stdout.write('hello world');

上面将 stdio 内数组的值配置为 pipe(默认不写就是 pipe),则通过流的方式实现主进程和子进程的通信,通过子进程的标准输出(可写流)写入,在主进程通过子进程的标准输出通过 data 事件读取的流在输出到窗口(这种写法很少用),上面都只在主进程中开启了一个子进程,下面举一个开启多个进程的例子。

例子的场景是主进程开启两个子进程,先运行子进程 1 传递一些参数,子进程 1 将参数取出返还给主进程,主进程再把参数传递给子进程 2,通过子进程 2 将参数写入到文件 param.txt 中,这个过程不代表真实应用场景,主要目的是体会主进程和子进程的通信过程。

/* 文件:process.js */
const { spawn } = require('child_process');
const path = require('path');

// 创建子进程
const child1 = spawn('node', ['sub_process_1.js', '--port', '3000'], {
  cwd: path.join(__dirname, 'test'),
});

const child2 = spawn('node', ['sub_process_2.js'], {
  cwd: path.join(__dirname, 'test'),
});


// 读取子进程 1 写入的内容,写入子进程 2
child1.stdout.on('data', data => child2.stdout.write(data.toString));
/* 文件:~test/sub_process_1.js */
// 获取 --port 和 3000
process.argv.slice(2).forEach(item => process.stdout.write(item));
/* 文件:~test/sub_process_2.js */
const fs = require('fs');

// 读取主进程传递的参数并写入文件
process.stdout.on('data', data => {
  fs.writeFile('param.txt', data, () => {
    process.exit();
  });
});

有一点需要注意,在子进程 2 写入文件的时候,由于主进程不知道子进程 2 什么时候写完,所以主进程会卡住,需要子进程在写入完成后调用 process.exit 方法退出子进程,子进程退出并关闭后,主进程会随之关闭。

在我们给 options 配置 stdio 时,数组内其实可以对标准输入、标准输出和错误输出分开配置,默认数组内为 pipe 时代表三者都为 pipe,分别配置看下面案例。

/* 文件:process.js */
const { spawn } = require('spawn');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: [0, 'pipe', 2]
});

// world
/* 文件:~test/sub_process.js */
console.log('hello');
console.error('world');

上面代码中对 stderr 实现了默认打印而不通信,对标准输入实现了通信,还有一种情况,如果希望子进程只是默默的执行任务,而在主进程命令窗口什么类型的输出都禁止,可以在数组中对应位置给定值 ignore,将上面案例修改如下。

/* 文件:process.js */
const { spawn } = require('spawn');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: [0, 'pipe', 'ignore']
});
/* 文件:~test/sub_process.js */
console.log('hello');
console.error('world');

这次我们发现无论标准输出和错误输出都没有生效,上面这些方式其实是不太方便的,因为输出有 stdoutstderr,在写法上没办法统一,可以通过下面的方式来统一。

标准进程通信

/* 文件:process.js */
const { spawn } = require('spawn');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: [0, 'pipe', 'ignore', 'ipc']
});

child.on('message', data => {
  console.log(data);

  // 回复消息给子进程
  child.send('world');

  // 杀死子进程
  // process.kill(child.pid);
});

// hello
/* 文件:~test/sub_process.js */
// 给主进程发送消息
process.send('hello');

// 接收主进程回复的消息
process.on('message', data => {
  console.log(data);

  // 退出子进程
  process.exit();
});

// world

这种方式被称为标准进程通信,通过给 optionsstdio 数组配置 ipc,只要数组中存在 ipc 即可,一般放在数组开头或结尾,配置 ipc 后子进程通过调用自己的 send 方法发送消息给主进程,主进程中用子进程的 message 事件进行接收,也可以在主进程中接收消息的 message 事件的回调当中,通过子进程的 send 回复消息,并在子进程中用 message 事件进行接收,这样的编程方式比较统一,更贴近于开发者的意愿。

退出和杀死子进程

上面代码中子进程在接收到主进程的消息时直接退出,也可以在子进程发送给消息给主进程时,主进程接收到消息直接杀死子进程,代码如下。

/* 文件:process.js */
const { spawn } = require('spawn');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: [0, 'pipe', 'ignore', 'ipc']
});

child.on('message', data => {
  console.log(data);

  // 杀死子进程
  process.kill(child.pid);
});

// hello world
/* 文件:~test/sub_process.js */
// 给主进程发送消息
process.send('hello');

从上面代码我们可以看出,杀死子进程的方法为 process.kill,由于一个主进程可能有多个子进程,所以指定要杀死的子进程需要传入子进程的 pid 属性作为 process.kill 的参数。

注意:退出子进程 process.exit 方法是在子进程中操作的,此时 process 代表子进程,杀死子进程 process.kill 是在主进程中操作的,此时 process 代表主进程。

独立子进程

我们前面说过,child_process 模块创建的子进程是被主进程统一管理的,如果主进程挂了,所有的子进程也会受到影响一起挂掉,但其实使用多进程一方面为了提高处理任务的效率,另一方面也是为了当一个进程挂掉时还有其他进程可以继续工作,不至于整个应用挂掉,这样的例子非常多,比如 Chrome 浏览器的选项卡,比如 VSCode 编辑器运行时都会同时开启多个进程同时处理任务,其实在 spawn 创建子进程时,也可以实现子进程的独立,即子进程不再受主进程的控制和影响。

/* 文件:process.js */
const { spawn } = require('spawn');
const path = require('path');

// 创建子进程
const child = spawn('node', ['sub_process.js'], {
  cwd: path.join(__dirname, 'test'),
  stdio: 'ignore',
  detached: true
});

// 与主进程断绝关系
child.unref();
/* 文件:~test/sub_process.js */
const fs = require('fs');

setInterval(() => {
  fs.appendFileSync('test.txt', 'hello');
});

要想创建的子进程独立,需要在创建子进程时配置 detached 参数为 true,表示该子进程不受控制,还需调用子进程的 unref 方法与主进程断绝关系,但是仅仅这样子进程可能还是会受主进程的影响,要想子进程完全独立需要保证子进程一定不能和主进程共用标准输入、标准输出和错误输出,也就是 stdio 必须设置为 ignore,这也就代表着独立的子进程是不能和主进程进行标准进程通信,即不能设置 ipc

fork 实现多进程

fork 的使用

fork 也是 child_process 模块的一个方法,与 spawn 类似,是在 spawn 的基础上又做了一层封装,我们看一个 fork 使用的例子。

/* 文件:process.js */
const fork = require('child_process');
const path = require('path');

// 创建子进程
const child = fork('sub_process.js', ['--port', '3000'], {
  cwd: path.join(__dirname, 'test'),
  silent: true
});

child.send('hello world');
/* 文件:~test/sub_process.js */
// 接收主进程发来的消息
process.on('message', data => console.log(data));

fork 的用法与 spawn 相比有所改变,第一个参数是子进程执行文件的名称,第二个参数为数组,存储执行时的参数和值,第三个参数为 options,其中使用 slilent 属性替代了 spawnstdio,当 silenttrue 时,此时主进程与子进程的所有非标准通信的操作都不会生效,包括标准输入、标准输出和错误输出,当设为 false 时可正常输出,返回值依然为一个子进程。

fork 创建的子进程可以直接通过 send 方法和监听 message 事件与主进程进行通信。

fork 的原理

其实 fork 的原理非常简单,只是在子进程模块 child_process 上挂了一个 fork 方法,而在该方法内调用 spawn 并将 spawn 返回的子进程作为返回值返回,下面进行简易实现。

/* 文件:fork.js */
const childProcess = require('child_process');
const path = require('path');

// 封装原理
childProcess.fork = function(modulePath, args, options) {
  const stdio = options.silent ?
    ['ignore', 'ignore', 'ignore', 'ipc'] :
    [0, 1, 2, 'ipc'];

  return childProcess.spawn('node', [modulePath, ...args], {
    ...options,
    stdio
  });
}

// 创建子进程
const child = fork('sub_process.js', ['--port', '3000'], {
  cwd: path.join(__dirname, 'test'),
  silent: false
});

// 向子进程发送消息
child.send('hello world');
/* 文件:~test/sub_process.js */
// 接收主进程发来的消息
process.on('message', data => console.log(data));

// hello world

spawn 中的有一些 fork 没有传的参数(如使用 node 执行文件),都在内部调用 spawn 时传递默认值或将默认参数与 fork 传入的参数进行整合,着重处理了 spawn 没有的参数 silent,其实就是处理成了 spawnstdio 参数两种极端的情况(默认使用 ipc 通信),封装 fork 就是让我们能更方便的创建子进程,可以更少的传参。

execFile 和 exec 实现多进程

execFileexecchild_process 模块的两个方法,execFile 是基于 spawn 封装的,而 exec 是基于 execFile 封装的,这两个方法用法大同小异,execFile 可以直接创建子进程进行文件操作,而 exec 可以直接开启子进程执行命令,常见的应用场景如 http-server 以及 weboack-dev-server 等命令行工具在启动本地服务时自动打开浏览器。

/* execFile 和 exec */
const { execFile, exec } = require('child_process');

const execFileChild = execFile('node', ['--version'], (err, stdout, stderr) => {
  if (error) throw error;
  console.log(stdout);
  console.log(stderr);
});

const execChild = exec('node --version', (err, stdout, stderr) => {
  if (err) throw err;
  console.log(stdout);
  console.log(stderr);
});

execexecFile 的区别在于传参,execFile 第一个参数为文件的可执行路径或命令,第二个参数为命令的参数集合(数组),第三个参数为 options,最后一个参数为回调函数,回调函数的形参为错误、标准输出和错误输出。

exec 在传参上将 execFile 的前两个参数进行了整合,也就是命令与命令参数拼接成字符串作为第一参数,后面的参数都与 execFile 相同。

cluster 集群

开启进程需要消耗内存,所以开启进程的数量要适合,合理运用多进程可以大大提高效率,如 Webpack 对资源进行打包,就开启了多个进程同时进行,大大提高了打包速度,集群也是多进程重要的应用之一,用多个进程同时监听同一个服务,一般开启进程的数量跟 CPU 核数相同为好,此时多个进程监听的服务会根据请求压力分流处理,也可以通过设置每个子进程处理请求的数量来实现 “负载均衡”。

使用 ipc 实现集群

ipc 标准进程通信使用 send 方法发送消息时第二个参数支持传入一个服务,必须是 http 服务或者 tcp 服务,子进程通过 message 事件进行接收,回调的参数分别对应发送的参数,即第一个参数为消息,第二个参数为服务,我们就可以在子进程创建服务并对主进程的服务进行监听和操作(listen 除了可以监听端口号也可以监听服务),便实现了集群,代码如下。

/* 文件:server.js */
// os 模块用于获取系统信息
const os = require('os');
const http = require('http');
const path = require('path');
const { fork } = rquire('child_process');

// 创建服务
const server = createServer((res, req) => {
  res.end('hello');
}).listen(3000);

// 根据 CPU 个数创建子进程
os.cpus().forEach(() => {
  fork('child_server.js', {
    cwd: path.join(__dirname)
  }).send('server', server);
});
/* 文件:child_server.js */
const http = require('http');

// 接收来自主进程发来的服务
process.on('message', (data, server) => {
  // // 子进程共用主进程的服务
  http.createServer((req, res) => {
    res.end('child' + process.pid);
  }).listen(server);
});

上面代码中由主进程处理的请求会返回 hello,由子进程处理的请求会返回 child 加进程的 pid 组成的字符串。

使用 cluster 实现集群

cluster 模块是 Node.js 提供的用来实现集群的,他将 child_process 创建子进程的方法集成进去,实现方式要比使用 ipc 更简洁。

/* 文件:cluster.js */
const cluster = require('cluster');
const http = require('http');
const os = require('os');

// 判断当前执行的进程是否为主进程,为主进程则创建子进程,否则用子进程监听服务
if (cluster.isMaster) {
  // 创建子进程
  os.cpus().forEach(() => cluster.fork());
} else {
  // 创建并监听服务
  http.createServer((req, res) => {
    res.end('child' + process.pid);
  }).listen(3000);
}

上面代码既会执行 if 又会执行 else,这看似很奇怪,但其实不是在同一次执行的,主进程执行时会通过 cluster.fork 创建子进程,当子进程被创建会将该文件再次执行,此时则会执行 else 中对服务的监听,还有另一种用法将主进程和子进程执行的代码拆分开,逻辑更清晰,用法如下。

/* 文件:cluster.js */
const cluster = require('cluster');
const path = require('path');
const os = require('os');

// 设置子进程读取文件的路径
cluster.setupMaster({
  exec: path.join(__dirname, 'cluster-server.js')
});

// 创建子进程
os.cpus().forEach(() => cluster.fork());
/* 文件:cluster-server.js */
const http = require('http');

// 创建并监听服务
http.createServer((req, res) => {
  res.end('child' + process.pid);
}).listen(3000);

通过 cluster.setupMaster 设置子进程执行文件以后,就可以将主进程和子进程的逻辑拆分开,在实际的开发中这样的方式也是最常用的,耦合度低,可读性好,更符合开发的原则。

总结

本篇着重的介绍了 Node.js 多进程的实现方式以及集群的使用,之所以在开头长篇大论的介绍 spawn,是因为其他的所有跟多进程相关的方法包括 forkexec 等,以及模块 cluster 都是基于 spawn 的封装,如果对 spawn 足够了解,其他的也不在话下,希望大家通过这篇可以在 Node.js 多进程相关的开发中起到一个 “路标” 的作用。