Linux内核Page Allocator深度解析:伙伴系统、内存碎片化与水位机制

Linux内核Page Allocator深度解析:伙伴系统、内存碎片化与水位机制 很多做 Linux 服务端和嵌入式开发的朋友对内存的理解往往停留在应用层malloc 一块内存free 释放至于这块内存到底来自哪里大多数时候没人关心。直到你开始写驱动、调性能、排查长时间运行后的稳定性才会撞上一个绕不开的名字Page Allocator。我早期调试某 ARM 平台 DMA 驱动时连续向内核申请 4MB 物理内存总是失败但系统 MemFree 明明还有几百 MB。排查到最后才发现问题出在物理页的碎片化上而碎片化恰恰是页面分配器这个基石没设计好就会出现的典型症状。这篇文章就带你彻底搞懂 Linux 内核里这个最基础也最重要的组件。Page Allocator 是 Linux 内核物理内存分配的核心它负责从物理内存中分配和回收连续页帧是 buddy system伙伴系统的直接实现者。无论你是做内核驱动、虚拟化还是单纯想理解为什么有时 malloc 会触发系统卡顿、为什么容器内存限额会跟主内存水位互相影响都需要理解它。这篇文章会从一次 malloc 的完整旅程讲起逐步拆散伙伴系统的数据结构、分配热路径、碎片化治理、水位回收机制最后结合我实际踩过的坑给你一套可以直接上手的排查思路。1. Page Allocator 到底在管什么一次内存请求背后的内核旅程1.1 从 malloc 到物理页这中间发生了什么很多人的认知里malloc 就是从堆里划一块内存free 就还回去。但在 Linux 上malloc 通常只是从进程地址空间拿到了虚拟内存真正物理内存的分配是懒加载的当你第一次读写这块虚拟内存时CPU 触发缺页异常内核才会去分配真实的物理页并建立页表映射。这里负责分配物理页的正是 Page Allocator。具体路径大致长这样malloc - glibc - brk/mmap - 缺页异常 - handle_mm_fault - do_anonymous_page - alloc_zeroed_user_highpage - alloc_pages也就是说无论用户态分配多大的内存最终都要跨过 Page Allocator 这扇门。内核态驱动也一样只是走得更直接kmalloc底层在需要新页时也会调用alloc_pages。所以整个系统的内存压力最后都会汇聚到这一层的决策上。1.2 物理内存如何被划分node、zone、page 三个层次要理解 Page Allocator先得理解物理内存的组织方式。现代 Linux 把物理内存按三个层次管理内存节点nodeUMA 架构只有一个节点NUMA 架构下每个 CPU 附近的内存是一个节点用struct pglist_data表示。内存区域zone每个节点内又按用途和硬件限制分成多个区域最常见的是ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_MOVABLE。x86 上ZONE_NORMAL是 4MB 以上、896MB 以下的内存如果开了 HIGHMEM 则不同ARM 上区域划分由内核配置决定。物理页page最终单位是 4KB 的页用struct page描述。分配器的职责就是在这三层结构中快速找到空闲页。比如某个 DMA 外设只能访问 32 位地址空间驱动就得从ZONE_DMA32分配用户态进程的普通匿名页则可以从ZONE_NORMAL或ZONE_MOVABLE分配。1.3 核心 API 与关键参数Page Allocator 对外提供了一套以 GFP mask 为核心的 API用得最多的就是struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); void *page_address(struct page *page); void __free_pages(struct page *page, unsigned int order);order表示要分配的连续页数量实际页数是 2 的 order 次方。gfp_mask则是一堆标志位决定了分配的行为。常见组合如下GFP 组合典型用途行为特点GFP_KERNEL内核常规分配可以睡眠、可以触发回收、可能触发 I/OGFP_ATOMIC中断上下文不能睡眠、不能回收只能快速路径分配GFP_HIGHUSER用户态内存优先使用高端内存允许迁移GFP_TRANSHUGETHP 透明大页尝试分配 2MB 连续页我在实际开发中建议驱动里非必要不要用GFP_ATOMIC分配大阶内存因为原子分配不能触发回收和内存规整失败率非常高。很多偶发空指针就是这么来的你以为是并发问题其实是分配直接返回了 NULL。2. 伙伴系统让内存碎片可控的几何级拆分算法2.1 order 与 free_area空闲内存怎么挂链Page Allocator 的核心算法叫伙伴系统。它把空闲页按 2 的幂次分成不同大小的块每个块的大小用 order 表示。内核用free_area数组维护每个 order 的空闲块链表数组下标就是 order。struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; };MAX_ORDER通常配置为 11也就是说最大连续块是 2 的 10 次方页即 4MB4KB 页时。你可能会问为什么不是 12、13因为系统里有一部分内存需要保留给紧急情况而且越大阶的连续块越容易出现碎片维护成本也越高。默认 MAX_ORDER 是 11CONFIG_FORCE_MAX_ZONEORDER在某些架构上会改成更大值但大多数场景 11 够用了。分配时如果要 order 38 页的块分配器先看 order 3 的链表有没有空闲块没有就往 order 4、5 找找到大块后不断折半拆开把多余的部分挂回低阶链表。释放时正好反过来释放一个块时检查它相邻的 buddy 是否也空闲如果空闲就合并成高一阶的块继续向上合并。这套机制保证了内存利用率最高也让碎片问题控制在一定范围内。2.2 什么是真正的 buddy两个相邻且大小相同的伙伴很多人对 buddy 的理解有偏差。不是随便两个空闲块就能合并它必须满足两个条件两个块的大小相同order 相同。它们的物理地址是相邻的且两者组合后正好能拼成一个更大的块。举例来说一个 order 2 块起始地址是 0x1000另一个 order 2 块起始地址是 0x2000它们互为 buddy可以合并成一个 order 3 块0x1000 开始的 4 页。但起始地址是 0x3000 的块跟 0x1000 的块虽然相邻大小相同却不能合并因为 0x1000 的 buddy 只能是 0x2000。地址上的严格对齐关系决定了一切。合并的关键是计算 buddy 的 PFN页帧号。内核里常见的一段逻辑是static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order) { return page_pfn ^ (1 order); }这个异或计算非常巧妙pfn ^ (1 order)正好得到伙伴页的起始地址。这也是为什么伙伴系统合并时 O(1)不用遍历链表找谁跟谁相邻。2.3 一次拆分的完整过程假设当前系统只有 16 个连续空闲页order 4 的一个块现在有进程申请 order 01 页拆分会这样发生order 0 到 order 3 链表都为空在 order 4 找到空闲块。把 order 4 的块一分为二一半挂到 order 3另一半继续拆。拆 order 3 为两个 order 2一个挂链表另一个继续拆。拆 order 2 为两个 order 1一个挂链表另一个继续拆。拆 order 1 为两个 order 0一个分配给请求方另一个挂回 order 0 链表。最终系统里多出 order 0、1、2、3 的空闲块各一个。下次再有请求就能直接从对应链表拿不用再拆。这套用空间换时间的思路让分配和释放都维持在常数到对数级别。3. 分配热路径拆解水位检查与逐层下探3.1 入口函数与区域选择内核核心分配函数是__alloc_pages_nodemask它前面有alloc_pages、alloc_page等封装。真正干活的是快速路径和慢速路径两部分。快速路径的核心是get_page_from_freelist。它要做两件事选择 zone检查水位。进程在哪个 CPU 上执行就优先分配本节点内存如果本节点不够才跨节点。zone 的选择顺序由 zonelist 决定通常是 NORMAL - DMA32 - DMA 这样一个降级顺序。水位检查是分配器的红绿灯内核为每个 zone 维护三个水位水位含义WMARK_MIN最低警戒线低于此值只有原子分配等紧急分配能成功WMARK_LOW低水位kswapd 在此水位开始唤醒回收WMARK_HIGH高水位回收线程希望回收到的目标zone_watermark_ok检查时默认要求分配后的剩余内存不低于目标水位。ALLOC_WMARK_MIN、ALLOC_WMARK_LOW、ALLOC_WMARK_HIGH对应不同压力等级。普通用户态内存分配通常检查 LOW 水位而核心回收路径会尝试达到 HIGH。3.2 rmqueue从链表中取页并拆分一旦水位检查通过就会调用rmqueue真正从伙伴系统取页。rmqueue分两种情况order 0 的分配走 PCPper-CPU pages快速通道这个后面专门讲order 大于 0 的走__rmqueue_smallest也就是按从小到大的 order 遍历 free_area找到第一个非空链表。__rmqueue_smallest的流程是for (current_order order; current_order MAX_ORDER; current_order) { area (zone-free_area[current_order]); if (list_empty(area-free_list[migratetype])) continue; page list_entry(area-free_list[migratetype].next, struct page, lru); list_del(page-lru); rmv_page_order(page); /* 如果找到的块比需要的大则拆分 */ expand(zone, page, order, current_order, migratetype); return page; }expand负责把大的块按前面说的折半方式逐级拆开多出来的小块挂回低阶链表。这里有个很多人忽略的细节拆分出来的低阶块要设置正确的migratetype否则会导致后续合并逻辑错乱。这也是伙伴系统代码里最容易出 bug 的地方之一。3.3 慢速路径从回收规整到 OOM如果快速路径没找到合适内存就进入__alloc_pages_slowpath。这一阶段做的事情很多唤醒 kswapd 后台回收线程让它异步回收内存。尝试__alloc_pages_direct_reclaim同步回收页缓存。尝试内存规整compact_zone把可移动页搬到一起腾出连续大块。如果以上都不行调用 OOM killer 杀掉进程释放内存。这解释了为什么内存紧张时系统会卡顿同步回收和内存规整都会遍历大量页面期间还可能等待磁盘 I/O 回写脏页。而 OOM killer 是最坏的兜底手段它会选择分数最高的进程杀掉。线上遇到过内存充足但 OOM的诡异情况往往是 min 水位被保护区lowmem_reserve吃掉了或者 cgroup 的 memory limit 触发局部 OOM。4. 内存碎片化的真实面目迁移类型与 pageblock 策略4.1 伙伴系统为什么救不了碎片理论上伙伴系统把内存拆成 2 的幂次块可以让申请大块时尽量凑出连续页。但长时间的分配释放后内存会因为类型混杂产生无法合并的空洞。比如一个 order 3 块的两半伙伴区域中一半被不可移动的页占用另一半虽然空闲但永远无法合并成大块。这就是外碎片。外碎片累积到一定程度会出现空闲内存很多但连续大块没有的状态。前面我提到的 4MB 分配失败就是这种问题。4.2 迁移类型把容易搬的页面跟不容易搬的分开为了对抗碎片化内核引入迁移类型migratetype每个 pageblock通常是 2 的 order 次方页一般是 1024 页即 4MB被标记为某种类型迁移类型含义MIGRATE_UNMOVABLE不可移动如内核代码、某些驱动内存MIGRATE_MOVABLE可移动如用户态匿名页、页缓存可以迁移和规整MIGRATE_RECLAIMABLE可回收如内核某些缓存MIGRATE_CMA连续内存分配器预留区MIGRATE_ISOLATE隔离区迁移中使用不会被分配分配时Page Allocator 优先从对应 migratetype 的空闲链表中取页。这样做的好处是不可移动的内存尽量集中在一起可移动页也集中在一起。当需要大块连续内存时内核可以通过内存规整把可移动页搬到一起而不会被不可移动页挡住。4.3 /proc/buddyinfo 与 /proc/pagetypeinfo 的读法排查碎片化最重要的两个 proc 文件是/proc/buddyinfo和/proc/pagetypeinfo。/proc/buddyinfo显示每个 node 和 zone 下各 order 空闲块数量Node 0, zone Normal, 325 128 56 21 5 2 1 0 0 0 0从左到右是 order 0 到 order 10 的数量。如果高 order比如 order 6的列长期为 0而低 order 数量很多说明碎片化严重。/proc/pagetypeinfo更进一步按 migrate type 和 order 展示页面分布可以直观看到不可移动页是否分散在各个 pageblock 里。如果Unmovable的块在很多 order 上都有并且分布在不同物理位置说明系统的不可移动内存没有做好聚拢。遇到碎片化我的处理优先级是先确认是不是真的碎片cat /proc/pagetypeinfo看 Movable 与 Unmovable 比例。能重启服务就重启让页缓存释放。紧急时echo 1 /proc/sys/vm/compact_memory触发全系统内存规整。根治方案驱动分配大块连续内存用 CMA应用层尽量避免频繁申请大块物理连续内存改用 vmalloc 或 IOMMU。5. 水线、kswapd 与慢速分配内存压力下的联动机制5.1 水线是怎么算出来的每个 zone 的三个水位由内核初始化时根据内存大小和min_free_kbytes计算。默认min_free_kbytes约为物理内存的 0.4% 到 1%然后分配到各 zone。zone 的 min 水位就是该 zone 保留的最小空闲页数量。代码里 low 和 high 在init_per_zone_wmark_min中设置zone-watermark[WMARK_LOW] min_wmark_pages(zone) (tmp 2); zone-watermark[WMARK_HIGH] min_wmark_pages(zone) (tmp 1);其中 tmp 与 min 有关大致可以理解为 low 约等于 min 的 1.25 倍high 约等于 min 的 1.5 倍。min、low、high 之间的差距决定了 kswapd 回收的激进程度。内核还允许通过/proc/sys/vm/watermark_scale_factor放大这个差距默认值是 10范围 1 到 3000值越大低水位到高水位的间隔越大kswapd 每次回收得越狠但唤醒频率会降低。5.2 kswapd 回收的节奏感每个 node 都有一个或者多个 kswapd 内核线程。它的唤醒条件是任意一个 zone 的空闲页低于 low 水位。回收目标是让空闲页回到 high 水位。这个设计很巧妙水位从 low 到 high 之间给了回收一个缓冲区间避免刚回收完又被分配掏空触发反复回收的震荡。但要注意kswapd 是异步的它回收的速度可能跟不上应用分配的速度。如果应用持续大量分配内存耗尽到 min 水位以下那么所有普通分配都失败只能进入 direct reclaim 或 OOM。线上看到kswapd0 CPU 占用 100%时我一般先看vmstat的 si/so再查是哪个 cgroup 在疯狂分配。5.3 与 cgroup 的碰撞内存 cgroupmemory controller在 Page Allocator 之上加了另一层限制。当 cgroup 内内存达到memory.max时会触发memcg reclaim这不是全局 kswapd 能解决的。一个典型的问题是两个 cgroup一个疯狂用内存另一个内存其实很空闲但每个 cgroup 自己的分配路径都会被卡在自身限额上。此时/proc/zoneinfo里全局水位可能还是很健康的但业务已经频繁卡顿。另外触发 memcg reclaim 时往往会发生直接回收这种回收发生在分配者的上下文里所以延迟抖动明显。我在生产环境遇到过一个 Java 服务 GC 线程偶发 100ms 级 STW查到最后发现和 memory.max 设置太紧有关系放开水位后一切正常。5.4 调参建议与实测影响我一般在遇到内存回收相关问题时会先看这几个参数/proc/sys/vm/min_free_kbytes不要盲目调大调大会让每个 zone 保留更多不可用内存用户态可用的会变少。我见过有人把 64GB 机器调到 1GB导致内存直接消失接近 1GB。/proc/sys/vm/watermark_scale_factor如果 kswapd 唤醒太频繁可以适当调大让每次回收更彻底但要注意分配延迟会更高。/proc/sys/vm/vfs_cache_pressure控制 kswapd 回收时 dentry/inode 缓存的倾向默认 100值越大越激进。有一回我调大 watermark_scale_factor 到 200 后kswapd 的唤醒次数直接从每秒几百次下降到几十次但系统出现偶尔的内存分配等待变长。所以在追求极致性能的数据库服务器上这种参数要谨慎测试不能照抄网上配置。6. 性能优化per-CPU 页表和 PCP 批量分配6.1 PCP给每个 CPU 一份 order-0 缓存伙伴系统在为 order 0 分配和释放页时需要操作 zone 的 free_area 链表而多个 CPU 同时操作时会有锁竞争。为了减少锁开销内核给每个 CPU 维护了一个 per-CPU 页表PCP专门缓存 order 0 的空闲页。struct per_cpu_pages里有几个 list分多个 migratetype。分配 order 0 时直接从本 CPU 的 PCP 里拿不需要加 zone 锁如果 PCP 为空才从伙伴系统批量搬一批页到 PCP。释放时也先归还到 PCP攒够批量再统一还给伙伴系统。这个设计很贴合实际内核里绝大多数内存分配都是 order 0 的小对象比如进程的页表、匿名页、页缓存。PCP 的存在让每秒几万次的分配也能保持比较低的延迟。6.2 batch 与 highPCP 的装载策略PCP 里有两个关键参数batch 和 high。batch表示一次从伙伴系统搬运多少页high表示 PCP 缓存的容量上限。当 PCP 页数超过 high 时会超出的部分归还给伙伴系统。zoneinfo里可以看到类似这样的输出Node 0, zone Normal pages free 12345 boost 0 min 234 low 291 high 351 spanned 196608 ... per-cpu: pageset 0: nrepages: 10389 pcp: 1: 21 31 24 25 20 ...这个pcp后面的一串数字是不同 migratetype 下的缓存页数。正常情况下不会有问题但如果你发现某个 CPU 上 PCP 里堆积了大量页而其他 zone 已经紧缺那可能需要关注是不是 NUMA 内存绑定的问题。6.3 批量分配接口与真实收益除了内核内部 PCP__alloc_pages_bulk是近几年合入的批量分配接口一次可以分配多个 order 0 页。网络协议栈的 page pool 和 io_uring 的 buffer ring 都是它的客户。批量分配减少了对 buddy 锁的争用也降低了重复检查水位的开销。我在做网络转发优化时用 page pool 后 pps 提升了大约 8%其中很大一部分来自减少 Page Allocator 的锁竞争。如果你的业务是大量小包收发强烈建议关注一下内核的 page pool 机制。7. 踩坑与调试我遇到过的 Page Allocator 相关故障7.1 场景一长时间运行后高阶连续内存分配失败现象设备运行若干天后驱动加载失败日志里有alloc_pages failed或者page allocation failure: order:6。此时cat /proc/buddyinfo你会发现 order 6 以上的列几乎全是 0但空闲总页数还行。这种我处理过不止一次根因是碎片化累积。最有效的短期方案是触发内存规整echo 1 /proc/sys/vm/compact_memory长期方案驱动改用 CMA 区域开机预留一大块连续内存。业务侧避免频繁申请大块连续物理内存。如果设备内存支持调整vm.min_free_kbytes稍微抬高为紧急大块分配留出空间。注意compact_memory是同步操作内存很大时会有明显延迟不要在业务高峰期随便执行。7.2 场景二min_free_kbytes 设置过大导致可用内存异常偏少有次朋友说系统 32GB 内存但是刚开机 MemAvailable 只有 30GB怎么少了一块最后发现他参考某性能调优文章把/proc/sys/vm/min_free_kbytes调到了 10485761GB。内核要为所有 zone 保留这 1GB 的不可动配额当然可用内存就少了。正确做法是不要超过物理内存的 1% 到 2%。大多数机器默认 67584 左右就够了。调小会加大内存紧张时的分配失败概率调大则浪费内存。移动设备上为了低延迟甚至有人调大来避免 OOM但在服务器上不建议这么做。7.3 场景三kswapd0 高 CPU 与反复回收现象是top里 kswapd0 一直占 CPUsar -B显示 pgscank/pgscand 很高。这通常不是分配器本身的问题而是内存压力持续较高。我先查cat /proc/zoneinfo看各 zone 水位如果大部分 zone 的 free 都低于 low说明内存确实不够。此时先排查业务侧有没有内存泄漏再看是否需要调高watermark_scale_factor减少唤醒频率。如果业务内存分配突发性强可以考虑调整 vm.swappiness 和 vfs_cache_pressure但别指望靠调参解决真正的内存不足。7.4 常用调试命令集最后分享一份我平时排查 Page Allocator 相关问题的命令清单cat /proc/buddyinfo看各 order 空闲块判断碎片化。cat /proc/pagetypeinfo看 migrate type 分布定位碎片来源。cat /proc/zoneinfo看水位、PCP、nr_free 等核心状态。cat /proc/pressure/memory看 PSI 内存压力指标判断是否内存短缺。dmesg | grep page allocation failure查找实际分配失败现场。echo 1 /proc/sys/vm/compact_memory紧急规整慎用。grep -r . /sys/kernel/mm/transparent_hugepage/查看 THP 设置THP 会放大 order 9 的分配需求容易暴露碎片问题。我看到分配失败日志时第一反应是看 order 和 Call Trace确定是哪个上下文在申请大页然后马上cat /proc/buddyinfo确认有没有对应 order 的空闲块。如果明明有但分配失败那大概率是被 zone 的lowmem_reserve挡住了这种要看zoneinfo里的protection字段而不是盲目调 min_free_kbytes。Page Allocator 这块内容确实多但把它拆成数据结构、分配路径、碎片治理、水位回收、性能优化几条线来看整个逻辑其实是非常清晰的。你在实际项目里如果遇到内存分配失败、系统卡顿、碎片化导致的连续内存问题按文章里的思路一步步排查大概率能在十分钟内定位到方向。最后留个建议动手改任何 vm 内核参数之前先在测试环境上压一把很多人把机器的调参搞崩都是因为照搬了根本不匹配的场景配置。