NodeJS 中的线程和进程
进程和线程
进程(Process)的概念
进程是系统进行资源分配和调度的基本单位,是操作系统的基础,进程是线程的容器
我们启动一个服务/运行一个实例,就是开启了一个进程。在 Node.js 中我们使用node server.js开启一个服务进程,进程之间的数据是不共享的
进程之间都有属于自己的独立运行空间,进程之间是不会相互影响的。例如我们的 Chrome 浏览器,就是一个多进程架构,包含浏览器进程/GPU 进程/网络进程/渲染进程/插件进程等,不会因为一个进程的崩溃导致程序崩溃
const http = require('http');
http.createServer().listen(8081, () => {
process.title = 'FBB test';
console.log('进程ID: ----', process.pid);
});

该示例中开启了一个进程,还有一个概念叫做多进程,它就是进程的复制(fork),fork 出来的每一个进程都有自己独立的空间地址、数据栈,只用建立了通信进程之间才可以数据共享
进程中包括要执行的代码,代码操作的数据,以及进程控制块 PCB(Processing Control Block),程序是代码在数据集上的执行过程,执行过程的状态和申请的资源需要记录在同一个数据结构中(PCB),所以进程是由代码、数据、PCB 组成的
PCB 中记录着 pid、执行到的代码地址、进程的状态(阻塞、运行、就绪等)以及用于通信的信号量、管道、消息队列等数据结构

线程(Thread)的概念
线程是操作系统能够进行运算调度的最小单位。线程是隶属于进程的,一个线程只属于一个进程,但是一个进程可包含一个或多个线程。进程线程是一个一对多的关系。
上述讲到进程之间有独立运行空间且相互不影响。线程自己不拥有系统资源,它与同属一个进程的其他的线程共享进程所拥有的全部资源。
同一进程的的多个线程有各自的调用栈(call-stack),寄存器环境(register context),线程的本地存储(thread-local storage)
单线程
单线程就是一个进程只开启一个线程。JavaScript 就是一个单线程,程序按着顺序执行,前一个执行完毕后一个才能开始。如果我们的代码中有一段耗时的同步代码,就会导致阻塞。
const http = require('http');
const computed = () => {
let sum = 0;
console.log('computed start!');
console.time('time');
// 计算0 累加到 10^10的结果
for (let i = 0; i < 1e10; i++) {
sum += i;
}
console.log('computed end');
console.timeEnd('time');
return sum;
};
http
.createServer((req, res) => {
if (req.url === '/computed') {
const sum = computed();
res.end(`Sum is ${sum}`);
}
res.end('!!!');
})
.listen(8080, 'localhost', () => {
console.log('server start');
});

当请求一次 computed 需要花销近 10 秒,在此之后的请求都会需要等待这一时间

多线程
多线程就是一个进程拥有多个线程,每个线程都拥有各自的内容

我们一直熟知的是 JavaScript 是单线程语言,在浏览器端为了解决单线程的弱点 HTML5 提供了 Web Worker 为 JavaScript 创建多线程环境;在 Node v10.5 提出了worker_threads,让 Node 拥有了工作线程
上一节的示例,由于计算量很多,会阻塞程序后面的执行。我们使用 worker_threads 修改上述代码,使用工作线程来做大量计算
主进程代码
const http = require('http');
const { Worker } = require('worker_threads');
const computed = (limit) => {
let sum = 0;
for (let i = 0; i < limit; i++) {
sum += i;
}
return sum;
};
http
.createServer((req, res) => {
if (req.url === '/computed') {
const computedThead = new Worker(`${__dirname}/worker_computed.js`);
computedThead.on('message', (sum) => {
console.log(`computed thread: ${sum}`);
res.end(`computed on computed thread: ${sum}`);
});
computedThead.postMessage(1e10);
} else if (url !== '/favicon.ico') {
const number = +req.url.slice(1);
const result = computed(number);
console.log(`main thread: ${result}`);
res.end(`computed on main thread: ${result}`);
}
})
.listen(8080, 'localhost', () => {
console.log(`server start at http://localhost:8080`);
});
工作线程代码
const { parentPort } = require('worker_threads');
const computed = (limit) => {
let sum = 0;
for (let i = 0; i < limit; i++) {
sum += i;
}
return sum;
};
parentPort.on('message', (limit) => {
const result = computed(limit);
parentPort.postMessage(result);
});

