Node进程学习笔记

1,650 阅读11分钟

前言

写作背景

有次在部门领了 Node 进程的分享 topic ,但是自身也不太熟悉 Node ,因此有了调研学习过程,故此产出了一篇学习笔记,也分享下给大家。其中有些内容是自己的见解,可能理解有误,如有还请各位读者帮忙指出~ 🙏

服务端类型

单进程单线程服务器

它的服务模式是一次只能处理一个请求,其他的请求需要按照顺序依次等待处理执行。

也就是说如果当前的请求正在处理的话,那么其他的请求都将处于阻塞等待的状态。可想而知,这样的服务器是无法处理并发请求的。

多进程单线程服务器

为了解决上面单进程服务器无法处理并发的问题,出现了多进程服务器,它的功能是一个请求需要一个进程来服务,也就是说如果有100个请求就需要100个进程来进行服务。

这样就会有很大进程的开销问题了,且相同的状态在内存中会有多种,这样就会造成资源浪费。

多进程多线程服务器

为了解决上面多进程中资源浪费的问题,引入了多进程多线程服务器模式,从之前一个进程处理一个请求,现在改为一个线程来处理一个请求,线程相对于进程来说开销会少很多,并且线程之间还可以共享数据。

单进程多线程服务器

类似多进程单线程的模型,主线程负责监听客户端的连接请求,workers 线程负责处理对应连接的逻辑事件

基于单进程单线程的事件驱动服务器

在单进程单线程的模型上延伸出了基于单进程单线程的事件驱动的模式,使用单线程的优点是:避免内存开销和上下文切换的开销。

所有的请求都在单线程上执行的,其他的异步IO和事件驱动相关的线程是通过 libuv 或别的异步处理的库来实现内部的线程池以及线程调度等功能。

影响事件驱动服务模型性能的主要是CPU的计算能力,因为单进程只能使用单核的CPU来处理事件驱动,但是我们的计算机目前都是多核的,如果能使用多核CPU的话,那么CPU的利用率就会得到一个很大的提升。

单进程中的三种模式比较

从左到右分别是单进程单线程,单进程多线程,单进程事件驱动,图中灰色部分是阻塞任务耗费的时间

总结

模型 good bad 例子
单进程单线程 1 逻辑简单
2 不存在多进程的切换导致消耗CPU
1 无法充分发挥多核CPU性能
2 无法处理并发请求
JavaScript
单进程多线程 1 充分利用了多核CPU
2 CPU切换只在一个进程内,消耗低
1 只有一个进程,一旦其中出现一个错误,整个进程都有可能挂掉,就算重启也会有感知
2 编写单进程多线程这样的服务器,在代码上非常容易出错,而且难以控制代码的稳定性。因为需要管理好锁、全局变量
Mysql
多进程单线程 1 充分利用了多核CPU
2 避免线程抖动和锁
3 进程挂断后,重启无感知
进程切换时,每个进程或线程都有自己的上下文堆栈保存,因此进程间的切换消耗更大一些 Nginx(默认配置,即单工作进程模式下)
多进程多线程 综合了单进程多线程、多进程单线程的优点、缺点 Nginx(多工作进程模式下)
单进程单线程事件驱动 1 不存在多进程的切换消耗性能
2 支持处理并发请求
1 只有一个进程,一旦其中出现一个错误,整个进程都有可能挂掉,就算重启也会有感知
2 无法充分发挥多核CPU性能
Node

Node的进程模式

Node 使用的是事件驱动的单进程单线程模型,这里的单线程同上事件驱动的补充,也只是说Node核心工作线程是单个,但是Node还需要别的辅助工作线程。

Node的多线程

在 Node 10.5 之前,网上说的 Node 多线程其实是假的多线程,实际上都是使用多进程的方式来模拟多线程,这是使用 Node 的 cluster 模块实现的(下一节会提到)。之后官方才出现了真正的多线程API:worker_threads (具体不清楚是哪个版本,但是自己用的版本v12.14.0 已经提供了多线程稳定版API)

回顾上述单进程多线程模型,它的架构实际上如下,master 是主线程,worker 是工作线程:

第一个例子:主线程与工作线程的通信

const {
    isMainThread, parentPort, workerData, threadId, Worker
} = require('worker_threads');
    
 function mainThread() {
    const worker = new Worker(__filename, { workerData: 1 });
    // once 与 on 是的区别是 once 监听到一次就停止
    worker.once('exit', code => { console.log(`传入的code是: ${code}`); });
    // 如果工作人员通过调用process.exit()退出,则exitCode参数将是传递的退出代码
    // 如果 worker 已经关闭,那么参数是1 默认值为0
    worker.on('message', msg => { // 监听工作进程来的消息
      console.log(`主线程将要发送数据 ${msg}`);
    //   if (msg === 3 ){ process.exit(-1) } // 这里传入的值不会生效 因为worker已经退出
      worker.postMessage(msg + 1);
    });
  }
    
