zone 里面有什么:水位线、free_area、buddy 和页面回收入口
上一篇把物理内存的地图铺开了:机器上可能有多个 NUMA node,每个 node 里又按用途和地址限制切成多个 zone;每个物理页框都有一个 struct page 描述它。
这篇继续往 zone 里面走。
当内核已经选定某个 zone 之后,它怎么知道这个 zone 还能不能分配页?又怎么找到一段连续的空闲物理页?
这两个问题分别对应两类核心机制:
- 水位线:判断一个 zone 的空闲页是否还够安全。
- buddy allocator:按
order管理连续页块,真正拿出物理页。
可以先用一张简化图定位:
struct zone
├─ _watermark[WMARK_MIN / LOW / HIGH]
├─ free_area[0] -> 1 page 空闲块
├─ free_area[1] -> 2 pages 空闲块
├─ free_area[2] -> 4 pages 空闲块
├─ ...
├─ managed_pages / spanned_pages / present_pages
├─ per-cpu pageset
└─ LRU / 回收 / 迁移类型相关状态
struct zone 不是“物理内存的一段字节”。它是内核管理这一段物理页的控制台:里面既有统计,也有阈值,也有空闲页链表,还和回收、迁移、碎片控制这些路径连在一起。
这里先把它和后面会讲到的 mm_struct、vm_area_struct 分清楚。
zone 属于物理内存管理层,回答的是:
哪些物理页还空闲?
哪些连续物理页块可以分配?
这个物理内存区域的水位线是否安全?
哪些已经使用的页可以通过回收重新变成空闲页?
mm_struct 和 vm_area_struct 属于进程虚拟地址空间层,回答的是:
这个进程有哪些合法虚拟地址区间?
这些虚拟地址区间允许读、写、执行吗?
背后是匿名内存、文件映射、堆、栈,还是动态库?
它们之间的关系不是“谁包含谁”,而是通过缺页和页表连接起来:
进程访问虚拟地址
│
▼
mm_struct / vm_area_struct
│
├─ 判断这段虚拟地址是否合法、权限是否允许
│
▼
页表
│
├─ 如果已经 present:虚拟页已经映射到某个物理页
│
└─ 如果 not present:可能触发缺页处理
│
▼
从 zone / buddy 分配物理页
│
▼
填入页表:虚拟页 -> 物理页框
所以不要把 mm_struct 或 VMA 理解成“已经分配出去的物理页面”。它们更像进程地址空间的合法区间表。一个 VMA 可以覆盖 1GB 虚拟地址,但其中大部分页表项可能还没有映射到任何物理页。只有真正访问到某一页、并且缺页处理决定要分配物理页时,内核才会从某个 zone 里拿出 page frame,再把页表补上。
一、struct zone 是物理页分配的核心管理单元
回顾上一篇的层级:
alloc_pages()
│
▼
选择 NUMA node
│
▼
选择允许使用的 zone
│
▼
在 zone 里分配 page frame
到了最后一步,内核面对的就不是“整个系统还有多少空闲内存”,而是“这个 zone 里有没有符合要求的空闲页”。
为什么要以 zone 为单位判断?
因为不同 zone 的页不完全等价。比如某些设备只能 DMA 到低地址内存,某些分配可以退到普通内存,某些页希望放到可迁移区域。内核不能因为系统总体还有很多空闲页,就随便消耗一个受限 zone 的低地址页。否则真正需要低地址页的设备驱动后面可能无页可用。
所以 struct zone 至少要回答几类问题:
- 这个 zone 覆盖哪些 PFN 范围。
- 这个 zone 里有多少页是可管理的。
- 当前空闲页是否低于水位线。
- 不同
order的连续空闲块各有多少。 - 这些空闲块属于哪种迁移类型。
- 页面回收和 compaction 应该从哪里介入。
可以把它理解成:
zone 不是内存内容本身,
zone 是内核给一段物理页建立的分配、统计和回收账本。
二、present_pages、managed_pages 和 spanned_pages
看 /proc/zoneinfo 时,经常能看到类似字段:
pages free 123456
min 1024
low 2048
high 3072
spanned 1048576
present 1044480
managed 1039000
这几个数字很容易混。它们不是同一个含义。
spanned_pages 表示这个 zone 从起始 PFN 到结束 PFN 跨过的页数。它描述的是地址范围跨度。物理地址空间可能有洞,所以跨度不等于真的有这么多 RAM。
zone PFN 范围:
start_pfn end_pfn
│ │
▼ ▼
[ RAM ][ RAM ][ hole ][ RAM ][ reserved ][ RAM ]
spanned_pages 覆盖整个跨度,包括中间的 hole
present_pages 表示这个 zone 里实际存在的物理页数量。内存洞不算进去,因为那里没有 RAM。
managed_pages 表示内核伙伴系统真正负责管理、可以用于普通页分配的页数。一些保留页、固件占用页、内核镜像占用页、特殊用途页,即使物理上存在,也不一定进入 buddy 的普通管理范围。
三者关系可以这样理解:
spanned_pages >= present_pages >= managed_pages
这不是数学公式层面的所有细节,而是读 /proc/zoneinfo 时最重要的直觉:
spanned看地址跨度。present看实际 RAM。managed看 buddy 能拿来分配的页。
讨论水位线和空闲页时,真正关心的是 managed_pages 这个口径。因为水位线要保护的是可分配页池,而不是地址空间跨度。
三、水位线:zone 不能被分到彻底见底
内存分配最直接的想法是:只要还有空闲页,就继续分配。Linux 不能这么做。
如果把一个 zone 的空闲页用到几乎为零,系统会进入很危险的状态:
- 中断上下文或持锁路径可能需要少量内存,不能长时间等待回收。
- 文件系统、块层、网络栈在回收过程中也可能需要临时内存。
- 高阶连续页需要更早感知碎片压力。
- 内核需要保留一定余量,避免所有分配都卡在同步回收上。
所以 zone 里有一组水位线,常见是:
WMARK_MIN
WMARK_LOW
WMARK_HIGH
抽象图如下:
空闲页多
▲
│
│ high:回收到这里,后台回收可以歇一歇
│ ─────────────────
│ low:低于这里,唤醒 kswapd 后台回收
│ ─────────────────
│ min:普通分配不应轻易跌破,可能直接回收
│ ─────────────────
│
▼
空闲页少
三个水位线大致表达三种状态:
- 高于
high:zone 相对宽裕。 - 低于
low:应该让后台回收线程kswapd开始补水。 - 低于
min:普通分配已经很紧张,分配路径可能需要直接回收或失败。
这里的“水位线”不是全系统一个数字,而是每个 zone 各有一套。不同 node、不同 zone 的压力要分别判断。
Node 0, Zone DMA32 有自己的 min/low/high
Node 0, Zone Normal 有自己的 min/low/high
Node 1, Zone Normal 也有自己的 min/low/high
这也是为什么系统明明还有空闲内存,某类分配仍然可能失败:它需要的那个 zone 或那个迁移条件下的空闲页不够。
四、一次分配如何检查水位线
内核分配页时,会根据分配标志、order、迁移类型、NUMA 策略等条件挑选可用 zone。这个过程细节很多,这里只讲够理解后文的简化版。
首先,分配标志会限制“哪些 zone 可以用”。比如有的分配必须来自低地址 DMA 区域,有的分配可以使用普通内存,有的用户页分配更偏向可迁移页区域。内核会根据这些约束确定一个可接受的 zone 范围。
其次,NUMA 策略会影响“先从哪个 node 找”。通常会优先当前 CPU 本地 node,因为访问更近;如果本地 node 不满足,再按 zonelist 里的顺序去其他 node 或其他允许的 zone 尝试。
迁移类型则影响“优先从哪类空闲链表拿”。比如可移动页尽量从 MIGRATE_MOVABLE 相关链表拿,不可移动页尽量别污染可移动区域。内存紧张时可以 fallback 到别的迁移类型,但这会增加后续碎片风险。
简化成一句话:
GFP 标志决定能用哪些 zone,
NUMA 策略决定先查哪些 node,
迁移类型决定优先用哪类 free list,
然后再对候选 zone 检查水位线和 buddy 空闲块。
对候选 zone,不是只看 free_pages > 0,而是检查分配后是否还能满足水位要求。
简化成图:
想从某个 zone 分配 2^order 页
│
▼
计算分配后剩余空闲页
│
▼
和目标 watermark 比较
│
├─ 仍然高于水位
│ └─ 可以尝试从 buddy 拿页
│
└─ 低于水位
├─ 唤醒 kswapd
├─ 尝试别的 zone / node
├─ 进入 direct reclaim
└─ 仍不满足时失败或走特殊保留路径
这里有一个容易忽略的点:order 越高,检查越严格。因为高阶分配需要连续页,不只是需要总量足够。一个 zone 可能有很多零散的 order-0 空闲页,但没有一个足够大的连续块。
比如要分配 order=4,需要连续 2^4 = 16 页。如果空闲页总数是 1000 页,但全都散成了单页,这次高阶分配仍然可能失败。
这不表示普通进程申请大块内存都需要连续物理页。多数用户态内存只要求虚拟地址连续,背后的物理页可以是零散的:
虚拟页: V0 V1 V2 V3
│ │ │ │
物理页: P9 P2 P80 P17
页表会把连续虚拟页分别映射到不同物理页框,所以 malloc(100MB) 或匿名 mmap 一大片地址时,通常不需要 100MB 连续物理内存。
真正需要高阶连续物理页的,多是内核或设备相关场景。比如某些 DMA 设备希望拿到一段总线地址连续的缓冲区,硬件可以从起始地址一路读写;再比如 2MB huge page 需要底层有一段足够连续的物理页框,才能用一个大页映射减少 TLB 压力。现代内核会用 scatter-gather、IOMMU、CMA、内存规整等机制减少这种压力,但只要需求是“物理地址连续”,buddy 的高阶空闲块就很重要。
水位线回答的是:
这个 zone 还能不能承受这次分配?
buddy 回答的是:
有没有一个大小合适的连续空闲块?
两个条件都要满足,分配才真正成功。
五、buddy allocator 用 order 管理连续页块
Linux 底层物理页分配器通常称为 buddy allocator,中文常叫伙伴系统。它管理的是以页为单位的连续物理内存块。
order 是 buddy 里的核心概念。这里的 order 不是“有序”的意思,而是“阶数”:一个 order-n 空闲块包含 2^n 个连续页。
order 0:2^0 = 1 页
order 1:2^1 = 2 页
order 2:2^2 = 4 页
order 3:2^3 = 8 页
...
如果页大小是 4KB,那么:
order 0 = 4KB
order 1 = 8KB
order 2 = 16KB
order 3 = 32KB
order 4 = 64KB
但不要把 4KB 当成所有架构固定事实。准确表达应该是:
order n 的大小 = 2^n * PAGE_SIZE
zone 里用 free_area[order] 保存每个阶数的空闲块:
free_area[0] -> 1 page 空闲块链表
free_area[1] -> 2 pages 空闲块链表
free_area[2] -> 4 pages 空闲块链表
free_area[3] -> 8 pages 空闲块链表
...
这里的链表节点不是额外分配出来的对象,也不是写在空闲物理页内容里的小结构。buddy 链表挂的是空闲页对应的 struct page。
上一篇已经说过,struct page 是内核另外维护的页描述符,通常位于 vmemmap 这类内核元数据区域;它不存放在被描述的那个物理页框里面。
struct page 元数据区
┌──────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ page for PFN 300 │ page for PFN 301 │ page for PFN 302 │ page for PFN 303 │
└──────────────────┴──────────────────┴──────────────────┴──────────────────┘
│
│ 首页对应的 struct page 代表整个 order-2 空闲块
▼
真实物理页框
┌─────────┬─────────┬─────────┬─────────┐
│ PFN 300 │ PFN 301 │ PFN 302 │ PFN 303 │
└─────────┴─────────┴─────────┴─────────┘
比如一个 order-2 空闲块包含 PFN 300 到 PFN 303 四个连续页,buddy 通常用 PFN 300 对应的 struct page 代表这一整块,并把这个 struct page 挂到 free_area[2] 链表里。这里说的“首页 struct page”,指的是“连续空闲块第一个页框对应的 struct page”,不是说 struct page 存在第一个物理页框内部。
抽象图如下:
struct zone
│
├─ free_area[0] ──► [PFN 100] ──► [PFN 408] ──► ...
├─ free_area[1] ──► [PFN 200-201] ──► ...
├─ free_area[2] ──► [PFN 300-303] ──► ...
└─ free_area[3] ──► [PFN 512-519] ──► ...
这里的 [PFN 300-303] 不是链表里存了四个节点,而是一个 order-2 空闲块,表示从 PFN 300 开始的 4 个连续页都空闲。
六、分配:没有刚好的块,就拆更大的块
假设内核要分配 order=2,也就是 4 个连续页。buddy 会先看 free_area[2] 有没有空闲块。
alloc_pages(order=2)
│
▼
free_area[2] 有空闲块?
│
├─ 有
│ └─ 取出一个 4 页块返回
│
└─ 没有
└─ 继续找更高 order
如果 free_area[2] 没有,但 free_area[4] 有一个 16 页块,buddy 会把大块逐级拆开:
一个 order-4 块:16 页
[ 0 1 2 3 4 5 6 7 | 8 9 10 11 12 13 14 15 ]
│
├─ 拆成两个 order-3 块,每块 8 页
│
▼
[ 0 1 2 3 | 4 5 6 7 ] [ 8 9 10 11 12 13 14 15 ]
│
├─ 继续拆其中一个 order-3 块
▼
[ 0 1 2 3 ] [ 4 5 6 7 ] [ 8 9 10 11 12 13 14 15 ]
返回一个 order-2 块:[0 1 2 3]
剩下的块挂回对应 order 链表
更一般地说:
目标 order 没有空闲块
│
▼
找更高 order 的空闲块
│
▼
拆成两个 buddy
│
├─ 一半继续拆或返回
└─ 另一半挂回较低 order 的 free_area
buddy 这个名字就来自这种“伙伴”关系。一个大块拆成两个同阶小块,这两个小块互为 buddy。它们地址相邻、大小相同、对齐关系固定。
七、释放:如果伙伴也空闲,就合并成更大的块
buddy 的另一个关键动作是释放时合并。
假设释放一个 order-2 块,也就是 4 页。内核会检查它的 buddy 是否也空闲、是否同样是 order-2、是否处在可合并状态。
free order-2 块 A
│
▼
找到 A 的 buddy:块 B
│
├─ B 也空闲且同阶
│ └─ A + B 合并成 order-3 块
│
└─ B 不可合并
└─ A 挂入 free_area[2]
那内核怎么找到并判断这个 buddy?
它不需要扫描整个 zone。buddy 的伙伴关系由起始 PFN 和 order 决定。简化理解,可以用下面这个公式找到伙伴块的起始 PFN:
buddy_pfn = pfn ^ (1 << order)
这里的 ^ 是按位异或。它的效果是翻转当前 order 对应的那一位,从而在同一个更大块内部找到“另一半”。
注意,buddy 不是“前后相邻都可以合并”。每个 order 块在当前阶数下只有一个合法伙伴。原因是合并后的更高 order 块也必须满足对齐要求。
比如 order-2 是 4 页,order-3 是 8 页。合法的 order-3 块必须按 8 页边界成组:
合法 order-3 块:
PFN 296-303
PFN 304-311
PFN 312-319
当前 order-2 块可能是某个 order-3 大块的前半边,也可能是后半边:
情况一:当前块是前半边
order-3 大块:PFN 296 297 298 299 300 301 302 303
└──── 当前块 ────┘ └──── buddy ────┘
当前块:PFN 296-299
buddy: PFN 300-303
情况二:当前块是后半边
order-3 大块:PFN 296 297 298 299 300 301 302 303
└──── buddy ────┘ └──── 当前块 ────┘
当前块:PFN 300-303
buddy: PFN 296-299
所以 buddy 不一定在当前块后面;如果当前块已经是更大块的后半部分,它的 buddy 就在前面。
也正因为有这个对齐要求,当前块不能随便和另一侧相邻块合并。比如 PFN 300-303 后面如果有一个空闲的 PFN 304-307,二者虽然连起来也是 8 页:
PFN 300-307
但它不是按 order-3 对齐的合法 buddy 块。真正合法的 order-3 分组是 PFN 296-303 和 PFN 304-311。如果随便合出 PFN 300-307,后续再按 buddy 规则拆分、找伙伴、继续合并都会失去一致性。
举个例子,假设正在释放一个 order-2 块:
当前块:PFN 300 - 303
order = 2
块大小 = 2^2 = 4 页
它的 buddy 起始 PFN 可以这样算:
buddy_pfn = 300 ^ (1 << 2)
= 300 ^ 4
= 296
所以当前块和 buddy 的关系是:
buddy B: PFN 296 297 298 299
当前块 A:PFN 300 301 302 303
合并后: PFN 296 297 298 299 300 301 302 303
order-3,共 8 页
找到 buddy_pfn 后,内核可以通过 pfn_to_page(buddy_pfn) 找到 buddy 首页对应的 struct page,再检查它的状态。重点不是检查物理页里的内容,而是检查 struct page 里的 buddy 元数据:
buddy 首页对应的 struct page
├─ 是否仍在 buddy 系统里,也就是这个块确实空闲
├─ 记录的 order 是否也是 2
├─ 是否和当前块属于同一个 zone
├─ 迁移类型是否允许合并
└─ 是否没有被隔离、保留或处在特殊状态
如果这些条件满足,说明 B 也是一个完整的 order-2 空闲块,内核就可以把 B 从 free_area[2] 链表摘下来,再和 A 合成一个 order-3 块。否则不能合并,只能把 A 自己挂回 free_area[2]。
比如下面这种情况就不能合并。注意这里不要把 B 理解成“当前还存在的完整块”。A 释放时只是按公式算出一个理论 buddy 范围:PFN 296-299。接下来检查这个范围首页 PFN 296 对应的 struct page,看它是不是一个完整的 order-2 空闲块。
当前块 A:PFN 300 301 302 303 正在释放,order-2
理论 buddy 范围:PFN 296 297 298 299
但这个范围在 A 释放前已经不是完整 order-2 空闲块:
PFN 296-297 是 order-1 空闲块,挂在 free_area[1]
PFN 298-299 正在使用
检查结果:
PFN 296 对应的 struct page 可能还在 buddy 系统里
但它记录的 order 是 1,不是 2
结果:
A 只能挂回 free_area[2]
不能合成 PFN 296-303 这个 order-3 块
如果合并成 order-3 后,它的新 buddy 也空闲,还可以继续向上合并:
order-2 + order-2 -> order-3
order-3 + order-3 -> order-4
order-4 + order-4 -> order-5
...
这就是 buddy 能在长期运行中尽量恢复大块连续内存的原因。释放路径不是简单地把页放进单页链表,而是不断尝试把相邻空闲伙伴合成更大的块。
但这个机制有前提:两个伙伴必须同时空闲,并且满足同阶、同 zone、迁移类型等条件。只要中间夹着一个仍在使用的页,大块就合不回来。
八、为什么会有外部碎片
buddy 解决了连续页块的拆分和合并问题,但不能消灭碎片。
看一个简化例子。假设一段 16 页物理内存里,偶数页空闲,奇数页被占用:
页号: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
状态: F U F U F U F U F U F U F U F U
这里有 8 个空闲页,总量看起来不少。但没有任何两个相邻空闲页,所以:
- order-0 分配可以成功很多次。
- order-1 分配可能失败,因为需要 2 个连续页。
- 更高 order 更不可能成功。
这就是外部碎片:空闲内存总量还在,但被已使用页切得太碎,无法满足连续页需求。
空闲总量够
│
└─ 但连续性不够
│
└─ 高阶分配失败
高阶分配常见于需要连续物理页的场景,比如某些驱动、页表页批量需求、大页相关路径、网络收发缓冲等。现代内核会尽量降低对高阶连续页的依赖,也会用 compaction、迁移类型、CMA 等机制缓解,但只要需求是“连续物理页”,碎片问题就一直存在。
这也是为什么 /proc/buddyinfo 比单纯的 free 更能反映连续页压力。
九、迁移类型:free_area 不是一条简单链表
前面为了讲清楚,把 free_area[order] 画成一条链表。真实内核里,它还会按迁移类型分组。常见迁移类型包括:
MIGRATE_UNMOVABLE:不可轻易迁移的页,例如一些内核长期对象。MIGRATE_RECLAIMABLE:可通过回收释放的页,例如部分文件缓存、slab 缓存。MIGRATE_MOVABLE:可迁移页,例如大多数用户匿名页和 page cache 页。MIGRATE_CMA:给连续内存分配器 CMA 预留或偏好的区域。MIGRATE_ISOLATE:内存隔离、下线或 compaction 过程中使用。
更接近源码字段的简化图应该这样看:
struct zone
└─ free_area[MAX_ORDER]
│
├─ free_area[0]
│ ├─ free_list[MIGRATE_UNMOVABLE]
│ ├─ free_list[MIGRATE_RECLAIMABLE]
│ ├─ free_list[MIGRATE_MOVABLE]
│ ├─ free_list[MIGRATE_CMA]
│ ├─ ...
│ └─ nr_free
│
├─ free_area[1]
│ ├─ free_list[MIGRATE_UNMOVABLE]
│ ├─ free_list[MIGRATE_RECLAIMABLE]
│ ├─ free_list[MIGRATE_MOVABLE]
│ ├─ ...
│ └─ nr_free
│
└─ ...
也就是说,zone->free_area[order] 先按 order 分层;每一层里不是一条链表,而是一个 free_list[MIGRATE_TYPES] 链表数组。具体空闲块挂在对应迁移类型的链表里,nr_free 记录这个 order 下空闲块数量。
用接近源码的形式表示,大概是:
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
struct zone {
struct free_area free_area[MAX_ORDER];
...
};
这里仍然是为了理解而简化过的图。不同内核版本里常量名、迁移类型数量和周边字段可能变化,但核心组织方式是:zone 里有多个 order,每个 order 下面再按 migratetype 分多条空闲链表。
比如一个 order-2、MIGRATE_MOVABLE 的空闲块,实际会挂到类似这样的位置:
zone->free_area[2].free_list[MIGRATE_MOVABLE]
链表节点仍然是这个空闲块首页对应的 struct page。分配时,内核会先根据目标 order 和迁移类型去对应链表找;当前 order 找不到时,再向更高 order 查找并拆分大块。释放时,合并结束后,也会把最终得到的空闲块挂回对应的 free_area[order].free_list[migratetype]。
为什么要分迁移类型?
核心目的之一是减少碎片。内核尽量把可移动页放在一起,把不可移动页放在一起。这样当系统需要整理出大块连续内存时,可移动页所在区域更容易通过迁移挪走,留下连续空闲块;不可移动页则不会随机散落到各处。
不分类型:
U M M U M U M M
不可移动页 U 散落,后续很难整理出大块
尽量分类型:
U U U U | M M M M
可移动区域更容易通过迁移整理
这不是绝对隔离。内存压力大时,内核可以在不同迁移类型之间 fallback。但这种分组能让长期运行后的碎片情况更可控。
十、per-cpu pageset:常见小分配不必每次都碰 zone 锁
如果每次分配和释放单页都直接操作 zone 的 buddy 链表,就会有明显锁竞争。尤其是多核机器上,大量 CPU 同时分配 order-0 页,都会争同一个 zone 锁。
所以 zone 还有 per-cpu pageset。更准确地说,它是 zone 为各个 CPU 准备的本地页缓存机制:某个 CPU 从某个 zone 频繁分配或释放低阶页时,可以先走这个 CPU 对应的 pageset,而不是每次都直接操作 zone 的全局 buddy 链表。
这不是说“每个 CPU 对每个可能存在的 zone 都固定持有一批页”。某个 pageset 当前有没有缓存页,取决于这个 zone 是否可用、这个 CPU 是否刚好从这里分配或释放过页,以及当前缓存是否已经被补充或排空。
某个 zone
│
├─ CPU 0 pageset ──► 可能缓存少量空闲页
├─ CPU 1 pageset ──► 可能缓存少量空闲页
└─ CPU 2 pageset ──► 可能缓存少量空闲页
│
└─ 背后批量补充 / 批量归还到这个 zone 的 buddy
分配单页时,内核可以先从当前 CPU 的 pageset 拿;本地缓存不够了,再批量从 buddy 里取一批补上。释放单页时,也可以先放回当前 CPU 的 pageset;超过阈值后,再批量还给 buddy。
这带来两个效果:
- 减少频繁拿 zone 锁的成本。
- 让常见 order-0 分配更快。
但要注意:per-cpu pageset 不是另一套独立内存。它缓存的页仍然属于某个 zone,只是暂时没有挂在全局 buddy 链表上。看空闲页统计和水位判断时,内核会把这些状态纳入考虑,避免本地缓存让全局判断失真。
十一、zone 和 LRU、回收入口的关系
zone 里不只管理空闲页。它还和页面回收紧密相关。
当水位线不足时,内核要想办法“补水”。补水的来源通常不是凭空出现新内存,而是从已经使用的页里找出可以释放或换出的页:
- 干净的文件页可以直接丢弃,需要时以后再从文件读。
- 脏文件页需要先写回,再释放。
- 匿名页如果要回收,通常需要写入 swap。
- 部分 slab 对象可以通过 shrinker 回收。
这些“已经使用但可能被回收”的页,不在 buddy 的 free_area 里。它们通常挂在 LRU 相关链表或各子系统自己的缓存结构中。只有回收成功后,页才会重新释放回 buddy,变成真正空闲页。
简化路径如下:
zone 空闲页低于 low
│
▼
唤醒 kswapd
│
▼
扫描可回收页
│
├─ 文件页:丢弃或写回后释放
├─ 匿名页:换出到 swap 后释放
└─ slab:通过 shrinker 释放对象页
│
▼
释放回 buddy
│
▼
zone 空闲页回升到 high 附近
如果分配路径等不及后台回收,或者后台回收还没来得及把水位补上,当前分配者可能自己进入 direct reclaim:
alloc_pages()
│
▼
watermark 不满足
│
├─ 唤醒 kswapd
└─ 当前线程同步回收一部分页
direct reclaim 会增加分配延迟,所以内核尽量通过 low 水位提前唤醒 kswapd,让后台线程在系统还没完全吃紧时开始回收。
这一篇只把入口关系讲清楚。真正的 LRU 分代、匿名页和文件页回收、swap、writeback、shrinker 等细节,后面可以单独展开。
十二、一次 alloc_pages(order=2) 的完整简化路径
把前面的概念串起来,假设内核要分配 4 个连续页:
alloc_pages(order=2)
│
▼
根据 GFP 标志和 NUMA 策略选择 zonelist
│
▼
遍历候选 zone
│
▼
检查 watermark
│
├─ 不满足
│ ├─ 尝试别的 zone
│ ├─ 唤醒 kswapd
│ └─ 必要时 direct reclaim / compaction
│
└─ 满足
│
▼
查 free_area[2]
│
├─ 有 order-2 块
│ └─ 取出并返回
│
└─ 没有
│
▼
查更高 order
│
├─ 找到大块
│ ├─ 逐级拆分
│ └─ 返回 4 个连续页
│
└─ 找不到
├─ 尝试回收 / 内存规整
└─ 失败
分配成功后,返回的通常是这个连续块首页的 struct page *。这个返回值本身只指向首页,不直接携带“这次一共有多少页”的信息。
那以后释放时,buddy 怎么知道要回收多少页?
如果调用者直接用 alloc_pages(gfp, order) 这类接口分配,那么调用者必须记住当初的 order,释放时再把同一个 order 传回来:
struct page *page = alloc_pages(gfp, 2); // 分配 4 页
__free_pages(page, 2); // 释放时也说明是 order-2
也就是说,buddy 不是从返回的 struct page * 里猜大小,而是由释放路径告诉它释放粒度。很多上层子系统还会有自己的元数据记录页的归属和大小,例如 slab、page cache、页表页、huge page 或 compound page。它们最终归还给 buddy 时,也要按正确 order 释放。
如果当初分配的是 order-2,释放时却按 order-0 释放,那就是严重 bug,会破坏 buddy 的空闲页账本。
调用者再根据用途做后续处理:
- 给用户匿名缺页:清零页面,建立页表映射。
- 给 page cache:把页接到文件缓存结构里。
- 给 slab:把页切成多个小对象。
- 给页表:作为新的页表页使用。
同一个底层页分配器,支撑了上层很多不同内存用途。
十三、从 /proc/buddyinfo 看 order 压力
可以用这个命令看 buddy 当前状态:
cat /proc/buddyinfo
输出通常类似:
Node 0, zone DMA 1 1 0 0 0 ...
Node 0, zone DMA32 1024 512 128 16 2 ...
Node 0, zone Normal 20000 3000 400 30 1 ...
每一行是一个 node + zone,后面的数字按 order 排列:
第 1 个数字:order-0 空闲块数量
第 2 个数字:order-1 空闲块数量
第 3 个数字:order-2 空闲块数量
...
注意这里统计的是“块数量”,不是页数量。比如 order-3 的一个块代表 8 页。
如果你看到低 order 很多,高 order 几乎没有,说明系统还有不少零散空闲页,但连续大块紧张:
order-0 多,order-1 多,order-8/order-9 接近 0
│
└─ 小页分配可能还行,高阶连续页分配压力大
/proc/pagetypeinfo 会进一步按迁移类型展示各 order 的分布,更适合观察碎片和迁移类型隔离效果:
cat /proc/pagetypeinfo
/proc/zoneinfo 则适合看水位线:
cat /proc/zoneinfo
读这些文件时,可以按这个顺序建立判断:
1. 看 node / zone 是哪一行
2. 看 free pages 和 min / low / high
3. 看 buddy 各 order 的空闲块数量
4. 再看 pagetypeinfo 里的迁移类型分布
不要只看系统总空闲内存。对物理页分配来说,关键经常是“哪个 zone、哪个 order、哪类迁移类型”。
十四、一个可控性有限的小实验
想观察 buddy 变化,可以先记录一次:
cat /proc/buddyinfo
然后用压力工具制造内存分配和释放,例如:
stress-ng --vm 2 --vm-bytes 70% --timeout 30s
再观察:
cat /proc/buddyinfo
cat /proc/zoneinfo
如果系统没有安装 stress-ng,也可以写小程序反复 mmap 大块匿名内存并逐页写入。但这里要强调:这个实验结果不一定稳定。原因很多:
- 内核可能使用透明大页。
- 后台回收和 compaction 会同时工作。
- 其他进程也在分配和释放内存。
- NUMA 策略、cgroup 限制、虚拟机环境都会影响结果。
所以正文不应该依赖某个固定数字。更可靠的观察方式是看趋势:
分配压力上来后:
free pages 下降
某些 order 的空闲块减少
watermark 附近可能触发 kswapd 活动
释放或回收后:
free pages 回升
buddy 可能合并出更高 order 块
这些趋势比具体数值更重要。
十五、把这一篇收束成一张图
内核需要物理页
│
▼
选 node / zone
│
▼
检查 zone watermark
│
├─ 水位不足
│ ├─ kswapd 后台回收
│ ├─ direct reclaim
│ └─ compaction / fallback / failure
│
└─ 水位足够
│
▼
查 free_area[order]
│
├─ 有合适空闲块
│ └─ 返回连续页
│
└─ 没有
├─ 找更高 order
├─ 拆大块
└─ 挂回剩余 buddy
现在可以把 zone 的位置说清楚了:
zone 是 Linux 物理页分配的核心管理单元;水位线决定能不能继续分,free_area 和 buddy 决定从哪里拿连续页,回收和迁移机制负责在压力和碎片出现时把页重新送回可分配池。
下一篇会从物理页分配切回进程地址空间,拆 vm_area_struct:当一个进程访问某个虚拟地址时,内核如何先判断“这段地址是不是合法的”。