Node 中的线程
Nodejs 是单进程单线程
- Nodejs 是单线程模型,但其基于事件驱动、非阻塞 I/O 模型,并且 Node 自身还有 I/O 线程存在,可以应用于并发场景
- 当项目中有大量的计算时,例如上面的示例,Nodejs 提供了 API 来实现多线程和多进程
- Nodejs 开发过程中,错误会导致整个应用的退出,所以进程守护是需要的
在单核 CPU 系统上采用的是单进程+单线程的模式来开发。在多核 CPU 系统上,通过 child_process.fork开启多个进程,v0.8 之后推出了 Cluster 来实现多进程架构,即多进程+单线程模型。开启多进程,主要是解决了单进程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。
Nodejs 单线程的误区

这是我们第一节运行 node,开启一个进程后在活动监视器的截图。我们一直再说 Nodejs 是一个单线程,那么 node 启动之后线程数量应该为 1,为什么会开启了 9 个线程呢 🤔
其实是因为 Node 中最核心的部分是 V8 引擎,在 Node 启动之后,会创建 V8 实例,该实例是一个多线程
- 主线程: 编译、执行代码
- 编译/优化线程: 在主线程执行时,可以优化代码
- 分析器线程: 记录分析代码运行时间,为 Crankshaft 优化代码执行提供依据
- 垃圾回收的线程
💡 因此常说的 Nodejs 是单线程仅代表 JavaScript 的执行是单线程的(编写的代码运行在单线程环境中),但是 JavaScript 的宿主环境 Nodejs 或者是浏览器都是多线程的。因为在 libuv 中有线程池的概念,libuv 会通过类似线程池的实现来模拟不同操作系统的异步调用,这是对开发者不可见的
Node 中的进程
Process
Nodejs 的中进程 Process 是一个全局对象,无需使用 require,能够提供当前进程中的相关信息,详细文档。
模块概览
是什么
Process 是 Node 中的全局模块,可以通过它来获取 node 进程相关信息或者设置进程相关信息
相关属性
环境变量 process.env
返回一个对象,存储当前环境相关的所有信息
一般使用是在上面挂载一些变量标识当前的的环境。例如: process.env.NODE_ENV用于区分 development 和 production
NODE_ENV=production node index
异步 process.nextTick
process.nextTick 是异步执行的,会在当前 event loop 执行完成之后执行
function tick() {
process.nextTick(() => console.log('tick'));
}
console.log('start');
setTimeout(() => {
console.log('setTimeout');
});
tick();
console.log('end');
获取命令行参数 process.argv
process.argv 返回一个数组 对于数组来说,前两个参数分别是 node 路径和文件路径
node index a=1 b=1
//[
// '/Users/shuangxu/.nvm/versions/node/v14.17.0/bin/node',
// '/Users/shuangxu/Documents/code/Node-RoadMap/src/Process/process/index',
// 'a=1',
// 'b=1'
//]
当前工作路径
- process.cwd 返回当前的工作路径
- process.chdir(directory) 更改当前的工作路径
console.log(process.cwd());
try {
process.chdir('./src'); // .../Node-RoadMap
console.log(process.cwd()); //.../Node-RoadMap/src
} catch (error) {}
标准流
stdin/stdout/stderr 标准输入流/标准输出流/标准错误流
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
var chunk = process.stdin.read();
if (chunk !== null) {
process.stdout.write(`data: ${chunk}`);
}
});
事件监听
process 实现了 EventEmitter 能够通过 process.on 来对具体的事件的监听
process.on('exit', (code) => {
console.log(`About to exit with code: ${code}`);
});
进程是如何退出的
NodJS 的退出因素有一些是可以预防的,例如代码抛出了一个异常;有一些不可预防,例如内存耗尽。如果是优雅退出,process 会派发一个 exit 事件,监听该事件来做清理工作
主动退出
process.exit(code) 是最直接结束进程的方式。code 是可选择参数,0~255 之间的任何数字,默认为 0。0 表示进程执行成功,非 0 标识进程执行失败
function findRightNode(node) {
if (!node.right) {
console.log('no right child node', process.pid);
process.exit(1);
}
console.log('This is findRightNode function');
}
findRightNode({ left: 1 });
process.exit 直接退出了,无法获取到有效的错误信息。所以在开发一些项目的时候,尽量使用抛出异常的形式
Exceptions
如果没有 try/catch 语句该错误会被认定为未捕获。如果是使用 throw new Error 退出时,exit 事件监听到的 code 为 1,表示异常退出
function findRightNode(node) {
if (!node.right) {
console.log('no right child node', process.pid);
throw new Error('no right child node');
}
console.log('This is findRightNode function');
}
findRightNode({ left: 1 });

