跳到正文

互斥锁与临界区

环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),g++ -std=c++17 -O2 -Wall -Wextra -static。锁的价格都取 5 次重复的中位数;死锁用父进程看门狗判定,不靠”我看着像卡住了”。

一、数据竞争的准确定义

不是”两个线程同时碰同一个变量”。正确表述是四个条件同时成立:

  1. 两个以上线程执行;
  2. 访问同一个内存位置;
  3. 至少一个是写;
  4. 二者之间没有 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 秒,对得上。

于是有三条硬规矩:

  1. 锁里不放:I/O、Sleep、条件等待、网络、磁盘、用户回调(回调意味着你不知道别人会在里面待多久)。
  2. 缩短持有时长优先于减少加锁次数:拷贝一份快照出来算,比”少加一次锁”有效得多。
  3. 能不分共享就不分:每线程一个分片、退出时合并(上表的分片版),一次锁都不用在热路径上。

四、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 不是解药(实测同样卡死);超时不是解药;监控要靠看门狗,因为死锁的表征是”安静”。