很多人学muduo是从仿写开始的很多人仿写muduo最后卡死在EventLoop上。我不算天才型选手EventLoop这个模块前前后后写了三版第一版连基本的事件分发都跑不起来第二版能跑但跨线程调用延迟严重第三版才算把结构理顺。今天这篇博文就是想把我写完EventLoop之后对整个模块的理解、实现思路、关键代码、以及最容易出问题的几个细节完整梳理一遍给正在啃muduo源码、准备仿写网络库或者只是想搞清楚Reactor模型底层原理的同学一个参考。muduo是陈硕开发的一套基于Reactor模式的C多线程网络库核心思想是one loop per thread也就是每个线程跑一个事件循环。EventLoop就是这个循环本身它做三件事等事件、分事件、执行回调。听起来简单但真正把它落实到代码上你会发现要处理的细节远比想象中多比如线程安全、唤醒机制、定时器整合、Channel生命周期管理每一项都值得展开细说。我按照自己实现时的顺序来讲先从整体架构切入再逐步深入每个子机制最后是踩坑记录。这样你既有一个全局视角也能在具体实现时直接参考我的做法。1. 仿写之前先想清楚EventLoop在整个网络库里到底扮演什么角色如果说muduo是一台机器Acceptor、TcpConnection、Connector这些都是挂在机器上的零件EventLoop才是驱动这台机器的电机。电机一转所有零件才跟着动。一个标准的muduo进程会有多个EventLoop实例主线程一个每个工作线程一个。主线程的EventLoop负责监听新的TCP连接连接建立后把TcpConnection对象分配到一个工作线程的EventLoop上之后这个连接上的所有IO事件都由那个EventLoop驱动。每个EventLoop独占它所负责的Channel、Timer、Fd任何跨线程操作都必须绕回到目标EventLoop所在线程执行。用一个最直观的类比EventLoop就像一个前台服务员。客人网络事件来了服务员挨个告诉对应的窗口Channel去处理同时后台同事其他线程想给某个窗口传话跨线程任务他们不会直接插队而是把便签pendingFunctor放到服务员的篮子里服务员忙完手头的事再统一处理。如果服务员不在线程阻塞在poll中同事就需要按一下服务铃wakeup让服务员注意到有新任务。想清楚这个角色定位之后EventLoop的职责边界就清晰了它也决定了EventLoop的核心成员有哪些Poller封装IO多路复用负责真正等事件Channel描述一个fd及其感兴趣的事件、回调TimerQueue负责定时任务wakeupFd和wakeupChannel负责跨线程唤醒pendingFunctors和mutex保护跨线程任务的缓冲队列。这里我想先强调一点如果你直接照抄源码很容易把EventLoop背下来却理解不了它为什么这样设计。仿写这件事最重要的不是和muduo源码保持一致而是理解每一行代码背后的权衡。EventLoop之所以长成这个样子不是因为陈硕喜欢这么写而是因为它必须同时满足事件分发、线程安全、定时调度、生命周期管理这四个硬需求。2. EventLoop的成员设计与构造函数里那些不显眼的细节我先把EventLoop的核心成员列出来你可以对照muduo源码看这里的每一样都不是摆着好看的class EventLoop : noncopyable { public: using Functor std::functionvoid(); EventLoop(); ~EventLoop(); void loop(); void quit(); void runInLoop(Functor cb); void queueInLoop(Functor cb); bool isInLoopThread() const { return threadId_ std::this_thread::get_id(); } private: std::thread::id threadId_; bool quit_ false; std::unique_ptrPoller poller_; std::vectorChannel* activeChannels_; int wakeupFd_; std::unique_ptrChannel wakeupChannel_; std::unique_ptrTimerQueue timerQueue_; mutable MutexLock mutex_; std::vectorFunctor pendingFunctors_; bool callingPendingFunctors_ false; Timestamp pollReturnTime_; };2.1 核心成员变量一览与设计理由先把每个成员变量和它的设计理由理清楚这比背代码有用得多成员职责关键设计理由threadId_记录创建线程ID用于isInLoopThread()校验从运行层面保证one loop per threadpoller_IO多路复用封装隔离poll/epoll差异方便切换底层实现activeChannels_活跃事件集合每次poll后由Poller填充EventLoop统一分发wakeupFd_eventfd文件描述符实现跨线程唤醒比pipe少一个fd开销更小wakeupChannel_wakeupFd_的Channel封装让wakeupFd_纳入Poller的监控而不是裸操作timerQueue_定时器管理用timerfd和set实现到期调度融入统一事件循环mutex_pendingFunctors_跨线程任务队列保护队列操作避免多个线程同时push造成数据竞争callingPendingFunctors_标记doPendingFunctors执行中在queueInLoop里判断是否需要wakeup的关键标志位这里要特别解释一下为什么需要wakeupFd_。事件循环最核心的操作就是阻塞在poller_-poll()上等待IO事件。如果IO线程此时正阻塞着另一个线程往pendingFunctors_里塞了一个任务IO线程不会知道。没有eventfd的话这个新任务要一直等到epoll_wait超时或者某个IO事件触发才会被执行延迟可能达到秒级。有了eventfd其他线程写一个字节IO线程的epoll_wait立刻返回任务得以快速执行。activeChannels_为什么存的是裸指针Channel*因为muduo中Channel的所有权要么在栈上要么由TcpConnection等对象持有EventLoop只负责调用不负责管理生命周期。也是这个原因后面才有了Channel::tie的补丁设计这个我们到踩坑篇再展开。2.2 构造函数里的事件注册顺序构造函数里做了四件事顺序很重要EventLoop::EventLoop() : threadId_(std::this_thread::get_id()), wakeupFd_(createEventfd()), wakeupChannel_(new Channel(this, wakeupFd_)), timerQueue_(new TimerQueue(this)) { wakeupChannel_-setReadCallback([this] { handleRead(); }); wakeupChannel_-enableReading(); }第一件记录threadId_之后这个EventLoop就只能在这个线程里运行。第二件创建wakeupFd_也就是eventfd。第三件为wakeupFd_创建Channel。第四件创建TimerQueue。比较微妙的是构造函数末尾wakeupChannel_-enableReading()这一步。如果你在仿写时把enableReading放在构造函数里它会触发Poller的updateChannel而updateChannel需要EventLoop内部的poller已经创建好。所以成员初始化列表里poller必须排在wakeupChannel_之前初始化。这个顺序问题在源码里看着不明显但自己写一遍就会撞上。我自己的仿写版本直接在构造函数末尾调用updateChannel(wakeupChannel_.get())效果一样逻辑更直白。2.3 eventfd的创建细节eventfd是Linux提供的一个专门用于事件通知的文件描述符它维护一个uint64_t计数器。往里写一个整数计数器加一读一下计数器清零。它的好处是不需要像pipe那样创建一进一出两个fd也不需要像socketpair那样处理发送缓冲它就是一个专门为通知别人我有事而生的东西。int createEventfd() { int evtfd ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); if (evtfd 0) { perror(eventfd error); abort(); } return evtfd; }EFD_NONBLOCK很重要。eventfd如果计数器达到一个极大值实际不太可能写入会阻塞更重要的是wakeupChannel在读事件处理中读取wakeupFd时非阻塞模式能避免意外卡住。EFD_CLOEXEC则是防止执行exec时泄漏fd这在编写正式服务时尤其要养成习惯。构造函数的另一个关键设置是wakeupChannel_只注册了读事件enableReading没有注册写事件。这背后的逻辑是wakeupFd_在EventLoop中只承担一个职责即接收唤醒信号并消费掉它永远不需要被写出数据写事件因此没有意义。如果你在仿写时图省事把所有fd都注册了读写事件你会在日志里看到一堆无意义的可写事件回调而且会让epoll的返回活跃事件数量显著增加白费CPU。3. loop()主循环看似只有几行实际上藏着整个模块的调度哲学直接上代码这是EventLoop最核心的部分我做了简化去掉了日志void EventLoop::loop() { assertInLoopThread(); while (!quit_) { activeChannels_.clear(); pollReturnTime_ poller_-poll(kPollTimeMs, activeChannels_); for (Channel* channel : activeChannels_) { channel-handleEvent(pollReturnTime_); } doPendingFunctors(); } }读起来很简单就是循环三件事poll等事件、分发事件、执行pendingFunctors。但有几个细节值得展开。3.1 为什么开头要assertInLoopThreadassertInLoopThread()是muduo里一个非常硬核的校验。如果发现loop()被一个非创建者线程调用会直接abortvoid EventLoop::abortNotInLoopThread() { LOG_FATAL EventLoop::abortNotInLoopThread - EventLoop this was created in threadId_ threadId_ , current thread id std::this_thread::get_id(); }这是一个很保险的设计等于把one loop per thread变成了运行时检查。我自己仿写时刚开始没加这个assert结果某一次在业务线程里误调了loop()导致事件在所有线程乱跳bug极难排查。加了这个断言之后至少能保证错误及时暴露而不是把事情搞成一锅粥后靠人肉debug。3.2 kPollTimeMs为什么是10秒而不是无限阻塞muduo源码里kPollTimeMs的值是10000单位是毫秒约10秒。我第一次看的时候很疑惑为什么epoll_wait不直接设成-1无限等后来想通了这10秒是兜底。正常运行时所有唤醒和事件都由eventfd和真实IO事件触发不会真的等到10秒但如果某些诡异场景导致所有唤醒路径都失效了比如eventfd被错误读取Loop至少每10秒能醒来一次避免永久静默。这个值是陈硕在源码里专门设置的网上很多解析文章会略过。我建议你自己写的时候也保留一个类似的兜底超时不要设-1。3.3 activeChannels_和handleEvent的配合poll()返回时Poller会把当前有事件的Channel指针填充到activeChannels中。随后EventLoop逐一对其调用handleEvent。要注意的是Channel::handleEvent内部会根据revents判断是读事件、写事件、错误事件还是关闭事件再调用用户设置的回调。这里有一个容易被忽略的残酷现实如果一个Channel在handleEvent的过程中被自己的回调销毁了比如TcpConnection的closeCallback里顺手把连接对象释放了那activeChannels_里残留的指针就成了悬垂指针下次handleEvent时就是use-after-free。muduo通过Channel::tie和EventLoop::removeChannel来解决这个问题这也是我在踩坑章节要重点说的内容。3.4 doPendingFunctors为什么要排在事件分发之后doPendingFunctors的位置不是随便放的它一定要放在所有活跃Channel的事件处理完之后。为什么因为muduo的设计中这些functor代表的是跨线程提交给IO线程执行的任务它应当被尽快执行但又不能打断当前正在处理的事件。放在for循环之后既保证了当前批次事件处理完整又能尽快处理新任务。如果反过来先执行doPendingFunctors再处理事件你可能会看到一些有趣但危险的竞态比如一个连接刚在functor里被关闭紧接着同一个Channel又处理了一个读事件。从调度优先级来说IO事件有实时性要求跨线程任务一般可以稍等因此这个顺序安排是合理的。4. 跨线程调用与wakeuprunInLoop和queueInLoop是如何协作的有了loop()之后一个立刻要面对的问题就是其他线程怎么把一个任务安全地丢给IO线程执行muduo的答案是两个函数void EventLoop::runInLoop(Functor cb) { if (isInLoopThread()) { cb(); } else { queueInLoop(std::move(cb)); } } void EventLoop::queueInLoop(Functor cb) { { MutexLockGuard lock(mutex_); pendingFunctors_.push_back(std::move(cb)); } if (!isInLoopThread() || callingPendingFunctors_) { wakeup(); } }4.1 runInLoop与queueInLoop的职责划分runInLoop的核心逻辑是如果调用方就是IO线程本身那太好了直接同步执行回调避免一次入队出队的开销如果不是则将回调打包成Functor放入pendingFunctors_队列等待loop()在某个时机取出来执行。这个当前线程检查回退到队列的模式看似简单其实省掉了大量不必要的加锁入队操作。你在仿写时很可能会遇到的一个问题是大量从IO线程内部提交的任务也走了queueInLoop导致每个任务都要加锁、入队、出队性能下降明显。我在第二版代码里就犯了这个错后来发现单线程内直接调用cb()配合IO线程自身的回调执行机制吞吐量能差出几个数量级。4.2 queueInLoop末尾的if条件为什么是关键queueInLoop的末尾那个if条件是整个EventLoop跨线程机制里最容易被人忽视的精妙点我详细说一下。!isInLoopThread()好理解外部线程提交了任务当然要wakeup因为IO线程此时很可能正阻塞在epoll_wait上不唤醒它任务就迟迟得不到执行。关键是后半段|| callingPendingFunctors_。callingPendingFunctors_这个标志位在doPendingFunctors执行期间为true。为什么在doPendingFunctors执行期间提交的任务也需要wakeup因为doPendingFunctors的执行过程不是原子的它先把pendingFunctors_里的内容swap到一个局部变量functors里再逐个执行。如果IO线程在swap之后、自己又向队列里添加了新任务那么这次doPendingFunctors已经不会再处理这些新任务了。新任务会留在pendingFunctors_里要等到下一轮for循环才能被执行。可是下一轮循环的第一步就是poll并阻塞。如果这个新任务恰好是在swap之后、doPendingFunctors还没结束时由IO线程自己提交的而queueInLoop里没有wakeup那IO线程在完成了本轮doPendingFunctors后会再次进入poll并阻塞直到某个外部事件到来。这个外部事件可能10秒后才来也可能永远不来新任务就被无限期搁置了。这显然是错误的。所以要加wakeup让IO线程从epoll_wait里醒来在下一轮循环中立刻执行pendingFunctors里的任务。这个细节是muduo源码中一个非常经典的设计也是面试官最爱问的点。如果你仿写时不加这个判断你的EventLoop在特定时序下会出现跨线程任务延迟数十秒的诡异现象而且极难复现。4.3 doPendingFunctors的swap为什么比直接遍历强接下来是doPendingFunctors的实现void EventLoop::doPendingFunctors() { std::vectorFunctor functors; callingPendingFunctors_ true; { MutexLockGuard lock(mutex_); functors.swap(pendingFunctors_); } for (size_t i 0; i functors.size(); i) { functors[i](); } callingPendingFunctors_ false; }这里的关键是swap而不是直接遍历pendingFunctors_。为什么要swap因为如果在遍历pendingFunctors_期间持锁其他线程的queueInLoop就会阻塞在加锁处导致跨线程提交任务被卡死。swap将队列一次性迁移到局部变量然后在锁外执行回调这样queueInLoop几乎永远不会长时间阻塞。锁的粒度被压到了最小只有swap这一瞬间。还有一个值得注意的点是执行完所有functors之后callingPendingFunctors_被置回false。这样当IO线程下一轮在queueInLoop里看到这个标志时又能正确地判断是否需要wakeup。wakeup()本身的实现也很直接就是往eventfd里写一个uint64_tvoid EventLoop::wakeup() { uint64_t one 1; ssize_t n ::write(wakeupFd_, one, sizeof(one)); if (n ! sizeof(one)) { LOG_ERROR EventLoop::wakeup() writes n bytes instead of 8; } }eventfd的计数器加上1之后内核就会把wakeupFd_标记为可读epoll_wait立刻返回。IO线程进入handleRead把计数器读回0继续做它该做的事。整个过程毫秒级完成。这里要特别提醒一点eventfd是电平触发level-triggered的。也就是说如果wakeup()写了数据但handleRead没把计数器读走wakeupFd_会一直处于可读状态epoll_wait会一直立即返回造成忙轮询CPU会烧得很高。所以handleRead里必须确保把8字节读走void EventLoop::handleRead() { uint64_t one 1; ssize_t n ::read(wakeupFd_, one, sizeof(one)); if (n ! sizeof(one)) { LOG_ERROR EventLoop::handleRead() reads n bytes instead of 8; } }如果你读了小于8字节比如0或者-1日志会告诉你read出现了异常。正常情况下eventfd读取要么返回8要么在非阻塞模式下返回EAGAIN当计数器为0时。handleRead里读到EAGAIN不应该报错因为可能是有多个线程同时wakeup而其中一些read发生了竞争。所以更健壮的做法是检查errno是否为EAGAIN否则打日志。我自己的版本是直接忽略EAGAIN。在这个模块的仿写过程中线程安全这块我踩的坑最多。我尝试过直接在业务线程里调用ioLoop-queueInLoop也尝试过在多个线程同时往一个EventLoop里塞任务结果就是要么任务丢失要么队列里出现重复。这些问题归根结底都是对muduoone loop per thread理念理解不透所有对EventLoop共享状态的访问都必须经由EventLoop所在线程完成跨线程访问一律走queueInLoop。5. 定时器整合EventLoop和TimerQueue如何配合实现runEveryEventLoop除了处理IO事件还要处理定时任务。很多人仿写的时候会把定时器单独封装成一个线程再用Future或者回调通知业务层但这在muduo里并不是主流做法。muduo的做法是把定时器也做成一个Channel注册到同一个EventLoop上让定时器的到期和IO事件在同一个循环里被统一处理。5.1 为什么用timerfd而不是alarmTimerQueue在构造函数里会创建一个timerfdint createTimerfd() { int timerfd ::timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); if (timerfd 0) { LOG_SYSFATAL Failed in timerfd_create; } return timerfd; }为什么用timerfd而不是alarm或者信号因为timerfd天然能当一个fd被epoll监听它到期时会触发可读事件你可以在EventLoop的Channel回调里读到到期信息。这样定时器和网络IO就统一在同一个事件源里了不需要为定时器单独引入信号处理机制也不用担心信号打断主逻辑。这是muduo在设计上非常优雅的一点。5.2 TimerQueue的到期分发逻辑TimerQueue内部维护一个按到期时间排序的set注册的每个Timer都带一个到期时间戳和一个回调。EventLoop对外暴露runAt、runAfter、runEvery三个接口它们都只是封装timerQueue_-addTimer。当timerfd到期epoll_wait返回后timerQueueChannel的读事件被触发timerQueue_取出所有已到期的Timer逐个执行其回调void TimerQueue::handleRead() { readTimerfd(); std::vectorEntry expired getExpired(); for (const Entry entry : expired) { entry.second-run(); } reset(expired); }getExpired会把所有到期时间在当前时刻之前的Timer从set中取出来执行完回调后如果是重复定时器再重新插入set并更新timerfd的到期时间。这个reset过程涉及从set中删除、重新插入、更新timerfd的read时间每一步都要小心否则定时器会丢失或者提前触发。5.3 poll超时时间要不要和定时器联动顺带说一下我在仿写时做的一个改进。muduo原版里EventLoop的poll超时时间直接用固定的kPollTimeMs10秒也就是说一个定时器即使马上要到期了IO线程可能还在epoll_wait上阻塞。最坏情况下定时器的精度会受10秒超时的影响。陈硕在书中也讨论过这个精度问题但原版并没有把poll超时和最近的定时器到期时间关联起来。我自己的仿写版本做的是每次进入loop循环时先取TimerQueue中最近的到期时间计算它和当前时间的时间差再把poll的超时时间设为min(kPollTimeMs, nextExpiration - now)。这样定时器到期后poll能立刻返回定时精度更高。实现上只需要在TimerQueue里暴露一个getNextExpiredTime()接口EventLoop在调用poll之前取一下即可。两边代码加起来不超过30行效果却很直观。还要提一个定时器相关的坑timerfd的到期时间精度。我一开始把定时精度依赖在timeval上结果出现了毫秒级漂移。muduo里用的是CLOCK_MONOTONIC也就是单调时钟不会受系统时间调整影响。如果你的定时器依赖系统时钟CLOCK_REALTIME那么当运维在线上手工调整系统时间时你的所有定时任务都会乱套。所以用timerfd_create时参数一定要选CLOCK_MONOTONIC。6. 仿写EventLoop过程中踩过的五个坑这一章是我真心想写给仿写新手的。很多坑在源码里看不太出来只有真正自己写一遍才会撞上。6.1 wakeupChannel忘记注册第一个坑wakeupChannel没有正确注册到Poller。症状是跨线程任务提交后queueInLoopIO线程迟迟不执行看起来像死锁。原因是我在构造函数里创建了wakeupChannel_但忘了调用enableReading或者updateChannel导致epoll根本没在监视这个fd。哪怕wakeup()写了eventfdIO线程的epoll_wait也感知不到。解决方式也很简单构造函数末尾一定记得让Poller监视wakeupFd_。我建议你在写完构造函数后立刻写一个小测试主线程创建EventLoop另一个线程跑一秒后queueInLoop提交一个打印任务看任务能否在一秒内执行。这一步能过滤掉一大堆后续问题。6.2 Channel在handleEvent中被析构第二个坑Channel在handleEvent中被析构。这个可以说是EventLoop最著名的生命周期难题。场景很常见一个TcpConnection收到远端关闭事件closeCallback里去掉了对连接对象的最后一个引用连接对象连同它的Channel一起被销毁但此时handleEvent的调用栈还正在执行channel-handleEvent()。如果后续的代码再访问这个Channel直接use-after-free。muduo官方的解决方案是引入tie()机制Channel持有一个std::weak_ptrhandleEvent执行前先尝试lock成shared_ptr保证对象在回调执行期间不会被提前释放。我仿写的时候一开始没做这个保护写压力测试时连续崩了好几轮后来老老实实把tie逻辑加上才稳定下来。这里给一个经验如果你仿写的范围暂时不含TcpConnection只做EventLoopChannel也要提前考虑好Channel的owner是谁。不要等到集成TcpConnection那一刻再补生命周期管理那时候排查起来的痛苦是几何级数上升的。6.3 queueInLoop漏掉callingPendingFunctors第三个坑queueInLoop缺少callingPendingFunctors_判断导致跨线程任务延迟秒级。这个坑我在讲queueInLoop时已经详细说过因为是重点再强调一遍。我第一版只判断了!isInLoopThread()结果在特定时序下IO线程自己提交一个周期任务时新任务要等到10秒后epoll超时或者下一个IO事件来了才执行服务器的定时器全程慢半拍。加上callingPendingFunctors_判断后立刻恢复。这也是为什么我坚持在工作笔记里把这个判断条件单独画了一个时序图来理解因为真的很容易漏。6.4 timerfd读事件没排干净导致忙轮询第四个坑timerfd的读事件处理不当导致忙轮询。epoll是电平触发的如果timerfd到期的数据没被读走它就会一直处于可读状态。我在实现timerQueue时handleRead里忘记读走timet结果CPU直接打满。这个问题相当隐蔽因为表面上看一切正常只是服务器CPU温度飙升。排查时可以用perf top看到epoll_wait频繁返回、timerfd处理函数被反复调用基本就锁定是这种问题。正确做法是用read(timerfd, expirations, sizeof(expirations))读走到期次数具体实现里通常读一个uint64_t即可把事件清干净。6.5 TimerQueue并发访问的数据竞争第五个坑多线程同时调用runEvery导致TimerQueue数据竞争。我前面建议在runAt/runAfter/runEvery里做线程检查或包装这里是教训来源。有一次我在业务线程里直接调loop-runEvery因为业务线程很多timerQueue_-insertTimer在并发下把set的顺序搞乱了某个Timer永远提前到期导致回调被疯狂执行。后来我把所有timer相关接口都改成走queueInLoop或runInLoop数据竞争才消失。这也是muduo源码里TimerQueue不加锁的原因——它假设所有访问都来自IO线程。如果你仿写时无法保证这点就得自己在外层加防护别把所有负担都丢给使用者应该自觉这条规则。写到这里基本把EventLoop从设计理念到具体实现再到踩坑经验都覆盖了。这次仿写给我的最大体会是EventLoop这个模块没有表面上看起来那么简单它把事件分发、跨线程通信、定时器调度、资源生命周期管理压缩在了一个很薄的接口后面。真正动手写过一遍再去读muduo源码很多之前想不通的地方都会突然贯通。如果你也在仿写muduo建议按这个顺序先写一个单线程的EventLoopChannelPoller验证事件分发没问题再引入eventfd做跨线程唤醒跑通queueInLoop最后加TimerQueue和TcpConnection。每一步都单独写测试别急着一步到位。
