1. 内存池存在的意义
在学习内存池之前,我们首先要知道为什么要学习内存池,它的存在有着什么意义?
malloc 和 free 相信大家已经非常熟悉了,标准库的分配器是一个设计极其精良的通用分配器,它既能处理像 1 字节这样的小请求,也能处理像 1GB 这样的大请求,此外,它还能保证线程的安全。事实上,malloc 的强大确实毋庸置疑,现在我们使用的 malloc 已经是无数前人优化后的版本,在大多数场景下它都能保证极高的效率。
但是,它的通用性往往也意味着性能的平庸。请看下面的特定极端场景:第一是高频小对象分配,网络服务器中通常会频繁创建和销毁任务结构体和网络包;第二是长期运行的,不能重启的嵌入式系统。在这些场景下,使用 malloc 会带来下面的危害:
1.1 性能损耗
malloc 内部为了管理各种不同大小的内存块,维护了一套复杂的数据结构,如红黑树、双向链表。每次分配时,它都要进行查找、分割、合并等操作。 此外,malloc 最终可能需要进行系统调用(brk 或 mmap),这涉及用户态到内核态的上下文切换。对于纳秒级的业务逻辑来说,这个开销是相当大的。
1.2 内存碎片
内存碎片化在嵌入式开发中会造成极其严重的后果,当我们频繁申请和释放不同大小的内存时,堆内存会被切割的支离破碎。 虽然系统剩余的总空闲内存很大,但全是分散的小碎片。当需要申请一块连续的 1MB 内存时,系统却找不到足够大的连续空间,只能报错 OOM (Out Of Memory) ,最终导致程序崩溃。
1.3 锁竞争
glibc 的 malloc 是线程安全的,这是因为它内部有锁。 在多线程高并发场景下,如果多个线程同时调用 malloc,它们就会争抢同一把锁。 这会导致大量的 CPU 时间被浪费在等待锁和唤醒线程上,而不是在真正干活,性能会随着线程数的增加而急剧下降。
1.4 解决方法
上面的几种危害,也就是内存池存在的意义。本文我将带大家从零手写一个定长内存池,对它进行不断的优化,通过嵌入式指针实现极致的单线程性能,并通过线程本地缓存彻底消灭锁竞争。
2. 单线程定长内存池
在设计内存池时,我们首先要考虑的问题是如何管理空闲的内存块。通常情况下,我们的第一想法是使用一个链表,但是链表需要一个额外的Node节点来存放指针,这意味着,为了管理内存,我们不得不申请更多的内存,这不仅浪费了空间,还造成了额外的malloc开销。
如此看来,链表显然是不合适的,实际上,在设计内存池时我们一般会用嵌入式指针。
2.1 什么是嵌入式指针
嵌入式指针的核心思想非常简单:它使用空闲内存的前8个字节来存放下一块内存的地址。
当内存被分配给用户时,里面存的是用户的数据。
当内存空闲时,它里面存的是指向下一块空闲内存的地址。
这样,我们不需要任何额外的内存开销,就能把所有空闲块串起来。
内存布局的结构图如下图所示:
这个示意图中我画了四个块,每个块大小为 32 字节,每个块的前8个字节用来存放下一个空闲块的地址,后面剩下的字节空闲,最后一个块比较特殊,它的前8个字节存着NULL,表示结束。从结构上来看,第一块内存中存放这指向第二块内存的指针,而第二块内存中又存放这指向第三块内存的指针,这样就形成了一个天然的,类似于链表的结构。而且这个结构本身并没有像链表那样需要额外的内存来存放指针,只是复用了正处于空闲状态的用于存放内容的内存。
下面这张图展示了使用一段时间之后的内存布局示意图:
我们假设,先将 Block 0 和 Block 1分配给了用户,然后用户使用完后归还了 Block 0 。此时,我们来看Block 1 ,里面的指针已经被用户数据覆盖了,而被用户归还的 Block 0 ,它里面的指针指向的是 Block 2 ,也就是下一个空闲的内存块,中间刚好跳过了那个正在被使用的 Block 1。这是典型的头插法回收,保证了刚刚释放的热数据能被立刻复用,提高了 CPU Cache 命中率。
2.2 核心代码实现
理解了原理,我们现在来看代码。
2.2.1 结构体定义
我们首先要定义用来管理内存池的结构体。
#include <stddef.h>
typedef struct {
void *memory_start;
void *free_list;
size_t block_size;
size_t total_blocks;
} MemoryPool;
我们先来看第一个成员 memory_start ,它是用来接收 malloc 的返回值的,而 malloc 的返回值类型正是 void * ,因此我们把它声明为 void * 类型。此外,我们在使用完内存池后,要销毁内存池,需要把 memory_start 传给 free ,而free接受的参数类型也是 void * 。因此,用 void * 是最标准的写法,表示这里指向一块内存,具体类型未知。
第二个成员 free_list 用来指向下一个空闲的内存块,这里使用 void * 也是有着深层次的考虑的。第一,内存池的mp_alloc函数是模仿标准库的malloc的,这个函数我们后面会写,用户申请一块内存,可能是想存int型数据,也可能想存double,为了让用户不需要强制类型转换就能够直接进行赋值,mp_alloc的返回值就必须是 void * 。既然返回出去的是 void *,那么我们在内部存储这个地址的 free_list 自然也应该是 void *,这样比较通顺一点。第二,我们把内存块的前 8 个字节当作指针,无论 free_list 定义成什么类型,我们在操作时最终都要强制转换成 void ** 才能去读写里面的地址,这个原因后面会详细解释,既然都要强转,那么定义成 void *无疑是最标准的写法了。
至于第三个和第四个成员从字面意思就可以猜出个大概了。第三个成员 block_size 表示每个内存块的大小为多少个字节。第四个成员total_blocks 表示内存池中总共有多少个内存块。
2.2.2 初始化
在创建内存池时,我们需要申请一大块连续内存,然后将它切成很多个小的内存块,每块的大小就是上面结构体中block_size的大小,总共切成的块数就是上面total_blocks的大小,最后用嵌入式指针串起来。
#include <stdio.h>
#include <stdlib.h>
#define MIN_BLOCK_SIZE sizeof(void*)
MemoryPool* mp_create(size_t block_size, size_t block_count)
{
if (block_size < MIN_BLOCK_SIZE)
{
block_size = MIN_BLOCK_SIZE;
}
MemoryPool *pool = (MemoryPool *)malloc(sizeof(MemoryPool));
size_t total_size = block_size * block_count;
pool->memory_start = malloc(total_size);
pool->block_size = block_size;
pool->total_blocks = block_count;
char *ptr = (char *)pool->memory_start;
for (size_t i = 0; i < block_count - 1; i++)
{
void *curr = ptr + i * block_size;
void *next = ptr + (i + 1) * block_size;
*(void **)curr = next;
}
void *last = ptr + (block_count - 1) * block_size;
*(void **)last = NULL;
pool->free_list = pool->memory_start;
return pool;
}
我们下面来深度剖析一下这段代码。
首先,我们定义了一个宏 MIN_BLOCK_SIZE ,它的值是 sizeof(void*) ,也就是地址所占的字节数。
在进入初始化函数,我们要做的第一步是检查block_size的大小是否小于MIN_BLOCK_SIZE,block_size是用户初始化是传入的块大小,按照我们前面讲的逻辑,我们要在空闲块中存放下一个空闲块的地址,如果这个块大小本身就小于地址的大小,那显然是不成立的。因此,当用户传入的块大小小于 MIN_BLOCK_SIZE时,我们将它置为MIN_BLOCK_SIZE,保证这个块最小都能够完全容纳下一个地址。
第二步,我们要为 MemoryPool 结构体本身分配内存,还要分配内存池的空间。这两次内存分配一定要区分开来,第一次内存分配是为内存池管理结构体分配内存,大小就是MemoryPool结构体的大小,第二次才是内存池本身的内存,大小就是每个块的大小乘以总共的块数。
第三步将用户传进来的块大小和块数量两个参数赋值给结构体中的第三、四个成员。
第四步, char *ptr = (char *)pool->memory_start可能有人疑惑这里为什么要强制转换成 char * ,这是因为在C语言标准中,void *指向的对象大小是未知的,因此不能进行加减运算(虽然 GCC 扩展支持把它当 1 字节算,但为了标准兼容性,必须强转)。而char *的步长是一个字节,转成 char * 后,ptr + 32 就是精确地向后移动 32 个字节。还有一点,这里为什么不直接操作 pool->memory_start ,而是将它赋值给ptr进行操作,这是因为后面在销毁内存池时,我们需要用到pool->memory_start,将它传给free,整个内存池就会被释放。
再往下看,第五步是一个for循环,这也是整个初始化阶段的最核心操作,要先说明一点,for循环内部没有处理最后一个块,因为最后一个块比较特殊,需要单独处理。我们来看for循环内部,整个for循环内部有三条语句。
第一条void *curr = ptr + i * block_size,定义一个void *类型的指针curr,当i=0时,curr会指向Block 0的首地址,也就是内存池的起始地址,当i=1时,他又会指向Block 1 的首地址,也就是说,整个for循环执行下来,它会依次指向除了最后一个块的所有块的首地址。
第二条void *next = ptr + (i + 1) * block_size,定义一个void *类型的指针next,当i=0时,它会指向Block 1的首地址,当i=1时,它会指向Block 2的首地址,总之,它始终指向curr所指向块的下一个块的首地址。在for循环执行到最后一次循环时,它会指向最后一个块的首地址。
再来看第三条*(void **)curr = next,前面说过到这里要详细讲一下为什么要转成void **。首先我们要明白,这条语句的目的是要写入next,而next本身是一个地址,我们要把一个地址放进内存里面,那么指向这块内存的指针,就必须是一个指向存放地址的内存的指针,也就是说它得是一个二级指针。只有 void ** 类型的指针,解引用之后,得到的才是 void * 类型的容器,才能装得下 next。
再明白了为什么要强制转换成void **类型之后,我们再来看看这条语句,curr的类型本来是void *,当i=0时,我们假设curr指向的是0X1000,这时我们只知道它指向的地址是0X1000,而不知道这个地址里面存的数据类型(void)是什么,更不知道它的大小是多少,然后我们将它强制转换成void **类型,也就是说这时0X1000里面存的数据类型是void *,这时就明确了,我们知道0X1000里面存的是一个地址,在 64 位系统下它的大小就是 8 个字节。然后对它解引用,也就是修改0X1000这个地址里面存放的内容,将里面存放的内容改为 next ,而next正是下一块内存的起始地址,也就是说我们已经将后一块内存的起始地址存放在了前一块内存中的前 8 位。这正是嵌入式指针的精髓,我们没有申请额外的内存来存指针,而是直接征用了空闲内存块本身的空间来存储链表关系。到这里,一轮for循环就走完了,循环往复,我们可以让除了最后一块的所有内存块中都存放着下一块内存的起始地址。
第六步,我们在来看看最后一块内存的特殊情况,我们已经知道这块内存里面要存的是NULL。先看这一句void *last = ptr + (block_count - 1) * block_size,定义了一个void *类型的指针last,让它指向最后一块内存的起始地址,然后同样是上面讲的强制类型转换,把NULL存进这块内存。
第七步pool->free_list = pool->memory_start,首先我们要知道 free_list 要始终指向第一块空闲的内存,由于这里是初始化,我们就让他先指向第一块内存的起始地址。
最后,将初始化好的pool返回出去。
2.2.3 申请与释放
我们可以把分配内存看作简单的链表头删,回收内存就是链表头插。这两个操作的时间复杂度都是 O(1) ,没有任何循环。
void* mp_alloc(MemoryPool *pool)
{
if (pool->free_list == NULL) return NULL;
void *block = pool->free_list;
pool->free_list = *(void **)block;
return block;
}
void mp_free(MemoryPool *pool, void *ptr)
{
if (ptr == NULL) return;
*(void **)ptr = pool->free_list;
pool->free_list = ptr;
}
我们先来看第一个函数,也就是内存申请函数,它的特点只有一个字,那就是快,没有循环检测,也没用系统调用,全是指针赋值。因为我们分配内存的逻辑是优先分配第一个空闲的内存块,因此我们需要传入的参数类型是MemoryPool *,让这个函数知道free_list指向的内存是什么,也就是第一个空闲的内存是什么。再来看函数返回值,因为我们是在从内存池中申请内存,传入了内存池控制结构体的指针,那我们要得到的当然是分配给我们的内存了,因此,这里返回值类型是void *,指向我们得到的内存。
再来看第一个函数内部。在分配内存时,我们先要检测内存池是否还有剩余内存,如果free_list为NULL,也就是说内存池没有剩余内存了,那么函数就返回NULL,表示内存分配失败。如果内存池还有剩余,那就继续往下执行void *block = pool->free_list,这条语句,定义了一个void *类型的指针block,并让他指向内存池中第一个空闲的内存块的首地址。然后第二条语句pool->free_list = *(void **)block ,前面我们已经知道了,一块空闲内存的内部存放着下一块空闲内存的地址,这里先将block强转为void **再进行解引用,就是为了将block指向的内存中存放的地址(这个地址是下一块空闲内存的地址)拿出来,把它赋给free_list,最后将分配好的内存块block返回出去。这时,block指向的内存已经被用户使用了,他就不是空闲的了,因此free_list依旧指向内存池中第一块空闲内存的首地址。
在了解了内存分配函数的实现,我们再来看看内存释放函数的实现。这个函数中,我们需要传入两个参数,一个是内存池指针,表明我们要释放的内存在哪个内存池里面,另一个是void *指针,表明我们要释放的内存是这个内存池中的哪一块内存。
如果传入的void *指针为NULL,这一般是由于用户调用时疏忽而导致的,但是由于在C语言标准中,free(NULL)是合法的,因此我们不需要给用户提示错误,直接返回即可。
第二条语句将free_list指向的地址存放到用户传入的ptr指向的地址内部,前面我们已经多次讲过,free_list指向的是内存池中第一块空闲的内存,而free_list指向的地址存放到了ptr指向的内存里面,也就是说ptr指向的内存块变成了内存池中的第一块空闲内存。
第三条语句pool->free_list = ptr是第二条语句的接续,他将free_list 指向ptr,保证了free_list始终指向内存池中第一块空闲内存的规则永远不变。
此外,刚释放的内存块立刻将其插回链表头,下一次申请时会优先拿到它。这使得该内存块极大概率还停留在 CPU 的 L1 或 L2 Cache 中,从而大幅减少 Cache Miss,提升内存分配性能。
2.3 性能测试
这里先把用来测试的main.c贴出来:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>
#include "fixed_pool.h"
#define THREAD_NUM 1
#define COUNT 4000000
#define BLOCK_SIZE 16
MemoryPool *g_pool;
void *test_pool(void *arg)
{
for(int i=0;i<COUNT;i++)
{
void *p = mp_alloc(g_pool);
if(p == NULL)
{
printf("内存池已空\n");
break;
}
*(int *)p = i;
mp_free(g_pool,p);
}
return NULL;
}
void *test_malloc(void *arg)
{
for(int i=0;i<COUNT;i++)
{
void *p = malloc(BLOCK_SIZE);
if(p == NULL)
{
break;
}
*(int *)p = i;
free(p);
}
return NULL;
}
int main()
{
pthread_t t[THREAD_NUM];
double time_pool,time_sys;
clock_t start,end;
printf("线程数:%d 单线程次数:%d 块大小:%d\n",THREAD_NUM,COUNT,BLOCK_SIZE);
g_pool = mp_create(BLOCK_SIZE,THREAD_NUM * 5);
start = clock();
for(int i=0;i<THREAD_NUM;i++) pthread_create(&t[i],NULL,test_pool,NULL);
for(int i=0;i<THREAD_NUM;i++) pthread_join(t[i],NULL);
end = clock();
time_pool = (double)(end - start) / CLOCKS_PER_SEC;
printf("内存池耗时%f秒\n",time_pool);
mp_destroy(g_pool);//销毁内存池
start = clock();
for(int i=0;i<THREAD_NUM;i++) pthread_create(&t[i],NULL,test_malloc,NULL);
for(int i=0;i<THREAD_NUM;i++) pthread_join(t[i],NULL);
end = clock();
time_sys = (double)(end - start) / CLOCKS_PER_SEC;
printf("malloc耗时%f秒\n",time_sys);
return 0;
}
由于最先用于单线程测试的main.c文件忘记保存,这里直接使用加入了多线程之后的main.c进行测试,但实际上只创建一个线程,因此对测试结果不会造成影响,请大家放心。
此外main.c中使用了mp_destroy函数,这个内存池销毁函数由于太简单,前面没有提到,这里也直接贴出来:
void mp_destroy(MemoryPool *pool)
{
free(pool->memory_start);
free(pool);
}
在进行测试之前我们先来分析一下main.c。在main函数中,我们先创建一个线程让他去执行test_pool,也就是内存池测试,执行完后再回收,在创建线程之前开始计时,回收线程之后结束计时,得到一个time_pool是整个过程消耗的时间。然后是同样的计时方式,只是让创建的那一个线程去执行test_malloc,也就是标准库的malloc测试,得到消耗的时间为time_sys。
我们现在来关注一下test_pool和test_malloc的内部实现,整体上来看,这两个函数都是在执行一个for循环,也就是循环执行下面这些操作:
第一步,申请一个固定大小的内存块。
第二步,检查内存分配是否成功。
第三步,对这个申请到的内存进行赋值。
第四步,释放这块内存。
两个函数都是将这个操作执行4000000次,最后统计他们消耗的时间。
这里需要提一下为什么加个赋值操作,这一步看起来没有什么意义。如果不加这个赋值操作,因为编译器发现你申请的内存并没有使用,所有test_malloc函数中的申请内存和释放内存这两步会被编译器优化掉,因此,加上这个赋值操作是为了防止编译器优化。而test_pool函数中使用的mp_alloc和mp_free,由于这是我们自己编写的函数,这并不会被编译器优化,所以不加这个赋值操作是可以的,但是为了控制变量,这里我还是加上了,确保只有分配内存和释放内存这两步才会影响到消耗的时间。
下面废话不多说,直接贴上测试的结果:
从测试结果可以看出来,在单线程环境下,我们的极简内存池比glibc的malloc快了将近4倍。
但实际上 glibc 的 malloc 已经是优化得极其变态的一个通用分配器,我们能用 100 行简单的 C 代码在特定场景下击败它,原因主要有下面几点:
第一,在算法层面:我们的 mp_alloc 逻辑极简,没有任何循环,没有任何条件判断(除了判空),只有几条汇编指令级别的指针赋值。而 malloc 作为通用分配器,必须处理复杂的逻辑,检查各个空闲链表、合并相邻块、处理系统调用边界等。
第二,在系统调用层面:我们的内存池只在初始化的时候向系统申请了一大块内存空间,从内存池中给用户分配并没有产生任何系统调用,全部在用户态完成。而 malloc 虽然也有缓冲,但在高频分配下,仍可能触发底层的 brk 或 mmap 扩展堆空间。
第三,在Cache Hit层面:我们的mp_free采用了头插法进行回收内存,这保证了刚回收的内存,下一次分配时会优先分配出去,而刚回收的这块内存有很大概率还在 L1 Cache 上面,这使得 CPU 的访问速度进一步提升。
由此看来,当我们牺牲了通用性,只能分配固定大小的内存,换来的是性能的极致提升。
后面,我们将继续探索,看看在多线程的环境下,内存池的优势还能不能继续维持下去。
3. 多线程测试
单线程测试中,我们的内存池凭借 O(1) 的算法和无系统调用的优势,再加上只分配固定大小内存的局限,性能碾压了 malloc。
但现代服务器都是多核多线程的,为了让内存池能在多线程环境下安全工作,最简单直接的方法就是加一把互斥锁。
3.1 加互斥锁
我们要对代码进行改造,改造很简单,就是在 mp_alloc 和 mp_free 的刚开始和快结束的地方,分别加上 pthread_mutex_lock 和 pthread_mutex_unlock。
代码需要修改的地方如下:
//第一处,在结构体中加上互斥锁成员
typedef struct {
//...
pthread_mutex_t mutex;
} MemoryPool;
//第二处,在mp_create的最后初始化锁
MemoryPool *mp_create(size_t block_size,size_t total_blocks)
{
//...
if(pthread_mutex_init(&pool->mutex,NULL) != 0)
{
free(pool->memory_start);
free(pool);
return NULL;
}
return pool;
}
//第三处,在alloc和free中加锁保护
void* mp_alloc(MemoryPool *pool)
{
pthread_mutex_lock(&pool->mutex); //刚进去就加锁
if(pool->free_list == NULL)
{
pthread_mutex_unlock(&pool->mutex);//没内存了,退出之前也要解锁
return NULL;
}
//......
pthread_mutex_unlock(&pool->mutex); //快结束时解锁
return block;
}
void mp_free(MemoryPool *pool, void *ptr)
{
pthread_mutex_lock(&pool->mutex); //刚进去时加锁
//......
pthread_mutex_unlock(&pool->mutex); //快结束时解锁
}
//第四处,销毁时把锁也销毁了
void mp_destroy(MemoryPool *pool)
{
pthread_mutex_destroy(&pool->mutex);
free(pool->memory_start);
free(pool);
}
3.2 性能测试
在main.c中,对线程数量和循环次数进行修改:
#define THREAD_NUM 4
#define COUNT 1000000
块大小依然是16个字节。
运行结果如下:
从运行结果的表面现象可以看到,这里 4 线程,每线程循环 1000000 次的条件下,malloc的耗时与上面单线程 4000000次 的情况耗时是无明显差距的。而内存池的耗时却翻了将近 30 倍,甚至比malloc还要慢7倍。
这里有两个疑点:
第一:对于malloc,4线程,每个线程循环 1000000 次,理论上 4 个线程是并行执行的,为什么会和第二章中的单线程循环 4000000 次消耗的时间不相上下,理论上,多线程并行执行消耗的时间不应该远小于单线程吗。
第二:前面第二章中单线程循环 4000000 次,才消耗了 0.011763 秒,而现在换成 4 线程并行执行,耗时没有减少,反而变为了原来的 30 倍。
3.3 疑点解析
我们先来看第一个疑点,malloc为什么没有变快。
事实上,我们的直觉并没有错,4 个线程并行执行,理论上确实应该比单线程快,但是这个理论的成立有一个前提,那就是资源是无限的,但这在现实中几乎是不可能的。
glibc 的 malloc 确实为多线程做了优化,它引入了 Arena 机制。简单来说,它会创建多个内存区域,让不同的线程尽量在不同的区域上分配,以减少冲突。但是,Arena 最大数量是有限的,在 64 位系统中最大数量通常是 CPU 核心数量的八倍,在高并发下,多个线程很有可能会被调度到同一个Arena进行排队,最终导致线程阻塞。总而言之,malloc 通过 Arena 缓解了锁竞争,但并没有根除。在高频 malloc/free 下,线程切换和锁的开销依然是主要瓶颈。因此,在我们的压测场景下(4个线程、高频、密集地调用),百万级别的循环次数,它们撞到同一个 Arena 上的次数将是一个很大的数字,并且一旦撞到,它们就必须竞争这个 Arena 的锁,这就意味着一定有线程会被阻塞。
最终就导致了多线程耗时和单线程耗时的差距不大。
再来看第二个疑点,我们只是加了 lock 和 unlock,为什么性能会崩塌式的下降。实际上,加锁的代价是非常昂贵的,这会导致线程在大部分时间中都在排队,而不是干正事,这并非我们的分配算法变慢了,而是全局锁带来了巨大的竞争开销。
我们有4个线程,却只有1把锁,这就意味着,在任何时候,只有一个线程能进入mp_alloc或mp_free的临界区,这严格意义上已经不能被称为多线程并行执行了,而是串行执行。
此外,pthread_mutex 在发生竞争时,会导致抢不到锁的线程被内核挂起。线程的挂起和唤醒是需要内核介入的操作,我们的内存分配逻辑(指针操作)本身可能只需要几纳秒,但一次线程切换可能就需要几微秒,这说明程序将大多数时间都浪费在了排队等待和线程切换上面。同时,这也证明了 glibc malloc 的 Arena 机制 的优秀之处,它有效缓解了这种全局锁的瓶颈。
4. 多线程优化
我们已经知道全局锁是性能杀手,那么我们该如何进行优化呢。事实上,我们无法修改 pthread_mutex 本身,但我们可以改变架构,让线程根本不需要去抢那把全局锁。
我们可以借鉴 Google 的 TCMalloc (Thread-Caching Malloc) ,其核心思想简单粗暴:通过线程本地缓存消除多线程内存分配中的锁竞争,让小对象分配和释放几乎无锁、真正并行执行。
4.1 架构升级
我们需要修改之前的内存池,将它改为二级缓存架构。也就是一级线程私有缓存和二级共享中心。
一级缓存中,我们使用 __thread 关键字,为每个线程创建一个私有的、无锁的小内存池。线程申请内存时,绝大多数情况下都从自己的私有缓存里面拿。
二级中心,全局唯一,带一把锁,当线程本地缓存用完了,就来中心进货,当线程本地缓存的空闲内存太多,就把多余的退还给中心。虽然共享中心依然要加锁,但因为每次都是批发内存,加锁的频率被降低了成百上千倍。
4.2 代码改造
__thread 是 GCC/Clang 提供的关键字,全称 Thread-Local Storage (线程局部存储) 。用它修饰的全局变量,每个线程都会拥有一份独立的副本,它们地址不同,互不干扰。
这里由于需要修改的地方比较多,我直接贴出完整代码:
先是fixed_pool.h:
#ifndef __FIXED_POOL_H
#define __FIXED_POOL_H
#include <stddef.h>
#include <pthread.h>
typedef struct{
void *memory_start;//整个大内存块的起始地址
void *free_list;//始终指向第一个可用的空闲地址
size_t block_size;//每个块的大小
size_t total_blocks;//总共块数
pthread_mutex_t mutex;//保护中央链表的互斥锁
}CentralPool;
typedef struct{
CentralPool *central;//归属的中央仓库
void *free_list;//本地空闲表头
int cache_count;//本地存储块数
int cache_limit;//本地最大缓存数量
}ThreadCache;
CentralPool *mp_create_central(size_t block_size,size_t total_blocks);
void mp_init_thread_cache(CentralPool *central,int limit);
void *mp_alloc();
void mp_free(void *ptr);
void mp_destroy(CentralPool *pool);
#endif
下来是fixed_pool.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "fixed_pool.h"
#define MIN_BLOCK_SIZE sizeof(void *)
static __thread ThreadCache g_tcache = {0};
CentralPool *mp_create_central(size_t block_size,size_t total_blocks)
{
if(block_size < MIN_BLOCK_SIZE)
{
block_size = MIN_BLOCK_SIZE;
}
CentralPool *pool = (CentralPool *)malloc(sizeof(CentralPool));
if(!pool)
{
return NULL;
}
size_t total_size = block_size * total_blocks;
pool->memory_start = malloc(total_size);
if(!pool->memory_start)
{
free(pool);
return NULL;
}
pool->block_size = block_size;
pool->total_blocks = total_blocks;
char *ptr = (char *)pool->memory_start;
for(size_t i=0;i<total_blocks-1;i++)
{
void *curr = ptr + i * block_size;
void *next = ptr + (i + 1) * block_size;
*(void **)curr = next;
}
void *last = ptr + (total_blocks - 1) * block_size;
*(void **)last = NULL;
pool->free_list = pool->memory_start;
if(pthread_mutex_init(&pool->mutex,NULL) != 0)
{
free(pool->memory_start);
free(pool);
return NULL;
}
return pool;
}
void mp_init_thread_cache(CentralPool *central,int limit)
{
g_tcache.central = central;
g_tcache.cache_count = 0;
g_tcache.cache_limit = limit;
g_tcache.free_list = NULL;
}
static int fetch_from_central(int n)//从中央仓库申请n个字节
{
CentralPool *pool = g_tcache.central;
pthread_mutex_lock(&pool->mutex);
//如果中央仓库也是空的,那就不能分配给线程本地
if(pool->free_list == NULL)
{
pthread_mutex_unlock(&pool->mutex);
return 0;
}
void *first = pool->free_list;
void *curr = first;
int fetch_count = 1;
while(fetch_count < n && *(void **)curr != NULL)//当分配数量没有达到n且curr不为NULL时,就继续分配
{
curr = *(void **)curr;
fetch_count++;
}
pool->free_list = *(void **)curr;
*(void **)curr = NULL;
pthread_mutex_unlock(&pool->mutex);
g_tcache.free_list = first;
g_tcache.cache_count += fetch_count;
return fetch_count;
}
void *mp_alloc()
{
if(g_tcache.free_list == NULL)
{
fetch_from_central(g_tcache.cache_limit / 2);
if(g_tcache.free_list == NULL)
{
return NULL;
}
}
void *block = g_tcache.free_list;
g_tcache.free_list = *(void **)block;
g_tcache.cache_count--;
return block;
}
static void return_to_central(void *start,void *end,int n)//向中央仓库归还
{
CentralPool *pool = g_tcache.central;
pthread_mutex_lock(&pool->mutex);
*(void **)end = pool->free_list;
pool->free_list = start;
pthread_mutex_unlock(&pool->mutex);
}
void mp_free(void *ptr)
{
if(ptr == NULL) return;
*(void **)ptr = g_tcache.free_list;
g_tcache.free_list = ptr;
g_tcache.cache_count++;
if(g_tcache.cache_count > g_tcache.cache_limit)
{
int move_count = g_tcache.cache_limit / 2;
void *start = g_tcache.free_list;
void *curr = start;
for(int i=0;i<move_count-1;i++)
{
curr = *(void **)curr;
}
void *new_local_head = *(void **)curr;
return_to_central(start,curr,move_count);
g_tcache.free_list = new_local_head;
g_tcache.cache_count -= move_count;
}
}
void mp_destroy(CentralPool *pool)
{
pthread_mutex_destroy(&pool->mutex);
free(pool->memory_start);
free(pool);
}
最后是main.c:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>
#include "fixed_pool.h"
#define THREAD_NUM 4
#define COUNT 1000000
#define BLOCK_SIZE 16
CentralPool *g_central_pool;
void *test_pool(void *arg)
{
mp_init_thread_cache(g_central_pool, 1000);
for(int i=0;i<COUNT;i++)
{
void *p = mp_alloc();
if(p == NULL)
{
printf("内存池已空\n");
break;
}
*(int *)p = i;
mp_free(p);
}
return NULL;
}
void *test_malloc(void *arg)
{
for(int i=0;i<COUNT;i++)
{
void *p = malloc(BLOCK_SIZE);
if(p == NULL)
{
break;
}
*(int *)p = i;
free(p);
}
return NULL;
}
int main()
{
pthread_t t[THREAD_NUM];
double time_pool,time_sys;
clock_t start,end;
printf("线程数:%d 单线程次数:%d 块大小:%d\n",THREAD_NUM,COUNT,BLOCK_SIZE);
g_central_pool = mp_create_central(BLOCK_SIZE,THREAD_NUM * COUNT + 10000);
if (!g_central_pool)
{
perror("Create Central Pool Failed");
return 1;
}
start = clock();
for(int i=0;i<THREAD_NUM;i++) pthread_create(&t[i],NULL,test_pool,NULL);
for(int i=0;i<THREAD_NUM;i++) pthread_join(t[i],NULL);
end = clock();
time_pool = (double)(end - start) / CLOCKS_PER_SEC;
printf("内存池耗时%f秒\n",time_pool);
mp_destroy(g_central_pool);//销毁内存池
start = clock();
for(int i=0;i<THREAD_NUM;i++) pthread_create(&t[i],NULL,test_malloc,NULL);
for(int i=0;i<THREAD_NUM;i++) pthread_join(t[i],NULL);
end = clock();
time_sys = (double)(end - start) / CLOCKS_PER_SEC;
printf("malloc耗时%f秒\n",time_sys);
return 0;
}
这次的代码改动虽然比较大,但核心逻辑还是第二章的那些指针操作,我已经全部细致的讲过了。新加的两个函数,fetch_from_central和return_to_central,如果大家仔细读一遍,也会发现里面的逻辑似曾相识。
修改后的代码的运行结果如下:
可以看到,malloc的耗时依旧变化不大,这个原因前面已经讲过了,而内存池的耗时下降为了原来的十分之一,并且比malloc还要快百分之四十左右。
这实际上是符合预期的,虽然 malloc 内部也有 Arena 机制来减少锁竞争,但它的实现更通用、更复杂,需要处理的边界情况更多。而我们的定长内存池算法极简,几乎全是纯粹的指针移动,因此在特定场景下依然能获得较大的性能优势。
5. 总结
通过手写三代内存池,我们得到的不仅仅是一个在特定场景下比 glibc malloc 更快的专用分配器,更重要的是,我们收获了对高性能C编程的深刻理解。
单线程内存池的极致性能,源于 CPU Cache (LIFO) 和底层指针操作,硬件往往是决定算法效率的关键因素。
多线程性能的崩塌,说明了那把全局锁成为了我们系统的效率瓶颈,并发和共享在一定意义上是处于对立关系的。
TCMalloc 的核心思想,就是用空间换时间和分治来消灭锁竞争。
毋庸置疑,glibc malloc 依然是伟大的通用分配器。
但当我们面对特定、高频、性能敏感的场景时,手写一个专用的内存池,永远是压榨硬件性能的终极方法。