nodejs的单线程+事件循环模式
java写的后台服务应对高并发时,使用的是多线程模型(一个进程里开多个线程),每个请求分配一个线程处理;如下图:
单个线程处理请求时,遇到 I/O 会同步等待,但不影响其他线程。
下面以餐馆为例:
一个客人点菜,就有一个服务员和一个厨师对应,只有前一个客人的菜做好了,服务员和厨师才能服务下一个。当客人很多的时候,只有增加厨师和服务员。
但是一个进程能增加的线程数量是有限度的(一般是10个),不能一直增加下去。同时,当厨师做饭的时候服务员是空闲的,也就是厨师做饭是I/O操作,服务员帮客人点菜是CPU操作,CPU操作很快就完成了,就在那里空闲等厨师做好菜。
nodejs是怎么做的呢?
nodejs只雇佣一个服务员,当客人来的时候就点菜,点完菜客人就去坐着。然后,服务员把菜单给厨师,厨师开始做菜。
服务员继续招待下一个客人点菜,这个服务员就一直闲不下来。
因此,nodejs 适用于高并发、I/O 密集的场景,不适用CPU密集场景。
如果是CPU密集,比如点菜,你点一个满汉全席,点菜就要花很多时间,那厨师就等着了,所以node不适合于处理CPU密集的场景。
什么是 I/O 操作(Input/Output Operations)
在 Node.js 里,常见的 I/O 操作包括文件读写、网络请求与响应等。
特点
-
耗时性:I/O 操作通常比较耗时,因为外部设备的读写速度远远低于 CPU 的运算速度。例如,从硬盘读取文件或者通过网络发送数据都需要一定的时间来完成物理层面的数据传输。
-
阻塞与非阻塞:
- 阻塞 I/O:当执行阻塞 I/O 操作时,程序会暂停执行后续代码,直到 I/O 操作完成。这会导致 CPU 处于空闲状态,浪费计算资源。
- 非阻塞 I/O:非阻塞 I/O 允许程序在进行 I/O 操作的同时继续执行后续代码,当 I/O 操作完成后会通过回调函数等方式通知程序。Node.js 采用事件驱动和非阻塞 I/O 模型,能够高效处理大量并发的 I/O 操作。
Node.js 采用单线程的事件循环机制来处理非阻塞 I/O 操作,但实际上底层会借助线程池来完成具体的 I/O 任务,这里的线程池由 libuv 库提供支持。
libuv是什么?
Libuv 是一个跨平台的异步 IO 库,它主要是封装各个操作系统的一些 API,提供网络还有文件进程这些功能。
我们知道在 JS 里面是没有网络文件这些功能的,前端是由浏览器提供,而 Node.js 里则是由 Libuv 提供。
- 左侧部分是 JS 本身的功能,也就是 V8 实现的功能,V8就是一个 JS 引擎,实现了 JS 解析和执行。
- 中间部分是一些C++ 胶水代码。
- 右侧部分是
Libuv的代码。
V8 和 Libuv 通过第二部分的胶水代码粘合在一起,最后就形成了整一个 Node.js。
因此,在 Node.js 里面不仅可以使用 JS 本身给我们提供的一些变量,如数组、函数,还能使用 JS 本身没有提供的 TCP、文件操作和定时器功能。
这些扩展出来的能力都是扩展到V8上,然后提供给开发者使用。
那么,libuv是怎么处理文件IO和网络IO的呢?
libuv 能实现跨平台异步 IO,全靠这两个核心模块,先记下来:
- 事件循环(Event Loop) :Node.js 主线程的消息中转站,接收 libuv 传回的 IO 完成通知,触发回调;
- 线程池(Thread Pool) :默认 4 个线程(可配置),专门处理IO 操作(比如大部分文件 IO)。
libuv 处理文件 IO:靠线程池 同步做,异步通知
大部分操作系统(比如 Linux/Windows)的文件 IO 本身是同步的(读文件必须等硬盘响应),libuv 没法直接拿到异步 API,所以用线程池曲线实现异步:
你(Node.js 主线程)要寄一个大件包裹(读大文件),但自己搬不动(主线程不能阻塞):
- 你把包裹交给快递公司(libuv),说: 帮我寄到 XX 地,寄完告诉我(发起异步文件 IO);
- 快递公司没有隔空寄件的魔法(操作系统无异步文件 API),但它有 4 个快递员(线程池);
- 快递公司派一个快递员(线程池里的线程),手动把包裹搬到快递车(同步读文件),这个过程快递员会等,但你完全不用管;
- 包裹寄到后(文件读完成),快递公司给你发微信通知(libuv 通过事件循环通知主线程);
- 你收到通知后,再处理包裹里的东西(执行文件 IO 回调)。
线程池默认 4 个线程,意味着同时最多能处理 4 个文件 IO 操作,超过的会排队。
你可以通过 UV_THREADPOOL_SIZE 环境变量调整线程数(比如设为 8),但不宜过多(线程多了会增加开销)。
所有文件 IO(读 / 写 / 删除)、压缩 / 加密、DNS 查询,都走这个线程池逻辑。
执行流程如下:
- 主线程:当在 Node.js 代码中发起一个非阻塞 I/O 操作(如文件读取、网络请求)时,主线程会将该 I/O 任务交给 libuv 的线程池处理。之后,主线程不会等待 I/O 操作完成,而是继续执行后续的代码。
- 线程池:libuv 的线程池通常默认包含 4 个线程(可以通过
UV_THREADPOOL_SIZE环境变量调整线程数量),线程池中的线程会负责执行具体的 I/O 操作。例如,当执行文件读取操作时,线程池中的某个线程会与操作系统进行交互,从磁盘读取数据。 - 事件循环:当 I/O 操作完成后,线程池会将完成的消息反馈给 Node.js 的事件循环。事件循环会将对应的回调函数添加到任务队列中,等待主线程空闲时执行该回调函数,以处理 I/O 操作的结果。
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log('继续执行其他代码');
在上述代码中,fs.readFile 是一个非阻塞 I/O 操作,主线程发起该操作后会继续执行 console.log('继续执行其他代码'),而文件读取任务会由线程池中的线程完成,完成后通过回调函数处理读取结果。
libuv 处理网络 IO:直接用操作系统的异步大招
和文件 IO 不同,所有主流操作系统都提供了原生异步网络 API(比如 Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP),libuv 直接封装这些 API,不用线程池,效率更高:
你(Node.js 主线程)要和朋友聊微信(网络 IO,比如 HTTP 请求):
- 你打开微信,发消息 “在吗?”(发起异步网络 IO),然后就去刷短视频了(主线程处理其他请求);
- 微信没有雇人帮你发消息(不用线程池),而是用运营商的异步短信通道(操作系统原生异步网络 API), 消息发出去后,运营商会自动处理传输,不用你等;
- 朋友回复 “在的”(网络 IO 完成),运营商直接给你手机推通知(libuv 通知事件循环);
- 你刷短视频时收到通知,点开回复(主线程执行网络 IO 回调)。
网络 IO 全程无额外线程,靠操作系统内核的事件监听机制实现异步,是真正的 非阻塞。
libuv 做的核心工作:把不同操作系统的异步网络 API(epoll/kqueue/IOCP)封装成统一的接口,让 Node.js 不用关心Linux 和 Windows 的网络 API 不一样。
所有网络 IO(HTTP/TCP/UDP)、定时器,都走这个原生异步逻辑,不走线程池。
文件 IO vs 网络 IO:核心差异
| 维度 | 文件 IO(libuv 处理) | 网络 IO(libuv 处理) |
|---|---|---|
| 依赖模块 | 线程池 + 系统同步文件 API | 系统原生异步网络 API |
| 是否阻塞线程 | 线程池里的线程会阻塞,但主线程不阻塞 | 全程无阻塞(内核异步处理) |
| 并发能力 | 受线程池数量限制(默认 4) | 无上限(只受系统资源限制) |
| 效率 | 中等(有线程切换开销) | 极高(纯内核异步) |
| 举例 | fs.readFile、fs.writeFile | http.request、net.createServer |
总之:libuv 处理 IO 是能走捷径(原生异步)就走捷径,走不了捷径(无异步 API)就雇人(线程池)干,干完都告诉你(事件循环)。
nodejs是如何处理http请求的?
有了上面的了解,就很清楚nodejs是怎么处理http请求的了,也就是主线程不会阻塞在耗时操作上,而是 接请求→丢给异步任务→继续接新请求。
-
主线程只做轻量活:创建
req/res、分发异步任务、组装响应; -
耗时活(I/O)交给内核:数据库、文件、网络请求等,主线程不等待;
-
多请求的
req/res完全隔离:请求 1 的req1和请求 2 的req2是两个独立对象,主线程通过事件循环回调能精准对应。
req/res 到底是什么?
req(Request)和 res(Response)是 Node.js/Express 为每个请求创建的「专属对象」,核心作用:
req:当前请求的 “信息包”,包含这个请求的所有数据:请求头、请求参数、Cookie、请求体等。相当于餐厅接单时,顾客的 “点餐单”,每个顾客的点餐单都不一样。
// 每个请求的 req 都是独立的
@Get('/user')
getUser(@Req() req: Request) {
console.log(req.url); // 只属于当前请求的 URL
console.log(req.headers.cookie); // 只属于当前请求的 Cookie
}
res:当前请求的 “响应工具”,包含发送响应的所有方法:res.send()、res.json()、res.setHeader() 等。相当于餐厅给这个顾客的 “出餐单”,只能用来给这个顾客出餐,不能给别人用。
// 每个请求的 res 都是独立的
@Get('/user')
getUser(@Res() res: Response) {
// 这个 res 只能给当前请求返回响应,和其他请求无关
res.send({ id: 123 });
}
nodejs在处理请求的时候,没有先后顺序,不会等待。
假设两个请求,请求 1 要等 3 秒(模拟查库),请求 2 是即时响应:
// 控制器
@Get('/delay3s')
async delay3s(@Req() req: Request, @Res() res: Response) {
console.log(`请求1:${req.url} 开始处理`);
await new Promise(resolve => setTimeout(resolve, 3000)); // 模拟3秒耗时
res.send(`请求1完成:${req.url}`);
}
@Get('/immediate')
immediate(@Req() req: Request, @Res() res: Response) {
console.log(`请求2:${req.url} 开始处理`);
res.send(`请求2完成:${req.url}`);
}
同时发送请求,请求 2 立刻返回 “请求 2 完成:/immediate”;3 秒后,请求 1 返回 “请求 1 完成:/delay3s”。
Node.js 没有等请求 1 完成再处理请求 2(非阻塞)。
什么时候会“阻塞”呢?
只有当主线程被同步耗时操作占满时,新请求才会等待, 比如:
// 同步死循环,主线程被卡死
@Get('/block')
block() {
while(true) {} // 同步操作,主线程无法处理新请求
}
此时访问 /block 后,再访问任何接口都会卡住,直到这个同步操作结束 —— 这也是 Node.js 开发的核心禁忌:不要在主线程写同步耗时代码。
req(http.IncomingMessage)核心事件
req 继承自 Node.js 的 http.IncomingMessage,事件主要围绕「请求数据接收」和「请求连接状态」,常用的有这 5 个:
| 事件名 | 触发时机 | 核心作用 | 实际场景 |
|---|---|---|---|
data | 客户端发送的请求体数据(POST/PUT)分片到达时 | 接收流式请求体(比如大文件上传) | 处理大体积请求体,避免一次性加载到内存 |
end | 请求体数据全部接收完成时 | 标记请求体接收完毕,可处理数据 | 解析完整的请求体(如 JSON / 表单数据) |
close | 请求的底层连接被关闭(客户端主动断开 / 网络异常) | 清理请求相关的资源(如定时器、临时文件) | 防止内存泄漏,比如取消请求对应的异步任务 |
error | 请求处理过程中发生错误(如网络错误) | 捕获请求相关的异常,避免程序崩溃 | 记录请求错误日志,返回友好的错误响应 |
aborted | 客户端主动中止请求(比如浏览器取消请求) | 识别 “半途而废” 的请求,做收尾处理 |
比如处理前端上传大文件(流式接收):
// 监听 req 的 data/end 事件,流式接收请求体
app.post('/upload', (req, res) => {
let body = '';
// 分片接收数据(data 事件)
req.on('data', (chunk) => {
body += chunk.toString();
console.log(`已接收 ${body.length} 字节数据`);
});
// 数据接收完成(end 事件)
req.on('end', () => {
console.log('请求体接收完毕:', body);
res.send('上传成功');
});
// 客户端取消上传(aborted 事件)
req.on('aborted', () => {
console.log('客户端取消了上传');
// 清理临时文件/取消异步任务
});
// 请求出错(error 事件)
req.on('error', (err) => {
console.error('请求处理出错:', err);
res.status(500).send('上传失败');
});
});
res(http.ServerResponse)核心事件
res 继承自 Node.js 的 http.ServerResponse,事件主要围绕「响应发送状态」,是日常开发中更常用的,核心有这 6 个:
| 事件名 | 触发时机 | 核心作用 | 实际场景 |
|---|---|---|---|
finish | 响应数据全部发送完成(最后一个字节发出去) | 标记请求处理彻底完成,做收尾 | 统计请求耗时、保存 Session、记录访问日志 |
end | 响应体开始发送(调用 res.end ()/res.send () 后) | 标记响应开始发送,轻量收尾 | 记录 “响应开始发送” 的时间点 |
close | 响应的底层连接被关闭(客户端断开 / 发送中断) | 清理响应相关资源 | 停止响应发送,避免无效的 IO 操作 |
error | 响应发送过程中出错(如网络中断) | 捕获响应发送异常 | 记录响应错误日志,告警 |
drain | 响应缓冲区满后再次可写(大响应体发送) | 继续发送缓冲区中的响应数据 | 处理大文件下载,避免数据积压 |
pipe | 响应被绑定到可读流(如 res.pipe (fileStream)) | 监听流绑定事件,做预处理 | 流传输前设置响应 |
比如统计每个请求的总耗时(用 finish 事件):
// 全局中间件,统计请求耗时
app.use((req, res, next) => {
const startTime = Date.now();
// 响应发送完成后,计算耗时(finish 事件)
res.on('finish', () => {
const duration = Date.now() - startTime;
console.log(`请求 ${req.url} 耗时:${duration}ms`);
// 这里可以保存 Session、记录日志等
});
// 响应发送出错(error 事件)
res.on('error', (err) => {
console.error(`响应 ${req.url} 发送失败:`, err);
});
next();
});
衡量服务的性能:每秒能处理的请求数(QPS)
现在讨论的是 Node.js 单进程 + 单核 CPU(因为 Node.js 默认单进程,只能利用一个核),且服务器配置为常见的 2GHz 主频、1GB 内存(入门级配置)。
CPU 主频:CPU 每秒钟能执行约 20 亿次基础运算(比如加减乘除、数据读写)。可以理解成CPU 的运算速度档位。主频越高,相同时间内,能完成更多的基础运算,处理任务的速度更快。
场景 1:纯内存轻量请求(无 I/O 操作)
特点:
- 逻辑只在内存中完成,比如:返回静态 JSON、做简单的数学计算、判断参数是否合法。
- 没有数据库查询、文件读写、网络请求等耗时 I/O 操作。
每秒能处理的请求数:1 万 - 5 万 QPS
- 极端例子:一个接口只返回
{ "code":200 },单核 Node.js 甚至能跑到 10 万 QPS(压测理想值)。 - 真实场景:加了简单的参数校验、中间件(如
cors),大概 2 万 - 3 万 QPS。
场景 2:常见 I/O 密集型请求(有数据库 / Redis 操作)
特点:
- 这是最常见的业务场景:接口需要查 MySQL/Redis、调用第三方 API 等。
- Node.js 单线程的优势就是 非阻塞 I/O:把数据库查询丢给内核后,主线程可以继续处理其他请求。
每秒能处理的请求数:500 - 2000 QPS
关键影响因素:I/O 延迟
- 如果数据库是本地的(延迟 < 1ms),能跑到 1000 - 2000 QPS;
- 如果数据库是远程的(延迟 10 - 50ms),大概 500 - 1000 QPS;
- 如果还要调用第三方 API(延迟 100ms 以上),可能只有 100 - 300 QPS。
举个真实例子:一个用户查询接口
- 解析请求参数 → 内存操作(0.1ms);
- 查 Redis 获取用户缓存 → I/O 操作(2ms);
- 返回结果 → 内存操作(0.1ms)。这样的接口在单核 Node.js 上,大概能跑 1500 QPS。
场景 3:CPU 密集型请求(有复杂计算)
特点:
- 逻辑需要大量 CPU 运算,比如:复杂的循环、数据加密(如 AES)、大 JSON 序列化 / 反序列化。
- Node.js 单线程的 短板:CPU 运算会阻塞主线程,无法处理其他请求。
每秒能处理的请求数:10 - 100 QPS
- 极端例子:一个接口需要循环 10 万 次,单核 Node.js 可能只能跑 10 QPS,且请求会排队等待。
- 解决方案:这种场景不适合 Node.js 单进程,需要用 多进程(cluster 模块) 或 把计算任务丢给专门的服务(如 Python) 。
简单说:大部分业务接口(查数据库),单核 Node.js 每秒能处理几百到两千个请求,这个量级足够支撑中小型应用(比如日活 10 万 用户)。
| 请求类型 | 单核 QPS 范围 | 核心影响因素 |
|---|---|---|
| 纯内存轻量请求 | 1 万 - 5 万 | 中间件数量 |
| I/O 密集型请求 | 500 - 2000 | 数据库延迟 |
| CPU 密集型请求 | 10 - 100 | 计算复杂度 |
从上面可以知道,对于nodejs来说,I/O才是决定qps的关键。
QPS(每秒处理请求数)是服务器一秒能接多少单。CPU 主频再高(相当于厨师切菜速度快),也架不住等食材(I/O 操作)的时间长。所以,I/O 操作的耗时(等食材)才是决定一秒能接多少单的核心,CPU 只是 “切菜的环节”,占比极低。
假设处理 1 个请求的总耗时 = CPU 运算耗时 + I/O 耗时:
场景 1:I/O 慢(等食材 1 秒),CPU 再快也没用
- CPU 主频 2GHz(切菜快):CPU 运算耗时 0.01 秒 / 请求;
- I/O 耗时(查数据库):1 秒 / 请求;
- 总耗时:1.01 秒 / 请求 → QPS ≈ 1(每秒只能处理 1 个请求)。
哪怕把 CPU 主频提升到 4GHz(切菜速度翻倍),CPU 耗时降到 0.005 秒 / 请求,总耗时还是 1.005 秒 / 请求 → QPS 依然≈1,几乎没变化。
场景 2:I/O 快(等食材 0.1 秒),CPU 慢一点也不影响
- CPU 主频 2GHz:CPU 耗时 0.01 秒 / 请求;
- I/O 耗时:0.1 秒 / 请求;
- 总耗时:0.11 秒 / 请求 → QPS ≈ 9(每秒能处理 9 个请求)。
哪怕 CPU 主频降到 1GHz(切菜慢一倍),CPU 耗时升到 0.02 秒 / 请求,总耗时 0.12 秒 / 请求 → QPS ≈ 8,几乎没变化。
实际工程中提升 QPS 的核心手段都是优化 I/O。
| 优化手段 | 本质 | 餐厅类比 |
|---|---|---|
| 数据库加索引 | 减少查库的 I/O 耗时 | 食材提前分类,找食材更快 |
| 用 Redis 缓存热点数据 | 用内存(快 I/O)替代数据库(慢 I/O) | 常用食材放在厨房台面,不用去仓库拿 |
| 接口异步化 / 并发调用 | 减少等待 I/O 的时间 | 同时催多个供应商送食材,不用等一个送完再催下一个 |
| 升级数据库服务器 | 提升 I/O 处理速度 | 供应商送货更快 |