物理内存管理:从连续块到一次分配的约束

本页目录

还有八页空闲,却申请不到两页连续内存;某个节点资源充足,另一个受限申请仍失败。物理分配需要同时满足数量、形状、位置和等待条件⁠。

先在 16 页的伙伴模型中拆分、合并,再把 NUMA、zone、水位和 GFP 约束逐层加回来。

要解释的现象阅读位置
order 到底表示多少内存?第 1~2 节:伙伴模型与实验
有空闲页,为何当前申请不能用?第 3~4 节:位置、水位与 GFP
刚释放的页,为什么 buddyinfo 没立即变化?第 5 节:PCP 批量缓存
页面是否可回收、可下线?第 6~7 节:引用、热插拔与观测

1. 从 order 理解申请形状

alloc_pages(gfp, order) 申请 2^order 个物理连续基础页,返回相应 struct page。基础页大小由架构与配置决定;若 P=4096 字节,order 0、1、4 分别是 4、8、64 KiB。order 不是字节数,也不是页数本身。

假设一段受管理范围只有 PFN 0 到 15,初始是一块 order 4 空闲块。现在申请 order 2,也就是 4 页。分配器不必把 16 页全交出去:先拆成 [0,8) 与 [8,16),再把前者拆成 [0,4) 与 [4,8),保留两块未用伙伴供后续分配。

请求四页:分裂的是连续块,保留的是未使用伙伴 order 4:PFN [0,16),共 16 页 order 3:[0,8) order 3:[8,16) 空闲 [0,4) 交给调用者 [4,8) order 2 空闲 释放 [0,4) 后,只有同阶伙伴 [4,8) 也空闲且允许合并,才可逐级恢复大块。

沿图继续做两次释放,合并条件就清楚了:

时刻[0,4)[4,8)能否恢复 [0,8)
第二次申请四页后使用中使用中不能
释放第一次申请空闲使用中不能,伙伴未空闲
再释放第二次申请空闲空闲可以,合成 order 3

若 [8,16) 也仍空闲,就能继续合并回最初的 order 4。

相邻不够:还要是同阶、对齐的伙伴

对于按 2^order 对齐的块起点 PFN,同阶伙伴起点可按 PFN XOR (1 << order) 计算。但得到一个数字只是找到候选:还需验证它属于可合并的管理范围、确实是同阶空闲块,并满足相关迁移类型等规则。两个在地址上相邻的块不必是这一阶的伙伴,例如 order 2 的 [4,8) 与 [8,12) 不能直接合成一个按 8 页对齐的 order 3 块。

为什么空闲八页仍可能拿不到两页

仍取 16 页,假设所有偶数 PFN 被占用,所有奇数 PFN 空闲。此时一半内存空闲,却没有任何两页连续空闲。总量足够,形状不满足。这是外部碎片,而不是申请者要求的字节数超过空闲总量。

如果占用页可迁移,内核可能把它们搬到一侧,腾出更大的连续范围;若关键位置被不可迁移对象或长期固定的页占据,整理未必成功。若业务根本不需要物理连续,就应从接口设计上避免过高 order,而不是要求分配器在任意碎片状态下制造连续区域。高 order 的上限也由目标内核配置决定,不应固定抄写 MAX_ORDER=11。

下面直接改变申请阶数与占用布局。空闲总量保持足够时,也可以通过一次申请失败看出缺少的究竟是哪一种形状。

正在呈现知识画面
空闲页够多,为什么申请仍然失败?

order k 需要按 2^k 对齐的连续 2^k 页。空闲总量不足以证明存在这种形状;释放后只有同阶、对齐的空闲伙伴才能合并。

2. 一个可执行的伙伴模型

下面只管理 Python 整数表示的 16 页,不分配或移动真实物理内存。它展示拆分、同阶合并和分配记录如何互相约束,省略 NUMA、水位、PCP 与并发同步。

free = [set() for _ in range(5)]
free[4].add(0)
allocated = {}

def alloc(order):
    if not 0 <= order < len(free):
        raise ValueError("invalid order")
    source = next((n for n in range(order, len(free)) if free[n]), None)
    if source is None:
        return None
    start = min(free[source])
    free[source].remove(start)
    while source > order:
        source -= 1
        free[source].add(start + (1 << source))
    allocated[start] = order
    return start

