epoll 到底快在哪?从快递柜讲到底层实现

本文适合写过 socket 编程、用过或听说过 select/poll,但不太清楚 epoll 底层为什么快的新手后端开发者。读完你能理解 epoll 的三数据结构设计、回调机制、ET/LT 区别,并手写一个能跑的 epoll 服务器。预计 20 分钟。


一、痛点:当你有 10000 个连接,但每秒只有 5 个活跃

你写了一个聊天服务器,用 select 管理所有客户端连接。最开始 10 个用户时一切正常,CPU 使用率 2%。后来用户涨到 5000,CPU 飙到 80%——但明明只有十几个人在同时发消息。

问题出在哪?

select 每次调用都要把全部 5000 个 fd 从用户态拷贝到内核态,内核再挨个遍历 5000 个 fd 去检查"你有没有数据?"——哪怕其中 4990 个根本没动静。

poll 好一点,没有 1024 的数量上限,但本质上一样:O(n) 遍历 + 每次重新拷贝

这就是 epoll 要解决的问题:如何在大量空闲连接中,只关注少数活跃的那几个?


二、核心思想:从"挨家挨户敲门"到"快递柜"

一个类比讲清楚三种方案

想象你是快递员,负责 1000 个客户,每天早上要知道"谁今天有包裹要寄"。

方案 做法 工作量
select 每天骑车从 1 号到 1000 号,挨家敲门问"你今天要寄吗?" 与总客户数成正比
poll 换了大车(没了数量上限),但还是挨家敲门问 与总客户数成正比
epoll 在小区门口放快递柜,客户要寄自己放进去,你只去看柜子里有没有 只与有包裹的客户数成正比

回到技术:epoll 用"注册制 + 回调通知"替代了"轮询"。你把要盯的 fd 注册一次,数据到了内核主动通知你——你不用反复去问。


三、底层三件套:红黑树、就绪链表、等待队列

epoll 在内核里由一个 eventpoll 结构体管着,里面三样东西各司其职:

struct eventpoll {
    红黑树    rbr;      // "花名册"——存所有被监听的 fd
    就绪链表  rdllist;  // "待取清单"——只存放已经有数据的 fd
    等待队列  wq;       // "闹钟"——没事件时,epoll_wait 的进程睡在这
};

它们是怎么协作的?

第一步:epoll_ctl(ADD) — 登记客户

你把 fd=3, 4, 5, 6 注册给 epoll。内核做的事:

  • 每个 fd 做成一个 epitem(登记条目),插入红黑树(增删查 O(log n))
  • 在对应 socket 上挂一个回调函数 ep_poll_callback——这步最关键
  • 检查这个 socket 现在有没有数据?有就立刻挂进就绪链表;没有就等着

第二步:数据到达 — 触发回调

网卡收到数据包 → 硬中断 → TCP 协议栈 → 数据进 socket 接收缓冲区 → 内核调用之前注册的 ep_poll_callback

ep_poll_callback(fd 对应的 epitem):
  1. 把 epitem 挂到 eventpoll 的就绪链表上
  2. 唤醒 eventpoll 等待队列上睡觉的进程(如果有的话)

第三步:epoll_wait — 只取就绪的

epoll_wait():
  检查就绪链表
    有东西 → 拷给用户,立刻返回(O(就绪数))
    空的   → 当前进程挂到等待队列上,睡觉,等回调来叫醒

一张图总结

  epoll_ctl(ADD)              数据来了                epoll_wait()
  ─────────────             ──────────              ─────────────
  把 fd 插进                触发回调                 检查就绪链表
  红黑树                    ↓                       ↓
  ┌──────┐  注册回调  ┌──────────┐  有数据  ┌──────────┐
  │ 红黑树 │ ───────→ │ socket   │ ───────→ │ 就绪链表 │ ←── 直接从这里取 fd
  │fd3,4,5,6│         │ 等待队列  │           │  fd=5    │
  └──────┘            └──────────┘           └──────────┘
                                                  │
  没就绪事件时 → 你睡在 →                     ┌──────────┐
                                              │ 等待队列 │
                                              │ (你的进程) │
                                              └──────────┘

