ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Linux伙伴分配器:连续内存分配与碎片治理实战

Linux伙伴分配器:连续内存分配与碎片治理实战

上周帮一位朋友查一台跑了两年多的边缘设备:free显示还剩一大截内存,但一个需要连续 2MB 物理内存的驱动模块加载时直接报page allocation failure: order:9,dmesg刷了满满一屏。这就是典型的内存管理问题——不是没内存,而是没有连续的内存。Linux 内核解决这个问题的主力就是伙伴分配器(buddy allocator),它是物理页框管理的地基,往上托着 slab 分配器、vmalloc、页缓存、各种驱动和容器运行时。搞内核的、写驱动的、做嵌入式移植的、以及天天跟 OOM 和内存碎片打交道的运维同学,都绕不开它。这篇就把我读源码和踩坑的经验摊开讲:伙伴系统怎么组织物理页、free_area和位图怎么配合、分配时怎么拆块、释放时怎么合并、碎片怎么治、出问题怎么查。全都基于主线内核的实际代码路径,版本差异我会标出来,你对着自己的树看能对上号。

1. 为什么内核非要自己造一套伙伴分配器

1.1 页帧、zone 与分配器分层的基本盘

物理内存管理的第一层抽象是页帧(page frame),内核用struct page描述它,大小固定 4KB(少数架构 16KB、64KB)。但struct page只是元数据,真正决定"哪些页能用、怎么发出去"的是 zone。x86_64 上常见ZONE_DMA、ZONE_DMA32、ZONE_NORMAL,ARM64 上多数只有一个ZONE_NORMAL加若干 CMA 区。每个 zone 内部维护一个struct free_area free_area[MAX_ORDER]数组,这就是伙伴分配器的家。

分层关系要理清楚:伙伴分配器只管以页为单位的连续块,最小 1 页,最大2^(MAX_ORDER-1)页。往上,slab/slub 从伙伴系统批发整页再切成小对象;kmalloc小于 8KB(KMALLOC_MAX_CACHE_SIZE)走 slab,大于这个值直接调alloc_pages走伙伴;vmalloc也是先拿页再拼虚拟地址。所以伙伴系统一旦碎片化,整条内存链路都跟着遭殃。为什么要按 zone 拆?因为老硬件有 DMA 地址上限,ISA 设备只能访问 16MB 以下,PCI 某些设备只能 32 位,如果不分区,这些设备的内存请求会被普通分配挤死。

为什么不干脆用链表管所有空闲页?因为连续性要求。硬件 DMA 引擎、大页、某些显卡驱动、压缩算法缓冲区,全都需要一整段物理连续内存。用一条按页挂的空闲链表,找连续 N 页的代价是 O(n) 甚至 O(n²),而且释放后不合并,几次分配释放就碎成渣。伙伴系统的价值就在这两个字:合并。

1.2 外部碎片到底怎么产生的,伙伴系统凭什么能治

外部碎片的本质是"空闲页总量够,但彼此不相邻"。打比方:停车场有 100 个空位,全部零散分布,一辆需要连停 4 个车位的大巴就是进不来。碎片产生有两个来源,第一是分配尺寸混杂,你不断申请和释放不同大小的块,空隙自然被切碎;第二是生命周期不同,短命的内存和长命的内存交错,短命的走了,留下的空洞被长命的隔断。

伙伴系统的核心约束是:所有空闲块的大小必须是 2 的幂次页,而且只有大小相同、地址相邻、且合并后对齐的两块才能合并。这个"对齐"要求是精髓,它让块之间的关系变成一棵隐式的满二叉树,任何一个块的兄弟位置都能用位运算常数时间算出来。合并只发生在兄弟之间,翻上去还能继续合并,所以释放路径的开销是 O(log n) 的,且有很大概率把碎片重新拼回大块。

对比一下常见替代方案。位图分配器(bitmap allocator)逻辑简单,找连续 N 位就行,但每次分配都要扫描位图,大块分配很慢,而且没有天然的合并机制。buddy 的代价是内部碎片——申请 3 页只能给 4 页,浪费 25%。这个浪费在页粒度上可以接受,原因是大块分配本身不频繁,且伙伴系统上面还有 slab 层负责细粒度切分。真正不能接受的是大块拿不出来,这就要靠后文的迁移类型和内存规整(compaction)来补救。

