news 2026/8/23 6:53:51

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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::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有两条路径,决策流程如下:

append 需要 len 字节空间

可写区 >= len ?

直接写入 writeIndex 前移

可写区加头部空闲区 >= len + kCheapPrepend ?

策略1 数据搬移 std::copy 一次搞定

策略2 真扩容 buffer_.resize

对应源码:

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 ^writeIndex

readFd 与 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;}

执行流程:

只装满 vec0

溢出到 vec1

readFd socket 有数据可读

准备 iovec 数组 vec0 加 vec1

可写区小于 64KB ?

iovcnt 等于 2 一次 readv 读两块

iovcnt 等于 1 只读 buffer

nread 落在哪里

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(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}}

处理逻辑分三步:

  1. peek 偷看头部:读取长度字段,但不消费数据
  2. 判断够不够:可读字节数 ≥ 头部 + 包体,才动手
  3. 不够就等:剩余半包留在 Buffer 里,等下一次读事件把数据补齐
    这是LT + 非阻塞 IO + 应用层缓冲区的经典配合:读到多少算多少,攒在 Buffer 里慢慢解析。

设计精髓总结

设计要点技术实现收益
预留头部空间kCheapPrepend = 8O(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 的使命。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/23 6:53:22

Revit建筑设计思维与BIM正向设计实战进阶指南

这次我们来看一个面向Revit建筑设计的配套视频资源。项目标题“Revit建筑设计思维课堂配套视频4-3-2”指向的是一套结构化的视频教程&#xff0c;其核心价值在于将建筑设计思维与Revit软件实操深度结合&#xff0c;帮助学习者从“会操作”进阶到“懂设计”。对于建筑、土木、室…

作者头像 李华
网站建设 2026/8/23 6:52:57

技术面试与简历优化实战指南

1. 项目概述最近帮几位朋友做了面试复盘和简历优化&#xff0c;发现很多技术人明明实力不错&#xff0c;却总在简历筛选和面试环节吃亏。作为经历过多次大厂面试的过来人&#xff0c;我整理了一套经过实战验证的方法论。这套方法曾帮助多位候选人成功拿到头部互联网公司的offer…

作者头像 李华
网站建设 2026/8/23 6:50:39

基于Spring Boot的自媒体平台网站系统(源码+lw+部署文档+讲解等)

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/8/23 6:48:26

数学规划模型实战:从线性到非线性,建模求解全解析

1. 从“拍脑袋”到“算最优”&#xff1a;数学规划模型的核心价值在数学建模竞赛或者实际工程、管理问题中&#xff0c;我们常常会遇到这样的场景&#xff1a;面对一堆约束条件&#xff08;比如预算有限、时间紧迫、资源不足&#xff09;&#xff0c;我们需要从无数种可能的方案…

作者头像 李华
网站建设 2026/8/23 6:46:47

基于 Android 的校园信息管理系统源码

摘要&#xff1a;本文以一个面向高校的 Android 校园信息管理系统&#xff08;campusInfoApp&#xff09;为案例&#xff0c;从架构设计、核心模块实现、关键技术选型三个维度展开分析&#xff0c;详细阐述 MVP 分层、角色路由、主题换肤、网络封装等工程化实践&#xff0c;为同…

作者头像 李华