核心要点:红黑树管"盯谁",就绪链表管"谁有数据",回调函数管"数据到了叫我"。epoll_wait 直接从就绪链表取结果,不碰红黑树。


四、select / poll / epoll 三兄弟对比


select poll epoll
fd 数量上限 1024(FD_SETSIZE) 无上限 无上限
每次调用的复杂度 O(n) 遍历全部 O(n) 遍历全部 O(就绪数)
内核态拷贝 每次调都拷整个 fd_set 每次调都拷整个 pollfd 数组 只注册时拷一次,fd 常住内核
返回结果 全部 fd(你自己筛选谁就绪) 全部 fd(你自己筛选) 只返回就绪的 fd
数据结构 位图 数组 红黑树 + 就绪链表
适用场景 fd 少且大部分活跃 fd 中等 fd 多且大部分空闲

一句话:fd 少的场景三者差别不大,上万连接且大部分空闲时,epoll 的优势是指数级的。


五、ET vs LT:边缘触发和水平触发

这是 epoll 面试的必考题。两者区别一句话:通知的时机和次数不同。


LT(水平触发,默认) ET(边缘触发)
通知规则 缓冲区有数据就叫你 只在"从无到有"的瞬间叫一次
比喻 快递柜屏幕一直显示"有包裹" 柜子只发一条短信,你不取就没了
对 read 的要求 读一次就行,下次还会叫你 必须循环读到 EAGAIN,否则那个 fd 再也没人叫
优点 简单安全,不容易丢事件 减少系统调用次数,吞吐更高
谁在用 默认模式 Nginx、Redis

ET 模式的黄金法则:fd 必须设非阻塞 + read 必须循环读到返回 EAGAIN + accept 也要循环到 EAGAIN。少一步都会丢事件。


六、动手写一个 epoll 服务器

完整代码在这里

// epoll_demo.cpp — 简易 epoll TCP 回显服务器
// 用法: ./epoll_demo [lt|et] [port]
// 然后用 telnet 127.0.0.1 <port> 连接测试,输入任意字符会回显

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <vector>

const int MAX_EVENTS = 64;
const int BUFFER_SIZE = 4096;

// 工具函数:设置 fd 为非阻塞
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 工具函数:创建监听 socket
int create_listen_socket(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);

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

    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(port);

    bind(fd, (sockaddr*)&addr, sizeof(addr));
    listen(fd, SOMAXCONN);

    return fd;
}

// ========== 水平触发(LT)模式:默认行为 ==========
void run_lt(int listen_fd) {
    printf("=== 水平触发 (LT) 模式 ===\n");
    printf("特点: 只要缓冲区有数据,每次 epoll_wait 都会通知你\n\n");

    int epfd = epoll_create1(0);  // ① 创建 epoll 实例

    // ② 把监听 socket 注册进去
    epoll_event ev{};
    ev.events = EPOLLIN;          // LT 是默认的,不用写 EPOLLLT
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    epoll_event ready[MAX_EVENTS];

    while (true) {
        // ③ 等待事件(-1 = 一直等到有事件)
        int n = epoll_wait(epfd, ready, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = ready[i].data.fd;

            if (fd == listen_fd) {
                // = 新连接来了 =
                sockaddr_in client_addr{};
                socklen_t   len = sizeof(client_addr);
                int client_fd = accept(fd, (sockaddr*)&client_addr, &len);

                set_nonblocking(client_fd);  // 必须设非阻塞

                epoll_event ev2{};
                ev2.events   = EPOLLIN;  // LT
                ev2.data.fd  = client_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev2);

                printf("[LT] 新连接: fd=%d 来自 %s:%d\n",
                       client_fd, inet_ntoa(client_addr.sin_addr),
                       ntohs(client_addr.sin_port));
            }
            else {
                // = 客户端发来数据 =
                char buf[BUFFER_SIZE];
                int  len = read(fd, buf, sizeof(buf));

                if (len <= 0) {
                    // 连接关闭
                    printf("[LT] 连接关闭: fd=%d\n", fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                    close(fd);
                }
                else {
                    buf[len] = '\0';
                    printf("[LT] fd=%d 收到 %d 字节: %s", fd, len, buf);
                    // 回显
                    write(fd, buf, len);
                    // LT 关键:这里只读了一次。如果还有数据,
                    // 下次 epoll_wait 会继续通知,所以不会丢数据
                }
            }
        }
    }
}

