文章目录
一、项目简介
1、1 项目介绍
1、2 开发环境和使用的技术
二、什么是内存池
2、1 池化技术
2、2 内存池
2、3 内存池解决的问题
三、定长内存池
3、1 malloc
3、2 定长内存池的设计
3、2、1 直接向堆申请空间
3、2、2 设计思路
3、2、3 代码实现
3、3 定长内存池与malloc的效率对比
四、高并发内存池设计框架
五、thread cache
5、1 thread cache 整体结构
5、2 映射规则
5、3 TLS 线程本地存储
5、4 向thread cache申请空间 Allocate()实现
5、5 慢增长向 central chche 申请空间
六、central_cache
6、1 central_cache 整体结构
6、2 span 结构详解
6、3 FetchRangeObj()实现
七、page_cache
7、1 page cache 的整体结构
7、2 NewSpan()实现
八、高并发内存池申请空间总结
♂️ 作者:@Ggggggtm ♂️
专栏:实战项目
标题:从0到1实现高并发内存池
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
该项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloc(tcmalloc源码),tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,是一种用于内存分配和管理的内存分配器(内存池)。这个项目旨在提高多线程应用程序的性能,实现了高效的多线程内存管理。特别是在需要频繁进行内存分配和释放操作的情况下。
其次tcmalloc是全球大厂google开源的,可以认为当时顶尖的C++高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。这也是我们在学习这个项目时,必须要熟悉掌握的一个重要原因!
本篇文章所实现的项目就是把tcmalloc中最核心的代码框架拿出来,模拟实现出一个自己的高并发内存池(高并发内存池项目源码),目的就是学习tcamlloc的精华。
本人所使用到的开发环境是vs2019。不过大部分可编译C/C++的环境都是可以的,只不过是其中的一小部分细节需要修改(相对简单)。
该项目所使用到的技术主要有:C/C++、链表、哈希表(哈希桶)、单例模式、多线程、TLS(Thread-Local Storage)线程本地存储、互斥锁等。
所谓池化技术,就是搞了一个空间(池子)来存储资源。而存储的资源是程序预先向系统申请过量的资源,然后放到自己的 “池子” 进行管理,以备不时之需。在计算机中,有很多使用"池"这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
有同学就会有所疑问:为什么要预先向系统申请过量资源呢?这样不就是浪费空间吗?之所以要申请过量的资源,是因为每次向系统申请该资源都有较大的开销,不如提前申请好了保存在我们的 “池子” 中,这样使用时直接去 “池子” 中拿取资源,不再频繁的向系统申请资源,这样就会变得非常快捷,大大提高程序运行效率。这样做会有空间浪费吗?答案是会有的,但是实际上并不会浪费很多。在申请的时候我们都会进行控制,程序结束时我们会再次还会给操作系统。
内存池是指程序预先从操作系统申请一块足够大内存,此后当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取。同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。很明显,内存池的目的是减少向系统申请和释放内存的次数,从而提高程序的性能和效率。
内存池首先解决的就是效率问题。其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?
内存碎片通常分为内部碎片(Internal Fragmentation)和外部碎片(External Fragmentation)。下面我将为您提供详细的例子来解释这两种碎片。
- 内部碎片(Internal Fragmentation): 内部碎片是指分配给进程的内存块中未被有效利用的部分。这通常发生在分配的内存块大于进程所需的内存大小时。以下是一个例子:假设有一个操作系统,它将内存以页(Page)为单位分配给进程。每个页的大小是8KB。现在,一个进程需要分配10KB的内存。操作系统分配了2个页面(16KB)给这个进程。由于内存以页为单位分配,而进程只使用了10KB内存,还有6KB是未被使用的,这部分未被使用的内存就是内部碎片。
- 外部碎片(External Fragmentation): 外部碎片是指内存中存在,但由于分散在不同位置或大小不适合进程需求,因此不能用于分配给新进程的可用内存。以下是一个例子:假设操作系统中有很多空闲的内存块,但它们都分散在内存中,并且没有足够的连续内存块以满足新进程的需求。这导致了外部碎片。例如,如果有10个1KB大小的空闲内存块,但一个进程需要分配一个4KB的内存块,由于这些空闲内存块无法合并在一起,就会导致外部碎片。
内部碎片和外部碎片都会浪费内存资源,因此操作系统和内存管理系统通常会采取不同策略来减少它们的影响。后面我们的项目中会有内部碎片与外部碎片的问题,到此会再次详解。
在学习定长内存池之前,我们再来认识一下malloc函数。C/C++中我们要动态申请内存都是通过malloc去申请内存。但是我们要知道,实际我们不是直接去堆获取内存的, 而malloc就是一个内存池。那么,使用 malloc() 在堆上分配内存到底是如何实现的呢?
一种做法是把 malloc() 的内存管理交给系统内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让 malloc() 使用这个系统调用去申请内存,不就可以了吗?当然这是一种理论上的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都要进行系统调用。我们知道系统调用的性能开销是比较大的,当程序对堆的操作比较频繁时,这样做的结果会严重影响程序的性能。
比较好的做法就是 malloc() 向操作系统申请一块适当大小的堆空间,然后由 malloc() 自己管理这块空间。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程 序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
malloc 的实现方 式有很多种,一般不同编译器平台用的都是不同的。比如windows 的 vs 系列用的微软自己写的一套, linux gcc用的 glibc 中的 ptmalloc。
我们知道 malloc 本质上也是一个内存池后,我们不妨自己也先实现一个较为简单的内存池,也就是定长内存池。怎么理解定长内存池和malloc中的内存池呢?malloc中的内存池可应用于申请任何空间大小,例如申请1byte、3byte、4byte、1kb、110MB等等。
定长内存池创建出对象后,向该内存池申请空间时,只能申请一个固定空间大小的内存。也就是说一个定长内存池对象只能应用于一种场景,但不代表只能申请一个对象!
为了方便我们后续对定长内存池和malloc做效率对比,在实现定长内存池中就不再采用malloc申请空间。我们直接采用系统调用向堆申请空间,在windows下的具体方法如下:
inline static void* SystemAlloc(size_t kpage) { #ifdef _WIN32 void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); #else // linux下brk mmap等 #endif if (ptr == nullptr) throw std::bad_alloc(); return ptr; }
宏定义_WIN32通常用于Windows平台的C和C++编程中,它是一个预处理器宏,用于判断当前代码是否在Windows环境下编译。当在Windows平台上编译代码时,宏定义_WIN32会被预处理器定义,以便开发者可以根据不同的平台条件编写相应的代码块。通常的使用方法如下:
#ifdef _WIN32 // Windows平台下的特定代码 // 在这里添加Windows平台特定的功能实现 #else // 非Windows平台下的代码 // 在这里添加非Windows平台下的功能实现 #endif
上述代码中,当在Windows平台下编译时,_WIN32这个宏通常由编译器自动定义,无需手动设置,从而执行#ifdef _WIN32下的代码块。而在其他平台下编译时,将执行#else下的代码块,实现了跨平台兼容性。需要注意的是,Windows平台的64位版本上编译时,通常会定义_WIN64,同时也定义了_WIN32。在Windows平台的64位版本上编译时,只有_WIN32别定义。
采用系统调用直接向堆申请空间时,我们所要申请的内存的大小,以字节为单位。注意,这个值应该是页大小的整数倍。
定长内存池也是一个内存池,在我们使用时首先得预先申请一批空间保存在我们的 “池子”中,这个 “池子” 具体是什么呢?不就是一个指针吗!当我们申请一批空间后, 用一个指针指向我们所申请空间的起始地址就可以了!为了方便使用,我们同时还应该需要记录 “池子” 中所剩余空间的大小。
在向定长内存池申请空间不再使用时,是直接还给操作系统吗?不是的!我们可以把释放的空间单独保存起来。
当再次申请空间时,我们先会去_freeList 中查看是否有空间。如果没有,就去内存池中查看是否有空间。如果两者都没有,就会向堆申请空间。这样也就会减少我们向堆申请空间的次数。
通过对设计思路的了解后,我们就很清楚的知道需要三个成员变量:
char* _memory = nullptr; // 内存块指针 size_t _remainBytes = 0; // 内存块剩余空间大小 void* _freeList = nullptr; // 回收链表(自由链表)
怎么很好的实现定长申请呢?这里我们引入类模板,可以很好的应用于不同定长的场景。
template
class ObjectPool { public: T* New() {} void Delete(T* obj) {} private: char* _memory = nullptr; // 内存块指针 size_t _remainBytes = 0; // 剩余空间大小 void* _freeList = nullptr; // 回收链表(自由链表) };
我们在实现回收链表时,并没有使用额外的变量来记录下一个节点,而是直接将下一个节点的地址存储在我们自身当中。我们找到下一个节点只需要解引用本身就可以了。具体如下图:
这也是对应的我们释放节点的代码,具体如下:
void Delete(T* obj) { obj->~T(); // 显示调用T对象的析构 // 头插到回收链表 *(int*)obj = _freeList; _freeList = obj; }
这里就涉及到一个问题:在32位下,指针的大小为4字节。而在64位下的指针大小是8字节。同时指针的类型决定我们解引用时访问多少个字节。那我们上述的代码正确吗?不要忘记了我们每个节点还存储着下一个节点的指针(地址)。
上述代码中,我们把obj对象强制类型转换为了 int*。这也就意味着我们在解引用访问其内容时,是固定访问的前4个字节。那要是在64位平台下,我们所存储的指针是8字节的,这样就是错误的!那怎么解决呢?我们本质是想解引用访问一个指针的大小,那么把obj强制转换成二级指针不就可以了吗!具体实现代码如下:
void Delete(T* obj) { obj->~T(); // 头插到回收链表 *(void**)obj = _freeList; _freeList = obj; }
我们再看申请空间的代码实现,具体如下:
T* New() { T* obj = nullptr; // 所要返回的对象指针 if (_freeList) // 优先从回收链表中申请空间 { void* next = *(void**)_freeList; obj = (T*)_freeList; _freeList = next; } else { size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T); if (_remainBytes < objSize) // 不足以开辟一个 T 对象的空间时进行申请空间 { _remainBytes = 128 * 1024; _memory = (char*)SystemAlloc(_remainBytes >> 13); if (_memory == nullptr) { throw std::bad_alloc(); } } obj = (T*)_memory; _memory += objSize; _remainBytes -= objSize; } // 定位new,显示调用T的构造函数初始化 new(obj)T; return obj; }
我们在申请空间时,应该考虑到所申请的对象T的大小是否额能够存储下一个节点的指针。假如我们T为char,我们只申请了一个字节的空间,这时该空间无法存储下一个节点的地址!所以我们应该最小申请的空间为一个指针的大小。其他的代码实现完全就是按照我们所讲述的申请空间的思路实现的。不再做过多详解。
设定一个函数用于测试定长内存池的性能,通过比较使用普通new运算符分配和释放内存与使用定长内存池的性能差异。测试思路如下:
- 在每轮测试中,它首先使用new分配 TreeNode对象,然后使用delete释放它们,并记录时间。
- 然后,它使用 ObjectPool 分配 TreeNode 对象,再使用Delete释放它们,并记录时间。
- 最后,它打印出两种分配方式的时间差异。
测试代码如下:
void TestObjectPool() { // 申请释放的轮次 const size_t Rounds = 5; // 每轮申请释放多少次 const size_t N = 1000000; std::vector
v1; v1.reserve(N); size_t begin1 = clock(); for (size_t j = 0; j < Rounds; ++j) { for (int i = 0; i < N; ++i) { v1.push_back(new TreeNode); } for (int i = 0; i < N; ++i) { delete v1[i]; } v1.clear(); } size_t end1 = clock(); std::vector v2; v2.reserve(N); ObjectPool TNPool; size_t begin2 = clock(); for (size_t j = 0; j < Rounds; ++j) { for (int i = 0; i < N; ++i) { v2.push_back(TNPool.New()); } for (int i = 0; i < N; ++i) { TNPool.Delete(v2[i]); } v2.clear(); } size_t end2 = clock(); cout << "new cost time:" << end1 - begin1 << endl; cout << "object pool cost time:" << end2 - begin2 << endl; } 我们来看运行结果:
通过对比发现,定长内存池确实要比 new(底层还是调用的malloc)快。原因就在于定长内存池处理的场景单一,而malloc处理的创景较多且为复杂,底层实现的也较为麻烦。
首先要明确我们所设计的项目所应用的场景:多线程下实现高并发。现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
我们所设计的高并发内存池主要由以下 3 个部分构成:
- thread cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内 存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
- central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。
- page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
此时,你大概率不能够很好的理解整体框架的设计。不过没关系,我们接着往下学习。当熟知其中的设计细节后,需要你再来回头看一下这个框架。并且需要你回答一个问题:这个项目为什么要设计成三层,设计成两层或者四层可以吗?为什么?
我们多线程在申请空间资源时,都是直接向thread cache层申请的。前面我们讲到了定长内存池可以处理固定大小的空间资源申请,但是现在我们需要申请不同大小的空间资源,需要怎么解决呢?同时对应的是:当我们释放不同大小的资源时,又需要怎么解决呢?
thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。
每个线程都会有一个thread cache 对象,这样每个线程在这里获取对象和释放对象时是无锁的(后续会讲解原因)。具体如下图:我们申请资源时,就去对应的哈希桶下所维护的自由链表申请即可。释放资源时,也是还回到对应的哈希桶下所维护的自由链表。其中我们需要对自由链表进行插入(释放资源)和删除(申请资源)操作,所以这里我们就对自由链表进行封装。代码如下:
class FreeList { public: void Push(void* obj) { assert(obj); // 头插 //*(void**)obj = _freeList; NextObj(obj) = _freeList; _freeList = obj; ++_size; } void* Pop() { assert(_freeList); // 头删 void* obj = _freeList; _freeList = NextObj(obj); --_size; return obj; } private: void* _freeList = nullptr; };
上述代码中的 NextObj 函数是对求下一个位置的封装,具体代码如下:
static void*& NextObj(void* obj) { return *(void**)obj; }
不要忘记了thread cache是一个哈希桶的结构,每个桶下面挂着的就是自由链表。那我们再看一下其代码实现:
class ThreadCache { public: // 申请空间 void* Allocate(size_t size); // 释放资源 void Deallocate(void* ptr, size_t size); private: FreeList _freeLists[NFREELIST]; // 哈希桶 };
thread cache 层支持申请小于256KB空间大小,申请空间大小大于256KB后文会讲解处理方法。如果我们像定长内存池那样,对于每种大小的资源申请都维护一个自由链表可以吗?答案是肯定不行的!1byte ~ 256KB一共有256*1024个不同的桶。姑且算他为20万个桶,单单存储自由链表指针就需要耗费大量的空间,那要是在多线程的情况下呢?更不用说了,肯定是不行的。那怎么来解决呢?
我们可以引入一种对齐规则。例如,1btye ~ 8byte 我们统一给它开辟8byte的空间,9byte ~ 16byte 统一开16byte的空间……。这样算下来我们需要维护桶的个数为:256*1024/8,这好像也有点多。为了尽量减少不同大小的个数和减少资源的浪费,我们采用以下的对齐规则:
- [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);
这样我们可以把浪费的空间控制在10%左右,同时也减少了所需要维护的桶的个数(208个桶)。那么现在我们就可以理解在向thread cache申请内存的过程了:
- 当内存申请size <= 256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标 i。
- 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。
- 如果_freeLists[i]中没有对象时,则批量从下一层(central cache)中获取一定数量的对象,插入到自由链表并返回一个对象。
根据映射规则,我们这里给出求取所申请对象大小按照我们上述规则对齐后大小的计算方法,同时给出结合申请对象的大小能够找到所对应桶下标的方法,具体代码如下:
// 对齐后大小的计算 inline size_t _RoundUp(size_t bytes, size_t alignNum) { return ((bytes + alignNum - 1) & ~(alignNum - 1)); } inline size_t RoundUp(size_t size) { if (size <= 128) { return _RoundUp(size, 8); } else if (size <= 1024) { return _RoundUp(size, 16); } else if (size <= 8 * 1024) { return _RoundUp(size, 128); } else if (size <= 64 * 1024) { return _RoundUp(size, 1024); } else if (size <= 256 * 1024) { return _RoundUp(size, 8 * 1024); } else { return _RoundUp(size, 1 << PAGE_SHIFT); } } inline size_t _Index(size_t bytes, size_t align_shift) { return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; } // 计算映射的哪一个自由链表桶 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 缓存呢?
TLS全称线程本地存储(Thread-Local Storage),是一种编程技术,用于在线程级别上创建和管理局部数据。它允许每个线程拥有自己独立的存储空间,以避免数据共享带来的竞争条件和数据混乱问题。
TLS的主要作用是为多线程应用程序提供一种机制,使每个线程能够访问和操作其自己的数据,而不会干扰其他线程的数据。这有助于确保线程之间的数据隔离和安全性。简单理解:在对线程情况下,TLS可以保证每个线程都有一个属于自己的变量实例,这意味着每个线程都可以独立地操作其自己的变量副本,而不会影响其他线程。
TLS与全局变量有什么不同?TLS与全局变量不同之处在于,全局变量是在整个程序中共享的,多个线程可以同时访问和修改全局变量,这可能导致数据竞争和不确定的行为。相比之下,TLS中的数据只能由拥有它的线程访问,从而避免了线程之间的竞争条件。总结以下TLS的优缺点:
TLS的优点:
- 数据隔离:每个线程都有自己的存储空间,不会干扰其他线程的数据。
- 线程安全:避免了竞争条件,减少了线程同步的需求。
- 简化多线程编程:提供了一种更容易管理数据的方式,特别是对于线程局部状态。
TLS的缺点:
- 内存开销:为每个线程维护独立的存储空间可能会增加内存开销。
- 复杂性:需要小心管理TLS变量,否则可能引入难以调试的问题。
TLS怎么使用呢?我们可在所需要的对象前加上:__declspec(thread)。__declspec(thread)是微软的 C++ 扩展语法,它用于定义线程局部存储(Thread Local Storage,TLS)变量。 具体代码实现:
static const size_t NFREELIST = 208; class ThreadCache { public: // 申请空间 void* Allocate(size_t size); // 释放资源 void Deallocate(void* ptr, size_t size); private: FreeList _freeLists[NFREELIST]; // 哈希桶 }; __declspec(thread) ThreadCache* pTLSThreadCache= nullptr; // 在调用时判断一下即可 if (pTLSThreadCache == nullptr) { pTLSThreadCache = new ThreadCache; }
TLS 保证了每个线程都有自己的一个 thread cache缓存对象。那么多线程在向申请thread cache 申请空间资源时,并不需要加锁。因为每个线程都是在向自己的 thread cache 缓存申请空间,与其他线程无关。那么这样是不是多线程就可以并发的向thread cache 申请对象了!!!
通过上述对thread cache 层的了解,那么接下来向thread cache缓存申请对象的代码实现就不难了!具体实现代码如下:
static const size_t MAX_BYTES = 256 * 1024; void* ThreadCache::Allocate(size_t size) { assert(size <= MAX_BYTES); // 对齐后的大小 size_t alignSize = SizeClass::RoundUp(size); // 哈希桶所映射的下标 size_t index = SizeClass::Index(size); if (!_freeLists[index].Empty()) // thread cache层有空间资源 { return _freeLists[index].Pop(); } else // thread cache层没有空间资源 { // 向下一层申请空间 return FetchFromCentralCache(index, alignSize); } }
我们一直在讨论向thread cache 层申请空间。那要是thread cache 层就没有对应的桶空间资源呢?这时候就需要向下一层(central cache)申请空间资源了。这里随之又带来一个问题:那么我一次向central cache 要多少的空间呢?
在向central chche申请空间时,我们应该考虑到以下几方面:
- 刚开始不要向central chche申请过多空间。假设我们第一次向central chche申请了100个对象的空间,后续一直不用就会产生浪费。
- 不能够申请固定的空间。例如,我们thread cache 中的哈希桶下有 8 字节的对象大小,也有256KB的对象大小,如果申请固定空间大小,必定会造成小块对象申请资源过多,大块对象申请资源过少。
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; }
- 采用变化的方式,也就是慢增长的方式。刚开始申请的空间少一点,后面持续增加(有上限)。
综上考虑,我们在向central cache申请空间时,采用缓慢增长方式。同时,对于大对象申请空间资源会多,对于小对象申请空间资源会少一点。具体实现代码如下:
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) { // 慢开始反馈调节算法 // 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完 // 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限 // 3、size越大,一次向central cache要的batchNum就越小 // 4、size越小,一次向central cache要的batchNum就越大 // 申请的批量资源 对象的个数 size_t batchNum = min(NumMoveSize(size), _freeLists[index].MaxSize()); //慢开始 if (_freeLists[index].MaxSize() == batchNum) { _freeLists[index].MaxSize() += 1; } }
上述的MaxSize()就是我们用于控制慢开始的一个数据,该数据是放在了 _freeList 中,也就是每个 _freeList 都有一个属于自己的MaxSize。例如,如果我们申请对象的大小为8btye,通过NumMoveSize()计算得到的是512个。实际上我们刚开始可能根本不需要这么多,于是MaxSize在这里起到了慢开始的关键作用。具体代码如下:
class FreeList { public: // …… size_t& MaxSize() { return _maxSize; } private: void* _freeList = nullptr; size_t _maxSize = 1; //用于慢增长部分 };
如果本次采用的值是_maxSize,那么将thread cache中该自由链表的_maxSize的值进行加1(可自行控制增量)。以便下次可申请更多的空间(慢增长)。
我们向 central chche 进行慢开始的申请空间。当申请完空间后,我们是不是还要将他们插入到对应的 thread cache 中哈希桶下的_freeList中,以便接着向 thread cache 申请空间。完整代码如下:
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) { size_t batchNum = min(SizeClass::NumMoveSize(size), _freeLists[index].MaxSize()); if (_freeLists[index].MaxSize() == batchNum) { _freeLists[index].MaxSize() += 1; } void* start = nullptr; void* end = nullptr; // 实际申请到的对象个数 size_t actulNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size); // 保证获取的对象个数最少为一个 assert(actulNum >= 1); if (actulNum == 1) { assert(start == end); return start; } else { _freeLists[index].PushRange(NextObj(start), end, actulNum - 1); return start; } }
以下一段代码就是再向 central chche 申请空间,后续(central cache)会对此详解:
size_t actulNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
我们接下来再看一下 _freeList 中的 PushRange()的实现,PushRang()的主要功能就是将获取到的批量对象插入到thread cache 对应的哈希桶下的_freeList中。具体代码如下:
class FreeList { public: void PushRange(void* start, void* end,size_t n) { NextObj(end) = _freeList; _freeList = start; _size += n; } private: void* _freeList = nullptr; size_t _maxSize = 1; //用于慢增长部分 };
以上即为thread cache 申请资源的核心思路和代码实现,我们并没有讲解释放资源,后续也会讲到释放资源。接下来我们来看一下central cache 的实现。
central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中。具体如下图:
什么是span呢?我们知道,通过系统调用向对空间申请空间时,都必须以页为单位。而页的大小一般都是4KB或者8KB。span其实本质上就是一个以页为单位的大块内存。
为什么span是以页为单位的大块内存呢?首先,当central cache中没有空间资源时,就会向pahe cache申请空间资源。而page cache中的空间资源都是直接通过系统调用来向堆申请空间的。其次,central cache 中的空间资源是为thread cache 提供的,就保证central cache中的资源不能太少,避免频繁向page cache 申请。
同时,central cache 在整个进程当中只有一份,所以这里我们采用了单例模式。 同时由于只有一份的原因,多个线程在向central cache 申请空间资源时是有竞争的,这就意味着需要加锁来保证线程安全(后续会详解)。我们再来看central cache 的代码框架实现:
class CentralCache { public: static CentralCache* GetInstance() { return &_sInst; } size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size); private: SpanList _spanLists[NFREELIST]; private: // 单例模式(饿汉模式) CentralCache() {} CentralCache(CentralCache&) = delete; static CentralCache _sInst; };
以下是span结构体详细字段:
struct Span { PAGE_ID _pageId = 0; // 大块内存起始页的页号 size_t _n = 0; // 页的数量 Span* _next = nullptr; // 双向链表 Span* _prev = nullptr; size_t _useCount = 0; // 大块内存切好的小块内存已经被分配的数量 void* _freeList = nullptr; // 切好的小块内存的自由链表 };
_freeList 字段就是用来指向该span中切好的一块块小内存。具体可结合下图理解:
那一个span怎么就表示了一块大段内存呢?答案是用 _pageId 页号字段和 _n 页的数量字段来表示的一个大块内存。页号就是与地址的映射,也可以理解为页号就是地址。具体可结合下图理解:
我们知道在32位平台下,进程地址空间的大小是2^32,而在64位平台下,进程地址空间的大小就是2^64。该项目我们认为以页的大小为8KB。那么在32位的平台下,整个地址空间会被切分为2^32 / 2^13(8KB) = 2^19 页。此时页号的取值范围为0~2^19。在64位平台下就会被切分为2^64 / 2^13(8KB) = 2^51 页。这时页号的取值范围即为0 ~ 2^51。这时候发现一个无符号整数并不能安全的存储页号,所以这里我们采用条件编译,具体代码如下:
#ifdef _WIN64 typedef unsigned long long PAGE_ID; #elif _WIN32 typedef size_t PAGE_ID; #else //Linux #endif
一个span到底是有几页呢?这个是不固定的,主要取决于申请对象的大小。假如一个8Byte哈希桶中的span,一般情况下该span的页数就是1页。因为1页的span就会被切成1024(8*1024byte / 8byte) 个8Byte大小的内存块挂到当前span的自由链表中,而一个对象的大小为256KB时,span的页数还能是1页吗?可定不是的。后续会有固定算法来求取页数。
central cache的每个哈希桶位置挂是SpanList链表结构,该链表结构是一个双向链表(在后续回收资源中可以很好的体现出原因)。所以span 结构体中有两个指针:_next、_prev。对该链表我们需要有插入和删除操作。具体实现如下:
class SpanList { public: SpanList() { _head = new Span; _head->_next = _head; _head->_prev = _head; } void PushFront(Span* span) { Insert(Begin(), span); } Span* PopFront() { Span* front = _head->_next; Erase(front); return front; } 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; } private: Span* _head; public: std::mutex _mtx; };
span结构中的_useCount成员是用来记录当前span中切好的小块内存已经被分配给thread cache的数量。当某个span的_useCount计数变为0时,代表当前span切出去的内存块对象全部还回来了,此时central cache就可以将这个span再还给page cache(后续会详解)。
第一次提到FetchRangeObj()函数是在5、5小节中,该函数功能是从central cache中批量对象返回给thread cache。
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
由于需要将获取到的批量对象连接到 thread cache 对应的_freeList 中,所以我们在 thread cache 中必须传入两个输出型参数(start 和 end)。为了很好的确定thread cache 需要申请对象的个数和申请对象的具体大小,我们还必须增加两个参数(batchNum 和 size)。size 同时也告知了central cache 需要去那个哈希桶下申请对象!
问题来了:假如 thread cache 实际向 central cache 申请了100个对象,central cache 就必须给 thread cache 100个对象吗?答案并不是!注意:thread cache 向 central cache 要批量对象的原因是以后不再频繁的向central cache申请对象,但是实际上 thread cache 此时需要的是一个对象就可以。所以 central cache 返回的对象必须大于等于1,但不是必须等于thread cache 实际申请的对象个数。
我们知道了central cache 在整个进程中只有一个实例对象,那么在对线程调用时必定会存在竞争问题。这时我们必需要加锁来保证线程的安全。怎么加锁合适呢?是直接把整个central cache 锁住吗? 下面我们来仔细分析一下。
如上图情况:线程1和线程2同时都向各自的thread cache 申请 8btye空间,而且同时两个线程各自的thread cache都没有对应的空间资源,这时两个线程都就向central cache 申请资源了。而且两个线程也都是在central cache的同一个哈希桶下寻找空间资源,这时候不加锁就会出现问题。但是线程3申请的对象大小与线程1和线程2并不相同,所以寻找到central cache中的哈希桶也不同,这时线程3并不会与线程1和线程2产生影响,因为他们就不再同一个桶下申请空间,所以也就没有竞争。这时候我们发现对central cache 整体加锁并不合适,更合适的是对central cache 下的桶进行加锁!加桶锁就可以很好的解决不同线程在central cache下相同的哈希桶申请资源竞争的问题。同时也不影响在其他的哈希桶下申请资源。所以我们在SpanList中添加了一个锁成员,这表明每个桶下面SpanList都会有一把锁。
前面我们该清楚了一些细节问题,接下来看一下具体的申请过程。
假设我们在 thread cache 中申请的对象大小为8字节, thread cache 对应的哈希桶下的_freeList 并没有空间资源,现在我们需要向 central cache 对应的哈希桶下的SpanList中申请。我们首先做的是要遍历SpanList找到一个合适的span,该span至少有一个小对象块。
还有一种可能:该SpanList中确实有很多span,但是每个span下的_freeList都为空,也就是span中的小对象块都被thread cache申请走了,这时候表明central cache 中并没有空间资源给 thread cache,这时候就需要向 page cache 申请空间资源了。我们看代码实现:
Span* CentralCache::GetOneSpan(SpanList& list,size_t size) { Span* begin = list.Begin(); while (begin != list.End()) { if (begin->_freeList != nullptr) { return begin; } else { begin = begin->_next; } } // 到这里说明没有找到合适的span,这也就意味着central cache中没有空间资源 // 需要去page cache中申请资源 Span* span = PageCache::GetInstance()->NewSpan(NumMovePage(size)); }
假设我们获得到了一个合适的span,那么我们就要看该span中有多少个对象资源了,最后返回实际从span中得到的对象的个数。下面我们看具体代码的实现:
// 从中心缓存获取一定数量的对象给thread cache 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; int i = 0; int actulNum = 1; while (i < batchNum - 1 && NextObj(end) != nullptr) { end = NextObj(end); ++i; ++actulNum; } span->_freeList = NextObj(end); NextObj(end) = nullptr; span->_useCount += actulNum; _spanLists[index]._mtx.unlock(); return actulNum; }
以上即为向central cache申请空间资源的核心思路与代码实现。下面我们看一下page_cache 的实现。
page cache也是哈希桶的结构,并且page cache的每个哈希桶下挂的也是多个的span,这些span也是按照双链表的结构链接起来形成SpanList。但是对应的哈希桶的映射关系是与thread cache 和 central cache 不同的。具体如下图:
page cache服务的对象是 central cache。central cache 向 page cache 要空间资源时,是申请的以页为单位的span大内存块(至于central cache向page cache申请多少页的span,是由central cache自己来决定,后续会讲解)。所以我们在page cache的映射关系是以不同页数的span来映射的。结合上述举例说明:第一个哈希桶下挂的全是1页大小的span对象(一个span的大小为8KB)、第二个哈希桶下挂的全是2页大小的span对象(一个span的大小为8KB*2)、第128个哈希桶下挂的全是128页大小的span对象(一个span的大小为8KB*128)。
这里提问:有没有想过为什么central cache中的spanList下的span对象还要被切分为多个小块对象?原因就在于central cache就是在为thread cache服务。
我们这里为什么这里最大的span为128页呢?因为线程申请单个对象最大是256KB,而128页(128*8KB)可以被切成4个256KB的对象,可以满足对上层的需求。当然,最大的span可以根据自己的需求进行体调整,并非固定为128页。
page cache 在整个进程当中也只有一份,所以这里我们采用了单例模式。多个线程从central cache 向 page cache 申请空间资源时肯定是有竞争的,这就意味着需要加锁来保证线程安全(后续会详解)下面我们看page cache 的代码框架:
static const size_t NPAGES = 129; // 最大页数,从1开始 static const size_t PAGE_SHIFT = 13; // 通常用来左移转换为一页的大小 // 1 << PAHE_SHIFT = 1 << 13 = 8 * 1024 class PageCache { public: static PageCache* GetInstance() { return &_sInst; } // central cache 向 page cache 获取资源 Span* NewSpan(size_t k); private: SpanList _spanLists[NPAGES]; public: std::mutex _pageMtx; private: PageCache() {} PageCache(const PageCache&) = delete; static PageCache _sInst; };
NewSpan是central cache 向page cache 申请空间资源,也就是申请span资源。问题来了:central cache 向page cache 申请几页大小的span呢?前面我们也讲到了主要取决于对象的大小。这里就不再过多解释,我们直接看代码实现:
// 计算一次向系统获取几个页 // 单个对象 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; }
当我们知道申请几页的span后,那么就要去对应的page cache的哈希桶下查找看是否有空间资源。如果page cache对应的哈希桶下没有空间资源,先去更大的桶(更大页数的span)中查找,是否有资源。具体例子理解一下:假如我们需要一个2页的span,page cache对应的哈希桶并没有,就需要去3~128页span的哈希桶中查找。如果3~128页span的哈希桶中有,就把对应的span进行切分。假如128页的span哈希桶有资源,就会把128页的span切分为2页和126页的span。具体如下图:
注意,这里是需要加锁的,下面我们再来仔细分析一下:加锁的原因和是加桶锁,还是对整体的page cache 加锁。
如上图,当多个线程都在向page cache 申请资源时,加锁是必须的。关键在于是加什么锁。假设我们这里加的是桶锁:线程1向page cahe申请资源时,central cache向page cache要一个2页的span,这时对page span中的2页的span的桶加锁。但是page span中并没有2页的span,所以还需要继续向下查找。假设直到128页的span才有空间资源,期间在查找时访问的每一个桶都需要进行加锁(避免其他线程同时访问资源导致数据错乱)和解锁(一但查到对应的哈希桶也没有资源就可以解锁了),这也就造成了大量的消耗!所以桶锁在 page cache 并不合适,需要对page cache 整体加锁,这样就避免了频繁的对锁资源的申请与释放!效率会更高!因为是整体加锁,所以我们直接在PageCache类中定义了一把锁(成员变量)。这里我们直接看其代码实现:
Span* PageCache::NewSpan(size_t k) { assert(k > 0); // 先检查第k个桶里面有没有span if (!_spanLists[k].Empty()) { return _spanLists[k].PopFront();; } // 检查一下后面的桶里面有没有span,如果有可以把他它进行切分 for (size_t i = k + 1; i < NPAGES; ++i) { if (!_spanLists[i].Empty()) { Span* nSpan = _spanLists[i].PopFront(); Span* kSpan = new Span; // 在nSpan的头部切一个k页下来 // k页span返回 // nSpan再挂到对应映射的位置 kSpan->_pageId = nSpan->_pageId; kSpan->_n = k; nSpan->_pageId += k; nSpan->_n -= k; _spanLists[nSpan->_n].PushFront(nSpan); return kSpan; } } // 走到这个位置就说明后面没有大页的span了 // 这时就去找堆要一个128页的span Span* bigSpan = new Span; void* ptr = SystemAlloc(NPAGES - 1); bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; bigSpan->_n = NPAGES - 1; _spanLists[bigSpan->_n].PushFront(bigSpan); // 递归调用自己,避免代码冗余 return NewSpan(k); }
怎么没有看见加锁呢?我们把加锁放在了调用该函数的地方,也就是 GetOneSpan()中,那我们接着补全 GetOneSpan()中的代码。
提问:在对page cache加锁时,可以把central cache 中的桶锁释放掉吗?首先想一下为什么会到page cache层?不就是central cache 层没有空间资源吗!而且page cache层加的是整体的锁,所以central cache 的桶锁完全可以释放掉。这样如果其他线程释放内存对象回来,不会阻塞(后续会讲解)。
申请到对应的span后,我们还需要将span切分为多个小块内存插入(尾插)到span的_freeList中去。在切分的过程中,由于没有连接到任何的哈希桶上去,所以不会被其他线程拿到!这里是线程安全的。
这里有一个小细节:为什么是尾插呢?因为我们如果是将切好的对象尾插到自由链表,这些对象逻辑上是链式结构,实际它们在物理上是连续的,这时当我们把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率(命中率)。在切好后续连接到central cache中对应的哈希桶时,这时候在把桶锁加上就行!具体实现代码如下:
Span* CentralCache::GetOneSpan(SpanList& list,size_t size) { Span* begin = list.Begin(); while (begin != list.End()) { if (begin->_freeList != nullptr) { return begin; } else { begin = begin->_next; } } // 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞 list._mtx.unlock(); // 走到这里说没有空闲span了,只能找page cache要 PageCache::GetInstance()->_pageMtx.lock(); Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size)); PageCache::GetInstance()->_pageMtx.unlock(); char* start = (char*)(span->_pageId << PAGE_SHIFT); // 该span的起始地址 size_t bytes = span->_n << PAGE_SHIFT; // 该span的大小 char* end = start + bytes; //把申请大块内存的span切分成对应小块内存(尾插进_freeList),再连入SpanList中 span->_freeList = start; start += size; void* tail = span->_freeList; while (start < end) { NextObj(tail) = start; tail = NextObj(tail); start += size; } NextObj(tail) = nullptr; // 切好span以后,需要把span挂到桶里面去的时候,再加锁 list._mtx.lock(); list.PushFront(span); return span; }
以上即为整个申请空间资源的过程。总结一下整个申请空间资源的过程:
当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。 central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache。 central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread cache,就++use_count。 当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。 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式 申请128页page span挂在自由链表中,再重复1中的过程。 需要注意的是central cache和page cache 的核心结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。
在thread_cache层释放资源时并不是真正的还会给了操作系统,而是还给了自由链表。其中的细节也是较多,我们下篇文章会对整个释放空间资源的过程进行详解。同时还会对我们整个高并发内存池进行性能测试与调优!感谢阅读ovo~