【C++】网络缓冲区设计(一):为什么需要Buffer?输入输出缓冲区与统一接口

一、为什么网络程序不能直接依赖read和write

刚开始学习 Socket 编程时,经常会写出这样的代码:

复制代码
char buf[1024] = {0};
int n = read(fd, buf, sizeof(buf));

if (n > 0)
{
    // 直接处理收到的数据
}

发送数据也是类似:

复制代码
write(fd, buf, len);

看起来整个流程非常简单:

复制代码
客户端
  ↓
Socket
  ↓
read()
  ↓
业务处理
  ↓
write()
  ↓
Socket

但是进入真正的 TCP 网络程序之后,这种方式很快就会出现问题。

首先需要明确一点:

TCP 是字节流协议,它并不知道什么叫"一条完整业务消息"。

例如客户端连续发送:

复制代码
hello\n
world\n

业务上认为这是两条消息:

复制代码
hello\n

world\n

但是服务器第一次调用:

复制代码
read(fd, buf, 1024);

实际可能只收到:

复制代码
hel

第二次收到:

复制代码
lo\nworld\n

也有可能第一次直接收到:

复制代码
hello\nworld\n

所以:

复制代码
send一次

并不等于:

复制代码
read一次

TCP 数据可能出现:

复制代码
半包
粘包
一次收到多条消息

例如一条完整消息:

复制代码
hello world\n

可能被拆成:

复制代码
第一次read:hello

第二次read: wor

第三次read:ld\n

如果每次 read() 完就立即交给业务层:

复制代码
hello
 wor
ld\n

业务层根本无法直接判断哪一段才是一条完整消息。

因此更合理的处理方式应该是:

复制代码
Socket
  ↓
read()
  ↓
输入缓冲区
  ↓
不断积累数据
  ↓
判断是否存在完整数据包
  ↓
取出完整数据
  ↓
业务处理

也就是说,read() 负责:

从内核 Socket 接收缓冲区读取当前已经到达的数据。

而应用层 Buffer 负责:

把这些零散的数据暂时保存起来,直到拼成完整业务消息。

发送端同样存在类似问题。

在阻塞 Socket 中:

复制代码
write(fd, data, len);

看起来好像可以直接发送全部数据。

但是在非阻塞 Socket 中:

复制代码
int n = write(fd, data, len);

可能出现:

复制代码
准备发送1000字节

实际只发送300字节

剩下:

复制代码
700字节

暂时无法继续发送。

这时候不能把剩余数据直接丢掉,而是需要:

复制代码
write发送一部分
       ↓
剩余数据
       ↓
保存到输出缓冲区
       ↓
等待Socket再次可写
       ↓
继续发送

因此一个比较完整的网络连接通常都会拥有两块缓冲区:

复制代码
                网络连接

        ┌─────────────────┐
Socket →│   input buffer  │→ 业务解析
        │    输入缓冲区    │
        └─────────────────┘

        ┌─────────────────┐
业务数据│  output buffer  │→ Socket
      → │    输出缓冲区    │
        └─────────────────┘

这就是网络 Buffer 存在的最基本原因。

二、一个连接为什么通常需要in和out两个Buffer

在 Reactor 网络模型中,可以为每一个连接保存一个事件对象:

复制代码
struct event_s
{
    int fd;          // 当前Socket
    reactor_t* r;    // 所属Reactor

    buffer_t* in;    // 输入缓冲区
    buffer_t* out;   // 输出缓冲区

    event_callback_fn read_fn;
    event_callback_fn write_fn;
    error_callback_fn error_fn;
};

这里最值得注意的就是:

复制代码
buffer_t* in;
buffer_t* out;

它们分别承担不同职责。

in 表示:

复制代码
Input Buffer
输入缓冲区

工作流程:

复制代码
网络数据到达
    ↓
read(fd)
    ↓
buffer_add(in)
    ↓
数据暂存在in中
    ↓
检查是否形成完整消息
    ↓
buffer_remove(in)
    ↓
交给业务层

out 表示:

复制代码
Output Buffer
输出缓冲区

工作流程:

复制代码
业务准备发送数据
        ↓
尝试write(fd)
        ↓
┌──────────────────┐
│ 是否全部发送成功? │
└──────────────────┘
      ↓        ↓
     是        否
      ↓        ↓
    完成    剩余数据
               ↓
        buffer_add(out)
               ↓
         等待EPOLLOUT
               ↓
           继续发送

