一、Buffer在epoll + Reactor中的位置
前面三篇已经分别介绍了:
第一篇
Buffer为什么存在
+
输入/输出缓冲区
↓
第二篇
Ring Buffer
+
head/tail
+
环形存储
↓
第三篇
Chain Buffer
+
链表节点
+
动态扩容
但是 Buffer 本身并不是一个独立运行的模块。
在真正的网络服务器中,它通常处在:
Socket
↓
epoll
↓
Reactor
↓
Buffer
↓
协议解析
↓
业务逻辑
整个结构可以理解成:
epoll_wait()
↓
┌───────────┐
│ Reactor │
└───────────┘
↓
找到event
↓
┌─────────────┴─────────────┐
↓ ↓
EPOLLIN EPOLLOUT
↓ ↓
read_cb() write_cb()
↓ ↓
Input Buffer Output Buffer
↓ ↓
协议解析 write(fd)
↓
业务处理
每一个客户端连接都可以对应一个:
event_t
例如:
typedef void (*event_callback_fn)(int fd, int events, void* privdata);
struct event_s
{
int fd; // 当前Socket
int events; // 当前监听事件
void* privdata; // 用户数据
buffer_t* in; // 输入缓冲区
buffer_t* out; // 输出缓冲区
event_callback_fn read_fn; // 可读回调
event_callback_fn write_fn; // 可写回调
reactor_t* reactor; // 所属Reactor
};
这里:
buffer_t* in;
buffer_t* out;
就是前面一直学习的两个 Buffer。
整个连接可以理解成:
event_t
┌────────────────┐
│ fd │
│ │
网络数据 ───→ │ in Buffer │ ───→ 协议解析
│ │
业务数据 ───→ │ out Buffer │ ───→ 网络发送
│ │
│ read_fn │
│ write_fn │
└────────────────┘
为什么不能共用一个 Buffer?
因为输入和输出是两个完全独立的数据流:
Input Buffer:
Socket
↓
read()
↓
Buffer
↓
业务层
而:
Output Buffer:
业务层
↓
Buffer
↓
write()
↓
Socket
输入 Buffer 主要解决:
一次read的数据不一定完整
输出 Buffer 主要解决:
一次write的数据不一定全部发送成功
所以:
一个完整的非阻塞网络连接,通常同时需要输入缓冲区和输出缓冲区。
创建连接时:
event_t* event_new(reactor_t* reactor, int fd)
{
event_t* e = (event_t*)calloc(1, sizeof(event_t));
if (!e) return nullptr;
e->fd = fd;
e->reactor = reactor;
e->in = buffer_new(16 * 1024);
e->out = buffer_new(16 * 1024);
return e;
}
此时:
Client1
↓
event1
├── fd = 5
├── in
└── out
Client2
↓
event2
├── fd = 6
├── in
└── out
不同连接之间的数据互不影响。
二、EPOLLIN:网络数据如何进入Input Buffer
当客户端发送数据以后,Linux 内核会把数据放入:
Socket接收缓冲区
如果这个 fd 注册了:
EPOLLIN
那么:
epoll_wait();
就会通知 Reactor:
这个Socket现在可以读取数据
Reactor 再调用:
read_fn
例如:
void read_cb(int fd, int events, void* privdata)
{
event_t* e = (event_t*)privdata;
if (event_buffer_read(e) <= 0) return;
// 后面继续协议解析
}
真正读取 Socket:
int event_buffer_read(event_t* e)
{
int total = 0;
while (true)
{
char tmp[4096];
ssize_t n = read(e->fd, tmp, sizeof(tmp));
if (n > 0)
{
// 网络数据先进入Input Buffer
if (buffer_add(e->in, tmp, (uint32_t)n) != 0) return -1;
total += (int)n;
continue;
}
if (n == 0)
{
// 对端关闭连接
return 0;
}
if (errno == EINTR) continue;
// 非阻塞Socket暂时已经没有更多数据
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
return -1;
}
return total;
}
这里最关键的是:
buffer_add(e->in, tmp, n);
而不是:
read多少
↓
马上处理多少
原因就是 TCP 没有消息边界。
例如客户端发送:
hello\r\n
第一次 read() 可能只得到:
hel
此时:
Input Buffer:
hel
还不能交给业务层。
第二次得到:
lo\r
Buffer:
hello\r
依然不完整。
第三次:
\n
现在:
hello\r\n
才形成完整消息。
所以真正流程是:
EPOLLIN
↓
read()
↓
读取当前所有能够读取的数据
↓
buffer_add()
↓
Input Buffer
↓
协议层判断数据是否完整
这里:
while (true)
也是非阻塞网络程序中比较常见的写法。
因为一次收到 EPOLLIN 后,Socket 中可能已经积累了很多数据。
所以通常会不断:
read();
直到:
EAGAIN / EWOULDBLOCK
表示:
当前内核接收缓冲区已经暂时读空了。
这并不是错误。
例如:
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
意思就是:
这一次先读到这里
↓
以后有新数据
↓
epoll再次通知
而:
errno == EINTR
表示系统调用被信号打断,可以:
continue;
重新读取。
因此一个典型非阻塞读取流程就是:
EPOLLIN
↓
read
↓
n > 0
↓
加入Buffer
↓
继续read
↓
n > 0
↓
继续加入Buffer
↓
read
↓
EAGAIN
↓
本轮读取结束
三、Input Buffer如何解决半包、粘包并交给业务层
数据进入:
Input Buffer
以后,下一步并不是直接:
处理整个Buffer
而是:
从Buffer中不断提取完整业务消息。
例如我们定义一个最简单的协议:
每条消息都以\r\n结束
客户端可能发送:
hello\r\nworld\r\n
一次 read() 甚至可能直接收到:
hello\r\nworld\r\n
这就是常说的:
粘包
如果代码只解析一次:
int len = buffer_search(e->in, "\r\n", 2);
if (len > 0)
{
// 只处理一次
}
就只能拿到:
hello\r\n
而:
world\r\n
还留在 Buffer 中。
更加完整的写法应该是:
void process_input(event_t* e)
{
while (true)
{
// 寻找一条完整消息
int len = buffer_search(e->in, "\r\n", 2);
if (len <= 0) break;
std::vector<char> message(len + 1, 0);
// 从Input Buffer中取走完整消息
buffer_remove(e->in, message.data(), len);
// 交给业务层处理
process_message(e, message.data(), len);
}
}
然后:
void read_cb(int fd, int events, void* privdata)
{
event_t* e = (event_t*)privdata;
int n = event_buffer_read(e);
if (n <= 0) return;
process_input(e);
}
例如 Buffer:
hello\r\nworld\r\nabc
第一次:
buffer_search(e->in, "\r\n", 2);
找到:
hello\r\n
执行:
buffer_remove();
Buffer 剩下:
world\r\nabc
第二次循环:
找到:
world\r\n
取走以后剩:
abc
第三次:
找不到\r\n
说明:
abc
只是一条还没有接收完整的数据。
于是停止解析:
break;
等待下一次:
EPOLLIN
新数据进入以后继续。
所以一次完整处理:
Input Buffer:
hello\r\nworld\r\nabc
↓
寻找\r\n
↓
得到hello\r\n
↓
remove
↓
world\r\nabc
↓
继续寻找
↓
得到world\r\n
↓
remove
↓
abc
↓
没有完整分隔符
↓
停止解析
↓
等待下一次网络数据
这同时解决了:
半包
+
粘包
问题。
实际协议当然不一定使用:
\r\n
还可能使用固定包头:
┌────────────┬─────────────────────┐
│ length = 8 │ payload │
└────────────┴─────────────────────┘
解析流程:
Buffer长度 >= 包头大小?
↓
读取length
↓
Buffer长度 >= 包头 + length?
↓
是
↓
完整数据包
例如:
struct PacketHeader
{
uint32_t length;
};
可以先判断:
if (buffer_len(e->in) < sizeof(PacketHeader)) return;
拿到包头以后再判断:
当前Buffer数据够不够完整payload
所以无论:
分隔符协议
还是:
长度字段协议
Buffer 的作用都一样:
先积累 TCP 字节流,再由应用层重新恢复业务消息边界。
业务处理完成以后,可能会生成响应:
void process_message(event_t* e, const char* data, int len)
{
// 示例:直接回显
event_buffer_write(e, data, len);
}
此时数据流就从:
Input Buffer
进入:
Output Buffer
阶段。
四、EPOLLOUT:write发送不完的数据如何留在Output Buffer
非阻塞 Socket 的一个重要特点是:
write(fd, data, len);
不保证:
一次发送len字节
例如准备发送:
10000字节
但是内核发送缓冲区当前只剩:
3000字节
那么:
ssize_t n = write(fd, data, 10000);
可能得到:
n = 3000
剩下:
7000字节
不能丢掉。
所以网络发送通常先提供一个统一接口:
int event_buffer_write(event_t* e, const void* data, uint32_t len)
{
// 先追加到Output Buffer
if (buffer_add(e->out, data, len) != 0) return -1;
// 尝试立即发送
return event_buffer_flush(e);
}
这样业务层并不直接:
write(fd);
而是:
业务层
↓
event_buffer_write()
↓
Output Buffer
↓
尝试flush
真正发送:
int event_buffer_flush(event_t* e)
{
while (buffer_len(e->out) > 0)
{
uint8_t* data = buffer_write_atmost(e->out);
uint32_t len = buffer_len(e->out);
if (!data || len == 0) break;
ssize_t n = write(e->fd, data, len);
if (n > 0)
{
// 已经发送成功的部分从Buffer删除
buffer_drain(e->out, (uint32_t)n);
continue;
}
if (n < 0 && errno == EINTR) continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
{
// Socket暂时不能继续写,开始监听EPOLLOUT
enable_write_event(e);
return 0;
}
return -1;
}
// Output Buffer已经发送完,不需要继续监听EPOLLOUT
if (buffer_len(e->out) == 0) disable_write_event(e);
return 0;
}
假设:
Output Buffer:
A B C D E F G H I J
一共10字节。
第一次:
write(fd, data, 10);
只发送:
A B C D
得到:
n = 4
那么:
buffer_drain(e->out, 4);
Buffer剩下:
E F G H I J
继续尝试:
write(fd, data, 6);
这次返回:
EAGAIN
意思不是:
发送失败
而是:
当前 Socket 发送缓冲区暂时已经没有足够空间。
所以不能一直:
while(true) write();
疯狂重试。
正确做法是:
剩余数据继续放在Output Buffer
↓
注册EPOLLOUT
↓
当前线程去处理其他Socket
↓
内核发送掉一部分数据
↓
Socket重新可写
↓
epoll通知EPOLLOUT
↓
继续发送Buffer中的剩余数据
修改 epoll 事件:
void enable_write_event(event_t* e)
{
e->events |= EPOLLOUT;
reactor_modify_event(e->reactor, e);
}
当 Socket 可写时:
void write_cb(int fd, int events, void* privdata)
{
event_t* e = (event_t*)privdata;
event_buffer_flush(e);
}
如果最终:
buffer_len(e->out) == 0
说明:
所有数据已经发送完成
就应该取消:
EPOLLOUT
例如:
void disable_write_event(event_t* e)
{
e->events &= ~EPOLLOUT;
reactor_modify_event(e->reactor, e);
}
为什么一定要取消?
因为很多情况下 Socket 本身长期都是:
可写状态
如果一直监听:
EPOLLOUT
那么:
epoll_wait();
可能不断返回这个 fd:
Socket可写
Socket可写
Socket可写
Socket可写......
但实际上:
Output Buffer已经为空
根本没有数据需要发送。
这会造成大量无意义事件。
所以正确关系应该是:
Output Buffer为空
↓
不监听EPOLLOUT
Output Buffer有数据但暂时发不完
↓
监听EPOLLOUT
收到EPOLLOUT
↓
继续发送
全部发送完成
↓
取消EPOLLOUT
这就是:
Buffer状态
和
epoll事件
之间非常重要的联动关系。
五、Reactor + Buffer完整数据流与四篇总结
把前面的过程全部串起来,一条客户端消息从进入服务器到返回,可以分成下面几个阶段。
首先客户端:
Client
↓
send()
数据进入服务器内核:
Socket Receive Buffer
由于 fd 可读:
epoll
↓
EPOLLIN
Reactor:
EPOLLIN
↓
read_cb()
↓
read()
↓
Input Buffer
然后协议层:
Input Buffer
↓
buffer_search()
↓
完整消息?
↓ ↓
否 是
↓ ↓
等待 buffer_remove()
↓
业务逻辑
业务处理以后:
业务响应
↓
event_buffer_write()
↓
Output Buffer
然后尝试发送:
Output Buffer
↓
write()
↓
┌───────────────────┐
│ 是否全部发送完成? │
└───────────────────┘
↓ ↓
是 否
↓ ↓
Buffer空 EAGAIN
↓ ↓
取消EPOLLOUT 注册EPOLLOUT
↓
epoll_wait()
↓
可写通知
↓
write_cb()
↓
继续flush Buffer
完整模型可以整理成:
Client
│
↓
Socket
│
EPOLLIN
↓
┌─────────┐
│ Reactor │
└─────────┘
↓
read_cb
↓
read()
↓
┌────────────────┐
│ Input Buffer │
└────────────────┘
↓
协议完整性判断
↓
buffer_search
↓
buffer_remove
↓
业务逻辑
↓
┌────────────────┐
│ Output Buffer │
└────────────────┘
↓
write()
↓
┌───────────┴───────────┐
↓ ↓
全部发送 EAGAIN
↓ ↓
Output Buffer空 EPOLLOUT
↓
epoll_wait
↓
write_cb
↓
继续发送
到这里,前面四篇 Buffer 内容就可以完整串起来。
第一篇解决:
为什么需要Buffer?
核心:
Input Buffer
+
Output Buffer
+
半包/粘包
+
统一Buffer接口
第二篇解决:
Buffer使用固定连续内存应该怎么实现?
对应:
Ring Buffer
核心:
size
head
tail
2的幂容量
& mask
跨数组尾部读写
第三篇解决:
如果数据大小变化很大,需要动态扩容怎么办?
对应:
Chain Buffer
核心:
buf_chain_t
链表
misalign
off
动态增加节点
跨节点读取
这一篇则把前三篇重新放回:
epoll + Reactor
真正形成:
Socket
↓
Reactor
↓
Buffer
↓
Protocol
↓
Business
从底层存储来看:
buffer_t
│
┌─────────┴─────────┐
↓ ↓
Ring Buffer Chain Buffer
│ │
连续内存 链式内存
│ │
固定容量 动态扩展
│ │
head/tail chain/misalign/off
└─────────┬─────────┘
↓
统一Buffer接口
↓
网络层完全不用关心
底层到底怎么实现
从网络方向来看:
读数据:
EPOLLIN
↓
read
↓
Input Buffer
↓
协议解析
发送数据:
业务层
↓
Output Buffer
↓
write
↓
EAGAIN?
↓
EPOLLOUT
↓
继续发送
这里还有几个容易混淆但非常重要的结论。
第一:
Buffer不是Socket内核缓冲区
Socket 本身已经有:
内核接收缓冲区
+
内核发送缓冲区
我们实现的:
Input Buffer
Output Buffer
属于:
用户态应用缓冲区
两者关系:
网络
↓
内核Socket接收缓冲区
↓
read()
↓
用户态Input Buffer
↓
业务程序
发送方向:
业务程序
↓
用户态Output Buffer
↓
write()
↓
内核Socket发送缓冲区
↓
网络
第二:
Buffer不是为了单纯追求速度
它更重要的作用是:
解耦
也就是:
网络什么时候来数据
和:
业务什么时候能够处理
不要求完全同步。
同样:
业务什么时候产生数据
和:
Socket什么时候能够发送
也不要求完全同步。
第三:
非阻塞网络程序
真正的思想不是:
所有操作都必须立即成功
而是:
现在能处理多少就处理多少,暂时处理不了的状态保存下来,等待下一次事件继续。
例如读取:
这次数据不完整
↓
留在Input Buffer
↓
下次EPOLLIN继续
发送:
这次发送不完
↓
留在Output Buffer
↓
下次EPOLLOUT继续
这也是 Reactor 模型为什么特别依赖 Buffer 的原因。
最终整个网络 Buffer 系列可以总结成:
网络缓冲区设计
│
┌───────────┴───────────┐
↓ ↓
Input Buffer Output Buffer
↓ ↓
处理接收数据 处理待发送数据
↓ ↓
半包/粘包 非阻塞write
│ │
└───────────┬───────────┘
↓
buffer_t
│
┌───────────┴───────────┐
↓ ↓
Ring Buffer Chain Buffer
↓ ↓
环形数组 链式节点
↓ ↓
固定容量 动态扩容
└───────────┬───────────┘
↓
epoll + Reactor
理解这一套流程以后,再看到网络库中的:
inputBuffer
outputBuffer
readCallback
writeCallback
EPOLLIN
EPOLLOUT
就不再是几个独立的概念。
它们实际上共同组成了一套:
"事件通知负责告诉程序什么时候能处理,Buffer负责保存暂时处理不完的数据,协议层负责从字节流中恢复完整消息"的网络数据处理机制。
到这里,整个网络缓冲区设计四篇系列就完整结束了。