Node.js理论实践之《进程与线程》

1,070 阅读13分钟

基础概念

进程

进程Process是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器。进程是资源分配的最小单位

Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构。 进程之间只有建立了IPC (Inter-process communication) ,即进程间通信,进程之间才可数据共享

示例:

const http = require('http');

const server = http.createServer();
server.listen(3000,()=>{
    process.title='Node.js理论实践进程';
    console.log('进程id',process.pid)
})

通过进程管理器,可以看到我们刚才创建的进程

image

线程

线程Thread是操作系统能够进行运算调度的最小单位,首先我们要清楚线程是隶属于进程的,线程被包含于进程之中

一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的。线程自己基本上不拥有系统资源,但可与同属一个进程的其他的线程共享进程所拥有的全部资源

单线程:一个进程只能开一个线程,比如Node.js语言。 Javascript 就是属于单线程,因为它只有一个唯一的执行栈。Node.js 同样是单线程模型,但是其基于事件驱动、异步非阻塞模式,可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。 Node是单线程的指的是 JavaScript 的执行是单线程的(开发者编写的代码运行在单线程环境中),但 Javascript 的宿主环境,无论是 Node 还是浏览器都是多线程的(注意上图一个进程开启后,产生8个线程,只有一个执行主线程)。

单线程面临的一些问题:

  1. Node.js 开发过程中,错误会引起整个应用退出,应用的健壮性值得考验,尤其是错误的异常抛出,以及进程守护是必须要做的。
  2. 单线程无法利用多核CPU,但是后来Node.js 提供的API(child_process.fork 和cluster)以及一些第三方工具相应都得到了解决。

多线程:一个进程多同时开多个线程,比如Java语言。 多线程的代价还在于创建新的线程和执行期上下文线程的切换开销,由于每创建一个线程就会占用一定的内存,当应用程序并发大了之后,内存将会很快耗尽。

进程与线程的区别

  • 进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。
  • 线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉。
  • 多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

Node.js进程

Process

  • process.env:环境变量,例如通过 process.env.NODE_ENV 获取不同环境项目配置信息
  • process.nextTick:这个在谈及 Event Loop 时经常为会提到
  • process.pid:获取当前进程id
  • process.ppid:当前进程对应的父进程
  • process.cwd():获取当前进程工作目录
  • process.platform:获取当前进程运行的操作系统平台
  • process.uptime():当前进程已运行时间,例如:pm2 守护进程的 uptime 值
  • 进程事件:process.on('uncaughtException', cb) 捕获异常信息、process.on('exit', cb)进程推出监听
  • 三个标准流:process.stdout 标准输出、process.stdin 标准输入、process.stderr 标准错误输出

创建进程

进程创建有多种方式,本文从child_process模块和cluster模块入手。

child_process

有四种方式,每一种方式都有对应的同步版本:

  • child_process.spawn():适用于返回大量数据,例如图像处理,二进制数据处理。
  • child_process.exec():适用于小量数据,maxBuffer 默认值为 200 * 1024 超出这个默认值将会导致程序崩溃,数据量过大可采用 spawn。
  • child_process.execFile():类似 child_process.exec(),区别是不能通过shell来执行,不支持像 I/O 重定向和文件查找这样的行为
  • child_process.fork(): 衍生新的进程,进程之间是相互独立的,每个进程都有自己的 V8 实例、内存,系统资源是有限的,不建议衍生太多的子进程出来,通长根据系统CPU核心数设置。

.exec().execFile().fork()底层都是通过.spawn()实现的。.exec()execFile()额外提供了回调,当子进程停止的时候执行。

child_process.spawn(command[, args][, options]) child_process.exec(command[, options][, callback]) child_process.execFile(file[, args][, options][, callback]) child_process.fork(modulePath[, args][, options])

示例:

var child_process = require('child_process')
var spawn = child_process.spawn
var execFile = child_process.execFile
var exec = child_process.exec

//spawn
var child = spawn('ls', ['-al', '.'])
child.stdout.on('data', function(data) {
	console.log(data);
});
child.stderr.on('data', function(data){
	console.log('error from child: ' + data);
});

//exec
exec('ls -al .', function(error, stdout, stderr) {
  if (error) {
    throw error
  }
  console.log(stdout)
})

//execFile
execFile('ls -al .', { shell: '/bin/bash' }, function(error, stdout, stderr) {
  if (error) {
    throw error
  }
  console.log(stdout)
})

execFile('ls', ['-al', '.'], function(error, stdout, stderr) {
  if (error) {
    throw error
  }
  console.log(stdout)
})

注意:

  • 默认情况下,spawn函数并不会衍生新的shell(设置参数{shell: true}后就可以),执行通过参数传递进来的命令。由于不会创建新的shell,这是spawn函数比exec函数高效的主要原因。
  • exec函数与spawn函数还有一点主要的区别,spawn函数通过流操作命令执行的结果,而exec函数则将程序执行的结果缓存起来,最后将缓存的结果传给回调函数中。
  • execFile与exec不同,execFile通常用于执行文件,而且并不会创建子shell环境,这也是execFile函数比exec函数高效的主要原因。
  • 如果你需要使用shell句法,并且期望命令操作的文件比较小,推荐使用exec,如果执行命令后得到的数据太大,推荐使用spawn。如果需要执行文件,推荐会用execFile。

