Are Mutexes Slow——互斥锁真的慢吗?

文章简介

这是Jon Gjengset的演讲稿,原文为《The Cost of Concurrency Coordination》。

文章指出,让并发变慢的不是锁本身,而是核心之间的协调------其背后真正原因是CPU的缓存一致性协议。

作者简介

Jon Gjengset是Rust社区知名的技术专家和布道者,是书籍《Rust for Rustaceans》的作者。

他毕业于麻省理工,拥有并行与操作系统组的博士学位;期间开发了Noria项目,后来商业化成功并以初创公司ReadySet形式继续推进。

他现于欧洲顶级防务AI公司Helsing担任首席工程师(Principal Engineer),在这之前,曾在AWS维护Rust Build基础设施。

性能崩塌现象

简单测试:一个共享计数器

Rust 复制代码
// 来自Jon Gjengset
let counter = Arc::new(Mutex::new(0u64));

loop {
    letmut guard = counter.lock().unwrap();
    std::hint::black_box(*guard);
}

首先定义了一个计数器counter,它的初始值为一个64位无符号整数0

Arc<Mutex<T>>负责多线程之间数据的安全并发共享。

循环里的操作就是尝试读这个计数器counter

black_box防止Rust编译器把这个看似无意义的循环优化掉。

在这个过程中,只对数据进行读取,而没有写操作。

测试结果:Mutex与RWLock没区别?

图来自于Jon Gjengset的视频。

在上面的测试基础上,Jon Gjengset对比了Mutex和RWLock在多线程下的表现。

对于Mutex来说,单线程时,它每秒能够达到2.5亿次操作。这意味着,在一个2.5GHz的CPU上,完成加锁读取的操作大约只需要10个时钟周期。

然而,一旦线程数增加到2,那么性能会直接断崖式下跌到原来的十分之一,从2.5亿次操作每秒降到2500万次每秒。

而之后,继续增加线程,下降也不再明显,折线图趋势较为平坦。

互斥锁的测试结果也许在意料之中,然而读写锁的测试结果却和互斥锁极为相似。

同样地,RwLock也在线程数增加到2的时候性能暴跌差不多十倍左右。

也许开始的时候,读写锁性能下降到程度比读写锁稍微好一点,然而,随着线程的增加,最终读写锁的性能甚至比互斥锁还要差一点。

这是一个违反直觉的结论,因为读写锁RWLock在设计之初就是为了读多写少的场景设计的,然而在这里------全读操作的测试表现却并没有比互斥锁优越很多、甚至可能还要差。

这是为什么呢?

幕后黑手:CPU缓存一致性

CPU缓存

图来自于Jon Gjengset的视频。

从制造工艺、材质特性与物理距离来说,RAM的访问延迟速度比CPU大得多,大概在百倍左右。

为了平衡它们之间的速度差距,于是有CPU缓存。

而CPU缓存能够提升速度的原因,主要在于程序运行的局部性原理。

一是时间局部性,刚被使用过的数据,短时间内大概率还会被重复访问;二是空间局部性,如果一段数据被访问了,那么它周围的数据大概率会被接连访问。

同时,为了兼顾速度与成本,CPU缓存一般有三级缓存。

这其中,L1缓存速度最快、容量极小,离CPU最近,并且每个CPU核心独享;L3缓存离CPU比较远、离内存更近,速度较慢、容量较大,所有核心共享;L2缓存一般介于二者之间。

缓存一致性协议与缓存行

在上述提到的CPU缓存中,有些是CPU核心独享的,例如L1缓存。

那么当核心A和核心B都在缓存中独享了一份变量的副本后,如果A将变量的值改变,而B不知道,那么就会因为数据错误而导致程序崩盘。

这就是缓存一致性带来的问题。

于是,为了解决缓存一致性问题,需要某种缓存一致性协议(MESI)来商量由谁写、什么时候写入,并确保正确的值最终能够回到内存。

也许这个协议还有很多变体(MSI、MOESI等),但它们大多数都与MESI类似。

MESI的核心思路是为缓存行标记4种不同的状态,协议的名字正是这四个字母的缩写:

  • Modified:已修改。该缓存行已被当前核心修改,与主存中不一致。
  • Exclusive:独占。该缓存行数据与主存一致,且由当前CPU核心缓存独占。
  • Shared:共享。该缓存行数据与主存一致,同时存在于其他核心缓存中,只读不写。
  • Invalid:无效。该缓存行数据已失效,需重新获取。

同时,缓存行是CPU缓存中的最小存储单位。

CPU从内存中读取数据时,不是按照字节读的,而是按照缓存行读取的。

缓存行大小一般为64字节。

RWLock为何会慢?

对共享数据的写操作需要在CPU核心之间进行协调。

