单进程Reactor模型实战:基于epoll的十万连接高并发服务器实现
去年整理网络服务代码库的时候我又把“单进程Reactor”这套老模型从epoll层面完整实现了一遍。讲实话很多人听到“单进程”第一反应是这不是浪费CPU吗现在机器都是多核单进程不等于自断一臂但恰恰是这个看似“落后”的模型在十万连接、几十万QPS的IO密集场景下依然是最稳、最可控、最好排查问题的方案之一。这篇文章不是教科书式地复述Reactor概念而是我从零实现一个基于epoll的单进程Reactor服务器之后的完整记录为什么选它、epoll用对了没、核心代码怎么组织、实测性能到底什么水平以及那些只有真正写完代码才会踩到的坑。适合谁看如果你正在准备网络编程相关的技术面试或者在写IM网关、消息推送、游戏服务器这类长连接服务又或者已经读过不少Reactor文章但始终没有动手实现过那这篇文章可以当作一份落地参考。1. 为什么单进程反而能扛住十万连接1.1 多线程模型的三道坎先聊聊我们为什么会被“多线程”困住。早期做网络服务最常见的思路是“一个连接一个线程”伪代码大概长这样while (1) { int fd accept(listen_fd, ...); pthread_create(tid, NULL, handle_conn, fd); }这种模型在几百个连接时没有任何问题逻辑简单代码好写。但连接数到几千、上万之后三个问题会同时出现第一是线程切换开销。每个线程在read/write阻塞时会被调度器挂起另一个线程被切换进来。上下文切换的代价大概在几微秒到十几微秒听起来不算大但连接一多、切换一频繁CPU时间就被白白吃掉了。我用perf数过一次在5000连接的情况下光上下文切换相关的开销就占到了CPU的30%以上。第二是锁竞争。线程多了以后共享的计数器、连接表、发送队列都得上锁。一旦锁的粒度没控制好性能会断崖式下跌。最难受的是这类问题不好复现压力小的时候一切正常峰值一来就卡死。第三是内存开销。每个线程默认栈空间8MB虚拟内存Kernel里还有task_struct等一堆结构。一万个线程光是栈空间就是80GB虚拟内存这还没算真实使用。更别谈线程创建销毁的开销在连接频繁建立断开的场景下非常肉疼。到了这个阶段惯性思维会引导你去做线程池、去做连接池看起来是在优化实际上只是在给原来的模型打补丁。1.2 单进程事件循环到底在解决什么问题单进程Reactor模型的本质是把“等待”这件事从业务代码里彻底拿掉。一个进程、一个线程、一个epoll实例所有连接的事件都往这一个地方汇聚有数据可读就去读有数据可写就去写有新连接就accept没有事件就休眠。这不是说CPU变强了而是说我们用IO多路复用的方式把“同时等很多个socket”变成了内核的事情。注意关键词同时等。以前一个线程只能等一个socket现在一个线程可以告诉内核“我等这一万个socket谁有事通知我”内核有事件了就往就绪队列里丢我们只要把就绪队列里的事件处理完就行。我见过不少人对“单进程处理高并发”抱有疑虑核心误解在于觉得“单进程 单核 只能串行处理 慢”。但对于IO密集型的服务真正的瓶颈从来不是CPU计算而是阻塞在等待数据上。只要把等待去掉一个核能做的事情远比想象中多。1.3 从阻塞IO到IO多路复用的思维转变理解Reactor有一个关键比喻把服务器想象成前台接待员而不是餐厅服务员。阻塞IO模型下每个服务员线程盯着一桌客人连接客人不招手数据不来就只能干等人一多就得大量雇人。多路复用模型则是一个接待员同时看着整个大厅谁举手了就走过去处理一下处理完回到前台继续看着所有人。只要举手的人没多到处理不过来一个人完全够用。所以单进程Reactor不是“性能上限低”而是把性能模型变得极其可预测总吞吐 单核处理速度 - 系统开销不会因为连接数增长而发生雪崩式的性能恶化。这个特性在长连接场景里价值巨大因为绝大多数连接大部分时间都是空闲的空闲连接不消耗CPU只占用一点内核fd和内存资源。2. epoll的底层机制与关键取舍2.1 三种数据结构决定了epoll的复杂度要做单进程ReactorLinux下绕不开epoll。很多人用过epoll但只停留在调API的层面这里把底面机制摊开讲透。epoll内部维护三样东西一棵红黑树、一个就绪链表、一组回调函数。红黑树管理的是你通过epoll_ctl注册进来的所有fd增删改查复杂度都是O(logN)。就绪链表存放的是“状态发生变化”的fd也就是epoll_wait能直接拿到的集合。当一个fd上有数据到达、可写、断开等事件时内核会调用对应的事件回调把它挂入就绪链表。epoll_wait做的事情很简单就是把就绪链表里的事件拷给用户同时清空链表。这套设计带来两个直接结果一是epoll_wait的效率只和“当前活跃的连接数”有关和“总共注册的连接数”无关。一万连接只有三个活跃我照样是O(1)拿到这三件事。二是每个连接只需要在加入和删除时操作一次红黑树平时不产生维护成本。对比一下select/poll就能理解为什么它们撑不住大连接——每次调用都要把整个fd列表从用户态拷进内核态再逐个遍历判断状态。注意epoll本身是同步的不是异步IO。它只是帮你检测“数据可读”这个状态真正读写数据还是得自己调用recv/send。异步IO是另外一套机制别把概念混了。2.2 水平触发与边缘触发选择背后的逻辑epoll支持两种触发模式这个选择直接决定了代码的复杂度。**水平触发LT**是默认模式只要fd上还有数据没读完每次epoll_wait都会通知你。比如一次来了10KB数据你只读了2KB下次调用依然会收到这个fd的可读事件。优点是容错性好漏读的数据不会永远沉睡代码简单。缺点是可能产生重复通知吞吐上有一点浪费但通常可以忽略。**边缘触发ET**模式则非常“抠门”只在状态从无数据变成有数据的那一刻通知一次。比如来了10KB数据你只读了2KB剩余8KB还躺在内核缓冲区里但内核不会再给你第二次可读通知直到又有新的数据到来。这要求开发者在收到ET事件时必须一次性把数据尽量读完而且要配合非阻塞IO使用读到出现EAGAIN为止。在单进程Reactor模型里如果采用ET模式还需要额外设一个读上限防止某个连接数据量极大时一口气读完导致其他连接饿死。这个问题我在后面的踩坑章节里会细讲。做实时服务时我最终的折中方案是用户态缓冲 单次事件最多读取一定字节数读不完的数据留在用户态缓冲区逻辑上保证数据不丢时间上保证每个事件处理都有上限。2.3 惊群、EPOLLONESHOT与EPOLLEXCLUSIVE单进程Reactor没有惊群问题但只要你未来往多进程演进就得懂这些。先说惊群多个进程/线程同时epoll_wait同一个listen fd时新连接到来时旧版内核会把所有等待者都唤醒但只有一个人能抢到accept其他人空转一圈再回去睡。这就是惊群连接少时没感觉连接建立频率高时能明显看到CPU不必要的飙升。Linux 4.5之后有EPOLLEXCLUSIVE标志可以让内核只唤醒其中一个等待者。另外SO_REUSEPORT可以让多个进程分别监听同一个端口把accept的工作直接交给内核负载均衡这其实已经从源头上绕开了惊群问题。EPOLLONESHOT则是另一个方向的工具事件通知一次后自动失效需要手动重新epoll_ctl才能再次监听。它在多线程模型里用来保证同一个fd不会被多个线程同时处理但在单进程Reactor里基本不需要因为我们根本没有第二个线程。3. 核心代码骨架EventLoop、Channel与连接分发3.1 四张核心数据结构先明确一下我用C来实现这个骨架但整套设计思路不依赖具体语言。核心只需要四个东西EventLoop、Channel、Connection、定时器。class EventLoop { int epfd; // epoll实例 std::vectorepoll_event events; // epoll_wait存储空间 bool running; // 每个fd对应一个Channel std::unordered_mapint, Channel* channels; }; class Channel { int fd; uint32_t events; // 关注的事件: EPOLLIN / EPOLLOUT / EPOLLET等 std::functionvoid() readCallback; std::functionvoid() writeCallback; std::functionvoid() closeCallback; };Channel是epoll事件和业务逻辑之间的薄薄一层封装。每个fd都对应一个ChannelChannel里存的是回调函数。这样epoll_wait返回事件后EventLoop只需要根据fd找到Channel调用对应回调就行。Connection则是业务层的连接对象里面维护了对端的地址、收发缓冲、状态标志以及一个指向Channel的引用或指针。连接建立时要创建Channel并注册到EventLoop连接关闭时要从EventLoop摘除Channel再销毁对象。曾经我图省事直接把Connection当成Channel用后来发现连接和通道的声明周期并不完全一致连接在TIME_WAIT等状态下即使不活跃Channel也可能需要保留。分开之后复用和测试都舒服很多。别嫌结构多后面排查问题的时候你会感谢当初把边界划清楚的自己。3.2 事件循环的主流程拆解事件循环是整个服务器的发动机核心代码很短但短不代表简单每一步都有讲究void EventLoop::loop() { running true; while (running) { int timeout getTimeout(); // 最近定时任务的剩余时间默认1000ms int n epoll_wait(epfd, events.data(), events.size(), timeout); if (n 0) { if (errno EINTR) continue; break; } for (int i 0; i n; i) { Channel* ch static_castChannel*(events[i].data.ptr); if (events[i].events (EPOLLERR | EPOLLHUP)) { ch-closeCallback(); continue; } if (events[i].events EPOLLIN) ch-readCallback(); if (events[i].events EPOLLOUT) ch-writeCallback(); } } }有几个细节是踩过坑之后才加上的。首先是getTimeout这里的超时时间不能随便写死成1000ms或-1。单进程内还要跑定时任务比如心跳超时检测。如果设-1进程永远沉睡定时任务就全部失效了如果设0epoll_wait每次立即返回CPU会空转到100%。正确做法是计算最近一个定时任务的剩余时间用这个时间作为超时值。其次是epoll_event.data这个字段很多初学的人用data.fd然后通过fd再去查Connection。简化代码倒是可以但每次都要做一次map查找。我改成data.ptr直接指向Channel省掉一层查表也让事件分发路径更短。要注意的是Channel的声明周期必须比它注册到epoll的时间更长否则会出现野指针这点在你处理连接关闭逻辑时要格外小心。最后是错误处理EPOLLERR和EPOLLHUP事件不一定会带EPOLLIN标志如果漏掉单独判断有些连接断开了你的进程却永远感知不到。3.3 accept新连接后的事件分发路径监听fd也是通过epoll管理的所以新连接到达时EventLoop同样收到一个EPOLLIN事件此时readCallback就是accept函数void Server::onAccept() { while (true) { int fd accept(listen_fd, (sockaddr*)addr, len); if (fd 0) { if (errno EAGAIN) break; // 没有新连接了 if (errno EMFILE) handleFdExhausted(); break; } setNonBlocking(fd); Connection* conn new Connection(fd); Channel* ch new Channel(fd); ch-readCallback [conn]() { conn-handleRead(); }; ch-writeCallback [conn]() { conn-handleWrite(); }; ch-closeCallback [conn, this]() { removeConnection(conn); }; loop-addChannel(ch, EPOLLIN | EPOLLOUT); // 连接计数、超时时间轮加入、业务层通知... } }这里有两个细节值得强调。一是accept要用while循环一口气把当前就绪队列里的所有新连接都接收完收到EAGAIN为止。如果只accept一次就返回万一同时有大量连接涌入剩余的连接会留在内核队列里反复触发EPOLLIN造成无意义的忙等。二是accept之前的listen fd必须设置非阻塞否则如果恰好最后一个连接在accept前已被客户端断开阻塞accept会卡住整个事件循环等于服务器全部瘫痪。3.4 定时器与信号的统一处理单进程里还要处理两类“非IO事件”超时定时器和进程信号。我的做法是把它们也变成fd塞进epoll。定时器用timerfd_create实现设置好到期时间和间隔后这个fd到了时间就会变为可读事件循环自然能感知到。好处是完全不需要单独的线程去sleep也不会打断主循环的节奏。再加上一个环形时间轮用来管理每个连接的超时检测连接活动时刷新它在时间轮里的位置时间轮走到哪个槽位就把槽位里所有连接判超时。信号处理用signalfd。传统signal handler是在异步中断上下文中运行的里面只能做一些异步安全的事情什么锁、堆操作、printf都不能做限制重重。改成signalfd之后信号变成普通fd的可读事件主循环统一处理无论是SIGINT优雅退出还是SIGPIPE的处置都干干净净。另外SIGPIPE这个信号一定要处理。默认情况下向一个对端已关闭的socket写数据会导致进程直接退出很多线上服务“莫名失踪”就是它干的。我一般在启动函数里直接signal(SIGPIPE, SIG_IGN)然后在写入函数里靠recv/send的返回值或EPIPE错误码来感知连接异常避免进程被信号杀死。4. 压测数据与三个调优手段4.1 测试环境与基准数据代码实现之后我在一台专用压力测试机上做了完整的压测。条件也不算苛刻某x86平台8核CPU16GB内存Linux 5.x内核测试时关闭超线程和动态变频。压测工具是自研的一个TCP回环客户端支持百万连接级别的并发模拟避免wrk这类HTTP工具在纯TCP长连接场景下的局限性。场景连接数CPU占用内存变化备注空闲长连接5万约0.4%约160MB主要是Socket缓冲区与连接结构空闲长连接10万约0.8%约320MB连接数翻倍内存近线性Echo回显1万并发约70%单核波动128B小包Echo回显1万并发单核打满波动1KB包吞吐受限单进程单核下128B小包Echo大约能跑到22万QPS左右这个成绩已经超过很多业务服务的实际需求。换成1KB数据包后大概8万QPS瓶颈已经不在事件循环而在数据拷贝和Socket缓冲区读写上。这个结果印证了一个观点对于IO密集型长连接服务单核单进程在绝大多数业务场景下性能都够用真正的瓶颈通常是业务逻辑和内存拷贝。4.2 调优手段一绑定CPU核心第一次压测时我就发现即便只有单进程吞吐也有明显的周期性波动。排查后发现是操作系统调度器在多个核之间搬运线程造成了缓存失效和调度延迟。解决办法是把进程绑定到一个固定的CPU核心上cpu_set_t cpuset; CPU_ZERO(cpuset); CPU_SET(0, cpuset); sched_setaffinity(0, sizeof(cpuset), cpuset);绑核之后QPS大约提升了15%到20%波动也小了很多。但要注意两点绑核之后一定要检查机器上是否有其他线程占用了同一个核心特别是某些驱动或内核线程否则软中断负载会干扰你的稳定性和吞吐。另一件事是不要把多个worker进程绑到同一个核心上那反而会互相抢时间片我的经验是给每个进程分配独立核心再把一个核心专门留给系统软中断或网卡处理。4.3 调优手段二减少epoll_ctl的调用次数epoll_ctl本身并不贵贵的是你频繁调整同一个fd的事件集时产生的系统调用开销和内核锁竞争。初期我实现时在每个数据包收发完成后都会重新设置EPOLLOUT事件压测时发现系统调用占比高得离谱。优化思路是连接建立时一次性把EPOLLIN和EPOLLOUT都注册上之后尽量通过用户态标志位来控制收发逻辑而不是反复MOD事件减少内核态和用户态的频繁切换。具体的方案是给Connection增加一个sendable状态位发送数据时先尝试send全部发完就置sendable为true如果send返回EAGAIN说明对端窗口满了才需要重新让epoll关注EPOLLOUT下次EPOLLOUT事件触发时再把缓冲区的剩余数据发出。这套逻辑让平时繁忙时的epoll_ctl调用量降了两个数量级。ctrl-C之类事件另外说EPOLLOUT这种边距事件本身就爱反复触发用户态状态机挡一道性能收益立竿见影。4.4 调优手段三时间轮与事件上限的平衡时间轮的槽位粒度直接决定了超时精度和CPU开销的折中。压测时我对比过两种配置1秒粒度的时间轮600个槽每分钟轮转一次CPU开销几乎为零但对某些需要秒级超时的业务比如心跳间隔5秒显得太粗糙。100ms粒度的时间轮6000个槽每分钟需要遍历6000次槽位单纯为了判断空槽白白占了不少CPU。最终我选了500ms粒度6000个槽两分钟一个周期既保证心跳检测不会因为粒度太粗而误杀又不会因为空转太频繁造成浪费。测试下来CPU增量大约在2%以内可以接受。同时我还在事件处理循环里加了单次事件上限。每次epoll_wait最多处理256个事件处理完就重新调用epoll_wait目的是防止单个连接或单批事件量过大造成其他事件长时间得不到响应。在ET模式下这个要配合用户态缓冲来做避免因为有上限而丢数据先读到用户态缓冲处理好缓冲区数据再返回。5. 从单进程到多进程的演进路线5.1 多进程Reactor的两种拓扑单进程Reactor性能虽好但只能用满一个核CPU多核利用率上不去。往多进程演进时常见的拓扑主要有两种。第一种是SO_REUSEPORT多进程组启动N个进程每个进程都有一个独立的EventLoop和listen fd内核在accept层面把新连接直接分发到某个进程。优点是代码改动极小每个进程都是独立的单进程Reactor互不干扰连锁都不用加缺点是内核只做简单的hash分发连接可能在某个进程特别忙而另一个进程特别闲均衡性一般。第二种是主从进程模型一个主进程只负责accept然后把新连接的fd通过socketpair 内核辅助数据SCM_RIGHTS辅助数据发送给某个子进程子进程把它注册进自己的epoll。好处是负载均衡逻辑可以自己定义比如按连接号取模、按业务逻辑分片代价是需要维护父子进程间的通信通道代码复杂度上一个台阶。5.2 为什么我建议保留单进程核心不少朋友觉得既然要上多进程单进程Reactor就可以废弃了。我的建议恰恰相反把每个Worker都继续写成单进程Reactor而不是在多进程里退回到多线程更不要让某个进程内建起线程池。原因有三。第一单进程事件循环让每个Worker内部完全无锁所有状态增删改查都发生在同一个线程上下文里不会有竞态。无锁这件事在追求极致性能的场景下价值极高。第二单进程的并行复杂度可控问题定位时抓一个进程的日志、看一个进程的CPU和内存都像在看单机排错成本远低于多线程共享状态。第三业务逻辑可以做成无状态分片把连接按规则固定到某个Worker上这样进程之间根本不需要共享任何东西横向扩容时只需要加进程数。5.3 演进过程最容易忽略的两个问题第一个是定时器状态的一致性。单进程里一切都在内存里连接数、超时时长随便查。多进程之后一个Worker里的状态对另一个Worker不可见。如果你要做整机级别的连接统计或者全局限流就得把统计信息定期同步到外部存储或者在业务层做汇总别指望直接用pid的/proc信息来看全局。第二个是优雅退出和连接迁移。多进程下如果直接kill某个Worker它手里的连接会全部断掉用户体验很差。比较好的方案是先停止接收新连接让内核或主进程把新连接分到其他Worker然后给当前Worker一个宽限期处理存量连接等存量降到零再退出。这个流程在单进程版本里根本不需考虑多进程之后就必须当成一个正式模块来设计。6. 调试半年积累的五个坑6.1 ET模式下读不完数据的假死循环ET模式下收到可读事件后必须反复调用recv直到EAGAIN这个规则几乎所有经验文章都会讲。但反过来如果不做限制对方持续发送数据时你的recv循环会一直读到数据事件循环整个卡在读循环里出不来其他连接全部得不到响应。我用gdb抓过一次现场单进程CPU占满但所有其他连接都表现为卡死堆栈全部停在recv上。根因就是读取循环里忘了加字节上限一条连接持续灌大流量时饿死了所有人。解决办法是设置单次事件处理的读取上限我设为1MB。每次ET读循环每累计读取1MB就退出循环剩余的数据留在用户态缓冲区等待下一次EPOLLIN事件。这里有个微妙点ET模式下如果没有新数据到来内核不会再触发可读事件所以要让逻辑不丢数据就必须保证“每次触发都把所有数据搬进用户态”。既然进到用户态了限制处理量就安全了——数据相当于已经在内核和用户态边界做了交接不丢。6.2 定时器未摘除引起的悬空指针这个坑发生在我引入时间轮之后。连接正常关闭时我只执行了从epoll中删除fd、释放Connection对象却漏了把连接在时间轮里的超时登记也摘掉。结果时间轮转动到那个槽位时仍然会调用一个被释放对象的回调随机崩。定位过程比较曲折因为崩溃的调用栈指向的是时间轮的触发函数和关闭连接的函数隔了很远的调用链。后来我在关闭连接的代码里梳理了所有“这连接还挂在什么结构里”才发现时间轮这东西往往不在你的第一反应里。解决办法是给每个连接记录一个timerKey加入时间轮时拿到槽位索引和链表节点引用任何路径下关闭连接时都先摘除时间轮节点再释放对象。我还加了一层校验回调执行前检查对象的内存标志位确保不是已释放的死对象。6.3 accept返回EMFILE时的雪崩进程有fd数量限制。连接数涨上来的时候如果碰巧超过ulimit限制accept会返回EMFILE。最糟糕的是这会发生雪崩只要内核接收队列里有连接请求监听fd就会一直可读epoll_wait一直返回EPOLLINaccept又一直失败事件循环陷入在一个不可能成功的accept循环里其他连接同样被冻住。解决思路是保留一个备用fd程序启动时就预先open一个不会用到的fd占位。当accept返回EMFILE时立刻close掉这个备用fd腾出位子执行一次accept这时的accept要么成功拿到新连接要么拿到的是内核队列里的老连接随后立即close掉这个新fd因为我们依然处于句柄耗尽状态不能真正接收连接最后再重新打开备用fd。这样内核队列中的待处理连接被排掉了一部分监听fd的可读状态解除系统从雪崩中恢复业务侧真正要做的其实是尽快释放其他占用的fd。注意备用fd的手法是保命用的不是正常处理逻辑真正的修复还是要提高进程fd上限和监督连接数。6.4 epoll_wait的超时参数设置这是一个会因为代码写顺手就忽略的细节。很多人习惯性把timeout写成0或-1timeout设为0时epoll_wait立即返回事件循环变成纯忙轮询CPU使用率直接飙到100%。如果你同时有sleep(1)控制节奏那响应延迟又会很搞笑。timeout设为-1时永久阻塞定时器永远没有机会运行。如果某个业务需要定时扫描空闲连接、定期清理缓存你会发现这些功能齐齐失效。要解决就绕回到第三章说的每次epoll_wait前计算一下最近一个定时任务还剩多久到期把这个差值作为timeout传入。如果没有任何定时任务默认给一个合理的最大超时比如1000ms避免进程彻底睡死。这个改造之后定时器准确、CPU正常整个循环才算是健康运转。6.5 SIGPIPE与其他“小”细节SIGPIPE是网络服务里最常见的隐形杀手。向一个对端已经关闭的socket调用send默认行为会触发SIGPIPE并终止进程。我前几个月调试一台推送服务时进程总是运行了几十分钟就莫名消失看日志也没有崩溃堆栈。后来排查到是某些客户端断开后服务端还有缓冲数据要发出send遇到已关闭连接直接触发SIGPIPE把整个服务带崩了。解决办法很简单启动时忽略它signal(SIGPIPE, SIG_IGN);然后靠send的返回值-1和errnoEPIPE去感知连接错误做正常清理。此外还有几个小细节在代码审查时值得反复确认所有fd都要设置成非阻塞包括accept返回的新连接从epoll中删除fd和关闭fd之间不要做多余的事情防止fd被复用导致事件错误触发发送数据时考虑粘包为应用层协议加入长度域或分隔符否则你会为脏数据调试到怀疑人生。最后说点体会单进程Reactor并不是什么新潮的概念甚至有些“过时”但它永远是理解高性能网络服务的敲门砖。我现在回过头看当初在这套模型上花掉的时间绝对值得——正是因为它足够简单我才能把epoll的每个机制、时间轮的每个细节、事件分发的每一条路径都看清。如果你也想彻底搞懂网络服务的基本功不妨自己动手实现一遍然后试着加上时间轮、signalfd、SO_REUSEPORT多进程这些经验会跟着你很久。实现过程中如果卡住了想想这个场景桌面上的接待员不急不躁地看着整个大厅谁举手就走向谁。想明白这句话Reactor的骨架就已经在你脑子里了。