function workerThread() {
    console.log(`当前工作线程为: threadId:${threadId},文件名为:${__filename}`);
    console.log(`工作线程初始化的数据为: workerDate: ${workerData}`);
    parentPort.on('message', msg => { // 监听主进程来的消息
      console.log(`工作线程当前接受到的数据: ${msg}`);
      if (msg === 5) { process.exit(33); } // 这里的33是on exit中的code
 
      parentPort.postMessage(msg);
    }),
    parentPort.postMessage(workerData);
}
    
if (isMainThread) {
    mainThread();
} else {
    workerThread();
}

第二个例子:工作线程之间的通信

const {
    isMainThread, parentPort, workerData, threadId, Worker
} = require('worker_threads');
    
if (isMainThread) {
    const worker1 = new Worker(__filename);
    const worker2 = new Worker(__filename);
    const subChannel = new MessageChannel();
    // 第二个参数transferList 可以传两种类型: ArrayBuffer与MessagePort ,本次例子传的是MessagePort
    worker1.postMessage({ port: subChannel.port2 }, [subChannel.port2]);
    worker2.postMessage({ port: subChannel.port1 }, [subChannel.port1]);
} else {
    parentPort.on('message', (value) => {
        // value 是 { port: subChannel.port1 }
        value.port.postMessage(`我是来自线程${threadId}的消息`);
        value.port.on('message', msg => {
            console.log(`当前线程 ${threadId}: 接受到的消息是: ${msg}`);
        });
    });
}

介绍这个内容之前,先回顾一个前提:线程之间是可以共享内存(即资源)的,那么在node中线程之间共享内存,是通过传输 ArrayBuffer 实例或共享 SharedArrayBuffer 实例来实现的。

然后开始介绍下第二个参数:transferList,它可以接受两种类型的参数:ArrayBufferMessagePort

它的作用是将 transferList 里面的内容的内存移动到另一个线程,因此在发送端线程中就不再可用,即使这个传输的内容没有包含在传输的 value 中,且当我们 postmessage 传送的 value 是 MessageChannel 时,transferList 也必须传入 MessageChannel,否则会报错。

传入ArrayBuffer的例子:

if (isMainThread) {
    const worker1 = new Worker(__filename);
    const uint8Array = new Uint8Array([ 1, 2, 3, 4 ])
    // 传递 ArrayBuffer 会修改该 Buffer 的访问权限给接受消息的线程
    worker1.postMessage(uint8Array, [uint8Array.buffer]);
} else {
    parentPort.on('message', (value) => {
        console.log(value)
    });
}

第三个例子:线程之间共享内存

const {
    isMainThread, parentPort, workerData, threadId, Worker
} = require('worker_threads');
 if (isMainThread) {
  const uint8Array = new Uint8Array([ 1, 2, 3, 4 ]);
  for (let i = 0; i < 2; i++) {
    let w = new Worker(__filename);
    w.postMessage(uint8Array);
  }
} else {
  parentPort.on('message', (msg) => {
    if(threadId === 1) {
      msg[0] = 2;
    }
    console.log(msg)
  });
}

可以看到改变线程 1 改变值后,线程 2 共享的数组是没有与线程1中的数组值一样。

这是因为 Node 对消息对象采取了克隆操作,保证在发布消息后进行修改而不会产生副作用。

那么想要同步的修改也是可以的,如下:

const {
    isMainThread, parentPort, workerData, threadId, Worker
} = require('worker_threads');
    
 
if (isMainThread) {
  const buffer = new SharedArrayBuffer(8); // 创建8个字节大小的缓冲区 即 8*8 = 64位 关键是该API 创建共享内存数据
  // 16位有符号整数形式的字节的数组,64 / 16 = 4 即创建4位长度的数组
  const arr = new Int16Array(buffer);
  for (let i = 0; i < arr.length; i++) {
    arr[i] = i;
  }
  for (let i = 0; i < 2; i++) {
    let w = new Worker(__filename);
    w.postMessage(arr);
  }
} else {
  parentPort.on('message', (msg) => {
    if(threadId === 1) {
      msg[0] = 2;
    }
    console.log(msg)
  });
}

Node的多进程

Node 提供了 child_process 模块来实现子进程,通过 child_process 模块,可以实现一个主进程,多个子进程模式。

