前言
自旋锁(spinlock)是最简单的同步原语:拿不到锁就不让出 CPU,在原地循环重试,直到锁被释放。它的适用场景很窄——临界区极短、线程数不多、且确实需要避免线程切换的开销。用错地方,它会比std::mutex慢得多,还会白白烧掉一个核心。
初学者常有的误解是「自旋锁比互斥锁快,所以能用就用」。实际上std::mutex在多数实现里会先短暂自旋再进入内核等待,已经做了自适应;手写自旋锁只有在你能确认「等待时间短于一次线程切换」时才有意义。另一个误解是「用volatile bool当标志就行」——那不是原子操作,并发读写是数据竞争,属于未定义行为。
本文用 C++11 起的std::atomic_flag实现一个可用的自旋锁,讲清楚内存序为什么必须是 acquire/release,配合 RAII 守卫避免忘记解锁,最后说明它的局限和常见错误。
一、自旋锁与互斥锁的区别
两者都提供互斥(mutual exclusion),区别在等待策略:
| 维度 | 自旋锁 | std::mutex |
|---|
| 等待方式 | 忙等,持续占用 CPU | 通常先自旋,再休眠 |
| 上下文切换 | 无 | 可能发生 |
| 适合的临界区 | 极短(几条指令) | 任意长度 |
| 单核表现 | 差,等待者空转 | 好,让出 CPU |
| 实现依赖 | 原子操作 | 操作系统原语 |
| 优先级反转 | 可能严重 | 通常有处理机制 |
自旋锁的优势是「没有系统调用、没有线程唤醒延迟」。当临界区只有几十条指令、竞争又不激烈时,这个优势能体现出来。但一旦临界区里有一次内存分配、一次 I/O、或者一次可能阻塞的调用,自旋就变成纯粹浪费。
一个经验判断:如果临界区里出现了new、std::cout、文件读写、锁之外的任何互斥量,就不该用自旋锁。
二、用 atomic_flag 实现
std::atomic_flag是标准保证无锁(lock-free)的原子布尔类型,接口极简:test_and_set和clear。这正是自旋锁需要的。
// 适用于 C++17
#include <atomic>
class SpinLock {
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// 锁已被占用,继续尝试
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};逐点说明:
test_and_set原子地把标志设为true,并返回之前的值。返回false表示「之前是 false」,即抢锁成功;返回true表示别人持有,继续循环。ATOMIC_FLAG_INIT保证初始状态为 clear。C++20 起std::atomic_flag的默认构造函数保证初始化为 clear,可以直接写std::atomic_flag flag_{};,但为了兼容 C++17,示例保留宏。std::atomic_flag不可拷贝、不可移动,所以SpinLock也应当显式删除拷贝构造和拷贝赋值,避免被意外复制。
// 适用于 C++17
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock&) = delete;三、内存序与 RAII 守卫
把acquire/release换成relaxed,代码仍然能编译,甚至在 x86 上「看起来正常」,但它是错的。
test_and_set用acquire的语义是:抢锁成功之后,后续的读操作不会被重排到这条指令之前。clear用release的语义是:解锁之前的写操作不会被重排到这条指令之后。两者配对,形成「前一个临界区的写 → 解锁 → 加锁 → 后一个临界区的读」的同步关系,保证临界区内的修改对下一个持锁者可见。
如果全用relaxed,编译器和 CPU 都可以自由重排:临界区里的写可能被挪到unlock之后,下一个线程拿到锁时读到的还是旧值。这不是「概率性问题」,而是标准层面的数据竞争,属于未定义行为。
C++20 起,std::atomic_flag还提供了test()和wait()/notify_one(),可以用「自旋若干次后进入等待」的混合策略,减少空转。这需要 C++20 支持,且wait的实现依赖平台,使用前应确认工具链。
3.1 RAII 守卫
手写lock()/unlock()最大的风险是异常路径漏掉解锁。用一个作用域守卫解决:
// 适用于 C++17
class SpinLockGuard {
public:
explicit SpinLockGuard(SpinLock& lock) : lock_(lock) {
lock_.lock();
}
~SpinLockGuard() {
lock_.unlock();
}
SpinLockGuard(const SpinLockGuard&) = delete;
SpinLockGuard& operator=(const SpinLockGuard&) = delete;
private:
SpinLock& lock_;
};构造函数加锁、析构函数解锁,即使临界区抛出异常,栈展开时析构也会执行。这要求SpinLock本身不抛异常——lock()只做原子操作和循环,unlock()只做一次clear,满足要求。
如果需要在作用域中途提前释放,可以加一个unlock()成员并记录状态,但大多数场景不需要;提前释放往往意味着临界区划分得不合理。
四、局限与改进方向
自旋锁在以下情况会出问题:
- 单核环境:持有者需要 CPU 时间片才能释放锁,等待者却一直占着 CPU,两者互相拖慢。操作系统调度器可能把等待线程一直运行下去,导致持有者迟迟得不到调度。
- 临界区长:等待者空转的时间远大于休眠再唤醒的成本。
- 竞争激烈:所有线程在同一缓存行上反复
test_and_set,缓存一致性流量很大。常见的缓解是「测试-测试-设置」(test and test-and-set):先用便宜的读操作检查,发现空闲再尝试原子置位。 - 优先级反转:低优先级线程持锁被抢占,高优先级线程自旋等待,中等优先级线程却一直运行。
改进手段包括:自旋若干次后调用std::this_thread::yield()让出时间片;或者用 C++20 的atomic_flag::wait;或者干脆改用std::mutex。选择哪一种,应当在目标平台上用std::chrono::steady_clock测量,而不是凭直觉。
完整示例
// 适用于 C++17
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
class SpinLock {
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock&) = delete;
SpinLock() = default;
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};
class SpinLockGuard {
public:
explicit SpinLockGuard(SpinLock& lock) : lock_(lock) { lock_.lock(); }
~SpinLockGuard() { lock_.unlock(); }
SpinLockGuard(const SpinLockGuard&) = delete;
SpinLockGuard& operator=(const SpinLockGuard&) = delete;
private:
SpinLock& lock_;
};
int main() {
SpinLock lock;
long long counter = 0;
constexpr int kThreads = 4;
constexpr int kPerThread = 100000;
std::vector<std::thread> workers;
workers.reserve(kThreads);
for (int t = 0; t < kThreads; ++t) {
workers.emplace_back([&] {
for (int i = 0; i < kPerThread; ++i) {
SpinLockGuard guard(lock);
++counter;
}
});
}
for (auto& th : workers) {
th.join();
}
std::cout << counter << '\n'; // 按语义应为 400000
return 0;
}几个细节:
counter是普通的long long,不是std::atomic。它只在持锁期间被访问,锁提供了互斥和可见性,所以不需要额外的原子类型。- 每个线程结束后必须
join(),否则std::thread析构时调用std::terminate。 - 按标准语义,最终结果应当是 400000;这里不讨论耗时,因为自旋锁的实际表现高度依赖核心数、竞争程度和调度策略。
常见坑点
- ❌ 用普通
bool做标志:
``cpp bool locked = false; while (locked) { } // 数据竞争,UB`✅ 用std::atomic_flag或std::atomic<bool>`。
- ❌ 全部使用
memory_order_relaxed:
``cpp while (flag_.test_and_set(std::memory_order_relaxed)) { } flag_.clear(std::memory_order_relaxed);`✅ 加锁用acquire,解锁用release`。
- ❌ 手动加锁后忘记解锁,尤其在有
return或异常的分支:
``cpp lock.lock(); if (bad) return; // 锁永远不释放 lock.unlock();`` ✅ 用 RAII 守卫,析构自动解锁。
- ❌ 在自旋锁保护下做耗时操作:
``cpp SpinLockGuard g(lock); std::this_thread::sleep_for(std::chrono::milliseconds(10));`` ✅ 自旋锁只保护几条指令的临界区,耗时操作放到锁外。
- ❌ 允许拷贝锁对象:
``cpp SpinLock a; SpinLock b = a; // 两个锁保护同一份数据却互不知情`` ✅ 删除拷贝构造和拷贝赋值。
- ❌ 在单核或线程数远大于核心数的环境里使用自旋锁。
✅ 改用std::mutex,或自旋几次后yield()。
- ❌ 同一个线程重复加锁:
``cpp lock.lock(); lock.lock(); // 死锁,自旋锁不可重入`✅ 明确锁的层级,或用std::recursive_mutex`(但通常说明设计有问题)。
- ❌ 认为
std::atomic_flag一定比std::mutex快。
✅ 是否更快取决于竞争程度和临界区长度,需要在目标平台测量。
总结
| 要素 | 正确做法 |
|---|
| 原子类型 | std::atomic_flag,ATOMIC_FLAG_INIT初始化 |
| 加锁 | test_and_set(std::memory_order_acquire)循环 |
| 解锁 | clear(std::memory_order_release) |
| 异常安全 | RAII 守卫 |
| 可拷贝性 | 显式删除 |
| 适用场景 | 极短临界区、竞争不激烈 |
自旋锁的代码只有十几行,难点全在「什么时候该用」。它的正确性依赖 acquire/release 的配对,它的性能依赖临界区足够短。把这两点想清楚,再决定是手写还是直接用std::mutex。