啊我摔倒了..有没有人扶我起来学习....
👱个人主页: 《 C G o d 的个人主页》 \color{Darkorange}{《CGod的个人主页》} 《CGod的个人主页》交个朋友叭~
💒个人社区: 《编程成神技术交流社区》 \color{Darkorange}{《编程成神技术交流社区》} 《编程成神技术交流社区》加入我们,一起高效学习,收割好Offer叭~
🌱刷题链接: 《 L e e t C o d e 》 \color{Darkorange}{《LeetCode》} 《LeetCode》快速成长的渠道哦~
前言
- 内存池是指程序预先从操作系统申请一块足够大的内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取
- 同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池
- 当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放给操作系统
一、项目介绍
1.1 这个项目做的是什么?
- 当前项目是实现一个多线程环境下的内存池,他的原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)
- 我们这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcmalloc的精华
1.2 这个项目要求的知识储备
- 这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等方面的知识
二、什么是内存池
2.1 池化技术
- 所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率
- 在计算机中,有很多使用
池
这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态
2.2 内存池
- 内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放
2.3 内存池主要解决的问题
- 内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?
再需要补充说明的是内存碎片分为外碎片
和内碎片
,上面我们讲的是外碎片问题。外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用
2.4 malloc
- C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池
- malloc() 相当于向操作系统
批发
了一块较大的内存空间,然后零售
给程序用。当全部售完
或程序有大量的内存需求时,再根据实际需求向操作系统进货
- malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc
三、项目开始
3.1 先设计一个定长的内存池(对象池)
- 作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,
但是什么场景下都可以用就意味着什么场景下都不会有很高的性能
- 下面我们就先来设计一个定长内存池(对象池),当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习他目的有两层:
- 先熟悉一下简单内存池是如何控制的
- 作为我们后面内存池的一个基础组件
- _memory指向从堆申请的大块内存,随后切分给每个申请的对象使用;当归还内存的时候,并不是直接还给堆,而是先还给内存池,用_freelist链接管理起来,重复利用
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr) throw std::bad_alloc();
return ptr;
}
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
// 如果自由链表有对象,直接取一个
if (_freeList)
{
void* next = *((void**)_freeList);
obj = (T*)_freeList;
_freeList = next;
}
else
{
// 剩余内存不够一个对象大小时,则重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remainBytes);
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr) throw std::bad_alloc();
}
obj = (T*)_memory;
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T); // 如果申请的内存小于一个指针的大小,那就分配一个指针的大小
_memory += objSize;
_remainBytes -= objSize;
}
// 使用定位new调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj)
{
// 显示调用的T的析构函数进行清理
obj->~T();
// 头插到freeList
*(void**)obj = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr; // 指向内存块的指针
int _remainBytes = 0; // 内存块中剩余字节数
void* _freeList = nullptr; // 管理还回来的内存对象的自由链表
};
3.2 高并发内存池整体框架设计
- 现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池
需要考虑以下几方面的问题
:- 性能问题
- 多线程环境下,锁竞争问题
- 内存碎片问题
- 高并发内存池主要由以下3个部分构成:
thread cache
:线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方central cache
:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache没有内存对象时才会找central cache,所以这里竞争不会很激烈page cache
:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题
3.3 申请内存的逻辑
- 为了更好地理解该项目过程,先走一遍申请内存的逻辑,再走释放内存的逻辑
3.3.1 thread cache的申请逻辑
-
thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的
(利用TLS)
-
申请内存:
- 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i
- 如果自由链表_freeLists[i]中有对象,则直接pop一个内存对象返回
- 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象
-
TLS——thread local storage:
关于TLS可以参考一下这篇文章《Thread Local Storage(线程局部存储)TLS》 -
thread cache申请逻辑代码框架:
ThreadCache.h
class ThreadCache
{
public:
void* allocate(size_t bytes); // 申请内存对象
void* fetchFromCentralCache(size_t index, size_t bytes); // 从中心缓存获取对象
private:
FreeList _freeLists[NFREELIST];
};
// TLS thread local storage 线程局部存储的声明
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
ThreadCache.cpp
void* ThreadCache::allocate(size_t bytes)
{
assert(bytes <= MAX_BYTES);
size_t align = SizeClass::roundUp(bytes);
size_t index = SizeClass::index(bytes);
if (!_freeLists[index].empty())
{
// thread cache对应桶里还有内存,直接取
return _freeLists[index].pop();
}
else
{
// thread cache对应桶里没有内存了,要去central cache那里取
return fetchFromCentralCache(index, align);
}
}
void* ThreadCache::fetchFromCentralCache(size_t index, size_t bytes)
{
// 慢开始反馈调节算法
// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
// 2、如果你不断申请这个bytes大小内存需求,那么batchNum就会不断增长,直到上限
// 3、bytes越大,一次向central cache要的batchNum就越小
// 4、bytes越小,一次向central cache要的batchNum就越大
size_t batchNum = min(SizeClass::NumMoveSize(bytes), _freeLists[index].maxSize());
if (batchNum == _freeLists[index].maxSize()) _freeLists[index].maxSize() += 1;
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::getInstance()->fetchRangeObj(start, end, batchNum, bytes);
assert(actualNum); // 实际拿到的个数
if (actualNum == 1) // 如果拿到一个,那么直接返回拿来用
{
assert(start == end);
return start;
}
else // 如果拿到多个,返回第一个拿来用,其他先插入桶里
{
_freeLists[index].pushRange(NextObj(start), end, actualNum - 1);
return start;
}
}
- 此时
Common.h
里的方法有
static const size_t MAX_BYTES = 256 * 1024;
static const size_t NFREELIST = 208;
static void*& NextObj(void* obj)
{
return *(void**)obj;
}
// 管理切分好的自由链表
class FreeList
{
public:
void push(void* obj)
{
assert(obj); // ************
//头插
NextObj(obj) = _freeList;
_freeList = obj;
++_size;
}
void pushRange(void* start, void* end, size_t len)
{
NextObj(end) = _freeList;
_freeList = start;
_size += len;
}
void* pop()
{
assert(_freeList); // **************
//头删
void* obj = _freeList;
_freeList = NextObj(obj);
--_size;
return obj;
}
bool empty()
{
return _freeList == nullptr;
}
size_t& maxSize()
{
return _maxSize;
}
private:
void* _freeList = nullptr;
size_t _maxSize = 1;
size_t _size = 0;
};
// 计算对象大小的对齐映射规则
class SizeClass
{
public:
// 整体控制在最多10%左右的内碎片浪费
// [1, 128] 8byte对齐 freelist[0, 16)
// [128+1, 1024] 16byte对齐 freelist[16, 72)
// [1024+1, 8*1024] 128byte对齐 freelist[72, 128)
// [8*1024+1, 64*1024] 1024byte对齐 freelist[128, 184)
// [64*1024+1, 256*1024] 8*1024byte对齐 freelist[184, 208)
//
// 对齐申请的bytes
static inline size_t __roundUp(size_t bytes, size_t align)
{
return ((bytes + align - 1) & ~(align - 1));
}
static inline size_t roundUp(size_t bytes)
{
if (bytes <= 128)
{
return __roundUp(bytes, 8);
}
else if (bytes <= 1024)
{
return __roundUp(bytes, 16);
}
else if (bytes <= 8 * 1024)
{
return __roundUp(bytes, 128);
}
else if (bytes <= 64 * 1024)
{
return __roundUp(bytes, 1024);
}
else if (bytes <= 256 * 1024)
{
return __roundUp(bytes, 8 * 1024);
}
else
{
assert(false);
}
}
// 计算index
static inline size_t __index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
static inline size_t index(size_t bytes)
{
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[1] + group_array[0];
}
else if (bytes <= 64 * 1024)
{
return __index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1] + group_array[0];
}
else if (bytes <= 256 * 1024)
{
return __index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
}
else
{
assert(false);
return -1;
}
}
// 一次thread cache从中心缓存获取多少个对象
static size_t NumMoveSize(size_t bytes)
{
assert(bytes);
// [2, 512],一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 大对象一次批量上限低
size_t num = MAX_BYTES / bytes;
if (num < 2) num = 2;
else if (num > 512) num = 512;
return num;
}
};
3.3.2 central cache的申请逻辑
- central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中
- 申请内存:
- 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个
桶锁
,尽可能提高效率 - central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache
- central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给threadcache,就++use_count
- 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个
- CentralCache 申请逻辑代码框架:
CentralCache.h
//单例模式————饿汉模式
class CentralCache
{
public:
static CentralCache* getInstance()
{
return &_Inst;
}
Span* GetOneSpan(SpanList& list, size_t bytes); // 获取一个非空的span
size_t fetchRangeObj(void*& start, void*& end, size_t batchNum, size_t bytes); // 从中心缓存获取一定数量的对象给thread cache
private:
CentralCache(){}
CentralCache(const CentralCache&) = delete;
SpanList _spanLists[NFREELIST];
static CentralCache _Inst; // 类内声明,类外定义
};
CentralCache.cpp
CentralCache CentralCache::_Inst;
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t bytes)
{
// 查看当前的spanlist中是否还有非空的span
Span* it = list.begin();
while (it != list.end())
{
if (it->_freeList != nullptr) return it;
else it = it->_next;
}
list._mtx.unlock(); // 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
// 走到这里说没有空闲span了,只能找page cache要
PageCache::getInstance()->_pageMtx.lock();
Span* span = PageCache::getInstance()->newSpan(SizeClass::NumMovePage(bytes));
span->_isUse = true;
span->_objSize = bytes;
PageCache::getInstance()->_pageMtx.unlock();
// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span
// 计算span的大块内存的起始地址和大块内存的大小(字节数)
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t len = span->_n << PAGE_SHIFT;
char* end = start + len;
// 把大块内存切成自由链表链接起来
// 1、先切一块下来去做头,方便尾插
span->_freeList = start;
start += bytes;
void* tail = span->_freeList;
while (start < end)
{
NextObj(tail) = start;
tail = NextObj(tail);
start += bytes;
}
NextObj(tail) = nullptr; //****************************
// 切好span以后,需要把span挂到桶里面去的时候,先加锁
list._mtx.lock();
list.pushFront(span);
return span;
}
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::fetchRangeObj(void*& start, void*& end, size_t batchNum, size_t bytes)
{
size_t index = SizeClass::index(bytes);
_spanLists[index]._mtx.lock(); // 加锁
Span* span = GetOneSpan(_spanLists[index], bytes);
assert(span); // 最起码要拿到一个span
assert(span->_freeList); // 拿到了的span不能为空
// 从span中获取batchNum个对象
// 如果不够batchNum个,有多少拿多少
start = span->_freeList;
end = start;
size_t i = 0;
size_t actualNum = 1;
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++i;
++actualNum;
}
span->_useCount += actualNum;
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
_spanLists[index]._mtx.unlock(); // 解锁
return actualNum;
}
其中,疑难点在于:
- 此时
Common.h
里的方法增加如下
static const size_t PAGE_SHIFT = 13;
// 一次thread cache从中心缓存获取多少个对象
static size_t NumMoveSize(size_t bytes)
{
assert(bytes);
// [2, 512],一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 大对象一次批量上限低
size_t num = MAX_BYTES / bytes;
if (num < 2) num = 2;
else if (num > 512) num = 512;
return num;
}
static size_t NumMovePage(size_t bytes)
{
size_t num = NumMoveSize(bytes);
size_t npage = num * bytes;
npage >>= PAGE_SHIFT;
if (npage == 0) npage = 1;
return npage;
}
// 管理多个连续页大块内存跨度结构
struct Span
{
PAGE_ID _pageId = 0; // 页号
size_t _n = 0; // 页数
Span* _prev = nullptr; // 双向链表的结构
Span* _next = nullptr;
size_t _objSize = 0; // 切好的小对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
void* _freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false;
};
// 带头双向循环链表
class SpanList
{
public:
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
bool empty()
{
return _head->_next == _head;
}
void pushFront(Span* span)
{
insert(begin(), span);
}
Span* popFront()
{
Span* front = _head->_next;
erase(front);
return front;
}
Span* begin()
{
return _head->_next;
}
Span* end()
{
return _head;
}
void insert(Span* pos, Span* newSpan)
{
assert(pos);
assert(newSpan);
Span* prev = pos->_prev;
prev->_next = newSpan;
newSpan->_prev = prev;
newSpan->_next = pos;
pos->_prev = newSpan;
}
void erase(Span* pos)
{
assert(pos);
assert(pos != _head); //***********
Span* prev = pos->_prev;
Span* next = pos->_next;
1、条件断点
2、查看栈帧
//if (prev == nullptr)
//{
// int x = 0;
//}
prev->_next = next;
next->_prev = prev;
}
private:
Span* _head;
public:
std::mutex _mtx; // 桶锁
};
3.3.3 page cache的申请逻辑
-
thread cache和central cache的映射规则一致,但page cache就大大不同了
-
申请内存:
- 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4页page,4页page后面没有挂span,则向后面寻找更大的span,假设在10页page位置找到一个span,则将10页pagespan分裂为一个4页page span和一个6页page span
- 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1中的过程
- 需要注意的是central cache和page cache 的核心结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存
-
PageCache 申请逻辑代码框架:
PageCache.h
class PageCache
{
public:
static PageCache* getInstance()
{
return &_Inst;
}
Span* newSpan(size_t k); // 获取一个K页的span
std::mutex _pageMtx;
private:
PageCache(){}
PageCache(const PageCache&) = delete;
SpanList _spanLists[NPAGES];
static PageCache _Inst;
};
PageCache.cpp
PageCache PageCache::_Inst;
// 获取一个K页的span
Span* PageCache::newSpan(size_t k)
{
assert(k > 0);
// 先检查第k个桶里面有没有span
if (!_spanLists[k].empty())
{
Span* kSpan = _spanLists[k].popFront();
return kSpan;
}
// 检查一下后面的桶里面有没有span,如果有可以把它进行切分
for (size_t i = k + 1; i < NPAGES; ++i)
{
if (!_spanLists[i].empty())
{
Span* kSpan = new Span;
Span* nSpan = _spanLists[i].popFront();
// 在nSpan的头部切一个k页下来
// k页span返回
// nSpan再挂到对应映射的位置
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
return kSpan;
}
// 走到这个位置就说明后面没有大页的span了
// 这时就去找堆要一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].pushFront(bigSpan);
return newSpan(k);
}
}
- 此时
Common.h
里的方法增加如下
static const size_t NPAGES = 129;
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
#ifdef _WIN32
#include <windows.h>
#else
// ...
#endif
其中,疑难点在于:
3.3.4 申请逻辑结构图
3.4 释放内存的逻辑
3.4.1 thread cache的释放逻辑
- 释放内存:
- 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]
- 当链表的长度过长,则回收一部分内存对象到central cache
- ThreadCache 释放逻辑代码框架:
ThreadCache.h
class ThreadCache
{
public:
void* allocate(size_t bytes); // 申请内存对象
void deallocate(void* ptr, size_t bytes); // 释放内存对象
void* fetchFromCentralCache(size_t index, size_t bytes); // 从中心缓存获取对象
void ListTooLong(FreeList& list, size_t bytes); // 释放对象时,链表过长时,回收内存到中心缓存
private:
FreeList _freeLists[NFREELIST];
};
// TLS thread local storage 线程局部存储的声明
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
ThreadCache.cpp
void ThreadCache::deallocate(void* ptr, size_t bytes)
{
assert(ptr);
assert(bytes <= MAX_BYTES);
// 找对映射的自由链表桶,对象插入进入
size_t index = SizeClass::index(bytes);
_freeLists[index].push(ptr);
// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
if (_freeLists[index].size() >= _freeLists[index].maxSize())
{
ListTooLong(_freeLists[index], bytes);
}
}
void ThreadCache::ListTooLong(FreeList& list, size_t bytes)
{
void* start = nullptr;
void* end = nullptr;
list.popRange(start, end, list.maxSize());
// 把切出的list还给中心缓存,此时只需要给start,因为该list的end直接遍历到空就是了
CentralCache::getInstance()->releaseListToSpans(start, bytes);
}
- 此时
Common.h
里的方法增加如下
// 管理切分好的自由链表
class FreeList
{
public:
void push(void* obj)
{
assert(obj); // ************
//头插
NextObj(obj) = _freeList;
_freeList = obj;
++_size;
}
void pushRange(void* start, void* end, size_t len)
{
NextObj(end) = _freeList;
_freeList = start;
_size += len;
}
void* pop()
{
assert(_freeList); // **************
//头删
void* obj = _freeList;
_freeList = NextObj(obj);
--_size;
return obj;
}
void popRange(void*& start, void*& end, size_t len)
{
assert(len <= _size);
start = _freeList;
end = start;
for(size_t i = 0; i < len - 1; ++i)
{
end = NextObj(end);
}
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= len;
}
bool empty()
{
return _freeList == nullptr;
}
size_t& maxSize()
{
return _maxSize;
}
size_t size()
{
return _size;
}
private:
void* _freeList = nullptr;
size_t _maxSize = 1;
size_t _size = 0;
};
3.4.2 central cache的释放逻辑
- 释放内存:
- 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时
--use_count
。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,page cache中会对前后相邻的空闲页进行合并
- 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时
- CentralCache 释放逻辑代码框架:
CentralCache.h
//单例模式————饿汉模式
class CentralCache
{
public:
static CentralCache* getInstance()
{
return &_Inst;
}
Span* GetOneSpan(SpanList& list, size_t bytes); // 获取一个非空的span
size_t fetchRangeObj(void*& start, void*& end, size_t batchNum, size_t bytes); // 从中心缓存获取一定数量的对象给thread cache
void releaseListToSpans(void* start, size_t bytes); // 将一定数量的对象释放到span跨度
private:
CentralCache(){}
CentralCache(const CentralCache&) = delete;
SpanList _spanLists[NFREELIST];
static CentralCache _Inst;
};
CentralCache.cpp
void CentralCache::releaseListToSpans(void* start, size_t bytes)
{
size_t index = SizeClass::index(bytes);
_spanLists[index]._mtx.lock();
while (start)
{
void* next = NextObj(start);
Span* span = PageCache::getInstance()->MapObjectToSpan(start);
NextObj(start) = span->_freeList;
span->_freeList = start;
span->_useCount--;
// 说明span的切分出去的所有小块内存都回来了
// 这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
if (span->_useCount == 0)
{
_spanLists[index].erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;
// 释放span给page cache时,使用page cache的锁就可以了
// 这时把桶锁解掉
_spanLists[index]._mtx.unlock();
PageCache::getInstance()->_pageMtx.lock();
PageCache::getInstance()->ReleaseSpanToPageCache(span);
PageCache::getInstance()->_pageMtx.unlock();
_spanLists[index]._mtx.lock();
}
start = next;
}
_spanLists[index]._mtx.unlock();
}
其中,疑难点在于:
- 此时
Common.h
里的方法增加如下
// 管理多个连续页大块内存跨度结构
struct Span
{
PAGE_ID _pageId = 0; // 页号
size_t _n = 0; // 页数
Span* _prev = nullptr; // 双向链表的结构
Span* _next = nullptr;
size_t _objSize = 0; // 切好的小对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
void* _freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false;
};
其中,疑难点在于:
3.4.3 page cache的释放逻辑
- 释放内存:
- 如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片
- PageCache 释放逻辑代码框架:
PageCache.h
class PageCache
{
public:
static PageCache* getInstance()
{
return &_Inst;
}
Span* newSpan(size_t k); // 获取一个K页的span
Span* MapObjectToSpan(void* obj); // 获取从对象到span的映射
void ReleaseSpanToPageCache(Span* span); // 释放空闲span回到Pagecache,并合并相邻的span
std::mutex _pageMtx;
private:
PageCache(){}
PageCache(const PageCache&) = delete;
SpanList _spanLists[NPAGES];
static PageCache _Inst;
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
};
其中,疑难点在于:
PageCache.cpp
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;
std::unique_lock<std::mutex> lock(_pageMtx); // 加锁(RAII)
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于128 page的直接还给堆
if (span->_n > NPAGES - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
delete span;
return;
}
// 对span前后的页,尝试进行合并,缓解内存碎片问题
while (true)
{
PAGE_ID preId = span->_pageId - 1;
auto ret = _idSpanMap.find(preId);
if (ret == _idSpanMap.end()) break; // 前面的span不存在,不合并了
Span* preSpan = ret->second;
if (preSpan->_isUse == true) break; // 前面相邻页的span在使用,不合并了
if (preSpan->_n + span->_n > NPAGES - 1) break; // 合并出超过128页的span没办法管理,不合并了
//可以合并了
span->_pageId = preSpan->_pageId;
span->_n += preSpan->_n;
//被合并了就应该清理
_spanLists[preSpan->_n].erase(preSpan);
delete preSpan;
}
// 向后合并
while (true)
{
PAGE_ID nextId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nextId);
if (ret == _idSpanMap.end()) break; // 前面的span不存在,不合并了
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true) break;
if (nextSpan->_n + span->_n > NPAGES - 1) break;
span->_n += nextSpan->_n;
_spanLists[nextSpan->_n].erase(nextSpan);
delete nextSpan;
}
_spanLists[span->_n].pushFront(span);
span->_isUse = false;
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;
}
3.4.4 申请逻辑结构图
3.5 大于256k的内存申请逻辑
- 此时需要完善一些代码
ConcurrentAlloc.h
static void* ConcurrentAlloc(size_t bytes)
{
// 申请大块内存(256k < bytes),直接跳过thread cache和central cache,去page cache里申请newSpan
if (bytes > MAX_BYTES)
{
size_t align = SizeClass::roundUp(bytes);
size_t kpage = align >> PAGE_SHIFT;
PageCache::getInstance()->_pageMtx.lock();
Span* span = PageCache::getInstance()->newSpan(kpage);
span->_objSize = bytes;
PageCache::getInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else
{
// 通过TLS 每个线程无锁地获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
return pTLSThreadCache->allocate(bytes);
}
}
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::getInstance()->MapObjectToSpan(ptr);
size_t bytes = span->_objSize;
if (bytes > MAX_BYTES)
{
PageCache::getInstance()->_pageMtx.lock();
PageCache::getInstance()->ReleaseSpanToPageCache(span);
PageCache::getInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->deallocate(ptr, bytes);
}
}
PageCache.cpp => Span* PageCache::newSpan(size_t k)
// 获取一个K页的span
Span* PageCache::newSpan(size_t k)
{
assert(k > 0);
if (k > NPAGES - 1) // 大于128 page的直接向堆申请
{
void* ptr = SystemAlloc(k);
Span* span = new Span;
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
_idSpanMap[span->_pageId] = span;
return span;
}
else
{
// 同上...
}
}
PageCache.cpp => void PageCache::ReleaseSpanToPageCache(Span* span)
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于128 page的直接还给堆
if (span->_n > NPAGES - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
delete span;
_spanPool.Delete(span);
return;
}
// 同上...
}
- 此时
Common.h
里的方法增加如下
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr) throw std::bad_alloc();
return ptr;
}
// 直接把空间还给堆
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
// sbrk unmmap等
#endif
}
/
static inline size_t roundUp(size_t bytes)
{
if (bytes <= 128)
{
return __roundUp(bytes, 8);
}
else if (bytes <= 1024)
{
return __roundUp(bytes, 16);
}
else if (bytes <= 8 * 1024)
{
return __roundUp(bytes, 128);
}
else if (bytes <= 64 * 1024)
{
return __roundUp(bytes, 1024);
}
else if (bytes <= 256 * 1024)
{
return __roundUp(bytes, 8 * 1024);
}
else
{
return __roundUp(bytes, 1 << PAGE_SHIFT); // 修改这里
}
}
- 扩展: windows和Linux下如何直接向堆申请页为单位的大块内存
3.6 用定长内存池替代new(malloc)
- 我们在
3.1
写的定长内存池是直接从堆申请内存的,所以采用它之后,这个项目也就彻底与malloc解耦了 - 并且该定长内存池效率会比malloc高
- 此时对以下代码进行调整
PageCache.h
PageCache.cpp
ConcurrentAlloc.h
四、多线程场景测试
4.1 构造多线程并发场景
- 多线程并发环境下,对比malloc和ConcurrentAlloc申请和释放内存效率对比
- 导入
BenchMark.cpp
进行测试
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
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 + 0);
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n", nworks, rounds, ntimes, free_costtime + 0);
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n", nworks, nworks * rounds * ntimes, 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, malloc_costtime + 0);
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n", nworks, rounds, ntimes, free_costtime + 0);
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n", nworks, nworks * rounds * ntimes, 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;
}
测试结果为:
4.2 使用性能测试工具分析瓶颈
- 可以看到,我们的高并发内存池比起malloc还是差了不少
- 为了确定我们的内存池的瓶颈,下面采用VS2019自带的性能检测工具
- 调试选项中选择性能探查器
- 开始检测,等待分析
- 分析性能瓶颈,可以看到ConcurrentFree()占用时间最多,究其原因是因为加锁
4.3 突破瓶颈的方案
- 翻阅tcmalloc源码,发现其使用
基数树
进行优化 - 单层基数树代码
// Single-level array
template <int BITS>
class TCMalloc_PageMap
{
private:
static const int LENGTH = 1 << BITS;
void** array_;
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap()
{
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);
}
void* get(Number k) const
{
if ((k >> BITS) > 0)
{
return nullptr;
}
return array_[k];
}
void set(Number k, void* v)
{
array_[k] = v;
}
};
- 于是修改以下代码:
PageCache.h
PageCache.cpp
- 此时再运行一次代码,发现性能极大地提高
- 再用性能检测工具检测看看,可以发现瓶颈不再是加锁问题,因为基数树代替了unordered_map,不需要加锁
- 分析:
- 基数树底层其实就是采用直接定址法的哈希,对于咱这个内存池,可以提前开好空间
- unordered_map插入时可能因为扩容而调整结构
- 基于以上两点,unordered_map的读写不分离,需要加锁。避免一个线程在读的时候另一个线程在写并改变了结构
- 而基数树提前开了空间,插入数据不可能扩容,不会改变结构。因此读写分离,读不需要加锁,因为要读的span和要写的span不会冲突(直接定址法)