一、为什么需要 epoll?

1.1 select 的缺点

在之前的教程中,我们学习了 select 模型。它可以同时监视多个套接字,但存在几个致命问题:

问题 说明 影响
描述符上限 默认 1024,虽可修改但效率下降 无法支持大量连接
每次都要传递集合 select 调用时,需要把 fd_set 从用户态拷贝到内核态 频繁拷贝,开销大
O(n) 扫描 返回后,应用程序需要遍历所有 fd 找出就绪的 连接越多越慢
集合被修改 select 会修改传入的集合,每次都需要重新设置 编程麻烦

简单来说:select 每次都要告诉内核“你要监视哪些 fd”,内核也要逐一检查所有 fd,效率低下

1.2 epoll 的优势

epoll 是 Linux 特有的 I/O 复用机制(Windows 没有,对应的是 IOCP),它解决了 select 的痛点:

  • 无描述符上限(受系统内存限制)。
  • 一次注册,多次使用:只需把要监视的 fd 告诉内核一次,后续只通知变化的部分。
  • 事件驱动:内核只返回真正发生事件的 fd,无需遍历所有 fd。
  • 支持水平触发和边缘触发(后文详细讲解)。

二、epoll 核心数据结构

epoll 在内核中维护一个红黑树(存储所有被监视的 fd)和一个就绪链表(存储就绪的 fd)。当 I/O 事件发生时,内核把对应的 fd 放入就绪链表,应用程序通过 epoll_wait 获取就绪链表的内容。


三、epoll 三大函数

3.1 epoll_create —— 创建 epoll 实例

#include <sys/epoll.h>
int epoll_create(int size);
  • 作用:创建一个 epoll 实例,返回一个文件描述符(epfd),后续所有 epoll 操作都使用它。
  • 参数 size:从 Linux 2.6.8 开始被忽略,但必须 >0(历史遗留)。
  • 返回值:成功返回 epfd(一个非负整数),失败返回 -1。

为什么需要 epfd?
epoll 实例本身也是一个文件描述符,可以被 close 关闭。它相当于一个“管理者”,记录所有监视的 fd。

3.2 epoll_ctl —— 控制 epoll 监视列表

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 作用:向 epoll 实例中添加、修改或删除要监视的文件描述符。
  • 参数
    • epfdepoll_create 返回的 epoll 文件描述符。
    • op:操作类型,取以下值:
      • EPOLL_CTL_ADD:添加 fd 到监视列表。
      • EPOLL_CTL_MOD:修改 fd 上监视的事件。
      • EPOLL_CTL_DEL:从监视列表中删除 fd。
    • fd:要操作的文件描述符(如套接字)。
    • eventstruct epoll_event 结构体指针,指定监视的事件类型和用户数据。

struct epoll_event 结构体

struct epoll_event {
    uint32_t     events;   // 事件类型(EPOLLIN、EPOLLOUT 等)
    epoll_data_t data;     // 用户数据,通常放 fd
};

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

常用事件类型

含义
EPOLLIN 有数据可读(包括连接请求、对方关闭)
EPOLLOUT 可写(输出缓冲区空)
EPOLLRDHUP 对方关闭连接或半关闭
EPOLLERR 发生错误
EPOLLET 边缘触发模式(Edge-Triggered)
EPOLLONESHOT 只触发一次,之后需要重新注册

示例:添加监听套接字,监视可读事件。

struct epoll_event event;
event.events = EPOLLIN;      // 监视可读
event.data.fd = serv_sock;    // 存储 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

3.3 epoll_wait —— 等待事件发生

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 作用:等待事件发生,返回就绪的 fd 列表。
  • 参数
    • epfd:epoll 实例文件描述符。
    • events:指向 epoll_event 数组的指针,用于接收就绪的事件列表。
    • maxevents:最多返回多少个事件(通常为数组长度)。
    • timeout:超时时间(毫秒)。-1 表示无限等待,0 表示立即返回。
  • 返回值:成功返回就绪的 fd 数量(0 表示超时),失败返回 -1。

注意epoll_wait 返回后,events 数组中只包含就绪的 fd,无需遍历所有 fd,效率极高。


四、水平触发(LT)与边缘触发(ET)

epoll 支持两种触发模式,这是它与 select 最大的不同之处。

4.1 水平触发(Level-Triggered,LT)

  • 默认模式
  • 行为:只要某个 fd 上有未处理的数据(或满足事件条件),epoll_wait 就会一直通知你。
  • 类比:门铃一直响,直到你出来处理。

