一、为什么网络程序不能直接依赖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如何判断长度和剩余空间
把网络缓冲区真正的底层存储结构展开。