高并发内存池 - 释放内存过程联调
项目 gitee 链接: 高并发内存池项目
项目 github 链接: 高并发内存池项目
我们先将测试函数展现出来:
cpp
#include "Comm.h"
#include "ConcurrentAlloc.h"
void Alloc1()
{
void* ptr1 = ConcurrentAlloc(1);
void* ptr2 = ConcurrentAlloc(2);
void* ptr3 = ConcurrentAlloc(3);
void* ptr4 = ConcurrentAlloc(4);
void* ptr5 = ConcurrentAlloc(5);
void* ptr6 = ConcurrentAlloc(6);
void* ptr7 = ConcurrentAlloc(7);
void* ptr8 = ConcurrentAlloc(8);
ConcurrentFree(ptr1, 1);
ConcurrentFree(ptr2, 2);
ConcurrentFree(ptr3, 3);
ConcurrentFree(ptr4, 4);
ConcurrentFree(ptr5, 5);
ConcurrentFree(ptr6, 6);
ConcurrentFree(ptr7, 7);
ConcurrentFree(ptr8, 8);
return;
}
int main()
{
Alloc1();
return 0;
}
这里至少回收 7 次才会最终进入 Page Cache 层中尝试合并 Span,下面分析一下为什么:
在申请内存的过程中,自由链表的 MaxSize() 涨到 5,代表只有自由链表的空间资源个数大于 5 时,才会从 Thread Cache 层中进入 Central Cache 层中,这个信息我们之后会用到。
接下来分析一下 Central Cache 的 Span 分出了多少空间资源出去,它的 _useCount 是多少?
| 申请次数 | 来源 | CentralCache 实际分配数 | 累计 useCount |
|---|---|---|---|
| 1 | CentralCache Fetch | 1 | 1 |
| 2 | CentralCache Fetch | 2 | 3 |
| 3 | ThreadCache 自由链表(上次剩的) | 0 | 3 |
| 4 | CentralCache Fetch | 3 | 6 |
| 5 | ThreadCache 自由链表 | 0 | 6 |
| 6 | ThreadCache 自由链表 | 0 | 6 |
| 7 | CentralCache Fetch | 4 | 10 |
所以 7 次申请后,CentralCache 里那个 1 页 Span 的 useCount = 10。
下面进入释放流程:
Deallocate 中,只有当自由链表长度 >= MaxSize(=5) 时,才会调用 ListTooLong 向 CentralCache 归还一批(一次归还 5 个)。
假设只做了 7 次申请(第 7 次 Fetch 后 ThreadCache 自由链表剩余 3 个):
| 释放次数 | 释放后 ThreadCache 长度 | 是否触发 ListTooLong | 本次归还后 CentralCache useCount |
|---|---|---|---|
| 1 | 3 + 1 = 4 | ❌ | --- |
| 2 | 4 + 1 = 5 | ✅ 第一次 | 10 - 5 = 5 |
| 3 | 0 + 1 = 1 | ❌ | --- |
| 4 | 1 + 1 = 2 | ❌ | --- |
| 5 | 2 + 1 = 3 | ❌ | --- |
| 6 | 3 + 1 = 4 | ❌ | --- |
| 7 | 4 + 1 = 5 | ✅ 第二次 | 5 - 5 = 0 ⬅️ 进入 PageCache |
-
第 2 次释放:第一次凑够 5 个,归还 5 个给 CentralCache,useCount 从 10 降到 5,Span 还在被使用,不会进入 PageCache。
-
第 7 次释放:第二次凑够 5 个,再归还 5 个,useCount 从 5 降到 0。此时 CentralCache 发现这个 Span 已经完全空闲,将其从桶中摘下,调用 RealeaseSpanToPageCache。
所以总结一下:为什么第七次释放才进入 Page Cache 中?
进入 PageCache 的充要条件是 CentralCache 中某个 Span 的 useCount 减到 0。
而 useCount 从 10 清到 0 需要经过 两轮 ListTooLong 归还(每轮 5 个):
- 第一轮发生在第 2 次释放
- 第二轮发生在第 7 次释放
因此第 7 次回收(释放)时,对象才最终离开 CentralCache 进入 PageCache,与 PageCache 中剩余的 127 页 Span 前后合并,恢复成一个完整的 128 页大 Span。
接下来讲解我在联调过程中遇到的 BUG:
1. 编译运行问题
在对程序排错的过程中有个问题困扰了我
cpp
void ThreadCache::Deallocate(void* ptr, size_t size) //归还空间
{
assert(ptr); //归还的空间不能为空指针
assert(size <= MAX_BYTES); //归还空间的大小不能超过 MAX_BYTES
std::cout << size << std::endl;
size_t index = SizeClass::Index(size); //犯错,原先传入的为 index
_freeList[index].Push(ptr);
if (_freeList[index].Size() >= _freeList[index].MaxSize()) //如果自由链表的长度大于了一次批量申请的数量
{
ListTooLong(_freeList[index], size);
}
}
在我将传入的 index 错误参数更改为 size 后,传入的参数理应为 1(在 ConcurrentFree(ptr1, 1) 中),但进入 Index 函数中查看时,却总是发现 bytes 的值始终为 8,我十分不解。
cpp
static inline size_t Index(size_t bytes)
{
//断言申请的空间是否大于 256 KB
assert(bytes <= MAX_BYTES);
//每个对齐数自由链表的个数
static int group_array[4] = {16, 56, 56, 56};
if (bytes <= 128)
{
return _Index(bytes, 3);
}
else if (bytes <= 1024)
{
return _Index(bytes - 128, 4) + group_array[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes - 1024, 7) + group_array[0] + group_array[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else
{
assert(false);
}
return -1;
}
它与我代码中的另一个错误强强联合
cpp
static inline size_t _Index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; //这里原先并没有 -1
}
所以造成了传入 0 ~ 7 结果为 0,一旦传入 8 结果为 1,但这并不符合对齐的规则,申请空间大小为 8 字节时,对应的是 0 号桶,所以发现这个错误后我进行了修正,但参数传递的问题还在,即使最后我显示传入 size1 变量,定义为 1,在 Index 中仍然看到传入值为 8,就像这样:
cpp
void ThreadCache::Deallocate(void* ptr, size_t size) //归还空间
{
assert(ptr); //归还的空间不能为空指针
assert(size <= MAX_BYTES); //归还空间的大小不能超过 MAX_BYTES
std::cout << size << std::endl;
int size1 = 1;
size_t index = SizeClass::Index(size1); //犯错,原先传入的为 index
_freeList[index].Push(ptr);
if (_freeList[index].Size() >= _freeList[index].MaxSize()) //如果自由链表的长度大于了一次批量申请的数量
{
ListTooLong(_freeList[index], size);
}
}
最后我看到 index 未初始化时值一直为 8,我突然想起来,我之前有一版错误代码一直传入的是 index,而 index 的值恰好为 8,自那以后一直按的是 F5 进行调试,基于旧版本的运行程序在观测,没有重新编译生成新的代码,所以自然传入值一直为 8。
到此一切水落石出,所以一定要养成随改随编的习惯,这也警醒了我自己。
2. 无法合并成大页的 128 Span
在我联调进入 Page Cache 层中,我发现走到最后 Span 合并时,始终无法合并成 128 页完整的 Span,这与推测是相悖的,所以代码的逻辑一定出现了问题,我先排查将 Span 放入哈希表的部分,因为如果此处没出错,那对应页号一定在 Span 中 ,但在调试代码时,我发现对应页号在哈希表中未找到,所以才跳出循环,此处一定出现问题。
果不其然,我将 spanK 的首末页号挂入了哈希表中,spanK 是要分配给 Central Cache 层的 Span,而 spanN 才是空闲在 Page Cahce 层中,要将首末页号挂入哈希表中的 Span。
cpp
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0 && k < NPAGES);
//先检查第 k 个桶中是否还有 Span
if (!_spanLists[k].Empty())
return _spanLists[k].PopFront(); //猜的第三个坑,没有加上 [k]
//走到这里说明桶中为空,要遍历 Page Cache 是否有大页 Span 可分割
for (int i = k; i < NPAGES; i++)
{
if (!_spanLists[i].Empty())
{
Span* spanN = _spanLists[i].PopFront();
Span* spanK = new Span; //踩的第一个坑,没有申请空间,野指针直接使用
spanK->_pageId = spanN->_pageId;
spanK->_n = k;
spanN->_pageId += k;
spanN->_n -= k;
_spanLists[spanN->_n].PushFront(spanN); //踩的第三个坑,把 spanK 挂上去了
//将 SpanN 的首页号和末页号存入哈希表中,方便归还合并
_idSpanMap[spanK->_pageId] = spanK; //这里踩坑,把 SpanK 挂上去了
_idSpanMap[spanK->_pageId + spanK->_n - 1] = spanK;
//将要分配给 Thread Cache 的 Span 建立页号和地址的映射,方便归还时寻找对应地址
for (int i = 0; i < spanK->_n; i++)
{
_idSpanMap[spanK->_pageId + i] = spanK;
}
return spanK;
}
}
//走到这里说明也没有大页 Span,所以此时需要向堆申请空间
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1); //申请一个最大页数的 Span
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; //踩的第二个坑,左移右移逻辑踩坑
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k); //递归调用自己,此时一定有大页 Span 可供分割
}
在将这次错误更正后,可以确定哈希表中一定存在 Span 的页号,但最后仍未合并成 128 页的 Span,此时问题极可能出在查找本身逻辑中,在仔细排查下,我发现了最终的问题:
cpp
PAGE_ID nextPage = span->_pageId + span->_n + 1; //这里踩坑,多加了一个 1
在计算下一 Span 的首页号时,多加了一个 1,所以在哈希表中没有对应的页号,无法查找到。
在将这个问题解决后,程序运行正常结束,无崩溃。
即使在多线程的情况下,代码也可以成功运行:
cpp
#include "Comm.h"
#include "ConcurrentAlloc.h"
void Alloc1()
{
void* ptr1 = ConcurrentAlloc(1);
void* ptr2 = ConcurrentAlloc(2);
void* ptr3 = ConcurrentAlloc(3);
void* ptr4 = ConcurrentAlloc(4);
void* ptr5 = ConcurrentAlloc(5);
void* ptr6 = ConcurrentAlloc(6);
void* ptr7 = ConcurrentAlloc(7);
void* ptr8 = ConcurrentAlloc(8);
ConcurrentFree(ptr1, 1);
ConcurrentFree(ptr2, 2);
ConcurrentFree(ptr3, 3);
ConcurrentFree(ptr4, 4);
ConcurrentFree(ptr5, 5);
ConcurrentFree(ptr6, 6);
ConcurrentFree(ptr7, 7);
ConcurrentFree(ptr8, 8);
return;
}
void Alloc2()
{
for (int i = 0; i < 5; i++)
void* ptr = ConcurrentAlloc(7);
}
void Thread()
{
std::thread t1(Alloc1);
std::thread t2(Alloc1);
t1.join();
t2.join();
}
int main()
{
Thread();
// Alloc1();
// Alloc2();
return 0;
}
在释放内存过程联调结束后,我们还会对项目的一些点进行优化,评估性能,让我们下期再见👋。