主进程叫做 master 进程,子进程叫做 worker 进程,在子进程中不仅可以调用其他 node 程序,我们还可以调用非 node 程序及 shell 命令等。

child_process 提供了4个方法,用于创建子进程,这四个方法分别为 spawn, execFile, execfork。 所有的方法都是异步的,且后三个方法都是基于spawn实现的。

名称 执行目标 返回
spawn 非node程序 以流的形式返回
ExecFile 非node程序 以回调的形式返回
exec Shell 命令 以回调的形式返回
Fork Node 程序 以流的形式返回

流可以简单认为是数据的集合,详情可以看看这里:【译】Node.js 流: 你需要知道的一切

其中 execexecFile 有同步的方法,区别仅是会阻塞 node 的事件循环,不做过多的介绍。

spawn

const { spawn } = require('child_process');
// const ls = spawn('echo', ['hello', 'world']);
const ls = spawn('ls', ['hello', 'world']);
 
ls.stdout.on('data', (data) => { // 监听正常输出
  console.log(`stdout: ${data}`);
});
 
ls.stderr.on('data', (data) => { // 监听错误输出
  console.error(`stderr: ${data}`);
});
 
ls.on('close', (code) => { // 监听退出
  console.log(`子进程退出,使用退出码 ${code}`);
});

execFile 和 exec

相同:执行的都是非node应用,且执行的结果以回调函数的形式返回

不同:在调用方式上不同

const cp = require('child_process');
cp.exec('echo hello world', function(err, res) {
  console.log(err,res);
});
cp.execFile('echo', ['hello', 'world'], function(err, res) { // 注意调用方式不同
    console.log(res);
});

fork

通过使用 fork 方法在单独的进程中执行 node 程序,新建 worker 进程,上下文都复制主进程。

通过父子之间的通信,子进程接收父进程的信息,并执行子进程后结果信息返回给父进程。

const childProcess = require('child_process');
 
for (let i = 0; i < 2; ++i) {
    console.log('创建进程中');
  childProcess.fork('./demo.js'); // 这里打开的是刚才的多线程 demo 代码
}

打开控制台可以看到新创建的进程

父子进程之间的通信

const childProcess = require('child_process');
const worker = childProcess.fork('./work.js');
 
// 主进程向子进程发送消息
worker.send('Hello World!');
 
// 监听子进程发送过来的消息
worker.on('message', (msg) => {
  console.log('从子进程来的消息:' + msg);
});
// 子进程文件 work.js
// 监听主进程的消息
process.on('message', (msg) => {
    console.log('从主进程接受到的信息:' + msg);
    // 子进程向主进程发送消息
    process.send('子进程发来贺电');
});

Node多进程应用

Demo1: 对网络请求进行分发

我们首先在 master 进程中创建一个TCP服务器,将服务器对象直接发送给 worker 进程,让 worker 进程去监听端口并处理请求,因为用的是同一个服务器对象,所以监听的都是统一个端口。当我们的客户端发送请求时候,我们的 master 进程和 worker 进程都可以监听到,我们没有在 master 进程处理具体的业务,所有业务都在 worker 进程处理。

本 Demo 是没有特殊处理的,因此使用的是先来先服务模式,即抢占式调度,因此就不能保证每个 worker 进程都能负载均衡。

// 主进程代码
const cpuNum = require('os').cpus().length;
 
const workers = [];
 
for (let i = 0; i < cpuNum; ++i) {
  workers.push(childProcess.fork('./tcp-work.js'));
  console.log('工作进程的pid是:' + workers[i].pid);
}
 
// 创建TCP服务器
const tcpServer = net.createServer();
 
tcpServer.listen(8989, () => {
  console.log('Server启动在 127.0.0.0: 8989');
  // 监听端口后将服务器对象发送给worker进程
  for (let i = 0; i < cpuNum; ++i) {
    workers[i].send('tcpServer', tcpServer);
  }
  // 关闭master线程的端口监听
  tcpServer.close();
});

建立工作进程进行接收请求后的逻辑处理

// 工作进程代码
process.on('message', (msg, tcpServer) => {
    if (msg === 'tcpServer' && tcpServer) {
        tcpServer.on('connection', (socket) => {
            setTimeout(() => {
                socket.end('该网络请求由进程:' + process.pid + "完成");
            }, 1000);
        })
    }
});

创建一个发送请求的代码

// 发送请求
const net = require('net');
const maxConnectCount = 10;
 
for (let i = 0; i < maxConnectCount; ++i) {
  net.createConnection({
    port: 8989,
    host: '127.0.0.1'
  }).on('data', (d) => {
    console.log(d.toString());
  })
}

