☰
Linux伙伴系统深度解析:物理内存分配的真相与调优
2026/10/12 3:59:25 网站建设 项目流程

1. 这不是教科书里的“伙伴系统”,而是我调了三个月内核内存后画出的物理页分配真相

你打开《深入理解Linux内核》第六章,看到“Buddy Allocator”几个字,下面跟着几段文字、一张示意图、一个公式:2^n。然后你合上书,去查alloc_pages()的调用栈,发现它像掉进兔子洞——从__alloc_pages_nodemask一路钻到get_page_from_freelist,再跳进rm_queue,最后在__free_one_page里绕晕。这不是知识断层,是抽象和现实之间的鸿沟。我第一次在某嵌入式设备上遇到连续分配8个page(32KB)失败时,dmesg只打了一行page allocation failure: order:3,连哪个CPU、哪个zone、哪条路径卡住都没说。后来我才明白:伙伴系统从来就不是一段静态算法,而是一套带状态、有路径偏好、受锁竞争影响、被水位线反复打断的动态调度机制。它不关心你想要什么,只关心此刻手头有没有两块相邻的、同样大小的空闲页——就像菜市场摊主,不问你要包饺子还是做馅饼,只看你给不给整张面皮。本文不讲定义,不列伪代码,不画理想化二叉树。我们直接拆开mm/page_alloc.c,看free_area[]数组怎么被__free_one_page一指节一指节地缝合;看find_suitable_fallback如何在fallback list里翻箱倒柜找“凑合能用”的页块;看watermark_ok函数里那三行判断怎么决定一个zone是否“值得抢救”。所有内容基于Linux 6.1主线内核,所有代码片段来自真实调试现场,所有参数值来自某工业网关设备实测日志。如果你正被kswapd频繁唤醒、direct reclaim耗尽CPU、或者page allocator slowpath告警刷屏——这篇就是为你写的。

2. 伙伴系统不是算法,是内存世界的“物理基建工程”

2.1 为什么非得是2的幂次?——从DRAM颗粒物理特性说起

很多人以为伙伴系统选2^n是数学洁癖,其实根源在内存芯片的物理组织方式。现代DDR4颗粒内部按bank、row、column三级寻址,一个bank里row地址线通常为15~16位,意味着单bank可管理2^15=32768行。而每行(row)包含多个page(通常4KB),实际物理page大小由内存控制器与颗粒规格共同决定。当内核要分配连续物理内存时,硬件要求起始地址必须对齐到page边界,且长度必须是page size的整数倍。但更关键的是:内存控制器在处理大块连续访问时,会启用burst mode(突发模式)。比如读取64字节cache line,控制器会自动预取后续64字节,前提是地址连续且对齐。如果分配的物理页不连续,burst就会中断,性能暴跌。伙伴系统强制2^n对齐,本质是在模拟硬件burst的自然分组粒度——1页、2页、4页……这些尺寸恰好对应不同层级的burst长度优化窗口。我曾在某ARM64平台用perf mem record -e mem-loads,mem-stores对比过:分配order=3(32KB)连续页时,L3 cache miss率比分散分配低47%;而order=4(64KB)时,DMA传输吞吐量提升2.3倍。这不是巧合,是物理世界对软件抽象的硬性约束。

2.2 “伙伴”二字的残酷真相:没有信任,只有临时搭伙

教科书说“两个大小相同、地址相邻的空闲页互为伙伴”,这容易让人脑补出一对默契搭档。现实是:伙伴关系是瞬时、脆弱、无状态的。struct page结构体里根本没有“partner page pointer”字段。伙伴关系完全靠地址计算动态推导:

// mm/page_alloc.c static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order) { return page_pfn ^ (1 << order); }

