Node.js 中的多进程与多线程

0. 背景

单线程运行模型
Node.js架构在Chrome V8引擎之上,它的模型与浏览器类似,js代码运行在单个进程的单个线程上。

  • 优点:程序状态是单一的,在没有多线程的情况下没有锁和线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率。
  • 缺点:如今CPU基本均是多核的,有的服务器往往还有多个CPU。一个Node进程只能利用一个核,这将导致Node应用无法充分利用多核CPU服务器。另外,由于Node执行在单线程上,不适合处理CPU密集型的任务。

1. 服务模型变迁历史

  • 同步 :同步服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。
  • 多进程 :每个连接都需要一个进程来服务,相同的状态将会在内存中存在很多份,造成浪费。
  • 多线程 :让一个线程服务一个请求,线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。apache httpdC10k问题
  • 事件驱动 :就是异步IO,使用事件机制,用单个线程来服务所有请求,避免了不必要的内存开销和上下文切换开销。

2. Node.js 中的进程操作

2.1 创建子进程

  1. child_process.spawn():适用于返回大量数据,例如图像处理,二进制数据处理。
  2. child_process.exec():产生一个 shell 并在该 shell 中运行命令,stdoutstderr 在完成时将和传递给回调函数。
  3. child_process.execFile():类似于 exec(),不同之处在于它默认情况下直接生成命令而无需生成新的 shell
  4. child_process.fork(): 产生一个新的 Node.js 进程,并使用建立的 IPC 通信通道调用指定的模块,该通道允许在父级和子级之间发送消息。生成的 Node.js 子进程独立于父进程,拥有自己的内存,并带有自己的V8实例。由于需要额外的资源分配,因此不建议生成大量 Node.js 子进程。

2.2 进程间通信——IPC

IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。

Node.js 中使用 fork() 创建子进程时同时创建了一个IPC管道,( 在linix系统中采用 Unix Domain Socket 实现 ) ,IPC管道与网络socket比较类似,可以双向通信,可以相互发送数据。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。

在Node中,IPC通道被抽象为 Stream 对象。在调用 send() 发送消息时,会先将消息序列化,然后发送到 IPC 中。接收到的消息时,先反序列化为对象,然后通过message 事件触发给应用层。

创建子进程和IPC管道

进程间通信示例:

  • master.js
const { fork } = require('child_process');
console.log('master process ', process.pid, ' start');
const child = fork('message-child.js');
child.send({ hello: 'Hello, I am master ' + process.pid });
child.on('message', m => {
  console.log('Master receive message from: ', child.pid, ', message: ', m)
})
  • child.js
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
process.on('message', m => {
  console.log('Child receive message: ', m);
})
process.send({ hello: 'Hello, I am child: ' + process.pid });
  • 输出如下:
master process  18547  start
child process  18554  start, parent process  18547
Child receive message:  { hello: 'Hello, I am master 18547' }
Master receive message from:  18554 , message:  { hello: 'Hello, I am child: 18554' }

2.3 共享句柄

在Node.js中,IPC管道发送数据会经过序列化和反序列化,所以无法直接发送对象引用。如果 IPC 管道仅仅只用来发送一些简单的数据,显然不够我们的实际应用使用。

所以在Node.js 中,扩展了IPC的能力,使其可以发送 句柄句柄是一个网络链接的文件描述符,在Node.js 中为net.Servernet.Socket或它们的子类的实例。

通过IPC管道发送HTTP server示例

  • socket-master.js
const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child = fork('socket-child.js');
server.listen(port, () => {
  console.log(`server start at ${port}`);
  child.send('server', server);
});
  • socket-child.js
const http = require('http');
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from child ' + process.pid + '\n');
});
process.on('message', (m, s) => {
  if (m === 'server') {
    console.log('Child receive server socket');
    s.on('connection', (socket) => {
      server.emit('connection', socket);
    })
  }
})
  • 运行结果:
$ node socket-master.js
master process  11687  start
server start at 3000
child process  11694  start, parent process  11687
Child receive server socket
  • 发送HTTP请求的响应结果
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from master 11687

通过以上示例,可以看到在master进程中创建了一个HttpServer,然后将这个Server发送给child进程,child进程接收到后也监听这个Server,如下图所示。

主进程将句柄发送给子进程

此时master进程和child进程同时在监听3000端口,都可以处理客户端发起的请求,请求可能是被父进程处理,也可能被子进程处理。

另一个神奇的现象是:如果在master中把Server发送给子进程后,关闭Server,子进程依然可以继续监听响应Server的请求。修改socket-master.js如下:

const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child1 = fork('socket-child.js');
const child2 = fork('socket-child.js');
const child3 = fork('socket-child.js');
server.listen(port, () => {
  child1.send('server', server);
  child2.send('server', server);
  child3.send('server', server);
  server.close();
});
  • 发送请求结果如下
$ curl localhost:3000/
response from child 14297
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14296
$ curl localhost:3000/
response from child 14303

示意图如下:

主进程发送完句柄并关闭监听后的结构

2.4 句柄发送过程

上面的示例看起来好像是master进程把Server对象发送了给子进程,但真实情况却不是这样。

当调用send()方法发送message和句柄时,发送到IPC管道中的实际上是我们要发送的句柄文件描述符,文件描述符实际上是一个整数值。这个 message 对象和句柄的文件描述符在写入到IPC管道时会通过 JSON.stringify() 进行序列化。所以最终发送到IPC通道中的信息都是字符串, send() 方法能发送消息和句柄并不意味着它能发送任意对象。