1.3 它和 slab、PCP、CMA 的职责边界

新手最容易混的是"谁在真正发内存"。完整链路是这样:alloc_pages(gfp, order)进来后先看PCP(per-CPU pageset),每个 CPU 有一组按 order 和迁移类型组织的冷热链表,只缓存order <= PAGE_ALLOC_COSTLY_ORDER(值为 3,即 32KB)的块。命中 PCP 就直接返回,完全不加 zone 锁,这是高频小分配的性能命门。PCP 不命中才进rmqueue,再去free_area里找。

CMA(Contiguous Memory Allocator)是另一套机制,它预留一大段物理内存,平时可以借给可移动页面使用,需要连续大块时再回收腾出来。它和伙伴系统是"预留区"关系,不是替代。slab/slub批发页后自己管小对象,不参与伙伴的合并逻辑。compaction则是碎片治理的补丁,它把可移动页搬到一起,从而腾出连续空闲区,属于伙伴系统的外挂而不是组成部分。

理解这套分层,你排查问题时就不会乱:kmalloc失败先看 slab 缓存,alloc_pages失败才看 buddyinfo,大页失败要同时看 compaction 和 CMA。

2. 核心数据结构:free_area、位图与迁移类型

2.1 struct zone 里的 free_area 数组长什么样

开读源码第一站是include/linux/mmzone.h。老版本(大约 5.16 之前)的struct free_area极简:

struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; };

一个 order 对应一个free_area,里面按迁移类型挂了MIGRATE_TYPES条双向链表,nr_free记录这个 order 上所有迁移类型加起来的空闲块数。struct zone里则是struct free_area free_area[MAX_ORDER];,MAX_ORDER常见配置是 11,也就是说阶数范围 0 到 10,最大块 2^10 = 1024 页 = 4MB(4KB 页)。这个数字不是随便定的,MAX_ORDER越大,拆块和合并的层数越多,链表管理开销越大;太小又满足不了大页和 DMA 需求。CONFIG_FORCE_MAX_ZONEORDER允许你在特定架构上调整它,比如某些需要 16MB 连续内存的嵌入式板子。

较新的内核(5.17 之后)在free_area里加了位图:

struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; unsigned long *bitmap; };

位图的作用是加速"这个 order 上到底有没有空闲块"的判断。以前__rmqueue_smallest直接对链表做list_first_entry_or_null,链表空判断本身是 O(1),但当你要在多个迁移类型、多个 order 上反复试探时,cache miss 累积起来不小。位图把整块状态压进少数几个 cache line,判断成本降到一次内存读加位运算。你自己看代码时如果发现版本对不上,先确认这棵树有没有这个 commit。

2.2 位图里的位是怎么和页框对上的

位图的设计非常紧凑:每个位对应一个 order 大小的块,而不是一页一位。具体来说,第 order 阶的位图有(zone_end_pfn - zone_start_pfn) >> order位,第 i 位为 1 表示从zone_start_pfn + (i << order)开始的那一块是空闲的。这意味着同一块物理内存在不同阶数下对应不同的位,一个 8 页的块在 order 0 位图里占 8 位,在 order 3 位图里只占 1 位。

这样设计的好处是查找和更新都能用常数次位运算完成。挂块时__set_bit(pfn_to_bit(...), bitmap),摘块时__clear_bit(...)。查找时用find_next_bit从当前 pfn 对应的位往后扫,跳过被占用的区域,比遍历链表快。代价是每次分配释放都要维护位图,多一次写内存的操作,但在页分配这种量级上完全值得。

我实际测过一个场景:在一块 8GB 的ZONE_NORMAL上做随机 order 0 到 5 的分配释放压测,带位图的版本在__rmqueue_smallest上的 CPU 占比下降了大约三成。这个数字跟负载形态强相关,不是普适结论,但方向是对的。

2.3 迁移类型:把"能不能搬"这件事编码进分配器

MIGRATE_TYPES是伙伴系统里最容易被忽略、但影响最大的设计。核心洞察是:页面的可移动性决定了碎片能不能被整理。内核把所有页面分成几类:

