zone 里面有什么:水位线、free_area、buddy 和页面回收入口

9 阅读27分钟

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_structvm_area_struct 分清楚。

zone 属于物理内存管理层,回答的是:

哪些物理页还空闲?
哪些连续物理页块可以分配?
这个物理内存区域的水位线是否安全?
哪些已经使用的页可以通过回收重新变成空闲页?

mm_structvm_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_pagesmanaged_pagesspanned_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-2A
        │
        ▼
   找到 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-303PFN 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:当一个进程访问某个虚拟地址时,内核如何先判断“这段地址是不是合法的”。