17、epoll模型(Linux):epoll_create/epoll_ctl/epoll_wait详解、ET与LT模式

说到Linux高性能网络编程,绕不开的就是epoll。我刚开始接触网络编程时,用的还是select和poll,后来项目压力上来,才发现那俩家伙在高并发下有多吃力。epoll的出现,说白了就是Linux给咱们开的一扇高性能大门。

今天咱们就把epoll的三个核心函数——epoll_createepoll_ctlepoll_wait——掰开揉碎了讲清楚。顺便把ET和LT这两种模式的区别也聊透。嗯,这里要注意,理解ET和LT是区分新手和老手的分水岭。

epoll的整体架构

先看一张图,帮你建立整体认知。epoll的工作流程其实很清晰:

epoll 核心工作流程 epoll_create 创建epoll实例 epoll_ctl 注册/修改/删除事件 epoll_wait 等待事件发生 循环调用 epoll_wait 事件驱动模型 • 内核维护一个事件表(红黑树 + 就绪链表) • epoll_ctl 将 socket 添加到事件表 • 当 socket 有数据时,内核将其放入就绪链表 • epoll_wait 直接从就绪链表取事件,O(1) 复杂度 • 相比 select 的 O(n) 轮询,epoll 性能优势巨大

epoll_create:创建epoll实例

这个函数是起点。你调用它,内核就给你创建一个epoll实例,返回一个文件描述符。

#include <sys/epoll.h>

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

参数size在Linux 2.6.8之后其实被忽略了,内核会动态调整。但我个人习惯还是传一个合理的值,比如你预计要管理1024个连接,就传1024。这算是个好习惯,万一哪天换到老内核呢?

小提示:epoll_create返回的fd要用close()关闭。我见过有人忘了关,导致进程fd泄漏,最后"Too many open files"报错。嗯,排查起来还挺头疼的。

epoll_ctl:控制事件

这个函数用来往epoll实例里添加、修改或删除你要监控的文件描述符。说白了,就是告诉内核:"你帮我盯着这个socket,有数据来了通知我。"

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

op参数有三个选项:

操作 含义 使用场景
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;      // 用户数据,通常放fd或指针
};

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

常用的events标志:

  • EPOLLIN:可读事件。有数据来了。
  • EPOLLOUT:可写事件。缓冲区可写。
  • EPOLLERR:错误事件。
  • EPOLLHUP:挂起事件。对端关闭连接。
  • EPOLLET:设置为边缘触发模式。这个后面重点讲。
  • EPOLLONESHOT:只触发一次,之后需要重新注册。
重点:data.fd通常存放socket描述符。这样在epoll_wait返回后,你可以直接从data.fd拿到是哪个socket有事件。我在项目中习惯用data.ptr指向一个连接对象,这样能携带更多上下文信息。

epoll_wait:等待事件

这是epoll的核心。调用它,线程就阻塞在这里,直到有事件发生或者超时。

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

参数说明:

  • events:传出参数,内核把就绪的事件填到这里。
  • maxevents:最多返回多少个事件。通常等于events数组的大小。
  • timeout:超时时间,毫秒。-1表示一直等,0表示立即返回。

典型的用法是循环调用:

struct epoll_event events[1024];
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
    // events[i].data.fd 就是有事件的socket
    // events[i].events 就是事件类型
    handle_event(events[i]);
}
注意:epoll_wait返回的events数组是内核复用的,每次调用都会覆盖。所以如果你需要保存事件,记得在本次循环内处理完,或者拷贝出来。我曾经踩过这个坑,在异步回调里用了过期的events指针,查了半天才发现。

LT模式 vs ET模式

终于到了重头戏。LT和ET是epoll的两种触发模式,理解它们,才算真正会用epoll。

LT模式(水平触发)

LT是默认模式。它的特点是:只要fd上有数据没读完,每次调用epoll_wait都会返回这个fd。

举个例子:socket缓冲区来了100字节,你只读了50字节。下次epoll_wait还会告诉你这个fd可读。说白了,就是"只要还有活没干完,我就一直提醒你"。

LT模式的优点:编程简单,不容易漏事件。缺点:可能会重复通知,效率略低。

ET模式(边缘触发)

ET模式需要你在epoll_ctl时加上EPOLLET标志。它的特点是:只在状态变化时通知一次。

还是那个例子:缓冲区来了100字节,你只读了50字节。下次epoll_wait不会再通知你,除非又有新的数据到达。

ET模式要求你:必须一次性把数据读完,或者写到缓冲区满为止。否则就会丢事件。

ET模式的核心规则:
  • 读操作:循环调用read/write,直到返回EAGAIN(表示暂时没数据了)。
  • 写操作:同样循环,直到EAGAIN或写完。
  • socket必须设置为非阻塞。否则在ET模式下,最后一次read会阻塞住。

LT vs ET 对比

对比项 LT模式 ET模式
触发条件 只要缓冲区有数据 状态变化时(无→有)
通知次数 可能多次通知 只通知一次
编程难度 简单,不易出错 较复杂,需循环读写
性能 稍低(重复通知) 更高(减少系统调用)
socket要求 阻塞/非阻塞均可 必须非阻塞
适用场景 连接数较少,追求简单 高并发,追求极致性能

完整示例代码

下面是一个ET模式的echo服务器骨架。你想想看,这段代码里包含了我们今天讲的所有知识点:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.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 listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(listen_fd);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);

    // 1. 创建epoll实例
    int epfd = epoll_create(1);

    // 2. 添加监听fd,使用ET模式
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    struct epoll_event events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];

    while (1) {
        // 3. 等待事件
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 新连接,ET模式要循环accept
                while (1) {
                    int conn_fd = accept(listen_fd, NULL, NULL);
                    if (conn_fd == -1) {
                        if (errno == EAGAIN) break;  // 没连接了
                        break;
                    }
                    set_nonblocking(conn_fd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = conn_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
                }
            } else {
                // 普通连接,ET模式要循环读
                int fd = events[i].data.fd;
                while (1) {
                    int n = read(fd, buffer, BUFFER_SIZE);
                    if (n == -1) {
                        if (errno == EAGAIN) break;  // 读完了
                        close(fd);
                        break;
                    } else if (n == 0) {
                        close(fd);  // 对端关闭
                        break;
                    }
                    write(fd, buffer, n);  // echo回去
                }
            }
        }
    }
    close(epfd);
    return 0;
}
我的经验:ET模式下,accept和read都要用while循环处理到EAGAIN。我刚开始写ET代码时,忘了在accept里加循环,结果高并发下大量连接堆积在backlog里,客户端一直连不上。排查了半天才发现是这个问题。

避坑指南

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

  • ET模式用了阻塞socket:read最后一次会阻塞住,整个线程卡死。记住,ET必须配非阻塞。
  • 忘记处理EAGAIN:ET模式下,不处理EAGAIN就退出循环,会导致数据没读完,后续事件丢失。
  • epoll_wait的events数组太小:如果同时有大量事件就绪,数组装不下,剩下的就丢了。建议至少1024。
  • 多线程共用epfd:epoll_wait不是线程安全的。多线程场景下,每个线程用独立的epoll实例,或者加锁保护。

嗯,epoll的内容就讲到这里。LT模式适合新手入门,ET模式适合追求极致性能的场景。我个人在线上服务中更倾向于ET模式,虽然编程麻烦点,但性能确实好。你根据自己项目的实际情况来选择就好。


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