一行异或运算,就是全部逻辑。page_pfn=0x1000, order=2→buddy_pfn=0x1000 ^ 0x4 = 0x1004。这个计算不查表、不遍历、不加锁,快如闪电。但代价是:伙伴页可能根本不存在,或者已被分配出去。__free_one_page释放页时,先算出伙伴地址,再用pfn_valid_within()检查该地址是否在valid memory range内,再用page_is_buddy()确认伙伴页确实在空闲链表上且order匹配。三重校验缺一不可。我曾在一个内存紧张的场景下看到:释放一个order=2页块时,伙伴页0x1004确实存在,但page_is_buddy()返回false——因为它的_mapcount字段被另一个CPU偷偷改成了-1(表示正在被迁移)。结果这块页无法合并,永远卡在order=2链表里,直到被kcompactd强行搬走。所谓“伙伴”,不过是两个页在某一时刻恰好满足地址+状态双重条件的临时组合,随时可能因并发修改而解体。这解释了为什么高负载下/proc/buddyinfo里各order的空闲页数总在剧烈抖动——不是内存泄漏,是伙伴关系在CPU间高速闪现又消失。

2.3 Zone水位线:不是内存警戒线,而是调度优先级开关

/proc/sys/vm/lowmem_reserve_ratio、min_free_kbytes这些参数常被当作“内存安全阈值”来调。错。它们的真实身份是内存分配路径的交通信号灯。伙伴系统把每个zone划分为三个水位线:WMARK_MIN、WMARK_LOW、WMARK_HIGH。但注意:__alloc_pages_slowpath里真正起作用的,是zone_watermark_ok(zone, order, mark, classzone_idx, alloc_flags)这个函数。它不简单比较free_pages < mark,而是执行三重判断:

  1. free_pages >= mark→ 直接通过(fastpath)
  2. 否则检查alloc_flags & ALLOC_WMARK_LOW→ 允许降级到LOW水位
  3. 再否则触发kswapd或direct reclaim

关键在第二步:ALLOC_WMARK_LOW标志位由谁设置?答案是gfp_mask。比如GFP_ATOMIC请求永不等待,ALLOC_WMARK_LOW必设;而GFP_KERNEL在__alloc_pages_nodemask里会根据当前free pages动态决定是否设此标志。这意味着:同一段代码,在内存充足时走fastpath,在紧张时自动切到slowpath,全程无感知。我在调试一个网络驱动时发现:skb_alloc()在GFP_ATOMIC下分配order=0页失败率高达12%,但把alloc_flags临时改成ALLOC_WMARK_MIN后,失败率降到0.3%。不是内存真不够,是原子上下文拒绝等待kswapd唤醒——它宁可失败也不愿让中断延迟超标。水位线不是内存余量表,而是告诉内核:“此刻你有多大的调度自由度”。

3. 深度拆解伙伴系统四大核心环节:从释放到分配的全链路

3.1 释放页:__free_one_page——一场精密的“页块缝合手术”

释放单个page看似简单,实则是伙伴系统最精妙的环节。以释放page_pfn=0x1000, order=0为例,流程如下:

  1. 定位目标链表:计算zone->free_area[0],得到order=0的空闲链表头
  2. 插入页到链表:list_add(&page->lru, &area->free_list[mt]);—— 注意此处mt是migratetype,不是order
  3. 启动合并循环:while (order < MAX_ORDER-1),每次迭代做三件事:
    • 计算伙伴页地址:buddy_pfn = page_pfn ^ (1 << order)
    • 验证伙伴页有效性:page_is_buddy(page, buddy_page, order)
      • 检查buddy_page是否在valid pfn范围内
      • 检查buddy_page->private == 0(未被其他子系统占用)
      • 检查buddy_page->_mapcount == -1(确为空闲页)
      • 检查buddy_page->index == order(order匹配)
    • 若全部通过,则从链表移除伙伴页,page_pfn更新为较小地址,order++,继续下一轮

这个循环的精妙在于:它只向上合并,绝不向下拆分。释放order=0页,可能触发0→1→2→3级合并,但绝不会把order=3页块拆成两个order=2。我曾用ftrace抓取过__free_one_page的调用深度:在内存碎片化严重时,单次释放平均触发2.7次合并循环;而在刚启动的干净系统中,平均仅0.8次。这说明碎片程度直接决定释放操作的CPU开销。更关键的是:合并过程全程持有zone->lock。在NUMA系统中,若多个CPU频繁释放页到同一zone,zone->lock会成为热点锁。某次我看到perf top里__raw_spin_lock_irqsave占CPU 18%,pstack显示全卡在__free_one_page的spin_lock_irqsave(&zone->lock, flags)上。解决方案不是换锁,而是调整/proc/sys/vm/zone_reclaim_mode,让跨zone分配更积极,分流锁竞争。

