用过几年服务器的人,多少都被 NUMA、物理核、超线程这几个词绕昏过。尤其当你面对一台 96 核、192 线程的机器,想搞清楚某个进程到底跑在哪颗核上、内存访问为什么忽快忽慢、虚拟机吵着说 CPU 资源不够时,这些概念就不是笔试题目了,而是每天都要处理的真实问题。
这篇文章想做的就是把这几个概念彻底捋顺:NUMA 节点到底是什么,物理核和超线程核在操作系统里是怎么呈现的,三层拓扑之间是什么关系,以及你在实际运维中应该怎么查看、怎么绑定、怎么避坑。内容偏实践,但我会先把原理讲透,因为只背命令不搞懂内因,换台机器你就又懵了。
1. 整体设计与思路拆解:为什么现代服务器离不开 NUMA
1.1 NUMA 到底在解决什么问题
NUMA,全称 Non-Uniform Memory Access,非统一内存访问。它不是什么新概念,但在今天的大规模服务器里,它的存在感比以往任何时候都强。
先说它解决的核心矛盾:CPU 越来越多,内存访问速度跟不上。
想象一下这个场景。你有一块主板,上面插着 2 颗物理 CPU,每颗 CPU 有 32 个物理核,每个物理核支持 2 个超线程,总共 128 个逻辑核。如果所有 CPU 和所有内存都挂在一个共享总线或者一个统一内存控制器上,那么当很多核同时访问内存时,总线会成为瓶颈,延迟会急剧上升。物理距离越远,信号传输越慢,访问延迟就会越高。这是一种"物理法则",绕不过去。
于是 NUMA 出现了。它的思路很简单:把整台服务器划分成若干个节点,每个节点有自己的 CPU 集合和本地内存。节点内部的 CPU 访问本地内存,速度最快;访问其他节点的内存,速度和延迟都会差一截。这就是"Non-Uniform"的含义——同一个进程在不同节点上访问内存,性能表现得不一样。
从架构上来讲,每个 NUMA 节点实际上是一个独立的片上系统,它拥有独立的 DDR 内存通道和 PCIe 根控制器。节点之间通过高速互联总线通信。在一个双路服务器里,两颗 CPU 之间的互联,就是典型的 NUMA 链路。当我在日常运维中遇到某个数据库实例性能突然跳水,第一反应往往不是 CPU 不够用了,而是想确认它是不是跨 NUMA 节点访问内存了,这个判断方向的正确率非常高。
1.2 物理核、逻辑核、超线程核,这三个概念先分清
很多刚接触服务器的人最容易在这个地方绕晕。我先给一个最简化的公式:逻辑核数量 = 物理核数量 × 每核超线程数。
物理核指的是 CPU 这个物理封装里实实在在的运算核心,它是真正的硬件实体,拥有独立的计算单元、一级缓存和二级缓存。而操作系统里看到的 CPU 编号,比如 cpu0、cpu1、cpu2……这些叫逻辑核或者逻辑处理器。如果 CPU 不支持超线程,那么逻辑核数量和物理核数量一一对应;如果支持超线程,一个物理核会以两个逻辑核的形式暴露给操作系统。
超线程(Hyper-Threading)的技术本质,是让一个物理核内部有两条执行流。它不是一个物理核拆成两个用,而是让物理核内部的执行单元利用率更高。举个例子,一个物理核执行一段很依赖内存的指令,ALU(算术逻辑单元)可能闲着,这时候另一条线程就可以上来用 ALU 做计算。这两条线程共享物理核的计算资源、缓存和队列,好处是能轻微提升吞吐,坏处是它们"打架"时性能反而更差。
判断你的机器支持多少逻辑核,在 Linux 里最直接的就是 lscpu:
bash复制$ lscpu
Architecture: x86_64
CPU(s): 128
On-line CPU(s) list: 0-127
Thread(s) per core: 2
Core(s) per socket: 32
Socket(s): 2
NUMA node(s): 4
NUMA node0 CPU(s): 0-31
NUMA node1 CPU(s): 32-63
NUMA node2 CPU(s): 64-95
NUMA node3 CPU(s): 96-127
注意看输出的几个关键字段:
Thread(s) per core: 2表示每个物理核开了 2 个超线程;Core(s) per socket: 32表示每颗物理 CPU 有 32 个物理核;Socket(s): 2表示有 2 颗物理 CPU;NUMA node(s): 4表示整机被划分成了 4 个 NUMA 节点。
这里有个细节容易忽略:2 颗 CPU、每颗 32 核,但 NUMA 节点是 4 个,而不是 2 个。这说明这台机器启用了某些平台特性,或者说每颗 CPU 内部的多个 Die(裸片)被独立划分成了 NUMA 节点。这是目前高端 CPU 的常见做法,比如某些型号的 32 核 CPU 内部实际上是 4 个 Die,每个 Die 拥有独立的内存控制器。此时"NUMA 节点数"不等于"CPU 颗数",你得按实际拓扑来规划业务部署。
1.3 内存访问的"因距离而异"到底能差多少
很多人对 NUMA 影响的理解停留在"跨节点会慢一点"这种模糊层面。实际数据会更有说服力。我在一台典型的双路服务器上测试过内存访问延迟,用 numactl --hardware 可以看到不同节点间的距离矩阵。
bash复制$ numactl --hardware
available: 4 nodes (0-3)
node 0 cpus: 0 1 2 3 ...
node 0 size: 192016 MB
node 0 free: 175292 MB
node 1 cpus: 32 33 34 35 ...
node 1 size: 193024 MB
node 1 free: 180022 MB
node distances:
node 0 1 2 3
0: 10 21 21 31
1: 21 10 31 21
2: 21 31 10 21
3: 31 21 21 10
节点自己到自己的距离是 10,跨节点的距离可能是 21,更远的跨 Die 访问甚至到 31。这个距离值不是纳秒,而是一个相对的估算值,但比例关系是真实的:本地内存访问比跨节点访问快 2 到 3 倍。换句话说,如果一个进程频繁跨节点访问内存,吞吐量可能直接下降 20% 到 40%,尤其是在内存密集型场景下,这个损失非常明显。
基于这个背景,后面所有的优化思路其实就一句话:让进程运行的节点和它使用的内存尽量靠近,最好是落在同一个 NUMA 节点上。接下来我详细讲怎么看清楚 CPU 与内存的真实映射关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:看清硬件拓扑的几种方式
2.1 用 lscpu 和 numactl 快速摸清底细
先说最常用的两个命令,lscpu 和 numactl。前面已经演示过 lscpu 的输出,它从系统层面汇总了 CPU、缓存、NUMA 的信息。而 numactl 更适合查看 NUMA 内存和 CPU 的亲和关系。
查看某个进程当前的 NUMA 策略和 CPU 绑定,可以用:
bash复制$ numastat -p <pid>
Per-node process memory usage (in MBs)
PID <pid> (process_name)
Node 0 Node 1 Node 2 Node 3
------- ------- ------- -------
12.34 1.02 0.00 0.00
Total 13.36
这个输出能看出进程在哪个节点上分配了内存。如果进程被绑在 node0 的 CPU 上,但内存却大量落在 node1,这就是典型的"CPU 亲和性和内存分配不匹配",也是程序跑得慢、但 CPU 占用率却不高的重要原因之一。
2.2 用 /sys 文件系统解析逻辑核到物理核的映射
lscpu 给的是汇总数据,但如果你想知道逻辑核 37 到底属于哪个物理核、哪个套接字、哪个 NUMA 节点,就得去 /sys 文件系统里逐个查。这也是写监控脚本、做 CPU 绑定时必须理解的底层机制。
以逻辑核 37 为例:
bash复制$ cat /sys/devices/system/cpu/cpu37/topology/physical_package_id
0
$ cat /sys/devices/system/cpu/cpu37/topology/core_id
18
$ cat /sys/devices/system/cpu/cpu37/topology/thread_siblings_list
36-37
这里的信息很关键:
physical_package_id = 0表示 cpu37 在第一颗物理 CPU(socket 0)上;core_id = 18表示它属于物理核 18;thread_siblings_list = 36-37表示物理核 18 被暴露成逻辑核 36 和 37,两个逻辑核共享同一个物理核。
再结合 lscpu 的输出 NUMA node1 CPU(s): 32-63,可以判断 cpu36、cpu37 都属于 NUMA node1。这样一个完整的映射关系就出来了:socket0 → 物理核18 → 逻辑核36/37 → NUMA node1。
写脚本遍历所有线程、批量统计物理核和逻辑核的映射关系时,通常就是用一个 for 循环读这些文件。比如找出所有属于物理核 18 的逻辑核,或者找出所有在 NUMA node1 里的逻辑核列表,都可以做到。
2.3 用 lstopo 画出整机拓扑图
如果觉得一条条命令查太碎,lstopo 可以直接输出一张完整的拓扑图。它来自 hwloc 工具包,很多发行版默认没装,需要手动安装一下:
bash复制# Ubuntu/Debian
apt install hwloc
# CentOS/RHEL
yum install hwloc
然后执行:
bash复制$ lstopo --output-format ascii
它会画出从 Socket、NUMA 节点、L3 Cache、物理核、逻辑核一层层嵌套的结构图。我个人的经验是,第一次接触一台不熟悉的高密度服务器时,lstopo 是最快的全局视图工具。它会明确显示哪些逻辑核共享同一个 L3,哪些核属于同一个物理核,这些信息直接影响你对进程调度的判断。
这里有一个容易被忽视的坑:很多云厂商的"裸金属"服务器或者虚拟化实例,物理拓扑是被过滤过的,lstopo 可能看不到真实的 NUMA 层级,只能看到虚拟化的 CPU 信息。别慌,这是虚拟化特性导致的正常现象,不代表你的命令有问题。
2.4 物理核与超线程的编号规律,看懂编号不再怕
Linux 内核枚举 CPU 时并不是按照直观顺序来的。它遵循以下规则:先按物理核枚举超线程,再按物理核枚举,最后再换下一个 socket。也就是说,在一个 2 路 32 核、超线程开启的机器上:
- 逻辑核 0 和 1 属于 socket0 的物理核 0;
- 逻辑核 2 和 3 属于 socket0 的物理核 1;
- 逻辑核 64 和 65 才是 socket1 的物理核 0 的超线程。
这是非常常见的排列规律。理解这个规律以后,当你看到一个进程绑定在逻辑核 0 和逻辑核 64 上时,你就知道它不是简单地用"前两个核",而是同时占用了两个物理核,这通常是为了避免超线程竞争而刻意做的选择。
3. 实操过程与核心环节实现:从绑定到分流,让进程跑得又快又稳
3.1 查看现有进程的 CPU 亲和性
做优化之前,先搞清楚现状。查看进程的 CPU 绑定情况,最常用的是 taskset:
bash复制$ taskset -p <pid>
pid 12345's current affinity mask: ff
输出里的 ff 是十六进制位图,表示这个进程可以在哪些 CPU 上运行。我们做一个换算:ff 的二进制是 11111111,代表逻辑核 0 到 7。如果输出是 f0,二进制是 11110000,代表逻辑核 4 到 7,这意味着该进程只能在 4 到 7 号核上运行。
还有一个命令可以看线程的亲和性,就是通过 /proc/ 文件系统查:
bash复制$ cat /proc/<pid>/status | grep Cpus_allowed_list
Cpus_allowed_list: 0-7
或者按线程查:
bash复制$ cat /proc/<pid>/task/<tid>/status | grep Cpus_allowed_list
这个 Cpus_allowed_list 比 mask 更直观,直接看范围即可。对于排查问题来说,先看这个字段能快速定位进程是否被绑在了某个狭窄的核集合上。
3.2 绑定到指定 NUMA 节点:numactl 与 taskset 的取舍
日常操作中,我们会用两类工具做绑定:taskset 管 CPU,numactl 既管 CPU 又管内存分配策略。
把进程绑定到指定 NUMA 节点的 CPU 上,可以这样:
bash复制# 启动新进程,绑定到 node1 的 CPU 和内存
numactl --cpunodebind=1 --membind=1 <command>
# 或者绑定具体到逻辑核范围,使用 taskset
taskset -c 36-39 <command>
如果需要更精确地指定物理核,而不是某一对超线程,可以先通过拓扑分析确定逻辑核编号,再绑定。比如想用 socket0 的第 3 个物理核,即逻辑核 5 和 6,可以:
bash复制taskset -c 5,6 <command>
使用 numactl --membind 和 --cpunodebind 的好处是,你直接把进程调度和内存分配都限制在同一个节点内,从源头上避免了跨 NUMA 访问。但也有代价:如果该节点资源不足,进程可能会因为拿不到内存而变慢,甚至出现 Cannot allocate memory 的错误。所以这个操作要基于对节点资源的准确判断来做。
3.3 内存分配策略:localalloc、preferred、interleave 各适用什么场景
numactl 支持多种内存分配策略,其中比较重要的是:
localalloc:进程在哪个节点上运行,内存就分配到哪个节点。这是默认策略,也是大多数场景下最推荐的做法;preferred:优先从指定节点分配,如果该节点内存不够,可以退化为其他节点,而不是直接失败;interleave:内存跨多个节点交替分配,能平衡带宽,但单个访问可能更慢;membind:只从指定节点分配,严格模式,内存不足会报错。
选哪个策略,取决于业务的内存访问特征。如果你是跑数据库,数据量大且访问频繁,localalloc 是最稳的选择;如果你在做高性能计算,有大量并行任务,且数据分散在多个 NUMA 节点上,interleave 可能反而更好;如果是虚拟机场景,宿主机通常会用 localalloc 结合 CPU 绑定的方式来保证性能稳定。
3.4 虚拟化里的 NUMA 优化:把 vCPU 和内存尽量放在同一个物理节点
这个部分单独拿出来说,是因为虚拟化场景里的 NUMA 问题比物理机更隐蔽。在一台宿主机上跑了多台虚拟机,如果只是给虚拟机分配了 vCPU 和内存,而不关心它们落在哪个物理 NUMA 节点上,那可能一台虚拟机的 vCPU 分布在两个不同物理节点上,内存则分布在另外两个节点上,整体性能表现就会非常糟糕。
在处理虚拟化实例时,我会优先把虚拟机的 vCPU 绑到同一个 NUMA 节点的物理核上,同时打开内存的 node interleave 或者 localalloc 策略,确保虚拟机的大部分内存访问落在同一个物理节点。虽然多数虚拟化平台有自动 NUMA 亲和的逻辑,但自动逻辑不一定适合你的高负载场景,手动规划往往更可靠。
3.5 一个真实优化案例:数据库进程为什么越跑越慢
之前遇到过一个典型问题:一套 MySQL 实例跑在一台 4 路服务器上,前两周一切正常,一到业务高峰就出现查询延迟陡增。排查的时候先看 CPU 占用,发现某个线程的 CPU 使用率并不高,但整个集群的延迟就是上不去。
我的排查步骤是:
- 先
numastat -p看内存分布,发现进程的内存七成分配在 node2,但进程的 CPU 绑定在 node0; - 再
taskset -p确认进程确实被绑在了 node0 的核上; - 用
numactl --hardware查看各节点内存使用量,发现 node2 和 node3 内存非常空闲,而 node0 和 node1 已经很紧张; - 结论是:早期进程在 node0 启动,内存从 node0 分配,但 node0 内存即将耗尽,内核把后续内存分配切到了 node2,而 CPU 仍留在 node0,造成严重的跨节点访问。
解决办法很简单:先 taskset 把进程 CPU 限制放宽,再利用 numactl --preferred=0 让新内存优先从 node0 分配,再逐步迁移已有内存页面,最后进程性能恢复,延迟降了一半左右。这个案例说明,NUMA 问题不是只看 CPU 或只看内存就能定位的,一定要把两者的拓扑关系放在一起看。
4. 常见问题与排查技巧实录:运维实战中绕不开的坑
4.1 CPU 利用率不均:所有核都在忙,但业务依然卡顿
一个常见的表象是:系统负载不算低,但 CPU 使用率在个别核上奇高,其他核空闲。这种情况多发生在没有绑定亲和性的进程上,内核的调度器可能在多个 NUMA 节点之间交替调度线程,导致进程频繁跨节点访问内存。
排查时可以用 mpstat -P ALL 1 看每个逻辑核的利用率。如果你发现同一物理核上的两个超线程核利用率都接近 100%,而相邻物理核却是空闲的,这说明超线程资源被打满,而物理核的计算资源没有完全释放出来。这时候再配合 lscpu -e 看看 CPU 编号和拓扑的对应关系,基本就能定位是哪一组超线程在竞争。
改善方向通常是重新规划线程的绑定策略:要么把所有繁忙线程分散在不同物理核上,避免超线程竞争;要么把有资源共享关系的线程放在同一物理核上,利用 L2 Cache 的局部性。不同场景要选择不同策略,没有一套方案走天下的说法。
4.2 内存分配不均:进程内存全长在远端节点
用 numastat 看系统级的内存分布,能很快发现节点的内存使用失衡:
bash复制$ numastat
node0 node1 node2 node3
----------- ----------- ----------- -----------
numa_hit 14587542 9823012 8734012 7904102
numa_miss 231456 890123 451023 302981
numa_foreign 891203 451232 871023 902341
重点关注 numa_hit 和 numa_miss。如果 numa_miss 数值很高,说明进程在节点本地分配内存失败的次数很多,内存被分配到了远端节点。numa_foreign 一看就是节点因本地内存不足而向外转移分配的次数。
如果节点间内存差异很大,而你的业务又不能简单迁移,可以考虑用 numactl --interleave=all 把内存分配策略切换成交替模式,让内存均匀分布在所有节点上。这种操作的代价是单个访问可能变慢,但适合内存使用量巨大且每个节点都吃紧的场景。实测对于某些大数据分析类任务,交替分配之后整体吞吐反而提升了,因为多个节点的内存带宽被同时利用起来了。
4.3 超线程带来的性能不升反降
超线程的设计初衷是提高物理核利用率,但并不是所有负载都能从超线程中获益。当两个线程同时跑在同一个物理核的两个超线程上,并且它们都依赖同样的执行单元(比如都是浮点密集计算),它们就会互相竞争,性能反而不如一个线程独占物理核。
定位方法还是那句:通过 lscpu -e 找到逻辑核与物理核的对应关系,如果两个 CPU 使用率都很高的线程恰好是同一个物理核的兄弟线程,那就是超线程竞争。解决办法要么调整线程绑定,把两个重负载线程分开到不同物理核;要么在 BIOS 层面关闭超线程,让系统只暴露物理核。对延迟敏感型业务来说,直接关掉超线程往往比开着省心。
4.4 快速排查速查表
这里整理一张我个人非常依赖的排查速查表,遇到问题可以照着看:
| 现象 | 可能原因 | 常用命令 |
|---|---|---|
| 进程 CPU 忙但延迟高 | 跨 NUMA 节点访问内存 | numastat -p <pid> |
| 单核 100%,其他空闲 | 线程绑定过紧,或超线程竞争 | mpstat -P ALL 1 |
| 内存集中在某个 node | 早期调度导致内存节点失衡 | numastat |
| 无法启动绑定到新节点 | 指定节点内存不足 | numactl --hardware |
| 多线程扩展性差 | 线程逻辑核分布不合理 | lscpu -e |
| 虚拟机性能时好时坏 | vCPU 跨物理 NUMA 节点 | lstopo |
这张表解决的是"看到现象、快速定位方向"的问题。真正要做优化,还是得把每个点的原理吃透,然后结合自己的业务负载去设计绑定策略。
4.5 我反复踩过之后总结出的几条经验
-
保持简单:不是所有服务都需要手动做 NUMA 绑定。内核的自动调度在很多场景下已经不错。只有当你明确观察到性能问题,并且确认和 NUMA 布局有关时,才值得花精力去调绑定策略。过早优化反而会引入新的约束问题。
-
先度量,后操作:改动亲和性之前,一定要先记录当前的性能指标、CPU 利用率和内存分布。改完之后要有明确的对比依据。没有度量的优化等于盲调,出了问题也不知道改的是对还是错。
-
别迷信"关闭超线程":是否关超线程取决于业务负载特性。如果你跑的是内存带宽敏感型任务,关掉超线程可能减少竞争;但如果是 IO 密集或者并发少而杂的任务,超线程能省下不少物理核资源。要先看自己的业务特征再决定。
-
注意 BIOS 层面的 NUMA 开关:机器在 BIOS 里可能有 NUMA 相关的选项。有时候默认是关的,此时操作系统无法看到完整的 NUMA 拓扑,所有数据都会走统一访问路径,性能表现反而更差。装机或者换机时,记得确认 BIOS 里的相关配置。
-
对虚拟化实例,宿主机是一个整体,但你的 vCPU 只是虚拟视图:不要在虚拟机里过分依赖拓扑信息,因为宿主机的物理映射对虚拟机不可见。业务压测结果异常时,多检查宿主机层面的分配情况。
5. 更进一步的思路:NUMA 感知的编程与调度
5.1 用户态代码如何利用 NUMA 信息
除了在系统层用命令绑定,应用程序自身也可以做 NUMA 感知。Linux 提供了 libnuma 库,里面封装了 numa_available()、numa_alloc_onnode()、numa_run_on_node() 等接口。数据库、消息中间件这类对性能敏感的程序,很多都内置了 NUMA 感知选项。
一个典型的做法是在程序初始化阶段,读取当前线程的 CPU 亲和性,然后根据 CPU 所在的 NUMA 节点来分配初始内存。这样可以让程序的每次内存分配尽量落在本地节点,减少跨节点访问。C 语言代码里可以这样写:
c复制#include <numa.h>
#include <sched.h>
#include <stdio.h>
int main() {
if (numa_available() < 0) {
fprintf(stderr, "NUMA not available\n");
return 1;
}
int cpu = sched_getcpu();
int node = numa_node_of_cpu(cpu);
printf("running on cpu %d, numa node %d\n", cpu, node);
void *mem = numa_alloc_onnode(1024 * 1024, node);
/* use mem */
numa_free(mem, 1024 * 1024);
return 0;
}
运行时还需要在编译时加上 -lnuma。这种做法的好处是,程序能够自适应地跟随调度器放置的 CPU 位置,动态分配本地内存,可靠性比手动指定固定节点更高。应用程序级别的 NUMA 感知,在高并发、高吞吐场景下收益非常明显。
5.2 内核参数与调度器对 NUMA 的考虑
现代 Linux 内核的调度器对 NUMA 的感知已经相当智能。进程在生命周期内,调度器会周期性评估进程的 NUMA 状态,并在必要时迁移页面,让进程尽量靠近它经常访问的内存页。但默认策略偏保守,不会频繁迁移,因为页面迁移本身有代价。
管理员可以通过一些内核参数来调节行为:
kernel.numa_balancing:默认值一般为 1,表示启用 NUMA 自动平衡。如果你的应用对确定性要求极高,不希望内核随意迁移页面,可以设置为 0。vm.zone_reclaim_mode:控制节点内存不足时,内核是否回收本地节点的缓存来满足请求,而不是直接向其他节点分配内存。默认值一般不需要动,但在特殊场景下可以调。
这些参数通常只在排查复杂性能问题时才需要碰,日常运维基本不用改。知道它们的存在,比盲目改动更有价值。
5.3 云原生环境下 NUMA 的挑战
最后说一个比较大但很现实的趋势:容器化和云原生环境下,NUMA 问题的复杂度又上了一个台阶。单个容器可能只使用了宿主机的部分 CPU 和内存,调度器不会保证容器自动落在同一个 NUMA 节点内。如果你在容器里跑高性能负载,建议和基础设施团队确认一下宿主机上容器的分配方式。很多云平台已经支持 CPU 管理器(比如 Kubernetes 的 CPU Manager),可以把容器固定在指定的 CPU 集合上,从而为 NUMA 优化创造条件。
我个人的经验是,容器场景下的 NUMA 优化,要先把宿主机的物理拓扑搞清楚,再决定容器的 CPU 请求量是不是物理核的整数倍,这直接关系到超线程的分组是否合理。很多踩坑案例,追到最后都是容器调度没考虑物理拓扑导致的跨节点访问。
这个话题如果展开写,内容量不亚于操作系统调度一整章。这里先提个引子,后面有机会我可以单独写一篇容器环境下的 CPU 与内存亲和性设计,把 CPU Manager、NUMA-aware 调度策略这些实操细节整理出来。
