文章目录
- 前言
- 一、项目介绍
- 二、内存池介绍
- 三、定长内存池
-
- [3.1 设计思路](#3.1 设计思路)
- [3.2 数据结构](#3.2 数据结构)
- 四、高并发内存池整体框架设计
-
- [4.1 thread cache](#4.1 thread cache)
-
- threadcache哈希桶映射对齐规则
- [threadcache TLS无锁访问](#threadcache TLS无锁访问)
- [4.2 central cache](#4.2 central cache)
- [4.3 page cache](#4.3 page cache)
-
- [page cache整体设计](#page cache整体设计)
- [从page cache中获取span](#从page cache中获取span)
- [4.4 联调申请内存](#4.4 联调申请内存)
- [4.5 回收内存](#4.5 回收内存)
-
- [thread cache回收内存](#thread cache回收内存)
- [central cache内存回收](#central cache内存回收)
- pagecache内存回收
- [4.6 联调回收内存](#4.6 联调回收内存)
- [4.6 大于256KB的大块内存申请问题](#4.6 大于256KB的大块内存申请问题)
- 4.7使用定长内存池配合脱离使用new
- [4.8 释放内存优化](#4.8 释放内存优化)
- 五、多线程环境下对比malloc测试
-
- 总结
前言
这篇文章是小博主的第一个正式的项目,因此是边写边学(其实怀着学习的目的成分更大一点),在做项目的途中会涉及到之前学的知识和未学习的盲区,权当复盘以及学习和打磨细节,在写项目中遇到的问题和知识点将会记录在本博客中。因此这篇博客实际上并不是一篇介绍高并发内存池很权威的文章,但博主也会尽力梳理其实现的脉络。因为想把所有相关知识以及自己视角下的心得呈现出来,在项目的主线下或许还会引出周边知识...
一、项目介绍
当前项目实现的是一个高并发的内存池(也可以叫做高性能内存池)。它的原型是google的⼀个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc。它是一个高性能内存分配器,专为多线程环境优化,旨在减少内存分配的开销 和内存碎片问题 。
tcmalloc实现了⾼效的多线程内存管理,可⽤于替代系统的内存分配相关的函数(malloc、free)。
tcmalloc的知名度也是非常高的,不少公司都在用它,比如Go语言就直接用它做了自己的内存分配器。
该项目就是把tcmalloc中最核心的框架简化后拿出来,模拟实现出一个mini版的高性能内存池,目的就是学习实现tcmalloc的精华和思维模式。
该项目主要涉及C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等方面的技术。
另外在写项目之前,给各位一个忠告:一定要仔细写每一行代码,要不然特别容易崩掉,并且问题还不容易找到,因为涉及大量指针和内存地址的操作,比如博主遇到的几个问题,像while循环里面少循环一次导致数据的错误还能通过时间调试出来,或者把解锁加到循环体里面导致第二次循环解锁找不到对应的锁都可以调试出来定位在哪里出错,但遇到内存地址和页号逻辑左右移问题少移一位地址丢失导致程序一会可以运行一会不可以运行错误,根本没法调试,真就摸不着头脑了,只能请教有经验的人了
二、内存池介绍
1.池化技术
池化技术(Pooling)是一种通过预先创建并管理一组可复用资源,以减少资源创建、销毁的开销。
之所以要申请过量的资源,是因为申请和释放资源都有较大的开销,不如提前申请一些资源放入"池"中,当需要资源时直接从"池"中获取,不需要时就将该资源重新放回"池"中即可。这样使用时就会变得非常快捷,可以大大提高程序的运行效率。(像STL里面容器的内存分配策略,我们使用的空间超过开辟的空间的时候就会扩容,但他不是按需扩容,而是成比例扩容出一大段空间,这也是一种池化?)
在计算机中,有很多使用"池"这种技术的地方,除了内存池之外,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想就是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求后,线程又进入睡眠状态。而线程池用的的技术其实就是锁(其实锁就是含所有权的二元信号量)和信号量之间的配合,以及单例模式的设计和回调函数之间的配合运用。
好像有点晦涩了,举个例子:博主上小学的时候门口有摊煎饼的,我妈想要让我每天放学都吃到煎饼,但是由于有自己的事情要忙,她又不想每天等待到我放学带着我付钱去买,于是便将钱在空闲的时候给摊主,而且一次给够一周的,接下来的一周,我妈便可以忙自己的事情,而我想吃的时候在任何摊主摆摊存在的时间点只需要到摊位上向摊主拿煎饼就行,不需要我妈再来到摊位前付钱。本质上是一种预定机制。
2.内存池
内存池是指程序预先向操作系统申请一块足够大的内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当释放内存的时候,并不是真正将内存返回给操作系统,而是将内存返回给内存池。当程序退出时(或某个特定时间),内存池才将之前申请的内存真正释放。
总的来说它一种通过预分配和自主管理内存块来优化动态内存分配的技术。
内存池主要解决的问题!!:
- 内存池主要解决的就是效率的问题,它能够避免让程序频繁的向系统申请和释放内存。(操作系统由用户态转到内核态会发生上下文切换和内存管理算法等等会消耗效率)
- 其次,内存池作为系统的内存分配器,还需要尝试解决内存碎片的问题。*
内存碎片分为内部碎片和外部碎片:
外部碎片:是一些空闲的小块内存区域,由于这些内存空间不连续,以至于合计的内存足够,但是不能满足一些内存分配申请需求。
内部碎片:是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。
cpp
初始内存布局(连续空闲):
+---------------------------------------------------------+
| 空闲 256B |
+---------------------------------------------------------+
分配 64B、128B、64B:
+-----------+-------------------+-----------+-------------+
| 已分配 64B | 已分配 128B | 已分配 64B | 空闲 0B |
+-----------+-------------------+-----------+-------------+
释放中间 128B:
+-----------+-------------------+-----------+-------------+
| 已分配 64B | 空闲 128B | 已分配 64B | 空闲 0B |
+-----------+-------------------+-----------+-------------+
再请求分配 128B:
- **成功**:空闲的 128B 可以满足。
释放两侧的 64B:
+-----------+-------------------+-----------+-------------+
| 空闲 64B | 空闲 128B | 空闲 64B | 空闲 0B |
+-----------+-------------------+-----------+-------------+
此时总空闲内存:64B + 128B + 64B = 256B
请求分配 200B:
- **失败**:空闲内存分散,无法合并为连续 200B → **外部碎片**。
-------------------------------------------------------------------------------------------
**内部碎片**
+-------------------+-------------------+-------------------+
| 已分配块(128B) | 已分配块(128B) | 已分配块(128B) |
| 用户实际使用 100B | 用户实际使用 100B | 用户实际使用 100B |
| 内部碎片 28B | 内部碎片 28B | 内部碎片 28B |
+-------------------+-------------------+-------------------+
|------------------------------------------------------------------------------------------------|
| 注意: 内存池尝试解决的是外部碎片的问题,同时也尽可能的减少内部碎片的产生。并且!!!内部碎片是会被映射到物理内存上的,而外部碎片则不会,外部碎片会导致无法连续分配虚拟地址未分配的大块内存 |
3.malloc视角下内存的管理
malloc 本身是 C 标准库提供的函数(如 glibc 的 ptmalloc,c++中new也是封装了malloc),不直接分配物理内存,而是管理进程的虚拟内存空间,是使用了池化的技术形成的一种内存池。
我们申请内存块时是先调用malloc,malloc再去向操作系统申请内存。malloc实际就是一个内存池,malloc相当于向操作系统"批发"了一块较大的内存空间,然后"零售"给程序用,当全部"售完"或程序有大量的内存需求时,再根据实际需求向操作系统"进货"。
当我们用c语言调用malloc函数的动态申请内存的时候它并不是直接去堆申请内存:
从 malloc 到硬件页表的完整链路:
- 用户层:malloc 管理虚拟内存,通过 brk/mmap 扩展堆空间。
- 内核层:分配虚拟内存区域(VMA),处理缺页异常,管理物理内存(伙伴系统/SLAB)。
- 硬件层:MMU 翻译地址,TLB 加速访问,页表维护虚拟到物理的映射。
cpp
//仅作了解
malloc流程
├─ 用户层
│ ├─ 调用malloc(size)
│ └─ 返回虚拟地址指针
│
├─ C库(glibc)
│ ├─ 空闲链表管理(bins/chunks) //接下来的定长内存池好像也是根据这个模式来写的。
│ ├─ 内存池策略(brk/mmap)
│ └─ 调用系统调用(brk/sys_mmap)
│
├─ 内核层
│ ├─ 系统调用处理
│ │ ├─ brk:调整堆顶指针
│ │ └─ mmap:映射匿名内存页
│ │
│ ├─ 虚拟内存管理(vm_area_struct)
│ └─ 缺页异常处理
│ ├─ 检查VMA合法性 //应该是通过对比VAM区间的值大小?
│ ├─ 分配物理页帧(伙伴系统/slab)//这个具体实现细节博主也不是很懂,不过项目最后好像会学
│ └─ 更新页表
│
├─ 页表与硬件
│ ├─ 多级页表(PGD→PUD→PMD→PTE)
│ ├─ TLB加速地址转换
│ └─ MMU处理缺页异常 //若TLB没有命中才会访问,TLB大大加快了寻址速度,而进程的切换会更新TLB等上下文,重新装填会有损耗,这是博主的理解。
│
└─ 物理内存
├─ 物理页帧(PFN)
└─ 总线访问(DDR)
//仅作了解
用户程序调用 malloc(4096) → 获得虚拟地址 0x12340000//此时页表还没发生映射,采用延迟分配策略
↓
程序首次写入 0x12340000 → CPU 检测到页表项无效
↓
触发缺页异常 → CPU 切换到内核态
↓
内核检查 0x12340000 是否在 VMA 链表中(合法)
↓
内核通过伙伴系统分配一个 4KB 物理页帧(PFN=0xABC)
↓
内核更新页表:将虚拟地址 0x12340000 映射到 PFN=0xABC,标记为 RW
↓
刷新 TLB(或依赖 ASID 隔离)
↓
返回用户态,重新执行写入指令 → MMU 通过页表找到 PFN=0xABC,完成内存写入
malloc的实现方式有很多种,并且是一个复杂的过程,一般不同编译器平台用的都是不同的。比如Windows的VS系列中的malloc就是微软自行实现的,而Linux下的gcc用的是glibc中的ptmalloc。
三、定长内存池
鱼和熊掌不可兼得,在计算机的世界同样是如此的。malloc 作为通用的内存分配器,在通用性和性能之间做了权衡。malloc在什么场景下都可以使用,但这也意味着malloc在什么场景下都不会有很高的性能,因为malloc并不是针对某种场景专门设计的。例如全局的 malloc 需要加锁保证线程安全,多线程高频分配时,锁竞争成为瓶颈。线程数越多,竞争越激烈,性能下降越明显
cpp
测试场景:多线程高频分配 128B 内存块(100 万次)
| 方案 | 耗时(ms) | 内存碎片(外部) |
|---------------|------------|------------------|
| `malloc` | 120 | 高 |
| `tcmalloc` | 45 | 中 |
| 定长内存池 | 8 | 无 |
定长内存池(对象池)就是针对固定大小内存块的申请和释放的内存池,由于定长内存池只需要支持固定大小内存块的申请和释放,因此我们可以将其性能做到极致,并且在实现定长内存池时不需要考虑内存碎片等问题,因为我们申请/释放的都是固定大小的内存块。
我们可以通过实现定长内存池来熟悉一下对简单内存池的控制,其次,这个定长内存池后面会作为高并发内存池的一个基础组件。
3.1 设计思路
- 初始化时向系统申请一大块连续内存(例如 N * block_size)。
- 将大块内存分割成多个固定大小的内存块,返回内存块的首地址供使用(例如每个块 64 字节)。
- 空闲块链表管理
-
将分割并使用返回后的内存块通过链表连接(使用内存块本身的资源去链接而不需要特定的链表结构),形成空闲块列表(Free List)。达到循环利用的目的。
注意!!:这里链表链接的内存块同时也存在在大块的内存块中,只不过是相同的数据被不同的结构使用了。有点像Linux操作系统的PCB设计?可以同时存在在运行队列和树状结构亦或者休眠等队列中。 -
分配时从链表头部取出一个块,释放时将块归还到链表头部。
- 高效分配与释放
-
分配和释放操作仅需操作链表指针,时间复杂度为 O(1)。
-
避免系统调用和内存碎片。
cpp
+-------------------------------------------------+
| 内存池结构 |
| |
| 大块内存(预分配) |
|
| +---------+---------+---------+-----+|
| char* mem->| Block 1 | Block 2 | ... | Block N ||
| +---------+---------+---------+-----+ |
| |
| 空闲链表头(Free List Head) → Block 1 → Block 2 → ... → Block N → NULL |
+-------------------------------------------------+
//所有内存块初始时都是空闲的,通过链表连接。
实现定长的两种类型
使用非类型模板参数,使得在该内存池中申请到的对象的大小都是N。
c
template<size_t N>
class ObjectPool
{};
此外,定长内存池也叫做对象池,在创建对象池时,对象池可以根据传入的对象类型的大小来实现"定长",因此我们可以通过使用模板参数来实现"定长",比如创建定长内存池时传入的对象类型是int,那么该内存池就只支持4字节大小内存的申请和释放。
c
template<class T>
class ObjectPool
{};
3.2 数据结构
|---------------------------------------------------------------------------------------------------------------|
| 任何东西的设计都是有迹可循并且不是一蹴而就的,所以对于定长内存池来说,我们会首先根据其设计思路考虑其成员变量有哪些,然后对这些成员变量的数据增删改查,其中对于指针的运用是占用主要的部分,其实就是一个先组织,再管理的过程 |
由上面的设计思路可以让定长内存池当中包含三个成员变量:
_memory:指向总大块内存的指针
_remainBytes:总内存剩余内存数,用于判断是否需要开辟内存块
_freeList:不用的内存块换回来后链入其中,做循环利用
释放对象需要做的管理
cpp
void Delete(T *obj)
{
obj->~T();//显示调用析构函数
_freeList = obj;
//*((int*)obj)=nullptr;//利用32位平台指针四字节特性和整形指针解引用为四个字节达到使用内存块本身存储指针的目的,适用于32位平台
*(void **)obj = nullptr;
// 与上同理,只不过上面的编译会报错将指针赋值给整形//目的是为了让内存块本身拿出适合平台指针大小的空间去存储地址,可适用于32位和64位平台
}
但是如果对象本身的大小小于一个指针呢?怎么对内存对象重复利用呢?使用的内存对象太多导致一开始开辟的总内存块不够用了怎么办嘞?如果是类对象的话还有构造和析构函数,以及内存池要给多大呢?不同平台下和不同位系统下的实现也要考虑其中。
以下是总的代码结构:
cpp
#include <iostream>
using std::cout;
using std::endl;
template <typename T>
class ObjectPool
{
public:
T *New()
{
T *obj = nullptr;
//优先使用换回来的内存对象,再次重复利用
if (_freeList)
{
obj = (T*)_freeList;
_freeList = *((void**)_freeList); // 得到next指针的地址
}
else
{
// 判断内存剩余数,不够一个对象大小重新开辟
if (_remainBytes < sizeof(T))
{
_remainBytes=128 * 1024;
_memory = (char *)malloc(128 * 1024);//可以看出来定长内存池本质上还是调用malloc的形式去虚拟地址上开空间,只不过是申请一大块内存
if (_memory == nullptr)
throw std::bad_alloc();
}
obj = (T*)_memory;
//必须保证给出的内存大小至少能存储一个指针,也就是4个字节大小!!!
size_t objsize=sizeof(T)>sizeof(void*)?sizeof(T):sizeof(void*);
_memory += objsize;
_remainBytes -= objsize;
}
new(obj)T;
return obj;
}
void Delete(T *obj)
{
obj->~T();
//将释放的对象头插到自由链表
// *((int*)obj)=nullptr;//利用32位平台指针四字节特性和整形指针解引用为四个字节达到使用内存块本身存储指针的目的,适用于32位平台
// 与上同理,只不过上面的编译会报错将指针赋值给整形,可以适用于32和64位平台//目的是为了让内存块本身拿出适合平台指针大小的空间去存储地址
*(void **)obj = _freeList;
_freeList = obj;
}
private:
char *_memory = nullptr; // 指向总内存的指针
size_t _remainBytes = 0; // 总内存剩余内存数,用于判断是否需要开辟内存块
void*_freeList = nullptr; // 不用的内存块xx
};
需要注意的是,与释放对象时需要显示调用该对象的析构函数一样,当内存块切分出来后,我们也应该使用placement new(即new(obj)T),显示调用该对象的构造函数对其进行初始化。
对于开辟内存池的大小这里做特殊声明:操作系统以页管理内存,每个页大小为4KB或者8KB,所以选择页大小的整数倍更能利用内存减少浪费
不同平台下系统调用的扩展:
cpp
// 直接去堆上按⻚申请空间 ,可以用在这个函数代替上面的malloc,来达到不同操作系统下使用系统调用来开辟内存的目的
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN325 void* ptr = VirtualAlloc(0, kpage*(1<<12),MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);//VirtualAlloc是window系统下的系统调用,使用不做赘述
#else// linux下brk mmap等
#endif if (ptr == nullptr) throw std::bad_alloc(); return ptr;
}
以下文章是对两个操作系统的内存开辟的系统调用的介绍:
VirtualAlloc
四、高并发内存池整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本⾝其实已经很优秀,那么我们项⽬的原型tcmalloc就是在多线程⾼并发的场景下更胜⼀筹,所以这次我们实现的内存池需要考虑以下⼏⽅⾯的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎⽚问题。
设计将会通过层级缓存和细粒度内存管理,显著提升多线程环境下的内存分配性
高并发内存池整体框架
高并发内存池主要由以下三个部分构成:
-
thread cache: 线程缓存是每个线程独有的,用于小于等于256KB的内存分配,每个线程独享一个thread cache。
-
central cache: 中心缓存是所有线程所共享的,当thread cache需要内存时会按需从central cache中获取内存,而当- thread cache中的内存满足一定条件时,central cache也会在合适的时机对其进行回收。
-
page cache: 页缓存中存储的内存是以页为单位进行存储及分配的,当central cache需要内存时,page cache会分配出一定数量的页分配给central cache,而当central cache中的内存满足一定条件时,page cache也会在合适的时机对其进行回收,并将回收的内存尽可能的进行合并,组成更大的连续内存块,缓解内存碎片的问题。
cpp
//图解结构
+-----------------------+
| ThreadCache | (Per-Thread)
|-----------------------|
| FreeList[0] → 8B |←┐
| FreeList[1] → 16B | │ (Size Classes)
| ... | │
| FreeList[N] → 256KB |←┘
+----------↑------------+
│ Alloc/Free
│ (无锁操作)
▼
+-----------------------+
| CentralCache | (Global, 分Size Class)
|-----------------------|
| CentralFreeList[0] |→ Span1 → [obj][obj]...
| CentralFreeList[1] |→ Span2 → [obj][obj]...
| ... | (Span分割为小对象)
+----------↑------------+
│ 请求/释放Span
│ (可能加锁)
▼
+-----------------------+
| PageCache | (Global)
|-----------------------|
| Free Spans: |
| - 1-page spans |→ [SpanA]→[SpanB]...
| - 2-page spans |→ [SpanC]→...
| - ... |
| - 256-page spans |
| |
| Large Object Spans |→ [SpanX(>256KB)]→...
+-----------------------+
进一步说明:
每个线程都有一个属于自己的thread cache,也就意味着线程在thread cache申请内存时是不需要加锁的,而一次性申请大于256KB内存的情况是很少的,因此大部分情况下申请内存时都是无锁的,这也就是这个高并发内存池高效的地方。
每个线程的thread cache会根据自己的情况向central cache申请或归还内存,这就避免了出现单个线程的thread cache占用太多内存,而其余thread cache出现内存吃紧的问题。
多线程的thread cache可能会同时找central cache申请内存,此时就会涉及线程安全的问题,因此在访问central cache时是需要加锁的来达到原子性访问的目的,但central cache实际上是一个哈希桶的结构,只有当多个线程同时访问同一个桶时才需要加锁,所以这里的锁竞争也不会很激烈。
各个部分的主要作用
thread cache主要解决锁竞争的问题,每个线程独享自己的thread cache,当自己的thread cache中有内存时该线程不会去和其他线程进行竞争,每个线程只要在自己的thread cache申请内存就行了。
central cache主要起到一个居中调度的作用,每个线程的thread cache需要内存时从central cache获取,而当thread cache的内存多了就会将内存还给central cache,其作用类似于一个中枢,因此取名为中心缓存。
page cache就负责提供以页为单位的大块内存,当central cache需要内存时就会去向page cache申请,而当page cache没有内存了就会直接去找系统,也就是直接去堆上按页申请内存块,实际运用中大部分场景都不会用到。
层级 | 核心作用 | 关键优化点 |
---|---|---|
ThreadCache | 线程本地无锁分配小对象(≤256KB) | 无锁、Size Class、批量操作 |
CentralCache | 全局协调 Span 分配,减少内存碎片 | 分 Size Class 管理、Span 合并 |
PageHeap | 管理物理内存,处理大对象与碎片控制(>256KB) | Span 合并、延迟归还、分层空闲链表 |
4.1 thread cache
thread cache的关键成员变量设计
定长内存池只支持固定大小内存块的申请释放,因此定长内存池中只需要一个自由链表管理释放回来的内存块。现在我们要支持申请和释放不同大小的内存块,那么我们就需要多个自由链表来管理释放回来的内存块,因此thread cache实际上一个哈希桶结构,每个桶中存放的都是一个自由链表。
thread cache支持小于等于256KB内存的申请,如果我们将每种字节数的内存块都用一个自由链表进行管理的话,那么此时我们就需要20多万个自由链表,光是存储这些自由链表的头指针就需要消耗大量内存,这显然是得不偿失的。
这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1~ 8字节的内存时会直接给出8字节,而当线程申请9~16字节的内存时会直接给出16字节,以此类推。这种方式将会产生内碎片问题
鉴于项目的复杂度,对于链表我们需要封装一个类来方便管理哈希桶。
目前我们就提供Push和Pop两个成员函数,对应的操作分别是将对象插入到自由链表(头插)和从自由链表获取一个对象(头删),后面在需要时还会添加对应的成员函数。
cpp
//common.hpp
//管理切分好的小对象的自由链表
class FreeList
{
public:
//将释放的对象头插到自由链表
void Push(void* obj)
{
assert(obj);
//头插
*(void**)obj = _freeList;
_freeList = obj;
}
//从自由链表头部获取一个对象
void* Pop()
{
assert(_freeList);
//头删
void* obj = _freeList;
_freeList = *(void**)obj;
return obj;
}
private:
void* _freeList = nullptr; //自由链表
};
因此thread cache实际就是一个数组,数组中存储的就是一个个的自由链表,至于这个数组中到底存储了多少个自由链表,就需要看我们在进行字节数对齐时具体用的是什么映射对齐规则了。
threadcache哈希桶映射对齐规则
在开销得失平衡之间,我们并不需要将所有字节都含有对应的链表,我们只需要让一个区间的大小归属于特定的字节数即可。
这些内存块是会被链接到自由链表上的,因此一开始肯定是按8字节进行对齐是最合适的,因为我们必须保证这些内存块,无论是在32位平台下还是64位平台下,都至少能够存储得下一个指针。
但如果所有的字节数都按照8字节进行对齐的话,那么我们就需要建立256 × 1024 ÷ 8 = 32768 个桶,这个数量还是比较多的,实际上我们可以让不同范围的字节数按照不同的对齐数进行对齐,具体对齐方式如下:
字节数 | 对齐数 | 哈希桶下标 |
---|---|---|
[1 , 128] | 8 | [0,16) |
[128+1 , 1024] | 16 | [16,72) |
[1024+1 , 1024 * 8] | 128 | [72,128) |
[8 * 1024+1 , 64*1024] | 1024 | [128,184) |
[64*1024+1 , 256 *1024] | 8 * 1024 | [184,208) |
虽然对齐产生的内碎片会引起一定程度的空间浪费,但按照上面的对齐规则,我们可以将浪费率控制到百分之十左右。
浪费率= 对齐后的字节数 浪费的字节数 \frac{对齐后的字节数}{浪费的字节数} 浪费的字节数对齐后的字节数
假如我们需要的内存数是129,那么根据规则对齐数为16,那么此时我们需要申请的哈希桶下标为16,也就是含有144字节的内存空间,也就是浪费了144-129=15的空间,占总空间的约等于%11,往下同理。
需要说明的是,1~128这个区间我们不做讨论,因为1字节就算是对齐到2字节也有百分之五十的浪费率,这里我们就从第二个区间开始进行计算。
我们可以封装一个类。根据字节数的对齐规则,在类中设置两个对应的函数,分别用于获取某一字节数对齐后的字节数,以及该字节数对应的哈希桶下标。
|----------------------------------|
| 这个类主要服务对象是central cache层,后面我们会了解 |
cpp
//common.hpp
//管理对齐和映射等关系
class SizeClass
{
public:
//获取向上对齐后的字节数
static inline size_t RoundUp(size_t bytes);
//获取对应哈希桶的下标
static inline size_t Index(size_t bytes);
};
注意:SizeClass类当中的成员函数最好设置为静态成员函数,否则我们在调用这些函数时就需要通过对象去调用,并且对于这些可能会频繁调用的函数,可以考虑将其设置为内联函数。
RoundUp()
首先判断属于那个字节数区间,然后让子函数去处理并获得对齐后的字节数大小
cpp
//common.hpp
//一般写法
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
size_t alignSize = 0;
//等于对齐数不需要处理,不等于就将字节数除对齐数+1*对齐数,便得到这个字节数区间中的自己对应的对齐字节数
if (bytes%alignNum != 0)
{
alignSize = (bytes / alignNum + 1)*alignNum;
}
else
{
alignSize = bytes;
}
return alignSize;
}
//获取向上对齐后的字节数
static inline size_t 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函数中对齐数的计算除了加减乘除,我们同样能利用对齐数都是二的整数倍来用位运算解决
cpp
//位运算写法
size_t _RoundUp(size_t bytes, size_t alignNum) {
return ((bytes) + alignNum - 1) & ~(alignNum - 1);
}
思想解释如下:
- 计算对齐余量
alignNum - 1 生成一个掩码(mask),用于后续位运算。例如:
- 若 alignNum = 8,则 alignNum - 1 = 7(二进制 0b0111)。
- 若 alignNum = 16,则 alignNum - 1 = 15(二进制 0b1111)。
- 调整字节数
bytes + alignNum - 1 将原始字节数 bytes 向上扩展,确保后续操作能覆盖对齐余量。例如:
- 若 bytes = 10, alignNum = 8,则 10 + 7 = 17。
- 位掩码清零低位
~(alignNum - 1) 生成一个掩码,将 alignNum - 1 的所有二进制位取反,从而清除原始字节数中不足对齐基数的部分。例如:
- 若 alignNum = 8,则 ~(7) = 0b1111...1000(所有高位为 1,低 3 位为 0)。
- 位运算 & ~(alignNum - 1) 会将低 3 位清零,结果必然是 alignNum 的倍数。
例 1:bytes = 5
alignNum - 1 = 7
bytes + alignNum - 1 = 5 + 7 = 12(二进制 0b1100)
~(alignNum - 1) = ~7 = 0b1111...1000
12 & ~7 = 0b1000(即 8)
最终结果为 8,对齐到 8 的倍数。
例 2:bytes = 15
alignNum - 1 = 7
bytes + alignNum - 1 = 15 + 7 = 22(二进制 0b10110)
22 & ~7 = 16(二进制 0b10000)
最终结果为 16,对齐到 8 的倍数。
关键点:
- alignNum 必须为 2 的幂
只有 alignNum 是 2 的幂时,alignNum - 1 的二进制形式才是全 1(如 7 = 0b0111),此时掩码操作才能正确清零低位。- 向上对齐的本质
通过 bytes + alignNum - 1 确保结果至少达到下一个对齐边界。
例如:1-8字节数对齐的都是8,他们的bytes+alignNum-1的结果是8-15即0b1000 - 0b1111,意味着二进制下按位与上0b0111后都是0b1000也就是8
通过掩码 & ~(alignNum - 1) 清除低位,得到对齐后的值。
公式本质:aligned_bytes= b y t e s + a l i g n N u m a l i g n N u m \frac{bytes+alignNum}{alignNum} alignNumbytes+alignNum×alignNum
Index()
获取对应的哈希桶下标与上面大致同理
cpp
//common.hpp
//获取对应哈希桶的下标
static inline size_t Index(size_t bytes)
{
//每个区间有多少个自由链表
static size_t groupArray[4] = { 16, 56, 56, 56 };
if (bytes <= 128)
{
return _Index(bytes, 3);
}
else if (bytes <= 1024)
{
return _Index(bytes - 128, 4) + groupArray[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes - 1024, 7) + groupArray[0] + groupArray[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes - 8 * 1024, 10) + groupArray[0] + groupArray[1] + groupArray[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes - 64 * 1024, 13) + groupArray[0] + groupArray[1] + groupArray[2] + groupArray[3];
}
else
{
assert(false);
return -1;
}
}
编写一个子函数来继续进行处理,容易想到的就是根据对齐数来计算某一字节数对应的下标。
cpp
//common.hpp
//一般写法
static inline size_t _Index(size_t bytes, size_t alignNum)
{
size_t index = 0;
//8字节往上
if (bytes%alignNum != 0)
{
index = bytes / alignNum;
}
//1-8字节需要做数组下标是0的考虑
else
{
index = bytes / alignNum - 1;
}
return index;
}
为了提高效率下面也提供了一个用位运算来解决的方法,需要注意的是,此时我们并不是传入该字节数的对齐数,而是将对齐数写成2的n次方的形式后,将这个n值进行传入,并且传入的bytes也需要减去前一个字节数区间的最大值,这样计算出在当前桶区间的第几位,加上当前区间最小值即可得到需要映射到哈希桶那个位置!!
cpp
//位运算写法
static inline size_t _Index(size_t bytes, size_t alignShift)
{
return ((bytes + (1 << alignShift) - 1) >> alignShift) - 1;
}
-
计算块大小
1 << alignShift 等价于 2^alignShift,表示内存块的单位大小。
示例:若 alignShift=3,块大小为 8B。
-
向上对齐内存请求
bytes + (块大小 - 1) 将内存请求向上扩展,确保后续除法操作能覆盖对齐余量。
示例:bytes=5, 块大小=8 → 5 + 7 = 12。
-
计算块数量
(扩展后的值) >> alignShift 等价于 扩展后的值 / 块大小,得到需要的块数量(向下取整)。
示例:12 >> 3 = 1(即 12 / 8 = 1.5,向下取整为 1)。
-
转换为索引
最终结果 块数量 - 1 将块数量转换为从 0 开始的索引。
示例:1 - 1 = 0。
sizeclass类代码
cpp
//common.hpp
class sizeclass
{
public:
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
return (bytes + alignNum - 1) & ~(alignNum - 1);
}
// bytes: 需要分配的内存字节数,需要减去上个区间的字节数,因为我们计算的映射值是区间的第几位
// alignShift: 对齐位移,表示内存块大小的基数为 2^alignShift(如 alignShift=3 时,块大小为 8B)
static inline size_t _Index(size_t bytes, size_t alignShift)
{
return ((bytes + (1 << alignShift) - 1) >> alignShift) - 1;
}
public:
// 整体控制在最多10%左右的内碎⽚浪费
// [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)
static inline size_t 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 size_t Index(size_t bytes)
{
// 每个区间有多少个自由链表
static size_t groupArray[4] = {16, 56, 56, 56};
if (bytes <= 128)
{
return _Index(bytes, 3);
}
else if (bytes <= 1024)
{
return _Index(bytes - 128, 4) + groupArray[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes - 1024, 7) + groupArray[0] + groupArray[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes - 8 * 1024, 10) + groupArray[0] + groupArray[1] + groupArray[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes - 64 * 1024, 13) + groupArray[0] + groupArray[1] + groupArray[2] + groupArray[3];
}
else
{
assert(false);
return -1;
}
}
};
threadcache类
按照上述的对齐规则,thread cache中桶的个数,也就是自由链表的个数是208,以及thread cache允许申请的最大内存大小256KB,我们可以将这些数据按照如下方式进行定义。
cpp
//common.hpp
//小于等于MAX_BYTES,就找thread cache申请
//大于MAX_BYTES,就直接找page cache或者系统堆申请
static const size_t MAX_BYTES = 256 * 1024;
//thread cache和central cache自由链表哈希桶的表大小
static const size_t NFREELISTS = 208;
现在就可以对ThreadCache类进行定义了,thread cache就是一个存储208个自由链表的数组,目前thread cache就先提供一个Allocate函数用于申请对象就行了,后面需要时再进行增加。
cpp
//ThreadCache.hpp
class ThreadCache
{
public:
//申请内存对象
void* Allocate(size_t size);
private:
FreeList _freeLists[NFREELISTS]; //哈希桶
};
在thread cache申请对象时,通过所给字节数计算出对应的哈希桶下标,如果桶中自由链表不为空,则从该自由链表中取出一个对象进行返回即可;但如果此时自由链表为空,那么我们就需要从central cache进行获取了,这里的FetchFromCentralCache函数也是thread cache类中的一个成员函数,在后面再进行具体实现。
cpp
//ThreadCache.cpp
void* ThreadCache::Allocate(size_t size)
{
assert(size<MAX_BYTES);
size_t alignSize=sizeclass::RoundUp(size);
size_t index=sizeclass::Index(size);
//哈希桶对应下标链表不为空,直接弹出,否则向下一层获取
if(!_freelist[index].Empty())
{
return _freelist[index].Pop();
}
else
{
return FetchFromCentralCache(index, alignSize);
}
};
threadcache TLS无锁访问
想要让每个线程都有属于自己的threadcache,肯定不能将其设置为全局变量,因为这样就不可避免的需要锁来控制,并且那个线程对应哪一个threadcache呢?又从什么时候创建呢?无疑增加了控制成本和代码复杂度。
我们能想到需要将它放入到其各自独有的tcb(线程控制块)中
要实现每个线程无锁的访问属于自己的thread cache,我们需要用到线程局部存储TLS(Thread Local Storage),这是一种变量的存储方法(就是定义一种特殊的类型),使用该存储方法的变量在它所在的线程是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。
TLS工作原理:
- 内存分配:
每个线程拥有独立的TLS存储区域(通常位于线程栈或堆中)。
TLS变量在首次访问时初始化(或通过显式初始化函数)。- 数据访问:
通过线程ID或TLS索引(如Windows的TlsIndex)定位线程私有数据。
编译器或运行时库自动插入代码,将TLS变量映射到线程本地存储区。- 生命周期:
TLS变量的生命周期与线程绑定(线程退出时自动释放)。
动态TLS需手动释放资源(如 TlsFree 或 pthread_key_delete)。
cpp
//ConCurrentAlloc.hpp
//TLS - Thread Local Storage
//static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;//vs环境下的TLS,Windows 特有的编译器扩展
static thread_local ThreadCache* pTLSThreadCache = nullptr; // 标准C++语法(推荐)
static void *ConcurrentAlloc(size_t size)
{
// 通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
return pTLSThreadCache->Allocate(size);
}
c
//测试代码 Linux系统下编译命令:g++ -o test test.cpp ThreadCache.cpp -std=c++11 -lpthread
//test.cpp
#include<iostream>
#include<thread>
#include"Common.hpp"
#include"ConCurrentAlloc.hpp"
using namespace std;
void run1()
{
for(int i=0;i<5;i++)
{
void* run1=ConcurrentAlloc(5);
}
}
void run2()
{
for(int i=0;i<5;i++)
{
void* run1=ConcurrentAlloc(14);
}
}
void tlsTest()
{
std::thread t1(run1);
std::thread t2(run2);
t1.join();
t2.join();
}
int main()
{
//cout<<sizeclass::RoundUp(7);
tlsTest();
return 0;
}
4.2 central cache
当线程申请某一大小的内存时,如果thread cache中对应的自由链表不为空,那么直接取出一个内存块进行返回即可,但如果此时该自由链表为空,也就是当thread cache中的哈希桶对应的某一个桶为空的时候,那么这时thread cache就需要向central cache申请内存了。
central cache的结构与thread cache是一样的,它们都是哈希桶的结构,并且它们遵循的对齐映射规则都是一样的。这样做的好处就是,当thread cache的某个桶中没有内存了,就可以直接到central cache中对应的哈希桶里去取内存就行了。`
central cache与thread cache的不同之处
-
central cache与thread cache有两个明显不同的地方,首先,thread cache是每个线程独享的,而central cache是所有线程共享的,因为每个线程的thread cache没有内存了都会去找central cache,因此在访问central cache时是需要加锁的。
central cache在加锁时用的是桶锁,central cache在加锁时并不是将整个central cache全部锁上了,而是给每个桶都加有一个锁。此时只有当多个线程同时访问central cache的同一个桶时才会存在锁竞争,如果是多个线程同时访问central cache的不同桶就不会存在锁竞争,因此锁竞争问题不会特别损耗性能。
-
central cache与thread cache的第二个不同之处就是,thread cache的每个桶中挂的是一个个切好的内存块,而central cache的每个桶中挂的是一个个的span。

每个span管理的都是一个以页为单位(通常为4KB或者8KB)的大块内存,每个桶里面的若干span是按照双链表的形式链接起来的,并且每个span里面还有一个自由链表,这个自由链表里面挂的就是一个个切好了的内存块,根据其所在的哈希桶这些内存块被切成了对应的大小。
central cache结构设计
central cache 主要有承上启下的作用,通过对数据的管理使内存使用更加精细化
对于上述页号的类型我们需要考虑不同位平台下的大小,因此我们需要编译器提供的预定义的宏来解决
页的大小一般是4K或者8K,我们以8K为例。在32位平台下,进程地址空间就可以被分成 2 ^ 19个页。页号本质与地址是一样的,在64位平台下,进程地址空间就可以被分成 2 ^ 51 个页。页号+页内偏移=地址。
由于页号在64位平台下的取值范围太大 ,因此我们不能简单的用一个无符号整型来存储页号,这时我们需要借助条件编译来解决这个问题。
cpp
//common.hpp
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// Linux/macOS等POSIX系统
#include <sys/types.h>
typedef uintptr_t PAGE_ID; // 或使用 `unsigned long`(需验证平台匹配性)
#endif
需要注意的是,在32位下,_WIN32有定义,_WIN64没有定义;而在64位下,_WIN32和_WIN64都有定义。因此在条件编译时,我们应该先判断_WIN64是否有定义,再判断_WIN32是否有定义
span结构
和_freeList一样,我们需要对span进行封装,方便使用和管理。
cpp
//管理以页为单位的大块内存
//common.hpp
struct Span
{
//若 _pageId = 5, _n = 3,表示该 Span 管理从第5页开始的连续3页内存(总大小 = 3 × 页大小)。
PAGE_ID _pageId = 0; //大块内存起始页的页号
size_t _n = 0; //页的数量
Span* _next = nullptr; //双链表结构,主要用于将内存归还给下一层
Span* _prev = nullptr;
size_t _useCount = 0; //切好的小块内存,被分配给thread cache的计数
void* _freeList = nullptr; //切好的小块内存的自由链表
};
对于span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后page cache进行前后页的合并(主要用于缓解外碎片的产出),因此span结构当中会记录所管理大块内存起始页的页号。
至于每一个span管理的到底是多少个页,这并不是固定的,需要根据多方面的因素来控制(像4b一页就够了,但是256kb就需要很多页了),因此span结构当中有一个_n成员,该成员就代表着该span管理的页的数量。这一变量主要服务于下一层的page cache。
此外,每个span管理的大块内存,都会被切成相应大小的内存块挂到当前span的自由链表中,比如8Byte哈希桶中的span,会被切成一个个8Byte大小的内存块挂到当前span的自由链表中,因此span结构中需要存储切好的小块内存的自由链表。
span结构当中的_useCount成员作用是:
线程缓存(thread cache)从 Span 的 _freeList 中获取小块内存时,_useCount++。
释放时递减:当线程缓存将内存归还到 Span 的 _freeList 时,_useCount- -。
回收条件:若 _useCount == 0,表示所有小块内存已归还,Span 可被合并或归还给操作系统。
每个桶当中的span是以双链表的形式组织起来的,当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。如果用单链表结构的话就比较麻烦了,因为单链表在删除时,需要知道当前结点的前一个结点。
定义完span后,我们要对它进行链接管理,那么使用双链表结构无疑是最好的选择,对于其实现属于是老生常谈了,所以不做过多赘述
cpp
//带头双向循环链表
//common.hpp
class SpanList
{
public:
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
void Insert(Span* pos, Span* newSpan)
{
assert(pos);
assert(newSpan);
Span* prev = pos->_prev;
prev->_next = newSpan;
newSpan->_prev = prev;
newSpan->_next = pos;
pos->_prev = newSpan;
}
void Erase(Span* pos)
{
assert(pos);
assert(pos != _head); //不能删除哨兵位的头结点
Span* prev = pos->_prev;
Span* next = pos->_next;
prev->_next = next;
next->_prev = prev;
}
private:
Span* _head;
public:
std::mutex _mtx; //桶锁
};
以此可得central cache的基础结构
cpp
class CentralCache
{
public:
private:
SpanList _spanLists[NFREE_LIST];//和thread cache的桶数相同,当thread cache的某个桶没有内存了,就可以直接去central cache对应的哈希桶进行申请就行了。
};
central cache 核心实现
在上面我们将central cache的主体部分封装好了,接下来我们就要对数据进行处理了。承接thread cache的内存获取函数,函数中回调FetchFromCentralCache(),这个函数便是central cache需要实现的核心函数。
当 Thread Cache 的内存不足时,向 Central Cache 请求批量内存块:
-
锁定对应 size class:
根据请求的内存大小,找到对应的 size class 索引,加锁保护链表操作。
-
查找可用 Span:
遍历 SpanList,寻找有足够空闲块的 Span。若不存在,向 Page Heap 申请新 Span。
-
分割 Span:
将 Span 切分为多个小块(如 8B),构建自由链表(_freeList),并更新 Span 的引用计数(_useCount)。具体实现暂不做过多赘述,知道有这个步骤都行。
-
转移块到 Thread Cache:
从 Span 的 _freeList 中批量转移若干块到 Thread Cache 的自由链表,减少后续锁竞争。
-
解锁:
释放锁,完成分配。
由于central cache在整个程序中只有一个,对于这种只有一个对象的类,我们可以将其设置为单例模式。
单例模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。单例模式又分为饿汉模式和懒汉模式,懒汉模式相对较复杂,我们这里使用饿汉模式就足够了。
cpp
//centralcache.hpp
#pragma once
#include "Common.hpp"
class CentralCache
{
public:
//提供一个全局访问点
CentralCache& GetInc()
{
return _sInc;
}
private:
SpanList _spanLists[NFREE_LIST];
private:
//构造函数私有化,拷贝构造,=运算符重载使用delete
CentralCache();
CentralCache(const CentralCache&)=delete;
CentralCache operator= (const CentralCache&)=delete;
static CentralCache _sInc;
};
//centralcache.cpp
#include"CenctralCache.hpp"
//声明
CentralCache CentralCache::_sInc;
最后central cache还需要提供一个公有的成员函数GetInc(),用于获取该对象,此时在整个进程中就只会有一个central cache对象了。
慢开始反馈调节算法
类似⽹络tcp协议拥塞控制的慢开始算法,但博主还没学网络所以不是很了解这个算法,只能从当前事例中来介绍
当thread cache向central cache申请内存时,central cache应该给出多少个对象呢?这是一个值得思考的问题,如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;但如果一次性给的太多了,可能thread cache用不完也就浪费了。
鉴于此,我们这里采用了一个慢开始反馈调节算法。当thread cache向central cache申请内存时,如果申请的是较小的对象,那么可以多给一点,但如果申请的是较大的对象,就可以少给一点。
通过下面这个函数,我们就可以根据所需申请的对象的大小计算出具体给出的对象个数,并且可以将给出的对象个数控制到2~512个之间。也就是说,就算thread cache要申请的对象再小,我最多一次性给出512个对象;就算thread cache要申请的对象再大,我至少一次性给出2个对象。
cpp
//common.hpp
//管理对齐和映射等关系
class SizeClass
{
public:
//thread cache一次从central cache获取对象的上限
static size_t NumMoveSize(size_t size)
{
assert(size > 0);
//对象越小,计算出的上限越高
//对象越大,计算出的上限越低
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
};
即使申请的是小对象,一次性给出512个也是比较多的,我们也需要控制一下获取的对象数目让其保持在一个高效益的值,基于这个原因,我们可以在FreeList结构中增加一个叫做_maxSize的成员变量,该变量的初始值设置为1,并且提供一个公有成员函数用于获取这个变量。也就是说,现在thread cache中的每个自由链表都会有一个自己的_maxSize。
后面在FetchFromCentralCache()函数里面只需要对比_maxSize和NumMoveSize返回值那个最小即可
cpp
//threadcache.hpp
//管理切分好的小对象的自由链表
class FreeList
{
public:
size_t& MaxSize()
{
return _maxSize;
}
private:
void* _freeList = nullptr; //自由链表
size_t _maxSize = 1;//控制获取内存块的数目
};
中心缓存返回对象
我们根据反馈调节算法可以知道,中心缓存可能会返回多个对象,而我们只需要一个对象给用户即可,剩下的便可以链入thread cache中对应字节数的自由链表中
插入一段范围的对象到自由链表
cpp
//管理切分好的小对象的自由链表
class FreeList
{
public:
//插入一段范围的对象到自由链表
void PushRange(void* start, void* end)
{
assert(start);
assert(end);
//头插
*(void**)end=_freeList;
_freeList=start;
}
private:
void* _freeList = nullptr; //自由链表
size_t _maxSize = 1;
};
每次thread cache向central cache申请对象时,我们先通过慢开始反馈调节算法计算出本次应该申请的对象的个数,然后再向central cache进行申请。
如果thread cache最终申请到对象的个数就是一个,那么直接将该对象返回即可。,如果申请到了多个对象,也只需要返回一个对象即可,后面剩余的链入对应的_freeList[N]中即可 。
为什么需要返回一个申请到的对象呢?因为thread cache要向central cache申请对象,其实由于某个线程向thread cache申请对象但thread cache当中没有,这才导致thread cache要向central cache申请对象。因此central cache将对象返回给thread cache后,thread cache会再将该对象返回给申请对象的线程。
cpp
//threadcache.cpp
void* ThreadCache::FetchFromCentralCache(size_t index,size_t size)
{
//慢开始反馈调节算法
//一种慢增长模式,使获得的内存块数目从1开始缓慢增长直到大于NumMoveSize所获得的值
//1.最开始不会一次向central cache要太多,因为可能用不完
//2.如果这个大小需求不够,那么会增长直到上限
//3,size越大,向central cache要的就越小
//4,size越小,向central cache要的就越大,但是都是从1开始给
size_t batchNum=std::min(_freelist[index].MaxSize(),sizeclass::NumMoveSize(size));
if(batchNum == _freelist[index].MaxSize())
{
_freelist[index].MaxSize()++;//可以自动调节想要的增长速度
}
//利用两个输出型指针获取从中心缓存获取到的一段指针链
void* start=nullptr;
void* end=nullptr;
//得到真实给的内存块数目
size_t actalNum=CentralCache::GetInc()->FectRangObj(start,end,batchNum,size);//核心处理函数
assert(actalNum>0);
//只有一个内存块直接返回,多个的话插入链表
if(actalNum == 1)
{
assert(start == end);
return start;
}
else
{
_freelist[index].PushRange(*(void**)start,end);
return start;
}
}
中心缓存获取一定数量的对象
这里我们要从central cache获取n个指定大小的对象,这些对象肯定都是从central cache对应哈希桶的某个span中取出来的,因此取出来的这n个对象是链接在一起的,我们只需要得到这段链表的头和尾即可,所以采用输出型参数进行获取。
cpp
//从central cache获取一定数量的对象给thread cache
size_t CentralCache::FectRangObj(void*& start,void*& end,size_t batchNum,size_t size)
{
size_t index=sizeclass::Index(size);
_spanLists[index]._mutex.lock();
Span* span=GetOneSpan(_spanLists[index],size);
assert(span);
assert(span->_freeList);
size_t actalNum=1;
start=span->_freeList;
end=start;
//从span中获取n个对象
//如果不够n个,有多少拿多少
//由于从start本身就是一个内存块,因此我们只需要获取n-1个内存块并返回start即可
while(batchNum-1 && (*(void**)end!=nullptr))
{
end=*(void**)end;
actalNum++;
batchNum--;
}
span->_freeList=*(void**)end;//取完后剩下的对象继续放到自由链表
*(void**)end=nullptr; //取出的一段链表的表尾置空
span->_useCount += actualNum; //更新被分配给thread cache的计数
_spanLists[index]._mutex.unlock();
return actalNum;
}
由于central cache是所有线程共享的,所以我们在访问central cache中的哈希桶时,需要先给对应的哈希桶加上桶锁,在获取到对象后再将桶锁解掉。
在向central cache获取对象时,先是在central cache对应的哈希桶中获取到一个非空的span,然后从这个span的自由链表中取出n个对象即可,但可能这个非空的span的自由链表当中对象的个数不足n个,这时该自由链表当中有多少个对象就给多少就行了。
也就是说,thread cache实际从central cache获得的对象的个数可能与我们传入的n值是不一样的,因此我们需要统计本次申请过程中,实际thread cache获取到的对象个数,然后根据该值及时更新这个span中的小对象被分配给thread cache的计数。
需要注意的是,虽然我们实际申请到对象的个数可能比n要小,但这并不会产生任何影响。因为thread cache的本意就是向central cache申请一个对象,我们之所以要一次多申请一些对象,是因为这样一来下次线程再申请相同大小的对象时就可以直接在thread cache里面获取了,而不用再向central cache申请对象。
4.3 page cache
page cache整体设计
thread cache向central cache获取内存。而central cache向page cache获取内存。
page cache结构
page cache的数据结构和central cache一样都是由哈希桶挂起的前面定义的span双向链表,不过page cache 采用哈希桶的策略是直接定址法。每个哈希桶下标和对应的span里面的页的数目相同,也就是一号桶挂的便是只有一页的span,二号桶挂的是2页的span,以此类推。
并且这个span便是一个整体,不需要进行分割,page cache主要服务对象是central cache,因此分割span是central cache需要操心的活,而page cache只需要关心给central多少页的span即可。
而page cache下的哈希桶的数目主要看实际业务需求。这里我们给128就够用了。我们线程设置的申请的最大内存是256KB,每个页8KB,因此含最大页的span能被切出4个256KB完全够用了。
而让桶号和含多少页数的span对应起来,让0号桶空出来即可,因此设置129作为哈希桶总下标。
cpp
//common.hpp
//page cache中哈希桶的个数
static const size_t NPAGES = 129;
page cache的上层便是堆空间了,一开始的page cache是空的,page cache从堆空间获取span的规则是什么呢?
- 当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。
- 如果找到_spanList[128]都没有合适的span,则向系统使⽤mmap、brk或者是VirtualAlloc等⽅式申请128⻚page span挂在⾃由链表中,再重复1中的过程。
- 需要注意的是central cache和page cache的核⼼结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache⼀样的⼤⼩对⻬关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成⼩块内存的⾃由链表。⽽page cache中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i⻚内存。
page cache的实现方式
当每个线程的thread cache没有内存时都会向central cache申请,此时多个线程的thread cache如果访问的不是central cache的同一个桶,那么这些线程是可以同时进行访问的。这时central cache的多个桶就可能同时向page cache申请内存的,所以page cache也是存在线程安全问题的,因此在访问page cache时也必须要加锁。
但是在page cache这里我们不能使用桶锁,因为当central cache向page cache申请内存时,page cache可能会将其他桶当中大页的span切小后再给central cache。此外,当central cache将某个span归还给page cache时,page cache也会尝试将该span与其他桶当中的span进行合并。
也就是说,在访问page cache时,我们可能需要访问page cache中的多个桶,如果page cache用桶锁就会出现大量频繁的加锁和解锁,导致程序的效率低下。因此我们在访问page cache时使用没有使用桶锁,而是用一个大锁将整个page cache给锁住。
此外,page cache在整个进程中也是只能存在一个的,因此我们也需要将其设置为单例模式。
cpp
//pagecache.hpp
//单例模式
class PageCache
{
public:
//提供一个全局访问点
static PageCache* GetInc()
{
return &_sInc;
}
private:
SpanList _spanLists[NPAGES];
std::mutex _pageMtx; //大锁
private:
PageCache() //构造函数私有
{}
PageCache(const PageCache&) = delete; //防拷贝
static PageCache _sInc;
};
程序运行起来后程序就会立马创建该对象。
cpp
//pagecache.cpp
PageCache PageCache::_sInc;
|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 如果central cache中的span 的usecount等于0,说明切分给thread cache的内存都还回来了,则central cache将这个span归还给page cache,page cache通过span中记录页号的变量,查看相邻页是否空闲,,是的话就合并,合并出更大的页,解决内碎片问题 |
从page cache中获取span
在上面的central cache层我们还没有对span的获取进行操作,接下来便完善GetOneSpan()
thread cache向central cache申请对象时,central cache需要先从对应的哈希桶中获取到一个非空的span,然后从这个非空的span中取出若干对象返回给thread cache。那central cache到底是如何从对应的哈希桶中,获取到一个非空的span的呢?
首先当然是先遍历central cache对应哈希桶当中的双链表,如果该双链表中有非空的span,那么直接将该span进行返回即可。为了方便遍历这个双链表,我们可以模拟迭代器的方式,给SpanList类提供Begin和End成员函数,分别用于获取双链表中的第一个span和最后一个span的下一个位置,也就是头结点。
cpp
//common.hpp
//管理以页为单位的大块内存
/*
struct Span
{
PAGE_ID _pageId = 0; //大块内存起始页的页号
size_t _n = 0; //页的数量
Span* _next = nullptr; //双链表结构
Span* _prev = nullptr;
size_t _useCount = 0; //切好的小块内存,被分配给thread cache的计数
void* _freeList = nullptr; //切好的小块内存的自由链表
};
*/
//带头双向循环链表
class SpanList
{
public:
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;
}
private:
Span* _head;
public:
std::mutex _mtx; //桶锁
};
central cache便可以先查看自己当前spanlist中是否有非空的span,有的话找到sapn中不为空的freelist返回即可
cpp
//centralcache.cpp
Span* CentralCache::GetOneSpan(SpanList& list,size_t types_size)
{
//如果centralcache有span
Span* it=list.Begain();
Span* end=list.End();
while(it!=end)
{
if(it->_freeList!=nullptr)
{
return it;
}
else
{
it=it->_next;
}
}
//走到这里说们没有span,找下一层pagecache要。。。。。。
}
那么我们pagecache该给centralcache多少页呢??根据centralcache给threadcache多少块内存的函数我们可以联想出类似的规则去设计一个函数
cpp
//common.hpp
//管理对齐和映射等关系
class SizeClass
{
public:
//central cache一次向page cache获取多少页
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size); //计算出thread cache一次向central cache申请对象的个数上限
size_t nPage = num*size; //num个size大小的对象所需的字节数
nPage >>= PAGE_SHIFT; //将字节数转换为页数 = nPage/8kb
if (nPage == 0) //至少给一页
nPage = 1;
return nPage;
}
};
代码中的PAGE_SHIFT代表页大小转换偏移,我们这里以页的大小为8K为例,PAGE_SHIFT的值就是13。
//页大小转换偏移,即一页定义为2^13,也就是8KB
static const size_t PAGE_SHIFT = 13;
需要注意的是,当central cache申请到若干页的span后,还需要将这个span切成一个个对应大小的对象挂到该span的自由链表当中。
如何找到pag ecache下一个span所管理的内存块呢?首先需要计算出该span的起始地址,我们可以用这个span的起始页号乘以一页的大小即可得到这个span的起始地址,然后用这个span的页数乘以一页的大小就可以得到这个span所管理的内存块的大小,用起始地址加上内存块的大小即可得到这块内存块的结束位置。
span起始地址算法逻辑:
span->_pageId 是 Span 起始页的逻辑页号(基于进程虚拟地址空间)。
左移 PAGE_SHIFT 位(如 PAGE_SHIFT=13 对应 8KB 页)得到原始虚拟地址。
示例:
若 span->_pageId = 5,PAGE_SHIFT=13,则计算出的地址为 5 << 13 = 40960 字节(即虚拟地址 0xA000)。
明确了这块内存的起始和结束位置后,我们就可以进行切分了。根据所需对象的大小,每次从大块内存切出一块固定大小的内存块尾插到span的自由链表中即可。
为什么是尾插呢?因为我们如果是将切好的对象尾插到自由链表,这些对象看起来是按照链式结构链接起来的,而实际它们在物理上是连续的,这时当我们把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率。
cpp
//centralcache.cpp
//获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& spanList, size_t size)
{
//1、先在spanList中寻找非空的span
Span* it = spanList.Begin();
while (it != spanList.End())
{
if (it->_freeList != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
//2、spanList中没有非空的span,只能向page cache申请
Span* span = PageCache::GetInc()->NewSpan(SizeClass::NumMovePage(size));
//计算span的大块内存的起始地址和大块内存的大小(字节数)
//注意:这里算出的start是虚拟地址空间堆上的地址,下面操作等同于页号*8kb
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
//把大块内存切成size大小的对象链接起来
char* end = start + bytes;
//先切一块下来去做尾,方便尾插
span->_freeList = start;
start += size;
void* tail = span->_freeList;
//尾插
while (start < end)
{
*(void**)(tail) = start;
tail = *(void**)(tail);
start += size;
}
*(void**)(tail) = nullptr; //尾的指向置空
//将切好的span头插到spanList
spanList.PushFront(span);
return span;
}
需要注意的是,当我们把span切好后,需要将这个切好的span挂到central cache的对应哈希桶中。因此SpanList类还需要提供一个接口,用于将一个span插入到该双链表中。这里我们选择的是头插,这样当central cache下一次从该双链表中获取非空span时,一来就能找到。
由于SpanList类之前实现了Insert和Begin函数,这里实现双链表头插就非常简单,直接在双链表的Begin位置进行Insert即可。
cpp
//带头双向循环链表
class SpanList
{
public:
bool Empty()
{
return _head == _head->_next;
}
//将从pagecache中获取的span头插到对应的spanlist中
void PushFront(Span* span)
{
Insert(Begin(), span);
}
private:
Span* _head;
public:
std::mutex _mutex; //桶锁
};
获取一个k页的span
当我们调用上述的GetOneSpan从central cache的某个哈希桶获取一个非空的span时,如果遍历哈希桶中的双链表后发现双链表中没有span,或该双链表中的span都为空,那么此时central cache就需要向page cache申请若干页的span了,下面我们就来说说如何从page cache获取一个k页的span。
因为page cache是直接按照页数进行映射的,因此我们要从page cache获取一个k页的span,就应该直接先去找page cache的第k号桶,如果第k号桶中有span,那我们直接头删一个span返回给central cache就行了。所以我们这里需要再给SpanList类添加对应的Empty和PopFront函数。
如果page cache的第k号桶中没有span,我们就应该继续找后面的桶,只要后面任意一个桶中有一个n页span,我们就可以将其切分成一个k页的span和一个n-k页的span,然后将切出来k页的span返回给central cache,再将n-k页的span挂到page cache的第n-k号桶即可。
但如果后面的桶中也都没有span,此时我们就需要向堆申请一个128页的span了,在向堆申请内存时,直接调用我们封装的SystemAlloc函数即可。
需要注意的是,向堆申请内存后得到的是这块内存的起始地址,此时我们需要将该地址转换为页号。由于我们向堆申请内存时都是按页进行申请的,因此我们直接将该地址除以一页的大小即可得到对应的页号。
cpp
//common.hpp
#ifdef __WIN32
#include<windows.h>
#else
#include <sys/mman.h> // mmap 函数和相关标志(如 MAP_ANONYMOUS、PROT_READ 等)
#endif
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << PAGE_SHIFT, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
void* ptr = mmap(
nullptr, // 由内核选择分配地址
kpage * (1 << PAGE_SHIFT), // 分配大小 = kpage * 页大小(如 8KB)
PROT_READ | PROT_WRITE, // 内存可读可写
MAP_PRIVATE | MAP_ANONYMOUS, // 私有匿名映射(不关联文件)
-1, // 文件描述符(匿名映射设为-1)
0 // 偏移量(无需偏移)
);
if (ptr == MAP_FAILED) { // mmap 失败返回 MAP_FAILED
ptr = nullptr; // 统一设置为 nullptr
}
#endif
if (ptr == nullptr) {
throw std::bad_alloc(); // 统一抛出异常
}
return ptr;
}
//pagecahce.cpp
Span* PageCache::NewSpan(size_t N)
{
//先检查第k个桶里面有没有span
if(!_spanLists[N].Empty())
{
return _spanLists[N].PopFront();
}
//检查一下后面的桶里面有没有span,如果有可以将其进行切分
for(int i=N+1;i<NPAGES;i++)
{
if(!_spanLists[i].Empty())
{
Span* nspan=_spanLists[i].PopFront();
Span* kspan=new Span;
//在nSpan的头部切k页下来
kspan->_pageId=nspan->_pageId;
kspan->_n=N;
//将剩下的挂到对应映射的位置
nspan->_pageId+=N;
nspan->_n-=N;
_spanLists[nspan->_n].PushFront(nspan);
return kspan;
}
}
//走到这里说明还没分配内存
//找内存要128页
Span* bigspan=new Span;
void* ptr=SystemAlloc(NPAGES-1);
bigspan->_pageId=(PAGE_ID)ptr>>PAGE_SHIFT;
bigspan->_n=NPAGES-1;
_spanLists[bigspan->_n].PushFront(bigspan);
//复用
return NewSpan(N);
}
这里说明一下,当我们向堆申请到128页的span后,需要将其切分成k页的span和128-k页的span,但是为了尽量避免出现重复的代码,我们最好不要再编写对应的切分代码。我们可以先将申请到的128页的span挂到page cache对应的哈希桶中,然后再递归调用该函数就行了,此时在往后找span时就一定会在第128号桶中找到该span,然后进行切分。
-
这里其实有一个问题:当central cache向page cache申请内存时,central cache对应的哈希桶是处于加锁的状态的,那在访问page cache之前我们应不应该把central cache对应的桶锁解掉呢?
-
这里建议在访问page cache前,先把central cache对应的桶锁解掉。虽然此时central cache的这个桶当中是没有内存供其他thread cache申请的,但thread cache除了申请内存还会释放内存,如果在访问page cache前将central cache对应的桶锁解掉,那么此时当其他thread cache想要归还内存到central cache的这个桶时就不会被阻塞。
-
因此在调用NewSpan函数之前,我们需要先将central cache对应的桶锁解掉,然后再将page cache的大锁加上,当申请到k页的span后,我们需要将page cache的大锁解掉,但此时我们不需要立刻获取到central cache中对应的桶锁。因为central cache拿到k页的span后还会对其进行切分操作,因此我们可以在span切好后需要将其挂到central cache对应的桶上时,再获取对应的桶锁。
这里为了让代码清晰一点,只写出了加锁和解锁的逻辑,我们只需要将这些逻辑添加到之前实现的GetOneSpan函数的对应位置即可。
cpp
spanList._mutex.unlock(); //解桶锁
PageCache::GetInstance()->_pageMtx.lock(); //加大锁
//从page cache申请k页的span
PageCache::GetInstance()->_pageMtx.unlock(); //解大锁
//进行span的切分...
spanList._mutex.lock(); //加桶锁
//将span挂到central cache对应的哈希桶
扩展:span中页号和虚拟内存的转换
虚拟内存分页:
操作系统将虚拟内存划分为固定大小的页(例如 4KB 或 8KB),每个页通过 页号(Page Number) 唯一标识。
虚拟地址(指针):
由 页号 + 页内偏移(Offset) 组成,例如在 8KB 页系统中,虚拟地址的高位表示页号,低位表示页内偏移。
2. 转换公式
(1) 指针 → 页号
页号=虚拟地址/页大小=虚拟地址≫PAGE_SHIFT
PAGE_SHIFT:页大小的位偏移量,满足 页大小=2^PAGE_SHIFT
例如:页大小 8KB → PAGE_SHIFT=13
(2) 页号 → 指针(基地址)基地址=页号×页大小=页号≪PAGE_SHIFT
基地址=页号×页大小=页号≪PAGE_SHIFT
基地址:该页的起始虚拟地址(页内偏移为 0)。
|------------------------------------------------------------|
| 当我们使用系统调用获取到一块内存地址的时候,我们就可以对其进行右移获得页号,后面我们只需要操作页号便可以获得一个地址 |
4.4 联调申请内存
由于博主是Linux系统写的代码不方便调试且不够直观,这里借用博主:2021dragon高并发内存池中联调的过程,
申请内存过程联调测试
由于在多线程场景下调试观察起来非常麻烦,这里就先不考虑多线程场景,看看在单线程场景下代码的执行逻辑是否符合我们的预期,其次,我们这里就只简单观察在一个桶当中的内存申请就行了。
下面该线程进行了三次内存申请,这三次内存申请的字节数最终都对齐到了8,此时当线程申请内存时就只会访问到thread cache的第0号桶。
cpp
void* p1 = ConcurrentAlloc(6);
void* p2 = ConcurrentAlloc(8);
void* p3 = ConcurrentAlloc(1);
当线程第一次申请内存时,该线程需要通过TLS获取到自己专属的thread cache对象,然后通过这个thread cache对象进行内存申请。

在申请内存时通过计算索引到了thread cache的第0号桶,但此时thread cache的第0号桶中是没有对象的,因此thread cache需要向central cache申请内存块。

在向central cache申请内存块前,首先通过NumMoveSize函数计算得出,thread cache一次最多可向central cache申请8字节大小对象的个数是512,但由于我们采用的是慢开始算法,因此还需要将上限值与对应自由链表的_maxSize的值进行比较,而此时对应自由链表_maxSize的值是1,所以最终得出本次thread cache向central cache申请8字节对象的个数是1个。
并且在此之后会将该自由链表中_maxSize的值进行自增,下一次thread cache再向central cache申请8字节对象时最终申请对象的个数就会是2个了。

在thread cache向central cache申请对象之前,需要先将central cache的0号桶的锁加上,然后再从该桶获取一个非空的span。
在central cache的第0号桶获取非空span时,先遍历对应的span双链表,看看有没有非空的span,但此时肯定是没有的,因此在这个过程中我们无法找到一个非空的span。
在向page cache申请内存时,由于central cache一次给thread cache8字节对象的上限是512,对应就需要4096字节,所需字节数不足一页就按一页算,所以这里central cache就需要向page cache申请一页的内存块。
但此时page cache的第1个桶以及之后的桶当中都是没有span的,因此page cache需要直接向堆申请一个128页的span。
这里通过监视窗口可以看到,用于管理申请到的128页内存的span信息

我们可以顺便验证一下,按页向堆申请的内存块的起始地址和页号之间是可以相互转换的。
现在将申请到的128页的span插入到page cache的第128号桶当中,然后再调用一次NewSpan,在这次调用的时候,虽然在1号桶当中没有span,但是在往后找的过程中就一定会在第128号桶找到一个span。
此时我们就可以把这个128页的span拿出来,切分成1页的span和127页的span,将1页的span返回给central cache,而把127页的span挂到page cache的第127号桶即可。

从page cache返回后,就可以把page cache的大锁解掉了,但紧接着还要将获取到的1页的span进行切分,因此这里没有立刻重新加上central cache对应的桶锁。
在确定内存块的开始和结束后,就可以将其切分成一个个8字节大小的对象挂到该span的自由链表中了。在调试过程中通过内存监视窗口可以看到,切分出来的每个8字节大小的对象的前四个字节存储的都是下一个8字节对象的起始地址。
当切分结束后再获取central cache第0号桶的桶锁,然后将这个切好的span插入到central cache的第0号桶中,最后再将这个非空的span返回,此时就获取到了一个非空的span。
由于thread cache只向central cache申请了一个对象,因此拿到这个非空的span后,直接从这个span里面取出一个对象即可,此时该span的_useCount也由0变成了1。

由于此时thread cache实际只向central cache申请到了一个对象,因此直接将这个对象返回给线程即可。

当线程第二次申请内存块时就不会再创建thread cache了,因为第一次申请时就已经创建好了,此时该线程直接获取到对应的thread cache进行内存块申请即可。

当该线程第二次申请8字节大小的对象时,此时thread cache的0号桶中还是没有对象的,因为第一次thread cache只向central cache申请了一个8字节对象,因此这次申请时还需要再向central cache申请对象

这时thread cache向central cache申请对象时,thread cache第0号桶中自由链表的_maxSize已经慢增长到2了,所以这次在向central cache申请对象时就会申请2个。如果下一次thread cache再向central cache申请8字节大小的对象,那么central cache会一次性给thread cache3个,这就是所谓的慢增长。
但由于第一次central cache向page cache申请了一页的内存块,并将其切成了1024个8字节大小的对象,因此这次thread cache向central cache申请2两个8字节的对象时,central cache的第0号桶当中是有对象的,直接返回两个给thread cache即可,而不用再向page cache申请内存了。
但线程实际申请的只是一个8字节对象,因此thread cache除了将一个对象返回之外,还需要将剩下的一个对象挂到thread cache的第0号桶当中。

这样一来,当线程第三次申请1字节的内存时,由于1字节对齐后也是8字节,此时thread cache也就不需要再向central cache申请内存块了,直接将第0号桶当中之前剩下的一个8字节对象返回即可。
linux系统下的小伙伴在运行的时候可能会遇到Segmentation fault,需要把PAGE_SHIFT = 13改成PAGE_SHIFT = 12
4.5 回收内存
thread cache回收内存
当某个线程申请的对象不用了,可以将其释放给thread cache,然后thread cache将该对象插入到对应哈希桶的自由链表当中即可。
但是随着线程不断的释放,对应自由链表的长度也会越来越长,这些内存堆积在一个thread cache中就是一种浪费,我们应该将这些内存还给central cache,这样一来,这些内存对其他线程来说也是可申请的,因此当thread cache某个桶当中的自由链表太长时我们可以进行一些处理。
如果thread cache某个桶当中自由链表的长度超过(一般最多是等于)它一次批量向central cache申请的对象个数时,那么此时我们就要把该自由链表当中的这些对象还给central cache。
cpp
void ThreadCache::Deallocate(void* ptr,size_t size)
{
assert(ptr);
assert(size<MAX_BYTES);
//找到对应的自由链表插入换回来的内存
size_t index=sizeclass::Index(size);
_freelist[index].Push(ptr);
//当自由链表长度大于一次批量申请的对象个数时就开始还一段list给central cache
if(_freelist[index].size()>=_freelist[index].MaxSize())
{
ListToLong(_freelist[index],size);
}
}
自由链表的长度大于一次批量申请的对象时,我们具体的做法就是,从该自由链表中取出一次批量个数的对象,然后将取出的这些对象还给central cache中对应的span即可。
cpp
void ThreadCache::ListToLong(FreeList& list,size_t size)
{
void* start;
void* end;
list.PopRange(start,end,list.MaxSize());
CentralCache::GetInc()->ReleaseListToSpans(start, size);
}
从上述代码可以看出,FreeList类需要支持用Size函数获取自由链表中对象的个数,还需要支持用PopRange函数从自由链表中取出指定个数的对象。因此我们需要给FreeList类增加一个对应的PopRange函数,然后再增加一个_size成员变量,该成员变量用于记录当前自由链表中对象的个数,当我们向自由链表插入或删除对象时,都应该更新_size的值。
cpp
// 管理切分好的小对象的自由链表
class FreeList
{
public:
// 将释放的对象头插到自由链表
void Push(void *obj)
{
assert(obj);
// 头插
*(void **)obj = _freeList;
_freeList = obj;
_size++;
}
// 从自由链表头部获取一个对象
void *Pop()
{
assert(_freeList);
// 头删
void *obj = _freeList;
_freeList = *(void **)obj;
_size--;
return obj;
}
//插入一段范围的对象到自由链表
void PushRange(void* start,void* end,size_t n)
{
*(void**)end=_freeList;
_freeList=start;
_size+=n;
}
//从自由链表获取一段范围的对象,其实这里的参数end和size可以在函数内直接定义,不过为了和上面获取一段相对应
void PopRange(void*& start,void*& end,size_t size)
{
assert(size<=_size);
start=_freeList;
end = start;
while(size--)
{
end=*(void**)start;
}
_freeList = *(void**)(end); //自由链表指向end的下一个对象
*(void**)(end) = nullptr; //取出的一段链表的表尾置空
_size -= size;
}
size_t size()
{
return _size;
}
bool Empty()
{
return _freeList==nullptr;
}
size_t& MaxSize()
{
return _maxsize;
}
private:
void *_freeList = nullptr; // 自由链表
size_t _maxsize=1;
size_t _size=0;
};
而在一开始我们从centralcache中因为自由链表中没有内存而获取的一段内存链表不知道各位看到这里是否还记得,对于这一段链表我们的处理方式是将第一个内存块返回,后面的通过FreeList类当中的PushRange成员函数插入到对应的哈希桶中,但是当时我们没有记录插入多少个,此时由于需要判断当前链表是否需要返回给centralcache内存就有必要将插入的数目记录下了
cpp
void* start=nullptr;
void* end=nullptr;
size_t actalNum=CentralCache::GetInc()->FectRangObj(start,end,batchNum,size);
assert(actalNum>0);
//只有一个内存块直接返回,多个的话插入链表
if(actalNum == 1)
{
assert(start == end);
return start;
}
else
{
_freelist[index].PushRange(*(void**)start,end,actalNum-1);//需要记录下插入多少个
return start;
}
我们这里的设计仅仅考虑到当前桶的自由链表全部还给centralcache,但这里在设计PopRange接口时还是设计的是取出指定个数的对象,因为在某些情况下当自由链表过长时,我们可能并不一定想把链表中全部的对象都取出来还给central cache,这样设计就是为了增加代码的可修改性。并且threadcache中_freelist到后面实际给出去的内存块一定是大 于要回收的内存块数量list.MaxSize()的
central cache内存回收
thread cache还回来的是一块链地址,我们如何根据地址来决定还回到那个span中去呢?ThreadCache的自由链表中可能混合存储了来自不同Span的内存块。当一次性归还多个块时,根据归还的内存大小我们只能知道它属于那个哈希桶,却不知道它属于那个块,若未按Span分类处理,Central Cache无法准确追踪各个Span的块归属。会导致Central Cache的Span管理混乱。
那么如何根据对象的地址得到对象所在的页号?
根据计算机中的除法规则我们可以轻易得出一个地址的归属页
假设1:页的大小是100,那么地址0~ 99都属于第0页,它们除以100都等于0,而地址100~199都属于第1页,它们除以100都等于1。
假设2:一个页页号是x那么乘以一个页的大小8KB,一个页页号是x+1同样乘以8KB,那么在这两组数值之间的地址除上8KB就能得到页号x。
测试代码
cpp
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// Linux/macOS等POSIX系统
#include <sys/types.h>
typedef uintptr_t PAGE_ID; // 或使用 `unsigned long`(需验证平台匹配性)
#endif
#ifdef _WIN64
static const size_t PAGE_SHIFT = 13;
#else
static const size_t PAGE_SHIFT = 12;
#endif
void test()
{
PAGE_ID id1 = 2000;
PAGE_ID id2 = 2001;
char* p1 = (char*)(id1 << PAGE_SHIFT);
char* p2 = (char*)(id2 << PAGE_SHIFT);
while (p1 < p2)
{
std::cout << (void*)p1 << ":" << ((PAGE_ID)p1 >> PAGE_SHIFT) << std::endl;
p1 += 8;
}
std::cout << "2000页的地址" << (void*)(id1 << PAGE_SHIFT);
}
int main()
{
//cout<<sizeclass::RoundUp(7);
//testcurrentAlloc();
test();
return 0;
}
运行结果:
根据返回来的地址我们能找到它属于那个页,但一个span可能含有多个页(例如第一次申请8KB的内存,pagecache就会返回一个含32页的span给centralcache),我们该如何确定内存块属于那个span呢?如果一个一个遍历,时间复杂度将会是O(n^2)
为了解决这个问题,我们可以建立页号和span之间的映射。由于这个映射关系在page cache进行span的合并时也需要用到,因此我们直接将其存放到page cache里面。这时我们就需要在PageCache类当中添加一个映射关系了,这里可以用C++当中的unordered_map进行实现,并且添加一个函数接口,用于让central cache获取这里的映射关系。(下面代码中只展示了PageCache类当中新增的成员)
此时我们就可以通过对象的地址找到该对象对应的span了,直接将该对象的地址除以页的大小得到页号,然后在unordered_map当中找到其对应的span即可。
cpp
// pagecache.hpp
// 单例模式
class PageCache
{
public:
Span *MapObjectToSpan(void *obj);
private:
std::unordered_map<PAGE_ID, Span *> _idSpanMap;
};
//pagecache.cpp
Span* PageCache::NewSpan(size_t N)
{
assert(N > 0 && N < NPAGES);
if(!_spanLists[N].Empty())
{
Span* kSpan = _spanLists[N].PopFront();
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; i++)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
//走到这里说明pagecahce中对应下标的哈希桶中没有span,需要往下遍历哈希桶
for(int i=N+1;i<NPAGES;i++)
{
if(!_spanLists[i].Empty())
{
Span* nspan=_spanLists[i].PopFront();
Span* kspan=new Span;
kspan->_pageId=nspan->_pageId;
kspan->_n=N;
nspan->_pageId+=N;
nspan->_n-=N;
_spanLists[nspan->_n].PushFront(nspan);
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kspan->_n; i++)
{
_idSpanMap[kspan->_pageId + i] = kspan;
}
return kspan;
}
}
//走到这里说明还没分配内存
//找内存要128页
Span* bigspan=new Span;
void* ptr=SystemAlloc(NPAGES-1);
bigspan->_pageId=(PAGE_ID)ptr>>PAGE_SHIFT;
bigspan->_n=NPAGES-1;
_spanLists[bigspan->_n].PushFront(bigspan);
//复用
return NewSpan(N);
}
//根据地址找到对应的span
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; //页号
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
//map中一定映射了span和页号的关系
else
{
assert(false);
return nullptr;
}
}
这时当thread cache还对象给central cache时,就可以依次遍历这些对象,将这些对象插入到其对应span的自由链表当中,并且及时更新该span的_useCount计数即可。
在thread cache还对象给central cache的过程中,如果central cache中某个span的_useCount减到0时,说明这个span分配出去的对象全部都还回来了,那么此时就可以将这个span再进一步还给page cache。
cpp
//centralcache.cpp
void CentralCache::ReleaseListToSpans(void *start, size_t size)
{
size_t index = sizeclass::Index(size);
_spanLists[index]._mutex.lock();
while (start)
{
void *next = *(void **)start;
Span *span = PageCache::GetInc()->MapObjectToSpan(start);
*(void **)start = span->_freeList;
span->_freeList = start;
span->_useCount--; // 更新被分配给thread cache的计数
if (span->_useCount == 0) // 说明这个span分配出去的对象全部都回来了
{
// 此时这个span就可以再回收给page cache,page cache可以再尝试去做前后页的合并
_spanLists[index].Erase(span);
span->_freeList = nullptr; // 自由链表置空,这三步可以直接放入Erase函数中去
span->_next = nullptr;
span->_prev = nullptr;
// 释放span给page cache时,使用page cache的锁就可以了,这时把桶锁解掉
_spanLists[index]._mutex.unlock(); // 解桶锁
PageCache::GetInc()->_pageMtx.lock(); // 加大锁
PageCache::GetInc()->ReleaseSpanToPageCache(span);
PageCache::GetInc()->_pageMtx.unlock(); // 解大锁
_spanLists[index]._mutex.lock(); // 加桶锁
}
start = next;
_spanLists[index]._mutex.unlock();
}
}
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 需要注意,如果要把某个span还给page cache,我们需要先将这个span从central cache对应的双链表中移除,然后再将该span的自由链表置空,因为page cache中的span是不需要切分成一个个的小对象的,以及该span的前后指针也都应该置空,因为之后要将其插入到page cache对应的双链表中。但span当中记录的起始页号以及它管理的页数是不能清除的,否则对应内存块就找不到了。 |
并且在central cache还span给page cache时也存在锁的问题,此时需要先将central cache中对应的桶锁解掉,然后再加上page cache的大锁之后才能进入page cache进行相关操作,当处理完毕回到central cache时,除了将page cache的大锁解掉,还需要立刻获得central cache对应的桶锁,然后将还未还完对象继续还给central cache中对应的span
pagecache内存回收
当span还回来的时候,我们好像只需要根据span中对应的参数size_t _n找到对应的桶插入即可?但是如果_n都是小值,当我们想要申请一个大块内存,需要一个含很多页的span时候,这时候就导致可能需要重新向堆申请内存,大量含少页的span没被使用,从而造成了外部内存碎片问题,因此我们需要尝试合并一些span中的页。
这里提供的大致逻辑是尝试查找前后相邻页进行合并,并且我们要知道挂在pagecache的span都可以尝试合并!
并的过程可以分为向前合并和向后合并。如果还回来的span的起始页号是num,该span所管理的页数是n。那么在向前合并时,就需要判断第num-1页对应span是否空闲,如果空闲则可以将其进行合并,并且合并后还需要继续向前尝试进行合并,直到不能进行合并为止。而在向后合并时,就需要判断第num+n页对应的span是否空闲,如果空闲则可以将其进行合并,并且合并后还需要继续向后尝试进行合并,直到不能进行合并为止。
因此page cache在合并span时,是需要通过页号获取到对应的span的,这就是我们要把页号与span之间的映射关系存储到page cache的原因。
但需要注意的是,当我们通过页号找到其对应的span时,这个span此时可能挂在page cache,也可能挂在central cache。而在合并时我们只能合并挂在page cache的span,因为挂在central cache的span当中的对象正在被其它线程使用。
可是我们不能通过span结构当中的_useCount成员,来判断某个span到底是在central cache还是在page cache。因为会存在一个间隙,当central cache刚向page cache申请到一个span时,这个span的_useCount就是等于0的,这时可能当我们正在对该span进行切分的时候,另外一个线程还回来一个相邻页,于是page cache就把这个span拿去进行合并了,这显然是不合理的。
鉴于此,我们可以在span结构中再增加一个_isUse成员,用于标记这个span是否正在被使用,而当一个span结构被创建时我们默认该span是没有被使用的。
cpp
struct Span
{
PAGE_ID _pageId = 0; // 大块内存起始页的页号
size_t _n = 0; // 页的数量
Span *_next = nullptr; // 双链表结构
Span *_prev = nullptr;
size_t _useCount = 0; // 切好的小块内存,被分配给thread cache的计数
void *_freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false; //是否在被使用
};
因此当central cache向page cache申请到一个span时,需要立即将该span的_isUse改为true。
span->_isUse = true;
而当central cache将某个span还给page cache时,也就需要将该span的_isUse改成false。
span->_isUse = false;
在central cache中回收内存时我们使用map需要把每个页和span建立对应的映射关系,但在pagecache中对于一个span只需要建立头和尾对它的映射关系
cpp
//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0 && k < NPAGES);
//先检查第k个桶里面有没有span
if (!_spanLists[k].Empty())
{
Span* kSpan = _spanLists[k].PopFront();
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; i++)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
//检查一下后面的桶里面有没有span,如果有可以将其进行切分
for (size_t i = k + 1; i < NPAGES; i++)
{
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
Span* kSpan = new Span;
//在nSpan的头部切k页下来
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
//将剩下的挂到对应映射的位置
_spanLists[nSpan->_n].PushFront(nSpan);
//存储nSpan的首尾页号与nSpan之间的映射,方便page cache合并span时进行前后页的查找
_idspanMap[nspan->_pageId] = nspan;
_idspanMap[nspan->_pageId + nspan->_n - 1] = nspan;
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; i++)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
}
//走到这里说明后面没有大页的span了,这时就向堆申请一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
//尽量避免代码重复,递归调用自己
return NewSpan(k);
}
此时page cache当中的span就都与其首尾页之间建立了映射关系,现在我们就可以进行span的合并了,其合并逻辑如下:
cpp
//释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
//对span的前后页,尝试进行合并,缓解内存碎片问题
//1、向前合并
while (1)
{
PAGE_ID prevId = span->_pageId - 1;
auto ret = _idSpanMap.find(prevId);
//前面的页号没有(还未向系统申请),停止向前合并
if (ret == _idSpanMap.end())
{
break;
}
//前面的页号对应的span正在被使用,停止向前合并
Span* prevSpan = ret->second;
if (prevSpan->_isUse == true)
{
break;
}
//合并出超过128页的span无法进行管理,停止向前合并
if (prevSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//进行向前合并
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
//将prevSpan从对应的双链表中移除
_spanLists[prevSpan->_n].Erase(prevSpan);
delete prevSpan;
}
//2、向后合并
while (1)
{
PAGE_ID nextId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nextId);
//后面的页号没有(还未向系统申请),停止向后合并
if (ret == _idSpanMap.end())
{
break;
}
//后面的页号对应的span正在被使用,停止向后合并
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true)
{
break;
}
//合并出超过128页的span无法进行管理,停止向后合并
if (nextSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//进行向后合并
span->_n += nextSpan->_n;
//将nextSpan从对应的双链表中移除
_spanLists[nextSpan->_n].Erase(nextSpan);
delete nextSpan;
}
//将合并后的span挂到对应的双链表当中
_spanLists[span->_n].PushFront(span);
//建立该span与其首尾页的映射
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;
//将该span设置为未被使用的状态
span->_isUse = false;
}
需要注意的是,在向前或向后进行合并的过程中:
- 如果没有通过页号获取到其对应的span,说明对应到该页的内存块还未申请,此时需要停止合并。
- 如果通过页号获取到了其对应的span,但该span处于被使用的状态,那我们也必须停止合并。
- 如果合并后大于128页则不能进行本次合并,因为page cache无法对大于128页的span进行管理。
在合并span时,由于这个span是在page cache的某个哈希桶的双链表当中的,因此在合并后需要将其从对应的双链表中移除,然后再将这个被合并了的span结构进行delete。
除此之外,在合并结束后,除了将合并后的span挂到page cache对应哈希桶的双链表当中,还需要建立该span与其首位页之间的映射关系,便于此后合并出更大的span。
4.6 联调回收内存
当我们写完三层的回收之后,至此就可以完成申请内存和释放内存的整个过程了
cpp
static void ConcurrentFree(void* ptr, size_t size/*暂时*/)
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
释放内存过程联调测试
之前我们在测试申请流程时,让单个线程进行了三次内存申请,现在我们再将这三个对象再进行释放,看看这其中的释放流程是如何进行的。
cpp
//test.cpp
void* p1 = ConcurrentAlloc(6);
void* p2 = ConcurrentAlloc(8);
void* p3 = ConcurrentAlloc(1);
ConcurrentFree(p1, 6);
ConcurrentFree(p2, 8);
ConcurrentFree(p3, 1);
首先,这三次申请和释放的对象大小进行对齐后都是8字节,因此对应操作的就是thread cache和central cache的第0号桶,以及page cache的第1号桶。
由于第三次对象申请时,刚好将thread cache第0号桶当中仅剩的一个对象拿走了,因此在三次对象申请后thread cache的第0号桶当中是没有对象的。
通过监视窗口可以看到,此时thread cache第0号桶中自由链表的_maxSize已经慢增长到了3,而当我们释放完第一个对象后,该自由链表当中对象的个数只有一个,因此不会将该自由链表当中的对象进一步还给central cache。
当第二个对象释放给thread cache的第0号桶后,该桶对应自由链表当中对象的个数变成了2,也是不会进行ListTooLong操作的。
直到第三个对象释放给thread cache的第0号桶时,此时该自由链表的_size的值变为3,与_maxSize的值相等,现在thread cache就需要将对象给central cache了。
thread cache先是将第0号桶当中的对象弹出MaxSize个,在这里实际上就是全部弹出,此时该自由链表_size的值变为0,然后继续调用central cache当中的ReleaseListToSpans函数,将这三个对象还给central cache当中对应的span。
在进入central cache的第0号桶还对象之前,先把第0号桶对应的桶锁加上,然后通过查page cache中的映射表找到其对应的span,最后将这个对象头插到该span的自由链表中,并将该span的_useCount进行--。当第一个对象还给其对应的span时,可以看到该span的_useCount减到了2。
而由于我们只进行了三次对象申请,并且这些对象大小对齐后大小都是8字节,因此我们申请的这三个对象实际都是同一个span切分出来的。当我们将这三个对象都还给这个span时,该span的_useCount就减为了0。

现在central cache就需要将这个span进一步还给page cache,而在将该span交给page cache之前,会将该span的自由链表以及前后指针都置空。并且在进入page cache之前会先将central cache第0号桶的桶锁解掉,然后再加上page cache的大锁,之后才能进入page cache进行相关操作。
由于这个一页的span是从128页的span的头部切下来的,在向前合并时由于前面的页还未向系统申请,因此在查映射关系时是无法找到的,此时直接停止了向前合并。(这里的页号由于重新调试地址改变了,实际应该和上面相同)
而在向后合并时,由于page cache没有将该页后面的页分配给central cache,因此在向后合并时肯定能够找到一个127页的span进行合并。合并后就变成了一个128页的span,这时我们将原来127页的span从第127号桶删除,然后还需要将该127页的span结构进行delete,因为它管理的127页已经与1页的span进行合并了,不再需要它来管理了。
紧接着将这个128页的span插入到第128号桶,然后建立该span与其首尾页的映射,便于下次被用于合并,最后再将该span的状态设置为未被使用的状态即可。

当从page cache回来后,除了将page cache的大锁解掉,还需要立刻加上central cache中对应的桶锁,然后继续将对象还给central cache中的span,但此时实际上是还完了,因此再将central cache的桶锁解掉就行
至此我们便完成了这三个对象的申请和释放流程。
4.6 大于256KB的大块内存申请问题
之前说到,每个线程的thread cache是用于申请小于等于256KB的内存的,而对于大于256KB的内存,我们可以考虑直接向page cache申请,但page cache中最大的页也就只有128页,因此如果是大于128页的内存申请,就只能直接向堆申请了。
申请内存的大小 | 申请方式 |
---|---|
x≤256KB(32页) | 向thread cache申请 |
32页<x≤128页 | 向page cache申请 |
x≥128页 | 向堆申请 |
申请的内存大于256KB时,虽然不是从thread cache进行获取,但在分配内存时也是需要进行向上对齐的,对于大于256KB的内存我们可以直接按页进行对齐。
而我们之前实现RoundUp函数时,对传入字节数大于256KB的情况直接做了断言处理,因此这里需要对RoundUp函数稍作修改。
cpp
//common.hpp
//class sizeclass
static inline size_t 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
{
// 大于256KB的按页对齐
return _RoundUp(bytes, 1 << PAGE_SHIFT);
}
}
现在对于之前的申请逻辑就需要进行修改了,当申请对象的大小大于256KB时,就不用向thread cache申请了,这时先计算出按页对齐后实际需要申请的页数,然后通过调用NewSpan申请指定页数的span即可。
cpp
static void* ConcurrentAlloc(size_t size)
{
if (size > MAX_BYTES) //大于256KB的内存申请
{
//计算出对齐后需要申请的页数
size_t alignSize = SizeClass::RoundUp(size);
size_t kPage = alignSize >> PAGE_SHIFT;
//向page cache申请kPage页的span
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kPage);
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else
{
//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
return pTLSThreadCache->Allocate(size);
}
}
也就是说,申请大于256KB的内存时,会直接调用page cache当中的NewSpan函数进行申请,因此这里我们需要再对NewSpan函数进行改造,当需要申请的内存页数大于128页时,就直接向堆申请对应页数的内存块。而如果申请的内存页数是小于128页的,那就在page cache中进行申请,因此当申请大于256KB的内存调用NewSpan函数时也是需要加锁的,因为我们可能是在page cache中进行申请的。
cpp
//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0);
if (k > NPAGES - 1) //大于128页直接找堆申请
{
void* ptr = SystemAlloc(k);
Span* span = new Span;
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
//建立页号与span之间的映射
_idSpanMap[span->_pageId] = span;
return span;
}
//先检查第k个桶里面有没有span
if (!_spanLists[k].Empty())
{
Span* kSpan = _spanLists[k].PopFront();
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; i++)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
//检查一下后面的桶里面有没有span,如果有可以将其进行切分
for (size_t i = k + 1; i < NPAGES; i++)
{
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
Span* kSpan = new Span;
//在nSpan的头部切k页下来
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
//将剩下的挂到对应映射的位置
_spanLists[nSpan->_n].PushFront(nSpan);
//存储nSpan的首尾页号与nSpan之间的映射,方便page cache合并span时进行前后页的查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; i++)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
}
//走到这里说明后面没有大页的span了,这时就向堆申请一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
//尽量避免代码重复,递归调用自己
return NewSpan(k);
}
大于256KB的内存块的释放
和上面相同我们需要判断内存块的大小制定对应决策
释放内存的大小 | 释放方式 |
---|---|
x≤256KB(32页) | 释放给thread cache |
32页<x≤128页 | 释放给page cache |
x≥128页 | 释放给堆 |
因此当释放对象时,我们需要先找到该对象对应的span,但是在释放对象时我们只知道该对象的起始地址。这也就是我们在申请大于256KB的内存时,也要给申请到的内存建立span结构,并建立起始页号与该span之间的映射关系的原因。此时我们就可以通过释放对象的起始地址计算出起始页号,进而通过页号找到该对象对应的span。
cpp
static void ConcurrentFree(void* ptr, size_t size)
{
if (size > MAX_BYTES) //大于256KB的内存释放
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
说明一下,直接向堆申请内存时我们调用的接口是VirtualAlloc,与之对应的将内存释放给堆的接口叫做VirtualFree,而Linux下的brk和mmap对应的释放接口叫做sbrk和unmmap。此时我们也可以将这些释放接口封装成一个叫做SystemFree的接口,当我们需要将内存释放给堆时直接调用SystemFree即可
cpp
//直接将内存还给堆
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
if (ptr != NULL) {
// 假设内存是通过 mmap 分配的,且在分配时记录了元数据(如大小)
size_t* meta_ptr = (size_t*)ptr - 1; // 回退指针到元数据位置
size_t size = *meta_ptr; // 读取分配时记录的内存块大小
void* base_ptr = (void*)meta_ptr; // 实际需要释放的起始地址
munmap(base_ptr, size + sizeof(size_t)); // 释放内存(含元数据)
}
#endif
}
因此page cache在回收span时也需要进行判断,如果该span的大小是小于等于128页的,那么直接还给page cache进行了,page cache会尝试对其进行合并。而如果该span的大小是大于128页的,那么说明该span是直接向堆申请的,我们直接将这块内存释放给堆,然后将这个span结构进行delete就行了。
cpp
//释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
if (span->_n > NPAGES - 1) //大于128页直接释放给堆
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
delete span;
return;
}
//对span的前后页,尝试进行合并,缓解内存碎片问题
//1、向前合并
while (1)
{
PAGE_ID prevId = span->_pageId - 1;
auto ret = _idSpanMap.find(prevId);
//前面的页号没有(还未向系统申请),停止向前合并
if (ret == _idSpanMap.end())
{
break;
}
//前面的页号对应的span正在被使用,停止向前合并
Span* prevSpan = ret->second;
if (prevSpan->_isUse == true)
{
break;
}
//合并出超过128页的span无法进行管理,停止向前合并
if (prevSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//进行向前合并
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
//将prevSpan从对应的双链表中移除
_spanLists[prevSpan->_n].Erase(prevSpan);
delete prevSpan;
}
//2、向后合并
while (1)
{
PAGE_ID nextId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nextId);
//后面的页号没有(还未向系统申请),停止向后合并
if (ret == _idSpanMap.end())
{
break;
}
//后面的页号对应的span正在被使用,停止向后合并
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true)
{
break;
}
//合并出超过128页的span无法进行管理,停止向后合并
if (nextSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//进行向后合并
span->_n += nextSpan->_n;
//将nextSpan从对应的双链表中移除
_spanLists[nextSpan->_n].Erase(nextSpan);
delete nextSpan;
}
//将合并后的span挂到对应的双链表当中
_spanLists[span->_n].PushFront(span);
//建立该span与其首尾页的映射
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;
//将该span设置为未被使用的状态
span->_isUse = false;
}
4.7使用定长内存池配合脱离使用new
tcmalloc本身是要求脱离malloc使用的,当我们开辟获取span等操作的时候就用了new,而new实现的逻辑就是malloc+使用构造函数初始化。
为了完全脱离掉malloc函数,此时我们之前实现的定长内存池就起作用了,代码中使用new时基本都是为Span结构的对象申请空间,而span对象基本都是在page cache层创建的,因此我们可以在PageCache类当中定义一个_spanPool,用于span对象的申请和释放。
cpp
//单例模式
class PageCache
{
public:
//...
private:
ObjectPool<Span> _spanPool;
};
后将代码中使用new的地方替换为调用定长内存池当中的New函数,将代码中使用delete的地方替换为调用定长内存池当中的Delete函数。
cpp
//申请span对象
Span* span = _spanPool.New();
//释放span对象
_spanPool.Delete(span);
注意,当使用定长内存池当中的New函数申请Span对象时,New函数通过定位new也是对Span对象进行了初始化的。
此外,每个线程第一次申请内存时都会创建其专属的thread cache,而这个thread cache目前也是new出来的,我们也需要对其进行替换。
cpp
//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
static std::mutex tcMtx;
static ObjectPool<ThreadCache> tcPool;
tcMtx.lock();
pTLSThreadCache = tcPool.New();
tcMtx.unlock();
}
这里我们将用于申请ThreadCache类对象的定长内存池定义为静态的,保持全局只有一个,让所有线程创建自己的thread cache时,都在个定长内存池中申请内存就行了。
但注意在从该定长内存池中申请内存时需要加锁,防止多个线程同时申请自己的ThreadCache对象而导致线程安全问题。
最后在SpanList的构造函数中也用到了new,因为SpanList是带头循环双向链表,所以在构造期间我们需要申请一个span对象作为双链表的头结点。
cpp
//带头双向循环链表
class SpanList
{
public:
SpanList()
{
_head = _spanPool.New();
_head->_next = _head;
_head->_prev = _head;
}
private:
Span* _head;
static ObjectPool<Span> _spanPool;
};
由于每个span双链表只需要一个头结点,因此将这个定长内存池定义为静态的,保持全局只有一个,让所有span双链表在申请头结点时,都在一个定长内存池中申请内存就行了。
4.8 释放内存优化
释放对象时优化为不传对象大小
当我们使用malloc函数申请内存时,需要指明申请内存的大小;而当我们使用free函数释放内存时,只需要传入指向这块内存的指针即可。
而我们目前实现的内存池,在释放对象时除了需要传入指向该对象的指针,还需要传入该对象的大小。
原因如下:
- 如果释放的是大于256KB的对象,需要根据对象的大小来判断这块内存到底应该还给page cache,还是应该直接还给堆。
- 如果释放的是小于等于256KB的对象,需要根据对象的大小计算出应该还给thread cache的哪一个哈希桶。
- 如果我们也想做到,在释放对象时不用传入对象的大小,那么我们就需要建立对象地址与对象大小之间的映射。由于现在可以通过对象的地址找到其对应的span,而span的自由链表中挂的都是相同大小的对象。
因此我们可以在Span结构中再增加一个_objSize成员,该成员代表着这个span管理的内存块被切成的一个个对象的大小。
cpp
//管理以页为单位的大块内存
struct Span
{
PAGE_ID _pageId = 0; //大块内存起始页的页号
size_t _n = 0; //页的数量
Span* _next = nullptr; //双链表结构
Span* _prev = nullptr;
size_t _objSize = 0; //切好的小对象的大小
size_t _useCount = 0; //切好的小块内存,被分配给thread cache的计数
void* _freeList = nullptr; //切好的小块内存的自由链表
bool _isUse = false; //是否在被使用
};
而所有的span都是从page cache中拿出来的,因此每当我们调用NewSpan获取到一个k页的span时,就应该将这个span的_objSize保存下来。
cpp
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_objSize = size;
1
2
代码中有两处,一处是在central cache中获取非空span时,如果central cache对应的桶中没有非空的span,此时会调用NewSpan获取一个k页的span;另一处是当申请大于256KB内存时,会直接调用NewSpan获取一个k页的span。
此时当我们释放对象时,就可以直接从对象的span中获取到该对象的大小,准确来说获取到的是对齐以后的大小。
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
if (size > MAX_BYTES) //大于256KB的内存释放
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
读取映射关系时的加锁问题
我们将页号与span之间的映射关系是存储在PageCache类当中的,当我们访问这个映射关系时是需要加锁的,因为STL容器是不保证线程安全的。
对于当前代码来说,如果我们此时正在page cache进行相关操作,那么访问这个映射关系是安全的,因为当进入page cache之前是需要加锁的,因此可以保证此时只有一个线程在进行访问。
但如果我们是在central cache访问这个映射关系,或是在调用ConcurrentFree函数释放内存时访问这个映射关系,那么就存在线程安全的问题。因为此时可能其他线程正在page cache当中进行某些操作,并且该线程此时可能也在访问这个映射关系,因此当我们在page cache外部访问这个映射关系时是需要加锁的。
实际就是在调用page cache对外提供访问映射关系的函数时需要加锁,这里我们可以考虑使用C++当中的unique_lock,当然你也可以用普通的锁。
cpp
//获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; //页号
std::unique_lock<std::mutex> lock(_pageMtx); //构造时加锁,析构时自动解锁
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
五、多线程环境下对比malloc测试
之前我们只是对代码进行了一些基础的单元测试,下面我们在多线程场景下对比malloc进行测试。
cpp
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&, k]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
v.push_back(malloc(16));
//v.push_back(malloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
free(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次malloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime);
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
v.push_back(ConcurrentAlloc(16));
//v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
ConcurrentFree(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次concurrent alloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime);
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}
int main()
{
size_t n = 10000;
cout << "==========================================================" <<
endl;
BenchmarkConcurrentMalloc(n, 4, 10);
cout << endl << endl;
BenchmarkMalloc(n, 4, 10);
cout << "==========================================================" <<
endl;
return 0;
}
其中测试函数各个参数的含义如下:
- ntimes:单轮次申请和释放内存的次数。
- nworks:线程数。
- rounds:轮次。
在测试函数中,我们通过clock函数分别获取到每轮次申请和释放所花费的时间,然后将其对应累加到malloc_costtime和free_costtime上。最后我们就得到了,nworks个线程跑rounds轮,每轮申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间。
注意,我们创建线程时让线程执行的是lambda表达式,而我们这里在使用lambda表达式时,以值传递的方式捕捉了变量k,以引用传递的方式捕捉了其他父作用域中的变量,因此我们可以将各个线程消耗的时间累加到一起。
我们将所有线程申请内存消耗的时间都累加到malloc_costtime上, 将释放内存消耗的时间都累加到free_costtime上,此时malloc_costtime和free_costtime可能被多个线程同时进行累加操作的,所以存在线程安全的问题。鉴于此,我们在定义这两个变量时使用了atomic类模板,这时对它们的操作就是原子操作了。
固定大小内存的申请和释放
我们先来测试一下固定大小内存的申请和释放:
c
v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));
此时4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次,运行后可以看到,malloc的效率还是更高的。
由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自thread cache的同一个桶,当thread cache的这个桶中没有对象或对象太多要归还时,也都会访问central cache的同一个桶。此时central cache中的桶锁就不起作用了,因为我们让central cache使用桶锁的目的就是为了,让多个thread cache可以同时访问central cache的不同桶,而此时每个thread cache访问的却都是central cache中的同一个桶。
不同大小内存的申请和释放
下面我们再来测试一下不同大小内存的申请和释放:
cpp
v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
运行后可以看到,由于申请和释放内存的大小是不同的,此时central cache当中的桶锁就起作用了,ConcurrentAlloc的效率也有了较大增长,但相比malloc来说还是差一点点。

总结
至此高并发内存池框架基本结束,但仍有诸多不足可以优化,例如使用基数树针对性能瓶颈做优化或者对于threadcache内存回收增加阈值等等。
博主写到后面感觉欠缺的太多了,因此后面还会对这篇文章做补充,但这个过程是用于查漏补缺,因此可能会是一段稍微漫长的时间,写项目的时间也好像是一场很隐秘的个人的战斗,由于是博主第一个项目,因此在面对项目的逻辑和处理上显得很吃力,并且也没有平衡好日常的生活和学习项目的时间,导致后面会学不下去等等和学习到的东西没法灵活运用,因此本章博客后面很大一部分是借鉴别人的。
从刚开始学C++到现在已经有小一年时间了,但是好像中间从来没有为学到东西而感觉到开心,想明白一个问题带来的只是如释重负的感觉,并且在知道理论知识和灵活运用之间还差着很远的路程,或许以后博主并不会成为一个合格的程序员,但这近一年的时间还是很珍贵的。
希望接下来的日子能静下心来开始一段新的学习,也能用心经营一段有意义的生活。