浅析 Node 进程与线程

原创不易,但愿能关注下咱们,再顺手点个赞~~

本文首发于政采云前端团队博客: 浅析 Node 进程与线程javascript

前言

进程与线程是操做系统中两个重要的角色,它们维系着不一样程序的执行流程,经过系统内核的调度,完成多任务执行。今天咱们从 Node.js(如下简称 Node)的角度来一块儿学习相关知识,经过本文读者将了解 Node 进程与线程的特色、代码层面的使用以及它们之间的通讯。css

概念

首先,咱们仍是回顾一下相关的定义:html

进程是一个具备必定独立功能的程序在一个数据集上的一次动态执行的过程,是操做系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。前端

线程是程序执行中一个单一的顺序控制流,它存在于进程之中,是比进程更小的能独立运行的基本单位。java

早期在单核 CPU 的系统中,为了实现多任务的运行,引入了进程的概念,不一样的程序运行在数据与指令相互隔离的进程中,经过时间片轮转调度执行,因为 CPU 时间片切换与执行很快,因此看上去像是在同一时间运行了多个程序。node

因为进程切换时须要保存相关硬件现场、进程控制块等信息,因此系统开销较大。为了进一步提升系统吞吐率,在同一进程执行时更充分的利用 CPU 资源,引入了线程的概念。线程是操做系统调度执行的最小单位,它们依附于进程中,共享同一进程中的资源,基本不拥有或者只拥有少许系统资源,切换开销极小。react

单线程?

咱们经常听到有开发者说 “ Node.js 是单线程的”,那么 Node 确实是只有一个线程在运行吗?算法

首先,在终行如下 Node 代码(示例一):编程

# 示例一
require('http').createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello World');
}).listen(8000);
console.log('process id', process.pid);
复制代码

Node 内建模块 http 建立了一个监听 8000 端口的服务,并打印出该服务运行进程的 pid,控制台输出 pid 为 35919(可变),而后咱们经过命令 top -pid 35919 查看进程的详细信息,以下所示:api

PID    COMMAND      %CPU TIME     #TH  #WQ  #POR MEM    PURG CMPRS  PGRP  PPID  STATE    BOOSTS     %CPU_ME
35919  node         0.0  00:00.09 7    0    35   8564K  0B   8548K  35919 35622 sleeping *0[1]      0.00000
复制代码

咱们看到 #TH (threads 线程) 这一列显示此进程中包含 7 个线程,说明 Node 进程中并不是只有一个线程。事实上一个 Node 进程一般包含:1 个 Javascript 执行主线程;1 个 watchdog 监控线程用于处理调试信息;1 个 v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行;4 个 v8 线程(可参考如下代码),主要用来执行代码调优与 GC 等后台任务;以及用于异步 I / O 的 libuv 线程池。

// v8 初始化线程
const int thread_pool_size = 4; // 默认 4 个线程
default_platform = v8::platform::CreateDefaultPlatform(thread_pool_size);
V8::InitializePlatform(default_platform);
V8::Initialize();
复制代码

其中异步 I/O 线程池,若是执行程序中不包含 I/O 操做如文件读写等,则默认线程池大小为 0,不然 Node 会初始化大小为 4 的异步 I/O 线程池,固然咱们也能够经过 process.env.UV_THREADPOOL_SIZE 本身设定线程池大小。须要注意的是在 Node 中网络 I/O 并不占用线程池。

下图为 Node 的进程结构图:

图片

为了验证上述分析,咱们运行示例二的代码,加入文件 I/O 操做:

# 示例二
require('fs').readFile('./test.log', err => {
  if (err) {
    console.log(err);
    process.exit();
  } else {
    console.log(Date.now(), 'Read File I/O');
  }
});
console.log(process.pid);
复制代码

而后获得以下结果:

PID    COMMAND      %CPU TIME     #TH  #WQ  #POR MEM    PURG CMPR PGRP  PPID  STATE    BOOSTS     %CPU_ME %CPU_OTHRS
39443  node         0.0  00:00.10 11   0    39   8088K  0B   0B   39443 35622 sleeping *0[1]      0.00000 0.00000
复制代码

此时 #TH 一栏的线程数变成了 11,即大小为 4 的 I/O 线程池被建立。至此,咱们针对段首的问题内心有了答案,Node 严格意义讲并不是只有一个线程,一般说的 “Node 是单线程” 实际上是指 JS 的执行主线程只有一个

事件循环

既然 JS 执行线程只有一个,那么 Node 为何还能支持较高的并发?

