一篇为防止遗忘而写的总结。从最基础的 API,到 eventfd 唤醒模式,再到生产级 IO 线程的事件循环设计,完整记录一下


一、为什么需要 epoll

假设要写一个服务器,同时处理 10000 个客户端连接。传统做法有几种:

  • 每连接一线程:10000 个线程,上下文切换爆炸,内存占用巨大
  • select / poll:每次调用都要把所有 fd 数组从用户态拷到内核态,内核还要线性扫描整个数组找出就绪的,O(N) 复杂度,fd 一多就崩
  • epoll:fd 注册一次就常驻内核,内核用红黑树管理,事件就绪时直接挂到就绪链表,O(1) 拿到结果

epoll 的核心思想:把"我们关心哪些 fd"这件事告诉内核一次,之后内核主动通知我"哪些 fd 现在可读/可写"。


二、三个核心系统调用

#include <sys/epoll.h>

// 1. 创建一个 epoll 实例(返回一个 fd)
int epoll_create1(int flags);

// 2. 增/删/改要监听的 fd 和事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 3. 阻塞等待事件就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_ctlop 参数:

含义
EPOLL_CTL_ADD 注册新 fd
EPOLL_CTL_MOD 修改已注册 fd 的事件
EPOLL_CTL_DEL 移除 fd

常用的事件标志:

含义
EPOLLIN 可读
EPOLLOUT 可写
EPOLLRDHUP 对端关闭连接
EPOLLERR 发生错误
EPOLLHUP fd 挂起
EPOLLET 边缘触发(默认水平触发 LT)
EPOLLONESHOT 触发一次后自动禁用,需要重新 EPOLL_CTL_MOD

这些宏全部定义在 <sys/epoll.h>,实际源头是内核的 <linux/eventpoll.h>,用下面的指令就能看到:

echo '#include <sys/epoll.h>' | gcc -E -dM -x c - | grep EPOLL

三、epoll_event 结构:

struct epoll_event {
    uint32_t     events;   // 事件位掩码(EPOLLIN | EPOLLOUT | ...)
    epoll_data_t data;     // 用户自定义数据(union)
};

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

data 字段是整个 epoll 编程的灵魂,必须理解清楚:

  • 它是一个 union,可以选择存 ptrfdu32u64 中的任意一种
  • 内核完全不解释这个字段,只是在事件就绪时原样回传给开发者
  • 它本质上是一个"路由标签"——把 fd 和我们的处理逻辑绑定起来
// 注册时存什么,事件返回时就拿到什么
ev.data.u32 = 42;
epoll_ctl(epfd, EPOLL_CTL_ADD, some_fd, &ev);
// ...
// epoll_wait 返回后
events[i].data.u32  // == 42

常见用法:

  • 存 fdev.data.fd = sock_fd; — fd 少时最直观
  • 存 u32 标签ev.data.u32 = kEventTimer; — 适合 switch 分发
  • 存指针ev.data.ptr = connection; — 大型服务器最常用,直接拿到连接上下文

四、最小可运行示例:TCP echo 服务器

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

#define MAX_EVENTS 64
#define PORT 8080

static int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void) {
    // 1. 创建监听 socket(非阻塞)
    int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, SOMAXCONN);

    // 2. 创建 epoll 实例
    int epfd = epoll_create1(EPOLL_CLOEXEC);

    // 3. 注册监听 socket(边缘触发)
    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];

    // 4. 事件循环
    for (;;) {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait"); break;
        }

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

            // 错误或对端关闭
            if (evs & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                close(fd);
                continue;
            }

            if (fd == listen_fd) {
                // ET 模式必须循环 accept 到 EAGAIN
                while (1) {
                    int conn_fd = accept(listen_fd, NULL, NULL);
                    if (conn_fd < 0) {
                        if (errno == EAGAIN) break;
                        perror("accept"); break;
                    }
                    set_nonblocking(conn_fd);
                    struct epoll_event cev;
                    cev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
                    cev.data.fd = conn_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
                }
            } else if (evs & EPOLLIN) {
                // 客户端数据:echo 回去
                char buf[4096];
                while (1) {
                    ssize_t cnt = read(fd, buf, sizeof(buf));
                    if (cnt > 0) {
                        write(fd, buf, cnt);
                    } else if (cnt == 0) {
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    } else {
                        if (errno == EAGAIN) break;
                        if (errno == EINTR) continue;
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }
                }
            }
        }
    }
    return 0;
}

