/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
第一部分:全局视野——网络栈、I/O 多路复用与四层架构
1.1 从"一个连接一个线程"的噩梦说起
假设你要写一个 TCP 服务器。最直觉的写法是什么?接收一个新连接,就开一个线程去处理。代码简单,逻辑清晰,在客户端只有几个的时候跑得毫无问题。
但如果你面对的是上万甚至十万的并发连接呢?很多人会说"操作系统能开的线程数有限",其实 Linux 理论上能开很多线程,真正的问题要复杂得多:
- 栈内存:每个线程默认分配几 MB 的栈空间,一万线程就是几十 GB,直接撑爆内存。
- 调度开销:线程多了,操作系统调度器要在成百上千个线程间切换,光是调度本身就能吃掉大量 CPU。
- 上下文切换:每次切换要保存和恢复寄存器、页表,还会导致 TLB/cache 污染,之前缓存的热点数据全废了。
- 锁竞争:线程越多,共享数据竞争越激烈,锁争用会让性能雪上加霜。
- 每连接状态维护成本:每个线程还要维护自己的连接状态、缓冲区等,内存和 CPU 双重打击。
所以,C10K 问题的核心,不是"线程不好",而是 "阻塞 I/O + 每连接一线程"这个组合扩展性极差。线程大部分时间都在傻等数据到来,CPU 利用率低得可怜。
后来大家发现,问题不是"线程不够",而是"线程都在傻等 I/O"。那有没有办法让少量线程就能管理海量连接,且不用傻等?
解决它的思路不止一条:线程池、协程、异步 I/O、多进程 + SO_REUSEPORT、用户态协议栈等都能帮忙。但其中最核心的基石,是 I/O 多路复用。
那么,I/O 多路复用到底是怎么解决"傻等"问题的?
1.2 I/O 多路复用:一个"事件管家"的诞生
所谓 I/O 多路复用,本质就是:把一堆文件描述符(fd)交给内核,让内核帮我们盯着,谁就绪了就通知我们。这样应用程序就不用阻塞在某个连接上傻等,而是可以"批量"处理所有就绪的连接。
在 Linux 下,这个技术经历了 select → poll → epoll 的演进:
- select:用固定大小的 fd_set 位图,默认最多 1024 个 fd;每次调用都要把整个集合从用户态拷贝到内核态;返回后还得 O(n) 扫描哪个 fd 就绪了。
- poll:改用 pollfd 数组,去掉了 1024 的数量上限,但依然要全量拷贝和 O(n) 扫描。
- epoll:用红黑树管理你订阅的所有 fd,用就绪链表只存活跃事件;epoll_wait 返回时只给你就绪的 fd,时间复杂度 O(1)(返回后处理),特别适合"连接多、活跃少"的场景。
不过要注意,epoll 不是异步 I/O。它只告诉你"哪个 fd 可以读/写了",读/写操作还得你自己调用 read/write 去完成。所以它是同步非阻塞 I/O 的就绪通知机制,而不是 Proactor 那种"内核帮你把数据读完再通知"的真异步。
演进到今天,Linux 还有了 io_uring,走真正的异步/Proactor 路线,但那是后话了,本文主线还是 epoll。
epoll 再强大,它也只是一个"事件通知器"。它只告诉你"谁就绪了",但并不会帮你处理数据。那么,它到底在整个网络栈里处于什么位置?
1.3 epoll 在网络栈中的生态位
想象一次完整的网络数据接收过程:
所以,epoll 处于应用层与内核协议栈之间,它注册回调,socket 就绪时把 epitem 挂到 ready list,epoll_wait 返回。它把"轮询所有连接"变成了"只处理就绪连接",从而把 CPU 从无效劳动中解放出来。
现在问题来了:epoll 帮我们解决了"等事件"的问题,但拿到事件后,我们的代码该怎么组织?总不能在一个 while 循环里写一堆 if (fd == listen_fd) … else if (fd == conn_fd) … 的巨型 switch-case 吧?这时候,就需要一种设计模式来规范这个"等待→分发→处理"的流程了。
1.4 Reactor 模式:事件驱动的核心范式
这个设计模式就是 Reactor(反应堆)模式。它的名字很形象——就像核反应堆一样,事件(中子)触发后,引发一连串的处理反应。
Reactor 模式的核心思想只有一句话:事件循环 + 事件分发。它把"等待事件"和"处理事件"解耦,让代码清晰且可扩展。
它的核心组件只有四个:
- Handle(文件描述符):就是 Linux 下的 fd,代表一个事件源(socket、timerfd、signalfd 等)。
- Demultiplexer(事件分离器):就是 epoll 本身(也可以是 select/poll),负责阻塞等待事件到来,然后返回就绪的 fd 列表。
- EventHandler(事件处理器):每个 fd 绑定一个,里面存放了这个 fd 对应的读回调、写回调、错误回调。比如一个 Acceptor 专门处理新连接,TcpConnection 专门处理已建立连接的数据读写。
- EventLoop(事件循环):整个模式的"发动机"。它死循环调用 epoll_wait,拿到就绪 fd 后,找到对应的 EventHandler,调用它的回调函数。
工作流程就是:
Reactor 模式给出了"事件循环+分发"的骨架,但具体到我们的 TCP 服务器,还需要更细化的职责划分。如何把网络细节、协议解析、业务逻辑彻底分开?这就需要把 Reactor 模式落地成一个具体的架构了。
1.5 一般化服务端架构:四层模型
为了避免代码变成屎山,我们在 Reactor 的基础上进一步分层,总结出四层模型:
- Reactor 层(事件循环):就是 1.4 节说的 EventLoop + epoll。它只管"等事件+分发",不关心数据内容,只关心"哪个 fd 发生了什么事"。
- Transport 层(连接与传输):管理连接的生命周期,负责非阻塞的读写、心跳保活(last_active 刷新)。每一个连接对应一个 Connection 对象,它持有读缓冲区、写缓冲区和连接状态。
- Protocol 层(协议解析):解决 TCP 粘包/拆包问题。比如用长度前缀法,tryDecode 从字节流中解析出完整消息。这里有个极易踩的坑:解析成功后必须 erase 掉已消费的数据,否则缓冲区头部永远卡着旧消息,后续所有解析都会失败。
- Business 层(业务逻辑):终于到业务了!这里只关心"收到什么 key,返回什么 value",完全不用管底层网络细节。
这四层各司其职,但马上就遇到一个灵魂拷问:层与层之间如何通信?如果上层直接调用下层的具体函数,那岂不是又耦合死了?
1.6 回调解耦:用 std::function 串联一切
答案就是回调机制。我们约定:下层不认识上层,只认识一个"函数接口"。当事件发生时,下层通过调用上层事先注册好的回调函数,把数据"推"上去。
在 C++ 里,我们可以用 std::function + Lambda 来实现这种解耦。比如:
- Transport 层读完数据后,调用 onMessage(buffer),这个 onMessage 是 Protocol 层注册的。
- Protocol 层解析出完整消息后,调用 onRequest(msg),这个 onRequest 是 Business 层注册的。
这样,网络层完全不知道业务层在干什么,它只管收发字节;业务层也完全不知道网络层怎么实现,它只管处理消息。两者通过接口契约连接,想换协议?想换业务逻辑?随便换,互不干扰。
不过,std::function 回调虽好,也有几个坑必须提前埋下伏笔:
- 生命周期陷阱:如果回调里捕获了 this 指针,而对象已经销毁,回调执行时就会访问悬空指针,直接崩溃。解决方案是结合 shared_from_this 和 weak_ptr,在回调执行前检查对象是否还活着。
- 线程归属问题:回调在哪个线程执行?是 Reactor 线程还是业务线程?如果 Reactor 线程被业务逻辑阻塞,那整个事件循环就停转了。所以必须明确:IO 相关回调在 Reactor 线程执行,耗时业务要丢到业务线程池。
这种解耦思想,正是高并发框架(如 Muduo、Netty)的基石。但光有架构还不够,具体怎么用 epoll 实现 Reactor?select/poll/epoll 到底差在哪?尤其是 epoll 的 ET 模式、LT 模式、那些让人头秃的边界条件……
别急,从下一部分开始,我们就钻进细节,一层层剥开 epoll 的底裤。
第二部分:epoll API 实战与 LT/ET 初探
2.1 epoll 的高效秘诀:红黑树与就绪队列的协作
很多人知道 epoll 快,但说不清它到底快在哪。答案就藏在内核里的两个数据结构以及它们之间的配合方式。
红黑树:你的"订阅登记表"
当你调用 epoll_ctl(EPOLL_CTL_ADD) 把一个 fd 注册到 epoll 实例时,内核会创建一个 epitem 结构体(里面装着 fd、监听事件、指向红黑树节点和链表节点的指针等),然后把它插入到一棵红黑树中。
这棵红黑树以 fd 为 key,每个节点代表一个你正在监听的 fd。为什么用红黑树?因为你需要频繁地添加、修改、删除监听的 fd,红黑树的增删改查都是 O(logN),而且它的结构是平衡的,不会因为 fd 数量多就退化成链表。
关键点:这棵树只在你调用 epoll_ctl 时才被修改,不是每次 epoll_wait 都动它。 对比 select:每次调用都要把整个 fd 集合从用户态拷贝到内核态重建一遍,epoll 只需要在注册时维护一次,之后 epoll_wait 完全不碰这棵树。
就绪队列:只装"有事要处理"的 fd
除了红黑树,epoll 实例内部还维护着一个双向链表(就绪链表,内核代码里叫 rdllist)。这个链表里只放一件事:当前已经就绪、等待你处理的 fd。
它是怎么被填充的?这就要说到 epoll 最核心的协作机制了——
完整协作流程:从注册到通知
为什么这个设计这么巧妙?
- 不扫描全量 fd:epoll_wait 只关心就绪队列里有什么,队列里没有就阻塞等(或者返回 0),完全不需要像 select/poll 那样遍历所有 fd 去问"你就绪了没"。
- 事件触发是"推"模型:不是你主动去问,而是内核在数据到达时主动通过回调把 fd 推到就绪队列。这是从"轮询"到"事件驱动"的本质跨越。
- 红黑树和就绪队列各司其职:红黑树管"我要监听谁"(持久化的订阅关系),就绪队列管"谁现在有事"(瞬态的就绪通知)。两者互不干扰,只在数据到达的那个瞬间通过回调串联一次。
一句话总结:红黑树让你高效地管理海量 fd 的订阅关系,就绪队列让你只处理真正活跃的连接。两者配合,就是 epoll 能扛住 C10K 乃至更高并发的底层秘密。
2.2 epoll 三部曲:从零搭建事件循环
epoll 的 API 就三个系统调用,我们叫它"三部曲"。配合一个最基础的 echo server 骨架来理解:
第一步:epoll_create1 创建事件管理器
int epfd = epoll_create1(0); // 0 = 默认标志,现在推荐用这个而不是老的 epoll_create
if (epfd == -1) {
perror("epoll_create1");
exit(1);
}
内核会创建一个 epoll 实例,返回一个 fd。你可以把它想象成一个"事件收集器"的句柄。
第二步:epoll_ctl 注册 fd 和感兴趣的事件
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = listen_fd; // 关联的数据,epoll_wait 返回时会原样带回来
// 实际项目中通常放指针指向 Connection 对象
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl ADD");
exit(1);
}
EPOLL_CTL_ADD:把 fd 加入监听。
EPOLL_CTL_MOD:修改已注册 fd 的监听事件(比如从只听读变成也听写)。
EPOLL_CTL_DEL:从 epoll 中移除 fd(注意:只是不再监听,不会 close fd)。
第三步:epoll_wait 等待事件到来
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // -1 = 阻塞等待
if (nfds == -1) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 新连接到来,accept 它,然后把新 fd 也 epoll_ctl ADD 进来
} else if (events[i].events & EPOLLIN) {
// 可读事件:调用 read 读取数据
} else if (events[i].events & EPOLLOUT) {
// 可写事件:可以往 fd 写数据了
}
}
}
这就是 epoll 的全部骨架——创建、注册、等待,循环往复。看起来简单,但马上就遇到一个灵魂选择:你用 LT 还是 ET?
2.3 LT vs ET 初探:两种通知哲学
创建 epoll 实例后,默认是 LT(Level Triggered,水平触发) 模式。你也可以通过 EPOLLET 标志显式开启 ET(Edge Triggered,边缘触发)。
两者的核心区别只有一句话:
LT:只要 fd 就绪(比如缓冲区还有数据),epoll_wait 就一直通知你。
ET:只在 fd 状态发生变化时通知你一次。
用个生活化的比喻:
- LT 像有人一直举着手说"我还有话要说",直到你听完放下他才走。你这次没听完?没关系,下次 epoll_wait 他还举着手。
- ET 像有人敲了一下门就走了。你这次没开门拿完东西?剩下的他不会再敲第二次,除非他又送来新东西(状态再次变化)。
从使用角度看两种模式的差异:
|
通知次数 |
只要没处理完,每次 epoll_wait 都通知 |
只通知一次 |
|
fd 是否必须非阻塞 |
不必须(但推荐) |
必须 |
|
读写方式 |
可以一次读一点,下次再来 |
必须循环读写直到 EAGAIN |
|
编程难度 |
简单,不容易漏事件 |
稍复杂,写错就丢数据 |
|
事件触发频率 |
高(可能重复通知) |
低(只在状态变化时) |
为什么 ET 必须配非阻塞 I/O?
ET 模式下,你必须在一次事件通知中把缓冲区里的数据全部读完(循环 read 直到返回 EAGAIN)。如果 fd 是阻塞的,最后一次 read 发现缓冲区已空,会直接把线程挂起在内核里等数据——你永远退不出循环,也回不到 epoll_wait,整个事件循环被这一个 fd 独占,其他所有连接全部饿死。非阻塞 read 在没数据时立即返回 EAGAIN,给你一个明确的"没数据了"的信号,让你安全退出循环回到 epoll_wait。
LT 就没这些烦恼? 确实,LT 下你可以只 read 一部分,剩下的下次 epoll_wait 还会通知你。但代价是:如果数据一直没读完,epoll_wait 每次都会返回这个 fd,造成不必要的系统调用开销。而且 LT 下如果监听了 EPOLLOUT 又不及时取消,会直接引发 CPU 100% 忙循环——这个坑我们第三部分会详细拆。
所以实际项目中怎么选? LT 编程简单、不容易出 bug,适合大多数场景;ET 通知次数少、效率高,但需要更严谨的代码,适合追求极致性能的场景(比如 Nginx 就用 ET)。
但不管是 LT 还是 ET,都有一些"刚开始学的时候绝对会踩、而且踩了还不知道为什么"的坑——listen fd 在 ET 下怎么 accept?EPOLLOUT 什么时候该注册什么时候该取消?fd close 了还需要 DEL 吗?EPOLLONESHOT 到底解决什么问题?CPU 100% 怎么排查?
这些,就是我们第三部分要逐个击破的核心内容。
第三部分:核心深挖——epoll 薄弱点攻坚与面试考点
3.1 从"读路径"到"写路径":epoll 不只是 EPOLLIN
第二部分我们一直在聊 EPOLLIN(可读事件),这是最直觉的路径——数据来了,读就完了。但实际服务器开发中,写比读复杂得多。为什么?
因为读是"被动"的:客户端发数据过来,你才读,节奏由客户端控制。而写是"主动"的:你想回数据,但对方的接收窗口可能满了、发送缓冲区可能满了,你写不进去。这时候怎么办?是阻塞等?还是先干别的等缓冲区有空了再写?
这个"写不进去怎么办"的问题,直接引出了我们第一个薄弱点:EPOLLOUT 的正确用法。
不过在聊 EPOLLOUT 之前,还有一个更基础的问题很多人搞错——listen fd 到底该怎么处理?
3.2 【薄弱强化】listen fd 的正确处理
问题:ET 模式下,epoll_wait 通知 listen fd 可读后,应该调用几次 accept?listen fd 应该设为阻塞还是非阻塞?
回答:
ET 模式下必须循环 accept 直到返回 -1 且 errno == EAGAIN。因为内核的 accept queue(已完成连接队列)里可能积压了多个已完成三次握手的连接,ET 只在 listen fd 从"无可接受连接"变为"有可接受连接"时通知一次。如果只 accept 一次,队列里剩余连接永远不会再触发通知,客户端连接看似建立成功但服务端永远不处理。
listen fd 必须设为非阻塞。循环 accept 过程中,当队列被取空后,非阻塞 accept 立即返回 EAGAIN,让你安全退出循环回到 epoll_wait。如果是阻塞的,最后一次 accept 会挂起线程,事件循环停转,所有其他 fd 饿死。
LT 模式下可以只 accept 一次(下次 epoll_wait 还会通知),但推荐也循环到 EAGAIN,避免队列积压。
常见错误点:
- 认为"accept 一次就够了"——在并发连接场景下会漏掉大量连接。
- 知道要非阻塞,但理由说成"防止阻塞新连接到来"——实际原因是防止队列空时阻塞事件循环。
- 在 LT 模式下不设非阻塞也不循环——虽然不会卡死,但高并发时 accept queue 积压会导致连接延迟。
3.3 【薄弱强化】EPOLLOUT 写路径与 LT 忙循环陷阱
问题:什么时候应该注册 EPOLLOUT?LT 和 ET 下监听 EPOLLOUT 分别有什么注意事项?
回答:
核心原则:不要一直监听 EPOLLOUT,只在真正需要时才注册。
标准写流程:
LT 模式下,EPOLLOUT 只要发送缓冲区有空间就会持续通知。如果不取消监听,epoll_wait 每次都返回该 fd 的可写事件,但你没东西要写,回到 epoll_wait 又立刻返回——死循环,CPU 100%。
ET 模式下,EPOLLOUT 只在发送缓冲区从"满"变"有空间"时通知一次,更需要精细控制:写不进去才注册,写完立刻取消,否则下次缓冲区有空间了你收不到通知。
常见错误点:
- LT 下注册 EPOLLOUT 后不取消,导致忙循环。这是 epoll 相关 CPU 100% 的最常见原因之一。
- 认为"有数据要发就直接注册 EPOLLOUT"——正确做法是先尝试直接 write,失败(EAGAIN)才注册。
- ET 下忘记写完取消 EPOLLOUT,导致后续再也收不到可写通知。
3.4 【薄弱强化】fd 生命周期与 close 的坑
问题:调用 close(fd) 后,还需要调用 epoll_ctl(DEL) 吗?为什么?
回答:
close(fd) 导致引用计数归零时,内核会自动将该 fd 从所有关联的 epoll 实例中移除,epoll_wait 不会再返回它。所以短期看,不调用 DEL 不会出事。
但最佳实践是先 epoll_ctl(DEL) 再 close。原因是 fd 编号复用:close 之后如果新文件或新连接拿到了相同的 fd 编号,而 epoll 实例里如果因为多线程竞争等原因残留了旧的事件注册,就会导致新 fd 被旧逻辑错误处理。这种 bug 极其隐蔽,线上很难排查。
另外,如果你用 C++ RAII 封装 fd,析构函数中应该同时做 epoll_ctl(DEL) + close,避免任何遗忘。
常见错误点:
- 认为"内核会自动移除,所以不需要手动 DEL"——功能上没错,但 fd 复用会导致隐蔽的逻辑混乱。
- 先 close 再 DEL——close 后 fd 已经无效,DEL 可能操作一个已经不存在的 fd(虽然通常不会崩溃,但逻辑上是错的)。正确顺序是先 DEL 再 close。
- 多线程环境下,一个线程 close 了 fd,另一个线程还在操作该 fd 的 epoll 事件——需要加锁或用 EPOLLONESHOT 规避。
3.5 【薄弱强化】EPOLLONESHOT 与多线程安全
问题:EPOLLONESHOT 的作用是什么?重新注册时用 ADD 还是 MOD?重新注册后是否立刻返回?它和 EPOLLEXCLUSIVE 有什么区别?
回答:
EPOLLONESHOT 的作用是:fd 的事件被 epoll_wait 返回一次后,内核自动禁用对该 fd 的监听。 它解决的是"多个线程同时 epoll_wait 同一个 epoll 实例时,同一个 fd 的事件被多个线程同时拿到"的竞争问题。每个 fd 的事件只会被一个线程处理,处理完后需显式重新注册。
重新注册用 EPOLL_CTL_MOD,不是 ADD。因为 EPOLLONESHOT 只是禁用了事件监听,epitem 节点仍然在红黑树上,ADD 会报 EEXIST。
重新注册后是否立刻返回取决于缓冲区状态:如果此时 fd 对应的缓冲区已有数据/空间,会立刻返回;否则等新事件到来才返回。
与 EPOLLEXCLUSIVE 的区别:EPOLLONESHOT 解决的是"同一个 fd 不被多个线程重复处理";EPOLLEXCLUSIVE 解决的是"多个进程/线程同时 epoll_wait 同一个 fd 时只唤醒一个"(解决惊群效应)。两者解决不同层面的问题,可以组合使用。
常见错误点:
- 把 EPOLLONESHOT 理解为"只唤醒一个线程"——这是 EPOLLEXCLUSIVE 的功能,两者混淆是面试高频错误。
- 重新注册时用 ADD 导致 EEXIST 错误。
- 忘记重新注册,导致 fd 再也不被通知(因为 ONESHOT 已经禁用了监听)。
- 在单线程 Reactor 中使用 EPOLLONESHOT——单线程不存在多线程竞争,用 ONESHOT 反而增加不必要的 MOD 调用开销。
3.6 【薄弱强化】CPU 100% 与 epoll_wait 死循环排查
问题:服务器 CPU 突然打满,怀疑和 epoll 有关,怎么排查?最常见的根因是什么?
回答:
最常见根因:事件来了但你没消费,导致 epoll_wait 反复返回同一个 fd。
具体场景:
排查步骤:
- top 确认 CPU 被谁吃了。
- strace -p <pid> 跟踪系统调用,看 epoll_wait 是否在疯狂返回。
- 在 epoll_wait 返回后打印 fd 和 events,确认是否同一个 fd 反复出现。
- 检查该 fd 的处理逻辑:事件是否被完整消费?EPOLLOUT 是否及时取消?timerfd 是否 read 了?
预防原则:
- 每个 epoll_wait 返回的事件必须被完整消费(读走数据 / 写完数据 / 取消监听)。
- LT 模式下不确定是否要持续监听的事件,用完立刻取消。
- timerfd 触发后必须 read(读一个 uint64_t)。
- Protocol 层解析后必须 erase 已消费数据。
常见错误点:
- 排查时只看应用日志,不看 strace——epoll 层面的死循环在应用日志里可能完全没有输出。
- 只检查了 EPOLLIN 的消费,忘了检查 EPOLLOUT 和 timerfd。
- 认为"CPU 100% 一定是死循环代码",忽略了 epoll 事件未消费导致的 busy loop。
3.7 面试高频考点速查表
|
epoll 为什么比 select/poll 快? |
红黑树管理订阅(O(logN)),就绪链表只返回活跃 fd(O(1)),不全量扫描 |
|
epoll 能监听普通文件吗? |
不能。普通文件始终就绪,会直接撑爆就绪链表 |
|
ET 为什么必须非阻塞? |
循环 read 到最后没数据时,阻塞 read 挂起线程,事件循环停转 |
|
LT 下监听 EPOLLOUT 有什么坑? |
会忙循环导致 CPU 100%,应按需注册、写完立即取消 |
|
listen fd 在 ET 下怎么 accept? |
循环 accept 直到 EAGAIN |
|
close 后需要 epoll_ctl(DEL) 吗? |
内核自动移除,但 fd 复用可能导致隐蔽 bug,先 DEL 再 close |
|
EPOLLONESHOT 的作用? |
fd 事件被返回一次后自动禁用监听,防止多线程重复处理 |
|
EPOLLONESHOT 重新注册用 ADD 还是 MOD? |
MOD,节点还在红黑树上 |
|
EPOLLEXCLUSIVE 解决什么问题? |
多进程/线程 epoll_wait 同一 fd 时只唤醒一个,解决惊群 |
|
CPU 100% 怎么排查? |
strace 看 epoll_wait 是否反复返回同一 fd,检查事件是否被消费 |
第三部分小结:
这六个问题覆盖了 epoll 从"会用"到"用对"的全部关键知识——listen fd 处理、写路径控制、fd 生命周期、多线程安全、CPU 100% 排查。每一个都是面试高频考点,也是线上事故的常见根源。
但 epoll 的故事到这里还没完。 当你真正把 epoll 用在生产环境中,你会发现还有更多进阶话题等着你——惊群效应的终极解法、线程间通知的优雅方式、以及 epoll 的"继任者" io_uring。这些,就是我们接下来要展开的。
第四部分:进阶延展与面试八股
4.1 EPOLLEXCLUSIVE:真正解决惊群效应
第三部分我们提过 EPOLLONESHOT 和 EPOLLEXCLUSIVE 的区别,但没展开 EPOLLEXCLUSIVE 本身。现在来补上。
什么是惊群效应?
多个进程或线程同时 epoll_wait 同一个 epoll 实例(或者各自 epoll_wait 自己的 epoll 但监听同一个 listen fd),当一个新的连接到来时,内核会唤醒所有正在等待的进程/线程。但最后只有一个能 accept 成功,其他全白醒了——这就是惊群。
为什么这是问题? 唤醒一个进程/线程是有成本的(上下文切换、缓存失效)。如果 8 个 worker 进程全被唤醒,7 个白跑一趟,在高并发下这个浪费会被放大。
传统的"解决方案":accept 加锁
Nginx 早期的做法是在 accept 前加一把进程间锁,只有一个进程能拿到锁去 accept。这确实解决了惊群,但引入了锁竞争——本质上还是串行化了。
EPOLLEXCLUSIVE(Linux 4.5+)怎么做?
EPOLLEXCLUSIVE 是一个 flag,在 epoll_ctl(ADD) 时设置。它的效果是:当多个 epoll 实例(或 epoll 内部多个等待者)监听同一个 fd 时,内核只唤醒其中一个。
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLEXCLUSIVE; // 注意:只能和 ADD 一起用,不能 MOD
ev.data.ptr = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
关键细节:
- 只能和 EPOLL_CTL_ADD 一起用,不能后续 MOD 加上去。
- 它解决的是"多 epoll 实例监听同一 fd"的惊群,不是"单 epoll 实例多线程 epoll_wait"的问题(后者靠 EPOLLONESHOT 或连接分发解决)。
- 唤醒策略是"第一个注册的优先",不是严格轮询,所以可能导致负载不均(某个 worker 被唤醒的概率更高)。
那 EPOLLONESHOT + EPOLLEXCLUSIVE 能一起用吗? 可以,但场景不同:EPOLLEXCLUSIVE 管"谁被唤醒",EPOLLONESHOT 管"被唤醒后只处理一次"。两者互补。
4.2 eventfd:线程间通信的 epoll 友好方式
你有一个 IO 线程跑着 epoll_wait,现在业务线程池处理完一个请求,需要通知 IO 线程把结果写回客户端。你怎么通知?
- 方案 A:加锁 + 条件变量。但条件变量不能和 epoll 统一事件源,你得额外搞一个唤醒机制。
- 方案 B:eventfd——一个轻量级的"事件等待/通知"fd,天然可以被 epoll 监听。
// IO 线程
int efd = eventfd(0, EFD_NONBLOCK);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = efd;
epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &ev);
// 业务线程
uint64_t val = 1;
write(efd, &val, sizeof(val)); // 写 8 字节,触发 IO 线程的 epoll_wait 返回
// IO 线程的 epoll_wait 返回后
if (events[i].data.fd == efd) {
uint64_t val;
read(efd, &val, sizeof(val)); // 必须 read 消费掉
// 执行待处理的写回任务
}
eventfd 本质上是一个 8 字节的计数器,write 加值,read 读值并清零。它让线程间通知变成了一个普通的 epoll 可读事件,完美融入事件循环。
4.3 signalfd:把信号也变成 epoll 事件
传统信号处理函数(signal / sigaction)有很多限制:不能调用 malloc、不能加锁、不能写日志……因为信号处理函数可能在任意时刻打断你的代码。
signalfd 把信号变成了一个可读 fd:
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL); // 阻塞信号,让它们被 signalfd 接管
int sfd = signalfd(-1, &mask, SFD_NONBLOCK);
// 把 sfd 加入 epoll 监听 EPOLLIN
当 SIGINT 到达时,epoll_wait 返回 sfd 可读,你 read 一个 signalfd_siginfo 结构体,里面包含了信号编号、发送者 PID 等信息。这样你就可以在事件循环的上下文中安全地处理信号,不用写蹩脚的信号处理函数。
4.4 io_uring:epoll 的继任者?
你可能会问:"epoll 这么好,为什么还需要 io_uring?"
epoll 的先天不足:
io_uring 怎么破?
io_uring 是 Linux 5.1+ 引入的异步 I/O 框架,核心思想是提交队列(SQ)和完成队列(CQ)共享内存环,用户态和内核态通过无锁的 ring buffer 通信:
- 你往 SQ 里塞一个 read 请求(只是往内存里写一个结构体),不需要系统调用。
- 内核从 SQ 取请求,执行 I/O,完成后往 CQ 里塞结果。
- 你从 CQ 取结果,也不需要系统调用。
批量操作:一次系统调用可以提交几百个 I/O 请求(io_uring_enter)。
支持所有 I/O 类型:网络、磁盘、文件,统一接口。
epoll 会被淘汰吗? 短期内不会。io_uring 生态还在成熟中,很多现有项目(Nginx、Redis)深度依赖 epoll,迁移成本巨大。但如果你在做新的高性能存储或网络项目,io_uring 值得认真评估。
面试怎么问? "epoll 有什么缺陷?io_uring 解决了什么?"——答:epoll 只管网络 fd、每次 I/O 都要 syscall、有拷贝开销;io_uring 通过共享内存环批量提交 I/O、支持所有 fd 类型、减少 syscall 次数。
4.5 协程:用同步思维写异步代码
你有没有想过:如果 epoll 事件驱动写起来这么绕(回调、状态机、缓冲区管理),有没有一种方式能像写阻塞代码一样写高并发网络程序?
协程就是答案。
协程是用户态的"轻量级线程",由程序自己调度,不需要内核参与。核心魔法在于:当你的协程调用 co_read(fd) 发现数据没准备好时,它挂起自己(保存上下文),让出 CPU 给其他协程,等 epoll_wait 通知这个 fd 可读了,再恢复那个协程继续执行。
对你来说,代码看起来是这样的:
// 看起来像阻塞代码,实际上是协程
string data = co_read(fd, 1024); // 没数据就挂起,有数据才返回
process(data);
co_write(fd, response); // 写不进去也挂起
底层还是 epoll:协程框架在背后维护 epoll 事件循环,每个 co_read / co_write 都是一次 epoll 注册 + 协程挂起 + epoll 回调恢复。
主流方案:libco(腾讯)、libgo、C++20 原生协程(co_await + asio)。
面试怎么问? "协程和 epoll 什么关系?"——答:协程是上层抽象,epoll 是底层事件通知机制。协程通过 hook 系统调用,在 I/O 阻塞时自动挂起并注册 epoll 事件,就绪后恢复,让开发者用同步写法获得异步性能。
4.6 面试场景题
场景题 1:设计一个支持百万连接的聊天服务器
面试官:如果让你设计一个支持百万长连接的即时通讯服务器,你会怎么架构?
回答思路:
首先,网络模型选 epoll + 多线程 Reactor。一个 main reactor 负责 accept 新连接,通过 round-robin 分发给多个 sub reactor(每个线程一个 epoll 实例),每个 sub reactor 负责几千个连接的读写。
然后,每个连接用非阻塞 fd + LT 模式(或者 ET + 非阻塞,看团队熟悉度)。写路径用"先尝试 write,失败注册 EPOLLOUT,写完取消"的策略。
业务层用线程池处理消息编解码和投递逻辑,通过 eventfd 通知 IO 线程写回。
连接状态用 Per-Connection 对象管理,用智能指针 + enable_shared_from_this 管理生命周期,回调里捕获 weak_ptr 防悬垂。
心跳用 timerfd 统一计时,超时 close 连接。限流在 onRequest 里实时检查,不用定时器。
连环追问:
- "为什么不用单线程 epoll?" → 单线程能扛 C10K,但百万连接下协议解析和业务逻辑会占满 CPU,需要多线程分摊。
- "连接怎么分发到 sub reactor?" → accept 后通过 EPOLLEXCLUSIVE 或者手动 round-robin 分配到某个 sub reactor 的 epoll 里。
- "如果一个客户端疯狂发消息不读响应,怎么防?" → 写缓冲区设上限,超过就断开;加流量限制(per-connection 或 per-user)。
- "你怎么保证消息不丢?" → 应用层加 ACK 机制,或者 TCP 本身可靠传输 + 应用层心跳检测死连接。
场景题 2:线上 CPU 100% 排查全流程
面试官:你的 epoll 服务器上线后 CPU 突然 100%,你怎么排查?
回答思路:
第一步:top 确认是哪个进程吃的 CPU。
第二步:strace -p <pid> 看系统调用。如果看到 epoll_wait 疯狂返回,说明是事件循环层面的忙循环。
第三步:在代码里给 epoll_wait 返回后加日志,打印 fd 和 events,看是不是同一个 fd 反复出现。
第四步:检查那个 fd 的处理逻辑——LT 下是不是监听了 EPOLLOUT 没取消?timerfd 是不是没 read?可读事件是不是没实际读数据?Protocol 层是不是没 erase 已消费数据?
第五步:确认是 LT 还是 ET。LT 更容易出这类问题。修复后加监控,对 epoll_wait 的返回频率做告警。
连环追问:
- "如果不是 epoll_wait 忙循环呢?" → 可能是业务线程池里某个任务死循环,或者锁竞争导致自旋。用 perf top 看热点函数。
- "你怎么预防?" → 代码 review 重点检查事件消费逻辑;LT 下用完 EPOLLOUT 立刻取消;timerfd 必 read;Protocol 层必 erase。
4.7 综合追问速查
|
红黑树和就绪队列分别干什么? |
红黑树管订阅,就绪队列管瞬态通知,回调串联 |
|
ET 为什么必须非阻塞? |
阻塞 read 在空缓冲区挂起线程,退不出循环 |
|
close 后不 DEL 会怎样? |
内核自动移除,fd 复用导致隐蔽 bug |
|
ONESHOT vs EXCLUSIVE? |
ONESHOT 防重复处理,EXCLUSIVE 解决惊群 |
|
LT 下 EPOLLOUT 为什么危险? |
忙循环 CPU 100% |
|
epoll 能监听普通文件吗? |
不能,始终就绪 |
|
io_uring 比 epoll 好在哪? |
批量 syscall、共享内存环、支持所有 fd |


