✨个人主页:熬夜学编程的小林
💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】【项目详解】
目录
1、使用定长内存池配合脱离使用new
1、tcmalloc是要在高并发场景下替代malloc进行内存申请的 ,因此tcmalloc在实现的时,其内部是不能调用malloc函数的,我们当前的代码中存在通过new获取到的内存,而new在底层实际上就是封装了malloc。
2、为了完全脱离掉malloc函数,此时我们之前实现的定长内存池 就起作用了,代码中使用new时基本都是为Span结构的对象申请空间 ,而span对象基本都是在page cache层创建 的,因此我们可以在PageCache类当中定义一个_spanPool,用于span对象的申请和释放。
// 单例模式
class PageCache
{
public:
//...
private:
//...
ObjectPool<Span> _spanPool; // 定长内存池替代new
};
然后将代码中使用new的地方替换为调用定长内存池当中的New函数,将代码中使用delete的地方替换为调用定长内存池当中的Delete函数。
// 申请Span对象
Span* span = _spanPool.New(); // 替换new
// 释放Span对象
_spanPool.Delete(); // 替换delete
PageCache::NewSpan,PageCache::ReleaseSpanToPageCache两个函数 需要将new或者delete替换成功New或者Delete!
PageCache::NewSpan
// 获取一个K页的span(加映射版本)
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0);
// 大于128页直接找堆申请
if (k > NPAGES - 1)
{
void* ptr = SystemAlloc(k);
//Span* span = new Span;
// 申请Span对象
Span* span = _spanPool.New(); // 替换new
//...
}
// 1.检查第k个桶里面有没有Span,有则返回
//...
// 2.检查一下后面的桶里面有没有span,有则将其切分
for (size_t i = k + 1; i < NPAGES; i++)
{
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
//Span* kSpan = new Span;
// 申请Span对象
Span* kSpan = _spanPool.New();
//...
}
}
// 3.没有大页的span,找堆申请128页的span
//Span* bigSpan = new Span;
// 申请Span对象
Span* bigSpan = _spanPool.New();
//...
// 递归调用自己(避免代码重复)
return NewSpan(k);
}
PageCache::ReleaseSpanToPageCache
// 释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于128页直接释放给堆
if (span->_n > NPAGES - 1)
{
void* ptr = (void*)(span->_pageID << PAGE_SHIFT);
SystemFree(ptr);
//delete span;
// 释放span对象
_spanPool.Delete(span);
return;
}
// 对span的前后页,尝试进行合并,缓解内存碎片问题
// 1、向前合并
while (true)
{
//...
//delete prevSpan;
// 释放span对象
_spanPool.Delete(prevSpan);
}
// 向后合并
while (true)
{
//...
//delete nextSpan;
// 释放span对象
_spanPool.Delete(nextSpan);
}
// 将合并后的span挂到对应的双链表中
//...
// 将该span设置为未使用状态
span->_isUse = false;
}
注意:当使用定长内存池当中的New函数申请Span对象时,New函数通过定位new也是对Span对象进行了初始化的。
此外,每个线程第一次申请内存时都会创建其专属的thread cache,而这个thread cache目前是new出来的,我们需要对其替换。
// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
static ObjectPool<ThreadCache> tcPool;
//pTLSThreadCache = new ThreadCache;
pTLSThreadCache = tcPool.New();
}
这里我们将用于申请ThreadCache类对象的定长内存池定义为静态的,保持全局只有一个,让所有线程创建自己的thread cache时,都在个定长内存池中申请内存就行了。
2、释放对象时不传对象大小
1、当我们使用malloc函数申请内存时 ,需要指明申请内存的大小;而当我们使用free函数释放内存时 ,只需要传入指向这块内存的指针即可。
2、而我们目前实现的内存池 ,在释放对象时除了需要传入指向该对象的指针,还需要传入该对象的大小。
原因:
- 如果释放的是大于256KB的对象 ,需要根据对象的大小来判断这块内存到底应该还给page cache,还是应该直接还给堆。
- 如果释放的是小于等于256KB的对象 ,需要根据对象的大小计算出应该还给thread cache的哪一个哈希桶。
如果我们也想做到,在释放对象时不用传入对象的大小,那么我们就需要建立对象地址与对象大小之间的映射 。由于现在可以通过对象的地址找到其对应的span,而span的自由链表中挂的都是相同大小的对象。
因此我们可以在Span结构中再增加一个_objSize成员,该成员代表着这个span管理的内存块被切成的一个个对象的大小。
// 管理多个连续页大块内存跨度结构
struct Span
{
PAGE_ID _pageID = 0; // 大块内存起始页的页号
size_t _n = 0; // 页的数量
Span* _next = nullptr; // 双向链表结构
Span* _prev = nullptr;
size_t _objSize = 0; // 切好的小对象的大小
size_t _useCount = 0; // 切好的小内存块,被分配给thread cache的计数
void* _freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false; // 是否在被使用
};
而所有的span都是从page cache中拿出来的,因此每当我们调用NewSpan获取到一个k页的span时,就应该将这个span的_objSize保存起来。
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_objSize = size;
代码中有两处,一处是在central cache中获取非空span时 ,如果central cache对应的桶中没有非空的span,此时会调用NewSpan获取一个k页的span;另一处是当申请大于256KB内存时 ,会直接调用NewSpan获取一个k页的span。
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
//...
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_objSize = size;
//...
}
// 申请内存
static void* ConcurrentAlloc(size_t size)
{
//...
Span* span = PageCache::GetInstance()->NewSpan(kPage);
span->_objSize = size;
//...
}
当我们释放对象时 ,就可以直接从对象的span中获取到该对象的大小,准确来说获取到的是对齐以后的大小。
// 释放内存(无对象大小版本)
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
// 大于256KB的内存释放
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
读取映射关系时的加锁问题
我们将页号与span之间的映射关系是存储在PageCache类当中的 ,当我们访问这个映射关系时是需要加锁的 ,因为STL容器是不保证线程安全的。
对于当前代码来说,如果我们此时正在page cache进行相关操作,那么访问这个映射关系是安全的,因为当进入page cache之前是需要加锁的,因此可以保证此时只有一个线程在进行访问。
但如果我们是在central cache访问这个映射关系,或是在调用ConcurrentFree函数释放内存时访问这个映射关系 ,那么就存在线程安全的问题。因为此时可能其他线程正在page cache当中进行某些操作,并且该线程此时可能也在访问这个映射关系,因此当我们在page cache外部访问这个映射关系时是需要加锁的。
实际就是在调用page cache对外提供访问映射关系的函数时需要加锁,这里我们可以考虑使用C++当中的unique_lock,当然你也可以用普通的锁。
// 获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; // 计算页号
std::unique_lock<std::mutex> lock(_pageMtx); // C++11锁,构造加锁,析构解锁
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
3、多线程环境下对比malloc测试
前面我们只是对代码进行了一些基础的单元测试,下面我们在多线程场景下对比malloc进行测试。
// ntimes 一轮申请和释放内存的次数
// nworks 线程数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
// atomic (const atomic&) = delete;
// atomic& operator= (const atomic&) = delete;
// std::atomic<size_t> malloc_costtime = 0; // 错误
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, (unsigned int)malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, (unsigned int)free_costtime);
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, (unsigned int)(malloc_costtime + free_costtime));
}
// 单轮次申请释放次数 线程数 轮次
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, (unsigned int)malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, (unsigned int)free_costtime);
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, (unsigned int)(malloc_costtime + free_costtime));
}
int main()
{
size_t n = 1000;
cout << "==========================================================" << endl;
BenchmarkConcurrentMalloc(n, 4, 10);
cout << endl << endl;
BenchmarkMalloc(n, 4, 10);
cout << "==========================================================" << endl;
return 0;
}
注意:atomic类不支持拷贝构造和赋值重载,因此此处需要使用构造函数,且打印时间时需要将类型强转成无符号整数。
测试函数各个参数的含义如下:
- ntimes:单轮次申请和释放内存的次数。
- nworks:线程数。
- rounds:轮次。
在测试函数中,我们通过clock函数分别获取到每轮次申请和释放所花费的时间 ,然后将其对应累加到malloc_costtime和free_costtime上 。最后我们就得到了,nworks个线程跑rounds轮,每轮申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间。
注意
- 我们创建线程时让线程执行的是lambda表达式 ,而我们这里在使用lambda表达式时,以值传递的方式捕捉了变量k,以引用传递的方式捕捉了其他父作用域中的变量,因此我们可以将各个线程消耗的时间累加到一起。
- 我们将所有线程申请内存消耗的时间都累加到malloc_costtime上, 将释放内存消耗的时间都累加到free_costtime上,此时malloc_costtime和free_costtime可能被多个线程同时进行累加操作的,所以存在线程安全的问题。鉴于此,我们在定义这两个变量时使用了atomic类模板,这时对它们的操作就是原子操作了。
3.1、固定大小内存的申请和释放
测试16字节大小内存的申请和释放
v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));
此时4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次 ,运行后可以看到,malloc的效率还是更高的。

由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自thread cache的同一个桶 ,当thread cache的这个桶中没有对象或对象太多要归还时,也都会访问central cache的同一个桶。此时central cache中的桶锁就不起作用了,因为我们让central cache使用桶锁的目的就是为了,让多个thread cache可以同时访问central cache的不同桶,而此时每个thread cache访问的却都是central cache中的同一个桶。
3.2、不同大小内存的申请和释放
测试不同大小内存的申请和释放
v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

运行后可以看到,由于申请和释放内存的大小是不同的,此时central cache当中的桶锁就起作用了 ,ConcurrentAlloc的效率也有了较大增长,但相比malloc来说还是差一点点。