一个只在大请求上翻车的网关

压测小包全部通过,上线后大请求(压缩上传、批量写)偶发卡死:连接停在 ESTABLISHED,客户端等响应等到自己超时。抓包看,客户端把 100 KiB 发完了,服务端只应答了开头一段。服务端的事件处理长这样:

// 错误示范:每次事件只读一次
void on_readable(int fd) {
    char buf[4096];
    ssize_t n = read(fd, buf, sizeof buf);
    if (n > 0) {
        handle_request(buf, n); // 100 KiB 的请求只到手 4 KiB
    }
}

EPOLLIN 的承诺是"现在 read 不会阻塞",不是"数据已经到齐"。一次 read 拿走 4 KiB,剩下 96 KiB 还躺在接收缓冲里,此时事件处理函数返回——LT 模式下,下次 epoll_wait 还会提醒你;ET 模式下,“从无到有"的边沿已经被消费掉,剩下的数据不会再产生通知。对端在等响应,不会发新数据,于是这次唤醒就是最后一次,卡死的机制完整成立。

LT 与 ET 是两份不同的合同

  • LT(默认):条件持续满足就持续上报。你可以懒,读一次就返回,反正它还会喊。
  • ET(EPOLLET):状态变化时上报一次。你必须在这次里处理到"再处理就会阻塞"为止,也就是读到 EAGAIN。

ET 换来的是更少的唤醒次数,代价是把正确性责任全部转给调用方。ET 的合同就一句话:要么排干,要么永远别再等通知。

读循环:排干是调用方的责任

// 前提:fd 已经设置 O_NONBLOCK。这不是优化,是 ET 的入场条件
void on_readable(int fd) {
    char buf[16384];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof buf);
        if (n > 0) {
            feed_parser(buf, (size_t)n); // 协议层自己处理半包与粘包
            continue;                    // 继续读
        }
        if (n == 0) { close_conn(fd); return; }      // 对端有序关闭
        if (errno == EINTR) continue;                 // 被信号打断,重读
        if (errno == EAGAIN || errno == EWOULDBLOCK)  // 排干了,本次结束
            return;
        close_conn(fd); return;                      // ECONNRESET 等:按错误关
    }
}

出口的语义要分清:n > 0 正常;n == 0 是对端 FIN,是正常 EOF 不是错误;-1 加 EAGAIN 表示排干完成;-1 加其他 errno 才是真正的错误。另外,阻塞 fd 混进这个循环,整个事件循环就卡死在这一处,其他连接全部陪葬。

写事件是租来的,不是常驻的

socket 几乎总是可写。把 EPOLLOUT 常年挂在兴趣集里,epoll_wait 每次都会返回它,一个核被空转事件吃满。正确的生命周期是:write 返回 EAGAIN,把残余放进输出缓冲,注册 EPOLLOUT,可写事件到达时冲缓冲,冲完立刻注销。

// 兴趣集统一管理:读永远关心;写只在有残余时关心
struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLRDHUP |
                                  (want_write ? EPOLLOUT : 0),
                          .data.ptr = c };
epoll_ctl(epfd, EPOLL_CTL_MOD, c->fd, &ev);
// 冲完缓冲的最后一行永远是注销 EPOLLOUT:写完不摘,就是 busy loop

关闭的三张面孔:RDHUP、HUP、ERR

  • EPOLLRDHUP:对端关闭了它的写方向(半关闭)或整个连接。必须显式注册才有,而它经常被忘。
  • EPOLLHUP:两个方向都挂了。总是会上报,无法屏蔽。
  • EPOLLERR:连接上有异步错误待取。总是会上报,用 getsockopt(SOL_SOCKET, SO_ERROR) 或再 read 一次拿 errno。

半关闭值得单独说:对端 shutdown(SHUT_WR) 之后,你的 read 返回 0,但你的写方向还活着,仍可以把响应发回去。read 到 0 就直接 close 的服务器,把"可以继续发"这个状态当成错误扔掉了。反过来,对端整个 close 之后你还在写,内核送你一个 SIGPIPE,默认动作是杀进程:

signal(SIGPIPE, SIG_IGN);           // 进程级:之后 write 返回 -1 且 errno == EPIPE
send(fd, buf, len, MSG_NOSIGNAL);   // 或者每次发送时规避(Linux 扩展)

EPOLLONESHOT:多线程的交接协议

事件交给线程池后出现新的竞态:worker 还没读完,epoll_wait(LT)把同一个 fd 再次派给另一个线程,两个线程同时 read 同一个 fd,字节流被劈成两半,协议解析直接错乱。EPOLLONESHOT 把"处理权"变成显式交接:

