Linux I/O复用之epoll

如果说 select 是遍历式查表,poll 是遍历式查数组,那么 epoll 就是回调式通知------它彻底解决了轮询带来的 O(n) 性能瓶颈

  1. 核心原理:红黑树 + 就绪链表

epoll 在内核中维护了两个核心数据结构:红黑树(rbtree):存储所有被监视的文件描述符。当调用 epoll_ctl 添加或删除 fd 时,操作的是这棵树,增删改查效率为 O(log n)。就绪链表(rdlist):存储已经发生事件的 fd。当网卡数据到达时,内核通过回调机制(中断处理)直接把就绪的 fd 挂到这个链表上。

工作流程:

当调用 epoll_wait 时,内核不再遍历全部 fd,而是直接把就绪链表(rdlist)中的数据返回给用户。这意味着返回的就绪 fd 数量 = 活跃连接数,时间复杂度仅为 O(1)。

  1. 三大核心 API

函数定义在 <sys/epoll.h> 中:① epoll_create1(创建 epoll 实例)

cpp 复制代码
int epoll_create1(int flags);

flags 通常设为 0(等同于旧的 epoll_create(size),size 已忽略),或 EPOLL_CLOEXEC(子进程不继承)。

返回值是一个文件描述符(epoll 句柄),用完需 close。

② epoll_ctl(增删改监视的 fd)

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op 操作类型:

EPOLL_CTL_ADD:添加 fd

EPOLL_CTL_MOD:修改监听事件

EPOLL_CTL_DEL:删除 fd(此时 event 可传 NULL)

③ epoll_wait(等待事件)

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

events:输出型参数,指向一块预分配内存,内核将就绪的事件拷贝到这里。

maxevents:最多返回几个事件(必须小于等于数组长度)。

timeout:超时毫秒数(-1 阻塞,0 立即返回)。

注意:epoll_wait 每次只返回就绪的 fd,不会修改 events 结构,也不需要像 select 那样重置

  1. 数据结构:struct epoll_event
cpp 复制代码
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;

关键点:data.fd 或 data.ptr 会原样返回,方便你定位是哪个 fd 就绪。

常用事件标志:

EPOLLIN 可读

EPOLLOUT 可写

EPOLLRDHUP 对端关闭连接

EPOLLET 边缘触发模式(默认是水平触发)

EPOLLONESHOT 事件触发一次后自动移除,防止多线程竞争

  1. LT(水平触发) vs ET(边缘触发)

LT(Level Triggered,默认) 只要 fd 有数据未读完,epoll_wait 就会反复通知

ET(Edge Triggered,高效) 只在 状态变化 的瞬间通知一次(无数据->有数据)

假设 socket 收到了 2KB 数据,但你在 ET 模式下只读了 1KB。LT 模式:下次 epoll_wait 会再次通知你还有数据。ET 模式:不会再通知你了!除非有新数据到达,触发新的状态变化。

ET 的正确写法:

将 socket 设置为 非阻塞(O_NONBLOCK)。

触发事件后,必须用 循环 read / recv 把数据读干净,直到返回 EAGAIN 或 EWOULDBLOCK 错误。

否则剩余数据会一直滞留在缓冲区,且不会再次触发事件,导致"假死"。

  1. C++ 完整实战:高并发 Echo 服务器(ET 边缘触发 + 非阻塞)

以下代码展示了标准的 ET 模式写法,包含设置非阻塞、循环读数据、EPOLLRDHUP 处理断开

cpp 复制代码
#include <iostream>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <errno.h>

const int PORT = 8888;
const int BUFFER_SIZE = 1024;
const int MAX_EVENTS = 1024;

