【C++】网络缓冲区设计(二):Ring Buffer环形缓冲区、head/tail与跨界读写
一、Ring Buffer的核心结构与内存布局
上一篇已经知道,在网络程序中通常需要:
Socket
↓
Input Buffer
↓
协议解析
↓
业务逻辑
↓
Output Buffer
↓
Socket
那么 Buffer 内部到底应该怎样保存数据?
一种非常常见的方案就是:
Ring Buffer
环形缓冲区
最基本的数据结构可以定义为:
struct ringbuffer_s
{
uint32_t size; // 缓冲区容量
uint32_t tail; // 写入位置
uint32_t head; // 读取位置
uint8_t* buf; // 真正保存数据的内存
};
然后:
typedef struct ringbuffer_s buffer_t;
于是外部仍然使用统一的:
buffer_t*
操作 Buffer,而不需要关心内部具体是不是 Ring Buffer。
这里最重要的四个成员是:
size
↓
整个缓冲区容量
head
↓
已经读取到哪里
tail
↓
已经写入到哪里
buf
↓
真正保存数据
例如创建一个容量为8的缓冲区:
物理数组:
下标
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
head = 0
tail = 0
此时:
head == tail
说明缓冲区为空。
如果写入:
A B C
那么:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
head tail
可以理解为:
head = 0
tail = 3
消费者读取两个字节以后:
A B
那么:
head = 2
tail = 3
此时真正有效的数据只剩:
C
需要注意:
head和tail在这个实现中不是一直限制在0 ~ size-1范围内,而是作为不断递增的逻辑位置使用。
真正访问数组时,再把逻辑位置映射到:
0 ~ size-1
这一点非常重要。
创建 Buffer 的代码:
buffer_t* buffer_new(uint32_t sz)
{
if (!is_power_of_two(sz)) sz = roundup_power_of_two(sz);
buffer_t* buf = (buffer_t*)malloc(sizeof(buffer_t) + sz);
if (!buf) return NULL;
buf->size = sz;
buf->head = 0;
buf->tail = 0;
// ringbuffer结构体后面的内存直接作为数据区
buf->buf = (uint8_t*)(buf + 1);
return buf;
}
这里:
malloc(sizeof(buffer_t) + sz);
一次申请了:
ringbuffer结构体
+
真正的数据区
内存大概是:
malloc申请的一整块内存
┌──────────────────────┬──────────────────────────────┐
│ ringbuffer_s │ buf │
│ │ │
│ size │ 0 1 2 3 4 5 6 7 ... │
│ head │ │
│ tail │ │
│ buf ────────────────────────────────┐ │
└──────────────────────┴──────────────┴───────────────┘
所以:
buf->buf = (uint8_t*)(buf + 1);
表示:
buffer_t结构体后面的地址,就是实际数据缓冲区的起始地址。
释放时只需要:
void buffer_free(buffer_t* r)
{
free(r);
}
一次就可以把:
结构体
+
数据区域
一起释放。
二、为什么容量使用2的幂,head和tail为什么不用直接取模
创建 Buffer 时有两个辅助函数:
static inline int is_power_of_two(uint32_t num)
{
if (num < 2) return 0;
return (num & (num - 1)) == 0;
}
它用来判断:
一个数是不是2的幂
例如:
2
4
8
16
32
64
1024
16384
都是2的幂。
例如:
8 = 1000
7 = 0111
执行:
1000
&
0111
----
0000
所以:
8 & (8 - 1)
结果为0。
如果传进来的容量不是2的幂:
if (!is_power_of_two(sz)) sz = roundup_power_of_two(sz);
就会向上调整。
例如:
sz = 10
最终调整成:
16
这样做的关键原因就是后面可以使用:
position & (size - 1)
代替:
position % size
实现环形下标映射。
假设:
size = 8
那么:
size - 1 = 7
逻辑位置:
0 1 2 3 4 5 6 7 8 9 10 11 ...
经过:
position & 7
以后:
0 1 2 3 4 5 6 7 0 1 2 3 ...
正好形成循环。
例如:
tail = 10
真正写入数组的位置:
tail & (size - 1)
就是:
10 & 7 = 2
所以:
逻辑位置10
↓
数组下标2
这也是这个 Ring Buffer 和最简单的:
tail = (tail + 1) % size;
写法不同的地方。
这里的:
head
tail
可以一直作为逻辑计数器递增。
真正访问数组时才:
head & (size - 1)
tail & (size - 1)
例如:
size = 8
head = 10
tail = 14
那么真实数组位置:
head:
10 & 7 = 2
tail:
14 & 7 = 6
但 Buffer 当前有效数据长度并不需要根据物理位置计算。
直接:
static uint32_t rb_len(buffer_t* r)
{
return r->tail - r->head;
}
即可。
例如:
head = 10
tail = 14
那么:
len = 14 - 10
= 4
当前有4字节有效数据。
判断空:
static uint32_t rb_isempty(buffer_t* r)
{
return r->head == r->tail;
}
判断满:
static uint32_t rb_isfull(buffer_t* r)
{
return r->size == (r->tail - r->head);
}
剩余空间:
static uint32_t rb_remain(buffer_t* r)
{
return r->size - r->tail + r->head;
}
其实就是:
remain
=
size - 当前有效数据长度
也就是:
size - (tail - head)
例如:
size = 8
head = 10
tail = 14
当前:
len = 4
所以:
remain = 8 - 4 = 4
这种设计最大的好处就是:
逻辑位置和物理数组位置分开处理,长度计算非常直接,而真正访问内存时再利用 mask 映射到环形数组。
三、buffer_add和buffer_remove如何处理跨界数据
Ring Buffer 最关键的地方就是:
如果数据写到数组最后,但是还没有写完怎么办?
例如容量:
size = 8
当前写位置已经到了:
6
现在准备写入4字节:
A B C D
数组最后只剩:
6
7
两个位置。
显然不能连续写成:
6 7 8 9
因为:
8
9
已经超过数组范围。
正确方式应该是:
先写数组尾部:
6 → A
7 → B
然后回到数组开头:
0 → C
1 → D
形成:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ C │ D │ │ │ │ │ A │ B │
└───┴───┴───┴───┴───┴───┴───┴───┘
这就是:
跨界写入
buffer_add() 正是在解决这个问题:
int buffer_add(buffer_t* r, const void* data, uint32_t sz)
{
// 剩余空间不够,直接写入失败
if (sz > rb_remain(r)) return -1;
// 计算从当前位置到数组尾部最多可以连续写多少
uint32_t i = min(sz, r->size - (r->tail & (r->size - 1)));
// 第一段:写当前位置到数组尾部
memcpy(r->buf + (r->tail & (r->size - 1)), data, i);
// 第二段:剩余数据从数组开头继续写
memcpy(r->buf, (const uint8_t*)data + i, sz - i);
// tail只更新逻辑位置
r->tail += sz;
return 0;
}
最关键的是:
r->tail & (r->size - 1)
得到当前真正写入位置。
然后:
r->size - (r->tail & (r->size - 1))
得到:
当前写位置
到
数组末尾
还剩多少连续空间。
例如:
size = 8
tail物理位置 = 6
sz = 4
那么:
size - tail位置
= 8 - 6
= 2
所以:
i = min(4, 2);
得到:
i = 2
第一段:
memcpy(..., data, 2);
写:
A B
第二段:
memcpy(r->buf, data + 2, 2);
再写:
C D
于是完成一次跨界写入。
可以把 buffer_add() 总结成:
准备写入sz字节
↓
剩余空间够不够?
↓
找到tail真实位置
↓
计算尾部连续空间
↓
┌─────────────────────┐
│ 数据能一次写完吗? │
└─────────────────────┘
↓ ↓
能 不能
↓ ↓
一次memcpy 拆成两次memcpy
↓
尾部 + 数组开头
↓
tail += sz
读取操作完全类似:
int buffer_remove(buffer_t* r, void* data, uint32_t sz)
{
assert(!rb_isempty(r));
// 最多只能读取当前已有的数据
sz = min(sz, r->tail - r->head);
// 计算从head到数组尾部可以连续读取多少
uint32_t i = min(sz, r->size - (r->head & (r->size - 1)));
// 第一段:读取head到数组末尾
memcpy(data, r->buf + (r->head & (r->size - 1)), i);
// 第二段:如果发生环绕,再从数组开头继续读取
memcpy((uint8_t*)data + i, r->buf, sz - i);
// head向后移动
r->head += sz;
return sz;
}
假设有效数据是:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ C │ D │ │ │ │ │ A │ B │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
head
真正的逻辑顺序其实是:
A B C D
所以读取4字节时:
第一段:
6 → A
7 → B
第二段:
0 → C
1 → D
最终用户拿到的仍然是连续的:
A B C D
所以 Ring Buffer 虽然内部的数据可能分成:
数组尾部一段
+
数组开头一段
但是通过两次 memcpy(),上层仍然可以拿到一块正常的连续数据。
四、buffer_drain和buffer_search如何处理网络数据
除了普通:
buffer_add();
buffer_remove();
网络 Buffer 还需要两个非常实用的接口:
buffer_drain();
buffer_search();
buffer_drain() 很简单:
int buffer_drain(buffer_t* r, uint32_t sz)
{
if (sz > rb_len(r)) sz = rb_len(r);
r->head += sz;
return sz;
}
它和:
buffer_remove();
最大的区别是:
buffer_remove
Buffer
↓
拷贝给用户
↓
head移动
buffer_drain
Buffer
↓
不拷贝
↓
直接head移动
例如 Buffer 中:
hello\nworld\n
如果前面的:
hello\n
已经处理完成,并且不需要复制出来,可以:
buffer_drain(buf, 6);
直接让:
head += 6
逻辑上前面6字节就已经从 Buffer 中删除了。
在网络协议处理中,更重要的是:
buffer_search();
例如使用:
\n
作为消息结束符,就可以查找:
int len = buffer_search(buf, "\n", 1);
但是 Ring Buffer 的搜索不能简单写:
strstr((char*)buf, "\n");
原因是数据可能发生环绕。
例如逻辑数据:
hello\n
实际可能存成:
数组:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ o │ \n│ │ │ │ h │ e │ l │
└───┴───┴───┴───┴───┴───┴───┴───┘
逻辑顺序:
5 → h
6 → e
7 → l
0 → o
1 → \n
所以搜索时也必须:
逻辑位置
↓
转换为物理位置
核心就是:
int pos = (r->head + i) & (r->size - 1);
其中:
head + i
表示当前正在检查的逻辑位置。
再通过:
& (size - 1)
转换成数组下标。
一个更安全、容易理解的搜索实现可以写成:
int buffer_search(buffer_t* r, const char* sep, int seplen)
{
uint32_t len = rb_len(r);
if (!sep || seplen <= 0 || len < (uint32_t)seplen) return 0;
for (uint32_t i = 0; i <= len - (uint32_t)seplen; ++i)
{
int matched = 1;
// 一个字符一个字符比较,即使分隔符跨越数组尾部也没有问题
for (int j = 0; j < seplen; ++j)
{
uint32_t pos = (r->head + i + j) & (r->size - 1);
if (r->buf[pos] != (uint8_t)sep[j])
{
matched = 0;
break;
}
}
if (matched) return i + seplen;
}
return 0;
}
例如:
Buffer逻辑数据:
hello\nworld\n
执行:
int len = buffer_search(buf, "\n", 1);
搜索过程:
h
↓
不是\n
e
↓
不是\n
l
↓
不是\n
l
↓
不是\n
o
↓
不是\n
\n
↓
匹配成功
于是返回:
6
上层再:
char data[1024] = {0};
buffer_remove(buf, data, len);
得到:
hello\n
所以整个 TCP 半包处理就变成:
read()
↓
buffer_add()
↓
Ring Buffer积累数据
↓
buffer_search()
↓
找到完整协议分隔符?
↓ ↓
没有 找到
↓ ↓
继续read buffer_remove()
↓
业务处理
这也是 Ring Buffer 真正应用到网络编程中的关键一步。
五、连续数据获取与Ring Buffer整体流程总结
Ring Buffer 还有一个问题:
数据发生环绕以后,怎样获得一块连续的数据用于
write()?
例如当前逻辑数据:
A B C D
但是物理内存:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ C │ D │ │ │ │ │ A │ B │
└───┴───┴───┴───┴───┴───┴───┴───┘
如果直接:
write(fd, r->buf + 6, 4);
就会访问:
6
7
8
9
显然不正确。
因此可以提供:
uint8_t* buffer_write_atmost(buffer_t* r);
用来获得尽可能连续的一块有效数据。
更稳妥的一种做法,是在发生环绕时先将有效数据整理到数组开头:
uint8_t* buffer_write_atmost(buffer_t* r)
{
uint32_t len = rb_len(r);
if (len == 0) return NULL;
uint32_t rpos = r->head & (r->size - 1);
uint32_t wpos = r->tail & (r->size - 1);
// 数据没有发生环绕,直接返回head位置
if (rpos < wpos) return r->buf + rpos;
// 数据发生环绕,临时整理成连续数据
uint8_t* temp = (uint8_t*)malloc(len);
if (!temp) return NULL;
uint32_t first = r->size - rpos;
memcpy(temp, r->buf + rpos, first);
memcpy(temp + first, r->buf, len - first);
// 再复制回原Buffer开头
memcpy(r->buf, temp, len);
free(temp);
// 重新调整逻辑位置
r->head = 0;
r->tail = len;
return r->buf;
}
这样原来:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ C │ D │ │ │ │ │ A │ B │
└───┴───┴───┴───┴───┴───┴───┴───┘
就可以整理成:
0 1 2 3 4 5 6 7
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
head tail
然后网络层就可以直接:
uint8_t* data = buffer_write_atmost(evbuf_out(e));
int len = buffer_len(evbuf_out(e));
write(fd, data, len);
当然,在更加追求性能的实现中,不一定非要为了连续数据进行一次整理拷贝,也可以分别处理:
数组尾部一段
+
数组开头一段
甚至使用:
writev()
进行分散写。
但作为 Ring Buffer 的基础实现,先把:
环形数据
↓
必要时整理成连续数据
这个思路理解清楚即可。
到这里,一个基础网络 Ring Buffer 的核心接口已经串起来:
buffer_new()
↓
创建固定大小Ring Buffer
buffer_add()
↓
向tail追加数据
buffer_len()
↓
获取有效数据长度
buffer_search()
↓
寻找完整协议消息
buffer_remove()
↓
从head读取并删除数据
buffer_drain()
↓
只删除,不复制
buffer_write_atmost()
↓
获得连续可写数据
buffer_free()
↓
释放缓冲区
其中最核心的数据结构始终只有:
struct ringbuffer_s
{
uint32_t size;
uint32_t tail;
uint32_t head;
uint8_t* buf;
};
真正需要掌握的是下面几组关系:
tail - head
=
当前有效数据长度
size - (tail - head)
=
当前剩余空间
以及:
position & (size - 1)
负责把:
不断增长的逻辑位置
转换成:
Ring Buffer中的真实数组下标
而当一次读写跨越数组末尾时:
第一段
数组当前位置 → 数组末尾
第二段
数组开头 → 剩余部分
通过两次:
memcpy();
完成数据搬运。
所以整个 Ring Buffer 可以概括成:
固定连续内存
+
head / tail逻辑位置
+
2的幂容量
+
& mask环形映射
+
跨界两段读写
相比普通动态字符串或不断扩容的数组,它最大的特点就是:
预先申请固定内存,并不断循环复用这一块空间,非常适合网络数据这种持续到达、持续消费的场景。
下一篇继续写 Chain Buffer 链式缓冲区,重点分析它为什么不需要固定容量、如何使用多个内存块连接数据,以及 buffer_node、链表扩容、跨节点读取和 Ring Buffer 相比到底有什么区别。
更多推荐



所有评论(0)