引言
原视频The Cost of Concurrency Coordination with Jon Gjengset。
Mutex 真的慢吗
很多人一谈到并发,就会下意识地把性能问题归到锁头上:mutex 很慢,读多写少就该上 reader-writer lock,再激进一点,最好直接换成 lock-free 结构。可真正把问题往下追,你会发现答案并没有这么简单。mutex 当然可能让程序变慢,但慢的根源往往不是“锁这个概念”本身,而是多个 CPU 核之间为了维持同一份内存视图而付出的协调成本。换句话说,表面上你看到的是锁,底层真正发热的是缓存一致性。
这件事之所以值得讲清楚,是因为它会直接改变你选并发原语的方式。很多经验法则在某些场景下有效,但一旦进入高竞争、短临界区、读操作极多的热路径,它们就可能从“经验”变成“误导”。
一个看起来很无辜的实验
先看一个极其简单的场景:一个共享计数器,没有任何写入,只有很多线程反复去读它。也就是说,大家争抢的并不是“更新数据”的权利,而只是想安全地看一眼当前值。
直觉上,这种场景不该慢。单线程下,吞吐量确实很高,大约能做到每秒 2.5 亿次读取;但一旦从 1 个线程增加到 2 个线程,性能会立刻掉到原来的大约十分之一,之后再加线程,曲线基本就贴着低位横着走了。更反直觉的是,换成 reader-writer lock 并不会神奇地解决问题。它在纯读场景下起步和 mutex 差不多,前面下降幅度略小,但随着读线程继续增加,表现反而会逐渐比 mutex 更差。也就是说,“读多写少就上读写锁”并不是放之四海而皆准的真理。
问题来了:如果 mutex 只允许同一时刻一个线程进入临界区,那它的性能大不了维持不变,为什么会随着线程变多而显著变差?答案不在 API 语义里,而在 CPU 怎么搬运和共享内存数据。
真正昂贵的不是“加锁”而是“协调”
CPU 访问内存不是一把梭地直冲 RAM。RAM 离核心太远,访问代价高,所以处理器会在更近的地方放多级缓存,常见的是 L1、L2、L3。越靠近核心,越快,但容量越小;越远,越大,但延迟越高。程序里的一次内存访问,真正快不快,很大程度上取决于数据此刻在哪一级缓存里。RAM 慢,不是抽象意义上的“慢”,而是对 CPU 来说,慢到足以让几百条指令的时间都被吞进去。
更关键的是,CPU 缓存不是按“变量”管理,而是按 cache line 管理。可以把它理解成一块一块固定大小的内存片段,常见大小是 64 字节。你改动其中一个字节,CPU 在乎的并不是这一个字节,而是整个 cache line 的所有权。
为了保证多个核心看到的数据一致,处理器会运行一套缓存一致性协议。常见的一类协议会把一条 cache line 标成几种状态:已修改、独占、共享、无效。核心如果想写一条当前处于共享状态的 cache line,就必须先让其他核心放弃它,拿到独占权,再进行修改。这个“我要写了,你们先别碰”的过程,就是跨核协调成本的来源。只要有共享写入,核心之间就得互相打招呼。锁慢,很多时候慢在这里。
为什么读写锁在纯读场景里也可能越来越慢
这正是并发里最容易踩的认知坑:你以为“读锁”只是读,但在很多实现里,获取读锁这件事本身就是一次写操作。
原因很简单。读写锁内部通常维护着一个“当前有多少个读者”的计数器。每个线程拿读锁时,都要把这个计数器加一;释放时,再把它减一。对业务数据来说,大家只是读;但对锁内部那个计数器来说,所有线程都在写。于是所有核心会为了同一个共享计数器来回争夺 cache line 的独占权,这就形成了典型的 cache line ping-pong。一个核心刚把这条线拿过去改完,另一个核心又要抢过去改;改完之后第三个再抢。线程越多,争用越严重。于是看上去是“读操作很多”,底层实际却是“对一个共享计数器的高频写竞争”。
这种来回弹跳的代价并不小。一次 cache line 转移大约就要几十纳秒;获取一次读锁再释放一次读锁,往往要经历两次这样的转移,好的情况下也已经要吃掉大约 60 纳秒,而访问主内存也不过百纳秒量级。对于“临界区里只有一个哈希表查询”这种极短操作来说,锁协调花掉的时间甚至可能比业务代码本身还多。此时程序慢,不是因为锁“设计得差”,而是因为你把一个高度共享、需要跨核协调的操作放进了最热的路径。
这也解释了为什么 mutex 在这个实验里虽然也掉速,但往往没有读写锁掉得那么离谱。mutex 的本质是串行进入临界区,大家轮流来,争用虽然存在,但不会像“所有读者都同时去写内部计数器”那样让共享 cache line 不断乱飞。换句话说,纯串行不一定快,但有时候比“表面并行、底层共享写竞争”更稳定。尤其在短临界区场景里,这种差别会非常明显。
所以 mutex 到底什么时候是好选择
如果临界区很长,mutex 往往完全没问题。你拿到锁之后,里面有一段足够长的工作要做,那么锁的获取和释放开销就会被摊薄,根本不是瓶颈。现实世界里,大多数代码其实都属于这一类,所以很多程序根本不需要为了“锁可能慢”而提前焦虑。真正需要往缓存一致性、cache line、MESI 这种层面钻的时候,通常已经进入了高竞争、超短临界区、极端性能敏感的专门场景。先测量,再优化,远比一开始就把锁妖魔化更靠谱。
当读特别多时,可以把负担转移给写端
如果问题出在“所有读者都要碰同一个共享 cache line”,一个自然的想法就是:能不能让读者彼此彻底独立,把协调成本尽量推给写者?
一种典型做法是 left-right 数据结构。它的思路非常直接:维护两份数据副本,一份叫 left,一份叫 right。中间放一个原子指针,告诉读者当前应该从哪一份读。写者始终修改“当前不对外提供读取的那一份”;等它改完一批更新,再把中间那个原子指针切过去。之后读者就会开始从另一份副本读,而写者则有机会回头去修改刚刚被切走的那一份。这样一来,读者之间不需要为了同一个读锁计数器互相干扰,读路径可以变得非常轻。对于“读远多于写”的场景,这是一种很有吸引力的权衡。
为了知道“所有旧读者是不是都已经离开旧副本了”,left-right 会给每个读者分配自己的计数器,而且这些计数器要尽量各自占据独立的 cache line。写者在切换指针后,会遍历这些读者计数器,确认每个读者都已经至少前进了一步,才开始安全地修改旧副本。这相当于把原本读路径上的共享写竞争,改造成写路径上的集中扫描。于是,读者可以做到几乎不互相协调,写者承担更多复杂度。
这种设计的收益很直接:读者不暂停、不加锁、不自旋,只需读取指针、更新自己的本地计数,然后访问数据。读路径甚至可以做到 wait-free。实验里,它的吞吐量曲线能随着线程数上升而接近线性增长,因为各个读线程不再需要频繁跨核协商。真正做到并行的前提,不是“没有锁”四个字,而是“没有共享写竞争”。
lock-free 也会翻车,因为没有锁不等于没有争用
很多人听到这里,会进一步得出一个错误结论:那就全换成 lock-free 吧。问题在于,lock-free 不等于 contention-free。
只要多个核心还在争用同一条 cache line,CPU 一样会为了维护一致性付出代价。哪怕线程写的是同一条 cache line 里的不同字段,也照样可能互相拖慢,这就是经典的 false sharing。表面上看,线程 A 只改自己的计数器,线程 B 也只改自己的计数器,彼此互不干扰;但如果这两个计数器恰好挤在同一个 64 字节 cache line 里,那么对 CPU 来说,它们争的仍然是同一个“所有权单位”。结果就是 cache line 继续在核心之间弹来弹去,性能照样掉。left-right 的实现里就出现过这个问题,修复方法反而非常朴素:把保存计数器的类型按 64 字节对齐,强行把这些计数器拆到不同的 cache line 上,问题就明显缓解了。这个例子特别能说明一件事:并发性能瓶颈有时并不在算法名字上,而在内存布局这种更底层的细节里。
所以,判断一个并发结构快不快,不能只看它是不是 lock-free,也不能只看跑分排行榜。很多工具只能告诉你“时间花在了哪里”,却不一定能直接告诉你“为什么这些核心之间要说这么多话”。而真正决定吞吐量的,恰恰常常是这种抽象层之下的通信模式。你最终应该选择的,不是“最快的锁”或者“最潮的无锁结构”,而是最匹配自己数据流动方式的算法。
left-right 很强,但绝不是通用替代品
left-right 很适合读远多于写、读路径极短、允许一定延迟可见性的场景,比如缓存、查询、查表这类工作负载。但它有一整套非常具体的代价。
第一,它要维护两份数据副本,内存成本天然翻倍。第二,读者可能看到旧数据,也就是只能提供某种程度的最终一致性;如果你需要“我刚写完,下一次读一定看到我自己的写入”,那么它就不一定合适。第三,它默认只有单写者;如果你非要支持多写者,通常还得在写侧外面再套一层 mutex,复杂度和收益都会变得尴尬。第四,写操作必须是可重放、可确定的,因为切换指针之后,写者还得把已经应用在一边的修改,再按同样结果应用到另一边,否则两份副本会逐渐漂移成不同状态。这样的限制决定了它不是“把任何现有数据结构一键改造一下”就能得到的方案。对于金融交易这类强一致语义很重的场景,它通常不是好主意;但对缓存和只读查询类业务,却可能非常合适。
真正该问的问题,不是“哪种锁最快”
更有用的问题其实是这些:
你的读写比例是什么样? 你的同步区到底有多短? 线程数量会不会大到让跨核协调开始主导成本? 你能不能接受最终一致性? 你是否需要 read-your-writes、线性一致性这类更强的语义保证?
这些问题的答案,决定了你应该选 mutex、reader-writer lock、left-right,还是别的结构。并发性能优化从来不是选一个“银弹库”扔进去就完事,而是根据工作负载的真实形状去匹配算法。很多时候,最好的优化甚至不是换一种锁,而是重构共享方式,减少需要被多个核心共同写入的状态。线程越多,越要珍惜每一次跨核协调。因为一旦共享写入进入热路径,系统真正支付的代价往往比你以为的大得多。
最后一句话
所以,mutex 真的慢吗?
答案依然是否定的,但要补上一句:慢的从来不只是 mutex,慢的是协调。 锁只是你在代码层面触碰到的接口,真正昂贵的是 CPU 为了保证正确性而做的缓存一致性维护,是 cache line 在多个核心之间来回转移,是那些你在源码里看不到、却实实在在发生在硬件里的通信。理解了这一点,再去看“读写锁为什么在纯读场景里也会掉速”“为什么无锁结构也会被 false sharing 拖垮”“为什么有时最朴素的 mutex 反而是更稳妥的方案”,很多现象就都顺了。真正成熟的并发优化,不是迷信某一种原语,而是学会识别你的程序到底在让 CPU 为哪一种协调买单。