示例:读缓冲区有 100 字节,你只读了 50 字节,那么下次 epoll_wait 还会立即返回,告诉你还有数据可读。

优点:编程简单,不容易漏掉数据。
缺点:可能会频繁唤醒,效率略低于边缘触发。

4.2 边缘触发(Edge-Triggered,ET)

  • 需要指定 EPOLLET 标志
  • 行为:只有当 fd 的状态发生变化时(如从无数据变为有数据),才通知一次。之后即使还有数据未读完,也不会再通知,直到下次状态变化。
  • 类比:门铃只在你到达门口时响一次,不管你有没有把东西拿完。

示例:读缓冲区从 0 字节变为 100 字节时,epoll_wait 返回一次。如果你只读了 50 字节,下次 epoll_wait 不会再返回,直到有新数据到来。

要求

  • 必须循环读取直到返回 EAGAINEWOULDBLOCK(表示无更多数据)。
  • 通常需要将套接字设为非阻塞模式,否则最后一次 read 会阻塞。

优点:唤醒次数少,效率更高。
缺点:编程复杂,必须处理非阻塞 I/O 和循环读取。

4.3 两种模式对比

特性 水平触发(LT) 边缘触发(ET)
通知次数 持续通知直到处理完 只通知一次状态变化
编程难度 简单 较复杂
性能 较低(可能多次唤醒) 较高
适用场景 通用 高并发、大量连接
是否需要非阻塞 不需要 必须

五、实战:epoll 回声服务器(水平触发版)

下面实现一个使用 epoll 的水平触发回声服务器,逻辑与 select 版相似,但效率更高。

5.1 服务器代码(逐行注释)

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

#define BUF_SIZE 100      // 消息缓冲区大小
#define EPOLL_SIZE 50     // epoll 可监视的最大 fd 数量(实际无上限,只是数组大小)

void error_handling(char *buf);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    // epoll 相关变量
    struct epoll_event *ep_events;   // 用于接收就绪事件的数组
    struct epoll_event event;        // 用于注册事件的结构体
    int epfd, event_cnt;             // epfd: epoll 实例句柄, event_cnt: 就绪事件数量

    if (argc != 2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    // ========== 1. 创建 TCP 套接字 ==========
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    // 绑定地址
    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");
    // 监听
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    // ========== 2. 创建 epoll 实例 ==========
    epfd = epoll_create(EPOLL_SIZE);   // 参数已忽略,但需 >0
    // 分配内存用于存放就绪事件(最多 EPOLL_SIZE 个)
    ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

    // ========== 3. 将监听套接字加入 epoll 监视 ==========
    event.events = EPOLLIN;      // 监视可读事件(连接请求、数据到达)
    event.data.fd = serv_sock;   // 存储 fd,后面可以根据这个判断是哪个套接字
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    // ========== 4. 事件循环 ==========
    while (1) {
        // 等待事件发生,-1 表示无限等待
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if (event_cnt == -1) {
            puts("epoll_wait() error");
            break;
        }

        // 遍历所有就绪的事件
        for (i = 0; i < event_cnt; i++) {
            // 情况1:监听套接字就绪 → 有新连接
            if (ep_events[i].data.fd == serv_sock) {
                adr_sz = sizeof(clnt_adr);
                clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                // 将新客户端套接字加入 epoll 监视
                event.events = EPOLLIN;      // 监视可读(数据到达)
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d\n", clnt_sock);
            }
            // 情况2:客户端套接字就绪 → 数据到达或连接关闭
            else {
                str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                if (str_len == 0) {   // 客户端正常关闭连接
                    // 从 epoll 监视列表中删除该 fd
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("closed client: %d\n", ep_events[i].data.fd);
                } else {   // 收到数据,原样返回(回声)
                    write(ep_events[i].data.fd, buf, str_len);
                }
            }
        }
    }

    close(serv_sock);
    close(epfd);
    free(ep_events);
    return 0;
}

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

5.2 客户端代码

客户端代码与 select 版本完全相同,这里不再重复。


六、边缘触发(ET)模式详解与代码

6.1 为什么边缘触发更高效?

边缘触发模式下,内核只在状态变化时通知一次,这大大减少了内核唤醒应用程序的次数。为了确保不遗漏数据,应用程序必须循环读取直到缓冲区为空(返回 EAGAIN)。

6.2 必须使用非阻塞 I/O

在边缘触发模式下,如果最后一次 read 没有数据可读,它会阻塞(因为套接字默认是阻塞的)。因此需要将套接字设为非阻塞模式,这样 read 返回 -1,并设置 errnoEAGAINEWOULDBLOCK,表示“暂时无数据”。

