SMP多核性能调优实战:从中断绑定到NUMA感知的完整思路
发布时间:2026/10/10 9:59:58 锦皓数字建站

很多年前我第一次给一台24核的服务器做性能调优时遇到了一个让我印象极其深刻的诡异现象机器明明有24个逻辑CPU业务压力上来之后却只有CPU0在忙其余23个核心全部闲着CPU0单核被打到100%整机吞吐上不去延迟却高得离谱。那时候我对SMPSymmetric Multi-Processing对称多处理的理解还停留在多核就是能同时跑多个线程这种肤浅层面。后来花了很长时间从Linux内核调度器原理、CPU拓扑结构、中断分发机制一路摸到NUMA内存访问延迟才慢慢把这个领域啃下来。这篇文章就是那段时间踩坑、实验、翻源码、看文档的完整记录也是我打算写的SMP心路历程系列的第一篇。把这套东西分享出来主要是想帮那些正准备接触多核性能调优、或者已经被线上多核问题折磨过的朋友少走一点我当年走过的弯路。1. 第一次看清服务器里的CPU格局从lscpu到CPU拓扑1.1 多核服务器变成单核的诡异开局先说说那个让我怀疑人生的线上事故。当时我们有一台跑网关业务的24核服务器架构是双路Intel Xeon每颗CPU 6个物理核心开超线程之后每颗变成12个逻辑CPU两颗加起来24个逻辑CPU。业务高峰期CPU总利用率只有30%左右可网关的转发延迟却从一开始的1毫秒级掉到了10毫秒级偶尔还出现丢包。我当时第一反应是CPU不够用要扩容但看了眼监控CPU明明还有70%是空闲的。用top切到1模式逐核看才发现原来不是CPU不够是只有CPU0一个核在扛其他23个核几乎都是0%——问题根本不是资源不足而是资源没有用起来。这个现象的本质就是SMP架构下最常见的资源分配失衡。操作系统确实把任务交给了多个CPU但在特定的中断密集场景下网卡的收包中断默认都落在CPU0上所有网络数据包都会挤在同一个核上处理。其他核为什么闲着因为它们压根没活干。后面我会详细展开这一点但这里先记住一个关键认知在SMP系统里性能瓶颈往往不是CPU总数不够而是某个关键路径上的单一CPU成为瓶颈。1.2 看懂lscpu输出Socket、Core、Thread到底谁是谁在动手优化之前先得把系统的CPU物理拓扑摸清楚。lscpu是最直接的工具我第一次认真看输出时说实话被那一大堆字段弄得有点晕后来才逐渐理清每个字段到底在说什么。$ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian CPU(s): 24 On-line CPU(s) list: 0-23 Thread(s) per core: 2 Core(s) per socket: 6 Socket(s): 2 NUMA node(s): 2 Vendor ID: GenuineIntel CPU family: 6 Model: 85 Model name: Intel(R) Xeon(R) Gold 6128 CPU 3.40GHz Stepping: 3 CPU MHz: 1200.000 BogoMIPS: 6800.00 L1d cache: 32K L1i cache: 32K L2 cache: 1024K L3 cache: 19712K NUMA node0 CPU(s): 0,2,4,6,8,10,12,14,16,18,20,22 NUMA node1 CPU(s): 1,3,5,7,9,11,13,15,17,19,21,23这几个字段的关系我后来习惯用一个不那么严谨但很好理解的方式来记Socket插槽是物理CPU颗数Core核心是每颗CPU里的物理核数Thread线程是每个物理核通过超线程虚拟出来的逻辑CPU数。三者相乘才等于总的CPU(s)。比如上面这台机器2 Socket × 6 Core × 2 Thread 24。有个非常容易踩的坑是操作系统里看到的CPU编号并不一定是按先核0的所有线程、再核1的所有线程这种自然顺序排列的。Intel的超线程编号策略在不同型号上不一样很多时候CPU0和CPU1其实是同一个物理核的两个超线程兄弟核而不是两颗CPU的第一个核。如果你不做区分用taskset随意绑核很可能把两个重负载进程绑到了同一个物理核的两个逻辑CPU上表面看是绑了两个核实际还是在抢同一个核的资源性能几乎没有提升。这种细节不踩一次坑是真的不会去注意。1.3 为什么说超线程只是锦上添花不是雪中送炭再花点篇幅说说超线程因为这是大家对SMP性能最容易高估的地方。超线程的本质是一个物理核内部多了一套寄存器文件和指令流水线状态让这个核能同时保持两个线程的上下文。CPU在执行线程A的指令时如果遇到cache miss等待内存返回执行单元有一段时间是空闲的这时候线程B就能穿插进来使用这部分执行资源。换句话说它提高的是CPU执行单元的利用率而不是凭空多出一倍的计算能力。所以两个超线程逻辑CPU各自最大能到100%但加起来绝对跑不到200%。在我实测中混合了计算密集型任务的场景超线程的收益通常在10%到30%之间如果两个线程都是纯浮点密集型互相抢FPU执行单元收益甚至会掉到5%以下极端情况下还可能出现负优化。理解了这一点做容量评估时就不能简单拿24个逻辑CPU当成24个核来算。我是后来被坑过一次才学乖的评估CPU算力时先按物理核心数估算再把超线程带来的增量按20%到30%的折扣计入这样预留的资源余量才比较可靠。2. SMP调度的核心逻辑运行队列、负载均衡与CPU亲和性2.1 操作系统如何决定这个任务给谁跑搞清楚了硬件层面的CPU格局接着就要看软件层面也就是Linux内核的调度器怎么把任务分发到各个CPU上。每个CPU在Linux内核里都有一个自己的运行队列runqueue里面装着等待在该CPU上执行的线程。调度器的核心工作就是决定两件事一是每个CPU运行队列上的线程按什么顺序执行二是各CPU运行队列之间的负载如何保持均衡。第一件事由调度策略决定。现代Linux内核2.6.23之后默认使用CFSCompletely Fair Scheduler它基于虚拟运行时间vruntime来决定下一个该执行谁——谁累积的欠账最少就优先让谁执行以此来保证所有线程都能公平地获得CPU时间。这个机制总体上是公平的但它有一个对性能敏感场景不太友好的特性CFS为了保证公平会在各种情况下主动进行负载均衡把线程从一个CPU迁移到另一个CPU这个迁移本身是有开销的。内核专门有一个调度域sched domain机制来描述CPU之间的层级关系同一个物理核内的两个超线程兄弟是一个域同一个CCXAMD或同一个dieIntel内的核心是一个域同一颗Socket上的核心是一个域整个系统又是一个域。负载均衡是分层级进行的通常优先在最低层级超线程兄弟之间做均衡因为它们的cache共享度最高迁移成本最低。2.2 负载均衡为什么不是越多核越好这里有一个反直觉的点调度器越积极地做负载均衡系统的整体吞吐可能越低。我后来用perf sched做过一次实验开一个8线程的密集计算程序跑在8核机器上默认调度策略下内核会不停地平衡各运行队列的负载线程时不时被从CPU2迁走到CPU5。单次迁移的开销看似不大无非是保存恢复上下文、把线程的cache内容在CPU之间搬一遍。但如果迁移频繁发生——比如每几十毫秒就切一次——累积的cache损失会非常可观。线程被迁走之后它在原CPU的L1/L2里热乎乎的数据全部作废新CPU的cache是凉的程序要重新从内存加载数据这一冷一热之间性能可能差出一个数量级。所以SMP系统在极端情况下会出现一个荒诞的现象程序用的核越多跑得反而越慢。原因就是线程在CPU之间的迁移和随之而来的cache失效开销超过了多核并行带来的收益。这个现象在锁竞争强的程序里尤其明显——多个核上的线程为了抢同一把锁不断把锁的cache line在各CPU之间颠来倒去这就是著名的cache line bouncing问题通信开销远大于计算收益。2.3 亲和性让CPU学会认人针对上面的问题Linux提供了CPU亲和性CPU affinity机制核心API就是sched_setaffinity()工具层面是taskset。taskset的用法很简单# 查看进程当前允许在哪些CPU上运行 taskset -p 12345 # 让进程12345只能在CPU2和CPU3上运行 taskset -p -c 2,3 12345 # 直接启动一个新进程并绑核 taskset -c 2 ./myapp我把这个机制的核心逻辑理解成让CPU认人一旦你把某个线程和某个CPU绑定调度器就再也不会把这个线程迁到别的核上。这么做的好处非常直接线程的cache一直保持在该CPU的cache里nova数据是热的上下文切换只需要在当前CPU上完成不需要跨核搬运。代价是放弃了一部分负载均衡能力——如果那个核忙不过来、其他核却闲着绑定的线程也只能排队。一个需要注意的点是taskset -c后面的CPU编号到底对应哪个物理核心要先查清楚。刚才说过超线程兄弟CPU在Linux里的编号是交错排列的比如CPU0和CPU1很可能是同一个物理核的两个逻辑CPU。如果你希望两个重负载进程分别跑在不同的物理核上就不能直接绑CPU0和CPU1得先查/sys/devices/system/cpu/cpu0/topology/thread_siblings_list这类拓扑文件确定哪些CPU编号是同一个物理核。我自己的习惯是宁可多花两分钟查一下拓扑也别凭感觉绑核。3. 第一次实战调优从软中断跨核到网卡多队列3.1 现象复现高并发业务下为何CPU0被打满回到开头那个24核网关的事故上现在可以结合前面讲的东西来正式分析了。那台机器CPU0被打满其他核空闲最典型的元凶就是中断没有均匀分配。网络数据包到达网卡后网卡硬件会产生硬中断IRQ通知CPUCPU收到硬中断后会触发软中断softirq来处理实际的数据包协议栈逻辑。默认情况下很多网卡的多个队列的硬中断都映射到同一个CPU上——通常是CPU0。所有进来的数据包都在CPU0上走完协议栈CPU0自然就满了。怎么确认这个判断两个检查项第一cat /proc/interrupts看网卡相关的IRQ号在各CPU上的分布是否均匀。如果几百行的中断计数几乎全部堆在CPU0问题就实锤了。第二mpstat -P ALL 1看实时各核利用率如果CPU0的softirq占比很高比如超过30%甚至50%而其他核几乎为0基本可以断定是软中断处理集中在这个核上。我当时看到的数据是CPU0的%soft达到了48%CPU1到CPU23的%soft全部是0.00。问题定位到这一步时方向已经很清晰了——要把中断从单核分散到多核。3.2 irqbalance与手动中断绑定解决中断扎堆问题有两种路线用irqbalance服务自动均衡或者手动设置/proc/irq/xxx/smp_affinity。irqbalance是Red Hat系系统自带的一个守护进程它会周期性检测各CPU的负载和中断分布情况自动把中断迁移到负载较低的CPU上。这个方案的好处是省心适合中断来源比较复杂、业务负载波动又比较大的通用场景。但我自己生产环境里的经验是几个核心的IRQ用irqbalance可以业务越重要越要手动绑。原因很简单irqbalance的迁移策略是通用的它不关心你的业务特性可能在吞吐量优先的网关设备上做了适合延迟敏感场景的迁移决策效果反而不好。手动绑定的做法如下# 找到网卡的中断号不同网卡不一样一般在中段号附近 $ cat /proc/interrupts | grep ens3f0 # 比如看到中断号78那就把78号中断的亲和性设置成CPU0~7 # 十六进制掩码bit0表示CPU0bit1表示CPU1... # 绑到CPU0~7掩码就是 0xff $ echo 0xff /proc/irq/78/smp_affinity需要注意smp_affinity接受的是十六进制掩码每一位对应一个逻辑CPU。如果像我开头说的拓扑里CPU编号是交错的你还得想清楚自己的掩码到底覆盖了哪些物理核。另外有个细节真正的网卡设备比如Intel的ixgbe驱动默认可能已经开启了每个队列一个中断的模式你需要先确认硬件实际有几个队列再对应设置。我处理那台网关机器时手动把所有网卡队列的中断分别绑到了8个不同的物理核上注意不是8个逻辑CPU是跨物理核的8个逻辑CPU设置完再跑mpstat -P ALL能看到软中断已经均匀分布在8个核上每个核的%soft在5%到8%之间CPU0的软中断占比从48%降到了4%。3.3 网卡多队列RSS参数设置手动绑中断只是第一步。如果网卡本身只有一个队列那就算把中断分散了数据还是要在入口处排队单队列的收包能力上限就是那个核的处理能力。现代服务器网卡几乎都支持多队列配置的核心机制叫RSSReceive Side Scaling。RSS的原理是网卡硬件根据数据包的五元组信息源IP、目的IP、源端口、目的端口、协议号做哈希把哈希结果映射到不同队列然后让不同队列的中断落在不同CPU上。这样同一个连接的数据包始终会被哈希到同一个队列但不同连接可以被拆分到不同CPU并行处理既保持了连接的顺序性又实现了横向扩容。查看和修改网卡队列数# 查询当前网卡支持的队列数 $ ethtool -l ens3f0 Channel parameters for ens3f0: Pre-set maximums: RX: 8 TX: 8 Combined: 8 Current hardware settings: RX: 2 TX: 2 Combined: 2 # 把RX队列开到8 $ ethtool -L ens3f0 rx 8设置完队列数之后再把每个队列的中断绑定到不同CPU上整体链路才算打通。我这个系列后续会专门写一篇网卡多队列与RSS参数调优的实操文章这里先不展开。但可以说个结果数据同样那台24核网关完成中断分散多队列配置后高峰期吞吐从每秒20万包左右提升到了37万包延迟从10毫秒级降回了1毫秒以内。4. NUMA感知一项被忽视的性能痛点4.1 从lscpu看到NUMA node双路服务器的CPU拓扑里有一个SMP时代从没遇到过的概念NUMANon-Uniform Memory Access非一致内存访问。回头再看一开始的lscpu输出里面有两行很容易被忽略NUMA node(s): 2 NUMA node0 CPU(s): 0,2,4,6,8,10,12,14,16,18,20,22 NUMA node1 CPU(s): 1,3,5,7,9,11,13,15,17,19,21,23这一行信息意味着这台机器有两颗物理CPU每颗CPU直连自己那组内存条。在NUMA架构下内存不再是所有CPU平等访问的单一资源。CPU访问本地节点的内存是最快的访问远端节点另一颗CPU的内存要经过跨Socket互联通道比如Intel的UPI或者QPI延迟会明显增加。用numactl --hardware可以看得更具体它会列出每个node的内存大小和距离表。距离distance这个值特别直观node0到node0的距离是10node0到node1的距离可能是21——数字越大代表访问延迟越高。通常情况下跨节点访问内存的延迟比本地访问高出50%到100%某些场景下降级接近一倍。4.2 内存跨节点访问的代价为什么NUMA对SMP性能这么重要因为Linux内核对内存分配默认采取的是亲和策略一个线程在哪个CPU上运行它申请内存时内核默认优先在CPU所在的本地节点分配。听起来好像没问题但这里藏着一个陷阱——如果线程发生了调度迁移比如被负载均衡搬到另一颗CPU上它原本在本地节点分配的内存并不会跟着搬走。线程在node0的CPU上运行时访问node0本地内存很快负载均衡把它迁到node1的CPU上后它对原来node0里的数据访问就全部变成了跨节点访问每次cache miss都要穿过Socket互联通道去拿数据延迟暴涨。这就是为什么高负载程序经常会遇到跑着跑着突然变慢过一会儿又恢复的间歇性性能问题——调度器把线程挪了个窝内存没动线程只能隔空取物。更隐蔽的问题发生在多线程程序里。如果两个线程分别被调度到了node0的CPU和node1的CPU它们共同访问一批共享数据比如一把频繁竞争的锁这批数据不管放在哪个节点的内存里总有至少一半的线程访问它是跨节点的。锁的cache line在node0和node1的CPU之间来回弹跳每次弹跳都是一次跨节点事务这个开销在小粒度锁竞争场景下可能是致命的。4.3 numactl与内存绑定理解了NUMA的代价之后我的调优手段就多了一个维度不只要绑定CPU亲和性还可能要绑定内存分配策略。numactl的基本用法# 显示当前NUMA拓扑和内存距离 $ numactl --hardware # 查看进程当前的NUMA策略 $ numactl --show # 在node0的CPU上运行myapp并只在node0的内存上分配 $ numactl --cpunodebind0 --membind0 ./myapp # 允许在node0和node1的CPU上运行但内存只在node0分配 $ numactl --cpunodebind0,1 --membind0 ./myapp还有一种是numactl --interleaveall内存会在所有节点间交叉分配。这个策略在特定场景下反而更好——比如你跑一个多个线程都大量访问共享数据的程序交叉分配可以把数据平均放在两个节点至少保证每个CPU访问远端内存的概率是均衡的不会出现一个节点热到爆、另一个节点闲着的情况。不过在大多数业务场景里我推荐的做法是先把线程CPU亲和性绑死再用--membind让内存跟着走最大程度减少跨节点访问。我做数据库类负载调优时的实际做法是先查进程运行在哪颗Socket的CPU上然后把进程绑到该Socket上的逻辑CPU集合里同时把内存绑定到同一节点。这样线程、数据、cache全部留在一个物理局部里访问路径最短。这个三者齐步走的思路在我之后每一个涉及SMP调优的场景里几乎都是必做项。5. 排查SMP问题时的三件套lscpu、mpstat、perf5.1 先看拓扑、再看监控、最后上采样分析聊完了底层机制和自己的实战案例最后分享一套我从那台网关事故里总结出来的排查方法论。很多朋友一上来就用perf抓热点结果抓出来一堆函数名不知道哪个才是瓶颈的根因。我的经验是排SMP相关的问题一定要按拓扑、监控、采样三层递进第一层先确认硬件拓扑。用lscpu看清架构、核数、NUMA节点用lscpu -e可以按列表方式查看每个逻辑CPU对应的物理核和节点编号必要时再看/sys/devices/system/cpu/cpu*/topology/目录下的文件。这一步是为了明确我们到底有多少资源可用、资源是怎么分布的。第二层使用全局监控定位花在哪了。实时看各CPU的利用率分布用mpstat -P ALL 1按进程看CPU占用用pidstat -p PID 1进程被调度到哪个CPU上执行用pidstat -P 1。这一层要回答的问题是负载是否在所有CPU间均匀分布还是某个核被单独压满。第三层用perf做精细采样。性能分析常用perf top实时热点和perf record录制后分析。perf record -F 99 -p PID -g以99Hz采样并记录调用栈跑一段时间后perf report看热点函数及调用关系。如果热点集中在锁相关的函数比如futex_wait、queued_spin_lock_slowpath说明多核间的锁竞争是瓶颈如果热点在网络协议栈的软中断函数说明中断分配不均衡。5.2 通过perf sched看调度迁移的伤害perf sched是perf子命令里专门分析调度行为的工具我强烈建议遇到多核性能问题时至少跑一次$ perf sched record -- sleep 5 $ perf sched timehist $ perf sched latencyperf sched latency会按线程统计平均/最大调度延迟以及被迁移到不同CPU的次数。如果某个线程的迁移次数特别多而且最大延迟明显高于平均延迟基本可以判断调度迁移导致的cache损失是性能杀手之一。我遇到过一个真实case一个Java服务在24核机器上有600多个线程默认调度下ForkJoinPool里的工作线程频繁被内核从node0迁移到node1GC线程的SDStop The World时间只有几十毫秒但业务线程的最大调度延迟一度超过800毫秒。后来就是通过perf sched latency发现的迁移异常用taskset把Java进程绑到了node0的CPU集合上同时配合numactl --membind0限制内存分配节点最大调度延迟降到了100毫秒以内。诊断手段和解决手段的一一对应关系就是从这种case里建立的。5.3 最后补一个容易忽略的配置坑排查SMP问题时还有一个低频但高杀伤力的坑内核参数kernel.sched_migration_cost_ns和kernel.sched_autogroup_enabled的设置。前者控制调度器计算迁移成本时的阈值设置太小会导致负载均衡过于频繁线程被疯狂迁移后者是自动组调度在CPU竞争激烈时把进程组内部的调度行为改变可能造成某些组内线程CPU饥饿。我犯过的错误是在跑高吞吐网络转发程序的机器上为了方便管理把kernel.sched_autogroup_enabled设成了0结果部分转发线程的CPU时间分配出现了明显抖动。这个参数牵扯着cgroup和调度组的细节改动前一定要先想清楚你的业务的调度模型是否依赖autogroup机制。如果只是单纯的绑核绑内存方案这个参数保持默认就行别乱动。总结这次调优的完整思路其实就是一条:从硬件拓扑入手弄清楚CPU和内存的物理分布再结合调度器、中断、NUMA这三个维度逐一排查资源分配失衡的原因最后用亲和性和绑核把关键资源钉在正确的位置上。这个过程里踩过的坑比学到的理论更值钱。这篇SMP心路历程之一主要讲了SMP的基础认知和首次调优经历后续文章我会继续拆解锁竞争分析、cache一致性开销、以及各类业务场景下SMP参数的全套优化实践。如果你的机器也出现过核很多但很慢的情况不妨先按这篇文章里的思路检查一遍拓扑、中断和NUMA大概率能有一个清晰的方向。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。