基础概念
进程
进程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)
})
通过进程管理器,可以看到我们刚才创建的进程
线程
线程Thread
是操作系统能够进行运算调度的最小单位
,首先我们要清楚线程是隶属于进程的,线程被包含于进程之中
。
一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的。线程自己基本上不拥有系统资源
,但可与同属一个进程的其他的线程共享进程所拥有的全部资源
。
单线程:一个进程只能开一个线程,比如Node.js语言。
Javascript 就是属于单线程,因为它只有一个唯一的执行栈。Node.js 同样是单线程模型,但是其基于事件驱动、异步非阻塞
模式,可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。
Node是单线程的指的是 JavaScript 的执行是单线程的(开发者编写的代码运行在单线程环境中),但 Javascript 的宿主环境,无论是 Node 还是浏览器都是多线程的(注意上图一个进程开启后,产生8个线程,只有一个执行主线程)。
单线程面临的一些问题:
- Node.js 开发过程中,错误会引起整个应用退出,应用的健壮性值得考验,尤其是错误的异常抛出,以及进程守护是必须要做的。
- 单线程无法利用多核CPU,但是后来Node.js 提供的API(child_process.fork 和cluster)以及一些第三方工具相应都得到了解决。
多线程:一个进程多同时开多个线程,比如Java语言。 多线程的代价还在于创建新的线程和执行期上下文线程的切换开销,由于每创建一个线程就会占用一定的内存,当应用程序并发大了之后,内存将会很快耗尽。
进程与线程的区别
进程有独立的地址空间
,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。- 线程有自己的堆栈和局部变量,但
线程之间没有单独的地址空间
,一个线程死掉就等于整个进程死掉。 - 多进程的程序要比多线程的程序健壮,但在
进程切换时,耗费资源较大
,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
Node.js进程
Process
process.env
:环境变量,例如通过 process.env.NODE_ENV 获取不同环境项目配置信息process.nextTick
:这个在谈及 Event Loop 时经常为会提到process.pid
:获取当前进程idprocess.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
- cluster模块
调用child_process中的fork方法来创建子进程
。 - cluster模块采用的是经典的
主从模型
,Cluster会创建一个master,然后根据你指定的数量复制出多个子进程,可以使用cluster.isMaster
属性判断当前进程是master还是worker(工作进程)。 由master进程来管理所有的子进程,主进程不负责具体的任务处理,而是负责调度和管理
。 - cluster模块使用内置的负载均衡使用了
Round-robin
算法(也被称之为循环算法)。 当使用Round-robin调度策略时,master accepts()所有传入的连接请求,然后将相应的TCP请求处理发送给选中的工作进程(该方式仍然通过IPC来进行通信)。 - 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);
}
以上示例是一个最简单的多进程架构模型。
进程间通信
在 Linux 系统中,可以通过管道、消息队列、信号量、共享内存、Socket 等手段来实现进程通信。
- 在 Node 中,父子进程可通过
IPC(Inter-Process Communication) 信道
收发消息,IPC 由 libuv 通过管道 pipe 实现。 一旦子进程被创建,并设置父子进程的通信方式为 IPC(参考 stdio 设置),父子进程即可双向通信。 - 还可以借助网络完成进程通信,不仅能跨进程还能跨机器。
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 运行环境 |
主线程与子线程通信,示例:
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,以及主线程的高效执行。
总结
- Node.js 本身设计为单线程执行语言,通过 libuv 的线程池实现了高效的非阻塞异步 I/O,保证语言简单的特性,尽量减少编程复杂度。
- 但是也带来了在多核应用以及 CPU 密集场景下的劣势,为了补齐这块短板,Node 可通过内建模块 child_process 创建额外的子进程来发挥多核的能力,以及在不阻塞主进程的前提下处理 CPU 密集任务。
- 为了简化开发者使用多进程模型以及端口复用,Node 又提供了 cluster 模块实现主-从节点模式的进程管理以及负载调度。
- 由于进程创建、销毁、切换时系统开销较大,worker_threads 工作线程模块又随之推出,在保持轻量的前提下,可以利用更少的系统资源高效地处理 进程内 CPU 密集型任务,如数学计算、加解密,进一步提高进程的吞吐率。
libuv 的线程池实现了高效的非阻塞异步 I/O
-> 内建模块child_process创建额外的子进程来发挥多核的能力
-> cluster 模块实现主-从节点模式的进程管理以及负载调度
-> worker_threads 工作线程模块高效地处理进程内CP密集型任务