epoll原理与实战:I/O多路复用、LT/ET与百万连接调优
2026/9/17 20:46:31 网站建设 项目流程

epoll 这个词,只要在 Linux 上写过网络服务,基本都绕不过去。我第一次真正被它"教育",是在做一个长连接推送网关的时候——老方案用的是 select,单机撑到八九百个连接就开始肉眼可见地发飘,CPU 一大半烧在了无谓的轮询和 fd 集合的来回拷贝上。换成 epoll 之后,同一台机器、同一套业务逻辑,连接数直接拉到五位数,CPU 反而更闲。那一次之后我才真正明白,epoll 不是"更快的 select",它是另一套完全不同的设计哲学:把"我该问谁"变成"谁有事谁来告诉我"。

这篇内容我打算把它写透一点。不管你是刚看完 APUE 想上手写第一个 epoll 服务,还是已经在生产环境里跑着几十万连接、被 ET 模式的各种怪异现象折腾过,我都尽量把"是什么""为什么这么设计""怎么写才对""写错了会怎样"四件事串起来讲。核心关键词 epoll 会贯穿始终,但我不想写成一份 API 手册——手册官方就有,我更像把自己这些年踩过的坑、翻过的内核源码、以及线上排障的经验摊开来讲。看完之后,你至少应该能做到三件事:能独立写出一个正确的 epoll 服务、能看懂 epoll 的行为为什么和你想的不一样、能在 fd 数上去之后知道该调哪些参数。

1. 先搞清楚 epoll 在 I/O 模型里的位置

1.1 从"一连接一线程"到 I/O 多路复用

要讲 epoll,得先讲清楚它要解决什么问题。最朴素的并发服务器写法是"来一个连接就 fork 一个进程"或者"来一个连接就开一个线程",代码写起来最直观,read/write 都是阻塞的,逻辑跟单连接版本没区别。但这套东西的扩展性是有硬上限的:线程/进程本身要占内存,内核调度它们也要开销,线程一多,上下文切换的成本会迅速吃掉你所有的 CPU。而且绝大多数长连接大部分时间是"安静"的——没有数据可读,可线程还傻乎乎地阻塞在那里占着一个栈。

I/O 多路复用就是为了对付这种"连接多、活跃少"的场景。它的核心思想是:用一个线程同时盯着成千上万个 fd,哪个 fd 有动静就处理哪个,没动静的完全不占用 CPU。这就像前台不是给每个客户配一个专属服务员,而是一个服务员站在大厅里,谁举手就去谁那儿。

select 是这套思路的第一代实现。它的接口长这样:你把关心的 fd 塞进 fd_set 位图,调 select,内核帮你阻塞等待,回来之后你遍历整个位图看哪些位被置上了。poll 是第二代,把位图换成了 pollfd 数组,解决了 fd 数量受 FD_SETSIZE(通常是 1024)限制的问题。但这俩有个共同的硬伤:每次调用都要把整个 fd 集合从用户态拷进内核态,内核还要线性扫描一遍。1000 个连接还行,10 万个连接就是 10 万次拷贝加 10 万次扫描,每来一批事件就重来一遍,纯属浪费。

epoll 就是在这个背景下出现的。它把"注册"和"等待"这两件事拆开了:fd 只在 epoll_ctl 里注册一次,之后内核自己维护一份清单;epoll_wait 只管去取已经就绪的那几个,不需要重新提交也没必要全量扫描。

1.2 三代实现的差异,一张表说清楚

我平时给团队做分享的时候,喜欢用一张表把三者的差别砸在屏幕上,因为这几个维度基本决定了你在什么场景下该选谁:

对比维度selectpollepoll
数据结构fd_set 位图pollfd 数组内核红黑树 + 就绪链表
fd 数量上限FD_SETSIZE,通常 1024仅受系统 fd 上限约束仅受系统 fd 上限约束
每次调用是否拷贝全部 fd否,只在 ctl 时处理单个 fd
查找就绪 fd 的代价O(n) 遍历O(n) 遍历O(就绪数量)
触发模式只有水平触发只有水平触发水平触发 + 边缘触发
跨平台几乎全平台类 Unix仅 Linux

这张表里最容易被误读的是最后那行。很多人以为 epoll 比 select 快就是因为"红黑树比数组快",其实不对。真正的差别在于工作量的量级:select/poll 的每次调用开销正比于"你监听了多少 fd",epoll_wait 的开销只正比于"这次有多少 fd 就绪"。前者是 O(n),后者是 O(ready)。在一个 10 万连接、每秒只有 200 个活跃连接的服务器上,这个差距是三个数量级。

