高并发内存池Central Cache:设计原理、锁优化与工程实践
1. 项目概述:Central Cache在高并发内存池中的核心定位
在上一篇文章中,我们详细拆解了Thread Cache的设计,它作为线程专属的内存缓存,通过TLS机制实现了线程无锁访问,是应对高并发场景下频繁小内存分配的第一道防线。然而,Thread Cache并非孤立存在,它需要一个“后勤补给中心”来维持自身的平衡与稳定。这个补给中心,就是我们今天要深入探讨的Central Cache。
你可以把Central Cache想象成一个“内存批发市场”。每个线程的Thread Cache是“零售小店”,当小店(Thread Cache)的某种规格内存块(Span)库存不足时,它不会直接去向系统(Page Heap)进货,因为系统调用(如malloc或brk)的成本太高,尤其是在高并发下,频繁的系统调用会成为性能瓶颈。相反,它会去“批发市场”(Central Cache)批量采购。反过来,当小店的某种内存块库存过多,占用资金(内存)时,它也可以将多余的库存退回给批发市场,由市场重新调配给其他缺货的小店。Central Cache的核心价值,就在于集中管理从Page Heap申请来的大块内存(Span),并将其切割成统一规格的小块,按需分配给各个Thread Cache,同时回收Thread Cache返还的冗余内存,实现跨线程的内存平衡与复用。
它的设计目标非常明确:作为Thread Cache和Page Heap之间的中间层,减少线程向系统直接申请内存的次数,同时避免某个线程独占过多内存而其他线程饥饿的问题。为了实现这个目标,Central Cache面临几个关键挑战:首先,它必须是一个全局唯一的单例,所有线程共享。其次,由于会被多个线程并发访问,锁的粒度控制至关重要,锁得太粗会阻塞所有线程,锁得太细又可能过于复杂。最后,它需要高效地管理不同大小规格的内存块链表,并处理与Thread Cache之间的“申请”与“回收”交互协议。
2. Central Cache的整体架构与设计思路拆解
Central Cache的架构设计,核心是围绕“规格化存储”和“锁粒度细化”两个原则展开的。这与Thread Cache的“线程局部”思路形成了鲜明对比。
2.1 核心数据结构:Span与SpanList的再进化
在Thread Cache中,我们管理的是一个个独立的内存块(FreeList)。但在Central Cache层面,我们管理的单元升级了,变成了Span。一个Span代表从Page Heap申请来的一大块连续内存,其大小是页(Page,例如4KB或8KB)的整数倍。Central Cache并不直接持有零散的内存块,而是持有这些Span,并根据Span内部的内存块使用情况来组织它们。
因此,Central Cache的核心数据结构是一个哈希桶,与Thread Cache类似,桶的个数对应不同的内存大小规格(例如8B, 16B, ..., 256KB)。但每个桶里挂的不是FreeList,而是SpanList——一个双向链表,链表中的每个节点都是一个Span对象。
// 简化版Span结构定义(在Central Cache视角) struct Span { PAGE_ID _pageId; // 起始页号,用于后续合并等操作 size_t _n; // 这个Span有多少页 Span* _next; Span* _prev; void* _freeList; // 指向Span内切分好的内存块自由链表(链表头) size_t _useCount; // 已被分配给Thread Cache的内存块数量 size_t _objSize; // 该Span切分出的每个内存块的大小 }; class CentralCache { private: SpanList _spanLists[NFREELIST]; // 哈希桶,每个桶是一个SpanList static CentralCache _sInst; // 单例对象 // ... 其他成员 };这里的关键在于Span::_freeList和_useCount。当一个Span刚从Page Heap申请来时,Central Cache会将其按对应的对象大小(_objSize)进行切分,形成一个由_freeList指向的内存块链表。_useCount则记录了这个Span中有多少块已经被分配出去(给了Thread Cache)。当_useCount == 0时,表示整个Span的所有内存块都已归还,这个Span就可以被释放回Page Heap。
2.2 锁的设计:桶锁代替全局锁
Central Cache是全局的,必然面临并发问题。最粗暴的方法是给整个Central Cache加一把大锁(全局锁),但这样所有线程在申请/归还内存时都要排队,并发性能会急剧下降。
高效的设计是采用桶锁(Bucket Lock)。即为哈希桶数组中的每一个桶(SpanList)配备一把独立的锁(例如std::mutex)。当Thread Cache需要申请或归还特定大小的内存时,它只需要锁住对应的那个桶即可。这样,不同大小规格的内存操作可以完全并行,只有操作同一规格内存的线程之间才需要竞争。这极大地提升了并发度。
class SpanList { public: // ... 链表操作接口 private: Span* _head; std::mutex _mtx; // 每个SpanList都有自己的锁 };注意:在实际实现中,选择锁的类型很重要。
std::mutex是操作系统级别的互斥锁,适用于一般竞争。在极端高并发场景下,也可以考虑使用更轻量的自旋锁(std::atomic_flag),但需谨慎评估CPU空转的代价。我们的设计先以std::mutex为基础。
2.3 与Thread Cache的交互协议
Central Cache与Thread Cache的交互是双向的,且遵循特定的“批量”协议,这是平衡性能与内存利用率的关键。
Thread Cache申请内存(FetchFromCentralCache):
- Thread Cache的某个自由链表为空或不足时,会向Central Cache发起申请。
- 它不会一次只申请一个块,那样效率太低。而是有一个慢启动的批量申请逻辑:初始申请少量(如1个),随着该规格内存需求持续旺盛,下次申请的数量会逐步增加(例如2,4,...),直至一个上限(如512个)。这既避免了初次申请就占用过多内存,又能适应高频分配需求。
- Thread Cache调用
CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size),传入期望的批量数量batchNum和内存块大小size。 - Central Cache在对应的桶中寻找一个非空的Span,从其
_freeList中取出至多batchNum个内存块,更新Span的_useCount,然后将这批内存块的首尾指针(start,end)返回给Thread Cache。 - 如果Central Cache中该规格的所有Span都为空(即没有可分配的内存块),则Central Cache会向Page Heap申请一个新的Span(一大块内存),将其切分后挂入对应桶的SpanList,然后再从中分配。
Thread Cache归还内存(ReleaseListToSpans):
- 当Thread Cache的某个自由链表过长(超过某个阈值,例如一次批量申请的数量),为了不浪费内存,它会将一部分内存块归还给Central Cache。
- Thread Cache将一串内存块链表(
start,end)和其大小size传给CentralCache::ReleaseListToSpans(void* start, size_t size)。 - Central Cache需要根据每个内存块的地址,找到它所属的Span。这是一个关键操作,通常需要通过页号映射来实现(后续与Page Heap联动时会详述)。找到对应Span后,将内存块头插回该Span的
_freeList,并递减_useCount。 - 如果某个Span的
_useCount减为0,说明这个Span的所有内存块都已归还。此时,Central Cache不能立即将其释放,因为可能还有其他线程正在操作该Span链表。一个常见的优化是将其移动到另一个“待释放”列表,或直接释放回Page Heap(需要桶锁保护)。
3. Central Cache核心功能实现细节解析
理解了架构,我们深入到代码层面,看看几个最核心的函数是如何实现的,以及其中隐藏的“坑”。
3.1 从Central Cache获取内存块(FetchRangeObj)
这是Thread Cache“进货”的入口。其核心逻辑是:找到对应大小的桶,锁住,遍历桶内的Span链表,找到一个有剩余内存块(_freeList不为空)的Span,然后进行切割分配。
// 从Central Cache获取一段范围的内存块给Thread Cache // start和end是输出参数,用于返回获取到的内存块链表的起始和结束 // batchNum是期望获取的个数,size是每个内存块的大小 size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size) { // 1. 根据size找到对应的哈希桶下标 size_t index = SizeClass::Index(size); // 2. 锁住这个桶 _spanLists[index]._mtx.lock(); Span* span = GetOneSpan(_spanLists[index], size); if (span == nullptr) { _spanLists[index]._mtx.unlock(); return 0; // 理论上GetOneSpan会保证有Span,这里防御性判断 } // 3. 从选中的Span中批量获取内存块 void* prev = nullptr; void* cur = span->_freeList; size_t actualNum = 0; for (; actualNum < batchNum && cur != nullptr; ++actualNum) { prev = cur; cur = NextObj(cur); // NextObj是一个宏或内联函数,通过内存头部的指针找到下一个块 } // 4. 调整Span的自由链表和已用计数 start = span->_freeList; // 这批块的起点就是原自由链表头 end = prev; // 这批块的终点是prev span->_freeList = cur; // Span的新链表头是cur(可能为空) span->_useCount += actualNum; // 分配出去多少块,计数就增加多少 // 5. 将获取到的这批内存块从链表中“断开” // 即让end->next = nullptr if (end != nullptr) { NextObj(end) = nullptr; } // 6. 解锁并返回实际获取的个数 _spanLists[index]._mtx.unlock(); return actualNum; }关键点与避坑指南:
- 锁的范围:锁必须在找到并操作完Span之后才能释放。如果在
GetOneSpan返回后就解锁,其他线程可能同时修改这个Span的_freeList,导致数据竞争。 GetOneSpan函数:这是FetchRangeObj的核心依赖。它的职责是保证返回一个至少有一个空闲块的Span。如果当前桶里所有Span的_freeList都为空,它需要向Page Heap申请新Span。这个函数内部也持有桶锁,因此需要小心递归锁或锁粒度调整。- 实际获取数量:循环条件
actualNum < batchNum && cur != nullptr。batchNum是期望值,但可能Span里剩余块数不足。因此必须用actualNum记录实际获取的数量,并据此更新span->_useCount。Thread Cache需要根据这个返回值来调整自己的自由链表。
3.2 为桶获取一个可用的Span(GetOneSpan)
这个函数是Central Cache内存供给的保障。它遍历指定桶的SpanList,寻找第一个非空的Span。如果找不到,就向Page Heap“进货”。
// 获取一个非空的Span,如果桶里没有,就向PageHeap申请 Span* CentralCache::GetOneSpan(SpanList& list, size_t size) { // 1. 先查看当前桶里是否有非空的Span Span* it = list.Begin(); while (it != list.End()) { if (it->_freeList != nullptr) { return it; } it = it->_next; } // 2. 走到这里,说明当前桶里所有Span都空了。需要解锁,然后向PageHeap申请。 // 注意:这里必须先解锁!因为PageHeap::NewSpan()可能涉及系统调用或操作其他全局结构,耗时较长。 // 持有当前桶锁去申请,会阻塞所有同规格内存的申请线程,性能极差。 list._mtx.unlock(); // 3. 向PageHeap申请一个大的Span。申请多少页?由SizeClass决定。 size_t npage = SizeClass::NumMovePage(size); Span* newSpan = PageHeap::GetInstance()->NewSpan(npage); if (newSpan == nullptr) { // 申请失败,通常意味着系统内存不足。这里可以重新加锁并返回nullptr,或者抛异常。 list._mtx.lock(); return nullptr; } // 4. 将申请到的大块内存(newSpan)切分成size大小的小块,并连接成自由链表 // 计算起始地址和结束地址 char* start = (char*)(newSpan->_pageId << PAGE_SHIFT); // 页号转地址 char* end = start + (npage << PAGE_SHIFT); // 起始地址 + 总字节数 // 先切成一块块 void* cur = nullptr; void* prev = nullptr; // 遍历切分,采用头插法构建链表(头插法更简单高效) for (char* obj = start; obj + size <= end; obj += size) { prev = cur; cur = obj; NextObj(cur) = prev; // 将当前块的next指向上一块 } newSpan->_freeList = cur; // 链表头是最后切分的那块 newSpan->_useCount = 0; // 初始时,一块都还没分配出去 newSpan->_objSize = size; // 记录这个Span切分的块大小 // 5. 将切分好的newSpan插入到桶的SpanList中。注意,这里需要重新加锁! list._mtx.lock(); list.PushFront(newSpan); // 6. 返回这个新的Span(此时它的_freeList非空) return newSpan; }这是整个Central Cache最易出错的地方之一:
- 解锁的时机:在遍历完桶发现没有可用Span后,必须先解锁
list._mtx,再去调用PageHeap::NewSpan。因为NewSpan可能很慢(涉及系统调用或复杂逻辑),长时间持有桶锁是灾难性的。这体现了锁粒度控制的重要性。 - 重新加锁的时机:从PageHeap拿到新的Span并完成切分后,在将其插入桶的链表之前,必须重新加上桶锁。因为插入操作修改了共享的链表结构,必须受锁保护。
- 链表构建:切分内存构建自由链表时,头插法是最简单的。注意计算好每个块的起始地址,并正确设置每个内存块头部的
next指针(通常是在每个内存块起始处存储一个void*)。 - Span信息记录:务必正确设置
_freeList、_useCount和_objSize。_useCount从0开始,因为此时还没有块被分配出去。
3.3 将内存块链表归还给Central Cache(ReleaseListToSpans)
这是Thread Cache“退货”的入口。其核心挑战是:给定一串内存块链表和它们的大小,如何高效地将每个块归还到其所属的Span中?
// 将Thread Cache归还的一串内存块链表,释放回Central Cache对应的Span中 void CentralCache::ReleaseListToSpans(void* start, size_t size) { // 1. 根据size找到对应的桶 size_t index = SizeClass::Index(size); SpanList& list = _spanLists[index]; // 2. 遍历归还的链表,将每个块插入对应Span的自由链表 void* cur = start; while (cur) { void* next = NextObj(cur); // 先保存下一个块,因为插入后cur的next会被修改 // 3. 关键步骤:根据内存块地址cur,找到它属于哪个Span Span* span = PageHeap::GetInstance()->MapObjectToSpan(cur); assert(span != nullptr); // 理论上应该总能找到 // 4. 将当前块头插到span的自由链表中 // 注意:此操作需要桶锁保护,因为多个线程可能同时归还内存到同一个桶(甚至同一个Span) list._mtx.lock(); NextObj(cur) = span->_freeList; span->_freeList = cur; span->_useCount--; // 归还一块,计数减一 // 5. 检查:如果span的_useCount减为0,说明所有块都已归还。 // 此时可以将整个Span释放回PageHeap,避免Central Cache占用过多空闲内存。 if (span->_useCount == 0) { // 先将该Span从桶的链表中摘除 list.Erase(span); // 解锁!因为ReleaseSpanToPageHeap可能涉及其他锁或耗时操作。 list._mtx.unlock(); // 释放Span回PageHeap PageHeap::GetInstance()->ReleaseSpanToPageHeap(span); // 注意:这里不需要重新加锁,因为当前Span已经不属于这个桶了。 } else { list._mtx.unlock(); } cur = next; // 处理下一个块 } }实现难点与优化点:
- 地址到Span的映射(MapObjectToSpan):这是整个内存池的基石之一。给定一个任意内存块的地址,如何快速找到它所属的Span?通常的解决方案是页映射表。在Page Heap申请Span时,会记录这个Span覆盖了哪些页(
_pageId到_pageId+_n)。我们可以建立一个全局的数组std::vector<Span*>或Span* [],数组下标是页号(PAGE_ID),数组元素是该页所属的Span指针。这样,对于任何内存地址,右移PAGE_SHIFT位得到页号,再用页号作为下标去查表,就能立刻找到其所属的Span。这个表由Page Heap负责维护。 - 锁的粒度:注意,我们在循环内对每个块处理时都进行了加锁和解锁。这是因为
span->_useCount--和修改_freeList是临界区。如果我们在循环外加锁,就会长时间持有锁,阻塞其他线程。在循环内加锁,锁的粒度更细,并发度更高。但这也带来了频繁加解锁的开销。一种折衷是,可以先遍历链表,将属于同一个Span的块分组,然后以Span为单位进行加锁和批量插入,减少锁操作次数。 - Span的释放时机:当
_useCount == 0时,意味着这个Span完全空闲。此时将其释放回PageHeap是合理的,可以防止Central Cache囤积过多空闲内存。释放前需要将其从桶链表中移除(Erase)。特别注意:在调用ReleaseSpanToPageHeap之前,必须先解锁桶锁,理由同GetOneSpan中调用NewSpan一样。 - 断言的使用:
assert(span != nullptr)是一个强有力的调试保障。如果这里找到空指针,说明地址映射出现了严重错误(如内存越界、重复释放等),应立即终止程序以便排查。
4. 性能关键:锁竞争优化与无锁化探索
Central Cache作为共享资源,锁竞争是其性能瓶颈的主要来源。我们采用了桶锁,这已经比全局锁好很多。但还有进一步优化的空间。
4.1 细粒度锁的代价与收益
我们当前的实现是“每个操作(获取/归还一个块)都可能加解锁一次”。在极端高并发下,这仍然可能成为热点。性能测试时,可以用perf工具观察_mtx.lock()的CPU周期占比。
优化思路一:批量操作合并锁在ReleaseListToSpans中,我们可以先不加锁遍历一次归还链表,用一个std::unordered_map<Span*, std::vector<void*>>将块按Span分组。然后遍历这个map,对每个Span,加锁,一次性将其所有块头插到该Span的自由链表中,并更新_useCount。这样,锁的次数从O(N)(N为块数)降到了O(M)(M为涉及的Span数)。通常M远小于N。
优化思路二:使用更轻量的锁std::mutex是通用互斥锁。对于临界区极短(只是修改几个指针和计数器)的场景,可以考虑使用自旋锁(std::atomic_flag)。自旋锁在获取不到锁时会忙等待(循环检查),避免了线程切换的开销,但会浪费CPU。适用于锁持有时间非常短(纳秒/微秒级)且竞争不特别激烈的场景。实现时需要仔细测试。
class SpinLock { std::atomic_flag flag = ATOMIC_FLAG_INIT; public: void lock() { while (flag.test_and_set(std::memory_order_acquire)); } void unlock() { flag.clear(std::memory_order_release); } }; // 然后将SpanList中的std::mutex替换为SpinLock。4.2 无锁链表的可能性(高级话题)
对于追求极致性能的场景,可以探索无锁(Lock-Free)数据结构。例如,实现一个无锁的SpanList或无锁的每个Span内部的_freeList。这通常使用std::atomic和compare_exchange_weak/strong(CAS)操作来实现。
例如,一个无锁栈(可用于实现自由链表)的push操作可能像这样:
void push(Node* new_node) { new_node->next = head.load(std::memory_order_relaxed); while (!head.compare_exchange_weak(new_node->next, new_node, std::memory_order_release, std::memory_order_relaxed)); }但是,无锁编程极其复杂,需要处理ABA问题、内存序(memory order)等,容易引入难以调试的bug。对于大多数项目,经过良好优化的细粒度互斥锁(桶锁+自旋锁)已经能提供非常出色的性能,并且代码可读性和可维护性远高于无锁实现。除非性能分析明确表明锁竞争是主要瓶颈,否则不建议轻易引入无锁编程。
5. 与Page Heap的联动及边界条件处理
Central Cache并不直接与系统内存打交道,它通过Page Heap这个“仓库管理员”来申请和释放大块内存(Span)。因此,两者的接口设计和协作至关重要。
5.1 申请Span(NewSpan)的协作
当Central Cache的某个桶没有可用Span时,GetOneSpan会调用PageHeap::NewSpan(npage)。npage是需要的页数,由SizeClass::NumMovePage(size)计算得出。这个计算需要权衡:一次申请太多页,可能造成浪费;申请太少,又会频繁调用Page Heap。
一个常见的策略是,根据要切分的内存块大小size,计算出一个能切分出大约batchNumMax(例如128或512)个块的页数,并向上对齐到页的整数倍。这样一次申请就能满足Thread Cache很多次的批量请求。
5.2 释放Span(ReleaseSpanToPageHeap)的协作
当Central Cache发现一个Span完全空闲(_useCount == 0)时,就调用PageHeap::ReleaseSpanToPageHeap(span)将其归还。Page Heap收到这个Span后,会尝试与它前后相邻的空闲Span进行合并,形成更大的空闲Span,以减少内存碎片。
这里有一个关键点:Central Cache在释放Span前,必须确保没有线程再持有指向该Span内任何内存块的指针。在我们的设计中,这是由_useCount计数器保证的。当_useCount为0时,所有从该Span分配出去的内存块都已归还。但这里存在一个非常隐蔽的并发问题:
考虑以下时序:
- 线程A执行
ReleaseListToSpans,发现某个Span的_useCount从1减为0,准备释放。 - 在线程A调用
list.Erase(span)之后,list._mtx.unlock()之前,线程B正在遍历这个桶的链表(例如在GetOneSpan中),它可能刚好看过这个Span,但还没读取其_freeList。 - 线程A解锁,然后调用
ReleaseSpanToPageHeap,Page Heap可能会立即将这个Span合并甚至归还给操作系统。 - 线程B随后尝试访问这个Span的
_freeList,就会导致访问已释放内存,程序崩溃。
解决方案:确保_useCount的检查和归零操作、Span从链表中的移除、以及后续释放操作,在一个连续的、受锁保护的临界区内完成,并且在这个临界区内,其他线程无法访问到这个Span。在我们的代码中,list.Erase(span)将其从链表移除后,其他线程通过链表遍历就找不到它了,即使锁暂时释放,它们也无法获得其指针。但更安全的做法是,将ReleaseSpanToPageHeap的调用也放在桶锁的保护下,或者使用引用计数等更安全的内存回收机制。对于学习项目,在锁内释放是更简单安全的选择,但要注意ReleaseSpanToPageHeap内部不能再去尝试获取同一个桶锁,否则会导致死锁。
5.3 内存碎片与合并策略的间接影响
Central Cache虽然不直接处理碎片,但其行为会影响碎片。如果Central Cache过快地将空闲Span释放回Page Heap,可能导致Thread Cache频繁地重新申请,增加系统调用。如果持有过久,又会导致内存利用率下降。因此,可以引入一个简单的缓存策略:即使_useCount == 0,也不立即释放,而是将其标记为空闲,保留在Central Cache的链表中一段时间(或直到需要内存时再释放)。这相当于在Central Cache层面做了一个小型的空闲Span缓存。
6. 测试、调试与性能分析实战
实现完Central Cache后,必须进行 rigorous 的测试。
6.1 单元测试设计
单线程基础功能测试:
- 测试
FetchRangeObj:申请不同大小的内存,检查返回的链表是否正确,Span的_useCount是否更新。 - 测试
ReleaseListToSpans:构造一个内存块链表归还,检查是否正确地插回了对应Span,_useCount是否减少,Span释放逻辑是否正确触发。 - 测试
GetOneSpan:模拟桶为空的情况,验证其能正确从Page Heap申请并切分新Span。
- 测试
多线程并发正确性测试:
- 压力测试:创建多个线程,每个线程随机进行大量次数的申请和释放(大小随机或固定)。运行一段时间后,检查:
- 是否有内存泄漏(最终所有内存应能全部归还)。
- 是否有双倍释放(通过地址映射表断言检查)。
- 程序是否崩溃(数据竞争导致)。
- 特定场景测试:
- 一个线程不断申请,另一个线程不断归还同一规格内存。
- 多个线程同时申请和归还不同规格的内存,测试桶锁的隔离性。
- 工具辅助:使用
ThreadSanitizer (TSan)来检测数据竞争。在GCC/Clang编译时添加-fsanitize=thread选项。
- 压力测试:创建多个线程,每个线程随机进行大量次数的申请和释放(大小随机或固定)。运行一段时间后,检查:
6.2 性能分析(Profiling)
使用性能分析工具(如gperftools的 CPU Profiler,或perf)来观察:
- 热点函数:时间主要消耗在
FetchRangeObj、ReleaseListToSpans还是锁操作上? - 锁竞争:
_mtx.lock()的等待时间是否很长?如果是,说明该桶是热点,可能需要进一步细分锁粒度(但规格已经是最细了),或者考虑无锁优化。 - 系统调用:
PageHeap::NewSpan被调用的频率是否过高?这反映了Central Cache的缓存效果。可以通过调整SizeClass::NumMovePage的逻辑来优化。
6.3 常见问题排查实录
程序随机崩溃,尤其在多线程环境下:
- 首先怀疑数据竞争:检查所有对
SpanList、Span成员(尤其是_freeList,_useCount,_next,_prev)的访问是否都在锁的保护之下。使用ThreadSanitizer验证。 - 检查地址映射表:在
MapObjectToSpan中增加断言和日志,确保任何归还的内存地址都能找到有效的Span。找不到通常意味着内存越界或重复释放了不属于内存池的内存。 - 检查链表操作:在
Erase、PushFront等链表操作前后,检查链表节点的_next和_prev指针是否被意外修改。
- 首先怀疑数据竞争:检查所有对
内存泄漏:
- 在程序结束时,遍历Central Cache所有桶的所有Span,检查是否还有
_useCount > 0的Span。如果有,说明有内存块没有归还。 - 更全面的方法是,实现一个
CentralCache::PrintLeaks()函数,打印出所有未释放的Span信息(页号、大小、未归还块数)。
- 在程序结束时,遍历Central Cache所有桶的所有Span,检查是否还有
性能不如预期:
- 锁竞争:使用
perf查看锁的contention。如果某个桶的锁竞争激烈,可以考虑是否该规格的内存分配过于频繁,是否需要调整Thread Cache的慢启动批量数量,或者引入更高效的锁。 - Span频繁申请/释放:如果
PageHeap::NewSpan调用频繁,说明Central Cache的缓存命中率低。可以尝试让Central Cache保留一些完全空闲的Span(延迟释放),或者调整NumMovePage让一次申请的Span更大。 - 遍历开销:在
GetOneSpan中,需要遍历SpanList寻找非空Span。如果链表很长,遍历开销大。可以考虑维护两个链表:非空Span链表和空Span链表,快速定位。
- 锁竞争:使用
Central Cache作为高并发内存池的“中枢神经”,其稳定性和性能直接决定了整个内存池的上限。它巧妙地在共享与隔离、速度与空间之间取得了平衡。理解其每一行代码背后的并发考量、数据结构设计和边界处理,是掌握高并发编程和内存管理精髓的绝佳途径。在实现过程中,多写测试,多用工具分析,遇到诡异问题时首先怀疑并发安全,这样才能构建出既快又稳的内存池中间层。
