你写一个 tcp 服务器,最常见的问题是:"我怎么知道哪个连接上有数据来了?"最笨的办法是一个连接一个线程,每个线程阻塞在自己的 read 上。可一旦连接数涨到几千、几万,线程的创建、切换、内存开销就大得吓人。于是 Linux 上孕育出了一家人丁兴旺的解决方案——I/O 多路转接(multiplexing,也有人叫 I/O 多路复用)。它的核心思想是:用一个"看门人"替我们把成千上万个文件描述符一起盯着,谁有动静,它一次性告诉我,我再从容地处理那一个。
家里辈分最高的是 select,后来是 poll,最后轮到我们今天的主角:epoll。它被公认为 Linux 2.6 及以后内核里性能最好的多路 I/O 就绪通知方法,是 Nginx、Redis 底层高性能的看家本领。这一篇,我们就把它从外到内拆个底朝天:接口长什么样,内核里到底用"红黑树 + 就绪链表 + 事件回调"干了哪些事,为什么一个"看门人"能扛住几万连接,水平触发(LT)和边缘触发(ET)两种工作方式差在哪,以及 ET 为什么非得配非阻塞 IO 不可。老规矩,思路掰开揉碎,代码逐行注释,每道思考题都附详解。
在往下之前,你先有一个大概的技术栈概念,这几样是理解全文的地基:
- 文件描述符(file descriptor,简称 fd):Linux 里一个整数,它就是"打开的文件/套接字/管道"的身份证号。
read、write、accept都拿它当参数。 - 用户态与内核态:程序跑在用户态,掌控硬件的操作系统核心跑在内核态。普通程序不能直接碰硬件,所有 IO 操作都得"陷入"内核,经过系统调用,再回来。多路转接的本质就是:把"监听一堆 fd"这件事交给内核去办。
- 阻塞与非阻塞:阻塞的
read没数据会一直杵在那等;非阻塞的read没数据会立刻返回一个-1,并告诉你错误码是EAGAIN或EWOULDBLOCK(含义先记着,后面 ET 一节是重头戏)。 - 就绪:一个 fd 上有了"可读/可写"等能力,就叫它"就绪"(ready)。多路转接监听的就是"谁就绪了"。
这些如果还陌生,建议先回头把 socket 编程和 select 那一篇过一遍再来。
I/O 多路转接之 poll(选学)
在 epoll 登场之前,我们先见见它的前任 poll。它是 select 的改进版,目标是解决 select 最让人头疼的两个毛病:位图大小写死、每次要重新设置 fd 集合。如果你已经跳过 select,直接看 poll 也完全能接下 epoll 的故事。
poll 的函数接口
poll 的头文件是 <poll.h>,原型长这样:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);它的参数非常直白:
fds:一个struct pollfd数组的指针,也就是你要监听的一堆 fd 的清单;nfds:这个数组的长度(数组里有多少个元素);timeout:超时时间,单位是毫秒。负值表示永久等待,0 表示立即返回,正值表示最多等这么久。
被监听的每一个 fd,都用下面这个结构描述:
struct pollfd {
int fd; /* file descriptor:要监听的哪个 fd */
short events; /* requested events:我要监听哪些事件(位图) */
short revents; /* returned events:内核实际返回的事件,就绪信息写在这 */
};注意看这个设计——events 和 revents 是同一个结构里的两个字段。events 是你"请求"监听的事件,是你要填给内核看的;revents 是内核"回填"给你的结果。正因为输入输出分居两个字段,poll 在调用时不需要像 select 那样每次循环都重建一遍 fd 集合——这个数组你自己管理,调用完还要再用,直接原样传。这就是它的设计聪明之处。
events 和 revents 能取的常用值是一批宏,我们用按位或 | 把它们拼起来:
#define POLLIN 0x001 /* 可读(对端正常关闭 / 有数据到达都会置位) */
#define POLLRDNORM 0x040 /* 有普通数据可读(POLLIN 的细化) */
#define POLLRDBAND 0x080 /* 有优先带数据可读(带外,少见) */
#define POLLPRI 0x002 /* 有紧急数据可读,即带外数据 */
#define POLLOUT 0x004 /* 可写 */
#define POLLWRNORM 0x100 /* 有普通数据可写(POLLOUT 的细化) */
#define POLLWRBAND 0x200 /* 有优先带数据可写(少见) */
#define POLLERR 0x008 /* 出错(只出现在 revents,你不能请求它) */
#define POLLHUP 0x010 /* 挂断,对端把连接关了(只回填) */
#define POLLNVAL 0x020 /* 非法请求,fd 未打开(只回填) */POLLERR、POLLHUP、POLLNVAL 这三个是"事件",但只能出现在 revents 里,你不能把它们写进 events 去请求。就算你不监听它们,只要 fd 发生了这些状况,它们照样会被内核填进 revents——因为这是"要不要命"的坏消息,内核得主动告诉你。
poll 的返回值
poll 的返回值有三档,含义对应三种结局:
- 小于 0:出错。比如被信号打断返回
EINTR,或fds里有POLLNVAL做了些不合法的事。 - 等于 0:超时了,在
timeout毫秒内没有任何一个 fd 就绪。 - 大于 0:有就绪事件发生了。这个数值表示"有几个 fd 产生了就绪事件"(一个 fd 同时满足读和写会算作两……不对,是算作一次?它在计数上的语义是"就绪的文件描述符的个数",一个 fd 无论置了几个
revents位,都只算一个)。具体是哪个 fd 发生了什么,你得自己去遍历fds数组,逐个看revents。
这正是 poll 和 select 共有的尴尬:假设你监听了 5 万个 fd,某次只有 1 个就绪,poll 只告诉你"有 1 个就绪了",却不肯直接告诉你"是第几个"。你被迫把 5 万个 pollfd 从头到尾翻一遍,就为了找到那 1 个。这就是"返回后要轮询"的痛苦。epoll 专门治这个病。
socket 的就绪条件
poll 判"可读"和 select 判"可读"是同一条标准,这是内核统一定的"就绪"定义。以 socket 为例,一个 fd "可读就绪"只要满足以下任意一条:
- 它的接收缓冲区里有数据(一个或更多字节),此时
read不会阻塞; - 它对端发送了 FIN(正常关闭),也就是你会读到 0 字节,这算"可读";
- 它是个监听 socket,且连接队列里有排队的待处理连接,此时
accept不会阻塞(所以监听 socket 的"可读"其实是"有连接可 accept"); - 它发生了错误待处理,此时
read会读到错误而不是阻塞。
"可写就绪"则相反,是发送缓冲区有足够空间(不阻塞就能写完),或对端已关闭导致写会出错、或 socket 被关闭。这组标准对 select 和 poll 通用,后面 epoll 判断就绪也沿用同一套底层逻辑。
poll 的优点
把 poll 和 select 放一块,poll 的优势一目了然:
- 接口更清爽:select 用三个并行的位图(
readfds/writefds/exceptfds)来表达三套 fd 集合,函数调用时又用"值—结果"混合传参,每次都要重建,容易出错;poll 一个pollfd数组搞定,事件请求(events)和事件结果(revents)同一个结构两个字段,干净利落,也不再需要每次循环重建集合。 - 没有 select 那个硬上限:select 受
FD_SETSIZE(x86 上默认 1024)限制,fd 超了位图就存不下,极容易踩"越界写坏内存"的隐蔽 bug;poll 用数组长度自行描述,数量由用户自己定,没有 1024 这种写死的天花板。
poll 的缺点
当然,坑也是实打实的,而且下面这三条,正是后来 epoll 挨个治掉的对象:
- 返回后还得轮询:和 select 一模一样,poll 只告诉你"有几个就绪了",不告诉你具体是谁,你得线性扫一遍整个
pollfd数组。fd 越多,这次扫荡越贵。 - 每次都整块拷贝:每次调用 poll,都要把整个
pollfd数组从用户态拷贝到内核态。5 万条记录,一层不落全拷进去,哪怕真正活跃的只有 5 个。这个拷贝是 O(n) 的。 - 效率随连接数线性下降:一个时刻真正就绪的 fd 通常只占极小比例,但 poll 每轮都要"全量遍历 + 全量拷贝",于是随着监控数量增长,效率线性恶化。活跃连接很少、闲置连接很多时,绝大部分工作量都浪费在"检查没事发生的人"上。
poll 示例:监控标准输入
下面是一个能直接编译运行的 poll 例子,监听标准输入(fd = 0)的可读事件:
// poll_stdin.c —— 用 poll 监控标准输入是否有输入
// 编译:gcc -o poll_stdin poll_stdin.c
#include <poll.h> // poll 函数与 struct pollfd
#include <stdio.h> // printf
#include <unistd.h> // read
int main(void) {
struct pollfd poll_fd; // 只监听一个 fd,单个结构就够了
poll_fd.fd = 0; // fd 0 = 标准输入(键盘)
poll_fd.events = POLLIN; // 我们想监听"可读"事件
for (;;) {
// 监听 1 个 fd,超时 1000 毫秒
int ret = poll(&poll_fd, 1, 1000);
if (ret < 0) { // 出错
perror("poll");
continue; // 重试
}
if (ret == 0) { // 超时,没有任何就绪
printf("poll timeout\n"); // 1 秒没输入,打印一条
continue;
}
// 到这里说明 ret > 0,poll_fd.revents 必非零
if (poll_fd.revents & POLLIN) { // 想读的事真的读到就绪位了
char buf[1024] = {0};
ssize_t n = read(0, buf, sizeof(buf) - 1);
if (n > 0) {
printf("stdin:%s", buf); // 把键盘输入原样回显
}
}
}
return 0; // 实际上走不到,程序会一直循环
}运行后 1 秒内不动键盘,屏幕上会不断打印 poll timeout;一旦你敲一行回车,它就把输入回显出来。这个"没就绪就睡、就绪才干活"的感觉,就是多路转接的雏形。而接下来你会看到,epoll 把这一整套做得更猛。
I/O 多路转接之 epoll
按 Linux man 手册的说法,epoll 是"为处理大批量句柄而作了改进的 poll"。它于 Linux 2.5.44 内核被引入(man 手册原话是 "epoll(4) is a new API introduced in Linux kernel 2.5.44"),在 2.6 里被正式接纳并走向稳定。它几乎继承了此前多路转接的所有优点,并首次把"事件驱动"这个思路落到了内核里——不做全量询问,而是"谁有事,谁主动喊我"。
为了方便对比,我们把三兄妹的画风先摆一张 ASCII 图:
用户进程(一个"看门人"管理一堆连接)
======================================
+-----------------------------------------+
| 连接清单 |
| fd3 fd5 fd7 ...(成千上万) |
+-----------------------------------------+
|
| 1) 告诉内核"我看哪些"
v
select: 每次把位图拷进内核,返回后只能知道"有几个就绪,还得扫描"
poll : 每次把 pollfd 数组拷进内核,返回后还是得扫描
epoll : 注册一次;内核里建红黑树;有人就绪 -> 回调 -> 挂进就绪链表
区别的实质在于:select/poll 每一轮都是"全量提交 + 全量扫描",epoll 是"一次注册 + 回调式就绪"。我们慢慢拆。
epoll 的三个系统调用
epoll 不像 select/poll 那样一个函数走到底,它把职责拆成三个系统调用,构成一个清晰的三部曲:
1. epoll_create -> 造一个 epoll 对象(它就是一整棵红黑树 + 一条就绪链表)
2. epoll_ctl -> 往这棵红黑树里 add / mod / del 想监听的文件描述符
3. epoll_wait -> 阻塞等待就绪链表非空,把就绪事件取出来
下面逐个讲。
epoll_create:创建 epoll 实例
int epoll_create(int size);它返回一个新的 epoll 对象的句柄(本质也是一个 fd,后面同样要 close)。这个 size 参数是个很有意思的历史包袱:
- 在内核 2.6.8 之前,
size用来提示内核"你大概要监听多少 fd",内核好据此预留空间; - 从 2.6.8 之后,
size被完全忽略了,内核内部用红黑树 + 链表动态分配,不再需要预分配。你随便传个正数(约定俗成传一个大于 0 的数,比如 10 或 1024)意思一下即可,不会报错。
更现代的新代码会用一个升级版函数 epoll_create1(内核 2.6.27+):
int epoll_create1(int flags); // flags 传 0 即可;可传 EPOLL_CLOEXEC 让 exec 后自动关闭老句柄用完一定要 close(),这和普通 fd 一样,忘了关就泄漏。
epoll_ctl:往红黑树里增删改
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);四个参数依次是:epoll 句柄、要执行的动作、目标 fd、一个描述"想监听什么事件"的结构。关键在第二个参数 op,它取三个宏:
EPOLL_CTL_ADD:把fd新注册进epfd这棵红黑树;EPOLL_CTL_MOD:修改已经注册过的fd的监听事件(比如从只读改成读写);EPOLL_CTL_DEL:把fd从epfd里删掉,不再监听。
它和 select 的根子区别就在这里:select 是每次去"监听"时才告诉内核要看谁,epoll 是提前用 ctl 把"谁要看什么"一次性注册进内核。之后哪轮想停,用 DEL 摘掉;想改,用 MOD 调整。这个"提前注册"正是后面 epoll 试图省掉"每次全量拷贝"的起点。
第四个参数的结构体定义如下:
typedef union epoll_data {
void *ptr; /* 用户自己藏的指针,想带什么带什么 */
int fd; /* 最常用:直接存这个事件对应的 fd */
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 我们要监听的事件(位图,见下) */
epoll_data_t data; /* 与事件绑定的用户数据(通常就放 fd) */
};注意 data 是个联合体,一般我们只关心 data.fd(或 data.ptr)。它是"事件和人"之间的纽带:等 epoll_wait 回来,就靠 data 告诉你"到底是谁就绪了"。
events 能取的宏(都可以用 | 拼):
EPOLLIN /* 对应 fd 可读(含对端正常关闭) */
EPOLLOUT /* 对应 fd 可写 */
EPOLLPRI /* 有紧急数据可读(带外数据) */
EPOLLERR /* 对应 fd 发生错误(只回填,别请求) */
EPOLLHUP /* 对应 fd 被挂断(只回填) */
EPOLLET /* 边缘触发模式(Edge Triggered)开关,见后文 */
EPOLLONESHOT /* 只监听这一次,处理完需重新 MOD 才能再触发 */前三个和后两个(EPOLLET、EPOLLONESHOT)是你需要主动填的;EPOLLERR、EPOLLHUP 属于"无论你听不听都会回填的坏消息",跟 poll 的 POLLERR/POLLHUP 一个脾气。
epoll_wait:取就绪事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epfd:epoll 句柄;events:你在用户态分配好的一个struct epoll_event数组,内核会把"发生的事件"拷贝填到这里;maxevents:告诉内核events数组最多能给多大(最多装几个元素);timeout:超时,毫秒。0立即返回(非阻塞地"看一眼"),-1永久阻塞,正数等这么久。
返回值:成功返回"本次有就绪事件的 fd 的个数"(也就是填进 events 数组的数量),返回 0 表示超时,返回负值表示出错(出错时检查 errno,常见 EINTR 表示被信号打断,应重试)。
这个设计点出了 epoll 和 select/poll "返回后还得自己翻"的本质差异——events 数组里装的,就是内核精心挑出来的"就绪的那几个"。epoll_wait 不给你上万个元素,它只给你"有意义的这么几个",你直接按数组长度循环一遍,就是所有就绪连接。这也正是 epoll"返回 O(1) 拿就绪"的落点。
有个要紧的边界要提醒:events 必须是用户态自己分配好的内存,不能是空指针,内核只负责往里拷,绝不帮你分配。"调用成功后数组里有多少个"由返回值告诉你;没装满的位置你不该碰。如果某轮就绪数量超过了 maxevents,那么多出来的事件不会丢,它们还留在内核的就绪链表里,epoll_wait 下一轮还会把它们给你——只是这一轮先给前 maxevents 个。放心,一个就绪事件只会被取走一次。
关于 maxevents 与 epoll_create(size) 的关系,网上和部分课件会说"maxevents 不能大于创建时传入的 size"。这个说法只在 2.6.8 之前成立;如今 size 已被忽略,内核只要求 maxevents 大于 0(否则返回 EINVAL)。换句话说,这句话是历史残留,别被它绑住手脚。
epoll 的工作原理:红黑树 + 就绪链表 + 回调
面试和深挖时,只懂三个 API 是不够的——你得说得出内核是怎么支撑起这个"高性能"来的。其实就三样东西,讲完你就通了。
当你调用 epoll_create,内核会创建一个 struct eventpoll 实例,它内部挂着两个与你密切相关的数据结构:
struct eventpoll(一个 epoll 实例)
┌──────────────────────────────────────────────────────────────┐
│ │
│ rb_root rbr ──► 一颗红黑树(存所有注册进来的 fd/事件) │
│ /\ /\ /\ │
│ fd3 fd7 fd9 ... │
│ │ │ │ │
│ 以 fd 为键排序,增删查都是 O(logn) │
│ │
│ list_head rdlist ──► 一条就绪链表(存"已经就绪"的事件) │
│ o -> o -> o -> o │
│ 都是已就绪、等着被 epoll_wait 取走 │
└──────────────────────────────────────────────────────────────┘
用代码骨架表示,就是大家常背的那个结构(去掉了无关字段):
struct eventpoll {
/* 红黑树的根节点:这棵树里存着所有添加到 epoll 的、正在被监控的事件 */
struct rb_root rbr;
/* 双向链表:里面放着"将要通过 epoll_wait 返回给用户的、已满足条件的事件" */
struct list_head rdlist;
/* ... 还有锁、文件引用等,这里省略 */
};而你每 epoll_ctl 注册一个 fd,内核都会为它建一个 struct epitem 挂在树上,可以理解为"树上的一片叶子":
struct epitem {
struct rb_node rbn; /* 红黑树节点:叶子就这么连上树 */
struct list_head rdllink; /* 链表节点:就绪时就靠它挂进 rdlist */
struct epoll_filefd ffd; /* 事件句柄信息:它对应哪个 fd */
struct eventpoll *ep; /* 指向它所属的 eventpoll 实例 */
struct epoll_event event; /* 这个 fd 期待发生的事件类型 */
};看到没:epitem 里同时有一根树钩子(rbn)和一根链表钩子(rdllink)。**同样的一个 epitem,平时在红黑树里呆着;一旦它对应的 fd 就绪,就会被挪到就绪链表 rdlist 里去排队。**树和链表是同一个节点的两种挂载方式,这就是"一棵观测树 + 一条就绪队列"并存的内核真相。
那么"谁把它从树挪到链表"?这一步是全篇文章最关键的动作——回调机制(事件驱动)。
- 当你把某个 fd 注册进 epoll 时,内核不仅把它写进红黑树,还会给这个 fd 挂上一个回调函数,叫
ep_poll_callback。这个回调被挂到该 fd 所属设备(比如网卡、socket 的等待队列)上。 - 当真的有事发生(比如网卡收到数据、socket 缓冲区可读),内核会通过等待队列唤醒并调起这个回调
ep_poll_callback。 ep_poll_callback干的事只有一个核心动作:把这个epitem用list_add_tail挂到rdlist就绪链表的尾部,并顺带唤醒正在epoll_wait上睡觉的进程。
于是 epoll_wait 的工作瞬间变简单:它只需要检查 rdlist 链表空不空。不空,就把链表里的就绪事件拷到用户态的 events 数组,返回个数;空,就睡觉直到被回调唤醒。检查 rdlist 是不是空、拿就绪事件,这个操作的时间复杂度是 O(1)——不管你挂了多少 fd,就绪链表里只有"真有事的那么几个"。
把这个"回调唤醒"的链路画出来,就是一张完整的时序图:
时间轴
│ 用户调 epoll_ctl 注册 fd3(监听可读)
│ └─► 内核建 epitem,塞进红黑树,并在 fd3 的等待队列挂回调 ep_poll_callback
▼
用户调 epoll_wait 去睡觉
│
▼
客户端往 fd3 发来 2KB 数据
│
▼
网卡/协议栈处理数据 ──► socket 缓冲区可读了
│ │
│ 协议栈唤醒等待队列里的回调 │
▼ ▼
ep_poll_callback 被调用 ──────► list_add_tail(epitem, rdlist) // 挂进就绪链表
│
▼
顺便唤醒正在睡 epoll_wait 的进程 ──► epoll_wait 醒来
▼
epoll_wait 看到 rdlist 非空 ──► 把就绪的 epitem 拷进用户 events 数组
▼
返回就绪个数 n,用户按 n 处理
核心机制总结成一句话:select/poll 是"我主动问,轮询遍历整个集合累得要死";epoll 是"我不问,谁有事谁通过回调主动来报道,我只在就绪名单里捡现成的"。 这,就是它高性能的本质。
至于红黑树在这里发挥的作用,也值得单独说。红黑树(red-black tree)是一种自平衡的二叉查找树,插入、删除、查找的时间复杂度都是 O(logn)。epoll 用它来管"哪些 fd 已注册":
- 靠红黑树,内核能快速识别重复添加——如果你手滑对同一个 fd 重复
EPOLL_CTL_ADD,内核在 O(logn) 内就能查出来并在树上定位,从而返回EEXIST错误,而不是稀里糊涂添加两份; - 增删改(ADD/MOD/DEL)都是 O(logn),几万个 fd 也就十几层树高,效率稳如泰山。
epoll 为什么快:把 select/poll 的缺点挨个治掉
把上一节的机制往回拽回工程视角,epoll 的优势正好是 select/poll 三条缺点的镜像,我们列张"病历单 vs 药方"的对照:
| select/poll 的痛点 | epoll 的药方 |
|---|---|
| 每次循环都要把 fd 集合整块拷进内核(O(n) 拷贝) | 只在 epoll_ctl ADD 时拷一次入树,之后不再全量拷贝 |
| 返回后要知道哪个就绪,得线性遍历全部 fd | 事件回调把就绪者自动送进就绪链表,epoll_wait 直接给就绪数组,O(1) 拿结果 |
| 事件数量越多,轮询成本越高,效率线性下降 | 无论挂多少 fd,只处理"真有事的",繁忙度由活跃连接个数决定 |
| select 有 1024 的 fd 上限 | 红黑树动态管理,fd 数量无硬上限 |
再补充三点更细腻的工程体验:
- 接口用起来顺手:epoll 虽然拆成三个函数,但结构反而更清晰——注册(ctl)与等待(wait)分离,输入参数(注册时给的事件)和输出结果(wait 返回的就绪数组)彻底分家。select 那种"每次现设置、调用后集合又被改"的别扭被一举消除。
- 数据拷贝轻量:核心代价只在第一次 ADD 时发生一次"把 event 结构拷进内核"。之后每次
epoll_wait只回拷就绪的那几个,而不是像 select/poll 每轮都把一大坨全拷一遍。 - 没有数量上限:fd 数量不受限制,撑到系统文件描述符上限为止(那通常是个高达六位数的值)。
一句话收进脑:epoll 把"查谁就绪"从"扫描全体"改成了"被通知"。就绪比例越低、连接规模越大,epoll 的碾压优势就越明显。
聊聊那个流传的 mmap 说法
网上不少博客会写:"epoll 使用了内存映射(mmap),内核直接把就绪队列用 mmap 映射到用户态,从而避免了拷贝开销。"这句话是个流传甚广的不准确说法,需要在这里掰正。
回忆一下我们前面写的用法:struct epoll_event events[...] 这个数组,是你自己在用户态申请的内存。epoll_wait 拿到它之后,内核做的是把就绪事件从内核里的就绪链表拷贝到这个数组——用户态和内核态是两块地址空间,就算用 mmap 也要映射才行,但事实是 epoll 并没有把就绪队列"mmap 共享"给用户,epoll_wait 靠的是实实在在的拷贝。你写的那个数组,就是拷贝的目的地,内核不会"替你在用户态分配",更不会让你直接摸到内核那张链表。
所以更严谨的认知是:就绪事件从内核链表到用户 events 数组,这层拷贝是存在的。epoll 的"省拷贝"主要体现在别处——省掉的是 select/poll 那种"每轮把整个 fd 集合拷进内核"的大块拷贝,而不是"把就绪结果拷回用户态"这一次小小回传。面试被问到时,能分清"mmap 是误传、实际还是要拷一次回来了",已经是超过大多数人的深度。
select、poll、epoll 三方对比
把三兄妹放一张表里,"为什么 epoll 能当主角"会格外清楚。这也几乎是网络编程面试的高频考题,建议背熟:
| 维度 | select | poll | epoll |
|---|---|---|---|
| 底层数据结构 | 三个位图(fd_set) | pollfd 数组 | 红黑树 + 就绪链表 |
| fd 数量上限 | 受 FD_SETSIZE 限制(默认 1024) | 无硬上限(有的话也是内存界内) | 无上限(受系统 fd 上限约束) |
| 输入参数构造 | 每轮重新设置三个位图 | 每轮传同一数组,无需重建 | 提前用 ctl 注册一次,之后不动 |
| 每轮内核拷贝 | 三个位图整块拷贝 | 整个 pollfd 数组拷贝 | 只有第一次 ADD 拷一次;wait 只拷就绪者 |
| 找就绪的方式 | 返回后线性扫描位图 | 返回后线性扫描数组 | 回调挂进就绪链表,wait 直接取 |
| 拿就绪的时间复杂度 | O(n) | O(n) | O(1) |
| 备注 | 会改动你传入的 fd_set(就绪的留下,其余清掉) | 输入输出分字段,相对温和 | 唯一能真正服务上万个连接的选择 |
epoll 的两种工作方式:LT 与 ET
把接口讲完,轮到 epoll 温情的、也最容易踩坑的话题——触发方式。先讲一个江湖上口口相传的段子(source: 你妈喊你吃饭):
你正在打游戏,眼看就要吃鸡进决赛圈,饭做好了你妈喊你吃饭。有两种妈妈:
- 喊一次,你没动,她接着喊第二次、第三次……(亲妈,这是水平触发)
- 喊一次,你没动,她就再也不喊了,丢下你饿着(后妈,这是边缘触发)
这个段子生动又精准,我们把它落到 socket 上。先构造一个经典场景:
内核某个 socket 的接收缓冲区: 填了 2KB 数据
你调 epoll_wait,返回了(可读就绪)
你 read,只读了 1KB,缓冲区还剩 1KB
你又调了一次 epoll_wait —— 会怎样?
水平触发(Level Triggered,LT)
LT 是 epoll 的默认工作方式,不需要任何额外标记。它的判决依据是"当前状态还满不满足":
- 你只读了 1KB,缓冲区还剩 1KB——状态上"依然可读";
- 于是第二次调
epoll_wait,它照样立刻返回,再次通知你"这个 socket 可读"; - 只要缓冲区没被清空到"不可读",
epoll_wait每回都会"孜孜不倦"地提醒你。直到你把数据全读完,它才停嘴。
要点:LT 下你可以"不立刻处理,或只处理一部分",后面还能拿到新的就绪通知"接着干"。它支持阻塞读写,也支持非阻塞读写——这是一个大大的"省心"。
边缘触发(Edge Triggered,ET)
如果你在注册时给 events 拼上 EPOLLET 标记,就进入了 ET(Edge Triggered,边缘触发) 模式。名字里的"边缘"指的是"状态发生跳变的那个瞬间"——从"没数据"变成"有数据"的那一下,编译器只在跳变沿通知你一次:
- 缓冲区从空变成有 2KB,触发一次可读通知;
- 你只读了 1KB,还剩 1KB,缓冲区依然是非空状态,但从"变非空"那一下之后,没有新的跳变;
- 于是第二次调
epoll_wait不会再次返回——它认为"你没吃完是你自己的事,活该"。
也就是说:ET 模式下,一个 fd 的事件就绪后,你只有一次处理机会;错过了(没一次读完),它不再提醒你,直到下一次状态出现新跳变(比如客户端又发来一批新数据,缓冲区再次出现"从空/少到多"的变化)。所以 ET 强迫你"一次就绪,一次搞定"。
用 ASCII 把两者的行为差异画出来最直观:
接收缓冲区里数据量(schedule)随时间:
数据量 │
│ ┌────────── 2KB 到达
│ ┌┘
2KB│ │ 读走1KB,剩1KB
│ ┌┴─────────────┐
1KB│ ┌┘ │
│ ┌┘ │
0KB│────┘ │(一直留着 1KB 没清)────► 时间
└──────────────────────────────────────────────►
t1(就绪) t2(再 wait)
LT 行为:t1 通知可读 -> 剩 1KB,t2 又通知可读(只要还有就"一直啰嗦")
ET 行为:t1 通知可读 -> 剩 1KB,t2 不通知(只认第一次跳变,错过拉倒)
LT 与 ET 的取舍
- LT 是默认、稳妥:写起来简单,不怕"没一次读完",代码心智负担小。select/poll 其实也都是 LT 的思维。对大多数"够用就好"的服务器,LT 是完全称职的。
- ET 性能更高:因为
epoll_wait返回的次数少得多——一个数据包分多次到达,LT 可能连续多次叫醒你,ET 只在状态跳变那一刻叫一次。Nginx 默认就是用 ET 模式的 epoll。但代价是代码复杂,且强逼着你"一次就绪就把能读的都读完"。 - 一个反直觉但很本质的结论:如果在 LT 下你也能做到"每次就绪都立刻把能读的全读完,绝不让就绪被重复提交",那么 LT 和 ET 的性能实际没有差别。 ET 的"高效"本质上是"逼你用最优的写法",而不是它凭空创造了效率。换句话说:性能差距更多来自"程序员有没有把数据一次读完",而不是模式本身。
为什么 ET 非要配合非阻塞 IO
ET 配非阻塞,这"不是接口层面的硬性要求,而是工程实践的铁律"。很多同学在这栽跟头,我们把原因彻底讲透。
先说灾难现场:ET + 阻塞 read
设想服务器收到一个 10KB 的请求,按协议要先读完完整 10KB 才回应答;而客户端要等收到应答,才发下一个请求。现在:
read是阻塞的,而且一次只读 1KB(read本就不保证一次读满,还可能被信号打断);- 你从 ET 的
epoll_wait拿到"可读"通知,进去read一下只拿到 1KB,就退出去了; - 缓冲区还剩 9KB,但因为 ET,
epoll_wait不再通知你要接着读; - 于是剩下 9KB 卡在缓冲区里,干等。要等什么?等客户端再发数据、产生新的"跳变",才轮到你再去读——可客户端又恰恰在等你的 10KB 应答才肯发下一个请求。
于是我俩隔着网络"你等我、我等你",死锁了。这就是 ET + 阻塞读最经典的 bug:
服务端只读完 1KB,要凑够 10KB 才回响应
▲ │
│ ▼
(9KB 卡在缓冲区,ET 不再提醒) 客户端读不到响应,不发下一个包
▲ │
└────────── 互相等待:死锁 ◄──────────┘
解药:非阻塞 + 循环读完
要打破僵局,得让"一次就绪"能"一次把数据榨干"。办法就是:把 fd 设为非阻塞,然后在可读通知到来时,用循环不停 read,直到读到 EAGAIN(或 EWOULDBLOCK)为止。
EAGAIN/EWOULDBLOCK(两者数值相同,是同一个错误码的别名)是非阻塞读"没数据可读"时的信号;- 于是"读循环"的生命周期就很清晰:
read一直有数据就读 1 块拼 1 块;某次read返回EAGAIN,说明缓冲区已经被你榨干了,本轮真的没货了,此时退出循环最合适。
这套"读到 EAGAIN 为止"的写法,是 ET 代码最核心的骨架。下面先给把 fd 设非阻塞的工具函数,再给一个非阻塞循环读的完整实现:
// set_noblock.c —— 把 fd 设为非阻塞(ET 前置必需步骤)
// 编译:gcc -o set_noblock set_noblock.c
#include <fcntl.h> // fcntl, F_GETFL, F_SETFL, O_NONBLOCK
#include <unistd.h> // 通用 fd 接口
#include <stdio.h> // 非本函数所需,仅为让示例可独立编译
// 把 fd 设成非阻塞,成功返回 0,失败返回 -1
static int set_noblock(int fd) {
// 先读出 fd 当前的属性标志位(F_GETFL)
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) {
return -1;
}
// 在原有标志位上加上 O_NONBLOCK,再写回去(F_SETFL)
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
// 示例里没什么真实 fd 可设,这里直接结束。
// 真实服务器中把 accept 返回的连接 fd 调 set_noblock 即可。
return 0;
}再看非阻塞循环读:
// read_loop.c —— ET 模式下的非阻塞循环读,读到 EAGAIN 才停
// 编译:gcc -o read_loop read_loop.c
#include <unistd.h> // read, ssize_t
#include <errno.h> // errno, EAGAIN, EWOULDBLOCK
#include <stdio.h> // 供 perror(也可去掉)
// 返回:读到多少字节;-1 表示出错或对端关闭(由 *peer_closed 区分)
static ssize_t read_until_eagain(int fd, char *buf, size_t cap, int *peer_closed) {
size_t used = 0; // 已读到的字节数
*peer_closed = 0;
for (;;) {
// 往 buf 的空闲区域里继续读(cap - used 是剩余容量)
ssize_t r = read(fd, buf + used, cap - used);
if (r > 0) { // 读到了数据
used += (size_t)r;
if (used == cap) { // 缓冲用尽,先返回,等下次就绪再补
break;
}
} else if (r == 0) {
*peer_closed = 1; // 读到 0:对端正常关闭了
return (ssize_t)used; // 把已读的一部分也交出去
} else {
// r < 0:出错
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 缓冲区被读空,ET 本轮结束,完美退出点
}
// 其他错误(如 ECONNRESET)
return -1;
}
}
return (ssize_t)used;
}对照这里的实际写法,希望你记住 ET 的完整配方:ET 触发 → 非阻塞 read → 循环读到 EAGAIN。只做到"非阻塞"却不在就绪时循环读完,数据依然会留在缓冲区;只做到"循环"却还用阻塞 read,读到最后一次会卡住整个进程。两个都到位,ET 才能安全运行。
那么 LT 就没这些破事吗?恰恰相反:LT 没有这个问题——因为只要缓冲区还有一字节没读完,epoll_wait 就还会再通知你可读。所以你在 LT 下用阻塞 read 读不完整,甚至读一半就返回,下次 epoll_wait 照样喊你起来接着读。这就是我们反复强调"LT 更稳"的全部含义。
epoll 版服务器实战
纸上谈兵到此为止,动手写一个能跑的 epoll 服务器。我们先用最稳的 LT 模式写一版自包含、可编译运行的 TCP 回显服务器(客户端发什么,原样送回),再演示如何改造成 ET。代码用纯 C,注释齐全。
LT 版服务器
// epoll_lt_server.c —— LT 模式下的 epoll TCP 回显服务器
// 编译:gcc -o epoll_lt_server epoll_lt_server.c
// 运行:./epoll_lt_server (监听 8899 端口)
// 测试:另开终端 nc 127.0.0.1 8899 然后输入任意内容,会被原样回显
#include <stdio.h> // printf, perror
#include <stdlib.h> // NULL
#include <string.h> // memset
#include <unistd.h> // close, read, write
#include <errno.h> // errno, EINTR
#include <sys/socket.h> // socket, bind, listen, accept
#include <sys/epoll.h> // epoll 相关全部符号
#include <netinet/in.h> // sockaddr_in, htons, htonl, ntohs
#include <arpa/inet.h> // inet_ntop, INADDR_ANY
#define PORT 8899 // 服务器监听端口
#define MAX_EVENTS 1024 // 每轮最多取多少个就绪事件
#define BUF_SIZE 4096 // 一次读写缓冲大小
#define BACKLOG 64 // listen 的未完成/已完成连接队列长度
static int make_listen_fd(void); // 前置声明,见文件末尾实现
int main(void) {
// 1. 造一个监听 socket
int lfd = make_listen_fd();
if (lfd < 0) {
return 1;
}
// 2. 创建 epoll 实例
int epfd = epoll_create(1024); // size 在 2.6.8 后已忽略,传正数即可
if (epfd < 0) {
perror("epoll_create");
return 1;
}
// 3. 把监听 socket 注册进 epoll,监听"可读"(可读=有连接可 accept)
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读事件(LT 是默认)
ev.data.fd = lfd; // 记住这个事件对应的 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
// 4. 进入事件循环
struct epoll_event ready[MAX_EVENTS];
for (;;) {
// 永久阻塞等待;最多取 MAX_EVENTS 个就绪事件,填进 ready 数组
int n = epoll_wait(epfd, ready, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue; // 被信号打断,重试
perror("epoll_wait"); // 真错了,退出
break;
}
// n 就是"本轮就绪的 fd 个数",直接按 n 循环,一个不多一个不少
for (int i = 0; i < n; i++) {
int fd = ready[i].data.fd; // 看清是谁就绪
if (fd == lfd) {
// 情况 A:监听 socket 就绪 ==> 有新的 TCP 连接等待 accept
struct sockaddr_in cli;
socklen_t len = sizeof cli;
int cfd = accept(lfd, (struct sockaddr *)&cli, &len);
if (cfd < 0) { // accept 偶尔也会出错,别让服务器崩了
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN]; // 把客户端 IP 转成可打印字符串
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof ip);
printf("新客户端 %s:%d -> fd=%d\n", ip, ntohs(cli.sin_port), cfd);
// 新连接也要注册进 epoll,接着监听它的读事件
struct epoll_event nev;
nev.events = EPOLLIN;
nev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &nev);
} else {
// 情况 B:一个已连接的数据 socket 可读
char buf[BUF_SIZE];
ssize_t r = read(fd, buf, sizeof buf);
if (r <= 0) {
// 读到 0 = 对端关闭;r < 0 = 出错。两种情况都回收这个 fd
if (r < 0) perror("read");
// 先关闭 fd,再从红黑树摘掉(顺序无关紧要)
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
printf("连接 fd=%d 关闭\n", fd);
} else {
// 回显:收到多少就写回多少
write(fd, buf, (size_t)r);
}
}
}
}
close(epfd);
close(lfd);
return 0;
}
// 创建监听 socket 的封装:socket -> bind -> listen
static int make_listen_fd(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) {
perror("socket");
return -1;
}
// SO_REUSEADDR:地址复用,服务器重启后端口不至于被 TIME_WAIT 卡住
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; // IPv4
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本机所有网卡
addr.sin_port = htons(PORT); // 绑定到 8899
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind");
return -1;
}
if (listen(lfd, BACKLOG) < 0) { // 开始监听,允许最多 BACKLOG 个排队连接
perror("listen");
return -1;
}
return lfd;
}注意 LT 版里有个"顺理成章但值得点破"的地方:因为 LT 会反复通知"还有没读完的数据",而我这里是"每来一次读事件就 read 尽量多",理论上应该读到 EAGAIN 才算读尽。但对 LT + 非阻塞其实没这个强要求——即便本轮没读完,LT 下 epoll_wait 还会再叫醒我。这里为了演示简单用了阻塞式 read 也能工作,因为数据量小于 BUF_SIZE 时一次就读完了,剩下的留给下一轮通知。这正是 LT 的宽容之处。
改造成 ET 版
把上面的 LT 服务器改成 ET,只差两处关键改动:
- 给每个连接 fd 加
EPOLLET标记,进入边缘触发; - 必须把连接 fd 设成非阻塞,并且在可读时循环读到
EAGAIN,否则就会掉进前面讲的那个 9KB 死锁坑。
epoll_ctl 里加标记的方法,与 LT 版只差 ev.events 一行:
// 把上面 LT 版注册数据 socket 的部分改成这样即可进入 ET
struct epoll_event nev;
nev.events = EPOLLIN | EPOLLET; // 关键:拼上 EPOLLET,开启边缘触发
nev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &nev);而读的部分,把原来一次 read 换成 set_noblock(fd) + read_until_eagain(...) 的组合(两个函数在前面已给出实现),核心就变成:
// ET 下数据 socket 的处理:非阻塞 + 循环读到 EAGAIN
set_noblock(cfd); // 先设非阻塞(在 accept 之后做一次即可)
// ... 在 epoll_wait 循环里,对就绪的数据 socket 这样处理:
int peer_closed = 0;
char buf_arr[4096 * 4];
ssize_t nr = read_until_eagain(fd, buf_arr, sizeof buf_arr, &peer_closed);
if (peer_closed || nr < 0) {
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 对端关闭或出错,回收
} else if (nr > 0) {
write(fd, buf_arr, (size_t)nr); // 回显
}这里有一个只有 ET 才能触发、LT 会掩盖的坑,值得单独点名:监听 socket(listen socket)本身要不要上 ET? 如果给 lfd 也加 EPOLLET,那么多个客户端在同一瞬间一起来连接时,epoll_wait 只会因为"第一个新连接"通知你一次,你 accept 一次后,后面排队的连接没有产生"新的跳变通知",就全卡在队列里没人接了。所以:
- 监听 socket 用 LT 更省事——只要有连接排队,LT 就一直通知你"可以 accept",直到
accept到EAGAIN为止; - 若执意给监听 socket 上 ET,就必须同理改成"非阻塞 + 循环 accept 直到 EAGAIN",否则大并发下一漏就是一堆连接。
这个"listen_fd 能不能 ET"的细节,正是课件里特意提醒、很多人初次 ET 化时翻车的地方。
epoll 的使用场景:它不是在哪儿都快的
把 epoll 吹了半天,也得清醒地说:它的高性能是有特定适用场景的,用错地方可能适得其反。
- 适合的场景:连接数非常多、但同一时刻真正活跃(有数据来往)的连接只占很小比例。典型例子就是各互联网 App 的入口服务器——同时挂着上万个客户端连接,其中活跃的往往只有几十上百个。这时候 epoll 的"回调就绪、O(1) 拿结果"优势发挥到极致,是绝配。
- 不适合的场景:只有零星几个连接的"内部通信",比如两台服务器之间就开了一两条长连接。这时候 epoll 的那套红黑树、回调、就绪链表的机制反而显得浪费——select 未必差多少,直接用阻塞模型甚至
accept+read阻塞单连的写法都够用且更简单。
选型原则一句话:连接多而活跃少,上 epoll;连接少而固定,老老实实用简单模型。 别一听"epoll 高性能"就无脑上,要结合场景判断。
epoll 的惊群与 EPOLLONESHOT(选学)
这两个概念是网络编程面试里的"加分题",也是真实服务器里躲不开的细节。我们先从现象讲起。
惊群(thundering herd)
设想一个多线程(或多进程)服务器:为了利用多核,几个线程都在对**同一个监听 socket(listen fd)**分别调用 epoll_wait,都睡在里面等新连接。这时一个客户端发起连接:
一个 listen fd,多个线程都在 epoll_wait 等它
│ 新连接到达
▼
┌──────────┼──────────┐
▼ ▼ ▼
线程1 线程2 线程3 (全部被同时唤醒去 accept)
│ │ │
└──都抢 accept,但最终只有一个线程成功──────► 其余全部扑空
**所谓"惊群",就是当一个事件到来时,把所有在等的线程/进程一起唤醒,但只有一个人能"吃到"这次事件,其余的被白白叫醒、空转一场。**被唤醒的进程在竞争 accept(或读同一个 fd)时,只有一个能拿到,其他型号(如旧内核 default_wake_function 的行为)就是"全量唤起"——这种无谓的唤醒会放大上下文切换开销,在连接风暴时会变成一场灾难。
经典应对思路有几条:
epoll的EPOLLEXCLUSIVE标志(Linux 4.5+):给epoll_ctl的events拼上它之后,唤醒变成"排他"——一次只唤醒一个在等的线程去 accept/处理,从根上消灭惊群。这是现代内核里解决"同一 epfd 被多线程 wait"的主流手段。SO_REUSEPORT:不共享一个 listen fd,而是让每个进程各自创建一个独立的监听 socket,绑定同一端口,由内核在多个监听队列之间做负载均衡分发新连接。相当于"提前把客人分到不同窗口排队",每个 epoll 只管自己那份,天然没有惊群。- 互斥锁 / 只让一个线程 accept:牺牲并发能力换取简单,属于下策。
EPOLLONESHOT:只触发一次
再来看 EPOLLONESHOT。设想另一个困境:多个线程都在对同一个 epfd 调 epoll_wait。某个连接的读事件就绪后,回调把它挂进就绪链表,结果可能被多个线程各自从 epoll_wait 里读到,于是多个线程同时去处理同一个 socket——数据被重复处理、乱成一团。
EPOLLONESHOT 就是为治理这种"一个事件、多方处理"而生的:
- 注册连接时给
events拼上EPOLLONESHOT,表示"只触发这一次"; - 只要这个 fd 触发过一次就绪,内核就从红黑树里把它"临时停用",之后不会再触发;
- 这样事件就绪后,只会有一个线程能从
epoll_wait里拿到它,独占处理; - 该线程处理完毕后,如果要继续监听这个 socket,就必须通过
EPOLL_CTL_MOD再把该 fd 重新注册/重新开启(课件里说的"再次把 socket 加到红黑树",实际上就是再走一次 ctl),否则这个 fd 就永远沉寂下去了。
EPOLLONESHOT 与多线程模型配合时几乎是标准动作:它保证"一个就绪事件同一时刻只被一个工作线程接管,谁处理完谁负责重新武装"。
顺带一提,上面讲的红黑树在这里又帮了忙:靠红黑树的快速查找定位,EPOLL_CTL_MOD 才能高效地"找到那个被停用的 epitem 并重新激活它"——一切都是 O(logn)。
思考题(附详解)
好,掰开揉碎讲完了。下面几道题用来检验(也用来面试)你是不是真懂,每题都带详细解答。建议先自己憋三分钟答案,再对照看。
思考题 1:监听了几万个 fd,某一轮只有 3 个就绪。从不考虑实现的"宏观效率"讲,select、poll、epoll 各是 O(几)?为什么?
答案:select 和 poll 都接近 O(n)(n 为监听的 fd 总数),epoll 接近 O(1)(准确说是 O(就绪数 + 常数))。
详解:select 和 poll 的一大半工作量花在"每次调用把整个 fd 集合拷进内核 + 返回后线性扫描全集合找就绪者"上——这两步都随 n 增长。哪怕只有 3 个就绪,你也要把几万个过一遍。而 epoll 在第一次 epoll_ctl 注册时就完成了"把 fd 写进内核",之后 epoll_wait 只需要:检查就绪链表 rdlist 空不空(O(1))→ 把就绪的这几个从链表摘下并拷回用户数组(O(就绪数))。所以就绪比例越低、总 fd 越多,差距越悬殊。这也是 epoll 在"Nginx、Redis 这种海量连接服务器"上成为标配的根本原因。
思考题 2:ET 模式下,写一个"把 socket 可读事件一次榨干"的读循环,关键退出条件是什么?为什么不能靠"读到 0 退出"?
答案:关键退出条件是非阻塞 read 返回 EAGAIN/EWOULDBLOCK;不能靠"读到 0"退出,因为读到 0 表示对端关闭,属于另一种(要回收连接的)情况。
详解:ET 模式只在"缓冲区从空到非空"的跳变沿通知一次。你必须在这一次通知里把能读的全部读走。做法是把 socket 设为非阻塞,然后 for(;;) read(...):读到数据就继续读;读到 EAGAIN 说明缓冲区已经被榨干、本跳变已经彻底消费干净,此时退出循环最合理。而"读到 0"是"对端发 FIN 关连接"的信号,读到 0 之后没准还有前面残留的数据,你并不能就此断定"读完了"——所以退出的正确判据必须是 EAGAIN,而不是 0。这也是 ET 与 LT 在使用上的分水岭:LT 读到哪算哪没关系,ET 必须死磕到 EAGAIN。
思考题 3:为什么说"ET 的高性能大多来自逼你用正确写法,而不是凭空创造效率"?
答案:因为如果在 LT 下也做到"每次就绪立即把数据全读完、绝不把就绪留到下一轮",那么 LT 和 ET 每一轮处理的就绪事件数、唤醒次数实际是几乎一样的,性能也就无差别。
详解:两种模式都依赖"回调挂就绪链表 + epoll_wait 取就绪"这套机制,底子是一样的。区别只在"同一个未读完的 socket,会不会被反复通知"。ET 因为只通知一次,迫使你一次读尽;LT 若不读尽,还会再通知。倘若 LT 下的程序员也足够勤快(一次读尽),那么"因为还有数据导致的多余唤醒"就不存在了,两种模式自然打成平手。所以面试时被问"ET 为什么比 LT 快",更准确的回答是"节省的是‘未读完导致的重复通知’,而节省的前提是你能一次读完;若都能一次读完,二者并无性能差距"。
思考题 4(自测):把 listen_fd 设成 ET 后,客户端在同一瞬间涌来 5 个连接,为什么可能只 accept 到 1 个?怎么改才安全?
答案:因为连接队列"新来了连接"只产生一次跳变通知;若只 accept 一次就退出,后面 4 个连接虽然在排队,但没有新的"从空变非空"跳变去触发下一次通知,于是被晾住。安全的做法是把 listen_fd 也设为非阻塞,并循环 accept 直到返回 EAGAIN(或 ECONNABORTED,表示找不到接收方),把队列里排队的连接尽量收干净。
详解:这正是课件反复提醒的坑,可惜很多人只盯着数据 socket 上 ET。监听 socket 本质上也是一个"可读就绪"的 fd,一旦上了 ET,就同样遵守"只通知一次"的规则。要想不丢连接,就得对 listen socket 套用和读数据 socket 一样的"循环到 EAGAIN"逻辑——只不过把 read 换成 accept。一个不严谨的 ET 服务器,往往排队的连接全被这一轮吞吐掉,这正是"ET 代码复杂程度更高"的生动注脚。
从 poll 的轮询遍历,到 epoll 的红黑树 + 就绪链表 + 事件回调,再到 LT 与 ET 这两套触发哲学,我们终于看清了"高性能 IO"这三个字的内部构造。其实 epoll 的胜利,本质上是一场思维方式的胜利:从"我跑去问每一扇门有没有客人",改成了"每扇门装了门铃,有人按铃我才起身"。而 ET 又再进一步——门铃只在"第一次有人按"时响一次,你要么在那一刻把人都迎进来,要么就只能等下一波访客。这份"省事与精确的权衡",恰恰是系统级编程最迷人的地方。
如果你把前面的 LT 服务器亲手编译跑起来,再用 nc 连进去发几条消息,再试着把 EPOLLIN 改成 EPOLLIN | EPOLLET、把数据 socket 设非阻塞,亲眼看看 ET 版和 LT 版在大量连接下唤醒次数的差异,那这一篇才真正"焊"进了你的手艺里。下一篇,我们顺着这条性能之路,去拆解更多内核与网络的细节。
还没有评论 — 第一条由你来留。