互斥锁与临界区
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),
g++ -std=c++17 -O2 -Wall -Wextra -static。锁的价格都取 5 次重复的中位数;死锁用父进程看门狗判定,不靠”我看着像卡住了”。
一、数据竞争的准确定义
不是”两个线程同时碰同一个变量”。正确表述是四个条件同时成立:
- 两个以上线程执行;
- 访问同一个内存位置;
- 至少一个是写;
- 二者之间没有 happens-before 关系(没加锁、没原子、没 join)。
满足就是未定义行为,不是”偶尔值不对”。这一点必须用实验来体会,因为三种”看起来安全”的现象全都是假的。
假象一:编译器把竞争消掉了,你复现不出来
4 个线程各做 200 万次 ++plain,期望 800 万:
非 volatile 期望 8000000 得到 8000000,丢 0(0.0%),耗时 0.4 ms
volatile 期望 8000000 得到 2345959,丢 5654041(70.7%),耗时 5.0 ms
同一个源文件,只加了 volatile,差别就在:非 volatile 版本被编译器改成了”每线程寄存器里累加,最后写回一次”(0.4ms 做 800 万次内存 RMW 是不可能的,这就是证据)。它看起来对,是因为它根本没在内存上竞争。加上 volatile 强制每次真的读-改-写,立刻丢掉 70.7%。
所以”我跑了一百次都对”不能证明没有竞争,只能证明编译器这次没把你的读提出循环。
假象二:编译器不报错
上面这个程序用 -Wall -Wextra 编译,零条警告。GCC 对数据竞争的态度是:这是 UB,但它没有义务诊断。
假象三:你以为有 ThreadSanitizer 兜底
$ g++ -std=c++17 -fsanitize=thread m1.cpp -o m1ts.exe
ld.exe: cannot find -ltsan: No such file or directory
MinGW-w64 的 GCC 13.1 没有 libtsan(和上一节遇到 -lubsan 缺失同源:MSVCRT 运行时不支持)。也就是说在 Windows/MinGW 上写并发代码,你手上没有现成的竞争检测器,只能靠:结构上让共享可变数据只有一个归属者、或者用原子/锁、或者把程序放到 Linux 上用 TSan 跑一遍。
真正的危害长什么样:不变量撕裂
一个线程维护 a + b == 1000,另一个线程只读:
读 20000000 次,看到 a+b != 1000 的次数 = 10432843(52.16%)
写的一侧是 --a; ++b; 两条指令,读的一侧夹在中间就看到了 999。单个对齐的 64 位读写在 x86 上确实不会撕裂,但”多字段的一致性”从第二条语句起就是暴露的——这才是需要锁的原因,而不是因为 CPU 会把一个 int 读一半。
二、锁到底值多少钱
单线程、无人竞争(uncontended)时,一次 lock/unlock 往返的单价:
store relaxed(对照,不带 RMW) 0.17 ns
fetch_add acq_rel 5.31 ns
fetch_add seq_cst 4.98 ns
fetch_add relaxed 5.45 ns
lock_guard<std::mutex> 16.65 ns
手写 mutex.lock/unlock 16.42 ns(RAII 的开销 = 0.23 ns)
lock_guard<recursive_mutex> 18.39 ns(比普通锁贵 10.4%)
lock_guard<timed_mutex> 18.09 ns(比普通锁贵 8.6%)
四条结论:
- RAII 是免费的(0.23ns),别为了”省一次析构”手写 lock/unlock,你省下的是异常安全。
- 锁比原子贵 3 倍(16.65 vs 5.31),但原子只能保护一个变量,锁能保护一组不变量。想用一个原子变量替换两把锁,通常只是把撕裂问题搬了个地方。
recursive_mutex只贵 10%,但它是病灶而不是药(见本篇第五小节)。timed_mutex贵 8.6%——而且贵得冤枉:它的超时精度在 Windows 上完全是另一个问题(本篇第四小节)。
竞争下,价格由临界区长度决定
8 个线程抢同一把 std::mutex,各做 2 万次加锁(共 16 万次),临界区里插一段用 now() 计时的忙等,名义长度分别为 0/50/200/1000ns:
临界区名义 0 ns: 18.8 ms → 每次加锁摊到 0.12 µs
临界区名义 50 ns: 28.2 ms → 0.18 µs
临界区名义 200 ns: 50.4 ms → 0.31 µs
临界区名义 1000 ns: 229.7 ms → 1.44 µs
无竞争时锁是 16.65ns,8 线程抢它时是 120ns(7 倍);临界区长一点,摊到每次的价格就到 1.44µs(86 倍)。原因是被抢到的锁要把等待线程投入内核睡眠再唤醒——正好是上一节量的那个 11.35µs 级别的交接成本的一部分。一句话:锁的费用 ≈ 持有时长 × 竞争者数,不是那次 atomic exchange。
三、把活搬出锁:实测 5.5 倍
同一个程序三种写法,每个线程做 2 万次「往共享 vector 追加 + 一段 4000 项的累加计算」:
T= 1 整段持锁 86.7 ms | 锁内只拷贝 87.2 ms | 每线程分片 89.8 ms
T= 2 整段持锁 231.6 ms | 锁内只拷贝 102.5 ms(快 2.3 倍)| 分片 88.6 ms(快 2.6 倍)
T= 4 整段持锁 437.8 ms | 锁内只拷贝 95.5 ms(快 4.6 倍)| 分片 120.0 ms(快 3.6 倍)
T= 8 整段持锁 886.8 ms | 锁内只拷贝 160.5 ms(快 5.5 倍)| 分片 155.4 ms(快 5.7 倍)
关键读法:T=1 时三种写法一样快(86.7 / 87.2 / 89.8ms),说明差异不是代码差异,纯粹是”锁把并行度抹平了”。整段持锁的版本在 T=8 时比 T=1 慢 10 倍(86.7→886.8ms)——加线程反而加时间,这是临界区里干重活的典型曲线。
再看等待类操作放进锁里的爆炸半径,8 线程 × 200 次,临界区里有一次”等 2ms”:
等待放锁里:24379.5 ms
等待放锁外: 3044.3 ms
8 倍。而且 24.4 秒不是 1600×2ms:因为 Sleep(2) 在本机实际睡 15.2ms(第一节量的定时器粒度),1600 次串行化 = 24.3 秒,对得上。
于是有三条硬规矩:
- 锁里不放:I/O、
Sleep、条件等待、网络、磁盘、用户回调(回调意味着你不知道别人会在里面待多久)。 - 缩短持有时长优先于减少加锁次数:拷贝一份快照出来算,比”少加一次锁”有效得多。
- 能不分共享就不分:每线程一个分片、退出时合并(上表的分片版),一次锁都不用在热路径上。
四、try_lock 与”超时”的坑
空闲时 try_lock_for(50µs):0.049 µs/次(成功)
锁被占用时 try_lock(): 7.45 ns/次(返回 false,200 万次尝试 0 次误判成功)
recursive_mutex 被占用时 try_lock():成功 0 / 200000
std::mutex 被占用时 try_lock():成功 0 / 2000000,平均 7 ns
try_lock() 失败只花 7ns,是免费的探路——先试它、失败就自己有界自旋几轮、再决定阻塞,是标准做法。
但 try_lock_for 在 Windows/MinGW 上是个陷阱。持锁者明确在临界区内时,各种名义超时的实测:
请求 10 µs:失败平摊 15921.3 µs/次
请求 50 µs:失败平摊 15708.1 µs/次
请求 200 µs:失败平摊 15830.6 µs/次
请求 1000 µs:失败平摊 15264.7 µs/次
请求 5000 µs:失败平摊 15757.0 µs/次
请求 20000 µs:失败平摊 31945.4 µs/次
请求 10µs 也要等 15.9 毫秒才告诉你失败,请求 20ms 会等 31.9ms(两个 tick)。这就是第一节的定时器粒度在同步 API 上的体现:相对超时被向上取整到 ~15.6ms 的 tick。所有轮次里”成功时持锁者仍在临界区”的计数都是 0,所以互斥语义没坏——坏的的是时间精度。
补一句边界:15.6ms 是进程默认分辨率。宿主程序若有活动的高精度定时器(
timeBeginPeriod(1),或 Qt 的事件循环),整个进程的 tick 会被抬到 1ms,这些数字会一起缩到 1ms 级——第一节的timeBeginPeriod实测与 Qt 篇的QTimer(1ms)=1.05ms都是这件事。结论不变:短超时的退避方案在 Windows 上不可靠,因为它依赖一个不由你控制的常数。
顺带一个读数据的教训。我先跑的另一版测量给出:
try_lock_for(50µs) 抢不到时超时返回: 75.41 µs/次(成功 19904 次)
平均 75µs、成功 19904/20000,两个数都像”锁被占用时也能大量成功”——但拆开算就反过来了:前 96 次每次卡 15.9ms(合计 1.5 秒,正好是持锁者的 Sleep(1500)),持锁者一退出,剩下 19904 次各花 0.049µs。总计 1.508 秒 ÷ 20000 = 75.4µs。平均值把 bimodal 分布抹平了,看到”平均”先去数一下有多少次落在哪个峰上。
结论:Windows 上不要用短超时锁做退避;要用 try_lock() + 自己的 YieldProcessor() 有界自旋,最后才 lock() 阻塞。
五、死锁:四条件与四种破坏
同一份代码,两个线程,两把锁,只有加锁顺序不同。父进程给 6 秒看门狗:
两把 std::mutex,两个线程反序加锁 ⇒ 卡死:6.0 秒没退出,强制终止
换成 std::recursive_mutex,其余不变 ⇒ 卡死:6.0 秒没退出,强制终止
两个线程都用 std::lock(A, B) ⇒ 0.03 秒正常退出(码 0)
两个线程都按固定地址顺序加锁 ⇒ 0.02 秒正常退出(码 0)
卡死前两个线程各打印了 2 个 checkpoint 然后永远沉默——死锁不报错、不崩溃、不返回,就是不动。这是为什么必须有看门狗/超时监控,而不是靠人盯日志。
四条必要条件与破坏方式:
| 条件 | 破坏手段 | 代价 |
|---|---|---|
| 互斥 | 做不到(锁本来就是互斥的) | — |
| 持有并等待 | 一次把所需锁全要(std::lock/scoped_lock) | 并发度略降 |
| 不可剥夺 | try_lock 失败就全部释放、退避重试 | 逻辑复杂、活锁风险 |
| 循环等待 | 全局固定加锁顺序 | 最简单,首选 |
实测:recursive_mutex 救不了跨线程死锁,因为递归锁解决的是”同一线程再次进入同一把锁”,而循环等待是两个线程互相等对方的锁。把 mutex 换成 recursive_mutex 只是把崩溃推迟,同时付出 +10.4% 的单价,并且让”重入即安全”的错觉留在代码里。需要重入时,正确做法是拆出不加锁的内部函数(foo_locked()),公开方法负责加锁后调用它。
多锁的写法成本(uncontended,2 把锁,5 次中位数):
两个 lock_guard(顺序必须自己保证) 25.07 ns
std::lock + adopt_lock 26.45 ns(1.06 倍)
std::scoped_lock(m1, m2) 24.91 ns(0.99 倍)
std::scoped_lock 和手写两个 lock_guard 一样快(24.91 vs 25.07ns,差别在噪声内),却自带防死锁的顺序处理。C++17 起多锁一律写 scoped_lock,没有理由手写两个 lock_guard。
最后一条:别把”加超时”当死锁解药。除了上面 15.9ms 的粒度问题,超时时拿不到第二把锁意味着你已经释放了第一把——如果业务上”半个锁”期间观察过共享状态,这个中途放弃可能留下不一致的中间态。超时只适合”我可以整件事重做一遍”的场景。
六、小结
- 数据竞争 = 无 happens-before 的并发读写,是 UB;本机
-Wall -Wextra零警告、-fsanitize=thread直接链不上、非 volatile 版本连丢数据都被优化掉——三道常见的保险在这套工具链上全是空的。 - 丢更新量化:volatile 强制真读写后,800 万次自增丢掉 70.7%;不变量撕裂 52.16% 的读能看到违规状态。
- 单价:无竞争锁 16.65ns、原子 5ns、8 线程争抢 120ns、临界区 1µs 时摊到 1.44µs。锁的费用主要来自”把别人叫醒”,不是来自那次原子交换。
- 优化顺序:缩小临界区 > 减少加锁次数 > 换更快的锁。搬出锁的实测收益 5.5 倍,分片 5.7 倍,换锁种通常只有 10–30%。
- Windows 上
try_lock_for名义 10µs 实测 15.9ms;短超时退避在这个平台上不成立。 - 死锁靠固定顺序或
scoped_lock预防;recursive_mutex不是解药(实测同样卡死);超时不是解药;监控要靠看门狗,因为死锁的表征是”安静”。