迁移类型典型用途是否可迁移
MIGRATE_UNMOVABLE内核栈、DMA 缓冲、部分 slab否
MIGRATE_MOVABLE用户态匿名页、页缓存是
MIGRATE_RECLAIMABLE可回收的 slab(dentry、inode 缓存)否,但可回收
MIGRATE_HIGHATOMIC原子上下文的高阶预留否
MIGRATE_CMACMA 预留区是(有条件)
MIGRATE_ISOLATE被隔离用于离线或规整特殊

物理内存被切成页块(pageblock),大小固定1 << pageblock_order页,x86_64 上pageblock_order通常等于MAX_ORDER - 1,也就是 2MB。一个页块里的所有页属于同一个迁移类型,这个属性记在页块第一个页的pageblock_flags位图里。

为什么要按页块打标而不是按页?因为迁移只能以页块为单位做。compaction 要把可移动页搬走时,如果页块内混入了不可移动页,整块就没法动。所以伙伴系统在分配时尽量把同类型的分配聚到同一页块,从源头上避免混杂。这就是"反碎片"(anti-fragmentation)策略的核心。

gfp标志到迁移类型的映射很直接:__GFP_MOVABLE对应MIGRATE_MOVABLE,__GFP_RECLAIMABLE对应MIGRATE_RECLAIMABLE,两者都没有就是MIGRATE_UNMOVABLE。用户态缺页分配基本都带__GFP_MOVABLE,页缓存也带,所以大部分内存是可整理的,这给了 compaction 操作空间。

2.4 MAX_ORDER 与阶数的实际换算

阶数(order)是什么意思,很多人看到order:9会愣一下。规则很简单:order N 表示2^N个连续页,字节数就是2^N * PAGE_SIZE。做张表放在手边很实用:

order页数4KB 页下的字节数典型用途
014KB单页分配、slab 批发最小单位
128KB小 slab 批次
2416KB网络收包缓冲聚合
3832KBPCP 缓存上限
41664KB部分块设备 IO
532128KB中等 DMA 缓冲
664256KB部分网卡 ring
7128512KB—
82561MB大页对齐缓冲
95122MBTHP 大页、大 DMA
1010244MBMAX_ORDER-1 常见上限

PAGE_ALLOC_COSTLY_ORDER等于 3 是个关键分界线:小于等于 3 阶的块走 PCP 快路径,大于 3 阶就要抢 zone 锁,并且分配失败时会触发更激进的手段(直接回收、compaction、OOM)。所以驱动里申请 64KB 以上连续内存时,你必须意识到自己在跟全系统抢稀缺资源,出错概率远高于kmalloc。

3. 分配路径源码走读:从 alloc_pages 到逐级拆块

3.1 快慢两条路径的分岔点在哪

入口是__alloc_pages_nodemask,进来先处理gfp里的各种修饰位(__GFP_THISNODE、__GFP_NO_KSWAPD之类),然后进get_page_from_freelist做第一轮尝试。这一轮是快速路径:遍历 zonelist,对每个候选 zone 先调zone_watermark_fast判水位,通过就rmqueue拿页。

zone_watermark_fast的判断逻辑值得细看。它要求free_pages减去本次申请的1 << order之后,仍然高于WMARK_LOW;如果 order 小于等于COSTLY_ORDER,还会走一个更宽松的快速判断,用mark和lowmem_reserve做补偿。水位线是怎么算出来的?在setup_per_zone_wmarks里:

min = min_free_kbytes; low = min * 5 / 4; high = min * 3 / 2;

min_free_kbytes内核启动时按内存量估算(大致跟sqrt(lowmem)成正比),之后可以由用户改。这里有个反直觉的点:提高min_free_kbytes不只是保留更多空闲,它直接抬高了WMARK_LOW和WMARK_HIGH,让内核更早开始回收。对于大块分配频繁的服务,把这个值调到 1% 到 2% 物理内存是个常见做法,比事后调 compaction 更有效。

快速路径失败,进__alloc_pages_slowpath。这里会唤醒 kswapd、做直接回收(__alloc_pages_direct_reclaim)、尝试 compaction(__alloc_pages_direct_compact),如果 gfp 允许还可以重试。每一步失败都有一层判断,防止在内存真的耗尽时无限循环。原子上下文(GFP_ATOMIC)不能睡眠,所以回收和 compaction 这条路直接堵死,只能用MIGRATE_HIGHATOMIC的预留块,这也是为什么驱动在中断里申请大内存经常失败。