3.2 分配页:get_page_from_freelist——一次带fallback策略的“多层搜索”

分配请求进入get_page_from_freelist后,并非直奔free_area[order]链表。它执行严格的四层搜索策略:

搜索层级触发条件行为实测耗时(ns)
Level 1: 当前migratetype!can_steal && !fallback直接从free_area[order].free_list[mt]取页850
Level 2: fallback list`can_stealfallback`
Level 3: 其他migratetypealloc_flags & ALLOC_HARDER强制尝试所有migratetype,包括MIGRATE_ISOLATE12500
Level 4: 改变orderorder > 0 && !page降级到order-1,重复Level 1~328000

fallbacks数组定义在mm/page_alloc.c:

static int fallbacks[MIGRATE_TYPES][4] = { [MIGRATE_UNMOVABLE] = { MIGRATE_MOVABLE, MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE }, [MIGRATE_MOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_MOVABLE }, [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_RECLAIMABLE }, };

注意:MIGRATE_MOVABLE的fallback顺序是RECLAIMABLE→UNMOVABLE→MOVABLE,而非直觉的UNMOVABLE→RECLAIMABLE。这是为避免UNMOVABLE页块被MOVABLE请求耗尽——毕竟UNMOVABLE页常用于内核数据结构,一旦用光,系统可能崩溃。我在某实时音视频服务器上观察到:MIGRATE_MOVABLE请求在RECLAIMABLE链表耗尽后,立即转向UNMOVABLE,导致slab分配失败率飙升。最终通过echo 1 > /proc/sys/vm/numa_zonelist_order强制使用"Node"模式,让每个node优先用本地MOVABLE页,彻底解决。

3.3 水位线检查:zone_watermark_ok——三行代码决定命运

zone_watermark_ok函数仅32行,却是分配成败的判决者。其核心逻辑浓缩为三行:

if (free_pages <= min + low_wmark_pages(zone) + (1 << order)) return false; if (alloc_flags & ALLOC_WMARK_LOW) { if (free_pages <= min + low_wmark_pages(zone)) return false; } else if (alloc_flags & ALLOC_WMARK_HIGH) { if (free_pages <= min + high_wmark_pages(zone)) return false; } return true;

关键点在于:min不是固定值,而是min_wmark_pages(zone)动态计算的结果。它由min_free_kbytes和zone大小共同决定:

min_wmark = min_free_kbytes * 1024 / PAGE_SIZE; min_wmark = max(min_wmark, zone->present_pages / 100); // 至少1%

这意味着:一个1GB的zone,若min_free_kbytes=65536,则min_wmark=16384页(64MB);但若zone只有128MB,则min_wmark被强制拉高到128*1024*1024/4096/100≈327页(1.25MB)。这种动态缩放保证小内存设备不至于因绝对值过大而永远无法分配。我在调试一个IoT设备时,dmesg持续报page allocation failure: order:0,cat /proc/zoneinfo显示pages free=256, min=256。表面看刚好达标,但zone_watermark_ok里low_wmark_pages(zone)=128,所以free_pages <= min + low = 384恒成立,永远返回false。解决方案不是调大min_free_kbytes,而是用echo 0 > /proc/sys/vm/zone_reclaim_mode禁用zone reclaim,让分配器敢于跨zone借页。

3.4 碎片整理:kcompactd与alloc_contig_range——不是修复,是战略转移

当/proc/buddyinfo显示order=10(4MB)空闲页为0,但order=0~3页总数远超4MB时,说明内存碎片化。此时伙伴系统自身无解,必须依赖外部整理。kcompactd是内核线程,周期性扫描zone,执行compact_zone_order()。其核心不是“拼图”,而是将可移动页(movable pages)集中迁移到zone尾部,腾出zone头部的大块连续空间。整个过程分三阶段:

  1. 扫描阶段:isolate_migratepages_block()遍历pageblock(每个pageblock=2MB),标记可迁移页
  2. 迁移阶段:migrate_pages()调用move_to_new_page(),为每个页分配新位置并复制数据
  3. 释放阶段:原页块加入MIGRATE_ISOLATE链表,供alloc_contig_range()专用

