epoll 与事件驱动

本页目录

epoll 维护感兴趣的描述符及就绪事件,让程序集中等待多个 I/O 来源。它返回的是“现在可以尝试读写”的提示,不是已经替应用完成读写,也不保证一次事件对应一条业务消息。

为什么适合许多空闲连接

select/poll 每轮提交描述符集合或数组并进行相关扫描;epoll 保留兴趣集合,通过底层就绪通知维护候选列表,通常能减少大量空闲描述符的重复处理。

这不等于任何 epoll_wait 都是 O(1):返回 k 个事件至少需要相应处理与复制成本,注册修改、回调、锁竞争和大量活跃连接也有代价。poll、epoll 的描述符数受资源限制,不能写成无限制;glibc select 的 fd_set 还有固定 FD_SETSIZE 限制。

三类操作

int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = client_fd };
int rc = epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

这是一段需要 <sys/epoll.h> 的 API 片段,须检查 epfd 和 rc。ADD 注册,MOD 修改兴趣和用户数据,DEL 删除。epoll_wait 的正返回值是本批事件数,0 是超时,-1 是错误;EINTR 不应被误认为没有连接或所有连接已关闭。

.data 是由应用保存、再原样返回的关联信息,可以用 fd、指针或带代次的 ID。内核不会替应用维护该指针对象的生命周期。

事件位不是互斥枚举

事件或标志含义与处理
EPOLLIN尝试读取;监听 socket 则尝试 accept
EPOLLOUT尝试写入,不表示任意长度都能一次发送
EPOLLERR错误,可结合 SO_ERROR 等查询;无需显式订阅才收到
EPOLLHUP挂断;仍可能有缓冲数据需要读取
EPOLLRDHUP流 socket 对端关闭写方向,Linux 2.6.17 起提供
EPOLLET改用边沿触发模式
EPOLLONESHOT交付事件后禁用该注册的后续通知,需 MOD 重置
EPOLLEXCLUSIVE特定注册场景减少多个 epoll 实例的唤醒,不是通用锁

IN、HUP、ERR 等可以同时出现。ONESHOT 不是自动执行 DEL,也不会关闭 fd。epoll_ctl(2)

LT 与 ET:何时可以再次等待

LT 是默认模式,条件仍就绪时可继续报告。应用可以处理一部分后回到等待,但若一直不消费数据或一直订阅可写,循环会持续被唤醒。

ET 不保证未消费的就绪状态会被反复提醒。通用做法是使用非阻塞 I/O,处理到 EAGAIN 后才认为可以依赖新的通知。不要把 ET 简化成对所有 fd 都严格只报告某一种二值状态翻转;具体唤醒来自底层事件,应用不应依赖额外通知修复漏处理。epoll(7)

就绪通知之后,应用仍需执行 I/O 并维护连接状态 epoll_wait 返回可读通知 非阻塞 recv n > 0 处理字节,继续读 n == 0 记录对端输入结束 EAGAIN 当前已无数据 EINTR 可重试;其他错误按协议处理。通知既不预留数据,也不代表业务消息完整。

下面是流 socket 的读取片段,consume_bytes 必须快速处理或入应用队列,不能在事件线程里阻塞。返回 0 表示读到 EAGAIN,1 表示输入方向 EOF,-1 表示其他错误:

#include <errno.h>
#include <stddef.h>
#include <sys/socket.h>

void consume_bytes(const unsigned char *data, size_t size);

int drain_socket(int fd) {
    unsigned char buf[4096];
    for (;;) {
        ssize_t n = recv(fd, buf, sizeof buf, 0);
        if (n > 0) {
            consume_bytes(buf, (size_t)n);
            continue;
        }
        if (n == 0) return 1;
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
        return -1;
    }
}

EOF 只结束对端到本端的输入方向,可能仍需发送响应或排空输出。是否关闭整个连接应由协议状态决定,而不是任何 HUP 都立即丢弃对象。

还有数据,为什么 epoll_wait 没有新事件?

在 ET 下执行“到达→wait→read→wait”,第二次 wait 可以返回0,而缓冲仍有两字节。应用保留的待处理标记让剩余工作能够继续调度;它不能因本轮没有新事件就被删除。读到恰好两字节也不自动证明排空,再尝试读得到 EAGAIN 才能确认这个边界。

