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

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

你写的每一行 fetch()、每一个 WebSocket 连接、每一次 Node.js 的文件读取,背后都在依赖同一套机制。

从 V8 / Node.js 说起

浏览器和 Node.js 都是单线程 + 事件循环模型:

scss 复制代码
┌─────────────────────────────────────────────────┐
│                  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)?

yaml 复制代码
1983  select  →  位图轮询,上限 1024
1986  poll    →  数组轮询,解除上限
2002  epoll   →  事件驱动,彻底告别 O(n)

select --- 最原始的方案

数据结构

scss 复制代码
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() 检查

调用全流程

scss 复制代码
用户态                           内核态
  │                                │
  ├─ 构造 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 --- 改良版,解除上限

数据结构

arduino 复制代码
struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 关注的事件(用户填,不会被改写)
    short revents;  // 实际发生的事件(内核回写)
};

struct pollfd fds[1000];
int ready = poll(fds, 1000, -1);
// 返回后遍历 fds[],检查 fds[i].revents != 0

相比 select 改进了什么

matlab 复制代码
select 的问题              poll 的解法
─────────────────────────────────────────────
fd 上限 1024         →    pollfd 数组,理论无上限
fd_set 被内核改写    →    events / revents 分离,events 不变
三组 bitmap 混乱     →    events 位掩码,更清晰

没有解决的问题

scss 复制代码
                    select        poll
全量内存拷贝          ✗             ✗    ← 每次都要拷贝整个数组
内核 O(n) 遍历        ✗             ✗    ← 逐个检查驱动 poll()
用户态 O(n) 遍历      ✗             ✗    ← 还是要自己循环找就绪的

poll 只是 select 的"形状更好的版本",性能瓶颈的根源没变:连接越多越慢,活跃率越低越浪费


epoll --- 真正的突破

核心思想转变

select/poll 是主动轮询 :每次调用都要问一遍"谁好了?"

epoll 是被动通知:谁好了,内核主动告诉我。

bash 复制代码
select/poll 模型:
  你:「fd 0 好了吗?没有。fd 1 好了吗?没有。fd 2 好了吗?...」

epoll 模型:
  内核:「fd 7 好了,fd 23 好了,就这俩。」
  你:直接处理这俩。

三个系统调用

ini 复制代码
// 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);
}

内核数据结构

scss 复制代码
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,不碰红黑树
拷贝量 = 就绪数量,与注册总量无关

关键路径:一次数据到达

markdown 复制代码
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 默认用
ini 复制代码
// 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 专属

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

scss 复制代码
你写的代码                    底层机制
──────────────────────────────────────────────────────
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 的关系

scss 复制代码
┌──────────────────────────────────────┐
│           Node.js 事件循环            │
│                                      │
│  timers → I/O callbacks → idle →     │
│  poll ──────────────────────────────►│
│    │                                 │
│    └── epoll_wait(epfd, events, ...)  │
│         阻塞直到有事件                │
│         返回就绪事件列表              │
│         → 执行对应 JS 回调           │
└──────────────────────────────────────┘

epoll 就是 Node.js "非阻塞 I/O"的操作系统基石。你每次写 await fetch(),都在隐式地使用它。


什么时候用哪个

bash 复制代码
连接数 < 100,追求可移植性    →  select(或直接用库)
需要跨平台,连接数适中        →  poll
Linux 高并发服务器            →  epoll(libuv/libevent/Nginx 的选择)
macOS/BSD                    →  kqueue(同 epoll 思想)
Windows                      →  IOCP(完成端口,异步模型不同)

实际开发中你几乎不会直接调用这三个------Node.js 的 libuv、Python 的 asyncio、Go 的 netpoll 都已封装好。但理解它们,你才能真正读懂"非阻塞"、"事件驱动"、"单线程高并发"这些词背后的含义。

相关推荐
用户5433081441942 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo2 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭2 小时前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木2 小时前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮2 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati2 小时前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉2 小时前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain
wuhen_n3 小时前
双端 Diff 算法详解
前端·javascript·vue.js
UrbanJazzerati3 小时前
Vue 3 纯小白快速入门指南
前端·面试