Buffer 是网络编程里专门用来高效收发 TCP 数据的 “内存缓冲区”,用来解决 TCP 粘包、半包、读写效率低等问题

Buffer都干什么?

  1. 接收网络数据:从 TCP 连接里读数据存起来

  2. 暂存待发送数据:要发的数据先放这里,等能发了再发

  3. 自动管理内存:自动扩容、自动整理空间,不用你手动 malloc/free

Buffer.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once

#include <vector>
#include <sys/types.h>
#include <string>

/*
+-------------------+-----------------------+---------------------------+
| 前置预留区 | 可读区 (readable) | 可写区 (writable) |
| (kCheapPrepend=8) | | |
+-------------------+-----------------------+---------------------------+
↑ ↑ ↑ ↑
0 readerIndex_ writerIndex_ buffer_.size()
*/

私有成员变量

1
2
3
std::vector<char> buffer_;
size_t readerIndex_;
size_t writerIndex_;
  • readerIndex_:可读数据开始位置

  • writerIndex_:可写空间开始位置

  • vector<char> buffer_:真正存数据的内存

内部函数

1
void makeSpace(size_t len);

扩容函数(核心)

1
2
3
4
5
6
7
8
9
char* begin()
{
return buffer_.data();
}

const char* begin() const
{
return buffer_.data();
}

获取底层 vector 内存的首地址(缓冲区最开始的位置),所有读写指针都是基于这个地址做偏移计算

为什么要实现常量和非常量两个版本?

  • 本质是函数重载const 成员函数里,只能调用const版本的 begin ()

① 非常量版本 char* begin()

用于修改数据append / writeFd / makeSpace等需要修改缓冲区内容 → 必须返回可写指针

② 常量版本 const char* begin() const

用于只读数据peek() / readableBytes() 等 const 成员函数等只看数据,不修改 → 必须返回只读指针

共有函数

1
2
3
4
5
6
7
8
9
10
11
class Buffer
{
public:
static const int kCheapPrepend = 8;
static const int kInitialSize = 1024;

explicit Buffer(size_t initialSize = kInitialSize)
: buffer_(kCheapPrepend + initialSize)
, readerIndex_(kCheapPrepend)
, writerIndex_(kCheapPrepend)
{}
  • static const int kCheapPrepend = 8;前面预留 8 字节空位!,专门给发送消息时插 4/8 字节协议头用。

  • static const int kInitialSize = 1024;缓冲区默认大小 1024 字节,不够自动扩容

  • 读写指针都初始化都8字节处,前面8个字节留着给数据头用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
size_t readableBytes() const
{
return writerIndex_ - readerIndex_;
}

size_t writableBytes() const
{
return buffer_.size() - writerIndex_;
}

size_t prependable() const
{
return readerIndex_;
}

计算缓冲区三块区域分别有多大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//返回缓冲区可读数据起始地址
const char* peek() const
{
return begin() + readerIndex_;
}

char* beginWrite()
{
return begin() + writerIndex_;
}

const char* beginWrite() const
{
return begin() + writerIndex_;
}

分别返回可读区和可写区的起始地址

1
2
3
4
//从fd上读数据
ssize_t readFd(int fd, int* saveErrno);
//通过fd写数据
ssize_t writeFd(int fd, int* saveErrno);

核心读写函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void retrieve(size_t len)
{
if(len < readableBytes())
{
readerIndex_ += len;
}
else
{
retrieveAll();
}
}

void retrieveAll()
{
readerIndex_ = writerIndex_ = kCheapPrepend;
}

读完数据怎么清除缓冲区

1
2
3
4
5
6
7
8
9
10
11
std::string retrieveAllAsString()
{
return retrieveAsString(readableBytes());
}

std::string retrieveAsString(size_t len)
{
std::string result(peek(), len);
retrieve(len);
return result;
}

读缓冲区数据,并清空缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ensureWritableByte(size_t len)
{
if(len > writableBytes())
{
makeSpace(len);
}
}

//往缓冲区写数据
void append(const char* data, size_t len)
{
ensureWritableByte(len);
std::copy(data, data + len, beginWrite()); //copy三个参数:从哪开始,到哪结束,往哪放
writerIndex_ += len;
}

往 Buffer 里写数据,先检查空间够不够 → 不够就扩容 / 整理 → 然后拷贝数据 → 移动写指针

Buffer.cc

1
2
3
4
#include "Buffer.h"

#include <unistd.h>
#include <sys/uio.h>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Buffer::makeSpace(size_t len)
{
if(writableBytes() + prependable() < len + kCheapPrepend)
{
buffer_.resize(len + writerIndex_)
}
else
{
size_t readable = readableBytes();
std::copy(
begin() + readerIndex_,
begin() + writerIndex_,
begin() + kCheapPrepend
);
readerIndex_ = kCheapPrepend;
writerIndex_ = kCheapPrepend + readable;
}
}
  • 扩容逻辑:
    • 剩余可写区空间(writableBytes()) + 0~读指针空间(prependable()) < 要写入的数据(len) + 预留8字节(kCheapPrepend) 时直接把 vector 底层内存变大len + writerIndex_当前数据长度 + 需要的空间
      • 先保存当前可读数据长度,因为后面会移动读写指针,所以要先保存
      • 把中间的可读数据,整块搬到从 8 号位置开始(**copy(...)**)
      • 重置读指针:8
      • 重置写指针:8 + 数据长度

扩容逻辑设计优点

  1. 空间够时不扩容,数据前移整理不申请新内存,仅仅是一段内存拷贝CPU 极快

  2. TCP 只能读写 连续内存,数据整体前移整理,保证内存连续,不会乱

