1. 从“统一”到“非统一”:内存访问的演进与内核设计的基石

如果你写过C语言程序,或者用过 malloc ,你可能觉得内存就是一块连续、均匀的空间,CPU访问它就像我们伸手拿桌上的水杯一样,距离和速度都是恒定的。但在多处理器(多核)系统的世界里,这个朴素的认知在几十年前就被打破了。内存访问并非总是“一碗水端平”,其背后是两种截然不同的体系架构思想:UMA(Uniform Memory Access,统一内存访问)和NUMA(Non-Uniform Memory Access,非统一内存访问)。理解这两者,尤其是NUMA,是深入Linux内核内存管理、性能调优乃至虚拟化、云计算等领域不可或缺的一课。这不仅仅是硬件架构的差异,更是操作系统内核(特别是像Linux这样的现代内核)设计时必须面对的根本性约束和优化出发点。

为什么内核开发者、系统架构师和性能工程师需要关心这个?因为内存访问的延迟和带宽,直接决定了应用程序的吞吐量和响应时间。在一个NUMA系统中,一个进程如果“跑错了”位置,其性能可能下降数倍。随着多核处理器成为绝对主流,从数据中心服务器到高端PC,NUMA架构无处不在。因此,无论是为了诊断线上服务的性能抖动,还是为了设计一个能榨干硬件潜力的高性能应用,亦或是单纯想理解 numactl 、 /sys/devices/system/node 这些工具和接口背后的原理,从UMA和NUMA这一对概念入手,都是最坚实的起点。

2. UMA:对称多处理的理想模型

在深入复杂的NUMA之前,我们先回顾一下它的前身——UMA。理解UMA有助于我们看清NUMA要解决什么问题,以及它带来了哪些新的挑战。

2.1 UMA的核心架构与工作模式

UMA,顾名思义,其核心特征是 所有处理器(CPU核心)访问整个系统内存的任何部分,所花费的时间(延迟)和可用的带宽都是相同的、一致的 。想象一个圆桌会议,所有参会者(CPU核心)到桌子中央的文件堆(内存)的距离完全相等,任何人取放文件的速度都一样。

在硬件实现上,典型的UMA系统有一个共享的 系统总线 (System Bus)或 交叉开关 (Crossbar Switch)。所有CPU和内存控制器都连接在这个共享互连上。当CPU需要读取内存时,它通过这个共享通道发送请求,内存控制器响应后,数据再通过同一通道返回。由于所有CPU都使用同一条“高速公路”访问唯一的内存“仓库”,因此访问特性是均匀的。

为什么说它是“理想模型”? 因为它极大地简化了操作系统内核的设计。内核的内存管理子系统可以认为系统只有“一块”内存,所有CPU对它的访问成本相同。因此,内核在分配内存、调度进程、进行负载均衡时,完全不需要考虑“内存的位置”这个因素。它只需要关注哪个CPU空闲,就把进程调度到哪个CPU上;需要分配内存时,从全局的空闲页面链表中分配即可。这种简化的抽象,使得早期SMP(Symmetric Multi-Processing,对称多处理)系统的内核设计相对直接。

2.2 UMA的瓶颈与 scalability 问题

然而,UMA的“理想”模型存在一个物理上的根本瓶颈: 共享互连的带宽和仲裁延迟 。

随着CPU核心数量的增加,所有核心对内存的访问请求都挤在一条共享总线上。这会导致:

  1. 总线争用 :多个核心同时发起内存访问时,需要仲裁,增加了延迟。
  2. 带宽饱和 :总线的最大带宽是固定的。当核心数增加到一定程度,总带宽需求超过总线能力时,就会成为系统性能的瓶颈。即使增加更多CPU,系统的整体性能也无法线性提升,甚至可能下降。

这就引出了计算机体系结构中的一个关键概念: 可扩展性 。UMA架构在核心数较少时(例如2-4个核心)工作良好,但当核心数量达到几十甚至上百个时,那个共享的“中央通道”就会成为整个系统的性能瓶颈。这就像一个小镇只有一条主干道,当居民(CPU核心)和车辆(数据请求)激增时,必然导致严重的交通堵塞。

3. NUMA:为可扩展性而生的分布式内存架构