def release(start):
    order = allocated.pop(start)  # 拒绝未分配/重复释放的起点
    while order + 1 < len(free):
        buddy = start ^ (1 << order)
        if buddy not in free[order]:
            break
        free[order].remove(buddy)
        start = min(start, buddy)
        order += 1
    free[order].add(start)

a, b = alloc(2), alloc(2)
assert (a, b) == (0, 4)
assert free[3] == {8}
release(a)
assert free[2] == {0}
release(b)
assert free[4] == {0} and not allocated
assert all(not blocks for blocks in free[:4])
print("分裂、伙伴阻塞与逐级合并:通过")

真实分配器必须在锁和记账协议下维护这些不变量,不能把模型中的 set 操作直接替换为无锁链表后使用。释放原始高阶申请时也要遵守对应 API 的阶数和引用契约,不能自行把其中任意页当独立分配结果释放。

3. Node 和 zone 给“可用”增加了位置条件

NUMA node 表达内存访问位置与代价,zone 表达同一节点内不同的使用约束。节点不一定等于 CPU socket:系统可以有只含内存的节点、跨多段物理地址的节点,单插槽机器也可能暴露 NUMA 拓扑。远端访问代价取决于硬件、访问模式和拥塞,不能固定写成慢 30%。

普通节点本地分配偏好可以减少远端访问,但用户内存策略、cpuset 和申请标志会影响允许集合及回退。应用只把线程绑到节点 0,而其已存在页面仍在节点 1,不会因为线程迁移就立即自动搬完所有内容。“first touch”也应理解为实际建立后备页时的放置行为,不能把 malloc 返回时刻当作所有页的物理归属时刻。

zone作用需要保留的区分
DMA / DMA32特定架构的寻址约束范围边界依架构;设备 DMA 应使用 DMA API 表达实际约束
NORMAL内核可常规访问的内存仍受节点、水位与分配用途限制
HIGHMEM部分 32 位配置没有永久直接映射的内存不是“内存占用高”的意思
MOVABLE尽量容纳可迁移内容,改善整理/下线能力不是任意内核对象都可放入,也不是绝对能下线
DEVICE为某些设备内存提供 page 等管理服务不等于普通 buddy 管理的通用 RAM

在常见 x86 配置中 DMA/DMA32 与低地址范围有关,但“申请 DMA32 内存”不能替代设备 DMA mask、IOMMU 映射及一致性处理。CXL 提供的某段内存也可能作为普通 System RAM 使用,不是看见 CXL 就一定属于 ZONE_DEVICE。拓扑与结构定义见 Physical Memory。

4. 水位不是三条固定的故障开关

空闲页保留一部分余量,是为了让突发申请及负责回收的路径本身还能取得资源。若普通申请耗尽所有页,回收所需的 I/O 或元数据申请也可能无法推进,形成更难解的循环。

水位在简化模型中的作用
low附近的平衡条件触发后台回收推进
high常用的后台恢复目标
min参与紧急保留及更严格的准入判断

实际检查还包括 order、低 zone 保护、不可用空闲页和 watermark boost。它们不是三个固定开关,不能直接推出“低于 low 必直接回收、低于 min 必 OOM”。

例如 NORMAL 有 1200 个空闲页,min/low/high 假设为 1000/1500/2000。这个状态提示后台平衡应推进,但不能仅凭 1200 就决定 order 4 一定成功:可能找不到连续 16 页,也可能申请受另一个节点或 zone 的约束。反过来,某些不能等待的申请失败时会直接返回,不会替调用者进行同步回收。

一次分配:逐层收窄允许使用的资源 申请形状 order、连续性、用途与调用上下文 1 节点和 zone 选择 NUMA 策略、cpuset 与寻址约束 2 尝试现成资源 PCP / buddy;同时满足水位和保留规则 3 慢路径按允许条件推进 回收、整理、重试或节点回退 4 成功或返回失败 可睡眠不等于必成功;紧急分配也有资源上限 5 这里表示约束关系;具体慢路径顺序依内核、order 和 GFP 而变。

GFP:调用者允许分配器做多少工作

