☰
多核编程底层原理:缓存一致性、锁竞争与调度优化
2026/10/27 3:34:59 网站建设 项目流程

写并发程序时最容易被忽略的一课,是把“多核”当成“多个更快的单核”。我刚接触多核编程那阵子,思路特别朴素:一个核跑一秒,八个核不就跑八分之一秒?结果拿一个纯计算任务去测,八线程只比单线程快了不到三倍,再加线程数甚至还往回掉。后来才慢慢明白,多核系统里真正昂贵的从来不是“算”,而是核与核之间的信息交换:缓存行在核间弹跳、锁的持有权来回交接、任务在队列之间迁移、内存访问跨了节点。理解多核原理,本质上就是理解这些“看不见的成本”从哪里来、怎么量、怎么省。这篇东西我想把操作系统在多个核上到底做了哪些事讲清楚,包括硬件一致性模型对软件同步原语的约束、内核里几种锁该怎么选、调度器怎么把任务分到各个核上、以及伪共享和锁竞争这类问题怎么用工具抓出来。内容偏底层,但只要你会写多线程程序、调过多核机器的性能,哪怕没看过内核源码,也能照着思路往下走;代码和命令都给了,可以直接在自己机器上复现。

1. 多核不是“多个单核”,先把硬件模型掰开揉碎

1.1 SMP 与 NUMA:两种完全不同的内存地形

讨论多核之前得先分清两种硬件拓扑,因为它们直接决定了内核调度和内存分配的写法。SMP是所有处理器核心挂在一个统一的一致性域里,共享内存控制器,任何核访问任何内存地址的延迟基本一样,这套模型简单、对称,代价是总线或互连带宽会成为上限,核数一多就撞墙。NUMA则是每个节点(通常对应一个物理插槽)自带内存控制器和一部分内存,核访问本地内存快,访问别的节点的内存要经过片间互连,延迟明显上升、带宽明显下降。这两种模型在软件层的表现完全不同:在 SMP 上你几乎不用关心数据放在哪,在 NUMA 上“数据放在哪个节点”能直接决定程序快慢百分之几十。

很多人踩的第一个坑就在这里:程序开了几十个线程,机器是双路 NUMA,结果所有线程的内存都分配在节点 0 上,跑在节点 1 的核上全是远程访问。原因通常是“首次触碰”策略——内存页默认分配在第一个写它的核所在节点,如果主线程先把大块内存初始化了一遍,那这块内存就全落在主线程所在的节点了。解决办法要么让每个线程自己初始化自己要用的那部分,要么显式绑定:

# 只在节点 0 的核上运行,并且内存只从节点 0 分配 numactl --cpunodebind=0 --membind=0 ./your_app # 查看当前机器的 NUMA 拓扑 numactl --hardware

numactl --hardware的输出里有每个节点的核列表、内存大小、以及节点间的相对距离(distance)。这个距离数字很有用,它近似反映跨节点访问的代价倍数,看到 20 以上的值,就说明跨节点访问在这个机器上真的不便宜。

注意:--membind是硬绑定,内存不够会直接失败;--preferred是软偏好,不够时可以回退到别的节点,生产环境里后者更安全。

1.2 缓存一致性与 MESI:所有同步原语的物理起点

多核之所以需要锁,根子上是因为每个核都有自己的私有缓存,而写操作必须让别的核看到。主流处理器用一套叫MESI的缓存行状态机来维持一致:每条缓存行在某个核的缓存里处于修改(M)、独占(E)、共享(S)、失效(I)四种状态之一。当一个核要写某条缓存行,它必须先拿到独占权,于是要通过互连向其他核广播“我要写这条行”,其他核把自己的副本置为失效。这个过程的代价远高于访问本地缓存:从 L1 命中到跨核拿到独占权,量级上差一到两个数量级。

这就解释了一个反直觉的现象:两个线程各写各的变量,只要这两个变量落在同一条缓存行里,性能就会崩。因为缓存行的粒度通常是 64 字节,两个变量物理上挨在一起,硬件把它们当成一个整体来维护一致性,于是这条行在两个核之间来回“弹跳”,每次写都要经历一次失效和重新获取。这就是伪共享(false sharing),它不需要任何锁,也不需要任何数据竞争,纯靠缓存协议就能把多核性能吃掉一大半。