function findRightNode(node) {
if (!node.right) {
console.log('no right child node', process.pid);
throw new Error('no right child node');
}
console.log('This is findRightNode function');
}
try {
findRightNode({ left: 1 });
} catch (error) {
console.log('hi, catch now');
}

可以通过 uncaughtException 事件来处理未捕获异常
function findRightNode(node) {
process.on('uncaughtException', (error) => {
console.log(1111);
console.error(error);
process.exit(1);
});
if (!node.right) {
console.log('no right child node', process.pid);
throw new Error('no right child node');
}
console.log('This is findRightNode function');
}
findRightNode({ left: 1 });

Rejection
Promise Rejection 与抛出异常类似,调用的 reject() 函数或者是 async 函数中抛出异常。如果是 reject() 或者使用 async 搭配 throw Error 通过 exit 事件监听到的 code 均为 0
function findRightNode(node) {
if (!node.right) {
console.log('no right child node', process.pid);
Promise.reject(new Error('no right child node!'));
}
console.log('This is findRightNode function');
}
async function findRightNode(node) {
if (!node.right) {
console.log('no right child node', process.pid);
throw new Error('no right child node');
}
console.log('This is findRightNode function');
}
findRightNode({ left: 1 });
我们可以采用监听 unhandledRejection 事件来处理未捕获的 Rejection
function findRightNode(node) {
process.on('unhandledRejection', (error) => {
console.log(1111);
console.error(error);
process.exit(1);
});
if (!node.right) {
console.log('no right child node', process.pid);
Promise.reject(new Error('no right child node!'));
}
console.log('This is findRightNode function');
}
findRightNode({ left: 1 });

信号
信号是操作系统提供了进程间通信的机制,信号是一个数字也可以使用字符串来标识
对于 SIGKILL/SIGSTOP 是不能够通过事件监听捕获到的。
子进程
除了 Process 之外,Nodejs 还提供了一个 child_process 的模块对子进程进行操作。 可以通过 Nodejs 的 child_process 模块创建子进程,并且父子进程使同一个消息系统,可以相互通信
四种创建方式
spawn
会在一个新的进程中启动一条命令,通过这条命令可以传递任意的参数
spawn 的第一个参数是一个可执行文件,第二个是参数数组
可以通过 spawn 创建子进程,返回一个 ChildProcess 实例,该实例实现了 EventEmitter API,所以在子进程上可以进行事件注册
const { spawn } = require('child_process');
const child = spawn('pwd');
child.on('exit', function (code, signal) {
console.log(`child process exited with code ${code} and signal ${signal}`);
});
每个子进程还会有三个标准 stdio 流,stdin/stdout/stderr。当流关闭的时候会触发子进程的 close 事件,和 exit 事件不同。 可以在子进程的 stdio 流上监听不同的事件,在子进程中,stdout/stderr 流是可读流,而 stdin 流是可写的。在可读流上我们可以监听 data 事件,获取到内容。
const ls_child = spawn('node', ['../exec/child.js']);
ls_child.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
ls_child.stderr.on('data', (data) => {
console.log(`stderr: ${data}`);
});
ls_child.on('close', (code) => {
console.log(`子进程退出码:${code}`);
});
ls_child.on('exit', (code) => {
console.log('exit');
});
//child.js
setInterval(() => {
process.stdout.write(`111 \n`);
}, 1000);
exec
node 会生成一个 shell 进程,并执行命令对应的 command 命令,并且在返回数据前,会将数据放入内存中。当子进程执行完毕之后,再调用回调函数,并把最终数据交给回调函数。
let childExec = exec('node ./child.js', (err, stdout, stderr) => {
console.log(err);
console.log(stdout);
});
childExec.on('exit', (code, sig) => [console.log(sig)]);
childExec.on('close', () => {
console.log('close');
});
//child.js
setInterval(() => {
process.stdout.write(`111 \n`);
}, 1000);
如果需要使用 shell 语法,并且命令数据不大时,可以选择 exec 函数;但是数据规模较大时,可选择 spawn 函数,被以流的方式处理
exec 可以通过 timeout 配置来控制子进程运行的时长,超过这个时长,父进程会发送 killSignal 属性(默认为 ‘SIGTERM’)
其实 spawn 也是可以执行 shell 语法,通过在 options 中配置shell: true
execFile
它的行为和 exec 函数是一样的,但接受的一个参数是可执行文件,不会衍生一个 shell,而是将可执行的 file 直接衍生为一个新进程
🤔 可执行文件是什么? 可以由操作系统进行加载执行的文件。可执行文件包含机器语言指令或可执行代码,并已经可以在计算机上运行。在 Windows 系统中,大多数可执行文件的后缀名为 .exe;Mac 系统使用 .DMG 以及 .APP 扩展名作为可执行文件
const { execFile } = require('child_process');
execFile('./file.sh', function (err, stdout, stderr) {
if (err) {
console.error(err);
return;
}
console.log('stdout:', stdout);
console.log('stderr:', stderr);
});
其中遇到的一个问题,权限不够,使用chmod 777 file.sh修改权限即可

