为什么前端工程师需要理解这个?
你写的每一行 fetch()、每一个 WebSocket 连接、每一次 Node.js 的文件读取,背后都在依赖同一套机制。
从 V8 / Node.js 说起
浏览器和 Node.js 都是单线程 + 事件循环模型:
┌─────────────────────────────────────────────────┐
│ JavaScript 单线程 │
│ │
│ fetch() → Promise → .then() │
│ setTimeout() → callback │
│ WebSocket.onmessage → handler │
└───────────────┬─────────────────────────────────┘
│ 所有 I/O 都是异步的
▼
┌─────────────────────────────────────────────────┐
│ libuv(Node.js 的异步核心) │
│ │
│ 事件循环 → 调用操作系统 I/O 接口 │
│ │
│ Linux: epoll_wait() │
│ macOS: kqueue() │
│ Windows: IOCP │
└─────────────────────────────────────────────────┘
核心矛盾:JS 是单线程的,但现实世界的 I/O 是并发的。
浏览器同时打开 100 个 WebSocket,不可能为每个连接开一个线程——你需要用一个线程监听 100 个 fd,谁有数据就处理谁。
这就是 I/O 多路复用要解决的问题。
三种机制的演进
历史上出现了三种方案,一代比一代好,解决同一个问题:如何高效地同时监听多个文件描述符(fd)?
1983 select → 位图轮询,上限 1024
1986 poll → 数组轮询,解除上限
2002 epoll → 事件驱动,彻底告别 O(n)
select — 最原始的方案
数据结构
fd_set readfds; // 本质是 1024 位的 bitmap
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);
// 返回后必须手动遍历 0~max_fd,逐个 FD_ISSET() 检查
调用全流程
用户态 内核态
│ │
├─ 构造 fd_set bitmap ──────────►│
│ ├─ 全量拷贝 bitmap(O(n) 内存拷贝)
│ ├─ 逐个遍历 fd,调用驱动 poll()(O(n))
│ [进程挂起等待] ├─ 某 fd 就绪 → 标记 bitmap
│ ├─ 全量拷贝 bitmap 回用户态(O(n) 内存拷贝)
│◄───────────────────────────────┤
├─ 再次遍历所有 fd(O(n)) │
│ FD_ISSET() 逐一检查 │
致命缺陷
| 问题 | 原因 |
|---|---|
| fd 上限 1024 | fd_set 是定长 bitmap,FD_SETSIZE = 1024 |
| 每次两次全量拷贝 | bitmap 从用户态 → 内核态 → 用户态 |
| 两次 O(n) 遍历 | 内核遍历 + 用户态遍历,n = max_fd |
| fd_set 被内核改写 | 每次调用前必须重置,不能复用 |
poll — 改良版,解除上限
数据结构
struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件(用户填,不会被改写)
short revents; // 实际发生的事件(内核回写)
};
struct pollfd fds[1000];
int ready = poll(fds, 1000, -1);
// 返回后遍历 fds[],检查 fds[i].revents != 0
相比 select 改进了什么
select 的问题 poll 的解法
─────────────────────────────────────────────
fd 上限 1024 → pollfd 数组,理论无上限
fd_set 被内核改写 → events / revents 分离,events 不变
三组 bitmap 混乱 → events 位掩码,更清晰
没有解决的问题
select poll
全量内存拷贝 ✗ ✗ ← 每次都要拷贝整个数组
内核 O(n) 遍历 ✗ ✗ ← 逐个检查驱动 poll()
用户态 O(n) 遍历 ✗ ✗ ← 还是要自己循环找就绪的
poll 只是 select 的"形状更好的版本",性能瓶颈的根源没变:连接越多越慢,活跃率越低越浪费。
epoll — 真正的突破
核心思想转变
select/poll 是主动轮询:每次调用都要问一遍"谁好了?"
epoll 是被动通知:谁好了,内核主动告诉我。
select/poll 模型:
你:「fd 0 好了吗?没有。fd 1 好了吗?没有。fd 2 好了吗?...」
epoll 模型:
内核:「fd 7 好了,fd 23 好了,就这俩。」
你:直接处理这俩。
三个系统调用
// 1. 创建 epoll 实例(一次性)
int epfd = epoll_create1(0);
// 2. 注册 fd(fd 信息常驻内核,无需每次传)
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sockfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // O(log n)
// 3. 等待事件(只返回就绪的 fd)
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
// n = 就绪数量,直接遍历 n 个,无需全量扫描
for (int i = 0; i < n; i++) {
handle(events[i].data.fd);
}
内核数据结构
epoll 实例(eventpoll)
├── 红黑树(rbr)
│ ├── fd 3 ← epoll_ctl ADD 时插入,O(log n)
│ ├── fd 7
│ ├── fd 23
│ └── ... ← 所有注册的 fd,常驻内核
│
└── 就绪链表(rdllist)
│
│ ← 网卡中断 → 驱动 → ep_poll_callback() → 插入此处
├── fd 7 (有数据了)
└── fd 23 (有数据了)
epoll_wait 只取 rdllist,不碰红黑树
拷贝量 = 就绪数量,与注册总量无关
关键路径:一次数据到达
1. 网卡 DMA 写数据 → 触发硬件中断
2. 内核中断处理 → 调用 TCP/IP 协议栈
3. 数据到达 socket 缓冲区
4. 驱动调用 ep_poll_callback()
5. 将对应 epitem 插入 rdllist
6. 唤醒 epoll_wait 等待的进程
7. epoll_wait 返回,只拷贝 rdllist 中的事件
整个过程,内核从不遍历所有注册的 fd。
LT vs ET 触发模式
LT(水平触发,默认) ET(边缘触发,EPOLLET)
─────────────────────────────────────────────────────────
fd 有数据 → 每次 epoll_wait 都返回 状态变化时通知一次
不读完没关系,下次还会通知 必须一次读完(循环到 EAGAIN)
实现简单,适合入门 性能更高,Nginx/Redis 默认用
// ET 模式必须配合非阻塞 I/O + 循环读
ev.events = EPOLLIN | EPOLLET;
fcntl(fd, F_SETFL, O_NONBLOCK);
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n <= 0) { close(fd); break; }
process(buf, n);
}
三者对比
| select | poll | epoll | |
|---|---|---|---|
| fd 上限 | 1024 | 无限制 | 无限制 |
| 内存拷贝 | 每次全量 | 每次全量 | 仅就绪事件 |
| 内核遍历 | O(n) | O(n) | O(1) 回调 |
| fd 信息 | 每次重传 | 每次重传 | 常驻内核 |
| 触发模式 | LT | LT | LT + ET |
| 平台 | POSIX | POSIX | Linux 专属 |
回到前端:这些机制在哪里工作
你写的代码 底层机制
──────────────────────────────────────────────────────
fetch('https://...')
.then(res => res.json()) → libuv → epoll_wait()
等待 TCP socket 就绪
new WebSocket('wss://...') → libuv 维护 socket fd
ws.onmessage = handler 数据到达 → epoll 回调 → 事件队列
→ JS 微任务队列 → handler()
fs.readFile('./data.json', → libuv 线程池(文件 I/O 特殊)
callback) 完成后 epoll 通知主线程
setTimeout(fn, 100) → timerfd(Linux)加入 epoll 监听
100ms 后 timerfd 就绪 → 回调
Node.js 事件循环与 epoll 的关系
┌──────────────────────────────────────┐
│ Node.js 事件循环 │
│ │
│ timers → I/O callbacks → idle → │
│ poll ──────────────────────────────►│
│ │ │
│ └── epoll_wait(epfd, events, ...) │
│ 阻塞直到有事件 │
│ 返回就绪事件列表 │
│ → 执行对应 JS 回调 │
└──────────────────────────────────────┘
epoll 就是 Node.js "非阻塞 I/O"的操作系统基石。你每次写 await fetch(),都在隐式地使用它。
什么时候用哪个
连接数 < 100,追求可移植性 → select(或直接用库)
需要跨平台,连接数适中 → poll
Linux 高并发服务器 → epoll(libuv/libevent/Nginx 的选择)
macOS/BSD → kqueue(同 epoll 思想)
Windows → IOCP(完成端口,异步模型不同)
实际开发中你几乎不会直接调用这三个——Node.js 的 libuv、Python 的 asyncio、Go 的 netpoll 都已封装好。但理解它们,你才能真正读懂"非阻塞"、"事件驱动"、"单线程高并发"这些词背后的含义。