day18-Buffer
Buffer 是网络编程里专门用来高效收发 TCP 数据的 “内存缓冲区”,用来解决 TCP 粘包、半包、读写效率低等问题
Buffer都干什么?
接收网络数据:从 TCP 连接里读数据存起来
暂存待发送数据:要发的数据先放这里,等能发了再发
自动管理内存:自动扩容、自动整理空间,不用你手动 malloc/free
Buffer.h
1 |
|
私有成员变量
1 | std::vector<char> buffer_; |
readerIndex_:可读数据开始位置writerIndex_:可写空间开始位置vector<char> buffer_:真正存数据的内存
内部函数
1 | void makeSpace(size_t len); |
扩容函数(核心)
1 | char* begin() |
获取底层 vector 内存的首地址(缓冲区最开始的位置),所有读写指针都是基于这个地址做偏移计算
为什么要实现常量和非常量两个版本?
- 本质是函数重载,
const成员函数里,只能调用const版本的begin ()
① 非常量版本 char* begin()
用于修改数据:append / writeFd / makeSpace等需要修改缓冲区内容 → 必须返回可写指针。
② 常量版本 const char* begin() const
用于只读数据:peek() / readableBytes() 等 const 成员函数等只看数据,不修改 → 必须返回只读指针。
共有函数
1 | class Buffer |
static const int kCheapPrepend = 8;前面预留 8 字节空位!,专门给发送消息时插 4/8 字节协议头用。static const int kInitialSize = 1024;缓冲区默认大小 1024 字节,不够自动扩容读写指针都初始化都8字节处,前面8个字节留着给数据头用
1 | size_t readableBytes() const |
计算缓冲区三块区域分别有多大
1 | //返回缓冲区可读数据起始地址 |
分别返回可读区和可写区的起始地址
1 | //从fd上读数据 |
核心读写函数
1 | void retrieve(size_t len) |
读完数据怎么清除缓冲区
1 | std::string retrieveAllAsString() |
读缓冲区数据,并清空缓冲区
1 | void ensureWritableByte(size_t len) |
往 Buffer 里写数据,先检查空间够不够 → 不够就扩容 / 整理 → 然后拷贝数据 → 移动写指针
Buffer.cc
1 |
1 | void Buffer::makeSpace(size_t len) |
- 扩容逻辑:
- 剩余可写区空间(writableBytes()) + 0~读指针空间(prependable()) < 要写入的数据(len) + 预留8字节(kCheapPrepend) 时直接把 vector 底层内存变大为
len + writerIndex_(当前数据长度 + 需要的空间)- 先保存当前可读数据长度,因为后面会移动读写指针,所以要先保存
- 把中间的可读数据,整块搬到从 8 号位置开始(**
copy(...)**) - 重置读指针:8
- 重置写指针:8 + 数据长度
- 剩余可写区空间(writableBytes()) + 0~读指针空间(prependable()) < 要写入的数据(len) + 预留8字节(kCheapPrepend) 时直接把 vector 底层内存变大为
扩容逻辑设计优点
空间够时不扩容,数据前移整理,不申请新内存,仅仅是一段内存拷贝CPU 极快
TCP 只能读写 连续内存,数据整体前移整理,保证内存连续,不会乱
扩容
resize()会重新分配内存、拷贝所有数据、释放旧内存,但Buffer 初始化大小 (1024)后,正常收发消息,数据读完就 retrieve,空间不够就往前移,很少走到 resize 扩容那一步
1 | //从fd上读数据 |
iovec是什么?
1 |
|
一个 iovec = 一段连续内存的(地址 + 长度),它本身不存储数据,只是描述一段数据在哪里、有多长。
作用:一次系统调用,读写多块不连续内存
- 但现实中数据经常是分散的,如:协议头一块,消息体一块或像逻辑里要读多块内存数据
- 如果用普通 write:
- 必须先把多块内存拷贝合并成一块大连续内存
- 再调用 write
- 多一次内存拷贝 → 性能损耗
- 用 iovec + writev/readv:
- 不用拷贝、不用合并
- 直接把多块分散内存打包成一个 iovec[] 数组
- 一次系统调用全部发出去
1 | ssize_t writev(int fd, const struct iovec *iov, int iovcnt); |
fd:文件描述符(socket、文件都可以)iov:iovec 数组(每一项代表一段数据)iovcnt:数组元素个数(几块内存)返回:成功写入的总字节数
readFd()逻辑
1. 栈上临时缓冲区:extraBuf[65536]
1 | char extraBuf[65536]; |
- 64KB 栈内存,极快、无需 malloc、无需释放。
- 作用:当 Buffer 内部缓冲区不够大时,作为临时收容所。
- 为什么 64K?Linux 内核默认 socket 接收缓冲区大小一般就是 64K,一次能读完,避免多次系统调用。
2. iovec vec[2]:两块内存区域
1 | struct iovec vec[2]; |
- 准备两块连续内存,用
readv一次性读取。 - 这是分散读:内核把数据依次填入两块内存,完全零拷贝。
3. 第一块:Buffer 内部可写区
1 | size_t writable = writableBytes(); |
iov_base:Buffer 内部可写地址iov_len:可写长度- 优先把数据读到 Buffer 内部
4. 第二块:栈上临时区
1 | vec[1].iov_base = extraBuf; |
- 备用缓冲区,只有内部缓冲区满了才会用到。
5. 决定用几块内存读
1 | const int iovcnt = writable < sizeof(extraBuf) ? 2 : 1; |
- 如果 Buffer 内部可写空间 ≥ 64K:只需要一块(vec [0])
- 如果 Buffer 内部可写空间 < 64K:用两块(vec [0] + vec [1])
- 避免浪费,也避免读不下
6. 核心调用:readv
1 | const ssize_t n = ::readv(fd, vec, iovcnt); |
一次系统调用,读取多块分散内存
内核自动把数据按顺序填入:
- 先填 vec [0](Buffer 内部)
- 填满了再填 vec [1](extraBuf)
真正的零拷贝、高性能
三种返回值处理
① 读失败:n < 0
1 | if(n < 0) |
- 只保存错误码,不打印、不处理
- 基础库组件只负责传递错误,不决定错误处理策略
② 数据很少:全部写入内部缓冲区
1 | else if(n < writable) |
- 数据量 ≤ 内部可写空间
- 直接移动写指针即可
- 零拷贝、零开销
③ 数据很多:内部缓冲区写满,剩余写入 extraBuf
1 | else |
- 内部缓冲区已经写满
- 剩下的数据被内核放到了
extraBuf - 把
extraBuf里的数据追加到 Buffer 内部,extraBuf 在栈上,函数退出数据就丢,必须把数据拷贝进 Buffer 长期保存
1 | //通过fd写数据 |
这是muduo缓冲区写函数简化版,把当前缓冲区里所有可读数据,一次性发给 fd。
真正muduo Buffer 是环状结构,当数据写到末尾,会从头部继续写,导致可读数据被切成两块:末尾一段,开头一段。这时候:
不能用一次 write 发完,必须用 writev 把两块一起发
Buffer设计逻辑
Buffer 靠两个指针管理一块连续内存,读过不删、不够才紧缩;读数据用 readv + 栈上 extraBuf 一次性读满内核数据,减少系统调用,既省内存又高性能;写数据直接发送,结构简单高效。
读数据完整流程(接收消息)
1 | readFd() 从 socket 读到可写区 |
因为是读出数据写入缓冲区,所以移动writeIndex_
readFd() 里移动 writerIndex_,本质上是「往 Buffer 里写数据」,而不是 “读数据”。
- 从 socket 视角看:这是读操作(从网卡读到应用)
- 从 Buffer 视角看:这是写操作(把数据写入缓冲区)
写数据完整流程(发送消息)
1 | append() 写入数据体 |
Buffer相关问题
为什么在头文件中实现这么多函数?
只有一两行的小函数且调用频繁,可以直接在头文件实现编译器会自动内联,消除函数调用开销,性能更高
prependable() 和 kCheapPrepend 关系?
他俩本质上没啥关系。
kCheapPrepend:固定 8 字节,是预留的协议头空间。prependable()=readerIndex_,是0~ 读指针之间的总空闲,包含 8 字节 + 已读废弃数据(整个 Buffer 里,只有makeSpace ()会用它)。==kCheapPrepend只有发送消息(写数据)时用,读数据时不会用==
- 什么时候会用到
kCheapPrepend:业务代码里先有数据体,后通过数据体算出数据头,头部必须放在最前面,但你已经把数据体写进缓冲区了,前面没空间。所以必须提前预留 8 个字节! - 接收时,头部本来就在数据最前面
- 什么时候会用到
为什么 Buffer 小、extrabuf 大?
Buffer是每个连接长期持有,不能太大,否则爆内存extrabuf是栈上临时变量,函数结束就销毁,所有连接轮流用,不占长期内存,所以越大越好
源码地址
Buffer.h:https://gitee.com/lpzdinghai/lpzmuduo/blob/master/Buffer.h
Buffer.cc:https://gitee.com/lpzdinghai/lpzmuduo/blob/master/Buffer.cc