高并发内存池

本文介绍了如何通过高并发内存池技术,利用池化、锁竞争减小和内存碎片管理来提升多线程程序的内存管理效率。核心是三层缓存机制,包括ThreadCache、CentralCache和PageCache,有效解决了内碎片和外碎片问题。
摘要由CSDN通过智能技术生成

代码: 高并发内存池

项目背景

高并发内存池: 参考了Google的开源项目tcmalloc实现的简易版, 其主要是通过3层缓存结构实现多线程下高效的内存管理. 本次项目旨在学习其优秀的结构思想(分层结构管理内存, 减小锁的竞争, 减小外碎片)

来源: google/tcmalloc (github.com)

项目框架

ThreadCache

  • 特点: 每个线程独有
  • 功能1: 用于小于256KB内存的申请

CentralCache

  • 特点: 所有线程共享, 全局一个
  • 功能1: 当ThreadCache内存不够的时候为其提供内存(Span对象), 所以会发生竞争, 但是是桶锁(竞争较小)
  • 功能2: 当ThreadCache占有过多的内存未使用时, 回收对应的内存到Span对象

PageCache

  • 特点: 全局一个
  • 功能1: 当CentralCache内存不够的时候为其提供内存(页为单位)
  • 功能2: 当从CentralCache释放回一个Span对象后, 尝试合并其前后的Span, 以减少内存碎片

主要流程

申请内存

释放内存流程

项目预备(定长内存池)

  • 专门用于特定内存的申请, 并使用链表管理这些内存
  • 相比于malloc的统一, 定长内存池直接去小块内存push, pop就行, 效率会高一些

框架

// 堆上申请空间
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;
}



template<class T>
class ObjectPool
{
public:
    T* New()
    {}

    void Delete(T* obj)
    {}
    

private:
    char* _memory = nullptr;         // 指向大块内存的指针
    size_t _remainBytes = 0;         // 大块内存所剩下的字节数
    void* _freeList = nullptr;       // 链表管理内存块
};

实现

    T* New()
    {
        T* obj = nullptr;

        // 1.先使用本地缓存的
        if (_freeList)
        {
            void* next = *((void**)_freeList);
            obj = (T*)_freeList;
            _freeList = next;
        }
        else
        {
            // 2.不够重新开一个更大的空间
            if (_remainBytes < sizeof(T))
            {
                _remainBytes = 128 * 1024;
                //_memory = (char*)malloc(_remainBytes);
                _memory = (char*)SystemAlloc(_remainBytes >> 13);
                if (_memory == nullptr)
                {
                    throw std::bad_alloc();
                }
            }

            obj = (T*)_memory;
            //处理T不够一个指针大小
            size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
            _memory += objSize;
            _remainBytes -= objSize;
        }

        // 定位new,显示调用T的构造函数初始化
        new(obj)T;

        return obj;
    }

项目实现

ThreadCache

  • 结构: 哈希桶 -- 内部是_freeList, 挂着对应大小的内存块
  • 特点: 每个线程都有ThreadCache, 申请内存时无需加锁, 效率高

需要实现的功能

  • 申请内存
    • 本地有内存: 从ThreadCache本地缓存中获取内存(根据内存大小-->计算在那个哈希桶-->找到自由链表-->拿出一块内存)
    • 本地无内存: 找CentralCache要一批小块内存
  • 回收内存
    • 将内存还给ThreadCache(根据内存大小-->计算在那个哈希桶-->找到自由链表-->头插进去)
    • 将自由链表的长度过长, 将这些小块还给对应的Span对象, 给CentralCache

1.实现每个线程独有ThreadCache

TLS--thread local storage : 一种变量的存储方法, 让该变量在其所在的线程内是全局访问的, 而其它线程不能访问(避免加锁)

// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
2.设计自由链表管理内存

思路: 使用内存块的前4/8个字节来存下一个内存块的地址, 这样就将内存块串起来了.

          将对象转换为(void**)再解引用就可以取出一个指针大小的内存

3.设计内存对齐映射规则

256KB的内存若按8byte均匀划分, 需要2^15次方个哈希桶, 数量过多(内存开销大)

256KB的内存按非均匀的方式划分,整体控制在最多10%左右的内碎片浪费, 需要208个桶

[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)
4.ThreadCache功能框架
class ThreadCache
{
public:
    // 申请内存
    void* Allocate(size_t size);
    // 释放内存
    void Deallocate(void* ptr, size_t size);
    // 内存不够, 从CentralCache中获取一批小块内存
    void* FetchFromCentralCache(size_t index, size_t size);
    // 释放对象时,链表过长时,回收内存回到CentralCache对应的Span对象中
    void ListTooLong(FreeList& list, size_t size);
private:
    FreeList _freeLists[NFREELIST];
};

// TLS本地缓存, 只有当前线程可见 -- 线程申请内存的时候实现无锁访问
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

CentralCache

  • 结构: 哈希桶 -- 内部是SpanList管理Span对象(内部是小块内存) -- 映射关系与ThreadCache保持一致
  • 特点: 桶锁(尽量减小锁的竞争,提高效率) 慢启动算法(一开始给ThradCache1个小块内存, 随着申请次数增加, 给的小块内存增加)

  • 为ThreadCache提供一批小块内存
    • 当Thread申请小块内存的时候, CentralCache先在对应的SpanList中寻找Span, 拿出小块内存给ThreadCache
    • 若Span对象里面的小块内存不够了, 重新申请一个新的Span对象, 然后将其切割并用链表串起来, 然后再给ThreadCache
  • 回收小块内存到Span对象中
    • 使用一个计数_usecount来记录哪些内存被分配出去了, 若ThreadCache还内存让_usecount减小到0, 就将该Span还给PageCache