// 注册:事件交付一次后,fd 被自动屏蔽
struct epoll_event ev = { .events = EPOLLIN | EPOLLRDHUP | EPOLLONESHOT,
                          .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

// worker 处理完这轮 I/O,负责复活——MOD 是唯一手段
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

accept 也会坑你:EMFILE

连接风暴把 fd 耗尽时,accept 返回 EMFILE。如果只记日志就返回:listen socket 上还有 pending 连接,LT 持续报 EPOLLIN,你持续 accept 失败,一个核被打满。经典解法(nginx 同款)是预留一个应急 fd,腾出名额把连接收下来再关掉:

close(spare_fd);              // 腾出一个 fd 名额
int cfd = accept(lfd, NULL, NULL);
if (cfd >= 0) close(cfd);     // 收下再关:客户端立刻得到拒绝,而不是挂死
spare_fd = open("/dev/null", O_RDONLY);

一个完整的 ET 回显服务器

把前面的规则全部焊在一起(C,Linux;on_emfile 的函数体就是上一节那四行):

/* echo_et.c —— ET 回显:读排干、写不动挂 EPOLLOUT、写完即摘 */
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>

#define OUT_CAP 65536
#define BUF_SZ  16384
struct conn {
    int fd;
    size_t out_len;    // 待写残余
    char out[OUT_CAP]; // 演示用固定缓冲,真实实现换动态缓冲
};
static int epfd, listen_fd, spare_fd; // spare_fd 为 EMFILE 预留

static void close_conn(struct conn *c) {
    epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd, NULL);
    close(c->fd);
    free(c);
}
static void mod_events(struct conn *c, int want_write) {
    struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLRDHUP |
                                      (want_write ? EPOLLOUT : 0),
                              .data.ptr = c };
    epoll_ctl(epfd, EPOLL_CTL_MOD, c->fd, &ev);
}
static void on_emfile(int lfd); // 函数体见上一节
/* 冲输出缓冲;返回 1 存活,0 已关闭 */
static int flush_out(struct conn *c) {
    size_t off = 0;
    while (off < c->out_len) {
        ssize_t n = write(c->fd, c->out + off, c->out_len - off);
        if (n > 0) { off += (size_t)n; continue; }
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            memmove(c->out, c->out + off, c->out_len - off); c->out_len -= off; // 残余前移
            mod_events(c, 1);   // 写不动:挂 EPOLLOUT 等通知
            return 1;
        }
        close_conn(c);          // EPIPE / ECONNRESET
        return 0;
    }
    c->out_len = 0;
    mod_events(c, 0);           // 写完立刻摘掉 EPOLLOUT
    return 1;
}
/* 读到 EAGAIN 或 EOF;返回 1 存活,0 已关闭 */
static int on_readable(struct conn *c) {
    char buf[BUF_SZ];
    for (;;) {
        if (OUT_CAP - c->out_len < BUF_SZ) {   // 输出缓冲快满:先冲一冲
            if (!flush_out(c)) return 0;
            if (OUT_CAP - c->out_len < BUF_SZ) return 1; // 还是满:等 EPOLLOUT
        }
        ssize_t n = read(c->fd, buf, sizeof buf);
        if (n > 0) {
            memcpy(c->out + c->out_len, buf, (size_t)n); c->out_len += (size_t)n;
            continue; // ET:必须继续读到 EAGAIN
        }
        if (n == 0) { close_conn(c); return 0; } // 对端有序关闭
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 排干
        close_conn(c); return 0;                 // ECONNRESET 等
    }
    return flush_out(c);       // 顺手把读到的写出去
}
static void do_accept(void) {
    for (;;) { // 循环 accept:ET 下必须,LT 下是好习惯
        int cfd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
        if (cfd >= 0) {
            struct conn *c = calloc(1, sizeof *c);
            c->fd = cfd;
            struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLRDHUP,
                                      .data.ptr = c };
            epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
            continue;
        }
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) return; // 收完了
        if (errno == EMFILE) { on_emfile(listen_fd); return; }
        perror("accept"); return;
    }
}
int main(void) {
    signal(SIGPIPE, SIG_IGN); // 对端关闭后再写:拿 EPIPE,不是被杀
    spare_fd = open("/dev/null", O_RDONLY);
    listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    int yes = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
    struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(9000),
                                .sin_addr.s_addr = htonl(INADDR_ANY) };
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0 ||
        listen(listen_fd, 511) < 0) { perror("bind/listen"); return 1; }
    epfd = epoll_create1(EPOLL_CLOEXEC);
    struct conn *lc = calloc(1, sizeof *lc); // listener 也包成 conn,
    lc->fd = listen_fd;                      // 统一用 data.ptr 派发
    struct epoll_event ev = { .events = EPOLLIN, .data.ptr = lc }; // LT 足够
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    for (;;) {
        struct epoll_event evs[128];
        int n = epoll_wait(epfd, evs, 128, -1);
        for (int i = 0; i < n; i++) {
            struct conn *c = evs[i].data.ptr;
            if (c->fd == listen_fd) { do_accept(); continue; }
            unsigned e = evs[i].events;
            if (e & (EPOLLERR | EPOLLHUP)) { close_conn(c); continue; }
            if (e & (EPOLLIN | EPOLLRDHUP)) {
                if (!on_readable(c)) continue; // 处理到 EOF 或 EAGAIN
            } else if (e & EPOLLOUT) {
                if (flush_out(c)) (void)on_readable(c); // 补读:读边沿可能已被消费
            }
        }
    }
}

常见坑点

阻塞 fd 混进 epoll

某个 fd 忘了设 O_NONBLOCK,read 一旦没有数据就直接阻塞,事件循环整个卡在这个 fd 上。常见来源:从磁盘文件、终端或第三方库手里接过来的 fd。

忘了注册 EPOLLRDHUP

纯写方向的连接(比如只上报不回读的通道),对端关闭后要等下一次写失败才能察觉,连接对象一直挂到超时。注册了 RDHUP,对端关闭的瞬间就能收到通知并清理。

ET 下 accept 也要循环

listener 用 ET 时,pending 了多个连接却只 accept 一个,剩下的永远叫不醒——和读不排干是同一个病,只是换了入口。

写完不摘 EPOLLOUT

可写是常态,常驻 EPOLLOUT 等于自己写了一个 busy loop。写事件只在"有数据写不动"时存在。

总结

epoll 的 API 就三个调用,正确性全部藏在 readiness 的语义里:事件是提醒不是承诺,ET 模式下提醒只有一次。读要排干,写事件按需租借,关闭按三张面孔分类,EMFILE 有专门预案,多线程用 ONESHOT 交接——这些细节共同决定了服务器是"压测能过"还是"线上稳”。

epoll 只负责喊一声"有情况";把事情做完,是你的事。