I/O 多路复用:从浏览器到 Linux 内核

40 阅读6分钟

为什么前端工程师需要理解这个?

你写的每一行 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 上限 1024fd_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);
}

三者对比

selectpollepoll
fd 上限1024无限制无限制
内存拷贝每次全量每次全量仅就绪事件
内核遍历O(n)O(n)O(1) 回调
fd 信息每次重传每次重传常驻内核
触发模式LTLTLT + ET
平台POSIXPOSIXLinux 专属

回到前端:这些机制在哪里工作

你写的代码                    底层机制
──────────────────────────────────────────────────────
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 都已封装好。但理解它们,你才能真正读懂"非阻塞"、"事件驱动"、"单线程高并发"这些词背后的含义。