正在呈现知识画面
还有数据,为什么 epoll_wait 没有新事件?

ET 不保证重复提醒未读数据。预算用尽时应保留应用待处理状态;read 返回 EAGAIN 才表明此时已排空。

写入背压与公平性

应用有待发数据时先尝试非阻塞发送;若只写了一部分或 EAGAIN,保存剩余区间并关注 EPOLLOUT。待发送队列清空后通常取消可写关注,否则 LT 模式可能形成空转。socket 写入还要有明确的 SIGPIPE 或 MSG_NOSIGNAL 策略。

一直服务一个高吞吐连接直到 EAGAIN,可能让其他连接饥饿。可以为每个连接设字节或时间预算;ET 下若预算用尽却未排空,需把连接留在应用就绪队列并继续安排,不能直接回 epoll_wait 指望再次通知。

监听 socket 也应循环 accept 到 EAGAIN;accept4 可原子指定新连接的 NONBLOCK/CLOEXEC。还需处理 fd 耗尽等错误,避免监听就绪导致持续失败的忙循环。

ET 处理预算耗尽,不等于当前已经没有数据 收到可读事件 加入应用就绪队列 本轮处理 64 KiB 为其他连接留出运行机会 尚未读到 EAGAIN 重新排到就绪队列尾部 应用就绪队列还有任务时,继续调度并非阻塞地收取新事件 读到 EAGAIN 才清除该方向的就绪标记;EOF/错误进入各自状态 字节预算仅为示例,还可限制时间和解析工作量;队列需要去重,避免重复排入。

公平调度需要显式保存“还有工作”

假设 A 当前可读 10 个单位、B 可读 2 个单位,每轮预算为 4。A 用完第一轮预算后仍有工作,将它排到队尾,B 就能先完成。若 ET 下直接遗忘 A,剩余字节可能一直等待一个并不保证会再来的通知。

from collections import deque
remaining = {"A": 10, "B": 2}
ready = deque(["A", "B"])
queued = set(ready)
turns = []
while ready:
    connection = ready.popleft()
    queued.remove(connection)
    count = min(4, remaining[connection])
    remaining[connection] -= count
    turns.append((connection, count))
    if remaining[connection] and connection not in queued:
        ready.append(connection)
        queued.add(connection)
assert turns == [("A", 4), ("B", 2), ("A", 4), ("A", 2)]
assert all(n == 0 for n in remaining.values())

这是已知工作量的离线模型。真实 socket 不能提前知道 remaining;用非阻塞 read 的 EAGAIN、EOF 和错误来更新状态,并把预算用尽但尚未排空的连接保留为应用可运行状态。若解析一条消息本身很耗时,限制读取字节仍不足以保证公平,还需限制解析/业务执行时间。

多工作线程与 ONESHOT

ONESHOT 常用于让一个处理者临时接管连接。处理完后,通过 EPOLL_CTL_MOD 重新启用关注;这不自动让所有连接字段变成线程安全,也不替代任务转交时的内存同步。

重置时要先发布好连接状态和兴趣集合,并考虑重置后其他线程马上收到事件。若未读尽就绪数据,重新启用后可能立即再次交付。锁、所有权或串行执行器应明确规定谁能修改输出队列和关闭连接。

用 eventfd 看见 ONESHOT 与重新启用

保存为 oneshot-demo.c。eventfd 初始值为 3,第一次 epoll_wait 收到可读事件后故意不读;计数还在,但 ONESHOT 已禁用后续通知。重新 MOD 后,现存的可读条件使事件再次返回。