为了解决UMA的可扩展性问题,NUMA架构应运而生。它的设计哲学从“集中共享”转向了“分布式协作”。

3.1 NUMA的基本原理与物理拓扑

NUMA的核心思想是: 将物理内存和与之紧密关联的CPU核心分组,形成一个“节点”。每个节点内的CPU访问本节点内的内存,速度很快(延迟低,带宽高),称为“本地访问”。而访问其他节点内存的速度则较慢(延迟高,带宽可能受限),称为“远程访问”。

这种“快慢有别”正是“非统一”一词的由来。在硬件上,这通常通过以下方式实现:

  • 每个CPU插槽(Socket) 通常构成一个NUMA节点。一个服务器可能有2个、4个甚至8个CPU插槽。
  • 每个插槽 集成了多个CPU核心、集成内存控制器(IMC)和一部分物理内存(称为本地内存)。
  • 节点之间 通过高速互连链路连接,如Intel的QPI(QuickPath Interconnect)、AMD的Infinity Fabric、ARM的CMN(Coherent Mesh Network)等。这些链路负责处理跨节点的内存访问和缓存一致性协议。

以一个双路服务器为例:

  • Node 0 : 包含 CPU Socket 0(比如24个核心)和直接插在该插槽附近的128GB内存(A组)。
  • Node 1 : 包含 CPU Socket 1(另外24个核心)和它自己的128GB内存(B组)。
  • Socket 0 上的核心访问内存A,路径极短(通过集成的内存控制器),速度极快。
  • Socket 0 上的核心如果需要访问内存B,请求必须通过CPU间的QPI/Infinity Fabric链路,转发到Socket 1的内存控制器,再返回数据。这个路径更长,延迟通常是本地访问的1.5到3倍,带宽也可能更低。

3.2 NUMA带来的优势与挑战

优势是显而易见的:可扩展性。 通过将内存访问“本地化”,大部分内存访问流量被限制在各个节点内部,避免了全局总线的拥堵。节点之间的互连链路虽然慢于本地访问,但远优于所有流量挤在一条总线上。这使得系统可以通过增加节点(CPU插槽)来近乎线性地增加总内存带宽和整体计算能力,从而支持拥有数百个核心的大型服务器。

然而,挑战转移到了软件层,尤其是操作系统内核:

  1. 意识 :内核必须首先“知道”系统是NUMA拓扑的。它需要在启动时探测硬件,构建出系统的NUMA节点图。
  2. 分配策略 :内核不能像在UMA下那样随意分配内存。最优策略是总是尝试从 当前正在运行的CPU所在的本地节点 分配内存。这被称为“本地分配”策略。
  3. 调度关联性 :进程调度和内存分配需要协同工作。理想情况是,一个进程被调度到某个CPU核心上运行,并且它所需的内存也尽可能从该核心所属的节点分配。如果进程在Node 0上运行,却大量使用Node 1上的内存,性能就会显著下降。这种现象称为“NUMA失衡”。
  4. 数据共享与迁移 :当多个进程(或线程)需要频繁访问同一块内存,而它们又运行在不同节点上时,这块内存应该放在哪里?内核有时需要在“让所有访问者都承受远程访问代价”和“在节点间迁移内存页面以跟随访问者”之间做出权衡。

4. Linux内核中的NUMA支持与实现细节

Linux内核从2.5版本开始就引入了NUMA支持,并随着时间推移不断强化。理解内核如何抽象和管理NUMA,是进行系统级调优的关键。

4.1 内核数据结构: pg_data_t 与内存节点

在UMA系统中,内核用一个全局的 mem_map 数组管理所有物理内存页。而在NUMA系统中,这个结构被分解了。内核为每个NUMA节点定义了一个 pg_data_t 结构(在源码中常称为 pglist_data )。

每个 pg_data_t 结构体管理一个节点内的所有物理内存。它包含了:

  • node_zones : 该节点内的内存区域数组(ZONE_DMA, ZONE_DMA32, ZONE_NORMAL等)。注意,每个节点都有自己的这些区域。
  • node_mem_map : 指向该节点所有物理页面描述符( struct page )数组的指针。
  • node_start_pfn : 该节点起始物理页帧号。
  • node_spanned_pages : 该节点跨越的总页面数(包含空洞)。
  • node_present_pages : 该节点实际可用的页面数。
  • 多个 per_cpu 的页面缓存(如 per_cpu_pageset ),用于加速本地内存分配。

