一个只在大请求上翻车的网关
压测小包全部通过,上线后大请求(压缩上传、批量写)偶发卡死:连接停在 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 只负责喊一声"有情况";把事情做完,是你的事。