所以两个 Buffer 的职责完全不同:

复制代码
in:

解决"网络数据什么时候完整"的问题。


out:

解决"数据暂时发不完怎么办"的问题。

创建一个新的连接事件时,就可以同时创建两个 Buffer:

复制代码
event_t* new_event(reactor_t* R, int fd, event_callback_fn rd, event_callback_fn wt, error_callback_fn err)
{
    event_t* e = get_event(R);

    e->r = R;
    e->fd = fd;

    e->in = buffer_new(16 * 1024);   // 输入缓冲区
    e->out = buffer_new(16 * 1024);  // 输出缓冲区

    e->read_fn = rd;
    e->write_fn = wt;
    e->error_fn = err;

    return e;
}

这样每一个 Socket 都拥有自己的:

复制代码
fd
+
input buffer
+
output buffer

例如三个客户端:

复制代码
Client1
   ↓
fd = 5
in  Buffer
out Buffer


Client2
   ↓
fd = 6
in  Buffer
out Buffer


Client3
   ↓
fd = 7
in  Buffer
out Buffer

不同连接的数据互不影响。

从结构上看:

复制代码
             event_t
        ┌──────────────┐
        │ fd           │
        │              │
        │ in  ─────────────→ Buffer
        │              │
        │ out ─────────────→ Buffer
        │              │
        │ read_fn      │
        │ write_fn     │
        └──────────────┘

因此 Buffer 并不是单独存在的,它通常属于某一个网络连接,并且处于:

复制代码
Socket
和
业务逻辑

之间。

三、Buffer需要提供哪些基本接口

虽然 Buffer 底层可以使用:

复制代码
数组
环形数组
链表
分块链表

等不同结构实现,但是对上层网络代码来说,最好不要直接操作这些内部结构。

例如上层没有必要知道:

复制代码
当前Buffer到底是Ring Buffer
还是Chain Buffer

只需要提供一套统一接口即可:

复制代码
typedef struct buffer_s buffer_t;

buffer_t* buffer_new(uint32_t sz);

uint32_t buffer_len(buffer_t* buf);

int buffer_add(buffer_t* buf, const void* data, uint32_t datlen);

int buffer_remove(buffer_t* buf, void* data, uint32_t datlen);

int buffer_drain(buffer_t* buf, uint32_t len);

int buffer_search(buffer_t* buf, const char* sep, int seplen);

uint8_t* buffer_write_atmost(buffer_t* buf);

void buffer_free(buffer_t* buf);

这些接口基本覆盖了网络 Buffer 最常见的操作。

接口 作用
buffer_new() 创建一个缓冲区
buffer_len() 获取当前有效数据长度
buffer_add() 向Buffer尾部追加数据
buffer_remove() 从Buffer头部取出数据并删除
buffer_drain() 删除指定长度数据,但不需要拷贝出来
buffer_search() 在Buffer中寻找协议分隔符
buffer_write_atmost() 获取尽可能连续的一段有效数据
buffer_free() 释放Buffer

最基本的两个操作就是:

复制代码
buffer_add();
buffer_remove();

可以把整个 Buffer 想象成:

复制代码
             buffer_add()
                  ↓
        ┌───────────────────┐
        │                   │
        │ A B C D E F G     │
        │                   │
        └───────────────────┘
          ↓
    buffer_remove()

例如:

复制代码
buffer_add(buf, "hello", 5);

Buffer:

复制代码
h e l l o

继续:

复制代码
buffer_add(buf, " world", 6);

变成:

复制代码
h e l l o   w o r l d

然后:

复制代码
char data[6] = {0};
buffer_remove(buf, data, 5);

得到:

复制代码
data:

hello

Buffer中剩下:

复制代码
 world

其中:

复制代码
buffer_len(buf);

返回的应该是:

复制代码
当前Buffer中仍然有效的数据数量

除了 remove(),还有:

复制代码
buffer_drain(buf, len);

它和 remove() 最大的区别就是:

复制代码
buffer_remove:

Buffer → 拷贝数据给用户 → 删除Buffer中的数据


buffer_drain:

直接删除Buffer中的数据

例如已经处理完前10字节,不需要再保存:

复制代码
buffer_drain(buf, 10);

就可以直接把这部分数据从缓冲区中移除。

这套接口设计还有一个比较重要的好处:

上层网络模块只依赖Buffer接口,而不依赖Buffer具体实现。

例如:

复制代码
buffer_add(evbuf_in(e), data, len);

