土法炼钢兴趣小组的算法知识备份

epoll 的数据结构:红黑树、就绪队列与回调机制

目录

2003 年,Davide Libenzi 向 Linux 内核提交了 epoll 补丁。在那之前,所有人用 select 或 poll 来做 I/O 多路复用——每次调用都要把整个文件描述符集合从用户态拷贝到内核态,内核线性扫描一遍看谁就绪了,再把结果拷贝回来。10 个连接没问题,1000 个也能忍,到 10 万个的时候,光这个扫描就把 CPU 吃光了。

epoll 彻底改变了模型:内核自己维护监控集合,只在事件发生时通过回调收集结果。三个核心数据结构——红黑树、就绪链表、回调函数——是 epoll 的全部秘密。本文从内核源码层面拆解它们,然后用一个完整的 echo server 把理论落地。

一、select/poll 的性能灾难

1.1 select 的实现

select 的签名暴露了它的设计缺陷:

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

fd_set 本质上是一个位图,大小固定为 FD_SETSIZE(通常是 1024)。每次调用 select,内核做以下事情:

  1. 把三个 fd_set 从用户态拷贝到内核态——O(n) 的 copy_from_user
  2. 遍历 0 到 nfds 的每一个位,对每个被设置的 fd 调用该 fd 对应的 poll 方法检查就绪状态——O(n) 的扫描。
  3. 如果没有就绪事件,将当前进程挂到所有被监控 fd 的等待队列上,然后睡眠。
  4. 被唤醒后,再次执行步骤 2。
  5. 把修改后的 fd_set 拷贝回用户态——又一次 O(n)。

问题在于:每次调用都是全量操作。 即使 10 万个连接中只有 3 个就绪,你也要拷贝 10 万个 bit,扫描 10 万个 fd。

1.2 poll 的改进与不足

poll 用链表替代了位图,去掉了 1024 的上限:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;
    short events;   // 关注的事件
    short revents;  // 返回的事件
};

但核心问题没变——每次调用仍然要拷贝整个数组、线性扫描所有 fd。poll 只是把 select 的 1024 限制去掉了,时间复杂度没有任何改善。

1.3 数字说话

我做过一个简单的基准测试,在同一台 4 核 8GB 的服务器上,监控不同数量的空闲 TCP 连接(没有任何实际 I/O),单纯测量一次系统调用的耗时:

连接数 select 耗时 poll 耗时 epoll_wait 耗时
100 12 us 11 us 8 us
1,000 85 us 78 us 8 us
10,000 830 us 790 us 9 us
100,000 不支持 8,100 us 10 us

select 和 poll 的耗时随连接数线性增长,epoll_wait 几乎不变。这不是常数因子的差异,是算法复杂度级别的差异

我个人认为,select/poll 在今天的唯一合理使用场景是:监控的 fd 数量少于 100 且你不想引入 epoll 的复杂性。超过这个范围,没有理由不用 epoll。

二、epoll 的三个系统调用

epoll 把”注册兴趣”和”等待事件”拆成了独立的操作。这个设计决策是它高性能的根源。

2.1 epoll_create:创建实例

int epoll_create(int size);    // size 参数已被忽略(历史遗留)
int epoll_create1(int flags);  // 推荐使用,flags 可传 EPOLL_CLOEXEC

epoll_create 在内核中创建一个 struct eventpoll 对象,并返回一个文件描述符指向它。没错,epoll 实例本身就是一个 fd——这意味着你可以把一个 epoll fd 放到另一个 epoll 实例中监控,甚至可以 poll 一个 epoll fd。

内核中 eventpoll 的核心结构(简化自 fs/eventpoll.c):

struct eventpoll {
    spinlock_t lock;              // 保护就绪链表的自旋锁
    struct mutex mtx;             // 保护红黑树操作的互斥锁
    wait_queue_head_t wq;         // epoll_wait 的等待队列
    wait_queue_head_t poll_wait;  // 当 epoll fd 本身被 poll 时使用

    struct rb_root_cached rbr;    // 红黑树:存储所有被监控的 fd
    struct list_head rdllist;     // 就绪链表:存储有事件的 fd

