项目介绍
当前项⽬是实现⼀个⾼并发的内存池,他的原型是google的⼀个开源项⽬tcmalloc,tcmalloc全称
Thread-CachingMalloc,即线程缓存的malloc,实现了⾼效的多线程内存管理,⽤于替代系统的内存分配相关的函数(malloc、free)。
这个项目就是将tcmalloc最核⼼的框架简化后拿出来,模拟实现出⼀个⾃⼰的⾼并发内存池,⽬的
就是学习tcamlloc的精华。
什么是内存池
提到内存池我们首先说一下池化技术,池化技术程序员先向系统申请过量的资源,然后自己管理以备不时只需,而之所以申请大量内存是因为申请资源有较大的开销,不如提前申请好大量空间提高效率。
在计算机中,有很多使⽤“池”这种技术的地⽅,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若⼲数量的线程,让它们处于睡眠状态,当接收到客⼾端的请求时,唤醒池中某个睡眠的线程,让它来处理客⼾端的请求,当处理完这个请求,线程⼜进⼊睡眠状态。
内存池是指程序预先从操作系统申请⼀块⾜够⼤内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,⽽是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,⽽是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
内存池主要解决的是效率的问题,其次如果作为系统的内存分配器的⻆度,还需要解决⼀下内存碎片的问题。那么什么是内存碎片呢?
内存碎片分为内碎片和外碎片,外碎⽚是⼀些空闲的连续内存区域太⼩,这些内存空间不连续,以⾄于合计的内存⾜够,但是不能满⾜⼀些的内存分配申请需求。内部碎⽚是由于⼀些对⻬的需求,导致分配出去的空间中⼀些内存⽆法被利⽤。内碎⽚问题,我们后⾯项⽬就会看到,那会再进⾏更准确的理解。
而malloc就是我们平时经常在堆上开辟空间的函数,C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,⽽malloc就是⼀个内存池。malloc相当于向操作系统“批发”了⼀块较⼤的内存空间,然后“零售”给程序⽤。当全部“售完”或程序有⼤量的内存需求时,再根据实际需求向操作系统“进货”。malloc的实现⽅式有很多种,⼀般不同编译器平台⽤的都是不同的。⽐如windows的vs系列⽤的微软⾃⼰写的⼀套,linux中gcc⽤的glibc中的ptmalloc。
malloc实际就是一个大的内存池!!!
那我们这里都有malloc,为什么还要学习tcmalloc?tcmalloc是google为了完善C/C++中多线程下的应用,比glibc2.3的malloc快,在多线程下tcmalloc有优势。
高并发内存池整体框架设计
现在很多计算机都是多核多处理器,因为单核的性能是有限制的。所以我们现在很多开发环境都是多核多线程的,在申请内存时一定会有激烈的锁竞争问题。这就是malloc的性能瓶颈,而我们设计的项目原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以我们这次设计的内存池要解决下面几个方面:1.性能问题 2.多线程环境下锁竞争问题3.内存碎片问题。
concurrent memory pool主要由以下3个部分构成:
1. thread cache:线程缓存是每个线程独有的,⽤于⼩于256KB的内存的分配,线程从这⾥申请内存不需要加锁,每个线程独享⼀个cache,这也就是这个并发线程池⾼效的地⽅。
2. central cache:中⼼缓存是所有线程所共享,thread cache是按需从central cache中获取的对
象。central cache合适的时机回收thread cache中的对象,避免⼀个线程占⽤了太多的内存,⽽其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的⽬的。central cache是存在竞争的,所以从这⾥取内存对象是需要加锁,⾸先这⾥⽤的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以这⾥锁竞争不会很激烈。
3. page cache:⻚缓存是在central cache缓存上⾯的⼀层缓存,存储的内存是以⻚为单位存储及分配的,central cache没有内存对象时,从page cache分配出⼀定数量的page,并切割成定⻓⼤⼩的⼩块内存,分配给central cache。当⼀个span的⼏个跨度⻚的对象都回收以后,page cache会回收central cache满⾜条件的span对象,并且合并相邻的⻚,组成更⼤的⻚,缓解内存碎⽚的问题。
总结一下:
thread cache为了解决锁竞争问题,每个线程都有属于自己的thread cache,当自己的thread cache有内存就不会与其余线程去竞争。
central cache起到居中调度的作用,不会让其中一个thread cache占用过多的内存,从而达到均衡分配。
page cache可以从操作系统直接获取内存,并且可以进行内存回收合并,解决了内存碎片问题。
高并发内存池--thread cache 申请
thread cache是哈希桶结构,每个桶是⼀个按桶位置映射⼤⼩的内存块对象的⾃由链表。每个线程都会有⼀个thread cache对象,这样每个线程在这⾥获取对象和释放对象时是⽆锁的。
上图就是thread cache的结构,其中就是哈希桶,而对应的每个桶节点挂对应内存大小的内存自由链表。小于256KB的内存都是在thread cache中申请的。而如果在每一种类型的内存挂一个节点,就需要256*1024byte的节点,所以我们不要这样做。
我们可以做一些牺牲,第一个节点挂8byte,第二个节点挂16byte,依次类推,后面的对齐规则有所变化,并不是一直以8byte对齐。当我们申请8字节以内(包括8字节)的就分配第一个节点中的内存,需要大于8byte小于等于16byte就取第二个节点挂的内存,依次类推。这样的好处就是可以减少节点数,但是会增加内存浪费,这就是我们之前所说的内碎片问题,举个例子只需要1byte,我们就给予8byte的内存,浪费7byte内存。
总体流程:
申请内存:
1. 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶⾃由链表下标i。
2. 如果⾃由链表_freeLists[i]中有对象,则直接Pop⼀个内存对象返回。
3. 如果_freeLists[i]中没有对象时,则批量从central cache中获取⼀定数量的对象,插⼊到⾃由链表并返回⼀个对象。
释放内存:
1. 当释放内存⼩于256k时将内存释放回thread cache,计算size映射⾃由链表桶位置i,将对象Push到_freeLists[i]。
2. 当链表的⻓度过⻓,则回收⼀部分内存对象到central cache。
首先我们需要整体逻辑无论是什么模块都需要哈希桶以及自由链表,所以我们需要在公共部分创建一个公共链表:
创建自由链表的数据结构:
// 获取内存对象中存储的头4 or 8字节值,即链接的下⼀个对象的地址
static void*& NextObj(void* obj)
{
return *((void**)obj);
}
//管理切分好的小对象的自由链表
class FreeList
{
public:
void Push(void* obj)
{
//头插
NextObj(obj) = _freeList;
_freeList = obj;
++_size;
}
void* Pop()
{
//头删
void* obj = _freeList;
_freeList = NextObj(obj);
--_size;
return obj;
}
void PushRange(void* start, void* end, size_t n)
{
NextObj(end) = _freeList;
_freeList = start;
_size += n;
}
void PopRange(void*& start, void*& end, size_t n)
{
assert(n <= _size);
start = _freeList;
end = start;
for (size_t i = 0; i < n - 1; i++)
{
end = NextObj(end);
}
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
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; //Freelist中有多少个节点
};
内存对齐与映射桶下标方法:
当我们需要写thread cache时我们发现,当我们需要申请一个内存时,我们需要进行内存对齐,1~8byte都需要分配8byte,所以我们还需要一个类来管理内存对齐。但是内存对齐这个我们一定是以8byte开始,因为小于8byte会在内存中存不下下一个指针。但是我们又不是只以8byte对齐,因为创建的自由链表数量也很多有32768个。所以这里有一个巧妙的办法。
// 整体控制在最多10%左右的内碎⽚浪费
// [1,128] 8byte对⻬ freelist[0,16)
// [128+1,1024] 16byte对⻬ freelist[16,72)
// [1024+1,81024] 128byte对⻬ freelist[72,128)
// [8*1024+1,641024] 1024byte对⻬ freelist[128,184)
// [64*1024+1,256*1024] 8*1024byte对⻬ freelist[184,208)
1Byte-256KB分为5段,每段取⼀个对⻬数,整体控制最⼤浪费率是10%~11%左右。
⽐如申请129byte,按16byte对⻬,就会给144byte,浪费了15byte,浪费率为15/144=10.4%。
申请1025,按128Byte对⻬,给1152字节,浪费127Byte,浪费率为127/1152=11%;
只有分段映射取值才能控制浪费率,否则给统⼀对⻬数,对⻬数太⼩,桶太多了,切得太碎。对⻬
数太⼤,申请内存⼩时,浪费太多了,⽐如你要8byte,按128byte对⻬,浪费120byte。
上⾯的分区间给的对⻬数只是⼀种⽅式,并不是必须这么给,假设你想让最⼤浪费率控制在最⼤
5%的浪费率,那么可以把区间分更多⼀些,对⻬数放⼩⼀些,每个区间只需要看最小值浪费率即
可。那么就可以设计设计出⼀套新的对⻬规则。也是就说对⻬映射⽅式有很多种,分段给对⻬数的
⽅式是不变的,具体怎么给,就看不想控制最⼤浪费率是多少的问题。
其次我们还要进行对桶的映射,就是找到内存与桶的位置的下标,这里我们也需要一个函数来计算出来,这里我们直接使用一个类来设计。
// 管理对⻬和映射等关系
class SizeClass
{
public:
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 {
return _RoundUp(bytes, 1 << PAGE_SHIFT);
}
return -1;
}
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中我们目前就需要这些对象和方法,现在我们可以完善thread cache对象,这里我们需要的函数先提前声明:
#pragma once
#include "Common.hpp"
class ThreadCache
{
public:
// 申请和释放内存对象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
// 从中⼼缓存获取对象
void* FetchFromCentralCache(size_t index, size_t size);
//当_freelists内存过长时,将多余内存返回给中心内存
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[NFREELIST];
};
// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
当多个线程时,我们每个线程应该什么时间创建threadCache,每个threadcache如何对应自己的线程都是一个问题。当两个线程同时创建threadcache,我们需要将对应的线程和threadcache进行数组存储,是不是还需要加锁,那我们应该如何解决这个无锁问题。
这里我们可以用到TLS(线程局部存储),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。
在window与Linux中都有对应的API和静态语言级别用法,上述代码中我们就使用了windows的静态语法实现,这样就可以无锁创建threadcache。
静态TLS的原理:
TLS 的实现依赖于操作系统的支持,不同操作系统的实现方式略有不同,但总体思路一致:为每个线程分配独立的存储空间,并通过线程上下文切换来管理这些空间 。
在X86CPU上,将为每次引用的静态变量TLS变量生成3个辅助机器指令。如果在进程中创建了另一个线程,那么系统就要将它捕获并自动分配给另一个内存块,以便存放新线程的静态TLS变量访问权,不能访问属于其他线程的TLS变量。
声明了_declspec(thread)的变量,会为每一个线程创建一个单独拷贝。 当我们使用线程,然后申请空间时我们就可以给每个线程创建一个属于自己的thread cache。
我们还要进行封装一层,这样就可以让用户不考虑具体的细节,还可以解耦合,但是thread cache的一些情况都需要进行centralcache来进行补充,我们先来看central cache。
高并发内存池--central cache 申请
central cache也是⼀个哈希桶结构,他的哈希桶的映射关系跟thread cache是⼀样的。不同的是他的每个哈希桶位置挂是SpanList双向链表结构,不过每个映射桶下⾯的span中的⼤内存块被按映射关系切成了⼀个个⼩内存块对象挂在span的⾃由链表中。而Central cache作用是承上启下,所以我们在链表选择了双向链表,当span中的内存全部收回时我们要进行回收到page cache中,而span的回收可能发生在链表的任何地方,方便进行中间的插入和删除。
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#endif
// 管理多个连续页大块内存跨度结构
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;
};
_useCount是用来回收内存的,_freeList是central cache用来切小内存的,pageId是用来记录页号,也是用来寻找连续页为了合并成更大内存,这里的page cache、central cache都是用这个Span对象,可以更好的复用。
申请内存:
1. 当thread cache中没有内存时,就会批量向central cache申请⼀些内存对象,这⾥的批量获取对象的数量使⽤了类似⽹络tcp协议拥塞控制的慢开始算法;central cache也有⼀个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不
过这⾥使⽤的是⼀个桶锁,尽可能提⾼效率。
2. central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请⼀个新的
span对象,拿到span以后将span管理的内存按⼤⼩切好作为⾃由链表链接到⼀起。然后从span中
取对象给thread cache。
3. central cache的中挂的span中use_count记录分配了多少个对象出去,分配⼀个对象给thread
cache,就++use_count。
释放内存:
1. 当thread_cache过⻓或者线程销毁,则会将内存释放回central cache中的,释放回来时--
use_count。当use_count减到0时则表⽰所有对象都回到了span,则将span释放回page cache,
page cache中会对前后相邻的空闲⻚进⾏合并。
全局只有⼀个central cache,他负责居中调度,分配和回收thread cache的内存。同时对接
page cache,获取⼤块内存和回收⼤块内存。
创建一个带头双向链表的结构:
class SpanList
{
public:
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _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;
prev->_next = next;
next->_prev = prev;
}
bool Empty()
{
return _head->_next == _head;
}
void PushFront(Span* newspan)
{
Insert(Begin(), newspan);
}
Span* PopFront()
{
Span* front = _head->_next;
Erase(front);
return front;
}
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;
}
private:
Span* _head;
public:
std::mutex _mtx;
};
然后我们的central cache不是每个线程独占,是公用一份所以我们可以使用单例饿汉模式设计,私有构造函数,并删除其对应的拷贝构造与赋值构造函数。
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInit;
}
// 获取⼀个⾮空的span
Span * GetOneSpan(SpanList& list, size_t size);
// 从中⼼缓存获取⼀定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
// 将⼀定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t size);
private:
SpanList _spanLists[NFREELIST];
private:
CentralCache()
{}
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
static CentralCache _sInit;
};
当我们将central cache结构完成后我们就可以写thread cache中当thread cache没有内存时向central cache要的函数。但是我们要考虑每一次给多少小内存块,一次给一个会导致索要频繁降低效率,一次给大量可能导致空间浪费,所以我们使用一个慢启动机制来控制给予内存块的数量。
慢开始反馈调节算法
// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
// 3、size越大,一次向central cache要的batchNum就越小
// 4、size越小,一次向central cache要的batchNum就越
首先使用NumMoveSize函数来控制上限与下限,哈希桶最大是256kb, 而我们所需要的小内存可能是8byte~256kb的大小,所以我们给予thread cahche的内存随着需要小内存增大而减小,当只需要8byte时我们算出给32768个内存块太多了可能会导致空间浪费,所以我们把上线控制到521个,而反之需要256kb时算出只能给1个所以我们控制下限为2。 这样做首先解决了给与空间过多或过少的极端情况。
// 一次thread cache从中心缓存获取多少个
static size_t NumMoveSize(size_t size)
{
assert(size > 0);
// [2, 512],一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 小对象一次批量上限低
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
但是这样做没有达到缓慢增长,所以我们还在FreeList对象中设置了一个成员变量_maxSize = 1,每创建一个对象起始值都是1,当thread cache向central cache索要内存时,就会触发下面的代码,这样就可以实现一个缓慢增长,但是会有一个峰值不会无限增长!
size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));//要申请多少个空间块
if (_freeLists[index].MaxSize() == batchNum)
{
_freeLists[index].MaxSize() += 1;
}
当我们写完后,我们就要在central cache中获取内存,创建单例申请对象,但是我们要进行分情况讨论,我们理想是要hatchNum个内存块,但是实际可能获取不了这么多,那我们就应该以实际为准,所以Central cache中的FetchRangeObj函数的返回值为实际获得的内存块数量,而我们还需要获取的起始指针与终极指针,所以还需要传入两个输出参数。
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);//返回值是实际申请了actualNum个span块
assert(actualNum > 0);
if (actualNum == 1)
{
assert(start == end);
return start;
}
else
{
_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
return start;
}
上述代码,如果申请了一个内存块直接返回给线程,而如果申请了多个内存块我们就需要进行将多出来的内存块插入到thread cache中的自由链表中去。
然后我们就可以完成central cache中的FetchRangeObj函数:
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();
Span* span = GetOneSpan(_spanLists[index], size);
assert(span);
assert(span->_freeList);
// 从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->_freeList = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
_spanLists[index]._mtx.unlock();
return actualNum;
}
因为FetchRangeObj函数要用到GetOneSpan函数,这个函数联系到Page Cache中,因为central cache也有可能没有span,就需要向Page cache进行申请内存所以我们先看Page cache架构,再来写GetOneSpan。
高并发内存池--page cache 申请
申请内存:
1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更⼤⻚寻找⼀个span,如果找到则分裂成两个。⽐如:申请的是4⻚page,4⻚page后⾯没有挂span,则向后⾯寻找更⼤的span,假设在10⻚page位置找到⼀个span,则将10⻚page span分裂为⼀个4⻚page span和⼀个6⻚page span。
2. 如果找到_spanList[128]都没有合适的span,则向系统使⽤mmap、brk或者是VirtualAlloc等⽅式申请128⻚page span挂在⾃由链表中,再重复1中的过程。
3. 需要注意的是central cache和page cache的核心结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache⼀样的⼤⼩对⻬关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成⼩块内存的⾃由链表。⽽page cache中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i⻚内存。
释放内存:
1. 如果central cache释放回⼀个span,则依次寻找span的前后page id的没有在使⽤的空闲span,
看是否可以合并,如果合并继续向前寻找。这样就可以将切⼩的内存合并收缩成⼤的span,减少内存碎⽚。
windows和Linux下如何直接向堆申请⻚为单位的⼤块内存:
上图就是一个特殊情况,当第一次向page cache申请内存时,就会出现page cache没有任何大内存块,然后调用系统调用来申请大块page, 然后进行进一步切分。
而回收内存也是早就计划好的,SpanList链表就是有一些成员变量,当thread cache释放内存后,central cache就会回收小内存块,而当usecount计数为0时证明这个span中的所有小内存全回来了,这时我们就可以进行下一步回收,将span交给page cache, 然后每个span就有自己的页号,当page cache回收到这个页号时,会看前后的相邻页号,如果都在page cache中则可以进行合并成为更大块内存。
而这里就不能使用桶锁,因为桶锁的竞争太高了,导致性能下降。因为当1和2都没有时都会向下寻找,导致频繁的加锁解锁,所以用一把大锁锁住全部即可。
现在我们就可以完善central cache中的GetOneSpan函数,当遍历当前位置hash桶的SpanList,如果其下面挂的FreeList还有剩余空间我们就可以返回,如果没有我们就直接向pagecache取要大块span。
下述代码是遍历central cache中SpanList的代码:
Span* it = list.Begin();
while (it != list.End())
{
if (it->_freeList != nullptr) {
return it;
}
else
{
it = it->_next;
}
}
如果循环走完还没有返回证明central cache中没有合适的span,所以我们就继续往下走,去page cache中要内存。生成一个单例的page cache 然后去要对应的页。
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
但是page cache中的哈希桶的映射关系与前两个不太一样,是按照page来映射的,这里我们设定的一个page是8kb,我们知道现在上面两个模块中所需要的size大小,我们需要一个函数来进行转换,使用NumModePage函数进行转换:
// 计算一次向系统获取几个页
// 单个对象 8byte
// ...
// 单个对象 256KB
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);
size_t npage = num * size;
npage >>= PAGE_SHIFT;
if (npage == 0)
npage = 1;
return npage;
}
我们使用NumMoveSize函数计算出对应需要小内存的数量,然后再乘以其小内存的大小得到所需的总页数,然后定义PAGE_SHIFT宏为13其实就是除以8kb算出需要多少页,如果算出页数为0至少给一页。
然后将得到的span先切分好挂在自己的FreeList中,然后将span挂在central cache对应的哈希桶中即可。
这里我们使用尾插来插入FreeList中,因为尾插可以让span中分配的内存是以顺序的形式进行消耗,这样的命中和缓存利用率比较高。
// 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
list._mtx.unlock();
//走到这里就没有空闲的span,需要去PageCache中去获取新的span
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_isUse = true;
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span
//计算span的大块内存的起始地址和大块内存的大小
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
//把大块内存切成自由链表链在_freelist上
span->_freeList = start;
start += size;
void* tail = span->_freeList;
while (start < end)
{
NextObj(tail) = start;
tail = start;
start += size;
}
NextObj(tail) = nullptr;
// 切好span以后,需要把span挂到桶里面去的时候,再加锁
list._mtx.lock();
list.PushFront(span);
这里我们去访问page cache时一定要把central cache中的锁解掉,虽然这里在申请内存时没有影响,当另一个线程来访问如果没有内存还会到page cache前阻塞,但是当我们释放时就可以访问这个桶,将内存还回去,如果有桶锁还内存可能也会被阻塞。当将span切分好后在加上锁插入即可。
然后我们将page cache中的大锁(锁整个page cache)加到获取page cache这个单例这里,这样可读性更高,更容易理解。
这里实际物理上是连续的,只是形式上是链表!!!
#pragma once
#include "Common.hpp"
#include"ObjectPool.hpp"
#include"PageMap.hpp"
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
//获取一个k页的span
Span* NewSpan(size_t k);
//获得从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到Pagecache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
std::mutex _pageMtx;
private:
SpanList _spanLists[NPAGES];
ObjectPool<Span> _spanPool;
//std::unordered_map<PAGE_ID, Span*> _idSpanMap;
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
PageCache()
{}
PageCache(const PageCache&) = delete;
static PageCache _sInst;
};
上述代码就是我们的page cache架构,创建一个单例模式,然后完善NewSpan函数,获取一个k页的span。而我们的整体思路就是先在page cache中k桶的位置找span, 如果有就返回没有就向下寻找,如果找到就将找到的大内存块先从桶中拿出来,再进行切分,切分成一个k页的span和一个n-k的span, k页的span直接返回给central cache,n-k页的span挂到n-k的tong上。
高并发内存池--thread cache 释放
当我们thread cache中存储的节点过多且没有被使用,我们就应该回收内存,而不应该一直放在thread cache中导致空间浪费。而thread cache的回收思路很简单:在FreeList对象中有成员变量_size记录freelist自由链表中有多少个节点,而还有一个_maxsize来记录一次最多申请多少个节点,_maxsize是为了缓慢增长算法而生的,而现在我们可以用这两个变量来选择是否回收多余节点,当_maxsize与_size相等时就回收对应小内存。
void ThreadCache::Deallocate(void* ptr, size_t size)
{
assert(ptr);
assert(size <= MAX_BYTES);
//找对应映射的自由链表桶,对象插入进去
size_t index = SizeClass::Index(size);
_freeLists[index].Push(ptr);
//当链表长度大于一次批量申请的内存时开始还给centralcache
if (_freeLists[index].MaxSize() < _freeLists[index].Size())
{
ListTooLong(_freeLists[index], size);
}
}
ListTooLong函数就是将这个哈希桶对应的小内存向central cache返回的函数。
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
高并发内存池--Central cache 释放
当我们要将thread cache中的内存回收到central cache后那些小内存块是不连续的,所以我们就要去判断哪些小内存是对应span中分出的内存,这里我们有个巧妙的办法。因为我们知道每个小内存的地址,也知道central cache中的span中的页号,这些页号都是由每个span的起始地址转换而来的,我们只要转换回去然后对每一个回收的小内存的地址除以每页大小都是对应每个小内存的页号。
我们可以利用下面的程序来验证出结果,输出的地址对应的页号都是2000:
void TestAddressShift()
{
PAGE_ID id1 = 2000;
PAGE_ID id2 = 2001;
char* p1 = (char*)(id1 << PAGE_SHIFT);
char* p2 = (char*)(id2 << PAGE_SHIFT);
while (p1 < p2)
{
cout << (void*)p1 << ":" << ((PAGE_ID)p1 >> PAGE_SHIFT) << endl;
p1 += 8;
}
}
每个span都有对应的页号,但是当我们算出每个小内存对应的页号,但是central cache中的每个桶挂的span很多,我们如果暴力寻找一个个变量也行,但是时间复杂度太高了,这时我们可以使用一个哈希表来记录页号与span的对应关系,这样我们就可以用O(1)的时间复杂度找到每个小内存对应span的位置。我们将这个哈希表写入page cache中去,因为到时候page cache也需要使用这个哈希表。这就是为什么我们在page cache对象看到有哈希表。
这个哈希表我们在每次从堆中申请出新内存后添加进哈希表,所以我们在page cache中的GetOneSpan函数中写:
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
我们就需要一个函数,进行查找对应关系:
/*std::unique_lock<std::mutex> lock(_pageMtx);
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}*/
然后我们就可以实现对应的小内存回收函数:
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
size_t index = SizeClass::Index(size);
_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->_next = nullptr;
span->_prev = nullptr;
span->_freeList = 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();
}
当我们一个span的usecount减到0时证明这个span中的所有小内存已经返回,所以我们就使用ReleaseSpanToPageCache函数,将这个span 收到page cache中,为合并大内存做准备。这时我们的central cache已经完成。
高并发内存池--Page cache 释放
当我们想要将span进行拼接时,我们回收一个span,然后去寻找前后的span是否是空闲的,如果是空闲的我们就可以合并成一个大块内存。而寻找前后内存就需要用到span中的pageid参数与n参数,假设2000的span被回收,且span的大小为5页,我们就要找2000之前的页号1999和2000后5页的序号2005是否空闲,如果有一个空闲就可以进行合并。
而是否空闲就取决于span是否被分配到central cache中去,我们不能使用usecount等于0来判断是否空闲,因为usecount是否空闲是处于临界区的不加锁的,当central cache刚得到一块内存时要将大块内存切成小块挂到_freelist上时usecount也是0,pagecache在使用向前向后搜索时搜索到usecount等于0,让正在使用的span被当作没用又合并回收掉。(这些内存实际物理位置都是没有变的,无论page cache还是central cache都能看到,只是在逻辑上好像分走了),所以我们在span的对象中引入_isUse参数,表面span是否被使用。
当_span分配到central cache时就要让false变为true标记为已使用,表示不能被回收。
我们之前已经将切分好小块的kspan全部存入了哈希表,但是剩下的nspan大块内存还没有存入哈希表,这里我们只需要存入大内存的前后两个页号就可以,不需要每个页号都存进去,因为我们前后合并寻找只找首位页号,不找中间的页号,而且小内存的页号我们都全部存进去了,不存在漏掉的情况。
// 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
// 进行的合并查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
我们万事俱备现在就可以进行合并内存了:
void PageCache::ReleaseSpanToPageCache(Span* span)
{
//对span前后的页,尝试进行合并缓解内存碎片问题
//向前合并
while (1)
{
PAGE_ID prevId = span->_pageId - 1;
//auto ret = _idSpanMap.find(prevId);
////前面的页号没有
//if (ret == _idSpanMap.end())
//{
// break;
//}
auto ret = (Span*)_idSpanMap.get(prevId);
if (ret == nullptr)
{
break;
}
//前面相邻页的span在使用
Span* prevSpan = ret;
if (prevSpan->_isUse == true)
{
break;
}
//合并出超过128页的span,无法管理
if (prevSpan->_n + span->_n > NPAGES - 1)
{
break;
}
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
_spanLists[prevSpan->_n].Erase(prevSpan);
/*delete prevSpan;*/
_spanPool.Delete(prevSpan);
}
//向后合并
while (1)
{
PAGE_ID nextId = span->_pageId + span->_n;
/*auto ret = _idSpanMap.find(nextId);
if (ret == _idSpanMap.end())
{
break;
}*/
auto ret = (Span*)_idSpanMap.get(nextId);
if (ret == nullptr)
{
break;
}
Span* nextSpan = ret;
if (nextSpan->_isUse == true)
{
break;
}
if (nextSpan->_n + span->_n > NPAGES - 1)
{
break;
}
span->_n += nextSpan->_n;
_spanLists[nextSpan->_n].Erase(nextSpan);
/*delete nextSpan;*/
_spanPool.Delete(nextSpan);
}
_spanLists[span->_n].PushFront(span);
span->_isUse = false;
/*_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;*/
_idSpanMap.set(span->_pageId, span);
_idSpanMap.set(span->_pageId + span->_n - 1, span);
}
我们无论向上还是向下寻找都是while死循环,就是为了找到直到没有连续的页后停止,一直找到头。
强调一下当合出128页后就不能合并了,因为这样就没有地方挂这个大内存。我们还要将合并的大内存放入哈希表中后面如果要合成更大的页还需要使用。那为什么要更新哈希表呢,哈希表存的是页号与一个连续span的首指针,一个span中有多个页号,而无论这个页号是哪个都指向span的首指针,因为内存合并后会变长,所以前后指针可能会变,保险起见前后都要更新一下,这个问题我想了很久!!
特殊情况处理
我们的内存池基本已经搞定,但是有一些特殊情况需要我们考虑,当申请内存小于256kb, 我们所以的逻辑是行的通的,但是当内存大于256kb时thread cache与central cache目前可以控制的内存为256kb,但是pagecache可以挂128page的大块内存,就是128 * 8 * 1024kb。我们就得分情况讨论:1.当内存大于256kb小于128 * 8 * 1024kb时我们直接向page cache要内存,当申请内存大于128 * 8 * 1024kb时直接使用系统调用分配内存。
这时我们就要处理一下之前写过的函数,将这些特殊情况加入进去。
ConcurrentAlloc函数:
if (size > MAX_BYTES)
{
size_t alignSize = SizeClass::RoundUp(size);
size_t kpage = alignSize >> PAGE_SHIFT;
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
我们先将内存进行对齐,然后算出对应的所需的页数,传入NewSpan中直接申请大页内存。然后返回即可。
如果是大于128页的内存,我们在NewSpan函数中直接向系统索要内存,增加NewSpan函数:
if (k > NPAGES - 1)
{
void* ptr = SystemAlloc(k);
/*Span* span = new Span;*/
Span* span = _spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
/*_idSpanMap[span->_pageId] = span;*/
_idSpanMap.set(span->_pageId, span);
return span;
}
释放流程也应该做出相对应的调整:
ConcurrentFree函数:
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
我们直接找pagecache回收,不找前两层的事情了。大于128页的也是直接释放掉:
//大于128page直接还给堆
if (span->_n > NPAGES - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
/*delete span*/;
_spanPool.Delete(span);
return;
}
使用定长内存池摆脱new与delete
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)
{
// 显示调用析构函数清理对象
obj->~T();
// 头插
*(void**)obj = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr; // 指向大块内存的指针
size_t _remainBytes = 0; // 大块内存在切分过程中剩余字节数
void* _freeList = nullptr; // 还回来过程中链接的自由链表的头指针
};
前面的代码其实我们已经早就使用这个定长内存池了。我们将newhewdelete替换即可。
性能瓶颈以及解决方式——基数树
我们测试完成的代码发现可能在不同桶申请内存自己写的与malloc消耗时间相差不大,如果在同一个桶上申请多个内存,自己写的性能甚至不如malloc,主要的瓶颈就是哈希表的查找以及哈希表加的锁的竞争,当我们查的时间越长,阻塞越严重导致性能越低
后来查阅源码发现他⽤基数树查找,居然是⽆锁的。后来仔细理解基数树发现他的设计⾮常巧妙,基数树也是⼀个哈希结构,存储{⻚号,Span*}的键值对,他提前给⻚号映射Span的地址分配好存储空间,申请内存时分配⼀个Span,将{⻚号,Span*}插⼊基数树不需要加锁,因为空间开好了且这时没有其他线程会访问这个⻚号位置且提前开好了空间。释放内存时,CentralCache⼤量在基数中⽤⻚号查询span,不需要加锁,因为这会没有⼈会对这个⻚号位置映射修改。⼀个Span空闲了归还PageCache时,合并Span后,修改{⻚号,Span*}也不需要加锁,这时Span已经回到PageCache了,CentralCache中不会⽤这个⻚号查询Span。基数树的设计不像红⿊树哈希表等,每个空间是提前开好的,⼀个位置的访问不会被其他位置影响,红⿊树查询⼀个节点,同时在插⼊其他节点,可能会引发旋转,所以不同值映射位置会互相影响,必须加锁。这⾥基数树,不同位置不会互相相同,同⼀个位置的插⼊、查询、修改不会同时发⽣,相当于读写时分离的,所以不需要加锁。
基数树是个多叉树!
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS;
void** array_;
}
这是一层基数树,就是一个直接定址法的哈希表,其中提供了一个非类型模板参数BITS,我们需要填写就是在32/64位操作系统下(填32-PAGE_SHIFT,64-PAGE_SHIFT),就是为了算出需要多少个位存储页号,一般常用于32位机器下。
// 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;
static const int ROOT_LENGTH = 1 << ROOT_BITS;
static const int LEAF_BITS = BITS - ROOT_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
void* (*allocator_)(size_t); // Memory allocator
}
这时一个2层基数树,第一层就是2的5次方32个槽位,第二次是 BITS - ROOT_BITS个槽位,按8k算就是2的14次方16384个槽位,但是其实两层与1层整体空间大小时不变的。低19位的前面5位存在第一层,证明是在第一层哪个槽位。而第二层存剩下14位。两层也适合32位机器。
三层适合64位机器,与两层的思路差不多,只是分了三层。
分多层的好处是一次可以开更小的内存,如果使用两层基数树开64位,第二层就得开2的46次方的内存,开不出来。于是我们拆分小后就可以拆分出来。
#pragma once
#include "Common.hpp"
#include"ObjectPool.hpp"
// 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(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap1() {
//array_ = reinterpret_cast<void**>((*allocator)(sizeof(void*) << BITS));
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 {
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) {
array_[k] = v;
}
};
// 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;
static const int ROOT_LENGTH = 1 << ROOT_BITS;
static const int LEAF_BITS = BITS - ROOT_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
void* (*allocator_)(size_t); // Memory allocator
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap2() {
//allocator_ = allocator;
memset(root_, 0, sizeof(root_));
PreallocateMoreMemory();
}
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;
}
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;
// Make 2nd level node if necessary
if (root_[i1] == NULL) {
//Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
//if (leaf == NULL) return false;
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;
}
void PreallocateMoreMemory() {
// Allocate enough to keep track of all possible pages
Ensure(0, 1 << BITS);
}
};
// 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;
}
void PreallocateMoreMemory() {
}
};
一二三层基数树完整代码,包含所有插入删除接口。
我们将所有的哈希表替换成基数树就ok了,这样我们的性能就远远优于malloc与free。
其中我们可以继续延续这个项目往下走,项⽬扩展有哪些:
• 我们只实现了windows下的版本,可以考虑去试下Linux版本。
• 我们只实现了32位的版本,没有实现64位的版本。(不过要注意的是,实现64位的版本,两个地⽅要注意变化,第⼀个地⽅就是64为堆⼤了很多,⻚⼤⼩要变⼤,其次哈希表和size映射规则要变
化,基数树要⽤两层甚⾄三层的基数树,这个部分需要参考源码了解或者灵活思考,对⼤家挑战是
不⼩的)
• 实际中tcmalloc是直接可以替换malloc的,Linux下使⽤了weak?alias的⽅式替换即可,windows
下可能需要⽤hook钩⼦技术才可以。
• 其次我们是简化实现的,很多地⽅还是相对粗糙的,tcmalloc中实现细节就很多,⽐如
ThreadCache释放桶时,我们只⽤了是如果桶⻓度超过慢启动控制增⻓的最⼤⻓度,就释放⼀批,
tcmalloc中还控制了⼤⼩,如果ThreadCache占⽤内存⼤⼩超过⼀定数值,也会释放。等等这样细
节tcmalloc还挺多的。
以上就是本次全部内容。