Epoll

Tecy 发布于 15 天前 61 次阅读


Linux 的 epoll 是一个强大的 I/O 事件通知机制,专为处理大量并发文件描述符(尤其是网络套接字)而设计。它是对早期 select 和 poll 系统调用的高效替代。

设计思想

  • 内核事件表: 在内核中维护一个 事件表(一个高效的数据结构,通常是红黑树 + 就绪链表)。用户程序通过 epoll_create 创建一个 epoll 实例(epfd),该实例对应这个内核事件表。
  • 注册/修改/删除: 使用 epoll_ctl 单独地 向这个内核事件表添加 (EPOLL_CTL_ADD)、修改 (EPOLL_CTL_MOD) 或删除 (EPOLL_CTL_DEL) 需要监听的描述符及其关注的事件类型(读、写、异常等)。避免了每次调用传递整个集合。
  • 事件等待: 使用 epoll_wait 等待事件发生。内核只会将 就绪的事件 填充到用户提供的缓冲区中。用户程序只需处理返回的就绪事件,避免了扫描整个集合。 返回的就绪事件数量通常远小于注册的总事件数 (O(1) 或 O(m)m 是就绪事件数)。

epoll_create() & epoll_create1()

int epoll_create(int size);
int epoll_create1(int flags);
创建 epoll 实例的核心对象,返回其文件描述符。
推荐使用 epoll_create1()

size

  • 原始意图:提示内核期望监听的描述符数量(内核据此分配初始空间)。
  • 现代内核行为(Linux 2.6.8+):
    • 参数被忽略,内核动态调整数据结构大小。
    • 必须 > 0(否则返回 EINVAL 错误)。
  • 兼容性建议:传入正整数(如 1),无需精确计算。

flags

flags:位掩码,控制 epoll 实例行为(支持按位或组合):

标志作用
EPOLL_CLOEXEC02000000设置 close-on-exec 标志
exec() 时自动关闭 epoll 实例)
其他标志-当前 Linux 未定义其他标志

return

返回值: 成功返回 epfd (非负整数),失败返回 -1 并设置 errno

错误码原因
EINVALsize ≤ 0 或 指定了无效的 flags
EMFILE进程打开的文件描述符数量超过限制
ENFILE系统打开的文件总数超过限制
ENOMEM内存不足
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) {
    switch (errno) {
        case EINVAL:
            fprintf(stderr, "Invalid flags specified\n");
            break;
        case EMFILE:
            fprintf(stderr, "Process file descriptor limit reached\n");
            break;
        case ENFILE:
            fprintf(stderr, "System-wide file descriptor limit exceeded\n");
            break;
        case ENOMEM:
            fprintf(stderr, "Insufficient kernel memory\n");
            break;
        default:
            perror("Unknown epoll_create1 error");
    }
    exit(EXIT_FAILURE);
}

epoll_ctl()

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
用于管理 epoll 实例(epfd)中的文件描述符监听列表,执行添加、修改或删除操作。

epfd

有效的 epoll 文件描述符。

op

指定的操作类型。

