前几天一个朋友来问我说他的网关连接数一上万就崩为什么不能用多线程一个一个连接地怼。这个问题背后刚好就是 Linux 的 IO 多路复用和 reactor 反应堆。很多人背过 epoll 的红黑树、就绪链表这些八股词但真让自己基于 epoll 写一个反应堆还是会卡在“回调往哪挂”这种地方。这篇我从阻塞模型是怎么死的说起把反应堆的四个角色、epoll 三件套、单线程到主从模型的演进捋一遍最后给你一句把反应堆锁死的大白话。1. 阻塞模型到底卡在哪里1.1 一个线程扛一个连接线程再多也不够为什么早期没人讨论 reactor因为那时候连接数小。一个服务几十个客户端开几十个线程每个线程阻塞在 recv 上等数据开发简单直接。可一旦连接数上千、上万这套模型的代价就暴露得很彻底。第一是线程资源撑不住。每个线程默认栈就有 8MB 虚拟内存几千个线程光是地址空间就非常夸张线程切换时内核要保存恢复寄存器、栈指针、页表缓存上下文切换频率高到 CPU 大量时间花在“换人”上而不是“干活”上。第二是阻塞等待纯属浪费。大多数连接建立后并不总在收发数据线程阻塞在 recv 上就是干等等一秒就浪费一秒的调度机会。第三是最难受的线程一多锁和共享状态的管理难度直线上升你不可能在多线程里毫无负担地操作共享队列。这就是 C10K 问题的来源。1999 年 Dan Kegel 那篇文章的核心诉求很简单一台服务器不该因为连接数到了一万就跑不动。要解决它不能继续堆线程必须换一种“等待”的方式。1.2 内核替你盯着这些 fdselect、poll、epoll 的差别换个思路看问题不管一万个连接还是一百万个连接服务端真正想知道的核心问题只有一个——“哪些 fd 现在可以读/写了”。把这件事交给内核去做线程只剩一件事等着内核告诉自己答案。这就是 IO 多路复用。select 最老把一堆 fd 塞给内核内核遍历检查谁就绪就标记谁返回后用户进程还要再遍历一遍 fd_set 才知道到底谁有事件。fd 数量还受 FD_SETSIZE 限制通常是 1024。poll 把 fd 数组换成链表突破了数量限制但遍历检查的本质没变而且每次调用都要把这堆 fd 从用户态拷贝到内核态。epoll 在思路上完全不同。epoll_create 在内核里建一张事件表epoll_ctl 把 fd 挂进去时是一次性登记之后的每次 epoll_wait 不需要再重新传整个 fd 集合。内核在底层用红黑树管理这些 fd每个 fd 有事件时就挂到一条就绪链表上epoll_wait 返回时直接把就绪链表里的 fd 给你。用大白话说select/poll 是每次都把所有候选人从头点名一遍epoll 是内核帮你建了花名册谁有事谁举手你只需要处理举手的那些。这里顺便说一句epoll 是 Linux 内核提供的通用抽象在虚拟化环境和容器里行为一致所以你的反应堆设计思路一趟吃透到处都能用。这个底层差别就是 epoll 在高并发下扛得住的根本原因也是各种 epoll 八股问题的标准答案。2. 拆开反应堆四个角色加一条循环2.1 反应堆的四个角色分别干什么reactor 翻译成“反应堆”有点物理味但它的四个角色非常清晰。事件源就是那些被监控的 fd监听连接用的 listen fd、每一条连接对应 conn fd、定时器 fd、信号 fd 都算同步事件多路分解器是 epoll_wait你把 fd 和关注的事件类型交进去它阻塞等待内核就绪后返回事件集合事件分发器拿到就绪事件后根据 data 里记录的 fd 或指针找到对应的处理器逐个调用事件处理器就是回调函数具体执行读、写和业务处理。整体循环长这样注册 → 阻塞等待 → 内核通知就绪 → 分发 → 回调处理 → 回到注册/等待。注意回调处理完常常又会注册新的事件比如读完数据后注册 EPOLLOUT 准备写回所以这套机制是自驱动、自己续上命的。2.2 为什么叫“反应堆”不叫“回调工厂”名字其实挺形象。事件到达会触发回调回调又会注册新的事件像连锁反应一样一环扣一环epoll 就是那个“中子源”不断把就绪事件轰进事件循环里每个回调都憋着劲等自己的事件。所以叫反应堆强调的是“事件驱动的连锁反应过程”而不是简单的回调列表。有个容易混淆的点是 proactor。reactor 是“我监听事件事件到了我自己去读”proactor 是“你帮我把数据读好读完了再通知我”这是由操作系统的异步 IO 能力支撑的比如 Windows 的 IOCPLinux 的 io_uring 在某些用法下也更贴近 proactor。面试里被问到两者区别抓住这一句就够了。2.3 一句话总结反应堆附拆解标题里要的那句话我直接放这里reactor 反应堆一句话总结一个线程阻塞在 epoll_wait 上内核把就绪的 fd 事件送上来框架按注册表找到对应的回调并调用它读、算、写被拆成短片段轮流执行于是单线程也能串行地扛住海量并发连接。拆开看就是三个词多路复用epoll 等系统调用、回调事件处理器、循环分发器不断转。你再去看 Redis、Nginx、Netty 的代码会发现跑圈的主循环骨架惊人地一致差别只在业务回调里干了什么。3. 用 epoll 搭一个最小可用的反应堆3.1 epoll 三件套的使用套路epoll 的用法概括起来就是三个函数。epoll_create(int size)创建 epoll 实例。size 参数在 Linux 2.6.8 之后实际上被内核忽略了但必须传一个正数这是历史包袱。返回的 epfd 就是内核里那张事件表。epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)负责增删改。op 是 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL 三选一。ev 里有两个关键内容events 掩码和 data 联合体。data.fd 或 data.ptr 是你在回调里找回上下文的钥匙生产环境通常挂一个结构体指针里面带 fd、缓冲区、状态。epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)阻塞等待最多返回 maxevents 个就绪事件拷贝到用户传进来的数组。timeout 传 -1 表示一直等传 0 表示非阻塞轮询。常用掩码里EPOLLIN 是读就绪EPOLLOUT 是写就绪EPOLLRDHUP 表示对端关闭EPOLLET 是边缘触发EPOLLONESHOT 是处理一次后自动休眠需要重新 MOD 才能再次触发。最小调用骨架长这样int epfd epoll_create(1); struct epoll_event ev {0}; ev.events EPOLLIN; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); struct epoll_event ready[1024]; int n epoll_wait(epfd, ready, 1024, -1); for (int i 0; i n; i) { handle(ready[i].data.fd, ready[i].events); }3.2 最小反应堆骨架注册回调、事件分发、就绪处理直接上代码。这个骨架只做三件事用 fd 当下标存回调事件循环里从 epoll_wait 拿就绪事件按 fd 找到回调并执行#include sys/epoll.h #include errno.h #include unistd.h #include fcntl.h #define MAX_EVENTS 1024 typedef void (*handler_t)(int fd, uint32_t events); struct reactor { int epfd; handler_t handlers[65536]; /* 教学写法直接以 fd 做下标 */ struct epoll_event evs[MAX_EVENTS]; }; static struct reactor r; void reactor_add(struct reactor *r, int fd, uint32_t mask, handler_t h) { struct epoll_event ev {0}; ev.events mask; ev.data.fd fd; r-handlers[fd] h; epoll_ctl(r-epfd, EPOLL_CTL_ADD, fd, ev); } void reactor_del(struct reactor *r, int fd) { epoll_ctl(r-epfd, EPOLL_CTL_DEL, fd, NULL); r-handlers[fd] NULL; } static void on_read(int fd, uint32_t events) { char buf[4096]; for (;;) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { /* 轻量处理请求比较重就投递给线程池 */ } else if (n 0) { close(fd); reactor_del(r, fd); return; } else if (errno EAGAIN || errno EWOULDBLOCK) { return; /* ET 模式读到 EAGAIN 才算读完 */ } else { close(fd); reactor_del(r, fd); return; } } } void reactor_run(struct reactor *r) { for (;;) { int n epoll_wait(r-epfd, r-evs, MAX_EVENTS, -1); for (int i 0; i n; i) { int fd r-evs[i].data.fd; if (r-handlers[fd]) { r-handlers[fd](fd, r-evs[i].events); } } } }监听 socket 的回调里先 accept把新连接设置为非阻塞再调用 reactor_add 注册 on_read。注意如果用了 EPOLLET连接 fd 必须是非阻塞的否则循环读数据时最后一次 read 会把自己堵死。on_read 里的 for 循环就是要把缓冲区里的数据尽量读完读到 EAGAIN 再返回这是边缘触发模式下最重要的行为约定。这个骨架的价值是拿来理解主循环别直接抄去线上。生产级实现不能用固定数组当下标fd 范围大、事件注册回调会复用要换成哈希表或者把上下文指针挂到 ev.data.ptr 上事件来了从指针里取状态。3.3 必须懂的坑LT、ET、EPOLLONESHOTLT 是默认电平触发只要缓冲区里还有没读完的数据内核就反复通知你。好处是不容易漏事件坏处是被“可读”事件连续唤醒可能造成忙轮询。ET 是边缘触发只在状态从“没有数据”变成“有数据”的那一瞬间通知一次。想理解为什么用 ET可以类比电梯只在到达楼层时响一次铃不会因为你一直站在里面就反复响。ET 逼着你把数据读干净看似麻烦实际上是内核通知次数最少的方案高并发下能明显减少系统调用。ET 有三条纪律fd 必须非阻塞read/write 要循环到 EAGAIN不能指望某次 epoll_wait 返回就能把整个事务做完半包和粘包状态必须自己兜住。EPOLLONESHOT 则是在多线程反应堆里用来防错的一个 fd 被某个线程处理完之前内核不再上报它避免两个线程同时处理同一个 fd。处理完之后必须用 EPOLL_CTL_MOD 重新挂上事件否则这个连接就“死”了。3.4 Redis 为什么敢用单线程跑反应堆很多人不理解 Redis 为什么单线程还能扛住高 QPS。答案就在它的网络层Redis 主循环是典型的单反应堆单线程一个线程阻塞在 aeMain → aeProcessEvents → epoll_wait 上读就绪了就执行命令、写回响应。命令本身是纯内存操作一次也就微秒级回调里又没有任何阻塞式系统调用所以单线程完全够用。单线程的好处非常明显没有锁没有上下文切换开销所有命令天然串行也就不会出现多线程交错破坏共享结构的问题。所以 reactor 的单线程形态不是“性能差”的代名词而是“匹配短小快任务”的利器。反过来如果你的回调里有磁盘 IO、慢 SQL、加密算法这些耗时操作单线程反应堆就会变成单线程瓶颈。Redis 6.0 之后虽然加了多线程 IO但命令执行依然是单线程串行Nginx 和 Netty 的做法则是把反应堆摊到多线程/多进程上也就是下一节要说的模型演进。4. 从单线程到主从多线程反应堆的三种变形4.1 单反应堆单线程最简单也最脆弱结构上就是一个 epoll 实例、一个线程、一个事件循环accept、read、业务、write 全在这条线里串行执行。这是我给刚开始接触这套模型的朋友推荐的第一版因为代码量最小没有并发问题出 bug 好查。它的天花板同样很明显任何一个回调里只要出现阻塞调用整个服务的网络事件就断流。适用场景基本是连接数大、单请求计算量小、业务里没有阻塞式依赖的轻量代理和中间件。还有一个容易被忽视的问题叫事件饥饿某个连接疯狂发数据它的回调执行太久其他连接的事件就一直排不上队。真让我给单线程形态下个判断我会说适合学习、适合轻量转发不太适合需要做复杂业务的商业服务。4.2 单反应堆多线程把耗时业务扔进线程池结构上主线程仍然是同一个反应堆但回调里只做“快速把数据收进来、快速把响应发出去”这类 IO 事务真正耗时的业务计算拆包丢给工作线程池去做。这个模式解决了上面的断流问题哪怕业务线程池里排队几百个任务网络事件循环依然是秒进秒出。代价是并发问题重新回来了。多个工作线程访问共享状态要加锁业务结果在某个工作线程里算完后不能直接在主线程之外 write 同一个 fd否则两个线程同时写肯定乱序。常规做法是业务结果统一塞回一个响应队列由事件循环去取、去写。这里最核心的纪律是“fd 的归属权永远属于反应堆线程”所有读写都在事件循环里做业务线程只负责算。4.3 主从多反应堆Nginx、Netty 的真实形态真实生产环境里单反应堆无论单线程还是多线程都压不满多核 CPU。主从模型再进一步一个主反应堆只负责 accept 新连接拿到新连接后把它分发到某个子反应堆线程每个子反应堆有自己独立的 epoll 实例和事件循环之后这个连接的所有读写事件都在那个子反应堆里处理。Nginx 是典型的每进程一个子反应堆master 只做管理工作worker 进程各自跑 epoll。Netty 是 bossGroup 负责 acceptworkerGroup 负责 IO 读写每个 NioEventLoop 就是一个小反应堆。Memcached 基本也是这个模型。这种设计有两个精妙之处。一是每个连接固定绑定一个子反应堆同一连接的事件永远在同一个线程内串行执行天然不需要给连接状态加锁二是不同子反应堆跑在不同 CPU 上多核资源直接吃满。代价是连接分发策略、子反应堆负载均衡、跨线程关闭连接这些问题都要小心处理。从单反应堆到主从反应堆本质是用更多的循环去分摊压力而不是用更多的线程去抢同一个循环。4.4 三种形态怎么选性能对比参考选型不复杂先看回调业务复杂度再看多核利用率。形态线程构成复杂度适用场景代表单反应堆单线程1 线程全流程低轻量代理、短命令类服务Redis单反应堆多线程1 个反应堆线程 业务线程池中计算较重但连接数可控早期网关、SDK 网络层主从多反应堆主循环 多个子循环高高并发、多核、海量连接Nginx、Netty、Memcached我的建议是回调里做纯内存操作优先单线程回调里有阻塞依赖或重计算加线程池连接量到几十万并且多核资源没用满就上主从。性能对比得结合具体机器压测不能凭印象拍脑袋。5. 实战中的常见问题与排查实录5.1 明明 epoll_wait 说可读read 却拿不到数据现象很经典epoll_wait 返回了 EPOLLIN结果调用 read 只读到 0 或者读了个寂寞。排查先把“0 字节”和“没数据”分清。read 返回 0 表示对端关闭连接收到 FIN 了这时要清理 fd 并摘除事件注册如果是 ET 模式下只读了一块就返回数据其实还在内核缓冲区里只是内核不再通知你了必须循环读到 EAGAIN。还有一种我踩得最多的坑回调里先 read 把数据拿走了但忘了把半包状态推进一步数据到了业务层却没人消费看起来就像丢了数据。建议用 strace 抓 epoll_wait 和 read 的返回顺序或者临时加日志打印每次 read 的 errno。看到 EAGAIN 就说明事件循环没问题是消费逻辑没跟上。5.2 回调里做了一个耗时操作把整个服务打死现象反应堆线程的处理延迟从 2ms 飙到 500ms连接大面积堆积。原因基本就是某个回调里出现了阻塞式操作同步 Redis、同步 MySQL、甚至一个不小心打印大对象的日志。反应堆模型最核心的纪律就是回调里不要阻塞。我早年代码里就干过这种蠢事给下游 Redis 设置的超时是 10 秒下游一抖动网关线程池被几十个慢查询占满最后连健康检查都超时整个服务雪崩。排查和治理套路是先用 perf 或 pstack 看卡住的线程栈确认阻塞点把这个阻塞操作丢进独立的线程池回调通过队列拿结果再不行就调小超时、加熔断。反应堆的吞吐靠的是回调块小每个块越短单位时间能处理的事件就越多。5.3 多进程/多线程下的惊群怎么压住多进程或多线程各自持有 epoll 实例监听同一个 fd 时一个新的就绪事件可能把多个等待者都唤醒但最终只有一个能处理掉其他人白跑一趟。这就是惊群。纯 accept 的场景内核后来做过唤醒优化但 epoll_wait 层面还是需要自己兜住。解法有三条路。一是连接分发任务归主线程子线程不再监听公共端口这是最简单也最推荐的路二是 Linux 内核 4.5 之后支持 EPOLLEXCLUSIVE给 epoll_wait 加互斥唤醒语义同组 fd 只唤醒一个等待者三是端口层面用 SO_REUSEPORT 把同一个端口绑定到多个 socket 上让内核在四层做哈希分发绕开用户态惊群。我在自己的项目里用的是主从模型主反应堆独占监听端口不 fork 不抢。5.4 回调与 fd 的生命周期管理以及两个调试习惯反应堆最容易出的隐蔽 bug 是生命周期连接关闭后 fd 被回收但回调注册表里还留着旧回调恰好新连接复用了同一个 fd事件一来就调用到过期处理函数轻则数据错乱重则崩溃。治本办法是别直接用裸 fd 当下标。结构体里带一个 generation 序号每次分配时递增回调取出指针后先核对序号不匹配直接丢弃。或者学 epoll 的标准用法把上下文指针挂在 ev.data.ptr 上关闭连接时统一释放并从事件表摘除。两个调试习惯我也分享下一是给事件循环加周期统计每次 epoll_wait 返回后记录耗时、就绪数和 fd 总量哪个周期异常就去剪裁回调短时间内能定位到问题处理函数二是关注 fd 的增删改路径确认连接关闭时有没有漏摘除。这两个习惯帮我少熬了很多通宵。最后把前面那句话再甩一次反应堆就是在 epoll_wait 上睡觉的调度员内核摇醒它、告诉它哪个 fd 有事件它翻开花名册叫对应的回调起来干活然后继续回去睡。抓住这个画面reactor、epoll、事件驱动三样东西你就都通了。
阅读完成 · 觉得有帮助?