高并发内存池(三):手把手从零搭建ThreadCache线程缓存

一、什么是ThreadCache

在 C++ 的内存管理库(如 tcmalloc、jemalloc 等)中,ThreadCache(线程缓存)是一种优化内存分配性能的机制,主要用于解决多线程环境下内存分配的效率和锁竞争问题。

其核心思想是:为每个线程分配一个独立的内存缓存区域,线程在进行内存分配时,首先尝试从自己的 ThreadCache 中获取内存块,而非直接向全局内存池申请。这样可以大大减少多线程间的锁竞争,提升内存分配效率。

ThreadCache 的主要特点包括:

  1. 线程私有 :每个线程拥有自己的 ThreadCache,避免了多线程操作全局内存池时的锁开销。
  2. 分级缓存:通常会根据内存块的大小(如 8B、16B、32B 等不同规格)维护多个空闲链表,快速满足对应大小的内存分配请求。
  3. 按需补充 :当 ThreadCache 中某种规格的内存块不足时,会从更上一级的内存池(如 CentralCache)批量申请内存块进行补充。
  4. 延迟释放 :线程释放的内存块不会立即归还给全局内存池,而是先缓存到 ThreadCache 中,供后续分配复用,减少内存碎片和系统调用。

通过 ThreadCache 机制,内存分配器能够在多线程场景下显著提升性能,这也是 tcmalloc 等高效内存分配库的核心优化手段之一。

二、搭建ThreadCache

在搭建ThreadCache我们首先需要明白ThreadCache在高并发内存池项目中承担的作用以及需要完成的功能。总结一下,ThreadCache在内部会以指针数组的形式维护多个内存块大小相互不同的自由链表,在这里我们将整个数据结构叫做"哈希桶"。当用户需要申请一个内存空间时,ThreadCache会根据用户申请的内存空间大小选择合适的桶(自由链表)并Pop一个内存块即可。

首先我们需要创建一个Commond.h和ThreadCache.h以及ThreadCache.cpp将ThreadCache的大体框架声明出来。Commond.h文件的作用是声明一些公共的数据结构和方法,首先我们先来封装一下自由链表:

cpp 复制代码
static inline void*& NextObj(void* obj)
{
	return *(void**)obj;
}
class FreeList
{
public:
	void Push(void* obj)
	{
		//头插:
		//*(void**)obj = _freelist;
		NextObj(obj) = _freelist;
		_freelist = obj;
	}
	void* Pop()
	{
		void* obj = _freelist;
		_freelist = NextObj(_freelist);
		return obj;
	}
	bool Empty()
	{
		return _freelist == nullptr;
	}
private:
	void* _freelist = nullptr;
};

这里需要注意的是:相比于前面我们设计的定长内存池,高并发内存池需要处理不同大小的内存申请需求,每个自由链表管理的内存块大小也互不相同。所以为了统一凡是涉及到对内存块操作我们都一律使用void*指针表示只关注指针本身,所以在上面的代码中内存块的Push和Pop的参数与返回值都是void*,因为可能需要处理不同大小内存块的插入和删除操作。

在ThreadCache.h中主要包含这么几个成员变量与方法:

cpp 复制代码
class ThreadCache
{
public:
	//申请内存:
	void* Allocate(size_t size);
	//释放内存:
	void Deallocate(void* ptr, size_t size);
	
private:
    //static const size_t NFREE_LIST = 208;
	FreeList FreeLists[NFREE_LIST];
};

这里来解释一下,作为高并发内存池与用户接触的"前沿阵地"需要为用户提供两个接口Allocate和Deallocate用来处理不同大小的内存申请和释放请求,所以这里指针ptr的类型为void*表示指向大小任意的内存块,Allocate的返回值void*表示返回大小任意的内存块。

对于成员变量来说,ThreadCache的数据结构是一个"哈希桶"它是一个元素为自由链表FreeList,大小为NFREE_LIST的数组。每个自由链表保存着不同大小的内存块用来高效快速地处理用户的内存申请请求。

接着我们创建一个ThreadCache.cpp用来实现接口的声明与定义分离避免重复包含,下面我们着重讲解一下Allocate和Deallocate接口的实现:

2.1 Allocate接口的初步搭建

2.1.1 对齐映射问题

