高并发内存池

什么是内存池??

内存池是一种动态内存分配与管理技术。通常情况下,程序员习惯直接使用new、delete、malloc、free等API申请分配和释放内存,这样导致的后果是:当程序长时间运行时,由于申请内存块的大小不定,频繁使用会造成大量的内存碎片从而降低程序和操作系统大的性能。内存池则是在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块动态分配,当释放时,将要释放的内存再放入池中,再次申请时可以从池中再取出来使用,放回后尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。

为什么需要内存池?

    1. 减少与系统的交互,从而提高申请和释放内存的效率。
    1. 尽量解决内存碎片(外碎片)。
      在这里插入图片描述

流程图

在这里插入图片描述

并发内存池concurrent memory pool

  • 主要解决:外碎片问题;性能问题;多核多线程下的锁竞争问题;

concurrent memory pool 构成:

  1. thread cache: 线程缓存是每个线程独有的,用于小于64K的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
  2. central cache :中心缓存是所有线程共享的,thread cache是按需从central cache中获取的对象。central cache周期性的回收thread cache中的对象,避免一个线程占用太多内存,而其他线程内存吃紧的情况。达到内存在多个线程当中均衡调度的目的。
  3. 页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的 ,central cache没有内存对象时,从page cache 获取一定数量的page(4K),并切割成定长大小的小块内存;当central cache满足一定条件时,page cache会回收central cache当中的 span对象,并合并相邻的页,组成更大的页,缓解内存碎片的问题。

在这里插入图片描述

具体细节以及用到的技术

thread cache

结构:哈希映射的自由链表,左边是一个FreeList对象的数组,FreeList对象里面有一个指针,还有所挂的内存对象的大小,每个位置下面挂着相对应大小的内存对象连接成的链表。

class FreeList
{
private:
	void* _head = nullptr;
	size_t _max_size = 1;  //用于慢启动是的增长
	size_t _size = 0;
};

为什么开始要以8字节对齐,因为要保证在64位编译环境下至少能存下一个指针的大小。
在这里插入图片描述
申请内存:

  1. 当内存申请size<=64k时在thread cache中申请内存,计算size在自由链表中的位置,如果自由链表中有内存对象时,直接从FistList[i]中Pop一下对象,时间复杂度是O(1),且没有锁竞争。
  2. 当FreeList[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。

释放内存:

  1. 当释放内存小于64k时将内存释放回thread cache,计算size在自由链表中的位置,将对象Push到FreeList[i].
  2. 当链表的长度过长,则回收一部分内存对象到central cache。

为什么没有锁的竞争??

我们使用thread local storage 保存每个线程本地的ThreadCache的指针,保证每个线程拥有自己的thread cache
TLS(thread local storage):
我们知道在一个进程中,所有线程是共享同一个地址空间的。所以,如果一个变量是全局的或者是静态的,那么所有线程访问的是同一份,如果某一个线程对其进行了修改,也就会影响到其他所有的线程。不过我们可能并不希望这样,所以更多的推荐用基于堆栈的自动变量或函数参数来访问数据,因为基于堆栈的变量总是和特定的线程相联系的。

不过如果某些时候,我们就是需要依赖全局变量或者静态变量(生命周期是全局的),那有没有办法保证在多线程程序中能访问而不互相影响呢?答案是有的。操作系统帮我们提供了这个功能——TLS线程本地存储。TLS的作用是能将数据和执行的特定的线程联系起来。
详解请点击

对齐方式与具体对应桶位置的计算
防止内存被切的太碎,太杂乱,不好管理所以要对齐;
要是采用定长映射,假如8字节对齐,对象大小可以达到64K(64K/8==8K),那么就要映射8千多个位置,这样有点大,所以我们采用分段对齐映射的方式(这样计算下来只用184个位置),具体来看:

// 控制在12%左右的内碎片浪费  (采用这种方式映射的原因 ) 
//申请的内存对象大小   <=128  字节时采用8字节对齐,对应到freelist中是[0,16)这些位置
// [1,128]                    8byte对齐        freelist[0,16)

//申请的内存对象大小  129=<  <=1024  字节时采用16字节对齐,对应到freelist中是[16,72)这些位置
// [129,1024]                 16byte对齐       freelist[16,72)

// 以此类推
// [1025,8*1024]              128byte对齐      freelist[72,128)
// [8*1024+1,64*1024]         1024byte对齐     freelist[128,184)

具体的计算方法

对齐大小计算:
按照要申请内存的字节大小,与该区间的对齐数进行计算,保证内存浪费控制在12%左右;
比如:在这里插入图片描述

static inline size_t _RoundUp(size_t bytes, size_t align)
{
	return (((bytes)+align - 1) & ~(align - 1));
}
// 对齐大小计算,浪费大概在1%-12%左右
static inline size_t RoundUp(size_t bytes)
{
	assert(bytes <= MAX_BYTES);
	if (bytes <= 128){
		return _RoundUp(bytes, 8);
	}
	else if (bytes <= 1024){
		return  _RoundUp(bytes, 16);
	}
	else if (bytes <= 8192){
		return  _RoundUp(bytes, 128);
	}
	else if (bytes <= 65536){
		return  _RoundUp(bytes, 1024);
	}
	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 <= 8192){
		return _Index(bytes - 1024, 7) + group_array[1] +
		group_array[0];
	}
	else if (bytes <= 65536){
		return _Index(bytes - 8192, 10) + group_array[2] +
		group_array[1] + group_array[0];
	}
	assert(false);
	return -1;
}