与之对应的是真共享:多个核确实要读写同一个变量,比如一个全局计数器。这时候用原子操作能保证正确性,但原子操作本身也要独占缓存行,争抢激烈时同样会成为瓶颈。解决思路不是“加更多锁”,而是“减少共享”——用 per-CPU 变量把计数拆开,最后再汇总。

1.3 内存序与屏障:编译器和 CPU 都在悄悄重排

写多核代码最容易翻车的地方是内存序。你在源码里写的两句赋值,编译器和 CPU 都可能给你换顺序,单线程下看不出问题,多核下就是灾难。x86 用的是较强的 TSO 模型,基本只允许“读后写”重排(StoreLoad),而 ARM、RISC-V 这类弱内存序架构允许的乱序多得多。所以一段在 x86 上“碰巧能跑”的无锁代码,换到别的架构上大概率出错——这不是玄学,是内存模型差异。

对抗重排有两层工具。第一层是编译器屏障,asm volatile("" ::: "memory")告诉编译器“别把这条线前后的内存访问挪过去”,但它对 CPU 无效。第二层是 CPU 屏障,内核里封装成smp_mb()、smp_rmb()、smp_wmb(),以及更轻量的smp_load_acquire()、smp_store_release()。在多核配置下它们是真实的硬件屏障指令,在单核配置下退化成纯编译器屏障,所以写内核代码时统一用smp_系列,别直接写mb()。

屏障类型保证什么典型用途
编译器屏障阻止编译期重排与硬件屏障配合,防止优化掉访问
smp_mb()前后所有内存访问不跨越通用全屏障,代价最高
smp_rmb()/smp_wmb()只约束读或只约束写生产者-消费者单向顺序
smp_load_acquire()/smp_store_release()单向、成对使用标志位发布、指针发布

我个人的经验是:写无锁结构时,先用 acquire/release 把“发布-订阅”关系表达清楚,实在拿不准的地方再退到全屏障。别一上来全用smp_mb(),性能损失是实打实的;也别一处不用,靠“我测过没问题”蒙混过关,换个编译器版本或换个架构就现原形。

2. 内核同步原语怎么选:一把锁背后的取舍

2.1 自旋锁、互斥锁、读写锁的适用边界

选锁的核心判断只有两个问题:临界区会不会睡眠,以及等锁的时间有多长。这两个问题一回答,答案基本就定了。自旋锁是忙等,等的时候 CPU 不干别的,所以只适合临界区极短(几条到几十条指令)、绝对不可能睡眠的场景。它的另一个约束是持有期间要禁止抢占,否则持锁线程被换下去、等锁的核在那儿空转,就是纯浪费。互斥锁在拿不到锁时把当前任务挂起让出 CPU,适合临界区可能阻塞的场景,比如要分配内存、要做 IO、要拷贝用户态数据。

判断口诀很直白:临界区里有任何可能睡眠的调用,就只能用互斥锁;临界区里只有几条内存操作,用自旋锁;介于两者之间,看争抢激烈程度,争抢少用互斥锁省 CPU,争抢多且临界区短用自旋锁省切换。

一个特别的坑是中断上下文。中断处理里不能睡眠,所以只能用自旋锁,而且必须用spin_lock_irqsave()这一族——因为如果中断处理程序在同一个核上抢同一把锁,而你持锁时没关本地中断,那就直接死锁了。这个死锁非常隐蔽,表现是系统偶发卡死,复现条件还挑机器。

读写锁的边界更微妙。理论上读多写少用读写锁划算,但实际写者饥饿问题很烦:读请求源源不断时,写者可能长时间拿不到锁。多数内核实现会让后来的读者排队等前面的写者,缓解但不根治。我的建议是,读写锁只在“读临界区短、写极少、且确实测出收益”时才用,别默认它比互斥锁快——读写锁的原子操作次数并不少,短临界区下甚至更慢。