从上文异步 I/O 咱们也能得到一些思路,Node 进程中经过 libuv 实现了一个事件循环机制(uv_event_loop),当执主程发生阻塞事件,如 I/O 操做时,主线程会将耗时的操做放入事件队列中,而后继续执行后续程序。

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

相关流程可参照下图:

图片

子进程

经过事件循环机制,Node 实现了在 I/O 密集型(I/O-Sensitive)场景下的高并发,可是若是代码中遇到 CPU 密集场景(CPU-Sensitive)的场景,那么主线程将长时间阻塞,没法处理额外的请求。为了应对 CPU-Sensitive 场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process 模块(官方文档)进行进程的建立、通讯、销毁等等。

建立

child_process 模块提供了 4 种异步建立 Node 进程的方法,具体可参考 child_process API,这里作一下简要介绍。

  • spawn 以主命令加参数数组的形式建立一个子进程,子进程以流的形式返回 data 和 error 信息。
  • exec 是对 spawn 的封装,可直接传入命令行执行,以 callback 形式返回 error stdout stderr 信息
  • execFile 相似于 exec 函数,但默认不会建立命令行环境,将直接以传入的文件建立新的进程,性能略微优于 exec
  • fork 是 spawn 的特殊场景,只能用于建立 node 程序的子进程,默认会创建父子进程的 IPC 信道来传递消息

通讯

在 Linux 系统中,能够经过管道、消息队列、信号量、共享内存、Socket 等手段来实现进程通讯。在 Node 中,父子进程可经过 IPC(Inter-Process Communication) 信道收发消息,IPC 由 libuv 经过管道 pipe 实现。一旦子进程被建立,并设置父子进程的通讯方式为 IPC(参考 stdio 设置),父子进程便可双向通讯。

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

示例

如下是 Node.js 建立进程和通讯的一个基础示例,主进程建立一个子进程并将计算斐波那契数列的第 44 项这一 CPU 密集型的任务交给子进程,子进程执行完成后经过 IPC 信道将结果发送给主进程:

main_process.js

# 主进程
const { fork } = require('child_process');
const child = fork('./fib.js'); // 建立子进程
child.send({ num: 44 }); // 将任务执行数据经过信道发送给子进程
child.on('message', message => {
  console.log('receive from child process, calculate result: ', message.data);
  child.kill();
});
child.on('exit', () => {
  console.log('child process exit');
});
setInterval(() => { // 主进程继续执行
  console.log('continue excute javascript code', new Date().getSeconds());
}, 1000);
复制代码

fib.js

# 子进程 fib.js
// 接收主进程消息,计算斐波那契数列第 N 项,并发送结果给主进程
// 计算斐波那契数列第 n 项
function fib(num) {
  if (num === 0) return 0;
  if (num === 1) return 1;
  return fib(num - 2) + fib(num - 1);
}
process.on('message', msg => { // 获取主进程传递的计算数据
  console.log('child pid', process.pid);
  const { num } = msg;
  const data = fib(num);
  process.send({ data }); // 将计算结果发送主进程
});
// 收到 kill 信息,进程退出
process.on('SIGHUP', function() {
  process.exit();
});
复制代码

结果:

child pid 39974
continue excute javascript code 41
continue excute javascript code 42
continue excute javascript code 43
continue excute javascript code 44
receive from child process, calculate result:  1134903170
child process exit
复制代码

集群模式

为了更加方便的管理进程、负载均衡以及实现端口复用,Node 在 v0.6 以后引入了 cluster 模块(官方文档),相对于子进程模块,cluster 实现了单 master 主控节点和多 worker 执行节点的通用集群模式。cluster master 节点能够建立销毁进程并与子进程通讯,子进程之间不能直接通讯;worker 节点则负责执行耗时的任务。

cluster 模块同时实现了负载均衡调度算法,在类 unix 系统中,cluster 使用轮转调度(round-robin),node 中维护一个可用 worker 节点的队列 free,和一个任务队列 handles。当一个新的任务到来时,节点队列队首节点出队,处理该任务,并返回确认处理标识,依次调度执行。而在 win 系统中,Node 经过 Shared Handle 来处理负载,经过将文件描述符、端口等信息传递给子进程,子进程经过信息建立相应的 SocketHandle / ServerHandle,而后进行相应的端口绑定和监听,处理请求。

cluster 大大的简化了多进程模型的使用,如下是使用示例:

