跳到正文

同步工具族

环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),g++ -std=c++20 -O2 -Wall -Wextra -static。这一节全部数字都是本机同期有 4 个后台任务在抢 CPU 的条件下测的:单项跑 3 轮取中位数,比值比绝对值可靠。C++20 的 std::barrier、std::counting_semaphore 在这套工具链上确实可用(下面有实测输出)。

一、先把单价排出来(无竞争,单线程)

30 万次加解锁 × 3 轮取中位数:

  std::mutex           13.29 ns = 1.00x
  自旋锁 atomic_flag    7.28 ns = 0.55x
  counting_semaphore   28.60 ns = 2.15x
  binary_semaphore     29.18 ns = 2.20x
  shared_mutex 独占    65.83 ns = 4.95x
  shared_mutex 共享    75.95 ns = 5.71x
  call_once 已完成      3.11 ns = 0.23x

四条读数:

  • call_once 的”已经初始化完了”路径只要 3.11ns,比 std::mutex 便宜 4 倍(13.29ns)。所以双检锁的”手写版”没有存在必要——std::call_once 更快、且标准保证异常安全(初始化抛异常时会重置标志,允许重试,这是手写双检锁最容易漏的一条)。
  • 自旋锁在无人竞争时只值 7.28ns,比 mutex 快 45%。听起来该用它,但本篇第四节会给出反例:这份便宜在超额订阅时会翻成 3.6 倍的劣势。
  • 信号量贵 2.2 倍(28.60ns)。它的语义比锁多一层”计数”,acquire 要判断计数、release 可能要唤醒等待者。
  • shared_mutex 是无竞争里最贵的:独占 65.83ns、共享 75.95ns。而且共享锁比独占锁还贵。这直接说明 libstdc++ 的 shared_mutex 不是”一条原子指令”的实现,而是”内部互斥锁 + 读者计数 + 条件变量”的复合结构——拿读锁也要动那把内部锁。

顺便一句方法论:同一列里我还留了一个”裸累加”对照组,它被 -O2 直接算成闭式,报出 0.00ns——这类”太快以至于不可能”的数字要当噪声处理。我把它剔掉了,只报以上七项。

二、读写锁:这台机器上它几乎没有赢过

8 个线程、每个 3000 次操作,读者只把共享变量累加进本地(临界区极短),写者做 ++data。表里”写入次数”是那一轮实际发生的写:

  读者   0%(全写)   mutex  1.3 ms | shared_mutex  4.0 ms | 读写锁 0.33x | 写入 24000 次
  读者  50%(4读4写) mutex  1.2 ms | shared_mutex  4.8 ms | 读写锁 0.25x | 写入 12000 次
  读者 100%(全读)   mutex  1.1 ms | shared_mutex 10.7 ms | 读写锁 0.11x | 写入     0 次

全读场景下读写锁反而慢 9 倍(10.7ms vs 1.1ms)。原因就在本节第一小节的单价:8 个读者并发进入 shared_lock 时,仍然要在那把内部互斥锁上排队,而 std::mutex 只需一次 13ns 的原子往返。

顺带一个必须写出来的测量错误:我原本还有一行”读者 90%“。用 i * 100 / T < pct 分桶时,T=8 的整数除法给出 0/12/25/37/50/62/75/87,根本没有 90 这一档,8 个线程全被划成读者,那一行和”全读”是同一次实验。想要 8:1 的读写比,线程数至少要 16。分桶逻辑要打印出实际的读/写线程数,不能只看标签。

那读写锁什么时候才划算?把读临界区拉长(里面做 N 次累加,仍然只读):

  读临界区    0 次累加:mutex   0.5 ms | shared_mutex  4.9 ms | 读写锁 0.10x
  读临界区  100 次累加:mutex   1.0 ms | shared_mutex  5.5 ms | 读写锁 0.18x
  读临界区  400 次累加:mutex   3.6 ms | shared_mutex  5.8 ms | 读写锁 0.62x
  读临界区 1600 次累加:mutex   7.2 ms | shared_mutex  7.9 ms | 读写锁 0.91x

规律清楚了:读写锁的收益全部来自”读者真的能同时待在临界区里”。临界区越短,固定开销(5.7x 单价)越占主导;临界区长到 1600 次累加才勉强追平。所以判据是:

读临界区的执行时间必须显著大于”读写锁单价 − 普通锁单价”(本机约 63ns),读写锁才可能回本;想赢 2 倍以上,得让单次读的实际工作到微秒级(遍历一个中等容器、解析一段快照)。只是”读一个字段”的话,std::mutex、原子量或者拷贝-改-换(COW 快照)都比 shared_mutex 快。

