程序总跨核迁移怎么办?新手看懂 CPU 亲和、NUMA 绑定和线程绑核

3 阅读14分钟

如果你的程序 CPU 看起来没打满,延迟却一会儿高一会儿低,排查时又发现上下文切换很多、缓存失效率也不太好看,那问题不一定出在算法本身,也可能出在线程总在不同核心之间“搬家”。这篇文章就讲清一件事:怎么用调度灵活性去换跨核迁移开销,以及为什么这件事有时能提速,有时却会把机器绑出新毛病。

先把结论摆在桌面上:CPU 亲和NUMA 绑定线程绑核,本质上都在做同一类优化,也就是尽量让线程别乱跑,让它和熟悉的核心、熟悉的缓存、熟悉的内存区域待得更久一点。它们通常适合高频上下文切换、缓存失效率高、跨 NUMA 节点访问明显的场景;但代价也很实在:调度器可发挥的空间变小,负载更容易不均,某些核心忙到冒烟,别的核心却在摸鱼。

读完你应该能回答三个问题:什么时候值得限制线程乱跑,三种手段分别控制什么,怎么验证自己到底是在优化,还是只是把问题从“迁移开销”换成了“负载失衡”。

先给你一个选法:三种手段分别管什么

| 手段 | 人话解释 | 最适合的场景 | 主要收益 | 主要代价 |

|---|---|---|---|---|

| CPU 亲和 | 给进程或线程划一个可运行的 CPU 范围 | 线程很多,但还不想绑得太死 | 减少无意义迁移,保留一定调度空间 | 仍可能在范围内来回跳 |

| NUMA 绑定 | 让线程尽量在某个 NUMA 节点上跑,并从该节点拿内存 | 多路 CPU、跨节点访存明显的服务 | 降低远程内存访问成本 | 绑错节点会更慢 |

| 线程绑核 | 把某个热点线程固定到一个核心或很小的核心集合 | 少数关键线程特别热、延迟特别敏感 | 缓存局部性最强,迁移最少 | 最容易造成负载不均 |

先按这张表选“最小动作”就够了,别一上来就把所有线程钉死在几个核上,不然调度器还没来得及帮你,机器先被你自己绑紧了。

先把几个关键词说成人话

调度灵活性:系统能不能自由挪人

人话版:操作系统调度器像一个现场经理,哪里空着就把线程安排到哪里,目的是尽量别让 CPU 闲着。

生活类比:像餐厅老板临时调服务员,A 区忙不过来就喊 B 区的人过去救场。灵活是灵活,但服务员刚熟悉 B 区桌号,又被叫去 C 区,腿没少跑,记忆还总被打断。

迷你案例:一个 Web 服务有 32 个工作线程,机器上有 32 个逻辑核。调度器如果完全自由,可以尽量把所有核都喂饱;但如果某几个线程特别热,它们不断被挪位置,缓存就会一再重建,尾延迟就容易抖。

跨核迁移开销:线程换核不是“瞬移”,是“搬家”

人话版:线程从 CPU 2 跑到 CPU 11,不只是换个座位那么简单。寄存器状态要恢复,之前在旧核心缓存里的热点数据在新核心未必还热,新核心往往得重新把数据从更远的缓存甚至内存里拿回来。

生活类比:你本来在熟悉的工位上干活,抽屉里放着常用笔记、便签和工具。突然把你换到另一层楼的空位上,桌子是新的,抽屉是空的,先别谈效率,光找东西就得花时间。

迷你案例:一个订单匹配线程前一秒还在 CPU 4 上处理相似订单,相关数据大概率还在缓存里;下一秒被调到 CPU 9,缓存命中掉下去,处理时间就会长一截。

上下文切换:线程轮流上 CPU 的“换人”动作

人话版:上下文切换就是 CPU 从线程 A 切到线程 B,保存旧状态、恢复新状态。