# 计算斐波那契数列第 43 / 44 项
const cluster = require('cluster');
// 计算斐波那契数列第 n 项
function fib(num) {
  if (num === 0) return 0;
  if (num === 1) return 1;
  return fib(num - 2) + fib(num - 1);
}
if (cluster.isMaster) { // 主控节点逻辑
  for (let i = 43; i < 45; i++) {
    const worker = cluster.fork() // 启动子进程
    // 发送任务数据给执行进程,并监听子进程回传的消息
    worker.send({ num: i });
    worker.on('message', message => {
      console.log(`receive fib(${message.num}) calculate result ${message.data}`)
      worker.kill();
    });
  }
    
  // 监听子进程退出的消息,直到子进程所有退出
  cluster.on('exit', worker => {
    console.log('worker ' + worker.process.pid + ' killed!');
    if (Object.keys(cluster.workers).length === 0) {
      console.log('calculate main process end');
    }
  });
} else {
  // 子进程执行逻辑
  process.on('message', message => { // 监听主进程发送的信息
    const { num } = message;
    console.log('child pid', process.pid, 'receive num', num);
    const data = fib(num);
    process.send({ data, num }); // 将计算结果发送给主进程
  })
}

复制代码

工做线程

在 Node v10 之后,为了减少 CPU 密集型任务计算的系统开销,引入了新的特性:工做线程 worker_threads(官方文档)。经过 worker_threads 能够在进程内建立多个线程,主线程与 worker 线程使用 parentPort 通讯,worker 线程之间可经过 MessageChannel 直接通讯。

建立

经过 worker_threads 模块中的 Worker 类咱们能够经过传入执行文件的路径建立线程。

const { Worker } = require('worker_threads');
...
const worker = new Worker(filepath);

复制代码

通讯

使用 parentPort 进行父子线程通讯

worker_threads 中使用了 MessagePort(继承于 EventEmitter,参考)来实现线程通讯。worker 线程实例上有 parentPort 属性,是 MessagePort 类型的一个实例,子线程可利用 postMessage 经过 parentPort 向父线程传递数据,示例以下:

const { Worker, isMainThread, parentPort } = require('worker_threads');
// 计算斐波那契数列第 n 项
function fib(num) {
  if (num === 0) return 0;
  if (num === 1) return 1;
  return fib(num - 2) + fib(num - 1);
}
if (isMainThread) { // 主线程执行函数
  const worker = new Worker(__filename);
  worker.once('message', (message) => {
    const { num, result } = message;
    console.log(`Fibonacci(${num}) is ${result}`);
    process.exit();
  });
  worker.postMessage(43);
  console.log('start calculate Fibonacci');
  // 继续执行后续的计算程序
  setInterval(() => {
    console.log(`continue execute code ${new Date().getSeconds()}`);
  }, 1000);
} else { // 子线程执行函数
  parentPort.once('message', (message) => {
    const num = message;
    const result = fib(num);
    // 子线程执行完毕,发消息给父线程
    parentPort.postMessage({
      num,
      result
    });
  });
}

复制代码

结果:

start calculate Fibonacci
continue execute code 8
continue execute code 9
continue execute code 10
continue execute code 11
Fibonacci(43) is 433494437

复制代码

使用 MessageChannel 实现线程间通讯

worker_threads 还能够支持线程间的直接通讯,经过两个链接在一块儿的 MessagePort 端口,worker_threads 实现了双向通讯的 MessageChannel。线程间可经过 postMessage 相互通讯,示例以下:

const {
  isMainThread, parentPort, threadId, MessageChannel, Worker
} = 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}`);
    });
  });
}

复制代码

结果:

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

复制代码

注意

worker_threads 只适用于进程内部 CPU 计算密集型的场景,而不适合于 I/O 密集场景,针对后者,官方建议使用进程的 event_loop 机制,将会更加高效可靠。

总结

Node.js 自己设计为单线程执行语言,经过 libuv 的线程池实现了高效的非阻塞异步 I/O,保证语言简单的特性,尽可能减小编程复杂度。可是也带来了在多核应用以及 CPU 密集场景下的劣势,为了补齐这块短板,Node 可经过内建模块 child_process 建立额外的子进程来发挥多核的能力,以及在不阻塞主进程的前提下处理 CPU 密集任务。

为了简化开发者使用多进程模型以及端口复用,Node 又提供了 cluster 模块实现主-从节点模式的进程管理以及负载调度。因为进程建立、销毁、切换时系统开销较大,worker_threads 模块又随之推出,在保持轻量的前提下,能够利用更少的系统资源高效地处理 进程内 CPU 密集型任务,如数学计算、加解密,进一步提升进程的吞吐率。因篇幅有限,本次分享到此为止,诸多细节期待与你们相互探讨,共同钻研。

推荐阅读

相关文章
相关标签/搜索