高并发内存池(13)-CentralCache回收内存 - 教程

高并发内存池(13)-CentralCache回收内存

这段测试代码的目的是验证页号与物理地址之间的转换是否正确,但代码中存在一个逻辑问题导致最终结果总是2000。

代码功能分析

1. 页号到地址的转换

PAGE_ID id1 = 2000;
PAGE_ID id2 = 2001;
char* p1 = (char*)(id1 << PAGE_SHIFT);  // 页号转物理地址
char* p2 = (char*)(id2 << PAGE_SHIFT);

假设 PAGE_SHIFT = 13(8KB页大小):

  • p1 = 2000 × 8192 = 16384000
  • p2 = 2001 × 8192 = 16384000 + 8192 = 16392192

2. 循环遍历地址

while (p1 > PAGE_SHIFT) << endl;
p1 += 8;  // 每次增加8字节
}

为什么结果总是2000?

数学原理

关键在于这行代码:

((PAGE_ID)p1 >> PAGE_SHIFT)

让我们计算一下:

  1. 初始状态
    • p1 = 2000 × 8192 = 16384000
    • (PAGE_ID)p1 >> 13 = 16384000 ÷ 8192 = 2000
  2. 第一次迭代后
    • p1 = 16384000 + 8 = 16384008
    • (PAGE_ID)p1 >> 13 = 16384008 ÷ 8192 = 2000.0009765625
    • 整数除法截断小数部分 → 2000
  3. 直到接近边界
    • 只有当 p1增加到 16392184时:
    • 16392184 ÷ 8192 = 2000.999755859375→ 截断为2000
    • 下一个地址 16392192 ÷ 8192 = 2001.0→ 刚好等于2001
  4. 循环条件
    • while (p1 < p2),当 p1 = 16392192时不再小于 p2
    • 所以永远不会打印出页号2001

可视化过程

地址范围: [16384000, 16392192)  // 2000页对应的地址范围
p1从16384000开始,每次+8:
16384000 ÷ 8192 = 2000.000000 → 2000
16384008 ÷ 8192 = 2000.000976 → 2000
16384016 ÷ 8192 = 2000.001953 → 2000
...
16392184 ÷ 8192 = 2000.999755 → 2000
16392192 ÷ 8192 = 2001.000000 → 2001(但循环已结束)

正确的测试方法

如果要看到页号从2000变为2001,应该:

void TestAddressShift()
{
PAGE_ID id1 = 2000;
PAGE_ID id2 = 2001;
char* p1 = (char*)(id1 > PAGE_SHIFT) << endl;
p1 += 8192;  // 按页大小递增,而不是8字节
}
}

代码的实际意义

这个测试验证了内存池中的关键算法

  • 页号 → 物理地址地址 = 页号 × 页大小
  • 物理地址 → 页号页号 = 地址 ÷ 页大小

在内存回收时,CentralCache需要通过对象的地址找到对应的Span:

// 通过地址找到页号,再找到Span
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;
Span* span = _idSpanMap.get(id);

这个测试确保了地址转换的准确性,这是内存池正确工作的基础!

这段代码是中心缓存(CentralCache) 中用于将内存块释放回对应Span的关键函数。我来详细解析它的工作原理:

函数功能

ReleaseListToSpans将一个内存块链表释放回对应的Span中,并管理Span的使用计数。当Span的所有内存块都归还时,将其返回给页缓存(PageCache)。


代码逐段解析

1. 初始化并加锁

size_t index = SizeClass::Index(size);  // 根据对象大小找到对应的桶号
_spanLists[index]._mtx.lock();          // 加桶锁保证线程安全

2. 遍历内存块链表

while (start)  // 遍历整个内存块链表
{
void* next = NextObj(start);  // 保存下一个内存块的地址
// 找到该内存块所属的Span
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);

这里使用MapObjectToSpan通过内存块地址找到对应的Span。

3. 将内存块插入Span的自由链表

NextObj(start) = span->_freeList;  // 新内存块的next指向Span当前链表头
span->_freeList = start;           // Span链表头指向新内存块(头插法)
span->_useCount--;                  // Span使用计数减1

头插法可视化

Span原链表: span->_freeList → A → B → nullptr
插入start: span->_freeList → start → A → B → nullptr

4. 检查Span是否完全空闲

if (span->_useCount == 0)  // 所有内存块都已归还
{
_spanLists[index].Erase(span);  // 从中心缓存移除
span->_freeList = nullptr;      // 清空自由链表
span->_next = nullptr;          // 断开链表连接
span->_prev = nullptr;
// 这里应该将span还给PageCache,但代码中缺少这一步!
}

5. 继续处理下一个内存块

start = next;  // 移动到链表中的下一个内存块

6. 释放锁