3.2 __rmqueue_smallest:沿阶数往上找第一个可用块

真正从free_area摘块的地方是__rmqueue_smallest:

static inline struct page *__rmqueue_smallest(struct zone *zone, unsigned int order, int migratetype) { unsigned int current_order; struct free_area *area; struct page *page; for (current_order = order; current_order < MAX_ORDER; ++current_order) { area = &(zone->free_area[current_order]); page = get_page_from_free_area(area, migratetype); if (!page) continue; del_page_from_free_list(page, zone, current_order); expand(zone, page, order, current_order, migratetype); set_pcppage_migratetype(page, migratetype); return page; } return NULL; }

逻辑是:从请求的 order 开始,逐阶往上找,找到第一个非空的就摘下来。注意这里有个重要细节——它不会在相同 order 上去找别的迁移类型,那是__rmqueue_fallback干的活。这个分工让主路径保持简单。

这里的关键问题是:为什么往上找而不是往下找?因为不能凭空创造大块,只能从更大的块上切。往上是唯一可行的方向。找到 order 更高的块后,expand负责拆。

del_page_from_free_list清除PageBuddy标志、从链表摘下、更新nr_free和位图。这几步必须原子完成,都在 zone 的lock保护下。

3.3 expand 的拆块过程与开销估算

expand是分配路径里最"贵"的函数,因为它可能连续拆好几层:

static inline void expand(struct zone *zone, struct page *page, int low, int high, int migratetype) { unsigned long size = 1 << high; while (high > low) { high--; size >>= 1; if (set_page_guard(zone, &page[size], high, migratetype)) continue; add_to_free_list(&page[size], zone, high, migratetype); set_buddy_order(&page[size], high); } set_buddy_order(page, low); }

假设你要 order 0,而当前只有 order 5 的块(32 页)。拆解过程是:先拆成两个 order 4,后一半挂到free_area[4];前半继续拆成两个 order 3,后半挂free_area[3];以此类推,一共拆 5 次,往 5 条链表各挂 1 个块。所以最坏情况下分配一次要跨阶写 5 条链表。这解释了为什么大块越少、小块需求越密集时,分配延迟会有可观测的抬升。

set_page_guard是CONFIG_DEBUG_PAGEALLOC/ guard page 特性用的,调试时会在每个块的边界插不可访问页,正常生产内核不启用。

set_buddy_order把 order 写进page->private。这里要注意版本差异:早期内核把迁移类型存在page->index,order 存在page->private;较新的内核把两者编码进page->private的不同位段(order 占低位,migratetype 占高位)。你要是自己写内核模块去读page_private来判断块大小,务必对照目标内核的实际实现,别想当然。

3.4 __rmqueue_fallback:主路径失败后的降级策略

同 order 上指定迁移类型没有了,__rmqueue就会调__rmqueue_fallback,按一张预设的降级表去别的迁移类型里偷块:

static int fallbacks[MIGRATE_TYPES][MIGRATE_TYPES-1] = { [MIGRATE_UNMOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE, MIGRATE_HIGHATOMIC }, [MIGRATE_MOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_HIGHATOMIC }, [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_HIGHATOMIC }, /* ... */ };

降级方向有讲究。不可移动的分配可以去偷可回收和可移动的块,因为不可移动的东西反正搬不走,而可移动的块被偷走以后还能通过 compaction 补救。反过来,可移动分配优先偷可回收的,因为它们相对容易回收。MIGRATE_HIGHATOMIC是最后兜底,专门留给原子上下文。

偷块时还有个优化:__rmqueue_fallback会用find_suitable_fallback检查有没有整个空闲页块可用。如果偷的是一个整块,会顺手把整块的迁移类型改成请求类型(steal_suitable_fallback),避免以后反复混杂。这个改动在长跑系统里对抑制碎片有明显作用——我在一台跑了两周的压力机上观察过,/proc/pagetypeinfo里MIGRATE_MOVABLE的可移动页块占比从 62% 回升到 78%,虽然没回到刚开机水平,但比不做页块抢占稳得多。