2.2 原子操作与 per-CPU 变量:把共享改成私有

原子操作是无锁编程的地基,底层通常是一条带lock前缀的指令或者一对 LL/SC 指令,保证读-改-写在多个核看来是原子的。它比锁轻,但并不免费:争抢同一条缓存行时,原子操作同样要反复获取独占权,热点的吞吐会随核数增加而下降。

真正能线性扩展的做法是把共享变成私有。内核里的 per-CPU 变量就是这个思路:每个 CPU 有一份自己的副本,读写只碰本地副本,不需要任何原子操作,也不会造成缓存行弹跳。代价是读取“总和”时要把所有 CPU 的副本加起来,且求和过程中可能有别的 CPU 在更新,所以结果只是近似值——对统计类用途完全够用。用户态程序也能用同样的技巧:线程局部变量(TLS)加对齐填充,基本就是用户态的 per-CPU。

/* 每个线程自己维护计数器,避免全部争抢同一个缓存行 */ #include <stdalign.h> typedef struct { alignas(64) unsigned long count; /* 独占一条缓存行 */ char pad[64 - sizeof(unsigned long)]; } local_counter; _Thread_local local_counter my_counter; void bump(void) { my_counter.count++; /* 无原子操作、无锁、无缓存行争用 */ }

alignas(64)这一行是关键,它保证结构体起始地址按 64 字节对齐(也就是常见的缓存行大小),从而不会和别的变量共享缓存行。注意 64 是常见值,不同处理器可能不同,跨平台代码最好从sysconf(_SC_LEVEL1_DCACHE_LINESIZE)读,或者用内核头里暴露的cache_line_size()。

2.3 RCU:读多写少场景里性价比最高的一招

RCU(Read-Copy-Update)在同步原语里位置特殊,因为它让读者的开销接近于零——不加锁、不做原子操作、不写共享变量,只做一次依赖顺序的指针读取。写者的开销则偏大:先复制一份新数据,把指针切过去,然后等待一个“宽限期”结束才释放旧数据。宽限期的定义是“所有 CPU 都经历过至少一次静默态”,静默态可以是上下文切换、返回用户态、进入空闲。这个定义很巧妙,它不需要知道有多少读者还在临界区里,只要所有 CPU 都换过一轮,就说明老的读者一定都退出了。

适合 RCU 的典型场景是配置表、路由表、模块列表这类读极多写极少、且读侧对实时性有要求的数据结构。不适合的场景是写很频繁,或者读侧临界区里要睡眠(标准的 RCU 读临界区不能睡眠,虽然也有可睡眠的变体,但使用限制更多)。

写 RCU 代码最容易犯的错是忘了正确的指针发布顺序:先初始化新结构体,再用带 release 语义的赋值把指针切过去,读侧用带 acquire 语义的读取或者rcu_dereference()。顺序反了,读侧就可能看到只初始化了一半的结构体。这个 bug 在测试环境里可能跑一万次都不出现,上线之后偶发崩溃,非常难查。

一个自查习惯:凡是你写了rcu_assign_pointer(),就在同一段代码里找找对应的rcu_dereference(),找不到就要怀疑自己是不是漏了。

3. 多核调度:让每个核都吃到合适的活

3.1 per-CPU 运行队列与负载均衡的三条触发路径

理解调度器的第一步是理解它为什么不为所有核维护一个全局队列。答案还是缓存和锁:全局队列意味着每次入队出队都要抢同一把锁,核数一多这把锁立刻成为瓶颈,而且任务在核之间乱跳会毁掉缓存局部性。于是主流做法是每个 CPU 一个运行队列,调度决策绝大多数时候只看本地队列,不需要跨核通信。

那负载不均怎么办?靠负载均衡。触发路径大致三类:周期性均衡,定时器 tick 到来时检查本核是否比别的核忙太多,忙的话拉一些任务过来;空闲均衡,某个核没活干了,主动去别的核队列里“偷”任务;唤醒均衡,任务被唤醒时决定放到哪个核上跑,通常优先选唤醒它的那个核或者有空闲算力的核,尽量保持缓存热度。

