brpc 高性能零拷贝缓冲 butil::IOBuf 完全指南:切割、拼接、序列化与源码实现剖析
【免费下载链接】brpcbrpc is an Industrial-grade RPC framework using C++ Language, which is often used in high performance system such as Search, Storage, Machine learning, Advertisement, Recommendation etc. "brpc" means "better RPC".项目地址: https://gitcode.com/GitHub_Trending/brpc/brpc
IOBuf 是 brpc 框架内部用于承载协议附件(attachment)与 HTTP body 的核心数据结构,是一种非连续(non-contiguous)的零拷贝缓冲,接口风格与std::string相似但语义不同。本文将以 docs/cn/iobuf.md 为主线,结合 src/butil/iobuf.h、src/butil/iobuf.cpp 与 test/iobuf_unittest.cpp 中的实现细节,完整讲解 IOBuf 的切割、拼接、protobuf 解析/序列化、fd 读写等实战操作,并深入剖析其 Block 引用计数与 TLS 内存池原理,帮助你在自己的服务中正确、高效地使用这一数据结构。
IOBuf 的定位:协议处理中的粘合剂
在 brpc 中,butil::IOBuf被用作若干协议的附件数据结构以及 HTTP body 的载体。从源码注释可以看出其设计定位:A non-continuous zero-copied buffer that can be cut and combined w/o copying payload(src/butil/iobuf.h),即"无需拷贝数据即可切割与拼接的非连续零拷贝缓冲"。
这套设计在 brpc 之前的大量项目中已经过验证,性能表现出色。如果你接触过 Kylin 框架中的BufHandle,那么更能体会到 IOBuf 的便利:BufHandle几乎未完整封装,直接暴露内部结构,使用者必须小心翼翼地手工维护引用计数,极易出错;而 IOBuf 把引用计数、Block 管理等底层细节全部封装在内部,对外只暴露与std::string相似但更强大的接口。
从线程模型上看,IOBuf 是thread-compatible的:不同线程同时使用不同的 IOBuf 是安全的,多个线程同时读取同一个静态 IOBuf 也是安全的;但它不是 thread-safe的:同一 IOBuf 被多个线程同时修改是不安全且很可能崩溃的(src/butil/iobuf.h)。这是使用 IOBuf 的第一条铁律。
底层结构:Block、BlockRef 与引用计数
理解 IOBuf 的零拷贝语义,关键在于它的三层内部结构(src/butil/iobuf.h):
- Block:真正持有数据的 8K 内存块(默认块大小为 8192 字节,见 src/butil/iobuf.cpp 的
default_block_size)。每个 Block 带引用计数,可被多个 IOBuf 共享。 - BlockRef:一个"切片描述符",记录
{offset, length, block},即某个 Block 上的一个连续数据区间。IOBuf 内部维护的是 BlockRef 的队列,而不是数据本身。 - SmallView / BigView:IOBuf 本体是一个 union。当 BlockRef 数量不超过 2 个时使用内联的
SmallView(refs[2]),不额外分配内存;超过 2 个时升级为BigView,动态分配 BlockRef 数组并用环形缓冲(ref_at中(start + i) & cap_mask)管理。
引用计数语义决定了各操作的代价:
| 操作 | 数据拷贝 | 引用计数 | 源码位置 |
|---|---|---|---|
append(IOBuf&) | 无 | 递增 | src/butil/iobuf.cpp |
append(void*, count) | 有(memcpy 进新 Block) | 无 | src/butil/iobuf.cpp |
cutn(IOBuf*, n) | 无(移动 BlockRef) | 转移 | src/butil/iobuf.cpp |
to_string() | 有(整块拼接成 string) | 无 | src/butil/iobuf.h |
正是因为 IOBuf 拷贝的是"管理结构(BlockRef)"而非数据,所以拷贝出来的新 IOBuf 与原来的 IOBuf 共享底层 Block,修改拷贝不会影响原 IOBuf,而数据本身始终只有一份。
TLS 块池:避免频繁分配
频繁 malloc/free 8K Block 的代价不小。IOBuf 的实现采用了每线程(TLS)块池策略:
share_tls_block()(src/butil/iobuf.cpp):追加数据时优先从当前线程的 TLS 块链上取一个未写满的 Block,线程第一次使用时会注册thread_atexit清理钩子。release_tls_block_chain()(src/butil/iobuf.cpp):块被释放时回收到 TLS 池,供同线程后续追加复用;当线程内块数超过阈值(max_blocks_per_thread)时直接归还给系统,避免单个线程囤积过多内存。
这一设计让高频的 append 操作在热路径上可以零系统调用地复用内存,是 IOBuf 高性能的重要来源之一。IOBuf 还提供了全局统计接口block_count()、block_memory()(字节数)、new_bigview_count()等(src/butil/iobuf.h),可用于观测运行时内存占用。
IOBuf 能做什么,不能做什么
原文档以两段清单精确划定了 IOBuf 的能力边界,这是选用数据结构时的第一判断依据:
IOBuf 能做的:
- 默认构造不分配内存(src/butil/iobuf_inl.h 中构造函数只是将两个
BlockRef重置为空,不碰任何堆内存)。 - 可以拷贝,修改拷贝不影响原 IOBuf;拷贝的只是管理结构(BlockRef),不是数据。
- 可以 append 另一个 IOBuf,不拷贝数据(仅共享 Block)。
- 可以 append 字符串,此时会拷贝数据。
- 可以从 fd 读取、写入 fd。
- 可以解析或序列化为 protobuf messages。
- 可以通过
IOBufBuilder把 IOBuf 当std::ostream使用。
IOBuf 不能做的:
- 不能当作程序内的通用存储结构。IOBuf 应保持较短的生命周期,以避免一个 IOBuf 通过引用计数"锁住"多个 8K Block,导致内存迟迟无法归还。典型用法是:从 socket 读到 IOBuf → 解析成消息 → 立即释放或复用,而不是长期驻留。
切割(Cut):从头部切下或弹出数据
切割是 IOBuf 最常用的操作,用于从缓冲头部消费"已经处理完"的字节,全程不拷贝 payload。
从source_buf头部切下 16 字节放入dest_buf:
source_buf.cut(&dest_buf, 16); // 当 source_buf 不足 16 字节时,切掉所有字节。cut对应源码中的cutn(IOBuf* out, size_t n)(src/butil/iobuf.cpp):它把source_buf头部的 BlockRef移动到out的尾部,若目标 Block 被部分切分,则生成一个新的更小 BlockRef。整个过程只改动描述符,数据原地不动。
从source_buf头部弹掉 16 字节(不保留,直接丢弃):
source_buf.pop_front(16); // 当 source_buf 不足 16 字节时,清空它pop_front(src/butil/iobuf.cpp)的实现同样是"改 offset / 减 length / 丢弃 BlockRef"三件套:如果弹出的量小于头 BlockRef 的 length,只需r.offset += n; r.length -= n;即可完成,连 BlockRef 都不用出队。
除cutn之外,IOBuf 还提供了一组高频配套切割接口(src/butil/iobuf.h):
cut1(void* c):从头部切出 1 个字节。cutn(void* out, n)/cutn(std::string* out, n):切到裸内存或std::string(此时发生数据拷贝)。cut_until(IOBuf* out, char const* delim):从头部切到匹配到分隔符为止,分隔符前的数据进入out;匹配失败返回 -1。单字符分隔符走_cut_by_char,多字符分隔符走按unsigned long滚动签名加速的_cut_by_delim(src/butil/iobuf.cpp)——这在按行解析文本协议(如 HTTP 头部)时非常实用。
源码证据:
pop_front的边界行为(n=0 无操作、n>=length 清空)在 test/iobuf_unittest.cpp 的IOBufTest.pop_front中有完整断言覆盖。
拼接(Append):共享 or 拷贝,由参数类型决定
在尾部追加另一个 IOBuf,不拷贝数据:
buf.append(another_buf); // no data copy其实现(src/butil/iobuf.cpp)就是把another_buf的每个 BlockRef 通过_push_back_ref压入buf的 BlockRef 队列,并对对应 Block 的引用计数+1。两个 IOBuf 从此共享同一份数据。
在尾部追加std::string,拷贝数据:
buf.append(str); // copy data of str into buf对应append(void const* data, size_t count)(src/butil/iobuf.cpp):从 TLS 池取块,用iobuf::cp(RISC-V 向量指令优化版或 memcpy,见 src/butil/iobuf.cpp)把数据拷进 Block,再压入 BlockRef。
此外还有几个实用变体:
appendv(const const_iovec vec[], size_t n):一次追加多段不连续数据,比逐个 append 快(src/butil/iobuf.h)。push_back(char c):追加单字符。append(const Movable&):追加并清空源 IOBuf(转移所有权),把"拷贝管理结构"进一步优化为"移动管理结构"。append_user_data(void* data, size_t size, std::function<void(void*)> deleter):零拷贝追加用户自管理的内存,IOBuf 只持有引用,当最后一个引用释放时回调 deleter(src/butil/iobuf.h)。这在大块数据(如已 mmap 的文件内容)需要拼进 IOBuf 又不想拷贝时非常有用,对应测试见 test/iobuf_unittest.cpp。
注意:
push_back/append系列在头文件注释中被明确标注为"为便利与偶发使用而实现,相对较慢",原因是频繁的 BlockRef 管理与引用计数开销(src/butil/iobuf.h)。高频写入场景应改用IOBufAppender或IOBufBuilder。
解析(Parse):把 IOBuf 还原成结构化数据
解析为 protobuf message
IOBuf 通过IOBufAsZeroCopyInputStream适配 protobuf 的零拷贝输入流:
IOBufAsZeroCopyInputStream wrapper(&iobuf); pb_message.ParseFromZeroCopyStream(&wrapper);IOBufAsZeroCopyInputStream(src/butil/iobuf.h)实现google::protobuf::io::ZeroCopyInputStream的Next/BackUp/Skip/ByteCount,让 protobuf 直接消费 IOBuf 内部的非连续 Block,中间不产生任何整块拷贝。源码注释强调了一个约束:该 wrapper 不会修改源 IOBuf,且 wrapper 存续期间源 IOBuf 不应被修改,因为构造函数保存了源 IOBuf 的内部快照信息。
解析为自定义二进制结构
利用 protobuf 的CodedInputStream,可以逐字段读取任意二进制协议:
IOBufAsZeroCopyInputStream wrapper(&iobuf); CodedInputStream coded_stream(&wrapper); coded_stream.ReadLittleEndian32(&value); ...这是 brpc 各种二进制协议在应用层做粘包解析的通用套路:先cut_until或按长度字段cutn出完整的包,再包一个IOBufAsZeroCopyInputStream用CodedInputStream读取各字段。
面向 std::istream 的解析视图
如果解析器只接受std::istream&(例如nlohmann::json::parse(std::istream&)),IOBuf 也提供了IOBufInputStream/IOBufAsInputStreamBuf(src/butil/iobuf.h):
butil::IOBufInputStream in(request_body); auto j = nlohmann::json::parse(in);它是只读、仅前向(不支持 seek)的流视图;使用期间源 IOBuf 同样不得被修改,否则backing_block()返回的 StringPiece 可能失效导致读到脏数据甚至崩溃。
序列化(Serialize):把结构化数据写进 IOBuf
protobuf message 序列化为 IOBuf
IOBufAsZeroCopyOutputStream wrapper(&iobuf); pb_message.SerializeToZeroCopyStream(&wrapper);IOBufAsZeroCopyOutputStream(src/butil/iobuf.h)实现ZeroCopyOutputStream,序列化结果直接落入 IOBuf 的 Block 中。与输入流不同,它不会清空源 IOBuf,而且允许在流空闲时向源 IOBuf 追加数据后再继续序列化("append → serialize → 再 append → 再 serialize"的模式是合法的)。其默认行为是共享当前线程的 TLS 块池;若同一线程同时存在大量流对象,可能产生较多碎片,此时可传入正数block_size让该流使用独立 Block。
用 IOBufBuilder 像 std::ostream 一样构造 IOBuf
IOBufBuilder os; os << "anything can be sent to std::ostream"; os.buf(); // IOBufIOBufBuilder(src/butil/iobuf.h)私有继承自IOBuf、IOBufAsZeroCopyOutputStream与ZeroCopyStreamAsStreamBuf,公开继承std::ostream,从而把任意可输出到std::ostream的内容(整数、浮点、字符串、自定义operator<<类型)直接写进 IOBuf:
buf():返回当前构造好的 IOBuf(内部先执行shrink()收回未用尾部)。move_to(target):把构造结果整体转移给目标 IOBuf(buf()被清空)。
面向 std::ostream 的写出视图
对应地,IOBufOutputStream/IOBufAsOutputStreamBuf(src/butil/iobuf.h)把 IOBuf 包装成std::ostream,让nlohmann::json这类只认 ostream 的库可以零中间拷贝地直接序列化进 IOBuf:
butil::IOBuf out; // 例如 controller->response_attachment() { butil::IOBufOutputStream os(out); os << json_reply; // 直接写入 IOBuf 的 Block } // 析构时 shrink(),out 恰好包含完整序列化字节注意一个易踩的坑:由于Next()会"超额占用"Block 尾部,IOBuf 的长度只有在shrink()/sync()/析构之后才精确反映已写入的字节数;若需要在流中途获取精确长度,应显式os.flush()。
更高吞吐的 IOBufAppender
对于纯粹的高频追加整数与短字符串场景,IOBufAppender(src/butil/iobuf.h)通过持有当前可写指针(_data/_data_end)减少每次调用的簿记开销。头文件注释给出的参考数据是:短数据 append 耗时约为IOBuf::append的 2/3,push_back约 3ns vs 13ns(Intel Xeon E5-2620 平台),长数据差距缩小。
打印(Print):直接输出到流
IOBuf 可以直接打印到std::ostream,注意示例中的 iobuf 必须只包含可打印字符:
std::cout << iobuf << std::endl; // or std::string str = iobuf.to_string(); // 注意: 会分配内存 printf("%s\n", str.c_str());operator<<(src/butil/iobuf.h)逐 Block 输出,不产生整块拷贝;to_string()则把全部数据拼进一个新std::string,需要分配内存,仅适合小数据或确需 string 的场景。此外,fetch(void* aux_buffer, size_t n)提供了"尽量少拷贝地取头部 n 字节"的能力:若数据在内部块中连续则直接返回内部指针(零拷贝),否则拷入用户提供的辅助缓冲(src/butil/iobuf.h)。
从 fd 读写:IOPortal 与零拷贝 IO
文档提到 IOBuf "可以从 fd 读取,可以写入 fd",具体实现分为两侧:
写入侧:cut_into_file_descriptor(fd, size_hint)把 IOBuf 头部数据直接切给fd。实现(src/butil/iobuf.cpp)把 IOBuf 的连续 Block 段拼装成struct iovec数组,一次writev写出——数据无需先合并成连续内存;出于 bthread 小栈安全考虑,单次最多组装IOBUF_IOV_MAX = 256个 iovec。写成功后相应字节通过pop_front(nw)从头部消费。另有pcut_into_file_descriptor(带偏移量,基于 preadv/pwritev,内核不支持时自动回退到用户态实现,见 src/butil/iobuf.cpp)与一次写多个 IOBuf 的cut_multiple_into_file_descriptor。
读取侧:IOPortal是 IOBuf 的子类(src/butil/iobuf.h),专为从 socket/fd 收字节设计,brpc 的网络层用它作为收包缓冲:
IOPortal portal; ssize_t n = portal.append_from_file_descriptor(fd, 1024*1024); // 之后 portal.cut(&msg_buf, msg_len) 切出完整消息append_from_file_descriptor走 readv 直接读入 IOPortal 持有的 Block;_block字段会在多次 append 之间缓存未写满的 Block,让同一次收包循环读出的消息更可能共享 Block、减少 BlockRef 数量。当缓冲被切空后,调用return_cached_blocks()(或 clear/析构)把缓存块还给 TLS 池——但不必在每次append_xxx之后都调用,那反而会损伤性能(src/butil/iobuf.h)。该接口还有 SSL 变体append_from_SSL_channel与对齐预留版本IOReserveAlignedBuf。
性能:设计目标与实测数据
IOBuf 的核心设计目标就是"以最少的拷贝完成缓冲区操作"。原文档给出的一组基准(动作链路为:文件读入 → 切割 12+N 字节 → 拷贝 → 合并到另一个缓冲 → 写出到 /dev/null)如下:
| 动作 | 吞吐 | QPS |
|---|---|---|
| 文件读入→切割12+16字节→拷贝→合并到另一个缓冲→写出到/dev/null | 240.423MB/s | 8586535 |
| 文件读入→切割12+128字节→拷贝→合并到另一个缓冲→写出到/dev/null | 790.022MB/s | 5643014 |
| 文件读入→切割12+1024字节→拷贝→合并到另一个缓冲→写出到/dev/null | 1519.99MB/s | 1467171 |
这组数据说明两点:其一,即使包含切割、拷贝、合并等完整链路,IOBuf 单条流水线的吞吐也可达 240MB/s 以上、QPS 数百万量级;其二,数据块越大,每次操作的固定开销占比越低,吞吐随之显著上升(16 字节 240MB/s → 1024 字节 1520MB/s)。在 brpc 的实际网络路径中,IOBuf 与 readv/writev、TLS 块池、引用计数共享相互配合,把"收包 → 切包 → 转发/回包"整条链路的拷贝次数压到最低。
性能调优相关的两个内置旋钮也值得了解:
GetDefaultBlockSize()/SetDefaultBlockSize(size_t)(src/butil/iobuf.cpp):默认块大小 8192 字节;SetDefaultBlockSize要求传入 4096 的整数倍且大于 0,且非线程安全,应在启动早期调用。- gflag
iobuf_aligned_buf_block_size(src/butil/iobuf.cpp):控制对齐缓冲的块大小,用于IOReserveAlignedBuf等对齐场景。
实践要点与避坑清单
结合文档与源码,使用 IOBuf 时请牢记以下几点:
- 生命周期要短:IOBuf 通过引用计数让多个 IOBuf 共享 Block,长期持有一个 IOBuf 会连带锁住多个 8K Block 及其引用,阻碍内存回收。它是协议处理链路上的"临时容器",不是通用字符串。
- 区分共享与拷贝:
append(IOBuf&)、cutn(IOBuf*, n)是零拷贝(共享/移动 BlockRef);append(void*, n)、append(std::string)、to_string()、cutn(void*, n)会真实拷贝数据。按语义选对 API,是性能的关键。 - 流包装器使用期勿改源缓冲:
IOBufAsZeroCopyInputStream、IOBufInputStream存续期间源 IOBuf 必须保持不变(src/butil/iobuf.h),否则内部指针失效。 - 输出流长度要"提交"后才精确:
IOBufOutputStream需在flush()/析构后才保证 IOBuf 长度精确对应已写字节。 - 线程模型:同一 IOBuf 禁止多线程并发修改;不同 IOBuf 可跨线程独立使用。
- 高频写入用专用工具:频繁小量追加优先
IOBufAppender,需要 ostream 风格则用IOBufBuilder/IOBufOutputStream,避免逐个push_back的簿记开销。
延伸阅读
- 完整英文版文档见 docs/en/iobuf.md,与本文所述内容一一对应。
- 接口全集与详细注释见 src/butil/iobuf.h;内联实现见 src/butil/iobuf_inl.h。
- 实现细节(TLS 块池、引用计数、iovec 组装、Block 分配)见 src/butil/iobuf.cpp。
- 行为验证与边界条件测试见 test/iobuf_unittest.cpp,覆盖
pop_front、pop_back、append、copy_to、reserve、append_user_data等全部核心操作。
【免费下载链接】brpcbrpc is an Industrial-grade RPC framework using C++ Language, which is often used in high performance system such as Search, Storage, Machine learning, Advertisement, Recommendation etc. "brpc" means "better RPC".项目地址: https://gitcode.com/GitHub_Trending/brpc/brpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考