☰
自旋锁原理与多核性能陷阱:从原子操作到MCS锁
2026/10/20 5:58:48 网站建设 项目流程

第37问:自旋锁 旋着旋着人就糊涂了

看到这个标题我愣了一下,这大概是我被问到过最多的一类问题。自旋锁从字面上理解特别简单:线程拿不到锁时,不睡,原地打转。可一旦你真正把它放进多核处理器、缓存一致性协议、内存模型里重新审视,就会发现"原地打转"四个字背后差不多覆盖了并发编程一半的硬核知识点。我不准备按教科书目录的顺序来讲,而是按我实际从"背概念"到"真正把它用明白"的路线来梳理,核心回答三个问题:自旋锁为什么快、为什么会翻车、什么时候该用它。

这篇文章也适合刚刚接触并发的初学者,你不需要先学会怎么写操作系统内核,只要知道线程、共享变量这些基础概念就能往下读。对于已经写过不少并发代码的朋友,后半部分的排查案例或许能帮你少走一次弯路,特别是那些你盯着代码盯到眼睛发酸也找不到原因的诡异性能问题。

1. 先搞懂锁与原子操作的老底

1.1 锁到底在解决什么问题

并发程序里最麻烦的一件事,就是多个线程同时修改同一份数据。假设两个线程同时对计数器的值执行加一操作,看起来只是"读一个数、加一、写回去"三步,但这两条线程会在完全相同的窗口期操作同一个内存地址。如果大家都是直接读写,最终结果就可能比预期少一两次,而且这种错误不是每次必现,只在特定调度时序下冒出来。它不至于让程序崩溃,但数值悄悄不对,排查时极其痛苦。

锁就是用来把这些并发操作串行化的。把"读改写"这段区域保护起来,同一时刻只让一个线程进入,其他线程必须等它完成。等待的方式有两种态度:一种是阻塞自己,让出 CPU,等锁释放后由系统唤醒——这是互斥锁;另一种是反复查看锁的状态,不睡,继续消耗 CPU——这就是自旋锁。

用大白话打个比方:互斥锁相当于取号排队之后去旁边坐着刷手机,叫到号了再回来;自旋锁则是一直站在柜台前盯着营业员的手,窗口一旦空闲马上冲上去。这里没有谁绝对更好的问题,要看站柜台和刷手机哪个代价更大。

我最初学并发的时候有个误区,以为"锁"必然和操作系统有关。实际上锁有好几个层次,操作系统级锁依赖内核调度,而用户态的自旋锁可能完全不进内核,仅仅依靠一条 CPU 原子指令就能完成互斥。这也是它最大的特点:省掉了上下文切换和线程睡眠唤醒的开销。代价是,它把 CPU 资源和硬件细节也一并压在了你身上。

1.2 自旋锁依赖的三种硬件原子指令

既然是站在柜台前反复观察状态,那"观察"和"抢"这两个动作就必须是原子的——不能说多个线程同时看到锁是空闲的,然后一起冲进去。所以自旋锁的根基是硬件提供的原子指令。这几条指令经常在底层实现里出现:

指令/操作作用典型用途
Test-and-Set (TAS)把某个内存单元设为 1,同时返回它原来的值最简单的自旋锁
Exchange/XCHG把寄存器与内存值互换,可视为 TAS 的另一种形态交换锁变量的值
Compare-and-Swap (CAS)只有当前值等于期望值时,才写入新值,并返回是否成功无锁编程、改良锁

以常见桌面 CPU 体系为例,类似 TAS 的指令会带总线锁或缓存锁前缀,保证"读旧值、写新值"这个操作不会被其他处理器打断。没有这条硬件保障,纯用普通赋值加 while 循环是无论如何做不出正确锁的。有不少朋友问过我:能不能直接用 volatile 的 if 判断来实现锁?我说这不行,因为 if 判断和后续赋值之间完全可以被插队,两个线程都可能抢到"最后一次"空档。

关于内存操作顺序还要提前打一个底:原子指令通常可以附带内存屏障效果,或者需要配合屏障使用。后面专门讲为什么锁住变量之后代码仍会出竞态,关键就在这一点上。现在先把"原子性"和"顺序性"当成两个互相独立的概念放在脑子里。

2. 三代自旋锁:从暴力抢占到排队叫号

把自旋锁理解成一个"仍在演进的算法类别",比把它当成固定 API 更准确。我们今天看到的各种自旋锁实现,光抢锁的方式就能差出几十倍性能。从演进路线看,大致可以分成三代。