    struct epitem *ovflist;       // 溢出链表(传输期间暂存)
    struct wakeup_source *ws;     // 用于 EPOLLWAKEUP
    struct user_struct *user;     // 创建者的用户信息
};

三个最关键的字段:rbr(红黑树)、rdllist(就绪链表)和 wq(等待队列)。后面会详细展开。

2.2 epoll_ctl:增删改监控项

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op 可以是:

每次 EPOLL_CTL_ADD 都会创建一个 struct epitem,这是 epoll 内部表示一个”被监控的 fd”的数据结构:

struct epitem {
    struct rb_node rbn;            // 红黑树节点(用于挂到 eventpoll->rbr)
    struct list_head rdllink;      // 就绪链表节点(用于挂到 eventpoll->rdllist)
    struct epitem *next;           // 溢出链表指针
    struct epoll_filefd ffd;       // 被监控的 fd + file 指针
    int nwait;                     // 等待队列计数
    struct list_head pwqlist;      // poll 等待队列链表
    struct eventpoll *ep;          // 所属的 eventpoll
    struct epoll_event event;      // 用户注册的事件和数据
};

注意 epitem 同时包含了红黑树节点(rbn)和链表节点(rdllink)。这意味着一个 epitem 可以同时存在于红黑树中(表示”被监控”)和就绪链表中(表示”有事件”)。这是一种典型的 Linux 内核设计模式:把链接节点嵌入到数据结构中,而不是在外部分配包装器。

2.3 epoll_wait:等待事件

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

epoll_wait 的实现非常简洁:

  1. 检查 rdllist 是否为空。
  2. 如果为空,把当前进程放到 wq 等待队列上,睡眠。
  3. 如果非空(或被唤醒后发现非空),把 rdllist 中的事件拷贝到用户态的 events 数组。
  4. 对于 LT 模式的 fd,如果事件仍然存在,重新插入 rdllist

关键点:epoll_wait 不做任何扫描。 它只是看就绪链表有没有东西,有就取出来,没有就睡觉。所有的”谁就绪了”判断已经在回调中完成了。

三、红黑树:监控集合的存储

3.1 为什么选择红黑树

epoll 需要一个数据结构来存储所有被监控的文件描述符。这个数据结构需要满足几个要求:

  1. 快速查找EPOLL_CTL_MODEPOLL_CTL_DEL 需要根据 fd 找到对应的 epitem。
  2. 快速插入/删除EPOLL_CTL_ADDEPOLL_CTL_DEL 要快。
  3. 不允许重复:同一个 fd 不能注册两次(除非用 dup 创建的不同 file 描述)。
  4. 有序不是必需的:不需要范围查询或顺序遍历。

候选方案的对比:

数据结构 查找 插入 删除 空间 缺点
哈希表 O(1) 均摊 O(1) 均摊 O(1) 均摊 偏大 最坏 O(n),需要调整大小
AVL 树 O(log n) O(log n) O(log n) 紧凑 旋转次数多,删除代价高
红黑树 O(log n) O(log n) O(log n) 紧凑 查找比哈希表慢
跳表 O(log n) O(log n) O(log n) 偏大 随机性,缓存不友好

红黑树看起来不是最快的——哈希表的均摊 O(1) 更好。但 Linux 内核选择红黑树的原因很实际:

  1. 最坏情况有界。内核代码不能接受均摊分析——一个哈希表 rehash 可能导致一次系统调用耗时几百毫秒,这在实时系统中是不可接受的。红黑树的每次操作严格 O(log n),最坏情况可预测。
  2. 不需要预估大小。哈希表需要选择桶数量,太小会退化,太大浪费内存。红黑树按需增长,没有这个问题。
  3. 内核中已有成熟实现lib/rbtree.c 是 Linux 内核中使用最广泛的数据结构之一,经过了十几年的优化和测试。CFS 调度器、内存管理的 VMA、ext4 的 extent tree 都用它。
  4. 删除操作更稳定。AVL 树在删除时可能需要 O(log n) 次旋转,红黑树最多只需要 3 次旋转。对于频繁增删 fd 的场景,这个差异很重要。

3.2 红黑树的键

