【C++】网络缓冲区设计(四):epoll + Reactor中Buffer的完整读写流程

一、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负责保存暂时处理不完的数据,协议层负责从字节流中恢复完整消息"的网络数据处理机制。

到这里,整个网络缓冲区设计四篇系列就完整结束了。

0voice · GitHub

相关推荐
西西弗Sisyphus1 小时前
Qt 实现一个 Windows 启动项查看器
开发语言·windows·qt
FfHUCisI1 小时前
Golang 函数调用的瞬间:栈帧、拷贝栈与逃逸分析
开发语言·后端·golang
万邦科技Lafite1 小时前
淘宝海外采购指南,taoworld资格申请对接流程讲解
开发语言·电商开放平台·淘宝开放平台·京东开放平台·比价
ThornArmor2 小时前
奔腾的呼吸:被驱逐者的灵感
开发语言·程序人生·架构
软件黑马王子2 小时前
12.缓存池优化:对象上限
开发语言·前端框架·c#
阿里嘎多学长2 小时前
2026-09-07 GitHub 热点项目精选
开发语言·程序员·github·代码托管
csdn_aspnet2 小时前
C++ 未排序数组中第 k 个最小/最大元素 | 最坏情况下的线性时间
数据结构·c++·算法
流浪0012 小时前
大模型技术全景(六):AI 的“逐字接龙“——Token 与自回归生成背后的真相
开发语言·人工智能·llm
Raas1003 小时前
AI网关和OpenRouter区别?MAI Gateway(魔芋企业级AI网关)企业级方案对比指南
大数据·开发语言·人工智能·gateway·php·ai网关·mai gateway