在上一章定长内存池的设计中,当用户传进来的模板参数T的大小小于一个指针大小(sizeof(void*))时我们默认给予一个指针大小的内存块,这本质上是一种对齐规则。而在高并发内存池中也存在着一种内存对齐规则,我们来详细说明一下:

内存池支持任意大小的内存申请操作但并不是"申请多少,分配多少"。首先申请的内存大小会有一个最大上限,在这个上限之内会由内存池来分配超出上限时,因为申请的内存太大直接会向堆上申请:

cpp 复制代码
static const size_t MAX_BYTES = 256 * 1024;

在256*1024之内如果"申请多少,分配多少"那么用户可能会有256*1024种申请情况(如果前8个字节统一都给8字节的话那么也要256*1024-7种可能情况)。此时就意味着ThreadCache的哈希桶中必须要有256*1024个桶(自由链表),而一个自由链表的成员变量是一个头指针,在32位系统中这个庞大的哈希桶的大小会是256*1024*4个字节,在64位系统中会更大达到了256*1024*8字节。

除了这种完全无对齐的 "按需分配"会导致庞大体积的哈希桶外还会导致以下问题:

  • 哈希映射 "无规律可循":完全随机的大小(17B、23B、45B)无法设计高效的哈希函数 ------ 要么冲突率极高(多个大小映射到同一桶,导致链表过长),要么需要复杂的哈希计算(如取模、哈希表扩容),让分配时的 "桶定位" 从 O (1) 退化到 O (n)(遍历查找)。
  • 空闲链表管理成本激增:完全随机的大小会导致大部分链表只有 0-1 个块(因为相同大小的申请概率极低)。释放内存时,需要先 "精确匹配" 大小才能挂到对应链表,而查找对应桶的过程本身就耗时;分配时,若目标桶为空,还需从更大的内存池(如 CentralCache)申请,而由于大小无规律,申请 / 拆分过程会异常复杂。

为了避免哈希桶体积过大和访问效率下滑的问题,在设计过程中通常依据以下的对齐规则:

cpp 复制代码
// [1,128]                8byte对⻬         freelist[0,16) 
// [128+1,1024]           16byte对⻬        freelist[16,72) 
// [1024+1,81024]         128byte对⻬       freelist[72,128) 
// [8*1024+1,641024]      1024byte对⻬      freelist[128,184) 
// [64*1024+1,256*1024]   8*1024byte对⻬    freelist[184,208)
      

这里我们拿一段来解释一下:

cpp 复制代码
// [1,128]                8byte对⻬         freelist[0,16) 

这说明当用户申请的字节数在1~128之间时我们统一按照8字节来对齐,怎么理解这里的8字节对齐呢?我们用一张图理解一下:

当用户申请的字节数小于8时统一给8字节;当用户申请的字节数大于8小于16时给16字节;当用户申请的字节数大于16小于24时给24字节,依次类推。这样的话对于可能出现的128种申请情况根据对齐规则之后我们只需要考虑128/8=16种可能出现的情况,这也意味着我们只需要16个自由链表(桶)即可。对应在哈希表中的数组下标的范围就是[0~15]。

当用户申请的内存大小在129~1024时按照16字节来对齐,**为什么不会再按照8字节来对齐呢?**因为如果整个内存池统一按照8字节来对齐的话,那么所需要的自由链表的数量是:(256*1024)/8还是过于庞大,8字节内存对齐虽然大大缓解了内碎片问题但是所带来的收益会随申请大小的增大而降低(因为过多的自由链表导致查找效率显著降低)。为了在内碎片问题与查找效率之间做平衡我们的对齐数也会随着申请大小增长而增长。

制定好对齐规则之后,我们发现对于256*1024种情况我们只需要208-1个自由链表也就是207个桶就可以解决所有问题。所以在前面ThreadCache.h中NFREE_LIST的值就是208。

在代码实现方面,当用户调用Allocate接口并传入要申请的内存大小时,我们首先根据对齐规则算出来内存池实际应该分配的内存大小。在Commond.h中创建SizeClass类并在其中声明方法Roundup。

cpp 复制代码
//管理对齐与映射的关系:
class SizeClass
{
public:
	//对齐大小的计算,也就是根据对其原则确定要分配多大内存块
	static inline size_t _RoundUp(size_t size, size_t align)
	{
		return (size + align - 1) & ~(align - 1);
	}
	static 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
		{
			assert(false);
            return -1;
		}
	}
}