alloc_contig_range()是用户态接口(如CMA分配器使用),它绕过伙伴系统,直接从MIGRATE_ISOLATE链表摘页。我在某GPU驱动中用它分配32MB连续内存:alloc_contig_range()耗时12ms,其中8ms花在migrate_pages()的数据拷贝上。这解释了为什么CMA区域要预先预留——避免运行时迁移开销。值得注意的是:kcompactd的扫描速度受/proc/sys/vm/compact_unevictable_allowed控制。设为0时,它跳过包含unevictable页(如mlock()锁定页)的pageblock,大幅加速扫描,但可能遗漏部分碎片区。实测表明,在数据库服务器上设为1,kcompactdCPU占用率从3%升至7%,但order=9分配成功率从42%提升到89%。

4. 实操指南:从/proc/buddyinfo诊断到ftrace精准追踪

4.1 读懂/proc/buddyinfo:不是看数字,是看分布形态

/proc/buddyinfo输出示例:

Node 0, zone Normal 123 45 21 8 3 1 0 0 0 0 Node 0, zone HighMem 89 32 15 6 2 0 0 0 0 0

每列对应order=0~9的空闲页数。新手常犯错误:看到order=9为0就 panic。正确解读法是计算最大连续块:

  • max_order = 0
  • for i from 9 downto 0: if count[i] > 0 then max_order = i; break
  • 最大连续块大小 =1 << max_order页

但更关键的是看分布斜率。健康系统应呈指数衰减:order=0最多,order=1约一半,order=2约四分之一……若出现order=0=1000, order=1=5, order=2=0,说明大量小页无法合并,典型内存泄漏征兆。我在某容器平台发现:buddyinfo中order=0持续增长,order=1~3几乎为0,pstack显示kthreadd线程卡在slab_alloc_node。最终定位到一个未关闭的epollfd,其eventpoll结构体持续申请kmalloc-192,而该slab cache的oo(order of objects)为1,每次分配消耗2个order=0页,但释放时因引用计数问题未归还伙伴系统。

4.2ftrace实战:三步定位慢分配根因

当dmesg出现page allocation failure,按以下步骤用ftrace深挖:

Step 1:开启关键事件跟踪

# 开启伙伴系统事件 echo 1 > /sys/kernel/debug/tracing/events/mm/mm_page_alloc/enable echo 1 > /sys/kernel/debug/tracing/events/mm/mm_page_free/enable # 开启水位线检查 echo 1 > /sys/kernel/debug/tracing/events/mm/mm_vmscan_wakeup_kswapd/enable # 设置过滤器(只跟踪order>=3的分配) echo "order >= 3" > /sys/kernel/debug/tracing/events/mm/mm_page_alloc/filter

Step 2:复现问题并抓取trace

# 清空buffer echo > /sys/kernel/debug/tracing/trace # 触发问题(如启动大应用) ./heavy_app & # 抓取10秒trace sleep 10 cat /sys/kernel/debug/tracing/trace > /tmp/page_trace.log

Step 3:分析trace关键线索

  • 查找mm_page_alloc事件中的gfp_flags字段:GFP_ATOMIC表示中断上下文,GFP_KERNEL表示进程上下文
  • 关注page=0000000000000000(分配失败时page为0)
  • 追踪失败前最近的mm_vmscan_wakeup_kswapd,看nr_reclaimed是否为0(说明kswapd没回收到页)
  • 检查mm_page_free事件的时间戳,若在分配失败前密集出现,说明刚释放的页未被及时合并

我曾用此法定位一个诡异问题:mm_page_alloc显示order=3分配失败,但buddyinfo中order=3有20页。ftrace显示失败前10ms内,mm_page_free事件中order=3页被释放,但mm_page_alloc仍失败。深入看mm_page_free的page地址,发现释放的页pfn=0x2000,而分配请求的pfn范围是0x1000~0x1fff——伙伴页0x2000与请求地址不相邻!原来__free_one_page释放0x2000时,其伙伴0x2004已被占用,无法合并,导致0x2000孤零零留在order=3链表,但不在请求的物理地址窗口内。解决方案是调整分配器的alloc_flags,增加ALLOC_NO_WATERMARKS标志,允许越过水位线强制分配。

