在多线程程序中,只要多个线程可能同时访问同一份共享资源,就必须考虑并发安全问题 。常见做法是使用互斥锁 mutex,当线程拿不到锁时让线程阻塞并等待唤醒。
但还有另外一种思路:
与其让线程睡眠,不如让它暂时在 CPU 上循环等待,直到锁被释放。
这种同步机制就是 自旋锁(Spin Lock)。
自旋锁的核心特征不是"锁住资源"的方式不同,而是:
获取锁失败后,线程采用忙等待,而不是阻塞等待。
这使它特别适合临界区极短、锁很快就会释放的场景。
一、自旋锁定义
假设存在一把锁:
lock
我们可以用一个状态表示它是否被占用:
false:锁空闲
true :锁已经被某个线程持有
线程获取锁时可以理解为:
while (锁已经被占用)
{
// 什么也不做
// 一直检查锁的状态
}
// 获取成功
进入临界区
如果线程 A 已经拿到锁:
线程A
↓
持有锁
↓
执行临界区
此时线程 B 尝试获取:
线程B
↓
发现锁被占用
↓
继续检查
↓
继续检查
↓
继续检查
↓
......
直到线程 A:
unlock();
线程 B 才有机会获取锁。
因此,自旋锁中的"自旋"实际上就是:
while (...)
{
}
也就是忙等待(Busy Waiting)。
这种机制不会让等待线程立即进入睡眠,因此可以减少阻塞、唤醒和线程上下文切换带来的调度开销。

