仿muduo实现高并发服务器——EchoServer完成

目录

    • [2. 摘要](#2. 摘要)
    • [3. 注释](#3. 注释)
    • [4. 分节正文](#4. 分节正文)
      • [4.1 架构总览与包含顺序](#4.1 架构总览与包含顺序)
      • [4.2 Buffer:两个下标与一个 vector](#4.2 Buffer:两个下标与一个 vector)
      • [4.3 Socket / Channel / Poller:事件层](#4.3 Socket / Channel / Poller:事件层)
      • [4.4 EventLoop:任务队列与唤醒](#4.4 EventLoop:任务队列与唤醒)
      • [4.5 TimeWheel:以析构时机驱动的任务](#4.5 TimeWheel:以析构时机驱动的任务)
      • [4.6 LoopThread / LoopThreadPool / Any](#4.6 LoopThread / LoopThreadPool / Any)
      • [4.7 Connection:状态机与收发缓冲](#4.7 Connection:状态机与收发缓冲)
      • [4.8 Acceptor / TcpServer](#4.8 Acceptor / TcpServer)
      • [4.9 EchoServer 与 main 的完整主链](#4.9 EchoServer 与 main 的完整主链)
    • [5. 总结](#5. 总结)
    • [6. 常见问题](#6. 常见问题)
    • [7. 标签](#7. 标签)

2. 摘要

这份代码是一个声明与实现同处 .hpp 的 header-only Reactor 服务端骨架:从 Buffer(双下标缓冲区)起步,经 Socket(fd 的 RAII 包装)、Channel(事件与五类回调)、Poller(epoll 封装)、TimerTask/TimeWheel(timerfd 驱动的秒级时间轮)、EventLoop(Poller + 任务队列 + eventfd 唤醒)、LoopThread/LoopThreadPool(多线程从属 loop)、Any(连接上下文)、Connection(连接状态机与收发缓冲)、Acceptor/TcpServer(监听与连接管理)逐层集成。EchoServer 只是最薄的一层装配:三个 std::bind 挂上连接、消息、断开回调;main 仅 make_unique<EchoServer>(8080) 加 Start()。它的价值在于把"事件层---调度层---连接层"的边界切得足够干净,业务侧只需理解 MessageCallBack(const PtrConnection&, Buffer*) 的语义即可替换协议。

代码仓库:https://gitee.com/btshadow/moduo_project/tree/master/key_nodes/EchoServer

3. 注释

按本文的写作约定,本文件内以 /* */ 包裹的字段注释段(回顾、新知识、注意、反思等)不在此节展开,本文全部内容只依据代码本体与 // 行内注释成文;代码里仅有编号标记、字段注释段又未给出解释的 bugN,同样不作叙述。

4. 分节正文

4.1 架构总览与包含顺序

单文件即依赖顺序:echo.hpp 引入 server.hpp,其后依次是 Buffer、Socket、Channel、Poller、TimerTask/TimeWheel、EventLoop、LoopThread(Pool)、Any、Connection、Acceptor、TcpServer。整份头文件没有一个 .cpp,因此没有链接期符号可供依赖。

为破循环包含,Channel 只保留 EventLoop*(Poller* 成员被注释掉了),Update()/Remove() 声明在类内、定义落在 EventLoop 之后:

cpp 复制代码
// EventLoop.hpp(文件末尾)
void Channel::Update() { _loop->AddMoniter(this); }
void Channel::Remove() { _loop->RemoveMoniter(this); }

TimeWheel::AddTask/RefreshTask/CancelTask 同样在文件末尾定义,因为它们的函数体要用 _evtloop->PushTaskqueue。由此产生一条需要记住的边界:这些类外定义都不是 inline,#pragma once 只能阻止同一编译单元重复包含,不能阻止多个编译单元各产生一份定义,所以这份头文件目前只在单编译单元接入(main.cpp)下成立。

4.2 Buffer:两个下标与一个 vector

三个成员:std::vector<char> _buf、_redidx、_wrtidx。构造时 reserve(BUF_CAPACITY=65536) 后 resize(0),容量一次到位、size 交给写入流程维护。

Begin() 取首元素地址,End() 是 Begin() + _buf.capacity() 而非 Begin() + _buf.size()。这一点决定了整套接口的坐标系:ReadOffset()、WriteOffset() 都用 Begin() + idx 计算,读写位置由下标独立描述,vector 的 size 只用来配合 insert 与 resize。行内注释给出的理由很直接:解决 _redidx 偏移后 size 难以决定的问题、缓和 capacity 与 size 的耦合。四个量因此各司其职:ReadableGet()=_wrtidx-_redidx、HeadableGet()=_redidx(内含与 ReadOffset()-Begin() 的一致性断言)、TailableGet()=capacity-_wrtidx、CapacityGet()=capacity。

ReadLen(len) 逐字节拼出 std::string,读完只推进 _redidx 而不 erase------因为"不存在洞",下标足以描述读取进度,搬移数据反而要额外处理左侧空位。副作用是:只要不触发 EnsureEnoughCapacity 的重排,_buf.size() 就与 _wrtidx 保持同步,Write 里 _buf.insert(_buf.begin()+_wrtidx, ...) 的插入点才始终合法。

Write(data,len) 的顺序是 EnsureEnoughCapacity(len) → insert → _wrtidx += len → assert(_wrtidx >= _redidx)。其中 assert(_buf.capacity() - _wrtidx > len) 用的是严格大于,而 EnsureEnoughCapacity 的前置判断是 >=:恰好写满剩余容量(len == capacity-_wrtidx)时能过前者、过不了后者,这是本文件里少数"边界比语义更严"的地方。

EnsureEnoughCapacity(uint32_t size) 分三支:右侧够用直接返回;不够则用 memmove(const_cast<char*>(Begin()), ReadOffset(), ReadableGet()) 左移压实(重叠拷贝,不能换 std::copy);仍不够则 reserve(capacity + size)。注意扩容判断发生在 memmove 之后、_wrtidx 折算之前,用的是压实前的写位置,判断偏保守,代价是多 reserve 一份。函数末尾的三步必须保持这个顺序:

cpp 复制代码
        _wrtidx -= ReadOffset() - Begin();
        _redidx = 0;
        _buf.resize(_wrtidx - _redidx);

ReadOffset() 依赖 _redidx,若先把 _redidx 归零,第二行恒减 0,_wrtidx 不被折算,紧接着的 resize(_wrtidx - _redidx) 会把 size 撑大,留下一段从未写入的区间。另外形参是 uint32_t 而调用点传 uint64_t,类型不一致值得记住。

ReadOnly/WriteOnly 走"先调用再反向补偿":ReadOnly 调 Read(len) 后 _redidx -= len,WriteOnly 调 Write 后 _wrtidx -= len,从而在不移动指针的前提下探测数据。FindCRLF() 用 memchr 找 '\n',未找到时返回 ""------于是 GetLine() 里的 pos == nullptr 判断不可达,且此时 pos - ReadOffset() 是一个无意义的巨大偏移,会直接当 len 传给 Read。这两处需要复看。

4.3 Socket / Channel / Poller:事件层

Socket 把 fd 收进 RAII。Create/Bind/Listen/Accept/Connect/Recv/Send 都是薄封装,值得记的有三点。一是 Bind 里 inet_pton 只判 < 0,而字符串非法时它返回 0,非法地址不会被拦下。二是 Recv/Send 把 EAGAIN/EWOULDBLOCK/EINTR 统一映射成 0、其余负值映射成 -1,于是调用层既拿不到"被信号中断需重试"的信息,也分不清"暂时无数据"与"对端关闭"(两者都是 0)。三是 fd 的多次 close 路径:Socket::Close() 没有把 _sockfd 置 -1,析构还会 close 一次;而 Connection 同时持有 _socket 与 _channel(Channel 按同一个 fd 构造,Channel::Close() 也 close),ReleaseInLoop 里先 _channel.Close(),之后 _socket 析构再 close 一次。

CreateServer(port, ip="0.0.0.0") 的顺序是 Create → SetReuseAddrPort → Bind → Listen,两个 setsockopt(SO_REUSEADDR / SO_REUSEPORT) 都落在 bind 之前。

Channel 持 _fd、_loop、_events、_revents 与五个 std::function。EnableRead/EnableWrite/RemRead/RemWrite/RemAll 一律"先判状态再改位再 Update()",重复启动只打告警;SetRevents() 在写入 _revents 后顺手 Update(),把"设置触发事件"和"同步 epoll"绑在一起。HandleEvent() 用的是并列 if 而非 else if:EPOLLIN|EPOLLRDHUP|EPOLLPRI 命中时若带 EPOLLRDHUP 就先调 _close_cb 并 return,其余分支(EPOLLOUT、EPOLLERR、EPOLLHUP)可叠加进入,因此一次就绪可能同时触发读、写、错、挂断多个回调;五个回调里只有 _close_cb 没有空判。

Poller 是 epoll_create(256) + _fdarr[1024] + unordered_map<int, Channel*>。ChannelSet() 做两件事:epoll_ctl 之后立刻 _channels[fd] = channel,新增与修改共用同一条写入。Poll() 取回事件后按 _fdarr[i].data.fd 查表,查不到时是 return 而非 continue,会把本轮剩余活跃 Channel 一并丢掉。RemoveEvent() 只做 DEL 加 erase,不 close,close 交给 Channel::Close()------职责划分清晰,但也意味着 fd 的关闭时机完全由连接层掌握。

4.4 EventLoop:任务队列与唤醒

成员顺序值得先看一眼:_thread_id、_event_fd、_mutex、_tasks、_poller、_event_channel、_time_wheel。构造列表里 _time_wheel(this) 在函数体之前完成,而 TimeWheel 构造期就会 CreateTimerfd() 并 EnableRead(),经 Channel::Update → AddMoniter 打进 _poller------所以 _poller 必须在声明顺序上早于 _time_wheel,否则构造期回调会落到未构造的对象上。

Start() 是三段:_poller.Poll(&actives) → 逐个 HandleEvent() → RunTaskQueueTasks()。

跨线程只走一条口子。PushTaskqueue(fc) 用 IsMatchEtLoop() 判线程:命中就当场 fc(),否则 Push。Push 在锁里 push_back,出锁后 WeakUpEventfd() 写一个 1,把可能阻塞在 epoll_wait 的 loop 拽醒(EFD_NONBLOCK 保证写不会阻塞,读侧 ReadEventfd 把计数器读空)。RunTaskQueueTasks() 在锁内 _tasks.swap(tmpFuncs)、出锁再逐个执行,临界区只保护容器本身,回调不持锁------这一点决定了任务里可以安全地再 Push。

由此带来一个容易忘的语义:Established()、Shutdown()、Send(data,len) 都只把 this 和调用方的裸指针 std::bind 起来排队,真正执行在 loop 线程。Send(const void*,size_t) 不做拷贝,所以调用方传入的 data 必须活到 SendInLoop 执行------EchoServer 传的是 _inbuf 的内部地址,而 Buffer 读后不 erase,正好成立。

4.5 TimeWheel:以析构时机驱动的任务

TimerTask 的执行逻辑写在析构里:_cancel 为真则只打印返回;为假则跑 _task_cb,再跑 _release。也就是说任务不是"到点被调用",而是"到点被销毁"。这要求外部保证最后一个 shared_ptr 在正确时刻释放。

构造函数把 _cancel(true),TimerAdd 里先 SetCancel(false) 再 SetRealese(std::bind(&TimeWheel::RemoveTimer, this, id))------顺序不能反,因为 SetRealese 在 _cancel 为真时会直接 ERR_LOG 返回,_release 会留空,任务随槽清空销毁后就没有人把 _timers 里的条目 erase 掉。

TimeWheel 用两张表分工:_wheel(vector<vector<PtrTask>>,长度 _capacity = Time_Out = 120)持有强引用,决定生命周期;_timers(unordered_map<uint64_t, WeakTask>)只做 id 索引与判活。初始化列表 _wheel(_capacity) 依赖 _capacity 先就位,必须与声明顺序一致。

CreateTimerfd() 用 CLOCK_MONOTONIC,it_value 与 it_interval 各 1 秒,于是 timerfd 每秒"读就绪"一次;该 fd 由 _timer_channel 监控,读回调 OnTime() = ReadTimerfd()(把计数器读空)+ TickRun()。

TimerAdd 按 pos = (_tick + delay) % _capacity 插入槽首,同时把 WeakTask 登记进 _timers。Refresh 从 _timers[id].lock() 取回强引用再按 (timeout + _tick) % _capacity 插一份新条目,旧槽里那份不删------同一任务会在多个槽各持一份强引用,重复刷新越多,条目越多,真正执行被推到最后一个槽被清空的时候。

TickRun() 先扫 _wheel[(_tick+1) % _capacity],只要发现任一 _cancel 为真的任务就打印后 return:_tick 不推进,槽也不清空,等于被取消项卡住整只轮子;否则推进 _tick 并 _wheel[_tick].clear(),靠 clear 触发的析构来执行任务。

另外 HashAble(uint64_t id) 写的是 _timers[id].lock() == 0,用的是 operator[]:查询即插入,对不存在的 id 会留下一个空 weak_ptr 条目。

4.6 LoopThread / LoopThreadPool / Any

LoopThread 的 Handle() 在子线程栈上构造 EventLoop,加锁把地址写回 _loop 并 notify_all(),随后 while(1) loop.Start()。GetLoop() 用 _cond.wait(lock, [this]{ return _loop != nullptr; }) 等这一句写完。构造列表里 _thread(std::thread(&LoopThread::Handle, this)) 会在对象构造完成前就把线程拉起来,它能成立的唯一前提是 _mutex/_cond 在声明顺序上早于 _thread。~LoopThread() 只 detach,没有停止路径,_loop 指向的仍是子线程栈上的对象。

LoopThreadPool::CreateThreads() 按 _thread_count resize 后逐个 new LoopThread() 并 GetLoop() 缓存指针;GetNextLoop() 先自增再取模,所以首个新连接落在 _loops[1](行内注释也写明"自1开始");_thread_count == 0 时整体回落到 _base_loop。

Any 用 holder/placeholder<T> 两级结构收口类型:clone() 做深拷贝,type() 返回 typeid(T),get<T>() 先 assert 类型一致再 dynamic_cast 取 _val;operator=(const T&) 走 Any(val).swap(*this),即构造临时对象再交换,swap 只换 _content 指针。

4.7 Connection:状态机与收发缓冲

Connection 继承 enable_shared_from_this,所有回调都通过 shared_from_this() 传出,避免裸 this 上再造一个 shared_ptr 造成双重释放。构造时把 Channel 的五类回调全部指向自身的 HandleRead/HandleWrite/HandleHup/HandleErr/HandleEvent,并 SetNonBlock()。

状态字段是 ConnStat _stat,枚举有四个值,但代码里只出现三处赋值:构造时 CONNECTING、ShutdownInLoop 里 DISCONNECTING、ReleaseInLoop 里 DISCONNECTED。没有把 _stat 置为 CONNECTED 的位置,因此 Connected() 恒为 false,HandleWrite 里那句断言也被注掉------后续要恢复"可通信"语义得补这一步。

读侧 HandleRead():栈上 char temp[BUF_CAPACITY],RecvNonBlock(temp, BUF_CAPACITY-1);n < 0 直接 ShutdownInLoop()(出错不当场关闭);其后 if ((ssize_t)_inbuf.TailableGet() < n || _connCB == nullptr) 打印错误并 return,数据被丢弃且连接不关;判断里用的是 _connCB,但真正被调用的是 _msgCB,两者不是同一个成员。n == 0 时仍会走完 Write(写 0 字节)并调用 _msgCB------结合 Socket::Recv 把 EAGAIN 也归零,这里无法区分"没数据"与"对端关了"。

写侧 HandleWrite():SendNonBlock(_outbuf.ReadOffset(), _outbuf.ReadableGet()) 之后不看 n 的实际值,直接 _outbuf.RdixMove(_outbuf.ReadableGet()) 全量前移,并无条件 RemWrite()。短写与"还有残留数据"两种情况都会被这次全量前移+关写监控吞掉。

ShutdownInLoop() 的顺序是先补处理输入缓冲(_inbuf.ReadableGet() > 0 就再投一次 _msgCB),再看输出缓冲:非空则确保写监控开着、等下一轮 HandleWrite;为空才 Release()。ReleaseInLoop() 是 Remove()(epoll DEL + 从 _channels 摘除)→ _channel.Close()(close fd)→ 若时间轮里还有该 id 就取消 → _closeCB → _svrCloseCB。

4.8 Acceptor / TcpServer

Acceptor 直接持 Socket 与 Channel:_socket(CreateServerFc(port)) 失败即 abort(),_channel(_socket.Fd(), loop) 依赖 _socket 在声明顺序上靠前。构造体内 EnableRead() 已经触发过一次 Update → AddMoniter,随后又显式 _loop->AddMoniter(&_channel),第二次走的是 MOD 分支,属冗余但无害。读回调 HandleRead() 只做 Accept() 加 _readCB(newclientfd)。

TcpServer::NewConnetion(fd):自增 _next_id 作连接 id,_loops.GetNextLoop() 选从属 loop,装好四个外部回调后 Established(),按 _enable_inactive_release 决定是否 EnableInactiveRelease(_timeout),最后 _conns[_next_id] = newConn 保活。

销毁路径目前不通:RemoveConnection 把 conn->Fd() 传给 RemoveConnectionInLoop(uint64_t fd),而 _conns 的 key 是 _next_id,按 fd 查必然查不到;同时当前代码没有任何地方调用 RemoveConnection,TcpServer 也没有通过 Connection::SetServerCB 把 _svrCloseCB 挂上去,因此 _conns 里不会有条目被移除。

EnableInactiveRelease(scds) 只置 _timeout 与开关,assert(scds < Time_Out) 把上限卡在 120 秒;EchoServer 传的是 30,行内注释写的是 10s,二者不一致。构造里还有 signal(SIGPIPE, SIG_IGN):客户端异常断开后继续发送会让进程直接被 SIGPIPE 终止。Start() 是 while(1) _baseloop->Start();,主线程永远停在 base loop;_baseloop 由裸 new EventLoop() 产生,析构里只打日志不 delete。

4.9 EchoServer 与 main 的完整主链

装配只有四行有效代码:EnableInactiveRelease(30),以及 SetConnectCB → ReadCallback、SetMessageCB → WriteCallback、SetClosedCB → HupCallback 三个 std::bind;线程数默认 2。

ReadCallback 挂在"连接建立"上,却直接用 read(conn->Fd(), buf, 1023) 去读 fd。此时 fd 已被 Connection 构造置为非阻塞,建连瞬间没有数据,read 返回 -1,只会打一条 DBG_LOG。数据真正到达时走的是 MessageCB,因此这个回调在整条链上实际只承担"触发一次读日志"的作用;n == 0(EOF)也没有单独处理。

WriteCallback 才是回声本体:ReadableGet() == 0 直接返回;否则 conn->Send(buf->ReadOffset(), buf->ReadableGet()) 把 _inbuf 中的原始指针投递到 loop 线程,SendInLoop 把它 Write 进 _outbuf 并 EnableWrite();EPOLLOUT 就绪后 HandleWrite 把 _outbuf 发回对端。投递之后本地紧接着 buf->RdixMove(buf->ReadableGet()) 全量前移读位置(数据仍在 _buf 里,所以指针依旧有效),最后 conn->Shutdown(),兑现行内注释里"仅通信一次即关闭对端"的决策。

完整回声链因此是:HandleRead → _inbuf → _msgCB(WriteCallback) → Send → _outbuf → HandleWrite → 对端。TCP 层的收发字节数由 Connection 负责,EchoServer 只在 MessageCB 里把数据从输入侧搬到输出侧。

5. 总结

这份实现按三层切分:事件层(Socket/Channel/Poller)只管 fd 与就绪事件,调度层(EventLoop/TimeWheel/LoopThread(Pool))只管 loop 归属、任务排队与定时,连接层(Connection/Acceptor/TcpServer)只管状态、缓冲区与生命周期。Buffer 的双下标设计把读写位置从 vector 的 size 里拆出来,收益是 End() 与两个 Offset() 处于同一坐标系、读后无需搬移;代价是所有下标访问都由自己保证不越界,resize、insert、clear 与下标折算的先后顺序都不能随手改。时间轮把"到点执行"寄托在析构时机上,因此任务生命周期完全绑定在槽位的 shared_ptr 上,取消与刷新都只是改变强引用的分布。反复需要复看的三处是:下标换算与 size 的同步顺序、短写与 n == 0 的处理、以及 _conns 的 key 与销毁路径是否接通。

6. 常见问题

1. Buffer::End() 为什么用 capacity() 而不是 size()?

用 capacity() 才能让 End() 恒等于"物理缓冲区末尾",与 Begin()+_redidx、Begin()+_wrtidx 同一坐标系;size() 在读取后不会缩小(ReadLen 只推进 _redidx),用它算 End() 会让"末尾"随读取漂移。见 echo.hpp 中 Buffer::End/Begin/ReadOffset/WriteOffset。

2. EnsureEnoughCapacity 末尾那两行写反会出现什么现象?

_wrtidx -= ReadOffset() - Begin(); 必须在 _redidx = 0; 之前。先归零时 ReadOffset() == Begin(),减数为 0,_wrtidx 保持旧值,随后 _buf.resize(_wrtidx - _redidx) 把 size 撑到旧写位置,缓冲区里多出一段从未写入的区间。见 Buffer::EnsureEnoughCapacity。

3. Write 里 assert(capacity - _wrtidx > len) 为什么是严格大于?

它与 EnsureEnoughCapacity 的 >= 不一致:恰好写满剩余容量时前者能通过、后者断言失败,len == 0 且在容量边界上也会触发。凡是贴着容量上限的写入路径都要按这个更严的约束预估。见 Buffer::Write。

4. HandleWrite 为什么可能丢掉没发完的数据?

发送后不看 SendNonBlock 的返回值,直接 RdixMove(ReadableGet()) 把读位置全量前移,并且无条件 RemWrite()。一旦发生短写,_outbuf 里剩余的数据其下标已被跳过,写监控也已关闭,不会再有下一轮 EPOLLOUT。见 Connection::HandleWrite。

5. _conns 为什么收不到连接销毁通知?

两个原因叠加:RemoveConnection 传的是 conn->Fd(),而 _conns 以 _next_id 为 key,RemoveConnectionInLoop 里 _conns.find(fd) 必然落空;且没有任何调用点触发 RemoveConnection,TcpServer 也未调用 Connection::SetServerCB 设置 _svrCloseCB。见 TcpServer::NewConnetion/RemoveConnection/RemoveConnectionInLoop 与 Connection::ReleaseInLoop。

6. 时间轮里出现被取消的任务会怎样?

TickRun() 扫下一槽时,只要有任一任务的 _cancel 为真就打印并 return,_tick 不推进、槽不清空;同时 HashAble() 用 _timers[id].lock() == 0 判断,operator[] 会给查询过的 id 留下空条目。两者都会改变 _timers 与 _wheel 的实际状态。见 TimeWheel::TickRun/HashAble。

7. 标签

header-only、Reactor、epoll、时间轮、C++ 服务端

相关推荐
Brilliantwxx1 小时前
【STM32】 内部 Flash 的 HAL 库函数详解与自定义封装实战
linux·服务器·网络
时间售卖铺1 小时前
Docker镜像构建全解析:从原理到实战优化
运维·docker·云原生·容器
西索斯coding2 小时前
qwen3.8-max-0902 和 minimax-m3 改真实仓库差多少?2026 年 50 个缺陷复现实测框架
java·服务器·网络·ai
小豆庄2 小时前
会议录屏忘打码后的技术复盘:本地自动化隐私脱敏方案
运维·opencv·目标检测·自动化·视频·纹理贴图
码途行者2 小时前
Linux 服务器 Golang 安装及环境变量配置
linux·运维·服务器
爱和冰阔落2 小时前
【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池
linux·运维·网络·udp
雪落漂泊2 小时前
Linux进程(三)概念:进程优先级与进程切换篇
linux·运维·服务器
拂拉氏2 小时前
【知识讲解】 Linux信号与可重入函数的认识
linux·运维·服务器
pikapika~~~3 小时前
计算机网络八股
服务器·网络·计算机网络