  3. 扩容 resize()重新分配内存、拷贝所有数据、释放旧内存,但Buffer 初始化大小 (1024)后,正常收发消息,数据读完就 retrieve,空间不够就往前移,很少走到 resize 扩容那一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//从fd上读数据
ssize_t Buffer::readFd(int fd, int* saveErrno)
{
char extraBuf[65536];
struct iovec vec[2];

size_t writable = writableBytes();
vec[0].iov_base = begin() + writerIndex_;
vec[0].iov_len = writable;
vec[1].iov_base = extraBuf;
vec[1].iov_len = sizeof extraBuf;

const int iovcnt = writable < sizeof(extraBuf) ? 2 : 1;
const ssize_t n = ::readv(fd, vec, iovcnt);

if(n < 0)
{
*saveErrno = errno;//Buffer是基础组件,只传递错误信息,不打印日志
}
else if(n < writable)
{
writerIndex_ += n;
}
else // extrabuf里面也写入了数据
{
writerIndex_ = buffer_.size();
append(extraBuf, n - writable); // writerIndex_开始写 n - writable大小的数据
}

return n;
}

iovec是什么?

1
2
3
4
5
6
#include <sys/uio.h>

struct iovec {
void *iov_base; // 缓冲区起始地址(指针)
size_t iov_len; // 缓冲区长度(字节数)
};
  • 一个 iovec = 一段连续内存的(地址 + 长度),它本身不存储数据,只是描述一段数据在哪里、有多长

  • 作用:一次系统调用,读写多块不连续内存

    • 但现实中数据经常是分散的,如:协议头一块,消息体一块或像逻辑里要读多块内存数据
    • 如果用普通 write:
      • 必须先把多块内存拷贝合并成一块大连续内存
      • 再调用 write
      • 多一次内存拷贝 → 性能损耗
    • 用 iovec + writev/readv:
      • 不用拷贝、不用合并
      • 直接把多块分散内存打包成一个 iovec[] 数组
      • 一次系统调用全部发出去
1
2
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
ssize_t readv(int fd, struct iovec *iov, int iovcnt);
  • fd:文件描述符(socket、文件都可以)

  • ioviovec 数组(每一项代表一段数据)

  • 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
2
3
size_t writable = writableBytes();
vec[0].iov_base = begin() + writerIndex_;
vec[0].iov_len = writable;
  • iov_base:Buffer 内部可写地址
  • iov_len:可写长度
  • 优先把数据读到 Buffer 内部

4. 第二块:栈上临时区

1
2
vec[1].iov_base = extraBuf;
vec[1].iov_len = sizeof 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);
  • 一次系统调用,读取多块分散内存

  • 内核自动把数据按顺序填入:

    1. 先填 vec [0](Buffer 内部)
    2. 填满了再填 vec [1](extraBuf)
  • 真正的零拷贝、高性能

三种返回值处理

① 读失败:n < 0

1
2
3
4
if(n < 0)
{
*saveErrno = errno;
}
  • 只保存错误码,不打印、不处理
  • 基础库组件只负责传递错误,不决定错误处理策略

② 数据很少:全部写入内部缓冲区

1
2
3
4
else if(n < writable)
{
writerIndex_ += n;
}
  • 数据量 ≤ 内部可写空间
  • 直接移动写指针即可
  • 零拷贝、零开销

③ 数据很多:内部缓冲区写满,剩余写入 extraBuf

1
2
3
4
5
else
{
writerIndex_ = buffer_.size();
append(extraBuf, n - writable);
}
  1. 内部缓冲区已经写满
  2. 剩下的数据被内核放到了 extraBuf
  3. extraBuf 里的数据追加到 Buffer 内部,extraBuf 在栈上,函数退出数据就丢,必须把数据拷贝进 Buffer 长期保存
1
2
3
4
5
6
7
8
9
10
//通过fd写数据
ssize_t Buffer::writeFd(int fd, int* saveErrno)
{
ssize_t n = ::write(fd, peek(), readableBytes());
if (n < 0)
{
*saveErrno = errno;
}
return n;
}

这是muduo缓冲区写函数简化版,把当前缓冲区里所有可读数据,一次性发给 fd。

真正muduo Buffer 是环状结构,当数据写到末尾,会从头部继续写,导致可读数据被切成两块:末尾一段,开头一段。这时候:

不能用一次 write 发完必须用 writev 把两块一起发

Buffer设计逻辑

Buffer 靠两个指针管理一块连续内存,读过不删、不够才紧缩;读数据用 readv + 栈上 extraBuf 一次性读满内核数据,减少系统调用,既省内存又高性能;写数据直接发送,结构简单高效。

读数据完整流程(接收消息)

1
2
3
4
5
6
7
8
9
10
11
readFd() 从 socket 读到可写区

writeIndex_ 后移

可读区 = 收到的数据

上层 peek() 查看4字节长度头

判断数据够不够一条消息

够 → retrieve() 取出消息(移动读指针)

因为是读出数据写入缓冲区,所以移动writeIndex_

readFd() 里移动 writerIndex_,本质上是「往 Buffer 里写数据」,而不是 “读数据”。

  • 从 socket 视角看:这是操作(从网卡读到应用)
  • 从 Buffer 视角看:这是操作(把数据写入缓冲区)

写数据完整流程(发送消息)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
append() 写入数据体

ensureWritableByte() 检查空间

不够 → makeSpace() 扩容/整理

数据写入可写区

writerIndex_ 后移

prepend() 插入4字节头(利用 kCheapPrepend 8字节)

writeFd() 发送

retrieve() 移除已发送数据

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