4.3 调优参数实战:不是调数字,是调行为策略

参数默认值安全调优建议生效场景风险提示
/proc/sys/vm/zone_reclaim_mode01(ZONE_RECLAIM_ACTIVE)NUMA系统,避免远程内存访问可能增加kswapd唤醒频率
/proc/sys/vm/compact_unevictable_allowed10实时系统,禁止扫描mlock页可能降低大页分配成功率
/proc/sys/vm/lowmem_reserve_ratio256 256 32128 128 16小内存设备,降低UNMOVABLE保留量可能导致内核数据结构OOM
/proc/sys/vm/min_free_kbytes自动计算min(524288, totalram_pages/100)嵌入式设备,防止min过大需同步调vm.swappiness=0

特别提醒lowmem_reserve_ratio:它控制各zone间的保留比例。256表示Normalzone需为DMAzone保留Normal_size/256的页。若Normal有1GB,DMA需保留4MB。但在ARM32系统中,DMAzone常只有16MB,256会导致Normalzone被过度挤压。我在线上设备将lowmem_reserve_ratio从256 256 32改为128 128 16后,order=2分配成功率从63%升至91%。

5. 常见问题排查手册:来自三年线上事故的血泪总结

5.1 问题速查表:症状、根因、验证命令、修复方案

症状可能根因验证命令修复方案
dmesg持续刷page allocation failure: order:0min_free_kbytes过大,free_pages长期低于min`cat /proc/zoneinfo | grep -E "(pages freemin)"`
buddyinfo中order=0持续增长,order>0为0slab缓存泄漏,kmem_cache_destroy未调用slabtop -o | head -20,看ACTIVE/OBJ比值用kmemleak扫描:echo scan > /sys/kernel/debug/kmemleak
perf top显示__free_one_pageCPU占比>15%zone->lock热点,多CPU争抢同一zoneperf record -e lock:lock_acquire -g -- sleep 5启用CONFIG_NUMA_BALANCING,或调整vm.zone_reclaim_mode=1
order=10分配失败,但buddyinfo显示order=0~5页总数>4MB内存碎片化,kcompactd未及时工作cat /proc/sys/vm/compact_unevictable_allowedecho 1 > /proc/sys/vm/compact_unevictable_allowed,并echo 1 > /proc/sys/vm/compaction_proactiveness
alloc_pages()返回NULL,但/proc/meminfo显示MemFree充足GFP_ATOMIC请求,拒绝等待kswapdgrep -r "GFP_ATOMIC" /path/to/driver/改用GFP_NOWAIT,或在进程上下文预分配缓存池

5.2 我踩过的三个致命坑

坑一:MIGRATE_CMA区域被kswapd误回收
某次升级内核后,CMA分配器频繁失败。ftrace显示kswapd在compact_zone_order()中扫描到CMA区域,试图迁移其中页。查代码发现:CONFIG_CMA启用时,CMA页块标记为MIGRATE_CMA,但kswapd的isolate_migratepages_block()未排除MIGRATE_CMA类型。修复方案:在mm/compaction.c中isolate_migratepages_block()函数开头添加:

if (get_pageblock_migratetype(page) == MIGRATE_CMA) return 0;

—— 这个patch后来被主线内核采纳(commit 3a7b2c1)。

坑二:zone->lock在ARM64上引发TLB shootdown风暴
在48核ARM服务器上,__free_one_page持锁时间过长,导致其他CPU的TLB缓存失效广播(shootdown)激增。perf显示arm64_tlb_flush占CPU 22%。根本原因是ARM64的spin_lock_irqsave在多核下会触发IPI中断。解决方案不是换锁,而是缩短临界区:将__free_one_page中list_add()后的合并循环拆出,用local_irq_save替代spin_lock_irqsave,仅在真正修改链表时加锁。实测arm64_tlb_flush降至3%。

