Linux内存规整机制深度剖析:伙伴系统应对高阶外部碎片的核心算法
在 Linux 运维或高负载服务开发中,经常能见到一种令人费解的现象:通过free -m观察,系统可用内存(Available Memory)还有数个吉字节(GB),但网卡驱动在尝试分配巨型帧(Jumbo Frame)连续 DMA 缓冲区时,或者内核透明大页(THP, Transparent Huge Pages)尝试分配 2MB 连续物理内存时,却频频报错-ENOMEM,甚至直接引发进程长时间卡顿在内核直接内存回收状态。
造成这一诡异现象的元凶正是物理内存外部碎片(External Fragmentation)。伙伴系统(Buddy System)将物理页按照 2 的幂次方组织为 Order 0 到 Order 10(4KB 到 4MB)的空闲链表。当系统长期高频分配释放不同生命周期的内存后,连续的大块物理页被严重打碎。为了在不杀进程、不重启系统的代价下重新拼凑出高阶连续内存,Linux 内核设计了精妙的内存规整机制(Memory Compaction)。
碎片根源与迁移类型隔离(Migrate Types)
如果所有的物理内存页都可以随意搬移,碎片整理将是一件水到渠成的事。但内核本身的静态数据结构、设备驱动的直接映射页表、硬件 DMA 锁定的页是绝对**不可移动(Unmovable)**的。一旦一个 4KB 的不可移动页恰好落在了某个 2MB 物理块的正中间,整个 2MB 块就永久性地失去了合并为高阶大页的能力。
为了从根本上遏制这种污染,内核将物理页按迁移类型(migratetype)实行严格的物理隔离:
MIGRATE_UNMOVABLE:分配给内核核心、中断栈、页全局目录等,生命周期内物理地址不可变动。MIGRATE_RECLAIMABLE:分配给文件系统元数据缓存(如 Dentry、Inode Cache),不可直接移动,但在内存吃紧时可以通过收缩器(Shrinker)主动丢弃释放。MIGRATE_MOVABLE:分配给用户态进程的匿名页(堆、栈)以及 Page Cache。由于用户态访问全部基于虚拟内存,内核只要在底层将物理内容拷贝到新页面,并原子更新进程的页表项(PTE),上层应用对其物理地址的漂移完全无感。
内存规整的主战场,正是针对MIGRATE_MOVABLE区域内散落的物理页进行空间重组。
规整算法核心:双指针相向扫描(Dual Scanner)
内存规整的精髓在于其极其克制且高效的双指针扫描算法。规整并不在全局胡乱调换页面,而是针对单个内存管理区(Zone,如 Zone Normal)启动两个扫描器:
- 空闲扫描器(Free Scanner):从 Zone 的物理起始地址(Zone Start PFN)自底向上单向步进,专门寻找处于未分配状态的空闲物理页,将其作为迁入目标(Target)。
- 迁移扫描器(Migrate Scanner):从 Zone 的物理末尾地址(Zone End PFN)自顶向下单向倒退,专门寻找处于使用中且允许搬移的物理页,将其作为迁出源(Source)。
规整的工作流水线如下:
- 迁移扫描器从顶部圈定一批可移动页,调用
isolate_migratepages()将它们从活跃 LRU 链表上暂时剥离,防止并发访问; - 空闲扫描器从底部圈定等量大小的空闲页块;
- 内核调用底层架构的拷贝函数(
copy_highpage)完成物理内容的完整复制; - 利用反向映射(RMAP)机制遍历所有映射了源页面的进程页表,更新其 PTE 并刷新 TLB 缓存;
- 原先位于顶部的源页被彻底释放并回归伙伴系统;
- 两个扫描器继续沿着相反方向推进,直到两个指针在 Zone 中间相遇,宣告一轮规整周期结束。
通过这种“把尾部的零碎数据搬运到头部空闲洞中”的策略,Zone 尾部的高阶物理块被大面积腾空。伙伴系统的伙伴合并机制(Buddy Merge)随即被激活,相邻的阶为 $k$ 的页面自动熔合成阶为 $k+1$ 的超大连续物理内存块。
异步守护进程与同步直接规整的权衡
规整并非全无代价,频繁的内存拷贝和 TLB 刷表会带来显著的 CPU 抖动。内核通过双轨机制来平衡吞吐与延迟:
- 异步后台规整(
kcompactd):每个 NUMA 节点绑定一个kcompactd内核线程。当高阶水位线(High Watermark)出现碎片预警时,该线程在后台以低优先级慢慢整理,避免业务线程感知。 - 同步直接规整(Direct Compaction):当用户线程在分配关键高阶内存失败,且 Direct Reclaim 依然凑不齐连续页时,分配路径会被迫就地挂起,进入深度的直接规整。此时若系统存在大量脏页,往往会诱发严重的毫秒级甚至秒级“毛刺”(Latency Spike)。
C23 实现:高阶碎片指数解析与主动规整触发器
在关键实时业务启动前,工程上通常需要主动探测当前系统的碎片指数,并在必要时显式向内核发起全局规整。以下是一段纯 C23 实现的碎片探测与干预工具。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> constexpr const char *BUDDYINFO_PATH = "/proc/buddyinfo"; constexpr const char *COMPACT_TRIGGER_PATH = "/proc/sys/vm/compact_memory"; // 读取并打印伙伴系统中特定 Zone 的高阶(Order 9 / 2MB)空闲页数量 void analyze_zone_fragmentation(void) { FILE *fp = fopen(BUDDYINFO_PATH, "re"); if (!fp) { perror("Failed to read buddyinfo"); return; } char line[512]; printf("%-20s %-10s %-12s\n", "Node/Zone", "Order 0(4K)", "Order 9(2M)"); printf("--------------------------------------------\n"); while (fgets(line, sizeof(line), fp)) { char node[32], zone[32]; int orders[11]; // 解析 buddyinfo 格式: Node 0, zone Normal 120 45 12 ... int matches = sscanf(line, "%*s %s %*s %s %d %d %d %d %d %d %d %d %d %d %d", node, zone, &orders[0], &orders[1], &orders[2], &orders[3], &orders[4], &orders[5], &orders[6], &orders[7], &orders[8], &orders[9], &orders[10]); if (matches == 13) { char zone_label[64]; snprintf(zone_label, sizeof(zone_label), "Node %s %s", node, zone); printf("%-20s %-10d %-12d\n", zone_label, orders[0], orders[9]); } } fclose(fp); } // 主动向内核下发强制规整指令 void trigger_proactive_compaction(void) { int fd = open(COMPACT_TRIGGER_PATH, O_WRONLY | O_CLOEXEC); if (fd < 0) { perror("Failed to open compact_memory (root privileges required)"); return; } constexpr const char trigger_val = '1'; if (write(fd, &trigger_val, 1) < 0) { perror("Write to compact_memory failed"); } else { printf("[SUCCESS] Global memory compaction successfully requested.\n"); } close(fd); } int main(void) { printf("=== Pre-Compaction Buddyinfo Snapshot ===\n"); analyze_zone_fragmentation(); printf("\nTriggering Manual Memory Compaction...\n"); trigger_proactive_compaction(); // 稍微休眠,等待后台扫描器收敛 sleep(1); printf("\n=== Post-Compaction Buddyinfo Snapshot ===\n"); analyze_zone_fragmentation(); return EXIT_SUCCESS; }生产环境参数调优指南
对于延迟极度敏感的高并发存储与网络节点,应当防患于未然:
- 调整
vm.extfrag_threshold:该内核参数控制内核倾向于分配规整还是直接回收,取值范围在 0 到 1000 之间。将其设为 500 可以让内核在碎片初露端倪时便更早介入整理,避免雪崩。 - 警惕
transparent_hugepage=always的陷阱:在随机小写密集的数据库(如 Redis、RocksDB)中,无脑开启总是分配巨页,会在碎片严重时引发大面积的直接规整阻塞。生产上更稳健的做法是将 THP 设定为madvise,仅在预先分配好连续大块内存的关键通道中,通过显式系统调用享受大页 TLB 加速,以此换取系统全局运行时的确定性。