调度域的层次结构决定了迁移的优先级。现代处理器通常是“超线程兄弟核 → 同物理核簇 → 同插槽 → 跨插槽”这样的树,均衡会从小到大逐层尝试,因为层级越近迁移代价越小(共享缓存更多,跨节点内存访问更少)。这也是为什么绑核绑得太死反而慢:你把任务钉在一个核上,调度器就没法在它空闲时把活分给它,也没法在它忙时把活挪走。

3.2 亲和性怎么设才不添乱

设亲和性的工具有三种粒度:进程级的sched_setaffinity、命令行的taskset、以及 cgroup 的 cpuset 控制器。区别在于 cpuset 是分层的、可以约束整棵进程树,适合做资源隔离;taskset适合临时验证;sched_setaffinity适合程序内部控制。

# 让程序只在前 4 个核上跑 taskset -c 0-3 ./app # 查看某个进程当前的亲和性掩码 taskset -cp $(pgrep -f your_app) # 用 cpuset 做长期隔离(示例路径,按实际挂载点调整) echo 0-3 > /sys/fs/cgroup/cpuset/mygroup/cpuset.cpus echo 0 > /sys/fs/cgroup/cpuset/mygroup/cpuset.mems

什么时候该绑核?我总结了三类值得绑的场景:延迟敏感型服务,绑核能避免任务被迁走导致缓存全冷;NUMA 感知型程序,绑核配合membind能保证本地访问;中断与业务分离,把网卡中断绑到独立的核上,避免中断处理和业务线程互相抢 CPU。反过来,计算密集且任务数远大于核数的场景千万别绑,绑了就是人为制造排队。

关于中断还有个细节值得单独提:很多机器的默认中断全落在 CPU0 上,网卡一忙,CPU0 先满,然后整机吞吐上不去,但你看总 CPU 利用率才 30%,很迷惑。查/proc/interrupts一眼就能看出来,各列对应各个 CPU 的中断计数,如果第一列数字远大于其他列,就是这个毛病。调法有两个方向:改/proc/irq/<号>/smp_affinity把中断分到不同核,或者打开 RPS/RFS 把软中断处理也分散开。

3.3 中断上下文、软中断与核间唤醒的隐形成本

多核系统里还有一类开销常被忽略:核间唤醒。一个任务被唤醒后,如果目标核正在跑别的任务或者处于空闲退出流程,就要发一个核间中断把它叫起来。这个中断从发出到目标核真正开始执行任务,中间有若干微秒级别(经验量级,具体随硬件和负载波动)的延迟。对吞吐型业务无所谓,对延迟敏感型业务(比如高频交易、实时控制)就是实打实的损失。

软中断的处理也会影响调度公平性。软中断默认在中断返回时执行,如果量太大,会交给每个核各自的ksoftirqd线程,这时候它就和普通任务一起参与调度竞争了。你如果看到某个核的ksoftirqd占用很高,通常说明这个核承接了过多网络收包,需要重新分配中断亲和性或者开 RPS。

还有一个常见现象是任务抖动。程序里线程数略多于核数时,调度器会频繁在核之间搬任务,每次搬迁都要重新暖缓存,perf stat里的cpu-migrations计数会很高。这种情况有时候减一个线程就解决了,比调任何参数都有效。

4. 多核实操:从启动流程到性能观测

4.1 多核是怎么一个个醒过来的

冷启动时并不是所有核同时开始跑内核,而是有一个引导核先起来,完成早期初始化、解析硬件拓扑描述表(x86 上是 MADT,ARM 上是设备树里的 cpu 节点),然后逐个唤醒其他核。被唤醒的核从一个固定的低地址入口进入,先切到保护模式或长模式,建立自己的栈和页表映射,最后跳进内核的次级启动路径,完成 per-CPU 数据初始化、时钟和中断控制器初始化,然后加入调度。

