云计算百科
云计算领域专业知识百科平台

仿muduo实现高并发服务器——EventLoop+_timer_wheel节点1

在这里插入图片描述

@bit::Shadow

✧(≖ ◡ ≖✿

目录

    • 2 摘要
    • 3 注释
      • 3.0 逻辑图
      • 3.1 记事本
      • 3.2 新知识
      • 3.3 反思
      • 3.4 回顾
      • 3.5 AI辅助
      • 3.6 误区
      • 3.7 习惯养成
      • 3.8 设计点
    • 4 架构总览
    • 5 关键流程与调用链
      • 5.1 构造与 fd 编号
      • 5.2 Start 三步与 revents 回填
      • 5.3 HandleEvent 分发与 timerfd 特判
      • 5.4 tick 与超时释放
      • 5.5 添加与刷新
      • 5.6 新链接接入
      • 5.7 线程边界
    • 6 开发中遇到的问题
    • 7 其余细节
    • 8 总结
    • 9 常见问题
    • 10 标签

2 摘要

EventLoop_TimeWheel 是 Channel+Poller(epoll 封装)之上把「时间轮 + timerfd」并入 IO 循环的一版:新链接 accept 后登记一个 20 秒定时任务;读写等就绪事件触发 _event_cb() 时刷新该任务在轮盘上的位置;timerfd 每秒就绪驱动轮盘前进一格,格子被 clear() 时 shared_ptr 计数归零、TimerTask 析构,析构里执行超时回调(HupCallback→RemAll())后再 _timers.erase(id)。价值在于把「超时检测」从业务代码搬进就绪事件,主循环不必自己算时间。

代码仓库:moduo_project / key_nodes / EventLoop_TimeWheel

3 注释

3.0 逻辑图

在这里插入图片描述

3.1 记事本

tcp_svr.cc 首块笔记:目的为「检测/刷新/添加(新)链接/释放旧链接(超时时间)」,落地三条——时间轮每秒移动一次、移至位置释放链接;读/写等事件刷新活跃度;新链接纳入活跃度维护队列。对「如何在主线程等待时 tick 前行」,结论是「不能这样解决因为这本身就是矛盾的」,改用 timerfd(到期即可读的 fd)。另两项取舍:Poll 要改成「每秒去轮转时间轮上的任务」;eventfd 不必纳入刷新,因为它只是内部通知机制,而刷新的目的是「刷新与删除」链接。末段「接口二次优化」按 A(外部模块怎么用)/B(接收新模块的大模块)/C(被纳入的模块)拆开,要求逐个接口核对 C 与 B 的联系。

3.2 新知识

合并五处:保证日志的确定性;EPOLLRDHUP 表示对端写端关闭、要读完读回调,EPOLLHUP 表示链接挂起、双方通信不可用、走 close 回调(Channel::HandleEvent() 即把前者归入读分支、后者交给 _close_cb());abort() 用于程序无法/不应继续的严格逻辑错误(Poller 构造失败、ReadTimerfd/ReadEventfd 失败处都在用);strerror(errno) 把错误码译成可读字符串(Poller::Poll()、ChannelSet()、ReadCallback 均有);「勿穷究,找适当」——_event_channel 只管 eventfd 的读写与回调,别把它和 EventLoop::_poller 合起来看。另有「看完一个模块再下手写,千万不要提早写」。

3.3 反思

放在 tcp_svr.cc 的 bug 段里,是「在释放资源时」的三条自查:一是逻辑目的先落到「记事本」,明确技术选取与模块划分;二是盘清资源都有哪些(关联在一起)、触发销毁逻辑是什么,注释点明「像 Hup 的前提是 timerfd 的读就绪及与之关联 _erase/clear」,并称这是 bug1 产生的主要原因;三是新增/使用销毁接口来完成销毁。

3.4 回顾

Channel.hpp:EPOLLIN/EPOLLOUT 是单比特位,故添加用 |=、解除用 &= ~、判断用 &;std::function 包装出的对象调用方式同一般函数;函数指针设置「均加 &」;_events 是监控集合、_revents 是触发结果;EPOLLPRI 表示有紧急数据/带外数据可读。

3.5 AI辅助

Channel.hpp:重复字段(Set 系列前的空值判定)借 AI 补全,写法仿自己那份。SetReadCB 保留了「防相同赋值做不到——std::function 无 ==,无法可靠比较,跳过」,只留 assert(… != nullptr)。

3.6 误区

两处合并:unordered_map::find 得到的迭代器是整体概念,别把 first/second 与迭代器搞混;unique_ptr 一定先构造智能指针自身、再决定是否构造所指类型——EventLoop 里 _event_channel 与 _time_wheel 的实际次序由成员声明顺序决定。

3.7 习惯养成

调 bug 要记日志且必须坚持,例如「明明发送了数据为何是 0?」这类现象先记下再查。

3.8 设计点

EventLoop.hpp 的核心说明:任务队列用 vector 而非队列,理由是方便管理内部数据、结构开销其次;EventLoop 管的是「线程单位内」与「任务队列」的调度,集成 Poller、Channel、_tasks(临界资源)。五条拆解:_thread_id 标识所属线程;因为 Poller 的异步阻塞与通知才引入 eventfd,防「未就绪事件一直阻塞」;因为有 _tasks 必须有 _mutex;因为维护 eventfd 读写才引入 Channel;引入 Poller 就是为管 fd 的 EPOLL 系列。第三条「暂时未纳入超时管理模块」是当时的记录,本版 _time_wheel 已是成员。

4 架构总览

自内向外:Channel.hpp → Poller.hpp → TimeWheel.hpp(含 TimerTask)→ EventLoop.hpp → tcp_svr.cc。Channel 持 _events/_revents 与五个回调;Poller 持 _epfd、epoll_event _fdarr[1024]、unordered_map<int, Channel*> _channels;TimeWheel 持 _tick、_capacity、_timers、_wheel、_timerfd、_evtloop、_timer_channel;EventLoop 持 _thread_id、_event_fd、_mutex、_tasks、_poller、_event_channel、_time_wheel。

注释对 TimeWheel/TimerTask 的定性是「与 EventLoop 耦合,是目前为止最复杂最难的」,理由三条:回环回调与各模块兼容性;轮盘移动要纳入 EventLoop 内 timerfd 的监控(落在构造函数);为方便外部接口把 timerfd 与 EventLoop* 一并放进 TimeWheel。

两处「借用现成设施」要记牢。其一,Channel::Update()、Remove()、IsTimerFd() 在 Channel.hpp 只有声明,定义统一放在 EventLoop.hpp 末尾,注释写的原因是「类函数在本类的 }; 后才开始编译」——Update() 要调 _loop->AddMoniter(this),此刻 EventLoop 必须完整。其二,TimeWheel 构造体内就 _timer_channel->EnableRead(),而它经 Update() 走到 AddMoniter();这条链成立靠 EventLoop 中 _poller 声明在 _time_wheel 之前、先完成构造。

5 关键流程与调用链

5.1 构造与 fd 编号

main 中 CreateServer(8080) 先出 listenfd(注释记 3),new EventLoop() 再出 _epfd(注释记 4)与 eventfd(注释记 5),时间轮构造里 CreateTimerfd() 再拿一个 fd 并立即交给 Channel 纳入 epoll。CreateTimerfd() 用 CLOCK_MONOTONIC(不受系统时间跳跃影响),it_value 与 it_interval 都设 1 秒(旁边「每 5.1s 超时响应器」的注释已与代码不符)。Poller 构造把 _fdarr 全部置 events=0、data.fd=MAX_EVENTS+1 作空位标记;ChannelSet() 里用 ev.data.fd = fd,所以 Poll() 只能靠 data.fd 反查 _channels。

5.2 Start 三步与 revents 回填

EventLoop::Start() 固定三步:_poller.Poll(&actives)(epoll_wait(…, -1) 阻塞)→ 逐个 HandleEvent() → RunAllTask()。Poll() 里先 push_back(it->second),再 SetRevents(_fdarr[i].events);而 SetRevents() 末尾又调 Update(),等于每个就绪事件都带一次 EPOLL_CTL_MOD。

5.3 HandleEvent 分发与 timerfd 特判

读分支 EPOLLIN|EPOLLRDHUP|EPOLLPRI → 先 _event_cb() 再 _read_cb();EPOLLOUT → _event_cb() 再 _write_cb();EPOLLERR → _err_cb();EPOLLHUP → _close_cb()。读分支里有 if(!IsTimerFd()) _event_cb();——timerfd 不刷新活跃度。原注释先自问 listenfd 要不要刷新,随即自答:刷新活跃度的本质是避免被释放,避免被释放的本质是已被纳入时间轮,而 listenfd 并未纳入。IsTimerFd() 的实现就是 Fd() == _loop->GetTimerfd()。

5.4 tick 与超时释放

timerfd 就绪 → 该 Channel 的 _read_cb 即 TimeWheel::OnTime():先 ReadTimerfd() 读走 8 字节计数(注释:「timerfd 本质仍是一个文件描述符,不读取永远维持就绪」),再 TickRun()。

// TimeWheel.hpp
void TickRun()
{
int flag = 0;
for (auto &e : _wheel[(_tick + 1) % _capacity])
{
if (e->GetCancelAble()) { ERR_LOG("TickRun部分失败 id:{%ld}", e->GetId()); flag—; continue; }
}
if (flag < 0) { ERR_LOG("TickRun部分失败个数:{%d}", –flag); return; }
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear();
}

