多进程 & Node.js 实现

2,052 阅读6分钟

进程与线程

进程和线程的诞生要从多任务谈起,多任务是指操作系统可以在同一时间内运行多个应用程序,CPU 按顺序执行代码,在同一时间内只能处理一个任务,而在单核时代主流操作系统都有了多任务能力,主要靠快速在多个任务之间切换,让人感觉多个任务同时执行


进程是指操作系统正在运行的应用程序,而一个进程内部可能有多个并发的子任务,这就是线程

Web 服务器模型

Web 服务器需要同时处理多个用户的请求,返回给用户响应内容,有几种不同的服务器模型实现多任务

多进程单线程

这种服务模型通过进程复制实现同时响应多个请求,每个请求使用一个单独的进程处理,但操作系统复制进程需要复制进程内部状态,这样相同的状态在内存中存在多份,对内存有一定的开销,可以同时处理的请求数和内存大小正相关

单进程多线程

为了避免复制多进程带来的内存浪费问题,多线程被引入 Web 服务器模型,一个线程响应一个用户请求,线程可以共享进程的内存,不会造成内存浪费,同时线程相对于进程的内存开销要小得多。但每个线程有自己的独立堆栈,需要占据一定的内存空间,因此只是缓解了多进程带来的资源浪费问题


另外操作系统在切换线程的同时需要切换线程的 context,当线程数量过多时 CPU 会被耗在 context 切换中。同时一个线程的崩溃可能会导致整个进程 crash,为服务器带来了相当程度的稳定性风险

多进程多线程

顾名思义多进程多线程模型就是启用多个进程,在每个进程内启用多个线程来解决高并发问题,集成了多进程和多线程模型的好处,但当用户量足够大的时候也同时拥有了另外两种模型的缺陷


当并发数达到千万级内存好用问题就会暴露出来,这就是著名的 C10k 问题,C10k 问题的本质在于:为了处理高并发创建的进程线程太多,数据拷贝频繁、进程/线程上下文切换消耗大, 导致操作系统崩溃

事件驱动

为了解决 Web 高并发问题 Nginx 使用了事件驱动的模型,在一个 CPU 上使用单进程、单线程来响应用户请求,把最耗时的阻塞任务 I/O 任务异步化,处理完成后通过事件通知主进程给用户响应,在等待 I/O 任务的时候处理下一个请求


这样的模型性能取决于 CPU 的运算能力,但不受多进程、多线程模式中资源上限的影响,非常适合 Web I/O 密集的特征,成了现在 Web 服务器的主流模型

master-worker 模式

Node.js 本身就使用的事件驱动模型,为了解决单进程单线程对多核使用不足问题,可以按照 CPU 数目多进程启动,理想情况下一个每个进程利用一个 CPU


Node.js 提供了 child_process 模块支持多进程,通过 child_process.fork(modulePath) 方法可以调用指定模块,衍生新的 Node.js 进程

worker.js

const http = require('http');
const randomPort = parseInt(Math.random() * 10000);
http.createServer((req, res) => {
  res.end('Hello world')
}).listen(randomPort);

master.js

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

for (let i = 0, len = os.cpus().length; i < len; i++) {
  fork('./worker.js');
}

使用 node master.js 启动,会复制 CPU 数量的进程数执行 worker.js,使用 ps aux | grep worker.js 可以看到对应的进程

undefined  5271  4931720  21584  0:00.13 /usr/local/bin/node ./worker.js
undefined  5270  4931720  21624  0:00.13 /usr/local/bin/node ./worker.js
undefined  5269  4931720  21640  0:00.13 /usr/local/bin/node ./worker.js
undefined  5268  4931720  21636  0:00.12 /usr/local/bin/node ./worker.js
undefined  5267  4931720  21616  0:00.13 /usr/local/bin/node ./worker.js
undefined  5266  4931720  21696  0:00.12 /usr/local/bin/node ./worker.js
undefined  5265  4931720  21648  0:00.13 /usr/local/bin/node ./worker.js
undefined  5264  4931720  21640  0:00.12 /usr/local/bin/node ./worker.js

image.png

这就是 Master-Worker 模式,主进程负责调度和管理工作进程,工作进程负责具体业务逻辑处理

进程通信

主进程管理工作进程,经常需要和工作进程通信,通过 child_process 复制的进程和主进程通信可以使用 WebWorker API

worker.js

const http = require('http');
const randomPort = parseInt(Math.random() * 10000);

http.createServer((req, res) => {
  res.end('Hello world')
}).listen(randomPort);

process.on('message', msg => {
  console.log(`worker get message: ${msg}`);
});

process.send(`${randomPort} ready`);

master.js

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

