你写一个 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 能当主角"会格外清楚。这也几乎是网络编程面试的高频考题,建议背熟:

维度selectpollepoll
底层数据结构三个位图(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: 你妈喊你吃饭):

你正在打游戏,眼看就要吃鸡进决赛圈,饭做好了你妈喊你吃饭。有两种妈妈:

  1. 喊一次,你没动,她接着喊第二次、第三次……(亲妈,这是水平触发)
  2. 喊一次,你没动,她就再也不喊了,丢下你饿着(后妈,这是边缘触发)

这个段子生动又精准,我们把它落到 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,只差两处关键改动:

  1. 给每个连接 fd 加 EPOLLET 标记,进入边缘触发;
  2. 必须把连接 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 版在大量连接下唤醒次数的差异,那这一篇才真正"焊"进了你的手艺里。下一篇,我们顺着这条性能之路,去拆解更多内核与网络的细节。