如果当前有一个处于"共享状态"的缓存行,并且当前CPU核心想修改它,那么这个核心需要和其他核心进行协调,因为它要保证其他核心不会在这个时候去修改、读取这个缓存行。

而这种跨核心通信正是部分性能开销都来源。

RWLock的底层实现

Rust 复制代码
// 来自Jon Gjengset
pub fn read(&self) -> RwLockReadGuard {
    self.reader_count.fetch_add(1, ...);
}

而读写锁,在底层实现中,本质上是一个状态机。

它一般由计数器、写锁标志、内部互斥锁和等待队列组成。

计数器用来记录当前读者线程数量,上面的伪代码就是一个计数器reader_count实现的一部分,所以哪怕只是读操作,也需要对这个计数器执行+1的写操作。

跨核心传输的消耗

(图来自Jon Gjengset的视频)

那么,当两个核心Core 0和Core 1都想获取读锁时:

  • 初始状态:Core 0独占计数器reader_count,Core 1缓存里没有这个计数器
  • Step 1:Core 0加读锁
    • Core 0加读锁,把计数器+1,由于它本来就独占计数器,所以不需要通知任何核心,状态从E(独占)改为M(已修改)
    • Core 1状态仍旧是I(无效)。
  • Step 2:Core 1也要加读锁
    • Core 1也要加读锁,但是发现自己的状态是I(无效),于是向总线广播,表示它要修改这个计数器。
    • Core 0收到了这个消息,于是把修改后的数据和控制权一起转移给Core 1,让Core 1把计数器继续+1
    • 此时,Core 0的状态为I(无效),Core 1的状态为M(已修改)。
  • Step 3:Core 0释放读锁
    • Core 0打算释放读锁,把计数器-1,然而发现自己状态无效。
    • 于是重走了一遍Step 2,Core 1把值为2的计数器和控制权一起转回给Core 0.
    • 此时Core 0的状态变为M(已修改),Core 1的状态变为I(无效)。

(图来自Jon Gjengset的视频)

从上述的例子可以看到,哪怕是只有两个CPU核心参与的并发读,带有这个计数器reader_count的缓存行都在Core 0和Core 1之间来回跑了两次(Step 2和Step 3)。

而单次跨核心过程需要花费30ns左右,上述过程大约需要60ns,而相比于直接从L1缓存中读取数据所花费的1ns,实际上速度非常慢。

而如果把这个核心数放大到100个,那么原本看似互不干涉的读操作,在底层实际上会变成一个都在等待M状态的串行单行道,效率极其低下。

损耗的实际影响:长临界区 vs 短临界区

(图来自Jon Gjengset的视频)

所以在上述跨核心消耗不可避免的情况下,某些时候互斥锁Mutex可能会比读写锁更有优势。

因为对于互斥锁来说,每个核心在持有锁期间锁的控制权是确定的,完全可以接受一个个串行执行;而读写锁的读过程却不一定,无法避免跨核心的消耗,而这种竞争消耗随着读者的增加而变得激烈。

当与锁交互时,往往是先获取锁,运行逻辑代码,再释放锁。

如果逻辑代码很长,即拥有长临界区,那么获取释放锁的这一部分开销可以忽略不计;而如果临界区很短,那么锁的开销可能会占据90%以上,这并不划算。

解决思路:Left-Right数据结构

Left-Right原理

图来自Jon Gjengset的视频。

这是一种以空间换时间的算法。

它维护了两个完全一样的对象副本(Left copy和Right copy)、一个原子指针,以及为每个读线程分配的独占缓存行的计数器。

当进行读操作时,无需加锁也无需等待,它只需:

  • 判断原子指针read_pointer指向的副本,假如被指向的副本是Left copy
  • 在自己的专属计数器上自增
  • 直接去读Left copy中的数据

在进行写操作时,有以下步骤:

  • 假如当前read_pointer指针指向Left copy,那么写者就会悄悄修改Right copy
  • 当Right copy里的数据被改完后,中间的指针将会从左且到右
    • 此时新来的读者会读取Right copy
    • 而旧的读者仍旧会读取Left copy
  • 此时指针会遍历读者的计数器,等待Left copy中的读者清空
    • 计数器的值变成了偶数,说明不在临界区里(进出临界区计数器都会自增)
    • 计数器的值比指针切换前增加了,说明它已经去读Right copy里的内容了
  • 当Left copy里的写者被清空后,写者就会进入,把两个副本的数据统一

在这个过程中,Right copy和Left copy并没有指定谁是永远读、永远写的,它们承载的读写功能是根据程序具体的运行情况灵活改变的。

而指针指向哪一边,读者就会去读哪个副本的内容。

(图来自Jon Gjengset的视频)

