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率大幅下降。

启用大页有两种方式:

  1. 透明大页(THP):内核自动帮你合并,但不可控,有时反而导致延迟抖动。
  2. 显式大页:通过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、大页、超卖的关系画清楚了:

内存池与虚拟内存核心关系 mmap 映射 直接向内核申请虚拟地址 大页 Huge Page 2MB/1GB 页表粒度 内存超卖 虚拟多、物理少 MAP_ANONYMOUS 不映射文件,纯匿名内存 MAP_HUGETLB 显式指定使用大页 PROT_NONE 初始 按需提交物理页 三者协同工作流程 1. mmap 申请大虚拟空间(超卖基础) 2. 按需提交物理页,使用大页减少TLB miss 3. 物理内存不足时,依赖超卖机制暂不分配

19.6 避坑与实战建议

最后,分享几个我踩过的坑:

  • 大页预留不足:如果系统没预留大页,mmap带MAP_HUGETLB会失败。记得检查返回值。
  • 超卖导致OOM:我曾在压测时把所有池子都写满,结果系统直接卡死。后来加了内存水位监控,超过80%就触发告警。
  • 大页与普通页混用:同一个内存池里,不要一部分用大页、一部分用普通页。管理复杂度会爆炸。
  • munmap时机:释放内存时,一定要用munmap归还给内核。如果只是free,虚拟地址空间不会释放,长期运行会地址空间耗尽。

嗯,这一章的内容就到这里。mmap、大页、超卖,这三个工具用好了,你的内存池就能在性能和资源利用率之间找到最佳平衡点。


公众号:蓝海资料掘金营,微信deep3321