ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ Reactor服务器从零实现:高并发网络编程核心实战

C++ Reactor服务器从零实现:高并发网络编程核心实战 简介这套资源是基于Reactor框架构建的C服务器工程项目源代码包面向具备一定C基础、希望深入理解事件驱动编程与高性能网络服务的开发者。工程完整实现了Reactor核心机制包括事件循环、IO多路复用、连接管理、消息分发与业务处理等模块同时提供配置文件、数据库脚本、通信协议定义和构建脚本便于直接编译运行与二次开发。资源共277个文件压缩包45.39MB主要包含85个头文件、64个C源文件以及makefile工程文件另有HTML文档、SQL脚本、protobuf定义、Shell辅助脚本和测试客户端/服务端程序可帮助读者对照源码理解框架结构、梳理调用链并验证服务器基本功能。项目还附带了libreactor静态库、MySQL客户端库以及echo等测试样例覆盖从配置加载、网络通信到数据落地的常见环节。已有178人学习下载适合用于课程设计、毕业设计或作为C网络编程进阶的参考项目。 如果你正在学C想找一个既能练手又能写进简历当谈资的项目我的建议一直很明确写一个基于Reactor框架的C服务器。这个项目最吸引人的地方在于它能把网络编程、操作系统、多线程、数据结构这些C核心知识点全部串起来而且面试官一听就知道你碰过真实场景里的高并发问题不是只会刷题背八股。这篇文章就说说我从零开始实现这个服务器的完整过程包括模块怎么拆、核心代码怎么写、多线程改造踩了哪些坑以及压测和面试复盘时怎么把项目讲明白。1. 为什么服务器必须Reactor化从一次阻塞IO事故说起1.1 阻塞IO模型的致命短板很多人第一次写并发服务器思路是accept一个连接就开一个线程线程里recv/send。代码跑通很容易但稍微想一下就知道问题有多大。假设每个连接都长时间不发数据那这个线程就死等在recv上1000个连接就是1000个线程。Linux下一个线程默认栈空间8MB哪怕只算栈内存8GB就没了这一条成本就直接劝退。更麻烦的是线程多了之后切换开销急剧上升CPU大量时间花在保存和恢复上下文的路上真正干活的资源所剩无几。我当初在模拟上万客户端接入的时候用这种模型进程创建线程直接失败CPU被打满系统卡到几乎不可用。那次之后我就明白了一个道理多线程不是免费的线程数量必须跟活跃连接数解耦。你不可能为每个连接都养一个线程因为绝大多数连接一分钟内可能也就发一两个请求其余时间都在沉默等待。1.2 Reactor的核心思路Reactor的思想其实一句话就能说清把等事件这件事交给内核不占用用户线程。内核的epoll或者poll/select负责同时监听成千上万个socket只把真正有事件的fd告诉你。你的程序用一个线程跑epoll_wait发现有数据可读的连接再调用对应的业务回调。回调处理完线程空闲下来继续等下一个事件。这和银行网点的逻辑有点像。传统模型是一个柜员从头陪一个客户到办完业务如果客户在窗口前犹豫半小时柜员只能干等。Reactor模型呢大堂经理epoll负责把有事办的人引导到窗口柜员只处理真正开始办业务的人办完马上叫下一个。这样无论大厅里有多少人排队需要的柜员数量始终是固定的。1.3 为什么成熟的服务器都在用事件驱动Redis、Nginx、Netty底层虽然细节不一样但核心事件循环的方向是共通的。这块逻辑如果搞不清楚回头去看muduo、libevent这些开源网络库的源码也会一头雾水。所以这个项目不只是跑起来的价值它帮你建立的是一个后端网络编程的通用心智模型。我后来看Netty的EventLoop、看Nginx的worker模型基本一眼就能对应到自己在项目里写的那个EventLoop上这种举一反三的感觉是这个项目最大的回报。2. 模块怎么拆EventLoop、Channel、Poller、Buffer的角色划定2.1 六个核心模块的角色划分我实际写下来一个可维护的项目最好拆成六块每个模块职责单一彼此通过接口通信模块职责一句话理解EventLoop事件循环核心跑着不会退出的循环负责调度一切Channel文件描述符封装记住一个fd关心哪些事件、就绪后回掉谁Poller多路复用封装内部就是epoll_create/epoll_wait隐藏系统调用细节TimerQueue定时器管理管理过期任务比如心跳检测和超时关闭ThreadPool线程池承接耗时业务避免拖慢事件循环Buffer读写缓冲区解决一次recv读不完、一次send发不完的问题拆模块有个很直接的好处出了问题你能快速定位。最开始我把日志、io、定时任务全塞在一个文件里跑了不到两天就放弃了根本没法维护。后来按这个结构重写每个类几百行逻辑清晰很多。2.2 单Reactor还是多Reactor这是设计开始时就要定的问题。单Reactor单线程最简单accept、IO回调、业务全在一个循环里因为所有逻辑顺序执行几乎不用考虑并发问题。但缺点是一旦某个回调里做了耗时操作比如查数据库、读本地文件、做复杂的加解密整个服务器都卡住其他连接全部超时。我当时在回调里加了一个sleep模拟慢业务QPS从几万直接掉到几百所有连接集体超时这个画面非常直观。多Reactor的思路是把工作拆成两层一个主Reactor只负责accept新连接然后把连接分发给多个sub-Reactor线程。每个sub-Reactor自己跑一个事件循环负责自己名下连接的IO读写。真正的耗时业务再丢给业务线程池。我最后选择的是多Reactor 业务线程池这也是生产环境中用得最多的组合。代价是代码复杂度上升涉及跨线程唤醒、回调同步问题这部分我放在第四节详细说。2.3 关键接口长什么样EventLoop的公开接口其实就三个核心点loop()启动循环内部调epoll_waitrunInLoop把一个任务放到当前Loop线程执行queueInLoop用于跨线程提交任务。Channel则是把fd和回调绑定在一起class Channel { public: void setReadCallback(std::functionvoid() cb); void setWriteCallback(std::functionvoid() cb); void enableRead(); // 注册读事件 void disableRead(); // 注销读事件 };这套设计我参考了muduo的分层思路但做了精简。核心体会有两点回调函数用std::function非常灵活但必须注意生命周期回调触发时Channel对应的连接可能已经关闭甚至对象已经析构这个坑后面专门讲另外enableRead和disableRead的实现不只是在Channel内部改状态最终要同步到Poller的epoll_ctl上所以Channel里要持有一个指向所属EventLoop的指针。3. epoll事件循环的核心实现Poller封装、LT/ET取舍、Buffer设计3.1 封一层epoll而不是到处裸写epoll的调用链很清晰epoll_create创建句柄epoll_ctl把fd注册进去epoll_wait等待事件。但项目中你绝对不想在每个线程里复制粘贴这些代码。我封装了一个Poller类class Poller { public: Poller(); std::vectorChannel* poll(int timeoutMs); void updateChannel(Channel* ch); private: int epfd_; std::vectorstruct epoll_event events_; };poll()内部调用epoll_wait然后把就绪的fd找到对应的Channel填进返回列表。EventLoop拿到这些Channel后逐个调用它的handleEvent()方法。这里有个非常实用的建议events_容器要提前resize否则高频调用下反复分配内存会浪费很大。我一开始没注意压测时用perf看了一下发现堆分配操作占比高得离谱resize之后明显改善。一个细节epoll_wait的超时时间不要写死。我在实际使用时会把最近一个定时任务的到期时间换算成timeout传进去这样既能及时处理IO事件又能在定时器到期时精确醒来两者兼顾。这个联动逻辑在第五节展开。3.2 LT和ET怎么选epoll支持水平触发LT和边缘触发ET差别在于LT只要数据没读完每次epoll_wait都会通知你ET只有状态变化时通知一次比如从无数据变成有数据。ET一般要配合非阻塞IO循环读到EAGAIN为止。两者对比触发模式通知次数必须非阻塞循环读代码复杂度典型场景LT直到读完不强制低新手友好、低并发够用ET状态变化一次需要高高吞吐、连接数大我项目里最后选的是LT。原因很简单这个项目阶段最重要的是把框架跑稳LT不容易漏读数据能大幅减少定位bug的时间。如果你追求极致性能可以换ET但ET的坑在于如果你没一次性读完数据会等到下一次新事件才触发很容易出现饥饿。要避免就只能set非阻塞、while循环读、碰到EAGAIN退出。不少网络库对ET也是这个套路。做项目阶段我建议先用LT把整体流程吃透再改成ET做对比实验两种模式的区别你会记得非常牢。3.3 Buffer的设计一次recv读不完怎么办这是所有网络库都绕不开的问题。客户端一次send可能发来半个请求也可能一次发来好几个请求recv每次返回的字节数完全是内核说了算。所以不能简单读完就处理必须放进Buffer等攒够了完整包再交给业务层。我的Buffer实现有块最核心的逻辑用readv一次性读两块内存ssize_t Buffer::readFd(int fd, int* savedErrno) { char extrabuf[65536]; struct iovec vec[2]; vec[0].iov_base begin(); vec[0].iov_len writableBytes(); vec[1].iov_base extrabuf; vec[1].iov_len sizeof(extrabuf); const ssize_t n ::readv(fd, vec, 2); // 如果n超过了第一块能装下的空间说明Buffer自身的可写区域不够 // 需要把溢出的部分从extrabuf追加到Buffer尾部 if (n 0) { *savedErrno errno; } else if (static_castsize_t(n) writableBytes()) { writableBytes() - n; } else { size_t first writableBytes(); writableBytes() 0; append(extrabuf, n - first); } return n; }用readv的好处是Buffer内部容量不够时数据可以先落到栈上的临时空间避免漏读。这个方法的巧妙之处在于只做了一次readv系统调用不管是Buffer够用还是不够用都不会出现先读一点发现不够再读一次的浪费。栈上预留64KB的extrabuf强调一点读写缓冲区和业务解析是两码事。网络层只管把字节流可靠地收进来、发出去拆包粘包的处理应该交给业务层去解析别把网络层和协议层糊在一起。注意千万别把Buffer做成无限增长。如果某个客户端一直发但业务层处理不过来Buffer会一直膨胀最后把内存吃光。解决思路是给Buffer设置上限超了直接断开连接或者触发业务层背压。这个我在第五节还会再提一次因为它是压测时一定会遇到的现象。4. 多线程改造里的三个关键动作线程池、eventfd唤醒、锁粒度控制4.1 什么时候非上线程池不可单Reactor单线程其实能扛住的连接量不小只要业务回调足够快两三万QPS是能到的。但真实业务不会总这么理想。我在测试时故意在回调里加了一次模拟耗时操作比如一次磁盘读或数据库查询立刻出现级联超时。原因很直白epoll_wait返回后回调是在事件循环线程上同步执行的一个连接卡住后面所有就绪事件全排队。解决办法是业务线程池。事件循环把封装好的任务投递到线程池队列业务线程用固定的线程数去消费比如8个线程。这样慢业务占的是线程池的资源事件循环线程可以继续处理新的事件不会再互相拖累。线程池的实现不复杂本质上就是一个任务队列加一组工作线程但要注意任务队列需要加锁这个锁的粒度也要控制好。4.2 eventfd跨线程唤醒的正确姿势引入线程池之后你立刻会面对一个问题业务线程处理完了想通知主EventLoop线程我有结果要提交回写怎么通知很多人第一反应是定义一个全局标志加锁去检查但事件循环线程现在正卡在epoll_wait上你要打扰它最优雅的办法是eventfd。方案是EventLoop持有两个东西——一个eventfd一个pendingFunctors任务队列。其他线程需要让EventLoop执行某个回调时加锁把回调放进queueInLoop队列往eventfd写一个1epoll_wait检测到eventfd可读事件循环醒来加锁取出队列里的所有回调解锁逐个执行伪代码如下void EventLoop::queueInLoop(Functor cb) { { std::lock_guardstd::mutex lock(queueMutex_); pendingFunctors_.push_back(cb); } uint64_t one 1; write(wakeupFd_, one, sizeof(one)); } void EventLoop::loop() { while (running_) { std::vectorChannel* activeChannels poller_-poll(timeoutMs_); for (Channel* ch : activeChannels) { ch-handleEvent(); } doPendingFunctors(); } }之所以用eventfd而不是pipe是因为eventfd在信号通知场景里比pipe更轻量pipe需要两个文件描述符每次传输要走消息队列而eventfd一个fd、直接传一个64位整数延迟和开销都小得多。muduo、Netty的唤醒也是这个思路。这里有一个容易踩的坑doPendingFunctors()里执行某个回调时回调又往队列里塞了新任务如果顺手再做一次wakeup会造成循环。所以正确的做法是取出队列后立刻交换一个空的局部队列把锁释放掉再执行回调这样既不会长时间持锁也不会出现重入问题。4.3 锁的粒度短临界区的价值我见过一些新手把事件循环改为多线程后习惯性地给整个handleEvent加锁结果锁竞争成了最大的性能瓶颈。实际上事件循环这个线程对IO事件的处理完全可以不加锁因为连接在当前Loop线程内部是私有数据跨线程只共享两个东西pendingFunctors队列和连接对象本身。正确的锁法是把临界区缩到最短只在往队列里push和取出时持有mutex。mutex本身可能短暂阻塞但因为临界区只有几十纳秒线程之间基本不会互等。改造完成之后我用perf测过锁相关开销比例可以控制在总CPU的2%以内这个数字比较健康如果超过5%说明你的锁粒度设计有问题。5. 连接关闭、定时器失效、Buffer膨胀三个隐蔽Bug的完整复盘5.1 场景一连接关闭后回调仍然触发的段错误这是我在项目里排查时间最长的一个bug场景是压测时突然段错误。排查步骤我完整记录一下用gdb跑core dump输入bt查看栈发现最上层是Channel::handleEventp打印this指针发现Channel对象地址指向一片已释放的内存翻日志发现连接已经触发close回调被析构了但epoll_wait返回的事件列表里还携带着这个Channel结论连接析构顺序和事件回调之间存在竞态为什么会有这个竞态因为epoll_wait是先拿到一批活跃事件再逐个处理。如果第一个事件是某个连接的对端断连这个连接在回调里被释放了但同一批事件列表里还有同连接的另一条IO记录处理到后面那条时指针已经悬空。修法分三层连接析构前必须调用poller_-removeChannel注销fd监听Channel分发时用shared_ptr临时接管保证对象在回调执行期间不会被提前析构更彻底的做法是延时释放把要析构的连接放进一个pending队列等当前事件批量处理完再统一删除。我的最终方案是批量处理完后统一清理同时在Channel分发时用shared_ptr临时接管兜底。这个Bug排查完我对事件驱动模型里对象生命周期的理解才算真正到位。5.2 场景二定时器和epoll_wait超时时间的联动服务器要做心跳检测、连接超时关闭就得有定时器。问题在于事件循环线程一直在epoll_wait上怎么定时执行任务答案是把定时器时间换算成epoll_wait的timeout。比如当前最近的一个定时任务是3秒后到期那epoll_wait的timeoutMs就传3000。到点之后无论有没有IO事件epoll_wait都会返回这时去检查定时器堆把超时任务全部执行。定时器容器我选了最小堆每次取堆顶就是最近要到期的任务查看O(1)、插入删除O(log n)。为什么不用时间轮因为时间轮适合大量有规律、时间跨度小的任务比如每秒上万的过期key清理连接超时这种场景任务量不大、时间跨度随机最小堆实现更简单也更好调试。这里的坑在取消定时器。客户端如果正常断连对应的心跳定时器如果不取消到期后回调会访问一个已经不存在的连接。我当时没有设计独立的TimerId直接在删除连接时遍历堆去查找导致复杂度退化且容易漏删。后来改成每个定时器创建时返回一个唯一id配合哈希表做O(1)取消问题就干净了。5.3 场景三慢客户端导致的Buffer膨胀压测到后期我会故意让一部分测试连接只收不发模拟慢客户端。很快发现内存占用不断上涨原因是服务器给这些连接发送数据时socket发送缓冲区满了剩余数据堆积在应用层Buffer里而连接一直不关闭、事件循环不断重试写Buffer越积越多。处理方法是给Buffer设置高水位线。当未发送数据超过阈值就停止从业务层接收新消息等对端消费掉一部分、水位回落后再继续。如果对端一直不消费超时后直接断开连接。TCP层面可以配合调整发送缓冲区大小但应用层必须有这道防线。这也是很多线上服务设置单连接最大未发送字节数的原因。6. 压测数据与面试复盘这个项目怎么变成你的谈资6.1 压测方法与实测数据我用的是自写的简易压测客户端加ab工具做对比压测场景是echo服务也就是收到什么返回什么。机器配置是普通的8核16G云主机。单Reactor单线程版本长连接压测大约能到2.5万QPS改成多Reactor加业务线程池后用8个sub-Reactor线程加8个业务线程QPS能到8到10万延迟P99从压测初期的4到5毫秒降到1毫秒左右。这个数据在不同机器、不同网络环境下波动很大不必纠结绝对值。关键是压测过程中发现的瓶颈点这些才是调优的价值。比如我是在压测时才注意到events_容器反复分配、Buffer反复扩容的问题。6.2 三个性价比极高的优化点减少系统调用用readv一次读多个缓冲区、用writev一次聚合多个输出块把零散的send合并成一次系统调用对吞吐提升非常明显。调高文件描述符上限ulimit -n默认1024压测几千连接就会命中。调成100万之后连接数才不再是瓶颈。这一步成本几乎为零但最容易被忽略。设置TCP_NODELAY禁用Nagle算法避免小块数据被内核缓存延迟发送。对实时性要求高的场景帮助很大对纯echo压测也有可感知的提升。6.3 面试复盘面试官追问杀伤力最大的几个问题这个项目写进简历你至少要能回答这几个问题。第一个是Reactor和Proactor的区别。Reactor是同步事件多路复用IO操作由用户线程完成Proactor把IO操作交给系统完成后通知用户Windows的IOCP是典型代表。一个很加分的说法是Reactor是通知你可以读了Proactor是通知你已经读完了。第二个是epoll的LT和ET区别以及为什么这样选。回答时主动说我对比过LT写起来更稳不容易漏数据如果要追求极高性能可以切ET配合非阻塞IO循环读但必须处理EAGAIN。第三个是如果一个回调函数里做了阻塞操作会怎样。这题考你对单线程事件循环的理解。要回答阻塞的是事件循环线程整个服务器的所有连接都会等它然后再说你的设计耗时操作丢线程池线程池回写通过eventfd唤醒。第四个是服务器最多能扛多少连接。这题考操作系统资源理解。C10K不是极限理论上只要文件描述符上限够、内存够几十万连接是能做到的。重点是活跃连接数和CPU处理能力而不是连接数本身。面试时大概率会问这个项目里最难的Bug是什么建议就用第五节说的连接悬空问题把整个排查链路讲清楚从段错误、gdb bt、到发现事件列表和析构竞态、再到三层修复方案。这段经历比背十道八股文都加分因为它证明了你是真的在调优中理解了这个框架。我做完这个项目最大的收获不是把QPS调到多高而是真正建立了对事件驱动这个词的体感。以后不管是看Nginx的worker模型、Redis的aeEventLoop还是在Java那边用Netty你一眼就能看出它们在哪个环节做了什么事。如果你准备动手写我建议先用单线程单Reactor把整个流程跑通再加线程池再做多Reactor难度一步步加别一开始就照着muduo全量复刻那样容易把自己劝退。中间遇到段错误、死锁、Buffer膨胀都不奇怪每排完一个坑你对C服务器开发的理解就深一层。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进