4. 内存分配策略基础:本地分配优先原则、first-touch策略、page allocator在NUMA下的工作流程、zone与node的关系

4.1 NUMA 内存分配的核心原则:本地分配优先

在 NUMA 架构下,内存访问延迟的核心矛盾在于:访问本地内存(Local Memory)的速度远快于访问远程内存(Remote Memory)。因此,Linux 内核的内存分配策略首要原则就是 本地分配优先(Local Allocation First)。

该原则的含义是:当一个 CPU 上的进程请求分配物理内存页时,内核会优先从该 CPU 所在的 NUMA 节点(Node)所关联的内存区域(Zone)中分配。只有当本地节点内存不足时,才会跨节点分配远程内存。

关键指标: 本地内存访问延迟通常为 100-200 ns,而跨节点(通过 QPI/UPI/CCIX 等互联)访问延迟可能高达 300-600 ns,甚至更高。带宽也可能下降 30%-50%。因此,违反本地分配原则会直接导致应用性能显著下降。

4.2 First-Touch 策略:决定内存页归属的关键

Linux 内核默认采用 First-Touch(首次接触) 策略来分配物理内存页。这意味着,虚拟内存地址的物理页框是在进程首次访问(读或写)该地址时才真正分配的,而不是在 malloc() 或 mmap() 调用时。

First-Touch 策略与 NUMA 本地分配原则紧密结合:

  1. 虚拟地址分配: 进程调用 malloc() 时,内核仅在进程的虚拟地址空间中分配一段连续的虚拟地址(VMA),并不立即分配物理页。
  2. 缺页异常(Page Fault): 当进程的某个线程首次访问该虚拟地址时,触发缺页异常。
  3. 物理页分配: 内核的缺页异常处理程序会检查当前执行该访问的 CPU 所在的 NUMA 节点,并从该节点的 本地内存区域 分配一个物理页框。
  4. 页表映射: 将分配的物理页框映射到该虚拟地址。
实践启示: 在 NUMA 系统上,哪个 CPU 线程先访问数据,数据就分配在哪个 CPU 的本地内存上。因此,如果初始化阶段由一个线程(例如主线程)分配并填充数据,而后续由另一个 NUMA 节点上的工作线程处理数据,就会导致大量的远程内存访问。正确的做法是让工作线程自己初始化其将要处理的数据。

4.3 Zone 与 Node 的关系:内存层次结构

理解 NUMA 内存分配,必须厘清 Node 和 Zone 这两个核心概念及其关系。

概念 层级 含义 NUMA 视角
Node (节点) 物理拓扑层 一个或多个 CPU 核心及其本地内存、IO 控制器的集合。每个 Node 有独立的 struct pglist_data 数据结构。 NUMA 架构的基本单元。跨 Node 访问即为远程访问。
Zone (区域) 内存属性层 根据物理内存的用途和地址范围划分的区域。常见 Zone 包括 ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_HIGHMEM、ZONE_MOVABLE。 每个 Node 内部都包含一组完整的 Zone。例如,Node 0 有自己的 ZONE_NORMAL,Node 1 也有自己的 ZONE_NORMAL。

关系总结: 一个物理系统包含多个 Node,每个 Node 包含多个 Zone。内存分配时,内核首先确定目标 Node(本地优先),然后在目标 Node 的各个 Zone 中按优先级(如 ZONE_NORMAL -> ZONE_DMA32 -> ZONE_DMA)尝试分配。

NUMA 系统内存层次结构:Node 与 Zone 的关系 NUMA Node 0 CPU 0, CPU 1 ZONE_NORMAL (0 - 896MB) ZONE_DMA32 (0 - 4GB) ZONE_MOVABLE NUMA Node 1 CPU 2, CPU 3 ZONE_NORMAL (0 - 896MB) ZONE_DMA32 (0 - 4GB) ZONE_MOVABLE QPI/UPI (远程访问) 本地访问 本地访问

4.4 Page Allocator 在 NUMA 下的工作流程

Linux 内核的页分配器(Page Allocator)基于 伙伴系统(Buddy System),在 NUMA 环境下,其工作流程遵循严格的层级决策:

  1. 确定分配 Node: 根据当前进程的 CPU 亲和性(cpuset)和内存策略(mempolicy),确定首选 NUMA Node。默认策略是 MPOL_DEFAULT,即本地分配优先。
  2. 遍历 Zone 列表: 在选定的 Node 内,按照 ZONE_NORMAL → ZONE_DMA32 → ZONE_DMA 的顺序尝试分配。每个 Zone 内部使用伙伴系统查找空闲页块。
  3. 水位线检查(Watermark): 分配前检查 Zone 的空闲页数量是否高于 WMARK_LOW 水位线。如果低于 WMARK_LOW,内核会唤醒 kswapd 内核线程进行异步回收。
  4. 分配失败回退: 如果本地 Node 的所有 Zone 都无法满足分配请求(例如内存不足),则触发 跨节点回退(Fallback)。内核会查询 node_distance 表,按距离从近到远依次尝试其他 Node。
  5. OOM Killer: 如果所有 Node 都无法分配,且分配请求不可等待,则触发 OOM Killer 终止进程以释放内存。
NUMA Page Allocator 工作流程图 分配请求到达 1. 确定目标 Node 2. 遍历 Zone 列表 水位线检查 空闲充足 → 分配成功 低于 WMARK_LOW 唤醒 kswapd 回收 3. 跨节点 Fallback 4. OOM Killer

4.5 关键数据结构与代码路径

NUMA 内存分配的核心数据结构是 struct pglist_data,每个 Node 对应一个实例。其内部包含 struct zone 数组,管理该 Node 下的所有 Zone。

// 每个 NUMA 节点对应一个 pglist_data 结构
struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES]; // 该节点下的所有 Zone
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 备用 Zone 列表(用于 Fallback)
    int nr_zones; // 该节点实际包含的 Zone 数量
    unsigned long node_start_pfn; // 该节点起始物理页框号
    unsigned long node_present_pages; // 该节点总物理页数
    unsigned long node_spanned_pages; // 该节点总跨度页数
    int node_id; // 节点 ID
    // ... 其他字段
};

// 每个 Zone 管理一段连续物理内存
struct zone {
    unsigned long watermark[NR_WMARK]; // 水位线: WMARK_MIN, WMARK_LOW, WMARK_HIGH
    long lowmem_reserve[MAX_NR_ZONES]; // 为低内存 Zone 保留的内存
    struct pglist_data *zone_pgdat; // 指向所属 Node
    struct free_area free_area[MAX_ORDER]; // 伙伴系统的空闲页块链表
    // ... 其他字段
};
性能陷阱: 当本地 Node 内存不足时,内核会通过 node_zonelists 中的备用列表进行跨节点分配。如果系统配置不当(例如 cpuset 限制过严),可能导致频繁的远程内存分配,造成严重的性能抖动。监控 /sys/devices/system/node/node*/numastat 中的 interleave_hit 和 other_node 指标可以诊断此类问题。

4.6 总结:策略、流程与数据结构对照

策略/概念 核心机制 数据结构 性能影响
本地分配优先 分配请求优先选择当前 CPU 所在 Node current->mempolicy 保证低延迟本地访问
First-Touch 物理页在首次访问时分配,分配 Node 由访问线程决定 缺页异常处理函数 do_anonymous_page() 数据局部性由初始化线程决定
Zone 遍历 按 ZONE_NORMAL → ZONE_DMA32 → ZONE_DMA 顺序尝试 struct zone 数组 确保优先使用通用内存
跨节点 Fallback 本地失败后按距离顺序尝试其他 Node node_zonelists 引入远程访问延迟