centeral cache

本质是一个哈希映射的SpanList对象自由链表
在这里插入图片描述

// 管理管理一个跨度的大块内存
struct Span
{
	PageID _pageId = 0; //页号
	size_t _n = 0;     // 页的数量

	Span* _next = nullptr;
	Span* _prev = nullptr;

	void* _list = nullptr;  //_list==nullptr,说明其没有合适的Span,大块的内存切小链接起来,这样回收回来的内存也方便链接
	size_t _usecount = 0;   // 使用计数, _usecount==0 说明对象都回来了

	size_t _objsize = 0;   // 切出来的单个对象的大小
};

申请内存

  1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,central cache也有一个哈希映射的freelist,freelist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的。
  2. central cache中没有非空的span时,则将空的span链在一起,向page cache申请一个span对象,span对象中是一些以页为单位的内存,切成需要的内存大小,并链接起来,挂到span中。
  3. central cache的span中有一个use_count,分配一个对象给thread cache,就++use_count

释放内存:

  1. 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时–use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回pagecache,page cache中会对前后相邻的空闲页进行合并。

当我们要从central cache 中获取对象时需要加锁,为了效率的提高我们这里给它加桶锁,就是在哪个桶中操作就锁哪个,这样就可以让需要不同内存块的线程并发进行。

具体技术

加锁在这里要说的是RAII技术:
RAII:也称为“资源获取就是初始化”,是c++等编程语言常用的管理资源、避免内存泄露的方法。它保证在构造时获取相应资源,在对象生命周期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候释放相应的资源。

C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,其会在构造函数的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。

在互斥量lock和unlock之间的代码很可能会出现异常,或者有return语句,这样的话,互斥量就不会正确的unlock,会导致线程的死锁。所以正确的方式是使用std::unique_lock或者std::lock_guard对互斥量进行状态管理:在创建std::lock_guard对象的时候,会对std::mutex对象进行lock,当std::lock_guard对象在超出作用域时,会自动std::mutex对象进行解锁,这样的话,就不用担心代码异常造成的线程死锁。

单例模式
因为我们有多个线程,但是在访问central cache的时候又希望它们都访问到同一个central cache,所以这里我们使用单例模式。

介绍:该类只能创建一个对象,并提供一个访问他的全局访问点,该实例被所以线程模块共享。
在这里插入图片描述
两种模式:懒汉模式,饿汉模式;

在这当中我们用到饿汉模式:不管你将来用不用,程序启动就创建一个唯一的实例对象。
因为在main函数之前就创建好了,所以不用加锁。

  • 优点:实现简单
  • 确定:可能会导致进程启动慢,且如果有多个单例类对象实例,创建顺序不确定。

Page cache

在这里插入图片描述
申请内存:

  1. 当Central cache向Page cache申请内存时,Page cache先检查对应位置有没有Span ,如果没有则向更大的页寻找一个Span,如果找到则分裂成两个。比如申请的是4Page,4Page后面没有,则向后寻找更大的Span,假设在10Page位置找到一个Span,则将10Page Span切分成一个4Page Span和一个6Page Span。
  2. 如果找到最后都没有合适的Span,则向系统使用VirtualAlloc或mmap、brk等方式申请128 Page Sapn挂在自由链表上,再重复1的过程。

释放内存

  1. 如果Central cache 释放回一个Span,则依次寻找Span的前后page id的Span,看是否可以合并,如果合并继续向前寻找,这样就可以将切小的内存合并收缩成大的Span,减少内存碎片。

如何申请内存??
Windows: VirtualAlloc
Linux: brk&&mmap

具体细节

  1. Page cache也是单例模式,原因和central cache一样,但是Page cache 的锁和central cache不一样,Page cache是一把大锁,将自己锁起来。
  2. 要用一个map将页号的对应Span存起来,便于合并。(tcmalloc 使用的是基数树)
    在这里插入图片描述

项目的扩展与不足

扩展:
实际中我们测试了,当前的实现比malloc/free 更加高效,那么我们能否替换掉malloc呢??
当然可以了。

  • 不同平台替换方式不同。 基于unix的系统上的glibc,使用了weak alias的方式替换。具体来
    说是因为这些入口函数都被定义成了weak symbols,再加上gcc支持 alias attribute,所以
    替换就变成了这种通用形式:
  • void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc))) // 调用malloc的都跳转到了tc_malloc

不足:
当前项目并未完全脱离malloc.
解决办法:
增加一个定长的ObjectPoll,对象池用brk、VirarulAlloc等直接向系统申请,小的内存块用对象池,大的直接找系统申请。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值