8、I/O多路复用之epoll(上):epoll的三大函数与LT/ET模式详解

聊到高性能网络编程,epoll 是个绕不开的话题。我记得刚入行那会儿,还在用 select 写并发服务器,连接数一上千,CPU 就飙得厉害。后来换了 epoll,整个世界清静了。今天咱们就来拆解 epoll 的核心机制。

epoll 是 Linux 下最高效的 I/O 多路复用方案。它解决了 select/poll 的三大痛点:

  • 每次调用都要传递全量 fd 集合
  • 内核需要线性扫描所有 fd
  • fd 数量有上限(默认 1024)

说白了,epoll 就是「事件驱动」的典范——只关注那些真正「有事」的连接。

epoll 核心知识体系 epoll_create epoll_ctl epoll_wait LT 模式(水平触发) ET 模式(边缘触发) 红黑树管理 fd 就绪链表 O(1) 事件通知 mmap 共享内存 epoll = 事件驱动 + 零拷贝 + 无上限

8.1 epoll_create —— 创建 epoll 实例

这是第一步。你调用 epoll_create,内核帮你创建一个 epoll 实例,返回一个文件描述符。

#include <sys/epoll.h>

int epoll_create(int size);
// 返回:成功返回 fd,失败返回 -1

嗯,这里要注意。在 2.6.8 之后的内核中,size 参数其实被忽略了。内核会动态调整内部数据结构的大小。但为了兼容性,我建议你传一个大于 0 的值,比如 1024 或 65535。

个人习惯:我一般传 1 或者 1024。传 1 也没问题,内核根本不看它。但传个正经数字,代码可读性更好。

还有一个变体:

int epoll_create1(int flags);
// flags 可以传 0 或 EPOLL_CLOEXEC

EPOLL_CLOEXEC 是个好东西。它会在执行 exec 时自动关闭这个 fd,防止子进程意外继承。我在写守护进程时吃过这个亏——子进程拿着父进程的 epoll fd 乱搞,排查了半天。

8.2 epoll_ctl —— 控制事件注册

创建完实例,接下来就是往里面添加、修改或删除 fd 了。这就是 epoll_ctl 的活。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 返回:成功返回 0,失败返回 -1

op 有三个选项:

  • EPOLL_CTL_ADD —— 添加一个新的 fd
  • EPOLL_CTL_MOD —— 修改已注册 fd 的事件
  • EPOLL_CTL_DEL —— 删除一个 fd

核心数据结构是 struct epoll_event

struct epoll_event {
    uint32_t     events;    // 感兴趣的事件
    epoll_data_t data;      // 用户数据
};

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

events 可以组合以下宏:

事件宏含义
EPOLLIN可读事件
EPOLLOUT可写事件
EPOLLERR错误事件
EPOLLHUP挂起事件(对端关闭)
EPOLLET设置为边缘触发模式
EPOLLONESHOT一次性触发,触发后自动移除
避坑指南:我曾经在项目中忘了设置 EPOLLERR 和 EPOLLHUP,结果对端断开后,服务器完全没感知,连接一直挂着。后来排查才发现,这两个事件默认不会自动上报,必须显式注册。

一个典型的注册代码:

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 边缘触发 + 可读
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

8.3 epoll_wait —— 等待事件发生

这是 epoll 的核心。你调用它,内核告诉你哪些 fd 就绪了。

int epoll_wait(int epfd, struct epoll_event *events, 
               int maxevents, int timeout);
// 返回:就绪的 fd 数量,超时返回 0,出错返回 -1

参数说明:

  • events —— 输出参数,内核把就绪事件写到这里
  • maxevents —— 最多返回多少个事件
  • timeout —— 超时时间(毫秒),-1 表示阻塞等待

为什么 epoll 快?因为它只返回「就绪」的 fd。select 要遍历所有 fd,epoll 直接给你结果。时间复杂度 O(1)。

我的经验:maxevents 一般设成 64 或 128。太小了要频繁调用 epoll_wait,太大了浪费内存。我习惯设 128,配合非阻塞 I/O,单线程能扛几万连接。

一个典型的事件循环:

struct epoll_event events[128];
while (1) {
    int n = epoll_wait(epfd, events, 128, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理可读事件
            handle_read(events[i].data.fd);
        }
        if (events[i].events & EPOLLOUT) {
            // 处理可写事件
            handle_write(events[i].data.fd);
        }
    }
}

8.4 LT 模式 vs ET 模式

这是 epoll 最让人困惑的地方。我当年也绕了很久。

LT(水平触发,Level Triggered)

  • 只要 fd 还有数据可读,epoll_wait 就会一直返回它
  • 你不用一次性读完,下次还能继续读
  • 简单、安全,不容易丢事件

ET(边缘触发,Edge Triggered)

  • 只有状态发生变化时才通知一次
  • 你必须一次性把数据读完(或写完)
  • 效率更高,但容易踩坑

你想想看,为什么 ET 效率高?因为 LT 模式下,内核要一直记住「这个 fd 还有数据」,每次 epoll_wait 都要检查。ET 模式只管「变化」,不管「存量」。

核心区别一句话:LT 是「有货就喊你」,ET 是「刚上货时喊你一次」。

ET 模式的使用要点:

  • fd 必须是非阻塞的
  • 读事件:循环 read 直到返回 EAGAIN
  • 写事件:循环 write 直到返回 EAGAIN 或写完

一个 ET 模式的读处理:

void handle_read_et(int fd) {
    char buf[4096];
    while (1) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            // 处理数据
        } else if (n == 0) {
            // 对端关闭
            close(fd);
            break;
        } else {
            if (errno == EAGAIN) {
                break;  // 数据读完了
            }
            // 真正的错误
            break;
        }
    }
}
我曾经踩过的坑:ET 模式下,如果没读完数据就返回了 epoll_wait,剩下的数据可能永远等不到通知。因为「边缘」已经过去了。所以 ET 必须配合循环读,直到 EAGAIN。

LT 模式就简单多了:

void handle_read_lt(int fd) {
    char buf[4096];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        // 处理数据,没读完下次还会通知
    } else if (n == 0) {
        close(fd);
    }
}

LT 模式不用循环,读一次就行。没读完的数据,下次 epoll_wait 还会触发。简单吧?

8.5 如何选择?

我个人建议:

  • 新手或简单场景:用 LT。不容易出错,代码好写。
  • 高性能场景:用 ET。减少 epoll_wait 的触发次数,CPU 占用更低。
  • 混合使用:同一个 epoll 实例可以同时注册 LT 和 ET 的 fd,互不影响。

我记得在做一个网关项目时,内部通信用 LT,外部连接用 ET。LT 处理管理连接,ET 处理数据连接。各取所长,效果很好。

一个小技巧:ET 模式下,注册事件时加上 EPOLLONESHOT,可以避免多线程下同一个 fd 被多个线程同时处理。触发一次后自动移除,处理完再重新注册。

好了,epoll 的三大函数和两种模式就聊到这儿。这些是 epoll 的基石,理解了它们,后面的高性能服务器设计才能展开。下一节我们继续深入 epoll 的内部实现和高级用法。


公众号:蓝海资料掘金营,微信 deep3321