生活类比:像驾驶员换班。换班本身不是坏事,机器总要轮流用;但如果换班太频繁,还老在不同路线之间来回倒腾,时间就都花在交接上了。

迷你案例:日志线程、网络线程、业务线程竞争同一组核心时,切换次数上来很正常。但如果切换高的同时 cpu-migrations 也高,那就不只是“换人”,而是“换人还换工位”。

这里要提醒一句:上下文切换高,不等于一定要绑核;缓存失效率高,也不等于锅一定在调度器。它们更像报警器,不是判决书。真正该不该下手,还要结合迁移次数、NUMA 远程访问、延迟抖动一起看。

为什么“别乱跑”有时真的能快不少

你可以把 CPU 缓存理解成离工位最近的小抽屉,L1、L2、L3 越往外越大,拿东西也通常越慢。线程如果持续待在相近的核心上,它的工作集更容易留在附近缓存里;线程如果总跨核迁移,缓存就像被清了半次,命中率容易掉。

下面这个过程,基本就是很多性能波动的缩影:

  1. 线程 T 先在 CPU 3 上跑,热点数据刚被放进附近缓存。

  2. 调度器为了平衡负载,把 T 挪到 CPU 10。

  3. CPU 10 上没有 T 的“热身数据”,于是先发生一轮缓存未命中。

  4. 如果机器还是 NUMA 架构,而这份数据主要在另一个节点的内存上,就可能再吃一次远程访问成本。

  5. 结果是吞吐未必立刻暴跌,但尾延迟、抖动和单位请求成本会变难看。

开始运行线程 T

在线程原核心上把热点数据“烤热”

线程被迁移到另一核心

新核心缓存偏冷,重新取数

如果还跨 NUMA 节点,再付一次更贵的访存代价

延迟抖动、缓存失效率升高

这段流程说明的不是“迁移一定有罪”,而是“迁移有成本”。下一步你该做的,是先确认成本是不是已经大到值得用更强约束去换掉它。

三种手段,差别到底在哪

1. CPU 亲和:先划活动范围,不急着钉死

CPU 亲和(CPU affinity)的人话解释是:告诉系统,这个进程或线程优先只在某些核心上跑。它通常不是“一核一线程”的死命令,而是“你就在这几个工位里活动吧”。

生活类比:给快递员划片区。你不用天天全城乱跑,只负责这个街区,路线更熟,往返更短,但片区里怎么跑,还是可以自己调。

迷你案例:一个消息队列消费者进程原来可以跑在 0 到 31 号所有逻辑核上,迁移频繁。把它限制到 0 到 7 号核后,迁移少了不少,但 0 到 7 内部仍有调度空间,通常比“直接绑死到单核”温和得多。

适合它的场景,是你已经怀疑迁移成本偏高,但还不确定热点线程是谁,或者服务流量波动比较大,不想把系统约束得太死。

2. NUMA 绑定:让线程和内存尽量住同一栋楼

NUMA 绑定的人话解释是:在多 NUMA 节点机器上,尽量让线程在某个节点的 CPU 上运行,并从同一节点分配内存。NUMA 可以先理解成“机器里不止一个大房间,每个房间都有自己更近的 CPU 和内存”。

生活类比:仓库和工人尽量安排在同一栋楼。工人如果总去隔壁楼拿货,单次看只是多走几步,次数一多,效率就明显掉。

迷你案例:一个内存数据库部署在双路服务器上,线程主要在 0 号节点跑,数据页却大量分配在 1 号节点。结果就是 CPU 忙得不算离谱,延迟却莫名其妙偏高。把 CPU 和内存都限制在同一节点后,波动会明显收敛。

NUMA 绑定特别适合双路或多路机器、内存访问量大、远程访存明显的服务。如果你在单路小机器上硬套这招,收益往往不明显。

3. 线程绑核:给关键线程一个固定工位

线程绑核的人话解释是:把某个线程固定到一个核心,或者非常小的核心集合。它是三者里最硬的一种限制。

