Linux 内核内存紧凑与反碎片化深度实战:从 Buddy 分配到页面迁移的完整工程解析


引言:为什么内存碎片化是生产系统的隐形杀手

在运行 AI 推理服务、数据库或大数据平台的生产服务器上,随着系统运行时间增长,物理内存碎片化悄然累积。即使仍有充足的空闲内存,内核也可能无法分配连续的物理页面(HugePages 申请失败、DMA 缓冲区分配失败),导致服务性能下降甚至崩溃。

Linux 内核通过两大利器应对碎片化问题:反碎片化(Anti-Fragmentation)机制 和 内存紧凑(Memory Compaction)机制。前者从分配源头减少碎片产生的概率,后者则主动整理已有碎片。本文将完整剖析这两套机制的设计思想、实现原理和工程实战调优方法。


一、碎片化的本质与分类

1.1 外部碎片 vs 内部碎片

内存碎片化分为两类:

  • 内部碎片:已分配但未被使用的内存(例如申请 4KB 但只用了 1KB)。
  • 外部碎片:大量不连续的小块空闲内存,导致无法满足大内存请求。

Buddy 分配器将内存按阶(order)组织为 2^n 个页面的块,天然容易产生外部碎片——频繁的分配/释放打乱了连续内存的物理分布。

1.2 碎片化的实际影响

在生产环境中的典型表现:

  • HugePages 分配失败:THP(Transparent Huge Pages)无法凑出 2MB 连续物理页,回退到普通 4KB 页,TLB miss 暴增。
  • DMA 缓冲区分配失败:设备驱动程序需要物理连续的大块内存(如 GPU 显存映射、网卡 ring buffer)。
  • CMA 区域碎片化:连续内存分配器区域碎片化后,图形渲染卡顿。
  • VM 启动失败:KVM 虚拟机申请大页内存失败。

Buddy 分配器自身通过 /proc/buddyinfo 暴露内存碎片状态,但这只是诊断的起点,解决碎片需要理解内核的反碎片化和紧凑机制。


二、反碎片化机制:按可移动性分组

2.1 设计思想

反碎片化的核心洞察是:不同类型的页面具有不同的可移动性。如果能将可移动页面和不可移动页面隔离到不同区域,那么释放可移动页面时就能利用紧凑机制把它们聚合成大块空闲内存。

Linux 内核在 buddy 分配器的每个 zone 内部,将页面按 migrate type(迁移类型)分组管理。

2.2 迁移类型(MIGRATE_TYPES)

内核定义了以下迁移类型(参见 include/linux/mmzone.h):

#define MIGRATE_UNMOVABLE     0   // 不可移动:内核数据结构、page tables、slab 对象
#define MIGRATE_MOVABLE       1   // 可移动:用户页面、页面缓存(file-backed pages)
#define MIGRATE_RECLAIMABLE   2   // 可回收但不可移动:内核缓存(如 dentries、inodes)
#define MIGRATE_PCPTYPES      3   // Per-CPU page cache(不在 buddy 中,独立管理)
#define MIGRATE_ISOLATE       4   // 完全隔离(用于内存热插拔)
#define MIGRATE_TYPES         5

2.3 按迁移类型的页面分组(Page Group by Mobility)

在 buddy 分配器的 free_area 结构中,每一阶(order)都维护一个按迁移类型分离的链表:

struct free_area {
    struct free_list free_list[MIGRATE_TYPES];  // 每种迁移类型一个链表
    unsigned long    nr_free;                    // 该阶总空闲数
};

当分配页面时,分配器先从指定的迁移类型链表中获取,只有在 fallback 时才从其他类型的链表中窃取(steal)。

2.4 Fallback 策略与 PCP 分配

分配器的 fallback 顺序在 fallbacks[] 数组中定义:

static int fallbacks[MIGRATE_TYPES][MIGRATE_TYPES-1] = {
    [MIGRATE_UNMOVABLE]   = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE, MIGRATE_ISOLATE },
    [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE,   MIGRATE_MOVABLE, MIGRATE_ISOLATE },
    [MIGRATE_MOVABLE]     = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_ISOLATE },
    [MIGRATE_ISOLATE]     = { MIGRATE_UNMOVABLE,   MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE },
};