编译运行:gcc -o server server.c && ./server,用 nc localhost 8080 测试。


五、对比 水平触发 (LT) 和 边缘触发 (ET)

水平触发 LT(默认)

只要 fd 仍处于就绪状态,每次 epoll_wait 都会返回它。

举例:socket 收到 100 字节,但只读了 50 字节。下次 epoll_wait 还会返回这个 fd,因为还有 50 字节没读完。

优点:编程简单,类似 select/poll 的行为
缺点:如果一直不处理,会被反复通知,浪费 CPU

边缘触发 ET

只在 fd 状态发生变化时通知一次:从"不可读"变成"可读"那一刻通知,之后就算还有数据,也不会再通知,直到你把数据全部读完,再有新数据到来时才会再次通知
该方案相比LT, 通知次数少,性能更高,但是需要配合以下使用要点:

  1. 必须配合非阻塞 fd 使用
  2. 必须循环 read/write 直到返回 EAGAIN,否则会丢事件
// ET 模式正确写法
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) { /* 处理数据 */ }
    else if (n == 0) { /* 对端关闭 */ break; }
    else {
        if (errno == EAGAIN) break;   // ← 关键:读到这里才能停
        if (errno == EINTR) continue;
        /* 其他错误处理 */
        break;
    }
}

适用场景

  • 简单应用、连接数不多 → LT
  • 高性能服务器、追求最低 syscall 开销 → ET
  • nginx、redis、libevent 默认都是 ET

用epoll + 某fd实现特定工具

eventfd + epoll 实现线程间唤醒

问题场景

当我们的 IO 线程正阻塞在 epoll_wait 上。这时另一个线程下发了一条命令,希望 IO 线程立刻处理,而不是等到下一个定时器超时。怎么办?

答案:用 eventfd 当作"门铃",把 eventfd 注册进 epoll。其他线程"按门铃",IO 线程就被唤醒

eventfd 是什么

#include <sys/eventfd.h>
int eventfd(unsigned int initval, int flags);

eventfd 是内核提供的一种计数器 fd

  • 内核里维护一个 uint64_t 计数器
  • write(fd, &v, 8)v 加到计数器上
  • read(fd, &v, 8) 读出当前值并清零
  • 只要计数器 > 0,fd 就处于 EPOLLIN 可读状态

常用 flags:

  • EFD_NONBLOCK:非阻塞模式
  • EFD_CLOEXEC:exec 时自动关闭
  • EFD_SEMAPHORE:信号量语义(每次 read 减 1 而不是清零)

完整唤醒流程

// 创建并注册
int event_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.u32 = kEventWake;   // ← 自定义标签
epoll_ctl(epfd, EPOLL_CTL_ADD, event_fd, &ev);

// IO 线程的事件循环
for (;;) {
    int n = epoll_wait(epfd, events, MAX, -1);
    for (int i = 0; i < n; i++) {
        switch (events[i].data.u32) {
            case kEventWake:
                handle_wake();   // 处理唤醒
                break;
            // 其他事件...
        }
    }
}

// 处理函数:必须读空 eventfd,否则下次 epoll_wait 会立即再次返回
void handle_wake() {
    uint64_t value;
    while (read(event_fd, &value, sizeof(value)) == sizeof(value)) {
        // 循环读直到 EAGAIN
    }
    do_some_work();
}

// 其他线程调用
void wake() {
    uint64_t value = 1;
    write(event_fd, &value, sizeof(value));   // 一行代码叫醒 IO 线程
}

整个唤醒链路图解

[其他线程]                          [IO 线程]
                                    阻塞在 epoll_wait()
                                          │
