【C++】网络缓冲区设计(三):Chain Buffer链式缓冲区、动态扩容与跨节点读写

一、为什么需要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、半包粘包以及可读/可写事件,把完整的数据流串起来。

0voice · GitHub

相关推荐
云飞云共享云桌面1 小时前
SolidWorks+AutoCAD,多并发。可以多人共享一台设计服务器吗
运维·服务器·3d·自动化·制造
Yan-英杰1 小时前
从“投屏“到“办公“,ToDesk鸿蒙版4.8.0.0补齐远控“进入→处理→离开“全流程
服务器·人工智能·爬虫·机器学习·ai开发工具
我命由我123452 小时前
Windows 操作系统 - 启用 D 盘虚拟内存
java·运维·服务器·windows·学习·java-ee·运维开发
fangjianj2 小时前
Dell PowerEdge R930 物理服务器重装 Ubuntu Server 22.04 全流程实战记录
linux·服务器
HackTwoHub2 小时前
BurpSuite2026.8专业(稳定版)下载Windows/Linux/Mac支持Java21以上(新增Burp AT智能体)
linux·运维·服务器·安全·macos·网络安全·自动化
软行3 小时前
LeetCode 每日一题 3876. 构造奇偶一致的数组 II
c++·算法·leetcode
充电zcx3 小时前
Linux:2:Linux下的基本指令
linux·运维·服务器
繁星蓝雨3 小时前
C++设计原理———重载(extern “C“的由来、顺序依赖、语义依赖、overload、名称修饰符、对象操作、运算符重载、新增运算符、枚举和布尔类型)
c语言·c++·extern c·overload·重载·语义依赖·顺序依赖
handler013 小时前
【Linux】虚拟地址空间解析
linux·运维·c++·线程·进程·虚拟地址空间·虚拟地址