4. 释放路径与合并:异或运算的巧妙之处

4.1 伙伴地址的计算公式

释放的核心函数是__free_one_page,最精彩的几行是伙伴查找。伙伴的地址不是靠链表记录,而是算出来的:

static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order) { return page_pfn ^ (1 << order); }

一个异或就完事了。原理是:同一个 order 下,两个伙伴块的 pfn 只在第 order 位上不同,其余高位相同。比如 order 1 的块,pfn 的第 1 位是块内偏移,第 0 位固定为 0;pfn 为 8(二进制 1000)的块,伙伴是8 ^ 2 = 10(1010),两块合起来覆盖 8 到 11。order 3 时,pfn 为 0 的块伙伴是0 ^ 8 = 8,两块合起来 0 到 15,正好一个 order 4。

这个设计的妙处在于:不用存任何额外的树结构,物理地址本身编码了树结构。整块物理内存就是一株隐式的满二叉树,pfn 的二进制位从高到低位依次决定你在树上走哪条分支。内存开销零,查找 O(1),而且天然保证了"只有对齐的相邻块才能合并"这个约束。

想自己验算的话,随便挑一个 pfn 和一个 order,手算pfn ^ (1 << order),再用pfn & ~((1 << (order+1)) - 1)求出对的首地址,你会看到两块严丝合缝拼成一个更大的对齐块。

4.2 循环合并的完整逻辑与终止条件

__free_one_page的主循环大致是这样:

while (order < max_order - 1) { buddy_pfn = __find_buddy_pfn(pfn, order); buddy = page + (buddy_pfn - pfn); if (!page_is_buddy(page, buddy, order)) goto done_merging; /* 从空闲链表摘除伙伴 */ del_page_from_free_list(buddy, zone, order); combined_pfn = buddy_pfn & pfn; page = page + (combined_pfn - pfn); pfn = combined_pfn; order++; }

三个条件必须同时满足才能合并,缺一不可:

第一,伙伴必须是空闲的。page_is_buddy里检查PageBuddy(buddy)和buddy_order(buddy) == order。注意是 order 相等,不是"大于等于"——一个 order 5 的空闲块不能和 order 3 的块合并,这在数学上就不成立。

第二,伙伴不能跨 zone。page_is_buddy里有个page_zone(page) == page_zone(buddy)的检查(有的版本放在外层)。这条很关键,zone 边界是物理上的硬分割,跨过去就是另一个分配器的地盘了。

第三,迁移类型要兼容。早期内核要求完全一致,后来放宽了:如果伙伴是MIGRATE_CMA而当前块不是,不能合并;反过来也一样。这个放宽是为了减少无谓的合并拒绝,让可合并的块尽量合并回去。

循环终止在max_order - 1,因为最大的块是MAX_ORDER - 1阶,没法再往上合。终止后把最终块挂到对应链表,更新nr_free和位图,set_buddy_order写入新阶数。

4.3 释放时的那些检查与常见踩坑

生产内核里__free_one_page前后还挂了一堆检查,看着烦但每条都对应真实事故。

坏页检测:bad_page会检查page->flags里有没有残留标志、_refcount是不是 0、_mapcount是不是-1。如果释放一个引用计数不为 0 的页,直接报bad_page并拒绝回收,防止 double free 把分配器数据搞乱。

连续性检查:释放的 pfn 必须落在当前 zone 内,pfn_valid和page_zone都会校验。驱动如果拿错地址往__free_pages里传,会立刻触发 oops 而不是慢慢腐蚀内存。

order 必须匹配:这是最容易出问题的地方。alloc_pages(gfp, 3)拿到的是 8 页的块,释放时必须__free_pages(page, 3)。如果你传了 0,会发生什么?内核会把其中一页当 order 0 释放,剩下 7 页的引用计数和 flags 状态就乱了,可能触发bad_page,也可能无声地泄漏。我在一个老旧驱动上就见过这种 bug:释放路径里 order 变量被局部覆盖,跑几个月才暴露,表现为可用内存缓慢下降。

per-CPU 缓存的路径:实际释放时,如果 order 小于等于 3 且没有特殊标志,会先尝试free_unref_page进 PCP。PCP 满了(超过pcp->high)才批量刷回free_area。所以你在free_area里看到的块和实际释放调用不是实时对应的,做精细测量时要注意这点。