6.3 设置非阻塞的函数

void setnonblockingmode(int fd)
{
    int flag = fcntl(fd, F_GETFL, 0);      // 获取当前文件状态标志
    fcntl(fd, F_SETFL, flag | O_NONBLOCK); // 添加非阻塞标志
}

6.4 边缘触发版回声服务器(逐行注释)

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

#define BUF_SIZE 4          // 故意设置小缓冲区,演示边缘触发
#define EPOLL_SIZE 50

void setnonblockingmode(int fd);
void error_handling(char *buf);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;

    if (argc != 2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    // 创建 TCP 套接字并绑定、监听(同水平触发)
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));
    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    // 创建 epoll 实例
    epfd = epoll_create(EPOLL_SIZE);
    ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

    // ========== 关键点1:监听套接字也要设为非阻塞 ==========
    setnonblockingmode(serv_sock);
    // 监听套接字使用水平触发(也可以边缘触发,但一般无需)
    event.events = EPOLLIN;       // 水平触发(默认)
    event.data.fd = serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    while (1) {
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if (event_cnt == -1) {
            puts("epoll_wait() error");
            break;
        }

        puts("return epoll_wait");   // 每次返回都打印,观察触发次数

        for (i = 0; i < event_cnt; i++) {
            if (ep_events[i].data.fd == serv_sock) {
                // 新连接
                adr_sz = sizeof(clnt_adr);
                clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                // ========== 关键点2:客户端套接字设为非阻塞 ==========
                setnonblockingmode(clnt_sock);
                // ========== 关键点3:使用边缘触发(EPOLLET) ==========
                event.events = EPOLLIN | EPOLLET;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d\n", clnt_sock);
            } else {
                // 数据到达或连接关闭
                // ========== 关键点4:循环读取直到 EAGAIN ==========
                while (1) {
                    str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                    if (str_len == 0) {   // 对方关闭连接
                        epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                        close(ep_events[i].data.fd);
                        printf("closed client: %d\n", ep_events[i].data.fd);
                        break;   // 退出 while 循环
                    } else if (str_len < 0) {
                        // 出错,检查 errno
                        if (errno == EAGAIN) {
                            // 没有更多数据可读,正常退出循环
                            break;
                        }
                        // 其他错误,退出循环并关闭
                        break;
                    } else {
                        // 收到数据,回声
                        write(ep_events[i].data.fd, buf, str_len);
                    }
                }
            }
        }
    }

    close(serv_sock);
    close(epfd);
    free(ep_events);
    return 0;
}

void setnonblockingmode(int fd)
{
    int flag = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

6.5 边缘触发代码的关键点总结

  1. 所有可能产生 EAGAIN 的套接字都必须设为非阻塞(监听套接字也最好设为非阻塞,但这里仅示范客户端)。
  2. 注册事件时加上 EPOLLET 标志
  3. while 循环中反复调用 read,直到返回 -1errno == EAGAIN,才能保证读完所有数据。
  4. 处理完数据后不要忘记处理连接关闭str_len == 0)。

6.6 运行对比

  • 水平触发:客户端发送 5 次消息,服务器端 epoll_wait 会返回 5 次(每次返回后立即处理,可能因为缓冲区小而返回多次,但总的唤醒次数与数据包数相近)。
  • 边缘触发:客户端发送 5 次消息,如果缓冲区较小(如 4 字节),服务器可能一次 epoll_wait 返回后,在 while 循环中读完所有数据,后续不会再次触发。因此 epoll_wait 的返回次数可能少于 5 次,效率更高。

七、epoll 与 select 的性能对比

对比项 select epoll
监视 fd 数量 通常 1024 无限制(受内存限制)
每次调用复制集合 否(只在注册时复制)
就绪通知方式 修改集合,需遍历 直接返回就绪列表
时间复杂度 O(n) O(1)(仅就绪 fd)
触发模式 仅水平触发 水平触发 + 边缘触发
适用场景 少量连接 大量连接(数千、数万)

八、总结

  • epoll 是 Linux 下高性能 I/O 复用的首选方案。
  • 三大核心函数epoll_create(创建实例)、epoll_ctl(添加/删除监视)、epoll_wait(等待事件)。
  • 水平触发(LT):简单,适合大多数场景。
  • 边缘触发(ET):效率更高,但必须配合非阻塞 I/O 和循环读取,编程复杂。
  • 实战中,对于一般的网络服务器,水平触发 + 非阻塞 I/O 已经足够优秀;对于超高并发(如游戏服务器、网关),可以使用边缘触发
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