生活类比:给收银员固定窗口。熟练度会越来越高,手边东西也最顺手;但只要这扇窗口突然变忙,就没人能立刻顶上。

迷你案例:撮合引擎里有一个关键撮合线程,处理逻辑短、频率高、对尾延迟非常敏感。把它固定到单独核心后,迁移和缓存干扰都降低,延迟常常更稳;但如果把一组热点线程都死绑到很少几个核上,排队又会反过来变严重。

所以,线程绑核更像手术刀,不像大扫把。热点明确时很好用,热点不明确时很容易绑出副作用。

什么时候该上,什么时候先别碰

下面这张判断表,可以帮你快速做第一轮筛选。

| 现象 | 更可能的动作 | 为什么 | 先别急着做什么 |

|---|---|---|---|

| 上下文切换高,但 cpu-migrations 不高 | 先查锁竞争、I/O 等待、线程数过多 | 问题可能不是跨核迁移 | 不要仅凭切换次数就绑核 |

| cpu-migrations 高,且缓存失效率也高 | 先试 CPU 亲和 | 先缩小活动范围,看迁移成本能否下降 | 不要直接全线程单核绑死 |

| 跨 NUMA 节点访存明显 | 优先试 NUMA 绑定 | 本地内存和远程内存成本差异可能更大 | 不要只绑 CPU 不管内存 |

| 只有少数线程是热点,且延迟很敏感 | 只绑热点线程 | 收益集中,副作用可控 | 不要把冷线程也一起绑死 |

| 流量波动大、任务长短差异大 | 保守使用亲和,少用硬绑核 | 调度器需要腾挪空间 | 不要为了“整齐”过度限制 |

先用这张表做判断,再做最小实验。优化最怕的不是没效果,而是把原本能自动平衡的系统,手工调成了偏科生。

给你一个可执行的排查流程

如果你在 Linux 环境里排查,这个顺序比较稳,也比较适合初学者照着走。

第一步:先确认是不是“迁移问题”

  1. pidstat -w -t 1 -p <pid> 看线程级的上下文切换情况。

  2. perf stat -e context-switches,cpu-migrations,cache-misses -p <pid> sleep 10 看切换、跨核迁移、缓存未命中是不是一起偏高。

  3. numastat -p <pid> 看远程内存访问是否明显。

  4. 同时记录吞吐、P95/P99 延迟、各核利用率,别只盯着一个指标。

如果这里只有上下文切换高,而迁移和缓存问题并不突出,先回头查锁、线程池大小、I/O 阻塞,更划算。

第二步:从最软的限制开始试

  1. 先对进程做范围限制,比如 taskset -cp 0-7 <pid>,观察迁移次数和延迟有没有明显改善。

  2. 如果机器是多 NUMA 节点,再尝试启动时用 numactl --cpunodebind=0 --membind=0 <command>,让 CPU 和内存先住在同一边。

  3. 如果已经找到明确热点线程,再考虑在线程级使用运行时或系统 API 做绑核,例如 C/C++ 里常见的 pthread_setaffinity_np

这一阶段的目标不是“把迁移打到零”,而是找到收益开始出现的那条线。很多程序只做温和亲和就够了,没必要上来就玩硬绑定。

第三步:复测,专盯副作用

你要特别看四件事:

  1. cpu-migrations 有没有下降。

  2. 缓存失效率和尾延迟有没有一起改善。

  3. 某几个核心是否被打满,而其他核心很闲。

  4. 吞吐是否因为绑得太死反而下降。

下面这组示意数据能说明“绑得适中”和“绑得过头”的区别。它不是你的真实结果,但很像真实世界会发生的形状。

| 策略 | cpu-migrations/s | cache-misses/s | P99 延迟 | 各核利用率形态 |

|---|---:|---:|---:|---|

| 不做限制 | 4800 | 2.1M | 42ms | 比较均匀 |