除了使用位运算外,_Roundup也可这样写:

cpp 复制代码
static inline size_t roundup(size_t size, size_t  align)
{
	size_t alignsize;
	if (size % align != 0)
	{
		alignsize = (size / (align + 1)) * align;
	}
	else
	{
		alignsize = size;
	}
	return alignsize;
}

2.1.2 下标映射问题

当计算出了实际的内存对齐数后就需要找到管理相应大小内存块的桶,也就是自由链表(比如用户需要申请5字节经过对齐运算内存池实际会分配8字节,这时要找到元素都是8字节的自由链表)。同样的,在Commond.h中我们声明一个Index方法用来计算实际映射到的桶(自由链表)的下标:

cpp 复制代码
    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[] = { 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[0]+ group_array[1];
		}
		else if (bytes <= 64*1024)
		{
			return _Index(bytes-8*1024, 10) + group_array[0] + group_array[1]+ group_array[2];
		}
		else if (bytes <= 256*1024)
		{
			return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
		}
		else
		{
			assert(false);
			return -1;
		}
	}

**为什么相比于+-*/来讲我们在项目中更加青睐位运算呢?**主要有以下几点:

1. 运算速度更快:减少硬件指令周期

计算机 CPU 的算术逻辑单元(ALU)处理位运算时,仅需 1 个指令周期即可完成(如与、或、异或、移位);而加减乘除运算需要更多指令周期:

  • 加法 / 减法:需处理 "进位" 或 "借位" 逻辑(例如 32 位加法可能需要分阶段处理每一位的进位),通常需要 1-2 个指令周期。
  • 乘法 / 除法:效率差距更明显。乘法本质是多次加法的叠加,除法是多次减法的迭代,32 位整数乘法可能需要 10-20 个指令周期,除法甚至更多(不同 CPU 架构差异较大,但普遍远慢于位运算)。

示例:用位运算实现整数乘法(乘以 2 的幂)和除法(除以 2 的幂),效率远高于算术运算:

功能 算术运算 位运算(等价效果) 优势说明
乘以 2(x 为整数) x = x * 2 x = x << 1 左移 1 位直接实现 ×2,无进位逻辑
除以 2(x 为非负整数) x = x / 2 x = x >> 1 右移 1 位直接实现 ÷2,无迭代逻辑
乘以 8 x = x * 8 x = x << 3 移位位数 = 2 的幂次(8=2³)
  1. 资源消耗更低:适配嵌入式 / 高性能场景

在资源受限的环境(如嵌入式系统、单片机)中,位运算对硬件资源的需求更低:

  • 位运算仅需简单的逻辑门(与门、或门、移位寄存器)即可实现,电路设计简单,功耗更低。
  • 加减乘除(尤其是乘除)需要更复杂的硬件模块(如乘法器、除法器),部分低端嵌入式芯片甚至不内置硬件乘除单元,需通过软件模拟(效率极低),而位运算可轻松替代其部分功能(如倍乘、整除 2 的幂)。

2.1.3 整体逻辑

当讲完对齐映射问题与下标映射问题之后,对于ThreadCache来讲重要的部分就已经解决完了下面主要理解一下代码逻辑:

在Allocate接口里我们主要完成计算对齐数-->计算下标找到对应的自由链表-->自由链表有元素就Pop一个,无元素就找CentralCache申请。对应代码如下:

cpp 复制代码
void* ThreadCache::Allocate(size_t bytes)
{
	if (bytes <= MAX_BYTES)
	{
		//要求的字节大小根据对齐原则确定应该分配的内存块大小:
		size_t align_size = SizeClass::RoundUp(bytes);
		//在大数组中确定对应的哈希桶的下标:
		size_t index = SizeClass::Index(bytes);

		if (index < NFREE_LIST && !(FreeLists[index].Empty()))
		{
			return FreeLists[index].Pop();
		}
		else
		{
			//对应下标的哈希桶为空,找下一层申请:
			return FetchFromCentralCache(index, align_size);
		}
	}
	else
	{
		assert(false);
        return nullptr;
	}
	
}

对于涉及到CentralCache操作的FetchFromCentralCache接口我们在CentralCache的设计搭建中来详细讲解。

