每位程序员应该了解的内存 第3部分

发布: (2026年1月2日 GMT+8 16:35)
8 min read
原文: Dev.to

Source: Dev.to

目录

1. UMA 与 NUMA:平等的终结

要理解现代服务器为何表现如此,我们需要回顾内存架构的演进。

UMA vs NUMA Architecture

1.1 UMA (Uniform Memory Access)

旧方式:SMP(对称多处理)时代,我们只有一个内存控制器和一条系统总线。所有 CPU 都连接在这条总线上。

含义: “Uniform(统一)”指访问 RAM 的成本对每个核心都是相同的。比如访问地址 0x0,Core 0 需要约 100 ns,Core 1 也同样需要 100 ns。

为何失效: 共享总线成为瓶颈。随着核心数增加(2、4、8…),它们争夺同一带宽——就像 64 辆车挤在单车道高速公路上。

1.2 NUMA (Non‑Uniform Memory Access)

新方式: 为了解决瓶颈,硬件架构师将内存拆分。

含义: 不再是一个巨大的 RAM 池,而是每个处理器插槽拥有专属的一块 RAM。处理器 + 其本地 RAM 称为 NUMA 节点

工作原理: 各节点通过高速互连(Intel UPI、AMD Infinity Fabric 等)相连。如果 CPU 0 需要位于 CPU 1 内存中的数据,它会请求 CPU 1 取出该数据并通过互连传输。

这解决了带宽问题(多条高速公路!),但引入了一个新问题:物理限制

2. 远程访问的成本

现在内存是物理分布的,距离变得重要。

NUMA 本地 vs 远程访问

如果 Node 0 上的 CPU 需要位于 Node 0 RAM 中的数据,路径短且快速。
如果 Node 0 上的 CPU 需要位于 Node 1 RAM 中的数据,请求必须通过互连传输,等待 Node 1 的内存控制器,然后把数据返回。

2.1 延迟惩罚

我们通常用 延迟因子 来表示这种成本:

访问类型相对延迟
本地1.0 × (baseline)
远程1.5 × – 2.0 × slower

每一次命中远程内存的缓存未命中(cache miss)都可能比本地未命中贵两倍。在高性能计算(HPC)或低延迟交易中,这将是灾难性的。

2.2 带宽饱和:堵塞的管道

不仅仅是延迟;还有容量。套接字之间的互连带宽有限。

如果你编写一个程序,让所有 64 核心的 所有 线程都积极读取 Node 0 的内存,就会造成交通拥堵。Node 0 上的本地核心可能正常获取数据,但其他节点的远程核心会因争夺互连带宽而停滞。

3. OS Policies: The “First Touch” Trap

那么操作系统是如何决定把你的内存放在哪里的呢?如果你 malloc(1 GB),它会落在 Node 0 还是 Node 1?

Linux 使用一种叫 First‑Touch Allocation(首次触碰分配)的策略。

3.1 Linux 如何分配内存

  1. malloc(1 GB) 返回一个 虚拟 地址范围;此时还没有分配物理 RAM。
  2. 只有当进程第一次写入该页面时(页面错误),才会分配物理页。
  3. 在那一刻,内核会查看 哪个 CPU 触发了错误。
  4. 页面被放置在与该 CPU 本地 的 NUMA 节点上。

因此,首次触碰页面的线程 决定了该页面的归属节点。

3.2 陷阱:主线程初始化

如果所有的首次写入都由主线程完成,那么每个页面都会被放在主线程所在的节点上。在多插槽系统上,这会导致内存集中在单个节点,其他插槽上的工作线程只能进行远程访问。

场景

  • 主线程(运行在 Node 0)分配一个巨大的数组并对其 memset
  • 所有页面都被分配到 Node 0。
  • 64 个工作线程在 Nodes 0‑3 上被创建,用来处理这些数据。

First Touch Trap

结果

  • Node 0 上的线程可以本地访问。
  • Node 1‑3 上的线程只能远程访问,导致互连带宽被占满。
  • 随着核心数增加,扩展性停滞,甚至出现性能下降。

解决方案

并行初始化 —— 让每个工作线程初始化它以后要处理的数据块。这样页面会被分配到该线程所在的节点,从而消除远程访问的惩罚。

3.3 “溢出”行为(Zone Reclaim)

当某个节点的本地内存耗尽时,内核可能会从 远程 节点分配页面(zone reclaim)。

  • 这会导致不可预测的延迟峰值。
  • 应用程序可能在一段时间内运行得很快,随后因为本地节点已满而出现显著慢下来,分配“溢出”到其他节点。
  • 监控 /sys/devices/system/node/ 下的 numa_miss 计数器是唯一可靠的检测手段。

4. 行业工具

4.1 使用 lscpu 进行分析

$ lscpu

lscpu 打印 CPU 拓扑信息,包括 NUMA 节点数量、每个节点的核心数以及互连架构。

4.2 距离矩阵 (numactl)

$ numactl --hardware

典型输出:

available: 2 nodes (0-1)
node 0 cpus: 0-15
node 1 cpus: 16-31
node 0 size: 128 GB
node 1 size: 128 GB
node 0 free: 124 GB
node 1 free: 126 GB
node distances:
node   0   1
  0:  10  20
  1:  20  10

distance(距离)值是相对的;数值越大表示延迟越高。

4.3 使用 numactl 控制策略

使用显式内存策略运行程序:

# Bind the process to node 0 and allocate memory only from node 0
numactl --cpunodebind=0 --membind=0 ./my_program

# Interleave memory across nodes (good for large, uniformly accessed data)
numactl --interleave=all ./my_program

4.4 使用 libnuma 编程

#include <numa.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    if (numa_available() == -1) {
        fprintf(stderr, "NUMA not supported on this system.\n");
        return EXIT_FAILURE;
    }

    /* Allocate 1

5. 结论

了解 UMA 与 NUMA 的区别、远程内存访问的延迟和带宽成本,以及操作系统的首次触碰分配策略,对于在现代多插槽服务器上编写可扩展软件至关重要。通过使用正确的工具(lscpunumactllibnuma)并采用并行初始化模式,开发者可以避免隐藏的性能陷阱,充分利用硬件的能力。

Back to Blog

相关文章

阅读更多 »