预检查只读不改状态;目标格内只要存在已取消任务,本次直接返回、轮盘不前进。真正推进后 clear() 销毁该格全部 shared_ptr,最后一个引用消失时 ~TimerTask():_cancel == false 才先 _task_cb()(示例中即 HupCallback),最后 _release();_cancel == true 时只打日志并 return,连 _release() 都不执行。

5.5 添加与刷新

// TimeWheel.hpp
void TimerAdd(uint64_t id, const TaskFunc &cb, uint32_t delay)
{
if (_timers.find(id) != _timers.end()) { ERR_LOG("timers 内已经有id:%ld Add失败", id); return; }
PtrTask pt = std::make_shared<TimerTask>(id, cb, delay);
pt->SetCancel(false); // BUG2
pt->SetRealese(std::bind(&TimeWheel::RemoveTimer, this, id));
_wheel[delay].insert(_wheel[delay].begin(), pt);
WeakTask wp(pt);
_timers[id] = wp;
}

顺序有依赖:SetRealese() 在 _cancel == true 时直接拒绝,必须先 SetCancel(false)。刷新不是改时间而是再插一份:Refresh() 用 _timers[id].lock() 取回 shared_ptr,按 pos = (GetTimeOut() + _tick) % _capacity 插到轮首;原位置仍留旧引用,clear() 只减计数不析构,析构发生在新位置被清空那次,_timers 中始终只有一份 weak_ptr,故 RemoveTimer 只 erase 一次。RefreshTimerTask 的注释「怎么一直调用?且不会析构」正指此事:引用未耗尽前任务不会执行。

5.6 新链接接入

Acceptor 在 accept 后 new Channel、EnableRead()、装五个回调、AddMoniter(),最后 AddTimerTask(id, bind(HupCallback, newcli, evtloop, id), 20);id 取 rand() % 10000 + 30,main 里 srand(time(nullptr))。_wheel[delay] 未取模,故 delay 必须小于 _capacity(60)。注意 EnableRead() 已 Update() 一次 EPOLL_CTL_ADD,紧随其后的 AddMoniter() 又走一次 MOD。

5.7 线程边界

TimeWheel::AddTask/RefreshTask/CancelTask 只把 TimerAdd/Refresh/SetCanCel 包成 Functor 交给 PushTaskqueue():同线程(IsMatchEtLoop())直接执行,否则 Push() 入 _tasks 后 WeakUpEventfd()。RunAllTask() 用 _tasks.swap(tmpFuncs) 在锁内换出、锁外执行,避免持锁跑用户回调。

6 开发中遇到的问题

6.1 不能对时间轮任务 Cancel(tcp_svr.cc bug1) 现象是 Segment fault。原因按注释:HupCallback 本身就是时间轮到期(timerfd 读就绪驱动、clear() 触发析构)时执行的,「如果 CancelTimerTask 就相当于对已经析构的成员设置,自然导致段错误」。处理是把 HupCallback 中那行 evtloop->CancelTimerTask(id) 注释掉。

6.2 添加任务忘设 _cancel(TimeWheel.hpp BUG2 / tcp_svr.cc 注释第 2 条) 带参构造给 _cancel(true),而析构只在 _cancel == false 时执行 _task_cb() 与 _release(),GetTimeOut() 在取消态还直接返回 0。故 TimerAdd 必须补 pt->SetCancel(false)。

6.3 只设内核、漏记成员(Poller.hpp bug1) ChannelSet() 里 epoll_ctl 之后忘了 _channels[fd] = channel;,于是 HashAble() 恒假、UpdateEvent() 反复走 ADD,Poll() 里 find 直接到 end,打印 Poller::Poll failed 后返回。修复即在末尾补这一行。

6.4 用了已销毁的成员,耗 4h(Poller.hpp bug2) 注释原文:「测试时忽略成员生命周期,于下一轮循环仍使用已销毁的成员。」定位靠 ~Channel() 的日志(「良好的编程习惯」被列为发挥极大作用的一条);调试技巧是「遇到搞不明白的现象先记录下来再思考」,本质是串联证据链;另记 gdb 里 fd 自 10 开始创建、并非 3,不必奇怪。

6.5 Poll 内漏 SetRevents(Poller.hpp bug3) epoll_wait 返回了,HandleEvent() 却看不到 _revents。根因是把「关心」与「回应」记混:epoll_ctl 设的是关心,epoll_wait 输出的 events 才是回应,注释写「本 bug 产生的原因是对知识忘记了」。修复即 it->second->SetRevents(_fdarr[i].events)。

