内存分配器大概是系统软件里最沉默的角色——你天天用它,却几乎不会注意到它,直到并发压力把全局锁变成瓶颈。微软研究院的 Daan Leijen 花了数年时间打磨的 mimalloc,给出的核心判断非常干脆:传统分配器让所有线程抢同一批内存,本身就是设计错误。 把分配变成线程本地的事,再在必要时刻做轻量级的全局协调,性能和可扩展性就自然跟上了。
全局锁为什么是瓶颈
经典分配器(glibc 的 ptmalloc2、jemalloc 的早期版本)在多线程场景下的典型路径:
- 线程 A 要分配 32 字节 → 拿全局锁 → 从 central free list 找一块 → 释放锁。
- 线程 B 要分配 32 字节 → 拿全局锁 → 等线程 A 释放 → ……
线程数从 4 涨到 64,锁竞争不是线性增长——是近似二次方。更隐蔽的问题是 缓存行乒乓:全局元数据被不同核心反复修改,L3 缓存命中率骤降,实际延迟比锁等待本身更致命。
mimalloc 的分片思路
mimalloc 的设计起点是一个简单观察:绝大多数分配请求来自线程自己最近释放的内存。既然如此,让每个线程维护自己的 free list,大部分分配根本不需要碰全局结构。
具体做法:
- 线程本地 heap:每个线程拥有独立的 heap,heap 内按大小分 page(类似 slab)。分配时先从本地 page 的 free list 取,无锁、无竞争。
- 局部碎片不外溢:一个 page 满了之后,线程不会把碎片推回全局池,而是把整个 page 标记为"full"。只有当线程需要新 page 时,才从全局 arena 批量拿一批空 page。全局操作是批量化的,频率极低。
- 延迟合并(deferred merging):free 操作不立刻合并相邻块,而是把块挂回 free list。合并只在 page 快空时才触发,减少了元数据操作次数。
这套设计让分配路径在热态下变成:读线程本地指针 → 拿到块 → 返回。没有原子操作,没有锁,缓存行全在本核。
和 jemalloc / tcmalloc 的关键差异
| 特性 | mimalloc | jemalloc | tcmalloc |
|---|---|---|---|
| 线程本地结构 | 每线程独立 heap + page | 每线程 tcache,回填依赖 central | 每线程 cache,central 作为后备 |
| 碎片处理 | page 级别整块回收,不逐块归还 | size class 级别归还 central | 类似,但归还策略更激进 |
| 合并策略 | 延迟合并,page 级触发 | 即时合并 | 即时合并 |
| 大块分配 | 直接走 OS mmap,不进 heap | 类似 | 类似 |
延迟合并是 mimalloc 和 jemalloc 最本质的差异。jemalloc 在 free 时倾向于立刻合并,减少碎片但增加元数据操作;mimalloc 选择"先挂着,等时机再合并",换来更快的 free 路径和更少的元数据写操作。在短生命周期对象密集的场景(Web 服务器、游戏引擎),这个选择收益明显。
实际接入:替换你的 malloc
mimalloc 支持两种接入方式——动态替换和编译链接。下面给出可直接跑的完整示例。
方式一:动态替换(零代码改动)
编译出共享库,通过 LD_PRELOAD 注入,所有 malloc/free 调用自动重定向到 mimalloc:
# 克隆并编译
git clone https://github.com/microsoft/mimalloc.git
cd mimalloc && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make -j$(nproc)
# 生成的 libmimalloc.so 在 build/lib/ 下
# 直接用 LD_PRELOAD 替换你的程序内存分配器
LD_PRELOAD=./lib/libmimalloc.so.2 your_program
# 对比基准:不替换时
your_program
这种方式适合快速验证:不改代码、不改构建系统,跑一遍 benchmark 就能看到差异。
方式二:编译链接(更可控)
把 mimalloc 作为子项目或系统库链接进来,显式调用 mi_malloc / mi_free,或者用宏覆盖标准接口:
// bench_mimalloc.c — 最小对比示例
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// 如果编译时链接 mimalloc 并启用 -DMI_MALLOC_OVERRIDE,
// 下面的 malloc/free 自动映射到 mi_malloc/mi_free
// 否则就是系统默认分配器
#define ALLOC_COUNT 1000000
#define ALLOC_SIZE 64
double bench_malloc_free(void) {
clock_t start = clock();
for (int i = 0; i < ALLOC_COUNT; i++) {
void *p = malloc(ALLOC_SIZE);
// 简单写入,确保分配不是被优化掉
*(char *)p = 'x';
free(p);
}
clock_t end = clock();
return (double)(end - start) / CLOCKS_PER_SEC;
}
int main(void) {
double t = bench_malloc_free();
printf("malloc/free %d rounds (size=%d): %.4f s\n",
ALLOC_COUNT, ALLOC_SIZE, t);
return 0;
}
编译和运行:
# 用系统 malloc 编译
gcc -O2 bench_mimalloc.c -o bench_system && ./bench_system
# 用 mimalloc 编译(假设你已经 make install)
gcc -O2 bench_mimalloc.c -lmimalloc -o bench_mimalloc && ./bench_mimalloc
在 8 核机器上跑多线程版本差异更显著。下面加一个 pthread 压测:
// bench_mimalloc_mt.c — 多线程分配压测
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#define THREADS 8
#define ROUNDS 500000
#define SIZE 64
void *thread_func(void *arg) {
for (int i = 0; i < ROUNDS; i++) {
void *p = malloc(SIZE);
*(char *)p = 'x';
free(p);
}
return NULL;
}
double bench_mt(void) {
pthread_t threads[THREADS];
clock_t start = clock();
for (int i = 0; i < THREADS; i++)
pthread_create(&threads[i], NULL, thread_func, NULL);
for (int i = 0; i < THREADS; i++)
pthread_join(threads[i], NULL);
clock_t end = clock();
return (double)(end - start) / CLOCKS_PER_SEC;
}
int main(void) {
double t = bench_mt();
printf("%d threads, %d rounds/thread (size=%d): %.4f s\n",
THREADS, ROUNDS, SIZE, t);
return 0;
}
# 系统分配器
gcc -O2 bench_mimalloc_mt.c -lpthread -o bench_mt_system
./bench_mt_system
# mimalloc
gcc -O2 bench_mimalloc_mt.c -lmimalloc -lpthread -o bench_mt_mimalloc
./bench_mt_mimalloc
# 或者用 LD_PRELOAD 快速对比
LD_PRELOAD=/path/to/libmimalloc.so.2 ./bench_mt_system
典型结果:单线程差距可能在 5-15%,8 线程场景差距可以拉到 30-50%,因为系统 malloc 的全局锁在并发下开销急剧上升。
什么时候该换,什么时候别换
mimalloc 不是万能药。几个实际判断:
适合换的场景:
- 多线程服务端程序(HTTP server、RPC framework),分配/释放频率高、对象生命周期短。
- 游戏引擎和实时系统,帧内大量临时对象分配,需要稳定的低延迟。
- 原有分配器下已经观察到锁竞争瓶颈(perf top 里 __libc_malloc 占比异常)。
不需要换的场景: - 单线程或低并发程序,系统 malloc 已经够用,替换收益微乎其微。 - 长期驻留的大块分配为主(比如图片/视频缓冲区),分配器差异几乎不影响。 - 对碎片率极度敏感的长期运行服务——mimalloc 的延迟合并策略在某些极端长驻模式下碎片率可能比 jemalloc 高,需要实测确认。
接入建议清单:
- 先用
LD_PRELOAD做一轮 benchmark,确认有收益再改构建系统。 - 生产环境建议编译链接而非
LD_PRELOAD,避免部署环境差异导致意外。 - 开启
MIMALLOC_SHOW_STATS=1环境变量,程序退出时打印分配统计,观察碎片率和 page 使用情况。 - 如果你的程序有自定义 allocator 接口(比如 C++
std::allocator),可以直接底层替换,上层不动。
mimalloc 的价值不只是"更快"——它把分配器设计从"全局协调"拉到"本地优先 + 批量协调"的模型上,这个思路对任何需要高并发内存操作的系统都有参考意义。