还有一个标准层面的坑:std::shared_lock 拿到的不是独占语义,写者必须在所有读点之外;而且 libstdc++ 的 shared_mutex 默认是”读者优先/无写者优先”的实现风格,长期有读者进出时写者可能饿死——这不是本机能量化的点,留给你在真实负载里压。

三、信号量与屏障:能用了,但各有代价

std::counting_semaphore 做限流,16 个线程、上限 4,验证”最多 4 个同时进去”:

  16 线程 ×300 次、上限 4:最大并发=3 完成=4800 用时 1.5 ms
  8 线程 ×400 次带竞争 acquire/release:1.1 ms ⇒ 0.34 µs/次(值=3200 期望 3200)

观测到的最大并发是 3,不是 4——不是信号量放少了,而是我放进去的工作量只有 30 次浮点加(约 30ns),窗口太窄,抓不到 4 个线程同处的那一刻。这是一个通用的测量教训:要观测”并发峰值”,临界区必须比采样动作长。想知道真实上限,把里面的活改成 1ms 级别的 sleep 或原子时钟打点,才谈得上”同时”。

带竞争的 acquire/release 摊到 0.34µs/次,是无竞争单价(28.60ns)的 12 倍——和第二节锁的行为一样:竞争时的价格由唤醒成本决定,不由计数逻辑决定。

std::barrier(C++20)每轮同步的开销:

  P= 2 × 500 轮:  7.3 ms ⇒ 每次 arrive_and_wait 14.59 µs(work=1000)
  P= 4 × 500 轮: 22.9 ms ⇒ 每次 arrive_and_wait 45.81 µs(work=2000)
  P= 8 × 500 轮: 25.3 ms ⇒ 每次 arrive_and_wait 50.57 µs(work=4000)
  P=12 × 500 轮: 16.0 ms ⇒ 每次 arrive_and_wait 31.95 µs(work=6000)

每轮 15–50µs,且不单调(P=12 比 P=8 快)。屏障的代价本质是”等最慢的那个人”,而这台机器当时正被其他任务抢占,谁最慢是随机的。所以这几行只能得出一个结论:std::barrier 每轮是十微秒量级,比一次原子操作贵三个数量级,不要把它放进细粒度循环;用它做”分阶段并行”(每轮做几万件事)才划算。work 计数(1000/2000/4000/6000 = P × 500)说明语义正确:每轮所有人都到了才放行。

信号量还有第三种用法值得记:计数资源限流(连接池、并发下载数)。它的语义是”计数 + 阻塞”,不是”持有 + 释放”,因此 release() 可以被不是 acquire() 的那个线程调用(这是合法的,也是 counting_semaphore 与 mutex 的语义分水岭)。

四、自旋锁:什么时候划算,什么时候是自杀

我写的最小自旋锁(atomic_flag::test_and_set(acquire) + YieldProcessor()):

struct SpinLock {
    std::atomic_flag f = ATOMIC_FLAG_INIT;
    void lock()   { while (f.test_and_set(std::memory_order_acquire)) YieldProcessor(); }
    void unlock() { f.clear(std::memory_order_release); }
};

无竞争时它 7.28ns,比 std::mutex 快 45%。8 线程抢 12000 次自增时也确实快一倍:

  std::mutex                1.2 ms  值=24000 每次加锁摊 0.05 µs
  自旋锁                 0.5 ms  值=24000 每次加锁摊 0.02 µs
  shared_mutex 独占       3.7 ms  值=24000 每次加锁摊 0.15 µs

然后把机器弄脏:再起 4 个只做 ++x 的可运行线程(模拟”别人也在抢核”,也就是超额订阅)。

  捣乱线程 0:std::mutex      0.5 ms(值=12000 期望 12000)
  捣乱线程 0:自旋锁       0.4 ms(值=12000 期望 12000)
  捣乱线程 4:std::mutex      0.5 ms(值=12000 期望 12000)
  捣乱线程 4:自旋锁       1.8 ms(值=12000 期望 12000)

加进 4 个捣乱线程,std::mutex 一点不变(0.5ms),自旋锁涨到 1.8ms,3.6 倍。 原因很直白:自旋者假设”持锁的人很快就能拿到 CPU”,而超额订阅下这个假设破产——持锁线程被调度走了, spin 线程却在核上原地烧掉它的时间片,形成”我等的人需要 CPU,而我在替他占着 CPU”。这正是内核里”持锁禁止抢占”和 Windows 上 SRWLOCK 会做优先级/等待让步的原因。

