1. Node.js 高并发基础:事件循环机制、非阻塞 I/O 与 libuv 架构解析
大家好,欢迎来到这门课的第一章。很多同学学 Node.js,上来就撸 Express 写接口,觉得挺简单。但一遇到高并发场景,服务器就卡死、内存暴涨,完全不知道问题出在哪。说白了,就是没搞懂 Node.js 最核心的「事件循环」和「非阻塞 I/O」到底是怎么回事。
我个人习惯,学任何技术都要先啃底层。你想想看,连 V8 和 libuv 怎么配合工作的都不清楚,怎么去优化几十万并发的长连接?这一章,我们就来把地基打牢。
1.1 从一道面试题说起:为什么 Node.js 能处理高并发?
我记得有一次面试,我问候选人:「Node.js 是单线程的,为什么还能支持高并发?」
他回答:「因为用了异步非阻塞 I/O。」
这个答案没错,但太笼统了。我追问:「那事件循环是怎么调度的?libuv 在里面扮演什么角色?」
他愣住了。
其实,Node.js 的高并发能力,靠的是三驾马车:
- V8 引擎:负责执行 JavaScript 代码,编译、优化、垃圾回收
- libuv 库:负责处理 I/O 操作、事件循环、线程池调度
- 事件循环机制:把异步任务排队,按优先级依次执行
这三者缺一不可。V8 是大脑,libuv 是手脚,事件循环就是指挥中心。
1.2 事件循环:Node.js 的「心脏」
事件循环是什么?说白了就是一个死循环,不断检查有没有任务需要处理。Node.js 启动后,主线程就进入这个循环,直到所有任务都完成才退出。
我刚开始学的时候,以为事件循环就是简单的「先进先出」队列。后来踩了坑才发现,它内部有六个阶段,每个阶段都有自己的任务队列。
事件循环的六个阶段(按顺序执行):
- timers:执行 setTimeout、setInterval 的回调
- pending callbacks:执行延迟到下一轮的 I/O 回调
- idle, prepare:内部使用,我们一般不用管
- poll:核心阶段,获取新的 I/O 事件,执行 I/O 回调
- check:执行 setImmediate 的回调
- close callbacks:执行 socket.on('close') 等关闭回调
每个阶段都有一个 FIFO 队列。事件循环会先清空当前阶段的队列,再进入下一个阶段。如果队列里有耗时操作,就会阻塞后续阶段。
嗯,这里要注意:事件循环不是并行执行,而是快速切换。它之所以能处理高并发,是因为大部分操作(比如读文件、查数据库)都交给了 libuv 的线程池,主线程只负责调度。
1.3 非阻塞 I/O:别让主线程「干等」
传统的阻塞 I/O 是什么样?你调用 readFile,线程就卡在那里,直到文件读完才返回。如果同时有 1000 个请求,就得开 1000 个线程,内存直接爆炸。
Node.js 的做法完全不同。它发起一个 I/O 请求后,立刻返回,继续处理下一个任务。等 I/O 操作完成了,libuv 会把回调函数扔回事件循环,等主线程空闲了再执行。
举个例子:
// 阻塞 I/O(伪代码)
const data = fs.readFileSync('bigfile.txt');
console.log(data); // 必须等文件读完
// 非阻塞 I/O
fs.readFile('bigfile.txt', (err, data) => {
console.log(data); // 文件读完后才执行
});
console.log('我先执行!'); // 立刻输出
我在项目中遇到过一个问题:有个同事用 readFileSync 读取配置文件,结果每次重启服务都要等好几秒。换成异步版本后,启动速度提升了 10 倍。你想想看,如果是在高并发场景下,阻塞 I/O 简直就是灾难。
1.4 libuv 架构:Node.js 的「幕后英雄」
libuv 是 C 语言写的跨平台异步 I/O 库。Node.js 能跑在 Windows、Linux、macOS 上,全靠它统一了底层接口。
libuv 的核心组件包括:
| 组件 | 作用 |
|---|---|
| 事件循环 (uv_run) | 驱动整个循环,调度各阶段任务 |
| 线程池 (Thread Pool) | 默认 4 个线程,处理文件 I/O、DNS 查询等 |
| 异步 I/O 操作 | 封装了 epoll (Linux)、kqueue (macOS)、IOCP (Windows) |
| 定时器 (Timer) | 管理 setTimeout、setInterval |
| 信号处理 (Signal) | 处理进程信号,如 SIGINT、SIGTERM |
为什么需要线程池?因为有些操作(比如文件读取)在操作系统层面没有统一的异步接口。libuv 就用线程池来模拟异步,把阻塞操作丢到后台线程,主线程继续跑。
小提示:libuv 的线程池默认只有 4 个线程。如果你有大量文件 I/O 或 CPU 密集型任务,可以通过环境变量 UV_THREADPOOL_SIZE 调整,比如设置成 8 或 16。但别设太大,线程切换也有开销。
1.5 事件循环的「坑」与避坑指南
我曾经在线上环境踩过一个坑:有个服务每隔 5 秒执行一次定时任务,结果某次任务里调了一个耗时的同步操作,导致事件循环被阻塞了 10 秒。后续的定时器全部延迟,请求超时率飙升。
为什么会这样?因为事件循环的 timers 阶段只检查定时器是否到期,但不会中断正在执行的回调。如果回调里有个 while 循环跑 10 秒,那这 10 秒内事件循环就卡死了。
避坑指南:
- 永远不要在回调里执行 CPU 密集型操作(如大数据排序、图片处理)。用 Worker Threads 或子进程分离出去。
- 小心递归的 process.nextTick。它会插队到当前阶段末尾,如果递归太深,会饿死 I/O 回调。
- setImmediate vs setTimeout(fn, 0):setImmediate 在 check 阶段执行,setTimeout 在 timers 阶段执行。如果都在 poll 阶段注册,setImmediate 会先执行。
// 演示 setImmediate 和 setTimeout 的顺序
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
// 输出结果不确定,取决于事件循环启动时的状态
// 但如果在 I/O 回调里,setImmediate 永远先执行
1.6 实战:用代码感受事件循环
光说不练假把式。我们来写个小程序,看看事件循环各阶段的执行顺序。
const fs = require('fs');
console.log('1. 同步代码开始');
setTimeout(() => {
console.log('2. setTimeout');
}, 0);
setImmediate(() => {
console.log('3. setImmediate');
});
fs.readFile(__filename, () => {
console.log('4. I/O 回调');
setTimeout(() => {
console.log('5. I/O 内的 setTimeout');
}, 0);
setImmediate(() => {
console.log('6. I/O 内的 setImmediate');
});
process.nextTick(() => {
console.log('7. I/O 内的 nextTick');
});
});
process.nextTick(() => {
console.log('8. 同步代码后的 nextTick');
});
console.log('9. 同步代码结束');
猜猜输出顺序?我直接告诉你答案:
1. 同步代码开始
9. 同步代码结束
8. 同步代码后的 nextTick
2. setTimeout
3. setImmediate
4. I/O 回调
7. I/O 内的 nextTick
6. I/O 内的 setImmediate
5. I/O 内的 setTimeout
注意看:process.nextTick 永远在当前阶段末尾执行,优先级高于微任务和宏任务。而 setImmediate 在 check 阶段,setTimeout 在 timers 阶段。I/O 回调在 poll 阶段,所以 I/O 内的 setImmediate 比 setTimeout 先执行。
警告:process.nextTick 虽然方便,但滥用会导致 I/O 回调被「饿死」。官方推荐优先使用 setImmediate,因为它更符合事件循环的设计意图。
1.7 总结:高并发的本质
Node.js 的高并发,说白了就是「用单线程 + 事件循环 + 非阻塞 I/O,把等待时间压缩到极致」。它不靠多线程并行,而是靠高效的调度和异步回调。
但记住,没有银弹。Node.js 擅长的是 I/O 密集型场景(如 Web 服务器、API 网关、实时推送)。如果是 CPU 密集型(如视频编码、科学计算),还是老老实实用多线程或 Go 语言吧。
下一章,我们会深入 libuv 的线程池和异步 I/O 实现,手写一个简易的事件循环。到时候你就知道,Node.js 的底层到底有多精巧。
公众号:蓝海资料掘金营,微信deep3321