这里还要顺手纠正一个流传很广的说法:网上经常能看到"epoll 用了 mmap 共享内存,所以省掉了拷贝"。这是错的。epoll 的内核实现里没有 mmap,就绪事件是从内核空间往用户空间正常拷贝的,只是拷贝的量只跟本次就绪的事件数有关,而不是跟注册总数有关。这类"听起来很合理"的错误结论我见过太多次,所以特别提醒一句:判断一个技术细节对不对,最好去看内核源码或者 man 手册,别信二手转述。

1.3 epoll 不适合做什么

有经验的人分享技术,通常也会说清楚"什么时候别用它"。epoll 有三个场景是明确不适用的,我在实际项目里都遇到过:

第一,监听普通文件。epoll_ctl 对磁盘文件会直接返回 EPERM。原因是普通文件的"可读"永远是就绪状态(除非到了 EOF),注册进 epoll 只会得到一个永远触发的事件,没有任何意义。磁盘 I/O 的瓶颈在于块设备的读写速度,多路复用这套机制对它根本不适用——你要解决磁盘 I/O 的并发,该用异步 I/O 或者线程池,不是 epoll。

第二,连接数很少的场景。如果服务常年只处理十几个连接,select 和 epoll 的性能差别你根本测不出来,而 select 的代码更短、跨平台更好。别为了"看起来先进"而上 epoll。

第三,需要跨平台。epoll 是 Linux 独有的,BSD/macOS 对应的是 kqueue,Windows 是 IOCP,而且 IOCP 是完全不同的"完成通知"模型,不是简单的接口替换。如果你的服务要跑在多个平台,老老实实用 libevent/libuv 这类封装库,别自己写两套。

2. 内核里 epoll 长什么样:三个关键数据结构

2.1 eventpoll:一个 epoll 实例的总台账

当你调用epoll_create1()的时候,内核会创建一个struct eventpoll,这就是你手里的那个 epoll fd 背后真正代表的东西。它的关键字段大致是这样的(基于fs/eventpoll.c,不同内核版本字段会略有增减,但核心成员一直稳定):

struct eventpoll { struct mutex mtx; // 保护本结构的互斥锁 struct wait_queue_head wq; // epoll_wait 时挂上来的等待队列 struct wait_queue_head poll_wait; struct list_head rdllist; // 就绪链表:所有有事件的 epitem struct rb_root_cached rbr; // 红黑树根:所有被注册的 epitem struct epitem *ovflist; // 溢出链表,见 2.3 节 struct user_struct *user; // 用于 max_user_watches 配额统计 struct file *file; // 对应的匿名 inode 文件 int visited; };

我习惯把它理解成一个"总台账":rbr是登记簿(谁被监听了),rdllist是待办清单(谁现在有事),wq是值班室(哪个线程在等着取活干)。你在用户态拿到的那个 int 类型的 epfd,本质上就是一个指向这个结构的文件描述符。

这里有个细节值得说:epoll_create返回的 fd 其实对应内核里的一个匿名 inode,它不是网络 fd 也不是磁盘 fd,所以你对它调 read/write 是没有意义的(会失败)。它的唯一用途就是当句柄传给 epoll_ctl 和 epoll_wait。理解这一点,你就能明白为什么 epoll fd 也要记得 close——它同样占着系统 fd 配额。

2.2 红黑树:为什么不用哈希表存被监听的 fd

被监听的 fd 集合用红黑树(rbr)来组织,每个节点是一个struct epitem。看到这里很多人会问:查找、插入、删除,哈希表不都是 O(1) 吗,红黑树只有 O(log n),为什么不用哈希?

这个问题我问过自己很久,后来把几个维度的取舍捋清楚就懂了:

  • 内存开销的可预期性。哈希表要么预先分配一大块桶数组,要么做动态扩容。一个监听 100 万 fd 的服务,如果哈希桶按 2 倍扩容,扩容瞬间会申请一大块连续内存并做 rehash,这在低延迟服务里是不可接受的抖动源。红黑树是按节点逐个分配,没有"某一次操作特别贵"的问题,延迟曲线很平。
  • 不需要哈希函数。fd 是整数,看着很适合哈希,但内核里要选一个够好的哈希函数、还要防哈希碰撞攻击,成本和复杂度都不低。红黑树靠数值比较就行,简单且稳定。
  • 需要有序遍历的场景。释放整个 epoll 实例时,内核需要遍历所有注册项做清理,树结构天然支持。
  • 删除时的定位需求。epoll_ctl 的 DEL/MOD 要按 (fd, file) 二元组精确定位节点,树的查找路径确定、无聚集风险。

所以这个选择不是"内核作者懒得写哈希",而是综合了延迟稳定性、内存分配模式和实现复杂度的结果。做技术选型的时候,"理论复杂度更优"永远要让位于"实际运行曲线更稳",这一点在很多工程场景里都成立。

顺带记一下红黑树的代价:epoll_ctl 的 ADD/DEL/MOD 单次操作是 O(log n)。所以如果你的服务有"高频注册注销短连接"的特征(比如每秒几万次的短连接接入),epoll_ctl 的开销也要纳入考虑。这也是为什么有些超高性能的短连接代理会自己实现 fd 池,尽量避免频繁 ctl。

2.3 就绪链表与回调:事件驱动真正的来源

rdllist是一条双向链表,里面挂的是"当前已经有事件待处理"的 epitem。epoll_wait 大部分时候做的第一件事就是看这条链表空不空——不空就立刻取走返回,空就睡觉等通知。这就是 epoll 高效的核心。

那链表里的节点是谁放进去的?答案是回调。当你在 epoll_ctl 里 ADD 一个 socket fd 时,内核会做一件关键的事:通过 socket 的poll方法,把一个回调函数ep_poll_callback注册到该 socket 的等待队列上。之后,当网卡收到数据、协议栈把数据放进 socket 的接收缓冲区时,socket 会唤醒自己等待队列上的所有等待者,ep_poll_callback就被调用了,它做的事很直接——把对应的 epitem 挂到rdllist,顺便唤醒正睡在eventpoll->wq上的线程。

这个机制带来的最大好处是:事件是推送过来的,不是扫描出来的。10 万个连接里只有 3 个有数据,就只会有 3 次回调触发,其余 99997 个连接完全不会被碰到。select 的模型下,内核得老老实实扫完这 10 万个位。

struct epitem的字段也值得看一眼:

struct epitem { union { struct rb_node rbn; // 挂进红黑树的节点 struct rcu_head rcu; }; struct list_head rdllink; // 挂进就绪链表的节点 struct epitem *next; // 溢出链表用 struct epoll_filefd ffd; // {fd, struct file *} int nwait; // 等待队列项数量 struct list_head pwqlist; // 挂在该 fd 等待队列上的 eppoll_entry struct eventpoll *ep; // 所属的 eventpoll struct epoll_event event; // 用户注册时传进来的 events 和 data };

注意rdllink是个list_head,内核用的是"链表节点自挂"的技巧:当 epitem 不在就绪链表中时,rdllink->next指向它自己。这样判断"是否已在链表中"只需要一句list_empty,不需要额外的标志位,也就避免了重复插入导致的链表成环。这种手法在内核里到处都是,看懂了对读源码很有帮助。

再说ovflist(溢出链表)。它解决的是一个很刁钻的并发问题:当 epoll_wait 正在把就绪事件往用户态拷贝时,如果此刻又有新事件回调进来,理论上会修改正在被遍历的rdllist,可能造成混乱。内核的做法是设置一个visited标记,拷贝期间新来的事件先挂到ovflist上,拷贝结束后再合并回rdllist。这个细节普通人写代码时感知不到,但它解释了为什么 epoll_wait 在大规模场景下能同时保持高吞吐和正确性。

2.4 eppoll_entry:连接 epitem 与 fd 等待队列的桥

除了 epitem,还有一个辅助结构struct eppoll_entry

struct eppoll_entry { struct list_head llink; // 挂到 epitem->pwqlist struct epitem *base; // 反向指回 epitem wait_queue_entry_t wait; // 真正挂到 fd 等待队列上的节点 wait_queue_head_t *whead; // 该 fd 的等待队列头 };

结构上,epitem挂在 epoll 的红黑树上,eppoll_entry挂在目标 fd 的等待队列上,两者互相引用。这样一个 epitem 就能被它监听的那个 fd "反向找到",回调触发时可以直接定位到该进哪个 epoll 的就绪链表。删除时的顺序也很讲究:必须先从 fd 的等待队列上摘掉 eppoll_entry,再把 epitem 从红黑树移除,否则会出现回调访问已释放内存的问题。这一段在内核里对应ep_remove的逻辑,有兴趣可以对着源码读一遍,比看十篇博客都管用。

3. epoll 三大接口的正确用法与参数细节

3.1 epoll_create1:从 epoll_create 到 EPOLL_CLOEXEC

最早的原型是int epoll_create(int size),那个size参数其实从 Linux 2.6 开始就被忽略了,内核只是要求它大于 0,纯粹是历史包袱。现在应该用的是:

int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); exit(EXIT_FAILURE); }

EPOLL_CLOEXEC这个标志非常关键,意思是当进程执行 exec 系列函数时,这个 fd 会被自动关闭。为什么必须加?因为服务端程序经常要 fork + exec 去跑子进程(比如执行外部命令、拉起工作进程),如果不加这个标志,epoll fd 会被子进程继承,导致一个你以为已经关闭的 epoll 实例一直活着,占着内存和 fd 配额,严重的还会引起"连接关了但资源没释放"的问题。

注意:epoll_create1需要 Linux 2.6.27 及以上。如果你的代码要兼容更老的内核(现在其实很少了),可以用epoll_create(1)然后手动fcntl(fd, F_SETFD, FD_CLOEXEC)补上。不过现代部署环境基本不需要这个兼容了。

3.2 epoll_ctl:ADD / MOD / DEL 的使用要点

控制接口的原型是:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op有三个值:EPOLL_CTL_ADD(注册新 fd)、EPOLL_CTL_MOD(修改已注册 fd 的监听事件)、EPOLL_CTL_DEL(注销)。struct epoll_event的定义是这样的:

struct epoll_event { uint32_t events; // 事件掩码,如 EPOLLIN | EPOLLET epoll_data_t data; // 用户数据,通常是 fd 或指针 }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;

这里有几个我在实际项目里反复强调的点:

第一,data是个联合体,只能用一个成员。最常见的选择是.data.fd = fd,简单直接。但如果你用 C++ 写,更推荐.data.ptr = conn,直接指向连接对象,回调里不用再去查表。我见过有人在同一个程序里一部分地方用 fd 一部分地方用 ptr,结果排查了半天才发现是数据不一致——这种 bug 特别隐蔽。

第二,ADD 一个已经注册过的 fd 会返回 EEXIST。正确的做法是:新连接用 ADD,已存在的连接改事件用 MOD。如果你不区分,代码在压力测试下一定会暴露问题。还有一种情况是 MOD 一个没注册过的 fd,会返回 ENOENT,同样要处理好。

第三,注册时传入的 events 会被完整保存,但 EPOLLERR 和 EPOLLHUP 永远会被报告,不管你注册没注册。这一点很重要,后面讲问题排查时会展开。

事件标志里最常用的几个:

标志含义使用要点
EPOLLIN可读连接接入、数据到达都靠它
EPOLLOUT可写只在发送缓冲区满时才需要关注
EPOLLERR错误无需注册,永远上报,必须处理
EPOLLHUP挂断无需注册,永远上报,必须处理
EPOLLRDHUP对端关闭写端需 2.6.17+,比 read 返回 0 更早感知
EPOLLET边缘触发见第 4 章,用法和 LT 完全不同
EPOLLONESHOT一次性触发一次后自动失效,需重新 MOD
EPOLLEXCLUSIVE独占唤醒4.5+,缓解多进程 accept 惊群

第四,一个很容易忽视的习惯:先epoll_ctl(DEL)close(fd)按理说 close 一个已注册的 fd,内核会自动把它从 epoll 里摘掉,确实如此——但有个前提:没有任何其他引用指向同一个 file 描述符。如果你的 fd 被 dup 过,或者被 fork 出来的子进程继承过,close 并不会立刻触发注销,结果就是 epoll_wait 可能返回一个已经失效的 fd 号,你去操作它就会拿到 EBADF 或者更糟——操作到了另一个被复用的 fd 上。这种 bug 的排查成本极高,所以养成"显式 DEL 再 close"的习惯,代价只是多一次系统调用。

3.3 epoll_wait:maxevents、timeout 与返回值的门道

等待接口:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

三个参数各有讲究:

events是你提供的一个数组,内核把就绪事件填进去。maxevents是数组容量,必须大于 0,否则返回 EINVAL。这个值设多大有讲究:设太小,一次取不完,剩下的留到下次,会多一轮系统调用;设太大,数组占内存,而且单次系统调用里拷贝的量也大。我的经验值是 1024 或者 4096,具体看你的活跃连接比例——高并发网关通常活跃比例不高,1024 足够;如果是个内部服务只有几十个连接但几乎全部活跃,那设 64 都行。

timeout单位是毫秒,三种取值语义不同:-1表示无限阻塞直到有事件(适合纯事件驱动的服务);0表示立即返回,不管有没有事件(配合其他 fd 做轮询时用);正数表示最多等这么多毫秒(适合需要做定时任务的场景,很多人就用它来实现心跳检查、超时踢连接)。

返回值也要处理好:大于 0 是就绪事件数;等于 0 是超时(timeout 到了但没事件);等于 -1 是出错,此时要看errnoerrno == EINTR是最常见的一种,表示被信号打断了,这种不算真错误,正确处理是重新调用而不是退出程序。很多新手写的 epoll 循环没处理 EINTR,一收到信号服务就挂了。

还有一个经典错误:events数组里的元素,只有前ret个是有效的。我见过有人写成for (i = 0; i < maxevents; i++),把上一次调用的残留数据也当成事件处理,结果就是随机地处理到已经关掉的 fd,各种诡异崩溃。正确的写法是for (i = 0; i < ret; i++)

再补一句性能相关的:epoll_wait的复杂度是 O(就绪数),不是 O(注册数),但注意这有个前提——如果就绪事件的量级本身很大(比如 10 万连接里有 8 万都活跃),那再快也快不到哪去,瓶颈会转移到你的业务处理逻辑上。所以"epoll 单机百万连接"这个说法,通常指的是 C10K/C100K 的连接保持场景,不是 100 万个连接同时高压收发数据。

3.4 一份可以直接跑的 LT 版 echo 服务

光讲参数容易飘,直接上代码。下面是一个水平触发(LT)模式的回显服务,我刻意写得紧凑但完整,你复制到 Linux 上编译就能跑:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <fcntl.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/epoll.h> #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void handle_read(int epfd, int fd) { char buf[BUF_SIZE]; while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 简单回显:写不完整也没关系,LT 模式会再通知可写 ssize_t off = 0; while (off < n) { ssize_t w = write(fd, buf + off, n - off); if (w < 0) { if (errno == EINTR) continue; break; } off += w; } } else if (n == 0) { printf("peer closed, fd=%d\n", fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; } else { if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) return; // 读空了 perror("read"); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; } } } int main(void) { int lfd = socket(AF_INET, SOCK_STREAM, 0); int on = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8888); if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } listen(lfd, 511); set_nonblock(lfd); int epfd = epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; ev.data.fd = lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); printf("listening on 8888 ...\n"); while (1) { 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 e = events[i].events; // 错误和挂断必须优先处理,否则容易死循环 if (e & (EPOLLERR | EPOLLHUP)) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } if (fd == lfd) { // 监听 fd 可读:有新连接,LT 模式下 accept 一次也行, // 但为稳妥起见循环 accept 到 EAGAIN while (1) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int cfd = accept(lfd, (struct sockaddr *)&cli, &len); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; perror("accept"); break; } set_nonblock(cfd); ev.events = EPOLLIN; ev.data.fd = cfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev) < 0) { perror("epoll_ctl add"); close(cfd); } } } else { handle_read(epfd, fd); } } } close(lfd); close(epfd); return 0; }

编译命令是gcc epoll_lt.c -o epoll_lt -Wall。这个版本虽然是 LT 模式,但我在handle_read里也写了循环读,因为配合非阻塞 fd 一起用是更稳的写法——读空就靠 EAGAIN 退出循环,不会因为边缘触发的语义问题踩坑。

有几个细节我要专门点一下,都是代码里容易写错的地方:

accept 一定要循环。即使是 LT 模式,如果同时来了 3 个新连接,你只 accept 一次,剩下的两个下次 epoll_wait 还会通知你(LT 的特性保证了这点),所以不会丢连接。但多一轮系统调用往返,性能损失是实打实的。ET 模式下不循环 accept 就会真的丢连接,因为 ET 不会再通知你。所以统一按循环写,省心。

写操作要处理部分写。write返回的字节数可能小于你要写的字节数,尤其是在缓冲区快满的时候。上面代码里用 while 循环推进偏移量处理了这一点。如果你的发送数据量可能很大(比如推送大文件),正确做法是维护一个发送缓冲区,写不完的部分挂上 EPOLLOUT,等可写事件再继续。

EPOLLERR/EPOLLHUP 要最先判断。如果你只判断 EPOLLIN,忽略了一个已经出现 EPOLLHUP 的连接,在 LT 模式下 epoll_wait 会像疯了一样不停地返回这个事件,CPU 直接被打满。我在线上见过两次这种事故,一次是别人写的代码,一次是我自己年轻时写的。

4. LT 与 ET:一次把触发模式讲透

4.1 水平触发与边缘触发在内核里到底差在哪

这两个名字来自数字电路:水平触发(Level Triggered,LT)看的是"电平状态",只要高电平还在,就一直报;边缘触发(Edge Triggered,ET)看的是"跳变瞬间",只在状态从低变高的那一下报一次。

放到 epoll 里,具体表现为:

  • LT(默认):只要 socket 接收缓冲区里还有数据没被读完,epoll_wait 每次都会把 EPOLLIN 事件报给你。你读了一半,下次还报,直到你读完为止。
  • ET:只有在"不可读"变成"可读"的那一次跳变时才报。报了之后如果你没读完,剩下的数据就静静躺在缓冲区里,epoll_wait 不会再通知你——直到有新数据到达,状态再次发生跳变。

内核实现上的差别也很清楚:epoll_wait在把事件拷贝给用户态之后,对于 LT 模式,会重新调用一次该 fd 的 poll 方法检查状态,如果还是就绪的,就把它重新挂回rdllist;对于 ET 模式,则不会做这个重挂动作,只有下一次真正的状态跳变触发ep_poll_callback才会重新入队。

理解了这个机制,很多"玄学现象"就有解释了。比如为什么 ET 模式下你的服务偶尔会"卡住"——数据明明来了却没人处理?答案基本就是这个连接某次没读完。

4.2 ET 必须配合非阻塞与循环读的真实原因

ET 模式的黄金法则是两条:fd 必须设为非阻塞读写必须循环到 EAGAIN。这两条不是"最佳实践",是"不这么做就是错的"。

先说循环读。ET 只在状态跳变时报一次,所以你在收到通知后必须把缓冲区里的数据全部读干净,一直读到read返回 -1 且errno == EAGAIN,这代表"现在确实没数据了"。如果你只读了一次就停下,假设缓冲区里有 100KB 数据,你只读了 4KB,那么剩下的 96KB 就永远躺在那里了——直到对端又发新数据触发下一次跳变。如果对端发完这 100KB 就等着你回应(很常见的请求-响应模式),那就是死锁:你等对端发数据,对端等你回响应。这个 bug 的可怕之处在于,它在低负载、小数据量的测试环境下完全不会出现,一上生产、数据稍微大一点就炸。

再说非阻塞。原因在于循环读的退出条件。如果 fd 是阻塞的,你在循环里读,读到缓冲区空了的时候read阻塞等待,整个线程就卡在这个连接上了——本来你是想用它来服务上千个连接的,结果被一个连接拖死。而有了非阻塞,缓冲区空了read立刻返回 -1 + EAGAIN,循环就可以干净地退出。这两个设计是配套的,拆开任何一个都不成立。

同样的道理适用于 accept。ET 模式下的监听 fd,收到 EPOLLIN 你必须循环 accept 到 EAGAIN,否则并发来的第 2 个、第 3 个连接就丢了。我见过一个很隐蔽的版本:代码里用if (cfd = accept(...))只接一个,压测时单线程发请求一切正常,一上多线程并发就随机丢连接,查了两天才定位到。

4.3 写事件用 ET 时容易踩的坑

ET 模式下的 EPOLLOUT 有个反直觉的地方:可写是常态,不可写才是异常。socket 刚建立的时候发送缓冲区基本是空的,也就是"一直可写"。如果你在注册连接的时候顺手把 EPOLLOUT 也加上,在 ET 模式下会立刻触发一次可写事件——然后你写完了,缓冲区又空了,但 ET 不会再报(因为状态没有跳变,它一直就是可写的)。看起来没事,但如果数据大、写了一半,剩下的部分你等 EPOLLOUT 通知,等来的可能是永远不来。

正确的写法是:默认只注册 EPOLLIN,只有在 write 返回 EAGAIN(发送缓冲区满)时,才用 epoll_ctl MOD 加上 EPOLLOUT;等可写事件来了、数据写完了,再 MOD 把 EPOLLOUT 去掉。

// 需要写但缓冲区满时 if (errno == EAGAIN || errno == EWOULDBLOCK) { struct epoll_event ev; ev.events = EPOLLIN | EPOLLOUT | EPOLLET; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); // 把剩余数据挂到该连接的发送队列,等 EPOLLOUT } // 可写事件来了,继续发 if (events[i].events & EPOLLOUT) { int done = flush_send_queue(conn); // 返回是否全部发完 if (done) { struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 去掉 EPOLLOUT ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); } }

这个"动态开关 EPOLLOUT"的习惯,是 epoll 编程里提升性能最立竿见影的一招。原因是:如果每个连接都常驻注册 EPOLLOUT,在多线程或单线程的 epoll_wait 里,所有连接都会持续报可写事件,你会陷入"什么都没干但 epoll_wait 一直返回"的空转,CPU 白烧。我做过对比测试,一个 1000 连接的推送服务,常驻 EPOLLOUT 时空转占了将近 70% 的 CPU 时间。

另外提一句 EPOLLRDHUP。它在对端关闭写端(发 FIN)时触发,比 read 返回 0 更早让你知道"对端不会再发数据了"。对于需要做半关闭(half-close)处理的协议非常有用,比如 HTTP 里客户端发完请求就 shutdown 写端,服务端处理完再关连接。如果你只是等 read 返回 0,也没问题,只是感知晚一点。

5. 实战疑难排查与调优清单

5.1 常见问题速查表

这一节是我这些年处理 epoll 相关问题的经验汇总,按现象整理成表,方便你排查时直接对号入座:

现象大概率原因排查与修复
CPU 100%,epoll_wait 一直立即返回就绪事件没被消费(LT 下没读完)或只判断 EPOLLIN 忽略了 EPOLLERR/EPOLLHUP检查读循环是否有 EAGAIN 退出;在所有事件分支里优先处理 ERR/HUP
ET 模式下数据偶尔丢失、请求卡住没有循环读到 EAGAIN,或读缓冲区小于一次到达的数据量改成 while 读至 EAGAIN;读缓冲区至少 4KB,建议 16KB+
ET 模式下随机丢新连接accept 没有循环到 EAGAIN循环 accept
epoll_wait 返回的 fd 操作时报 EBADFfd 被 close 但没 epoll_ctl DEL,或者 fd 被复用养成先 DEL 再 close 的习惯;日志里记录 fd 生命周期
epoll_ctl 返回 EEXIST对已注册的 fd 调了 ADD新连接 ADD,老连接 MOD,用状态位区分
epoll_ctl 返回 EPERM试图监听普通磁盘文件磁盘 I/O 不要走 epoll
程序一收到信号就退出epoll_wait 没处理 EINTRif (ret < 0 && errno == EINTR) continue;
处理了 maxevents 个事件而非 ret 个遍历循环写成了 i < maxevents改成i < ret
fork 出的子进程莫名持有连接epoll fd 或连接 fd 没设 CLOEXEC所有 fd 都加 FD_CLOEXEC / EPOLL_CLOEXEC
大量 TIME_WAIT 影响新连接主动关闭方未开启地址复用设 SO_REUSEADDR;评估 tcp_tw_reuse

这张表里我个人觉得最值得反复念叨的是前两条。epoll 的绝大多数"疑难杂症",追根到底都是没把就绪状态处理干净。你要在心里建立一个模型:就绪是一种"内核交给你的责任",你有义务把它清掉(读空或者写空),否则 LT 下它会一直烦你,ET 下它会让你丢数据。

5.2 惊群、EPOLLONESHOT 与多线程收编

当你的服务要利用多核时,很自然会想到"开多个线程,每个线程一个 epoll 实例"。但这里有几个坑。

第一个坑是 accept 惊群。如果你让多个线程/进程监听同一个 listen fd,新连接到来时所有等待者都会被唤醒,但只有一个能 accept 成功,其余白白被唤醒一次,这就是惊群。Linux 4.5 引入的EPOLLEXCLUSIVE标志可以缓解:给 listen fd 注册事件时加上它,内核只会唤醒一个等待者。更彻底的方案是SO_REUSEPORT——每个线程创建自己独立的 listen socket 绑到同一端口,内核在协议栈层面做负载均衡,完全没有惊群,而且线程之间零共享,扩展性最好。我自己现在写多线程服务器基本都用 SO_REUSEPORT,代码更简单,性能也更好。

第二个坑是一个连接被多个线程同时处理。如果用"每个线程一个 epoll,共享连接池"的模型,一个连接上的事件可能同时被两个线程取到,然后两个线程同时 read 同一个 socket,数据就乱了。EPOLLONESHOT就是为这个场景设计的:注册时加上它,事件触发一次后该 fd 的注册就自动失效,处理线程处理完后需要显式用 MOD 重新注册,才能接受下一次事件。这样就保证了一个连接在任一时刻只被一个线程持有。

// 注册时 ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.ptr = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 处理完毕,交还 ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.ptr = conn; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

提示:用了 EPOLLONESHOT 之后,每一次处理完都必须重新 MOD,包括读出错、需要关闭连接的路径也要考虑清楚,否则连接会"沉默"在那里再也不响应。我见过因为漏了一次 MOD 导致连接彻底僵死的线上问题,排查时完全看不出异常日志。

不过说实话,多线程模型的复杂度远高于单线程 epoll + 工作线程池。如果业务逻辑本身不重(比如只是做协议解析和转发),单线程 epoll 配合 SO_REUSEPORT 开几个进程,往往比精心设计的多线程 epoll 更稳、更好维护。不要把"多线程"当成性能的同义词,架构复杂度本身也是成本。

5.3 突破百万连接的参数与习惯

最后聊聊大家最感兴趣的"单机百万连接"。这件事在技术上早就可行了,难点不在 epoll 本身,而在一堆配套的系统参数和编码习惯。我做过一个 C100K 级别的压测项目,这里把关键清单列一下。

系统层面要动的参数:

参数位置作用参考值
文件描述符上限ulimit -n/limits.conf单进程能打开的 fd 数1048576
系统级 fd 上限/proc/sys/fs/file-max全系统 fd 总量按内存估算
epoll 监听配额/proc/sys/fs/epoll/max_user_watches单用户可注册的 fd 总数按需调大
本地端口范围net.ipv4.ip_local_port_range主动建连时的源端口范围1024 65535
somaxconnnet.core.somaxconnlisten backlog 上限65535
tcp_max_syn_backlognet.ipv4.tcp_max_syn_backlog半连接队列长度65535

这里特别说一下max_user_watches。它统计的是"所有 epoll 实例注册的 fd 总数",默认值跟内存相关,在内存小的机器上可能只有几万。如果你是长连接服务,这个值不调大,注册到一半就会拿到 ENOSPC,表现为"连接能建立但加不进 epoll",非常容易被误判成业务 bug。

编码层面的习惯:每个连接的内存占用要压到最低。100 万连接,如果你给每个连接分配 4KB 的读写缓冲区,光缓冲区就是 8GB。我的做法是:接收缓冲区用一个全局的共享缓冲(比如 16KB),事件到达时读进共享缓冲、立刻处理完;只有需要暂存数据的连接才分配独立缓冲,而且按需增长、闲置时回收。这一条比任何 epoll 参数调优都更能决定你的连接上限。

另外一个反直觉的经验:连接数上去之后,瓶颈经常不是 epoll,而是你处理事件的方式。比如每个事件都写一条日志,100 万连接的日志 IO 就能把你压垮;比如每个事件都去查一次全局哈希表,锁竞争就会成为热点。我在那次压测里最大的性能提升,来自把日志从"每事件同步写"改成"批量异步写",单这一项就提升了 30% 多的吞吐。

至于定时器,很多人用epoll_wait(timeout)加一个全局定时器链表来管理心跳和超时。这个方案的坑在于精度:timeout 设成 1000ms,那所有超时检查的最小粒度就是 1 秒,而且事件密集时 epoll_wait 频繁返回,定时器检查反而不准。更稳的做法是用时间轮(time wheel)或者小顶堆,把超时管理独立出来,epoll_wait的 timeout 只用它该用的值——也就是"距离下一个超时还有多久"。

我个人在这些年折腾 epoll 的过程中,最大的体会是:它本身的 API 就这么三个函数,半天就能学会;真正的门槛在于对"事件"这件事的心智模型——你要时刻清楚当前内核认为的"就绪状态"是什么,以及你的代码有没有把这个状态消费掉。把这个模型建立起来之后,ET 和 LT 的选择就不再是玄学,而是根据业务特征做的工程决策:需要极致性能和可控读写行为的场景用 ET,追求代码简单和容错性的场景用 LT,两者没有绝对的高下之分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询