brpc 内存管理深度解析:ResourcePool / ObjectPool 与 bthread_t 的高效生成机制
brpc 内存管理深度解析ResourcePool / ObjectPool 与 bthread_t 的高效生成机制【免费下载链接】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/brpcbrpc 作为面向搜索、存储、机器学习等高性能场景的工业级 RPC 框架其底层 M:N 线程库 bthread 需要在微秒甚至纳秒级别完成线程的创建与调度。本文以 brpc 官方文档《内存管理》为骨架结合仓库源码深入讲解 brpc 如何通过等长对象 线程本地缓存的 ResourcePoolT 与 ObjectPoolT 实现低竞争、低浪费的内存分配并剖析 bthread_t 的 id 生成机制32 位偏移量 32 位版本号与 bthread 栈管理mmap mprotect guard page。读完本文你将掌握 brpc 内存池的设计权衡、核心数据结构和关键源码路径并理解为何 brpc 官方明确不建议业务代码直接使用这两个类。多线程内存分配的两个核心权衡内存管理始终是程序中的重要一环。在多线程时代一个好的内存分配器大都在如下两点之间权衡线程间竞争少。内存分配的粒度大都比较小对性能敏感。如果不同的线程在大多数分配时会竞争同一份资源或同一把锁性能将会非常糟糕原因无外乎与 cache 一致性有关这已被大量 malloc 方案所证明。浪费的空间少。如果每个线程各申请各的速度也许不错但万一一个线程总是申请、另一个线程总是释放内存就会爆炸。线程之间总是要共享内存的如何共享就是方案的关键。一般的应用可以使用 tcmalloc、jemalloc 等成熟的内存分配方案但这对于较为底层、关注性能长尾的应用是不够的。多线程框架广泛地通过传递对象的 ownership所有权来让问题异步化如何让分配这些小对象的开销变得更小是值得研究的问题。其中有一个特点较为显著大多数结构是等长的。这个属性可以大幅简化内存分配的过程获得比通用 malloc 更稳定、更快速的性能。brpc 中的ResourcePoolT和ObjectPoolT即提供这类分配。需要特别说明的是这篇文章并不鼓励用户使用ResourcePoolT或ObjectPoolT事实上 brpc 官方反对用户在程序中使用这两个类。因为等长的副作用是某个类型独占了一部分内存这些内存无法再被其他类型使用如果不加控制地滥用反而会在程序中产生大量彼此隔离的内存分配体系既浪费内存也不见得会有更好的性能。ResourcePoolT以偏移量为 id 的等长对象池ResourcePoolT是 brpc 内存管理的核心抽象其声明位于 src/butil/resource_pool.h具体实现位于 src/butil/resource_pool_inl.h。核心语义偏移量即指针创建一个类型为T的对象并返回一个偏移量这个偏移量可以在O(1) 时间内转换为对象指针。这个偏移量相当于指针但它的值在一般情况下小于 2^32所以可以把它作为 64 位 id 的一部分。对象可以被归还但归还后对象并没有被删除也没有被析构而是仅仅进入 freelist。下次申请时可能会取到这种使用过的对象需要重置后才能使用。当对象被归还后通过对应的偏移量仍可以访问到对象即ResourcePool只负责内存分配并不解决 ABA 问题。但对于越界的偏移量ResourcePool会返回空指针。在源码中偏移量的载体是ResourceIdT其内部就是一个uint64_t值见 resource_pool_inl.htemplate typename T struct ResourceId { uint64_t value; operator uint64_t() const { return value; } ... };分配流程Thread-local 优先的三级路径由于对象等长ResourcePool通过批量分配和归还内存来避免全局竞争并降低单次开销。每个线程的分配流程如下查看thread-local free block。如果还有 free 的对象返回没有则进入步骤 2。尝试从全局取一个 free block若取到则回到步骤 1否则进入步骤 3。从全局取一个 block返回其中第一个对象。原理比较简单工程实现上数据结构、原子变量、memory fence 等问题会复杂一些。从 resource_pool_inl.h 的BAIDU_RESOURCE_POOL_GET宏可以看到与文档完全一致的实现顺序/* Fetch local free id */ if (_cur_free.nfree) { ... return unsafe_address_resource(free_id); } /* Fetch a FreeChunk from global. */ if (_pool-pop_free_chunk(_cur_free)) { ... return unsafe_address_resource(free_id); } /* Fetch memory from local block */ if (_cur_block _cur_block-nitem BLOCK_NITEM) { ... return p; } /* Fetch a Block from global */ _cur_block add_block(_cur_block_index); ...工程实现LocalPool / Block / BlockGroup 三级结构从源码结构看ResourcePoolT内部采用三级组织见 resource_pool_inl.hBlock一次批量分配的单位内部以AlignedMemory数组存放BLOCK_NITEM个等长对象并带BAIDU_CACHELINE_ALIGNMENT对齐以避免伪共享BlockGroup每个 BlockGroup 通过butil::atomicBlock*数组持有最多RP_GROUP_NBLOCK65536个 BlockBlockGroup本身也是原子计数管理LocalPool每个线程一个thread-local持有_cur_block当前正在使用的块和_cur_free本地空闲 id 栈FreeChunk归还时先进入本地_cur_free本地满后整体 push 到全局_free_chunks链表由互斥锁_free_chunks_mutex保护。ResourcePool的寻址空间上限由常量约束RP_MAX_BLOCK_NGROUP 65536、RP_GROUP_NBLOCK 65536即一个类型最多可寻址 65536 × 65536 个 Block。address_resource()在解析 id 时会逐级检查 group、block、offset 是否越界越界即返回nullptr见 resource_pool_inl.h。可定制参数Block 大小与 FreeChunk 上限ResourcePoolT支持通过特化模板结构体来覆盖默认参数见 resource_pool.h模板参数默认值作用ResourcePoolBlockMaxSizeT64 * 1024字节单个 Block 的内存上限ResourcePoolBlockMaxItemT256个单个 Block 的对象个数上限实际取min(BlockMaxSize / sizeof(T), BlockMaxItem)ResourcePoolFreeChunkMaxItemT256个本地 FreeChunk 合并到全局链表前的对象个数上限ResourcePoolValidatorT恒返回 true新对象构造后的校验回调返回 false 则立即析构并使get_resource返回 nullptr用于构造内部失败如 ENOMEM 的场景特化示例namespace butil { template struct ResourcePoolBlockMaxSizeFoo { static const size_t value 1024; }; }对外 API 与性能参考ResourcePoolT通过单例模式singleton()双检锁初始化对外暴露以下核心函数见 resource_pool.hget_resource(ResourceIdT* id, args...)获取一个对象并写入 idreturn_resource(ResourceIdT id)归还对象不析构成功返回 0失败返回 -1address_resource(ResourceIdT id)O(1) 将 id 转换为对象指针非法 id 返回 nullptrclear_resources()回收资源线程退出时自动调用一般无需手动describe_resources()/for_each_resource()诊断与遍历接口。源码头部注释中保留了一组高竞争场景下的实测对比resource_pool.hgetint耗时约 24.767.8 个时间单位、returnint约 4.76.5而同场景下newint约 170.6319.2、deleteint约 219.0672.8可见等长对象池在竞争激烈时相对new/delete有数量级的提升。这些数据来自仓库源码注释实际性能请以目标平台实测为准。ObjectPoolT不带 id 的对象池变种ObjectPoolT是ResourcePoolT的变种不返回偏移量而直接返回对象指针声明位于 src/butil/object_pool.h。其内部结构和ResourcePool类似一些代码更加简单。对于用户来说这就是一个多线程下的对象池brpc 内部也正是这么用的。ObjectPoolT的 API 对应为get_objectT(args...)、return_object(T* ptr)、clear_objects()、describe_objects()等且同样支持ObjectPoolBlockMaxSizeT、ObjectPoolBlockMaxItemT、ObjectPoolFreeChunkMaxItemT、ObjectPoolValidatorT等特化参数默认值与 ResourcePool 一致。另外它还提供一个ObjectPoolWithASanPoisonT特化开关用于在 ASan 环境下对空闲对象做毒化poison以辅助检测悬垂使用。一个典型的真实用例Socket::Write中把每个待写出的请求包装为WriteRequest该对象就是用ObjectPoolWriteRequest分配的。在 src/brpc/socket.cpp 中可以找到WriteRequest* req butil::get_objectWriteRequest(); ... butil::return_object(req); // 写完后归还对象不析构进入池中复用这正是通过传递对象 ownership 让问题异步化的落地方式发送方把请求打包进WriteRequest对象池对象异步交给 IO 线程处理处理完归还即可全程避免new/delete带来的锁竞争与 cache miss。生成 bthread_t32 位偏移量 32 位版本号的 id 设计用户期望通过创建 bthread 获得更高的并发度所以创建 bthread 必须很快。在目前的实现中创建一个 bthread 的平均耗时小于 200ns数据来自官方文档。如果每次都要从头创建是不可能这么快的。创建过程更像是从一个 bthread 池子中取一个实例我们又同时需要一个 id 来指代一个 bthread所以这正是ResourcePool的用武之地。bthread 在代码中被称作 Task其结构被称为TaskMeta定义在 src/bthread/task_meta.h所有的TaskMeta由ResourcePoolTaskMeta分配。bthread_t 的组成bthread 的大部分函数都需要在 O(1) 时间内通过bthread_t访问到TaskMeta并且当bthread_t失效后访问应返回 NULL 以让函数做出返回错误。解决方法是bthread_t 由 32 位的版本version和 32 位的偏移量offset组成版本解决 ABA 问题偏移量由ResourcePoolTaskMeta分配。查找时先通过偏移量获得TaskMeta再检查版本如果版本不匹配说明 bthread 已失效。注意这只是大概的说法在多线程环境下即使版本相等bthread 仍可能随时失效在不同的 bthread 函数中处理方法都不同有些函数会加锁有些则能忍受版本不相等。从 task_meta.h 可以看到TaskMeta中与 id 设计直接相关的字段struct TaskMeta { // [Not Reset] 保证 version_butex 的可见性 pthread_spinlock_t version_lock{}; // [Not Reset] 任意时刻仅被一个 bthread 修改无需原子 uint32_t* version_butex{nullptr}; // The identifier. It does not have to be here, however many code is // simplified if they can get tid from TaskMeta. bthread_t tid{INVALID_BTHREAD}; ... bthread_attr_t attr{BTHREAD_ATTR_NORMAL}; ContextualStack* stack{nullptr}; ... };注意TaskMeta的构造函数只初始化标注了[Not Reset]的字段版本锁、version_butex 等其余业务字段栈指针、用户函数、统计信息等在bthread_start*系列函数中被显式重置——这正呼应了文档下次申请时可能取到使用过的对象需要重置后才能使用的描述也解释了为什么池化的TaskMeta能被反复复用而创建开销极小。这种 id 生成方式在 brpc 中应用广泛brpc 中的SocketId、bthread_id_t也是用类似的方法分配的即版本号 资源池偏移量的组合 id兼顾了 O(1) 寻址、ABA 防护和失效检测。栈管理不同大小的栈由不同 pool 管理使用ResourcePool加快创建的副作用是一个 pool 中所有 bthread 的栈必须是一样大的。这似乎限制了用户的选择不过基于 brpc 的观察大部分用户并不关心栈的具体大小而只需要两种大小的栈尺寸普通但数量较少尺寸小但数量众多。所以 brpc 用不同的 pool 管理不同大小的栈用户可以根据场景选择。三种栈属性两种栈分别对应属性BTHREAD_ATTR_NORMAL栈默认为 1M适用于常规业务逻辑BTHREAD_ATTR_SMALL栈默认为 32K适用于数量众多的轻量任务BTHREAD_ATTR_LARGE栈大小和 pthread 一样默认 8M由于尺寸较大bthread 不会对其做 caching创建速度较慢。server默认使用BTHREAD_ATTR_NORMAL运行用户代码。三个属性的定义见 src/bthread/types.h栈的实际默认值由 src/bthread/stack.cpp 中的 gflags 定义DEFINE_int32(stack_size_small, 32768, size of small stacks); DEFINE_int32(stack_size_normal, 1048576, size of normal stacks); DEFINE_int32(stack_size_large, 8388608, size of large stacks); DEFINE_int32(guard_page_size, 4096, size of guard page, allocate stacks by malloc if its 0(not recommended)); DEFINE_int32(tc_stack_small, 32, maximum small stacks cached by each thread); DEFINE_int32(tc_stack_normal, 8, maximum normal stacks cached by each thread);即 small / normal / large 栈的默认大小分别为 32768 / 1048576 / 8388608 字节guard page 默认 4K且每个线程对 small 与 normal 栈分别缓存最多 32 / 8 个以加速复用large 栈不做缓存。这些参数都可以通过 gflags 在启动时调整。mmap mprotect 与 guard page栈使用mmap分配bthread 还会用mprotect分配 4K 的 guard page 以检测栈溢出。从 src/bthread/stack.cpp 的实现可以看到完整流程栈大小按页对齐最小为两页guard size 也按页对齐最小一页使用mmap一次性映射stacksize guardsize的内存MAP_PRIVATE | MAP_ANONYMOUS用mprotect将栈顶的 guard 区域设置为PROT_NONE一旦越界写入即触发段错误从而第一时间暴露栈溢出s-bottom指向栈底高地址配合VALGRIND_STACK_REGISTER在 Valgrind 下也能正确识别栈区间。实现中还对 mmap 失败给出了明确提示possibly limited by /proc/sys/vm/max_map_count。这是因为mmap mprotect 不能超过 max_map_count默认为 65536当 bthread 非常多之后可能要调整此参数。另外当有很多 bthread 时内存问题可能不仅仅是栈也包括各类用户和系统 buffer需要整体评估内存预算。与 goroutine 栈策略的对比goroutine 在 1.3 之前通过segmented stacks分段栈动态地调整栈大小发现hot split问题后换成了变长连续栈类似于 vector resizing只适合内存托管语言。而 bthread 由于基本只会在 64 位平台上使用虚存空间庞大对变长栈的需求不明确加上 segmented stacks 的性能有影响bthread 暂时没有变长栈的计划。这决定了 bthread 采用固定大小 分级 pool guard page 检测这一更简单、更可控的栈策略。总结何时使用这些机制机制返回形式典型用途源码位置ResourcePoolT偏移量 idO(1) 转指针分配TaskMeta、SocketId、bthread_id_t等需要 id 指代的对象src/butil/resource_pool.h、src/butil/resource_pool_inl.hObjectPoolT对象指针Socket::Write中的WriteRequest等短生命周期消息对象src/butil/object_pool.h、src/brpc/socket.cppbthread 栈三种固定大小 guard page支撑 M:N 调度的高并发线程src/bthread/stack.cpp、src/bthread/types.hbrpc 通过等长对象 线程本地缓存 批量块分配三管齐下把多线程小对象分配的开销压缩到纳秒量级同时用版本号 偏移量的复合 id 兼顾寻址效率与 ABA 防护最终支撑起创建 bthread 平均耗时小于 200ns的高性能指标。这套设计是 brpc 内部异步化编程模型传递对象 ownership的地基——而作为使用者理解其原理、遵循官方建议不滥用这两个类才能让这套机制在框架内部发挥最大价值。【免费下载链接】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),仅供参考