系列文章:
- Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序
- Rust 原理丨从汇编角度看原子操作
- Rust 实战丨手写一个 SpinLock
- Rust 实战丨手写一个 oneshot channel
- Rust 实战丨手写一个 Arc
- Rust 原理丨操作系统并发原语
- Rust 实战丨手写一个 Mutex
- Rust 实战丨手写一个 Condvar
- Rust 实战丨手写一个 RwLock
- 读书笔记丨《Rust Atomics and Locks》👈 本篇
本文整理总结 Mara Bos 所著《Rust Atomics and Locks》一书的核心内容,系统归纳 Rust 并发编程中原子操作与锁机制的关键概念和技术细节。内容涵盖原子操作与内存顺序、Rust 并发的基础工具(内部可变性、线程安全保证、操作系统并发原语),以及多种常用并发原语(自旋锁、一次性通道、原子引用计数、互斥锁、条件变量、读写锁)的实现要点。通过这些内容的串联,展示概念之间的联系、底层原理、Rust 标准库实现方式与实际工程实践的关系,方便读者快速回顾并用作知识索引。
原子操作基础
Rust 提供了一系列原子类型(如
AtomicBool
、AtomicUsize
等)来实现线程之间的无锁并发数据共享。对原子类型的操作分为三大类别:
- Load(加载) 与 Store(存储):分别用于原子地读取和写入值。
- Fetch-and-Modify(获取并修改):在返回旧值的同时,原子地对值进行修改,例如
fetch_add
、fetch_sub
、fetch_or
、fetch_and
、fetch_xor
、swap
等。 - Compare-and-Exchange(比较并交换):即原子地执行“如果当前值等于预期值则交换”的操作,包括
compare_exchange
和compare_exchange_weak
。
不同计算机体系结构对原子操作的支持方式有所差异,主要体现在指令集类别和内存模型上。现代CPU通常分为两种指令集架构:
- CISC(复杂指令集):典型代表是 x86 架构(包括 x86-64)。CISC 指令集功能丰富、单条指令可执行复杂操作,但硬件实现复杂。x86-64 是基于 CISC 的 64 位扩展架构,由 AMD 设计主导,追求通过硬件复杂性换取高性能和广泛兼容性,主导了桌面与服务器领域。
- RISC(精简指令集):典型代表是 ARM 架构(如 ARM64)。RISC 指令集精简、每条指令功能单一,硬件实现相对简单且能效更高。ARM64 基于 RISC 的 64 位架构,由 ARM 设计,指令简洁、低功耗,在移动设备占主导地位并逐步进入服务器和 PC 领域。
原子性的实现机制: 在 x86-64
上,保证原子性的关键是使用 lock
前缀锁定总线或缓存行来原子执行指令;而在 ARM64 上,则依赖
Load-Linked/Store-Conditional(LL/SC)指令对实现原子操作。例如,x86
上原子的 compare-and-swap 通常通过 LOCK CMPXCHG
指令完成,而 ARM
上通过一对原子链式的加载/存储指令完成。如果处理器不支持所需的原子指令,Rust
会在编译期报错以防止不安全的并发操作。
内存序模型差异: 不同架构的内存序保证也不同。x86-64 属于强顺序(Total Store Order, TSO)模型,对内存操作的重排序有限制,而 ARM64 属于弱顺序模型,需要显式的内存屏障来保证顺序。具体来说,x86-64 默认禁止以下重排序:
- Load → 后续操作 不允许乱序:例如在同一线程中,先执行的读取不能被后执行的操作越过。
- Store → 前序操作 不允许乱序:一个存储不能提前到先前未执行完的操作之前。
- Store → 随后的 Load 则可能乱序:即一个存储操作后紧跟的加载操作在实际执行中可能被处理器提升到存储之前执行(典型的 Store-Load 重排)。
相比之下,ARM
等弱序模型中,大多数内存操作在缺乏同步指令时都可能被重排序,因此所有原子操作默认可能乱序,需要利用内存屏障指令(ARM
上如
dmb ish
、dmb ishld
)来提供顺序保证。此外,在
compare-and-exchange 操作上,x86-64
并未提供真正的“弱”版本(即不会出现无故失败的情况),因而 Rust 中的
compare_exchange_weak
在 x86 上实际上与强语义等价;而 ARM64
的 LL/SC 实现存在可能的自发失败,因此区分了 weak
版本以便需要时重试。
综上,硬件架构对原子操作的支持直接影响Rust并发库的实现策略:在强序的 x86 上,一些内存序保证可由硬件天然提供,而在弱序的 ARM 上则必须借助显式屏障指令来达成。Rust 的原子类型实现会针对不同架构插入相应的汇编指令,以确保提供声明的原子性和内存序语义。
内存顺序与内存模型
多线程环境下面临两大核心问题:
- 指令乱序执行(Out-of-Order Execution):现代CPU会对指令进行乱序执行和优化,这可能导致程序实际执行顺序与源码顺序不一致。
- 跨线程内存可见性:不同线程对内存修改的可见性无法保证——一个线程写入的数据,何时及如何对其他线程可见,需要通过同步手段来控制。
为解决上述问题,需要了解内存模型中的两类关键顺序关系:
- Sequenced-Before(先行顺序):描述单个线程内操作的先后顺序。按照程序中的先后关系确定,在同一线程内如果操作 A 在源码中位于操作 B 之前,那么 A sequenced-before B。先行顺序遵循以下规则:若操作 B 数据依赖于 A,则 A 必定在 B 之前执行;针对同一原子变量的操作按程序顺序执行;若两个独立操作无数据依赖且访问不同变量,那么处理器可能对它们重排。总之,先行顺序限定单线程的执行次序,为编译器和 CPU 提供本线程内优化的依据。
- Happens-Before(先发生):描述跨线程的操作顺序和可见性。如果操作
A happens-before 操作 B,意味着 A 的所有内存效果对于 B
是可见的。Happens-Before
建立在线程间的同步关系上,例如:在同一线程中,函数
f()
调用在前而g()
在后,则f()
happens-beforeg()
;一个线程调用thread::spawn
创建新线程发生在对该新线程的join
之前;再如互斥锁的加锁先于解锁操作(unlock 发生在 lock 之前释放锁的线程完成临界区之后)。这些 Happens-Before 规则确保了特定事件的跨线程可见性和执行顺序。
Rust 的原子操作支持五种内存顺序(Ordering
枚举),从最松弛到最严格依次为
Relaxed、Release、Acquire、AcqRel、SeqCst。不同的内存顺序决定了原子操作在乱序和可见性方面的保证强度。下表总结了它们的语义、保证、使用场景和示例:
内存顺序 | 说明 | 保证 | 适用场景 | 示例 |
---|---|---|---|---|
Relaxed | 最宽松的内存顺序 | - 仅保证操作的原子性 - 不提供任何同步保证 - 不建立 happens-before 关系 |
- 简单计数器- 极高性能要求且确定不需要跨线程同步 - 已通过其他方式确保数据可见性同步 |
counter.fetch_add(1, Ordering::Relaxed) |
Release | 用于存储操作 | - 此操作之前的所有内存访问不会被重排到它之后 - 与后续线程的 Acquire 操作配对可建立 happens-before 关系 |
- 典型“生产者-消费者”模型- 发布共享数据给其他线程 - 写入一个“初始化完成”标志 |
data.store(val, Ordering::Release) |
Acquire | 用于加载操作 | - 此操作之后的所有内存访问不会被重排到它之前 - 与另一个线程先前的 Release 操作配对可建立 happens-before 关系 |
- “生产者-消费者”模型中获取数据-
读取共享数据(需确保数据已由其他线程准备好) - 检查某个初始化完成的标志 |
let val = data.load(Ordering::Acquire) |
AcqRel | 读改写操作的组合语义 | - 同时具有 Acquire 和 Release 的所有内存顺序保证 - 只能用于原子读-改-写操作(RMW),对读取部分提供 Acquire 保证,对写入部分提供 Release 保证 |
- 需要双向内存同步的原子操作v-
实现锁等同步原语(例如原子自增既读取又写入) - 较复杂的原子同步场景 |
value.fetch_add(1, Ordering::AcqRel) |
SeqCst | 全局顺序一致的最强顺序 | - 包含 AcqRel 的所有保证 - 所有线程对所有 SeqCst 原子操作的观察顺序一致(总排序) - 提供跨线程全局的内存顺序一致性 |
- 需要严格的全局一致性场景 - 不确定使用哪种顺序时采用(保守策略) - 对性能要求不敏感的代码 |
flag.store(true, Ordering::SeqCst) |
需要注意的是,Release 通常与 Acquire 搭配使用,共同建立线程间的同步关系。当线程 A 使用 Release 语义写入某个共享变量,线程 B 之后使用 Acquire 语义读取到了该值,那么可以保证:线程 A 中那次 Release 写入之前的所有内存写操作,对线程 B 在 Acquire 读取之后的所有操作都是可见的。换言之,通过 Release-Acquire 的配对建立了跨线程的 happens-before:生产者线程写入的数据对消费者线程可见。这就是典型的生产者-消费者模式同步的原理。例如,一个线程完成初始化后将标志位设为 true(Release),另一个线程反复以 Acquire 读取该标志,当读到 true 时即可安全地读取之前初始化的数据。
另一个重要概念是 释放序列(Release Sequence)。释放序列指的是:“以一次 Release 操作为开头,紧跟其后的、在同一原子变量上的所有同一线程的写操作或读改写操作(RMW),共同形成一个连续序列”。如果某线程在 Acquire 读取时读到了这个序列中的任意一个写,那么该 Acquire 将和序列开头的那次 Release 建立同步关系。简单理解,Release 序列涵盖了 Release 写入线程接下来对同一原子变量的后续修改,以及可能由其他线程执行的 RMW 操作,从而确保 Acquire 端读取到序列中任何结果时,都能看到序列开头 Release 之前的所有内存效果。
底层实现上,编译器和CPU通过内存屏障(Memory Barrier)指令来实现上述内存顺序保证。主要有三类内存屏障:
- 读屏障(Load Barrier):确保屏障之前的所有读操作都已完成,并阻止后续读操作提前执行(即后面的读不会跑到屏障之前)。这相当于 Acquire 语义的效果,在很多架构上,Acquire Load 会在汇编层插入读屏障指令或使用带Acquire语义的特殊读指令。
- 写屏障(Store Barrier):确保屏障之前的所有写操作都已对内存可见,并阻止后续写操作提前执行(不让后面的写越到屏障之前)。这对应 Release 语义,Release Store 常通过写屏障指令或带Release语义的原子写指令实现。
- 全屏障(Full
Barrier):同时具有读屏障和写屏障效果,禁止任何读或写的重排序。这通常用于
SeqCst 场景,确保全局一致的内存顺序。在 x86 上
MFENCE
指令就是一个全屏障,而 ARM 上则需要DMB ISH
等全内存屏障指令来达到 SeqCst 效果。
通过合理选择内存顺序和屏障,程序员可以在性能和内存可见性保证之间取得平衡:在确保数据跨线程可见性和操作顺序正确的前提下,尽量减少不必要的开销。
Rust 并发的基础工具
UnsafeCell:内部可变性的支柱
Rust
的所有权和借用规则在编译期就保证了单线程情况下的数据安全,例如通过不可变引用
&T
阻止对数据的修改,通过可变引用
&mut T
确保独占访问。然而,在实现并发数据结构时,经常需要突破这一限制:比如在只有不可变引用的情况下也能修改内部数据(典型场景是通过锁对象的不可变引用来获取内部数据的可变引用)。为此,Rust
提供了一个底层机制 UnsafeCell
来支持内部可变性。
UnsafeCell 是 Rust
标准库中的一个关键类型,它包装了一个数据,使得即使只有对该容器的不可变引用,也可以通过
UnsafeCell 提供的方法来修改内部的数据。当然,这种内部修改必须在
unsafe
块中进行,因为它突破了编译器的借用检查。很多线程同步原语(例如
Mutex
、RwLock
,甚至原子类型如
AtomicBool
本身)内部都使用了 UnsafeCell
来绕过编译期的限制,从而允许在并发场景下安全地修改受保护的数据。
基于 UnsafeCell,Rust 标准库封装了几种提供不同程度内部可变性的类型:
- Cell:提供最小程度的内部可变性,只能用于复制语义的类型(实现
Copy
的类型)。通过get
和set
方法在不违反借用规则的情况下读取或修改内部的值。 - RefCell:提供运行时的借用检查,实现更灵活的内部可变性,可以在运行时确保不可变借用和可变借用的不混淆。但
RefCell
不是线程安全的(不实现Sync
),只能用于单线程场景。 - Mutex:它利用锁机制保证线程安全的内部可变性。Mutex 本质上也是通过 UnsafeCell 允许内部数据可变访问,但同时提供了线程间互斥保护。相应地,Mutex的使用成本较高,需要在多线程间进行加锁和解锁的开销。
通过 UnsafeCell 提供的内部可变性支撑,Rust 才能编写出安全的并发数据结构。在这些结构中,我们将看到 UnsafeCell 的身影,它保证了在编译器视角下“不可能”的行为(即在不可变引用下修改数据)在运行时被合理地使用。
Send 与 Sync:无数据竞争的基石
Rust 类型系统通过两个重要的标记 trait(Marker Trait)来实现线程安全的静态检查,这两个 trait 就是 Send 和 Sync。它们没有具体的方法,实现这两个 trait 的类型会自动享有编译器的特殊处理,用于标记跨线程的使用安全性:
- Send
表示一个类型的所有权可以在线程间安全地传递。如果类型
T
实现了 Send,则T
或&mut T
可以从一个线程转移到另一个线程。例如,大部分基础类型和完全由 Send 组成的复合类型都是 Send。如果某个类型内部包含不安全的共享状态且未保护,那它可能不会实现 Send(编译器会自动推导决定,开发者也可手动禁止)。 - Sync
表示一个类型的不可变引用可以在线程间安全共享。也就是说,如果
&T
实现了 Sync,则允许多个线程同时拥有对该类型的不可变引用。这要求类型内部的所有可能被并发访问的可变状态都已经被保护(比如用了原子或锁)。大部分基本类型的不可变引用都是 Sync,像&i32
等是可以多线程共享的。如果某类型不是 Sync,则即使它本身是只读的,也无法同时被多个线程引用(例如Rc<T>
因为不是线程安全的引用计数,因此&Rc<T>
也不是 Sync)。
Send 和 Sync 这两个标记 trait 是 Rust 保证无数据竞争(Data Race
Free)的基石。编译器通过这两个trait禁止不安全的跨线程操作:任何在多个线程间共享或转移的值必须是
Send/Sync
的,否则将无法编译。这种机制在编译期就杜绝了绝大部分数据竞争情况,开发者只有在显式使用
unsafe
绕过时才可能违背这个保证。因此,在实现并发原语时,确保正确实现
Send/Sync(或适当地禁止它们)是非常重要的。Rust 标准库多数类型默认实现
Send/Sync(如果其组成部分实现的话),但像
Rc<T>
(非线程安全引用计数)或
RefCell<T>
(仅供单线程内部可变性)则没有实现,以防误用。
操作系统并发原语:原子等待与唤醒
高效的阻塞式并发离不开操作系统提供的底层同步原语。目前主流的桌面/服务器操作系统(如 Linux、macOS、Windows)都提供了类似的原子等待/唤醒机制,以构建更高级的锁和并发结构。这些机制允许线程在等待某个条件时挂起睡眠,避免忙轮询浪费CPU,并在条件达成时高效地被唤醒。概括来说,有三个最重要的底层操作:
- wait( &AtomicU32, expected_value
):当指定的原子变量值等于给定期待值时,让当前线程陷入休眠等待;如果原子值不匹配则立即返回。这个操作通常是原子级的检查并挂起,如
Linux 上的
futex
系统调用 (futex_wait
) 或 Windows 上的WaitOnAddress
等,它们允许用户态线程在内核中高效睡眠,等待变量改变。 - wake_one( &AtomicU32
):唤醒一个在指定原子变量上等待的线程。对应地,Linux
futex 提供
futex_wake
可以唤醒等待在同一个地址上的一个线程;Windows 提供WakeByAddressSingle
等。 - wake_all( &AtomicU32
):唤醒所有在该原子变量上等待的线程。对应
futex 的
wake
可以指定唤醒所有等待的线程,Windows 有WakeByAddressAll
等。
这些原语本质上将原子变量的变化和线程调度结合起来,实现了类似
“当原子变量处于某值时让线程睡眠,直到变量改变再唤醒”
的机制。这为构建更高级的同步工具奠定了基础:例如 Mutex
在获取不到锁时,可以调用 wait
挂起线程等待锁的原子标志变为可用;当释放锁时,调用 wake_one
唤醒一个等待线程继续争夺锁。相比纯用户态的自旋,这种机制大幅提高了效率,因为等待线程无需占用
CPU。Rust 标准库的实现以及第三方并发库(如
parking_lot
)都会利用操作系统提供的此类原语(在 Linux
上通常就是
futex,在其他平台可能用条件变量等模拟)来实现高性能的阻塞同步。
本书的示例实现中多次使用了 atomic-wait
crate 来模拟
futex 等行为,以便在稳定版上构建自定义锁。
自旋锁(SpinLock)
自旋锁是一种最简单的锁实现,其特性是当一个线程尝试获取锁而锁已被占用时,该线程不会阻塞睡眠,而是在循环中反复检查锁是否可用(“忙等待”)。自旋锁避免了线程切换的开销,适用于临界区极短、锁持有时间非常小的场景,因为忙等待会浪费CPU时间。如果临界区较长,使用自旋锁会导致CPU空转浪费资源,此时应采用会阻塞线程的锁(如 Mutex)。Rust 标准库并未提供显式的自旋锁类型,但可以使用原子操作很容易地实现一个。书中通过实现一个简化的 SpinLock 展示了原子操作和 RAII 等在并发中的应用:
- v0:最小化原子标记的实现 –
采用一个原子布尔标志表示锁状态(如
AtomicBool
),最初版本只实现了基本的lock()
和unlock()
。当标志从false
变为true
表示获取锁成功。线程获取锁时使用原子交换 (swap
) 将标志设为 true,并在获取失败时不断重试(忙等)。该版本功能最小,不负责保护任何数据。 - v1:绑定受保护数据 –
将锁和需要保护的数据封装在一起,定义为如
SpinLock<T>
,内部持有一个UnsafeCell<T>
来存储数据。这保证了数据只能通过获取锁后才能访问,提升了数据安全性。接口上提供lock()
返回一个包裹了&mut T
的锁守卫类型,以确保在持有锁期间才能访问内部数据。 - v2:引入 RAII 实现自动解锁 – 利用 Rust 的
RAII(Resource Acquisition Is Initialization)惯用法,返回一个锁守卫(如
SpinLockGuard
),其Drop
实现中自动调用解锁操作。这样,当锁守卫离开作用域时(不论是正常离开还是因为 panic 等非常路径),都能确保锁被正确释放,不必依赖用户手动调用unlock()
。这一版本显著提高了易用性和安全性,防止了因忘记解锁导致的死锁。
通过这三个版本的迭代,我们看到了从低级原子操作构建锁的基本过程,以及RAII 模式在确保资源释放中的威力。自旋锁虽然简单,但在Rust并发中并不作为公开接口广泛使用,更多是用于底层实现。例如标准库 Mutex 在短暂自旋优化时内部也会自旋尝试锁,以减少进入内核的机会。一般来说,高层代码应尽量使用更高级的 Mutex 而非自旋锁,除非在非常性能敏感且确定临界区极短的场景下才考虑自旋锁。
一次性通道(One-shot Channel)
一次性通道指的是只发送一次消息并被接收一次的通信通道。它可以理解为只能容纳单个消息的生产者-消费者队列,在某些场景下(例如线程初始化后将结果发送给主线程)非常有用。Rust
标准库提供的 std::sync::mpsc
通道是多次发送的通用通道,而一次性通道可以做特别的优化。书中通过多个版本构建了一个一次性通道,以展示锁与无锁编程、内存管理以及线程同步的技巧:
- v0:基于互斥锁的简单通道 –
首先实现一个通用版本,内部用
Mutex
+Condvar
等构造一个能发送任意条消息的通道,然后限制只发送一次。这是最直接的实现,但完全依赖锁,性能较低。 - v1:引入 UnsafeCell 与 AtomicBool 去锁化 –
利用内部可变性和原子操作,实现一个无锁的一次性通道。使用
AtomicBool
标志消息是否已被接收,UnsafeCell
存放实际消息数据,实现 send 时设置数据和标志位,recv 时读取数据。通过原子操作保证发送和接收的同步,不再使用锁,从而减少开销。 - v2:使用
MaybeUninit<T>
代替Option<T>
– 优化内存布局。由于一次性通道中的消息在发送前不存在,发送后存在,而 Option 构造会在内存中额外存储一个枚举标志,占用空间且可能导致不必要的初始化检查。改用MaybeUninit
后,只在内存中保留消息所需空间,避免了初始化与未初始化状态的双重判断,减少内存开销。 - v3:增加动态检查以提高安全性 – 在发送和接收操作中增加运行时检查,以确保不会出现重复发送、重复接收等误用情况。例如,如果重复调用 send 就立即 panic,防止破坏内部状态。这些检查提升了组件的健壮性。
- v4:实现
Drop
Trait 以自动清理 – 为通道的发送端和接收端实现Drop
,在对象被销毁时自动清理未被接收的值或通知另一端。这样可以避免内存泄漏,并在接收端丢弃前通知发送端等,提高使用时的正确性。 - v5:拆分 Sender 和 Receiver 类型 – 将通道的发送者和接收者角色分开成不同的类型,确保编译层面对各自操作的权限限制。例如 Sender 只能发送不能接收,Receiver 只能接收不能发送。这一版本还通过类型系统防范了一些误用(如接收端克隆等)。
- v6:用生命周期替代 Arc,消除引用计数开销 –
之前版本为了让 Sender 和 Receiver 在不同线程中存在,可能使用了
Arc
来共享通道内部状态。然而在一次性通道中,发送和接收两者的作用域其实可以静态地确定(如在线程函数中先发送后接收)。通过引入 Rust 的生命周期参数,将通道限定在创建它的作用域内传递引用,而非通过 Arc,在编译期保证了内存安全的同时,免去了原子引用计数的运行时开销。 - v7:使用线程停放(park/unpark)机制,实现阻塞等待 –
先前版本的接收操作可能需要不停查询标志(自旋等待消息就绪),v7 引入了
Rust 标准库的
park()
/unpark()
机制:当接收端发现消息尚未准备好时,调用thread::park
挂起当前线程;发送端在放入消息后调用接收线程的句柄的unpark
来唤醒它。这种做法避免了忙等占用 CPU,转而让线程阻塞等待,提升效率。同时通过去掉先前用于轮询的如is_ready
标志,使误用的可能性进一步降低。 - v8:使用 PhantomData 防止跨线程误用 – 最终版本利用 PhantomData 标记保证类型安全。通过在 Sender/Receiver 中加入带有生命周期或非 Send/Sync 标记的 PhantomData,编译器可以禁止用户将 Sender/Receiver 在不安全的方式跨线程使用。例如,可以防止 Sender/Receiver 被不恰当地发送到其他线程导致未定义行为。PhantomData 不占用空间,但可以在类型系统中充当标记,确保一次性通道的用法受到静态约束,彻底封堵错误用法。
经过上述多次迭代,一次性通道从最初的锁实现逐步演进为无锁且高效的实现,并且在类型系统层面保证了安全使用。这个过程展示了
Rust
并发编程从简单正确到性能优化再到类型安全的完整思路。在实际工程中,类似的思想被用于实现各种高性能通道和同步数据结构,如
Rust 标准库和 crossbeam
库中的通道实现等。
Arc 原子引用计数智能指针
Arc(Atomically Reference Counted)是 Rust 标准库提供的多线程引用计数智能指针类型,允许在多个线程间共享所有权。通过原子增减引用计数,Arc 能确保即使多个线程同时持有指针,底层数据也只会在最后一个指针被释放后销毁。书中通过实现一个简化版的 Arc 及其改进版本,解释了 Arc 的工作原理和演进:
- v0:基础版本,单原子计数的多所有权 –
使用一个原子计数器(如
AtomicUsize
)记录引用数,每次克隆 Arc 增加计数,删除时减少计数,减到零时销毁数据。这个版本实现了跨线程的多所有权共享。然而,此时 Arc 依然缺乏两方面能力:内部可变性(无法获得可变引用去修改内部数据),以及防止循环引用(如果两个 Arc 相互引用会导致计数永不为零,内存泄漏)。 - v1:引入独占借用
&mut Arc<T>
及内部锁,实现可变访问 – 标准库的 Arc 并不支持在存在多个引用时直接获得内部数据的可变引用,但本书尝试了一个改进思路:当我们持有 Arc 的独占引用&mut Arc<T>
时(意味着当前线程拥有 Arc 对象的独占管理权,没有其他 Arc 克隆存在),可以安全地修改内部数据。为此,v1 在 Arc 内部增加了一把原子锁或标志,用于在独占修改内部数据时临时阻止其他线程的访问。从而提供一个安全的get_mut()
方法:当检测到当前只有一个强引用且锁定成功时,返回内部数据的可变引用。这实现了条件下的内部可变性:在不破坏线程安全的前提下,允许独占 Arc 进行内部修改。 - v2:加入弱引用(Weak)以解决循环引用问题 – 弱引用是
Arc 非常重要的补充。Weak
指针不计入强引用计数,因此不会阻止数据被回收。v2 引入
Weak<T>
类型,当需要引用可能形成环的对象时,用 Weak 替代 Arc 中的一部分引用关系。这样即使对象之间形成环,相互的弱引用不会使强计数增加到无法归零,一旦没有强引用,数据可以正确释放。Weak 指针需要通过upgrade()
尝试提升为 Arc 使用,在数据已被释放时会得到None
,从而避免了悬垂引用。 - v3:区分强引用计数和弱引用计数,优化无循环场景性能 – 在 v2 基础上,v3 将引用计数拆分为两个独立的计数:强引用计数和弱引用计数。强计数为 0 时表示数据可销毁,但实际销毁要等弱计数也为 0(表示没有悬挂的 Weak 指针)。通过分离计数,Arc 在没有 Weak 场景下也不需要去增减弱计数,减少了不必要的开销。只有在存在 Weak 的情况下才维护弱计数。这一优化与实际 Rust 标准库 Arc 的实现一致:标准库 Arc 在内部使用两个计数(一个原子usize记录强引用数,一个记录弱引用数),并据此管理内存回收。
Arc
智能指针的实现展示了原子操作在内存管理中的应用。由于使用了原子引用计数,Arc
的克隆、释放可以由多线程安全地执行。此外,引入 Weak
指针解决循环引用、双计数优化性能等设计,都是实际工业级智能指针需要考虑的问题。Rust
标准库的 Arc<T>
正是采用类似机制,保证了线程安全又兼顾性能。在并发编程实践中,Arc
被广泛用来共享只读数据或通过内部加锁来共享可变数据(比如
Arc<Mutex<T>>
组合),理解其实现对深入掌握 Rust
的内存管理和线程安全非常有帮助。
互斥锁(Mutex)
Mutex(互斥锁)是一种常用的线程同步原语,用于保证同一时刻只有一个线程可以访问某份数据。Rust
提供了 std::sync::Mutex<T>
实现安全且高效的互斥锁,在内部结合了自旋和操作系统锁机制。书中构建了一个简化的
Mutex 实现,通过多个版本逐步逼近实际库中的优化:
- v1:基本可用的互斥锁 – 首先利用
atomic-wait
等底层原语实现一个基本的 Mutex。内部使用一个原子标志表示锁状态,采用类似自旋锁的方式尝试获取锁,如果失败则调用底层wait()
将线程阻塞,等待锁可用时被唤醒。同时运用 RAII 思想,实现一个锁守卫,在Drop
中释放锁。这一版本已经具备 Mutex 的核心功能:能阻塞等待,避免忙等;用 RAII 确保解锁。 - v2:避免不必要的系统调用 – 优化 Mutex
在无竞争情况下的性能。设想如果锁空闲,线程应尽量避免调用内核提供的
wait
等系统调用,因为进入内核态有开销。v2 改进在于:获取锁时先用原子操作快速检查和设置锁标志,如果成功则直接进入临界区,不需要任何系统调用;只有当锁已被占用且需要等待时,才调用wait
进入睡眠。同样,释放锁时如果发现没有任何线程在等待(可通过一个等待计数或标志判断),则不调用wake_one
,避免无谓的系统调用开销。这个版本通过“先检查再睡眠”的策略,提高了低争用情况下的性能。 - v3:加入短暂自旋进一步减少系统调用 –
在锁竞争发生时,立即阻塞线程进入内核可能不是最优选择。如果锁很快就会被释放,先忙等一小段时间可避免不必要的上下文切换。v3
引入了自旋等待的优化:当发现锁被占用时,不马上调用
wait
阻塞线程,而是让线程自旋循环尝试获取锁若干次(或等待若干纳秒)。如果在短暂自旋期间锁被释放获取成功,就无需进入内核;只有超过自旋时限仍未成功,才执行阻塞等待。这个优化利用了临界区很短这种常见情况,大幅减少了内核调度开销。在实际实现中,Rust 标准库和parking_lot
库的 Mutex 都采用了类似的自旋-休眠结合策略,以平衡延迟和吞吐量。
经过这些改进,Mutex
实现已经非常接近真实场景:在无竞争时开销极低(原子操作和用户态逻辑),在有轻微竞争时通过自旋避免陷入内核,在竞争激烈或锁长时间被持有时再进入内核等待,从而综合优化不同场景下的性能。值得一提的是,Rust
标准库的 Mutex 实际上在底层是调用操作系统的原生实现(例如 Windows
临界区,pthread mutex等),而社区提供的 parking_lot
crate
则使用了自定义的高性能实现(正是类似书中描述的策略)。理解这些机制有助于选择和使用锁,以及调优并发性能。
条件变量(Condvar)
条件变量是一种配合互斥锁使用的同步原语,用于线程间等待和通知机制。一个典型的
Condvar 场景是:线程 A
获取锁并检查某个条件,如果条件不满足则在条件变量上等待(这会释放锁并挂起线程A);另一个线程B稍后满足条件后获取同一把锁,改变条件并通知唤醒条件变量,线程A被唤醒后重新获取锁继续执行。Rust
提供了 std::sync::Condvar
来实现这一机制。书中通过两步实现了 Condvar 的核心原理并进行了优化:
- v1:利用 atomic-wait 实现等待/唤醒 –
条件变量需要将线程挂起和唤醒与某个共享条件相结合。v1 实现中,每个
Condvar 内部维护一个原子计数或标志,当线程调用
wait()
时,先解锁关联的 Mutex,然后调用atomic_wait
在该原子上休眠等待。另一线程调用notify_one
或notify_all
时,对同一个原子值执行修改并调用wake_one
或wake_all
来唤醒等待线程。这样利用前述操作系统原语,就实现了条件变量的等待和唤醒机制。需要注意唤醒后线程会重新尝试获取最初的Mutex锁,以恢复对共享状态的保护(这一过程通常由Condvar实现封装好)。 - v2:增加等待者计数避免无效唤醒 –
为了优化唤醒通知的性能,v2 在 Condvar 内部引入了一个原子计数
num_waiters
,记录当前有多少线程在此 Condvar 上等待。这样,当调用notify_one/all
时,可以先检查如果没有等待者就直接返回,避免进行系统调用唤醒。同理,在等待时也增加和减少这个计数。这个简单的计数避免了无谓的唤醒操作调用(例如没有线程在等待却调用了唤醒系统调用)。实际中,大部分条件变量实现都会维护类似的状态来提升效率。Rust 标准库的Condvar
在具体实现上依赖于系统提供的条件变量(如 pthread_cond_t),但原理一致。
Condvar 的实现难点在于要安全地结合互斥锁使用,以及防止经典的“丢失信号”问题(即信号发送与等待错过时机导致永久沉睡)。通过精心设计的顺序(例如在 Mutex 解锁和 wait 挂起之间避免竞争)以及必要的重试循环(被唤醒后通常需要再次检查条件),可以确保 Condvar 的正确性。这部分内容体现了操作系统原语与高级并发抽象之间的结合:Rust 的实现隐藏了许多细节,但理解它有助于我们正确使用 Condvar(例如明白必须在循环中等待条件、防止虚假唤醒等)。
读写锁(RwLock)
读写锁允许多个读者并行地读取数据,但在有写者持有锁时所有读者都被阻止。这样在读多写少的场景下能提高并发性能。Rust
提供了 std::sync::RwLock<T>
来实现读写锁。与 Mutex
类似,RwLock
在实现上也可以结合自旋和操作系统原语,并需考虑读者与写者的公平性。书中实现的简易
RwLock 经过了三步演进,着重解决写者可能的饥饿问题:
- v1:核心读写锁语义 – 初始版本使用
atomic-wait
等机制和 RAII,支持多个读者或单个写者的互斥。具体来说,可用一个原子计数来表示锁状态:例如计数为非负时表示当前有该数目的读者持锁,计数为 -1 表示有写者持锁。实现read_lock
时尝试将计数递增(若当前不是写模式),write_lock
则尝试将计数从0变为 -1 来独占。如果操作失败则调用 wait 挂起等待。当持锁线程释放锁时,更新计数并根据情况调用 wake_one/all 唤醒等待的线程。这一版本实现了基本的 RwLock 功能。 - v2:增加独立的写者唤醒计数 –
为了避免写线程在竞争中空转,v2 引入了一个单独的
writer_wake_counter
或标志。因为读锁可能同时唤醒多个等待的读者,而写者通常只需单独唤醒。当一个写线程在等待时,如果持续有读锁进来,它可能长时间得不到机会。通过维护一个专门针对写者的等待计数或标志,释放锁时如果发现有写者在等待,可以更有针对性地唤醒写线程。这避免了写线程无谓地循环等待所有读者离开锁。 - v3:使用奇偶数编码巧妙解决写者饥饿 –
最终版本采取了一个巧妙的方案:利用原子计数的最低位或奇偶性来标记写者意图,从而平衡读/写公平性。一种常见做法是,将计数的某一位作为“有写者等待”的标记。当有写线程等待时,设置该标记使新的读锁请求被延迟(即不再允许新的读者获取锁),从而逐步清空现有读者并最终让写者上锁。一旦写者获取锁并释放后,再清除标记放行读者。这种通过奇偶位(或其他位)区分读写状态的编码方式,可以防止写者一直被源源不断的读者饿死,又不会过度牺牲读性能。实际工程中,不少读写锁实现(包括
Rust 标准库和
parking_lot
的 RwLock)都采用类似思路:既避免写者饥饿,又保持尽可能高的读并发。
读写锁相对于互斥锁有更复杂的状态管理,需要处理多读者、单写者之间的切换,以及公平性策略。通过上述改进,我们既实现了基本功能,又确保在竞争激烈时系统不会偏科(比如始终偏向读者或写者)。在
Rust 标准库中,RwLock
使用系统的 pthread_rwlock
或相似机制实现,而更优化的方案如 parking_lot::RwLock
则采用自己的算法,和书中描述的策略不谋而合。对于开发者来说,了解这些实现细节能够帮助理解
RwLock
的特性(例如为什么有时写锁可能拿不到是因为读锁频繁进来等),从而做出更好的并发设计决策。
总结
通过对《Rust Atomics and Locks》全书内容的梳理,我们系统了解了 Rust 并发编程从底层原理到高级抽象的一系列知识点:
- 原子操作及其内存序保证构成了无锁并发的基础,不同架构对其支持有所区别,理解硬件内存模型有助于写出正确高效的原子操作代码。
- 内存模型中的 happens-before 等概念和五种内存顺序提供了分析并发行为的工具,Rust 的类型系统和内存屏障一起确保了跨线程操作的可见性和有序性。
- UnsafeCell 和 Send/Sync 等机制是 Rust 提供的编译期保障,既允许必要的“不安全”修改又保证线程安全边界分明,使我们能放心地构建并使用并发原语。
- 操作系统提供的 futex 等原语连接了用户态原子操作和内核调度,Rust 并发库巧妙地加以利用,实现既省CPU又高吞吐的锁和阻塞结构。
- 通过几个典型并发原语(自旋锁、通道、Arc、Mutex、Condvar、RwLock)的实现迭代,我们看到如何将上述原理应用于实际:从简单正确的初始版本,不断优化以提高性能和安全性,最终达到与工业级实现类似的效果。这些案例也强调了概念之间的联系——如自旋锁和 Mutex 体现了忙等与阻塞两种等待策略,Arc 的内部可变性依赖 UnsafeCell 而其计数安全依赖原子操作,条件变量依赖 Mutex 配合以及操作系统原语等等。
Rust 并发的设计追求“无数据竞争”的同时,通过类型系统和底层优化取得了很好平衡。《Rust Atomics and Locks》深入浅出地展示了这一领域的方方面面。本笔记希望帮助读者快速回顾书中内容,在需要时可将各章节要点作为知识索引参考。相信将来面对具体并发编程挑战时,这些基础理论和实现经验将成为宝贵的指导,帮助我们写出健壮高效安全的 Rust 并发代码。
Happy Coding! Peace~