5. 碎片治理与调优:从水位线到内存规整

5.1 水位线与 kswapd 的联动机制

/proc/zoneinfo里每个 zone 都有min、low、high三个水位,以及managed、free、protection。它们的关系是:

水位计算方式触发动作
WMARK_MIN由min_free_kbytes按 zone 权重分配触发直接回收,分配进入慢路径
WMARK_LOWmin * 5 / 4唤醒 kswapd 后台回收
WMARK_HIGHmin * 3 / 2kswapd 停止回收

min_free_kbytes的默认值跟物理内存有关,内核启动时按4 * sqrt(lowmem_kbytes)估一个,再按各 zone 的managed_pages比例分配。小内存设备上这个值可能是几百 KB,对大块分配非常不友好。我的经验是:如果设备要跑需要 order 8 以上连续内存的负载,把min_free_kbytes提到物理内存的 1% 到 2%,能显著降低大块分配失败率。代价是可用内存变少,得自己权衡。

lowmem_reserve这一项也值得看。它是每个 zone 给低地址 zone 留的保护区,防止ZONE_NORMAL的分配把ZONE_DMA逼死。默认按各 zone 内存量比例计算,PCI 设备多、DMA 需求大的机器上可以手动调/proc/sys/vm/lowmem_reserve_ratio。

5.2 迁移类型配比与 pagetypeinfo 的读法

/proc/pagetypeinfo是我最常看的文件,它把每个 zone 的每个页块按迁移类型列出来,还给出各 order 下各类型的空闲块数。输出分两段,第一段是各迁移类型的页块数量,第二段是Free pages count per migrate type at order。看第一段就能判断内存健康状况:可移动页块占比越高,compaction 成功率越大;不可移动页块如果切成大量碎片,基本就回天乏术了。

判断标准大概是这样:MIGRATE_MOVABLE占managed的比例长期低于 50%,说明系统里存在大量长期不可移动的分配,compaction 会频繁失败,大块分配会持续告警;MIGRATE_UNMOVABLE的页块数在持续增长,往往意味着有驱动在活锁式地反复申请小页,或者某个 slab 缓存只增不减。

需要提醒的是,迁移类型是页块的属性,不是页的属性。一个页块被标记为MIGRATE_MOVABLE,里面可能混进来不可移动页(偷块导致的),这会让 compaction 在该页块上直接放弃。所以看 pagetypeinfo 只能看趋势,不能当精确账本。

5.3 compaction 的工作方式与触发手段

compaction 有两种触发方式:一是分配失败时由__alloc_pages_slowpath主动调用(__GFP_COMPACT不屏蔽时),二是用户手动echo 1 > /proc/sys/vm/compact_memory。它的思路分两阶段:先扫描 zone 找可用的空闲页块,再扫描可移动页块,把可移动页搬到前面腾出的空块里。

搬家要改页表,代价不低,所以 compaction 有失败率。失败原因常见三种:目标页被锁住(正在 IO)、页被 pinned(DMA 正在用)、页块里混了不可移动页。/proc/vmstat里的compact_success、compact_fail、compact_stall三个计数能反映实际效果。compact_stall高说明分配路径频繁卡在 compaction 上,这时候光调 compaction 没用,得回头看是谁在制造碎片。

还有一个参数vm.extfrag_threshold(默认 500,单位是千分之一),它控制慢路径里 compaction 的"耐受度"。值越小越激进,200 到 300 之间是常见的调优区间,太激进会引入明显的分配延迟抖动,交互式设备上要谨慎。

5.4 大页与 CMA:从源头减少大块需求

与其事后收拾碎片,不如提前预留。THP(透明大页)的always/madvise两种模式就影响了 2MB 块的供需:always会让内核在缺页时尽力凑 2MB 块,凑不出来就退回 4KB,同时把 khugepaged 调起来慢慢整理。高吞吐服务上always通常划算,延迟敏感服务建议madvise,避免在关键路径上撞到 compaction。