for (let i = 0, len = os.cpus().length; i < len; i++) {
  const worker = fork('./worker.js');
  worker.on('message', msg => {
    console.log(`master get message: ${msg}`);
  });

  worker.send('ok');
}

句柄传递

在上面的例子中每个工作进程都使用了一个随机端口,如果设置成一样的会出现端口号被占用的错误

Error: listen EADDRINUSE :::9527
    at Server.setupListenHandle [as _listen2] (net.js:1360:14)
    at listenInCluster (net.js:1401:12)
    at Server.listen (net.js:1485:7)

这个问题可以通过 master 监听 80 端口,分发请求给工作进程,工作进程使用不同的端口号解决,所以上面例子使用了随机端口号

image.png

但进程每接收一个连接会使用一个文件描述符,上面的模型因为使用了代理服务,每次连接需要消耗两个文件描述符,而操作系统的文件描述符是有限的,代理方案浪费了一倍的文件描述符影响了系统吞吐量


为了解决这个问题 master 可以把句柄(标识资源的引用,内部包含了指向对象的文件描述符)发送给工作进程

send(message, handler);

也就是说 master 进程接收到请求后把 socket 直接发送给 worker,不用为了和 worker 连接重新创建一个 socket

master.js

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

const workers = [];
for (let i = 0, len = os.cpus().length; i < len; i++) {
  const worker = fork('./worker.js');
  workers.push(worker);
}

const server = net.createServer();
server.listen(9527, () => {
  workers.forEach(worker => {
    worker.send('SERVER', server);
  });
  server.close();
});

在主进程中创建一个 tcp server,监听 9527 端口后把 tcp server 发送给所有 worker,然后关闭 tcp server,所有监听交给 worker 处理

worker.js

const http = require('http');

// 创建 http 服务器,不监听任何端口号
const httpServer = http.createServer((req, res) => {
  res.end(`Hello world by ${process.pid}\n`);
});

process.on('message', (msg, tcpServer) => {
  // 如果是 master 传递来的 tcp server
  if (msg === 'SERVER') {
    // 新连接建立的时候触发
    tcpServer.on('connection', socket => {
      // 把 tcp server 的连接转给 http server 处理
      httpServer.emit('connection', socket);
    });
  }
});

这样写之后为什么多个进程可以监听同样的端口号,不报 EADDRINUSE 错误了呢?


Node.js 对每个端口监听的时候设置了 SO_REUSEADDR 选项,允许不同的进程对相同的端口号监听

setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

独立启动的进程服务器 socket 的文件描述符(listenfd)不同,所以监听相同的端口号会失败,而上面代码 socket 都使用 master 发送的 socket,所以可以监听成功


多个应用监听相同的端口号时文件描述符同一时间只能被一个进程占用,也就是说网络请求向服务器发送的时候只有一个进程可以抢占到对请求提供服务

稳定性

利用 master 和 worker 的通信机制可以让 master 对 worker 进行管理

worker 自动重启

master.js

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

const workers = {};

function createWorker(server) {
  const worker = fork('./worker.js');
  worker.send('SERVER', server);
  workers[worker.pid] = worker;
  console.log(`worker ${worker.pid} created`);

  worker.on('exit', () => {
    // worker 进程退出,自动重新创建
    console.log(`worker ${worker.pid} exited`);
    delete workers[worker.pid];
    createWorker(server);
  });
}

const server = net.createServer();
server.listen(9527);

for (let i = 0, len = os.cpus().length; i < len; i++) {
  createWorker(server);
}

master 关闭自动关闭 worker

master.js

process.on('exit', () => {
  for (const pid in workers) {
    workers[pid].kill();
  }
});

cluster 模块

上面讲的内容可以通过 Node.js 的内置模块 cluster 实现

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

if (cluster.isMaster) {
  console.log(`主进程 ${process.pid} 正在运行`);

  // 衍生工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`工作进程 ${worker.process.pid} 已退出`);
  });
} else {
  // 工作进程可以共享任何 TCP 连接,在本例子中,共享的是 HTTP 服务器。
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('你好世界\n');
  }).listen(8000);

  console.log(`工作进程 ${process.pid} 已启动`);
}

cluster 事件

cluster 模块也暴露了一些事件给开发者更多的定制性

  1. disconnect:在工作进程的 IPC 管道被断开后触发。
  2. exit:当任何一个工作进程关闭的时候,cluster 模块都将会触发
  3. fork:当新的工作进程被衍生时,cluster 模块将会触发
  4. listening:当一个工作进程调用 listen() 后,主进程上会触发
  5. message:当集群主进程从任何工作进程接收到消息时触发
  6. online:当衍生一个新的工作进程后,工作进程应当响应一个上线消息。 当主进程收到上线消息后将会触发此事件
  7. setup:当 .setupMaster() 被调用时触发

image.png