红黑树的比较键不是简单的 fd 数值,而是 (file *, fd) 的组合。为什么不能只用 fd?

因为 Linux 中 fd 只是进程级别的索引号,同一个底层 file 对象可以对应多个 fd(通过 dup/dup2),而不同的 file 对象也可能指向同一个 fd(通过 close + open 重用了 fd 号)。用 (file *, fd) 组合作为键可以精确标识一个打开的文件描述。

// 内核中 epitem 的比较函数(简化)
static inline int ep_cmp_ffd(struct epoll_filefd *p1,
                              struct epoll_filefd *p2)
{
    // 先比较 file 指针
    if (p1->file > p2->file)
        return 1;
    if (p1->file < p2->file)
        return -1;
    // file 相同则比较 fd
    return p1->fd - p2->fd;
}

3.3 红黑树操作的锁保护

红黑树操作(插入、删除、查找)受 eventpoll->mtx(互斥锁)保护。这是一个睡眠锁,因为红黑树操作可能涉及内存分配(kmalloc),而内存分配可能会睡眠。

注意这里用的是互斥锁而不是自旋锁——这意味着 epoll_ctl 可能会阻塞,但不会忙等。在高并发场景下,如果大量线程同时对同一个 epoll 实例调用 epoll_ctl,这个锁会成为瓶颈。这也是为什么一些高性能框架(比如早期的 Nginx)选择每个 worker 使用独立的 epoll 实例,而不是共享一个。

我的经验是:如果你的 fd 增删频率超过每秒 10 万次,考虑分片——用多个 epoll 实例,每个管理一部分 fd。

四、就绪链表与回调机制

4.1 就绪链表(rdllist)

eventpoll->rdllist 是一个标准的 Linux 内核双向链表(struct list_head),存储所有当前有事件的 epitem。

为什么用链表而不是其他数据结构?因为就绪链表的操作模式极其简单:

链表在这里是完美的选择。任何更复杂的数据结构都是浪费。

4.2 回调函数 ep_poll_callback

这是 epoll 的核心机制。当通过 epoll_ctl 添加一个 fd 时,内核做了以下事情:

  1. 创建一个 epitem,插入红黑树。
  2. 调用该 fd 的 poll 方法(注意,这不是系统调用 poll,而是 file_operations->poll),传入一个等待队列入口(wait_queue_entry)。
  3. 这个等待队列入口的回调函数被设置为 ep_poll_callback

当 fd 上发生事件时(比如 TCP socket 收到数据,或者连接建立完成),内核的协议栈代码会唤醒该 fd 的等待队列。此时 ep_poll_callback 被调用:

// 简化版 ep_poll_callback
static int ep_poll_callback(wait_queue_entry_t *wait,
                            unsigned mode, int sync, void *key)
{
    struct epitem *epi = container_of(wait, struct eppoll_entry, wait)->base;
    struct eventpoll *ep = epi->ep;
    unsigned long flags;

    // 检查事件是否匹配用户关注的事件
    if (key && !((unsigned long)key & epi->event.events))
        return 1;

    spin_lock_irqsave(&ep->lock, flags);

    // 如果 epitem 不在就绪链表中,加入就绪链表
    if (!ep_is_linked(epi))
        list_add_tail(&epi->rdllink, &ep->rdllist);

    // 唤醒 epoll_wait 中等待的进程
    if (waitqueue_active(&ep->wq))
        wake_up_locked(&ep->wq);

    spin_unlock_irqrestore(&ep->lock, flags);

    return 1;
}

这段代码做了三件关键的事:

  1. 检查事件类型是否匹配(通过 key 参数与用户注册的事件做按位与)。
  2. 把 epitem 加入就绪链表(通过 list_add_tail,O(1) 操作)。注意 ep_is_linked 检查防止重复添加。
  3. 唤醒 epoll_wait(通过 wake_up_locked)。

就绪链表的自旋锁 ep->lock 用的是 spin_lock_irqsave,会关中断。这是因为回调可能在中断上下文中被调用(比如网卡收到数据触发的软中断),此时不能睡眠,也不能被同级中断打断。

4.3 整体流程图

把所有的数据结构和操作串起来:

用户态                         内核态
──────                         ──────

