
1. 先把框架的骨架想清楚为什么是 ReactorLinux 高性能服务器编程这件事说到底是在解决一个矛盾单机要扛住几万甚至几十万条并发连接而 CPU 核心数就那么几个线程上下文切换、内存拷贝、系统调用每一项都在偷偷吃掉你的吞吐。高性能服务器框架之所以存在就是把「怎么接连接、怎么读数据、怎么派活、怎么回包、怎么管超时」这一整套脏活累活沉淀成可复用的骨架让写业务的人只需要关心「收到一条完整请求之后该干什么」。我见过不少团队的第一版服务端是「一个连接一个线程」写出来的压测到两三千连接就开始抖CPU 里一半时间花在调度上剩下一半花在无谓的唤醒上。后来换成事件驱动模型同样的机器、同样的业务逻辑连接数直接上了一个量级。这篇内容就是把这套从「能跑」到「能扛」的框架拆开讲Reactor 模式的取舍逻辑、epoll 的正确用法、事件循环与线程池怎么配合、定时器和缓冲区的工程实现、以及压测现场踩过的那些坑。不管你是刚接触网络编程、想搞明白epoll_wait到底在等什么的新手还是已经写过几版服务端、正在为毛刺和内存增长发愁的老手下面这些内容应该都能直接拿去对照自己的代码。我个人的定位是这不是一份教科书而是一份「我照着这么写线上跑得比较稳」的记录。里面的参数、代码、排查思路都是围绕一个目标——让框架在真实压力下不掉链子。1.1 从「一连接一线程」说起瓶颈到底在哪里先把最朴素的模型摆出来。主线程accept到新连接就pthread_create一个线程线程里阻塞read读到数据就处理处理完write回去连接断了线程退出。这个模型的好处是心智负担极低代码线性、调试友好业务逻辑里出现阻塞调用也无所谓反正每个连接独占一个线程。问题在于它的扩展性是线性的、而且斜率很陡。一条连接一个线程意味着每条连接至少要占一个线程栈。默认栈大小 8MB就算你pthread_attr_setstacksize调到 256KB一万条连接也是 2.5GB 虚拟内存打底这还是没算线程控制块、调度队列、TLS 区域的开销。更要命的是调度成本一万个线程在几十个核上跑内核的 CFS 调度器要不停地做负载均衡和上下文切换每次切换还要刷 TLB、换栈、可能触发缓存失效。你以为线程在等网络数据其实它大部分时间在「被换进来、发现没数据、又被换出去」。这里有个常被忽略的点阻塞 IO 的线程在等待期间是被挂起的看起来不占 CPU但「唤醒」本身是有成本的。当一万条连接里每秒有十万个数据包到达就意味着每秒十万次唤醒和挂起这个量级的调度开销足以把一两个核吃干净。而真正干活的时间可能只占百分之几。所以结论很清楚并发连接的规模一旦超过「核数 × 几十」这个量级线程就必须和连接解耦。线程只负责「有事干的时候干活」没事干的时候绝对不能占着一个线程空等。这就是 IO 多路复用的出发点——用少量线程通过一个系统调用同时盯住大量文件描述符谁有事件就处理谁。1.2 Reactor 与 Proactor 的取舍为什么 Linux 上多数人选 Reactor事件驱动模型有两大流派。Reactor是「事件到了通知你你自己去读」Proactor是「你告诉我缓冲区在哪、读多少读完了通知你」。前者是同步非阻塞 IO 的封装后者依赖操作系统的异步 IO 能力。在 Linux 上做服务端绝大多数框架走的都是 Reactor。原因不复杂epoll本质上就是个就绪通知机制它告诉你「这个 fd 可读了」至于读多少、读完没有得你自己调read去问。真正意义上的异步 IOio_uring、POSIX AIO在生态成熟度、调试便利性、跨内核版本兼容性上对普通业务框架来说还是有门槛的。所以一个务实的判断是先把 Reactor 这套吃透它是当前 Linux 服务端最主流、资料最全、出问题最容易查的路线。Reactor 内部还有单 Reactor 单线程、单 Reactor 多线程、主从 Reactor 多线程几种变体。单 Reactor 单线程就是 Redis 那套思路所有事件处理、业务逻辑都在一个线程里跑好处是没有锁、没有竞态、逻辑极度清晰坏处是一个慢请求就能卡住所有连接。所以它适合「业务处理极快、几乎不阻塞」的场景。主从 Reactor 多线程是目前通用框架的主流选择主线程也叫 acceptor 线程只负责accept新连接把连接分发给若干个子 Reactor 线程IO 线程每个子线程有自己的epoll实例和事件循环负责自己名下连接的读写业务逻辑如果耗时再丢给独立的工作线程池。这样职责分层清晰acceptor 不被业务拖慢IO 线程不被计算拖慢工作线程可以水平扩展。1.3 线程模型的三种形态与选型依据落到具体实现线程模型大概有三种常见形态选哪个取决于你的业务特征。第一种是IO 线程即工作线程子 Reactor 收到完整请求后直接在本线程执行业务逻辑。优点是零跨线程传递、零锁、缓存局部性好缺点是业务里只要有一个慢操作查库、写文件、调下游这条 IO 线程上的其他连接全部陪跑。适合业务极轻、纯内存计算的场景。第二种是IO 线程 工作线程池IO 线程只做收包、拆包、组包把完整请求封装成任务丢进队列工作线程池取出执行执行完把响应交回 IO 线程发送。这是通用性最好的方案代价是要处理跨线程的对象生命周期问题——请求对象从 IO 线程传到工作线程再传回去中间任何一处提前释放都是崩溃。第三种是多进程 每进程单 Reactor典型如 Nginx 的 worker 模型。进程间隔离性好一个 worker 崩了不影响其他也没有多线程的锁竞争但进程间共享状态困难扩容要靠SO_REUSEPORT或父进程分发。适合无状态的服务入口层。我自己的经验是先按第二种搭骨架因为它最通用如果压测发现跨线程投递成为瓶颈再考虑把轻量业务收回 IO 线程内联执行。这个优化在很多场景下能省掉 20% 以上的延迟因为省掉了一次任务入队、一次线程唤醒、一次缓存失效。2. 核心组件拆解一个能扛住压力的框架由哪几块拼成框架这东西拆开看就是几个模块的组合。但每个模块的边界在哪、接口怎么定直接决定了后面好不好维护、好不好调优。我按「数据从网卡进来、到业务处理完、再回到网卡」这条链路把组件一个个过一遍。2.1 事件循环框架的心脏也是最容易写歪的地方事件循环的本质是一个while循环阻塞等待事件、取出就绪事件、逐个分发处理、处理完回到等待。听起来很简单但真正的坑都在细节里。第一是等待超时怎么定。epoll_wait的第四个参数是毫秒级超时。如果传 -1就是无限阻塞直到有事件或者被信号打断。问题是如果你的框架里有定时器比如连接空闲超时、心跳检测你就不能让循环无限睡下去否则定时器永远不触发。常见做法是算出「距离最近一个定时器到期还有多少毫秒」把它作为超时值传进去。这样定时器精度取决于你的最小时间粒度一般取 1ms 到 100ms 之间。第二是事件处理的时间必须可控。整个事件循环是单线程的你在回调里做了一次 200ms 的同步操作这条线程上所有其他连接就集体延迟 200ms。所以框架必须有一条硬规矩IO 线程里的回调只做内存操作任何可能阻塞的事情一律投递到工作线程。这条规矩写在文档里没用得在代码 review 和压测里反复强调。第三是循环的退出与优雅关闭。线上做灰度发布、配置热更新都需要「停止接受新连接、处理完存量请求、再退出」。这就要求事件循环有一个明确的停止标志并且能被跨线程唤醒——不能傻等在epoll_wait里不出来。最常用的办法是往自己的epoll里注册一个eventfd需要唤醒时往这个 fd 写 8 个字节epoll_wait立刻返回循环检查停止标志后决定是否退出。2.2 epoll 的两种触发模式与配套的非阻塞 IOepoll有水平触发LT和边缘触发ET两种模式这个知识点被讲烂了但真正用对的人不多。水平触发的语义是只要缓冲区里还有数据没读完下次epoll_wait还会告诉你这个 fd 可读。所以你可以只读一次、读一部分剩下的下次再说绝对不会丢事件。代价是如果某个 fd 一直有数据而你处理得慢它会反复出现在就绪列表里占着epoll_wait的返回数组。边缘触发的语义是只在「状态发生变化」的那一刻通知一次。从没数据变成有数据通知你读了一部分还剩一部分不通知又有新数据到达通知。这就要求你每次收到可读事件必须循环读到返回EAGAIN为止否则剩下的数据会一直躺在内核缓冲区里再也没有人来通知你。这里有一组必须绑定的组合边缘触发 非阻塞 fd 循环读直到 EAGAIN。三个条件缺一不可。如果 fd 是阻塞的你循环读到最后一次会卡死在read上整条 IO 线程就此挂掉如果不循环读到 EAGAIN数据会积压。我见过最典型的线上事故就是代码里设了 ET但某个分支忘了循环读压测时表现为「偶发请求超时」抓包发现服务端根本没回包最后定位是数据卡在内核缓冲区里没人取。至于选 LT 还是 ET我的看法是LT 是安全牌ET 是性能牌。LT 的代码容错性高写错一点最多是效率低不会丢事件ET 减少epoll_wait的返回次数在高并发下有实实在在的收益但对代码正确性的要求高一个档次。如果团队里新人多、代码分支复杂我倾向于全用 LT如果是稳定的核心框架、有完善的压测和回归用 ET 更划算。还有一个容易被忽略的点ET 模式下循环读到 EAGAIN 也不能真的「无限制」读。如果某条连接一直在高速发数据你的这一次回调可能读了几百 MB 才返回期间其他连接的请求全部被饿死。所以工程上通常给单次读取设一个上限比如单次最多读 64KB 或者循环最多 16 次读完还有剩余就主动把这个 fd 重新「挂起」下轮再处理——怎么重新挂起可以在自己的连接对象上打个标记把连接塞进一个待处理队列事件循环每轮检查一次。这是很多成熟框架里不写在文档里的小技巧。2.3 缓冲区与应用层协议解析TCP 是字节流没有消息边界。你read一次可能读到半个请求也可能读到三个半请求。所以框架里必须有一层「读缓冲区 协议解析器」的组合。读缓冲区的实现有两种常见策略。一种是固定大小缓冲区每条连接预分配比如 8KB读满了还没解析出完整包就扩容或者报错。优点是内存可控、实现简单缺点是大请求要扩容、小请求浪费内存。另一种是可增长缓冲区类似std::vectorchar初始 1KB 到 4KB不够就翻倍。灵活但有内存碎片和拷贝成本。我踩过的一个坑是初始缓冲区给太小比如 512 字节每条请求要扩容两三次压测时 CPU 有相当一部分花在memcpy和realloc上。后来把初始值调到 4KB扩容次数降到很低QPS 直接涨了一截。这个参数的选取没有魔法就是看你协议里最常见的包有多大初始缓冲区覆盖住 90% 的包就够了。缓冲区还有一点要注意读指针和写指针的移动以及数据搬移。每次解析完一个完整包缓冲区前面会空出一段。如果你不搬移缓冲区尾部很快就不够用了如果每次解析完都memmove数据量大时拷贝成本很高。常见的折中做法是「读指针追上来超过缓冲区一半时才搬移一次」或者干脆用环形缓冲区。环形缓冲区没有搬移成本但实现更复杂解析跨越尾部时要做两段处理。我的建议是先用简单的线性缓冲区加上「过半搬移」策略等真的成为瓶颈再换。2.4 定时器时间轮和最小堆怎么选只要你的服务有连接就一定需要定时器连接空闲超时、心跳发送、请求超时、重试退避、限流窗口全是定时任务。最朴素的做法是「每条连接记一个最后活跃时间戳事件循环每轮扫一遍所有连接超时的踢掉」。这个方案在连接数几千的时候没问题几万的时候每轮扫描就是几万次比较一秒扫十轮就是几十万次白烧 CPU。而且扫描间隔决定了超时精度扫描太频繁浪费太稀疏不准。更好的方案有两种。最小堆所有定时器按到期时间组织成堆堆顶就是最早到期的那个每轮只需检查堆顶。插入和删除是 O(logN)取最近到期时间是 O(1)。实现简单、精度高是通用框架的常见选择。时间轮把时间轴切成固定的槽每个槽挂一个链表指针每秒走一格。插入和删除是 O(1)特别适合定时器数量巨大、且超时时间相对固定比如统一 60 秒空闲超时的场景。经典实现是多级时间轮类似水表低级别转一圈进一位。缺点是精度受槽粒度限制且实现复杂度高。我的实践结论是连接级的空闲超时用时间轮业务级的各种一次性定时任务用最小堆。连接超时数量大、超时时间统一时间轮最合适业务定时器数量少但时间离散最小堆更灵活。有些框架为了减少依赖统一用最小堆也能跑得很好只是连接数上了十万之后要留意堆操作的 CPU 占比。另外一个细节定时器删除时的「惰性删除」。一条连接关闭时它的定时器节点还挂在堆里或轮里这时候有两种做法立刻从结构里摘除需要反向指针实现复杂或者打个「已取消」标记等时间到了取出来发现标记就跳过。后者实现简单、没有并发问题代价是结构里会堆积一些无效节点需要定期清理。我倾向于惰性删除因为连接的关闭路径越简单越好。2.5 线程池与任务队列跨线程传递的那些讲究工作线程池的经典结构是「一个任务队列 N 个消费者线程 一把互斥锁 一个条件变量」。IO 线程把任务push进队列并notify_one工作线程被唤醒后pop出来执行。这个结构简单可靠但在高并发下有几个性能陷阱。第一个陷阱是锁竞争。所有 IO 线程和所有工作线程抢同一把锁QPS 上到十几万的时候这把锁会成为明显的热点。优化方向有两个一是用无锁队列比如基于 CAS 的单生产者单消费者队列或者多生产者队列二是分片——每个工作线程一个队列IO 线程按连接 ID 哈希取模投递。分片的好处是锁粒度小、缓存局部性好坏处是负载可能不均某个线程的队列堆积而其他线程空闲需要额外的窃取机制来平衡。第二个陷阱是唤醒风暴。notify_one每次只唤醒一个线程听起来很合理但如果任务是批量到达的比如一次epoll_wait返回了 500 个可读事件产生 500 个任务你会连续调 500 次notify_one每次都可能触发一次系统调用和一次线程唤醒。更糟的是被唤醒的线程还没来得及跑队列里已经堆了 500 个任务。这种情况下更好的策略是「批量投递 一次唤醒」先把这一轮所有任务都塞进队列最后统一notify_all或者根据空闲线程数决定唤醒几个。第三个陷阱是任务对象的生命周期。任务在 IO 线程创建、在工作线程执行、执行完可能还要回到 IO 线程发响应。如果任务持有了连接的裸指针而连接在工作线程执行期间被关闭了就是典型的 use-after-free。解决办法要么是用shared_ptr管理连接有原子引用计数的开销要么是给连接加一个「世代号」任务里存世代号回来的时候比对不匹配就丢弃。后者性能更好但要小心设计。2.6 内存与对象生命周期管理框架跑起来之后最容易被忽视、也最容易出问题的就是内存。连接对象频繁创建销毁如果不做池化new/delete的开销和内存碎片会慢慢累积。连接对象池的思路很简单启动时预分配一批连接对象accept时从池里取一个连接关闭时还回去对象内的缓冲区复用不释放。这样做的好处是分配路径上几乎没有系统调用缓存局部性好内存总量可控可预测。写缓冲区更值得池化。每条连接的写缓冲在「有响应要发」时才用闲着的时候如果也占着几 KB十万连接就是几百 MB 的浪费。常见做法是「小响应用连接内嵌的固定缓冲区超过阈值才去池里取一块大缓冲」用完立刻归还。这个阈值一般设在 4KB 到 16KB 之间覆盖大多数响应的同时避免大块内存常驻。这里给一组内存估算方便你规划容量。假设目标单机 10 万并发连接项目单连接估算10 万连接合计连接对象元数据fd、状态、时间戳、指针等约 256 B约 25 MB读缓冲区初始大小4 KB约 400 MB写缓冲按 30% 连接同时有数据算4 KB约 120 MB定时器节点约 64 B约 6 MB内核态 socket 接收/发送缓冲下限约 4 KB 4 KB约 800 MB线程栈16 个 IO 线程 32 个工作线程256 KB 栈-约 12 MB合计大约 1.4 GB 内存加上业务态的热数据一台 8GB 的机器能比较从容地撑住 10 万连接。如果你发现实际内存远超这个估算先查两端一是写缓冲是不是没及时释放二是内核的tcp_rmem/tcp_wmem下限是不是设得太高导致每条连接的内核缓冲被撑大。3. 代码落地从零搭一个可跑的最小框架理论讲完落到代码上。下面这套代码我给的是一个可编译、可压测的最小骨架Windows 用户跑不了需要 Linux 环境加 g 支持 C17。为了让重点突出我省略了日志、配置、错误处理的大部分细节只保留框架的骨架部分。3.1 工程目录与构建目录按职责划分别把所有东西塞一个文件里后面调优的时候你会感谢自己。mini_server/ ├── include/ │ ├── Poller.h # epoll 封装 │ ├── EventLoop.h # 事件循环 │ ├── Connection.h # 连接对象与缓冲区 │ ├── TimerWheel.h # 定时器 │ ├── ThreadPool.h # 工作线程池 │ └── Server.h # 组装入口 ├── src/ │ └── *.cpp ├── test/ │ └── echo_test.cpp └── CMakeLists.txt构建用 CMake编译选项里务必打开-O2 -g同时把-Wall -Wextra打开。很多资源泄漏、类型截断的警告靠编译器就能提前发现比线上排查便宜一百倍。另外建议加-fsanitizeaddress单独出一个 Debug 版本压测时用 ASan 跑一轮use-after-free 和越界基本躲不掉。3.2 epoll 封装的实现要点封装的目标是把epoll_create1、epoll_ctl、epoll_wait三个调用包起来对外提供「增删改查监听」的接口并且把 fd 和对应的处理对象关联起来。// Poller.h #pragma once #include sys/epoll.h #include vector #include unordered_map #include cstdint class Poller { public: explicit Poller(int maxEvents 1024); ~Poller(); bool add(int fd, uint32_t events, void* ptr); bool mod(int fd, uint32_t events, void* ptr); bool del(int fd); // 返回就绪事件个数timeoutMs -1 表示无限阻塞 int wait(int timeoutMs); struct Ready { int fd; uint32_t events; void* ptr; }; const std::vectorReady readyList() const { return ready_; } private: int epfd_; std::vectorepoll_event events_; std::vectorReady ready_; };实现里有两个细节值得说。一是epoll_event.data这个字段8 个字节可以存 fd 也可以存指针。存指针比存 fd 好用得多——事件返回时你直接拿到连接对象省掉一次unordered_map查找。高并发下这次查找的成本不低因为它是随机内存访问缓存命中率差。二是epoll_ctl的返回值必须检查EBADF、EEXIST、ENOENT都代表状态不一致在大压力下这些错误会真实发生比如连接被对端 RST 后 fd 已经关了你还在mod。int Poller::wait(int timeoutMs) { int n ::epoll_wait(epfd_, events_.data(), static_castint(events_.size()), timeoutMs); if (n 0) { if (errno EINTR) return 0; // 被信号打断正常返回 return -1; } ready_.clear(); ready_.reserve(n); for (int i 0; i n; i) { Ready r; r.fd events_[i].data.fd; // 实际用指针法时可从 ptr 反推 r.events events_[i].events; r.ptr events_[i].data.ptr; ready_.push_back(r); } return n; }注意EINTR的处理。服务端进程经常收到信号比如定时器信号、日志轮转信号如果不在epoll_wait返回 -1 且 errno 为EINTR时正常返回 0你的循环会误判成严重错误然后退出。这个坑我在早期版本里踩过表现为「服务跑着跑着莫名退出」日志里只有一行epoll_wait failed。3.3 事件循环主流程事件循环把 Poller 和定时器串起来负责算超时、分发事件、处理唤醒。void EventLoop::loop() { while (!quit_) { int timeout timer_.nextTimeoutMs(); // 距最近定时器到期 int n poller_.wait(timeout); if (n 0) { for (const auto r : poller_.readyList()) { auto* conn static_castConnection*(r.ptr); if (!conn) continue; if (r.events EPOLLERR) { conn-handleError(); continue; } if (r.events EPOLLIN) { conn-handleRead(); } if (r.events EPOLLOUT) { conn-handleWrite(); } } } // 每轮处理到期的定时器同时检查连接空闲超时 timer_.tick(nowMs()); } }这里有一个顺序问题先处理 IO 事件再处理定时器。如果反过来某条连接刚好在这一轮到期但它的数据也刚好到达你会先把它踢掉再处理数据客户端会看到一个莫名其妙的断连。先 IO 后定时器可以让刚刚有过活动的连接在这一轮里刷新活跃时间避免误杀。另一个容易写错的是timeout的计算。如果定时器堆为空返回 -1 让它无限等如果最近一个定时器在 37ms 后到期就传 37。注意epoll_wait的超时是毫秒整数传 0 会变成非阻塞轮询CPU 直接打满所以要确保nextTimeoutMs()最少返回 1。3.4 连接对象与读写缓冲连接对象是框架里最核心的数据结构把它设计好了后面一切都顺。class Connection { public: Connection(int fd, EventLoop* loop); void handleRead(); void handleWrite(); void handleError(); void closeConn(); bool send(const char* data, size_t len); // 线程安全地投递发送任务 void refreshActive() { lastActiveMs_ nowMs(); } private: int fd_; EventLoop* loop_; uint32_t interest_ EPOLLIN; // 当前关注的事件 bool closed_ false; std::vectorchar inBuf_; // 读缓冲 std::string outBuf_; // 写缓冲未发完的数据留在这 size_t readIdx_ 0; // 已消费位置过半才搬移 int64_t lastActiveMs_ 0; uint64_t generation_ 0; // 世代号防止悬空任务 };handleRead里是 ET 模式的关键void Connection::handleRead() { char tmp[65536]; int rounds 0; while (rounds 16) { ssize_t n ::read(fd_, tmp, sizeof(tmp)); if (n 0) { inBuf_.insert(inBuf_.end(), tmp, tmp n); if (inBuf_.size() - readIdx_ kMaxRequestSize) { closeConn(); // 单请求超过 8MB判为异常 return; } continue; } if (n 0) { closeConn(); return; } // 对端关闭 if (errno EAGAIN || errno EWOULDBLOCK) break; // 读干净了 if (errno EINTR) continue; // 被信号打断重试 closeConn(); return; } refreshActive(); // 循环解析完整请求 while (true) { size_t consumed 0; Request req; ParseResult r parser_.parse(inBuf_.data() readIdx_, inBuf_.size() - readIdx_, req, consumed); if (r ParseResult::NeedMore) break; if (r ParseResult::Error) { closeConn(); return; } readIdx_ consumed; dispatchToWorker(std::move(req)); // 投递到工作线程池 } // 消费过半才搬移减少 memmove if (readIdx_ 0 readIdx_ * 2 inBuf_.size()) { inBuf_.erase(inBuf_.begin(), inBuf_.begin() readIdx_); readIdx_ 0; } }rounds 16这个限制就是前面说的「防饿死」措施。如果一条连接特别猛这一轮读满 16 次就主动让出把连接塞进一个待续读队列下一轮再继续。这样其他连接不会被一条洪水连接卡住。handleWrite处理「发不完」的情况void Connection::handleWrite() { while (!outBuf_.empty()) { ssize_t n ::send(fd_, outBuf_.data(), outBuf_.size(), MSG_NOSIGNAL); if (n 0) { outBuf_.erase(0, n); continue; } if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { // 内核发送缓冲满等下次可写事件 if (!(interest_ EPOLLOUT)) { interest_ | EPOLLOUT; loop_-poller().mod(fd_, interest_, this); } return; } if (n 0 errno EINTR) continue; closeConn(); return; } // 全部发完取消对 EPOLLOUT 的关注避免空转唤醒 if (interest_ EPOLLOUT) { interest_ ~EPOLLOUT; loop_-poller().mod(fd_, interest_, this); } }这里有个必须强调的点未注册EPOLLOUT时如果对端一直不读数据你的epoll_wait永远等不到可写事件outBuf_就会一直涨。所以框架里必须有「写缓冲高水位」的保护比如待发数据超过 4MB 就判定为慢客户端直接踢掉。这个阈值要看你的业务响应大小纯控制的场景可以设小一点几百 KB文件下载类就得设大一些。3.5 定时器接入我用的是一个简化版的时间轮连接超时用 60 秒、槽数 60、每秒走一格实现起来只有几十行。class TimerWheel { public: static constexpr int kSlots 64; void add(Connection* conn); // 放入当前槽 void refresh(Connection* conn); // 活跃时移到当前槽 void tick(int64_t nowMs); private: std::listConnection* slots_[kSlots]; int cur_ 0; int64_t lastTickMs_ 0; };tick里的逻辑是算出距离上次 tick 过了几秒指针往前走几格每个走到的槽里的连接如果超过超时时间就关闭否则重新丢回当前槽。注意要处理「一次 tick 跨越多个槽」的情况比如进程被暂停了两秒指针要一次走两格不能只走一格否则定时器会被拖慢。刷新操作的开销是 O(1)——把节点从旧链表摘下来挂到当前槽前提是节点里有迭代器或者链表节点指针。如果为了省事用「只记时间戳、不移动链表」的惰性方案tick 的时候要多判一次时间但省掉了移动的开销也是很常见的做法。3.6 线程池与跨线程唤醒线程池的骨架不复杂但要把「批量投递 一次唤醒」做好。class ThreadPool { public: void start(int n); void submitBatch(std::vectorstd::functionvoid() tasks) { { std::lock_guardstd::mutex lk(mu_); for (auto t : tasks) queue_.push(std::move(t)); } // 一次唤醒多个唤醒数不超过空闲线程数 int waken std::minint(tasks.size(), idleCount_.load()); for (int i 0; i waken; i) cv_.notify_one(); } private: std::queuestd::functionvoid() queue_; std::mutex mu_; std::condition_variable cv_; std::atomicint idleCount_{0}; };idleCount_这个计数器很关键。不加它的话notify_one可能唤醒一个正在忙的线程虽然在condition_variable的语义下不会唤醒已拿锁的线程但频繁唤醒和抢锁依然浪费也可能在有很多空闲线程时只唤醒一个导致任务排队。维护一个空闲计数按需唤醒实测在高 QPS 下能减少一截无谓的唤醒。回包路径上还有一个必须处理的问题工作线程算完结果怎么让 IO 线程去发。不能直接在工作线程里调send因为那样会和 IO 线程同时操作outBuf_竞态。标准做法是把「发送任务」重新投递回该连接所属的 IO 线程最常用的手段是eventfd或者「任务队列 唤醒」。每个 IO 线程维护一个自己的待执行任务队列工作线程push之后往该线程的eventfd写 8 字节IO 线程的epoll_wait立刻返回在事件循环的「处理唤醒 fd」分支里把队列里的任务全取出来执行。这个模式叫「线程内消息循环」是几乎所有多线程框架的标配。3.7 编译、运行与压测验证跑起来之后先做功能验证再做压力验证。功能上至少覆盖正常请求响应、半包粘包、客户端异常断开、服务端主动关闭、大包传输、超时踢连接。每一条都写个小的测试客户端用脚本跑一遍。压测我一般用两个工具交叉验证。一个是通用的 HTTP 压测工具先用它把 QPS 打上去看曲线拐点另一个是自己写的裸 TCP 客户端可控地模拟「大量长连接 低频心跳」这种真实场景因为很多通用压测工具是短连接模型测不出长连接框架的真实问题。观察指标不要只看 QPS更要看连接建立速率、平均延迟和 P99 延迟、单核 CPU 使用率、常驻内存增长曲线、epoll_wait的返回次数。P99 延迟最能反映框架有没有「卡顿点」内存曲线如果呈阶梯上升不回落基本就是缓冲区或者连接对象没释放干净epoll_wait每秒返回次数如果远高于理论事件数说明有 fd 在空转。4. 踩坑实录那些文档里不会写的问题框架写出来能跑只是开始真正花时间的是各种「理论上不该出现」的现象。下面这几个是我印象最深的。4.1 ET 模式下请求偶发超时抓包却一切正常现象是压测到一定并发后有千分之几的请求超时客户端没收到响应。抓包看客户端的数据包确实到了服务端服务端的 ACK 也回了但就是没有响应包。排查过程走了不少弯路。先怀疑是业务线程池卡住加了日志发现任务根本没进来再怀疑是连接被误关闭日志显示连接还活着。最后把epoll_wait返回的事件全打出来才定位到问题某个连接在一次handleRead里读到了半个包循环读到EAGAIN就退出了剩下的半个包在新数据到达前无法解析。这是正常行为。真正的问题是——新数据到达时事件没有被通知。原因是代码里有个分支解析出错之后调了closeConn()但closeConn里没有把 fd 从epoll里摘掉而是延迟到析构时才摘。在这个窗口期里fd 号可能被新连接复用新旧两个连接对象的 epoll 注册状态互相打架导致新 fd 的事件丢失。修复方式很直接关闭连接的路径上第一步就是EPOLL_CTL_DEL且必须检查返回值。顺序也很重要先摘 epoll再close(fd)绝不能反过来。反过来会有更诡异的后果close之后 fd 号被系统回收另一个线程刚好创建了新 fd 用了同一个号你再去EPOLL_CTL_DEL就删掉了别人的注册。4.2 多线程 accept 的惊群与负载不均早期版本我让每个 IO 线程都往同一个监听 fd 上注册EPOLLIN想着这样能分摊 accept 压力。上线后发现两个问题一是新连接分配严重不均某个线程承担了 80% 的连接二是 CPU 有大量无谓的唤醒。第一个问题的根源是epoll的唤醒策略——它倾向于唤醒等待队列里的第一个而不是轮流。第二个问题就是经典的惊群一个连接到达多个线程被唤醒只有一个accept成功其他全部返回EAGAIN白跑一趟。标准解法是只让 acceptor 线程监听accept 成功后通过 round-robin 把连接分给 IO 线程。这需要把 fd 的所有权跨线程传递做法是acceptor 里accept4得到 fd设置非阻塞和CLOEXEC然后把 fd 加上连接对象打包成任务投递到目标 IO 线程的队列并唤醒它。目标线程在自己的循环里执行「把 fd 注册到我的 epoll」从此这条连接归它管。这样既没有惊群分配也完全可控。顺带说一句accept4它比accept多一个 flags 参数能原子地把 fd 设成非阻塞和CLOEXEC。用accept再fcntl设非阻塞中间有一个极短的窗口 fd 是阻塞的虽然概率极低但在高并发下这种窗口真实存在而且一旦踩中就是「整条 IO 线程卡死」的严重问题。4.3 发送缓冲满了之后数据悄悄丢了这个坑很隐蔽。现象是压测时客户端偶尔收到残缺的响应或者两个响应粘在一起但内容错位。原因是send的返回值被忽略了。send返回的实际发送字节数可能小于请求长度尤其是内核发送缓冲快满的时候。有些人写代码习惯性地认为「send要么全发要么失败」这在阻塞 socket 上碰巧成立阻塞模式下send会一直等到发完但在非阻塞 socket 上完全不成立。正确的处理就是前面代码里那套记录「已发送偏移」循环send直到返回EAGAIN剩下的留在outBuf_里注册EPOLLOUT等下次可写。这里还要注意MSG_NOSIGNAL标志——如果对端已经关闭send会触发SIGPIPE默认行为是终止进程。你的服务端可能因为一个客户端断开就整个挂掉这个损失大到不能接受。另一种做法是全局signal(SIGPIPE, SIG_IGN)但我更倾向于每个send调用显式带上标志影响范围可控。4.4 连接数上去之后 CPU 单核打满压测到 5 万连接、流量很低每秒几百个心跳包时某个核的 CPU 直接跑到 100%。这个场景很典型连接数高但事件稀疏说明问题不在数据处理而在事件循环本身。用perf top一看热点在epoll_wait到用户态的返回、epoll_ctl调用、以及定时器 tick 的链表遍历上。逐个排查第一定时器 tick 里每次都全量遍历了所有连接的活跃时间检查而不是只在槽到期时才遍历。改成只处理「指针走过的那个槽」CPU 立刻降下来一截。第二心跳检测的实现有问题。原来的做法是每秒对「所有连接」发一次心跳5 万连接就是 5 万次send即使大部分连接没有业务数据也要走一遍内核。改成「按连接分批、每批只处理一部分」把 5 万次摊到 50 个 tick 里每次 1000 条压力曲线平了。第三EPOLLOUT的空转。有些连接在发送完成后忘记取消对EPOLLOUT的关注只要内核发送缓冲有空间epoll_wait就会一直返回可写事件形成空转。这个问题的排查方法是统计「每秒可写事件数 / 每秒实际发送数」比值远大于 1 就说明有空转。修复就是在outBuf_清空的瞬间立刻EPOLL_CTL_MOD去掉EPOLLOUT。4.5 常见问题速查表把上面这些和另外几个常见现象整理成一张表方便对照排查。现象最可能的原因排查手段处理方式请求偶发无响应ET 模式漏读、事件丢失打印epoll_wait返回事件序列循环读到EAGAIN关闭路径先EPOLL_CTL_DEL连接分配严重不均多线程同时 accept 导致的惊群统计各 IO 线程连接数改为主线程 accept 轮询分发响应内容错位或残缺send部分写未处理统计send返回值与请求长度差写缓冲 EPOLLOUT续发单核 CPU 凭空打满EPOLLOUT空转、定时器全量扫描perf top看热点发送完成即取消EPOLLOUT定时器按槽处理内存阶梯上涨不回落写缓冲未释放、连接对象未归还池valgrind/ ASan 跑长稳测试高水位保护 对象池归还进程莫名退出SIGPIPE、epoll_wait的EINTR未处理看退出码和 coreMSG_NOSIGNALEINTR当正常返回新连接进不来accept队列溢出netstat -s看 listen overflow调大somaxconn和tcp_max_syn_backlog高并发下延迟毛刺明显IO 线程里执行了阻塞操作在回调里打耗时统计阻塞操作全部投递到工作线程5. 性能调优与观测让框架跑得更稳代码写对只是及格线要让框架在真实流量下稳得住还得在内核参数、socket 选项、观测手段上做文章。这部分内容散落在各处文档里我按实际用到的顺序整理一下。5.1 内核参数与 socket 选项先说文件描述符。默认的ulimit -n通常是 1024这个值对服务端来说毫无意义。要支撑 10 万连接至少设到 655350。注意ulimit -n分软硬限制硬限制只有 root 能改改/etc/security/limits.conf时两个都要写而且要对运行服务的用户生效有些情况下systemd管的服务需要在 unit 文件里单独设LimitNOFILE光改limits.conf不生效——这个坑我踩过排查了半天以为是框架泄漏 fd。再说backlog。listen的第二个参数决定已完成三次握手的连接队列长度但它会被内核参数net.core.somaxconn截断。现代内核的somaxconn默认是 4096对高并发服务偏小建议调到 32768。同时net.ipv4.tcp_max_syn_backlog控制半连接队列也一起调大。如果netstat -s里listen queue overflow在涨说明这两个值不够。socket 选项上SO_REUSEADDR基本是必开的避免重启时因为TIME_WAIT状态绑不上端口。SO_REUSEPORT在多进程模型下很有用多个进程可以绑同一个端口内核做负载均衡在多线程模型下意义不大反而不开更简单。TCP_NODELAY建议开尤其是交互型服务Nagle 算法会为了攒小包引入几十毫秒延迟这个延迟在 P99 上非常刺眼。SO_KEEPALIVE我个人不推荐依赖它的默认探测间隔是两个小时实际用处不大应用层自己做心跳更可控。TCP_DEFER_ACCEPT可以减少「只握手不发数据」的空连接占用但会让首包延迟的判断变复杂用之前最好量一下。还有一组关于端口耗尽的作为客户端主动发起连接的一方net.ipv4.ip_local_port_range决定了可用临时端口范围默认大概是 32768 到 60999两万多个。如果你的服务是短连接模式、每秒建几千连接这个范围会很快打满表现为连接超时。调大到 1024 到 65535同时降低tcp_fin_timeout能让端口回收快一些。5.2 观测指标与压测方法线上排查问题最重要的是「看得见」。一个框架至少要对内暴露这些指标当前连接数、每秒新建连接数、每秒请求数、平均响应时间、P99 响应时间、待发数据总量、工作线程队列深度、每秒定时器触发次数、epoll_wait每秒返回次数。其中待发数据总量这个指标特别值得盯。它持续上涨说明下游或客户端消费不过来是「慢客户端」的早期信号。工作线程队列深度持续大于 0 说明业务处理能力不足要么加线程要么优化业务逻辑。压测方法上我的建议是分三阶段。第一阶段用固定并发、逐步加压找到吞吐拐点观察在临界点附近 P99 是怎么抬头的。第二阶段做长时间稳定性测试至少跑两小时重点看内存曲线和连接数是否稳定。第三阶段做异常注入随机断链、随机慢客户端、随机发超大包看框架能不能自己恢复会不会出现连接泄漏。特别提一句关于「长连接 低流量」的压测。很多框架在纯吞吐压测下表现很好但在 10 万连接、每秒只有几百个事件的场景下会暴露问题——因为这时候瓶颈不在数据处理而在事件循环的空转开销、定时器的扫描开销、以及内核对大量空闲 socket 的管理成本。这个场景更接近真实线上别跳过。5.3 一些工程经验最后分享几条我在实际项目里总结出来的经验都是那种「写进文档没人看、踩过一次就记住」的。连接对象里不要存大对象。我见过有人把 SSL 会话、业务会话数据、甚至序列化缓冲全塞进连接对象导致单连接几百 KB十万连接直接内存爆掉。连接对象应该尽量瘦只放网络层需要的东西业务态挂在别处用 ID 索引。这样即使连接数暴涨内存也可控。跨线程传递对象优先用「移动 世代号」而不是共享指针。共享指针的原子引用计数在几十个线程高频操作时缓存行来回失效性能损失比想象中大。世代号方案是连接对象有一个自增的generation_任务里带上快照回到 IO 线程时比对当前值不一致就丢弃任务。实现简单零原子操作。任何「上限」都要显式设定不要依赖默认值。单请求最大长度、单连接写缓冲最大长度、单次读取最大字节数、定时器最大数量、线程池队列最大深度这些都要有明确的值和超限处理策略。没有上限的地方就是线上事故的入口。堆内存被一条恶意请求撑爆、连接对象因为写缓冲无限增长被 OOM 杀掉这类问题在所有框架上都出现过。压测环境一定要和线上硬件规格接近。我在小机器上调好的参数搬到生产的大机器上完全失效因为核数变了、NUMA 拓扑变了、网卡队列数变了。线程数、epoll实例数、CPU 亲和性绑定这些都要按目标机器的实际核数重新算。给自己留一个「降级开关」。框架里最怕的是某个优化在特定场景下反而变慢而你没法快速关掉它。所以 ET/LT、时间轮/最小堆、内联执行/线程池投递这些关键决策最好在编译期或者配置里留个开关出问题能一分钟切回去。这不叫设计过度这叫给自己留后路。至于框架后续还能怎么演进我个人比较关注两个方向一是把 IO 路径上的内存分配彻底干掉全部走预分配的环形缓冲二是把「连接管理」和「协议解析」的边界切得更干净让同一个框架能同时承载多种协议。这两件事都不复杂但都需要在真实流量下反复验证急不来。