操作说明
EPOLL_CTL_ADD1添加新 fd 到监听列表,并关联 event 中指定的事件
EPOLL_CTL_MOD2修改已注册 fd 的监听事件,并指定的新事件
EPOLL_CTL_DEL3从监听列表中删除 fd,event 参数可忽略(设为 NULL

fd

操作的目标文件描述符。

要求:

  • 必须是有效的文件描述符。
  • 支持的类型:socket、pipe、tty、eventfd、timerfd 等。
  • 不支持:常规文件(返回 EPERM 错误)。

event

定义监听的事件类型和用户数据。
结构:

typedef union epoll_data {
    void    *ptr;   // 最常用:指向自定义数据结构
    int      fd;    // 存储另一个文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;  // 事件标志(位掩码)
    epoll_data_t data;    // 用户数据
};

events 事件标志详解:

标志说明
EPOLLIN0x001数据可读(包括对端关闭连接)
EPOLLOUT0x004数据可写
EPOLLRDHUP0x2000对端关闭连接(需显式设置)
EPOLLPRI0x002紧急数据可读(带外数据)
EPOLLERR0x008错误发生(总是监控
EPOLLHUP0x010挂起/断开连接(总是监控
EPOLLET0x80000000边缘触发模式(默认水平触发)
EPOLLONESHOT0x40000000单次事件通知(需重新激活)
EPOLLWAKEUP0x20000000阻止系统休眠(需 CAP_BLOCK_SUSPEND)
EPOLLEXCLUSIVE0x10000000避免惊群效应(Linux 4.5+)

return

返回值: 成功返回 0,失败返回 -1 并设置 errno

错误码原因
EBADFepfd 或 fd 是无效文件描述符
EEXISTop=ADD 但 fd 已注册
EINVAL无效参数:
epfd 不是 epoll 实例
op 无效
fd 不支持 epoll
events 包含无效标志
ELOOPfd 是 epoll 实例自身(自监听)
ENOENTop=MOD/DEL 但 fd 未注册
ENOMEM内存不足
EPERMfd 不支持 epoll(如常规文件)
ENOSPC超过 /proc/sys/fs/epoll/max_user_watches 限制
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) {
    fprintf(stderr, "create epoll error: %s\n", strerror(errno));
    exit(EXIT_FAILURE);
}   

int fd = socket(AF_INET, SOCK_STREAM, 0); 
if (fd < 0) {
    fprintf(stderr, "create socket error: %s\n", strerror(errno));
    exit(EXIT_FAILURE);
}

struct my_data {
    uint16_t data;
};

my_data *d = new my_data{1}; // 数据的生命周期要至少和ev一样大

epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监听该socket是否可读 + ET触发
ev.data.fd = fd;
ev.data.ptr = d;

if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
    delete d;
    fprintf(stderr, "epoll ctl error: %s\n", strerror(errno));
    exit(EXIT_FAILURE);
}

close(epfd);  

epoll_wait()

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
用于等待注册的文件描述符上发生事件。

epfd

有效的 epoll 文件描述符。

events

用户分配的缓冲区,用于接收就绪事件。

  • 内存大小 = maxevents * sizeof(struct epoll_event)

maxevents

指定 events 数组能接收的最大事件数(必须大于 0)。

timeout

等待超时时间(毫秒)。

行为
-1无限期阻塞(永久等待)
0立即返回(非阻塞轮询)
>0等待指定毫秒数

return

返回值含义
>0返回就绪的文件描述符数量(即 events 中有效元素个数)
0超时时间内没有事件发生
-1出错(检查 errno
错误码原因
EBADFepfd 不是有效的文件描述符
EFAULTevents 指向的内存不可访问
EINTR调用被信号中断(可安全重启)
EINVALepfd 不是 epoll 文件描述符,或 maxevents ≤ 0
#define MAX_EVENTS 64

int main() {
    int epfd = epoll_create1(EPOLL_CLOEXEC);
    // ... (添加监听描述符 epoll_ctl)

    struct epoll_event events[MAX_EVENTS];

    while (1) {
        // 等待事件(最多1秒)
        int n = epoll_wait(epfd, events, MAX_EVENTS, 1000);

        if (n == -1) {
            if (errno == EINTR) continue; // 被信号中断,重试
            perror("epoll_wait");
            break;
        }

        if (n == 0) {
            // 超时处理(可选)
            continue;
        }

        // 处理就绪事件
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            uint32_t revents = events[i].events;

            // 错误检查(必须优先处理)
            if (revents & (EPOLLERR | EPOLLHUP)) {
                handle_error(fd);
                continue;
            }

            // 可读事件
            if (revents & EPOLLIN) {
                if (is_listen_socket(fd)) {
                    accept_connections(fd, epfd);
                } else {
                    handle_read(fd);
                }
            }

            // 可写事件
            if (revents & EPOLLOUT) {
                handle_write(fd);
            }

            // 对端关闭
            if (revents & EPOLLRDHUP) {
                handle_peer_close(fd);
            }
        }
    }
}

close

int close(int fd);
关闭文件描述符。

LT vs ET

水平触发 (Level-Triggered - LT) (默认):

  • 核心原则:只要文件描述符处于就绪状态(读缓冲区非空、写缓冲区有空间),就会持续通知你。
  • 行为:
    • 当 epoll_wait 报告一个 EPOLLIN 事件后,你可以不一次性把所有数据读完。只要下次调用 epoll_wait 时该 socket 的接收缓冲区中还有数据未读,epoll_wait 会再次报告它(即再次返回这个 fd 的 EPOLLIN 事件)。
    • 当 epoll_wait 报告一个 EPOLLOUT 事件后,你可以不一次性把所有数据写完。只要下次调用 epoll_wait 时该 socket 的发送缓冲区中还有空间,epoll_wait 会再次报告它(即再次返回这个 fd 的 EPOLLOUT 事件)。
  • 优点: 编程模型更简单,不容易遗漏事件。可以分多次读写数据。
  • 缺点: 在极端高并发且大部分 fd 始终活跃的情况下,可能产生更多的 epoll_wait 返回和系统调用(尽管比 select/poll 好很多)。如果忘记处理一个始终就绪的 fd,它会被反复报告。

边缘触发 (Edge-Triggered - ET):

  • 核心原则:仅在文件描述符状态发生变化时通知你一次(例如,从不可读变为可读,从不可写变为可写)。之后,无论其状态是否持续就绪,都不会再通知,除非状态再次发生变化。
  • 行为:
    • 当 epoll_wait 报告一个 EPOLLIN 事件(且 events 包含了 EPOLLET)时,它表示有新的数据到达。如果你在这次事件处理中没有一次性把接收缓冲区里的数据全部读完,那么下次调用 epoll_wait 时,即使缓冲区里还有数据,也不会再报告这个 fd 的 EPOLLIN 事件,直到再次有新的数据到达(状态从非可读变为可读)。
    • 对于 EPOLLOUT 同理:通知一次可写后,如果你只写了一部分数据,缓冲区还没满,只要没有新的空间释放出来(即状态没有从不可写变为可写),就不会再通知
  • 要求:
    • 非阻塞 I/O: 使用 ET 模式的 fd 必须 设置为非阻塞模式 (O_NONBLOCK)。因为你需要循环读写直到返回 EAGAIN 或 EWOULDBLOCK 错误,确保在本次事件触发时处理完所有就绪的数据或空间。阻塞 I/O 在最后一次读写时可能会阻塞住整个线程。
    • 循环读写直到 EAGAIN: 当 ET 事件触发时,你必须立即、连续地进行读写操作,直到系统调用返回错误(errno 为 EAGAIN 或 EWOULDBLOCK),表示本次状态变化带来的所有可用数据或空间都已处理完毕。
  • 优点:
    • 减少了 epoll_wait 返回的次数和系统调用开销,尤其在长连接、活跃连接比例高的情况下效率更高。
    • 避免了 LT 模式下对持续就绪 fd 的重复通知。
  • 缺点: 编程模型更复杂,必须使用非阻塞 I/O 并确保一次性处理完所有可用数据/空间,否则可能丢失事件或造成数据饥饿。对错误处理要求更高。
  • 适用场景: 需要极致性能的场景;连接长期保持活跃且数据交互频繁。