6.6 _capacity 必须定义在 _wheel 之前(TimeWheel.hpp bug1) _wheel(_capacity) 依赖 _capacity 已为 60;声明序 _capacity→_wheel、初始化列表序 _capacity(60), _wheel(_capacity),两处一致才拿到 60,注释记 gdb 也判断不出初始化顺序。【待我补充:注释写了「解决办法有两种」,只记下一种】

6.7 释放用 erase 不用 clear(TimeWheel.hpp bug3) 注释:「应该用 erase——AI」,并标注 _timers.clear() 是「将整个 timers 清空」。RemoveTimer 是每个任务的 _release,每析构一次就调一次,写成 clear() 会连带清掉其它任务的索引。

6.8 轮盘用「距离」访问(TimeWheel.hpp bug4) 注释:「wheel 用的是距离访问」。所以 SetCanCel(id) 不能写 _wheel[id](id 是任务号,_wheel 下标是滴答位置),须先 uint32_t pos = _timers[id].lock()->GetTimeOut(); 再遍历 _wheel[pos]。附带两点:这行没有 find 与 lock() 判空;循环是对该格内所有任务 SetCancel(true),而非只针对 id。

6.9 TickRun 里不能 SetCancel(true)(TimeWheel.hpp bug5) 注释在 // if (!e->SetCancel (true))可以吗? bug5 不可以!! 处只给结论,代码保留只读判断 GetCancelAble()。【待我补充:注释未记录「不可以」的具体后果】

6.10 成员函数指针必须 &类名::函数名(EventLoop.hpp bug1) std::bind(ReadEventfd, this) 与 std::bind(EventLoop::ReadEventfd, this) 都过不去,gcc 无法定位 ReadEventfd 的「位置」;正确写法 std::bind(&EventLoop::ReadEventfd, this)。

7 其余细节

  • ~Channel() 先 _fd = -1 再 WRN_LOG("Channel已经析构fd:%d", _fd),日志永远是 -1。
  • RemAll() 用 Update() 而非 Remove()(注释已标),故 fd 仍留在 _channels,只是不再关心任何事件。
  • Poll() 中 find 失败走 return 而非 continue,本次 epoll_wait 剩余就绪 fd 一并丢弃。
  • TimerAdd 按 _wheel[delay] 放置,Refresh 按 (timeout+tick)%capacity 放置,而 SetCanCel 又用 GetTimeOut() 当位置——三处口径不完全一致,重定位过的任务用 SetCanCel 可能取不到真正那份。
  • HupCallback 只 RemAll();Acceptor 里 new 出的 Channel 全篇没有 delete。

8 总结

骨架:Channel 定「一个 fd 关心什么、就绪后按什么顺序回调」,Poller 管「把关心交给内核、把回应取回来」,TimeWheel 用 timerfd 把时间变成可读事件、用轮盘位置表达延迟、用 shared_ptr 析构触发超时,EventLoop 在线程内串起三者并接住跨线程任务。最该记住两点:刷新是「再插一份 shared_ptr」而非改字段;超时执行写在析构里,所以 _cancel 状态、_release 绑定、erase 语义必须同时正确。坑集中在三处——状态位忘设、epoll「关心/回应」混淆、成员生命周期与初始化顺序。

9 常见问题

  • HupCallback 里那行 CancelTimerTask 为何必须注释? 该回调本身由 ~TimerTask() 在轮盘 clear() 时执行,再取消同一条任务等于对已析构成员设状态(§6.1)。
  • Refresh() 后为何不会执行两次? _timers 只有一份 weak_ptr,轮盘多份 shared_ptr 指向同一 TimerTask,~TimerTask() 只在最后一个引用释放时执行一次(§5.5)。
  • timerfd 的 Channel 为何跳过 _event_cb()? IsTimerFd() 为真即跳过;它未纳入时间轮,刷新无意义,其 _read_cb 是 OnTime(),自己读计数再 TickRun()(§5.3)。
  • SetCancel() 何时返回 false? b == _cancel 时。TimerAdd 里 SetCancel(false) 因构造已置 true 才成功;SetCanCel 对已取消任务再置 true 会返回 false 并打「SetCanCel失败」。
  • Channel::Update() 为何放到 EventLoop.hpp 末尾? Channel.hpp 只有声明,Update() 要调 _loop->AddMoniter(this),须等 EventLoop 完整(§4)。
  • 10 标签

    C++ epoll 时间轮 timerfd EventLoop

    【统计:全文约 4,5xx 字 / 原代码 944 行 / 比例 ≈4.8;注释字段小节 8 个 / 成文 bug 10 处】

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 仿muduo实现高并发服务器——EventLoop+_timer_wheel节点1
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!