关键设计:UNMOVABLE 优先 fallback 到 RECLAIMABLE,避免侵入 MOVABLE 区域;MOVABLE 优先 fallback 到 RECLAIMABLE,避免污染 UNMOVABLE 区域。这种不对称设计确保了 MOVABLE 区域尽可能保持为移动页面的"乐园"。

2.5 实际分类逻辑

页面在内核中被标记为可移动的场景:

  • 匿名页面(PageAnonymous):进程堆/栈分配的用户匿名内存 → MIGRATE_MOVABLE
  • 页面缓存(PageMappedToDisk):文件映射的页面缓存 → MIGRATE_MOVABLE
  • 内核 slab 分配的内存:取决于 slab cache 是否标记 SLAB_RECLAIM_ACCOUNT

内核在 gfpflags_to_migratetype() 函数中根据 gfp_flags 决定页面的迁移类型:

static inline int gfpflags_to_migratetype(const gfp_t gfp_flags)
{
    /* 不可移动:无 __GFP_MOVABLE 且指定 __GFP_RECLAIMABLE 时可能为 RECLAIMABLE */
    if (unlikely(page_group_by_mobility_disabled))
        return MIGRATE_UNMOVABLE;

    if (gfp_flags & __GFP_MOVABLE)
        return MIGRATE_MOVABLE;

    if (gfp_flags & __GFP_RECLAIMABLE)
        return MIGRATE_RECLAIMABLE;

    return MIGRATE_UNMOVABLE;
}

关键点:通过控制 gfp_flags 中的 __GFP_MOVABLE、__GFP_RECLAIMABLE 等标志,内核从源头保证了不同可移动性的页面被分配到对应的迁移类型区域。


三、内存紧凑(Memory Compaction):主动整理碎片

3.1 触发条件

内存紧凑在以下场景被触发:

  1. 直接路径中的分配失败:当高阶分配(order >= pageblock_order) 无法满足时,内核调用 try_to_compact_pages()。
  2. khugepaged:透明大页后台守护进程在合并小页到大页前先执行 compaction。
  3. 手动触发:echo 1 > /proc/sys/vm/compact_memory 手动触发全系统紧凑。
  4. kcompactd:每个内存 zone 的后台守护进程,周期性检查碎片情况。
  5. hugeltb 分配前:大页分配尝试前先 compact。

3.2 紧凑算法:双扫描器设计

内存紧凑的核心逻辑在 mm/compact.c 中实现,采用双扫描器设计:

struct compact_control {
    struct list_head freepages;     // 已扫描到的空闲页面
    struct list_head migratepages;  // 已扫描到的可迁移页面
    unsigned long nr_migratepages;  // 已发现的可迁移页面数
    unsigned long nr_freepages;     // 已发现的空闲页面数
    unsigned long free_pfn;         // 空闲页面扫描器当前位置
    unsigned long migrate_pfn;      // 可迁移页面扫描器当前位置
    unsigned long fast_start_pfn;   // 快速模式起始位置
    bool fast_only;                 // 仅快速模式
    bool contended;                 // 是否发生竞争
};

算法流程:

阶段 1(快速模式 - fast compaction):
    - 从 zone->compact_cached_free_pfn 开始
    - 同时扫描两个扫描器:
      ① free_pfn 从低地址向高地址扫描,寻找连续空闲页面
      ② migrate_pfn 从高地址向低地址扫描,寻找可迁移页面
    - 当找到足够的可迁移页面 > 空闲页面时,执行迁移

阶段 2(慢速模式 - slow compaction):
    - 跳过 migrate 扫描器的 "跳过" 标记区域
    - 对每个 pageblock 检查是否为 SKIP_MIGRATE
    - 更彻底的扫描

为什么两个扫描器方向相反? 从两端同时扫描可以在 zone 的两侧"挤压"页面——低端的高地址空闲页面和低端的需要迁移的页面形成互补,最大化紧凑效率。

3.3 页面迁移过程

紧凑过程中每个页面的迁移步骤:

static int migrate_pages(struct list_head *l, migration_fn get_new_page,
                         free_page_t put_old_page, unsigned long private,
                         enum migrate_mode mode, int reason)
{
    // 1. 隔离页面从 LRU 和 buddy 中移除
    unmap_and_page_rollback()

    // 2. 分配目标页面(在连续空闲区域)
    newpage = get_new_page(page, private);

