CPU 亲和性与 NUMA 感知调度:多线程程序为什么会越跑越慢

本文从多线程程序性能波动的现象出发,解释CPU亲和性与NUMA感知调度的原理,分析线程迁移和跨NUMA访存的代价,并通过对比和实战建议帮助读者找到合理的线程绑定与内存分配策略。

很多团队会遇到一个奇怪的现象:程序逻辑没变,机器配置也不算差,但多线程程序跑起来之后,性能反而不如单线程稳定。甚至有时候同样的代码,在一台机器上表现很好,换到另一台 CPU 更多的机器上,延迟反而变高了。

Three spheres reflecting blue lights on a dark background

如果排除锁竞争和资源争用,最常见的嫌疑就是线程被操作系统频繁调度,在不同 CPU 核心之间迁移。这个问题在 CPU 数量变多、尤其在 NUMA 架构的服务器上,会被明显放大。CPU 亲和性和 NUMA 感知调度,正是解决这类问题的关键机制。

这篇文章从实际工程里的现象出发,讲清楚 CPU 亲和性是什么、NUMA 架构为什么会带来额外代价、多线程程序为什么需要感知 NUMA,以及在什么时候值得做线程绑定、怎么做才不至于帮倒忙。

为什么多线程程序反而更慢

多线程程序变慢,并不一定是代码写得有问题。在很多场景里,真正导致性能劣化的因素是线程在执行过程中被打断、被迁移到另一个 CPU 核心上,导致之前依赖的缓存状态全部失效。

一个线程在 CPU 核心 A 上运行一段时间后,L1、L2 甚至 L3 缓存里都充满了它需要的数据。这时候如果操作系统把它调度到核心 B,新的核心没有这些缓存内容,程序只能重新从内存中加载数据和指令。这个代价在小程序里看不出来,但在一套频繁访问共享数据的高并发系统里,一次迁移带来的性能损失可能达到毫秒级,比执行任务本身还贵。

另一个容易忽视的问题是 CPU 数量越多,调度器的选择空间越大,线程迁移的概率也就越高。很多团队把线程池大小从 4 调到 16,发现性能不是线性提升,反而出现抖动,原因往往就是线程开始频繁跨核心迁移,而不是 CPU 核心不够用。

这里还隐藏着一个常见误区:很多人以为只要线程数等于 CPU 核数,就能充分利用硬件。但实际上,操作系统调度器并不了解业务线程之间的关系。它可能把一个线程从一个核心迁走,又马上把另一个线程迁过来,导致缓存反复失效。线程数恰好等于核数,反而让调度器更容易做出“看似公平、实则有害”的迁移决策。

CPU 亲和性:把线程钉在某个核心上

CPU 亲和性(CPU affinity)就是让线程或进程与某个 CPU 核心集合绑定,调度器只会在这个集合中选择运行核心,从而减少不必要的迁移。

Linux 下设置亲和性最直接的方式,是使用 sched_setaffinity 系统调用:

#include <sched.h>

cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(2, &set); // 允许线程运行在 CPU 2 上
sched_setaffinity(0, sizeof(set), &set); // 对当前线程生效

如果是命令行启动程序,可以用 taskset 指定 CPU 列表:

taskset -c 0-3 ./server

上面的命令把 server 进程限制在 0 到 3 号 CPU 上运行。对于已经有业务逻辑的程序,这是最省事的介入方式,不需要改代码,适合快速验证“线程迁移是否是性能瓶颈”。

但使用亲和性有一个前提需要理解:亲和性限制的是“允许运行在哪些核心上”,它并不会强制线程一直待在某个核心上。即使设置了亲和性,调度器仍然可以在允许的集合内迁移线程。真正想让线程固定住,需要结合实时调度策略或者谨慎配置绑核范围。

工程上常见的做法是把亲和性集合设成一个物理核对应的超线程兄弟核心范围,而不是绑定到单个逻辑 CPU。绑定太死,会让线程在核心被其他任务占满时无从退让,反而增加调度延迟。

NUMA 架构下,问题变得不一样

NUMA(Non-Uniform Memory Access,非统一内存访问)是高性能服务器上普遍采用的内存架构。它的核心特征是:每个 CPU 分组(NUMA 节点)拥有自己的本地内存,访问本地内存的延迟远低于访问其他节点的远端内存。

在多路处理器平台上,内存访问已经不是等价的。一个运行在 Node 0 上的线程,访问 Node 0 的内存可能只有 80 纳秒,而跨到 Node 1 访问内存可能就需要 140 纳秒以上,同时远端内存访问还会占用 QPI/UPI 互联链路,影响其他跨节点通信。

