拓冰建站拓冰建站
首页 / 资讯中心 / 正文

muduo网络库(十三):Buffer 缓冲区类

muduo网络库十三Buffer 缓冲区类muduo网络库十三Buffer 缓冲区类一句话理解 Buffer为什么必须有应用层缓冲区设计目标内存布局三段式设计预留区 kCheapPrepend为协议头留的前排座位append 与扩容先整理再搬家readFd 与 readv一次系统调用retrieve消费数据只是挪指针实战粘包半包处理模板设计精髓总结系列串联muduo网络库十三Buffer 缓冲区类一句话理解 Buffer把网络收发想象成收快递没有驿站快递员内核每次到货都要求你当场签收、当场处理——你手头正忙就完蛋了有驿站Buffer快递先堆在货架上你有空了按顺序来取取走的位置不用立刻清空货架还能继续堆新包裹muduo 的Buffer就是网络数据的中转驿站。它是 TCP 连接的储物间所有到达的数据先在这里攒着攒够了完整的消息再交给业务处理发不出去的数据也暂存在这里等网络空闲了再发。为什么必须有应用层缓冲区初学网络编程很容易忽略缓冲区的必要性三个原因说明它不可或缺1. 非阻塞读能读多少读多少socket 设置为非阻塞后read()有多少数据就能读多少。一次 EPOLLIN 事件到来你可能收到 100 字节也可能收到 10000 字节——读到的数据总得有地方放这就是 inputBuffer。2. 非阻塞写写不完先存着write()也可能失败——内核发送缓冲区满了会返回 EAGAIN。剩余没发出去的数据必须暂存在 outputBuffer 里并注册 EPOLLOUT 事件等 socket 可写时继续发。3. TCP 是字节流粘包与半包TCP 不保证消息边界。客户端连发两条消息服务器可能一次读到一条半半包也可能两条拼在一次读取里粘包。必须在 Buffer 里攒数据凑够完整消息再解析。客户端发送: [消息A][消息B] ┌─ 一次读: [消息A][消息B] → 粘包 服务器读取 ─────────────────────────┤ ├─ 第一次读: [消息A前半] → 半包 └─ 第二次读: [消息A后半]设计目标陈硕在《Linux 多线程服务端编程》中提出了 muduo Buffer 的设计准则对外表现为一块连续内存方便peek()查看、send()发送大小自动增长不用事先预估数据量节省内存空闲时不占用大块内存读方向一次系统调用尽量多读配合 LT 触发模式减少 epoll 通知次数muduo 的实现用四个特性交出答卷零拷贝消费、内存高效复用、readv 系统调用优化、extrabuf 安全兜底。内存布局三段式设计┌─────────────────────────────────────────────────────────────┐ │ buffer_ (std::vectorchar) │ ├────────────┬─────────────────┬──────────────────────────────┤ │ 预留区域 │ 可读区域 │ 可写区域 │ │ kCheapPrep │ [read, write) │ [write, buffer_.size()) │ │ 8 bytes │ 已接收未读取 │ 空闲可写入 │ └────────────┴─────────────────┴──────────────────────────────┘ ^ ^ readIndex_ writeIndex_区域范围用途预留区[0, kCheapPrepend)快速追加协议头部如长度字段可读区[readIndex_, writeIndex_)已接收但未读取的数据可写区[writeIndex_, buffer_.size())空闲空间用于写入新数据核心成员只有三个全部 O(1) 可计算std::vectorcharbuffer_;// 底层存储连续内存size_t readIndex_;// 读指针指向下一个可读字节size_t writeIndex_;// 写指针指向下一个可写字节size_treadableBytes()const{returnwriteIndex_-readIndex_;}// 可读字节数size_twritableBytes()const{returnbuffer_.size()-writeIndex_;}// 可写字节数size_tprependableBytes()const{returnreadIndex_-kCheapPrepend;}// 头部空闲区大小这个布局的关键洞察数据消费后不必清空内存只需移动 readIndex_。已读区域不是垃圾而是下一轮写入的预备空间。预留区 kCheapPrepend为协议头留的前排座位staticconstsize_t kCheapPrepend8;// 预留8字节什么场景需要在数据前面加东西最典型的是长度前缀协议先收到数据体再补一个总长度字段到头部方便对端解析。// 在数据前追加4字节长度voidprepend(constvoid*data,size_t len){assert(lenprependableBytes());readIndex_-len;// 读指针往前退constchar*dstatic_castconstchar*(data);std::copy(d,dlen,begin()readIndex_);// 在腾出的位置写入}图解 prepend 的过程prepend 前: [预留8B][ ABCD | 可写区...] ^readIndex prepend(len2) 后: [预留6B][ XY | ABCD | 可写区...] ^readIndex后退2格如果头部空间不够呢muduo 会先搬移数据腾出前置空间。没有预留区的话每次加协议头都得整体搬移数据O(n) 变 O(1) 的差别就在这 8 个字节。append 与扩容先整理再搬家voidappend(constchar*data,size_t len){ensureWriteableBytes(len);// 先确保缓冲区有足够空间std::copy(data,datalen,beginWrite());writeIndex_len;}空间不足时expandSpace有两条路径决策流程如下是否是否append 需要 len 字节空间可写区 len ?直接写入 writeIndex 前移可写区加头部空闲区 len kCheapPrepend ?策略1 数据搬移 std::copy 一次搞定策略2 真扩容 buffer_.resize对应源码voidexpandSpace(size_t len){// 策略2真扩容前方后方空闲空间拼起来都不够if(writableBytes()prependableBytes()lenkCheapPrepend){buffer_.resize(writeIndex_len);}else{// 策略1数据搬移读操作让前方空出了位置// 把可读数据整体前移把碎片空间连成整块size_t readablereadableBytes();std::copy(begin()readIndex_,begin()writeIndex_,begin()kCheapPrepend);readIndex_kCheapPrepend;writeIndex_kCheapPrependreadable;}}注意优先级是先整理、后扩容数据搬移只是一次std::copy不涉及堆上重新分配内存代价更小只有拼起来都不够时才resize扩大 vector搬移前碎片化: [已读空洞 ABC 读过的][EF 可读][一点可写] ^readIndex ^writeIndex 搬移后空间归一: [预留][EF 可读][大大的一段可写空间] ^readIndex ^writeIndexreadFd 与 readv一次系统调用这是 muduo Buffer 的核心亮点。思考一个两难问题每个连接的可写区预留大了→ 10000 个连接各占 64KB内存爆炸预留小了→ 一次读不完得再调一次 read系统调用翻倍muduo 用readv分散读 栈上临时缓冲区完美破局ssize_tBuffer::readFd(intfd,int*saveError){charextrabuf[65536]{0};// 64KB 栈上临时缓冲区structiovecvec[2];constsize_t writeablewritableBytes();vec[0].iov_basebeginWrite();// 第一块buffer_ 的可写区vec[0].iov_lenwriteable;vec[1].iov_baseextrabuf;// 第二块栈上的 extrabufvec[1].iov_lensizeof(extrabuf);constintiovcnt(writeablesizeof(extrabuf))?2:1;constssize_t nread::readv(fd,vec,iovcnt);// 一次系统调用读两块if(nread0){*saveErrorerrno;}elseif(nreadwriteable){writeIndex_nread;// 数据都在 buffer_ 里指针一移完事}else{writeIndex_buffer_.size();// buffer_ 写满了append(extrabuf,nread-writeable);// 溢出部分收编进 buffer_触发扩容}returnnread;}执行流程是否只装满 vec0溢出到 vec1readFd socket 有数据可读准备 iovec 数组 vec0 加 vec1可写区小于 64KB ?iovcnt 等于 2 一次 readv 读两块iovcnt 等于 1 只读 buffernread 落在哪里writeIndex 前移 零额外开销append 触发搬移扩容 收编 extrabuf三个设计意图逐一拆解1. 为什么 extrabuf 在栈上64KB 的临时缓冲区放在栈上函数返回即释放不占用任何持久内存。绝大多数情况下数据量不大extrabuf 根本用不上——等于白捡了一个无限容量的兜底而平时成本为零。2. 为什么用 readv 而不是两次 read一次readv同时试探两块缓冲区数据溢出时不需要第二次系统调用。配合 LT 触发模式muduo 的理念是一次把数据读完避免 epoll 反复通知。3. 溢出时才扩容只有数据真的多到装不下才通过append触发搬移/扩容。扩容是例外而非常态——这就是内存高效的底气。retrieve消费数据只是挪指针std::stringretrieveAsString(size_t len){std::stringresult(peek(),len);// peek(): 不消费地看一眼retrieve(len);// 确认消费returnresult;}voidretrieve(size_t len){if(lenreadableBytes()){readIndex_len;// 部分消费一条加法指令}else{retrieveAll();// 全部消费两个指针归位}}peek()返回可读区起始指针但不移动任何东西retrieve()才真正消费。peek retrieve 的组合拳正是解决粘包半包的钥匙先 peek 出头部看长度够不够一个完整包够了才 retrieve。消费的成本仅仅是readIndex_ len——零拷贝。被消费的区域不做任何清除动作它的内存将在下一轮 expandSpace 时被回收复用。实战粘包半包处理模板// 网络数据接收 长度前缀协议解析Buffer buf;intsavedErrno;ssize_t nbuf.readFd(sockfd,savedErrno);// 一次尽量多读while(buf.readableBytes()headerLen){constchar*headerbuf.peek();// 只看不消费intbodyLenparseHeader(header);if(buf.readableBytes()headerLenbodyLen){buf.retrieve(headerLen);// 够一个完整包消费头部std::stringbody(buf.peek(),bodyLen);buf.retrieve(bodyLen);// 消费包体processMessage(body);// 交给业务处理}else{break;// 数据不足一个完整包留着等下次 EPOLLIN}}处理逻辑分三步peek 偷看头部读取长度字段但不消费数据判断够不够可读字节数 ≥ 头部 包体才动手不够就等剩余半包留在 Buffer 里等下一次读事件把数据补齐这是LT 非阻塞 IO 应用层缓冲区的经典配合读到多少算多少攒在 Buffer 里慢慢解析。设计精髓总结设计要点技术实现收益预留头部空间kCheapPrepend 8O(1) 追加协议头部双指针管理readIndex_/writeIndex_无拷贝的数据消费内存复用expandSpace()先搬移后扩容减少内存分配次数分散读优化readv() 栈上 extrabuf一次系统调用内存与效率兼得peek/retrieve先看后消费粘包半包的天然解法vector 底层std::vectorchar自动内存管理连续地址空间系列串联Buffer 是下一章主角 TcpConnection 的左膀右臂每条连接持有inputBuffer_收数据和outputBuffer_发数据读写事件触发时与 Buffer 配合完成非阻塞收发。有了 Channel 的事件分发、Poller 的 IO 复用、EventLoop 的循环驱动、Acceptor 的接客和 Buffer 的中转剩下的事就是把它们粘合成完整的 TCP 服务器——这正是 TcpServer 与 TcpConnection 的使命。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门