一、为什么需要Chain Buffer,核心结构是什么
上一篇介绍的 Ring Buffer 使用一整块连续内存:
┌──────────────────────────────────┐
│ Ring Buffer │
│ │
│ 0 1 2 3 4 5 6 7 ... │
└──────────────────────────────────┘
它最大的特点是:
提前申请固定容量
↓
head / tail不断移动
↓
循环复用已有内存
这种方式非常适合容量比较明确、数据持续生产和消费的场景。
但是网络数据大小并不总是能够提前确定。
例如一次收到:
100字节
下一次可能是:
2KB
甚至突然收到:
几十KB
如果 Ring Buffer 一开始只申请:
4KB
数据超过容量以后就必须:
扩容
+
重新分配内存
+
搬移数据
因此还可以采用另一种思路:
不要要求所有数据都存放在一整块连续内存中,而是创建多个内存块,再通过链表把这些内存块连接起来。
这就是:
Chain Buffer
链式缓冲区
其基本结构可以理解成:
Chain1
┌──────────────────┐
│ A B C D E │
└──────────────────┘
│ next
↓
Chain2
┌──────────────────┐
│ F G H I J K │
└──────────────────┘
│ next
↓
Chain3
┌──────────────────┐
│ L M N │
└──────────────────┘
从逻辑上看,它们仍然是一段完整数据:
A B C D E F G H I J K L M N
只是物理内存被拆成了多个节点。
每一个节点可以定义成:
struct buf_chain_s
{
struct buf_chain_s* next; // 指向下一个内存块
uint32_t buffer_len; // 当前内存块总容量
uint32_t misalign; // 前面已经消费掉的空间
uint32_t off; // 当前剩余有效数据长度
uint8_t* buffer; // 真正的数据区域
};
这里四个变量尤其重要:
buffer_len
↓
这个节点总共有多少空间
misalign
↓
节点前面已经有多少空间被消费
off
↓
当前还剩多少有效数据
buffer
↓
真正的数据起始地址
例如:
buffer_len = 16
misalign = 4
off = 7
内存可以理解成:
0 1 2 3 4 5 6 7 8 9 10 ...
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐
│旧 │旧 │旧 │旧 │ A │ B │ C │ D │ E │ F │ G │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘
↑
misalign
│
└──────── off = 7 ────────→
所以真正有效数据的起始位置是:
chain->buffer + chain->misalign
而当前节点尾部剩余空间可以计算:
#define CHAIN_SPACE_LEN(ch) ((ch)->buffer_len - ((ch)->misalign + (ch)->off))
除了单个节点以外,还需要一个总的 Buffer 管理整条链表:
struct buffer_s
{
buf_chain_t* first; // 第一个节点
buf_chain_t* last; // 最后一个节点
buf_chain_t** last_with_datap; // 最后一个包含有效数据的位置
uint32_t total_len; // 整个Buffer有效数据总长度
uint32_t last_read_pos; // 上一次搜索分隔符的位置
};
整体结构:
buffer_t
┌──────────────────────────┐
│ first ───────────────────────┐
│ last ────────────────────────────────┐
│ total_len │ │
│ last_read_pos │ │
└──────────────────────────┘ │
↓ ↓
┌────────┐ ┌────────┐
│Chain 1 │→ │Chain 2 │→ NULL
└────────┘ └────────┘
整个 Buffer 的有效数据长度不需要遍历所有节点计算,直接:
uint32_t buffer_len(buffer_t* buf)
{
return buf->total_len;
}
即可。
这也是链式 Buffer 设计中很常见的一种做法:
节点负责管理局部数据,buffer_t 负责管理整个链表的全局状态。
二、内存块如何创建、扩容与重新对齐
创建 buffer_t 时,并不一定立刻申请一个很大的数据区:
buffer_t* buffer_new(uint32_t sz)
{
(void)sz;
buffer_t* buf = (buffer_t*)malloc(sizeof(buffer_t));
if (!buf) return NULL;
memset(buf, 0, sizeof(*buf));
buf->last_with_datap = &buf->first;
return buf;
}
刚创建时:
first = NULL
last = NULL
total_len = 0
也就是说:
buffer_t
↓
暂时没有Chain
真正有数据需要写入时,再创建:
buf_chain_t
节点。
节点创建函数:
#define MIN_BUFFER_SIZE 1024
#define BUFFER_CHAIN_MAX (16 * 1024 * 1024)
#define BUFFER_CHAIN_SIZE sizeof(buf_chain_t)
#define BUFFER_CHAIN_EXTRA(t, c) (t*)((buf_chain_t*)(c) + 1)
然后:
static buf_chain_t* buf_chain_new(uint32_t size)
{
uint32_t to_alloc;
buf_chain_t* chain;
if (size > BUFFER_CHAIN_MAX - BUFFER_CHAIN_SIZE) return NULL;
size += BUFFER_CHAIN_SIZE;
if (size < BUFFER_CHAIN_MAX / 2)
{
to_alloc = MIN_BUFFER_SIZE;
// 逐渐按照2倍扩展,直到能够容纳当前数据
while (to_alloc < size) to_alloc <<= 1;
}
else
{
to_alloc = size;
}
chain = (buf_chain_t*)malloc(to_alloc);
if (!chain) return NULL;
memset(chain, 0, BUFFER_CHAIN_SIZE);
chain->buffer_len = to_alloc - BUFFER_CHAIN_SIZE;
// 结构体后面的空间直接作为真正的数据区
chain->buffer = BUFFER_CHAIN_EXTRA(uint8_t, chain);
return chain;
}
这里使用了和前面 Ring Buffer 类似的技巧:
malloc(结构体大小 + 数据大小);
一次申请:
┌────────────────────┬────────────────────────────┐
│ buf_chain_t │ 数据区域 │
│ │ │
│ next │ A B C D E F ... │
│ buffer_len │ │
│ misalign │ │
│ off │ │
│ buffer ──────────────────────┐ │
└────────────────────┴─────────┴──────────────────┘
这样一个 Chain:
节点管理信息
+
节点数据
就在同一次内存分配中完成。
另外一个值得注意的地方是:
while (to_alloc < size) to_alloc <<= 1;
例如需要:
1500字节
开始:
1024
不够:
1024 << 1
=
2048
于是分配更大的内存块。
如果需要:
3000字节
则:
1024
↓
2048
↓
4096
最终:
4096
这种方式可以减少频繁分配一些很零碎的内存块。
Chain Buffer 还有一个比较关键的优化:
重新对齐
realign
假设原来节点:
┌───────────────────────────────────────┐
│旧│旧│旧│旧│ A │ B │ C │ D │ 空闲 │
└───────────────────────────────────────┘
↑
有效数据
前面的:
旧 旧 旧 旧
已经被消费,所以:
misalign > 0
虽然数组尾部空间可能不够继续写,但是:
前面已经空出来很多空间
这时候没必要立即创建新 Chain。
可以把:
A B C D
向前移动:
移动前:
┌───────┬───────────┬──────────────┐
│无效区 │ A B C D │ 剩余空间 │
└───────┴───────────┴──────────────┘
移动后:
┌───────────┬──────────────────────┐
│ A B C D │ 可用空间 │
└───────────┴──────────────────────┘
代码:
static void buf_chain_align(buf_chain_t* chain)
{
memmove(chain->buffer, chain->buffer + chain->misalign, chain->off);
chain->misalign = 0;
}
这里必须使用:
memmove();
而不是简单:
memcpy();
因为源地址和目标地址属于同一块内存,而且可能发生重叠。
所以 Chain Buffer 的扩容并不是简单粗暴地:
空间不够
↓
永远new一个节点
而是:
当前节点空间够
↓
直接追加
当前尾部不够,但是前面有大量废弃空间
↓
重新对齐后继续使用
整个节点确实放不下
↓
创建新的Chain
这种设计可以提高已有内存块的利用率。
三、buffer_add、buffer_remove和buffer_drain如何跨节点工作
先看最重要的:
buffer_add();
核心逻辑可以整理成:
int buffer_add(buffer_t* buf, const void* data_in, uint32_t datlen)
{
const uint8_t* data = (const uint8_t*)data_in;
buf_chain_t* chain = *buf->last_with_datap;
// 当前没有任何节点,创建第一个Chain
if (!chain)
{
chain = buf_chain_new(datlen);
if (!chain) return -1;
buf->first = buf->last = chain;
buf->last_with_datap = &buf->first;
}
uint32_t remain = chain->buffer_len - chain->misalign - chain->off;
// 当前节点空间足够,直接追加
if (remain >= datlen)
{
memcpy(chain->buffer + chain->misalign + chain->off, data, datlen);
chain->off += datlen;
buf->total_len += datlen;
return 0;
}
// 当前节点剩余空间先利用起来
if (remain)
{
memcpy(chain->buffer + chain->misalign + chain->off, data, remain);
chain->off += remain;
buf->total_len += remain;
data += remain;
datlen -= remain;
}
// 剩余数据放进新的Chain
buf_chain_t* new_chain = buf_chain_new(datlen);
if (!new_chain) return -1;
memcpy(new_chain->buffer, data, datlen);
new_chain->off = datlen;
chain->next = new_chain;
buf->last = new_chain;
buf->total_len += datlen;
return 0;
}
这里为了方便理解,省略了部分节点复用优化,但核心流程是相同的。
假设第一个 Chain 只剩:
3字节
但是现在要加入:
ABCDEFG
共7字节。
那么:
Chain1剩余3字节
┌───────────────┬─────┐
│ 原有数据 │空 空 空│
└───────────────┴─────┘
先写:
A B C
于是:
Chain1:
原有数据 A B C
剩余:
D E F G
再创建:
Chain2
保存:
D E F G
最终:
Chain1 Chain2
┌───────────────────┐ ┌──────────────┐
│ 原数据 A B C │ ──→ │ D E F G │
└───────────────────┘ └──────────────┘
逻辑上仍然是:
原数据 A B C D E F G
这就是 Chain Buffer 和 Ring Buffer 很大的区别:
Ring Buffer
空间不够时需要考虑整个Buffer容量
Chain Buffer
空间不够就可以继续增加节点
读取数据时也是一样。
首先可以设计一个内部:
buf_copyout();
从多个 Chain 中依次复制数据:
static uint32_t buf_copyout(buffer_t* buf, void* data_out, uint32_t datlen)
{
buf_chain_t* chain = buf->first;
uint8_t* data = (uint8_t*)data_out;
if (datlen > buf->total_len) datlen = buf->total_len;
uint32_t nread = datlen;
while (datlen && datlen >= chain->off)
{
uint32_t copylen = chain->off;
memcpy(data, chain->buffer + chain->misalign, copylen);
data += copylen;
datlen -= copylen;
chain = chain->next;
}
if (datlen)
{
memcpy(data, chain->buffer + chain->misalign, datlen);
}
return nread;
}
假设:
Chain1:
A B C
Chain2:
D E F G
Chain3:
H I J
现在:
buffer_remove(buf, data, 8);
那么读取过程:
Chain1
↓
读取 A B C
共3字节
还需要5字节
↓
Chain2
↓
读取 D E F G
共4字节
还需要1字节
↓
Chain3
↓
读取 H
最终用户得到:
A B C D E F G H
虽然物理数据分散在三个节点中,但是业务层拿到的仍然是一块连续数据。
真正的:
buffer_remove();
可以理解成:
int buffer_remove(buffer_t* buf, void* data_out, uint32_t datlen)
{
uint32_t n = buf_copyout(buf, data_out, datlen);
if (n > 0) buffer_drain(buf, n);
return n;
}
也就是:
buffer_remove
↓
先copyout
↓
把数据复制给调用者
↓
再drain
↓
从Buffer删除已经读取的数据
buffer_drain() 不需要复制数据,只负责消费链表。
例如:
Chain1:A B C
Chain2:D E F G
Chain3:H I J
执行:
buffer_drain(buf, 5);
首先:
A B C
整个 Chain1 都已经被消费,所以直接:
free(chain1);
还需要删除:
2字节
于是进入 Chain2:
D E F G
消费:
D E
此时 Chain2 变成:
F G
也就是更新:
chain->misalign += 2;
chain->off -= 2;
最终:
Chain2:
┌─────┬───────┐
│D E │ F G │
│无效 │ 有效 │
└─────┴───────┘
↑
misalign
整条 Buffer 的有效数据:
F G H I J
所以 buffer_drain() 的基本规则是:
要删除整个节点
↓
直接free节点
只删除节点一部分
↓
增加misalign
减少off
这种设计使消费者不需要每次删除数据都:
把后面的所有内容向前memmove
而只需要移动几个位置变量。
四、buffer_search如何实现跨Chain协议搜索
Chain Buffer 在网络程序中同样需要:
buffer_search();
例如 Buffer 中存放:
hello\r\nworld\r\n
上层希望寻找:
"\r\n"
但是分隔符可能刚好跨越两个 Chain。
例如:
Chain1:
h e l l o \r
↓
Chain2:
\n w o r l d
真正的数据:
hello\r\nworld
其中:
\r
位于 Chain1 最后一个字节,
而:
\n
位于 Chain2 第一个字节。
因此不能简单在每个节点内部:
strstr();
否则就找不到这个:
跨节点分隔符
所以可以设计:
static bool check_sep(buf_chain_t* chain, int from, const char* sep, int seplen)
{
for (;;)
{
int sz = chain->off - from;
// 当前Chain已经能够完整容纳剩余分隔符
if (sz >= seplen)
{
return memcmp(chain->buffer + chain->misalign + from, sep, seplen) == 0;
}
// 当前Chain只能比较分隔符的一部分
if (sz > 0)
{
if (memcmp(chain->buffer + chain->misalign + from, sep, sz) != 0)
return false;
}
// 当前部分匹配成功,继续到下一个Chain
chain = chain->next;
if (!chain) return false;
sep += sz;
seplen -= sz;
from = 0;
}
}
假设要找:
\r\n
当前:
Chain1最后只剩1字节:
\r
那么第一次:
拿 \r
和
分隔符第一个字符 \r
比较:
匹配成功
然后:
进入Chain2
继续比较:
\n
发现:
匹配成功
于是整个:
\r\n
匹配成功。
外层搜索:
int buffer_search(buffer_t* buf, const char* sep, const int seplen)
{
buf_chain_t* chain = buf->first;
if (!chain) return 0;
int position = 0;
while (chain)
{
for (uint32_t from = 0; from < chain->off; ++from)
{
if (check_sep(chain, from, sep, seplen))
return position + from + seplen;
}
position += chain->off;
chain = chain->next;
}
return 0;
}
为了进一步减少重复搜索,还可以保存:
uint32_t last_read_pos;
例如第一次 Buffer:
hello wor
搜索:
\r\n
没有找到。
如果下一次只新加入:
ld\r\n
没有必要重新从:
h
开始完整扫描。
可以从之前已经搜索过的位置继续:
第一次已经检查过
↓↓↓↓↓↓↓↓↓
hello wor
下一次:
hello world\r\n
↑
从附近继续搜索
这就是:
last_read_pos
存在的意义。
找到分隔符以后:
last_read_pos = 0
下一条消息重新开始。
如果没有找到:
记录当前搜索位置
等待下一批网络数据到达以后继续。
所以 Chain Buffer 处理 TCP 半包的过程仍然是:
第一次read
↓
"hello\r"
↓
buffer_add()
↓
Chain1
↓
buffer_search("\r\n")
↓
没有完整分隔符
↓
继续等待
第二次read
↓
"\nworld\r\n"
↓
buffer_add()
↓
可能进入Chain2
↓
buffer_search("\r\n")
↓
跨Chain找到分隔符
↓
buffer_remove()
↓
得到完整消息
也就是说:
Chain Buffer 虽然底层数据并不连续,但上层协议解析仍然可以把它当成一条连续字节流。
五、连续数据整理以及Chain Buffer和Ring Buffer的区别
Chain Buffer 最后还有一个实际问题:
网络发送时
write()通常希望得到一段连续内存,但 Chain Buffer 的数据可能分散在多个节点中。例如:
Chain1
┌───────────┐
│ hello │
└───────────┘
↓
Chain2
┌───────────┐
│ world │
└───────────┘
↓
Chain3
┌───────────┐
│ !!! │
└───────────┘
逻辑数据:
helloworld!!!
如果:
write(fd, data, len);
需要一次得到连续:
helloworld!!!
就可以通过:
buffer_write_atmost();
整理多个 Chain。
它的基本思想是:
当前Buffer只有一个Chain
↓
本身已经连续
↓
直接返回buffer地址
当前Buffer包含多个Chain
↓
第一块空间够不够?
↓ ↓
够 不够
↓ ↓
整理到第一块 创建更大Chain
↓ ↓
└────合并所有数据────┘
↓
返回连续内存地址
一个简化版本可以理解成:
uint8_t* buffer_write_atmost(buffer_t* buf)
{
if (!buf || !buf->first) return NULL;
buf_chain_t* first = buf->first;
// 所有数据已经在第一个Chain中
if (first->off >= buf->total_len)
return first->buffer + first->misalign;
// 创建能够容纳全部数据的新Chain
buf_chain_t* new_chain = buf_chain_new(buf->total_len);
if (!new_chain) return NULL;
uint8_t* dst = new_chain->buffer;
buf_chain_t* chain = buf->first;
// 将所有Chain的数据依次合并
while (chain)
{
memcpy(dst, chain->buffer + chain->misalign, chain->off);
dst += chain->off;
chain = chain->next;
}
new_chain->off = buf->total_len;
// 原来的多个节点可以释放并替换成一个连续节点
buf_chain_free_all(buf->first);
buf->first = new_chain;
buf->last = new_chain;
buf->last_with_datap = &buf->first;
return new_chain->buffer;
}
于是:
整理前:
Chain1 → Chain2 → Chain3
hello world !!!
整理后:
┌─────────────────────┐
│ hello world !!! │
└─────────────────────┘
网络层就可以:
uint8_t* data = buffer_write_atmost(evbuf_out(e));
uint32_t len = buffer_len(evbuf_out(e));
write(fd, data, len);
如果只发送了一部分:
准备发送1000字节
↓
write实际发送400字节
那么:
buffer_drain(evbuf_out(e), 400);
把已经成功发送的数据从 Buffer 中删除。
剩余:
600字节
继续留在输出 Buffer 中,等待下一次可写事件。
到这里,Chain Buffer 的核心流程就已经完整:
buffer_new()
↓
创建Buffer管理结构
buffer_add()
↓
向最后一个Chain追加数据
↓
空间不足时增加Chain
buffer_search()
↓
跨节点寻找完整协议数据
buffer_remove()
↓
跨多个Chain复制数据
↓
再调用drain删除
buffer_drain()
↓
完整节点直接释放
↓
部分节点调整misalign/off
buffer_write_atmost()
↓
必要时整理多个Chain
↓
得到连续内存
buffer_free()
↓
释放链表节点
最后把 Ring Buffer 和 Chain Buffer 放在一起,就更加容易理解两种设计。
Ring Buffer:
┌─────────────────────────────────┐
│ A B C D E F G ... │
└─────────────────────────────────┘
固定的一整块连续内存
特点:
容量通常提前确定
内存连续
循环复用
读写位置计算简单
跨尾部时可能拆成两段
而 Chain Buffer:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Chain1 │ → │ Chain2 │ → │ Chain3 │
└─────────┘ └─────────┘ └─────────┘
特点:
不要求所有数据连续
可以按需增加节点
容量更加灵活
非常适合大小变化明显的数据
跨节点读取逻辑更加复杂
可以简单总结:
| 对比项 | Ring Buffer | Chain Buffer |
|---|---|---|
| 内存结构 | 一块连续内存 | 多个内存块组成链表 |
| 容量 | 通常提前确定 | 可以动态增加 |
| 数据循环 | head/tail环绕 | Chain节点向后扩展 |
| 空间复用 | 环形复用 | misalign + 节点复用 |
| 数据连续性 | 物理上基本连续 | 可能分散在多个节点 |
| 跨界处理 | 数组尾部跨到开头 | 跨多个Chain |
| 实现复杂度 | 相对简单 | 相对复杂 |
| 适合场景 | 高频、容量较稳定 | 数据长度变化较大 |
虽然两种 Buffer 的内部实现完全不同,但是对网络层依然能够提供完全一致的接口:
buffer_new();
buffer_len();
buffer_add();
buffer_remove();
buffer_drain();
buffer_search();
buffer_write_atmost();
buffer_free();
这就是把 Buffer 单独抽象成一层的重要意义:
上层只关心"怎样存、怎样取、怎样搜索",而不需要知道底层究竟使用 Ring Buffer 还是 Chain Buffer。
到这里,网络 Buffer 的两种主要底层实现已经比较清楚。最后一篇就可以把前面三篇真正放回网络程序中,结合 epoll + Reactor、输入/输出 Buffer、非阻塞 read/write、半包粘包以及可读/可写事件,把完整的数据流串起来。