至于内部是:

复制代码
Ring Buffer

还是:

复制代码
Chain Buffer

网络层并不关心。

这样以后即使更换缓冲区底层实现,上层代码也基本不需要修改。

四、buffer_search如何解决半包和粘包

有了 Buffer 以后,还需要解决一个非常关键的问题:

怎么知道Buffer里面已经存在一条完整消息?

最简单的一种协议就是使用:

复制代码
\n

作为消息结束符。

例如客户端发送:

复制代码
hello\n

服务器收到:

复制代码
h e l l o \n

发现:

复制代码
\n

以后,就知道:

复制代码
前面的数据已经组成一个完整数据包

因此可以设计:

复制代码
int buffer_search(buffer_t* buf, const char* sep, int seplen);

例如:

复制代码
int len = buffer_search(buf, "\n", 1);

如果没有找到:

复制代码
返回0

表示:

复制代码
数据还不完整
继续等待后续网络数据

如果找到:

复制代码
返回完整消息长度

例如 Buffer 当前只有:

复制代码
hel

执行:

复制代码
buffer_search(buf, "\n", 1);

结果:

复制代码
0

因为:

复制代码
还没有\n

下一次网络又收到:

复制代码
lo\n

通过:

复制代码
buffer_add(buf, "lo\n", 3);

Buffer变成:

复制代码
hello\n

再次:

复制代码
int len = buffer_search(buf, "\n", 1);

得到:

复制代码
len = 6

说明:

复制代码
hello\n

已经是一条完整消息。

于是:

复制代码
char data[1024] = {0};
buffer_remove(buf, data, len);

把完整数据取出来。

完整过程:

复制代码
第一次read
   ↓
"hel"
   ↓
buffer_add()
   ↓
Buffer:"hel"
   ↓
search("\n")
   ↓
没有找到
   ↓
继续等待


第二次read
   ↓
"lo\n"
   ↓
buffer_add()
   ↓
Buffer:"hello\n"
   ↓
search("\n")
   ↓
找到
   ↓
buffer_remove()
   ↓
得到完整消息

粘包同样可以解决。

假设一次 read() 收到:

复制代码
hello\nworld\n

加入 Buffer:

复制代码
hello\nworld\n

第一次:

复制代码
int len = buffer_search(buf, "\n", 1);

找到:

复制代码
hello\n

然后:

复制代码
buffer_remove(buf, data, len);

Buffer中还剩:

复制代码
world\n

再次搜索:

复制代码
world\n

又能够得到第二条消息。

所以 Buffer + 分隔符解析,本质上把:

复制代码
TCP没有消息边界

转换成:

复制代码
应用层自己定义消息边界

除了:

复制代码
\n

实际协议还可能使用:

复制代码
\r\n
\r\n\r\n
固定包头
长度字段
特殊Magic

等方式进行数据包划分。

例如 HTTP Header 使用:

复制代码
\r\n\r\n

判断头部结束。

就可以:

复制代码
int len = buffer_search(buf, "\r\n\r\n", 4);

因此 buffer_search() 实际解决的是网络编程中非常核心的:

复制代码
数据包完整性判断

问题。

五、从Socket到Buffer再到业务层的完整流程

把前面的内容组合起来,一个基础网络读取流程可以写成:

复制代码
int event_buffer_read(event_t* e)
{
    int fd = e->fd;
    int total = 0;

    while (true)
    {
        char buf[1024] = {0};
        int n = read(fd, buf, sizeof(buf));

        if (n > 0)
        {
            // 当前读到的数据先放入输入缓冲区
            buffer_add(evbuf_in(e), buf, n);
            total += n;
        }
        else if (n == 0)
        {
            // 对端关闭连接
            return 0;
        }
        else
        {
            if (errno == EINTR) continue;
            if (errno == EWOULDBLOCK || errno == EAGAIN) break;

            return -1;
        }
    }

    return total;
}

这里最关键的一句就是:

复制代码
buffer_add(evbuf_in(e), buf, n);

不是:

复制代码
read多少
就立即处理多少

而是:

复制代码
read多少
   ↓
先存多少
   ↓
等数据完整以后再处理

于是读取回调可以继续:

