
⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页 :YYYing.
⭐️高并发内存池项目专栏:C++项目之高并发内存池
系列上期内存:【高并发内存池 (四)】三层缓存的空间回收流程详解
系列下期内容:暂无
目录
[📖 需要强调的细节](#📖 需要强调的细节)
[📖 优化前的性能测试](#📖 优化前的性能测试)
[📖 使用基数树对内存池进行性能优化](#📖 使用基数树对内存池进行性能优化)
[📖 优化后代码与性能测试](#📖 优化后代码与性能测试)
[📖 结语](#📖 结语)

前言:
那么这一篇讲完后这个项目差不多就完结了,此篇我们讲解一些项目中剩下的细节与性能优化,如果不优化的话,碰上较大数据量的效率会非常低劣,本篇最后将会奉上此项目的gitee仓库。
📖 需要强调的细节
1、申请和释放大于256KB的空间
· 我们之前讲的申请与释放都是基于单次申请和释放空间不超过256KB,但实际上是一定会有单次申请大于256KB的情况的,那么此时我们又该如何解决呢?
申请流程
回忆一下,我们pc中的span最大是不是128页,假设一页现在是8KB,那么pc中最大的span管理的空间就是128 * 8KB = 1024KB,也就是最大的span可以管理1M的空间。
既然tc中单次申请空间不能超过256KB,那么超过256KB的内存就不要向tc申请了,我们不妨直接去向pc申请,只要单次申请的空间在(256KB, 1024KB]之间的,就可以直接向pc要,pc对于这些空间是可以管够的。
那如果连1M也超过了呢?简单,那就直接向OS去申请,不过也需要经过一下pc,pc也要对要到的空间进行管理。

但不管是申请多少页,我们都要对齐到一个完整的块大小才能向下层申请,这里超过256KB的空间也是,那么就要修改一下前面的对齐中的规则,如果size大于了256KB,那就直接按照页大小来对齐。

在一开始申请空间的时候,直接在ConcurrentAlloc特判:
cpp
// 其实就是tcmalloc,线程调用这个函数申请空间
static void* ConcurrentAlloc(size_t size)
{
// 如果申请空间超过256KB,就直接找下层的去要
if (size > MAX_BYTES)
{
size_t alignSize = SizeClass::RoundUp(size); // 先按照页大小对齐
size_t k = alignSize >> PAGE_SHIFT; // 算出来对齐之后需要多少页
PageCache::GetInstance()->_pageMtx.lock(); // 对pc中的span进行操作,加锁
Span* span = PageCache::GetInstance()->NewSpan(k); // 直接向pc要
PageCache::GetInstance()->_pageMtx.unlock(); // 解锁
void* ptr = (void*)(span->_pageID << PAGE_SHIFT); // 通过获得到的span来提供空间
return ptr;
}
else // 申请空间小于256KB的就走原先的逻辑
{
/* 因为pTLSThreadCache是TLS的,每个线程都会有一个,且相互独立,所以不存在竞
争pTLSThreadCache的问题,所以这里只需要判断一次就可以直接new,不存在线程安全问题*/
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
//cout << std::this_thread::get_id() << " " << pTLSThreadCache << endl;
return pTLSThreadCache->Allocate(size);
}
}
那么再来修改一下NewSpan的逻辑,NewSpan的参数是表示需要多少页,那么这里就是要补充一下超过128页的逻辑,小于等于128页的都可以复用原先的代码:

申请的流程我们要改的就这点。
释放流程
释放跟我们刚才申请一样,不走tc,直接走pc,还是在开始回收的地方特判。
cpp
// 线程调用这个函数用来回收空间
static void ConcurrentFree(void* ptr, size_t size)
{ /*这里第二个参数size后面会去掉的,
这里只是为了让代码能跑才给的*/
assert(ptr);
// 通过ptr找到对应的span,因为前面申请空间的
// 时候已经保证了维护的空间首页地址已经映射过了
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
// 通过size判断是不是大于256KB的,是了就走pc
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock(); // 记得加锁
PageCache::GetInstance()->ReleaseSpanToPageCache(span); // 直接通过span释放空间
PageCache::GetInstance()->_pageMtx.unlock(); // 解锁
}
else // 不是大于256KB的就走tc
{
pTLSThreadCache->Deallocate(ptr, size);
}
}
然后补充ReleaseSpanToPageCache的逻辑,就是加上释放大于256KB的逻辑,但也还是和申请的NewSpan一样,不需要考虑释放256KB到1024KB的span,只需要释放大于128页的空间就可以了,也就是大于128页的时候直接还给os,但是前面没有写直接将空间还给os的逻辑,这里写一个:

然后补充:

大空间申请测试
这里就将测试代码直接给出了。
cpp
void BigAlloc()
{
void* p1 = ConcurrentAlloc(257 * 1024);
ConcurrentFree(p1, 257 * 1024);
void* p2 = ConcurrentAlloc(129 * 8 * 1024);
ConcurrentFree(p2, 129 * 8 * 1024);
}
这里p1就是257KB的,对齐之后33页,你看你申请之后span是不是33页的,会走NewSpan中原先的逻辑,释放的时候走ReleaseSpanToPageCache中原先的逻辑,其他就没啥了。

第二个p2就是129页的,也就是大于128页的,申请的时候走NewSpan中申请大于128页的逻辑,释放的时候走ReleaseSpanToPageCache中大于128页的逻辑,也就是都向OS申请和释放。你看看你的大致流程是不是这,没有报错就应该没啥问题。报错了就慢慢调试吧。
2、使用定长内存池替换new
这个项目做完以后如果要求没那么高的话是可以替代malloc的,说要求没那么高是因为这个项目也就一千多行代码,想要完全替代malloc是现实的,真想完全替代就用谷歌开源的tcmalloc,这里只是为了学习tcmalloc的部分核心才实现的一个简易版本。
先来换一下每个线程的TLSThreadCache:

这里pTLSThreadCache是不需要进行Delete的,因为整个流程一直在使用。
然后就是Span的new与delete,因为只有在PageCache中new了Span,所以直接在PageCache中搞一个定长内存池的成员:

我就不带着一起改了,把PageCache.cpp所有的new span和delete span改为_spanPool.New()和.Delete()就行。
到这里其实还差点意思,这里如果进行多线程测试的话还会出问题,因为pTLSThreadCache是每个线程独有的一个对象,但是为它申请空间的objPool就不是了,我们不妨回顾一下之前的定长内存池:

我们不妨极端一点------t1就直接停在了红框的那个函数,t1停在这个红色框函数前已经对_remainentBytes进行了修改,然后我们t2运行到了上面对_remainentBytes的特判处,那么对于t2来说此时_remainentBytes肯定是大于T的,那么就会直接跳到下面的 obj = (T*)_memory; 处,但此时_memory是nullptr,t2这块就直接获取到了一个空指针,给定位new传空指针会直接导致程序崩掉,所以这里是线程不安全的,就需要加锁,我们直接在定长内存池的类中定义了。

然后我们此处就直接在创建ThreadCache这加锁了,至于为什么------这里的创建ThreadCache只会让每个线程执行一次,同时后面的span对象在创建的时候也会用这里的定长内存池,所以说这里如果加到了New中,后面的span在申请的时候也需要加上这里的_poolMtx,就会影响效率。

而不给span加锁是因为,pc在创建和删除的时候是会加桶锁的。
然后接着测试下,多线程,大空间的那几个例子,能跑应该就没啥问题。
3、释放时不传对象大小
这是应该上个世纪留下来的问题了,没想到我们项目结束才解决(doge,我给可能忘了的兄弟说下就是我们 ConcurrentFree 的第二个参数:

那如何做到不需要传这个参数呢?也就是怎么才能直接根据ptr得到其所指空间的大小。
我们在这里选择在span中加入成员_objSize,表示span所管理的页被切分成的块大小。
当然我们也可以直接去建立页号 与该页被切分成的块大小之间的映射,具体思路差不多是在pc中再添加一个映射,在获取到新span后,在cc中进行切分的同时将页号与块大小的映射关系搞好。然后ConcurrentFree中先通过指针右移PAGE_SHIFT位得到页号,再根据页号的映射找到该页的块大小,那么这个块大小就是ptr所指向的空间的大小。自己可以下去写一下,不过这个效率可能会慢。
我们在span中加入一个新的成员变量_objSize:

然后我们在cc中将这个变量进行计算:

这个值后续也不用管了,因为pc中并不会拿这个字段干什么事。还有一个就是NewSpan中申请大于128页的span也需要加上统计_objSize,可以在NewSpan中加,也可以直接在ConcurrentAlloc中加,在NewSpan中加的时候就直接给成页数左移PAGE_SHIFT位就行,在ConcurrentAlloc中加就直接给成申请的size就行,只要在Free的时候能通过span找到_objSize,知道_objSize是大于MAX_BYTES的就行。大于MAX_BYTES就走的不是前面正常的逻辑。
这里就在ConcurrentAlloc中添加了:

这样我们在ConcurrentFree的时候就可以先通过ptr左移PAGE_SHIFT位算出来页号,然后再根据页号与span*的映射关系找到对应的span,最后根据span中的_objSize就可以得知ptr所指的空间的大小了:

4、调用MapObjToSpan的时候加锁
在调用MapObjToSpan的时候加锁,是因为STL不是线程安全的,PageCache中用的unordered_map不是线程安全的,如果增删的时候导致原先unordered_map的结构发生了改动(扩容导致原空间丢失什么的),此时查找的时候如果还在查找原先的结构,就可能会找出来一个野指针的span,所以说MapObjToSpan要加锁。
当然也是有两种方法:函数内部与调用函数两边。
但又因为MapObjToSpan并不像NewSpan那样是递归的,所以可以直接在MapObjToSpan函数内部加一个互斥锁,只需要加pc中的那一把锁就行。
cpp
// 页地址找span
Span* PageCache::MapObjectToSpan(void* obj)
{
// 通过块地址找到页号
PageID id = (((PageID)obj) >> PAGE_SHIFT);
// 此处用智能锁
std::unique_lock<std::mutex> lc(_pageMtx);
// 通过哈希找到页号对应span
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
📖 优化前的性能测试
这里与malloc对比下性能,给一个benchmark.cpp的文件,测试代码随便看两眼就行:
cpp
/*这里测试的是让多线程申请ntimes*rounds次,比较malloc和刚写完的ConcurrentAlloc的效率*/
#include"ConcurrentAlloc.h"
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
// nwors表示创建多少个线程
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&, k]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
//v.push_back(malloc(16)); // 每一次申请同一个桶中的块
v.push_back(malloc((16 + i) % 8192 + 1));// 每一次申请不同桶中的块
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
free(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次malloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime.load());
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
//v.push_back(ConcurrentAlloc(16));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
ConcurrentFree(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次concurrent alloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime.load());
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
int main()
{
size_t n = 10000;
cout << "==========================================================" << endl;
// 这里表示4个线程,每个线程申请10万次,总共申请40万次
BenchmarkConcurrentMalloc(n, 4, 10);
cout << endl << endl;
// 这里表示4个线程,每个线程申请10万次,总共申请40万次
BenchmarkMalloc(n, 4, 10);
cout << "==========================================================" << endl;
return 0;
}
可以看到,这里每次申请不同桶下的块空间只是稍微比malloc快一点,在差一点的电脑上甚至有可能比malloc还会慢,没事我们后面还会优化

再来看看我们每次申请同一个桶下的块空间是怎么样的;

可以看到这次直接干不过malloc了,我们还可以再缩小到 1w/线程 的程度,在我这测的情况和上面两种对比是一样的。那么现在写出来的ConcurrentAlloc整体的性能其实还是没有malloc高,尤其是free的时候,差的还是蛮多的,所以我们需要进行优化处理。
📖 使用基数树对内存池进行性能优化
我们上面的性能问题,很大程度是取决于数据量太大造成了unordered_map查找消耗比较大,还有就是锁的消耗很大,如果你很好奇为什么,那你可以去进行性能探查:

此处感兴趣的可以去站内搜搜,我这里就不解释了。
那么实际上tcmalloc中就是用基数树来进行映射存储,而非我们写的哈希表。tcmalloc源码中基数树给了3棵,你可以把基数树理解成一个多叉树,而每棵的层数是不一样的。分别是1层、2层、3层。学过操作系统中内存管理的同学可能对这个东西还是很好理解的。
1、单层基数树
不用想,单层的基数树肯定是最简单的,就是一个数组,严格的来说其实是一个哈希表,一个用直接定址法来映射的哈希表,其中的 K-V 关系就是 页号-span*。
页号就是一个数组,那么在页号位数没有那么大的情况下,把数组的大小开到最大的页号,假设数组就是arr,那么直接arr[页号]就能找到该页号对应的页,这就是单层基数树。

不过基数树本身内部不是span*,而是void*,但也没有关系,都是地址,能找到对应地址就行。
那么来看一下单层基数树的代码(简单过一眼就行,不要细看):
cpp
// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS; // 数组要开的长度
void** array_; // 底层存放指针的数组
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap1() {// 开空间
size_t size = sizeof(void*) << BITS;
size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
memset(array_, 0, sizeof(void*) << BITS);
}
// Return the current value for KEY. Returns NULL if not yet set,
// or if k is out of range.
void* get(Number k) const { // 通过k来获取对应的指针
if ((k >> BITS) > 0) {
return NULL;
}
return array_[k];
}
// REQUIRES "k" is in range "[0,2^BITS-1]".
// REQUIRES "k" has been ensured before.
//
// Sets the value 'v' for key 'k'.
void set(Number k, void* v) { // 将v设置到k下标
array_[k] = v;
}
};
先来说一下非类型模版参数BITS是啥,BITS表示存储所有的页号至少需要多少比特位。
而且这个变量是按照平台来定的:比如说32位下,按一页8KB来算,页内偏移就是13位(8KB = 2^13),所以页号能占32 - 13 = 19位,那么就是32 - PAGE_SHFIT,上面PAGE_SHFIT定义的是13,所以这里BITS就是19。也就是说数组要开2^19 个span*大小的空间,也就是 2^19 * 4 ⇒ 2^21,也就是2M,所以说这里一个数组要开2M的空间,完全是在可接受范围内的。
但如果是64位呢?64 - PAGE_SHFIT ⇒ 51,64位下的一个指针是8B大小,那总共就要开2 ^51 * 8 ⇒ 2^54,但哪怕1TB也才2^40,这都干到2^14TB了,这个对于普通电脑来说简直是一粒蜉蝣见青天,所以说一层肯定不行,其实两层也不行,所以我们需要用三层的。
里面有两个接口,一个get,一个set,分别是获取某个页号对应的span*,和将某个span*放到对应的数组下标处。详细的逻辑就不说了。下面来说两层的,等三个都说完,再来说为啥基数树不需要加锁。
2、两层基数树
其实和一层的差不多,就是多了一层数组,而且这里很像一级页表,二级页表那样,所以我刚才说如果你学过OS的话这里应该会熟悉一些。
先挑出来页号的前几位来决定第一层数组的大小,然后后几位来决定第二层数组的大小。这里32位下,页号有19位,那么挑出来前5位来作为第一层数组的直接定址,然后后面的14位用来第二层的直接定址,看图:

然后:

那么第一层中每个元素指向的都是一个数组,也就是后14位的数组,而第二层中每个元素就是一个span*:

就这么easy,这里第二层和第一层也没啥大区别,32位下,这里最后总共的span*也照样会开到2M的空间,但是比第一点好的是,这里在前期可以稍微节省一点空间,因为如果前5位中如果有一个数还没有映射的时候就可以先不开其对应14位的二层数组,比如前五位为0x00000还没有映射到第一层时,那就不把第一层0号下标对应的二层数组开出来。当需要映射的时候再开。
我们再把第二层的代码给下:
cpp
// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
static const int ROOT_BITS = 5; // 32位下前5位搞一个第一层的数组
static const int ROOT_LENGTH = 1 << ROOT_BITS;
static const int LEAF_BITS = BITS - ROOT_BITS; // 32位下后14位搞成第二层的数组
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Leaf node
struct Leaf { // 叶子就是后14位的数组
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // 根就是前5位的数组
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap2() { // 直接把所有的空间都开好
memset(root_, 0, sizeof(root_));
PreallocateMoreMemory(); // 直接开2M的span*全开出来
}
void* get(Number k) const {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 || root_[i1] == NULL) {
return NULL;
}
return root_[i1]->values[i2];
}
void set(Number k, void* v) {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
ASSERT(i1 < ROOT_LENGTH);
root_[i1]->values[i2] = v;
}
// 确保从start开始往后的n页空间开好了
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> LEAF_BITS;
// Check for overflow
if (i1 >= ROOT_LENGTH)
return false;
// 如果没开好就开空间
if (root_[i1] == NULL) {
static ObjectPool<Leaf> leafPool;
Leaf* leaf = (Leaf*)leafPool.New();
memset(leaf, 0, sizeof(*leaf));
root_[i1] = leaf;
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
// 提前开好空间,这里就把2M的直接开好
void PreallocateMoreMemory() {
// Allocate enough to keep track of all possible pages
Ensure(0, 1 << BITS);
}
};
里面有一个Ensure函数,是为了确保某些页号对应的空间开好了,如果没开好就直接在这个函数中开。
3、三层基数树
一样的逻辑,主要是给64位用的,结构如下:

其中第三层也就是绿色的部分,前两层中的数组都是存放下一层中每个数组的指针,也就是说前两层放的都是数组指针,最后一层放的是span*。
同理,这样的结构可以不需要把所有的空间在初始的情况下都开好,这样就能保证需要的页都能映射到对应的span,而且一个进程是不可能将所有的页全部映射的,这样把不需要映射的页对应的数组就不开了,这样就能节省空间,从而再64位下就能用了。
再给下代码,不过代码如果此处没学过分配器的话,看起来还是有点难度的,没事我们等会会讲:
cpp
// 当 BITS 较大(如 35 以上)时,两层树会导致根节点或叶节点过大,
// 使用三层可以更均匀地分割索引位,减少单级数组长度。
// ---------- 三层基数树(64 位系统,BITS = 51 左右)----------
//// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
// How many bits should we consume at each interior level
static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;
// How many bits should we consume at leaf level
static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Interior node
struct Node {
Node* ptrs[INTERIOR_LENGTH];
};
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Node* root_; // Root of radix tree
void* (*allocator_)(size_t); // Memory allocator
Node* NewNode() {
Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
if (result != NULL) {
memset(result, 0, sizeof(*result));
}
return result;
}
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
allocator_ = allocator;
root_ = NewNode();
}
void* get(Number k) const {
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 ||
root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
return NULL;
}
return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
}
void set(Number k, void* v) {
ASSERT(k >> BITS == 0);
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
// Check for overflow
if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_->ptrs[i1] == NULL) {
Node* n = NewNode();
if (n == NULL) return false;
root_->ptrs[i1] = n;
}
// Make leaf node if necessary
if (root_->ptrs[i1]->ptrs[i2] == NULL) {
Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
if (leaf == NULL) return false;
memset(leaf, 0, sizeof(*leaf));
root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
};
📖 优化后代码与性能测试
1、优化代码
这里我只搞64位的代码,感兴趣的32位可以下去玩玩,64位会了的话玩32位简直是降维打击。
首先直接将PageCache中的unordered_map替换成三层的基数树,那这个时候一个大问题就来了,怎么定义和这个基数树变量呢?
我们看回我们的原函数,我们发现类中的成员变量有这么一个东西:

这个东西就是我们的内存分配器了,正如字面上那样说的,分配器就是一个负责"给内存"的工具 。在 C++ 里,最常见的分配器就是 new / malloc,它们从堆 上拿内存。但有时候,我们不想用默认的堆,而想从自己管理的内存池、或者直接从操作系统拿内存,这时候就需要自定义分配器。
可以看到,我们的根节点、结点与叶子结点都是用了分配器,但树内部完全不知道内存是怎么来的,它只相信分配器会给它一块可用的内存。
那我们的哈希表定义就应该是这样,不光要定义变量,这个变量的初始化就交给了我们的构造函数,然后传入我们的分配器函数参数,如果是一层基数树就不用什么分配器了,直接定义即可。

那么分配器是这样的,这里的new其实是一种普通写法,但我们之前就说了将new换成向系统申请,所以这里就写成向系统申请:
cpp
// 分配器函数
inline void* PageMapAlloc(size_t size)
{
//return ::operator new(size);
size_t pages = (size + (1 << PAGE_SHIFT) - 1) >> PAGE_SHIFT;
void* ptr = SystemAlloc(pages);
if (ptr == nullptr)
{
throw std::bad_alloc(); // 与你的 SystemAlloc 失败行为一致
}
return ptr;
// 如果需要释放,也要提供对应的释放函数,但基数树不需要释放(或由系统回收),暂可忽略
}
那么我们的基数树就定义完了,现在来看PageCache内要改的代码段。
把相关原先用unordered_map中的获取span*换成调用get方法,设置span*的换成调用set方法。但由于位置太过分散,我就直接说咋改就行了。
都在PageCache中:
- 对于原先位_idSpanMap[页号] = span的都改为_idSpanMap.set(页号, span),然后我们需要在set之上再写一个_idSpanMap.Ensure(页号, 1),这步的意思就是遍历从 start 到
start+n-1的所有页号,对每个页号:
计算它的三段索引(
i1根索引,i2中间索引,i3叶子索引)。检查路径上是否存在对应的节点(中间节点、叶子节点),如果没有,就当场调用分配器创建并清零。
这样,走完这个范围后,所有这些页号对应的叶子槽位就都已经存在了,后续的
set可以直接往里面写值如果不写这个是会报错的(记得把二层和一层都注释掉)。
- 对于原先位_idSpanMap.find(页号)的都改为_idSpanMap.get(页号);而且此时的返回值就是Span,不是原先的迭代器了,所以用到auto ret = _idSpanMap.find(页号)的地方都直接变成auto ret = _idSpanMap.get(页号)即可。判断中有的地方是ret != _idSpanMap.end(), 直接改成ret != nullptr就行。return ret->second的改为return (Span*)ret,至于为什么不能直接用Span*接收,是因为我们返回的是void*型。
此时我们就可以直接将MapIdToSpan中的锁去掉了:

但为什么可以去掉呢?这里是在对基数树进行读操作,进行读操作的地方只有两个,一个是在ConcurrentFree里,另一个在ReleaseListToSpans中,二者都存在于回收的逻辑当中。而对于基数树的写操作也是只有两个函数会走到,一个是NewSpan,一个是ReleaseSpanToPageCache。
那么写操作,就是对于数组中的某一个元素进行写入,直接写入一个span*的指针。
读操作,就是通过下标找到对应span*,从而得到这个span*。
但对于一个页号的映射,会不会出现一个线程读某个页号,另一个线程写同一个页号?还有基数树的结构在整个流程中会变化吗?
我们来依次回答一下这两个问题:
1、首先,我们调用MapObjectToSpan读一个页号,那么这个页号一定是不会在pc中的,而是在cc中的,因为MapObjectToSpan的调用是在回收逻辑中的,通过start指针来找span,这个span是要还回去的,一定不在pc中。
而对于写操作,NewSpan是在将Span从pc中拿出去之前就映射了,也就是对基数树进行写操作,而ReleaseSpanToPageCache是在Span归还到pc中之后才映射的。所以读写操作是一定不会同时对一个span操作的。
2、事实上我们基数树在整个流程中并不会变化,因为我们数组都是提前开好的,或是遇到了之后在原先的基础之上再开空间,不会修改原先的空间。而unordered_map遇到容量不够的情况时会出现扩容等情况,假如说线程t1在扩容前的结构上进行查找,而线程t2进行了扩容,那么t1线程此时就有可能会找到一个野指针,但t1并不知情,t1拿着这个野指针解引用其找到的Span*就会崩掉,所以STL中的unordered_map在整个流程中是可能修改其本身的结构的,所以我们um是线程不安全的,需要加锁。
但既然基数树的结构不会修改,那整个流程中查找到的就一定是一棵树不会改变的树,在这一点的基础上配合第二点,就不需要加锁了。
2、优化后性能测试
测试代码还是上面那个:
4个线程,每个申请10w次,申请不同桶中的块的结果:

4个线程,每个申请10w次,申请一个桶中的块的结果:

4个线程,每个申请1w次,申请不同桶中的块的结果:

4个线程,每个申请1w次,申请一个桶中的块的结果:

可以看到是比malloc快了不少的,在4个线程,每个申请10w次,申请不同桶中的块的情况下甚至能快了20倍左右。
那到此为止这个项目就彻底完成了,再强调一遍,这个项目只是为了学习大佬的成果,从而提升自己,并不是为了完全复刻tcmalloc的源码,只是把其中特别核心的内容拿出来讲的,如果你真的对源码感兴趣可以去github上看。我在第一篇中也给出了源码传送门。
那么在此处我就把源码公开来,github和gitee,两个你们喜欢用哪个就看哪个吧。
📖 结语
不过当前实现的并发内存池在单用户日常使用情况是比malloc/free是更加高效的,那么我们能否替换到系统调用malloc呢?
- 答案是可以的。但是不同平台替换方式不同。
一般而言,像本篇中实现出来的东西可以做成动静态库,这样后续可以直接引相应的头文件直接用,想要生成动静态库也很简单,修改一下生成文件就行:


选好之后,我们再生成解决方案:

然后就看文件夹中有没有.dll的文件,有的话就没问题,静态库也是同理。

OK啊,正式完结了,也许后面可能还会进行这个项目的优化与总结,不过近期我们是完结了。
我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。
无限进步,我们下次再见!
---⭐️ 封面自取 ⭐️---
