epoll 递给你的是状态,不是数据

最近给 ACGHub 做私信系统的时候,我卡在一个很笨的问题上:几千个人同时挂在线,服务器到底怎么知道是「谁」发来了消息?就是这个问题,把我第一次推到了 epoll 面前——然后我发现,我对它的理解从头到尾都是错的。
起点:私信把我问住了
私信的「发」其实不难。A 要发给 B,我拿 B 的 user_id 去连接表里一查,找到 B 那条连接,写进去就行——这是一次哈希查找,O(1),不慌。
真正卡住我的是「收」。服务器手里攥着几千条 TCP 连接,绝大多数时候它们都是安静的;可我不知道此刻是哪几条来了新消息。我的第一版很诚实,也很蠢:
// 每个 tick,把所有连接都问一遍“你有数据吗”
for (int i = 0; i < conn_count; i++)
try_read(conns[i]); // 几千次系统调用,绝大多数白跑 每一轮都 O(n),而且 n 里 99% 是空闲连接。人一多,CPU 全耗在「挨个问一遍」上。我隐约知道这不对,但说不清正确的形状长什么样。
我的第一直觉:epoll 像 RabbitMQ
那阵子我刚在玩消息队列,所以撞见 epoll 时,脑子里第一个浮现的就是 RabbitMQ:我「订阅」一堆连接,它把「事件」推给我。
这个类比,对了一半。
对的那半是「注册」:
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = conn_fd };
epoll_ctl(ep, EPOLL_CTL_ADD, conn_fd, &ev); // 注册一次:我对这个 fd 的“可读”感兴趣 epoll_ctl(ADD) 真的很像在 RabbitMQ 里绑一个队列、声明「我关心这种事件」。你注册一次,之后就只管消费。
但有一句话,当时我能感觉到、却说不清楚:RabbitMQ 把消息体送到你手上,epoll 不送。 这句话,是我后来才真正咂摸明白的——也是这整篇文章的题眼。先按下,往后看。
第一处打脸:它不是「更快的 hashmap」
我当时给自己的解释是:select 是 O(n),epoll 像 hashmap,所以 O(1)。
前半句没错。select / poll 的死穴在于,你每次调用都得把整张 表交给内核,内核再挨个扫一遍:
while (1) {
fd_set rfds = all_fds; // 每轮都要重建整张表
select(maxfd + 1, &rfds, NULL, NULL, NULL);
for (int fd = 0; fd <= maxfd; fd++) // O(n):扫所有人,哪怕只有一个就绪
if (FD_ISSET(fd, &rfds)) handle(fd);
} 后半句——「O(1) 的 hashmap」——是错的,而且错得挺关键。epoll 快,不是因为它「查得快」,而是因为它根本不去扫:
- 你
epoll_ctl注册时,内核给每个 fd 挂上一个回调; - 哪个 fd 就绪了(网卡来了数据、TCP 协议栈把这个 socket 标成可读),它的回调就触发,把这个 fd 塞进一条「就绪链表」;
epoll_wait什么也不扫,只是把这条就绪链表交给你。
struct epoll_event events[MAX];
while (1) {
int n = epoll_wait(ep, events, MAX, -1); // 只返回“就绪”的那几个
for (int i = 0; i < n; i++) // O(就绪数),不是 O(总连接数)
handle(events[i].data.fd);
} 所以代价是 O(就绪数),不是 O(1),更不是 O(n)。10 万条连接里只有 10 条活跃,epoll_wait 就只干 10 份活;select 得把 10 万条全扫一遍。你只为「活跃」付费,不为「在线」付费——这才是它真正赢的地方。
它到底递给你什么:状态,不是数据
现在回到那句按下的话。
epoll_wait 返回之后,你手里拿到的不是消息,而是一张名单:这几个 fd 现在可读 / 可写了。数据还乖乖躺在内核的 缓冲区里——你得自己去 read(),而且还可能读到一半返回 。
这就是所谓的「就绪模型」(readiness):epoll 只告诉你「现在动手不会阻塞」,它不替你动手。
对照着看就清楚了:近年的 io_uring、Windows 的 IOCP 是另一套——「完成模型」(completion):你把活儿交给内核,它读完了再通知你,数据直接送到你给的 buffer 里。那个才像 RabbitMQ「把消息体端给你」。
所以我的类比得改:
epoll 不是把菜端上桌的服务员,它是叫号机——「3 号桌好了」,菜你自己去窗口端。
水平触发 vs 边缘触发
这俩当时我也有个模糊的直觉。我的原话是:「LT 像没收到 ACK 就一直重试,ET 像只通知一次、不管你收没收到。」
方向是对的,但差了要命的一层。
水平触发 (LT,默认):只要缓冲区里还有没读完的数据,下一次 epoll_wait 照样把这个 fd 报给你——它会一直烦你,直到你读干净为止。笨,但省心,你漏读了它会再提醒。
边缘触发 (ET):只在「状态跳变」的那一下报一次。新数据到达 = 一个「边缘」。你要是没在这一次里把数据读干净,剩下的它不会再提醒你——那条连接就这么静悄悄地卡住了,直到下一批新数据来制造一个新边缘。
所以 ET 有条铁律:循环 read 到 EAGAIN,而且 fd 必须是非阻塞的。
// 边缘触发:必须一次榨干,否则剩下的数据再也不会被通知
while (1) {
ssize_t k = read(fd, buf, sizeof buf);
if (k > 0) { handle(buf, k); continue; }
if (k == -1 && errno == EAGAIN) break; // 读空了,安心等下一个“边缘”
if (k == 0) { close(fd); break; } // 对端关闭
} 名字其实来自电路:电平 (level)——高就一直触发;边沿 (edge)——跳变那一瞬才触发。ET 唤醒更少、更省,但坑也更深;LT 啰嗦一点,却不容易自己埋雷。当年的我果断选了 LT,现在回头看,那是对的——别在还没吃透的时候去碰 ET。
修正之后,我的心智模型
把被打脸的地方都补上,现在我是这么理解 epoll 的:
flowchart LR
subgraph K[内核]
I["兴趣集合<br/>(epoll_ctl 注册一次)"] --> CB[某个 fd 就绪 → 回调]
CB --> RL[就绪链表]
end
RL -->|epoll_wait 取走名单| APP[你的事件循环]
APP -->|对就绪的 fd 自己 read| APP - 注册一次,不是每轮重交(这是对
select的第一个超越); - 就绪由回调推进就绪链表,内核不扫空闲连接;
epoll_wait拿到的是名单,代价 O(就绪数);- 它给你状态,不给你数据——
read是你自己的事; - LT 啰嗦保平安,ET 省唤醒但要榨干。
它其实一直在你脚下
就算你从不直接写这些系统调用,epoll 也一直在替你干活:Redis 的单线程事件循环、nginx、Node 的 libuv、Rust 的 tokio / mio——在 Linux 上,底下大多是 epoll。换到别的系统,BSD / macOS 是 kqueue,Windows 是 IOCP,思路一脉相承。
我们平时写的 async / await、各种「高并发框架」,本质上都是在这一层之上盖的楼。把最底下这层——「内核只递给你一张就绪名单,数据你自己取」——想明白了,上面那些「魔法」也就没那么玄了。
说到底我也才刚摸到门口,这篇是我现在的理解,不是什么权威结论;哪里说岔了,欢迎来私信纠我——反正 ACGHub 的 DM 系统,现在扛得住了。
// related