fork,父进程parent.js示例:

const { fork } = require('child_process');

const forked = fork('child.js');

forked.on('message', (msg) => {
  console.log('Message from child', msg);
});

forked.send({ hello: 'world' });

fork,子进程child.js示例:

process.on('message', (msg) => {
  console.log('Message from parent:', msg);
});

let counter = 0;

setInterval(() => {
  process.send({ counter: counter++ });
}, 1000);

在父进程的程序中,开发者可以fork文件(这个文件将会通过node命令执行),然后监听message事件。 当子进程调用process.send函数的时,父进程的message事件将会被触发。在上面的代码中,子进程每分钟都会调用一次process.send函数。 当从父进程向子进程传递数据时,在父进程中调用send函数后,子进程的message监听事件将会被触发,从而获取到父进程传递的消息。

当执行上面的父进程后,父进程将会向子进程传递对象{hello: 'world'},然后子进程将会把这些父进程传递的消息打印出来。 同时子进程将每隔一分钟向父进程发送一个递增的数字,这些数字将会在父进程控制窗口打印出来。

cluster

image

  1. cluster模块调用child_process中的fork方法来创建子进程
  2. cluster模块采用的是经典的主从模型,Cluster会创建一个master,然后根据你指定的数量复制出多个子进程,可以使用cluster.isMaster属性判断当前进程是master还是worker(工作进程)。 由master进程来管理所有的子进程,主进程不负责具体的任务处理,而是负责调度和管理
  3. cluster模块使用内置的负载均衡使用了Round-robin算法(也被称之为循环算法)。 当使用Round-robin调度策略时,master accepts()所有传入的连接请求,然后将相应的TCP请求处理发送给选中的工作进程(该方式仍然通过IPC来进行通信)。
  4. cluster模块为什么可以让多个子进程监听同一个端口呢?原因是master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器, 当来自前端的请求触发服务器的connection事件后,master会将对应的socket具柄发送给子进程。

无论是 child_process 模块还是 cluster 模块,为了解决 Node.js 实例单线程运行,无法利用多核 CPU 的问题而出现的。 核心就是父进程(即 master 进程)负责监听端口,接收到新的请求后将其分发给下面的 worker 进程

示例:

const http = require('http');
const numCPUs = require('os').cpus().length;
const cluster = require('cluster');

if(cluster.isMaster){
  process.title = 'node-master'
  console.log('Master proces id is',process.pid);

  // 开启子进程
  for(let i= 0;i<numCPUs;i++){
    cluster.fork();
  }
  cluster.on('exit',function(worker,code,signal){
    console.log('worker process died,id',worker.process.pid)
  })
}else{
  // Worker可以共享同一个TCP连接
  // 这里是一个http服务器
  process.title = 'node-worker'
  http.createServer(function(req,res){
    res.writeHead(200);
    res.end('hello word');
  }).listen(8000);
}

image

以上示例是一个最简单的多进程架构模型。

进程间通信

在 Linux 系统中,可以通过管道、消息队列、信号量、共享内存、Socket 等手段来实现进程通信。

  1. 在 Node 中,父子进程可通过 IPC(Inter-Process Communication) 信道收发消息,IPC 由 libuv 通过管道 pipe 实现。 一旦子进程被创建,并设置父子进程的通信方式为 IPC(参考 stdio 设置),父子进程即可双向通信。
  2. 还可以借助网络完成进程通信,不仅能跨进程还能跨机器。

IPC,示例:

var n = child_process.fork('./child.js');
n.on('message', function(m) {
  console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });

// ./child.js
process.on('message', function(m) {
  console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });

进程之间通过 process.send 发送消息,通过监听 message 事件接收消息。 当一个进程发送消息时,会先序列化为字符串,送入 IPC 信道的一端,另一个进程在另一端接收消息内容,并且反序列化,因此我们可以在进程之间传递对象。

Socket,示例:

//main.js
const { spawn } = require('child_process');
const child = spawn('node', ['./child.js'], {
  stdio: [null, null, null, 'pipe'],
});
child.stdio[3].on('data', data => {
  console.log('222', data.toString());
});

//child.js
const net = require('net');
const pipe = net.Socket({ fd: 3 });
pipe.write('killme');

Node.js线程

Node 10.5.0 的发布,官方给出了一个实验性质的模块 worker_threads 给 Node 提供真正的多线程能力。 所以解决 CPU 密集型操作的性能问题是使用 Worker Threads。浏览器在很久之前就已经有了 Workers 特性了。

  • worker_threads 是一个实验模块,启动时需要使用--experimental-worker标志。
  • worker_threads模块提供了真正的单进程多线程使用方法,我们可以将CPU密集的任务交给线程去解决,等有了结果后通过MessageChannel跨线程通信/或者使用共享内存.
  • worker_threads 模块允许使用多个线程来同时执行 JavaScript 代码。

