目录
5.2 thread cache向central cache申请内存,慢启动
5.4 page cache当前桶没有Span,切割大页Span
前言
该项目是基于谷歌开源的项目tcmalloc的基础上做了简化。tcmolloc的作用在多线程的情况下,申请内存可以比glib的malloc快了很多倍。为了学习tcmalloc的思想,在tcmalloc的基础上做了大量的简化,并且参考了一些资料,实现了一个比malloc快的高并发内存池。
一.关于普通内存池的优缺点
1.1优点
- 性能高
一般池化技术都有性能高的优点。内存池实际是一个大块内存,内存池性能高的原因在于,当用户申请内存时,直接从内存池中申请内存,不需要向系统申请。当用户频繁的申请内存时,如果频繁的向操作系统申请内存,效率是很低的。从内存池中申请内存,不需要频繁的跟操作系统打交道,性能会有所提高。
1.2 缺点
- 内存碎片
当用户频繁的申请小块的内存,当归还了少量的内存,可能会导致内存不连续。当要申请一个大块内存时,可能内存空间够,当时由于空间不连续,导致申请失败。
- 多线程的情况下,由于锁的竞争,导致性能降低
在多线程的情况下,由于内存池是一个临界资源,当多个线程进入会导致线程不安全。所以需要加锁。当一个线程上锁,其它线程就会被阻塞,导致性能降低。
二.重点目标
主要针对上面的几个问题,即下面三个重点目标:
- 性能问题
- 该项目一点程度上解决了内存碎片问题,但是没有彻底解决。
- 一定程度解决在多核多线程情况下,锁竞争的问题。
三.结构
该项目主要有三个部分组成:thread cache,central cache,page cache。
3.1 thread cache线程缓存
线程缓存是每个线程独有,用于64KB的小块内存分配。线程从这里申请内存不需要加锁。在多线程的情况下,一定程度上解决的锁锁竞争的问题,但是该项目并没有根本解决锁的问题,下面有解释。
怎么实现thread cache 线程独有:运用tls(thread local storage)线程本地存储技术。
我们知道全局变量和静态变量,在多线程的情况下,访问到的是同一个变量。当多个用户访问到一个变量时,可能会导致线程安全的问题。特别是在多个线程同时写入该变量时。
TLS技术,为可以为每一个使用全局变量的线程提供一个变量值副本。每一个线程均可改变自己的变量副本,而不会影响其它的线程。
TLS有分为静态TLS和动态TLS。我们在项目中使用的是静态TLS。在window下,使用__declspec(thread)修饰全局变量即可。
结构:
thread 结构是一个哈希桶结构,如下:每个哈希桶下面,连接的都是对应位置大小的内存块。
thread cache的哈希桶数不一定限制为64KB,可以有编程者设定。
3.2 central cache 中心缓存
中心缓存是线程共享的,thread cache按需从central cache中获取对象。central cache周期性回收thread cache中的内存块,避免一个线程占用太多的内存块,有不使用,导致其它线程用不到。达到内存在多个线程中均衡的按需调度。
由于central cache是线程共享的,所以当线程访问该线程时,需要加锁。但是我们实现的,当thread cache向central cache申请一块内存时,并不只是给一块,而是给多块。所以到了后期,thread cache并不会频繁的向central cache申请内存,锁的竞争没有那么强。
结构:
central cache 的结构也是一个哈希桶,桶的个数和thread cache相同。thread cache向central cache申请内存,直接在对应位置取内存即可。
哈希桶桶的结构是一个带头双向链表,结点是一个Span。Span的作用是管理大块的内存。central cache的Span的大块内存都按桶对应位置的大小切割好了。
central cache起到均衡调度的作用,当thread cache某一桶中程度到达某一值,central cache会回收该桶一定数量的内存块,以便其它线程需要。
通过下面的流程讲述,再回来看。
为什么设计成双向链表?
当central cache桶中的结点Span里的内存块,从thread cache中全部回收,需要将该Span回收到page cache中,方便删除。
为什么要将Span管理的大块内存切割成一个个的小块内存?
如果指向大块内存,不切割,thread cache方便申请内存,但是当thread cache要归还内存时,会很麻烦。
如果central cache中的Span直接指向的大块内存,当需要thread cache需要内存时,central cache需要向切割一块内存,再将该内存切割成小块,给thread cache。
当thread cache返还内存时,还需要先合成成大块内存才能返还。并且,返还也不好返还到那个位置,可能前面的位置也被申请出去了,要等到前面的内存全部返还了,才能放上去。
注意:一个Span切割的内存块是定长的,不能切割成其它长度,连接到其它桶中。
Span的结构:Span管理的内存块大小以页为单位。
typedef size_t PageID;//为了后面映射span用
//以页为单位的大块内存
struct Span{
//为pagecache合并用
PageID pageid = 0;//第一个页号
size_t n = 0;//有多少个页
Span *next = nullptr;//将Span连接起来
Span *prev = nullptr;
size_t size = 0;//只针对切割相同大小为size的内存
//不同central哈希桶下size不同
void *list = nullptr;//大块内存
size_t use_count = 0;//大块内存分割个数,0代表没有分割,为page cache回收使用
};
3.3 page cache 页缓存
页缓存时central cache上的一层缓存,存储的内存是以页为单位的。当central cache没有内存时,从page cache中分配一定页数的内存,并且会将该内存切成定长的大小的小块内存。
page cache还会回收central cache中满足条件的Span对象(条件是:当central cache中的小块内存全部从thread cache中返还),并且会合并相邻的页,来组成更大页的内存,来供下一次更好的使用,缓解内存碎片的问题。
结构:
page cache的结构意识一个哈希桶。桶的位置映射的是内存块的页数。桶下保存的是对应页数的内存块。central cache需要多大页数的内存,直接从对应位置取内存即可,如果当前位置没有,会向后查找更大页的内存,进行分割。如果后面也没有,会只将向系统申请。
page cache的桶也是一个带头双向链表,结点保存的也是Span。但是该Span管理的内存块没有被切割成小块内存。
page cache哈希桶的桶数我们设置为128.说明page cache中最大的内存为128页,即128*4096字节。
page cache的哈希桶数,是可以修改的,意思就是不一点是128页。但是由于是哈希桶,总会有上限。
注意:central cache和page cache桶中保存的都是Span。区别是:central cache中的Span是切割了的,page cache中的Span是未切割的。
四.流程
- 申请内存
用户申请内存,当大于64KB时,直接向page cache申请。当小于等于64KB时,向thread cache申请,找到对应位置的哈希桶,当桶中有内存块时,直接返回给用户,当桶中没有内存时,thread cache会向central cache申请。central cache会根据用户申请内存块的大小,找到对应位置的哈希桶,遍历桶中的Span,当某个Span的大块内存中有内存,就会直接返回多个(至少一个)给thread cache,如果central cache的桶中没有Span或者Span中的大块内存被申请完了,central cache 会向page cache申请Span。page cache根据Span大块内存的页数,找到对应位置的哈希桶,如果对应位置有Span,先将大块内存切割好,再申请给central cache。如果当前位置没有,会向大页的Span(后面的桶)找是否有Span,如果有,切割成两个Span,返回需要的Span,新的Span连接到新的桶中。如果后面的桶也没有Span,则会向系统申请Span。
当大于64KB时,直接向page cache申请,page cache的哈希桶的桶数也有限,也就是说,内存块的大小也是有限的,当超过了page cache的最大内存块,会向内存申请。否则,找到page cache对应桶的位置,查找是否有Span,如果有,直接返回,如果没有,同样向后找,找到切割,没找到找系统要。
流程图:
- 释放内存
用户释放内存,当释放的内存大小超过64KB,释放给page cache。当小于64KB,将释放的内存块连接到thread cache对应的哈希桶位置。当该桶数量超过一定长度时,central cache需要回收一定数量的内存块,放到对应内存块的Span中,如果其它线程需要使用,可以直接提供使用。当Span的内存块全部返回,page cache会回收central cache的Span,并且会循环找前后页号,是否全部返回到page cache,如果返回到page cache,会将Span合并成更大页的内存,再连接到对应的哈希桶位置,方便后面申请。
当释放的空间超过64KB,如果大于page cache最大内存块,直接返还给系统。否则,会再page cache中循环找前后页号,是否全部返回到page cache,如果返回到page cache,会将Span合并成更大页的内存,再连接到对应的哈希桶位置,方便后面申请。
五.细节
thread cache,central cache,page cache的哈希表中保存的不是一个简单的指针。而是保存的是一个类。通过该类来管理桶(链表)。做到模块分离。
比如:thread cache中保存的是一个管理单向链表的类,结点是内存块。page cache和central cache中保存的是管理双向链表的类,结点时Span。
5.1 区间对齐
central cache和thread cache哈希表大小是一样的,大小是64KB。我们设计并不是以一字节作为对齐数,来保存对应大小的内存块。即,并不是,第一个桶保存大小为1字节的内存块,第二个桶保存大小为2字节的内存块,依次类推。
而是将对齐数设大一点。并且,将64KB的范围分多个区间,每个区间的对齐数不同。如下:
[1, 128]字节, 对齐数8, 区间大小为[0, 15] 16个
[129, 1024]字节, 对齐数16, 区间大小为[16, 71] 56个
[1025, 8*1024]字节, 对齐数128, 区间大小为[72, 127] 56个
[8*1024+1, 64*1024]字节, 对齐数1024, 区间大小为[128, 183] 56个
也就是用户申请的空间大小在[1,128]字节范围内,我们给用户的空间都会向上对齐到8的整数倍。在[129, 1024]字节,我们给用户的空间会向上对齐到16的整数倍。在[1025, 8*1024]字节,我们给用户的空间会向上对齐到128的整数倍。在[8*1024+1, 64*1024]字节,我们给用户的空间会向上对齐到1024的整数倍。这样设计是参考了tcmalloc,为了空间浪费率在10%左右。
来个例子说明:比如用户申请的字节数为5字节,我们给用户的空间为8字节。
这样设计的原因:
- 首先在thread cache中,我们直接用内存块来保存下一个内存块的地址,所以最小的内存块需要大于等于指针大小。
- 为了防止哈希桶的通过过大,导致占用的内存过大。
缺陷:
- 导致内碎片问题。给的空间比用户实际使用的空间大,导致空间浪费。
这样设计需要我们针对用户申请的空间大小来计算对应桶的位置,进行特殊处理。
思想是:计算出距离当前区间起始桶的距离,加上前面所有区间的桶数。
static inline size_t _Index(size_t bitNum, size_t n){
//n为对齐数
//(1<<n)对齐数,加上(1<<n)-1防止bitNum出现小于区间的值,>>n除以对齐数
//如果直接除以对齐数,5/8-1为负数,越界了。
//加上对齐数减1,不是对齐数倍数的会到下一个对齐数倍数,并且正好为对齐数倍数的也不会为下一个倍数。
//统一了求位置的方式
return ((bitNum + ((1 << n) - 1)) >> n) - 1;
}
//获取threadcache哈希桶的位置
static size_t Index(size_t size){
assert(size <= MAX_BYTES);
//每个区间哈希桶桶的个数
size_t RangeNum[] = { 16, 56, 56, 56 };
//计算出每个区间距离区间起始位置的距离,加上之前的区间个数,就是当前位置
//所以需要减去前面区间的数,前面区间数对齐数不同
if (size <= 128){
return _Index(size - 0, 3);
}
else if (size <= 1024){
return _Index(size - 128, 4) + RangeNum[0];
}
else if (size <= 8192){
return _Index(size - 1024, 7) + RangeNum[1] + RangeNum[0];
}
else if (size <= 65536){
return _Index(size - 8192, 10) + RangeNum[2] + RangeNum[1] + RangeNum[0];
}
else{
}
return -1;
}
当central cache向page cache申请到Span,需要对Span进行切割成对应大小的小块内存块。切割的大小,需要向上对齐到当前区间对齐数的整数倍处。求出切割大小。
思想:将对齐数后几位置为0。
static inline size_t _RoundUp(size_t bitNum, size_t n){
//&~((1 << n) - 1), 让后面几位无效,对齐数为8的倍数,让后三位无效.
//比如:如果8进制~((1 << n) - 1) 二进制为 1111 1111 1111 1111 1111 1111 1111 1000,让后面3位无效,直接向上对齐
//对齐到16的整数倍,让后4位无效,对齐到128整数倍,让后7位无效。。。。
return (bitNum + ((1 << n) - 1) - 1)&(~((1 << n) - 1));
}
//让用户需要的内存大小向上对齐
//比如:用户申请5字节,需要向上申请到8字节
//计算出向上对齐到多少字节
static size_t RoundUp(size_t size){
if (size <= 128){
return _RoundUp(size, 3);
}
else if (size <= 1024){
return _RoundUp(size, 4);
}
else if (size <= 8192){
return _RoundUp(size, 7);
}
else if (size <= 65536){
return _RoundUp(size, 10);
}
else{
//大于64kb
return _RoundUp(size, PAGE_SHIFT);
}
}
5.2 thread cache向central cache申请内存,慢启动
当thread cache中没有用户申请的空间时,thread cache需要向central cache申请内存。central cache会将对应桶位置,找到有内存的Span。分割多块内存给thread cache。
为什么分割多块?
为了防止用户频繁申请该大小的内存,导致thread cache 频繁的向central cache申请。由于central cache时线程私有的,频繁的进入central cache会需要加锁。说白了,就是为了提高效率。
central cache 时采用"慢启动"这样的方式将内存块给thread cache的。意思就是:一开始给的少,后面会越给越多。但是,central cache也并不是无上限个数的给内存块给thread cache。程序中的上下限为[1,512]。
为什么才用慢启动的方式?
为了防止用户,只需要少量内存。之后不会再申请了。如果一次central cache就给大量的内存,就可能导致thread cache桶中的内存用不到了,并且也很难回收。说白了,就是防止内存碎片化太严重。
程序中如何实现?
在thread cache管理链表的类中,加了一个成员变量maxsize,来记录现在链表长度的一个上限值,是动态变化的。该值在central cache回收内存块时,也起到了长度限制的作用。
class FreeList{
private:
void *head;//链表头指针
size_t maxsize;//记录链表长度上限
size_t size;//链表长度
public:
FreeList();
size_t GetSize();
size_t GetMaxsize();
void SetMaxsize(size_t msize);
//将多个centralcache给的内存连接到链表中
void PushRange(void* start, void* end, size_t n);
void PopRange(void*& start, void*& end, size_t n);
void Push(void *obj);
void *Pop();
bool Empty();
~FreeList()
{}
};
实际向central cache申请内存块个数为:
//批量向centralcache申请内存块的个数,
size_t num = min(SizeClass::NumMoveSize(size), freelist[pos].GetMaxsize());
NumMoveSize(size),主要是用来设定申请个数的上下限的。实现如下:
//从centralcache获取内存块,可能的个数
static size_t NumMoveSize(size_t size){
//用最大字节数除申请的字节数
size_t num = MAX_BYTES / size;
if (num == 0){
return 2;
}
if (num > 512){
return 512;//上限
}
return num;
}
5.3 central cache找到对应Span需要切割
thread cache向central cache申请内存,对应桶位置如果有Span,并且Span有内存会直接放回一个Span。
如果没有Span,获知Span没有内存,需要向page cache申请。注意申请完回来,需要将Span的大内存切割,在连接到central cache桶中。
5.4 page cache当前桶没有Span,切割大页Span
central cache计算出页数,想page cache申请一定页数的Span。如果page cache没有Span,会向桶后查找更大页的Span。如果存在,进行切割。切割成需要的Span大小和另一块新的Span。将需要的Span返回。将新Span连接到page cache对应桶的位置。
切割:如果central cache需要n页Span。申请一个新的NewSpan,NewSpan的页号为Span页号加(Span的页数减n),页数为n。返回NewSpan给central cache即可。
如果后面的桶也没有Span,则向系统申请最大页数Span,再进行切割。
Span* PageCache::NewSpan(size_t pageNum){
std::lock_guard<std::recursive_mutex> lock(mt);
if (pageNum >= NUMPAGES){
//超过哈希桶个数
void *ptr = SystemAllocPage(pageNum);
Span* sp = new Span;
sp->pageid = (size_t)ptr >> PAGE_SHIFT;
sp->n = pageNum;
IsSpanMap[sp->pageid] = sp;
return sp;
}
if (!(pageSpanlist[pageNum].IsEmpty())){
return pageSpanlist[pageNum].PopFront();
}
//向后找大的page
for (int i = pageNum + 1; i < NUMPAGES; i++){
if (!pageSpanlist[i].IsEmpty()){
//切割,连接到新位置,返回需要的
if (!(pageSpanlist[i].IsEmpty())){
//尾切效率高,一般需要小块的内存,更新映射关系少
Span *sp = pageSpanlist[i].PopFront();
sp->n = sp->n - pageNum;
Span* newsp = new Span;
newsp->pageid = sp->pageid + sp->n;
newsp->n = pageNum;
//更新映射关系
for (size_t i = 0; i < newsp->n; i++){
IsSpanMap[newsp->pageid + i] = newsp;
}
//插入到新的pagecache哈希桶中
pageSpanlist[sp->n].PushFront(sp);
return newsp;
}
}
}
//说明pageSpanlist没有,需要申请
Span* sp = new Span;
void *memory = SystemAllocPage(NUMPAGES - 1);//直接申请一个最大的
//注意写一下,可以通过页号来推导出指针
sp->pageid = (size_t)memory >> 12;//指针是对字节的编号,除以页的单位,就知道属于那个页
sp->n = NUMPAGES - 1;//页数
pageSpanlist[NUMPAGES - 1].PushFront(sp);
for (size_t i = 0; i < sp->n; i++){
IsSpanMap[sp->pageid + i] = sp;
}
return NewSpan(pageNum);//下一次肯定有,递归申请
}
5.4 页号和地址相互转化
页号和地址是可以相互转化的。在进程地址空间中,是以页为单位的。一般一个页为4KB(其它地方可能不同)。在32位系统下,进程地址空间一共4G。
所以进程地址空间一共有4G/4KB = 2^20个页。
而地址是以字节为单位,地址从低到高对字节进行编号。页号也是从低到高进行编号。地址包含在页中,只是地址的划分粒度更细。将地址除以一页的字节数,就可以得到页号。如果想通过页号转化为地址,只能转化为当前页的起始地址。即 页号乘以一页的字节数。
如果一页为4KB,想求出地址为x的页号。即 页号 = x / 4096。
5.4 如何记录内存块是属于哪个Span
central cache回收thread cache中的内存块,是一定数量的内存块。但是不同的内存块可能在不同的Span中,如何知道当前内存块属于拿个Span呢?
在page cache中,建立一个页号映射Span地址的map。返回来的内存块我们知道地址,先求出页号,通过map,就可以知道对应的Span。
在向系统申请Span,page cache切割Span,page cache合并Span时需要更新页号和Span的映射关系。
5.4 如何记录内存块全部返回
在Span的结构中,有一个成员变量usecount,记录了当前内存块是否被thread cache申请的个数。
在thread cache申请内存时,增加usecount,在thread cache返还内存块时,减少usecount。如果usecount为0,说明当前Span的内存块全部返回。此时就可以被page cache回收。
5.5 如何合并
当page cache回收了central cache的Span,需要循环遍历前后页号的Span是否归还,如果归还,需要合并成更大页的内存。方便下一次使用。
知道当前Span的页号,通过映射表,循环查找连续的前面和后面页号的Span。如果,页号和Span的映射关系存在,并且Span的usecount为0,说明归还了page cache。此时就可以将两页合并。
合并只需要更新页号。前面页号的Span。将Span的页号更新到前面的页号,页数增加。修改Span在map中和页号的映射关系。合并后面的Span,页号不变,增加页数,修改后面一个页号Span的在map中映射页号的关系。
5.6 单例模式
由于central cache和page cache时每个线程共享的,只存在一个对象。需要设计成单例模式。
单例模式的介绍:单例模式
5.7 加锁
由于central cache和page cache是线程共享的,多个线程访问,会导致线程安全问题。
为什么要加锁?
当多个线程访问一个桶,由于判断不是原子的,两个线程进到同一桶中,可能会出现取走同一个内存块的情况。导致两个线程用来同一个内存块。
而central cache的加锁位置,只需要对桶进行加锁即可。因为,线程访问不同的桶,并不会影响其它线程。
page cache加锁则需要对整个哈希桶进行加锁。因为page cache可能有切割的动作。在将新的Span插入到新的桶中,可能会影响其它的线程。
通过页号获得Span的动作也需要加锁。因为在page cache中有存在修改map的动作,在其它地方有读map的动作。所以在读map的动作需要加锁。在读时,不能修改。
六.缺陷
- 当前项目并没有完全脱离malloc,在里面依然用了malloc和new来生成对象。
解决:我们可以不使用malloc和new。在项目中增加一个定长内存池,定长内存池中使用sbrk,virtualAlloc向系统申请内存。用对象池来替换项目中的malloc和new。
- 使用map映射,耗时太长,效率变低
通过map建立页号和Span的映射,由于通过页号获取Span需要加锁处理,但是这个接口需要频繁的被调用。会导致效率降低的问题。
解决:思想,只能使通过页号查找Span的过程时间更快一点,使线程不会阻塞太久。
不用map来保存页号和Span的映射关系。
在32位系统下,我们使用直接定址法。
在32位系统下,一共有2^20个页号,我们直接开辟一个大小为2^20次方大小的数组。数组里面直接保存Span的地址。数组最大占用空间也就4M多。通过页号,直接就可以找到Span。
在64位系统下,使用直接定址法不适用,会导致数组占用空间太大。
参考tcmalloc,我们可以使用基数树。思想:多阶哈希。结构如下:
tcmalloc用的三阶哈希。
在64位系统下,如果一页位4KB。一共有2^52次方个页号。如果需要保存奖励Span和每个页号的关系,需要建立一个2^52次方大小的数组。占用内存太大。
于是tcmalloc中,采用三阶哈希。
用高15位来表示第一层的哈希表,第一层的哈希表位置指向第二层的对应的哈希表,第二层哈希表用中间15位表示。第二层的哈希表的对应位指向第三层对应哈希表。第三层哈希表用最后22位表示,第三次哈希表中保存的是对应Span的地址。
第一层一个哈希表,个数为2^15次方。第二层2^15次方个哈希表,每个哈希表的大小为2^15次方。第三次2^30个哈希表。每一个哈希表大小为2^22次方。
-
平台及兼容性
七.代码和测试结果
#include "ConcurrentAlloc.h"
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
size_t malloc_costtime = 0;
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));
}
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);
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime);
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);
size_t malloc_costtime = 0;
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));
}
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);
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime);
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}
int main()
{
std::cout << "==========================================================" << std::endl;
BenchmarkMalloc(10000, 50, 100);
std::cout << std::endl << std::endl;
BenchmarkConcurrentMalloc(10000, 50, 100);
std::cout << "==========================================================" << std::endl;
system("pause");
return 0;
}
测试结果:
七.问题
- 如何实现比malloc快的?
该项目实现比malloc快并不在于使用了内存池。malloc底层使用的也是用来池化技术。
其实mallco底层思想和本项目的底层思想差不多。该项目比malloc快的情况,是在多线程斌且频繁申请内存的情况。重点是因为thread cache的存在。
在多线程的情况下,mollac每次申请内存都会需要进行加锁。加锁就会导致效率降低。
而该项目中,因为有thread cache的存在,每个线程私有thread cache,线程向thread cache申请内存不需要加锁。虽然,当thead cache没有内存时,需要向central cache申请内存,也会需要加锁。但是,由于central cache会使用慢启动的方式该内存块给thread cache。到后面,thread cache并不会频繁的进入central cache。加锁的概率会大大减小。
- 能不能将thread cache和central cache合并?
答案是不能。
如果合并成thread cache的结构。
- 在page cache中就会存在切割了的Span和没有切割的Span。
- 如果用户申请大块内存,就需要在page cache桶中遍历查找,效率降低
- 如果thread cache需要内存,需要查找切割了的Span,均衡调度会受影响
- page cache需要将整个哈希桶加锁,锁的竞争大大增加
如果合并成central cache结构
- 用户申请内存需要遍历Span查找是否Span存在内存。
- 并且还需要加锁
- thread cache线程私有,如果线程销毁了,即thread cache销毁了,上面还有内存块怎么办?
可以在创建thread cache时,调用接口来注册一个回调函数。当线程销毁,会自动调用回调函数,来讲thread cache的内存块回收到central cache中。