// 工具函数:设置文件描述符为非阻塞
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() {
    // 1. 创建监听 socket
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenFd == -1) {
        perror("socket");
        return -1;
    }

    int opt = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    if (bind(listenFd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
        perror("bind");
        close(listenFd);
        return -1;
    }

    if (listen(listenFd, 128) == -1) {
        perror("listen");
        close(listenFd);
        return -1;
    }
    // 监听 socket 也必须设为非阻塞,这样 accept 才能配合 ET 模式
    set_nonblocking(listenFd);

    // 2. 创建 epoll 实例
    int epollFd = epoll_create1(0);
    if (epollFd == -1) {
        perror("epoll_create1");
        close(listenFd);
        return -1;
    }

    // 3. 将监听 socket 注册到 epoll(使用 ET 边缘触发)
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET;  // 边缘触发
    ev.data.fd = listenFd;
    if (epoll_ctl(epollFd, EPOLL_CTL_ADD, listenFd, &ev) == -1) {
        perror("epoll_ctl: listenFd");
        close(listenFd);
        close(epollFd);
        return -1;
    }

    std::cout << "Epoll (ET mode) Server listening on port " << PORT << std::endl;

    // 预分配事件数组
    std::vector<struct epoll_event> events(MAX_EVENTS);

    while (true) {
        // 4. 等待事件(阻塞,-1 表示无限等待)
        int readyCount = epoll_wait(epollFd, events.data(), MAX_EVENTS, -1);

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

        for (int i = 0; i < readyCount; ++i) {
            int curFd = events[i].data.fd;
            uint32_t evt = events[i].events;

            // 处理错误 / 挂起
            if (evt & (EPOLLERR | EPOLLHUP)) {
                std::cout << "Error/Hangup on FD " << curFd << std::endl;
                close(curFd);
                epoll_ctl(epollFd, EPOLL_CTL_DEL, curFd, nullptr);
                continue;
            }

            // 5. 处理新连接(监听 socket 可读)
            if (curFd == listenFd) {
                while (true) {  // ET 模式必须循环 accept,直到 EAGAIN
                    struct sockaddr_in clientAddr;
                    socklen_t addrLen = sizeof(clientAddr);
                    int clientFd = accept(listenFd, (struct sockaddr*)&clientAddr, &addrLen);
                    if (clientFd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            // 所有连接已处理完毕,正常退出循环
                            break;
                        } else {
                            perror("accept");
                            break;
                        }
                    }
                    std::cout << "New client connected. FD: " << clientFd << std::endl;

                    // 设置客户端为非阻塞(ET 强制要求)
                    set_nonblocking(clientFd);

                    // 注册到 epoll,监听可读和对端关闭
                    struct epoll_event clientEv;
                    clientEv.events = EPOLLIN | EPOLLRDHUP | EPOLLET; // 边缘触发
                    clientEv.data.fd = clientFd;
                    if (epoll_ctl(epollFd, EPOLL_CTL_ADD, clientFd, &clientEv) == -1) {
                        perror("epoll_ctl: client");
                        close(clientFd);
                    }
                }
                continue;
            }

            // 6. 处理客户端数据(普通 fd 可读,且不是监听 fd)
            // ET 模式核心:必须循环读,直到返回 EAGAIN
            if (evt & EPOLLIN) {
                char buffer[BUFFER_SIZE];
                bool connectionClosed = false;

                while (true) {
                    ssize_t bytesRead = read(curFd, buffer, sizeof(buffer) - 1);
                    if (bytesRead > 0) {
                        buffer[bytesRead] = '\0';
                        std::cout << "FD " << curFd << " says: " << buffer;
                        // Echo 回写(注意:生产环境可写可能阻塞,需注册 EPOLLOUT)
                        write(curFd, buffer, bytesRead);
                    } 
                    else if (bytesRead == 0) {
                        // 对端优雅关闭
                        std::cout << "Client FD " << curFd << " disconnected." << std::endl;
                        connectionClosed = true;
                        break;
                    } 
                    else { // bytesRead == -1
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            // 缓冲区数据全部读完,符合 ET 预期,正常退出循环
                            break;
                        } else {
                            perror("read");
                            connectionClosed = true;
                            break;
                        }
                    }
                }

                // 如果连接关闭或出错,清理资源
                if (connectionClosed) {
                    close(curFd);
                    epoll_ctl(epollFd, EPOLL_CTL_DEL, curFd, nullptr);
                }
            }

            // 可选:处理对端关闭(EPOLLRDHUP),这里读事件已经覆盖了 0 字节情况
            if (evt & EPOLLRDHUP) {
                std::cout << "FD " << curFd << " sent RDHUP (half-close)." << std::endl;
                // 通常此时立即关闭,无需再读
                close(curFd);
                epoll_ctl(epollFd, EPOLL_CTL_DEL, curFd, nullptr);
            }
        }
    }

    // 清理
    close(listenFd);
    close(epollFd);
    return 0;
}

代码关键点解析(ET 模式)

非阻塞是 ET 的命脉:set_nonblocking 必须设置。如果不设置,当 while(read()) 读到最后一次时,线程会永久阻塞在 read 上。

accept 也要循环:高并发下可能瞬间涌入多个连接,ET 模式下 listenFd 只触发一次 EPOLLIN,必须循环 accept 直到 EAGAIN,否则会有连接"饿死"。

EAGAIN 是"读完"的信号:在 ET 下,read 返回 -1 且 errno == EAGAIN,是成功的标志,表示本次数据已清空,可以安全退出循环。

EPOLLRDHUP:强烈建议加上,可以在对端关闭写端时(FIN)提前感知,避免多余的 read 调用

相关推荐
Java陈序员10 分钟前
轻量运维面板!一款现代化的服务器控制面板工具!
运维·服务器·python·react.js·github
bosins30 分钟前
WSL 迁移指南:如何将 Linux 环境完整复制到另一台电脑
linux·运维·服务器
企鹅的蚂蚁2 小时前
LubanCat RK3588 实时 Linux 开发(六):启用 gs_usb 驱动并完成 USB-CAN 收发
linux·rk3588·linux驱动·socketcan·gs_usb·usb-can
2601_962299882 小时前
Linux执行Python脚本方法
linux·python·脚本·解释器·shebang
snow@li2 小时前
服务器运维:Linux命令速查手册(全景梳理)
linux·服务器·网络
handler012 小时前
【Linux】线程与虚拟内存的底层世界
linux·运维·服务器·c++·线程·虚拟地址空间·共享内存
布莱克6052 小时前
TCP 三次握手与四次挥手详解
服务器·网络·tcp/ip·计算机网络·网络编程
新时代牛马3 小时前
实时 Linux:PREEMPT_RT、延迟来源与观测
linux·运维·服务器
微露清风3 小时前
# Linux 进程全景:从 PCB 到 O(1) 调度队列
linux·服务器
H_oRIZoN_3 小时前
Linux入门DAY38(手写 HTTP 客户端抓取天气 + 简易 HTTP 服务器实现元器件 Web 系统)
linux·linux应用编程