// ========== 边缘触发(ET)模式:高性能模式 ==========
void run_et(int listen_fd) {
    printf("=== 边缘触发 (ET) 模式 ===\n");
    printf("特点: 只在'无→有'的瞬间通知一次,你必须循环读到 EAGAIN\n\n");

    int epfd = epoll_create1(0);

    epoll_event ev{};
    ev.events = EPOLLIN | EPOLLET;  // ← 关键:加 EPOLLET 标志
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    epoll_event ready[MAX_EVENTS];

    while (true) {
        int n = epoll_wait(epfd, ready, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = ready[i].data.fd;

            if (fd == listen_fd) {
                // = 新连接:ET 模式下 accept 也要循环! =
                while (true) {
                    sockaddr_in client_addr{};
                    socklen_t   len = sizeof(client_addr);
                    int client_fd = accept(fd, (sockaddr*)&client_addr, &len);

                    if (client_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 所有新连接都 accept 完了
                        }
                        perror("accept");
                        break;
                    }

                    set_nonblocking(client_fd);

                    epoll_event ev2{};
                    ev2.events   = EPOLLIN | EPOLLET;  // ET
                    ev2.data.fd  = client_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev2);

                    printf("[ET] 新连接: fd=%d 来自 %s:%d\n",
                           client_fd, inet_ntoa(client_addr.sin_addr),
                           ntohs(client_addr.sin_port));
                }
            }
            else {
                // = 客户端数据:ET 模式必须循环读到 EAGAIN =
                char buf[BUFFER_SIZE];
                while (true) {
                    int len = read(fd, buf, sizeof(buf));

                    if (len == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 读完了,没有更多数据
                        }
                        perror("read");
                        break;
                    }
                    else if (len == 0) {
                        // 对端关闭连接
                        printf("[ET] 连接关闭: fd=%d\n", fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                        close(fd);
                        goto next_event;  // fd 已关闭,跳出外层循环
                    }
                    else {
                        buf[len] = '\0';
                        printf("[ET] fd=%d 收到 %d 字节: %s", fd, len, buf);
                        write(fd, buf, len);
                    }
                }
                next_event:;
            }
        }
    }
}

// ========== 对比总结 ==========
// LT: 简单安全,每次读一次就行,下次 wait 还会通知
// ET: 必须非阻塞 + 循环读到 EAGAIN,但减少系统调用次数,吞吐更高

int main(int argc, char* argv[]) {
    const char* mode = (argc > 1) ? argv[1] : "lt";
    int port = (argc > 2) ? atoi(argv[2]) : 8080;

    int listen_fd = create_listen_socket(port);
    set_nonblocking(listen_fd);

    printf("epoll 演示服务器已启动: 端口 %d\n", port);
    printf("测试方式: 打开多个终端,运行: telnet 127.0.0.1 %d\n", port);
    printf("输入任意文字 + 回车,服务器会回显\n\n");

    if (strcmp(mode, "et") == 0) {
        run_et(listen_fd);
    } else {
        run_lt(listen_fd);
    }
}

先看核心骨架——就三步:

// 1. 创建
int epfd = epoll_create1(0);

// 2. 注册要盯的 fd
struct epoll_event ev;
ev.events  = EPOLLIN;           // LT 默认;ET 写 EPOLLIN | EPOLLET
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

