19、内存池与虚拟内存:mmap映射、大页(Huge Page)支持、内存超卖
聊到内存池,很多人第一反应就是「提前切好一堆小块,用的时候直接拿」。这个思路没错,但只对了一半。真正的高性能分配器,必须跟操作系统底层打交道。你想想看,内存从哪来?不是从天上掉下来的,是从虚拟内存系统里要来的。
这一章,我们就深入底层,看看mmap怎么用、大页为什么能提速、以及内存超卖这个「刀尖上跳舞」的技巧。
19.1 mmap:直接跟内核要内存
传统上,我们申请大块内存用malloc。但malloc背后,其实也是调用brk或mmap。对于内存池这种场景,我个人更倾向直接用mmap。为什么?因为可控。
mmap可以直接映射匿名内存,不经过glibc的堆管理。你申请1GB,它就真的给你1GB的虚拟地址空间。而且,你可以指定起始地址、权限、甚至映射到文件。
来看一个简单的例子:
#include <sys/mman.h>
#include <unistd.h>
void* pool = mmap(NULL, 1024 * 1024 * 1024, // 1GB
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (pool == MAP_FAILED) {
perror("mmap failed");
exit(1);
}
// 使用完后释放
munmap(pool, 1024 * 1024 * 1024);
这段代码申请了1GB的虚拟内存。注意,虚拟内存不等于物理内存。你只是拿到了地址空间,物理页还没分配。真正分配物理页的时刻,是你第一次读写这块内存的时候——这就是所谓的「按需调页」。
避坑指南:我曾经在项目中直接用mmap申请了2GB内存,然后memset全部置零。结果系统直接OOM了。为什么?因为memset触发了所有页的物理分配。后来我改用MAP_POPULATE标志,让内核提前分配,但只分配真正需要的部分。
19.2 大页(Huge Page):减少TLB Miss
普通页大小是4KB。如果你有1GB内存,就需要262144个页表项。CPU的TLB(快表)根本装不下这么多。每次访问内存都要查页表,性能就下来了。
大页就是把这个粒度放大。x86架构下,大页有2MB和1GB两种。用大页,1GB内存只需要512个2MB页表项,TLB miss率大幅下降。
启用大页有两种方式:
- 透明大页(THP):内核自动帮你合并,但不可控,有时反而导致延迟抖动。
- 显式大页:通过mmap指定MAP_HUGETLB标志,或者通过hugetlbfs文件系统。
显式大页的用法:
void* pool = mmap(NULL, 2 * 1024 * 1024, // 正好2MB
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0);
但要注意,系统需要预先保留大页内存。你可以通过/proc/sys/vm/nr_hugepages来设置:
echo 512 > /proc/sys/vm/nr_hugepages # 预留512个2MB大页
注意:大页内存一旦预留,其他进程就不能用了。如果预留太多,普通进程可能申请不到内存。我见过有人把全部内存都配成大页,结果sshd都启动不了——嗯,这是个真实事故。
19.3 内存超卖:用虚拟内存骗过物理限制
内存超卖,说白了就是「你申请了10GB,但实际只用了2GB」。这在虚拟化场景下很常见。比如你跑10个虚拟机,每个都申请4GB内存,但实际每个只用了1GB。如果严格按申请量分配,你得准备40GB物理内存。但用了超卖,可能16GB就够了。
在内存池里,超卖怎么玩?
核心思路是:只提交(commit)你真正需要的物理页。mmap默认就是超卖的——它只分配虚拟地址,不分配物理页。但如果你用malloc,glibc可能会过度提交。
我习惯的做法是:
// 先申请一个大虚拟地址空间
void* huge_region = mmap(NULL, 16ULL * 1024 * 1024 * 1024,
PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
// 然后按需提交子区域
void* sub_region = mmap(huge_region + offset, 64 * 1024,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1, 0);
这样,你只提交了64KB的物理页,但虚拟地址空间占了16GB。这就是超卖。
核心要点:内存超卖依赖的是虚拟内存的「按需分配」特性。但要注意,如果所有进程同时把超卖的内存写满,系统就会OOM。这时候内核的OOM Killer会随机杀进程——你肯定不希望自己的内存池进程被干掉。
19.4 三者结合:一个高性能内存池的骨架
把mmap、大页、超卖结合起来,就能构建一个高性能内存池:
typedef struct {
void* base; // 虚拟基地址
size_t total_size; // 虚拟总大小
size_t committed; // 已提交物理大小
size_t page_size; // 页大小(普通页或大页)
} huge_pool_t;
huge_pool_t* pool_create(size_t virtual_size, int use_hugepage) {
huge_pool_t* pool = malloc(sizeof(*pool));
pool->total_size = virtual_size;
pool->committed = 0;
pool->page_size = use_hugepage ? (2 * 1024 * 1024) : getpagesize();
int flags = MAP_PRIVATE | MAP_ANONYMOUS;
if (use_hugepage) flags |= MAP_HUGETLB;
pool->base = mmap(NULL, virtual_size,
PROT_NONE, flags, -1, 0);
if (pool->base == MAP_FAILED) {
free(pool);
return NULL;
}
return pool;
}
void* pool_alloc(huge_pool_t* pool, size_t size) {
// 对齐到页边界
size_t aligned = (size + pool->page_size - 1) & ~(pool->page_size - 1);
if (pool->committed + aligned > pool->total_size) {
return NULL; // 虚拟空间耗尽
}
void* addr = pool->base + pool->committed;
void* result = mmap(addr, aligned,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1, 0);
if (result == MAP_FAILED) return NULL;
pool->committed += aligned;
return result;
}
这个池子,虚拟空间很大,但物理内存只按需提交。配合大页,TLB miss也少了。
19.5 一张图看懂本章知识体系
下面这张SVG图,把mmap、大页、超卖的关系画清楚了:
19.6 避坑与实战建议
最后,分享几个我踩过的坑:
- 大页预留不足:如果系统没预留大页,mmap带MAP_HUGETLB会失败。记得检查返回值。
- 超卖导致OOM:我曾在压测时把所有池子都写满,结果系统直接卡死。后来加了内存水位监控,超过80%就触发告警。
- 大页与普通页混用:同一个内存池里,不要一部分用大页、一部分用普通页。管理复杂度会爆炸。
- munmap时机:释放内存时,一定要用munmap归还给内核。如果只是free,虚拟地址空间不会释放,长期运行会地址空间耗尽。
嗯,这一章的内容就到这里。mmap、大页、超卖,这三个工具用好了,你的内存池就能在性能和资源利用率之间找到最佳平衡点。