9、I/O多路复用(epoll):从原理到实战

聊到高性能网络编程,epoll 是绕不开的话题。我记得刚入行那会儿,还在用 select 写并发服务器,连接数一上千,CPU 就飙得厉害。后来一位老前辈甩给我一句话:「小子,去学学 epoll 吧。」嗯,从那以后,我的服务器才算真正「站起来了」。

这一讲,咱们就彻底搞懂 epoll。说白了,它就是 Linux 下最高效的 I/O 多路复用机制。没有之一。

9.1 为什么是 epoll?

先问个问题:select 和 poll 有什么毛病?

  • 每次调用都要传全部 fd 集合——内核和用户态之间来回拷贝,连接一多就扛不住。
  • 线性扫描所有 fd——O(n) 复杂度,连接数 10 万,每次都要扫 10 万次。
  • fd 数量有限制——select 默认 1024,改大了也治标不治本。

epoll 怎么解决的?三个字:事件驱动。它只关心「有事件发生的 fd」,而不是傻乎乎地遍历所有 fd。复杂度从 O(n) 降到了 O(1)。

核心思想: epoll 在内核中维护了一个事件表,应用程序只需告诉内核「我要关注哪些 fd 的哪些事件」,然后等着内核通知就行了。不需要每次都重新传一遍。

9.2 三大核心函数

epoll 的使用就三个函数,记住它们,你就掌握了 80%。

9.2.1 epoll_create —— 创建 epoll 实例

#include <sys/epoll.h>

int epoll_create(int size);
// 返回值:成功返回 epoll 文件描述符,失败返回 -1

这个 size 参数,在 2.6.8 之后的内核中其实被忽略了,但建议还是传一个大于 0 的数,比如 1024。我个人习惯传 1,因为内核会动态扩展,但传个正数显得专业嘛。

小提示: epoll_create 返回的 fd 记得用 close() 关闭,否则会泄漏。我在项目中见过有人忘了关,结果 /proc/sys/fs/epoll/max_user_instances 被撑爆了。

9.2.2 epoll_ctl —— 控制事件

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

三个操作:

  • EPOLL_CTL_ADD:注册新 fd 到 epoll 实例
  • EPOLL_CTL_MOD:修改已注册 fd 的事件
  • EPOLL_CTL_DEL:从 epoll 实例中删除 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一次性事件

注意: 我曾经在项目中犯过一个低级错误——用 EPOLL_CTL_ADD 添加一个已经存在的 fd,结果返回 -1,errno 是 EEXIST。正确的做法是先 MOD 再 ADD,或者用 DEL 清理后再 ADD。

9.2.3 epoll_wait —— 等待事件

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

timeout 参数:

  • -1:阻塞等待,直到有事件发生
  • 0:立即返回,不管有没有事件
  • >0:等待指定毫秒数

我个人习惯用 -1,配合非阻塞 I/O 使用。为什么?你想想看,如果 timeout 设成 100ms,高并发下每次都要多等 100ms,延迟就上去了。

9.3 LT 模式 vs ET 模式

这是 epoll 最让人头疼的地方,也是面试最爱问的。我尽量说清楚。

9.3.1 LT(水平触发)—— 默认模式

只要 fd 上有数据没读完,epoll_wait 就会一直通知你。说白了,就是「有剩饭就喊你」。优点是不会丢事件,缺点是可能重复通知。

举个例子:

// 假设 socket 收到 100 字节
// 你只读了 50 字节
// 下次 epoll_wait 还会通知你 EPOLLIN
// 直到你把 100 字节全部读完

9.3.2 ET(边缘触发)—— 高效模式

只有状态发生变化时才通知。比如从「无数据」变成「有数据」的那一刻,通知一次。之后就算你没读完,也不会再通知了。

这就意味着:你必须一次性把数据读完,否则就丢了。

ET 模式的使用铁律:

  1. fd 必须设置为非阻塞
  2. 读操作必须循环调用 read() 直到返回 EAGAIN
  3. 写操作同理,直到写缓冲区满返回 EAGAIN

我在项目中遇到过一个问题:用 ET 模式,但忘了设置非阻塞。结果 read 阻塞在最后一次调用上,整个线程卡死了。排查了半天才发现,嗯,血的教训。

9.4 epoll 事件驱动模型

epoll 的事件驱动,说白了就是「有事件才干活,没事件就睡觉」。配合非阻塞 I/O,可以实现单线程处理成千上万个连接。