所以判据是三句话:

  1. 临界区短于一次唤醒的成本(本机约 11µs,见第二节)才考虑自旋;
  2. 必须保证可运行线程数 ≤ 可用硬件线程数,否则自旋变成互相拖垮;
  3. 一旦要自旋,就自旋有限次再退避(YieldProcessor → Sleep(0) → 阻塞),而不是一直转到天荒地老。

现实建议:不要自己写生产用的自旋锁。Windows 的 SRWLOCK(AcquireSRWLockExclusive)、Linux 的 futex 已经在用户态做了”先短自旋再阻塞”的混合策略,std::mutex 在两个平台上都桥接到它们。上表里 std::mutex 的 13.29ns 已经是”用户态快速路径”的水平,自旋锁只省 6ns,却把调度假设写死进了你的代码。

五、once 与惰性初始化:直接用标准件

本节第一小节给出单价:call_once 已完成路径 3.11ns,比 std::mutex(13.29ns)便宜 4 倍,比自旋锁(7.28ns)也便宜。加上它的异常语义(初始化抛异常时标志会被复位,下次调用重试),手写双检锁没有任何理由存在:

std::once_flag flag;
Config& cfg() {                     // 正确且快
    static Config c = [] { return Config::load(); }();   // 其实函数内静态就够
    return c;
}

补一句更实用的:C++11 起”函数内静态局部变量”本身就是线程安全的惰性初始化,连 once_flag 都不必写。本机 GCC 13.1 -O2 反汇编验证了它有多轻:

; struct Big { long a; Big():a(42){} };  Big& get(){ static Big b; return b; }
   c: movzbl 0x0(%rip),%eax      ; 读守卫字节
   e: test   %al,%al
  10: lea    0x8(%rip),%rax      ; 已初始化 → 直接返回对象地址
  1c: ret
  20: ...                        ; 未初始化才走 __cxa_guard_acquire

热路径就是一次字节 load + 一次 test + 一次分支。更省的是可零初始化的对象:static Big b{};(无需动态初始化)编译出来只剩 lea; ret,守卫直接不存在:

; Big& get() { static Big b{}; return b; }
   0: lea    0x0(%rip),%rax
   7: ret

要不要显式 call_once,取决于你能否接受静态对象在程序退出时才析构(以及你需要”抛异常可重试”的语义时,call_once 才是正解)。

六、选路清单

场景选什么本机依据
保护几个字段的小临界区std::mutex + scoped_lock13.29ns 无竞争,竞争时 0.05µs
只读一个标量/指针原子量或 COW 快照比任何锁都快一个量级
读极重、写极少,且单次读 µs 级std::shared_mutex只有读临界区 ≥1600 次累加才追平 mutex
限并发数(连接池、下载数)std::counting_semaphore语义正确(4800 次全部串行在 4 槽内),带竞争 0.34µs
分阶段并行的回合点std::barrier每轮 15–50µs,只适合粗粒度回合
一次性初始化静态局部变量 / call_once3.11ns,且异常安全
想写自旋锁不要超额订阅下 3.6 倍劣化,且省下的是 6ns

七、小结

  • 无竞争单价(本机):call_once 3.11ns < 自旋锁 7.28ns < std::mutex 13.29ns < 信号量 28.60ns < shared_mutex 独占 65.83ns < 共享 75.95ns。读写锁的共享路径是最贵的,不是最便宜的——libstdc++ 用”内部锁 + 计数 + 条件变量”实现它。
  • 读写锁只有在读临界区真的很长(本机要 1600 次累加量级)时才回本;读一个字段用读写锁是负优化(全读时慢 9 倍)。
  • 信号量与屏障在 MinGW 的 GCC 13.1/C++20 下可用且语义正确,但竞争时的价格是唤醒价格(0.34µs、每轮 15–50µs),不是计数价格;细粒度循环里别用。
  • 自旋锁的优势只有 6ns,代价是把”持锁者一定能被调度”写进代码:多 4 个可运行线程就慢 3.6 倍。混合等待(有限自旋 + 退避 + 阻塞)才是正确答案,而那件事系统锁已经替你了。
  • 两个测量自身的教训:并发峰值只有在临界区长于采样动作时才测得到(我读到 3 而非 4);整数分桶的”90% 读者”在 8 线程下不存在,标签必须打印实际读/写数。