| 只做 CPU 亲和(0-7) | 1700 | 1.4M | 31ms | 仍较均匀 |

| NUMA 绑定 + 只绑热点线程 | 600 | 1.0M | 24ms | 可接受 |

| 全线程硬绑到很小核组 | 120 | 0.9M | 38ms | 明显失衡 |

这张表告诉你的下一步不是“迁移越低越好”,而是“在收益和副作用之间找拐点”。如果迁移继续下降,但尾延迟重新变差,通常说明你已经开始为低迁移付出过高的负载不均成本。

一个小场景,把三种手段串起来

假设你有一个在线推荐服务,部署在双路服务器上。白天流量高时,服务线程很多,热点数据集中,P99 延迟忽高忽低。

第一轮观察,你发现:上下文切换高、cpu-migrations 也高、cache-misses 不低。于是你先做进程级 CPU 亲和,把服务限制在一组核心里。结果迁移下降、延迟略稳,但波动仍在。

第二轮观察,你发现:numastat 提示远程内存访问不小。于是再把 CPU 和内存都绑定到同一 NUMA 节点。结果延迟进一步收敛。

第三轮观察,你确认真正最热的是两个工作线程,于是只把这两个线程固定到专用核心,其余线程仍保留一定调度空间。最后得到的往往不是“理论上最整齐”的配置,而是“线上最稳”的配置。

这就是实战里的常见节奏:先缩范围,再对齐内存,最后只给少数关键线程固定工位。别一步到位,容易一步到坑里。

最容易踩的 5 个坑

坑 1:把“高上下文切换”直接翻译成“马上绑核”

上下文切换可能来自锁竞争、网络阻塞、线程数失控,也可能来自正常调度。先看 cpu-migrations 和缓存指标,再决定是否限制线程位置。

坑 2:只绑 CPU,不管内存在哪

这在 NUMA 机器上很常见。线程虽然不乱跑了,但老去远处拿内存,效果就像住得更固定了,却把仓库搬远了。

坑 3:把所有线程都绑得一样死

热点线程、冷线程、I/O 线程、后台线程,对核心稳定性的需求完全不同。统一硬绑,最容易让热线程互相挤,把原本还能自我平衡的系统堵住。

坑 4:只看平均值,不看尾延迟和核分布

有时平均延迟没变,P99 却好很多;也有时吞吐差不多,某个核心却长期 100%。如果你只看平均值,很容易误判优化方向。

坑 5:在一台机器上测出结论,就当成普遍规律

不同 CPU 拓扑、不同缓存结构、不同 NUMA 布局,结果都可能不一样。绑核策略很吃环境,离开观测数据谈“通用最佳实践”,大概率会翻车。

给初学者的落地顺序

如果你刚接触这一类优化,最稳的顺序通常是这样:

  1. 先测 context-switchescpu-migrationscache-misses、P99 延迟、各核利用率。

  2. 先试 CPU 亲和,只缩小活动范围,不急着单线程单核硬绑。

  3. 如果是多 NUMA 节点机器,再看 NUMA 绑定,让 CPU 和内存尽量同节点。

  4. 只对真正的热点线程做 线程绑核,别一锅端。

  5. 每改一次都复测,确认不是用“更低迁移”换来了“更差负载均衡”。

调度器不是你的敌人,它平时其实很勤快。你要做的不是一把夺权,而是在它太热心、热心到让线程老搬家时,给它加一点边界。

最后记住这 5 句话

  1. 先检查 cpu-migrations,再决定要不要限制线程位置。

  2. 先选择 CPU 亲和 这种温和手段,再逐步测试更硬的绑定。

  3. 在 NUMA 机器上,记得同时检查 CPU 所在节点和内存所在节点。

  4. 只绑定真正的热点线程,并持续观察各核利用率是否失衡。

  5. 每次优化后都重新验证延迟、吞吐、缓存失效率,而不是只看一个漂亮指标。