cd ../articles

网络/后端

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

4 min read
终端里一串 socket 连接,只有零星几条亮着“就绪”。
终端里一串 socket 连接,只有零星几条亮着“就绪”。

最近给 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 有条铁律:循环 readEAGAIN,而且 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

作者头像
yen@harvey:~$ exit 0