核心流程:

  1. 创建 epoll 实例
  2. 将监听 socket 注册到 epoll,关注 EPOLLIN
  3. 循环调用 epoll_wait
  4. 收到事件后,判断是监听 socket 还是已连接 socket
  5. 监听 socket:调用 accept,将新连接注册到 epoll
  6. 已连接 socket:读取数据,处理业务逻辑

下面这张图帮你理清思路:

epoll 事件驱动模型流程图 epoll_create epoll_ctl (ADD listen_fd) epoll_wait (阻塞等待事件) 是 listen_fd? accept() 新连接 处理客户端数据

9.5 epoll 服务器完整实现

光说不练假把式。下面是一个完整的 epoll 服务器骨架,LT 模式,支持多客户端连接。

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

#define MAX_EVENTS 1024
#define PORT 8080
#define BUFFER_SIZE 4096

// 设置非阻塞
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd, epoll_fd, nfds;
    struct sockaddr_in addr;
    struct epoll_event ev, events[MAX_EVENTS];
    
    // 1. 创建监听 socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }
    
    // 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // 绑定地址
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    
    // 监听
    if (listen(listen_fd, SOMAXCONN) == -1) {
        perror("listen");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    
    // 2. 创建 epoll 实例
    epoll_fd = epoll_create(1);
    if (epoll_fd == -1) {
        perror("epoll_create");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    
    // 3. 将监听 socket 注册到 epoll
    ev.events = EPOLLIN;  // LT 模式,默认
    ev.data.fd = listen_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
        perror("epoll_ctl: listen_fd");
        close(listen_fd);
        close(epoll_fd);
        exit(EXIT_FAILURE);
    }
    
    printf("Epoll server listening on port %d...\n", PORT);
    
    // 4. 事件循环
    while (1) {
        nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            break;
        }
        
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 新连接
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int conn_fd = accept(listen_fd, 
                    (struct sockaddr*)&client_addr, &client_len);
                if (conn_fd == -1) {
                    perror("accept");
                    continue;
                }
                
                set_nonblocking(conn_fd);
                
                ev.events = EPOLLIN | EPOLLET;  // 这里用 ET 模式试试
                ev.data.fd = conn_fd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
                    perror("epoll_ctl: conn_fd");
                    close(conn_fd);
                }
                
                printf("New client connected, fd=%d\n", conn_fd);
            } else {
                // 客户端数据
                int fd = events[i].data.fd;
                char buffer[BUFFER_SIZE];
                int n;
                
                // ET 模式必须循环读
                while ((n = read(fd, buffer, sizeof(buffer) - 1)) > 0) {
                    buffer[n] = '\0';
                    printf("Received from fd %d: %s", fd, buffer);
                    
                    // 回显
                    write(fd, buffer, n);
                }
                
                if (n == 0) {
                    // 客户端关闭
                    printf("Client fd %d disconnected\n", fd);
                    close(fd);
                } else if (n == -1 && errno != EAGAIN) {
                    // 出错
                    perror("read");
                    close(fd);
                }
                // n == -1 && errno == EAGAIN 表示数据读完了,正常
            }
        }
    }
    
    close(listen_fd);
    close(epoll_fd);
    return 0;
}

关于 ET 模式的一点经验: 上面代码中,我把客户端连接注册成了 EPOLLET。如果你改成默认的 LT 模式,while 循环读可以去掉,但效率会低一些。我个人建议:新手上路先用 LT,等把 epoll 玩熟了再切 ET。ET 虽然高效,但坑也多。

9.6 避坑指南

最后,分享几个我踩过的坑:

  • ET 模式忘记设置非阻塞——read 最后一次调用会阻塞,整个线程卡死。我曾经因为这个,线上服务挂了 10 分钟才被发现。
  • epoll_ctl 的 EPOLL_CTL_DEL 忘记调用——关闭 fd 后,epoll 实例里还残留着这个 fd 的记录。虽然内核会清理,但显式删除是好习惯。
  • epoll_wait 的 events 数组太小——如果同时有大量事件触发,数组装不下,事件就丢了。建议设成和 maxevents 一样大。
  • 忘记处理 EPOLLERR 和 EPOLLHUP——这两个事件不处理,epoll_wait 会一直返回,导致 CPU 空转。

嗯,epoll 的内容就到这里。记住三个函数、两种模式、一个循环,你就能写出高性能的网络服务器了。多写多练,慢慢就有感觉了。


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