在 NUMA 架构下,线程迁移的代价不只是缓存失效,还包括内存访问路径的变化。一个线程原本在 Node 0 上运行,分配的内存也来自 Node 0;一旦它被调度到 Node 1,再访问自己之前分配的内存,就变成了远程访问。

所以对 NUMA 系统来说,关键不只是减少线程迁移,还要保证线程运行节点和它访问的内存节点尽量一致。这正是 NUMA 感知调度要解决的核心问题:把线程调度到合适的位置,让它的内存访问尽量落在本地节点。

很多运行在物理机上的中间件和数据库,默认就带有 NUMA 感知能力,但普通业务程序并不会自动具备这种意识。如果一个服务部署在 NUMA 机器上,又没有配置任何亲和性策略,它访问自身数据的路径很可能有一半都发生在远端内存上,性能自然上不去。

Linux 调度器在 NUMA 上做了什么

Linux 内核的 CFS 调度器已经引入了 NUMA 感知的自动平衡机制。简单来说,调度器会周期性地扫描线程的运行情况,尝试把线程迁移到它访问内存最频繁的节点上,同时也会尝试均衡各节点的负载。

这种自动平衡在多数场景下是有帮助的,但它的判断基于统计数据,并不理解业务访问模式。一个高并发服务的数据访问可能在不同阶段变化很快,调度器可能刚完成一次迁移,发现新的数据访问模式又变了,于是再次迁移,形成频繁的“抖动式迁移”。

更麻烦的是,调度器为了均衡负载,会倾向于把线程分散到不同节点。这在 CPU 密集且数据共享较少的场景下是正确的,但在一组线程频繁访问同一批共享数据的场景下,分散反而会加剧跨节点通信。

举个例子,一个数据分片存储在线程组的内存里,组内线程需要互相交换中间结果。此时如果调度器把这些线程分散到两个 NUMA 节点,每次交换数据都变成跨节点访问,吞吐量可能出现成倍的下降。

因此,NUMA 感知调度并不是一个可以完全依赖内核默认行为的特性。对性能敏感的应用,往往需要显式地参与调度策略,甚至关闭某些自动平衡行为。

NUMA 感知调度的实践路径

在工程上,NUMA 感知调度通常分成三个层面来做,先看当前情况,再决定怎么干预。

  • 第一层,通过 numactl --hardware 查看服务器的 NUMA 拓扑,包括节点数量、CPU 分布、内存大小。
  • 第二层,观察线程当前运行在哪个节点,以及它分配的内存来自哪些节点,判断是否存在跨节点访问。
  • 第三层,根据业务特征选择合适的内存分配策略和线程绑定方式。

查看线程的 NUMA 运行位置,可以使用 numastat 或者读取 /proc/<pid>/numa_maps。假设我们发现一个进程的线程访问远端内存占比很高,就可以用 numactl 强制按节点分配内存:

numactl --cpunodebind=0 --membind=0 ./app

这条命令把进程绑定在 Node 0 的 CPU 上,并让它只从 Node 0 分配内存。如果程序的内存总量没有超过 Node 0 的内存容量,这通常能很直接地减少跨节点访问。

但需要注意,--membind 是硬性约束。一旦 Node 0 内存不足,进程会面临内存分配失败,而不是自动使用其他节点的内存。对内存消耗较大的服务,更合适的选择是 --preferred=0,它优先使用 Node 0 内存,不够时再分配其他节点。

还有一种常见做法是按 NUMA 节点拆分进程实例。例如一个服务总共需要 16 个 worker,服务器有 2 个 NUMA 节点,就每个节点运行 8 个 worker,分别设置绑定到对应节点。这样既避免跨节点线程迁移,又让每个 worker 的内存访问保持在本地。

这种“按节点拆分实例”的思路,在数据库、网关、缓存服务中都很常见,比让单个进程内部感知 NUMA 要简单得多,效果也更可控。

线程迁移与缓存命中:一次代价测算

有人可能会问,跨节点访问到底会造成多大影响?这个代价很难一概而论,因为和缓存命中率、访存频率、数据竞争比例都有关系,但我们可以做一个大致估算。

假设一个线程每次从内存读取 64 字节的共享数据,本地访问延迟约 100 纳秒,远端访问延迟约 200 纳秒。如果这个线程每秒读取 100 万次,跨节点访问带来的额外延迟约 100 毫秒。这个数字看起来不大,但如果再加上缓存完全失效、指令缓存重新加载、TLB 刷新,实际影响会扩大好几倍。

更关键的是,远端内存访问往往会占用节点间的互联带宽,当多个线程同时跨节点访问时,延迟会进一步恶化。