// 3. 等待
struct epoll_event ready[64];
int n = epoll_wait(epfd, ready, 64, -1);
for (int i = 0; i < n; i++) {
    int fd = ready[i].data.fd;  // 只遍历就绪的!
    // 处理 fd...
}

LT 和 ET 的核心区别在代码层面只有两处:

// === LT:读一次就停 ===
int len = read(fd, buf, sizeof(buf));
if (len > 0) write(fd, buf, len);  // 回显
// 下次 epoll_wait 如果还有数据,照样通知你

// === ET:必须循环读到 EAGAIN ===
while (true) {
    int len = read(fd, buf, sizeof(buf));
    if (len == -1 && errno == EAGAIN) break;  // 读完了
    if (len <= 0) { /* 连接关闭 */ break; }
    write(fd, buf, len);
}

完整代码已给

epoll_demo.cpp,编译运行方式:

# 编译
g++ -std=c++17 -o epoll_demo epoll_demo.cpp

# 运行 LT 模式
./epoll_demo lt 8080

# 运行 ET 模式
./epoll_demo et 8080

然后用 telnet 127.0.0.1 8080 连接测试,多开几个窗口看多连接效果。


七、翻车现场:两个你一定踩的坑

坑 1:ET 模式只读了一次,数据丢了

// 错:ET 下只读一次
int len = read(fd, buf, sizeof(buf));
write(fd, buf, len);
// 如果缓冲区还有数据,epoll 不会再通知你——丢了

修复:循环读到 errno == EAGAIN

坑 2:ET 下 fd 忘了设非阻塞

// 错:fd 是阻塞模式
int fd = accept(listen_fd, ...);  // 没设 O_NONBLOCK
// ET 下循环 read,最后一次 read 会永远阻塞

修复fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);


八、面试速查表

面试问题 回答要点
epoll 为什么快? 注册制 + 回调通知,只返回就绪 fd,O(就绪数) 而非 O(总数)
三个核心数据结构? 红黑树存所有 fd,就绪链表存有数据的 fd,等待队列存阻塞的进程
回调函数怎么工作的? epoll_ctl 时注册到 socket 上,数据到了协议栈触发,把 fd 挂到就绪链表
LT 和 ET 区别? LT 有数据就通知,ET 只在无→有瞬间通知一次
ET 两个要求? fd 非阻塞 + 循环读到 EAGAIN
和 select/poll 的核心区别? select/poll 每次 O(n) 遍历 + 每次拷贝全部 fd;epoll 只注册时拷贝一次,事件驱动
epoll 有啥缺点? 系统调用多(create/ctl/wait),短连接场景ctl开销大,高并发下有锁竞争

九、接下来做什么?

思考题:epoll 用红黑树存 fd,为什么不选择哈希表?提示:想想 epoll_ctl 支持哪些操作,以及 100 万个 fd 时哈希表的扩容代价。

动手实验:修改桌面上的 epoll_demo.cpp,加一个统计功能——每 10 秒打印一次"当前管理的连接数"和"最近 10 秒处理的请求数"。你会发现连接数涨了但请求数不涨时,CPU 几乎不变——这就是 epoll 的魅力。

延伸阅读

  • Linux 内核源码 fs/eventpoll.c——ep_poll_callback 函数不到 100 行,注释清晰
  • The C10K Problem(Dan Kegel, 1999)——epoll 诞生的背景论文,理解"为什么要解决万级并发"
  • io_uring 入门——epoll 解决了"谁就绪",但读写还是得你自己调两个系统调用。io_uring 把等和读写全异步化了

📋 全文回顾 —— 你能不能回答这几个问题?

  1. 为什么快递柜的比喻里,"快递柜"对应 epoll,"挨家敲门"对应 select/poll?
  2. 就绪链表和红黑树的角色能不能互换?(想想看)
  3. 如果你写的 ET 模式服务器偶尔丢消息,第一反应应该检查什么?


Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