复刻Muduo TcpServer:用epoll事件驱动构建高并发回显服务器

发布时间:2026/10/17 4:04:30
复刻Muduo TcpServer:用epoll事件驱动构建高并发回显服务器 国内做C网络编程的同学基本都绕不开Muduo这个名字。它的半同步半异步事件驱动模型让高并发网络服务的写法从“手写线程池裸socket”里稍微解脱出来一点。这次我做了个简化复刻重点实现TcpServer模块然后顺手搭了一个回显服务器验证整条链路。项目代码量不大但覆盖了监听、事件分发、连接管理、缓冲区读写、回调触发这些网络库的核心环节。对想搞懂高性能网络库内部结构的同学来说是个很合适的切入点对已经在写业务代码但没碰过事件循环的同行也能帮你理解框架底层到底在做什么。1. 从裸socket到事件驱动TcpServer模块到底要解决什么问题1.1 传统阻塞式写法的问题出在哪里先看最朴素的服务端写法。很多初学者接触TCP编程时都是下面这个套路创建监听socketbindlisten然后在while循环里accept每来一个连接就开一个线程去处理。单个连接的时候没问题几个客户端同时连也能扛住但并发一上来麻烦就非常明显了。每个连接占一个线程线程数量跟着并发连接数线性增长。操作系统的线程数量有上限线程切换本身也要消耗CPU。假设有几千个并发连接每来一个连接就创建线程光上下文切换就能把CPU吃满服务响应时间会越来越长最后整个进程直接不可用。更难受的是这些线程大部分时间都阻塞在read上没干实事却在占资源这属于典型的资源浪费。阻塞IO还有一个麻烦就是程序同时要关注很多socket时只能靠select或者poll轮询。select有FD_SETSIZE上限poll没有上限但每次都要把所有fd从头到尾扫一遍连接多了以后轮询开销巨大。真正的问题在于连接数量很多但真正活跃的连接很少大量时间都浪费在没有事件发生的fd上。1.2 事件驱动模型的核心思路eopll模型的出现就是为了解决这个问题。事件驱动的思路很直白如果某个fd上有事件发生内核主动通知应用程序程序只需要处理这些活跃的fd不需要一遍遍轮询所有连接。这样做的好处很明显。单线程事件循环就能管理成千上万个连接因为epoll内部用红黑树管理fd每次只返回有事件发生的fd列表。即使100个连接里只有1个在发数据程序也只需要处理那1个活跃连接CPU开销只在有事件发生时产生。这个模型跟现实生活很像。班主任不可能挨个问班里五十个学生“你有没有问题”而是让学生有问题主动举手有举手的才处理。学生就是fd举手就是事件班主任就是事件循环。人还是那个班主任但处理效率提升了不止一个量级。1.3 复刻时确定的架构边界完整复刻Muduo工作量不小本次项目我把边界切成了两部分。TcpServer模块只负责IO事件的分发、连接的接受与关闭、数据到达后的回调触发具体业务逻辑不掺和进来。业务层通过注册回调函数来感知连接建立、消息到达、连接关闭等事件。模块对外暴露三类回调连接建立与关闭回调、消息到达回调、写完成回调。我在这一版里还做了一个简化没有把独立的计算线程池纳入TcpServer内部而是把重心放在事件循环和多连接管理上。数据缓冲方面我实现了一个简化版Buffer避免每条消息都重新分配内存。连接生命周期用shared_ptr托管避免回调触发时连接对象已经被销毁导致悬空指针。边界清晰之后回显服务器的实现就变得非常简单业务层只需要在消息回调里把数据原样写回网络细节全部由TcpServer内部搞定。2. TcpServer模块核心类拆解从EventLoop到TcpConnection把网络模块拆开细看最难的不是某单个类而是理解这些类如何协作。TcpServer看起来是门面入口真正干活的是EventLoop、Channel、Acceptor、TcpConnection这几个对象。复刻时我把每层尽量压到最简每层只做自己的事回调通过绑定逐层传递。2.1 EventLoop与Channel的协作关系EventLoop是事件循环核心内部持有epoll实例对外暴露updateChannel、removeChannel、loop等接口。loop函数调用epoll_wait拿到活跃事件列表后逐个调用Channel对应的回调。Channel本质上是对fd和事件类型的封装。每个Channel绑定一个fd保存这个fd上感兴趣的事件比如EPOLLIN、EPOLLOUT。当epoll_wait返回后EventLoop根据返回的事件类型调用Channel的handleEvent方法。为什么不在EventLoop里直接存fd和回调非要包一层Channel因为同一个fd在不同阶段关心的事件和需要触发的函数可能不一样。监听socket accept完新连接后短时间内不需要关心写事件客户端fd则可能一会儿关心可读一会儿关心可写。Channel把这些变化封装起来提供setReadCallback、setWriteCallback等接口EventLoop只做无脑分发不需要知道具体逻辑。Channel注册到EventLoop时我把Channel指针放在epoll_event的data.ptr字段里。这样epoll_wait返回后能直接通过指针拿到Channel不用再根据fd查表。这个细节虽然省不了多少毫秒但在高频事件分发场景下值得养成习惯。有一个必须遵守的约定Channel的回调都绑定在所属的EventLoop线程上执行绝对不能跨线程直接调用。实际操作中如果需要从别的线程触发某个fd操作我会用queueInLoop把函数丢回IO线程让它在正确的线程上下文中执行。2.2 Acceptor如何把新连接交给TcpServerAcceptor负责监听套接字内部包含一个listen fd构造时完成socket、bind、listen三个步骤。这里有个关键点listen fd必须设置成非阻塞否则accept会被慢速客户端拖住整个事件循环都跟着卡住。当有新连接到达时epoll返回EPOLLIN事件Channel调用Acceptor的handleRead。handleRead里不能只accept一次而要循环accept直到返回EAGAIN。原因是epoll只通知“有连接来了”但可能进程睡眠期间已经积累了很多连接一次全部收完可以避免请求积压。每次accept成功拿到新的conn fd传给TcpServer的newConnection方法。TcpServer在这一层根据IO线程分配情况创建TcpConnection对象绑定好connectionCallback和messageCallback把连接插入连接管理表。管理表我用了unordered_mapkey是conn fdvalue是shared_ptr 。如果accept时返回的errno是EMFILE说明进程的fd表已经满了。这种情况不能简单忽略也不能硬着头皮继续收否则会出现惊群甚至死循环。常见做法是暂时停止accept等fd数量下降后再恢复监听。这个细节在压力测试时很容易踩到。2.3 TcpConnection的生命周期与缓冲区设计TcpConnection是真正处理数据的对象封装了客户端fd、Channel、输入输出Buffer。它的生命周期是整个模块最容易被搞崩的地方。为什么容易被搞崩事件循环是异步触发回调的某个时刻连接对端断开同一时刻业务层可能还在处理这个连接上的数据。如果连接对象已经释放回调里再去访问成员变量就是典型的使用已释放内存直接段错误。解决办法是用shared_ptr管理TcpConnection。TcpServer的连接管理表持有一个shared_ptrTcpConnection内部在用shared_from_this生成临时shared_ptr传给回调。客户端断开时先从管理表中移除自己的shared_ptr引用计数归零后对象安全销毁。整个过程里如果某个回调还持有一份shared_ptr对象就不会被释放安全性得到了保证。Buffer设计参考了Muduo的思路。核心结构是vector 配合readIndex和writeIndex两个索引。读数据时从fd读到Buffer的writeIndex位置业务层从readIndex开始取数据。当writeIndex到达vector末尾时如果读过的数据占了不少空间先移动数据再分配减少内存重新分配的频率。对回显服务器这种小数据量场景可能看不出性能差异但换成高频消息服务就能明显感受到好处。3. 手把手搭建回显服务器架构拆完该动手写代码了。回显服务器的目标很明确客户端发送任何内容服务器原样返回。功能足够简单又能完整覆盖TcpServer所有核心节点接受连接、触发消息回调、写回数据、关闭连接。3.1 搭建最小依赖骨架先把最小文件集列出来。这个项目我没有引入任何第三方库只依赖标准库和epollEventLoop.h/cpp事件循环封装Channel.h/cppfd事件分发Acceptor.h/cpp监听与acceptTcpConnection.h/cpp连接对象内含BufferTcpServer.h/cpp门面类整合上面所有模块EchoServer.cpp回显服务器入口EventLoop核心的loop函数本质上就这几行void EventLoop::loop() { while (!quit_) { int numEvents epoll_wait(epfd_, events_, kMaxEvents, timeoutMs); for (int i 0; i numEvents; i) { Channel* ch static_castChannel*(events_[i].data.ptr); ch-handleEvent(events_[i].events); } } }这个实现的关键在于epoll_event的data.ptr直接保存了Channel指针epoll_wait返回后不需要任何查找就能拿到Channel对象。这比“用fd在map里查一次”多省一次哈希计算在高并发下有一定意义。3.2 注册回调把读到的数据原样写回回显服务器业务逻辑全部在main函数和回调里。主流程是这样的int main() { EventLoop loop; TcpServer server(loop, 0.0.0.0, 9877); server.setMessageCallback([](const TcpConnectionPtr conn, Buffer* buf) { std::string msg buf-retrieveAllAsString(); conn-send(msg); }); server.setConnectionCallback([](const TcpConnectionPtr conn) { if (conn-connected()) { printf(client connected: %s\n, conn-peerAddress().c_str()); } else { printf(client disconnected\n); } }); server.start(); loop.loop(); return 0; }设置messageCallback之后TcpServer内部每次收到数据会把对应连接的输入Buffer交给业务层。业务层拿着字符串原样调用conn-send发回客户端。这就是回显的全部逻辑网络细节完全不用管。TcpConnection::send这个接口要单独解释。它对socket执行非阻塞write如果数据一次没写完剩余部分追加到输出Buffer等下次EPOLLOUT事件触发时继续发送。这样设计是必要的因为非阻塞socket上write可能只写入一部分字节不能假定一封数据调用一次write就能全部发出。3.3 编译、启动与联调验证编译时我用cmake组织图省事时一行g也能搞定g -stdc17 -o echo_server EchoServer.cpp EventLoop.cpp Channel.cpp Acceptor.cpp TcpConnection.cpp TcpServer.cpp -lpthread启动服务器后另开终端用nc连接nc 127.0.0.1 9877客户端输入hello服务器立即返回hello。多开几个客户端同时输入不同内容服务器都能正确回显说明多连接事件分发正常。再顺手测试断开场景在客户端按CtrlC此时服务器不能崩溃要能正常处理连接关闭事件。如果想做稍微大一点的并发验证可以用一个几行脚本模拟多个客户端同时发消息。import socket import threading def worker(): s socket.create_connection((127.0.0.1, 9877)) s.sendall(bping) data s.recv(4) assert data bping s.close() threads [threading.Thread(targetworker) for _ in range(500)] for t in threads: t.start() for t in threads: t.join() print(all done)这个脚本会创建500个客户端连接每个连接发一条消息并验证回显结果。单线程事件循环跑这个测试完全没问题这也能直观感受到事件驱动比“每连接一线程”在资源占用上的优势。4. 调试过程中踩过的坑与排查思路复刻网络库最折磨人的不是写代码环节而是调试。事件驱动、非阻塞IO、生命周期管理这三样任何一样出问题表象都可能是崩溃、丢数据或者进程直接退出。4.1 客户端断开后收到可读事件却没数据epoll有一个容易忽略的行为对端关闭时本端的epoll也会返回EPOLLIN事件但这时读取数据read返回0表示对端已关闭。很多人只判断read返回值大于0对返回0的情况没有处理导致这个fd的事件会反复触发CPU空转fd泄漏。正确做法是在Channel可读事件处理里对read返回0的情况立刻执行连接关闭流程清理epoll注册、触发connectionCallback通知业务层、从TcpServer连接表里移除连接。我在实现里把连接关闭也设计成回调确保业务层能感知连接断开。4.2 非阻塞IO必须处理EAGAIN将连接设为非阻塞后read和write都可能返回-1同时errno被设为EAGAIN表示“当前没有数据可读”或“缓冲区已满”。这时候绝不能当成错误处理。否则只是某个瞬间数据没准备好连接就会被误杀客户端会莫名其妙地遇到连接被重置。我在循环read时遇到过这个问题。每次读出数据后还要继续尝试读直到EAGAIN出现才停。这个循环在处理大量突发数据时很有用能减少epoll_wait的调动次数。但如果不判断EAGAIN循环就可能在读到-1时直接退出并触发错误处理好好的连接被干掉。4.3 粘包与半包回显服务器到底要不要处理回显服务器不处理粘包也能正常“工作”因为它是把读到的所有字节原样写回不分消息边界。但如果在真实业务里照搬这个做法比如客户端发来的是JSON或者固定结构体就必须处理半包和粘包问题。TCP本身是字节流协议不保证一次read能读到一个完整的应用层消息。可能一次read里包含了两条消息也可能一条消息分多次到达。常见的解决办法是自定义协议在消息头部带上长度字段收到数据后先解析包头再按长度读取完整消息。回显服务器阶段可以先不实现这套逻辑但理解它是写其他业务协议的基础。4.4 连接对象悬空与跨线程回调我在调试过程中还遇到过一个比较隐蔽的崩溃问题业务回调里引用了连接对象但连接在回调执行前已经被关闭销毁。用shared_ptr管理连接后这种问题大大减少但不能完全放松警惕。还有一个跨线程问题。EventLoop只允许在单线程里跑所有回调默认也只在那个线程执行。如果业务层在另一个线程直接调用conn-send会对同一个Buffer做并发写产生数据竞争。我的处理方式是让send接口保证线程安全如果不是当前IO线程调用就把发送任务打包成std::function丢进EventLoop的任务队列通过wakeup机制唤醒事件循环让send在正确的线程中执行。4.5 忽略SIGPIPE与处理EMFILE另一个容易踩的坑是SIGPIPE。服务器向一个已经关闭的连接写数据时操作系统会发送SIGPIPE信号默认行为是终止整个进程。如果服务器不做任何防护一次客户端异常退出就可能把整个进程干掉。解决办法是在main函数开头忽略SIGPIPEsignal(SIGPIPE, SIG_IGN);忽略之后write仍然会返回错误码但进程不会退出错误留给上层逻辑去处理。EMFILE这个问题在压力测试时遇到过一次。当时一次性创建了大量连接系统fd数量到达上限accept返回EMFILE。如果不作处理accept会反复触发EPOLLIN导致事件循环空转。我当时采取的方案是遇到EMFILE先暂停监听适当关闭一些空闲连接后再恢复。回显服务器场景可能不常遇到但生产级服务一定要考虑。项目做到这里我最大的体会是TcpServer这类网络模块代码量不如业务系统大但每一行都在跟并发和资源生命周期打交道。需要理解的不只是epoll怎么调用更是对象在哪个时机会被销毁、回调在哪个线程执行。我建议想深入网络编程的同学不要急着抄一份完整的高性能网络库先从小而复刻做起把回显服务器跑通之后再去看并发模型和线程池理解会清晰很多。后续在这个架构上可以继续加东西比如计算线程池处理耗时业务或者内嵌简单的长度前缀协议解析器让模块往生产方向再进一步。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询