2.2 Deallocate接口的初步搭建

在定长内存池的设计实现中我们讲过,因为对象销毁或生命周期等原因释放回来的内存块并不是直接回到系统堆空间,而是重新链入到自由链表中重复利用提高效率。在ThreadCache的设计中也遵循了这一点,只不过多了一步找到对应自由链表下标的动作:

cpp 复制代码
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	if (size> MAX_BYTES)
	{
        assert(false);
	}
	else
	{
		size_t index = SizeClass::Index(size);
		FreeLists[index].Push(ptr);
	}
	
}

三、TLS技术实现无锁访问

自 2005 年前后 CPU 进入 "多核时代" 后,单核性能提升逐渐放缓,厂商转而通过增加核心数(如 4 核、8 核、16 核)提升整体算力。但单线程程序只能使用一个 CPU 核心,即使在 16 核 CPU 上,单线程也只能利用 1/16 的硬件资源,导致大量核心闲置。

在当前的软件生态中,多线程已成为程序设计的主流选择。这就意味着当我们的内存池面对的是一个多执行流的程序环境,如果不对内存池进行特殊处理被所有线程共享,那么就意味着为了保护内存资源我们必须解决线程同步的问题。常用的方法就是对"公共资源"进行加锁与解锁,频繁地加锁与解锁会极大降地资源的访问效率,这与我们设计高并发内存池的目标相违。

为了解决以上问题,我们在项目的ThreadCache层引入了线程TLS技术(线程本地存储)。

3.1 什么是线程TLS技术

线程 TLS(Thread-Local Storage,线程本地存储)是一种编程语言或操作系统提供的机制,允许为每个线程创建独立的变量副本,使得线程对变量的访问仅限于自身的副本,避免多线程环境下的共享资源竞争问题。

简单来说,TLS 相当于为每个线程开辟了一块 "私有内存空间",其中的变量虽然名字相同,但每个线程看到的都是自己独有的版本,彼此之间不会相互干扰。

当引入TLS技术后ThreadCache不会是多个执行流中"公共资源",而是每个线程中的"私有"成员线程之间的ThreadCache是不可见也无法访问的。这样的话,每个线程只会对自己的哈希表进行操作避免了锁竞争提升了分配效率。

3.2 实现TLS线程无锁访问

首先,我们在ThreadCache.h中添加以下声明:

cpp 复制代码
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
  • static:用于声明静态变量,这里使得该变量具有内部链接属性(在本编译单元内可见)。
  • _declspec(thread):这是 Microsoft 编译器特有的扩展,用于指定一个变量是线程局部的。也就是说,每个线程都有这个变量的一份独立副本,线程之间的副本互不干扰。
  • ThreadCache*:表示这是一个指向 ThreadCache 类型的指针。ThreadCache 应该是一个自定义的类或者结构体,通常在内存分配相关的场景(比如实现内存池等机制)中会用到,用于线程本地的缓存管理,以减少多线程下内存分配的竞争。
  • pTLSThreadCache:是变量名,用于存储指向 ThreadCache 的指针。
  • nullptr:将指针初始化为空指针,表示该指针最初不指向任何有效的 ThreadCache 对象。

然后创建一个ConcurrnetAlloc.h,在里面声明并定义以下方法:

cpp 复制代码
#pragma once
#include"Commond.h"
#include"ThreadCache.h"

static void* ConcurrentAlloc(size_t size)
{
	// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;

	return pTLSThreadCache->Allocate(size);
}

static void ConcurrentFree(void* ptr, size_t size)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(ptr, size);
}

此时当用户有申请内存的需求时会首先调用ConcurrentAlloc方法,在内部会检查pTLSThreadCache是否为空,如果为空首先会创建一个pTLSThreadCache指针指向一个线程局部存储的ThreadCache对象,每个线程之间的ThreadCache对象不可见也不可访问。这样就避免了多线程环境下的共享资源竞争问题。

之后我们可以直接这样使用:

cpp 复制代码
void MultiThreadAlloc1()
{
	
	void* ptr = ConcurrentAlloc(5);
}
void MultiThreadAlloc2()
{
	
	void* ptr = ConcurrentAlloc(16);
}
void MultiThreadAlloc()
{
	std::thread t1(MultiThreadAlloc1);
	std::thread t2(MultiThreadAlloc2);

	t1.join();
	t2.join();
}

