ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发内存池三层架构详解

高并发内存池三层架构详解 目录源码高并发内存池_仿tcmalloc: 仿tcmalloc的小项目代码量在1千行左右。该项目缺少线程退出后的内存回收机制还需要配合回调函数使用https://gitee.com/han-ses-first-stick/high-concurrency-memory-poolhttps://gitee.com/han-ses-first-stick/high-concurrency-memory-pool高并发内存池三层架构详解一、三层架构总览二、哈希桶对齐规则三、为什么设计成 Span 模式第一便于空闲内存的回收第二减少锁的粒度实现均衡调度第三利于释放的分散内存合并总结四、Central Cache 锁粒度设计解析五、Page Cache 设计解析六、Thread Cache 上限设计解析七、定长内存池与 new/delete 对比八、性能测试与优化源码高并发内存池_仿tcmalloc: 仿tcmalloc的小项目代码量在1千行左右。该项目缺少线程退出后的内存回收机制还需要配合回调函数使用https://gitee.com/han-ses-first-stick/high-concurrency-memory-poolhttps://gitee.com/han-ses-first-stick/high-concurrency-memory-poolhttps://gitee.com/han-ses-first-stick/high-concurrency-memory-pool摘要本文深入解析仿 tcmalloc 的高并发内存池三层架构设计。文章从 Thread Cache、Central Cache、Page Cache 三层结构入手详细讲解哈希桶对齐规则、Span 模式的设计动机、Central Cache 的桶锁粒度选择、Page Cache 的慢启动算法与整体锁设计以及 Thread Cache 上限的权衡方案。同时对比定长内存池与 new/delete 的差异并通过性能测试展示使用基数树优化哈希表后申请定长内存耗时从 1704ms 降至 819ms验证了优化思路的正确性。高并发内存池三层架构详解1. Thread Cache 256KB 的内存2. Central Cache 管理回收 Thread Cache 并分配内存需要加锁存在多线程竞争内存用的桶锁为了减少锁竞争这里反而采用了增加锁的粒度的方式。3. Page Cache 管理回收 Central Cache 并分配内存一、三层架构总览Thread Cache线程私有高速缓存无锁分配控制内碎片Central Cache全局共享Span 管理页与小对象映射跨线程调度、负载均衡、对象生命周期管理PageCache页级大块内存与 OS 交互处理页回收合并减少系统调用保障缓存局部性分层管理1-3递进Thread Cache 是一个哈希桶结构每一个桶存定长的内存块链表最大时 256KB这也是上面说 Thread Cache 只能申请 256KB 的原因。桶结构比如8byte16byte32byte......256KB每一个内存块的前部分存下一个内存块的地址。二、哈希桶对齐规则central cache也是一个哈希桶结构(映射规则也一样)但是也有不同如下为什么映射规则也是一样的是因为central cache是中央内存池thread cache类似一个一个的自由链表内存还是去central cache中所以拿多少怎么拿规则和thread cache一致就节省了很多代码复杂度。central cache中存的是k字节的链表对应的一个一个的span(连续的大块内存空间可能跨几页)需要k字节就去k字节的value的自由链表中切一个k字节就行了。span是我们自定义的结构体如下三、为什么设计成 Span 模式第一便于空闲内存的回收我们的central cache需要做内存的回收管理这里需要一个前提知识点free(ptr)其中ptr只能是malloc申请出来的起始空间地址不能是其中的偏移量因为libstdc的底层的回收逻辑就是这样的所以我们的central cache为什么要以span为单位就是这个逻辑thread cache来central cache来切内存了把span申请的空间切成了一个一个的小块我们只需要记录切出去的块个数及_usecount当我们回收的时候只需要看_usecount是否为0即可为0我们就还到上一层page cache中自始至终我们的span都是完整的连续空间。第二减少锁的粒度实现均衡调度如果是一个一个的自由链表那么锁的粒度是加在链表上的多个线程同时竞争时就会卡住降低的并发量但是我们使用span,我们的锁粒度就可以加在span上这就相当于一个分片锁了一个锁上的等待时间总体就减小了并发量就上来了。第三利于释放的分散内存合并总结操作系统以页作为内存管理单位连续页空间局部性好CPU 缓存命中率更高。我们从 OS 拿到整页大块内存但不能直接交付用户需要切分成小对象。为提升并发使用 TLS 实现线程独立的 Thread Cache内部按 size 维护哈希自由链表降低内碎片。Thread Cache 只作为本地缓冲区从 Central Cache 批量拿已经切好的对象分配优先走本地实现无锁。Central Cache 是全局中转站哈希桶下挂载多个 SpanSpan 维护页元信息与对象引用计数多 Span 拆分锁粒度实现负载均衡支持内存跨线程流转。当 Span 所有对象归还就将整块页还给 PageCache控制外碎片。PageCache 负责向 OS 申请、回收、合并页面减少系统调用开销。四、Central Cache 锁粒度设计解析1.central cache中的锁为什么不加到Span上而是加到SpanLists上加的是桶锁解答这里考虑的就是锁的数量和代码开发和维护成本的问题如果加到Span上一个SpanLists中的一个Lists链表中要管理n个Span链锁的数量就上来了加在桶上只需要数组大小个锁(我的项目中是定义的208个)细分到Span上就不一样了每一个桶中又有很多个Span乘起来锁有成千上万个了虽然可以增加并发数但是代码量就上来了变得更加复杂代码维护成本也变高了并且在并发时多个线程等待锁拿锁切换线程的开销也变大了。五、Page Cache 设计解析在去Page Cache中拿内存的时候会用到一个类似慢启动的算法在慢开始算法这里如果有频繁申请就代表需求高那我下一次多取一倍直到我自设的上限。这个上限大字节为2小字节为512page cache也是哈希桶映射但是这里有区别central cache和thread cache的映射关系是一致的但是page cache不一样是1-128的递增数字单位是页。并且哈希桶中的自由链表是不会切的我直接给central cache根据你需要的页数来算。其中这里只会去申请128页的固定内存有两个好处第一减少内存外碎片。如果每一页都去申请内存内存申请的大小间隔是不固定的那么前面多个分散的小内存回收了我想申请一块大内存就申请不了了。第二方便内存的回收管理关于page cache的为什么是整体锁而不是桶锁问题第一如果是桶锁那么在分页的时候多个线程就会竞争多把锁线程被唤醒和等待的次数变多线程切换开销变大性能大大降低了第二桶锁还可能导致死锁问题。线程 A 需要 2 页内存B线程需要8页内存A去拿到10页锁去拆成8和2页然后去申请8页锁B拿到了8页锁想去申请10页锁此时线程 A 和线程 B 就会各自持有锁并互相等待对方的锁从而导致死锁问题。在物理内存回收和切分这里都是逻辑切只告诉你起始位置和内存块大小回收的时候根据%8k的余数来确定是哪一页的。在page Cache合并的时候不能使用usecount0这是线程不安全的。为什么能主要是usecount等于0不等于span空闲可合并只能说明没有上层对象引用这个spanusecount并不是用来说明page Cache是否可以合并的充分条件只是来说明central cache中的span分出去的空间是否归还了所以我们就拿一个_isUse来判断是否在使用方便我们在page Cache中做合并判断。在申请内存这里申请256K时就找线程内存池当大于256k小于128*8k时找page cache大于128*8k时找系统堆要。六、Thread Cache 上限设计解析为什么不增加thread cache的上限呢而是向page cache或者堆要呢。解答ThreadCache 设置上限是一个权衡如果把 ThreadCache 上限设置很大每个线程都会预留大量空闲内存多线程场景下内存占用会爆炸内存利用率低如果上限很小频繁向 CentralCache 请求锁竞争变多性能下降。所以采用折中方案给 ThreadCache 设置一个不大的上限。 线程本地内存够用就本地分配本地内存达到上限或者空闲过多时就归还对象给 CentralCacheCentralCache 没有内存再去 PageCachePageCache 没有才向 OS 堆申请。 平衡并发性能和内存利用率。七、定长内存池与 new/delete 对比在我们的内存池中不使用new和delete而是使用我们的定长内存池我们直接使用new和delete是不是更好呢直接将内存还给OS定长内存池满了怎么办呢解答第一个就是脱离原生的malloc和free因为原生malloc自己也会维护一个CRT堆而且很重每一层都会加锁第二就是我们自己设置的定长内存池是无锁的而且是O(1)的八、性能测试与优化使用vs下的性能分析工具发现哈希这里调用次数比较多我们可以对哈希表进行优化使用基数树因为哈希表底层是加了锁的所以我们可以用基数树使用无锁且没有哈希冲突做到O(1)的访问如下申请变长内存的时候对比两者情况如下申请定长内存的时候对比两者情况如下为什么unordered_map会拖慢这么多呢主要是在哈希表底层是加了锁的所以高并发的时候就会造成锁竞争所以下面的优化我们就是直接使用基数树进行一个无锁操作逻辑上的无锁最后再使用的时候一般会打成静态库或者动态库在Linux下我们代码中可以照常写malloc和frer在gcc编译时带上tcmalloc就可以实现替换windows上就使用一个hook(钩子)的操作进行替换。最终使用基数树优化后的申请定长内存的跑分数据如下可以看到我们的内存池从原来的1704ms跑到了819ms说明我们的优化思路是正确的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进