2.1 第一代 TAS 锁:最朴素的忙等待

最直白的自旋锁,就是每次都调用原子写操作来尝试抢占:

// TAS 锁伪代码(C 风格) volatile int locked = 0; void lock(void) { while (test_and_set(&locked, 1) == 1) { // 自旋,继续尝试 } } void unlock(void) { locked = 0; }

test_and_set 的语义是:把 locked 设置为 1,同时返回它原来的值。如果返回 0,说明之前没人持有,这次抢锁成功;如果返回 1,说明锁已被别人占着,继续循环。

这个实现胜在简单、正确。但在多核机器上,它有一个致命成本:每个线程在每一次循环尝试中,都会往 locked 变量上执行一次原子写。即便锁已经被占住,所有等待线程也在持续发送原子写请求,把同一个内存地址反复改写。你可能觉得写同一个值没有区别,但在硬件层面,每一次写都会导致缓存行失效,迫使缓存一致性协议在系统内广播,所有核心都要同步状态。线程越多,总线上的消息越密集,整体执行速度反而被拖垮。

我曾在 8 核机器上用一个共享计数器做对比,TAS 锁在 8 个线程争抢下,比后面要讲的 TTAS 慢了好几倍。性能不是随着线程数增加而线性上去,反而往反方向恶化。所以现在的生产环境里,几乎没人直接用这种原始实现。

2.2 第二代 TTAS 锁:多一次普通读,性能天壤之别

TTAS 的英文全称是 test-and-test-and-set。改法很小,但背后的道理不浅:

// TTAS 锁伪代码 volatile int locked = 0; void lock(void) { while (1) { // 第一阶段:普通读,只观察 while (locked == 1) { // 自旋,只读不写 } // 第二阶段:仅在看起来空闲时,做一次原子抢锁 if (test_and_set(&locked, 1) == 0) { return; } } } void unlock(void) { locked = 0; }

关键区别在第一层 while 只是普通内存读,它会命中线程本地的缓存,不会向总线发出写请求。当锁被占住时,所有等待线程只是在本地不停读同一个已经被标记为"只读共享"的缓存行,几乎是零成本。直到锁被释放的那一瞬间,locked 变为 0,这个变化通过缓存一致性协议广播出去,各线程的缓存行失效一次,之后大家才有机会进入 test_and_set。真正的原子写竞争被压缩到锁释放前后的极短时间窗口里。

用大白话讲:TAS 是不停冲上去硬掰那个已经锁死的门把手,TTAS 则是先远远看一眼门把手的状态,锁着就站在原地观察,直到看见门把转动才冲过去。就多这么一个"远看"的动作,缓存一致性协议上的压力就少了一个量级。

我在实际工作中如果必须手写自旋锁,最低要求就是 TTAS 版本。TAS 版本只适合当教材讲解用例,直接拿到生产环境属于给自己埋雷。

2.3 第三代 Ticket 锁:引入公平性的排号机制

TTAS 还有一个隐蔽问题:当锁释放时,所有自旋线程同时醒来争抢,但只有一个能成功,抢不到的人又回到自旋。这叫"惊群效应"。低竞争时没什么感觉,线程多了就会反复引发缓存失效风暴。而且它不公平——后来者也可能恰好在释放瞬间抢到锁,已经等了很久的线程却一直拿不到。

Ticket 锁解决这个问题的方法非常朴素:把竞争变成取号排队。

// Ticket 锁伪代码 volatile int next_ticket = 0; volatile int now_serving = 0; void lock(void) { int my_ticket = atomic_fetch_add(&next_ticket, 1); // 取号 while (now_serving != my_ticket) { // 自旋等待叫号 } } void unlock(void) { atomic_fetch_add(&now_serving, 1); // 叫下一个 }

每个线程进入时先取一个不重复的递增号码,然后自旋等待自己的号码被叫到。持锁线程释放锁时,只有下一个号段的线程真正进入战斗,其余线程继续安静等待,不会全体一拥而上。公平性也有保障:先来后到,谁也别插队。

代价是它需要两个共享变量,而且原子递增本身有开销,在低竞争场景下 Ticket 锁不一定比 TTAS 快。但在线程数较多、竞争较激烈的多核环境里,它的稳定性和扩展性都要好不少。很多底层库和操作系统内核里采用的排号思想,追根溯源都是这一代实现。

3. 真正的 Boss:缓存一致性才是自旋锁的头号暗雷