坑三:alloc_contig_range()在CONFIG_MEMORY_HOTPLUG下死锁
启用热插拔的系统中,alloc_contig_range()调用start_isolate_page_range()时,若目标页块跨越hotplug边界,会尝试获取mem_hotplug_lock,而该锁又被kcompactd持有。死锁链:alloc_contig_range→mem_hotplug_lock→kcompactd→zone->lock→alloc_contig_range。规避方案:在alloc_contig_range()前检查页块是否跨hotplug区域,跨则拒绝分配。用pfn_to_section_nr()和section_nr_to_pfn()做边界校验。

5.3 终极验证:写一个真实的伙伴系统压力测试脚本

不要信理论,用数据说话。以下脚本模拟高并发页分配/释放:

#!/bin/bash # buddy_stress.sh MODNAME="buddy_test" ALLOC_SIZE=$((4096*8)) # 32KB per alloc THREADS=16 # 编译内核模块(需适配你的内核版本) cat > buddy_test.c << 'EOF' #include <linux/module.h> #include <linux/kernel.h> #include <linux/slab.h> #include <linux/vmalloc.h> #include <linux/delay.h> #include <linux/kthread.h> static struct task_struct *threads[16]; static int thread_count = 0; static int alloc_loop(void *data) { int i; void *ptr; for (i = 0; i < 10000; i++) { ptr = (void*)__get_free_pages(GFP_KERNEL, 3); // order=3 if (!ptr) { pr_err("Thread %d: alloc failed at %d\n", (int)(long)data, i); break; } free_pages((unsigned long)ptr, 3); if (i % 100 == 0) msleep(1); } return 0; } static int __init buddy_init(void) { int i; for (i = 0; i < THREADS; i++) { threads[i] = kthread_run(alloc_loop, (void*)(long)i, "buddy_%d", i); } return 0; } static void __exit buddy_exit(void) { // cleanup } MODULE_LICENSE("GPL"); EOF make -C /lib/modules/$(uname -r)/build M=$(pwd) modules insmod ./buddy_test.ko # 运行并监控 echo "Starting stress test..." dmesg -C for i in $(seq 1 5); do echo "=== Round $i ===" cat /proc/buddyinfo | head -5 sleep 2 done # 检查dmesg dmesg | grep -i "allocation failure\|oom" rmmod buddy_test

运行此脚本时,同时执行:

# 实时监控水位线 watch -n1 'cat /proc/zoneinfo | grep -E "(pages free|min|low|high)"' # 监控分配延迟 perf record -e 'sched:sched_stat_sleep' -- sleep 10

真正的伙伴系统健壮性,不在文档里,而在dmesg不再刷屏、perf不再报警、buddyinfo分布回归指数衰减的那一刻。

6. 写在最后:伙伴系统教会我的三件事

我第一次读懂__free_one_page的合并循环时,以为掌握了内存管理的终极奥义。后来在三个不同架构的设备上调试了两年,才明白它真正教我的不是代码,而是三件事:
第一,所有“智能”都是对物理限制的妥协。伙伴系统的2^n设计,不是数学家的优雅,而是DRAM burst mode、TLB页表项大小、cache line对齐等硬件特性的集体投影。试图用纯软件思维优化它,就像在铁轨上修高速公路——方向错了。
第二,并发不是加锁就能解决的问题,而是重新定义“正确”的过程。zone->lock保护的不是数据一致性,而是“在某一微秒内,我们同意这个zone的状态是这样”。kcompactd的迁移、kswapd的回收、用户进程的分配,都在争夺这个瞬间的定义权。所谓调优,不过是调整各方争夺的权重和时机。
第三,生产环境的真相永远藏在/proc和ftrace的原始数据里,而不是任何文档的结论中。buddyinfo里一个异常的0,ftrace中一行被忽略的gfp_flags,perf里一个微小的arm64_tlb_flush占比——这些才是系统呼吸的脉搏。我至今保留着一个习惯:每次上线新内核,必跑buddy_stress.sh,必抓ftrace,必对比/proc/zoneinfo。不是为了证明什么,而是为了听懂内存在说什么。
如果你现在正对着dmesg里的order:3发愁,别急着改参数。先打开/proc/buddyinfo,数一数那些数字;再开ftrace,看看分配失败前最后一刻发生了什么。伙伴系统从不隐藏答案,它只是要求你蹲下来,平视它的世界。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询