wake()                                    │
   │                                      │
   └─► write(event_fd, 1)                 │
         │                                │
         ▼                                │
   [内核 eventfd 计数器: 0 → 1]            │
         │                                │
         └─► fd 变为可读                   │
               │                          │
               └─► epoll 把 event_fd 加入就绪链表
                      │                   │
                      └─────唤醒─────────►│
                                          ▼
                                    epoll_wait 返回 n=1
                                    events[0].data.u32 == kEventWake
                                          │
                                          ▼
                                    switch → case kEventWake
                                          │
                                          ▼
                                    handle_wake()
                                      ├─ read 清零计数器
                                      └─ 执行业务逻辑

关于 write 写入值的几个小坑

写入的 value 只要 > 0 就能唤醒,业界惯例写 1。但 eventfd 对 write 有几条硬规则:

写入情况 结果
0 系统调用成功,但计数器没变化,不唤醒
1 ~ 0xFFFFFFFFFFFFFFFE 累加到计数器,正常工作
UINT64_MAX (0xFFFFFFFFFFFFFFFF) 返回 EINVAL(内核硬限制)
字节数不是 8 字节 返回 EINVAL(必须按 uint64_t 读写)

为什么大家写 1:最简、意图清晰、绝不会触发计数器溢出。


定时器:timerfd + epoll

类似地,timerfd 把定时器变成一个 fd,到点了 fd 变可读,epoll 就能统一管理"周期任务"和"IO 事件"。

#include <sys/timerfd.h>

int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

struct itimerspec spec = {0};
spec.it_interval.tv_sec = 0;
spec.it_interval.tv_nsec = 5 * 1000 * 1000;  // 5ms 周期
spec.it_value = spec.it_interval;             // 5ms 后第一次触发
timerfd_settime(tfd, 0, &spec, NULL);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.u32 = kEventTimer;
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);

// 定时器触发时:read 出"过期次数",不读的话 fd 会一直可读
uint64_t expirations;
read(tfd, &expirations, sizeof(expirations));

产品常用的结构:统一事件循环

把 timerfd(周期任务)+ eventfd(异步唤醒)+ socket fd(数据 IO)全部塞进一个 epoll,是 Linux 实时控制、网络服务、消息中间件的经典架构。下面是一个抽象出来的通用模板:

class EventLoop {
public:
    // 事件源标签
    enum EventTag : uint32_t {
        kTagTimer   = 1,   // 周期任务
        kTagWake    = 2,   // 外部唤醒
        kTagBusA    = 3,   // 业务 IO 通道 A
        kTagBusB    = 4,   // 业务 IO 通道 B
    };

    bool setup() {
        epoll_fd_ = ::epoll_create1(EPOLL_CLOEXEC);
        timer_fd_ = ::timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
        event_fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);

        // 注册周期定时器(如 5ms 一次)
        arm_timer(timer_fd_, std::chrono::milliseconds(5));
        add_fd(timer_fd_, kTagTimer, EPOLLIN);

        // 注册唤醒 fd
        add_fd(event_fd_, kTagWake, EPOLLIN);

        // 注册业务 IO fd(边缘触发,同时监听收发)
        add_fd(bus_a_fd_, kTagBusA, EPOLLIN | EPOLLOUT | EPOLLET);
        add_fd(bus_b_fd_, kTagBusB, EPOLLIN | EPOLLOUT | EPOLLET);
        return true;
    }

    void run() {
        while (running_.load(std::memory_order_acquire)) {
            epoll_event events[8]{};
            int n = ::epoll_wait(epoll_fd_, events, 8, /*timeout_ms=*/50);
            if (n < 0) {
                if (errno == EINTR) continue;
                break;
            }
            for (int i = 0; i < n; ++i) {
                dispatch(events[i]);
            }
        }
    }

    // 其他线程调用此方法立即唤醒事件循环
    void notify() {
        uint64_t v = 1;
        ::write(event_fd_, &v, sizeof(v));
    }