    // 3. 迁移页面内容
    migrate_page(page, newpage);

    // 4. 释放旧页面回到 buddy(变为连续大块)
    // 5. 更新反向映射(rmap)和进程页表
}

具体步骤细化为:

  1. 隔离(isolate):将页面从 LRU 链表和用户空间页表中断开。
  2. 解除映射(unmap):清除所有映射到该页面的 PTE。
  3. 分配新页(allocate):在获得的连续空闲区域中分配新页面。
  4. 拷贝内容(copy):copy_highpage() 将原页面内容复制到新页面。
  5. 更新映射(remap):更新所有映射到新 PTE 指向新物理地址。
  6. 释放旧页(free):旧页面变为空闲,扩展连续空闲区域。

3.4 对文件系统缓存的特殊处理

对于 file-backed 页面(如文件页面缓存),紧凑流程需要额外处理脏页:

  • 干净的页面缓存:直接标记为可迁移,迁移后还需要更新 address_space 的反向映射。
  • 脏页(dirty pages):需要先回写,回写完成后才能迁移。
  • 如果在迁移过程中有进程通过 mmap 尝试写入该页面,会触发 write-protect page fault 等待迁移完成。

3.5 紧凑深度与碎片指数(Fragmentation Index)

/proc/sys/vm/extfrag_threshold(默认 500)控制 extfrag 行为的敏感度。内核还通过 /sys/kernel/debug/extfrag/extfrag_index 暴露每个阶的碎片指数:

碎片指数 = 1 - (最大连续空闲块大小 / 该阶总空闲数)
  • 接近 0:内存基本连续,无碎片
  • 接近 1:严重碎片化,空闲页面散布在 zone 各处

kcompactd 守护进程根据这个指数决定是否启动周期性紧凑:

static int kcompactd(void *p)
{
    pg_data_t *pgdat = (pg_data_t *)p;

    while (!kthread_should_stop()) {
        // 等待唤醒或超时(compact_interval)
        wait_event_freezable(pgdat->kcompactd_wait,
            kcompactd_should_work(pgdat) || kthread_should_stop());

        kcompactd_do_work(pgdat); // 执行紧凑

        schedule_timeout_interruptible(
            msecs_to_jiffies(pgdat->kcompactd_max_defer));
    }
    return 0;
}

四、工程实战:诊断与调优

4.1 诊断碎片化状态

# 查看 buddy 分配器各阶空闲情况
cat /proc/buddyinfo

# 输出示例(Node 0, zone Normal):
# Node 0, zone   Normal      1     0     2     1     2     1     2     1     0     1     3
# Order:                 0     1     2     3     4     5     6     7     8     9    10
# Order 10 = 1024 pages = 4MB 连续块

# 查看碎片指数
cat /sys/kernel/debug/extfrag/extfrag_index

# 查看 compact 统计
grep -i compact /proc/vmstat
# compact_scanned:  已扫描页面数
# compact_migrated: 已迁移页面数
# compact_isolated: 已隔离页面数
# compact_daemon_wake: kcompactd 唤醒次数
# compact_stall:    触发 compact 次数(分配路径)

# 按迁移类型查看空闲分布
cat /proc/pagetypeinfo

4.2 关键调优参数

参数 默认值 说明
vm.extfrag_threshold 500 extfrag 敏感度,越低越激进
vm.compact_memory 0 写 1 触发全系统 compact
vm.compact_unevictable_allowed 1 是否允许 compact 不可驱逐页面
vm.defrag_mode - THP 碎片整理模式选择
vm.numa_zonelist_order Node NUMA 节点分配顺序
kcompactd 超时 500ms kcompactd 睡眠间隔

4.3 生产场景调优策略

场景一:AI 推理服务(大页敏感)

# 提前在 zone 中预留大量可移动页,给 compact 更多素材
echo 1 > /proc/sys/vm/compact_unevictable_allowed

# 降低 extfrag threshold,更早触发 compact
echo 100 > /proc/sys/vm/extfrag_threshold

# 启用 THP(Transparent Huge Pages)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag

场景二:数据库工作负载(大量脏页,compact 效率受限)

# 数据库场景中,脏页回写会拖慢 compact 速度
# 控制脏页比例,减少 compact 过程中的脏页阻塞
echo 10 > /proc/sys/vm/dirty_ratio
echo 5 > /proc/sys/vm/dirty_background_ratio