_spanLists[index]._mtx.unlock();  // 释放桶锁

在内存回收流程中的角色

graph TD
A[ThreadCache释放内存块] --> B[调用CentralCache.ReleaseListToSpans]
B --> C[遍历内存块链表]
C --> D[找到每个内存块对应的Span]
D --> E[将内存块插入Span的自由链表]
E --> F{Span使用计数是否为0?}
F -->|是| G[从CentralCache移除]
F -->|否| H[继续处理下一个内存块]
G --> I[应该将Span还给PageCache]

关键设计思想

1. 批量处理优化

  • 一次处理整个内存块链表,而不是单个内存块
  • 减少锁竞争和函数调用开销

2. 使用计数管理

  • _useCount跟踪Span中已分配的内存块数量
  • 当计数归零时,说明Span完全空闲,可以回收

3. 地址到Span的映射

Span* span = PageCache::GetInstance()->MapObjectToSpan(start);

这是通过_idSpanMap实现的,存储了页号到Span的映射,可以快速查找。

代码中的问题

缺少重要步骤:当span->_useCount == 0时,应该将Span返回给PageCache:

if (span->_useCount == 0)
{
_spanLists[index].Erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;
// 应该添加:将span还给PageCache
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}

实际应用场景

当ThreadCache中的内存块过多时,会批量归还给CentralCache:

// ThreadCache中的回收逻辑
void ThreadCache::Deallocate(void* ptr, size_t size)
{
// ... 将内存块插入thread cache的自由链表
// 如果自由链表过大,归还给central cache
if (freeList.Size() >= freeList.MaxSize())
{
freeList.PopRange(start, end, batchNum);
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
}

这个函数是内存池回收机制的核心,确保了内存块能够正确回到对应的Span中,从而实现内存的重用和合并。

这段代码是页缓存(PageCache) 中的关键函数,它的作用是通过内存块地址找到对应的Span对象。这是内存回收机制的核心组件。

函数功能

MapObjectToSpan(void* obj)接收一个内存块的地址,返回管理这个内存块的Span对象的指针。


代码逐行解析

1. 计算页号

PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

作用:将内存地址转换为对应的页号。

原理

  • PAGE_SHIFT通常是13(表示页大小8KB = 2¹³字节)
  • 右移13位相当于除以8192
  • 例如:地址16384000 >> 13 = 2000(第2000页)

2. 查找Span对象

auto ret = _idSpanMap.find(id);

作用:在映射表中查找该页号对应的Span。

3. 返回结果

if (ret != _idSpanMap.end())
{
return ret->second;  // 找到,返回Span指针
}
else
{
assert(false);       // 找不到,触发断言(不应该发生)
return nullptr;
}

关键数据结构

_idSpanMap映射表

这是一个关键的数据结构,存储了:

  • 键(Key):页号(PAGE_ID
  • 值(Value):管理该页的Span对象指针

映射关系

页号2000 → 管理2000页的Span对象
页号2001 → 管理2001页的Span对象
...

为什么需要这个函数?

内存回收的场景

当CentralCache要回收一个内存块时:

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
// 需要知道这个内存块属于哪个Span
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
// 才能将内存块插入正确的Span的自由链表
}

地址到Span的映射原理

  1. Span管理连续内存页:一个Span可能管理多个连续的页
  2. 每个页对应同一个Span:Span中的所有页都映射到同一个Span对象
  3. 快速查找:通过页号可以快速找到管理该内存的Span

可视化示例

假设:

  • 页大小:8KB
  • Span管理3个页:2000、2001、2002
  • 内存块地址:16384000(第2000页)

映射关系建立

// 当创建Span时,建立所有页的映射
for (PAGE_ID i = 0; i _n; ++i)
{
_idSpanMap[span->_pageId + i] = span;
}

结果:

  • _idSpanMap[2000] = span
  • _idSpanMap[2001] = span
  • _idSpanMap[2002] = span

查找过程

void* obj = (void*)16384008;  // 某个内存块地址
PAGE_ID id = 16384008 >> 13; // = 2000
Span* span = _idSpanMap.find(2000)->second;  // 找到对应的Span

设计优势

  1. O(1)时间复杂度:哈希表查找非常快速
  2. 精确映射:每个内存块都能找到正确的Span
  3. 内存高效:只需要存储页号到指针的映射,开销小
  4. 线程安全:通常由外层锁保护

在内存池架构中的位置

graph TD
A[内存块地址] --> B[计算页号]
B --> C[查询_idSpanMap]
C --> D[找到对应的Span]
D --> E[内存回收操作]

这个函数是内存池能够正确回收和合并内存的基础设施,确保了内存管理的精确性和高效性。

posted @ 2025-08-29 09:06  yjbjingcha  阅读(12)  评论(0)    收藏  举报