上一台机器从16核升到32核,压测结果不升反降的时候,我才认认真真把对称多处理(SMP)的底细翻了个底朝天。当时第一反应是"代码写得有问题",查了一圈锁、看了一圈缓存,最后发现自己对"多核到底是怎么协作的"这件事,其实一知半解。这篇东西不是教科书复读,而是把我理解的SMP拆开揉碎,讲给同样被多核性能问题折磨过的人听。
先简单交代背景:SMP全称Symmetrical Multi-Processing,中文叫对称多处理。它描述的是一种多核CPU架构——2个、4个、8个甚至更多处理器核心,共享同一块物理内存、同一条总线,由同一个操作系统实例统一调度。你平时说的"多核CPU",绝大多数指的就是SMP。它解决的核心问题只有一个:单核性能到头了,怎么用更多的核把吞吐撑上去。但"把核加上去"这句话背后,藏着缓存一致性、锁竞争、调度均衡、内存访问延迟这一连串麻烦。下面的内容会围绕这些麻烦展开,把我踩过的坑和摸清的规律一次讲透。
1. 从单核到多核:SMP到底解决了什么问题
1.1 单核性能为什么"到头了"
早些年芯片性能靠频率硬堆。某款处理器从1GHz提到3GHz,单线程性能肉眼可见地涨。但频率到了4GHz以上,功耗和散热开始失控——频率和功耗大致是三次方关系,频率翻倍,功耗可能要翻六到八倍。散热跟不上的时候,芯片只能降频保命,性能反而开倒车。
后来业界转向另一条路:一个芯片上放多个核心,靠并行把总吞吐拉上去。单核干不过,就双核、四核、八核一起干。这个思路本身不复杂,复杂的是多个核怎么协同工作。
频段的物理极限只是个引子,真正推动SMP走向主流的是应用需求的变化。服务器要同时扛成千上万个请求,数据库要处理海量事务,视频编码、科学计算、编译器这类计算密集型任务,全都天然存在可并行的空间。把一个任务拆成多个子任务,分给多个核跑,总时间确实能下来,这是SMP被大规模采用的根本原因。
1.2 SMP的"对称"到底指什么
搞懂SMP,先理解"对称"两个字。对称指的是内存访问对称:任何一颗核心访问内存中任意地址,在硬件层面走的是同一套总线/互连路径,访问延迟基本一致。不像后来的NUMA(非一致内存访问)架构,不同核心访问不同位置的内存延迟差距很大。这一点我在第5节会详细展开。
SMP还有一个容易被忽略的前提:所有核心共享同一个内存地址空间,并且跑同一个操作系统实例。理论上任意一个进程可以被调度到任意一颗核心上,进程看到的地址空间不变,系统对它们是"一视同仁"的。
与之相对的是非对称多处理(ASMP)——某些处理器只负责特定任务(比如专门处理中断或I/O),分工明确但灵活性差。SMP的优势是通用性强、负载均衡方便,操作系统可以把活分给最空闲的核。代价则是,一旦两个核同时操作同一个数据,就必须通过缓存一致性和锁机制来兜底,这一兜底,就兜出了后面几大章的内容。
2. 缓存一致性:SMP最容易栽的跟头
2.1 为什么每个核都非要自己的缓存
内存的访问延迟大概是几十纳秒量级,而CPU核心的时钟周期是零点几纳秒,差了差不多两个数量级。如果每条指令都直接去内存取数,CPU大部分时间都在干等,性能惨不忍睹。于是硬件在每颗核心内部加了容量小但速度快的缓存:L1一般在几十KB,延迟大概1纳秒;L2几百KB到几MB,延迟十几纳秒;L3是几个核共享的,容量几MB到几十MB,延迟几十纳秒。
问题就来了:如果两个核各自把同一个内存地址的数据缓存了一份副本,一核改写了数据,另一核还握着一份旧副本,那程序读到的东西就对不上了。这就是经典的缓存一致性问题。
缓存一致性并非常规编程中"缓存"的概念——程序缓存没这层负担。这里说的是硬件Cache与主存之间的一致性协议。很多多线程Bug追踪到最后,根本不是业务逻辑错,而是数据在多个核的Cache里打架。
2.2 MESI协议:缓存是怎么"商量"着来的
为了解决一致性问题,硬件实现了缓存一致性协议,最常见的是MESI(Modified、Exclusive、Shared、Invalid)四个状态:
- Modified:本核独享数据,且已修改,和主存不一致,写回主存前其他核不能碰。
- Exclusive:本核独享数据,还没修改,和主存一致。
- Shared:多核都有这份数据的副本,数据未被修改。
- Invalid:缓存行里的数据已经失效,读之前必须重新从别处获取。
当某个核要写一个处于Shared状态的数据时,它需要先向其他核心广播"我要独占这一行",收到确认后把其他核副本标记为Invalid,然后本核才能写。这个过程叫缓存行失效广播,是缓存一致性最核心也最花钱的操作。
不同厂商在此基础上还有变体,比如AMD的MOESI协议增加了Owned状态(数据被本核改动过,但允许其他核保留一份共享副本用于读)。理解到MESI这个粒度,已经足够排查绝大部分多核性能问题,细节不必背体系,重点是抓住"失效广播"这个代价。
2.3 伪共享:致命且隐蔽的性能杀手
缓存一致性协议带来一个极其反直觉的坑:伪共享(False Sharing)。
假设两个线程分别在处理一个结构体中两个相邻的int变量。结构体在内存里连续分布,两个变量很可能落在同一条64字节的缓存行里。线程A在核0上频繁修改变量x,线程B在核1上频繁修改变量y。虽然x和y根本不相关,但因为它们在同一条缓存行,A每写一次x,就要把B手里的缓存行失效掉;B写y时又反过来让A失效。两个核来回抢同一条缓存行,性能可能比单线程跑还差。
我在一个项目里踩过这个坑。当时一个热点结构体十几个字段,多线程更新时性能随线程数增长极度缓慢,后来用工具一看,是高频更新的两个字段恰好挨在一起。解决方式很简单:把热点字段拆开,用__attribute__((aligned(64)))让它们落在不同的缓存行里,或者干脆填充对齐。改完之后吞吐直接上了一个量级,一行代码的差距。
识别伪共享最有效的方式是观察缓存未命中率和CPU总线流量。
perf c2c这类工具能直接定位到缓存行级别的冲突,比纯靠猜靠谱得多。
3. 锁的代价与演进:自旋锁、读写锁到RCU
3.1 原子操作:无锁的基础砖块
多核环境里,最简单的"锁"是硬件级别的原子操作。比如xchg、compare-and-swap(CAS)、fetch-and-add这类指令,在硬件层面保证了"读-改-写"不可分割。它们的实现机制很直接:处理器会锁住总线或内部的某个互斥路径,保证一个原子操作期间其他核无法插入。
原子操作是自旋锁、互斥锁、无锁队列的底层砖块。但要注意:原子操作虽然避免了锁数据结构本身的开销,它在执行时会对总线或缓存一致性流量产生影响。高频CAS在某些架构上会造成可观的总线争抢,表现是CPU利用率不低但吞吐上不去——这也是无锁编程"看着没锁其实贼慢"的来源之一。
在某些弱内存模型的架构里,原子操作通常还伴随内存屏障语义(memory barrier),用来约束指令重排。你写x=1; flag=1,其他核心看到的顺序未必一致,可能先看到flag的更新再看到x的更新。这在实现自旋锁、无锁队列时是致命细节,有内存屏障的地方绝不能省略。
3.2 自旋锁:短临界区的收支平衡
自旋锁的行为是"等锁的时候不断重试,直到拿到锁"。它不睡眠,所以没有线程上下文切换的开销,最适用于临界区极短的场景——比如更新个计数器、操作个链表头。
但自旋锁有个致命弱点:如果持锁线程被操作系统抢占(比如时间片耗尽),其他等锁的线程只能白白占着CPU转圈。所以Linux内核的自旋锁明确要求临界区里不能睡眠、不能调用可能调度的函数。用户态写代码时如果临界区太长或者可能阻塞,用自旋锁是灾难。
我在用户态会用atomic_flag或std::atomic模拟一个简单的自旋锁,只锁几十条指令的区间,实测比mutex快不少。临界区一旦超过微秒级,自旋锁的收益就没了,甚至因为CPU烧在轮询上而变慢。
3.3 互斥锁:sleep与wakeup的代价
互斥锁的做法是拿不到锁就让线程睡下,等持锁线程释放时再把等待者唤醒。Linux上的futex机制就是这种思路,用户态快速路径用原子指令尝试获取,失败后才进入内核态挂起线程。
这个设计的盈亏很明显:锁竞争不激烈时,互斥锁和自旋锁差距不大;竞争激烈时,互斥锁避免了一大堆CPU空转,代价是线程睡眠唤醒带来的上下文切换。上下文切换本身要几微秒,如果临界区只有几十纳秒,这个成本就非常可观。
所以在服务端编程里,我一般遵循一个规律:临界区极短且线程都绑在核上不迁移,用自旋;临界区涉及I/O、网络或者可能有几十微秒以上延迟,用互斥。需要更细致的操作时,可以用读写锁——读多写少时让读方并行,只有写方需要独占。读写锁的缺陷是写方容易饿肚子,有些实现里读方太多会导致写方一直拿不到锁,需要配合写优先策略才能平衡。
3.4 RCU:读多写少场景的终极优化
RCU(Read-Copy-Update)是Linux内核里一个非常经典的多核同步机制。基本思路是:写方不直接改共享数据,而是先复制一份,在副本上修改,再原子地切换指针指向新副本;旧副本要等所有读者都离开临界区之后才能真正释放。
好处是读者几乎不为同步付任何代价,不用取锁也不用原子操作,极大优化了"读多写少"的场景。比如路由表、文件系统里的dentry缓存、内核模块列表这些,都是RCU的典型应用。缺点则是写方开销大——每次更新要复制整个数据,还要等宽限期(grace period)结束才能回收旧副本。用户态也有类似实现,比如一些无锁哈希表就用到了RCU思想,但需要手动处理垃圾回收和内存屏障,复杂度高得多。
选同步原语不是越高级越好,而是看读和写的比例、临界区的时长、线程数、CPU架构。RCU适合90%都是读的场景;如果写占一半,老老实实用读写锁反而更稳。
4. 调度与亲和性:让进程去该去的核
4.1 调度器怎么决定进程落在哪个核上
SMP架构下,操作系统要决定每个就绪线程跑在哪颗核上,这是调度器的职责。Linux的CFS(完全公平调度器)会为每个CPU维护一个运行队列(runqueue),按虚拟运行时间挑选下一个要执行的线程。
系统里有一个负载均衡逻辑,会定期把任务从繁忙的核迁移到空闲的核上,尽量让所有核的工作量均衡。这个机制看似美好,实际会带来一个隐患:线程在不同核之间迁移时,它在旧核Cache里的数据全部失效,新核上的Cache冷冰冰的,要重新从内存加载,导致性能跳水。
如果线程是绑核运行的,Cache里存的热数据能持续命中,性能稳定得多。这就是为什么很多性能敏感的服务会做CPU亲和性绑定或者用cpuset划分核。
4.2 亲和性设置:taskset与cpuset的实践
在Linux上设置亲和性最直接的工具是taskset:
# 让进程1234只能跑在CPU 0-3上 taskset -pc 0-3 1234 # 启动时直接绑定 taskset -c 0,2 ./my_server更灵活的是cpuset cgroup,可以为一组进程划定专门的CPU集合,并且和调度器的负载均衡隔离起来。比如把繁忙的工作线程分到CPU 0-3,把管理线程分到CPU 4-7,两边互不干扰。
绑核的正确姿势不是越多越好。我见过不少人把进程绑定到所有核上,这等于没绑。真正的价值在于:高频协作的线程放在同一颗物理核的超线程兄弟上,或者放在共享同一L3缓存的核心簇里;完全独立的负载反而应该分散到不同核心上,避免缓存空间互相挤压。具体怎么分,先看清楚CPU拓扑,再设计绑核策略。
4.3 隔离核:追求最低延迟的代价
对延迟极度敏感的场景,绑核还不够,需要做核隔离。Linux内核参数isolcpus可以把部分核从通用调度器中隔离出去,让普通线程不能跑到这些核上;配合nohz_full关闭这些核的周期性时钟中断,让进程可以长时间独占一个核,避免中断打扰带来的抖动。
我做过一个低延迟网关项目,把收包线程绑到两个隔离的核上,网络中断也定向到其中一颗核,管理面和数据面彻底分离。效果非常直接:P99延迟从几毫秒降到几百微秒,抖动明显减少。代价是那些隔离核的算力被"浪费"了,通用任务不能使用它们。调度这个领域,真的是处处在做资源置换。
5. 从SMP到NUMA:内存访问不再平等的时代
5.1 总线架构的物理局限
前面说过SMP是统一总线、统一内存,每个核访问内存的延迟都差不多。但随着核心数增长,一颗颗核去抢同一条总线的带宽,冲突越来越严重。总线不得不越做越宽、频率越提越高,但物理布线、功耗、成本全都压不住。
于是硬件架构转向NUMA:把处理器核心和内存控制器一起分成多个Node,每个Node拥有自己最近的本地内存。核心访问本Node内的内存快,访问远端Node的内存慢。从软件视角看,地址空间仍然是统一的,但延迟不再一致——这就是Non-Uniform Memory Access(非一致内存访问)名字的来源。
5.2 NUMA拓扑识别与分配策略
在Linux上可以用numactl --hardware查看NUMA拓扑,你会看到每个Node上有哪些CPU、多少内存,以及Node之间的距离矩阵。写性能敏感的代码时,要保证线程尽可能跑在数据所在Node的核上,否则每次内存访问都走远端,延迟能差一倍以上。
内存分配策略通常有几种:--localalloc(优先分配线程当前所在Node的内存)、--preferred(优先指定Node,失败再降级)、--interleave(在多个Node之间交叉分配)。默认策略通常是本地分配,但需要注意:
- 如果线程绑定在Node 0,数据却在Node 1分配了,访问就会绕远路。
- 如果进程只绑了核没绑内存,内核可能把内存散到多个Node上,初期看着没问题,一旦数据量增长,跨Node访问比例上来了,性能就开始崩。
一个例子:某数据库实例在NUMA机器上,刚开始数据量小全落在本地Node,一切正常;数据量涨起来后部分内存落到远端Node,查询延迟肉眼可见地上涨。调整方案是把大页内存、绑核策略和NUMA策略一起配合,避免内存四处散落。
5.3 容器与虚拟化场景下的NUMA陷阱
现代云原生环境更复杂。容器默认只知道自己看到的那几个CPU,看不到宿主机完整的NUMA拓扑;如果容器的CPU被调度器分散在多个Node上,共享内存的访问延迟就会忽高忽低,性能测试数据波动特别大。
解决思路是让容器和物理拓扑对齐:为容器分配同一Node下的CPU和内存,必要时用cpuset把容器圈在一个Node范围内。虚拟机的vCPU也面临同样的问题——如果vCPU分布在不同物理Node上,客户机里的SMP会遭遇严重的跨Node访问。开虚拟机时指定vCPU和内存针对同一NUMA Node,比在后端瞎调优高效得多。
分配策略没有银弹。写线程多、访问共享内存多的,优先本地分配;大数据集且访问模式分散的,interleave反而更好。拿真实业务负载去压测,别拍脑袋。
6. 多核性能排查案例与通用方法
6.1 场景:一个"核越多越慢"的诡异问题
之前调过一个模拟负载,一个多线程服务,线程数从4调到16,吞吐先涨后跌,到16线程时甚至比4线程还差。第一反应是锁竞争,但代码里明明对临界区做了细化,锁粒度已经很细了。用perf top一看,热点不是业务函数,而是raw_spin_lock相关,锁本身成了热点。
顺着perf record继续查,发现大部分自旋时间集中在同一个锁上,而它保护的是一个全局统计结构体。进一步用perf c2c分析,发现是统计结构体里的计数字段和配置字段挨得太近,多个线程一边更新计数一边读配置,触发了伪共享。把统计字段单独对齐到缓存行后,问题直接消失,4到16线程的扩展性恢复正常。
6.2 排查工具链:从mpstat到perf
排查多核问题,我一般按这个顺序来:
top/mpstat -P ALL:看各个核的利用率是否均衡、系统态和用户态占比。vmstat/pidstat:看上下文切换和运行队列有没有异常飙高。perf top:抓取CPU热点函数,锁定真正忙的代码路径。perf stat -e cache-misses,context-switches:量化缓存未命中和切换代价。perf c2c:专门对付伪共享,能看到缓存行级别的争抢。bpf trace类工具:用动态追踪看某个锁的等待分布、某个函数的调用次数。
这个组合基本覆盖了锁竞争、伪共享、调度不均衡、缓存未命中这四类主要多核性能杀手。不要太快下"是业务代码慢"的结论,先让数据说话。
6.3 通用调优顺序:先看扩展性曲线,再动代码
拿到一个多核性能问题时,我习惯按下面的顺序判断当前处于哪一类瓶颈:
- 核数翻倍但吞吐不涨:大概率是共享资源瓶颈,看缓存、总线、锁。
- 吞吐随核数涨但涨幅远小于线性:可能是锁竞争或伪共享,看
perf c2c和上下文切换。 - 部分核忙到100%、其他核空闲:调度不均衡,检查亲和性和负载均衡设置。
- 核数增加性能反降:几乎可以确定是高代价的缓存一致性流量——伪共享或者锁竞争到极端。
先判断类型,再决定动代码还是动配置。无脑加锁、无脑绑核、无脑用无锁结构,我全都见过翻车案例。调优是条单行道,从可观测性入手,哪怕多花点时间,也远比瞎试高效。
最后分享一个我自己的经验:SMP环境里,性能问题的根因往往藏在"看不见的数据流动"里——缓存行在核间广播失效,锁变量在原子操作间争抢总线,线程在核间跑来跑去把Cache热度冲散。多数的优化方向,其实是让"流动"变少:减少共享、减少迁移、减少一致性的广播。看到性能瓶颈时,先问一句:到底什么东西在各个核之间频繁流动?这个问题想明白了,调优方向也就八九不离十了。