C++项目 – 高并发内存池(二)Thread Cache
文章目录
- C++项目 -- 高并发内存池(二)Thread Cache
- 一、高并发内存池整体框架设计
- 二、thread cache设计
- 1.整体设计
- 2.thread cache哈希桶映射规则
- 3.TLS无锁访问
- 4.thread cache代码
一、高并发内存池整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
concurrent memory pool主要由以下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设计
1.整体设计
thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的。
- 类比定长内存池,一大块内存用来分配空间,freeList用于管理已经分配好的定长内存块
- thread cache的freeList可以设计多个大小的定长list,如下图,8字节、16字节…;对象小于等于8字节的由8字节的freelist管理,9到16字节的由16字节的freeList管理;
- 但这样设计会造成空间浪费,比如一个对象大小为6字节,为它分配了8字节的空间,那么这剩下的2字节就会成为碎片,这叫做内碎片;
- thread cache整体是一个哈希桶结构,将对象的大小映射到对应大小的freeList中进行管理;
申请内存:
- 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。
- 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。
- 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。
释放内存:
- 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。
- 当链表的长度过长,则回收一部分内存对象到central cache。
2.thread cache哈希桶映射规则
- thread cache最大支持一个对象申请256KB的内存空间,则对象的大小范围是1 ~ 256KB,如果以8字节为对齐数,指定freeList,就是1 ~ 8字节大小的对象按照8字节对齐,分配8字节空间,连接到第一个freeList后面;9 ~ 16字节大小的对象分配16字节空间,连接到第二个freeList后面,这就是内存对齐;
- 每一个对齐的freeList都是一个哈希桶,这就是哈希映射;
- 如果将所有的哈希桶对齐规则都为8字节,则一共需要32768个哈希桶,数量太多;
因此我们需要制定一个对齐规则:
分段安排对齐数的大小;
- 对象大小在1 ~ 128字节之间的,按照8字节对齐,即每个哈希桶大小增长8字节;
- 那么8字节对齐的哈希桶就有16个,即对应freeLists[0] - freeList[15];
第一个桶链接对象大小在1 ~ 8字节之间的,第二个链接9 ~ 16字节之间,依次类推; - 对象大小在129 - 1024之间的,按照16字节对齐,即每个哈希桶大小增长16字节,以此类推;
- 最终分配下来,8字节对齐的哈希桶共16个,16字节对齐的哈希桶共56个,128字节对齐的哈希桶共56个,1024字节对齐的哈希桶共56个,8KB字节对齐的哈希桶共56个,所有的哈希桶加起来一共208个,也就是说一共有208个freeList;
- 根据上面的对齐规则可以将对象的size按照对齐数进行对齐,对齐的逻辑:
如果size不是对齐数alignNum的倍数,就需要根据对齐数调整最终分配空间的大小,否则size就是最终分配空间的大小; - 上面的计算过程可以使用下面的位运算进行代替,因为这段代码会被频繁调用,位运算的效率更高:
这样分配的好处:
- 能够减少哈希桶的数量
- 能够将内碎片浪费控制在10%左右
- 以16字节对齐为例,16字节对齐数能浪费的空间最大为15字节;如果一个对象分配到了129字节的内存,其对应的对齐数是16,则最终系统会为该对象分配145字节的空间,那么就有15字节的空间浪费,则内碎片为15字节,浪费率 = 15 / 145 = 0.1034 ,后面128字节等的对齐规则类似
- 前面8字节对齐的部分可能不止10%,但是从16字节开始就能够控制在10%左右;
在制定好字节对齐规则后,还需要制订哈希映射规则,将不同大小的对象映射到对应的freeList中:
- 根据字节对齐规则,1 ~ 128字节大小的对象,映射在8字节对齐的哈希桶,其映射逻辑如下:
129 ~ 1024字节大小的对象也是这样的规则; - 上面的代码可以使用位运算代替,因为这段代码会被频繁调用,位运算的效率更高:
3.TLS无锁访问
在多线程环境下,ThreadCache的创建和访问会涉及到锁的问题,我们希望每个线程都有独立的ThreadCache,并且访问自己的ThreadCache都无须加锁,这样就需要使用TLS;
线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。
Linux下TLS
win下TLS
TLS —— thread local storage:线程本地存储,我们这里使用静态的TLS:
声明以下代码:
_declspec(thread) DWORD data=0;
声明了_declspec(thread)的变量,会为每一个线程创建一个单独的拷贝。
- 静态TLS的原理
在×86CPU上,将为每次引用的静态TLS变量生成3个辅助机器指令。如果在进程中创建了另一个线程,那么系统就要将它捕获并且自动分配另一个内存块,以便存放新线程的静态TLS变量。新线程只拥有对它自己的静态TLS变量的访问权,不能访问属于其他线程的TLS变量。
ThreadCache.h
- 在该头文件下定义一个全局的
ThreadCache*
类型的静态指针pTLSThreadCache
,使用_declspec(thread)
声明后,每一个线程都会为该指针创建一个单独的拷贝,本线程在访问pTLSThreadCache
指针时是能够全局访问的,但是其他线程不能访问本线程的pTLSThreadCache
指针,这样就实现了多线程环境下的无锁访问; - 其实就是每一个线程都有一个独立的
pTLSThreadCache
,线程之间访问互不干扰
ConcurrentAlloc.h
- 上面的代码只是声明了TLS的指针,并没有指向实际的ThreadCache对象,实际上每个线程在运行的时候,都需要将TLS指针指向自己的ThreadCache对象;
- 这个头文件是对多线程环境下ThreadCache申请和释放内存的功能进行了封装,保证每个线程的ThreadCache都是本线程独有的,
ConcurrentAlloc
函数会检测pTLSThreadCache
是否为空,如果为空,证明初次调用,就需要构建一个新的ThreadCache对象,并将pTLSThreadCache指针指向该对象,这样本线程独有的ThreadCache对象就创建好了,再通过pTLSThreadCache指针去调用Allocate
函数开辟空间;
4.thread cache代码
Common.h
- 公共的头文件,共有部分的代码可以写在这里面;
- NextObj函数用于获取当前obj对象指向的下一个对象的指针:
- 将自由链表定义为一个类
FreeList
,实现链表的基本操作 - 定义一个管理字节对齐和哈希映射规则的类
SizeClass
:- 类中的所有成员函数都定义为静态的内联函数,方便外部直接调用;
RoundUp
是用来计算当前对象size字节对齐之后对应的size,先判断对象的size在哪个对齐区间,再根据对齐数来计算对齐后的size(调用子函数_RoundUp
)Index
函数用来计算对象size映射到哪一个哈希桶(freelist),根据对象size所属的对齐区间和对齐数,调用_Index
函数计算该对象映射到的哈希桶下标;
注意从第二个对齐区间开始,由于前面部分的对齐数是不同的,因此在计算下标的时候,先要用size减去前面不同对齐数的区间,带入_Index
函数计算该对象在当前区间内的相对下标,最后再加上前面所有的哈希桶个数,得到最终的下标;
例如:如果映射到的是16字节对齐的区域,先要用分配空间减去128,因为前面的128字节是8字节对齐的,要减去,剩下的按照16字节对齐计算,再加上前面减去的桶的数量,以此类推
#pragma once
//公共头文件
#include <iostream>
#include <vector>
#include <assert.h>
#include <thread>
using std::cout;
using std::endl;
using std::vector;
static const size_t MAX_BYTES = 256 * 1024; //ThreadCache能分配对象的最大字节数
static const size_t NFREELIST = 208; // 最大的哈希桶数量
// 访问obj的前4 / 8字节地址空间
static void*& NextObj(void* obj) {
return *(void**)obj;
}
//自由链表类,用于管理切分好的小内存块
class FreeList {
public:
void Push(void* obj) {
assert(obj);
//头插
NextObj(obj) = _freeList;
_freeList = obj;
}
void* Pop() {
assert(_freeList);
//头删
void* obj = _freeList;
_freeList = NextObj(obj);
return obj;
}
bool Empty() {
return _freeList == nullptr;
}
private:
void* _freeList = nullptr;
};
// 管理对齐和哈希映射规则的类
class SizeClass {
public:
//对齐规则
// 整体控制在最多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)
//RoundUp的子函数,根据对象大小和对齐数,返回对象对齐后的大小
static inline size_t _RoundUp(size_t size, size_t align) {
//if (size % align == 0) {
// return size;
//}
//else {
// return (size / align + 1) * align;
//}
//使用位运算能够得到一样的结果,但是位运算的效率很高
return ((size + align - 1) & ~(align - 1));
}
//计算当前对象size字节对齐之后对应的size
static inline size_t RoundUp(size_t size) {
assert(size <= MAX_BYTES);
if (size <= 128) {
//8字节对齐
_RoundUp(size, 8);
}
else if (size <= 1024) {
//16字节对齐
_RoundUp(size, 16);
}
else if (size <= 8 * 1024) {
//128字节对齐
_RoundUp(size, 128);
}
else if (size <= 64 * 1024) {
//1024字节对齐
_RoundUp(size, 1024);
}
else if (size <= 256 * 1024) {
//8KB字节对齐
_RoundUp(size, 8 * 1024);
}
else {
assert(false);
}
return -1;
}
//Index的子函数,用于计算映射的哈希桶下标
static inline size_t _Index(size_t size, size_t alignShift) {
//if (size % align == 0) {
// return size / align - 1;
//}
//else {
// return size / align;
//}
//使用位运算能够得到一样的结果,但是位运算的效率很高
//使用位运算需要将输入参数由对齐数改为对齐数是2的几次幂、
return ((size + (1 << alignShift) - 1) >> alignShift) - 1;
}
//计算对象size映射到哪一个哈希桶(freelist)
static inline size_t Index(size_t size) {
assert(size <= MAX_BYTES);
//每个区间有多少个哈希桶
static int groupArray[4] = { 16, 56, 56, 56 };
if (size <= 128) {
return _Index(size, 3);
}
else if (size <= 1024) {
//由于前128字节不是16字节对齐,因此需要减去该部分,单独计算16字节对齐的下标
//再在最终结果加上全部的8字节对齐哈希桶个数
return _Index(size - 128, 4) + groupArray[0];
}
else if (size <= 8 * 1024) {
return _Index(size - 1024, 7) + groupArray[0] + groupArray[1];
}
else if (size <= 64 * 1024) {
return _Index(size - 8 * 1024, 10) + groupArray[0] + groupArray[1] + groupArray[2];
}
else if (size <= 256 * 1024) {
return _Index(size - 64 * 1024, 13) + groupArray[0] + groupArray[1] + groupArray[2] + groupArray[3];
}
else {
assert(false);
}
return -1;
}
};
ThreadCache.h
- 用于声明ThreadCache类的头文件
- ThreadCache类包括一个
FreeList
类型的数组,这就是哈希桶的数组,还有完成ThreadCache功能的成员函数的声明; - 定义了由
_declspec(thread)
声明的TLS指针,用于实现无锁访问
#pragma once
#include "Common.h"
class ThreadCache {
public:
//申请和释放对象内存
void* Allocate(size_t size);
void Deallocate(void* obj, size_t size);
//从中心缓存获取对象
void* FetchFromCentralCache(size_t index, size_t alignSize);
private:
FreeList _freeLists[];
};
//声明_declspec(thread)后,会为每一个线程创建一个单独的拷贝
//使用_declspec(thread)声明了ThreadCache*指针变量,则该指针在该线程中会创建一份单独的拷贝
//pTLSThreadCache指向的对象在本线程内是能够全局访问的,但是无法被其他线程访问到,这就做到了多线程情景下的无锁访问
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
ThreadCache.cpp
- 这个.cpp文件中来实现ThreadCache中的成员函数:
Allocate
函数为对象申请内存空间- 先获取对齐后的size和对应的哈希桶下标
- 如果该哈希桶的freeList不为空,就Pop一个内存块给该对象,如果为空就需要向CentralCache申请空间
Deallocate
函数用于归还对象的内存空间- 先获取对象对应的freeList的下标
- 直接将该内存块插入对应的freeList中
FetchFromCentralCache
用于从中心缓存获取对象空间
#include "ThreadCache.h"
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()) {
//若对应的freeList桶不为空,直接pop一个内存块给该对象
return _freeLists[index].Pop();
}
else {
//否则需要从CentralCache获取内存空间
return ThreadCache::FetchFromCentralCache(index, alignSize);
}
}
void ThreadCache::Deallocate(void* obj, size_t size) {
assert(obj);
assert(size <= MAX_BYTES);
//找该对象对应的freeList的桶,直接插入
size_t index = SizeClass::Index(size);
_freeLists[index].Push(obj);
}
void* ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize) {
return nullptr;
}
ConcurrentAlloc.h
- 该头文件用于进一步封装ThreadCache的功能,进而使其能够实现多线程情况下的无锁访问
#pragma once
#include "Common.h"
#include "ThreadCache.h"
static void* ConcurrentAlloc(size_t size) {
if (pTLSThreadCache == nullptr) {
//如果pTLSThreadCache指针是空的,就构造一个ThreadCache对象,并指向它
//则这个ThreadCache对象就是本线程专属的ThreadCache对象
pTLSThreadCache = new ThreadCache;
}
//使用pTLSThreadCache访问本线程专属的ThreadCache对象来开辟空间
return pTLSThreadCache->Allocate(size);
}
static void ConcurrentFree(void* obj, size_t size) {
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(obj, size);
}
UnitTest.cpp
- 该cpp文件用于测试ThreadCache的功能,重点测试TLS无锁访问的功能
- 创建两个线程,分别使用ThreadCache申请空间,使用并行监视窗口监视每个进程的内容;
- c++11的多线程,是将一个线程封装成一个对象
构造thread对象时,传入该线程执行的函数的指针以及参数;
#include "ObjectPool.h"
#include "ConcurrentAlloc.h"
#include "ThreadCache.h"
void Alloc1() {
for (int i = 0; i < 5; i++) {
void* obj = ConcurrentAlloc(5);
}
}
void Alloc2() {
for (int i = 0; i < 5; i++) {
void* obj = ConcurrentAlloc(8);
}
}
void TestTLS() {
std::thread t1(Alloc1);
std::thread t2(Alloc2);
t1.join();
t2.join();
}
int main() {
TestTLS();
return 0;
}
测试结果:
-
两个不同的线程都获取和ThreadCache对象,但是通过TLS获取到的是两个不同的ThreadCache,每个线程各一个,两个线程通过pTLSThreadCache指针访问各自的ThreadCache对象
-
也可以通过输出线程id和pTLSThreadCache指针来观察验证