四、本次设计源码

ThreadCache.h

cpp 复制代码
#pragma once
#include"Commond.h"

class ThreadCache
{
public:
	//申请内存:
	void* Allocate(size_t size);
	//释放内存:
	void Deallocate(void* ptr, size_t size);
	//向CentralCache要内存块:
	void* FetchFromCentralCache(size_t index,size_t size);
private:
	FreeList FreeLists[NFREE_LIST];
};

ThreadCache.cpp

cpp 复制代码
#include"ThreadCache.h"


void* ThreadCache::Allocate(size_t size)
{
	assert(size < MAXBYTES);
	//根据对齐原则确定对齐数:
	size_t alignsize = SizeClass::_RoundUp(size);
	size_t index = SizeClass::_Index(size);
	assert(index < NFREE_LIST);
	//确定对应下标的桶里面有没有空闲内存块:
	if (!FreeLists[index].Empty())
	{
		//Pop一个
		return FreeLists[index].Pop();
	}
	else
	{
		//向下层CentralCache要内存块
		return FetchFromCentralCache(index,size);
	}

}
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(size <= MAXBYTES);
	size_t index = SizeClass::_Index(size);
	FreeLists[index].Push(ptr);
}

Commond.h

cpp 复制代码
#include<iostream>
#include<assert.h>
using namespace std;

static const size_t MAXBYTES = 256 * 1024;
static const size_t NFREE_LIST = 208;
static inline void*& NextObj(void* obj)
{
	return *(void**)obj;
}
class FreeList
{
public:
	void Push(void* obj)
	{
		//头插:
		//*(void**)obj = _freelist;
		NextObj(obj) = _freelist;
		_freelist = obj;
	}
	void* Pop()
	{
		void* obj = _freelist;
		_freelist = NextObj(_freelist);
		return obj;
	}
	bool Empty()
	{
		return _freelist == nullptr;
	}
private:
	void* _freelist = nullptr;
};

//创建一个工具类:
class SizeClass
{
public:
	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 <= MAXBYTES);
		static int group_array[] = { 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[0] + group_array[1];
		}
		else if (bytes <= 64 * 1024)
		{
			return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
		}
		else if (bytes <= 256 * 1024)
		{
			return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
		}
		else
		{
			assert(false);
			return -1;
		}
	}
	/*static inline size_t roundup(size_t size, size_t  align)
	{
		size_t alignsize;
		if (size % align != 0)
		{
			alignsize = (size / (align + 1)) * align;
		}
		else
		{
			alignsize = size;
		}
		return alignsize;
	}*/
	static inline size_t roundup(size_t size,size_t  align)
	{
		return (size + align - 1) & ~(align - 1);
	}
	static inline size_t _RoundUp(size_t size)
	{
		assert(size <= MAXBYTES);
		//1->128
		if (size > 0 && 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);
		}
	}
private:

};

ConcurrnetAlloc.h

cpp 复制代码
#pragma once
#include"Commond.h"
#include"ThreadCache.h"

static void* ConcurrentAlloc(size_t size)
{
	// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;

	return pTLSThreadCache->Allocate(size);
}

static void ConcurrentFree(void* ptr, size_t size)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(ptr, size);
}
相关推荐
杨筱毅2 小时前
【算法】430.扁平化多级双向链表--通俗讲解
算法·链表·深度优先
yongui478343 小时前
INTLAB区间工具箱在区间分析算法中的应用与实现
数据结构·算法
情深不寿3173 小时前
传输层————TCP
linux·网络·c++·tcp/ip
wewe_daisy3 小时前
python、数据结构
开发语言·数据结构·python
我是华为OD~HR~栗栗呀3 小时前
20届-高级开发(华为oD)-Java面经
java·c++·后端·python·华为od·华为
那个指针是空的?3 小时前
无痛c到c++
c语言·开发语言·c++
bkspiderx3 小时前
C++设计模式之创建型模式:原型模式(Prototype)
c++·设计模式·原型模式
今天也好累5 小时前
贪心算法之会议安排问题
c++·笔记·学习·算法·贪心算法
phdsky9 小时前
【设计模式】中介者模式
c++·设计模式·中介者模式