高并发内存池项目

目录

前言

一.关于普通内存池的优缺点

        1.1优点

        1.2 缺点

二.重点目标

三.结构

        3.1 thread cache线程缓存

         3.2 central cache 中心缓存

        3.3 page cache 页缓存

四.流程

 五.细节

        5.1 区间对齐

        5.2 thread cache向central cache申请内存,慢启动

        5.3 central cache找到对应Span需要切割

        5.4 page cache当前桶没有Span,切割大页Span

        5.4 页号和地址相互转化

        5.4 如何记录内存块是属于哪个Span

        5.4 如何记录内存块全部返回

        5.5 如何合并

        5.6 单例模式

        5.7 加锁

六.缺陷

七.代码和测试结果

七.问题


前言

        该项目是基于谷歌开源的项目tcmalloc的基础上做了简化。tcmolloc的作用在多线程的情况下,申请内存可以比glib的malloc快了很多倍。为了学习tcmalloc的思想,在tcmalloc的基础上做了大量的简化,并且参考了一些资料,实现了一个比malloc快的高并发内存池。

        TCMALLOC 源码阅读

一.关于普通内存池的优缺点

        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次方。

  • 平台及兼容性
        解决:可以使用条件编译。在window操作系统下,使用VirtualAlloc申请内存。在Linux下使用sbrk/brk/mmap来申请内存。

七.代码和测试结果

测试代码:
#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的结构。

  1. 在page cache中就会存在切割了的Span和没有切割的Span。
  2. 如果用户申请大块内存,就需要在page cache桶中遍历查找,效率降低
  3. 如果thread cache需要内存,需要查找切割了的Span,均衡调度会受影响
  4. page cache需要将整个哈希桶加锁,锁的竞争大大增加

        如果合并成central cache结构

  1. 用户申请内存需要遍历Span查找是否Span存在内存。
  2. 并且还需要加锁
  • thread cache线程私有,如果线程销毁了,即thread cache销毁了,上面还有内存块怎么办?

        可以在创建thread cache时,调用接口来注册一个回调函数。当线程销毁,会自动调用回调函数,来讲thread cache的内存块回收到central cache中。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
老师可能会提出以下问题: 1. 什么是内存池?为什么需要内存池? 答:内存池是一种内存管理技术,用于提高内存分配和释放的性能。它通过预先分配一定数量的内存块,并在程序运行期间重复利用这些内存块来避免频繁的内存分配和释放操作,从而提高程序的运行效率。 2. 内存池如何实现高并发? 答:内存池可以通过多线程技术来实现高并发。一般情况下,内存池会将内存块分配给不同的线程进行使用,每个线程都有自己的内存池。当多个线程同时请求内存块时,内存池可以进行加锁操作来保证线程安全。 3. 如何处理内存池中的内存碎片问题? 答:内存池中的内存碎片问题可以通过两种方式来解决。一种是使用内存池的分配算法来减少内存碎片的产生,另一种是定期对内存池进行整理和重组,以消除已有的内存碎片。 4. 如何进行内存池的扩展和收缩? 答:内存池的扩展和收缩可以通过动态调整内存池的大小来实现。当内存池中的内存块被耗尽时,可以重新分配一定数量的内存块,并将它们添加到内存池中。当内存池中的内存块处于空闲状态时,可以将它们从内存池中移除,以释放内存空间。 5. 如何测试内存池的性能? 答:测试内存池的性能可以使用一些基准测试工具,如Google Benchmark等。在测试时,可以比较内存池的分配和释放操作与系统默认的分配和释放操作之间的性能差异。同时,还可以测试内存池高并发环境下的性能表现。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值