由于每个读者都独占属于自己的计数器、不共享缓存行,所以可以避免上面的消耗,让读性能随着线程数的增加呈线型增长。

无处不在的伪共享

当Jon Gjengset对Left-Right架构进行基准测试时,遇到了一个问题。

理想情况应该是随着核心数增加,读性能也呈线性增长;然而当核心数量增加到4时,性能却断崖式下滑了将近10倍,这并不符合预期。

在排除了NUMA(非统一内存访问)或跨CPU插槽的问题之后,Jon Gjengset发现是伪共享的问题。

因为在原来的程序中,多个读者的内置计数器被分配在了同一个缓存行中,所以只要一个读者线程去对它的计数器进行写入,在同一个缓存行的其他计数器也会进入无效状态。

这又触及到了上面的跨核心传输的消耗问题。

解决方法也很简单,强制对齐填充缓存行即可------最终让每个读者的计数器都能够独占一条缓存行。

Rust 复制代码
// 来自Jon Gjengset

#[repr(align(64))]
structPaddedEpoch(AtomicUsize);

无锁不等于无竞争,让数据在不同CPU之间转移也是开销很大的操作。而很多的开销,并不是一些性能分析软件能够代替人来进行判断的。

Left-Right的代价

同样地,Left-Right算法也有它的局限性。

首先,它的空间开销会翻倍,毕竟为数据准备了左右两个副本。

其次就是,当遇到花费时间很长的读者时,写者可能会因迟迟等待它退出而被"卡死",无法写入数据。

不过这并不会影响数据的安全性;只是写者会一直陷入等待中,等待这个读者写好以至于无法处理后续的任何写请求,导致写入量为0。

同时,这个结构只有在写操作数量较少的情况下才起效;否则,由于在这其中写者承担了更多额外工作,效果并不会比互斥锁更好甚至会更差。

接着,由于先写完再转移读指针,读者也难免会读到过时的旧数据。

虽然也可以提供一种同步等待的机制,在旧读者完全转移到新副本之前不要返回写操作,然而这样又不可避免会给写操作带来额外的成本。

然后,它还只支持单写者。一旦有多个并发写者,为了保证数据的安全性,又得在写者之上使用互斥锁,这样子又偏离了原本追求极致并发的初衷。

最后,写入左右副本的数据还必须具有确定性。因为在同步左右副本的数据时具有一定时间差,如果在这个过程中数据进行了意料之外的变动的话,也会导致错误。

当然,这其中可能还会有许许多多的没有提到的问题,也许有些程序需要很严格的线性一致性,而Left-Right只能提供最终一致性;也许有些业务无法容忍读到旧数据,有些可以;等等。

权衡

实际上并没有一种并发原语是万能的,一切选择都需要与业务对齐、顺应底层。

这有几个关键问题:

  • 读写操作比例是多少?这往往会决定采用哪种并发算法。
  • 临界区是长还是短?这关系到锁开销在其中的占比。
  • 程序涉及的线程数是多少?线程过少讨论这些没有意义。
  • 能容忍最终一致而非线形一致吗?需要确定什么才是不可容忍的。
  • 能容忍可能会读到旧数据吗?至少Left-Right有读到旧数据的可能。

最终还是看程序的核心需求是什么。

总结

这篇文章通过一个测试引入,指出在让并发变慢的不是锁本身,而跨核心的缓存一致性协调才是造成消耗的元凶。

由于它的存在,即使是为多读少写设计的RWLock在多线程并发下的表现也不尽如人意。

同时,介绍了适合多读少写的Left-Right数据结构,以及它的优劣。

最后指出,不论如何,还是要根据具体的业务需求和底层硬件来选择并发策略。适合的才是最好的。

相关推荐
禁默1 小时前
[鸿蒙PC命令行移植适配]移植rust三方库eza到鸿蒙PC的完整实践
华为·rust·harmonyos
绍磊leo1 小时前
Tauri 2.x 教程系列 (一):Hello Tauri — 从零搭建第一个桌面应用
rust·tauri
smallswan1 小时前
第十七 位运算
rust
我是一颗柠檬2 小时前
【Redis】Redis性能优化Day14(2026年)
数据库·redis·性能优化
禅思院2 小时前
大列表性能优化 · 工程实战·四
开发语言·前端·性能优化·前端框架·php·异步加载
ct9783 小时前
ES6 新特性
前端·vue.js·性能优化
时代文章18 小时前
UCX 官方文档和 InfiniBand 架构知识整理
网络·ai·性能优化
a诠释淡然21 小时前
C++ vs Rust:哪个更适合你的下一个项目?
开发语言·c++·rust
189228048611 天前
NV078固态MT29F16T08EWLCHD6-QAES:C
性能优化