我们启动主进程,之后再运行发送TCP请求的JS文件,模拟发送请求,结果如下:

实际上这就是一个很简单的 cluster 。

Demo2: 进程守护

我们都知道,进程是会有自动被关闭的可能的,比如该程序本身出错有自动销毁进程的处理,比如系统本身的处理等等。

在网络应用场景中,进程被挂掉是非常常见的,此时我们需要对这些进程采取好手段,即进程守护/守护进程 (常驻进程的一种实例)。

我们进一步基于上面的这个 demo 优化,完成一个守护进程,主要逻辑是增加一个监听子进程退出的事件

// 主进程代码
const cpuNum = require('os').cpus().length;
 
const workers = [];
 
for (let i = 0; i < cpuNum; ++i) {
  workers.push(childProcess.fork('./tcp-work.js'));
  console.log('工作进程的pid是:' + workers[i].pid);
}
 
// 创建TCP服务器
const tcpServer = net.createServer();
 
tcpServer.listen(8989, () => {
  console.log('Server启动在 127.0.0.0:8989');
  // 监听端口后将服务器对象发送给worker进程
  for (let i = 0; i < cpuNum; ++i) {
    workers[i].send('tcpServer', tcpServer);
    // 增加了下面这段代码
    workers[i].on('exit', ((i) => { // 通过闭包 保存了i的值 用于重启对应worker的进程
        return () => {
          console.log('worker-' + workers[i].pid + ' exited');
          workers[i] = childProcess.fork('./tcp-work.js');
          console.log('Create worker-' + workers[i].pid);
          workers[i].send('tcpServer', tcpServer);
        }
    })(i));
  }
  // 关闭master线程的端口监听
  tcpServer.close();
});

在控制台手动模拟下进程异常退出

可以看到旧进程被退出后,马上生成一个新的进程,保持我们服务的稳定。

Demo3:更便利的API-Cluster

Node 提供 cluster 这个API,可以使我们更方便地完成上述功能

// cluster-demo
const cluster = require('cluster');
if (cluster.isMaster) { // 主进程 创建子进程 分发任务
  const cpuNum = require('os').cpus().length;
  for (let i = 0; i < cpuNum; ++i) {
    cluster.fork('./tcp-work.js');
  }
  // 创建进程完成后输出信息
  cluster.on('online', (worker) => {
    console.log('创建了进程:' + worker.process.pid);
  });
 
  // 监听子进程退出后重启事件
  cluster.on('exit', (worker, code, signal) => {
    console.log('子进程' + worker.process.pid + '销毁代号为' + code + '信号是' + signal);
    cluster.fork('./tcp-work.js'); // 重启子进程
  });
} else {
  const net = require('net');
  net.createServer().on('connection', (socket) => {
    setTimeout(() => {
      socket.end('Request handled by worker-' + process.pid);
    }, 10)
  }).listen(8989)
}

模拟请求结果

销毁进程结果

Demo2 与 Demo3 ,两者的区别如下:

Demo2 的模式,主进程只负责创建子进程、绑定端口工作,工作进程负责监听端口、处理请求工作:

Demo3 的模式与 Demo2 核心区别是主进程接受请求并额外处理 dispatch 任务,决定由哪个 worker 进行处理工作,该算法官方使用的是 round-robin 算法

pm2

pm2 是一个比较出名的 Node 进程管理工具,可以用它来管理 Node 进程,并查看 Node 进程的状态,同时支持性能监控,进程守护,负载均衡等功能。

万变不离其宗,其负载均衡、守护进程核心原理也是使用 cluster 实现的,这里就不在重复说了。

pm2源码分析 很有趣(指命名),有兴趣的话可以看看

下面列一下常用API:

API 常用参数 功能 例子
pm2 start --name xxx 用于重命名
--watch 监控该项目,当代码发生变化时,会自动重启服务
-i X 以cluster模式启动,X为数字
启动项目 pm2 start app.js
pm2 start app.js -i 4
pm2 stop - 停止项目 pm2 stop app.js
pm2 delete - 删除项目 pm2 delete app.js
pm2 delete all (删除所有)
pm2 restart - 重启项目 pm2 restart app.js
pm2 restart all (重启所有)
pm2 list - 列出所有进程/应用
pm2 describe - 查看某项目的详细信息 pm2 describe app.js
pm2 monit - 查看资源消耗情况
pm2 logs - 查看日志 pm2 logs 查看所有日志
pm2 logs app.js 查看该项目的日志

打探了一些同学大厂的 Node 的线上模式,比较主流的方案是:Kubernetes + docker + pm2,利用 Kubernetes + docker 打造容器隔离、编排应用,利用 pm2 集群调度以及进程守护。