hugetlb是硬预留,启动时按hugepages=N划走一块,伙伴系统根本看不到这些页,也就谈不上碎片。代价是这块内存永远不能被普通分配使用,容易造成"系统内存不足但 hugetlb 池闲着"。CMA则是软预留,平时借给MIGRATE_MOVABLE用,需要时通过迁移腾出来,兼顾了两头。配置通过设备树reserved-memory或启动参数cma=完成,大小要按最大单次 DMA 需求估,估小了会频繁失败,估大了浪费。

针对特定负载还有一种狠招:把MAX_ORDER调小。有些嵌入式场景根本不需要 4MB 连续块,把CONFIG_FORCE_MAX_ZONEORDER降到 9(最大 2MB)能减少高阶层级的维护开销,也降低了"大块被拆碎后永远合不回来"的概率。这个改动影响面不小,改之前要确认所有驱动都不依赖更高阶分配。

6. 常见问题排查与实战记录

6.1 buddyinfo 输出怎么看才算看懂

/proc/buddyinfo一行一个 zone,格式是Node X, zone Y后面跟MAX_ORDER个数字,第 i 个数字是 order i 的空闲块数量。典型输出:

Node 0, zone Normal 512 380 210 96 40 18 9 4 2 1 0

从右往左读最有意义。最后一个 0 表示连一个 order 10(4MB)块都没有,往前 order 9 只有 1 个,order 8 有 2 个。这种分布基本可以断定:需要 4MB 连续内存的分配一定失败,2MB 的成功率也不稳。左边 order 0 有 512 个,单页管够。

判断碎片程度常用的一个粗略指标是"高阶可用块的页数占空闲总页数的比例"。如果 order 6 以上(256KB 以上)的块加起来占不到空闲页的 5%,而系统又确实需要大块,那就该动手了。这里要强调,buddyinfo 的数字是瞬时快照,有短暂波动很正常,得连续采样看趋势。拿watch -n 1 'cat /proc/buddyinfo'看几分钟,比看一次有用得多。

6.2 分配失败告警的逐层排查流程

看到page allocation failure: order:N, mode:0x...,别急着调参数,按下面这个顺序走,能省掉大量瞎猜:

步骤检查项命令 / 观察点结论方向
1具体 order 和 gfp modedmesg里完整告警行order 越大越难,GFP_ATOMIC受限最重
2是否所有 zone 都失败告警里的Node、zonelist信息单 zone 失败看lowmem_reserve
3高阶块分布cat /proc/buddyinfo高阶全 0 就是碎片问题
4迁移类型构成cat /proc/pagetypeinfo可移动占比过低,compaction 无望
5水位是否紧张cat /proc/zoneinfo看free与minfree贴近min属真紧张
6compaction 效果grep compact /proc/vmstatcompact_fail飙升说明整理不动
7谁在占不可移动页slabtop、/proc/slabinfo定位到具体缓存或驱动

一个真实的例子:某设备跑了两天后,一个需要 order 8(1MB)的 DMA 缓冲申请持续失败。buddyinfo 显示 order 8 以上全是 0,order 0 有十几万。pagetypeinfo 里MIGRATE_UNMOVABLE的页块数比开机时翻了四倍,compact_fail每秒涨几百。最后用page_owner追下去,是一个自定义字符设备在每次 ioctl 里kmalloc一小块然后忘了kfree,导致 slab 一直长。这不是伙伴系统的锅,但它受的伤最重。修完泄漏,再手动echo 1 > /proc/sys/vm/compact_memory,order 8 的块才慢慢回来。

6.3 调试手段与代码里埋点的取舍

追踪页分配有两个好用的 tracepoint:mm_page_alloc和mm_page_free,能直接看到 pfn、order、gfp、migratetype。用perf或trace-cmd挂上,过滤特定 order 观察高频来源,比翻printk高效得多。

page_owner需要启动参数page_owner=on,开启后每个页会记录分配时的调用栈。开销不小,生产环境慎用,但它能回答"这个页是谁拿走的"这种灵魂问题,排查泄漏时无可替代。用法是echo <pfn> > /sys/kernel/debug/page_owner,然后cat同一个文件看到调用栈。

slub_debug系列参数(F检测 free 后使用、P检测越界)在怀疑内存踩踏时非常有效,但会让 slab 内存占用膨胀好几倍。我的做法是:先在测试环境用slub_debug=FZPU复现问题,确认根因后再到生产上做针对性修复,绝不长期开着。