复制代码
void read_cb(int fd, int events, void* privdata)
{
    event_t* e = (event_t*)privdata;

    // 从Socket读取数据并追加到输入Buffer
    int n = event_buffer_read(e);

    if (n <= 0) return;

    // 查找一条以\n结尾的完整消息
    int len = buffer_search(evbuf_in(e), "\n", 1);

    if (len > 0 && len < 1024)
    {
        char buf[1024] = {0};

        // 从Buffer中取出完整消息
        buffer_remove(evbuf_in(e), buf, len);

        // 业务处理,这里直接回显
        event_buffer_write(e, buf, len);
    }
}

整个数据流就非常清楚了:

复制代码
                   网络数据
                      ↓
                   Socket
                      ↓
                    read()
                      ↓
                buffer_add()
                      ↓
              ┌──────────────┐
              │ Input Buffer │
              └──────────────┘
                      ↓
               buffer_search()
                      ↓
              数据是否完整?
                ↓         ↓
               否         是
                ↓         ↓
             继续等待  buffer_remove()
                           ↓
                        业务处理
                           ↓
                         write()
                           ↓
                  是否全部发送成功?
                     ↓         ↓
                    是         否
                     ↓         ↓
                    完成   Output Buffer
                               ↓
                           等待可写事件
                               ↓
                            继续发送

因此网络缓冲区最重要的作用并不是简单地:

复制代码
"存一些数据"

而是位于:

复制代码
Socket
和
业务协议

之间,解决两边速度和数据边界不一致的问题。

整个基础结构可以概括成:

复制代码
              Reactor / Event

┌─────────────────────────────────┐
│                                 │
│ Socket                          │
│   ↓                             │
│ Input Buffer                    │
│   ↓                             │
│ 协议解析                         │
│   ↓                             │
│ 业务逻辑                         │
│   ↓                             │
│ Output Buffer                   │
│   ↓                             │
│ Socket                          │
│                                 │
└─────────────────────────────────┘

这一篇主要解决的是:

复制代码
为什么需要Buffer?
       ↓
为什么一个连接需要in/out两个Buffer?
       ↓
Buffer应该提供哪些统一接口?
       ↓
怎么利用Buffer处理半包和粘包?

至于 Buffer 内部具体应该怎么存数据,还存在两种非常典型的设计方式:

复制代码
Ring Buffer
环形缓冲区

        和

Chain Buffer
链式缓冲区

Ring Buffer 采用:

复制代码
固定连续内存
+
head / tail
+
循环复用

而 Chain Buffer 采用:

复制代码
多个内存块
+
链表连接
+
按需扩容

两种结构最终却可以向上提供完全一致的:

复制代码
buffer_add();
buffer_remove();
buffer_search();
buffer_len();

接口,这也是 Buffer 抽象设计中非常重要的一点。

下一篇将从 Ring Buffer 环形缓冲区 开始,重点分析:

复制代码
为什么容量设计成2的幂
head和tail如何工作
为什么可以用&代替%
数据跨数组尾部时怎么拷贝
环形Buffer如何判断长度和剩余空间

把网络缓冲区真正的底层存储结构展开。

0voice · GitHub

相关推荐
lzfshub1 小时前
Open-DIS Python发送DIS实体状态PDU:实现坦克炮塔与主炮部件参数
java·网络·python·dis
SatanII2 小时前
OpenStack核心组件详解|Keystone+Glance+Nova+Cinder原理与实操笔记
网络·笔记·openstack
吹什么轩2 小时前
linux网络:UDP原理
linux·网络·udp
啊阿狸不会拉杆11 小时前
《计算机网络-自顶向下方法》5.4 ISP之间的路由选择:BGP 读书笔记
网络·计算机网络·接口隔离原则
Delite80212 小时前
摆脱实验室束缚:便携式卡尔费休微量水分检测技术与现场应用解析
大数据·网络·人工智能
ACP广源盛1392462567312 小时前
M6/M5 Pro Mac mini 端侧 AI 落地@ACP#YLB3116 中端多盘存储扩展在 AI 服务中的机会与应用场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos
ACP广源盛1392462567313 小时前
M6/M5 Pro Mac mini 端侧 AI 新形态@ACP#GSV5800 Serdes 长距离视频传输在 AI 服务中的机会与落地场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos·音视频
我爱cope14 小时前
【计算机网络 | 传输层3:TCP 协议概述:面向连接、可靠传输到底意味着什么?】
网络·网络协议·学习·tcp/ip·计算机网络·传输层
OIDCAT14 小时前
国产USB转千兆网卡芯片CH398-RTL8153国产替代实测与选型
网络·嵌入式硬件·智能硬件·国产芯片·usb转千兆网卡