这个流程里有个容易忽略的设计:每个核的 per-CPU 数据是独立分配和初始化的,包括当前任务指针、栈指针、抢占计数、本地时钟等。内核通过一个专用寄存器(x86 上是 GS 基址,ARM64 上是 TPIDR_EL1)快速定位到本核的数据,这就是为什么内核里读current这么快——一次寄存器间接寻址,不是查表。

运行期还支持 CPU 热插拔。下线一个 CPU 要做的事情不少:把它的任务迁走、把它的中断迁走、停掉它的定时器、把它从调度域里摘出去。所以热插拔不是零成本的,高频上下线会带来明显的抖动。我见过把热插拔当“省电开关”用的方案,效果很差,因为每次上线都要重新初始化一遍,还打断了正在跑的任务的缓存局部性。

4.2 伪共享复现:一段能亲眼看到差异的代码

理论讲完,最好亲手跑一遍伪共享,感受一下差距有多夸张。下面这段代码起两个线程,各自对一个全局变量累加两亿次,唯一区别是这两个变量在内存里挨着还是隔开:

#include <pthread.h> #include <stdio.h> #include <stdalign.h> /* 版本 A:两个变量在同一缓存行 */ struct tight { volatile long a; volatile long b; } tight_pair; /* 版本 B:两个变量各占一条缓存行 */ struct padded { alignas(64) volatile long a; char pad[64 - sizeof(long)]; alignas(64) volatile long b; } padded_pair; static void *worker(void *arg) { volatile long *p = (volatile long *)arg; for (long i = 0; i < 200000000L; i++) { *p += 1; } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, worker, (void *)&tight_pair.a); pthread_create(&t2, NULL, worker, (void *)&tight_pair.b); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_create(&t1, NULL, worker, (void *)&padded_pair.a); pthread_create(&t2, NULL, worker, (void *)&padded_pair.b); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("tight : %ld %ld\n", tight_pair.a, tight_pair.b); printf("padded : %ld %ld\n", padded_pair.a, padded_pair.b); return 0; }

编译跑一下:

gcc -O2 -pthread false_sharing.c -o fs ./fs time ./fs

在我见过的机器上,版本 A 的耗时通常是版本 B 的两倍以上,有的平台差到三四倍。这个差距不是因为指令多了,纯粹是缓存行在两个核之间来回搬。想看得更细,可以上perf c2c:

perf c2c record -g ./fs perf c2c report --stdio

perf c2c专门统计缓存行的跨核命中(HITM),输出里会直接告诉你哪条缓存行、哪个变量地址、被哪些核争抢,命中率一栏数字高的基本就是元凶。这个工具在排查大型服务里说不清来源的 CPU 飙高时极其好用,比猜快得多。

补充一句:这里的volatile只是为了不让编译器把循环优化掉,它并不提供任何多线程安全保证。要真做并发计数,还是得用原子变量或者 per-CPU 拆分。

4.3 锁竞争的定位与消解路径

比伪共享更常见的是锁竞争。典型的信号是:CPU 利用率上不去(比如 8 核只跑到 300%),但任务延迟很高,perf top里能看到大量时间花在自旋或者 futex 等待上。定位顺序我一般这么走:

# 1. 看上下文切换和迁移频率 perf stat -e context-switches,cpu-migrations,cache-misses -p <pid> sleep 10 # 2. 看函数级热点 perf top -p <pid> -g # 3. 追一次完整的采样,落盘再分析 perf record -F 999 -g -p <pid> -- sleep 20 perf report --stdio --sort dso,symbol

如果热点集中在futex、pthread_mutex_lock、__lll_lock_wait这类符号上,基本可以确认是用户态锁竞争。接下来要做的是缩小临界区:把锁外能做的事情全部挪出去,尤其是字符串格式化、内存分配、日志打印这类又慢又容易阻塞的操作。

消解的优先级从高到低,我个人的排序是:改成无锁或 per-thread 分片>缩小临界区>换更细粒度的锁>换读写锁 / RCU 语义>调参数。前两步几乎总能拿到最大收益,最后一步通常只是把问题往后推。比如把一个大哈希表拆成 32 个分段锁,每个分段一把锁,争抢概率直接降一个量级;再进一步,每个线程一个本地缓存加定期合并,争抢就基本消失了。