你可以通过 /sys/devices/system/node/ 目录查看系统中的NUMA节点信息。例如, node0 目录下的内容就对应着第一个 pg_data_t 结构管理的内存和CPU信息。

4.2 内存分配策略:伙伴系统与zonelist

当内核或应用程序请求分配内存时(例如通过 alloc_pages ),内核需要决定从哪个节点分配。这是通过 分配策略 和 zonelist 实现的。

1. 分配策略: 内核定义了几种NUMA内存分配策略,最常用的是:

  • MPOL_DEFAULT : 默认策略。内核根据当前线程的CPU关联性( cpuset )和调度情况,自动选择“本地节点优先”。
  • MPOL_BIND : 将分配严格绑定到指定的一个或一组节点。
  • MPOL_PREFERRED : 优先从指定节点分配,如果失败则尝试其他节点。
  • MPOL_INTERLEAVE : 在指定的多个节点间交错(轮询)分配页面,可以用于均衡内存带宽压力。

这些策略可以通过 set_mempolicy() 系统调用,或者更常用的 numactl 命令行工具来设置。

2. Zonelist(区域列表): 在每个进程的 struct task_struct 中,有一个 nodemask_t 表示其允许的内存节点掩码。内核为每个节点和每种分配策略(如 GFP_KERNEL , GFP_USER )预计算了一个 zonelist 。这是一个有序列表,指明了当从当前节点分配失败时,应该按什么顺序去尝试其他节点。

对于默认策略, zonelist 的顺序通常是: 本地节点 -> 最近的其他节点(根据互联距离) -> 更远的节点 。这个“距离”信息来自ACPI SLIT(System Locality Information Table)或硬件提供的信息,内核用其构建一个“节点距离矩阵”,你可以在 /sys/devices/system/node/nodeX/distance 中看到。

4.3 调度器与NUMA平衡

现代Linux调度器(CFS)与NUMA紧密集成,其目标之一就是实现“NUMA亲和性”:让进程尽量运行在它的内存所在的节点上。

内核中有一个重要的后台内核线程叫 kswapd ,而在NUMA环境下,还有一个更关键的线程叫 migrated (或与NUMA平衡相关的任务)。它们负责监控系统的NUMA状况,并在必要时执行两种关键操作:

  1. 页面迁移 :如果一个页面被某个节点上的进程频繁访问,但该页面却位于另一个节点,内核可能会尝试将这个页面迁移到访问者所在的本地节点。
  2. 进程迁移 :如果一个进程的大部分内存都在某个节点上,但它却长时间在另一个节点上运行,内核可能会考虑将整个进程(或其线程)迁移到内存所在的节点。

这个平衡过程是动态的、持续的。内核会统计“NUMA失效率”(远程访问次数),作为平衡的依据。你可以通过 /proc/vmstat 中的 numa_hit , numa_miss , numa_foreign 等指标来观察系统的NUMA局部性情况。

5. 实战:观察、诊断与优化NUMA系统

理论最终要服务于实践。我们来看看如何在Linux系统上操作和优化NUMA。

5.1 探查系统NUMA拓扑

首先,你需要了解你的系统硬件拓扑。

# 1. 使用 numactl 查看硬件拓扑
numactl --hardware

# 输出示例:
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
node 0 size: 128841 MB
node 0 free: 56432 MB
node 1 cpus: 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 129019 MB
node 1 free: 98765 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

这个输出清晰地告诉我们:系统有2个节点(Node 0和1),每个节点有24个CPU核心和约128GB内存。 node distances 显示,节点自身距离是10(基准值),跨节点距离是21(值越大,访问越慢)。

# 2. 使用 lscpu 命令
lscpu | grep -i numa
# 输出会显示每个CPU核心属于哪个NUMA节点。

# 3. 查看 /sys 文件系统
ls /sys/devices/system/node/ # 列出所有节点
cat /sys/devices/system/node/node0/cpulist # 查看node0的CPU列表
cat /sys/devices/system/node/node0/meminfo # 查看node0的内存信息