private:
    bool add_fd(int fd, uint32_t tag, uint32_t events) {
        epoll_event ev{};
        ev.events  = events;
        ev.data.u32 = tag;
        return ::epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev) == 0;
    }

    void dispatch(const epoll_event& ev) {
        switch (ev.data.u32) {
            case kTagTimer:
                handle_timer();        // 周期任务:心跳、状态检查、超时清理
                break;
            case kTagWake:
                handle_wake();         // 外部唤醒:处理命令队列
                break;
            case kTagBusA:
                if (ev.events & EPOLLIN)  handle_rx(bus_a_fd_);
                if (ev.events & EPOLLOUT) handle_tx(bus_a_fd_);
                break;
            case kTagBusB:
                if (ev.events & EPOLLIN)  handle_rx(bus_b_fd_);
                if (ev.events & EPOLLOUT) handle_tx(bus_b_fd_);
                break;
        }
    }

    int epoll_fd_{-1}, timer_fd_{-1}, event_fd_{-1};
    int bus_a_fd_{-1}, bus_b_fd_{-1};
    std::atomic<bool> running_{true};
};

设计要点:

  1. 三类事件源统一调度:定时器周期任务、异步命令唤醒、底层 IO 收发,全部归一到一个事件循环里,避免多线程竞争
  2. data.u32 当路由标签:用枚举常量区分事件源,switch 分发清晰
  3. ET 模式 + 非阻塞 IO:性能敏感的数据通道用 ET,搭配 EPOLLIN | EPOLLOUT 同时监听收发
  4. eventfd 实现低延迟响应:外部线程下发命令后立刻 notify(),IO 线程不必等到下一个定时周期才响应
  5. 超时兜底epoll_wait 给个 50ms 超时,即使没事件也能定期循环检查 running_ 标志,便于优雅停止

这个骨架可以套用在很多场景:网络服务器(accept fd + client fd + 控制 fd)、设备驱动用户态守护进程(device fd + timer + signalfd)、消息队列消费者(socket + 定时刷新 + 命令通道)等。


九、常见坑总结

解决方案
ET 模式只读一次,剩下数据再也收不到 必须 while (read() != EAGAIN) 循环读空
ET 模式用阻塞 fd 卡死 ET 必须配合 O_NONBLOCK
eventfd 写完不读,CPU 100% 空转 handle_event 里必须 read 清空计数器
close(fd) 后忘记 EPOLL_CTL_DEL 一般 close 自动移除,但 fd 被 dup 过时需要手动删
信号中断导致 epoll_wait 返回 -1 检查 errno == EINTR 后 continue
多线程同时操作同一个 epoll fd epoll 本身线程安全,但要注意事件分发的竞争
EPOLLOUT 一直触发导致 CPU 空转 socket 一旦可写就会一直触发 EPOLLOUT;没数据要发时应该 EPOLL_CTL_MOD 取消 EPOLLOUT

整体架构回顾

                    ┌──────────────────────────┐
                    │   epoll 实例 (epoll_fd)   │
                    │                          │
                    │   红黑树管理注册的 fd      │
                    │   ┌──────────────┐       │
                    │   │ timer_fd     │──┐    │
                    │   │ event_fd     │──┤    │
                    │   │ socket_fd_1  │──┼─► 就绪链表 (rdllist)
                    │   │ socket_fd_2  │──┤    │
                    │   │ ...          │──┘    │
                    │   └──────────────┘       │
                    └──────────────┬───────────┘
                                   │
                          epoll_wait() 取就绪
                                   │
                                   ▼
                    ┌──────────────────────────┐
                    │  events[N]                │
                    │  ├─ events  (位掩码)       │
                    │  └─ data.u32 (路由标签)    │
                    └──────────────┬───────────┘
                                   │
                                   ▼
                    switch (data.u32) {
                       case kEventTimer:  ...
                       case kEventWake:   ...
                       case kEventSocket: ...
                    }

相关文档

  • man 7 epoll — 系统级权威文档
  • man 2 eventfd / man 2 timerfd_create
  • Linux 内核源码:fs/eventpoll.c
  • libuv、libevent、asio 的 epoll 后端实现

核心:epoll 是一个"事件总线",所有能变成 fd 的东西(socket、定时器、信号、用户事件、文件、inotify、signalfd…)都可以塞进来统一调度,其中的fd正好是linux编程的精髓

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