二、方法实现
最容易想到的代码可能是:
bool lock = false;
void Lock()
{
while (lock)
{
}
lock = true;
}
看起来好像没问题:
lock == false
→ 没人持有
→ 设置 true
→ 获取锁
但多线程环境下这段代码并不安全。
假设:
lock = false
线程 A 和线程 B 同时运行。
可能发生:
线程A:读取 lock → false
← 此时发生线程切换
线程B:读取 lock → false
线程B:lock = true
线程B:进入临界区
← 又发生切换
线程A:lock = true
线程A:进入临界区
结果:
线程A ─┐
├─ 同时进入临界区
线程B ─┘
锁完全失效。
根本原因在于:
if (lock == false)
lock = true;
这里实际上包含:
读取
判断
修改
多个步骤。
它们不是一个不可分割的整体。
因此,真正实现自旋锁必须依赖:
原子操作。
资料中的示例使用 atomic_flag 实现自旋锁:
#include <stdatomic.h>
atomic_flag spinlock = ATOMIC_FLAG_INIT;
void spinlock_lock()
{
while (atomic_flag_test_and_set(&spinlock))
{
// 忙等待
}
}
void spinlock_unlock()
{
atomic_flag_clear(&spinlock);
}
这里最关键的是:
atomic_flag_test_and_set(&spinlock);
三、atomic_flag_test_and_set
可以把:
atomic_flag_test_and_set(&spinlock)
理解成一个不可分割的操作:
读取旧值,同时把当前值设置为
true,然后返回旧值。
而且这整个过程具有原子性。
假设最开始:
spinlock = false
线程 A 执行:
atomic_flag_test_and_set(&spinlock);
发生:
旧值 = false
spinlock = true
返回 false
于是:
while (false)
循环结束。
线程 A:
获取锁成功
↓
进入临界区
这时候线程 B 再执行:
atomic_flag_test_and_set(&spinlock);
此时:
旧值 = true
spinlock = true
返回 true
于是:
while (true)
{
}
线程 B 开始自旋。
只要线程 A 尚未释放锁,线程 B 就会不断得到:
true
true
true
true
......
当线程 A 执行:
atomic_flag_clear(&spinlock);
锁变成:
false
线程 B 下一次执行:
atomic_flag_test_and_set()
就可能得到:
旧值 = false
于是退出循环并获取锁。
整个过程可以画成:
初始:
spinlock = false
线程A
│
├─ test_and_set
│
├─ 得到 false
│
├─ spinlock = true
│
└─ 进入临界区
线程B
│
├─ test_and_set → true
├─ test_and_set → true
├─ test_and_set → true
│
│ 自旋等待
│
│
线程A unlock
│
spinlock = false
│
线程B test_and_set → false
│
└─ 获取锁
这里最重要的不是 while。
真正保证互斥的是:
读取锁状态与修改锁状态必须是原子的。
四、自旋锁和互斥锁
自旋锁和互斥锁是两种完全不同的锁。
其实从"互斥"的目标来看,它们解决的是同一个问题:
同一时刻
只有一个执行流
可以进入临界区
真正的区别主要出现在:
获取锁失败以后怎么办。
普通互斥锁
可以简单理解为:
线程B获取锁
↓
获取失败
↓
阻塞
↓
线程进入等待状态
↓
操作系统调度其他线程
↓
以后重新唤醒
这会产生线程调度以及上下文切换成本。
自旋锁
线程B获取锁
↓
获取失败
↓
继续占用CPU
↓
不断尝试
↓
不断尝试
↓
锁释放
↓
立即竞争
因此两种机制没有绝对的"谁更快"。
关键要看:
等待时间
假设线程 A 只需要:
lock();
++count;
unlock();
临界区非常短。
此时线程 B 如果刚好获取失败:
自旋几十纳秒
很可能比:
阻塞
→ 调度
→ 上下文切换
→ 唤醒
→ 再调度
更加划算。
因此自旋锁特别适合:
锁持有时间很短的场景。
资料中也明确强调,自旋锁适用于短时间的锁竞争,否则会造成 CPU 资源浪费。
反过来,如果:
lock();
sleep(10);
unlock();
线程 B 可能持续:
自旋
自旋
自旋
自旋
......
整整十秒。
这时一个 CPU 核心可能大量时间都浪费在:
while(...)
{
}
上。
因此:
锁持有时间越长,自旋的成本越高。
这也是使用自旋锁最重要的原则之一。
五、Linux/POSIX 中的自旋锁接口
在 POSIX 线程接口中,可以直接使用:
#include <pthread.h>
pthread_spin_lock();
pthread_spin_trylock();
pthread_spin_unlock();
pthread_spin_init();
pthread_spin_destroy();
相关接口在资料中有直接列出。
它们和之前学习的:
pthread_mutex_init();
pthread_mutex_lock();
pthread_mutex_unlock();
pthread_mutex_destroy();
在使用方式上非常相似。
例如:
pthread_spinlock_t lock;
初始化:
pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);
加锁:
pthread_spin_lock(&lock);
临界区:
// 操作共享资源
解锁:
pthread_spin_unlock(&lock);
最后:
pthread_spin_destroy(&lock);
整体就是:
pthread_spinlock_t lock;
pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);
pthread_spin_lock(&lock);
// 临界区
pthread_spin_unlock(&lock);
pthread_spin_destroy(&lock);
其中还有:
pthread_spin_trylock(&lock);
它和:
pthread_spin_lock()
的重要区别是:
pthread_spin_lock
拿不到
↓
继续等
pthread_spin_trylock
拿不到
↓
立即返回
因此 trylock 适合:
"我先尝试获取,如果现在拿不到,我就去做其他事情。"
六、自旋锁中的死锁、活锁和饥饿
这一部分特别容易混淆。
首先要明确:
线程正在自旋,不等于发生了活锁。
1. 普通自旋等待
例如:
线程A:持有锁
线程B:自旋
线程C:自旋
线程D:自旋
只要线程 A 最终执行:
unlock();
那么 B、C、D 中就会有线程有机会获得锁。
这只是:
忙等待。
并不是死锁,也不是活锁。
2. 死锁 Deadlock
死锁的核心特征是:
执行流彼此等待,系统无法继续向前推进。
最经典的情况:
线程A:
拿到锁1
等待锁2
线程B:
拿到锁2
等待锁1
形成:
A 持有 lock1
│
└──── 等待 lock2
↑
│
B 持有 lock2
│
└──── 等待 lock1
双方永远无法继续。
自旋锁同样可能出现死锁。
例如一个线程重复获取同一把普通非递归自旋锁:
pthread_spin_lock(&lock);
...
pthread_spin_lock(&lock);
第一次:
成功获取 lock
第二次:
发现 lock 已经被占用
↓
开始自旋
但问题是:
这把锁恰恰就是它自己持有的。
于是:
自己拿着锁
↓
等待自己释放锁
↓
只有继续执行才能释放
↓
但是自己已经卡在第二次 lock
因此永远无法继续。
这是典型的死锁。
3. 活锁 Livelock
活锁和死锁最大的区别在于:
活锁中的线程仍然不断执行动作,但是系统状态始终无法取得实质性进展。
例如线程 A 和 B 分别持有两个资源:
A:拿到锁1
B:拿到锁2
A 发现拿不到锁2:
A:那我释放锁1,让一下
B 发现拿不到锁1:
B:那我也释放锁2,让一下
然后两者同时再次尝试:
A 又拿到锁1
B 又拿到锁2
继续:
A 放锁1
B 放锁2
A 拿锁1
B 拿锁2
A 放锁1
B 放锁2
......
两个线程始终:
在运行
在修改状态
在重新尝试
但是:
谁都没有完成任务
这才是典型的:
活锁。
因此,仅仅因为多个线程不断自旋等待同一把正常工作的自旋锁,并不能直接称为活锁。
在高竞争情况下,没有退避机制更典型的问题是:
CPU浪费
缓存一致性开销
锁竞争严重
公平性下降
线程饥饿
而不是"所有线程因为自旋本身形成活锁"。
4. 饥饿 Starvation
还有一个非常重要的概念:
系统整体一直在工作,但某个线程长期得不到资源。
例如:
A 抢到锁
↓
A 释放
B 抢到锁
↓
B 释放
A 又抢到
↓
A 释放
B 又抢到
......
但是线程 C:
一直抢不到
此时系统并没有死锁:
A和B一直正常执行
也没有活锁:
任务一直在取得进展
只是:
C 长期得不到执行机会
这叫:
饥饿。
所以这四个概念一定要区分:
| 情况 | 本质 |
|---|---|
| 自旋等待 | 拿不到锁时占着 CPU 等 |
| 死锁 | 相互等待,整体无法推进 |
| 活锁 | 一直执行动作,但整体无法推进 |
| 饥饿 | 整体能推进,但某个线程长期得不到资源 |
这是理解并发控制时非常重要的一组概念。
七、用售票系统理解自旋锁
考虑一个共享变量:
int ticket = 1000;
多个线程同时执行:
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
这是资料最后给出的典型多线程售票场景。
问题在于:
ticket
属于:
多个线程共同访问的共享资源。
假设:
ticket = 1
线程 A 执行:
if(ticket > 0)
得到:
true
但是还没有:
ticket--;
线程被切换出去。
此时线程 B:
if(ticket > 0)
看到的仍然是:
ticket == 1
于是也成立。
随后就可能出现:
A:卖票1
B:也卖票1
或者最终:
ticket < 0
这就是典型的:
竞态条件(Race Condition)。
因为:
判断 ticket
打印 ticket
ticket--
这一整段逻辑不能被其他线程随便穿插。
因此必须把它保护起来:
pthread_spin_lock(&lock);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
pthread_spin_unlock(&lock);
此时:
线程A拿锁
↓
检查ticket
↓
卖票
↓
ticket--
↓
释放锁
在线程 A 执行期间:
线程B
线程C
线程D
只能在外面自旋等待。
于是保证:
同一时刻
只有一个线程
操作 ticket
这就是自旋锁实现线程互斥的完整过程。
总结
自旋锁是一种基于原子操作实现的互斥机制。线程获取锁失败时不会立即阻塞,而是在用户态持续忙等待,因此能够减少线程阻塞、唤醒及上下文切换的开销,适用于临界区较短的场景;如果锁持有时间较长或竞争激烈,则会造成明显的 CPU 资源浪费。