epoll_create()  ───────→  创建 eventpoll 结构体
                           初始化红黑树 rbr
                           初始化就绪链表 rdllist
                           初始化等待队列 wq

epoll_ctl(ADD, fd) ────→  创建 epitem
                           插入红黑树 rbr    ──────→  O(log n)
                           在 fd 的等待队列上
                           注册 ep_poll_callback

                          ─── 某个时刻,fd 上有事件 ───

                           网卡收到数据 / 连接建立
                           ↓
                           协议栈唤醒 fd 的等待队列
                           ↓
                           ep_poll_callback 被调用
                           ↓
                           epitem 加入就绪链表  ──→  O(1)
                           ↓
                           唤醒 epoll_wait 中的进程

epoll_wait() ──────────→  检查 rdllist 是否为空
  返回就绪事件 ←─────────  将 rdllist 中的事件
                           拷贝到用户态数组

这就是 epoll 的”O(1)“来源:epoll_wait 本身只做链表摘取和内存拷贝,不做任何 fd 扫描。扫描工作被分摊到了每个 fd 各自的回调中——但回调是事件驱动的,只有真正就绪的 fd 才会触发回调。

我一直觉得这是一个非常优雅的设计:把工作从”集中式轮询”变成了”分布式回调”。每个 fd 自己负责在就绪时报到,而不是让一个中央调度器挨个询问。

4.4 溢出链表 ovflist

eventpoll->ovflist 是一个容易被忽略但很重要的细节。当 epoll_wait 正在把就绪链表中的事件传输到用户态时,它需要持有 ep->lock。但在这段时间内,新的事件回调可能发生。为了避免锁竞争,epoll 使用了一个巧妙的手法:

  1. epoll_wait 开始传输前,把 ovflist 设置为非 EP_UNACTIVE_PTR
  2. 此后 ep_poll_callback 发现 ovflist 是活跃的,就不往 rdllist 中添加,而是追加到 ovflist 中(用 epi->next 单链表)。
  3. epoll_wait 传输完成后,把 ovflist 中的所有 epitem 移到 rdllist 中。

这相当于一个双缓冲机制:读写分离,避免了生产者(回调)和消费者(epoll_wait)的锁竞争。

五、ET 与 LT:两种触发模式

5.1 LT(Level-Triggered)模式

LT 是 epoll 的默认模式,语义与 select/poll 一致:只要 fd 处于就绪状态,每次 epoll_wait 都会返回这个 fd。

实现上,LT 模式在 epoll_wait 取出事件后做了一个额外操作:

// ep_send_events_proc 中的简化逻辑
if (epi->event.events & EPOLLET) {
    // ET 模式:不重新插入就绪链表
} else {
    // LT 模式:如果事件仍然存在,重新插入就绪链表
    if (revents & epi->event.events)
        list_add_tail(&epi->rdllink, &ep->rdllist);
}

LT 模式的好处是编程简单:你不需要一次读完所有数据,下次 epoll_wait 还会通知你。但代价是:如果你注册了一个 fd 却不处理它的数据,epoll_wait 会反复返回这个 fd,浪费 CPU。

5.2 ET(Edge-Triggered)模式

ET 模式只在状态变化时通知一次。“状态变化”的定义是:

如果 socket 缓冲区里还有未读数据,但没有新数据到达,ET 模式不会通知你。因此在 ET 模式下,你必须在收到通知后循环读取直到 EAGAIN

// ET 模式下正确的读取方式
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
            break;  // 所有数据已读完
        perror("read");
        break;
    }
    if (n == 0) {
        // 对端关闭连接
        close(fd);
        break;
    }
    process_data(buf, n);
}

如果你在 ET 模式下只读了一部分数据就返回了 epoll_wait,剩余的数据会一直待在缓冲区里,直到有新数据到达触发下一次通知。这是 ET 模式下最常见的 bug——数据饥饿

5.3 ET vs LT 的实际性能差异

ET 减少了 epoll_wait 返回的事件数量,理论上更高效。但实际项目中差距不大——Nginx 用 ET,Redis 用 LT,两者都能处理百万级连接。选择更多是编程模型的偏好。

