🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :从"等待一把短时间持有的锁"出发,解释自旋锁为什么忙等、为什么普通
bool不够、原子读改写和内存序分别解决什么问题,并用 Linuxpthread_spin_*修复一个多线程售票程序。💡 学习目标 :能独立实现最小自旋锁;理解
test_and_set、acquire/release、缓存行乒乓、TAS/TTAS 与退避;知道何时适合自旋、何时应该选择互斥锁。📌 阅读说明:示例面向 Linux,C 代码使用 C11 原子库和 pthread。自旋锁不是"比互斥锁更快的锁",它只是在特定前提下,用一段 CPU 忙等换掉阻塞与唤醒成本。
文章目录
- 一、从等待开始:自旋锁究竟在做什么
- 二、自旋等待与阻塞等待的根本区别
- 三、为什么普通布尔变量做不成一把锁
- 四、atomic_flag:最小原子锁的状态机
- [五、acquire 与 release:互斥之外还要保证可见性](#五、acquire 与 release:互斥之外还要保证可见性)
- [六、用 C11 与 C++ 实现一把教学自旋锁](#六、用 C11 与 C++ 实现一把教学自旋锁)
- [七、pthread_spin_*:Linux 中怎样使用自旋锁](#七、pthread_spin_*:Linux 中怎样使用自旋锁)
- 八、售票实验:从数据竞争到正确临界区
- 九、怎样验证锁真的解决了问题
- 十、高竞争优化:pause、TTAS、退避与混合等待
- 十一、缓存一致性视角:为什么线程越多可能越慢
- 十二、自旋锁、互斥锁与原子变量怎样选择
- 十三、常见误区、高频面试题与练习
- 总结
一、从等待开始:自旋锁究竟在做什么
假设线程 A 已经进入临界区,线程 B 此时也想访问同一份共享数据。B 没有立刻拿到锁,它必须等待。
等待大体有两条路径:
text
路径一:继续运行,不断检查锁是否释放
路径二:把自己阻塞起来,等待系统稍后唤醒
自旋锁选择第一条。所谓"自旋",就是线程在一个短循环中反复尝试:
cpp
while (lock_is_busy())
{
// 不做业务工作,继续检查
}
线程没有因为锁忙而主动睡眠,调度器仍把它看作可运行线程。锁一旦释放,它可以马上参与下一轮争抢,不必先经历"阻塞 → 唤醒 → 重新调度"的完整路径。
这解释了自旋锁的低延迟,也同时暴露了它的代价:等待期间,CPU 正在执行没有业务产出的循环。

1.1 自旋锁不是零成本等待
如果持锁线程只执行几十条指令,自旋几十或几百个周期可能比睡眠再唤醒更便宜。但如果临界区中包含下面这些操作:
- 文件或网络 I/O;
sleep、usleep或条件等待;- 可能触发长时间缺页的内存访问;
- 复杂计算或不可控的第三方函数;
- 再去获取另一把可能阻塞的锁;
等待线程就会持续消耗 CPU。锁持有 10 微秒和持有 100 毫秒,对自旋线程而言是完全不同的世界。
1.2 判断是否适合自旋的三个前提
可以先问三个问题:
- 临界区是否确定很短? 平均值短还不够,尾延迟也要可控。
- 运行线程数是否不超过可用 CPU? 如果系统已经超卖,自旋者可能占着时间片,让真正持锁者反而拿不到 CPU。
- 竞争是否较低? 大量线程同时写同一个锁状态,会造成缓存一致性流量和争抢风暴。
只有这些前提大致成立,自旋才有可能比阻塞更划算。
二、自旋等待与阻塞等待的根本区别
自旋锁和互斥锁都可以建立互斥,但竞争失败后的处理路径不同。
| 对比项 | 自旋等待 | 阻塞等待 |
|---|---|---|
| 等待线程状态 | 通常仍可运行 | 进入等待,由系统管理 |
| 是否持续占用执行资源 | 是 | 通常否 |
| 是否需要睡眠/唤醒 | 通常不需要 | 竞争路径可能需要 |
| 适合的持锁时间 | 极短且可控 | 可以更长 |
| 对超卖的敏感度 | 很高 | 相对较低 |
| 高竞争下表现 | 可能形成争抢风暴 | 调度开销增加,但不会一直空转 |
2.1 为什么"避免上下文切换"不是绝对结论
常见说法是"自旋锁不会发生上下文切换"。更严谨的表达应该是:
自旋锁的锁实现不主动把竞争者阻塞,但自旋线程仍可能因为时间片用完、更高优先级任务到来或中断等原因被调度出去。
因此,自旋减少的是由锁竞争主动触发的睡眠和唤醒路径,并不等于系统从此没有调度。
2.2 单核机器上的危险
单核环境只有一个 CPU 在执行。若线程 B 自旋等待线程 A 释放锁,而 A 此刻没有运行,B 的自旋不可能让锁自己变为空闲,只能等调度器再次运行 A。
用户态调度最终通常会切走 B,所以不一定形成永久死锁,但等待期间的循环没有任何推进作用。实时优先级配置不当时,甚至可能出现高优先级自旋者压住低优先级持锁者的优先级反转问题。
2.3 超卖环境与虚拟机
即使机器有多个核,只要可运行线程远多于 CPU,持锁者也可能被调度出去。其余线程还在其他核上忙等,系统看似很忙,真正能释放锁的线程却没得到执行机会。
这就是为什么"核数多"并不自动等于"应该使用自旋锁"。还要看 CPU 配额、容器限额、虚拟机 vCPU 调度和实际运行队列。
三、为什么普通布尔变量做不成一把锁
最直觉的写法是:
cpp
bool locked = false;
void lock()
{
while (locked)
{
}
locked = true;
}
void unlock()
{
locked = false;
}
这段代码有两个根本问题。
3.1 "检查"和"设置"不是一个原子动作
线程 A 与线程 B 可能发生下面的交错:
text
A 读取 locked,得到 false
B 读取 locked,得到 false
A 写入 true,认为自己成功
B 写入 true,也认为自己成功
A、B 同时进入临界区
锁要保证的恰恰是"同一时刻最多只有一个成功者"。如果读取旧状态和设置新状态之间存在可插入的时间窗口,这个协议从根上就是坏的。

3.2 普通变量访问本身形成数据竞争
在 C/C++ 内存模型中,一个线程写普通变量、其他线程无同步地读写同一变量,会形成数据竞争,程序行为未定义。编译器并不需要替我们保留"每次循环都重新从内存读取"的朴素直觉。
即使加上 volatile 也不行:
cpp
volatile bool locked = false;
volatile 主要约束编译器对某些访问的处理,常用于内存映射 I/O 等场景;它不把"读 + 判断 + 写"合并成原子事务,也不建立线程之间所需的 happens-before 关系。
3.3 锁状态需要原子读-改-写
我们需要这样一个不可分割的操作:
text
读取旧值
把新值写成"已占用"
返回旧值
如果旧值为 false,当前线程是唯一赢家;如果旧值为 true,说明别人已经持锁,当前线程继续等待。
底层处理器可能提供 exchange、test-and-set 或 compare-and-swap 一类原子指令。C11/C++ 标准库再把它们抽象为可移植的原子操作。
四、atomic_flag:最小原子锁的状态机
atomic_flag 是标准保证无锁的原子布尔标志类型,正适合展示最小自旋锁。它最关键的两个操作是:
text
test_and_set:返回旧值,同时把标志设为 true
clear:把标志清为 false
状态只有两个:
false:未上锁;true:已上锁。

4.1 为什么返回旧值很重要
假设标志原来是 false:
text
test_and_set 返回 false
同时状态已经变成 true
只有执行这次原子操作的线程看到"旧值为 false",因此它获得锁。后来的线程看到旧值为 true,继续循环。
4.2 test-and-set 与 CAS 不要混为一谈
PDF 原文把示例概括为 CAS 思路,这在"都属于原子读改写"这一层面可以帮助理解,但两种操作并不完全相同:
text
test-and-set / exchange:无条件写入新值,并返回旧值
compare-and-swap:只有当前值等于 expected 时才写入 desired
atomic_flag_test_and_set 更接近原子 exchange/test-and-set,而不是带 expected 参数的 compare-exchange。写博客和面试回答时,最好把这层区别说清楚。
4.3 最小状态机不等于完整工程锁
原子标志解决了"只有一个线程成为赢家",但工程锁还要考虑:
- 临界区中的写入怎样对下一位持锁者可见;
- 竞争循环对处理器是否友好;
- 是否公平,会不会有线程长期抢不到;
- 线程被取消、异常或提前返回时是否一定释放;
- 高竞争或长等待时是否转为阻塞。
这些问题分别落到内存序、退避、公平队列和 RAII/清理协议上。
五、acquire 与 release:互斥之外还要保证可见性
一把锁保护的不只是 flag 本身,而是临界区中的共享数据。
cpp
lock();
shared_data = 42;
unlock();
另一个线程随后成功加锁,必须能够观察到前一位持锁者在临界区完成的修改。为此需要 acquire/release 语义。
5.1 获取锁使用 acquire
成功获取锁后,当前线程之后的普通内存操作不能被重排到加锁之前;同时,它应看到上一位持锁者在释放前完成的写入。
c
atomic_flag_test_and_set_explicit(
&lock,
memory_order_acquire
);
5.2 释放锁使用 release
释放锁之前,临界区中的读写必须先完成,不能跑到解锁之后:
c
atomic_flag_clear_explicit(
&lock,
memory_order_release
);
同一原子对象上的 release 与后续成功 acquire 配对后,建立跨线程的同步关系:
text
线程 A 临界区写入
↓
unlock(release)
↓ synchronizes-with
lock(acquire)
↓
线程 B 临界区读取
5.3 为什么默认顺序也能工作
C/C++ 原子操作默认使用 seq_cst,它比 acquire/release 更强,因此最初的 atomic_flag_test_and_set(&flag) 与 atomic_flag_clear(&flag) 也能建立正确同步。
显式写出 acquire/release 的意义是表达"这就是锁需要的同步强度",同时避免无意使用更强的全局顺序约束。是否产生性能差异取决于架构、编译器和上下文,不能脱离测量武断下结论。
5.4 失败的自旋循环怎样选择内存序
最简单的 atomic_flag 版本每次都执行 test-and-set。每一次失败都在做原子写入式争抢,成本不低。C++20 可以先用 atomic_flag::test(relaxed) 观察;C11 或较早 C++ 中也可以用 atomic<bool> 写出 TTAS,等待时主要做 relaxed load,真正看到空闲再 exchange(acquire)。这一优化将在第十节展开。
六、用 C11 与 C++ 实现一把教学自旋锁
6.1 C11 atomic_flag 版本
c
#include <stdatomic.h>
typedef struct
{
atomic_flag flag;
} spin_lock_t;
#define SPIN_LOCK_INIT { ATOMIC_FLAG_INIT }
static inline void spin_lock(spin_lock_t* lock)
{
while (atomic_flag_test_and_set_explicit(
&lock->flag,
memory_order_acquire))
{
// busy wait
}
}
static inline void spin_unlock(spin_lock_t* lock)
{
atomic_flag_clear_explicit(
&lock->flag,
memory_order_release);
}
初始化:
c
spin_lock_t lock = SPIN_LOCK_INIT;
使用:
c
spin_lock(&lock);
// 访问共享状态
spin_unlock(&lock);
6.2 在 x86 等待循环中加入 pause
在 x86 平台,可以给忙等循环加入 pause 提示:
c
static inline void cpu_relax(void)
{
#if defined(__x86_64__) || defined(__i386__)
__asm__ __volatile__("pause" ::: "memory");
#endif
}
再把锁循环改为:
c
while (atomic_flag_test_and_set_explicit(
&lock->flag,
memory_order_acquire))
{
cpu_relax();
}
pause 不会释放锁,也不会把线程阻塞起来。它只是告诉处理器当前处于自旋等待,可用于减轻某些流水线与 SMT 场景中的副作用。不同架构有不同提示指令,生产代码应使用经过移植封装的 cpu_relax(),不要到处散落架构汇编。
6.3 C++ 封装与 RAII
C++ 可以把 std::atomic_flag 包装成满足 BasicLockable 形式的类,再交给 std::lock_guard 管理解锁:
cpp
#include <atomic>
#include <mutex>
class SpinLock
{
public:
SpinLock() noexcept = default;
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock&) = delete;
void lock() noexcept
{
while (flag_.test_and_set(std::memory_order_acquire))
{
#if defined(__x86_64__) || defined(__i386__)
__builtin_ia32_pause();
#endif
}
}
bool try_lock() noexcept
{
return !flag_.test_and_set(std::memory_order_acquire);
}
void unlock() noexcept
{
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};
调用时:
cpp
SpinLock lock;
int shared_counter = 0;
void AddOne()
{
std::lock_guard<SpinLock> guard(lock);
++shared_counter;
}
RAII 的价值不在于代码更"现代",而在于提前返回或异常离开作用域时,析构函数仍会调用 unlock(),减少忘记解锁造成的永久等待。
6.4 教学实现的边界
上面的类没有承诺公平性、递归加锁、超时、阻塞回退、所有者检查或死锁检测。它适合帮助理解原理,不应因为代码短就直接替换成熟并发库中的锁。
七、pthread_spin_*:Linux 中怎样使用自旋锁
Linux/glibc 环境提供 pthread 自旋锁接口:
c
#include <pthread.h>
int pthread_spin_init(
pthread_spinlock_t* lock,
int pshared
);
int pthread_spin_lock(pthread_spinlock_t* lock);
int pthread_spin_trylock(pthread_spinlock_t* lock);
int pthread_spin_unlock(pthread_spinlock_t* lock);
int pthread_spin_destroy(pthread_spinlock_t* lock);
这里要纠正一个常见表述:pthread_spin_* 是 POSIX 线程库接口,不应直接称为"Linux 自旋锁系统调用"。库的内部实现可能使用用户态原子指令,也可能因平台和版本而异;是否陷入内核不能只从函数名判断。

7.1 初始化参数 pshared
同一进程的线程之间使用:
c
pthread_spin_init(
&lock,
PTHREAD_PROCESS_PRIVATE
);
跨进程共享时可请求:
c
pthread_spin_init(
&lock,
PTHREAD_PROCESS_SHARED
);
但 PTHREAD_PROCESS_SHARED 不是把一个普通全局变量自动变成跨进程对象。锁本体必须位于真正的进程共享内存中,例如:
text
shm_open / ftruncate / mmap(MAP_SHARED)
创建者只初始化一次;使用者映射同一对象后直接使用;销毁前必须保证所有进程都不会再访问。
7.2 lock、trylock 与返回值
pthread_spin_lock 竞争失败时等待,直到获得锁或发生错误。pthread_spin_trylock 不愿等待:锁空闲则获得,锁忙通常返回 EBUSY。
c
int rc = pthread_spin_trylock(&lock);
if (rc == 0)
{
// 已获得锁
}
else if (rc == EBUSY)
{
// 当前忙,执行其他策略
}
else
{
// 真实错误
}
pthread 函数通常直接返回错误码,不应只调用 perror 检查 errno:
c
int rc = pthread_spin_lock(&lock);
if (rc != 0)
{
fprintf(stderr, "pthread_spin_lock: %s\n", strerror(rc));
}
7.3 销毁不是强制解锁
pthread_spin_destroy 的含义是结束锁对象生命周期,不是"无论谁持有都强行释放"。销毁一个仍被持有、仍有人等待或仍可能被访问的锁,会违反接口前提。
正确顺序通常是:
text
通知工作线程停止
↓
join / wait 所有使用者
↓
确认无人访问
↓
pthread_spin_destroy
八、售票实验:从数据竞争到正确临界区
PDF 使用四个线程销售同一个 ticket 变量,正适合观察互斥问题。
8.1 未加锁版本为什么有问题
核心代码如下:
c
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
ticket > 0、打印当前票号和 ticket-- 不是一个不可分割的操作。线程 A 检查完后被切走,线程 B 也可能看到同一个票号,最终产生重复销售、越界递减或不可预测行为。
从 C 语言标准角度看,多个线程无同步地读写 ticket 已经形成数据竞争,不能把某一次运行"刚好输出正常"当作正确性证据。
8.2 锁必须覆盖完整不变量
本例的不变量是:
text
每张票至多被分配一次
票数用尽后不再分配
因此"检查是否还有票"和"消耗一张票"必须位于同一个临界区。耗时的打印和模拟业务延迟可以放到锁外,但要先在锁内把分配结果复制到线程局部变量。

8.3 可运行的 pthread_spin 完整版本
c
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
enum { THREAD_COUNT = 4, INITIAL_TICKETS = 1000 };
static int ticket = INITIAL_TICKETS;
static pthread_spinlock_t lock;
static void die_pthread(int code, const char* operation)
{
if (code == 0)
{
return;
}
fprintf(stderr, "%s: %s\n", operation, strerror(code));
exit(EXIT_FAILURE);
}
static void* route(void* arg)
{
const char* id = (const char*)arg;
for (;;)
{
int current = 0;
die_pthread(
pthread_spin_lock(&lock),
"pthread_spin_lock");
if (ticket > 0)
{
current = ticket;
--ticket;
}
die_pthread(
pthread_spin_unlock(&lock),
"pthread_spin_unlock");
if (current == 0)
{
break;
}
printf("%s sells ticket: %d\n", id, current);
usleep(1000);
}
return NULL;
}
int main(void)
{
pthread_t threads[THREAD_COUNT];
const char* names[THREAD_COUNT] = {
"thread 1",
"thread 2",
"thread 3",
"thread 4"
};
die_pthread(
pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE),
"pthread_spin_init");
for (int i = 0; i < THREAD_COUNT; ++i)
{
die_pthread(
pthread_create(
&threads[i],
NULL,
route,
(void*)names[i]),
"pthread_create");
}
for (int i = 0; i < THREAD_COUNT; ++i)
{
die_pthread(
pthread_join(threads[i], NULL),
"pthread_join");
}
die_pthread(
pthread_spin_destroy(&lock),
"pthread_spin_destroy");
printf("remaining tickets: %d\n", ticket);
return EXIT_SUCCESS;
}
编译:
bash
gcc -std=c11 -O2 -Wall -Wextra -pthread spin_ticket.c -o spin_ticket
运行:
bash
./spin_ticket
最后应该看到:
text
...
thread 2 sells ticket: 3
thread 4 sells ticket: 2
thread 1 sells ticket: 1
remaining tickets: 0
由于打印已经移到锁外,输出行的先后顺序不一定严格等于票号分配顺序,但每个 current 只会在锁内产生一次,不会重复分配。
8.4 为什么 usleep 必须放在锁外
如果在线程持有自旋锁时执行 usleep(1000),其他线程会在这 1 毫秒内持续自旋。1 毫秒对 CPU 来说已经很长,教学代码等于刻意制造最不适合自旋锁的场景。
正确结构是:
text
lock
检查共享状态
更新共享状态
保存局部结果
unlock
打印 / sleep / 业务处理
这条原则同样适用于日志、文件访问、网络调用和任何不受控的回调。
九、怎样验证锁真的解决了问题
并发程序不能只靠"多跑几次没出错"验证。比较有效的检查分为三层。
9.1 验证业务不变量
把售票数缩小,并把输出重定向到文件:
bash
./spin_ticket > result.txt
检查票号数量、唯一性和边界:
bash
grep "sells ticket" result.txt | wc -l
grep "sells ticket" result.txt \
| awk '{print $NF}' \
| sort -n \
| uniq -d
期待:
- 销售行数等于
INITIAL_TICKETS; uniq -d没有输出;- 最终余票为 0;
- 没有 0 或负数票号被分配。
9.2 用 ThreadSanitizer 检查数据竞争
先故意运行未加锁版本:
bash
gcc -std=c11 -O1 -g \
-fsanitize=thread -pthread \
unsafe_ticket.c -o unsafe_ticket_tsan
./unsafe_ticket_tsan
再检查修复版本:
bash
gcc -std=c11 -O1 -g \
-fsanitize=thread -pthread \
spin_ticket.c -o spin_ticket_tsan
./spin_ticket_tsan
工具支持与 pthread 自旋锁拦截能力会因平台版本而异。如果 sanitizer 对某个库原语不识别,不应只看"是否报错",还要结合最小复现、工具版本和锁语义判断。
9.3 测量 CPU 与调度行为
可以用 time 观察 wall time 与 CPU time:
bash
/usr/bin/time -v ./spin_ticket > /dev/null
也可以用 perf stat 观察:
bash
perf stat -e \
task-clock,context-switches,cpu-migrations,cycles,instructions \
./spin_ticket > /dev/null
比较时至少准备三组实现:
pthread_spinlock_t;pthread_mutex_t;- 对简单计数直接使用原子
fetch_sub。
然后改变线程数、临界区长度和竞争频率。单个固定场景的结果不能推广成"某种锁永远最快"。
9.4 基准测试最容易犯的错
- 在锁内
printf,测到的主要是 I/O; - 每轮
usleep,把临界区结构完全改变; - 只测无竞争路径,不测高竞争;
- 线程数远超 CPU,却不记录运行队列;
- 没有固定 CPU、频率与 NUMA 节点;
- 只看平均值,不看长尾;
- 调试构建与优化构建混在一起比较。
锁的成本处在纳秒到微秒量级时,测量方法本身就是实验的一部分。
十、高竞争优化:pause、TTAS、退避与混合等待
最朴素的 TAS 自旋锁:
cpp
while (flag.exchange(true, std::memory_order_acquire))
{
}
每个等待者每一轮都执行原子读改写。高竞争下,这会不断争夺锁所在缓存行。

10.1 pause:让单次循环更温和
pause 或其他架构的等待提示可以减轻紧密自旋对执行资源的压力,在 SMT 场景中也可能改善兄弟逻辑线程的处境。
但它没有减少"多少线程在抢",也没有改变每轮 exchange 对缓存行的写争用。它是一块基础拼图,不是万能优化。
10.2 TTAS:先读,看到空闲再写
Test-Test-And-Set 的思路是:
text
先用普通原子 load 观察锁
锁忙时只读等待
看到空闲后再 exchange 争抢
C++ 示例:
cpp
#include <atomic>
class TtasSpinLock
{
public:
void lock() noexcept
{
for (;;)
{
if (!locked_.exchange(
true,
std::memory_order_acquire))
{
return;
}
while (locked_.load(std::memory_order_relaxed))
{
#if defined(__x86_64__) || defined(__i386__)
__builtin_ia32_pause();
#endif
}
}
}
void unlock() noexcept
{
locked_.store(false, std::memory_order_release);
}
private:
std::atomic<bool> locked_{false};
};
锁保持忙碌时,等待者主要共享读取缓存行,不会每一轮都申请写所有权。锁刚释放的瞬间,多个等待者仍可能一起 exchange,所以高竞争问题只是减轻,没有消失。
10.3 固定退避与指数退避
争抢失败后不立即重试,可以等待若干个 pause 周期:
text
第 1 次失败:等待 4 轮
第 2 次失败:等待 8 轮
第 3 次失败:等待 16 轮
...
达到上限后维持或切换策略
加入随机抖动还能减少多个线程在相同时刻再次碰撞。退避的代价是锁已经释放时,某些线程可能仍在等待,因此参数必须结合临界区和机器特性测量。
10.4 先自旋,超阈值后阻塞
成熟互斥锁常采用混合思路:
text
短暂自旋 N 轮
├── 期间拿到锁:直接进入
└── 仍然失败:进入内核等待或 park
它试图同时覆盖短等待与长等待。但阈值不是越大越好,还可能根据 CPU 数量、锁历史、运行队列和虚拟化环境自适应。
10.5 公平锁:ticket lock 与队列锁
简单 TAS/TTAS 不保证先来先得。刚释放锁的线程或离缓存行更近的线程,可能反复获胜,导致其他线程饥饿。
常见公平化方向包括:
- ticket lock:每个线程领取票号,按顺序进入;
- MCS lock:每个等待者进入队列,主要在自己的节点上自旋;
- CLH lock:以链式前驱状态组织等待。
公平通常要付出额外状态、更多指令或不同的缓存行为。是否需要公平,也应由业务的延迟目标和饥饿风险决定。
十一、缓存一致性视角:为什么线程越多可能越慢
自旋循环不是只在一个抽象布尔值上打转。真实机器中,锁变量位于某条缓存行,多个 CPU 核通过缓存一致性协议观察和修改它。

11.1 TAS 为什么容易制造缓存行乒乓
原子 exchange/test-and-set 属于读改写操作。处理器通常需要取得包含锁变量的缓存行的独占修改权限。多个核不断执行这一操作,缓存行所有权会在核之间转移:
text
CPU 0 想写
CPU 1 想写
CPU 2 想写
CPU 3 也想写
即使锁一直处于 true,失败者仍在制造写争用。线程越多,锁交接不一定越快,反而可能把大量时间花在一致性通信上。
11.2 false sharing:锁旁边的变量也会受影响
如果锁与一个频繁修改、但逻辑上无关的计数器恰好位于同一缓存行,两者会互相放大缓存失效:
cpp
struct BadLayout
{
std::atomic_flag lock;
std::uint64_t unrelated_counter;
};
C++17 可以用硬件干扰大小提示做隔离:
cpp
#include <new>
struct alignas(std::hardware_destructive_interference_size)
PaddedSpinLock
{
std::atomic_flag flag = ATOMIC_FLAG_INIT;
};
该常量是否可用、值是否准确与实现有关,生产代码仍要结合目标平台和结构布局检查。对齐会增加内存占用,不应机械套用到大量低竞争对象。
11.3 NUMA 会进一步放大距离
跨 NUMA 节点争抢同一缓存行时,一致性消息和数据迁移成本更高。单插槽小机器上的微基准结果,未必能预测多插槽服务器表现。
在 NUMA 系统上,高竞争共享锁本身就可能说明数据结构需要拆分:按核分片、按 NUMA 节点分片、批量合并或使用无共享设计,常常比继续打磨一把全局锁更有效。
11.4 关键优化可能是缩小共享,而不是优化循环
如果 64 个线程都在争一把锁,优先考虑:
- 能否把一份全局计数改成每线程计数,最后汇总;
- 能否按哈希桶、连接或对象分片;
- 能否把写操作批量提交;
- 能否用无锁队列把工作交给单一所有者;
- 能否减少临界区中的工作量。
锁算法优化解决的是争用成本,数据结构重构则可能直接消除争用。
十二、自旋锁、互斥锁与原子变量怎样选择
12.1 先问:是否真的需要一把锁
如果共享操作只是简单计数:
cpp
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
直接使用原子操作可能比"先锁住再加一"更清晰。但当多个字段必须共同维持不变量时,一把锁往往更容易证明正确。
12.2 决策表
| 场景 | 优先考虑 | 原因 |
|---|---|---|
| 单个计数或状态位 | 原子变量 | 避免额外锁协议 |
| 临界区极短、竞争低、线程不超卖 | 自旋或自适应锁 | 可能避免阻塞/唤醒成本 |
| 临界区可能稍长 | pthread_mutex_t / std::mutex |
等待者可让出 CPU |
| 临界区含 I/O、sleep、条件等待 | 互斥锁及更高层同步 | 自旋会浪费大量 CPU |
| 要等待"状态变为某条件" | 条件变量、信号量、事件 | 锁只保护状态,不负责高效等待事件 |
| 强公平或高竞争 | 队列锁/成熟并发原语 | 简单 TAS 可能饥饿和抖动 |
| 内核/中断上下文 | 遵循内核对应原语规则 | 与用户态 pthread 语义不同 |
12.3 一个实用判断流程
text
操作能否由单个原子完成?
├── 能:先考虑 atomic
└── 不能:需要保护复合不变量
↓
临界区是否可能阻塞或不可控?
├── 是:使用 mutex / 更高层同步
└── 否:继续评估
↓
持锁时间是否极短,且线程不超卖?
├── 否:使用 mutex
└── 是:自旋锁可以进入基准测试候选
注意最后一句是"进入候选",不是"直接确定"。仍要在目标负载、目标机器和真实线程数量下测量吞吐、CPU 消耗与长尾延迟。
十三、常见误区、高频面试题与练习
13.1 常见误区
误区一:自旋锁一定比互斥锁快
错误。自旋锁省掉阻塞路径,却付出忙等和缓存争用成本。锁持有较长、竞争高或线程超卖时,它可能明显更慢。
误区二:加上 volatile 就能实现自旋锁
错误。volatile 不提供跨线程原子读改写,也不建立锁所需的同步关系。
误区三:atomic_flag_test_and_set 就是 CAS
不严谨。两者都属于原子 RMW,但 test-and-set 通常无条件把标志设为 true;CAS 带 expected/desired,并只在比较成功时写入。
误区四:自旋锁完全不会发生上下文切换
锁实现不主动阻塞竞争者,但调度器仍可能因为时间片、优先级或中断切换线程。
误区五:只要锁变量是原子的,临界区数据自然可见
还要使用正确的内存序。锁获取与释放需要建立相应同步关系,典型配对是 acquire/release。
误区六:持锁时 sleep 只是让测试更容易观察
它会把示例变成自旋锁最不适合的场景:持锁者睡眠,其他线程持续消耗 CPU。延迟模拟应放在锁外。
误区七:pthread_spin_destroy 可以解开一把锁
销毁不是解锁。必须先确保无人持锁、无人等待且对象不会再被访问。
误区八:PTHREAD_PROCESS_SHARED 能自动跨进程
锁对象必须放在真正的共享内存中,且只初始化一次。普通全局变量仍然是每个进程各自的一份。
误区九:退避可以保证公平
退避主要降低碰撞和争用,不自动保证先来先得。公平性需要 ticket/MCS/CLH 等明确协议。
误区十:用了锁就没有其他并发问题
锁顺序错误会死锁,忘记解锁会永久等待,错误粒度会降低并发度,返回锁内对象的裸引用还可能在锁外失效。
误区十一:临界区代码行少,就一定很短
一行容器操作可能分配内存,一行日志可能阻塞,一次缺页可能很慢。应看最坏路径和实测耗时,不看源代码行数。
误区十二:线程越多,锁保护的程序越快
共享锁会串行化临界区。更多线程还会增加争用、缓存行迁移与调度成本,吞吐可能在某个点之后下降。
13.2 高频面试题
问题 1:自旋锁和互斥锁的核心区别是什么?
竞争失败后,自旋锁通常保持可运行并反复检查锁;互斥锁的慢路径通常会让线程等待并由系统稍后唤醒。前者用 CPU 时间换取低唤醒延迟,后者用调度开销换取 CPU 可让渡。
问题 2:为什么检查锁再设置锁必须是原子操作?
否则多个线程可能同时观察到空闲,再分别写入"已占用",导致多个赢家共同进入临界区。
问题 3:atomic_flag_test_and_set 返回什么?
返回修改前的旧值,同时把标志设置为 true。旧值为 false 的线程获得锁,旧值为 true 的线程继续等待。
问题 4:acquire/release 在锁里解决什么?
它们保证临界区普通内存访问的顺序和跨线程可见性。前一线程 release 解锁之前的写入,对后一线程成功 acquire 加锁后的访问可见。
问题 5:为什么 TAS 在高竞争下昂贵?
每轮失败也执行原子读改写,需要争夺锁缓存行的修改权限,造成缓存行乒乓和一致性流量。
问题 6:TTAS 怎样减轻竞争?
锁忙时主要用原子 load 读取,看到空闲后才 exchange。共享读取比每轮写争抢更温和,但释放瞬间仍可能有惊群竞争。
问题 7:pause 会让线程睡眠吗?
不会。它是处理器等待提示,仍属于忙等路径,不等价于 sched_yield、futex 等阻塞机制。
问题 8:为什么单核或超卖环境不适合长时间自旋?
等待者可能占用持锁者本可使用的时间片,锁无法更快释放,系统只是在做无效循环。
问题 9:pthread 的错误为什么不能只看 errno?
许多 pthread 函数直接返回错误码。应先检查返回值,再用 strerror(rc) 解释。
问题 10:自旋锁会死锁吗?
会。重复获取非递归自旋锁、锁顺序形成环、持锁线程永不运行或异常路径忘记释放,都可能导致永久等待。
问题 11:怎样减少自旋锁临界区长度?
只在锁内读取/更新共享状态并保存局部快照;把格式化、日志、I/O、sleep、回调和昂贵计算移到锁外。
问题 12:什么时候应该换成数据分片?
当大量线程长期争夺同一全局锁,且临界区已经很难再缩短时,应优先减少共享:按核、线程、哈希桶或 NUMA 节点拆分,再批量汇总。
13.3 排查清单
- 锁状态是否由真正的原子 RMW 修改?
- 获取与释放是否使用正确内存序?
- 所有临界区出口是否保证解锁?
- 是否存在同一线程重复获取非递归锁?
- 锁内是否调用 I/O、sleep、日志或未知回调?
- 临界区最坏耗时是否经过测量?
- 线程数是否超过实际可用 CPU 配额?
- 高竞争时是否出现缓存行乒乓?
- 锁是否与无关热变量产生 false sharing?
- 是否需要公平性或超时语义?
-
pshared是否与锁对象的存储位置一致? - 销毁前是否已经 join 所有使用者?
- pthread 返回码是否被逐一检查?
- 是否用 TSAN 和业务不变量双重验证?
- 是否与 mutex 和直接 atomic 做过同条件基准?
13.4 建议练习
练习一:复现未加锁售票错误
把线程数和票数调大,在检查与递减之间加入短暂延迟,结合 ThreadSanitizer 观察数据竞争。
练习二:实现 try_lock
为 C11 自旋锁增加非阻塞尝试接口,返回本次是否获得锁。
练习三:比较 seq_cst 与 acquire/release
在目标机器上编译并查看汇编,再用相同负载测量差异。不要只从内存序名称推断性能。
练习四:实现 TTAS
用 atomic_bool 完成 load + exchange 的双阶段等待,并统计高竞争下原子写尝试次数。
练习五:加入指数退避
记录每个线程的重试次数和等待轮数,比较吞吐、平均延迟和 P99 延迟。
练习六:制造超卖
把线程数设置为 CPU 数量的 2 倍、4 倍和 8 倍,对比 spinlock 与 mutex 的 CPU time 和 wall time。
练习七:观察 false sharing
让锁与热点计数器同处一条缓存行,再用 padding 分离,使用 perf 观察差异。
练习八:实现 ticket lock
用 next_ticket.fetch_add(1) 领取票号,再等待 now_serving,比较公平性与缓存流量。
练习九:把锁替换为原子递减
思考售票只需要唯一编号时,是否可以用原子 fetch_sub 实现;再加入"票号 + 买家记录必须一起更新"的复合不变量,观察为什么锁重新变得有价值。
练习十:设计自旋后阻塞
先固定自旋若干轮,再通过条件变量或 futex 思路等待。重点分析"决定睡眠"和"发送唤醒"之间如何避免丢失通知。
13.5 参考资料
- Linux manual page:
pthread_spin_init(3) - Linux manual page:
pthread_spin_lock(3) - C++ reference:
std::atomic_flag - C++ reference:
std::memory_order - Intel 文档检索入口:
PAUSE - Spin Loop Hint
总结
从表面看,自旋锁只是一个 while 循环;真正理解它,需要把原子性、内存序、调度和缓存一致性放到同一张图中。
完整路径可以压缩为:
text
线程尝试获取锁
↓
原子 test-and-set / exchange
├── 旧值 false:acquire 成功,进入临界区
└── 旧值 true:pause / 只读观察 / 退避
↓
再次争抢
↓
在临界区维护共享不变量
↓
release 解锁
↓
下一位成功者观察到之前的写入
最值得记住的是下面八点:
- 自旋锁用 CPU 忙等换取避免主动阻塞与唤醒的机会,只适合极短且可控的等待。
- 普通
bool和volatile都不能替代原子读改写;检查与占用必须只有一个赢家。 atomic_flag_test_and_set更接近 test-and-set/exchange,不应与 CAS 完全画等号。- 原子性保证互斥,acquire/release 继续保证临界区数据的顺序与可见性。
- TAS 高竞争时会反复争夺缓存行;pause、TTAS 和退避分别缓解不同层次的成本。
- 锁内不要 sleep、I/O 或执行不可控操作;先保存局部结果,再尽快解锁。
pthread_spin_*是 pthread 接口,错误码直接从返回值读取,生命周期必须由明确所有者管理。- 真正的选择标准来自测量:临界区时长、竞争度、CPU 配额、吞吐与长尾延迟缺一不可。
自旋锁最有价值的地方,不只是让我们多认识一种同步原语,而是迫使我们看清:并发控制从来不是"加一把锁"这么简单。它是在正确性已经成立的前提下,对处理器、缓存、调度器和业务不变量共同做出的取舍。