前言:本文将要介绍的高并发内存池,它的原型是Google的⼀个开源项⽬tcmalloc,全称Thread-Caching Malloc,近一个月我将以学习为目的来模拟实现一个精简版的高并发内存池,并对核心技术分块进行精细剖析,分享在专栏《高并发内存池》里,期待小伙伴们的热情支持与关注!
项目专栏:高并发内存池_敲上瘾的博客-CSDN博客
一、项目介绍
该篇文章将要介绍的⾼并发的内存池,它的原型是google的⼀个开源项⽬tcmalloc,全称Thread-Caching Malloc,即线程缓存的malloc,实现了⾼效的多线程内存管理,⽤于替代系统的内存分配相关的函数(malloc、free)。
在多线程环境下进行内存申请本质上属于对公共资源的访问,高并发场景下对内存分配锁的竞争会异常激烈,这将严重拖慢程序运行效率。此外,频繁的系统级内存申请不仅会产生额外开销,还可能引发内存碎片问题。传统的malloc内存分配机制由于这些固有缺陷,已难以满足现代高性能开发中对内存管理效率的需求。
而tcmalloc在高并发场景下是如何高效、安全的分配和释放内存呢?它的设计核心如下:
- 减少锁竞争:传统内存分配器使用全局锁保护内存管理结构,高并发下锁竞争严重。高并发内存池通过以下方式降低锁冲突:
- 线程本地缓存(Thread Local Storage, TLS):每个线程维护独立的内存缓存,大部分分配/释放操作在本地完成,无需加锁。
- 分层设计:将内存管理分为线程本地缓存、中央共享缓存和全局堆,逐层解耦。
- 内存预分配:预先分配大块内存(如分页或固定大小的块),减少频繁的系统调用(如
brk
或mmap
)。 - 内存分类管理:
- 按大小划分:小对象(如<64KB)、中对象和大对象采用不同分配策略。
- 对齐与碎片优化:固定大小的内存块减少内存碎片。
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实现
Thread Cache实现有些类似定长内存池,大家有兴趣可以阅读一下:
定长内存池原理及实现-CSDN博客
但是定长内存池有一个缺点,即它每次申请的空间只能是固定的,它的核心原理是:
先向系统申请一大块空间,然后当我们再需要申请内存时就从这块空间上获取,我们把空间用完后不用急着释放回系统,而是把它用一个自由链表连接起来,进行二次利用。也就是说我们再次申请空间时直接到自由链表上找,如果没有再去大空间上找,如果还不够再去向系统申请。
但我们希望从开辟的内存池申请的空间不是固定的,它可以是4字节、10字节、50字节,500字节等等。申请多少字节不是关键的问题,因为字节大小本来就是可以随意申请的,问题在于这些空间被弃用后,我们如何把它们管理起来并进行二次利用。
定长内存池使用一个自由链表来维护弃用的空间,那么我们用多个自由链表来维护不同的字节大小空间,需要多少字节就到指定的自由链表去找不就行了吗?答案是:是的!
为了高效的找到相应的自由链表,我们可以做一个哈希表。如下:
需要注意几个问题:
- 不能直接一个任意字节就做一个对应自由链表,这样数组的开销太大了,需要引入对齐机制,比如我们需要5字节,6字节,8字节等都统一申请8字节,如果需要9字节,15字节等都统一申请16字节。当然这是会造成一定的内存浪费的,像这样因为对齐带来的内存浪费把它称为内碎片。
- 因为要做成自由链表所以为了保证在任何环境下都能存下一个指针的大小,最少的对齐数必须是8 byte。
- 因为对齐会产生内碎片,所以对齐数的选取是很重要的,如果我们就以8的倍数来选定对齐数,它需要开辟的数组大小是256*1024/8=32768,这未免有些太大,我们可以根据字节大小采用不同分配策略。
为突出该哈希表的性质,下文就称它为自由链表桶。
1.框架设计
首先用一个common.h文件来写一些需要用到的公共的类,比如自由链表的封装,对齐数的计算 和 哈希表下标映射的计算也封装成类放在里面。如下:
FreeList:
- Push:把用完的内存插入链表中。
- Pop:从链表中取出内存。
- Empty:判断该自由链表中是否有节点,以便判断是否需要到central cache中取内存。
- _list_head:储存自由链表的头。
SizeClass:
- RoundUp:计算对齐数和对齐后实际需要申请的空间大小。
- Index:计数自由链表桶中对应的下标。
这里把它写成静态成员函数是为方便在没有类对象情况下对函数的直接调用,因为现在并没有成员变量所以没必要实例化出对象。
可以把它做为内联函数,减少函数栈帧的开辟。
创建ConcurrentAlloc.h,在这个文件里提供一些直接能让用户使用的类和接口。我们封装一个ConcurrentAlloc类,如下:
- ConAlloc:用来给让用户申请内存。
- ConFree:用来让用户释放内存。
接下来就是创建ThreadCache.h文件,来声明一些在thread cache这一层需要用到的类方法等。如下:
- Allocate:在自由链表桶里面申请空间。
- Deallocate:把不用的空间放到自由链表桶。
- FetchFromCentralCache:当自由链表里没有空间时到Central Cache这一层获取,该函数在该篇文章不进行实现,请期待下一篇文章的讲解。
- _freelists[ ]:自由链表桶。
2.自由链表封装
Push实现
Push功能是把不用的空间放到自由链表中,因为头插效率比较高时间复杂度为O(1),我们直接把它头插到自由链表中。接下来就是头插操作:
把这块需要插入到链表的空间记为obj,首先把_list_head值储存到obj空间的前4/8字节,因为并不确定用户用的是32位系统还是64位系统,所以可以用这样一个操作来解决:
*(void **)obj = _list_head;
然后让obj成为新的头,即_list_head = obj,如下:
void Push(void *obj)
{*(void **)obj = _list_head;_list_head = obj;
}
Pop实现
Pop是从自由链表中获取到内存,我们直接取头节点即可,如下:
void *Pop()
{void *obj = _list_head;_list_head = *(void **)obj;return obj;
}
- void *obj = _list_head:取到头指针,并保存到obj中。
- _list_head = *(void **)obj:把_list_head更新为头节点指向的下一个节点,也可写为 _list_head = *(void**)_list_head;
Empty实现
直接返回 _list_head == nullptr即可
3.对齐数计算和下映射计算
在这里做这样一个规则把内存浪费率控制在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) |
浪费率:
(浪费的字节数 / 对齐后的字节数)*100%
[1,128] 区间对齐数为8byte,所以最大的浪费数为7byte,而区间内的前8个字节对齐后申请的内存是8,所以浪费率 = (7/8)*100%87.50%
同理各个区间的浪费率为:
[1,128] | (7/8)*100% |
[128+1,1024] | (15/16*9)*100% |
[1024+1,8*1024] | (127/128*9)*100% |
[8*1024+1,64*1024] | (1023/1024*9)*100% |
[64*1024+1,256*1024] | ( (8*1024-1) / (8*1024*9) )*100% |
虽然[1,128]区间浪费率有点高但实际浪费的并不多,无伤大雅。
对齐后字节大小计算
接下来就是计算对齐数,也就是让用户任意扔一个数字过来,需要通过对齐规则计算它实际开辟的空间。在RoundUp中实现。
我们可以用 if else 把数据分开处理,如下:
static inline int RoundUp(size_t bytes)
{if (bytes <= 128)return _RoundUp(bytes, 8);else if (bytes <= 1024)return _RoundUp(bytes, 16);else if (bytes <= 8 * 1024)return _RoundUp(bytes, 128);else if (bytes <= 64 * 1024)return _RoundUp(bytes, 1024);else if (bytes <= 256 * 1024)return _RoundUp(bytes, 8 * 1024);else{assert(false);return 1;}
}
_RoundUp函数实现也很简单,如果这个数本身就对齐直接返回即可,如果不是再做简单的处理,如下:
int _RoundUp(size_t size, size_t alignNum)
{if (size % alignNum == 0)return size;elsereturn (size / alignNum + 1) * alignNum;
}
但是代码中又是取模,又是除,效率还略逊一筹,可以考虑把它改成位运算,即:
static inline int _RoundUp(size_t bytes, size_t alignNum)
{return (bytes + alignNum - 1) & ~(alignNum - 1);
}
- bytes + (alignNum - 1):把这个过程也当成为运算,作用是把bytes中不足下一个alignNum倍数的部分补上。
- &~(alignNum - 1):再把补过头的部分消去。
这里大家可以带进去数值来理一理。
下标映射计算
接下来是实现Index,假设我们已经知道用户要申请的空间大小(size)和对齐数是2的多少次方(align_shift),那么可以做这样的计算:
static inline int _Index(size_t bytes, size_t align_shift)
{return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
1左移align_shift位得到对齐数,再减1,加上bytes,作用是把bytes中不足下一个alignNum倍数的部分补上。然后右移align_shift相当于除以对齐数,最后再减去1因为数组下标是从0开始的。
以上计算出来的只是这个对齐后的字节数 对于相应 对齐数 在数组上起始位置的相对位置,还需要加上前面对齐占用的数组元素个数才能得到正确的下标。即:
static inline int _Index(size_t bytes, size_t align_shift)
{return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
static inline int Index(size_t bytes)
{// 每个区间有多少个链static int group_array[4] = { 16, 56, 56, 56 };if (bytes <= 128)return _Index(bytes, 3);else if (bytes <= 1024)return _Index(bytes, 4)+group_array[0];else if (bytes <= 8 * 1024)return _Index(bytes, 7)+group_array[0]+group_array[1];else if (bytes <= 64 * 1024)return _Index(bytes, 10)+group_array[0]+group_array[1]+group_array[2];else if (bytes <= 256 * 1024)return _Index(bytes, 13)+group_array[0]+group_array[1]+group_array[2]+group_array[3];else{assert(false);return 1;}
}
回顾上文框架,以上我们相当于完成了common.h文件,接下来处理ThreadCache和ConcurrentAlloc的封装就简单多了。
刚才我们设计了对齐数,接下来就可以确定自由链表桶的大小了,即208(从对齐规则表里得到)。我们可以把208做成一个全局的 static const size_t 类型 或者 宏定义。
单独创建一个ThreadCache.cpp文件用来做接口的实现:
#include "ThreadCache.h"
void* ThreadCache::Allocate(size_t bytes)
{//得到对齐后的字节数int objSize = SizeClass::RoundUp(bytes);//得到自由链表桶的下标映射int intex = SizeClass::Index(bytes);//先去链表桶里面申请内存,如果没有到CentralCache中获取if(!_freelists[intex].Empty())return _freelists[intex].Pop();elsereturn FetchFromCentralCache(objSize,intex);
}
void ThreadCache::Deallocate(void* obj,size_t bytes)
{int intex = SizeClass::Index(bytes);_freelists[intex].Push(obj);
}
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{//... ...return nullptr;
}
我们希望每个线程都有自己独立的Thread Cache对象,但又不想让用户在线程内自己申请。那么可以直接把它定义为全局变量 并在前面加上static thread_local关键字,让各自线程在自己的线程局部储存内都申请一份Thread Cache对象,而不是共享。
所以在ThreadCache.h文件中定义:
static thread_local ThreadCache* pTLSThreadCache = nullptr;
ConcurrentAlloc封装如下:
#pragma once
#include "Common.h"
#include "ThreadCache.h"
class ConcurrentAlloc
{
public:static void* ConAlloc(size_t size){if(pTLSThreadCache == nullptr)pTLSThreadCache = new ThreadCache;return pTLSThreadCache->Allocate(size);}static void ConFree(void* ptr,size_t size){assert(pTLSThreadCache);pTLSThreadCache->Deallocate(ptr,size);}
};
三、源码
1.Common.h
#pragma once
#include <iostream>
#include <assert.h>
#include <thread>
using namespace std;
class FreeList
{
public:void Push(void *obj){*(void **)obj = _list_head;_list_head = obj;}void *Pop(){void *obj = _list_head;_list_head = *(void **)obj;return obj;}bool Empty(){return _list_head == nullptr;}private:void *_list_head = nullptr;
};
class SizeClass
{// 整体控制在最多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)
public:static inline int _RoundUp(size_t bytes, size_t alignNum){return (bytes + alignNum - 1) & ~(alignNum - 1);}static inline int RoundUp(size_t bytes){if (bytes <= 128)return _RoundUp(bytes, 8);else if (bytes <= 1024)return _RoundUp(bytes, 16);else if (bytes <= 8 * 1024)return _RoundUp(bytes, 128);else if (bytes <= 64 * 1024)return _RoundUp(bytes, 1024);else if (bytes <= 256 * 1024)return _RoundUp(bytes, 8 * 1024);else{assert(false);return 1;}}static inline int _Index(size_t bytes, size_t align_shift){return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;}static inline int Index(size_t bytes){static int group_array[4] = {16, 56, 56, 56};if (bytes <= 128)return _Index(bytes, 3);else if (bytes <= 1024)return _Index(bytes, 4) + group_array[0];else if (bytes <= 8 * 1024)return _Index(bytes, 7) + group_array[0] + group_array[1];else if (bytes <= 64 * 1024)return _Index(bytes, 10) + group_array[0] + group_array[1] + group_array[2];else if (bytes <= 256 * 1024)return _Index(bytes, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];else{assert(false);return 1;}}private:
};
2.ConcurrentAlloc.h
#pragma once
#include "Common.h"
#include "ThreadCache.h"
class ConcurrentAlloc
{
public:static void* ConAlloc(size_t size){pTLSThreadCache = new ThreadCache;return pTLSThreadCache->Allocate(size);}static void ConFree(void* ptr,size_t size){assert(pTLSThreadCache);pTLSThreadCache->Deallocate(ptr,size);}
};
3.ThreadCache.h
#pragma once
#include "Common.h"
static int const FREE_LIST_SIZE = 208;
class ThreadCache
{
public://从Thread Cache中申请void* Allocate(size_t bytes);void Deallocate(void* obj,size_t bytes);//CentralCache中申请void* FetchFromCentralCache(size_t index, size_t size);
private:FreeList _freelists[FREE_LIST_SIZE];
};
static thread_local ThreadCache* pTLSThreadCache = nullptr;
3.ThreadCache.cpp
#include "ThreadCache.h"
void* ThreadCache::Allocate(size_t bytes)
{int objSize = SizeClass::RoundUp(bytes);int intex = SizeClass::Index(bytes);if(!_freelists[intex].Empty())return _freelists[intex].Pop();elsereturn FetchFromCentralCache(objSize,intex);
}
void ThreadCache::Deallocate(void* obj,size_t bytes)
{int intex = SizeClass::Index(bytes);_freelists[intex].Push(obj);
}
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{//... ...return nullptr;
}
5.UnitTest.cpp
#include "ConcurrentAlloc.h"
void Alloc1()
{for(int i=0;i<5;i++)ConcurrentAlloc::ConAlloc(7);
}
void Alloc2()
{for(int i=0;i<5;i++)ConcurrentAlloc::ConAlloc(8);
}
int main()
{thread t1(Alloc1);thread t2(Alloc2);t1.join();t2.join();return 0;
}