高并发内存池如何实现

目录

定长内存池

核心原理

关键特点

实现核心步骤

代码实现

类的成员变量说明

Delete函数

New函数

向系统申请内存

brk()和sbrk()系统调用函数

mmap系统调用函数

测试代码

总结:

补充知识点:

高并发内存池整体框架设计

[Thread Cache](#Thread Cache)

[Central Cache](#Central Cache)

[page Cache](#page Cache)

问题:


项目源代码GitHub网址

Fcw0551/tcmalloc: 参考 Google TCMalloc 设计思想,实现一个高效的高并发内存池,解决传统 glibc malloc 在多线程环境下的锁竞争与内存碎片问题。https://github.com/Fcw0551/tcmalloc

定长内存池

由于malloc的内存分配器是一种通用的场景,就注定了效率低,所以针对不同的场景最好要进行定制,这里我们先开发一个定长内存池

定长内存池是一种专注于分配固定大小内存块的内存管理机制,核心优势是高效、低碎片。

核心原理

  • 提前预分配一块连续的大内存区域,按固定大小划分成多个相同的内存块。
  • 用空闲链表管理可用内存块,分配时直接从链表头部取出一块,释放时将块归还给链表。
  • 避免了通用内存分配器的大小计算、复杂适配逻辑,分配和释放均为 O (1) 时间复杂度。

关键特点

  • 内存碎片极低:所有块大小一致,不会产生无法利用的小块碎片。
  • 效率极高:无需复杂算法,仅需操作链表指针,适合高频分配释放场景。
  • 适用场景明确:仅适用于固定大小对象的内存申请,如网络数据包、队列元素等。
  • 空间开销小:管理结构简单(仅需空闲链表指针),额外开销远低于通用内存池。

实现核心步骤

  1. 初始化:申请一块连续内存,分割为 N 个固定大小的块,用链表串联所有块。
  2. 分配:从空闲链表头部移除一块,返回给用户。
  3. 释放:将用户归还的块重新插入空闲链表,无需额外整理。
  4. 销毁:释放初始化时申请的整块连续内存,避免内存泄漏。

代码实现

类的成员变量说明

_memory:这个是内存分配器向os申请的内存空间

_leftBytes:这个是内存块剩余的字节数

_freeList:自由链表,管理相同大小块的内存空间,用来串联所有块,让其高效管理

Delete函数

相当于模仿delete的功能,先调用完析构函数之后再进行清理,用户无需感知内存空间的释放,只需要处理自己析构函数的逻辑

cpp 复制代码
   void Delete(T* obj){
        //显示调用T的析构函数进行清理
        obj->~T();
        //把内存回收,头插到自由链表
        *((void**)obj)=_freeList;
        _freeList=obj;
    }

解读:*((void**)obj)=_freeList;(注意32位系统指针占4字节,64位占8字节)

首先要理解一个指针的类型是决定其解引用之后能够寻多大的字节,比如int*p,p++是跳过4字节如果是char*p p++就是跳过1个字节,所以指针的类型决定了其寻址能力,所以我们这里把obj的类型强制转换成void**,就是一个二级指针,那寻址能力就是4/8字节(存放下一个内存块的地址),不用一级指针是因为解引用void*是错误的语法,而且解引用之后你需要存储的是void*的类型,所以需要强制转换成二级指针类型再进行解引用

另一个视角,因为你要把obj的内存块的内容改变,内容的类型是void*,所以我们需要void**然后再解引用

New函数

相当于模仿c++中的new函数,new的功能是会调用一个类的默认构造函数的,所以我们实现New的时候也要考虑

cpp 复制代码
 T *New()
    {
        T *ptr = nullptr;
        // 1.先判断自由链表是否有空间
        if (_freeList)
        {
            ptr = (T *)_freeList;
            // 更新自由链表
            _freeList = *((void **)_freeList);
        }
        else
        {
            // 链表没有可分配的内存空间了
            // 需要判断内存块中剩余的是否足够
            if (_leftBytes < sizeof(T))
            {
                // 不够需要额外申请
                int kpage=128;
                _memory = (char *)SystemAlloc(kpage); // 系统申请空间
                if (_memory == nullptr)
                {
                    throw std::bad_alloc();
                }
                _leftBytes=kpage*(1<<12);
            }
            ptr = (T *)_memory;
            size_t ptrSize = sizeof(T) < sizeof(void *) ? sizeof(void *) : sizeof(T); // 这里是为了实现自由链表
            _memory += ptrSize;                                                       // 保证下一次获取
            _leftBytes -= ptrSize;
        }

        // 使用定位new调用T的构造函数初始化
        new (ptr) T;
        return ptr;
    }

解读:

这里的ptrSize是为了保证每个内存块的最小必须是4/8字节,因为要再自由链表当中存储下一个结点的指针

这里并没有先进行释放,然后再申请内存空间,而是直接丢弃,因为丢弃的这部分的空间不足以分配一个内存块,所以很小,可以直接丢弃,属于用空间换时间,如果还要显示调用释放会有时间开销,不如直接丢弃(但这里没有野指针的问题)(遗留问题:内存池的析构函数)

向系统申请内存

了解不同系统下的系统函数

VirtualAlloc_百度百科

Linux进程分配内存的两种方式--brk() 和mmap() - VinoZhu - 博客园https://www.cnblogs.com/vinozly/p/5489138.html

brk()和sbrk()系统调用函数

brk的参数:如果传入的地址变大,那就是申请内存,如果传入的地址变小,就是释放内存

成功返回0,失败返回-1,并且设置错误码

sbrk的参数,如果传入0,那就是获取当前的brk的地址,如果传入字节数,就是以当前地址偏移多少(正表示申请,负表示释放)

所以sbrk是brk的封装,sbrk(100)=brk(sbrk(0)+100)

mmap系统调用函数

addr通常传入NULL,让os自动寻址合适的地址

length:申请的字节数

prot:内存保护权限(自行查一下)

flags:映射类型与属性,设为**MAP_ANONYMOUS即可,这里主要和Linux系统中的内存管理有关,设为MAP_ANONYMOUS是代表匿名映射,就是不和底层文件有关联**

fd:如果flags为MAP_ANONYMOUS,fd为-1(因为不和底层文件有关联),offest无效

成功返回void*,失败返回-1,设置错误码

cpp 复制代码
inline static void *SystemAlloc(size_t kpage)
{
#ifdef _WIN32
    void *ptr = VirtualAlloc(0, kpage * (1 << 12), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
    // linux
    void *ptr = mmap(0, kpage * (1 << 12), PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
#endif
    if (ptr == nullptr)
    {
        throw std::bad_alloc();
    }
    return ptr;
}
测试代码
cpp 复制代码
struct TreeNode
{
    int _val;
    TreeNode *_left;
    TreeNode *_right;
    TreeNode()
        : _val(0), _left(nullptr), _right(nullptr)
    {
    }
};
void TestMemoryPool()
{

    // 使用ptmalloc
    //  申请释放的轮次
    const size_t Rounds = 3;
    // 每轮申请释放多少次
    const size_t N = 100000;
    size_t begin1 = clock();
    std::vector<TreeNode *> v1;
    v1.reserve(N);
    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();

    // 使用定长内存池
    MemoryPool<TreeNode> MP;
    size_t begin2 = clock();
    std::vector<TreeNode *> v2;
    v2.reserve(N);
    for (size_t j = 0; j < Rounds; ++j)
    {
        for (int i = 0; i < N; ++i)
        {
            v2.push_back(MP.New());
        }
        for (int i = 0; i < N; ++i)
        {
            MP.Delete(v2[i]);
        }
        v2.clear();
    }
    size_t end2 = clock();
    cout << "ptmalloc time:" << end1 - begin1 << endl;
    cout << "MemoryPool time:" << end2 - begin2 << endl;
}

经过测试我们发现快了百分之20左右

说明在频繁的申请和释放小对象的场景中,定长内存池的方案优于ptmalloc

这种性能优势在 "频繁申请 / 释放同尺寸小对象" 的场景中尤为突出(如链表节点、树节点、小型结构体等)。如果你的业务中存在大量这类内存操作,使用定长内存池可以显著提升程序的执行效率。

总结:

这个定长内存池是针对相同的对象,比如这里都是树的结点,那如果对象是int呢,double呢

1:创建不同的示例MemoryPool<TreeNode> mp,MemoryPool<int> mp来管理不同的对象

2:分箱操作,在分配器的实现当中实现不同字节的内存管理,比如4字节的内存块放在一起,8字节的放在一起,针对new,我们要算多大,然后去不同的箱子里面(ptmalloc就是使用了分箱方案)

补充知识点:

inline:内联函数,这个在我的c++专栏中有讲

static:修饰全局函数的时候把外部链接改成内部链接,限制这个函数的作用域,提高了封装性,避免了命名冲突,配合inline避免了连接问题

条件编译:这是为了适应不同的系统,ifdef和endif要配对出现,ifdef和else相当于if-else

定位new:针对一块已分配的内存空间中调用构造函数初始化一个对象,代码中使用来调用默认构造函数

抛异常处理:这个在c++专栏中有讲

高并发内存池整体框架设计

现代很多的开发环境都是 多核多线程 ,在申请内存的场景下,必然存在激烈的 锁竞争问题 。 malloc 本身其实已经很优秀,那么我们项目的原型tcmalloc 就是在 多线程高并发 的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题

  1. 性能问题。
  2. 多线程环境下,锁竞争问题。
  3. 内存碎片问题。

    concurrent memory pool 主要由以下 3 个部分构成:
  4. thread cache :线程缓存是每个线程独有的,用于小于 256KB 的内存的分配, 线程从这里申请内 存不需要加锁,每个线程独享一个 cache ,这也就是这个并发线程池高效的地方
  5. central cache :中心缓存是 所有线程所共享 , thread cache 是 按需从 central cache 中获取 的对
    象。 central cache 合适的时机回收 thread cache 中的对象,避免一个线程占用了太多的内存,而
    其他线程的内存吃紧, 达到内存分配在多个线程中更均衡的按需调度的目的 。 central cache 是存
    在竞争的,所以从这里取内存对象是 需要加锁 , 首先这里用的是桶锁,其次只有 thread cache
    没有内存对象时才会找 central cache ,所以这里竞争不会很激烈
  6. page cache :页缓存是在 central cache 缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache 没有内存对象时,从 page cache 分配出一定数量的 page ,并切割成定长大小的小块内存,分配给central cache 。 当一个 span 的几个跨度页的对象都回收以后, page cache
    会回收 central cache 满足条件的 span 对象,并且合并相邻的页,组成更大的页,缓解内存碎片

Thread Cache

这里就是之前在学习定长内存池的时候针对不同对象的分箱操作,你总不能来一个对象就创建一个内存分配器吧,我们只需要在内存分配器中使用分箱操作,把适合大小的内存块分给对象就行

thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的(每个线程都独有一个Thread Cache)。

申请内存:

  1. 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。

  2. 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。

  3. 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。

释放内存:

  1. 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。

  2. 当链表的长度过长,则回收一部分内存对象到central cache。


知识点(原理)补充:

对于多线程,可以移步我的Linux系统编程章节进行了解,这里不做过多的介绍,多线程来说共享虚拟地址空间,但是栈8MB是每个线程独有的,因为要存局部变量和函数调用(这里的栈是通过mmap匿名内存|私有映射创建出来的,然后每个栈的生长方向的最后会设置一个区,如果访问超过就会报段错误)

除此之外还有一个TLS段(Thread Local Storage),专门用于动态 TLS 的独立区域(部分系统会将其归为数据段的 "子段",但逻辑上独立)。注意这个TLS段是每个线程独有的,不与其他线程共享

一个进程被创建的时候,os会为其分配虚拟地址空间,栈空间和TLS空间也确定了,但是一旦当你创建新线程,就会分配新的栈和TLS给你,TLS就在栈区域附近,这个是线程的独有区域

ThreadCache如何做到每个线程独享一份堆空间?堆不是所有线程共享吗?是因为你分配了空间,还需要一个指针指向堆空间,这个指针就存储在TLS空间,这个空间每个线程独有,所以指向的堆空间就变成独有的了,这就避免了锁竞争,提高了性能

本质就是你去alloc的时候,申请的是一个threadCache对象,然后把这个对象的指针存在TLS,所以即使每个线程创建的threadCache都在共享堆区上,但是访问只能通过自身的TLS访问到自己的threadCache对象,所以导致threadCache在逻辑上变成线程私有的

ThreadCache 借助 TLS 指针,将共享堆的一部分 "绑定" 为线程私有,实现无锁高效分配。

Thread Local Storage(线程局部存储)TLS - 知乎


关于哈希桶的设计,这里我们并不是每个字节设一个哈希桶,而是一段范围一个哈希桶

假设内存分配从1byte~256KB,如果是一个字节一个哈希桶

第一管理自由链表的成本剧增,指针至少4或者8字节,那仅仅是存一个头指针都需要1MB

第二映射关系复杂,内存池的核心优势是 "快速定位对应链表"(通过大小映射)。若有 26 万个链表,映射逻辑会变得复杂(需要维护字节到链表的映射表),且缓存这些链表的头指针会占用大量 CPU 缓存,导致缓存命中率下降,分配速度变慢。

所以采取分级对齐+少量链表,这必然会造成内存碎片的,这是在性能和空间之间的平衡

对于申请的小内存,我们需要小字节的对齐,比如你申请5字节我就给你8字节,申请14字节我就给你16字节,所以这里就需要两种类型的桶,一个是8字节一个是16字节

为了避免浪费,这里有个公式浪费率

cpp 复制代码
设计规则:
  相邻两个 size class 之间的比值不超过 1/(1-0.125) ≈ 1.143

也就是说:
  下一个 size class ≤ 上一个 × 1.143

为什么这个比值能保证 12.5%?

假设两个相邻 size class:A 和 B(B > A)
用户请求大小 x 满足:A < x ≤ B
最坏情况:x = A+1,但分配了 B
浪费 = B - (A+1) ≈ B - A

浪费率 = (B - A) / B = 1 - A/B

要保证浪费率 ≤ 12.5%:
  1 - A/B ≤ 0.125
  A/B ≥ 0.875
  B/A ≤ 1/0.875 ≈ 1.143
cpp 复制代码
实际 size class 举例验证:

8B  → 16B:  比值 2.0   → 浪费率最高 50%  (小对象特殊处理)
16B → 32B:  比值 2.0   → 浪费率最高 50%  (同上)
32B → 48B:  比值 1.5   → 浪费率最高 33%
48B → 64B:  比值 1.33  → 浪费率最高 25%
64B → 80B:  比值 1.25  → 浪费率最高 20%
...
越到大对象,比值越接近 1.143,浪费率越接近 12.5%

小对象(<64B)浪费率高,但绝对浪费字节数少
大对象浪费率低,绝对浪费字节数可接受
→ 整体平均浪费率控制在合理范围

为了降低内存浪费率,我们需要合理设置一个对齐数,比如小空间,1-128都用8字节的对齐数

也就是申请4字节就去8字节的桶,申请25字节就需要去32字节的桶

这里的浪费率可能有点高,最多有7/8的空间浪费率,但是这是不可避免的,是空间的平衡,假设你用4字节,那桶的个数又会增加,所以用8比较好

所以对齐数的设计要内碎片率(浪费率)尽可能低自由链表数量少(管理成本低)对齐粒度随内存大小动态增长(小内存精细对齐,大内存粗粒度对齐)。

// 整体控制在最多 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)

使用这个对齐数,假设申请的空间是129,碎片率就是(144-129)/144=%10.42

其他的也可以这样计算,基本都在百分之10左右


至此原理已经讲解完毕,编写threadcache的代码部分需要包含以下

1.自由链表管理封装

2.申请释放内存函数--对应自由链表的push、pop等函数

3.映射关系封装:分箱操作


代码编写

cpp 复制代码
//获取内存对象中头4或8字节
inline static void*& nextObj(void*obj){
    return *((void**)obj);
}
//自由链表
class FreeList{
    public:
    void push(void* obj){
        //头插链表,表示释放空间
        assert(obj);
        nextObj(obj)=_freeList;
        _freeList=obj;
        _size++;
    }
    void pushRange(void*start,void*end,size_t n){
        //头插一批,表示释放一批空间
        assert(start);
        assert(end);
        nextObj(end)=_freeList;
        _freeList=nextObj(start);
        _size+=n;
    }
    void* pop(){
        //头删链表,表示申请空间
        assert(_freeList);
        void* obj=_freeList;
        _freeList=nextObj(obj);
        _size--;
    }
    void popRange(void*&start,void*&end,size_t n){
        //头删一批,表示申请一批空间
        assert(n);
        if(n>_size){
            //链表空间不足,向Central cache申请内存
        }
        start=_freeList;
        end=start;
        for(int i=1;i<n;i++){
            end=nextObj(end);
        }
        _freeList=nextObj(end);
        nextObj(end)=nullptr;
        _size-=n;   
    }
    bool empty(){
        return _freeList;
    }
    size_t size(){
        return _size;
    }
    size_t maxSize(){
        return _maxSize;
    }
    private:
    void* _freeList=nullptr;
    size_t _maxSize=1;//用于限制自由链表中的最大个数
    size_t _size=0;
};

注意:

对于获取内存块的头4/8字节的空间,需要返回引用,否则返回void*的话就是值拷贝,相当于临时变量tmp,外部获取到之后是会另外开辟一个空间,你改变的不是原来的空间

比如int test(),这个函数如果没有加引用,你返回一个整型出去就会导致外部用int a=test(),接受的这个a就是别的空间

加了引用由于出了这个作用域变量生命周期还在所以外面改变就是本体空间改变

具体不了解的可以移步我的c++专栏中关于引用的讲解

c++之引用-CSDN博客

cpp 复制代码
class ThreadCache{
    public:
    //申请内存-字节数
    void* alloc(size_t size){
    }
    //释放内存
    void dealloc(void* obj,size_t size){

    }
    //从CenterCache申请内存
    void* allocFromCentralCache(size_t index,size_t size){

    }
    //释放内存到CeneterCache
    void delloctoCentralCache(FreeList& list,size_t size){

    }
    private:
    FreeList _freeLists[NFREELISTS];//哈系桶
};

这个是对外封装的功能,也就是用户malloc,free等功能

cpp 复制代码
//自由链表的哈希桶跟对象大小的映射关系
static const size_t MAX_BYTES=256*1024;//大于向系统或者PageCache申请,小于向thread cache
static const size_t NFREELISTS=208;//thread cache和central cache的自由链表哈希桶的表大小
static const size_t NAPAGES=129;//page cache管理的span list哈希表大小
static const size_t PAGE_SHIFT=12;//页大小转换偏移,即一页定义为2^12,4KB

//地址类型大小,32位下是4bytes 64位是8bytes
#ifdef _WIN32
    typedef size_t ADDRESS_INT; 
#else
    typedef unsigned long long ADDRESS_INT;    
#endif

//页编号类型,32位下是4bytes,64位是8bytes
#ifdef _WIN32
    typedef size_t PAGE_ID; 
#else
    typedef unsigned long long PAGE_ID;    
#endif
cpp 复制代码
// 管理对齐和映射关系
//  整体控制在最多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)
class SizeMap{
    public:
    static inline size_t _roundUp(size_t bytes,size_t align){
        //根据字节数和对齐粒度算出对齐数
        //计算大于等于align的最小的align倍数
        //(align-1):align-1让高位全是0,低位全是1
        //按位取反,那高位全是1,低位全是0,相当于保留只能被align整除的部分
        //按位与就能够保留高位整除align的部分,省略掉低位
        return (((bytes)+align-1)&~(align-1));
    }
    static inline size_t roundUp(size_t bytes){
        //对齐大小计算
        if(bytes<=128){
            //8对齐数
            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{
            return _roundUp(bytes,1<<PAGE_SHIFT);
        }
        return -1;
    }
    
    static inline size_t _index(size_t bytes,size_t align_shift){
        //计算该bytes应该映射到目前级别下的第几个链表
        //1<<align_shift  1<<3=8  对齐8字节
        //向上取整
        return ((bytes+(1<<align_shift)-1)>>align_shift)-1;
    }
    //计算映射到哪个自由链表桶
    static inline size_t index(size_t bytes){
        assert(bytes<=MAX_BYTES);//必须小于256KB

        //每个区间有多少个链
        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[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[3]+group_array[4];
        }
        else{
           assert(false);//不会有这一步因为上一个assert已经判断了
        }
        return -1;
    }

    // thread cache从central cache申请多少个内存块
    //运用慢启动:小对象多拿,大对象少拿,因为central cache是一个全局共享,需要加锁
    //小对象如int等类型申请的多
    static size_t numMoveSize(size_t size){
        //策略:小内存块可以申请多个,大内存块申请少个
        if (size == 0) return 0;
    
        //计算需要多少个块
        int num = MAX_BYTES / size;
        if (num < 2)
            num = 2;
    
        //限制最多能够拿512个内存块
        if (num > 512)
            num = 512;
        return num;
    }

    //计算一次向系统获取几个页
    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;
    }
};

这里采取的方案和之前讲的一样,并且绝大多数函数能够采取位运算的就采取,这样能够提高性能相比于直接+-*/,并且能够设成inline函数的就设成,static的话让这个类能够通过类名直接调用,而不是创建一个对象之后再进行调用,如果没有使用static,需要创建类对象和传参的时候有个隐含的this指针,都是有性能和空间的开销的,所以为了避免,再一些通用的工具类我们可以使用static修饰,这样直接通过类名调用


Central Cache


spanlist的理解:

span:跨度等概念,是用于标识一段连续的物理内存页,并管理这样页被分隔成的"小内存块"

一开始我们是通过span是一页或者多页,我们将其内部划分为多个和ThreadCache的小内存块,比如都是8KB,或者16KB等等,供ThreadCache拿取

如果有一天,span的内部所有小内存块都回来了,CentralCache将这个 Span 从 SpanList 中摘除,整体归还给 PageCachePageCache 就可以把它和前后的空闲页合并,解决外碎片问题!

这个是为了避免外碎片,因为你频繁的申请和不同时间的释放会导致外碎片,比如你有0~7,一共8页的物理内存,如果申请了两个页那就是0和1,后面再申请4个那就是2、3、4、5,剩余空闲页就是6、7,此时释放了0和1,空闲页为0、1、6、7,申请4页的空间是够的,但由于不是连续的导致不能申请成功,span的出现就是为了合并连续的内存,组成更大的连续的内存。

span就是再回收的时候尽可能的看一下附近连续的空间是否被释放了,如果释放了就进行合并成更大的页,比如释放了0,但是还没有释放1,下一次释放1的时候就会看一下附件有没有连续的物理内存,这时候就可以合并0和1变成8kb的大页(注意这里的pagecache设计时会有个定期扫描空闲的span策略)

为什么不像ThreadCache那样直接划分8KB或者16KB.........,而是要用一个span去管理,然后内部再划分呢???

如果串在一个大链表里,当 ThreadCache 归还内存时,CentralCache 根本不知道这块内存属于哪个页。这就引出了 SpanList 存在的根本原因:为了实现内存的"页级回收"。

层级关系:

ThreadCache:使用了分级技术(对齐策略),让单个内存块使用率更高,尽可能地降低内碎片,从 Central Cache 批量获取小内存块,缓存到线程私有链表中,实现无锁高效分配。

CentralCache:从 PageCache 申请 Span,切割成小内存块后,将这些块挂到对应大小的自由链表中(如 8 字节块挂到 8 字节链表),供ThreadCache使用

PageCache:使用span合并机制,减少外碎片,负责物理页的分配、回收和合并


申请内存:

  1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对

象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的

spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不

过这里使用的是一个桶锁,尽可能提高效率。

  1. central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的

span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span

中取对象给thread cache。

  1. central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread

cache,就++use_count

释放内存:

  1. 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--

use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,

page cache中会对前后相邻的空闲页进行合并。


注意:这里的中心缓存和页缓存都需要设成单例模式

所谓的单例模式就是全局只有一个实例对象,也就对应只有一个中心缓存和一个页缓存,所有地方都只能获取到唯一的一个实例,前面的ThreadCache不需要是因为每个线程独享一个

单例中有两种模式:懒汉和饿汉

懒汉:第一次调用 GetInstance() 时才创建实例,节省内存(懒加载)。

饿汉:程序启动时(静态变量初始化阶段)就创建实例,后续直接返回,无需加锁,线程安全。

缺点:如果实例初始化开销大,且程序全程没用到,会浪费内存。


page Cache


申请内存:

  1. 当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。

  2. 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式 申请128页page span挂在自由链表中,再重复1中的过程。

  3. 需要注意的是central cache和page cache 的核心结构都是spanlist的哈希桶,但是他们是有本质 区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist 中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的 spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。

释放内存:

  1. 如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span

看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少****内存碎片。

注意:这个也是得实现锁,因为不是线程独有的部分


对于页号的映射,采用基数树

TCMalloc_PageMap1 是其为了高效管理 "页号(PAGE_ID)到内存跨度(Span)的映射" 而设计的基数树(Radix Tree)结构 ,专门用于替代 STL 容器(如 unordered_mapmap),在性能、线程安全和内存效率上做了极致优化,是 TCMalloc 能够在多线程场景下保持高性能的关键设计之一。

在讲解原理之前,我们先回答一个最根本的问题:为什么用 std::unordered_mapstd::map

  • 性能 :红黑树(map)查找是 O(log⁡N),哈希表(unordered_map)最好也是O(1),但哈希表有哈希冲突和扩容导致的性能抖动。
  • 并发安全mapunordered_map 在多线程下读写必须加粗粒度锁。而在 PageCache 中,mapObjectToSpan(根据内存地址找 Span)是被极高频率调用的,如果用锁,内存池直接退化。
  • 基数树的优势绝对的O(1) 时间复杂度,且全程无锁(只要预分配好内存)
cpp 复制代码
template <int BITS>//需要多少个bit位进行存储页号,比如2^64次方如果按照4kb一页  那么需要2^20存储,即需要20个bit位进行存储
class RadixTree
{
    private:
    // 新增中间层统一常量
    static const int INTERIOR_BITS = (BITS + 2) / 3;       // 第一、二层共用比特数
    //static const size_t INTERIOR_BITS = 10;              // 第一、二层共用比特数
    
    static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS; // 第一、二层共用数组大小
    static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS; // 第三层比特数
    static const int LEAF_LENGTH = 1 << LEAF_BITS;         // 第三层数组大小
    // 非叶子结点,即一二层
    struct Node{
        Node *ptrs[INTERIOR_LENGTH];
    };
    //叶子结点,即第三层
	struct Leaf{
		void* values[LEAF_LENGTH];
	};
    Node* newNode(){
		static MemoryPool<Node> nodePool;
		Node* result = nodePool.New();
		if (result != NULL)
		{   
            //清空防止数据干扰
			memset(result, 0, sizeof(*result));
		}
		return result;
	}

	Node* root_;//根节点
    
    public:
    typedef uintptr_t Number;//基数树的键值类型,页号

32位系统,按 4KB(212212)分页,32位地址减去12位页偏移,页号需要 20 位(BITS=20)。

64位系统,同理,页号需要 52 位(BITS=52)。

基数树的原理就像查字典:把一个长长的页号,切分成三段,分别对应树的第一层、第二层、第三层。

基数树里没有链表,全都是纯数组,这就是它快的原因。

  1. 第一层(根节点 root_ :是一个 Node,里面有一个指针数组 ptrs[128](因为 27=12827=128)。
  2. 第二层(中间节点 Node :也是一个 Node,里面同样是指针数组 ptrs[128]
  3. 第三层(叶子节点 Leaf :是落地层,里面存的是真正的 Span* 指针数组 values[64](因为 26=6426=64)。

查找映射图解(假设页号二进制为 1010 010 | 1101 001 | 0010 10):

  1. 取高 7 位 -> 在 root_->ptrs 数组中找到第二层节点。
  2. 取中 7 位 -> 在第二层节点的 ptrs 数组中找到第三层叶子。
  3. 取低 6 位 -> 在第三层叶子的 values 数组中直接拿到 Span*
cpp 复制代码
                               【20位页号】
                         +-------+-------+-------+
                         | 高7位 | 中7位 | 低6位 |
                         +-------+-------+-------+
                              |      |      |
                              k1     k2     k3

========================================================================
                               Root (第一层)
                             [Node: 指针数组大小 128]
                               | | | | | | ... | |
                               | | | | | |      NULL
                               | | | | | |
          +--------------------+ | | | | +--------------------+
          |                      | | | |                      |
          v                      v v v v                      v
     【k1 = 0】             【k1 = 1】   ...              【k1 = 127】
  +----------+            +----------+                  +----------+
  | ptrs[128]|            | ptrs[128]|                  | ptrs[128]|
  |  NULL    |            |  NULL    |                  |  NULL    |
  |  ...     |            |  ...     |                  |  ...     |
  | ptrs[1]--|----+       | ptrs[5]--|----+             | ptrs[9]--|----+
  |  NULL    |    |       |  NULL    |    |             |  NULL    |    |
  +----------+    |       +----------+    |             +----------+    |
                  |                       |                             |
========================================================================
                  |                       |    第二层 (中间节点)
                  v                       v  [Node: 指针数组大小 128]
             【k2 = 1】              【k2 = 5】
             +----------+           +----------+
             | ptrs[128]|           | ptrs[128]|
             |  NULL    |           |  NULL    |
             |  ...     |           |  ...     |
             | ptrs[3]--|----+      | ptrs[0]--|----+
             |  NULL    |    |      |  NULL    |    |
             +----------+    |      +----------+    |
                             |                      |
========================================================================
                             |                      |    第三层 (叶子节点)
                             v                      v  [Leaf: Span*数组大小 64]
                        【k2 = 3】              【k2 = 0】
                        +----------+            +----------+
                        | values[] |            | values[] |
                        | values[0]|-> Span*    | values[0]|-> Span*
                        | values[1]|-> Span*    | values[1]|-> Span*
                        | ...      |            | ...      |
                        | values[63]-> Span*    | values[63]-> Span*
                        +----------+            +----------+
                             ^                       ^
                             |                       |
                          【k3 取值范围 0~63】     【k3 取值范围 0~63】

问题:

对于threadcache满了就全部还给中心缓存的策略,官方的是采用batch慢回收

这里还需要理解一下mmap中的匿名映射下的私有映射

mmap申请的是文件映射区域,在文件映射区域中有一种叫匿名映射,匿名映射有私有映射和公有映射,这里为什么要加私有映射,私有映射

这里的 MAP_PRIVATE 的主要作用,不是为了防文件被改,而是为了防 fork 出的子进程改!

  1. 父进程 malloc 了一大块内存(底层 mmap 匿名私有),并且往里面写了数据(此时已分配物理页 A)。
  2. 父进程调用 fork() 创建子进程。
  3. fork 的特性是:子进程复制父进程的地址空间。此时,子进程的页表也指向了物理页 A,但是 OS 把父子进程的页表都改成了只读
  4. 子进程写操作(触发COW):子进程试图修改这块内存,CPU 发现是只读的,触发缺页中断(写保护异常)。
  5. COW登场 :OS 知道这是私有映射,必须让父子进程互相干扰。于是 OS 再申请一块新物理页 B,把物理页 A 的数据拷贝到物理页 B,修改子进程的页表指向 B,并标记为可写。子进程在 B 上改,父进程在 A 上不受影响。

其实按照我们之前的malloc,也是这样私有映射,正因为这样我们配合cow才可以进程写时拷贝,当fork之后,拷贝页表,os会把页表当中的权限修改成只读,然后当父子进程有对数据进行修改时,页表当中是只读,此时发生页表异常,陷入内核态,内核程序进行检查

  • 如果 VMA 说这块本就该不可写 → 真的是段错误;
  • 如果 VMA 说可写,但 PTE 只读 → 可能是 COW:
    • 再看这个物理页是否被多个进程共享(引用计数 > 1);
    • 且映射类型是 MAP_PRIVATE。
  • 如果满足 COW 条件:
    • 分配一个新物理页 B;
    • 把旧页 A 的内容拷贝到 B;
    • 当前进程的 PTE 改成指向 B,权限改成 RW;
    • 旧页 A 的引用计数 -1(如果减到 0,才真的释放)。

出现free使用了valgrind报错检查

Valgrind 是一套在 Linux 上做"程序诊断"的工具集,主要用来:查内存错误、查线程竞争问题、做性能和缓存分析。

perf进行性能瓶颈分析,在未使用基数树之前

核心是释放的流程的开销大于申请的流程,主要是没有使用基数树之前span和id的映射关系查找太慢

修改完逻辑之后(小内存大内存+基数树)

至此整个项目基本已经构建完毕,后续的优化主要是在一些慢回收和慢增长上面

相关推荐
ComputerInBook1 小时前
C++ 关键字 constexpr 和 consteval 之注意事项
开发语言·c++·constexpr·consteval
米啦啦.1 小时前
STL(标准模板库)
开发语言·c++·stl
咩咦2 小时前
C++学习笔记08:指针和引用的区别
c++·学习笔记·指针·引用·指针和引用
洛水水2 小时前
【力扣100题】34.二叉搜索树中第K小的元素
c++·算法·leetcode
许长安2 小时前
gRPC Keepalive 机制
c++·经验分享·笔记·rpc
wangjialelele2 小时前
Linux SystemV 消息队列 + 责任链模式:实现客户端消息处理流水线
linux·服务器·c语言·网络·c++·责任链模式
智者知已应修善业3 小时前
51单片机4按键控制共阳LED霓虹灯切换1整体闪烁2流水下3流水上4间隔闪烁】2023-10-27
c++·经验分享·笔记·算法·51单片机
洛水水3 小时前
结构性设计模式详解
c++·设计模式
曦夜日长3 小时前
C++ STL容器string(二):删除与插入、数据查找、自定义输入
java·开发语言·c++