同步工具族
环境: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 会做优先级/等待让步的原因。
所以判据是三句话:
- 临界区短于一次唤醒的成本(本机约 11µs,见第二节)才考虑自旋;
- 必须保证可运行线程数 ≤ 可用硬件线程数,否则自旋变成互相拖垮;
- 一旦要自旋,就自旋有限次再退避(
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_lock | 13.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_once | 3.11ns,且异常安全 |
| 想写自旋锁 | 不要 | 超额订阅下 3.6 倍劣化,且省下的是 6ns |
七、小结
- 无竞争单价(本机):
call_once3.11ns < 自旋锁 7.28ns <std::mutex13.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 线程下不存在,标签必须打印实际读/写数。