5.2 使用numactl控制进程的NUMA策略

numactl 是管理NUMA策略最直接的工具。

# 1. 将程序绑定到Node 0的CPU上运行,并且内存只从Node 0分配
numactl --cpunodebind=0 --membind=0 ./my_application

# 2. 将程序绑定到Node 0和1的CPU上,内存交错分配(适用于内存带宽密集型应用)
numactl --cpunodebind=0,1 --interleave=0,1 ./my_application

# 3. 查看一个正在运行进程的NUMA状态
numactl --show <pid>
# 或者查看 /proc/<pid>/numa_maps
cat /proc/$(pidof my_application)/numa_maps | head -20

/proc/<pid>/numa_maps 文件非常强大,它显示了该进程的每一段虚拟内存映射对应的物理页面分布在哪个NUMA节点上,是分析NUMA局部性的金钥匙。

5.3 诊断NUMA性能问题与常见陷阱

症状 :系统CPU使用率不高,但应用吞吐量上不去,性能监控显示较高的“内存访问延迟”或“跨节点流量”。

诊断步骤:

  1. 检查是否启用了NUMA平衡 : cat /proc/sys/kernel/numa_balancing 。如果为1,表示启用。对于某些延迟极度敏感的应用,有时禁用平衡(设为0)并手动绑定策略可能更稳定,但需要深入理解应用行为。
  2. 监控NUMA统计信息 :
    # 查看全局NUMA事件
    cat /proc/vmstat | grep numa_
    # 关注 numa_hit(本地访问), numa_miss(本该本地却远程), numa_foreign(其他节点访问本节点)
    # 如果 numa_miss 和 numa_foreign 持续很高,说明局部性很差。
    
    # 使用 perf 观察内存访问
    perf stat -e node-loads,node-load-misses <command>
    # node-load-misses 高意味着很多访问落到了远程节点。
    
  3. 分析应用行为 :使用 numastat 命令查看各节点的内存分配和使用情况。
    numastat -c <program_name> # 查看特定程序名在各节点的内存分布
    numastat # 查看系统全局各节点的内存统计
    
    如果发现某个进程的内存大量分布在非其运行节点的其他节点上,这就是典型的NUMA失衡。

常见陷阱与优化建议:

  • 默认策略不一定最优 :内核的默认“本地优先”策略在大多数情况下很好,但对于一些特殊场景,如:
    • 内存密集型单线程应用 :将其绑定到单个节点( --membind )可以避免内存碎片化到多个节点,可能提升缓存命中率。
    • 大规模并行初始化 :如果很多线程同时启动并大量分配内存,可能导致所有内存都集中分配到主节点(通常是Node 0),造成“节点0热点”。使用交错分配( --interleave )可以缓解。
  • 大页(Hugepages)与NUMA :使用大页时,NUMA策略同样重要。一个大页(如2MB)的所有物理页面必须来自同一个NUMA节点。在分配大页池时(如通过 /sys/kernel/mm/hugepages ),内存就是从当前节点分配的。因此,需要确保在每个节点上都配置了足够的大页。
  • 虚拟化环境(KVM) :在给虚拟机分配vCPU和内存时,必须考虑NUMA亲和性。最佳实践是将虚拟机的vCPU和内存绑定到同一个物理NUMA节点上。Libvirt的 <numatune> 和 <cpu> 的 <topology> 标签可以用于此目的。否则,虚拟机内的性能会因跨节点访问而严重受损。
  • 数据库系统 :像MySQL、PostgreSQL这样的数据库,通常对内存和CPU缓存非常敏感。强烈建议为数据库进程配置NUMA策略,通常将其绑定到一个或一组特定的节点,并关闭全局的NUMA平衡(在数据库启动脚本中使用 numactl ),因为数据库自身的内存管理可能比内核的通用平衡策略更优。

理解UMA和NUMA,是从“程序员视角”升级到“系统架构师视角”的重要一步。它让你看到硬件与软件之间那道关键的抽象层,并学会在必要时穿透这层抽象,让软件更好地贴合硬件,从而释放出真正的性能潜力。在云计算和微服务时代,虽然容器和编排平台试图隐藏底层复杂性,但在处理性能关键型服务时,NUMA依然是那个无法绕过的底层真相。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