78、epoll 模型:Linux 下高性能 IO 模型

说实话,搞 Linux 下网络编程的人,早晚都得跟 epoll 打交道。我记得刚入行那会儿,还在用 select 做并发,结果一上线就出问题——连接数一多,CPU 直接飙到 100%。后来老员工丢给我一句话:「去学学 epoll 吧。」

嗯,今天我们就来聊聊这个。

为什么需要 epoll?

先说说传统 IO 模型的痛点。select 和 poll 的问题在哪?说白了,它们每次调用都要把整个文件描述符集合从用户态拷贝到内核态。你想想看,如果同时管理几千个连接,每次都要全量拷贝,这开销有多大?

而且 select 还有最大 1024 个 fd 的限制。这在嵌入式设备上可能够用,但在服务器端,简直就是个笑话。

epoll 的出现,就是为了解决这些问题。它有三个核心机制:

  • 事件驱动:只关注「有事件发生」的 fd,而不是遍历所有 fd
  • 红黑树 + 就绪链表:内部用红黑树管理所有注册的 fd,用链表记录就绪事件
  • mmap 共享内存:减少用户态和内核态的数据拷贝

核心结论:epoll 的时间复杂度是 O(1),而 select 是 O(n)。当连接数超过 1000 时,差距非常明显。

epoll 的三种工作模式

这里有个容易混淆的点——epoll 本身有两种触发模式,但很多人会忽略第三种「边缘触发 + 非阻塞」的组合。我一个个说。

模式 触发方式 特点 适用场景
LT(水平触发) 只要缓冲区有数据,就一直通知 简单、不易丢数据 初学者、低并发场景
ET(边缘触发) 只在状态变化时通知一次 高效、但容易漏数据 高并发、大流量场景
ET + 非阻塞 边缘触发 + 非阻塞 IO 最推荐、性能最好 生产环境首选

注意:ET 模式下,如果你没有把数据一次性读完,后续数据不会再通知你。我曾经因为这个 bug 导致线上丢包,排查了整整两天。

核心 API 详解

epoll 的接口其实就三个函数,但每个都有坑。我一个个拆开讲。

1. epoll_create

int epoll_fd = epoll_create(1);  // 参数在 Linux 2.6.8 之后被忽略,但必须大于 0

这个函数返回一个 epoll 实例的文件描述符。注意,它返回的是一个 fd,用完要记得 close。我在项目中见过有人忘记关闭,导致 fd 泄漏,最后进程崩溃。

2. epoll_ctl

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 监听可读事件,边缘触发
ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);

这里有个细节:epoll_event 结构体中的 data 是一个联合体,你可以存 fd,也可以存指针。我个人习惯存指针,这样回调时能拿到更多上下文信息。

小技巧:如果你用 ET 模式,记得把 EPOLLINEPOLLOUT 分开注册。不要一次性注册所有事件,否则容易触发惊群效应。

3. epoll_wait

struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
    // 处理 events[i].data.fd
}

这个函数的超时参数,我建议设成 -1(无限等待)或者一个合理的毫秒数。设成 0 的话,相当于轮询,CPU 会空转。我曾经在调试时设成 0,结果 CPU 占用率直接 100%,还以为是死循环。

完整代码示例

下面是一个简单的 epoll 服务器框架。注意,这是 ET + 非阻塞的写法。

#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>

#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int epoll_fd = epoll_create(1);
    struct epoll_event ev, events[MAX_EVENTS];

    // 假设 listen_fd 已经创建并绑定
    set_nonblocking(listen_fd);
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 处理新连接
                struct sockaddr_in client_addr;
                socklen_t addr_len = sizeof(client_addr);
                int client_fd = accept(listen_fd, 
                    (struct sockaddr*)&client_addr, &addr_len);
                set_nonblocking(client_fd);
                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = client_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
            } else {
                // 处理客户端数据
                char buf[BUFFER_SIZE];
                int n = read(events[i].data.fd, buf, sizeof(buf));
                if (n <= 0) {
                    close(events[i].data.fd);
                } else {
                    // 处理数据...
                }
            }
        }
    }
    close(epoll_fd);
    return 0;
}

注意:ET 模式下,read 必须用循环读到 EAGAIN,否则会丢数据。上面的代码为了简洁没写循环,实际项目中一定要加。

epoll 的工作流程

下面这张图展示了 epoll 的核心流程。我画得比较简洁,但关键节点都在了。

用户态 应用程序 epoll_wait() 等待事件 处理就绪的 fd 内核态 epoll 实例 红黑树管理所有 fd 就绪链表记录活跃 fd epoll_ctl 就绪事件返回 事件处理流程 注册 fd 等待事件 处理就绪 关闭/移除

避坑指南

我这些年用 epoll 踩过的坑,列几个典型的:

  • 忘记设置非阻塞:ET 模式下,如果 fd 是阻塞的,read 会卡住。我曾经在代码审查时发现同事漏了这一步,差点上线。
  • ET 模式下没读完数据:只 read 一次就退出,结果下次 epoll_wait 不再通知,数据永远留在缓冲区里。
  • epoll_wait 返回 -1 没处理:信号中断会导致 epoll_wait 返回 -1,errno 是 EINTR。正确的做法是重试,而不是直接退出。
  • 忘记 close epoll_fd:epoll_create 返回的 fd 也是资源,不关闭会导致 fd 泄漏。

我的建议:生产环境优先使用 ET + 非阻塞模式。虽然代码写起来麻烦一点,但性能提升很明显。如果你刚开始学,先用 LT 模式跑通流程,再切换到 ET。

性能对比

最后给个直观的数据。我在一个嵌入式项目里做过测试,管理 5000 个连接:

模型 CPU 占用 内存占用 响应延迟
select 85% 不稳定
poll 70% 一般
epoll (LT) 25% 稳定
epoll (ET) 15% 优秀

嗯,差距就是这么明显。所以别再抱着 select 不放了,该升级就升级。


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