在真实项目里,我们不一定需要把延迟精确到纳秒级,只需要关注几个信号:CPU 利用率不高的场景下任务完成时间拉长、业务延迟周期性出现尖刺、NUMA 节点间网络流量异常升高。这些都是线程迁移或跨节点内存访问的表现。

常见误区与边界

CPU 亲和性和 NUMA 感知调度虽然有效,但并不是万能的。这里有几个容易踩的坑值得单独拿出来说。

误区一:绑定得越死越好

不少人在尝到绑核的甜头之后,会把进程严格绑定到单个 CPU 核心,甚至关闭调度器的迁移能力。这种做法在纯计算型负载中可能没问题,但服务型程序通常存在 IO 等待、锁等待、和其他线程的协作。如果线程被绑定在一个核心上阻塞,其他空闲核心也无法帮它分担,整体吞吐反而下降。

合理的做法是允许线程在一个由物理核组成的集合内调度,例如绑定到 4 个物理核心及其超线程兄弟,给调度器一定弹性,又不会让线程跑得太远。

误区二:只绑线程,不管内存

有时候程序已经通过 sched_setaffinity 把线程固定到 Node 0,但线程内部通过 malloc 分配的内存却可能来自 Node 1,尤其是使用 glibc 默认的内存分配策略时,线程可能在不同节点上分配内存。这样线程本身没有迁移,但访问的内存仍可能是远端内存。

解决方法是使用 libnumambindnuma_alloc_onnode 在关键数据上指定内存节点,或者在启动时通过 numactl 设置内存绑定策略。

误区三:开了自动 NUMA 平衡就不用管了

内核的自动 NUMA 平衡在负载稳定的场景下表现尚可,但对于突发性强、线程数量频繁变化的应用,它的迁移决策滞后,反而可能造成额外的性能抖动。遇到这类场景,可以考虑通过 kernel.numa_balancing 关闭自动平衡,显式控制线程和内存的位置。

经验提醒:所有优化都要以测量为前提。先确认远端内存访问占比确实高、线程迁移频繁,再决定是否绑定或关闭自动平衡。

要不要做 NUMA 感知调度:判断方法

并不是所有程序都需要花精力做 NUMA 优化。对绝大多数 IO 密集型服务来说,网络和磁盘的延迟早就超过了内存访问差异,纠结 NUMA 意义不大。真正值得做 NUMA 感知调度的,通常是以下场景:

  • CPU 密集型服务,线程长时间占用 CPU,访存频率高。
  • 延迟敏感型服务,要求稳定的 P99 甚至 P999 延迟。
  • 运行在多路物理 CPU 服务器上的数据库、缓存、实时计算引擎。
  • 线程之间存在大量数据交换,共享数据集的场景。

如果一个服务同时满足 CPU 密集和延迟敏感,那 NUMA 感知调度基本应该当成强制要求,而不是性能优化项。

对应的,如果服务本身线程数很少、内存占用不大、部署在单路服务器上,或者网络 IO 才是瓶颈,那么做 NUMA 优化收益很低,甚至因为增加了绑定约束而降低资源利用率。

再到生产环境之前,先做这三步

如果判断下来确实需要优化,建议按照下面的顺序推进,避免一上来就大改部署脚本和代码。

  1. 先用 perf statnumastat 观察程序是否已经存在明显的节点迁移和远端访问,获得基线数据。
  2. tasksetnumactl 做一次进程级的绑定测试,对比绑定前后的吞吐和延迟。
  3. 如果进程级绑定有效果,再考虑在代码层面对线程分组、显式分配内存,并把配置固化到启动脚本或编排系统中。

第三步需要谨慎。线程级绑定往往意味着程序要对硬件拓扑负责,会降低代码可移植性,尤其云环境中的 CPU 拓扑可能发生变化。对于部署在容器里的服务,还需要确认容器是否能暴露正确的 NUMA 拓扑,否则绑定反而会出错。

从理解机制到形成判断

CPU 亲和性和 NUMA 感知调度,本质上是一种“让运行的代码更贴近它需要的数据”的思路。线程迁移导致缓存失效,跨节点访存导致延迟上升,优化的方向就是让线程和它访问的内存在位置上保持一致。

多线程程序变慢,并不是因为 CPU 不够,往往是因为调度器在维持全局公平的过程中破坏了数据的局部性。理解了这一点,就不会盲目增加线程数,也不会粗暴地把所有程序都绑在一个核心上。

每个系统的负载特征不同,没有一套配置能适配所有场景。但掌握测量方法、理解调度机制、有节奏地调整策略,比直接复制网上的“最佳实践”更能解决实际问题。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/976/

(0)
上一篇 1小时前
下一篇 52分钟前

相关推荐