5.4 惊群问题与 EPOLLEXCLUSIVE

当多个线程/进程同时 epoll_wait 在同一个 epoll 实例上时,一个新连接到达会唤醒所有等待的线程。但只有一个线程能成功 accept,其他线程白白被唤醒——这就是惊群效应(thundering herd)。

LT 模式下情况更糟:即使一个线程已经 accept 了连接,其他线程的 epoll_wait 仍然会返回这个监听 fd(因为 LT 会重复报告就绪状态)。

Linux 4.5 引入了 EPOLLEXCLUSIVE 标志:

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

设置了 EPOLLEXCLUSIVE 后,当事件到达时,内核只唤醒等待队列中的一个线程(而不是全部)。这大幅减少了不必要的上下文切换。

Nginx 从 1.11.3 开始支持 EPOLLEXCLUSIVE,在配置文件中使用 accept_mutex off(默认已经关闭)配合内核的 EPOLLEXCLUSIVE 来避免惊群。在此之前,Nginx 用一个进程间互斥锁(accept_mutex)来串行化 accept 操作,效率低得多。

我的建议:如果你的目标内核版本 >= 4.5,直接用 EPOLLEXCLUSIVE,忘掉应用层的 accept_mutex 方案。

六、epoll 与 io_uring 的对比

6.1 就绪模型 vs 完成模型

epoll 是就绪通知(readiness notification)模型:它告诉你”fd 可以读了”,然后你自己去调用 read()。这意味着:

  1. epoll_wait 返回后,你还需要做一次系统调用(read/write/accept)。
  2. 如果多个线程同时得到就绪通知并去读同一个 fd,需要额外的同步。

io_uring 是完成通知(completion notification)模型:你提交一个”读 1024 字节”的请求,内核帮你读完了才通知你。这意味着:

  1. 数据在通知时已经在你的缓冲区里了,不需要额外的系统调用。
  2. 从提交请求到收到结果,可以实现零系统调用(通过共享内存的提交队列和完成队列)。

6.2 性能对比

在网络 I/O 场景下,epoll 和 io_uring 的差距不大。原因是网络事件的瓶颈通常在协议栈处理和数据拷贝上,而不是系统调用本身。

但在磁盘 I/O 场景下,io_uring 明显胜出。传统的 read()/write() 对普通文件可能会阻塞(即使用了 O_NONBLOCK——Linux 的文件系统层几乎不支持非阻塞 I/O),而 io_uring 真正支持异步磁盘 I/O,不需要线程池来模拟。

特性 epoll io_uring
模型 就绪通知 完成通知
系统调用次数 每次 I/O 至少 2 次 可以实现 0 次
网络 I/O 性能 优秀 优秀(略好 5-15%)
磁盘 I/O 性能 不适用 显著优于 AIO
编程复杂度 中等
内核版本要求 2.6+ 5.1+(5.6+ 才成熟)
生态成熟度 20 年实战验证 快速成熟中

6.3 该选哪个

我的观点是:

不要因为 io_uring 更新就急着迁移。epoll 在网络场景下的性能已经足够好了,而 io_uring 的安全面(CVE 历史)仍然是一个值得关注的风险。

七、完整实现:epoll echo server

下面是一个生产级的 epoll echo server 实现,使用 ET 模式 + 非阻塞 I/O:

