1. 先把框架的骨架想清楚:为什么是 Reactor
Linux 高性能服务器编程这件事,说到底是在解决一个矛盾:单机要扛住几万甚至几十万条并发连接,而 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去问。真正意义上的异步 IO(io_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 的两种触发模式与配套的非阻塞 IO
epoll有水平触发(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::vector<char>,初始 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++ 支持 C++17。为了让重点突出,我省略了日志、配置、错误处理的大部分细节,只保留框架的骨架部分。
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打开。很多资源泄漏、类型截断的警告,靠编译器就能提前发现,比线上排查便宜一百倍。另外建议加-fsanitize=address单独出一个 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::vector<Ready>& readyList() const { return ready_; } private: int epfd_; std::vector<epoll_event> events_; std::vector<Ready> 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_cast<int>(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_cast<Connection*>(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::vector<char> 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::list<Connection*> 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::vector<std::function<void()>>&& tasks) { { std::lock_guard<std::mutex> lk(mu_); for (auto& t : tasks) queue_.push(std::move(t)); } // 一次唤醒多个,唤醒数不超过空闲线程数 int waken = std::min<int>(tasks.size(), idleCount_.load()); for (int i = 0; i < waken; ++i) cv_.notify_one(); } private: std::queue<std::function<void()>> queue_; std::mutex mu_; std::condition_variable cv_; std::atomic<int> 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未处理 | 看退出码和 core | MSG_NOSIGNAL;EINTR当正常返回 |
| 新连接进不来 | 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 路径上的内存分配彻底干掉,全部走预分配的环形缓冲;二是把「连接管理」和「协议解析」的边界切得更干净,让同一个框架能同时承载多种协议。这两件事都不复杂,但都需要在真实流量下反复验证,急不来。