# 限制 compact 避免过度消耗 I/O 带宽
echo 0 > /proc/sys/vm/compact_unevictable_allowed

# 考虑禁用 THP,改用显式大页(hugetlbfs)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo 2048 > /proc/sys/vm/nr_hugepages

场景三:容器化环境(高密度部署)

# 在容器中 compact 可能引发性能抖动
# 通过 cgroup 限制 compact 触发频率

# 容器场景推荐使用 cgroup v2 的 memory.high 限制
echo "2G" > /sys/fs/cgroup/memory.high

# 容器内禁用 compact(交由宿主机层统一管理)
echo 0 > /proc/sys/vm/compact_memory  # 不允许手动触发

4.4 kcompactd 与调度的交互

在 NUMA 系统中,每个节点独立运行 kcompactd 守护进程。多节点场景下的 compact 策略需要注意:

  • AutoNUMA balancing 会将页面迁移到本地节点,可能与 compact 竞争页面迁移。
  • 建议在 AutoNUMA 环境降低 compact 频率,避免"拉锯": bash echo 0 > /proc/sys/kernel/numa_balancing # 或惰性模式

4.5 compact 失败的后果与应对

当 compact 无法成功消除碎片时:

  1. 直接回收(direct reclaim):尝试回收更多内存,将它们变为 MOVABLE 类型。
  2. OOM killer:申请 order-0 页面时也失败,触发 OOM。
  3. compact stall 统计:cat /proc/vmstat | grep compact_stail 可观察 compact 频繁触发的节点。

长时间 compact_stall 高频次增长是碎片化的强烈信号,需要: - 检查是否有内核 slab 泄漏(不可移动页膨胀) - 考虑重启工作负载或 NUMA 节点 rebalance - 使用 slub_debug 检查 SLAB 分配器状态


五、内核演进:compact 机制的未来方向

Linux 内核社区在 compact 方向的持续演进:

5.1 Proactive Compaction(主动紧凑)

6.11+ 内核引入 /proc/sys/vm/proactive_compact 参数,允许后台在非分配失败时主动执行 compact,减少分配路径上的延迟抖动。

5.2 与 THP 的深度协作

内核逐步实现了 defrag_mode 选择策略: - always:总是先 compact 再分配大页(最低延迟但最高开销) - defer:先分配普通页,后台 khugepaged 负责合并 - madvise:仅对 madvise(MADV_HUGEPAGE) 区域进行 compact - never:禁用 THP 碎片整理

5.3 NUMA-aware Compaction

当前 compact 主要工作在单个 zone 内,跨 NUMA 节点的 compact 代价较高。社区正在探索跨节点 compact 优化,将碎片化严重节点的可迁移页面迁移到可整型空间更多的节点。

5.4 与 memory folio 的协同

folio(大页抽象层)的引入让 compact 可以以更大的粒度(如 1GB HugePages、THP 对应的 folio)为单位优化,减少 compact 迭代次数。


总结

Linux 内核的内存碎片化治理分为两层架构:

预防层:按迁移类型(migrate type)分组页面,从分配源头隔离不同可移动性的页面。UNMOVABLE/MOVABLE/RECLAIMABLE 三类页面的分离保证了可移动页面聚集的区域可以通过紧凑恢复连续性。

修复层:内存紧凑通过双扫描器机制,将分散的可迁移页面集中到已发现的空闲区域,将空闲"空洞"连续化。配合 kcompactd 守护进程和碎片指数监控,实现对碎片化的持续治理。

工程实践中,理解碎片化的触发条件和 compact 的性能开销是关键。不同负载场景(AI 推理、数据库、容器化)需要差异化的 compact 策略——没有"一刀切"的最优参数,只有根据实际碎片化特征和性能需求进行的权衡调优。

掌握 /proc/buddyinfo、/proc/pagetypeinfo、/proc/vmstat 中的 compact 计数器,以及 extfrag_index 等诊断工具,是系统工程师识别和解决内存碎片化问题的核心能力。


参考资料

  • Linux Kernel Source: mm/compact.c、mm/page_alloc.c、mm/migrate.c
  • Documentation: Documentation/admin-guide/mm/transhuge.rst
  • Documentation: Documentation/admin-guide/sysctl/vm.rst
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部