/*
 * epoll_echo_server.c - ET 模式 epoll echo server
 *
 * 编译: gcc -O2 -Wall -o echo_server epoll_echo_server.c
 * 运行: ./echo_server 9527
 * 测试: echo "hello" | nc localhost 9527
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAX_EVENTS  1024
#define BUF_SIZE    4096

static int set_nonblocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

static int create_listen_socket(int port)
{
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0) {
        perror("socket");
        return -1;
    }

    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);

    if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(lfd);
        return -1;
    }

    if (listen(lfd, SOMAXCONN) < 0) {
        perror("listen");
        close(lfd);
        return -1;
    }

    if (set_nonblocking(lfd) < 0) {
        perror("set_nonblocking");
        close(lfd);
        return -1;
    }

    return lfd;
}

static int epoll_add(int epfd, int fd, uint32_t events)
{
    struct epoll_event ev;
    ev.events = events;
    ev.data.fd = fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) < 0) {
        perror("epoll_ctl ADD");
        return -1;
    }
    return 0;
}

static void handle_accept(int epfd, int listen_fd)
{
    /* ET 模式下必须循环 accept 直到 EAGAIN */
    while (1) {
        struct sockaddr_in client_addr;
        socklen_t addr_len = sizeof(client_addr);

        int cfd = accept(listen_fd,
                         (struct sockaddr *)&client_addr, &addr_len);
        if (cfd < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
                break;  /* 所有待处理的连接都已接受 */
            perror("accept");
            break;
        }

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, ip, sizeof(ip));
        printf("[+] new connection: %s:%d (fd=%d)\n",
               ip, ntohs(client_addr.sin_port), cfd);

        if (set_nonblocking(cfd) < 0) {
            close(cfd);
            continue;
        }

        /* 用 EPOLLIN | EPOLLET 监控客户端 fd */
        if (epoll_add(epfd, cfd, EPOLLIN | EPOLLET) < 0) {
            close(cfd);
            continue;
        }
    }
}

static void handle_client(int epfd, int fd)
{
    char buf[BUF_SIZE];

    /* ET 模式下必须循环读取直到 EAGAIN */
    while (1) {
        ssize_t nread = read(fd, buf, sizeof(buf));
        if (nread < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
                break;  /* 所有数据已读完 */
            perror("read");
            goto close_conn;
        }
        if (nread == 0) {
            /* 对端关闭连接 */
            printf("[-] client disconnected (fd=%d)\n", fd);
            goto close_conn;
        }

        /* echo 回写,处理短写 */
        ssize_t total = 0;
        while (total < nread) {
            ssize_t nwrite = write(fd, buf + total, nread - total);
            if (nwrite < 0) {
                if (errno == EAGAIN || errno == EWOULDBLOCK) {
                    /*
                     * 写缓冲区满了。生产环境中应该:
                     * 1. 把未写完的数据存到用户态缓冲区
                     * 2. 注册 EPOLLOUT 事件
                     * 3. EPOLLOUT 触发时继续写
                     * 这里简化处理,直接丢弃剩余数据。
                     */
                    fprintf(stderr, "write buffer full, data truncated\n");
                    break;
                }
                perror("write");
                goto close_conn;
            }
            total += nwrite;
        }
    }
    return;

close_conn:
    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
    close(fd);
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <port>\n", argv[0]);
        return 1;
    }

    int port = atoi(argv[1]);
    if (port <= 0 || port > 65535) {
        fprintf(stderr, "Invalid port: %s\n", argv[1]);
        return 1;
    }

    /* 1. 创建监听 socket */
    int listen_fd = create_listen_socket(port);
    if (listen_fd < 0)
        return 1;

    /* 2. 创建 epoll 实例 */
    int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd < 0) {
        perror("epoll_create1");
        close(listen_fd);
        return 1;
    }

    /* 3. 将监听 fd 加入 epoll,使用 ET 模式 */
    if (epoll_add(epfd, listen_fd, EPOLLIN | EPOLLET) < 0) {
        close(listen_fd);
        close(epfd);
        return 1;
    }

    printf("Echo server listening on port %d (ET mode)\n", port);

    /* 4. 事件循环 */
    struct epoll_event events[MAX_EVENTS];

    for (;;) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            if (errno == EINTR)
                continue;  /* 被信号中断,重试 */
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;
            uint32_t ev = events[i].events;

            /* 错误处理 */
            if (ev & (EPOLLERR | EPOLLHUP)) {
                if (fd == listen_fd) {
                    fprintf(stderr, "listen socket error\n");
                    goto cleanup;
                }
                fprintf(stderr, "error on fd=%d\n", fd);
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                close(fd);
                continue;
            }

            if (fd == listen_fd) {
                handle_accept(epfd, listen_fd);
            } else if (ev & EPOLLIN) {
                handle_client(epfd, fd);
            }
        }
    }

cleanup:
    close(listen_fd);
    close(epfd);
    return 0;
}

编译和测试:

gcc -O2 -Wall -o echo_server epoll_echo_server.c
./echo_server 9527