fork
fork 函数是 spawn 函数针对于衍生 node 进程的一个变种。两者的在于使用 fork 时,会和父进程创建 IPC 通道用于通信,应用层使用 process.on/process.send 方法
child_process 的 exit/close 执行时机
-
exit 当子进程结束的时候会触发 exit 事件,code 为进程最终的退出码,否则为 null;如果进程是接受到信号退出的,signal 是信号的字符串名称,否则为 null。触发该事件时两者之一不为 null
-
close 在进程结束并且子进程的 stdio 流已经关闭后触发 close 事件,close 事件会在 exit 后触发。code 如果子进程自己退出,则为退出码;signal 终止子进程的信号。
const exec = require('child_process').exec;
const child = exec('ls ../../', (error, stdout, stderr) => {
if (error) {
console.log('stderr ', stderr);
return;
}
console.log('stdout \n', stdout);
});
child.on('close', (code) => {
console.log(`close: ${code}`);
});
child.on('exit', (code) => {
console.log('exit');
});
对于上述代码,会先执行 exit 事件,再输出 stdout 中的内容,最后在执行 close 事件
创建子进程
通过 fork 的方式来创建子进程,fork 中需要指定执行的 JavaScript 文件模块,就能够创建出子进程
通过 fork 创建子进程之后,父子进程会创建一个 IPC 通道,方便父子进程直接通信,可以直接使用 process.send/process.on 来进行通信
// master.js
const childProcess = require('child_process');
const cpus = require('os').cpus().length;
for (let i = 0; i < cpus; i++) {
childProcess.fork('./worker.js');
}
console.log("FBB's master");
// worker.js
console.log(`FBB's worker , pid: ${process.pid}`);
父子进程的事件监听
使用 fork 创建一个子进程实例,通过这个实例可以监听来自子进程的消息(worker.on)或者向子进程发送消息(worker.send)。worker 进程则通过 process 对象接口监听来自父进程的消息(process.on)或者向父进程发送消息(process.send)
// master.js
const childProcess = require('child_process');
const worker = childProcess.fork('./worker.js');
worker.send(`Hi, child process, my pid is ${process.pid}`);
worker.on('message', (msg) => {
console.log('[Master] Received message from worker: ' + msg);
});
// worker.js
process.on('message', (msg) => {
console.log('[worker] Received message from master: ' + msg);
process.send(
`Hello, parent process, my pid: ${process.pid}, my ppid: ${process.ppid}`,
);
});
