所属分类:web前端开发
node.js极速入门课程:进入学习
本文论点主要面向 Node.js 开发语言
>> Show Me Code,目前代码正在 dev 分支,已完成单元测试,尚待测试所有场景。
>> 建议通读 Node.js 官方文档 -【不要阻塞事件循环】
Node.js 即服务端 Javascript,得益于宿主环境的不同,它拥有比在浏览器上更多的能力。比如:完整的文件系统访问权限、网络协议、套接字编程、进程和线程操作、C++ 插件源码级的支持、Buffer 二进制、Crypto 加密套件的天然支持。【相关教程推荐:nodejs视频教程】
Node.js 的是一门单线程的语言,它基于 V8 引擎开发,v8 在设计之初是在浏览器端对 JavaScript 语言的解析运行引擎,其最大的特点是单线程,这样的设计避免了一些多线程状态同步问题,使得其更轻量化易上手。
一、名词定义
学术上说,进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。我们这里将进程比喻为工厂的车间,它代表 CPU 所能处理的单个任务。任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态。
进程具有以下特性:
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
后来,随着计算机的发展,对 CPU 的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元。这里把线程比喻一个车间的工人,即一个车间可以允许由多个工人协同完成一个任务,即一个进程中可能包含多个线程。
线程具有以下特性:
Node.js 的多进程有助于充分利用 CPU 等资源,Node.js 的多线程提升了单进程上任务的并行处理能力。
在 Node.js 中,每个 worker 线程都有他自己的 V8 实例和事件循环机制 (Event Loop)。但是,和进程不同,workers 之间是可以共享内存的。
二、Node.js 异步机制
Node.js 的单线程是指程序的主要执行线程是单线程,这个主线程同时也负责事件循环。而其实语言内部也会创建线程池来处理主线程程序的 网络 IO / 文件 IO / 定时器
等调用产生的异步任务。一个例子就是定时器 Timer 的实现:在 Node.js 中使用定时器时,Node.js 会开启一个定时器线程进行计时,计时结束时,定时器回调函数会被放入位于主线程的宏任务队列。当事件循环系统执行完主线程同步代码和当前阶段的所有微任务时,该回调任务最后再被取出执行。所以 Node.js 的定时器其实是不准确的,只能保证在预计时间时我们的回调任务被放入队列等待执行,而不是直接被执行。
多线程机制配合 Node.js 的 evet loop 事件循环系统让开发者在一个线程内就能够使用异步机制,包括定时器、IO、网络请求。但为了实现高响应度的高性能服务器,Node.js 的 Event Loop 在宏任务上进一步划分了优先级。
Node.js 宏任务之间的优先级划分:Timers > Pending > Poll > Check > Close。
Node.js 微任务之间的优化及划分:process.nextTick > Promise。
node 11 之前,Node.js 的 Event Loop 并不是浏览器那种一次执行一个宏任务,然后执行所有的微任务,而是执行完一定数量的 Timers 宏任务,再去执行所有微任务,然后再执行一定数量的 Pending 的宏任务,然后再去执行所有微任务,剩余的 Poll、Check、Close 的宏任务也是这样。node 11 之后改为了每个宏任务都执行所有微任务了。
而 Node.js 的 宏任务之间也是有优先级的,如果 Node.js 的 Event Loop 每次都是把当前优先级的所有宏任务跑完再去跑下一个优先级的宏任务,那么会导致 “饥饿” 状态的发生。如果某个阶段宏任务太多,下个阶段就一直执行不到了,所以每个类型的宏任务有个执行数量上限的机制,剩余的交给之后的 Event Loop 再继续执行。
最终表现就是:也就是执行一定数量的 Timers 宏任务,每个宏任务之间执行所有微任务,再一定数量的 Pending Callback 宏任务,每个宏任务之间再执行所有微任务。
三、Node.js 的多进程
Node.js 程序通过 child_process 模块提供了衍生子进程的能力,child_process 提供多种子进程的创建方式:
const spawn = require('child_process').spawn;
const ls = spawn('ls', ['-lh', '/usr']);
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
ls.stderr.on('data', (data) => {
console.log(`stderr: ${data}`);
});
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
登录后复制
execFile('/path/to/node', ['--version'], function(error, stdout, stderr){
if(error){
throw error;
}
console.log(stdout);
});
登录后复制
exec('ls -al', function(error, stdout, stderr){
if(error) {
console.error('error:' + error);
return;
}
console.log('stdout:' + stdout);
console.log('stderr:' + typeof stderr);
});
登录后复制
var child = child_process.fork('./anotherSilentChild.js', {
silent: true
});
child.stdout.setEncoding('utf8');
child.stdout.on('data', function(data){
console.log(data);
});
登录后复制
其中,spawn 是所有方法的基础,exec 底层是调用了 execFile。
以下是使用 Cluster
模块创建一个 http 服务集群的简单示例。示例中创建 Cluster 时使用同一个 Js 执行文件,在文件内使用 cluster.isPrimary
判断当前执行环境是在主进程还是子进程,如果是主进程则使用当前执行文件创建子进程实例,如果时子进程则进入子进程的业务处理流程。
/*
简单示例:使用同一个 JS 执行文件创建子进程集群 Cluster
*/
const cluster = require('node:cluster');
const http = require('node:http');
const numCPUs = require('node:os').cpus().length;
const process = require('node:process');
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
// Fork workers.
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
// Workers can share any TCP connection
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
登录后复制
Cluster
模块允许设立一个主进程和若干个子进程,使用 child_process.fork()
在内部隐式创建子进程,由主进程监控和协调子进程的运行。
子进程之间采用进程间通信交换消息,Cluster 模块内置一个负载均衡器,采用 Round-robin 算法(轮流执行)协调各个子进程之间的负载。运行时,所有新建立的连接都由主进程完成,然后主进程再把 TCP 连接分配给指定的子进程。
使用集群创建的子进程可以使用同一个端口,Node.js 内部对 http/net
内置模块进行了特殊支持。Node.js 主进程负责监听目标端口,收到请求后根据负载均衡策略将请求分发给某一个子进程。
PM2 是常用的 node 进程管理工具,它可以提供 node.js 应用管理能力,如自动重载、性能监控、负载均衡等。
其主要用于 独立应用
的进程化管理,在 Node.js 单机服务部署方面比较适合。可以用于生产环境下启动同个应用的多个实例提高 CPU 利用率、抗风险、热加载等能力。
由于是外部库,需要使用 npm 包管理器安装:
$: npm install -g pm2
登录后复制
pm2 支持直接运行 server.js 启动项目,如下:
$: pm2 start server.js
登录后复制
即可启动 Node.js 应用,成功后会看到打印的信息:
┌──────────┬────┬─────────┬──────┬───────┬────────┬─────────┬────────┬─────┬───────────┬───────┬──────────┐
│ App name │ id │ version │ mode │ pid │ status │ restart │ uptime │ cpu │ mem │ user │ watching │
├──────────┼────┼─────────┼──────┼───────┼────────┼─────────┼────────┼─────┼───────────┼───────┼──────────┤
│ server │ 0 │ 1.0.0 │ fork │ 24776 │ online │ 9 │ 19m │ 0% │ 35.4 MB │ 23101 │ disabled │
└──────────┴────┴─────────┴──────┴───────┴────────┴─────────┴────────┴─────┴───────────┴───────┴──────────┘
登录后复制
pm2 也支持配置文件启动,通过配置文件 ecosystem.config.js
可以定制 pm2 的各项参数:
module.exports = {
apps : [{
name: 'API', // 应用名
script: 'app.js', // 启动脚本
args: 'one two', // 命令行参数
instances: 1, // 启动实例数量
autorestart: true, // 自动重启
watch: false, // 文件更改监听器
max_memory_restart: '1G', // 最大内存使用亮
env: { // development 默认环境变量
// pm2 start ecosystem.config.js --watch --env development
NODE_ENV: 'development'
},
env_production: { // production 自定义环境变量
NODE_ENV: 'production'
}
}],
deploy : {
production : {
user : 'node',
host : '212.83.163.1',
ref : 'origin/master',
repo : 'git@github.com:repo.git',
path : '/var/www/production',
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production'
}
}
};
登录后复制
pm2 logs 日志功能也十分强大:
$: pm2 logs
登录后复制
一般我们使用计算机执行的任务包含以下几种类型的任务:
计算密集型任务:任务包含大量计算,CPU 占用率高。
const matrix = {};
for (let i = 0; i < 10000; i++) {
for (let j = 0; j < 10000; j++) {
matrix[`${i}${j}`] = i * j;
}
}
登录后复制
IO 密集型任务:任务包含频繁的、持续的网络 IO 和磁盘 IO 的调用。
const {copyFileSync, constants} = require('fs');
copyFileSync('big-file.zip', 'destination.zip');
登录后复制
混合型任务:既有计算也有 IO。
一、进程池的适用场景
使用进程池的最大意义在于充分利用多核 CPU 资源,同时减少子进程创建和销毁的资源消耗。
进程是操作系统分配资源的基本单位,使用多进程架构能够更多的获取 CPU 时间、内存等资源。为了应对 CPU-Sensitive 场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process
模块用于创建子进程。
子进程的创建和销毁需要较大的资源成本,因此池化子进程的创建和销毁过程,利用进程池来管理所有子进程。
除了这一点,Node.js 中子进程也是唯一的执行二进制文件的方式,Node.js 可通过流 (stdin/stdout/stderr) 或 IPC 和子进程通信。
通过 Stream 通信
const {spawn} = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
ls.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
登录后复制
通过 IPC 通信
const cp = require('child_process');
const n = cp.fork(`${__dirname}/sub.js`);
n.on('message', (m) => {
console.log('PARENT got message:', m);
});
n.send({hello: 'world'});
登录后复制
二、线程池的适用场景
使用线程池的最大意义在于多任务并行,为主线程降压,同时减少线程创建和销毁的资源消耗。单个 CPU 密集性的计算任务使用线程执行并不会更快,甚至线程的创建、销毁、上下文切换、线程通信、数据序列化等操作还会额外增加资源消耗。
但是如果一个计算机程序中有很多同一类型的阻塞任务需要执行,那么将他们交给线程池可以成倍的减少任务总的执行时间,因为在同一时刻多个线程在并行进行计算。如果多个任务只使用主线程执行,那么最终消耗的时间是线性叠加的,同时主线程阻塞之后也会影响其它任务的处理。
特别是对 Node.js 这种单主线程的语言来讲,主线程如果消耗了过多的时间来执行这些耗时任务,那么对整个 Node.js 单个进程实例的性能影响将是致命的。这些占用着 CPU 时间的操作将导致其它任务获取的 CPU 时间不足或 CPU 响应不够及时,被影响的任务将进入 “饥饿” 状态。
因此 Node.js 启动后主线程应尽量承担调度的角色,批量重型 CPU 占用任务的执行应交由额外的工作线程处理,主线程最后拿到工作线程的执行结果再返回给任务调用方。另一方面由于 IO 操作 Node.js 内部作了优化和支持,因此 IO 操作应该直接交给主线程,主线程再使用内部线程池处理。
Node.js 的异步能不能解决过多占用 CPU 任务的执行问题?
答案是:不能,过多的异步 CPU 占用任务会阻塞事件循环。
Node.js 的异步在 网络 IO / 磁盘 IO
处理上很有用,宏任务微任务系统 + 内部线程调用能分担主进程的执行压力。但是如果单独将 CPU 占用任务放入宏任务队列或微任务队列,对任务的执行速度提升没有任何帮助,只是一种任务调度方式的优化而已。
我们只是延迟了任务的执行或是将巨大任务分散成多个再分批执行,但是任务最终还是要在主线程被执行。如果这类任务过多,那么任务分片和延迟的效果将完全消失,一个任务可以,那十个一百个呢?量变将会引起质变。
以下是 Node.js 官方博客中的原文:
“如果你需要做更复杂的任务,拆分可能也不是一个好选项。这是因为拆分之后任务仍然在事件循环线程中执行,并且你无法利用机器的多核硬件能力。 请记住,事件循环线程只负责协调客户端的请求,而不是独自执行完所有任务。 对一个复杂的任务,最好把它从事件循环线程转移到工作线程池上。”
场景:间歇性让主进程 瘫痪
每一秒钟,主线程有一半时间被占用
// this task costs 100ms
function doHeavyTask() { ...}
setInterval(() => {
doHeavyTask(); // 100ms
doHeavyTask(); // 200ms
doHeavyTask(); // 300ms
doHeavyTask(); // 400ms
doHeavyTask(); // 500ms
}, 1e3);
登录后复制
场景:高频性让主进程 半瘫痪
每 200ms,主线程有一半时间被占用
// this task costs 100ms
function doHeavyTask() { ...}
setInterval(() => {
doHeavyTask();
}, 1e3);
setInterval(() => {
doHeavyTask();
}, 1.2e3);
setInterval(() => {
doHeavyTask();
}, 1.4e3);
setInterval(() => {
doHeavyTask();
}, 1.6e3);
setInterval(() => {
doHeavyTask();
}, 1.8e3);
登录后复制
以下是官方博客的原文摘录:
“因此,你应该保证永远不要阻塞事件轮询线程。换句话说,每个 JavaScript 回调应该快速完成。这些当然对于 await,Promise.then 也同样适用。”
进程池是对进程的创建、执行任务、销毁等流程进行管控的一个应用或是一套程序逻辑。之所以称之为池是因为其内部包含多个进程实例,进程实例随时都在进程池内进行着状态流转,多个创建的实例可以被重复利用,而不是每次执行完一系列任务后就被销毁。因此,进程池的部分存在目的是为了减少进程创建的资源消耗。
此外进程池最重要的一个作用就是负责将任务分发给各个进程执行,各个进程的任务执行优先级取决于进程池上的负载均衡运算,由算法决定应该将当前任务派发给哪个进程,以达到最高的 CPU 和内存利用率。常见的负载均衡算法有:
「 对单一任务的控制不重要,对单个进程宏观的资源占用更需关注 」
进程池架构图参考之前的进程管理工具开发相关 文章,本文只需关注进程池部分。
更多示例见:进程池 mocha 单元测试
main.js
const { ChildProcessPool, LoadBalancer } = require('electron-re');
const processPool = new ChildProcessPool({
path: path.join(__dirname, 'child_process/child.js'),
max: 4,
strategy: LoadBalancer.ALGORITHM.POLLING,
);
登录后复制
child.js
const { ProcessHost } = require('electron-re');
ProcessHost
.registry('test1', (params) => {
console.log('test1');
return 1 + 1;
})
.registry('test2', (params) => {
console.log('test2');
return new Promise((resolve) => resolve(true));
});
登录后复制
processPool.send('test1', { value: "test1"}).then((result) => {
console.log(result);
});
登录后复制
processPool.sendToAll('test1', { value: "test1"}).then((results) => {
console.log(results);
});
登录后复制
1)基本代理原理:
2)单进程下客户端执行原理:
3)多进程下客户端执行原理:
以上描述的是客户端连接单个节点的工作模式,节点订阅组中的负载均衡模式需要同时启动多个子进程,每个子进程启动 ss-local 执行文件占用一个本地端口并连接到远端一个服务器节点。
每个子进程启动时选择的端口是会变化的,因为某些端口可能已经被系统占用,程序需要先选择未被使用的端口。并且浏览器 proxy 工具也不可能同时连接到我们本地启动的子进程上的多个 ss-local 服务上。因此需要一个占用固定端口的中间节点接收 proxy 工具发出的连接请求,然后按照某种分发规则将 tcp 流量转发到各个子进程的 ss-local 服务的端口上。
之前做过一个支持 SMB 协议多文件分片上传的客户端,Node.js 端的上传任务管理、IO 操作等都使用多进程实现过一版本,不过是在 gitlab 实验分支自己搞得(逃)。
为了减小 CPU 密集型任务计算的系统开销,Node.js 引入了新的特性:工作线程 worker_threads,其首次在 v10.5.0
作为实验性功能出现。通过 worker_threads 可以在进程内创建多个线程,主线程与 worker 线程使用 parentPort 通信,worker 线程之间可通过 MessageChannel 直接通信。worker_threads 做为开发者使用线程的重要特性,在 v12.11.0
稳定版已经能正常在生产环境使用了。
但是线程的创建需要额外的 CPU 和内存资源,如果要多次使用一个线程的话,应该将其保存起来,当该线程完全不使用时需要及时关闭以减少内存占用。想象我们在需要使用线程时直接创建,使用完后立刻销毁,可能线程自身的创建和销毁成本已经超过了使用线程本身节省下的资源成本。Node.js 内部虽然有使用线程池,但是对于开发者而言是完全透明不可见的,因此封装一个能够维护线程生命周期的线程池工具的重要性就体现了。
为了强化多异步任务的调度,线程池除了提供维护线程的能力,也提供维护任务队列的能力。当发送请求给线程池让其执行一个异步任务时,如果线程池内没有空闲线程,那该任务就会被直接丢弃了,显然这不是想要的效果。
因此可以考虑为线程池添加一个任务队列的调度逻辑:当线程池没有空闲线程时,将该任务放入待执行任务队列 (FIFO),线程池在某个时机取出任务交由某个空闲线程执行,执行完成后触发异步回调函数,将执行结果返回给请求调用方。但是线程池的任务队列内的任务数量应该考虑限制到一个特殊值,防止线程池负载过大影响 Node.js 应用整体运行性能。
「 对单一任务的控制重要,对单个线程的资源占用无需关注 」
调用者可通过 StaticPool/StaticExcutor/DynamicPool/DynamicExcutor
实例向线程池派发任务(以下有关键名词说明),各种实例的之间最大的不同点就是参数动态化能力。
任务由线程池内部生成,生成后任务做为主要的流转载体,一方面承载用户传入的任务计算参数,另一方面记录任务流转过程中的状态变化,比如:任务状态、开始时间、结束时间、任务 ID、任务重试次数、任务是否支持重试、任务类型等。
任务生成后,首先判断当前线程池的线程数是否已达上限,如果未达上限,则新建线程并将其放入线程存储区,然后使用该线程直接执行当前任务。
如果线程池线程数超限,则判断是否有未执行任务的空闲线程,拿到空闲线程后,使用该线程直接执行当前任务。
如果没有空闲线程,则判断当前等待任务队列是否已满,任务队列已满则抛出错误,第一时间让调用者感知任务未执行成功。
如果任务队列未满的话,将该任务放入任务队列,等待任务循环系统取出将其执行。
以上 4/5/6 步的三种情况下任务执行后,判断该任务是否执行成功,成功时触发成功的回调函数,Promise 状态为 fullfilled。如果失败,则判断是否支持重试,支持重试的情况下,将该任务重试次数 + 1 后重新放入任务队列尾部。任务不支持重试的情况下,直接失败,并触发失败的异步回调函数,Promise 状态为 rejected。
整个线程池生命周期中,存在一个任务循环系统,以一定的周期频率从任务队列首部获取任务,并从线程存储区域获取空闲线程后使用该线程执行任务,该流程也符合第 7 步的描述。
任务循环系统除了取任务执行,如果线程池设置了任务超时时间的话,也会判断正在执行中的任务是否超时,超时后会终止该线程的所有运行中的代码。
execFunction/execString/execFile
执行参数来启动工作线程,执行参数在进程池创建后不能更改。execFunction/execString/execFile
且不可更改。execFunction/execString/execFile
执行参数即可创建线程池。执行参数在调用 exec()
方法时动态传入,因此执行参数可能不固定。null
,其它参数比如:任务超时时间、任务重试次数、transferList 等都可以通过 API 随时更改。null
。execFunction/execString/execFile
,执行参数可以随时改变。worker_threads
API。更多示例见:线程池 mocha 单元测试
main.js
const { StaticThreadPool } = require(`electron-re`);
const threadPool = new StaticThreadPool({
execPath: path.join(__dirname, './worker_threads/worker.js'),
lazyLoad: true, // 懒加载
maxThreads: 24, // 最大线程数
maxTasks: 48, // 最大任务数
taskRetry: 1, // 任务重试次数
taskLoopTime: 1e3, // 任务轮询时间
});
const executor = threadPool.createExecutor();
登录后复制
worker.js
const fibonaccis = (n) => {
if (n < 2) {
return n;
}
return fibonaccis(n - 1) + fibonaccis(n - 2);
};
module.exports = (value) => {
return fibonaccis(value);
}
登录后复制
threadPool.exec(15).then((res) => {
console.log(+res.data === 610)
});
executor
.setTaskRetry(2) // 不影响 pool 的全局设置
.setTaskTimeout(2e3) // 不影响 pool 的全局设置
.exec(15).then((res) => {
console.log(+res.data === 610)
});
登录后复制
const { DynamicThreadPool } = require(`electron-re`);
const threadPool = new DynamicThreadPool({
maxThreads: 24, // 最大线程数
maxTasks: 48, // 最大任务数
taskRetry: 1, // 任务重试次数
});
const executor = threadPool.createExecutor({
execFunction: (value) => { return 'dynamic:' + value; },
});
threadPool.exec('test', {
execString: `module.exports = (value) => { return 'dynamic:' + value; };`,
});
executor.exec('test');
executor
.setExecPath('/path/to/exec-file.js')
.exec('test');
登录后复制
暂未在项目中实际使用,可考虑在前端图片像素处理、音视频转码处理等 CPU 密集性任务中进行实践。
这里有篇文章写了 web_worker 的一些应用场景,web_worker 和 worker_threads 是类似的,宿主环境不同,一些权限和能力的不同而已。
最开始 项目 做为 Electron 应用开发的一个工具集提供了 BrowserService / ChildProcessPool / 简易进程监控 UI / 进程间通信
等功能,线程池的加入其实是当初没有计划的,而且线程池本身是独立的,不依赖 electron-re 中其它模块功能,之后应该会被独立出去。
进程池和线程池的实现方案上还需完善。
比如进程池未支持子进程空闲时自动退出以解除资源占用,当时做了另一版监听 ProcessHost 的任务执行情况来让子进程空闲时休眠,想通过此方式节省资源占用。不过由于没有 node.js API 级别的支持以分辨子进程空闲的情况,并且子进程的休眠 / 唤醒功能比较鸡肋 (有尝试通过向子进程发送 SIGSTOP/SIGCONT
信号实现),最终这个特性被废除了。
后面可以考虑支持 CPU/Memory 的负载均衡算法,目前已经通过项目中的 ProcessManager
模块来实现资源占用情况采集了。
线程池方面相对的可用度还是较高,提供了 pool/excutor
两个层级的调用管理,支持链式调用,在一些需要提升数据传输性能的场景支持 transferList
方式避免数据克隆。相对于其它开源 Node 线程池方案,着重对任务队列功能进行了加强,支持任务重试、任务超时等功能。
更多node相关知识,请访问:nodejs 教程!
以上就是聊聊使用Node如何实现轻量化进程池和线程池的详细内容,更多请关注zzsucai.com其它相关文章!