# 另一个终端
echo "hello epoll" | nc localhost 9527

代码中的几个关键设计决策

  1. 监听 socket 也用 ET 模式。这意味着 handle_accept 必须循环 accept 直到 EAGAIN。如果不这样做,多个连接同时到达时可能只 accept 了一个,其余的连接请求会丢失(直到下一个新连接触发 ET 通知)。

  2. SO_REUSEPORT。允许多个进程绑定同一个端口,内核在 TCP 层做负载均衡。这比 EPOLLEXCLUSIVE 更彻底——每个进程有自己的监听 socket 和 accept 队列,完全没有争用。

  3. EPOLL_CLOEXEC。确保 fork 后子进程不会继承 epoll fd。这是一个防御性编程的好习惯——忘记加这个标志导致的 fd 泄漏是非常难调试的。

  4. 写操作的简化处理。生产环境中,当 write 返回 EAGAIN 时,应该把未写完的数据放入用户态写缓冲区,注册 EPOLLOUT 事件,等写缓冲区可用时再继续写。本示例为简洁省略了这部分逻辑。

八、性能特征与可伸缩性

8.1 各操作的时间复杂度

操作 时间复杂度 说明
epoll_create O(1) 分配 eventpoll 结构体
epoll_ctl(ADD) O(log n) 红黑树插入 + 注册回调
epoll_ctl(DEL) O(log n) 红黑树删除 + 取消回调
epoll_ctl(MOD) O(log n) 红黑树查找 + 修改事件
epoll_wait(有就绪事件) O(k) k = 就绪事件数量,不依赖总 fd 数
epoll_wait(无就绪事件) O(1) 直接睡眠
回调(每个事件) O(1) 链表追加 + 唤醒

关键洞察:epoll_wait 的复杂度只与就绪事件数 k 有关,而不是监控的总 fd 数 n。 这就是为什么即使监控 10 万个 fd,只要活跃连接不多,epoll_wait 依然很快。

8.2 内存开销

每个被监控的 fd 在内核中占用一个 epitem 结构,大小约 128-160 字节(取决于内核版本和配置)。监控 10 万个 fd 大约需要 12-16 MB 的内核内存。

epoll 还有一个 per-user 限制 /proc/sys/fs/epoll/max_user_watches,默认约 40 万。监控更多 fd 需要调大此值。

8.3 可伸缩性瓶颈

epoll 的性能不是无限的。以下是我在实践中遇到的几个瓶颈:

  1. 单个 epoll 实例的锁争用epoll_ctl 需要获取互斥锁修改红黑树,ep_poll_callback 需要获取自旋锁修改就绪链表。当监控的 fd 非常多且事件频繁时,这些锁可能成为瓶颈。解决方案:分片,每个线程/CPU 核心一个 epoll 实例。

  2. 用户态内存拷贝epoll_wait 需要把就绪事件从内核拷贝到用户态。如果一次返回上千个事件,这个拷贝开销不可忽视。io_uring 的共享内存方案完全消除了这个开销。

  3. 缓存污染。当活跃连接数很多时,epoll_wait 返回大量事件,处理这些事件时会访问大量不相关的内存地址(不同连接的缓冲区),导致 CPU 缓存频繁失效。

8.4 调优参数

# 系统级/进程级 fd 上限
cat /proc/sys/fs/file-max
ulimit -n

# epoll 监控上限
cat /proc/sys/fs/epoll/max_user_watches

# TCP 连接相关
cat /proc/sys/net/core/somaxconn
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
cat /proc/sys/net/ipv4/ip_local_port_range

九、工程陷阱速查表

以下是我和同事在生产环境中踩过的坑,整理成表格供参考:

陷阱 症状 原因 解决方案
fd 泄漏 系统 fd 数量持续增长,最终 EMFILE close(fd) 之前没有 EPOLL_CTL_DEL,或 close 后没有清理用户态状态 封装 conn_close() 函数统一处理;注意 close 会自动从 epoll 移除 fd,但如果 fd 被 dup 过则不会
过期事件(stale event) 访问已关闭 fd 的数据结构导致 crash 或逻辑错误 epoll_wait 返回的事件数组中,可能包含在本轮循环早些时候已经被关闭的 fd 使用 data.ptr 指向连接对象而不是 data.fd,在连接对象中维护一个 closed 标志
ET 模式数据饥饿 客户端发送数据后得不到响应 没有循环读取到 EAGAIN 严格遵循 ET 读取模板:while (read() > 0)
EPOLLHUP 未处理 连接挂起后 CPU 100% 对端关闭连接产生 EPOLLHUP,LT 模式下反复通知 始终检查 EPOLLERR | EPOLLHUP,出现时立即关闭 fd
EPOLLOUT 忘记取消 CPU 使用率异常高 写缓冲区清空后没有取消 EPOLLOUT 监控 写完所有数据后用 EPOLL_CTL_MOD 去掉 EPOLLOUT
EPOLL_CTL_DEL + close 顺序 偶发 EBADF 错误 在多线程环境中,close(fd) 后 fd 号被回收重用,另一个线程对旧 fd 执行 EPOLL_CTL_DEL EPOLL_CTL_DEL,再 close;或者用 data.ptr 而不是 data.fd
fork 后的 epoll fd 子进程行为异常 fork 后父子共享 epoll 实例,事件会被随机分发 子进程中关闭继承的 epoll fd,重新创建;或使用 EPOLL_CLOEXEC
EPOLLERR 不需要注册 疑惑为什么没注册却收到了 EPOLLERREPOLLHUP 始终会被报告,无论你是否在 events 中设置 这是设计行为,不是 bug;始终处理这两个事件

关于 close 与 EPOLL_CTL_DEL 的关系

一个常见误解是”close(fd) 就够了,不需要 EPOLL_CTL_DEL”。简单场景下确实如此。但如果 fd 通过 dup/fork 被复制过,close 一个副本不会触发 epoll 的自动清理——只有当所有指向同一 file 对象的 fd 都关闭后,epitem 才被移除。

我的建议:永远显式 EPOLL_CTL_DEL,不要依赖 close 的隐式清理。

十、从源码到直觉:个人思考

epoll 的设计哲学

回顾 epoll 的三个数据结构,我看到的是一个非常清晰的职责划分:

这种”注册一次,通知多次”的模式在系统设计中随处可见:数据库的触发器、消息队列的订阅、UI 框架的事件绑定,本质都是同一个思想。epoll 只是把它在操作系统层面实现得特别精致。

为什么理解内部实现很重要

有人说”用好 API 就够了,不需要知道内部实现”。我不同意。理解 epoll 的内部实现至少带来三个好处:

  1. 调试能力。当你的服务 CPU 100% 而 QPS 没变时,如果你知道 LT 模式下 EPOLLHUP 会导致忙循环,你能在 5 分钟内定位问题。否则你可能花一整天在 perf 里大海捞针。

  2. 设计决策。当你需要在 ET 和 LT 之间选择、在单 epoll 实例和多实例之间选择时,理解红黑树的锁和就绪链表的机制能帮你做出有根据的决定,而不是盲从 StackOverflow 上的建议。

  3. 跨领域迁移。epoll 的红黑树+链表+回调模式在很多其他系统中出现:kqueue 的 knote、Windows 的 IOCP、甚至游戏引擎的事件系统。理解一个,触类旁通。

一个时代的终结?

epoll 已经 20 年了。io_uring 正在改变 Linux I/O 的格局。但我不认为 epoll 会很快消失。就像 TCP 没有被 QUIC 取代一样,成熟的技术有巨大的惯性。

更重要的是,epoll 的设计思想——“用合适的数据结构解决合适的问题”——永远不会过时。红黑树用于有序集合,链表用于 FIFO 队列,回调用于异步通知,这些基本模式会一直存在,只是穿着不同的外衣出现在不同的系统中。

理解 epoll,不是为了写出下一个 Nginx(虽然也不是不行),而是为了在面对任何”如何高效处理大量并发事件”的问题时,脑子里有一个经过 20 年实战验证的参考架构。


上一篇: 文件系统的树 下一篇: 定时器算法

相关阅读: - 内存分配器对决 - 红黑树与 AVL 树


By .