#include <stdint.h>
#include <stdio.h>
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <unistd.h>
int main(void) {
    int fd = eventfd(3, EFD_NONBLOCK | EFD_CLOEXEC);
    if (fd == -1) return 1;
    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep == -1) { close(fd); return 1; }
    int failed = 1;
    struct epoll_event interest = { .events = EPOLLIN | EPOLLONESHOT };
    interest.data.u64 = 42;
    struct epoll_event event;
    if (epoll_ctl(ep, EPOLL_CTL_ADD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 1 || event.data.u64 != 42) goto done;
    if (!(event.events & EPOLLIN)) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 0) goto done;
    if (epoll_ctl(ep, EPOLL_CTL_MOD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 1 || event.data.u64 != 42) goto done;
    uint64_t count;
    if (read(fd, &count, sizeof count) != sizeof count || count != 3) goto done;
    if (epoll_ctl(ep, EPOLL_CTL_MOD, fd, &interest) == -1) goto done;
    if (epoll_wait(ep, &event, 1, 0) != 0) goto done;
    puts("notified, disabled, rearmed; count=3; drained");
    failed = 0;
done:
    if (close(ep) == -1) failed = 1;
    if (close(fd) == -1) failed = 1;
    return failed;
}

运行 cc -Wall -Wextra oneshot-demo.c -o oneshot-demo 后执行 ./oneshot-demo。所有等待都用零超时,检查已经存在的就绪条件;本例没有其他信号源,等待失败直接终止,通用循环仍需处理 EINTR。ONESHOT

这里 read 返回 8 字节,读取的计数为 3,两者含义不同。未启用 EFD_SEMAPHORE 时,成功读取把当前累计值取出并清零;eventfd 作为通知计数器,并不保存三份独立业务消息。eventfd(2)

关闭与 fd 数字复用

fd 是可复用的整数。一个批次中可能已有旧连接的事件,随后某回调关闭它,新连接又取得相同 fd。直接按这个数字查当前对象,可能误把旧事件应用到新连接;对象代次、稳定 ID、延迟回收或校验可解决此类问题。

dup 后的描述符还可能共享打开文件描述,关闭一个 fd 不一定让所有相关注册立即消失。需要确定性撤销时,在对象生命周期仍有效时执行 DEL,并按应用队列中已有事件的规则清理。内核移除注册不能撤销已经复制到用户空间的事件。

一个旧事件怎样误投给新连接

假设本批事件保存了 (slot=7, generation=4)。前一个回调关闭该连接并释放槽位,新连接复用 slot=7,但代次变为 5。分发剩余事件时必须核对代次,不能只按 slot 或 fd 找到一个非空对象就继续。

current = {7: {"generation": 5, "name": "new connection"}}
old_event = (7, 4)
new_event = (7, 5)
def resolve(event):
    slot, generation = event
    item = current.get(slot)
    if item is None or item["generation"] != generation:
        return None
    return item
assert resolve(old_event) is None
assert resolve(new_event)["name"] == "new connection"

代次需要足够的范围,并定义回绕与复用策略;它也不替代并发内存回收。若 data.ptr 已经是释放后的指针,先解引用去读代次仍然不安全。可以用整数 ID 查询仍有效的注册表,或让事件批次持有对象引用直到分发结束。

timerfd、signalfd 与 eventfd

timerfd 创建后还需 timerfd_settime 才启动;读取通常取得累计到期次数,不等于每次可读只有一次到期。eventfd 读取的是通知计数。两者都应按接口规定的记录大小读,并处理非阻塞空读。

signalfd 需要先在相关线程阻塞目标信号。下面是用于程序初始化阶段的片段,需 <signal.h>、<pthread.h>、<sys/signalfd.h>:

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
int err = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (err != 0) return err;
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd == -1) return errno;

应在创建工作线程前建立希望它们继承的 mask,或逐线程协调;否则信号可能被未阻塞它的其他线程接收。忘记 sigemptyset 会让 mask 内容未初始化。SIGKILL、SIGSTOP 不能经 signalfd 接管。signalfd(2)

事件驱动的真实边界

就绪回调里执行阻塞 DNS、磁盘读取、长计算或等待普通锁,仍会阻塞整个事件线程。把耗时任务交给合适的执行机制,并通过队列、eventfd 或其他通知回传结果。

epoll 适合 readiness 模型;io_uring 以请求完成为主要接口。两者都需要应用状态机、背压、取消和资源管理,接口名称不会自动解决这些问题。

关键词:epoll, readiness, LT, ET, EPOLLONESHOT, half close, backpressure, fairness, fd reuse, timerfd, signalfd, eventfd