很多人学到上面为止,还能用"排队、抢锁"的故事框架理解自旋锁。真正让人"旋着旋着就糊涂"的,往往是接下来这个话题。它也是程序从单核思维转向多核思维时最不习惯的地方。

3.1 多核缓存结构与 MESI 状态机快览

现代 CPU 每个核心都有自己的 L1 缓存,L2 可能是每核私有也可能是分组共享,L3 通常是多核共享。核心读写内存时不会每次都真的去主存,而是先查自己的私有缓存。既然每个核心手里都有自己的副本,那"同一个地址现在到底是几"就成了一个需要全局协调的问题。硬件依靠缓存一致性协议来保证:一个核心修改了某个缓存行,其他核心持有的同一缓存行副本必须失效或更新。

MESI 是最常见的状态机,记录每个缓存行是 Modified(已修改)、Exclusive(独占)、Shared(共享)还是 Invalid(失效)。简化为一个直觉模型:

  • 多个核心同时读一个缓存行时,状态是 Shared,大家都有副本;
  • 某个核心要写它,先把其他核心的副本标记为 Invalid,自己改成 Modified;
  • 其他核心再访问这个地址,发现缓存行已经是 Invalid,只好去向总线要新数据,这就是一次缓存未命中。

自旋锁的"自旋",本质上是大量线程反复读同一个共享缓存行。如果这个缓存行被反复改写,所有等待线程就会一次又一次经历 Invalid 和重新加载。缓存未命中的代价不可小看:L1 命中通常几个周期,走到 L3 或主存则可能几十到几百周期。竞争激烈时,锁变量所在的缓存行会是全系统广播的中心,性能瓶颈就在那里。

3.2 伪共享:一个比普通竞争更隐蔽的性能杀手

伪共享更阴险。它发生在两个线程各自访问完全不同的变量,但这两个变量恰好落在同一个缓存行内。每个线程实际上没有写对方的变量,但由于缓存行粒度太大,只要一方修改,整行副本都要失效,另一方被迫重新加载自己那个根本没变的变量。

看一个一眼就能明白的例子:

// 伪共享演示(伪代码) struct unaligned_data { int a; // 线程 1 只操作 a int b; // 线程 2 只操作 b }; // 注意:a、b 落在同一缓存行 struct alignas(64) aligned_data { int a; char pad[60]; // 填充,让 b 落在另一缓存行 int b; };

两个线程各改各的成员。用第一种布局,两个线程会互相拖累,每次写都导致对方缓存行失效;用第二种布局把 a 和 b 分到不同缓存行,性能可以提升数倍到十几倍。这个差异很可观,我在 8 核机器上验证过,两个线程各做几百万次自增,伪共享版本比隔离版本慢了一半还多。

这和自旋锁有什么关系?锁变量本身就是极热门的内存地址。如果锁变量周围再紧挨着同样高频读写的计数器、状态 flag,伪共享就会把"根本没有竞争"的业务场景也拖下水。所以分配自旋锁时不要贪那一点内存,把锁对象单独放,必要时用 alignas(64) 做缓存行对齐。听起来是一个不起眼的优化,真实项目里它可能直接决定延迟抖动曲线是平滑还是毛刺遍布。

3.3 终极版本:MCS 锁如何用队列根除缓存风暴

无论 TTAS 还是 Ticket,所有等待线程仍然盯着同一个共享锁变量地址自旋。只要一个线程释放锁,所有等待者都会感知一轮缓存失效。另一种思路是:让每个线程在自己的本地变量上自旋,锁释放时只通知下一个线程。这就是 MCS 锁的核心动机,它以两位计算机科学家的名字命名,也被称为排队锁。

MCS 锁的基本结构是一个队列链表。每个等待者把自己的节点挂到队尾,然后在自己的节点上自旋。前一个持有者释放锁时,把后一个节点的状态置为可获取,后一个线程在本地缓存行上看到变化,不再与其他等待线程共享热地址。这样每一时刻真正"热"的缓存行只存在于当前持锁者和下一个等待者之间,是一条单点通道,而不是一个全体广播的热点。

代价是实现复杂,稍有不慎就会引入链表操作上的 ABA 问题或内存序错误。在大多数业务系统里,我们不会真的手工实现一个 MCS 锁,但理解它的思路,能让你看懂为什么某些高性能库可以把锁相关的 cache miss 压得那么低。你也会明白,优化自旋锁的核心主线之一,永远是"减少共享写、分散热点"。

4. 自旋锁怎么用才对:场景、取舍、可复现实验

把原理说完,该聊实操了。很多开发者在原理上已经理解,但一遇到具体需求就开始犹豫:到底要不要用自旋锁?用的话会不会反而更慢?

4.1 什么场景才值得用自旋锁

自旋锁的最大优势是等待锁的时间很短,不需要进入内核切换线程。一次线程上下文切换动辄几十上百微秒甚至更多,如果临界区只有几十纳秒,用互斥锁的代价可能比临界区本身还大。想清楚这个数字关系,选型就顺理成章了。

比较典型适合自旋锁的场景:

  • 临界区只是几条指令级别的操作,比如计数器自增、链表头部插入、状态标志位翻转;
  • 并发线程数可控,程序基本跑在多核机器上;
  • 对延迟敏感,不能容忍线程睡眠唤醒带来的不可控调度延迟。

反过来,一旦临界区较长、可能涉及 磁盘 IO、内存分配、睡眠、系统调用,自旋锁就很不合适。因为自旋期间一直占着 CPU,如果其他线程和持锁线程被调度到同一个核心上,持锁线程根本轮不到执行机会,整个系统就陷入空转。这是自旋加忙等最可怕的放大效应。

还有一个常见疑问:单核机器上用自旋锁是不是更快?不一定。单核下自旋锁还有潜在死锁场景——如果持锁线程在临界区中被操作系统切走,自旋线程占住 CPU,持锁线程可能永远等不到恢复执行,程序直接卡死。所以自旋锁和抢占式调度之间的互动必须慎重对待。

4.2 自旋锁、互斥锁、原子操作怎么选

永远不要在看到"自旋锁快"之后,就到处把互斥量替换成自旋锁。我常用下面这张判断表来给自己泼冷水:

方案等待方式适用条件主要代价
原子操作(直接 CAS)无等待单个变量的读改写只能覆盖极简单场景
自旋锁忙等临界区极短、多核、竞争适中浪费 CPU、调试困难
互斥锁睡眠/唤醒临界区较长、可能阻塞上下文切换开销
读写锁分类等待读多写少实现复杂、写者可能饥饿
无锁结构重试+帮助严格验证过的算法正确性极难保证

选择原则就一句话:先测量。如果锁平均持有时间远小于线程切换成本,再考虑自旋锁;如果没有实测数据,我默认业务应用层先用互斥锁。很多项目把锁换到自旋锁之后吞吐并没有变好,因为瓶颈根本不在锁上,换一种等待方式只是换了张桌子继续排队。

4.3 一个能自己跑的性能实验

把原理落到代码上,下面这个实验很适合自己跑一遍。两个线程各做两千万次共享计数器累加,观察不同实现的时间差。

// 实验伪代码:共享计数器累加 #define N 20000000 volatile long counter = 0; spinlock_t lock; void worker(void *arg) { for (long i = 0; i < N; i++) { spin_lock(&lock); counter++; spin_unlock(&lock); } }

先跑单线程版本作为基线,再跑 2 线程、8 线程版本。你会观察到几个现象:单线程下自旋锁版本反而不如直接对普通变量累加,因为每次都有原子操作和同步开销;竞争变大后,自旋锁版本比无锁但错误的版本慢,但正确性有保证;如果把 TTAS 换乘起来跑,你会直观感受到锁的瓶颈往往不在抢到锁那一刻,而在所有线程反复自旋等待时共同制造的缓存一致性压力。

实验做完再对比互斥锁版本,你对"锁内代码越短,锁的等待开销越伤"这句话会有非常具体的体感。纸上谈兵永远不如一次亲手跑出来的数字。

5. 令人糊涂的高频翻车现场排查实录

这一节是我和身边同事真实踩过、或者围观过的坑。你可以按症状快速对照,很多问题表面上是自旋锁 API 用错了,实际上全是软硬件交互的系统性问题。

5.1 临界区里出现 IO 或睡眠,是谁在转圈?

场景:有人为了"提高性能"把所有锁都换成自旋锁,临界区里带着日志输出、网络发送甚至随手一个 Sleep。现象是程序占用 CPU 很高,吞吐却上不去,严重时直接卡死。原因很好解释——持锁线程在临界区里睡眠时,其他线程还在死命自旋,白白烧着核心。

