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::vector<char>) │ ├────────────┬─────────────────┬──────────────────────────────┤ │ 预留区域 │ 可读区域 │ 可写区域 │ │ kCheapPrep │ [read, write) │ [write, buffer_.size()) │ │ 8 bytes │ 已接收未读取 │ 空闲可写入 │ └────────────┴─────────────────┴──────────────────────────────┘ ^ ^ readIndex_ writeIndex_| 区域 | 范围 | 用途 |
|---|---|---|
| 预留区 | [0, kCheapPrepend) | 快速追加协议头部(如长度字段) |
| 可读区 | [readIndex_, writeIndex_) | 已接收但未读取的数据 |
| 可写区 | [writeIndex_, buffer_.size()) | 空闲空间,用于写入新数据 |
核心成员只有三个,全部 O(1) 可计算:
std::vector<char>buffer_;// 底层存储,连续内存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 kCheapPrepend=8;// 预留8字节什么场景需要在数据前面加东西?最典型的是长度前缀协议:先收到数据体,再补一个"总长度"字段到头部,方便对端解析。
// 在数据前追加4字节长度voidprepend(constvoid*data,size_t len){assert(len<=prependableBytes());readIndex_-=len;// 读指针往前退constchar*d=static_cast<constchar*>(data);std::copy(d,d+len,begin()+readIndex_);// 在腾出的位置写入}图解 prepend 的过程:
prepend 前: [预留8B][ ABCD | 可写区...] ^readIndex prepend(len=2) 后: [预留6B][ XY | ABCD | 可写区...] ^readIndex(后退2格)如果头部空间不够呢?muduo 会先搬移数据腾出前置空间。没有预留区的话,每次加协议头都得整体搬移数据,O(n) 变 O(1) 的差别就在这 8 个字节。
append 与扩容:先整理,再搬家
voidappend(constchar*data,size_t len){ensureWriteableBytes(len);// 先确保缓冲区有足够空间std::copy(data,data+len,beginWrite());writeIndex_+=len;}空间不足时,expandSpace有两条路径,决策流程如下:
对应源码:
voidexpandSpace(size_t len){// 策略2(真扩容):前方+后方空闲空间拼起来都不够if(writableBytes()+prependableBytes()<len+kCheapPrepend){buffer_.resize(writeIndex_+len);}else{// 策略1(数据搬移):读操作让前方空出了位置,// 把可读数据整体前移,把碎片空间连成整块size_t readable=readableBytes();std::copy(begin()+readIndex_,begin()+writeIndex_,begin()+kCheapPrepend);readIndex_=kCheapPrepend;writeIndex_=kCheapPrepend+readable;}}注意优先级是先整理、后扩容:
- 数据搬移只是一次
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 writeable=writableBytes();vec[0].iov_base=beginWrite();// 第一块:buffer_ 的可写区vec[0].iov_len=writeable;vec[1].iov_base=extrabuf;// 第二块:栈上的 extrabufvec[1].iov_len=sizeof(extrabuf);constintiovcnt=(writeable<sizeof(extrabuf))?2:1;constssize_t nread=::readv(fd,vec,iovcnt);// 一次系统调用读两块if(nread<0){*saveError=errno;}elseif(nread<=writeable){writeIndex_+=nread;// 数据都在 buffer_ 里,指针一移完事}else{writeIndex_=buffer_.size();// buffer_ 写满了append(extrabuf,nread-writeable);// 溢出部分收编进 buffer_(触发扩容)}returnnread;}执行流程:
三个设计意图逐一拆解:
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(len<readableBytes()){readIndex_+=len;// 部分消费:一条加法指令}else{retrieveAll();// 全部消费:两个指针归位}}peek()返回可读区起始指针但不移动任何东西,retrieve()才真正消费。peek + retrieve 的组合拳正是解决粘包半包的钥匙:先 peek 出头部看长度够不够一个完整包,够了才 retrieve。
消费的成本仅仅是readIndex_ += len——零拷贝。被消费的区域不做任何清除动作,它的内存将在下一轮 expandSpace 时被回收复用。
实战:粘包半包处理模板
// 网络数据接收 + 长度前缀协议解析Buffer buf;intsavedErrno;ssize_t n=buf.readFd(sockfd,&savedErrno);// 一次尽量多读while(buf.readableBytes()>=headerLen){constchar*header=buf.peek();// 只看不消费intbodyLen=parseHeader(header);if(buf.readableBytes()>=headerLen+bodyLen){buf.retrieve(headerLen);// 够一个完整包,消费头部std::stringbody(buf.peek(),bodyLen);buf.retrieve(bodyLen);// 消费包体processMessage(body);// 交给业务处理}else{break;// 数据不足一个完整包,留着等下次 EPOLLIN}}处理逻辑分三步:
- peek 偷看头部:读取长度字段,但不消费数据
- 判断够不够:可读字节数 ≥ 头部 + 包体,才动手
- 不够就等:剩余半包留在 Buffer 里,等下一次读事件把数据补齐
这是LT + 非阻塞 IO + 应用层缓冲区的经典配合:读到多少算多少,攒在 Buffer 里慢慢解析。
设计精髓总结
| 设计要点 | 技术实现 | 收益 |
|---|---|---|
| 预留头部空间 | kCheapPrepend = 8 | O(1) 追加协议头部 |
| 双指针管理 | readIndex_/writeIndex_ | 无拷贝的数据消费 |
| 内存复用 | expandSpace()先搬移后扩容 | 减少内存分配次数 |
| 分散读优化 | readv()+ 栈上 extrabuf | 一次系统调用,内存与效率兼得 |
| peek/retrieve | 先看后消费 | 粘包半包的天然解法 |
| vector 底层 | std::vector<char> | 自动内存管理,连续地址空间 |
系列串联
Buffer 是下一章主角 TcpConnection 的左膀右臂:每条连接持有inputBuffer_(收数据)和outputBuffer_(发数据),读写事件触发时与 Buffer 配合完成非阻塞收发。有了 Channel 的事件分发、Poller 的 IO 复用、EventLoop 的循环驱动、Acceptor 的接客和 Buffer 的中转,剩下的事就是把它们粘合成完整的 TCP 服务器——这正是 TcpServer 与 TcpConnection 的使命。