标志允许行为的重点调用者仍须处理
GFP_KERNEL合适上下文中可睡眠并回收申请失败与清理
GFP_NOWAIT避免直接回收造成等待更受限的可用资源
GFP_ATOMIC不能睡眠的重要申请有不同的保留访问条件储备耗尽;不适用于任意 NMI 路径

GFP 是行为约束,不是成功率承诺。

__GFP_RECLAIM 包含直接回收与 kswapd 相关许可,不是 __GFP_DIRECT_RECLAIM 的简单新名字;__GFP_NORETRY 也不是完全禁止任何轻量回收尝试。文件系统或块 I/O 内部若回收可能重入自己持有的资源,需要合适的 NOFS/NOIO 范围控制;随手全改成 GFP_ATOMIC 只是缩小资源选择,未解决依赖设计。见 内存分配指南。

5. PCP 缓存如何降低锁竞争,又为何影响观测

假设一个 CPU 连续申请、释放很多小阶页,每次都去修改 zone 的全局空闲结构,会把本地操作变成共享锁竞争。PCP pagesets 将一批页留在 CPU 侧,常见申请从本地批量缓存取,耗尽时再补充;释放先进入本地缓存,达到阈值后再批量归还。

它将全局操作摊薄,也意味着刚释放的页未必立即出现在 buddy 的高阶空闲链表里。PCP 排空后才可能与其他伙伴合并,观测时要分清“系统空闲页”与“当前 buddy 按阶列出的块”。PCP 支持哪些阶、采用哪种本地同步依版本与配置变化,不能永远描述成“只有 order 0,完全无锁”。

迁移类型:把相近用途放在一起

与 PCP 不同,迁移类型主要试图把具有相近可迁移性的分配聚在一起,避免不可迁移对象散落在每个大范围中。RECLAIMABLE 表示所属子系统有机会回收对象,MOVABLE 表示可以改变物理位置而维持语义,二者不是一回事。回退与运行时固定引用仍可削弱最初的分类效果。

6. page 描述符、引用和内存下线

struct page 是管理元数据,不是页内容本身。字段含义随页用途而变,普通 RAM、compound page、slab 和设备页也不应硬套一个伪造结构布局。引用计数与映射计数回答不同问题:没有用户 PTE 映射的页仍可能被页缓存或内核持有;拿到一个 page 指针也不自动代表拥有可无限延长的引用。

若基础页为 4 KiB,假设某配置的 page 描述符占 64 字节,单看这一项就是 64/4096=1.5625% 的比例;这个计算用于理解元数据规模,不能把 64 字节写成所有内核的固定大小。大型内存系统还要考虑 vmemmap 组织、保留区和其他管理结构。

热插拔:加入范围与交给分配器是两步

阶段必须完成的状态变化
add建立内存范围及管理元数据
online让页分配器可以使用这些资源
offline停止继续分配,并迁移或释放占用内容
remove移除范围与管理状态

MOVABLE 提高下线机会,但长期 pin、不可迁移内容或迁移目标不足仍可能阻止 offline。见 内存热插拔。

7. 读 buddyinfo,得到证据而非成功保证

from pathlib import Path
import os
import re

page_size = os.sysconf("SC_PAGE_SIZE")
for line in Path("/proc/buddyinfo").read_text().splitlines():
    match = re.match(r"Node\s+(\d+),\s+zone\s+(\S+)\s+(.*)", line)
    if not match:
        continue
    node, zone, rest = match.groups()
    blocks = [int(value) for value in rest.split()]
    pages = sum(count * (1 << order) for order, count in enumerate(blocks))
    highest = max((i for i, count in enumerate(blocks) if count), default=None)
    print(f"node={node} zone={zone} buddy_bytes={pages * page_size} "
          f"highest_nonempty_order={highest}")

每列是该 order 的块数,不是页数,要乘 2^order 再乘基础页大小。最高非空阶只说明读取时存在该阶块;并发申请、水位、迁移类型和节点策略仍可使你的请求失败。结合 zoneinfo 的水位/保护、进程 NUMA 策略与分配上下文,才能解释失败原因。

下一篇 Slab 与 kmalloc 把一批物理页切成对象;页面回收与交换 则解释慢路径怎样把已用内存重新变成可用资源。