如果你需要在 Node.js 中运行 CPU 密集型的操作,目前不建议在生产环境中使用 worker 线程,不然不断地创建 worker 线程的代价将会超过它带来的好处。

worker_threads模块内的对象和类

  • isMainThread: true表示为主线程, false表示为 worker 线程
  • parentPort: 主线程为null, worker 线程表示为父进程的 MessagePort 类型对象
  • SHARE_ENV: 指示主线程和 worker线程应共享的环境变量
  • threadId: 当前线程的整数标识符,唯一
  • workerData: 创建 worker 线程的初始化数据
  • MessageChannel 类: 类的实例表示异步双向通信通道,即 MessagePort 实例
  • MessagePort 类: 类的实例表示异步双向通信通道的一端
  • Worker 类: 表示一个独立的 JavaScript 执行线程

Node.js单线程与多线程区别:

单线程下的 Node.js 多线程Workers下 Node.js
一个进程 一个进程
一个执行主线程(外加一些辅助线程) 一个执行主线程+多个执行工作线程(外加一些辅助线程)
一个事件循环 每个线程都拥有独立的事件循环
一个 JS 引擎实例 每个线程都拥有一个 JS 引擎实例
一个 Node.js 实例 每个线程都拥有一个 Node.js 实例
一个 JavaScript 运行环境 多个独立的 JavaScript 运行环境

image

主线程与子线程通信,示例:

const { Worker, isMainThread, threadId, parentPort, workerData, MessageChannel } = require('worker_threads');
if (isMainThread) {
  console.log('我是主线程', isMainThread);
  const worker = new Worker(__filename, { workerData: 0 });//加载文件方式,__filename指当前文件

  worker.postMessage({name: 'hi, 主线程同学'});
  worker.once('message', (message) => {
    console.log('主线程接收信息:', message);
  });
} else {
  console.log(`我是worker线程, threadId: ${threadId}, workerData:${workerData}`);
  parentPort.once('message', (obj) => {
    console.log('子线程接收信息:', obj);
    parentPort.postMessage(obj.name);
  })
}

执行node --experimental-worker index.js后,输出:

$ 我是主线程 true
$ 我是worker线程, threadId: 1, workerData:0
$ 子线程接收信息: { name: 'hi, 主线程同学' }
$ 主线程接收信息: hi, 主线程同学

线程间通信,示例:

const { Worker, isMainThread, threadId, parentPort, workerData, MessageChannel } = require('worker_threads');
if (isMainThread) {
  const worker1 = new Worker(__filename);
  const worker2 = new Worker(__filename);
  // 创建通信信道,包含 port1 / port2 两个端口
  const subChannel = new MessageChannel();
  // 两个子线程绑定各自信道的通信入口
  worker1.postMessage({ port: subChannel.port1 }, [ subChannel.port1 ]);
  worker2.postMessage({ port: subChannel.port2 }, [ subChannel.port2 ]);
} else {
  parentPort.once('message', value => {
    value.port.postMessage(`Hi, I am thread${threadId}`);
    value.port.on('message', msg => {
      console.log(`thread${threadId} receive: ${msg}`);
    });
  });
}

执行node --experimental-worker index.js后,输出:

$ thread2 receive: Hi, I am thread1
$ thread1 receive: Hi, I am thread2

事件循环

既然 JS 执行线程只有一个,那么 Node 为什么还能支持较高的并发? Node 进程中通过 libuv 实现了一个事件循环机制(uv_event_loop),当执主程发生阻塞事件,如 I/O 操作时,主线程会将耗时的操作放入事件队列中,然后继续执行后续程序。

uv_event_loop 尝试从 libuv 的线程池(uv_thread_pool)中取出一个空闲线程去执行队列中的操作,执行完毕获得结果后,通知主线程,主线程执行相关回调,并且将线程实例归还给线程池。 通过此模式循环往复,来保证非阻塞 I/O,以及主线程的高效执行。

image

总结

  1. Node.js 本身设计为单线程执行语言,通过 libuv 的线程池实现了高效的非阻塞异步 I/O,保证语言简单的特性,尽量减少编程复杂度。
  2. 但是也带来了在多核应用以及 CPU 密集场景下的劣势,为了补齐这块短板,Node 可通过内建模块 child_process 创建额外的子进程来发挥多核的能力,以及在不阻塞主进程的前提下处理 CPU 密集任务。
  3. 为了简化开发者使用多进程模型以及端口复用,Node 又提供了 cluster 模块实现主-从节点模式的进程管理以及负载调度
  4. 由于进程创建、销毁、切换时系统开销较大,worker_threads 工作线程模块又随之推出,在保持轻量的前提下,可以利用更少的系统资源高效地处理 进程内 CPU 密集型任务,如数学计算、加解密,进一步提高进程的吞吐率。

libuv 的线程池实现了高效的非阻塞异步 I/O -> 内建模块child_process创建额外的子进程来发挥多核的能力 -> cluster 模块实现主-从节点模式的进程管理以及负载调度 -> worker_threads 工作线程模块高效地处理进程内CP密集型任务