简介:这套资源是基于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_wait;runInLoop把一个任务放到当前Loop线程执行;queueInLoop用于跨线程提交任务。Channel则是把fd和回调绑定在一起:
class Channel { public: void setReadCallback(std::function<void()> cb); void setWriteCallback(std::function<void()> 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::vector<Channel*> poll(int timeoutMs); void updateChannel(Channel* ch); private: int epfd_; std::vector<struct 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_cast<size_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写一个1
- epoll_wait检测到eventfd可读,事件循环醒来
- 加锁取出队列里的所有回调,解锁,逐个执行
伪代码如下:
void EventLoop::queueInLoop(Functor cb) { { std::lock_guard<std::mutex> lock(queueMutex_); pendingFunctors_.push_back(cb); } uint64_t one = 1; write(wakeupFd_, &one, sizeof(one)); } void EventLoop::loop() { while (running_) { std::vector<Channel*> 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::handleEvent
- p打印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++服务器开发的理解就深一层。
本文还有配套的精品资源,点击获取