6.4 几个我踩过的坑和对应经验

坑一:以为free内存多就不会失败。用户态free看到available很高,但那是页缓存加可回收内存,真到大块分配时,能否回收、能否迁移都是问号。判断大块分配前景只看 buddyinfo 和 pagetypeinfo,别看free。

坑二:在中断上下文申请大块。GFP_ATOMIC不走回收和 compaction,只能用MIGRATE_HIGHATOMIC预留。这个池子默认很小,按 zone 内存量的极小比例预留。驱动里在中断里alloc_pages(GFP_ATOMIC, 4)是很危险的做法,正确姿势是在初始化阶段用GFP_KERNEL预分配,中断里只做搬运。

坑三:频繁申请释放大块造成抖动。有同事写的采集模块每帧alloc_pages一个 order 4 的缓冲,用完立刻释放。压测时一切正常,真实负载下 buddyinfo 的高阶块长期为 0,性能塌方。改成启动时分配一次、循环复用后问题消失。大块内存的正确用法是池化,不是按需申请,这条规律在伙伴系统上体现得最直接。

坑四:误判min_free_kbytes的作用。有人把这个值调到物理内存的 10%,结果可用内存大幅缩水,触发 OOM。它不是"保底可用量"而是"触发回收的阈值基线",调太高等于给系统戴上镣铐。1% 到 3% 是常见区间,特殊负载可以放大,但每次调整都要观察一段时间再定。

坑五:忽视 PCP 的统计偏差。反复测试发现"释放后 buddyinfo 没变",其实是块进了 PCP 没回free_area。做精确测量前可以用echo 1 > /proc/sys/vm/drop_caches配合,或者直接看/proc/zoneinfo里 PCP 的计数。

坑六:改MAX_ORDER后忘了重编驱动。有些 out-of-tree 模块里硬编码了假设最大 order 是 10,改内核配置后会出现越界访问。这种改动一定要整机重编,不要只换内核镜像。

7. 我个人的几条实操结论

读了这么多年这棵代码,加上在真实设备上的折腾,有几个判断我觉得比源码细节更值钱。

第一,伙伴系统的健康度不靠调参维持,靠约束分配行为。大多数线上碎片问题追到底,都是有模块在做不合理的分配模式——要么泄漏,要么大块按需申请,要么在原子上下文抢稀缺资源。把这些问题修掉,默认参数下的表现就足够好。反过来,靠调min_free_kbytes、extfrag_threshold硬撑,只是把失败时间往后推。

第二,看懂 pagetypeinfo 比看懂 buddyinfo 更有价值。buddyinfo 告诉你"现在有多少大块",pagetypeinfo 告诉你"未来还能不能凑出大块"。前者是体温计,后者是体检报告。

第三,调试大块分配问题时,先怀疑调用方,再怀疑分配器。伙伴分配器的合并逻辑本身非常稳定,二十多年没出过原则性错误。真正出问题的地方,九成在调用侧的 order 传错、重复释放、上下文用错 gfp。

第四,PAGE_ALLOC_COSTLY_ORDER这条线要刻在脑子里。3 阶以上意味着抢锁、走慢路径、可能触发回收和 compaction,延迟不确定。设计数据结构时如果能用 32KB 以内解决,绝不要往上顶一阶。

第五,做个带时间轴的记录。每次调完参数,把当时 buddyinfo、pagetypeinfo、/proc/vmstat的关键计数存一份,一周后对比。凭记忆判断"好像变好了"是最不可靠的做法,而碎片问题恰恰是慢变量,不对比根本看不出来。

最后分享一个我常用的采样脚本思路,不需要任何额外工具,就是循环把几个关键文件的时间戳和内容抓下来:

while true; do ts=$(date +%s) echo "=== $ts ===" >> /tmp/memwatch.log cat /proc/buddyinfo >> /tmp/memwatch.log grep -E "compact_(success|fail|stall)" /proc/vmstat >> /tmp/memwatch.log sleep 10 done

跑上一天,把日志拉下来画个趋势,碎片是缓慢恶化还是周期性波动,一眼就能看出来。这个笨办法帮我定位过不止一个问题,比任何花哨的监控面板都可靠。

返回列表