自旋锁:从忙等待、原子操作到线程同步

在多线程程序中,只要多个线程可能同时访问同一份共享资源,就必须考虑并发安全问题 。常见做法是使用互斥锁 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 资源浪费。

相关推荐
kaixin_啊啊1 小时前
PandaWiki 本地 AI 知识库实战:文档导入、智能问答与远程访问
linux·服务器·人工智能·windows·ai
handler011 小时前
【Linux】进程退出、等待与程序替换
linux·运维·exec·进程·进程等待·进程退出·程序替换
Linux-lucky1 小时前
33-Linux学习之旅之MySQL用户管理
java·linux·运维·学习·mysql·ubuntu
像风一样的男人@1 小时前
linux --安装openGL,EGL/OSMesa
linux·运维·服务器
Maynor9962 小时前
「原子弹爆炸」级别:Astra 复刻游戏合集(含实机截图)
java·linux·运维·数据库·gpt·游戏
CV艺术家2 小时前
openssL生成免费的证书
java·linux·服务器
脚踏实地,坚持不懈!2 小时前
Android 系统工程师(性能/功耗/稳定性)岗位问题深度解析:从内核源码到实战排查(完善版)
android·linux·运维·服务器
0+1112 小时前
Linux --进程信号
linux·运维·服务器
木卫四科技3 小时前
从函数劫持到智能体控制平面:Hook 如何从 Linux-Android 演化到 Agent Runtime
android·linux·人工智能·安全