连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()解析后,如果消息包含文件描述符,则将其还原出一个对应的句柄对象,再触发 message 事件将消息和句柄传递给应用层使用。

句柄的发送与还原示意图

2.5 端口共同监听原理

前面示例中多个进程可以监听到相同的端口而不引起 EADDRINUSE 异常,是因为Node.js在创建socket监听端口时,指定了SO_REUSEPORT参数,而且在不同进程中都使用相同的文件描述符句柄。

在多个进程监听相同端口时,端口上的请求,默认使用抢占式策略,会由操作系统内核随机挑选一个进程,来进行响应。Node.js还支持另外一种调度模式——Round-Robin(轮叫调度),轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作进程。分发的策略是在N个工作进程中,每次选择第 i = (i + 1) mod n 个进程来发送连接。要使用轮叫调度策略需要使用cluster模块。

3. Node.js 多进程集群

使用上面的示例代码,可以实现一个多进程共享监听端口的Web Server集群。但是使用起来比较麻烦,因此Node.js又提供了一个cluster模块,专门用来实现多进程集群。

  • cluster 示例
const cluster = require('cluster');
const http = require('http');
const workerCount = 3;
const port = 3000;

if (cluster.isMaster) {
  console.log('master process ', process.pid, ' start');
  for (let i = 0; i < workerCount; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  console.log('child process ', process.pid, ' start, parent process ', process.ppid);
  http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('response from child ' + process.pid + '\n');
  }).listen(port, () => console.log('worker [', process.pid, '] start at ', port));
}

事实上 cluster 模块就是 child_processnet 模块的组合应用。 cluster 启动时,它会在内部启动TCP服务器,在 cluster.fork() 创建子进程时,将这个TCP服务器端socket的文件描述符发送给工作进程。如果进程是通过 cluster.fork() 复制出来的,那么它的环境变量里就存在 NODE_UNIQUE_ID,如果工作进程中存在 listen() 侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEPORT 端口重用,从而实现多个子进程共享端口。

4. Node.js多线程

Node.js在10.5之前是没有多线程支持的,要使用多线程需要使用C++扩展模块来支持。从v10.5开始,Node.js添加了线程的支持模块worker_threads,其实现类似于浏览器中的。在v10.x中只是试验版本,需要加--experimental-worker参数才能启用 。到了v12.xworker_threads模块成为了稳定版。下面是一个简单的示例:

#! node --experimental-worker
const { Worker, isMainThread } = require('worker_threads');
let num = 100;
if (isMainThread) {
  num = 200;
  console.log('main thread pid:', process.pid, 'num=' + num);
  new Worker(__filename);
} else {
  console.log('worker thread pid:', process.pid, 'num=' + num);
}
  • 输出如下
main thread pid: 846 num=200
worker thread pid: 846 num=100

可以看到,主线程和worker线程的PID相同,而且主线程和worker线程不能直接共享变量值。

4.1 线程间通信

Node.js线程间发送消息是通过MessageChannelMessagePort进行通讯的,MessageChannel是一个异步双向通信通道,包含port1port2两个MessagePort类型对象。MessagePort是一个通信的一个端,通过postMessage()on(message)来发送与接收消息。在创建Worker时会自动创建一个MessageChannel

postMessage()方法可以发送的消息对象将会通过HTML结构化克隆算法 (HTML structured clone algorithm)序列化,与Json的序列化有较大的区别:

  1. 可以有循环引用
  2. 可以包含内置JS类型的实例,例如RegExps,BigInts,Maps,Sets等。
  3. 可以包含使用ArrayBuffers和SharedArrayBuffers的类型化数组。(实现内存共享)
  4. 可以包含WebAssembly.Module实例。
  5. 可以包含MessagePorts对象。

MessagePort区别于child_process,当前不支持传送句柄。由于对象克隆使用结构化克隆算法,因此不会保留不可枚举的属性,属性访问器和对象原型。

  • 进程间发送消息示例
#! node --experimental-worker
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
  console.log('== main thread pid:', process.pid);
  const worker = new Worker(__filename);
  worker.postMessage('hello');
  const sharedUint8Array = new Uint8Array(new SharedArrayBuffer(4));
  worker.postMessage(sharedUint8Array);
  console.log('== parent thread:', sharedUint8Array);
  worker.on('message', (m) => {
    if (m === 'ok') {
      console.log('== parent thread:', sharedUint8Array);
    }
  });
} else {
  console.log('-- worker thread pid:', process.pid);
  parentPort.on('message', (m) => {
    console.log('-- receive message from main thread:', m);
    if (m instanceof Uint8Array) {
      m[0] = 1;
      m[2] = 100;
      parentPort.postMessage('ok');
      console.log('-- changed data:', m)
    }
  })
}
  • 输出如下:
== main thread pid: 5758
== parent thread: Uint8Array [ 0, 0, 0, 0 ]
-- worker thread pid: 5758
-- receive message from main thread: hello
== parent thread: Uint8Array [ 1, 0, 100, 0 ]
-- receive message from main thread: Uint8Array [ 0, 0, 0, 0 ]
-- changed data: Uint8Array [ 1, 0, 100, 0 ]

* 参考资源:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容