Buffer缓冲区的设计思想与实现
前言在一个高并发场景的服务器中有一个十分基础核心的组件就是我们今天的主题Buffer模块。无论使用多么先进的 I/O 多路复用epoll/io_uring如果 Buffer 设计不当数据拷贝、内存分配和锁竞争会把你的服务器性能拉回石器时代。为什么网络服务器需要 Buffer呢1、TCP 是流式协议没有边界发送端send(Hello) - send(World) 接收端recv() 可能一次性收到 HelloWorld也可能分两次收到 Hel loWorldBuffer 的存在解决了粘包和半包问题它负责将无序、不定长的网络数据流组装成逻辑完整的数据包。2、 协调速度不匹配生产者网卡通过 DMA 将数据写入内存速度极快Gbps 级别消费者应用层 HTTP 解析、业务逻辑处理速度相对较慢涉及磁盘/计算Buffer 充当了水库的作用削峰填谷防止数据丢失。在设计 Buffer 时我们面临几种常见选择数据结构优点缺点适用场景固定数组简单、零分配空间固定无法应对大包嵌入式、串口通信动态std::string使用方便频繁扩容拷贝头部删除效率极低低频小数据链表碎片化插入删除快Cache 命中率低内存碎片严重实时性要求极高场景环形缓冲区内存复用、Cache 友好、支持动态扩容实现稍复杂网络服务器首选我们的选择是环形缓冲区 std::vector(跟以往的一读一写双缓冲区不一样)。它兼具数组的连续内存优势CPU Cache 友好和动态扩容能力。所谓环形缓冲区并不值得是它的物理结构是环形的。无论实现方式如何环形缓冲区的本质是固定大小的内存块或可动态扩容读写指针分离各自向前移动逻辑上首尾相连实现内存复用当写指针到达末尾时绕回到开头继续写我们要实现的就是这样的一个特点的缓冲区。缓冲区的思想梳理对于我们的缓冲区代码我们首先要明确缓冲区存在的作用肯定是存储数据与取出数据。那你就得要有一块内存空间吧内存空间好获得但是内存空间的管理可不好管理。如果你用固定大小的数组就很难去处理扩容的需求遇见稍微大点的数据就容易接收不了。所以我们这里的选择就是vector char 数组使用一个vector来对我们的内存空间进行动态管理。这里为什么不用string类型呢这是因为string类型包括其内置函数大多还是用于字符串操作遇见字符串结束符就会结束所以我们还是避免使用string。接下来就是思考一下我们应该怎么读取数据。因为你不可能每次都从数组的开头去往后面读因为我们不可能每次读一部分后下次读之前就把剩下的内容往前移动放到数组开头。所以我们想到环形缓冲区的读写指针我们可以使用两个下标读和写我们可以命名为、_reader_idx与_writer_idx来标记我们下次读和下次写的位置下标。当我们的空闲空间不够时我们再考虑让剩下的数据移动到缓冲区最前面去将已读区域覆盖。物理内存布局 ┌─────────────────────────────────────────────────────┐ │ 已读区域(废弃) │ 可读数据(待消费) │ 空闲空间 │ └─────────────────────────────────────────────────────┘ ↑ ↑ _reader_idx _writer_idx如果是已读区域加上空闲空间仍然不够存储那么我们就对vector进行一个扩容处理就行了。对于写入操作来说_writer_idx指向哪里我们就从哪里开始写入一共要先经历两次判断。第一次是先判断空闲空间够不够用vector的size减去写入标记的位置。第二次是判断空闲加已读区域足够不如果不足够就扩容足够的话就把可读数据向前移动最后进行写入移动的时候读下标与写下标都要向前对于读取操作来说我们要从_reader_idx位置开始读取可读数据的大小为写下标与读下标的位置之差。整体流程可以这样理解初始状态_buffer: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] ↑ _reader_idx0 _writer_idx0写入 “Hello”[H] [e] [l] [l] [o] [ ] [ ] [ ] [ ] [ ] ↑ ↑ _reader_idx0 _writer_idx5读取 3 字节“Hel”[H] [e] [l] [l] [o] [ ] [ ] [ ] [ ] [ ] ↑ ↑ _reader_idx3 _writer_idx5继续写入 “World”尾部空间不够触发紧缩紧缩前 [ ] [ ] [ ] [l] [o] [ ] [ ] [ ] [ ] [ ] ↑ ↑ _reader_idx3 _writer_idx5 紧缩后 [l] [o] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] ↑ ↑ _reader_idx0 _writer_idx2 写入 World 后 [l] [o] [W] [o] [r] [l] [d] [ ] [ ] [ ] ↑ ↑ _reader_idx0 _writer_idx7数据lo被移动到了开头,虽然物理上数据被移动了但逻辑上这等价于环形缓冲区中写指针绕回开头继续写, 内存得到了复用没有浪费空间缓冲区的代码实现对于一个Buffer缓冲区我们其实主要实现的就是以下几个功能1.获取当前写位置地址2.确保可写空间足够移动扩容3.获取前沿空闲空间大小这里的前沿指的是写下标后的4.获取后沿空闲空间大小5.将写位置向后移动指定长度6.获取当前读位置地址7.获取可读数据大小8.将读位置向后移动指定长度9.写入数据10.读取数据11.清理功能#define BUFFER_DEFAULT_SIZE 1024 class Buffer { private: std::vectorchar _buffer; // 使用vector进行空间管理 uint64_t _writer_idx; // 写下标 uint64_t _reader_idx; // 读下标 public: Buffer() : _writer_idx(0), _reader_idx(0), _buffer(BUFFER_DEFAULT_SIZE) {} // 获取当前写入起始地址 char *WritePosition() {} // 获取当前读取起始地址 char *ReadPosition() {} // 获取缓冲区末尾空闲空间大小-- 写偏移之后的空闲空间 uint64_t TailIdleSize() {} // 获取缓冲区起始空闲空间大小--读偏移之前的空闲空间 uint64_t HeadIdleSize() {} // 获取可读数据大小 uint64_t ReadAbleSize() {} // 将读偏移向后移动 void MoveReadoffset(uint64_t len){} // 将写偏移向后移动 void MoveWriteoffset(uint64_t len){} // 写入数据 void Write(const void *data, uint64_t len){} // 读取数据 void Read(void *data, uint64_t len){} // 清理缓冲区 void clear() {} };前几个获取大小和地址的接口十分容易实现begin接口是我们专门用来获取vector的起始地址的接口。配合begin我们只需要做很简答的加减法就可以获取大小与地址。// 获取buffer的起始地址 char *Begin() { return _buffer.data(); } // 获取当前写入起始地址 char *WritePosition() { return Begin() _writer_idx; } // 获取当前读取起始地址 char *ReadPosition() { return Begin() _reader_idx; } // 获取缓冲区末尾空闲空间大小-- 写偏移之后的空闲空间 uint64_t TailIdleSize() { return _buffer.size() - _writer_idx; } // 获取缓冲区起始空闲空间大小--读偏移之前的空闲空间 uint64_t HeadIdleSize() { return _reader_idx; } // 获取可读数据大小 uint64_t ReadAbleSize() { return _writer_idx - _reader_idx; }接下来考虑一下移动问题我们要移动下标首先要确定的是不会出现非法下标的问题。所以不管是读偏移想移动还是写偏移想移动都需要先判断一下向后移动的距离大小是否越界。// 将读偏移向后移动 void MoveReadOffset(uint64_t len) // len是移动的距离大小 { assert(len ReadAbleSize()); // 向后移动的距离必须小于可读数据的大小 _reader_idx len; } // 将写偏移向后移动 void MoveWriteOffset(uint64_t len) { assert(len TailIdleSize()); _writer_idx len; }这里把写偏移向后移动时只检查后面的可写空间是因为此时我们还没有把数据向前缩所以只能算后面的空间不能算前面的可用空间。对于写入数据的功能实现我们也要明确的是我们要先保证可写空间足够由于这个功跟之前的判断不一样稍微复杂一点吗所以我们可以专门写一个接口来确保可写空间足够这就可能涉及到扩容的操作// 写入数据 void Write(const void *data, uint64_t len) { if (len 0) return; // 写入数据为0直接就返回就行了 EnsureWriteSpace(len); const char *d static_castconst char *(data); std::copy(d, d len, WritePosition()); } // 读取数据 void Read(void *data, uint64_t len) { assert(lenReadAbleSize()); std::copy(ReadPosition(), ReadPosition() len, static_castchar *(data)); }至于清除数据接口我们没必要真的把数据置为空只需要把读写下标置为0就行。// 清理缓冲区 void Clear() { _writer_idx _reader_idx 0; }但是这里我们自己要清楚clear()之后必须调用Write()写入新数据后再进行Read绝对不能在clear()后直接尝试读取。我们目前只提供了基础的Write与Read接口针对我们日常使用的情况这种基础的接口是明显不够用的。如果只调用Read我们就面临是否还需要手动调用读指针的情况因为我们需要判断这次的Read是查看还是取出。在日常使用中其实我们更多还是面临直接想传入一个string类或者调用其他Buffer类的情况所以其实我们可以提供更多接口void WriteString(const std::string data) { return Write(data.c_str(), data.size()); } void WriteBuffer(Buffer data)//这里我们没有使用const所以我们使用场景中就不要定义一个constBuffer传入进这个函数 { return Write(data.ReadPosition(), data.ReadAbleSize()); } //有了写就再加点读 std::string ReadAsString(uint64_t len)//读取len长数据并当做一个string返回 { assert(lenReadAbleSize()); std::string str;//对于这些临时变量其实c11之后编译器就已经有了很多优化比如移动拷贝 str.resize(len); Read(str[0],len); return str; }同时我们知道有些情况可能只需要看一下你的内容是不是我要的内容如果不是我就不要所以这个情况是不会取走内容的这可以由我们手动控制是否把读下标后移来实现但是为了规范与防止写代码时遗忘手动调用其实我们可以完全把其组装成接口单独让其被调用//五个调用后会使读写下标增加的接口 void WriteAndPush(const void *data, uint64_t len) { Write(data, len); MoveWriteOffset(len); } void WriteStringAndPush(const std::string data) { WriteString(data); MoveWriteOffset(data.size()); } void WriteBufferAndPush(Buffer data) { WriteBuffer(data); MoveWriteOffset(data.ReadAbleSize()); } void ReadAndPop(void *buf, uint64_t len) { Read(buf, len); MoveReadOffset(len); } std::string ReadAsStringAndPop(uint64_t len) { assert(len ReadAbleSize()); std::string str ReadAsString(len); MoveReadOffset(len); return str; }我们知道我们服务器中大多使用的是https协议在这个场景下我们可能会出现只读取一行的情况。所以我们也可以写一个这个常用操作的特殊接口首先就是先找到换行符我们http中的换行符虽然是\r\n但是我们只需要关注\n就行了因为找到了它就代表找到了一行// http中读取一行的操作 char *FindCRLF() { char *res (char *)memchr(ReadPosition(), \n, ReadAbleSize()); return res; } /*通常获取一行数据这种情况针对是*/ std::string GetLine() { char *pos FindCRLF(); if (pos NULL) { return ; } // 1是为了把换行字符也取出来。 return ReadAsString(pos - ReadPosition() 1); } std::string GetLineAndPop() { std::string str GetLine(); MoveReadOffset(str.size()); return str; }我们只提取这一行的数据连同换行符一起提取而换行符我们不知道是\r\n还是\n所以我们全部提取出去在外面处理也保证了功能的单一性。结语至此一个高效、完整的 Buffer 模块就实现完成了。回顾整个设计过程我们始终围绕几个核心目标展开减少数据拷贝、避免频繁内存分配、提高 Cache 命中率。通过环形缓冲区的思想配合std::vector的动态扩容能力我们实现了读写指针的分离与内存的复用在灵活性与性能之间取得了良好的平衡。这个 Buffer 模块的核心优势零拷贝思想通过指针偏移操作避免了对已读数据的物理删除减少了不必要的数据移动智能扩容策略优先通过数据紧缩复用已读空间只有在整体空间不足时才扩容降低了内存分配次数接口完备既提供了基础的读写操作也封装了WriteString、ReadAsString、GetLine等高频场景的便捷接口提升了开发效率职责单一Buffer 只负责数据的存储与取出不关心具体协议格式换行符的解析交由上层处理保持了模块的通用性在高并发网络服务器中Buffer 作为数据流转的核心枢纽其性能直接影响着整个系统的吞吐量。一个设计良好的 Buffer 模块能够有效应对 TCP 流式协议的粘包与半包问题通过读写指针分离实现了生产者和消费者速度的解耦内存复用机制减少了频繁的内存分配与释放降低了系统调用开销连续内存布局保证了 CPU Cache 的高命中率当然这个 Buffer 模块还有进一步优化的空间零拷贝发送结合writev/sendfile支持直接从 Buffer 发送数据减少内核态与用户态之间的拷贝分散读支持从多个 Buffer 中读取数据到单一目标适配复杂的协议解析场景内存池化对于高频创建销毁的场景可以考虑引入对象池进一步减少内存分配开销更精细的扩容策略根据实际负载动态调整扩容因子在内存占用与扩容频率之间做进一步权衡总之Buffer 模块虽小却是整个网络服务器架构的基石。它不仅承载了数据的传输更承载了对性能的极致追求。希望这篇内容能帮助你对高并发服务器的 Buffer 设计有一个清晰的认识也欢迎在实际使用中根据具体场景不断打磨优化。总代码如下#include iostream #include vector #include string #include cstring #include cassert #include cstdint #define BUFFER_DEFAULT_SIZE 1024 class Buffer { private: std::vectorchar _buffer; // 使用vector进行空间管理 uint64_t _writer_idx; // 写下标 uint64_t _reader_idx; // 读下标 public: Buffer() : _writer_idx(0), _reader_idx(0), _buffer(BUFFER_DEFAULT_SIZE) {} // 获取buffer的起始地址 char *Begin() { return _buffer.data(); } // 获取当前写入起始地址 char *WritePosition() { return Begin() _writer_idx; } // 获取当前读取起始地址 char *ReadPosition() { return Begin() _reader_idx; } // 获取缓冲区末尾空闲空间大小-- 写偏移之后的空闲空间 uint64_t TailIdleSize() { return _buffer.size() - _writer_idx; } // 获取缓冲区起始空闲空间大小--读偏移之前的空闲空间 uint64_t HeadIdleSize() { return _reader_idx; } // 获取可读数据大小 uint64_t ReadAbleSize() { return _writer_idx - _reader_idx; } // 将读偏移向后移动 void MoveReadOffset(uint64_t len) // len是移动的距离大小 { assert(len ReadAbleSize()); // 向后移动的距离必须小于可读数据的大小 _reader_idx len; } // 将写偏移向后移动 void MoveWriteOffset(uint64_t len) { assert(len TailIdleSize()); _writer_idx len; } // 确保可写空间是否足够 void EnsureWriteSpace(uint64_t len) { if (TailIdleSize() len) { return; } if (TailIdleSize() HeadIdleSize() len) // 我们就要把数据往前移动了 { uint64_t rsz ReadAbleSize(); // 先把当前可读数据的大小保存起来 std::copy(ReadPosition(), ReadPosition() rsz, Begin()); // 调用copy函数进行数据的拷贝 // 调整读写下标的起点位置 _writer_idx rsz; _reader_idx 0; } else // 否则就是所有的可写空间都不够我们需要扩容 { _buffer.resize(_writer_idx len); // 这里还是采取精确扩容而不是倍数扩容否则空间开大了每个连接都开大造成不必要的内存过多耗用 } } // 写入数据 void Write(const void *data, uint64_t len) { if (len 0) return; // 写入数据为0直接就返回就行了 EnsureWriteSpace(len); const char *d static_castconst char *(data); std::copy(d, d len, WritePosition()); } // 读取数据 void Read(void *data, uint64_t len) { assert(len ReadAbleSize()); std::copy(ReadPosition(), ReadPosition() len, static_castchar *(data)); } // 清理缓冲区 void Clear() { _writer_idx _reader_idx 0; } void WriteString(const std::string data) { return Write(data.c_str(), data.size()); } void WriteBuffer(Buffer data) // 这里我们没有使用const所以我们使用场景中就不要定义一个constBuffer传入进这个函数 { return Write(data.ReadPosition(), data.ReadAbleSize()); } // 有了写就再加点读 std::string ReadAsString(uint64_t len) // 读取len长数据并当做一个string返回 { assert(len ReadAbleSize()); std::string str; // 对于这些临时变量其实c11之后编译器就已经有了很多优化比如移动拷贝 str.resize(len); Read(str[0], len); return str; } // 五个调用后会使读写下标增加的接口 void WriteAndPush(const void *data, uint64_t len) { Write(data, len); MoveWriteOffset(len); } void WriteStringAndPush(const std::string data) { WriteString(data); MoveWriteOffset(data.size()); } void WriteBufferAndPush(Buffer data) { WriteBuffer(data); MoveWriteOffset(data.ReadAbleSize()); } void ReadAndPop(void *buf, uint64_t len) { Read(buf, len); MoveReadOffset(len); } std::string ReadAsStringAndPop(uint64_t len) { assert(len ReadAbleSize()); std::string str ReadAsString(len); MoveReadOffset(len); return str; } // http中读取一行的操作 char *FindCRLF() { char *res (char *)memchr(ReadPosition(), \n, ReadAbleSize());//其实只需要找到\n return res; } /*通常获取一行数据这种情况针对是*/ std::string GetLine() { char *pos FindCRLF(); if (pos NULL) { return ; } // 1是为了把换行字符也取出来。 return ReadAsString(pos - ReadPosition() 1); } std::string GetLineAndPop() { std::string str GetLine(); MoveReadOffset(str.size()); return str; } };