1.CentralCache功能框架
class CentralCache
{
private:
    SpanList _spanLists[NFREELIST];                    //spanList链表 -- 管理Span对象(内含对应的小块内存)
    static CentralCache _sInst;                        //单例模式
    CentralCache() {}
    CentralCache(const CentralCache&) = delete;

public:
    static CentralCache* GetInstance() { return &_sInst; }

    // 获取一个非空的span
    Span* GetOneSpan(SpanList& list, size_t byte_size);

    // 获取一定数量的小块内存给ThreadCache
    size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);

    // 还一部分的小块内存给CentralCache
    void ReleaseListToSpans(void* start, size_t byte_size);

};

PageCache

  • 结构: 哈希桶 -- SpanList挂Span对象. 但是其是按下标桶号映射, 第i号桶, 挂的都是第i页的内存
  • 特点: 回收Span的时候, 尝试根据Span前后的PageID去合并空间的Span对象, 减小内存碎片

  • 申请内存
    • 1.先在对应的桶中检查有没有空闲的Span
    • 2.看后面的桶有没有空闲的Span, 有的话进行切割 + 返回
    • 3.找到最后都没有, 则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中. 再重复1
  • 回收Span对象
    • 根据PageId, 找到其前后的空闲的Span对象进行合并

1.根据PageId计算对应的内存地址

根据id --> 计算地址

  • 第0页的起始地址是0第一页的起始地址是8 * 1024KB...以此类推
  • 使用地址 / 8 / 1024 可以获取页数
  • 使用页数 * 8 * 1024 可以获取起始地址

合并内存

2.PageCache功能框架
class PageCache
{
private:
    SpanList _spanLists[NPAGES];                        
    std::unordered_map<PAGE_ID, Span*> _idSpanMap;        //记录PAGE_ID与Span的映射关系

    static PageCache _sInst;                            //单例
    PageCache() {}
    PageCache(const PageCache&) = delete;

public:
    std::mutex _pageMtx;                                //全局锁
    static PageCache* GetInstance() { return &_sInst; }

    // 获取从对象到span的映射
    Span* MapObjectToSpan(void* obj);
    // 释放空闲span回到Pagecache,并合并相邻的span
    void ReleaseSpanToPageCache(Span* span);
    // 获取一个K页的span
    Span* NewSpan(size_t k);
};

优化

1.对于大于256KB内存的申请

  • 修改接口: NewSpan 与 ConcurrentAlloc
  • 若是32Page - 128Page : 直接找PageCache申请
  • 若是超过128Page : 找堆申请

2.使用定长内存池,脱离new

//定义定长的span内存池以脱离使用new
ObjectPool<Span> _spanPool;

测试

运行测试

测试发现: 效率并没有malloc高...

性能分析

unordered_map的查找消耗(find遍历) + 里面的锁消耗

使用基数树优化

基数树_百度百科 (baidu.com)

  • key是页号, val是指针
  • 使用基数树替换哈希表, 存 -- 采用直接定址法

一层基数树

  • 直接定址法

二层基数树

  • 使用多层去获取Span
  • 总消耗内存不变, 但在64位下, 要覆盖所有页号, 一次性是开不出这么多空间的, 需要分层
  • 有效利用空间: 第一层全部覆盖即可, 和后面的层按需去开空间

使用基数树来建立PageID与Span对象的映射会更快原因
  • 1.读取的时候可以不加锁
    • 基数树提前开好了空间(写入操作不会改结构). 读的时候不需要加锁
    • 而使用hash表存其映射, 若进行写, 可能回导致底层结构变化(扩容, 新旧表的问题),所以必须加锁
  • 2.查找
    • 使用哈希表需要find根据id获取Span, 基数树提前开好了空间, 直接定址法查找
  • 高度更低(1~3层)
    • 32位下, 记录页数 = 2^32 / 一页的大小(2^13) = 2^19. 能直接开出来, 但64位就不能
    • 第一二层时预先分配的(尽可能覆盖大多数地址空间) 第三层按需分配来节省空间

结果

总结

1.项目比malloc性能高的原因

  • 1.ThreadCache为每个线程独有, 无需加锁访问
  • 2.CentralCache加的是桶锁, 线程之间竞争较小
  • 3,使用基数树预先分配且静态结构优化, 读取Span对象无需加锁, 写操作不会改变结构

2.缺陷

实际上在释放内存时由thread cache将自由链表对象归还给central cache只使用了链表过长这一个条件,但是实际中这个条件大概率不能恰好达成,那么可能出现thread cache中自由链表挂着许多未被使用的内存块,从而出现了线程结束时可能导致内存泄露的问题。

  • 不过当整个进程结束后, 所有内存都会回收
  • 解决方法是通过回调函数来回收这部分的内存,并且通过申请批次统计内存占有量,保证线程不会过多占有资源

3.收获

  • 学习了3层缓存设计思想
  • 了解了池化技术
  • 了解了慢启动算法, 基数树
  • 调式(学会看函数堆栈, 一层一层往上查找问题)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值