1)结构:
- 首先假设内存分配器的最小内存分配单元为mem_unit,需要确定最小分配单元的大小。如果设置太小,将使得内存单元过于琐碎,过大则造成空间浪费。基于这个考虑,设置多个大小类别的mem_unit。申请内存单元时,将分配能够满足该大小的最小内存单元。
- 由于同一类别的mem_unit是随机申请的,空间不连续,所以采用单向链表结构管理同一类别的mem_unit。
- 为了能够快速定位某一个类别的men_unit_list, 通过对men_unit的size进行hash建立索引。
基于以上三点考虑,建立的内存分配器结构如下图:
2)流程
- 申请内存:根据申请内存的大小选择合适的mem_unit, 若相应的mem_unit_list为NULL,则向操作系统申请内存空间,然后构建mem_unit进行分配;若mem_unit_list不为NULL,则从中取出mem_unit进行分配。
- 释放内存:将men_unit添加到相应的mem_list中。
3)总结
- 申请内存时间复杂度为O(1),释放内存时间复杂度为O(1);
- 存在空间浪费,单个mem_unit最大浪费4KB;
- 只申请内存不释放,因此对于内存波动大的应用空间浪费会比较严重;
- 多线程应用时,每个mem_unit_list必须是线程安全的。
三、并发内存池
该项目主要模拟实现tcmalloc最核心的框架。高并发内存池主要解决的是内存碎片问题,而内存碎片又分为内碎片和外碎片。在32位平台下以外碎片为例,如下图:
假设已申请3G的内存,现在想再申请950M的内存,虽然合计剩余内存大于950M,但是由于空间不连续,因此无法申请到950M的内存空间,这就是由于外碎片问题导致申请内存失败。
内存池是程序在向操作系统申请内存时,申请过量内存,由进程自己将这些过量内存组织起来,待到需要使用内存的时候,直接分配,而不需要频繁地向操作系统申请内存,频繁向操作系统申请资源也是一种性能消耗。当上层释放内存时,将内存重新在内存池中组织起来,等待下次使用,而不是直接让操作系统回收。
3.1整体结构
首先要讨论一下如何管理内存?
上层将要申请的内存大小无法预测,但又不能直接将整块申请来的过量内存交给上层,因此需要对大块内存进行切分。这些内存块无疑是要被容器管理起来的,并且要保证当我们要申请使用的时候,能快速找到适合的内存块大小,哈希表就十分契合这些需求,将切分后的内存块大小与其对应内存建立 大小-内存指针 的kv映射。
高并发内存池主要由三个模块构成:
注意:以下反复虽然提到哈希的key->value的映射关系,但实际实现的时候,大部分不需要真的借助unordered_map,而是通过其他规律就能达到和key->value映射的效果,为了方便,以下直接使用key->value映射表述
ThreadCache: 线程缓存。直接与上层交互,为上层提供内存并将释放的内存以类似哈希桶的思想组织起来==(kb -> 内存块地址)==,等待下次申请。线程缓是每个线程独有的,因此线程缓存是无锁的。多线程同时使用线程缓存申请内存时互不影响,这也是并发内存池效率高的一个重要原因。
CentralCache: 中心缓存。中心缓存和线程缓存的整体结构是相似的,key值也是kb。只不过内存划分单位不同,中心缓存中的内存是以页为单位构建Span管理页内存的。中心缓存是所有线程所共享的,当线程缓存中没有适合分配给用户的内存块时,就会向中心缓存申请对应大小(mem_size)的内存块。中心缓存从 mem_size 所对应的Span链中提取Span,并将该Span页切割成多个大小为 mem_size 的内存块,然后返回部分内存块给线程缓存。因此中心缓存的kv映射是 kb -> Span。
由于中心缓存是所有线程共享,因此需要加锁。但中心缓存的锁是桶锁,意味着只要不是要申请的内存块大小相同,一般不会有竞争锁的问题,这使得CentralCache虽然有锁,但是效率相比无锁不会下降很多。
PageCache: 页缓存。页缓存中的内存划分也是以页为单位。当中心缓存中对应内存大小上的Span链中没有可用内存了,就要来向
页缓存申请页内存。它和中心缓存的最大区别在于它不切割页,而是直接就按页分配向上交付,也因此它的kv映射是 page -> 页内存。通过算法由申请的内存大小,计算出分配多少页给CentralCache较为合适,然后到页大小key对应的桶链上拿页内存。
如果该桶链上没有页内存,就从当前桶开始,不断往下继续搜寻(类比线性探测法),因为比当前桶key值(页大小)大的桶,如果有页内存,则该内存页大小一定比我们需要的页大小大。
把向下搜寻到的该页内存切成两部分:上层所需页内存大小 + 切割后剩余页内存大小。然后把上层所需页内存大小返回给上层,将剩余页内存大小按照key值插入到对应桶中。
或者往下探测直到最后一个桶:128页大小,都没有找到有一个桶中有页内存,则需要直接进行系统调用,向系统堆申请页内存,然后切割并分配。
规定:
- ThreadCache:最多一次性只能分配256kb的内存,即最大划分内存块为256kb。若单次申请内存大于256kb,则直接向PageCache申请页
- CentrolCache:显然,因为结构与ThreadCache基本相同,所以CentralCache也是最多一次性只能分配256kb的内存,意味着Span页切割小块内存最大把每块小块内存切成256kb。
- PageCache:最多一次性分配128页内存。若单次申请内存大于128页,则需要直接使用系统调用向系统堆申请内存。
3.2ThreadCache模块
如何切分内存?
如果我们以kb为内存划分的最小单位,能否将内存按1kb、2kb、3kb…直到256K这样切分?
这样管理起来,整个数据结构太过庞大,不可取。因此,我们需要设定对齐数,在可接受的范围内,既保证用户申请的内存大小和我们划分的内存块大小尽量接近,又要保证不使整个结构过于庞大。
对齐数设置
参考tcmalloc,我们设置对齐数如下:
整体控制在最多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)
以对齐数8为例:
当用户申请内存大小:1、2、3、4、5、6、7、8字节时,满足[1,128]的区间,按8字节对齐,分配 8 * 1的字节,即都是分配8字节
当用户申请内存大小:9、10、11、12、13、14、15字节时,满足[1,128]的区间,按8字节对齐,分配 8 * 2的字节,即都是分配16字节
依此类推
以8字节对齐的内存,假设用户申请1B,分配8B,则浪费7B内存。像这样,因分配的内存大于实际申请的内存,导致部分内存被浪费,
我们称被浪费的这部分内存为内碎片。第一个区间[1,128]内,由于用户申请的内存比较小,容易产生较大的内碎片浪费率,但是因为本身最多也就分配128B,所以第一个区间的浪费率我们忽略不计。
不考虑第一个区间:
以16B对齐,则内碎片最大为 16 − 1 = 15 16 - 1 = 1516−1=15,最小为0(刚好申请内存大小为对齐数的整数倍)
若申请129B,则内碎片浪费率为 15 / 144 ≈ 10 % 15 / 144 ≈ 10\%15/144≈10%。同理,申请145B,内碎片浪费率为 15 / 160 ≈ 10 % 15 / 160 ≈ 10\%15/160≈10%
以128B对齐,则内碎片最大为 128 − 1 = 127 128 - 1 = 127128−1=127,最小为0(刚好申请内存大小为对齐数的整数倍)
若申请1025B,则内碎片浪费率为 127 / 1152 ≈ 11 % 127 / 1152 ≈ 11\%127/1152≈11%。
以1024B对齐,则内碎片最大为 1024 − 1 = 1023 1024 - 1 = 10231024−1=1023,最小为0(刚好申请内存大小为对齐数的整数倍)
若申请(8*1024+1)B,则内碎片浪费率为 1023 / 9216 ≈ 11 % 1023 / 9216 ≈ 11\%1023/9216≈11%。
以8*1024B对齐,则内碎片最大为 8 ∗ 1024 − 1 = 8191 8*1024 - 1 = 81918∗1024−1=8191,最小为0(刚好申请内存大小为对齐数的整数倍)
若申请(64*1024+1)B,则内碎片浪费率为 8191 / 73728 ≈ 11 % 8191 / 73728 ≈ 11\%8191/73728≈11%。(8*1024+1)B,则内碎片浪费率为 1023 / 9216 ≈ 11 % 1023 / 9216 ≈ 11\%1023/9216≈11%。
综上,ThreadCache的结构图如下:
实现过程中的部分注意点:
//向CentralCache申请内存 void* ThreadCache::FetchFromCentralCache(size_t MemSize) { //上层只申请了一块内存,ThreadCache也只向中心缓存要一块来供给上层,合情合理 //但是ThreadCache也可以多申请几块(补充库存) //这样下次上层再向ThreadCache申请内存的时候,就能直接给上层分配,不用频繁向中心缓存申请了。 //申请策略?慢增长 size_t requestMemNum = min(_freeList[index].GetMaxUseNum(), SizeClass::RequestWay(MemSize)); }
//申请策略。计算实际向下层申请的内存数量 //参数:申请的内存大小 static size_t RequestWay(size_t MemSize) { assert(MemSize > 0); //算法:固定值 / 申请的内存大小 //分母越大,结果越小 //申请的内存越小,一次性可以多要一些 //申请的内存越大,一次性要少点,否则对系统负担大 size_t ret = MaxMemSize / MemSize; if (ret < 2) //下限 { ret = 2; } if (ret > 512) //上限 { ret = 512; } return ret; }
//释放内存 //ThreadCache桶链太长的话,应当回收一部分给CentralCache //太长?长短的标准是什么? //定义使用频数成员变量,当长度 > 使用频数的时候,就可以考虑回收一部分了:回收使用频数个
3.3CentralCache模块
kb -> Span
CentralCache与Thread结构相似,不同点在于kb映射的是Span页,Span页中是一个个切割的小块内存,以及每个桶链中都有桶锁:
为什么CentralCache的桶链中的数据结构是带头双向循环链表,而ThreadCache的桶链结构是单链表?
因为CentralCache中,内存被Span结构体管理着,Span是一个结构体,要形成双向循环链表无非就是在结构体中添加分别指向前驱Span和后继SPan的指针。
ThreadCache中,管理的是原生内存,没有经过任何封装,因此无法像结构体那样增加前驱和后继指针,形成带头双向循环链表。
既然ThreadCache中的内存没有经过任何封装,没有添加指针,那它是怎么将内存链起来的?
内存本身天然就是数据的容器!!! 数据是写在内存上的,换句话说,内存上是可以写数据的!!! 只要我们能把下一段内存的地址写入当前内存中,不就好像把他们链起来了吗?!!
32位平台下指针地址占4Byte,64位平台下指针地址占8Byte。所以我们也可以大胆猜测一下为什么要设置对齐数从8Byt开始,
或许就是为了让内存池无论是在32位平台还是64位平台下,都至少能把地址写进去,都能够按照这套逻辑进行,保证程序的移植性。
如果内存再大一点,比如16Byte,应该就能想办法做到前驱内存地址,比如:前8Byte用来记录后继内存地址,后8Byte用来记录前驱内存地址。只是这样做,代码可能会变得更复杂,而且8Byte内存无法这样做,无法统一逻辑。或许出于种种考虑,因此不这么做了。
如何正确地修改内存,让它保存下一个内存的地址?
假设有一个指针mem指向一段内存:
void* mem;
如果我们要改变mem指针所指向内存的内容,需要访问这段并修改,回顾一下指针类型的意义:
- 数据是存储在内存中的,指针类型决定了指针如何看待所指内存,看待内存的角度不同,取出来的数据值也不同
- 指针类型决定了指针访问所指内存的字节数
int main() { //00000001 00001001 00001001 00001001 //小端: 09 09 09 01 unsigned int i = 0x01090909; cout << i << endl << endl; cout << 0x09 << endl; char* p = (char*)&i; cout << "*p = " << (int)* p << endl << endl; //9; //这里需要在打印的时候再把值强转成int类型,否则ASCII中,9对应的字符是'\t' //char类型把他当'\t'打印了,运行结果显示什么也没有。 cout << 0x0909 << endl; wchar_t* q = (wchar_t*)&i; cout << "*q = " << (int)*q << endl << endl; unsigned int* r = &i; cout << "*r = " << *r << endl; }
这段代码可以很充分地帮我们验证指针类型的意义。
综上述,如果要修改内存中的内容,只要正确使用指针即可。
能否这样写:
//已知void* m1指向一段内存,void* m2指向另一段内存 *(int*)(m1) = m2;
逐步分析:
- 强转int*:将m1所指内存当做当做一个存储int数据类型的内存,即将来解引用时,访问4个字节。
- 解引用并赋值:将m1内存中的前4个字节的内容写入m2(另一段内存的地址)
像以上这样的操作,在32位平台下,是没有问题的,但是在64位平台下,就有问题了。
无论是32位平台还是64位平台,int都是占4个字节!*(int*)表示访问内存的4个字节,并记录另一段内存的地址。
但是在64位平台下,指针(地址)占8个字节,只用4个字节记录,是无法正确记录另一段内存的地址的。 如果上面那样去修改内存内容,在64位平台下会出错,代码的可移植性差。
需要一个类型,在32位平台下是4个字节,64位平台下是8个字节,让我们能够正确强转。 实际上,问题的答案在一开始就有了,指针在32位平台下4个字节,64位平台下8个字节。
但是我们最后是要解引用的,一级指针解引用后就不是指针了,所以强转的指针类型必须是2级指针
*(void**)(m1) = m2;
实现过程中的部分注意点:
//从CentralCache的自由链表中取出一个Span Span* CentralCache::GetOneSpan(SpanList&list, size_t MemSize) { //.... //若桶链中没有Span或所有Span都没有剩余内存 //向PageCache申请Span //考虑到ThreadCache向CentralCache要内存并不是需要多少拿多少,而是多拿 //如果原本CentralCache中有Span可用,有多少给多少,不一定够,尽量满足也就罢了 //既然调到了这个函数,就说明CentralCache中没有Span,要再找PageCache申请Span //那既然要申请,那就理所当然申请一定能够满足ThreadCache的内存数 //那就去计算这样大小的内存,上层实际会申请会申请多少个,转换成页是多少页? //再按照这样的页数报给PageCache去申请 size_t pageNum = SizeClass::NumberOfPages(MemSize); //PageCache使用的是大锁,锁住整个PageCache PageCache::GetPageCache()->PageCacheLock(); Span* newSpan = PageCache::GetPageCache()->GetSpanToUpper(pageNum); PageCache::GetPageCache()->PageCacheUnlock(); assert(newSpan); // 为什么申请完Span就解锁了?切割不需要加锁吗? // 切割不需要加锁,因为切割是把新申请的Span切割,这个newSpan指针是只属于当前线程的局部变量 // 也就是说只有当前线程能够看到这个Span。不存在线程安全问题,因此切割的时候不需要加锁。 // 这样就能提高效率,如果有其他的CentralCache桶中无Span,也需要申请,但是PageCache只有一把大锁 // 如果切割的时候没用到却没有释放掉,其他线程就只能阻塞了。 }
3.4PageCache模块
Page -> Span
虽然PageCache和CentralCache的桶链结构都是Span,但是他们的区别还是很大的,Central中的Span页内存,终将切割成为一块块与key值对应的小内存块,而PageCache中的Span与它的key值(页大小)直接对应,key值多大,Span中管理的页内存就是多大,不会进行切割。
实现过程中的部分注意点:
//参数:上层要拿k页 Span* PageCache::GetSpanToUpper(size_t k) { assert(k > 0); //申请页数 > 128,直接向堆申请 if (k > NPage - 1) { //Span* p = (Span*)SystemAlloc(k); //不能这么写 //栈上的局部变量,出了作用域就销毁了 //但是我们在释放的时候,还要用Span来释放,所以必须在堆上开Span避免被自动销毁 void* ptr = SystemAlloc(k); //Span* span = new Span; Span* span = _spanPool.New(); span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; span->_n = k; span->_isUse = true; _pageMapSpan[span->_pageId] = span; return span; } else { //页数 = 桶下标 //到对应桶中拿Span if (!_pclist[k].Empty()) { Span* kSpan = _pclist[k].PopFront(); kSpan->_isUse = true; for (int i = 0; i < kSpan->_n; ++i) { _pageMapSpan[kSpan->_pageId + i] = kSpan; } return kSpan; } //走到这里说明对应页的桶中无Span,往下面的桶里找 size_t n = k + 1; while (n < NPage) { //往下找,找到了非空页桶,取出其中的span拿来切割成pages页 和 i - pages页 if (!_pclist[n].Empty()) { Span* nPages = nPages = _pclist[n].PopFront(); //Span* kPages = new Span; Span* kPages = _spanPool.New(); //切割成两部分 //前一部分的页号就是原本的页号 不需要改动 kPages->_pageId = nPages->_pageId; kPages->_n = k; nPages->_pageId += k; //后一部分的起始页号 = 原本的起始页号 + 前一部分的页数 nPages->_n -= k; //切割后的页数要改一下 //建立id和Span的映射,方便查找 for (PAGE_ID i = 0; i < kPages->_n; ++i) { _pageMapSpan[kPages->_pageId + i] = kPages; } //没有向上交付的页,即nPages的首尾页映射一下即可 //kPage要把Span中的每一页都映射是因为CentralCache中没有页的概念 //对于CentralCache而言,它拿到的就是一个Span,然后把这个Span不断切割成等量小块内存 //因此中间页的内存也会被切割,和大Span在逻辑上分离。 // 例如: 某Span有 100 101 102 三个页 //假设我们CentralCache层也只映射首尾页,当某个内存需要回收,那它首先需要根据地址找到它所在的页 //如果它所在的页是101页,当我们拿着这个页去映射它对应的Span时,就会发现映射不到Span //因为我们只映射了首尾:100 102 //但nPages不同,nPages映射的目的是为了在回收内存的时候去合并更大的页, //因此就要找某页的前后页,如果它的前后页也没有被使用,那么它们就能合并 //例如: 页号为200的Span被回收,并尝试合并 //它会向前找页号199的Span(Span中可能有多页,199是该Span的最后一页), 向后找页号201的Span(201是该Span的第一页),看它们是否被使用 //但首尾位置是相对的,我们无法预知 //就像如果199所在的Span有3页:197 198 199 //对于200来说,它通过且只能通过199页去找所在的它所在Span是否被使用, //因为程序无法确定198是不是和199在同一个Span,如果不是,那么200要合并的是199所在的Span //而不是198所在的Span,因为200的Span只和199所在的Span连续 //但对于196来说,他需要的却是197而不是199 //因此相对位置无法确定,首尾页都必须映射。 //并且上述例子又再次印证了为什么nPage不需要映射中间页 //就像200不能够使用198去映射一样,中间页在Span中是没有被切割的,并且在这一层使用中间页映射是不合逻辑的 _pageMapSpan[nPages->_pageId] = nPages; _pageMapSpan[nPages->_pageId + nPages->_n - 1] = nPages; //最后一页的页号 = _pageId + n - 1 //将 i - page页的Span插入相应的页桶中, 然后将page页的Span交付上层。 _pclist[nPages->_n].PushFront(nPages); //PushVmap(); return kPages; } n++; } //走到这里,说明直到最后一个桶都没找到未分配的Span //需要直接向堆申请内存页。直接申请一个最大内存页:128页 //Span* newSpan = new Span; Span* newSpan = _spanPool.New(); void* ptr = SystemAlloc(NPage - 1); newSpan->_pageId = (PAGE_ID)ptr >> 13; // 页号 = 地址 / 8k newSpan->_n = NPage - 1; //链接到128页对应的桶 _pclist[newSpan->_n].PushFront(newSpan); return GetSpanToUpper(k); //再调一次该函数,自动完成切割并向上交付 } }
回收的时候消耗过大:map查找有消耗 + 锁竞争。 并且map越慢,竞争越激烈
精品文章推荐阅读: