并发编程里有一句流传很广的话:"并发的问题,一半是数据竞争,一半是头铁不上锁。"当你决定给共享资源加锁,摆在面前的第一道选择题往往就是:用哪种锁?互斥锁、信号量、读写锁、自旋锁……名字一大堆,各有各的脾气。这堂课我们只把其中一位成员的脾气彻底摸透——自旋锁(spinlock)。
为什么单挑它?因为它是所有锁里最"容易上手、最容易写错"的那一个。它的接口简单到只有两个函数:加锁、解锁。但就这两个函数背后,藏着一整套关于 CPU、原子操作、内存序、公平性、优先级反转的知识。很多人不明就里地在临界区里跑了一个 sleep,或者干脆在单核机器上自旋,结果把 CPU 打满、程序卡死,却始终想不通"锁明明没问题,问题到底出在哪"。
这篇文章我们只讲一件事:把自旋锁讲透。从它到底是什么、和别的锁差在哪,到你该不该用它、怎么在 Linux 里用官方接口,再到怎么在用户态"自己动手从零写一把"自旋锁。每一个知识点我都配了可编译运行的代码,一步步拆给你看。看完你会发现,自旋锁不是"难"的问题,而是一个"你以为你懂了其实没懂"的问题——而我今天要帮你把最后那点"其实没懂"补上。
自旋锁与忙等待:它到底在"等"什么
先给一个最朴素的定义。自旋锁是一种用在多线程之间的同步机制,用来保护共享资源、防止多个线程同时访问而产生数据竞争。
这句话里"多线程""共享资源""同步"你应该都听过,关键在"自旋"这两个字上。自旋(spin)的意思是:当一个线程发现锁被别人占着的时候,它不睡觉、不退出,而是像一个转圈的人盯着门锁一样,一遍又一遍地回头检查"锁是不是空出来了"。这种"反复循环检查、绝不主动让出 CPU"的等待方式,就叫忙等待(busy waiting),也叫忙轮询。 "自旋"和"忙等待"在这里基本是一对同义词——它在循环里打转,CPU 被它占着忙得团团转,所以叫"忙"。
好,现在你把视野放到一个具体的场景里。假设你开了一家小票务窗口,柜台上摆着最后 1000 张票,四个售票员同时卖。每个售票员干的事是一模一样的:"看看还有没有票,有就卖一张、把余票减一"。这里"余票数"就是共享资源,四个售票员就是四个线程。麻烦在于:两个售票员可能在同一瞬间看到了"还剩 100 张",然后各自卖出一张,结果余票被减了两次,对外却只表现出消耗了一张。这就是并发里的经典事故——数据竞争(data race)。
锁的职责就是把"看有没有票 + 卖一张 + 减一下"这整段操作包起来,让任何时刻只允许一个人进来执行,其他人只能在门外等。而自旋锁规定的"等法"特别简单粗暴:门口放一个牌子,谁要进就先把牌子摘下来(置成"已占用"),摘不下来就站在门口不停伸手去掰,直到牌子能掰下来为止。 这个"不停伸手去掰"的动作,就是自旋。
为了把这些话落到代码里,你只要记住下面这一个最最小的自旋锁原型,它虽然不是完整实现(真正的实现我们要在后面几节慢慢补全),但它的骨架已经把"自旋"两个字演得清清楚楚:
// 一个示意性的自旋锁骨架,先看逻辑,细节后文逐行讲
// 共享的标志位:0 表示锁空闲,1 表示锁被占用
int lock_flag = 0;
void spin_lock(void)
{
// 不停的"摘牌子":尝试把 lock_flag 从 0 变成 1
while (/* 尝试占用失败,说明锁被占着 */) {
// 什么都不做,就在这里打转,一遍遍重试
}
}
void spin_unlock(void)
{
// 把牌子放回去,恢复成空闲
// lock_flag 置回 0
}你看 spin_lock 里那个 while 循环,就是"自旋"的肉身——只要拿不到锁,它就卡在循环里一遍遍重试。spin_unlock 就是把标志置回 0,告诉外面"门开了"。现在问题来了:为什么加锁只需要"把一个 int 从 0 改成 1",听上去这么简单,却需要专门研究一节课?因为这里藏着一个致命细节:"检查标志位 + 把它改成 1" 这两步,必须是一瞬间完成、不能中途被打断的。 如果先检查发现是 0(空闲),正准备改成 1 的时候,另一个线程也检查了一遍发现也是 0,两个人都认为自己抢到了锁,锁就形同虚设了。这个问题,就是原子操作要解决的核心矛盾。
原子操作:为什么"看一眼再改"会翻车
前面说的"检查后再修改"这个隐患,在操作系统原理里有个专门的名字,叫竞态(race condition)。注意区分两个词:刚才我们说的"数据竞争"指两个线程同时读写同一块数据、顺序不确定;"竞态"泛指"结果取决于线程执行的先后次序"。它们经常被混着提,但严格说,数据竞争是竞态的一种常见诱因。我们这里遇到的,就是典型的竞态:整个"读-改-写"三步必须打包成不可分割的一步。
为什么不可分割?想象一下,两个线程 T1 和 T2 同时在多核 CPU 上运行,它们几乎同时执行下面三行散开的代码:
// 这三行"拆开写"的做法是错的,仅用于演示为什么需要原子性
if (lock_flag == 0) { // 第 1 步:读——看到空闲
lock_flag = 1; // 第 2 步:改成占用
} // 第 3 步:进入临界区
// 危险:T1 和 T2 可能同时通过第 1 步的"检查",都认为锁是空闲的在多核机器上,T1 和 T2 有可能在你的 if (lock_flag == 0) 这一行"同时"通过。因为这一行只是"读",两个核可以并行地读同一个变量、得到同样的结果——"空闲"。然后两个线程都认为锁是自己的,双双走进去,锁就彻底失效了。问题不在"读",而在"读了之后到写之间,中间隔了一段能给对手钻空子的窗口"。
解决的办法有两个层面:
- 软件层面用锁来保护这个标志位——但这就成了"用锁去构造锁",循环依赖,行不通。
- 硬件层面提供原子指令——CPU 直接提供一个"读-改-写"一步到位的指令,让操作系统和语言层面能基于它搭出锁来。这条路才是正解。
于是我们引出自旋锁的地基:原子操作(atomic operation)。所谓原子,就是"不可分割"——是指一个操作要么完整执行完,要么完全不执行,中途不可能被插入任何别的操作,别的线程绝对看不到它的中间状态。 你可以把"原子"{:data-lang="c"} 理解成"它执行时整个世界都被按了暂停键,至少对本操作涉及的这块数据,不会有第二个人插进来干扰"。
C11 标准在 <stdatomic.h> 里提供了一套跨平台的原子操作接口,其中就有一个专门用来实现自旋锁的宝贝,叫 atomic_flag,以及配套的两个函数:
atomic_flag_test_and_set:旧称"测试并设置",缩写 TAS(Test-And-Set)。它的动作是"先把当前标志位原来的值取出来,再把它置成 1,最后把拿到的旧值返回",三步一气呵成、原子执行。atomic_flag_clear:把标志位清回 0。
你品一下 TAS 的精妙:它返回的是"旧值"。如果旧值是 0,说明这锁本来空闲,而现在它已经被我置成 1 了——我抢到了。如果旧值是 1,说明锁早被别人占着,我没抢到。"检查 + 占用"被压缩成一条原子指令,对手再也找不到那个钻空子的窗口了。 这就是我们能安全实现自旋锁的根本原因。
自旋锁的完整工作过程
现在我们把地基铺好了,可以拼出一把真正能跑的自旋锁了。这里先定义两个马上就要反复出现的术语:
- 临界区(critical section):指一段"同一时刻只允许一个线程进入"的代码区域。自旋锁的任务,就是保证一次只有一个人能进临界区。
- 锁竞争(lock contention):指多个线程同时想抢同一把锁、争抢得不可开交。竞争越激烈,自旋的时间往往越久。
一把成熟的自旋锁完整工作过程分四步:
- 尝试获取(acquire):执行 TAS,把标志从 0 试改成 1。
- 判断结果:若 TAS 返回 0(旧值是空闲),说明锁到手,直接进入临界区;
- 自旋等待:若 TAS 返回 1(旧值是被占),说明锁被别人拿着,于是回到第 1 步继续循环,直到某一刻抢到为止;
- 释放(release):离开临界区时,用
atomic_flag_clear把标志清回 0,让门口的人有机会进来。
原课件里有一版浓缩的伪代码,大致长这样(我把它规范化、加了可用头文件与注释),你边看边把上面四步对上号:
// spin_tas.c —— 用 C11 原子标志位实现的一把可运行的最小自旋锁
#include <stdio.h> // 用到了 printf
#include <stdatomic.h> // 提供 atomic_flag、TAS、clear 等原子操作
#include <pthread.h> // 用到了 pthread_create、pthread_t
// ATOMIC_FLAG_INIT 表示把标志初始化成 0(空闲状态)
static atomic_flag g_spin = ATOMIC_FLAG_INIT;
// 加锁:核心是原子指令 atomic_flag_test_and_set
void civil_add_lock(void)
{
// 不断尝试:把标志置 1 并取回旧值
while (atomic_flag_test_and_set(&g_spin)) {
// 若返回旧值非 0(已被占用),就空转等待,循环重试
}
// 跳出循环 = 本期某次 TAS 返回了 0,锁已成功归我
}
// 解锁:把标志清回 0,代表空闲
void civil_unlock(void)
{
atomic_flag_clear(&g_spin);
}
// 一段测试外壳:打印提示,验证能编译、能链接、流程完整
int main(void)
{
printf("最小自旋锁演示环境就绪。\n");
civil_add_lock(); // 在单线程里自测加锁解锁
civil_unlock();
printf("加锁解锁往返成功。\n");
return 0;
}上面这段代码,在 Linux 上可以用 gcc spin_tas.c -pthread -latomic -o spin_tas 编译(-latomic 是为原子库链接,部分平台可省略;-pthread 一定得加)。运行后它会打印两行提示,表示最低限的自旋锁已验证可用。这一版虽然能被 main 拉起来跑,但它还只是个"演示壳",真正有价值的并发演示,我们放到"售票系统"那一节再来。
讲到这里,你大概已经能回答"自旋锁的原理是什么"了。但一位优秀的程序员不会只问"怎么实现",还会追问两个更尖锐的问题:它凭什么能这么快?它又凭什么会翻车? 下面两节,我们就把自旋锁的优点和缺点,一条条掰开看。
自旋锁的优点:把上下文切换彻底省掉
要说自旋锁最大的价值,就得先弄明白别的主流锁(比如互斥锁、信号量)在"等锁"时做了什么。以互斥锁(mutex,mutual exclusion,互斥)为例:当一个线程发现 mutex 被别人占着,它会进入睡眠——把自己从"运行队列"里摘下来,背后的 CPU 转去做别的线程,直到锁释放、有人把睡着的它重新叫醒,它才回来继续抢锁。
这个"睡下去又被叫醒"的过程,操作系统要干一大堆活:保存当前线程的现场(寄存器、栈指针、程序计数器),把它从运行队列挪到等待队列,再挑一个别的线程切换上来、恢复它的现场。这一整套"切换正在运行的线程"的动作,叫线程上下文切换(context switch)。上下文切换不是免费的,它要花几百到几千纳秒,而且切换期间 CPU 实际是"空转"在系统调度逻辑上,本身就在消耗资源。
自旋锁的高明之处,恰恰在于它反其道而行,拒绝睡觉。持锁的人很快就走,等在门口的线程干脆就站在原地一遍遍检查,一步也不挪、一次上下文切换也不做。这样带来两个直接好处:
- 低延迟:当临界区很短、锁很快会被释放时,等锁线程自旋几圈就进去了。比起"睡下再唤醒"那一整套开销,自旋的耗时要小得多,加锁解锁的整体性能更高。
- 减少系统调度开销:因为没有线程被阻塞、没有上下文切换,系统的调度器也就少了很多进出队列的工作,整体吞吐更稳。
一句话总结:自旋锁是靠"多烧一点 CPU 的空转,来换取"省掉上下文切换的延迟"。 一句话就把"优点"和"代价"都说了——这正是自旋锁这种设计的哲学核心。
当然你看出来了:这套哲学的成立前提,是"持锁时间足够短,短到自旋的浪费小于上下文切换的浪费"。一旦前提不成立,优点会立刻反转成灾难,这就是下一节要说的缺点。
自旋锁的缺点:CPU 打满与活锁
优点讲得越漂亮,缺点就越刺眼。自旋锁最被人诟病的两个缺点,我一次性讲透。
缺点一:CPU 资源被白烧
当锁被持的时间一长,等在门口的线程就会一直空转,把 CPU 燃到接近 100%。如果你用 top 命令看,会发现一堆 %CPU 高高挂起的进程,忙活半天其实一件事也没办成。更麻烦的是,自旋的线程越多、自旋越久,浪费的电和 CPU 周期就越可观。这就是"忙等待"最直白的代价:用脚趾头的力气,换来了 CPU 的燃烧。
原课件里一句话说得特别到位——"不合理的自旋锁使用,可能会造成 CPU 的浪费"。什么叫不合理?很简单:临界区里干了太久的事。 后面"适用场景与我们该避开的坑"那一节,我会教你怎么判断"多久算太久"。
缺点二:活锁——所有人都在转,谁也没法进
第二个缺点相对难理解,但非常经典,叫活锁(livelock)。它和**死锁(deadlock)**是一对容易搞混的兄弟:
- 死锁:大家都卡住不动,谁也别想继续,程序整体停摆。
- 活锁:大家都没停——线程还在"忙"、还在不停地检查、还在疯狂占用 CPU——但就是谁都进不了临界区,外部看过去进程一直"活着",实际上毫无进展。
为什么自旋锁会活锁?关键在于:多个自旋线程在极端竞争下,可能会互相"在你等我的时候我也在等你"地空转,谁也不让谁、谁也没占上谁。举个具体例子:线程 A、B、C 抢同一把锁,某个瞬间 C 抢到了,A、B 只能自旋。可如果 C 在临界区里性能下降、或者 A、B 疯狂轮询高到把持锁线程 C 挤得得不到 CPU 时间片(这在操作系统里叫优先级反转 / 优先级倒置(priority inversion),我们后面细讲),就可能出现"大家都在忙、但锁迟迟不被释放"的僵局。于是三个人都在烧 CPU,却谁都进不去——外部看是活着的,实际是卡死了。
要缓解活锁,业界通常给自旋加退避(backoff)策略:自旋一定次数后,暂停一下(比如调用 sched_yield() 主动让出 CPU、或者短暂地睡几个微秒)再继续抢,给持锁线程喘息的时间。这就好比门口排队,大家不是死磕着挤门,而是轮着退半步喘口气——反而更容易轮流进门。
把优缺点摆上台面
为了让你一眼记住,我把这两个维度的得与失列成一张对照表:
| 维度 | 自旋锁 | 互斥锁(sleep 型) |
|---|---|---|
| 等锁方式 | 忙等,就地循环空转 | 睡眠,让出 CPU,睡醒再抢 |
| 是否做上下文切换 | 不做,省调度开销 | 做,睡与醒各切换一次 |
| 临界区短时表现 | 快,延迟低(优选) | 慢,睡-唤醒开销大于实际工作 |
| 临界区长时表现 | 很差,CPU 狂烧(劣选) | 好,睡眠释放 CPU(优选) |
| 线程数关系 | 不阻塞,越多烧越多 CPU | 阻塞,越多只越多队列负担 |
| 主要风险 | 活锁、优先级反转、CPU 打满 | 上下文切换抖动、死锁(需正确使用) |
这张表你最好背下来——"临界区短用什么、临界区长用什么"的答案,全在效率一栏里。
自旋锁 vs 互斥锁 vs 信号量:该怎么选
到这里,你已经知道自旋锁和互斥锁是"一快一慢、各擅胜场"的两条路线。但很多书上还会再提一个物种:信号量(semaphore)。你得把它和上面两位的关系也理清楚,不然概念会打架。
信号量本质上是一个计数器,它和"锁"(不管是自旋还是互斥)不是一码事。锁解决的是"互斥——同一时刻只有一个人进",而信号量解决的是"资源总量——池子里最多允许几个使用者"。不过当计数器的上限设为 1 时,**二值信号量(binary semaphore)**在行为上就可以拿来当"互斥锁"用了。所以三者放到一张图里,大致是:
- 自旋锁:等锁 = 忙等不睡觉。适合极短临界区。
- 互斥锁(mutex):等锁 = 睡眠让出 CPU。适合中等到长的临界区,也是最通用的锁。
- 信号量:核心是"计数",范围从二值信号量(当互斥用)到多值信号量(当资源池用)。等它通常也是睡眠型。
一个区分的小技巧:你是想保护"一块共享数据",还是想限制"最多几个消费者"? 前者用锁,后者用信号量。两者并不互斥,很多系统里会同时出现。
现在核心问题来了:我到底该选谁? 给你一个够用的决策顺序:
- 先想清楚临界区有多长。 临界区极短(比如就 CAS 一次、改一个计数器、更新一个指针),自旋锁优先,因为它快、省调度。
- 临界区一旦涉及 I/O、系统调用、sleep、等待磁盘……一律别用自旋锁。 这种"几十微秒以上"的临界区,自旋锁会让 CPU 白白燃烧,必须上互斥锁(睡眠型)。
- 多核并行、追求极致吞吐且临界区根本不舍得睡的场景,才值得把手动自旋锁搬上舞台——这正是它在 Linux 内核、一些用户态高频框架里依然闪光的原因。
- 绝大多数默默写业务代码的你,默认请用互斥锁和条件变量。 只有你很清楚"这里不该睡"的时候,才动用自旋锁。
记住,自旋锁不是"更高级的锁",它是一把"特定前提下的特化武器"。 拿它去干长临界区的活,不是武器不好,是用法错了。
一组必须记住的边界与坑
这一节全是干货。把下面这几条刻进脑子,你对自旋锁的理解就从"会用"升级到了"懂它"。每条我都附了解释,务求你看完能说出"为什么"。
坑一:自旋锁"不睡眠"
自旋锁在等待时绝不主动进入睡眠,除非你自己在临界区里调用会睡眠的 API。这是它的第一条铁律,也决定了第二条坑:
坑二:自旋锁的临界区必须极短
这条是自旋锁能不能用的分水岭。临界区里如果有以下任何一样,自旋锁就"不配"了:
- 调用
sleep、usleep、nanosleep等睡眠函数; - 做磁盘读写、网络收发等 I/O(通常会阻塞在等待上);
- 获取另一把锁(一旦和别的锁嵌套,死锁风险也直线上升);
- 计算量很大的纯 CPU 工作(运算本身就该和锁分开)。
为什么?因为只要临界区一长,等在门口的自旋线程就在"无限期烧 CPU"。烧多久?取决于持锁线程跑到什么时候。你不设上限地烧,就是最容易进性能事故现场的那种写法。判断标准就是这句话:临界区的耗时,必须小到"自旋浪费"小于"上下文切换开销"才有意义。 如果你不敢确定,默认就用互斥锁。
坑三:单核系统上自旋锁会"死"——也可能没意义
这可能是最反直觉、也最值得讲清楚的一条。自旋锁在多核(SMP,对称多处理)上才真正有意义;在单核系统上,它非但没意义,还充满危险。
理由分两层:
先说"没意义"。自旋锁省的是"多核之间等待时不做上下文切换"的开销。可在单核上,同一时刻只有一个线程能跑。等锁线程在自旋,占用着唯一的 CPU,那持锁线程就"根本没有机会被调度上来执行",锁也就迟迟释放不了——这不是死锁,却会退化成一种很糟的僵局:等锁的人占着 CPU 不干活,持锁的人想干活却没 CPU。
再说"会死"。更严重的情况是优先级反转:假设低优先级线程 P1 持锁进了临界区,此时高优先级线程 P2 抢到 CPU 并开始自旋等锁。因为 P2 优先级高,调度器会优先让 P2 运行,P1 就被"踩下去"得不到时间片,永远没法释放锁;P2 又死磕着锁不放。结果高优先级的 P2 反而被"低优先级没机会执行"的 P1 拖到等死——高优先级线程反而被低优先级任务卡住,这就是经典的优先级反转。单核加上抢占式调度,这一幕极其容易发生。
所以内核对单核场景的处理是:单核上往往干脆把自旋锁实现成"关中断 / 禁抢占",干脆不让调度发生在持锁期间,而不是真的去自旋。而你写用户态程序时要记住:如果你跑在一颗核的机器或容器里,自旋锁基本就是错的选择,选互斥锁更安全。
坑四:竞争激烈时,朴素自旋锁的不公平与饥饿
我们后文讲的 TAS/CAS 自旋锁实现,属于"抢占制"的不公平锁——谁手快谁抢到,没有任何排队机制。在这种锁下,某个幸运线程可能连续很多次抢到锁,而另一名倒霉线程可能一直抢不到,这种现象叫饥饿(starvation):它不是死锁(程序还活着),但某个线程被"饿死",永远得不到资源。后面我们会有专门一节讲公平性,先在这里埋个引子。
坑五:用户态手写自旋锁,绕不开"内存序"
最后埋一个大坑给你——用户态写自旋锁,光有原子指令还不够,还要处理内存序(memory order),否则在你的构建优化/特定架构下可能悄悄出错。这属于"用户态无锁需格外小心"的经典主题,我们专门用一整节来讲。这里先记住:原子不等于同步完整,锁的逻辑成立还依赖"内存可见性"这一层保证。
手写一把自旋锁:TAS 实现
好了,理论够了,我们真的动笔。前文演示壳讲的是 atomic_flag(TAS)路线,这节把它做完整、讲透彻,下节再讲 CAS 路线,两种主流实现我都给你配齐。
TAS(Test-And-Set,测试并设置):一条原子指令,把标志的位置成 1,同时返回它原来的值。你已经知道怎么用了,这里我们要把代码补上关键的内存序参数和自旋时的 CPU 提示,让它既正确又高效。
先看脑图式的正确性关键:一个人进临界区后写数据,另一个人拿到锁后要"看得见"这些写入。因此:
- 拿锁必须携带**acquire(获取)**语义——它保证"我拿到锁之后"已执行的一切读写,都能看到"持锁线程释放锁之前"写入的数据;
- 放开锁必须携带**release(释放)**语义——它保证"我释放锁之前"写入的数据,对下一个成功拿到锁的人可见。
atomic_flag_test_and_set 和 atomic_flag_clear 的"裸版本"默认就带这套语义(等价于 acquire/release),所以最朴素写法是安全的。但为了让你看清 CPU 如何省力,我再给一个带显式 explicit 和"自旋时让位"优化的版本:
// spin_tas_full.c —— TAS 路线,带内存序与 CPU 自旋优化
#include <stdio.h> // printf
#include <stdatomic.h> // atomic_flag / memory_order_acquire / memory_order_release
#include <pthread.h> // 线程创建
// 锁:flags;同时增加一个"自旋计数",纯粹为了演示 how
static atomic_flag g_lock = ATOMIC_FLAG_INIT;
// x86 的 PAUSE 提示,便于 CPU 让出自旋的空闲周期;非 x86 也无妨(空实现)
static int cpu_relax(void)
{
// 在 x86/amd64 下,这段会被编译成 pause 指令,降低功耗、提高抢锁性能
return 0;
}
// 拿锁
void my_spin_lock(void)
{
// 循环尝试 TAS;acquire 语义保证拿到锁后能看到持锁者释放前的写入
while (atomic_flag_test_and_set_explicit(&g_lock, memory_order_acquire)) {
// 锁被占用:什么都不做,自旋;偶尔让 CPU 喘口气、减功耗
(void)cpu_relax();
}
}
// 放锁
void my_spin_unlock(void)
{
// release 语义保证:我释放锁之前写入的数据,对下一个拿锁者可见
atomic_flag_clear_explicit(&g_lock, memory_order_release);
}
// 一段自测:单线程里收到信号触发,加锁解锁来回跑一遍
int main(void)
{
printf("TAS 自旋锁已就绪。\n");
my_spin_lock();
my_spin_unlock();
printf("自旋锁往返 OK。\n");
return 0;
}atomic_flag_test_and_set_explicit(&g_lock, memory_order_acquire):带显式内存序的 TAS。返回旧值,非 0 说明仍被占用。atomic_flag_clear_explicit(&g_lock, memory_order_release):带显式内存序地清 0,代表释放。cpu_relax()里那行return 0;是占位;在真实内核/库中它是asm volatile("pause"),用于降低自旋时对流水线和功耗的冲击。我们这里不写汇编,只留一个"扩展点",你也完全可以保持空循环。
这段代码在 Linux 下依旧 gcc spin_tas_full.c -pthread -latomic -o spin_tas_full 即可编译。它自己不能验证"并发正确性"——真正的并发验证,请跳到后面的"售票系统"一节,那里会用真线程去压测。
手写一把自旋锁:CAS 实现
TAS 是"置 1 并返回旧值",只能简简单单表示"锁状态"这一个位。如果我们要更通用的锁(比如锁里还有状态、版本、等待者计数),就需要更通用的原子原语。**CAS(Compare-And-Swap,比较并交换)**就是那个更通用的家伙。
CAS 的语义是:把一个内存位置的值和我给的一个"期望值"比较,如果相等,就把它换成我给的"新值",整个动作原子完成;无论成不成功,都会把当前位置当前的"实际值"回填到我的结果里去。 一句话记法:"如果它还是我期望的样子,我就把它换掉;否则告诉我现在是什么。"
CAS 的自旋锁做法:锁是 0 空闲、1 占用。我期望它就是 0,想把它换成 1。CAS 成功,说明它确实是 0、我已经把它设成 1 了——锁到手;CAS 失败,说明它本来就是 1(别人占着),回填出来的实际值还是 1,自旋重试。
C11 里有两个 CAS 变体,注意别写混:
atomic_compare_exchange_strong:强版本,要么成功、要么明确失败;atomic_compare_exchange_weak:弱版本,即使是理应成功的"值相等"情形,也可能"假失败"(返回一个失败,但没改变值)。它在某些平台上性能更好,所以自旋锁这种"愿意反复重试"的场景,弱版本再合适不过——反正失败了就再试一次。
写的时候唯一要留神的坑是:CAS 在失败时会自动更新 expected 为你传入的那个地址里的当前值,所以你下一次循环想继续"期望它变成 0"的话,必须手动把 expected 重新赋回 0,否则第二次比较就带错了期望值。我把这个细节在代码里用注释标出来,你一看就懂:
// spin_cas.c —— CAS 路线,带完整内存序
#include <stdio.h> // printf
#include <stdatomic.h> // atomic_int / atomic_compare_exchange_weak_explicit
#include <pthread.h> // 线程
// 锁状态:0 空闲,1 占用
static atomic_int g_lock = 0;
void my_spin_lock(void)
{
int expected = 0; // 我希望锁当前是空闲(0)
// 尝试把 g_lock 从 0 换成 1;acquire 语义保证拿到锁后能看到别人写的数据
while (!atomic_compare_exchange_weak_explicit(&g_lock, &expected, 1,
memory_order_acquire,
memory_order_relaxed)) {
// 走到这里说明 CAS 失败:要么锁本来就不是 0,要么是弱版本的一次假失败
// 关键:把 expected 重新设回 0,再次尝试
expected = 0;
// 空循环自旋,顺便可以让 CPU 喘气(可加 pause)
}
}
void my_spin_unlock(void)
{
// release 语义:我释放前写入的数据对下一个拿锁者可见
atomic_store_explicit(&g_lock, 0, memory_order_release);
}
int main(void)
{
printf("CAS 自旋锁已就绪。\n");
my_spin_lock();
my_spin_unlock();
printf("CAS 自旋锁往返 OK。\n");
return 0;
}强调两个 expected = 0; 前的那行注释:失败分支的 memory_order_relaxed 传的是"失败时"的内存序。 因为失败时我们只是读到"当前确实是 1",没有产生新数据同步需求,用最宽松的 relaxed 就够,这比一上来就用 acquire 更高效。这也是 C11/explicit 版本存在的意义:给成功和失败两个分支各自最合适的内存序。
到这里,TAS 和 CAS 两条手写路线就都齐了。看着是不是挺完整?但我要泼一盆冷水:这段代码在 x86 上大概率能跑对,在其它弱内存模型架构上却不一定。 原因就是我们下一节要正面攻克的主题——内存序。
用户态无锁,绕不开的内存序
这里要小心,我讲的不是"上锁",而是"无锁习惯"里最隐晦的那层砖。前两节我没细说的 memory_order,正是这层砖的名字。
内存序(memory order):指的是"在多线程/多核环境下,某个线程对内存的写入,何时、以什么顺序对另一个线程可见"的这套规则。 为什么同样写上锁能跑对?因为锁自带 acquire/release 语义在背后替我们做了"可见性"的保证。而如果你手写自旋锁还"默认安全",那也仅仅是因为你恰好用了 C11 里test_and_set 的裸版本(它隐含 acquire/release);一旦你把内存序参数改成了 relaxed,却又没补上其它同步手段,那么在弱内存模型(weak memory model,如 ARM、RISC-V)的 CPU 上就可能出怪事。
你必须理解、也必须告诉自己在做什么:自旋锁本身,只是一扇"保证只有一个线程能进的窄门"。它并不自动保证"你进了门,就一定看得见别人进门之前写的数据"——除非你把 acquire/release 用对。
举一个会翻车的例子。假设你在 my_spin_unlock 里忘掉了 release,随手写成一个 relaxed 的 atomic_store;同时我们代码里残留着锁外的普通写。在某个弱内存序的核上,可能出现这样的乱序:A 线程往共享变量写了个新值,随后释放锁;B 线程自旋到锁、读共享变量,却读到了旧值。 因为 relaxed 没有建立"先于(happens-before)"关系,CPU 完全可以在内存层面把 A 的普通写在锁释放之后才真正落地——B 抢到锁却看不到它。
于是自旋锁在你的机器上"时好时坏、偶发 bug",这种问题极难排查,因为它不崩溃、不报错,就是偶尔给你一个错值。教训就一句话:手写任何自旋锁 / 无锁结构,务必在"拿锁处用 acquire、放锁处用 release",并且临界区内的数据访问必须由这层关系覆盖。 这不是小题大做,而是多核真并发的硬性规矩。
顺带一提,memory_order 这个词对不同平台的强弱也有差异:x86 属于强内存模型(strong memory model),多数时候很宽容,很多"松散写法"在 x86 上碰巧能跑,这特别容易给人"我代码没问题"的错觉;而 ARM/RISC-V 是弱内存模型,可重排的行为更多、更容易暴露 bug。摸过一遍,你就知道为什么很多并发问题"换台机器才现原形"了。
Linux 提供的自旋锁:pthread_spin_* 接口
你自己手写的代码,Linux 的 glibc 早就帮你封装好了。glibc/pthread 提供了一组专门的自旋锁接口,头文件是 <pthread.h>,一共五个函数:
| 函数 | 作用 |
|---|---|
pthread_spin_init(lock, pshared) | 初始化自旋锁 |
pthread_spin_destroy(lock) | 销毁自旋锁 |
pthread_spin_lock(lock) | 加锁(自旋等) |
pthread_spin_trylock(lock) | 尝试加锁,立即返回(不等待) |
pthread_spin_unlock(lock) | 解锁 |
关于 pthread_spin_init 的第二个参数 pshared,必须单独说清楚,因为很多人栽在这:
PTHREAD_PROCESS_PRIVATE(值 0,默认):锁只在本进程内部的线程之间共享。绝大多数场景用这个。PTHREAD_PROCESS_SHARED:锁可以在多个不同进程之间共享,前提是这把锁本身被放在了两个进程都mmap映射到的共享内存里(比如shm_open出来的一段映射)。如果你没有把锁放在共享内存里,却传了这个值,跨进程语义不成立,容易踩坑。
trylock 是它的一个贴心用法:它不进入自旋,立即试一次、成功返回 0、不成功返回 EBUSY。你可以"先试试、试不到就去干点别的,稍后再试",从而避免长时间烧 CPU。这在"轮询式"高并发里很有用。
这些接口的返回错误码很重要:
pthread_spin_lock、pthread_spin_unlock:成功返回 0;参数非法返回EINVAL;deadlock情形可能返回EDEADLK。pthread_spin_trylock:占用时返回EBUSY(不是死锁)。
配合一份标准用法,你会看到它们的正确姿势:
// pthread_spin_usage.c —— pthread 官方自旋锁的正确打开方式
#include <stdio.h> // printf / perror
#include <pthread.h> // 自旋锁与线程
#include <errno.h> // 错误码
// 全局共享计数器
static int g_count = 0;
// 一把 pthread 自旋锁
static pthread_spinlock_t g_sp;
void *worker(void *arg)
{
// 每个线程拿锁、累加、放锁,反复做
for (int i = 0; i < 100000; i++) {
// 错误码必须检查,别想当然成功
int r = pthread_spin_lock(&g_sp);
if (r != 0) {
perror("pthread_spin_lock");
return (void *)1;
}
g_count++; // 临界区:只有极短的一条累加,适合自旋锁
pthread_spin_unlock(&g_sp);
}
return NULL;
}
int main(void)
{
// 初始化:进程内共享
if (pthread_spin_init(&g_sp, PTHREAD_PROCESS_PRIVATE) != 0) {
perror("init");
return 1;
}
pthread_t t[4];
for (int i = 0; i < 4; i++)
pthread_create(&t[i], NULL, worker, NULL); // 起 4 个线程
for (int i = 0; i < 4; i++)
pthread_join(t[i], NULL); // 等所有线程结束
printf("g_count = %d(期望 400000)\n", g_count);
pthread_spin_destroy(&g_sp); // 用完记得销毁
return 0;
}编译:gcc pthread_spin_usage.c -pthread -o spin_usage。跑起来你会看到 g_count 稳定等于 400000——四个线程、每个累加 10 万次,由于锁保护,最终结果严丝合缝。这份代码的临界区非常短(就一次 g_count++),正是自旋锁最如鱼得水的场景。想体验"错误示范"的同学,可以把 pthread_spin_lock 注释掉几个,立刻就能看到丢掉的累加——那正是无保护共享变量的现场。
综合演示:用自旋锁保护一个售票系统
原课件里那个"卖票系统"是个极好的教学案例,因为它同时展示了"问题在哪"和"怎么修"。我们先把"不带锁"的版本写出来,你会亲眼看见它"卖超票";再给它套上锁,看它恢复正常。
裸奔版:数据竞争现场
// ticket_race.c —— 不加锁,等待被数据竞争撕碎
#include <stdio.h> // printf
#include <unistd.h> // usleep,模拟售票耗时
#include <pthread.h> // 线程
static int g_ticket = 1000; // 共享的余票
void *seller(void *arg)
{
char *id = (char *)arg; // 谁在卖
while (1) {
// 危险地带开始:读 g_ticket 与改 g_ticket 之间没有锁
if (g_ticket > 0) {
usleep(1000); // 模拟"出票"耗时,放大竞态窗口
printf("%s sells: %d\n", id, g_ticket);
g_ticket--; // 卖出一张
} else {
break; // 票卖完了
}
// 危险地带结束
}
return NULL;
}
int main(void)
{
pthread_t t1, t2, t3, t4;
// 四个售票员同时上阵
pthread_create(&t1, NULL, seller, (void *)"thread 1");
pthread_create(&t2, NULL, seller, (void *)"thread 2");
pthread_create(&t3, NULL, seller, (void *)"thread 3");
pthread_create(&t4, NULL, seller, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
printf("最终余票:%d(应为 0)\n", g_ticket);
return 0;
}跑几遍你大概率会看到"最终余票:一个 0 以外的小负数",比如 -3。为什么会卖出超过 1000 张?因为 if (g_ticket > 0) 的检查和随后 g_ticket-- 之间,被 1 毫秒的 usleep 插出了巨大的窗口;期间另一个线程也通过了 >0 的判断,两个人都认为自己卖的是"最后那张它能卖到的票",各自减一,余票就透支了。这种 bug 表现随机、极难复现,正是数据竞争最可恶的地方。
上锁版:用 pthread 自旋锁止血
修法很简单:把整段"判断 + 卖票 + 减一"包进自旋锁。注意我们故意在临界区里留了那个 usleep——真实项目里这样写自杀式慢临界区不可取,但这里是教学,它能逼你亲眼看到"自旋锁保护下,计数终于正确了"。正确的工业写法我会在后面补充说明。
// ticket_spin.c —— 用 pthread 自旋锁保护临界区
#include <stdio.h> // printf / perror
#include <unistd.h> // usleep
#include <pthread.h> // 线程与自旋锁
#include <errno.h> // 错误码
static int g_ticket = 1000;
static pthread_spinlock_t g_sp; // 一把自旋锁,保护 g_ticket
void *seller(void *arg)
{
char *id = (char *)arg;
while (1) {
if (pthread_spin_lock(&g_sp) != 0) { // 先抢锁
perror("spin_lock");
return (void *)1;
}
// —— 进入临界区,同一时刻只允许一个售票员 ——
if (g_ticket > 0) {
usleep(1000);
printf("%s sells: %d\n", id, g_ticket);
g_ticket--;
} else {
pthread_spin_unlock(&g_sp); // 票已卖完,先放锁再退
break;
}
// —— 退出临界区 ——
pthread_spin_unlock(&g_sp); // 放锁
}
return NULL;
}
int main(void)
{
if (pthread_spin_init(&g_sp, PTHREAD_PROCESS_PRIVATE) != 0) {
perror("spin_init");
return 1;
}
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, seller, (void *)"thread 1");
pthread_create(&t2, NULL, seller, (void *)"thread 2");
pthread_create(&t3, NULL, seller, (void *)"thread 3");
pthread_create(&t4, NULL, seller, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
printf("最终余票:%d(应恰好为 0)\n", g_ticket);
pthread_spin_destroy(&g_sp);
return 0;
}编译同前:gcc ticket_spin.c -pthread -o ticket_spin。现在无论跑多少遍,最终余票都稳定为 0。你要注意那份"破局的微小细节":在 else 分支里 break 退出前必须先 unlock,否则会漏解锁,导致后续线程全部自旋死等。这是自旋锁使用中极其容易踩的坑——任何出口都要解锁,break/return/异常都要记得先 unlock。
顺带回答你心里的疑问:"临界区里有 usleep 还用自旋锁,不是作死吗?"——是的,这里故意如此。工业里正确的做法,是把"usleep 模拟重活"从临界区拆出去,或者直接把锁换成互斥锁。但作为教学片,它如实展现了"锁能保证数据一致性,但自旋锁面对长临界区会浪费大量 CPU"这两个并存的真相。你用 top 在运行中去观察,就能看到这种版本的 CPU 占用是把裸奔版甩开一大截的——那就是"为正确性付出的 CPU 租金"。
公平性问题:饥饿、ticket 锁与更复杂的设计
最后我们聊一个"朴素自旋锁做得不够好、却每个严肃系统都得面对"的问题:公平性。
概念先立住:一个锁所说的"公平(fairness)",大致指"后来的线程不会一直被前面的线程堵到饿死"。 前面我们写的 TAS/CAS 自旋锁都不公平——它们像"抢停的红绿灯",谁手够快、谁抢到,全看运气,没有任何"排队"机制。在极端竞争下,会发生饥饿(starvation):某个倒霉线程可能连续很多次都没抢到锁,尽管它在不停自旋、CPU 也没闲着——"抢占式锁"里的幸运儿和倒霉蛋,命运差别可以非常悬殊。
要治饥饿,经典的改进方案有两级:
第一级:Ticket 锁
Ticket 锁的思路就是把"抢"改成"排队"。 它有两个计数器:一个"发号器"(表示当前发到几号),一个"当前轮到几号"。每个线程来拿锁时,原子地拿一个号;锁的拥有者是"号恰好等于当前轮到号"的那位。等持锁者释放后,轮到号 +1,于是下一个号的人接管。某种程度上像银行取号:
- 好处:严格先到先得,公平,杜绝饥饿。
- 代价:为了实现"发号 + 轮换",需要两个原子位置,状态下更多的内存与指令;而且它还是自旋——每个取号的人仍在轮询"该轮到我了吗"。所以 ticket 锁治的是"公平",不治"自旋烧 CPU"。
第二级:队列锁(如 MCS 锁)
再进一步,就是 MCS(Mellor-Crummey and Scott)锁,它把每个自旋线程组织成一条双向链表队列,每个线程只"自旋检查自己在队列前一个节点是否轮到"——理想情况下,自旋的线程不会像 ticket 锁那样全体轮询同一个共享计数器,缓存未命中(cache miss)更少、可扩展性更好,还能优雅支持"把锁交给下一个线程"的移交。当然,代价是结构明显更复杂,用户态一般用不到,但它正是 Linux 内核里那些要求极高扩展性自旋锁的演进方向。
你不需要亲手写出各版本,但要有印象:朴素的 TAS/CAS 自旋锁图的是"极简 + 极快",公平性是它牺牲掉的东西;追求公平与可扩展,就要向 ticket / 队列锁的更高复杂度进发。 这也是为什么生产级的、被反复压测过的锁,往往都不是我前面那"十几行朴素的版本",而是一堆工程细节堆积的产物。
思考题:亲手检验你这一课的掌握度
下面的题我都附了详解。建议你先自己动脑想一遍,再看答案,收获最大。
思考题 1:自旋锁在处理器配备上有什么硬前提?为什么?
详解: 自旋锁真正的用武之地是多核(SMP)系统。因为只有多核,才会有"一个线程在核 A 上自旋、另一个持锁线程在核 B 上并行执行"这种真并行可能;自旋省下的"上下文切换"开销,也是在这种前提下才值得。在单核上,等锁线程自旋会占住唯一 CPU,持锁线程反而无法被调度上来释放锁,自旋非但没意义,还会造成阻塞与优先级反转风险(详见"坑三")。所以一句话:单核无意义且危险,多核且临界区极短,才适合自旋。
思考题 2:为什么自旋锁的临界区必须极短?临界区里 sleep 会怎样?
详解: 自旋锁等锁方式是"忙等",不睡眠。临界区越长,等在门外的一堆线程就烧越久的 CPU,但一件实事没干。若临界区里有 sleep/I/O,持锁线程会长时间占着锁不放,等待线程就无限期自旋,CPU 白烧到 100%,还可能进一步诱发活锁/优先级反转。判断标准:临界区耗时必须小于"自旋浪费"对"上下文切换开销"的权衡线。凡是涉及睡眠、阻塞、重计算的临界区,都该改用互斥锁,而不是自旋锁。
思考题 3:请问 TAS 和 CAS 这两种自旋锁实现,核心区别是什么?
详解: TAS(Test-And-Set)是一条"置 1 返回旧值"的指令,天生适合只用一个 bit 表达"锁状态"的场景,简单直接;CAS(Compare-And-Swap)是"比较并交换",能表达更通用的状态(带期望值/新值),可扩展性更强(ticket、MCS 都以 CAS 为地基)。在内存序细节上,CAS 用 atomic_compare_exchange_weak_explicit 还能给"成功"和"失败"两个分支分别指定内存序(实践上失败分支常用 relaxed 提效),这是 TAS 裸接口没有的灵活度。性能上二者在不同架构都各有说法,但语义表达能力 CAS 明显更强。
思考题 4:什么是朴素自旋锁的"饥饿"问题?ticket 锁为什么能解决?
详解: 朴素 TAS/CAS 自旋锁是"抢占式"、非公平的,谁能抢到全凭原子操作的手速。在强竞争下,可能有线程一直抢不到,长期得不到运行,这就是饥饿。Ticket 锁把策略换成"先来后到":每人原子拿一个递增号,持锁者是"轮到号=当前号"的人,释放后轮到号 +1,下一个号接力。因为号是公平递增的,每个线程最终总能轮到,从根上杜绝了"长期抢不到"的饥饿,实现了公平。
思考题 5:用户态手写自旋锁为什么要注意内存序?忽略会有什么后果?
详解: 原子操作解决了"互斥"(只有一个人能进门),但还要保证"可见性与顺序":拿锁者必须能看见持锁者释放锁之前写入的数据。实现上就是拿锁处用 acquire、放锁处用 release。如果改成 relaxed 且临界区又依赖这份可见性,在弱内存模型(ARM、RISC-V)的 CPU 上,可能出现"拿锁者读到旧值"的偶发 bug——不崩溃、不报错、偶尔错一次,极难排查。x86 因强内存模型常"碰巧"无事,更隐蔽地放大了这种风险。结论:没把握时就别去重排内存序,按规范的 acquire/release 老老实实写。
思考题 6:pthread_spin_trylock 和 pthread_spin_lock 有什么不同?各自合适什么场景?
详解: pthread_spin_lock 会自旋等待,直到拿到锁;pthread_spin_trylock 只试一次、立即返回——成功返回 0,锁被占返回 EBUSY。trylock 适合"拿不到锁就走开去干别的、稍后再来"的轮询式场景,能避免长期空烧 CPU(比如你同时有别的独立工作可做,不值得为这把锁死等);而假如临界区极短、你又确实必须马上用锁,pthread_spin_lock 让它自旋更快。实际取舍就是那句老话:你愿不愿意为这把锁"死磕着等",决定了用哪个。
思考题 7:售票系统里为什么会在 else 分支内先 unlock 再 break?
详解: 因为"票卖完退出"也是一个离开临界区的出口。自旋锁的使用铁律是所有出口都必须解锁——无论是正常 break、return、抛异常,还是被信号触发提前退出,都得先解锁。如果漏了 else 里的 unlock 就直接 break,下面永远没人放锁,其余线程将永久自旋,程序直接进死锁式的卡死。这也是把"任何出口都要 unlock"奉为纪律的原因。
到现在,一把自旋锁的里里外外就都被我们拆完了。回顾这一课,我们从"多线程抢共享资源需要锁"出发,认识了自旋与忙等待,明白了为什么"读-改-写"必须用原子指令,然后用 TAS 和 CAS 两种思路亲手实现了自旋锁,并特别强调了用户态最容易翻车的"内存序"问题;接着我们梳理了它"省上下文切换"的优点和"烧 CPU、易活锁、有饥饿"的缺点,给出了与互斥锁/信号量的选型原则,把单核、长临界区这些坑一一标记,最后端出了 pthread 官方接口和一份真实的售票系统完整演示。
我把这一课最值得带走的三句话再挂回你耳边:第一,自旋锁适合"多核 + 极短临界区 + 不愿睡眠"这三个前提;第二,临界区一旦出现 sleep、I/O、重计算,请立刻回头用互斥锁;第三,手写无锁结构,离了正确的内存序,再快的 CPU 也给不了你正确的答案。 下节课,我们不妨把视线从"锁"移到"无锁数据结构"——看看当临界区只剩一条 CAS,我们能不能进一步把锁也优化掉。你准备好了吗?
还没有评论 — 第一条由你来留。