内核态的锁竞争定位思路类似,但工具不同。/proc/lockdep_stats能看锁依赖图统计,ftrace的sched_switch、irq_handler_entry事件能看调度和中断的时序,lock_stat打开后可以统计每把锁的争抢次数和等待时间。这些开关会带来额外开销,务必只在排查时打开,查完关掉。

5. 常见问题与排查实录

5.1 高频问题速查表

多核问题有一些典型的“症状-病根”对应关系,把这些对应关系记住,现场排查能省很多时间:

症状最可能的病根快速验证方法
加线程反而变慢锁竞争或伪共享perf c2c、perf top -g看热点符号
总 CPU 利用率上不去,单核跑满串行点或中断集中mpstat -P ALL 1、看/proc/interrupts各列
延迟抖动大、长尾明显任务迁移、核间唤醒perf stat看cpu-migrations
NUMA 机器性能只有预期一半内存分配跨节点numactl --hardware+ 程序内查页分布
偶发卡死,复现困难中断上下文死锁打开 lockdep 跑一遍压测
计数器结果偶尔偏小per-CPU 汇总的近似性检查是否把统计值当成了精确值
中断处理里抢同一把锁卡住持锁没关本地中断改用spin_lock_irqsave系列
扩容到一定核数后吞吐不涨共享缓存行或全局锁观察是否所有核都在同一地址上写

这张表里最值得单独说一句的是“计数器结果偶尔偏小”。per-CPU 变量的汇总本来就不是原子快照,读到的是一个近似值。这不算 bug,但如果上层业务拿它做精确判断(比如“计数到了 1000 就触发一次动作”),就会出现丢事件或者重复触发。我吃过这个亏,最后改成汇总后再判断,并且允许误差范围。

5.2 几个踩过的坑与判断思路

第一个坑是把性能问题当成代码问题。有一次多线程任务怎么调都慢,我盯着代码看了半天,最后发现是机器的超线程和物理核对不上,nproc报的 16 里有一半是超线程兄弟核,两个兄弟核共享执行单元,跑浮点密集型任务时互相拖累。这个场景下“16 个线程”的期望本来就是错的,得看物理核数,或者干脆把兄弟核用亲和性隔开。

第二个坑是屏障用多了反而拖慢。为了让无锁队列“绝对正确”,我在每个操作前后都加了全屏障,结果单核性能掉了三成。后来改成 acquire/release 配对,正确性没变,开销小多了。屏障这件事的规律是:只在真正需要表达顺序的地方加,加完再想一遍“这两个访问的顺序真的必须固定吗”,很多时候答案是“不必”。

第三个坑是误判负载均衡。看到某个核特别忙,第一反应是调度器没均衡好,其实很多时候是中断或者软中断集中在那里。先看/proc/softirqs和/proc/interrupts,排除中断因素之后再怀疑调度,顺序反过来会浪费很多时间。

第四个坑是锁粒度拆得太细。把一把大锁拆成几十把细锁,结果一次操作要按顺序拿好几把,死锁风险上升、代码复杂度爆炸,性能提升却很有限。我现在的做法是先拆成 4 到 16 段的量级,测出瓶颈段再继续拆,绝不一上来就拆到底。

最后一个观察:多核性能优化里真正见效的招数往往非常朴素——减少共享、对齐缓存行、减少跨核唤醒、把中断分散开。这些做法都不用改架构,改几十行代码就能拿到收益,反而是各种“高级技巧”在真实负载下经常白忙一场。我平时排查的顺序就是先从这几个朴素点过一遍,过完还没解决,再去动调度参数和内核配置。

关于参数选择还有个实用建议:任何和核数相关的配置(线程池大小、队列长度、批量阈值)都不要写死,从 CPU 数量推出来。但推导的时候要谨慎,比如线程池大小设成核数加一是为了覆盖偶发阻塞,设成核数乘二在纯计算场景下就是纯粹的上下文切换开销。这条线怎么划,最终还是要靠压测曲线说话,别看别人配多少就抄多少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询