
1. 从“统一”到“非统一”内存访问的演进与内核设计的基石如果你写过C语言程序或者用过malloc你可能觉得内存就是一块连续、均匀的空间CPU访问它就像我们伸手拿桌上的水杯一样距离和速度都是恒定的。但在多处理器多核系统的世界里这个朴素的认知在几十年前就被打破了。内存访问并非总是“一碗水端平”其背后是两种截然不同的体系架构思想UMAUniform Memory Access统一内存访问和NUMANon-Uniform Memory Access非统一内存访问。理解这两者尤其是NUMA是深入Linux内核内存管理、性能调优乃至虚拟化、云计算等领域不可或缺的一课。这不仅仅是硬件架构的差异更是操作系统内核特别是像Linux这样的现代内核设计时必须面对的根本性约束和优化出发点。为什么内核开发者、系统架构师和性能工程师需要关心这个因为内存访问的延迟和带宽直接决定了应用程序的吞吐量和响应时间。在一个NUMA系统中一个进程如果“跑错了”位置其性能可能下降数倍。随着多核处理器成为绝对主流从数据中心服务器到高端PCNUMA架构无处不在。因此无论是为了诊断线上服务的性能抖动还是为了设计一个能榨干硬件潜力的高性能应用亦或是单纯想理解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上需要分配内存时从全局的空闲页面链表中分配即可。这种简化的抽象使得早期SMPSymmetric Multi-Processing对称多处理系统的内核设计相对直接。2.2 UMA的瓶颈与 scalability 问题然而UMA的“理想”模型存在一个物理上的根本瓶颈共享互连的带宽和仲裁延迟。随着CPU核心数量的增加所有核心对内存的访问请求都挤在一条共享总线上。这会导致总线争用多个核心同时发起内存访问时需要仲裁增加了延迟。带宽饱和总线的最大带宽是固定的。当核心数增加到一定程度总带宽需求超过总线能力时就会成为系统性能的瓶颈。即使增加更多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的QPIQuickPath Interconnect、AMD的Infinity Fabric、ARM的CMNCoherent 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插槽来近乎线性地增加总内存带宽和整体计算能力从而支持拥有数百个核心的大型服务器。然而挑战转移到了软件层尤其是操作系统内核意识内核必须首先“知道”系统是NUMA拓扑的。它需要在启动时探测硬件构建出系统的NUMA节点图。分配策略内核不能像在UMA下那样随意分配内存。最优策略是总是尝试从当前正在运行的CPU所在的本地节点分配内存。这被称为“本地分配”策略。调度关联性进程调度和内存分配需要协同工作。理想情况是一个进程被调度到某个CPU核心上运行并且它所需的内存也尽可能从该核心所属的节点分配。如果进程在Node 0上运行却大量使用Node 1上的内存性能就会显著下降。这种现象称为“NUMA失衡”。数据共享与迁移当多个进程或线程需要频繁访问同一块内存而它们又运行在不同节点上时这块内存应该放在哪里内核有时需要在“让所有访问者都承受远程访问代价”和“在节点间迁移内存页面以跟随访问者”之间做出权衡。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 SLITSystem Locality Information Table或硬件提供的信息内核用其构建一个“节点距离矩阵”你可以在/sys/devices/system/node/nodeX/distance中看到。4.3 调度器与NUMA平衡现代Linux调度器CFS与NUMA紧密集成其目标之一就是实现“NUMA亲和性”让进程尽量运行在它的内存所在的节点上。内核中有一个重要的后台内核线程叫kswapd而在NUMA环境下还有一个更关键的线程叫migrated或与NUMA平衡相关的任务。它们负责监控系统的NUMA状况并在必要时执行两种关键操作页面迁移如果一个页面被某个节点上的进程频繁访问但该页面却位于另一个节点内核可能会尝试将这个页面迁移到访问者所在的本地节点。进程迁移如果一个进程的大部分内存都在某个节点上但它却长时间在另一个节点上运行内核可能会考虑将整个进程或其线程迁移到内存所在的节点。这个平衡过程是动态的、持续的。内核会统计“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 --cpunodebind0 --membind0 ./my_application # 2. 将程序绑定到Node 0和1的CPU上内存交错分配适用于内存带宽密集型应用 numactl --cpunodebind0,1 --interleave0,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使用率不高但应用吞吐量上不去性能监控显示较高的“内存访问延迟”或“跨节点流量”。诊断步骤检查是否启用了NUMA平衡cat /proc/sys/kernel/numa_balancing。如果为1表示启用。对于某些延迟极度敏感的应用有时禁用平衡设为0并手动绑定策略可能更稳定但需要深入理解应用行为。监控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 高意味着很多访问落到了远程节点。分析应用行为使用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依然是那个无法绕过的底层真相。