解决办法分三步:第一步把日志和网络操作移到锁外;第二步检查临界区里有没有任何可能阻塞或睡眠的系统调用;第三步,如果确定无法避免阻塞,把这个锁换回互斥锁。我见过有人嘴硬说"就一行日志,没事",但那一行日志底层可能触发缓冲区刷新,特别是用户态打印到控制台时,延迟完全不可控。自旋锁最不适合的就是这种"看似微小但底层不可控"的操作。

5.2 内存屏障缺失:锁"看起来"拿了,代码却照样竞态

自旋锁用原子操作保护的是锁变量的更新,但它同时需要配套的内存屏障,保证临界区里普通变量的写入在释放锁之前对其他线程可见,也保证后续临界区里的读取能看到其他线程提交的数据。如果你自定义实现时只做了原子写,忘了加释放屏障,就会出现一种让人崩溃的现象:所有人明明按锁进入临界区,看起来互斥了,但另一个线程读到的还是旧值。

原因是编译器或 CPU 对普通读写进行了乱序重排,锁变量更新和普通数据更新顺序被打乱。记住这个准则:获取锁应提供 Acquire 语义,释放锁应提供 Release 语义。用 C11 的 atomic 库时,明确使用 memory_order_acquire 和 memory_order_release,不要图省事全部填 relaxed。这块是"纸面上对了,运行起来还是错"的高发地带。我排查过不少诡异竞态,最后发现锁本身没问题,屏障没做对。

5.3 优先级反转:高优先级线程被低优先级线程卡死

在带优先级调度的系统里,如果低优先级线程持有自旋锁后又被调度器切走,高优先级线程开始自旋等待它,但它根本没有机会完成临界区。高优先级线程空转占满 CPU,低优先级线程更得不到调度,系统看起来就是越紧急的线程越等不到资源。

这是自旋锁在实时任务里的经典陷阱。很多实时操作系统会要求临界区中禁止抢占,或者建议自旋锁配合优先级继承协议使用。普通应用层面,我通常建议在自旋过程中加退避策略:自旋若干次后主动让出 CPU,给持锁线程一个真正的执行机会。你可以在自旋循环里设置次数阈值,超过阈值就调用线程切换调度接口,避免无限空转。

5.4 递归加锁:自旋锁对重入说不

自旋锁通常不记录持锁线程是谁,更不支持重入。如果一个函数加锁之后,它自身或它调用的子函数又尝试获取同一把锁,立刻形成死锁:所有线程都在等这把锁,但持锁线程就是当前正在运行的这个,而它又在等自己释放,永远不可能完成。

排查技巧是看调用链里是否出现递归、回调、钩子这类间接重入。自己封装自旋锁的话,建议在结构体里加上持有线程 ID,开发期用断言检测重入。生产环境为了性能可以去掉,但开发期这个检查能救很多人。我在项目里遇到过回调函数列表里不显眼的一行调用,直接把锁死场景复现了三天,查出来之后大家都沉默了很久。

5.5 观察与定位:自旋锁是不是真瓶颈

不要靠猜。先抓 CPU 热点,看火焰图里到底哪些函数在消耗时间。如果热点集中在锁定区域的自旋等待循环,而非任务本身,再进一步确认竞争级别。

常用定位步骤:

  • 用性能剖析工具观察热点函数,比如快速采样进程热点,看高频退避集中在哪个符号;
  • 观察线程调度状态:大量线程处于 Running 状态但有效任务进度极低,基本就是忙等;
  • 对比不同线程数下的吞吐曲线:线程数增加反而导致吞吐下降,很大概率是自旋锁竞争或伪共享在拖后腿。

我之前调试过一套系统,表现为"线程一多就变慢"。一开始怀疑无锁队列出了问题,结果采样一看,热点全压在自定义自旋锁上。把实现从 TTAS 换成 Ticket 之后,吞吐曲线稳定多了。那次经验让我真正意识到,自旋锁不是一把锁,它是一个与硬件缓存、调度机制深度交互的算法。选型时只看锁的名气,不看这些周边因素,一定会踩坑。

最后分享一个我一直在用的土办法:手写任何自定义锁之前,先写个小测试把单核、双核、多核以及不同线程数下的耗时都跑一遍。别一上来就追求高吞吐,先保证正确性,再考虑缓存行对齐,最后才谈要不要换成 MCS 这类复杂实现。先把原子指令和缓存一致性这两条主线理顺,再回头看各种自旋锁实现,基本不会绕晕。希望这一篇能帮你少走几个弯,下次再遇到"高并发要不要自旋锁"的争论,你有足够的底气说出自己的判断。

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

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

立即咨询