brpc 内存管理深度解析:ResourcePool<T> / ObjectPool<T> 等长对象池与 bthread 栈分配原理 📅 发布时间:2026/9/14 2:57:04 👁 浏览次数: brpc 内存管理深度解析ResourcePool / ObjectPool 等长对象池与 bthread 栈分配原理【免费下载链接】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内存分配一直是 C 多线程服务中性能与稳定性的关键一环。本文以 docs/cn/memory_management.md 为骨架结合 brpc 仓库中ResourcePoolT、ObjectPoolT的实际实现resource_pool.h、resource_pool_inl.h、object_pool.h以及bthread_t生成与 bthread 栈管理的源码佐证系统讲解 brpc 如何在线程间竞争少与浪费空间少之间取得平衡。读完本文你将掌握等长对象池的设计动机、偏移量句柄 版本号解决 ABA 问题的方案、bthread_t的 O(1) 寻址原理以及 bthread 栈的尺寸选择与内核参数调优方法。背景多线程时代内存分配的两难内存管理总是程序中的重要一环。在多线程时代一个好的内存分配器大都在如下两点间权衡线程间竞争少。内存分配的粒度大都比较小对性能敏感。如果不同的线程在大多数分配时会竞争同一份资源或同一把锁性能将会非常糟糕。原因无外乎和 cache 一致性有关这已被大量的 malloc 方案证明。浪费的空间少。如果每个线程各申请各的速度也许不错但万一一个线程总是申请、另一个线程总是释放内存就爆炸了。线程之间总是要共享内存的如何共享就是方案的关键了。一般的应用可以使用 tcmalloc、jemalloc 等成熟的内存分配方案但对于较为底层、关注性能长尾的应用来说这还不够。多线程框架广泛地通过传递对象的 ownership 来让问题异步化如何让分配这些小对象的开销变得更小是值得研究的问题。其中有一个特点较为显著大多数结构是等长的。这个属性可以大幅简化内存分配的过程获得比通用 malloc 更稳定、快速的性能。brpc 中的ResourcePoolT和ObjectPoolT即提供这类分配。注意本文并不鼓励用户在自己的程序中使用ResourcePoolT或ObjectPoolT事实上 brpc 官方反对用户直接使用这两个类。因为等长的副作用是某个类型独占了一部分内存这些内存无法再被其他类型使用如果不加控制地滥用反而会在程序中产生大量彼此隔离的内存分配体系既浪费内存也不见得会有更好的性能。这两个类是 brpc 内部框架组件的基石读者应将其视为了解原理而非直接调用的对象。通用 malloc 与等长对象池的差距ResourcePoolT是为在缓存行对齐的前提下、高并发地分配等长小对象而设计的。在 resource_pool.h 的头部注释中记录了一组高竞争场景下ResourcePoolint与裸new/deleteglibc 2.3.4的对比数据单位 ns/次getint26.1 returnint4.7 getint46.1 returnint5.4 getint27.5 returnint5.3 ... -------------------------------- newint295.0 deleteint234.5 newint299.2 deleteint359.7 ...从这组仓库自带数据可以看出等长对象池的分配约 2570ns与归还约 5ns相比通用堆分配分配约 170320ns、释放约 219673ns有一个数量级以上的差距且波动更小——这正是关注性能长尾的底层框架所需要的稳定性来源。ResourcePool 以偏移量为句柄的等长对象池ResourcePoolT的核心思路是创建一个类型为 T 的对象并返回一个偏移量这个偏移量可以在 O(1) 时间内转换为对象指针。核心语义这个偏移量相当于指针但它的值在一般情况下小于 2^32所以它可以作为 64 位 id 的一部分。对象可以被归还但归还后对象并没有删除也没有被析构而是仅仅进入 freelist。下次申请时可能会取到这种使用过的对象需要重置后才能使用。当对象被归还后通过对应的偏移量仍可以访问到对象即ResourcePool只负责内存分配并不解决 ABA 问题ABA 问题由上层用版本号解决见下文bthread_t一节。对于越界的偏移量ResourcePool会返回空指针nullptr。在源码层面这个偏移量就是ResourceIdT本质是一个 64 位无符号整数resource_pool_inl.htemplate typename T struct ResourceId { uint64_t value; operator uint64_t() const { return value; } template typename T2 ResourceIdT2 cast() const { ResourceIdT2 id { value }; return id; } };分配流程三层递进由于对象等长ResourcePool通过批量分配和归还内存以避免全局竞争并降低单次的开销。每个线程的分配流程如下查看 thread-local free block。如果还有 free 的对象返回没有的话进入步骤 2。尝试从全局取一个 free block若取到的话回到步骤 1否则进入步骤 3。从全局取一个 block返回其中第一个对象。原理是比较简单的但工程实现上的数据结构、原子变量、memory fence 等问题会复杂一些。对应到 resource_pool_inl.h 中的LocalPool::get()resource_pool_inl.h其路径依次是若本地_cur_free一个 FreeChunk中还有空闲 id直接从栈顶取出一个否则调用pop_free_chunk从全局_free_chunks批量拉取一个 FreeChunk 填充本地否则从当前线程的_cur_block中切分新对象若本地 block 已满则通过add_block向全局申请一个新 Block。归还路径LocalPool::return_resourceresource_pool_inl.h与之对称优先写入本地 FreeChunk本地满后再把整个 chunk 合并进全局_free_chunks列表从而把跨线程的同步开销摊薄到每批 256 个对象上。内部结构Block / BlockGroup / LocalPool 三级组织从 resource_pool_inl.h 的类定义resource_pool_inl.h可以看到它的三级存储结构Block每个 Block 内含BLOCK_NITEM个缓存行对齐的对象槽位AlignedMemorysizeof(T), __alignof__(T)通常只被申请它的线程使用以改善 cache locality。BlockGroupRP_GROUP_NBLOCK 1 16个 Block 指针为一组一个ResourcePool最多有RP_MAX_BLOCK_NGROUP 65536个 BlockGroup因此偏移量的容量上限为 2^32 个对象槽位。LocalPool每个线程一个thread-local持有_cur_block与_cur_free是分配/归还的快路径。一个 Block 的大小受两个可特化的模板参数约束resource_pool.h模板参数默认值含义ResourcePoolBlockMaxSizeT64 * 1024字节单个 Block 的内存大小上限ResourcePoolBlockMaxItemT256单个 Block 的对象数量上限ResourcePoolFreeChunkMaxItemT256线程本地 FreeChunk 最多缓存的对象数实际每个 Block 的对象数BLOCK_NITEM min(BlockMaxSize / sizeof(T), BlockMaxItem)。块内对象等长、地址连续因此偏移量到指针的换算就是一次整除和一次寻址static inline T* unsafe_address_resource(ResourceIdT id) { const size_t block_index id.value / BLOCK_NITEM; return (T*)(_block_groups[(block_index RP_GROUP_NBLOCK_NBIT)] .load(butil::memory_order_consume) -blocks[(block_index (RP_GROUP_NBLOCK - 1))] .load(butil::memory_order_consume)-items) id.value - block_index * BLOCK_NITEM; }带边界检查的address_resourceresource_pool_inl.h则逐级校验 BlockGroup、Block 与offset b-nitem任何一级越界都返回nullptr——这正是文档所述对于越界的偏移量ResourcePool 会返回空的源码实现。多线程安全依赖memory_order_consume/release的配对add_block_group用 release 语义发布新 BlockGroupaddress_resource用 consume 语义读取保证读者线程不会看到未构造完成的结构resource_pool_inl.h。对外 API 一览ResourcePoolT通过 resource_pool.h 暴露以下自由函数均转发到类型的单例get_resourceT(ResourceIdT* id, args...)申请一个 T 对象把偏移量写入id返回对象指针。无参时 T 必须可默认构造。return_resourceT(ResourceIdT id)归还对象对象不析构之后可能被复用。返回 0 成功、-1 失败。address_resourceT(ResourceIdT id)偏移量转指针未分配的越界 id 返回nullptr。clear_resourcesT()回收所有 T 类型资源仅在最后一个线程调用时真正生效线程退出时会自动调用一般无需手动。describe_resourcesT()输出池的统计信息可能较慢不宜频繁调用包括local_pool_num、block_group_num、block_num、item_num、total_size等。for_each_resourceT(f)遍历所有已分配对象。每个 T 类型对应一个全局单例singleton()resource_pool_inl.h这也是等长对象独占一部分内存、无法被其他类型复用的由来。若某类型的构造函数可能失败如内部 ENOMEM可以特化ResourcePoolValidatorT校验失败的对象会被立即析构并返回nullptr。ObjectPool 直接返回指针的变种ObjectPoolT是ResourcePoolT的变种不返回偏移量而直接返回对象指针。内部结构和 ResourcePool 类似一些代码更加简单见 object_pool.h 头注释 a derivative class of ResourcePool to allocate and reuse fixed-size objects without identifiers。对于用户来说这就是一个多线程下的对象池brpc 内部也正是这么用的。其对外 API 与 ResourcePool 一一对应object_pool.hget_objectT(args...)直接返回T*无需 id。return_objectT(T* ptr)把对象放回池中不析构0 成功、-1 失败。clear_objectsT()/describe_objectsT()回收 / 描述对象池。local_pool_free_emptyT()判断当前线程本地 FreeChunk 是否为空。Block 大小、FreeChunk 大小、Validator 等模板参数与 ResourcePool 同名同默认值ObjectPoolBlockMaxSize默认 64KB、ObjectPoolBlockMaxItem与ObjectPoolFreeChunkMaxItem默认 256并可额外特化ObjectPoolWithASanPoisonT来控制 ASan 毒化行为。实例Socket::WriteRequestbrpc 在Socket::Write中把每个待写出的请求包装为WriteRequest这个对象就是用ObjectPoolWriteRequest分配的。WriteRequest定义于 socket.cpp在写请求完成或失败后ReturnSuccessfulWriteRequest/ReturnFailedWriteRequestsocket.cpp会调用return_object将其归还池中注释中明确提醒Do not access p after it is returned to ObjectPool——即归还后对象只是进入 freelist并不会被析构或清零读写必须严格发生在归还之前。实战案例生成 bthread_t用户期望通过创建 bthread 获得更高的并发度所以创建 bthread 必须很快。在目前的实现中创建一个 bthread 的平均耗时小于 200ns。如果每次都要从头创建是不可能这么快的。创建过程更像是从一个 bthread 池子中取一个实例我们又同时需要一个 id 来指代一个 bthread所以这儿正是ResourcePool的用武之地。bthread 在代码中被称作 Task其结构被称为TaskMeta定义在 task_meta.h 中task_meta.h。TaskMeta中既保存了调度所需的状态机字段status、current_waiter、current_sleep、stop等也保存了用户函数指针fn、参数arg、ContextualStack* stack与属性attr。所有的 TaskMeta 由ResourcePoolTaskMeta分配——这正是文档明确指出的核心用法。版本号 偏移量O(1) 寻址与 ABA 防护bthread 的大部分函数都需要在 O(1) 时间内通过bthread_t访问到TaskMeta并且当bthread_t失效后访问应返回 NULL 以让函数做出返回错误。解决方法是bthread_t 由 32 位的版本和 32 位的偏移量组成。版本解决 ABA 问题偏移量由ResourcePoolTaskMeta分配。查找时先通过偏移量获得TaskMeta再检查版本如果版本不匹配说明 bthread 失效了。上述 id 布局正是前文配图resource_pool.png所描绘的结构ResourcePool的偏移量 2^32恰好填入 64 位 bthread id 的低 32 位高 32 位留给版本号。注意这只是大概的说法在多线程环境下即使版本相等bthread 仍可能随时失效在不同的 bthread 函数中处理方法都是不同的有些函数会加锁有些则能忍受版本不相等。从 task_meta.h 的实现细节看版本号被存于TaskMeta::version_butex一个通过butex_create_checkeduint32_t()创建的 uint32初始值为 1task_meta.h并配有一把version_lock自旋锁来保证版本号读写的可见性。TaskMeta构造函数只初始化Not Reset字段版本号、锁、wait 状态等其余字段fn、arg、stack、stat等由bthread_start*系列函数在取到对象后重置——这与文档中下次申请时可能会取到这种使用过的对象需要重置后才能使用的描述完全吻合池只负责内存重置是使用方的责任。同族设计SocketId 与 bthread_id_t这种版本号 偏移量的 id 生成方式在 brpc 中应用广泛。SocketId、bthread_id_t也是用类似的方法分配的在 socket.cpp 的注释中明确写着SocketId 32-bit version 32-bit slot即同样的版本槽位布局Socket::SetFailed、Socket::Status等接口均以SocketId为参数socket.cpp以便在 O(1) 内校验并取出Socket对象。bthread_id_t由 id.cpp 实现同样以版本号规避 id 复用导致的 ABA 问题。可见等长对象池 版本号句柄是贯穿 brpc 底层标识符体系bthread、Socket、id的统一模式。栈的管理按尺寸分池 mmap 保护页使用 ResourcePool 加快创建的副作用是一个 pool 中所有 bthread 的栈必须是一样大的。这似乎限制了用户的选择不过基于 brpc 的观察大部分用户并不关心栈的具体大小而只需要两种大小的栈尺寸普通但数量较少、尺寸小但数量众多。所以 brpc 用不同的 pool 管理不同大小的栈用户可以根据场景选择。三种栈属性属性栈默认大小说明BTHREAD_ATTR_NORMAL1Mserver 默认使用该属性运行用户代码BTHREAD_ATTR_SMALL32K尺寸小但数量众多适合大量轻量并发BTHREAD_ATTR_LARGE与 pthread 一致尺寸较大bthread 不会对其做 caching创建速度较慢其中BTHREAD_ATTR_NORMAL是 server 运行用户代码的默认选择。选择BTHREAD_ATTR_SMALL可以在同样的内存预算下承载远多于普通栈的 bthread 实例代价是需要更小心地控制栈上使用量如避免大数组、深递归。mmap mprotect 守卫页栈使用mmap分配bthread 还会用mprotect分配 4K 的 guard page 以检测栈溢出。这带来一个运维层面的注意事项由于 mmap mprotect 不能超过max_map_count默认为 65536当 bthread 非常多后可能要调整此参数。即当 bthread 数量规模极大时可能需要通过sysctl vm.max_map_count调高系统限制。另外当有很多 bthread 时内存问题可能不仅仅是栈也包括各类用户和系统 buffer——排查时不应只盯着栈尺寸。与 goroutine 的对比文档还从设计取舍角度对比了 Go 的 goroutinegoroutine 在 1.3 之前通过 segmented stacks分割栈动态地调整栈大小但发现 hot split热点分割问题后换成了变长连续栈类似于 vector resizing这种方案只适合内存托管的语言。而 bthread 基本只会在 64 位平台上使用虚存空间庞大对变长栈需求不明确加上 segmented stacks 对性能有影响bthread 暂时没有变长栈的计划——固定尺寸按池管理是刻意的工程取舍而非能力缺失。使用建议与注意事项综合原文档与源码实现总结几条可落地的经验不要在生产代码中直接使用ResourcePoolT/ObjectPoolT。它们是 brpc 内部组件的基石属于框架实现细节滥用会制造大量彼此隔离的内存池得不偿失。若确需自行封装等长对象池请牢记语义差异get_resource/return_resource只做内存周转不构造、不析构复用对象前必须由使用方重置归还已归还/未申请的 id 属于未定义行为。bthread_t/SocketId的版本号 偏移量布局是理解 brpc 标识符体系的关键O(1) 寻址 版本校验但多线程下版本相等仍不代表对象永不过期接口层面的加锁/容错策略各不相同。bthread 数量极大时留意vm.max_map_count默认 65536对 mmap 栈数量上限的影响并综合评估用户态与系统 buffer 的整体内存占用。栈尺寸按需选择默认 server 用BTHREAD_ATTR_NORMAL1M追求大量轻量并发可选BTHREAD_ATTR_SMALL32K需要 pthread 级大栈用BTHREAD_ATTR_LARGE但要接受其不做 caching、创建较慢的代价。小结brpc 的内存管理围绕等长对象这一关键属性展开ResourcePoolT以偏移量 三级 Block 结构 thread-local 快路径实现了低竞争、低浪费、O(1) 寻址的对象分配ObjectPoolT则去掉句柄直接返回指针二者共同支撑起bthread_t、SocketId、bthread_id_t等高频标识符的快速生成而 bthread 栈则按尺寸分池、以 mmap mprotect 守卫页保障安全。理解这套设计的动机docs/cn/memory_management.md与实现细节resource_pool_inl.h、task_meta.h、socket.cpp有助于你在面对高并发、低长尾延迟的 C 服务时做出正确的取舍。【免费下载链接】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),仅供参考