Rust原子和锁——构建自己的锁

231 阅读44分钟

在本章中,我们将构建我们自己的互斥锁、条件变量和读写锁。对于每种锁,我们会从一个非常基础的版本开始,然后逐步扩展,使其更高效。

由于我们不会使用标准库中的锁类型(那样就没有挑战了),我们需要使用第8章介绍的工具来让线程在等待时避免忙等待。然而,正如我们在第8章中看到的,不同平台提供的工具差异很大,这使得构建跨平台可用的同步原语变得困难。

幸运的是,大多数现代操作系统都支持类似于futex的功能,或者至少支持基本的唤醒和等待操作。例如,Linux自2003年以来通过futex系统调用提供支持,Windows自2012年起通过WaitOnAddress系列函数提供支持,FreeBSD自2016年起通过_umtx_op系统调用提供支持,等等。

最显著的例外是macOS。虽然其内核确实支持这些操作,但并未通过任何稳定、公开可用的C函数暴露出来。然而,macOS附带了一个最近版本的libc++(C++标准库的实现)。该库支持C++20,而C++20内置了非常基础的原子等待和唤醒操作支持(例如std::atomic<T>::wait())。尽管由于多种原因从Rust使用这些功能稍显复杂,但完全是可行的,这也为我们在macOS上提供了类似futex的基本等待和唤醒功能。

我们不会深入探讨这些复杂的实现细节,而是使用atomic-wait这个来自crates.io的crate作为我们锁原语的构建基础。这个crate仅提供了三个函数:wait()wake_one()wake_all()。它在所有主要平台上都实现了这些功能,使用了我们上文讨论的各种特定于平台的实现。只要我们使用这三个函数,就不需要再考虑任何平台特定的细节。

这些函数的行为与我们在“Futex”部分中为Linux实现的函数完全相同,但我们快速回顾一下它们的工作方式:

wait(&AtomicU32, u32)

此函数用于等待,直到原子变量的值不再是给定的值。如果原子变量的当前值等于给定值,它会阻塞。当另一个线程修改了原子变量的值后,该线程需要调用下面的唤醒函数之一(针对相同的原子变量),以唤醒正在等待的线程。

此函数可能会无缘无故地返回(即使没有对应的唤醒操作)。因此,在返回后务必检查原子变量的值,并在必要时重复调用wait()

wake_one(&AtomicU32)

此函数唤醒当前在相同原子变量上被wait()阻塞的一个线程。在修改原子变量后立即使用此函数,通知一个等待的线程值已更改。

wake_all(&AtomicU32)

此函数唤醒当前在相同原子变量上被wait()阻塞的所有线程。在修改原子变量后立即使用此函数,通知所有等待线程值已更改。

注意:
仅支持32位原子变量,因为这是所有主要平台都支持的唯一大小。

提示:
在“Futex”部分中,我们讨论了一个展示这些函数实际用法的简单示例。如果你已经忘记了,可以先查看该示例再继续。

使用atomic-wait crate

要使用atomic-wait crate,在你的Cargo.toml文件的[dependencies]部分中添加以下内容:

atomic-wait = "1"

或者运行以下命令,这会自动帮你完成上述操作:

cargo add atomic-wait@1

这三个函数定义在crate的根目录中,可以通过以下方式导入:

use atomic_wait::{wait, wake_one, wake_all};

注意:
在你阅读本文时,可能已经有该crate的更新版本,但本章内容仅适用于1.x版本的接口。后续版本可能不再兼容。

现在我们已经准备好了基础构建块,让我们开始构建吧!

互斥锁 (Mutex)

我们将参考第4章中的SpinLock<T>类型,来构建我们的Mutex<T>。其中,与阻塞无关的部分(如锁守卫类型的设计)将保持不变。

类型定义

首先,从类型定义开始。与自旋锁相比,我们需要进行一个更改:将AtomicBool替换为AtomicU32,使用0表示未锁定,1表示已锁定,这样可以与原子等待和唤醒函数配合使用。

pub struct Mutex<T> {
    /// 0: unlocked
    /// 1: locked
    state: AtomicU32,
    value: UnsafeCell<T>,
}

与自旋锁一样,我们需要保证Mutex<T>可以在线程之间共享,即使它包含了一个危险的UnsafeCell

unsafe impl<T> Sync for Mutex<T> where T: Send {}

我们还将添加一个MutexGuard类型,实现Deref特性,提供一个完全安全的锁接口,就像在“使用锁守卫实现安全接口”一节中所做的那样:

pub struct MutexGuard<'a, T> {
    mutex: &'a Mutex<T>,
}

impl<T> Deref for MutexGuard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.mutex.value.get() }
    }
}

impl<T> DerefMut for MutexGuard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.mutex.value.get() }
    }
}
初始化

让我们实现Mutex::new函数,用于初始化互斥锁:

impl<T> Mutex<T> {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0), // unlocked state
            value: UnsafeCell::new(value),
        }
    }

    …
}
锁定和解锁

现在,剩下的两部分是锁定(Mutex::lock())和解锁(MutexGuard<T>Drop实现)。

锁定操作

在我们的自旋锁中,锁定操作通过原子交换操作尝试获取锁,如果成功将状态从“未锁定”更改为“已锁定”,则返回;如果不成功,则立即重试。

在实现互斥锁时,我们的操作几乎相同,但在尝试再次获取锁之前,我们使用wait()进行等待:

pub fn lock(&self) -> MutexGuard<T> {
    // 将状态设置为1(已锁定)。
    while self.state.swap(1, Acquire) == 1 {
        // 如果已锁定,则等待,直到状态不再是1。
        wait(&self.state, 1);
    }
    MutexGuard { mutex: self }
}

解锁操作

MutexGuardDrop实现负责解锁互斥锁。对于自旋锁,解锁很简单:将状态设置回false(未锁定)。但对于互斥锁,仅设置状态回0(未锁定)是不够的。如果有线程正在等待获取锁,它不会知道锁已经被解锁,除非我们通过唤醒操作通知它。如果不唤醒,它可能会永远休眠。

因此,我们需要在将状态设置回0后立即调用wake_one()

impl<T> Drop for MutexGuard<'_, T> {
    fn drop(&mut self) {
        // 将状态设置回0(未锁定)。
        self.mutex.state.store(0, Release);
        // 唤醒一个等待线程(如果有)。
        wake_one(&self.mutex.state);
    }
}

唤醒一个线程已经足够,因为即使有多个线程在等待,也只有一个线程能够获取锁。下一个获取锁的线程会在完成后唤醒另一个线程,以此类推。如果一次唤醒多个线程,会浪费宝贵的处理器时间,因为只有一个线程能成功获取锁,其余线程会再次进入休眠。

实现正确性

即使没有waitwake函数,上述互斥锁实现从技术上仍然是正确的(即内存安全)。因为wait()操作可能会无缘无故地返回,我们无法假设其返回的时机。我们仍需自行管理锁的状态。如果移除waitwake调用,该互斥锁将与自旋锁基本相同。

一般来说,从内存安全角度看,原子等待和唤醒函数不会影响正确性。它们仅是一种优化,用于避免忙等待。

使用lock_api库简化实现

如果你计划将实现Rust锁作为一个新爱好,可能很快会因涉及到的样板代码而感到无聊。例如UnsafeCellSync实现、守卫类型、Deref实现等。

可以使用crates.io上的lock_api库来自动处理这些事情。你只需定义一个表示锁状态的类型,并通过lock_api::RawMutex特性提供(不安全的)锁定和解锁函数。作为回报,lock_api::Mutex类型会基于你的锁实现,提供一个完全安全且易用的互斥锁类型,包括一个锁守卫。

避免系统调用

在我们的互斥锁实现中,最慢的部分是waitwake操作,因为它们(可能)会触发系统调用,即进入操作系统内核。与内核的交互是一个复杂的过程,相比于原子操作,速度明显较慢。因此,为了实现高性能的互斥锁,应尽可能减少waitwake的调用。

幸运的是,我们已经完成了一半的优化。在锁定函数中,由于while循环在调用wait()之前检查了状态,在互斥锁未被锁定的情况下,wait()操作会被完全跳过。然而,在解锁时,我们无条件调用了wake_one()

如果我们能知道没有其他线程在等待,就可以跳过wake_one()调用。为此,我们需要自行跟踪是否有线程在等待。

将锁定状态拆分为两种

我们可以将“锁定”状态拆分为两个独立的状态:

  • “锁定但没有等待线程”
  • “锁定且有等待线程”

我们将分别使用值12表示这两种状态,并更新结构体中state字段的文档注释:

pub struct Mutex<T> {
    /// 0: unlocked
    /// 1: locked, no other threads waiting
    /// 2: locked, other threads waiting
    state: AtomicU32,
    value: UnsafeCell<T>,
}

锁定操作

对于未锁定的互斥锁,锁定函数仍需将状态设置为1。但是,如果互斥锁已被锁定,则锁定函数现在需要在进入休眠前将状态设置为2,以便解锁函数能够判断是否存在等待线程。

为实现这一点,我们将使用一个比较交换操作(compare_exchange)尝试将状态从0更改为1。如果成功,表示已成功锁定互斥锁,且没有其他线程在等待,因为互斥锁之前未被锁定。如果失败,说明互斥锁当前已被锁定(状态为12)。此时,我们将使用原子交换操作(swap)将状态设置为2。如果交换操作返回的旧值为12,说明互斥锁仍然被锁定,此时我们才使用wait()进入休眠,直到状态发生变化。如果交换操作返回0,说明我们已成功通过将状态从0更改为2来锁定互斥锁。

以下是锁定函数的实现:

pub fn lock(&self) -> MutexGuard<T> {
    if self.state.compare_exchange(0, 1, Acquire, Relaxed).is_err() {
        while self.state.swap(2, Acquire) != 0 {
            wait(&self.state, 2);
        }
    }
    MutexGuard { mutex: self }
}

解锁操作

解锁函数可以利用新的状态信息,在不必要时跳过wake_one()调用。现在,解锁操作不再简单地将状态设置为0,而是使用交换操作(swap)获取之前的状态值。仅当之前的状态值为2时,才唤醒一个线程:

impl<T> Drop for MutexGuard<'_, T> {
    fn drop(&mut self) {
        if self.mutex.state.swap(0, Release) == 2 {
            wake_one(&self.mutex.state);
        }
    }
}

线程协作

当状态被设置回0时,它不再表示是否存在等待线程。被唤醒的线程负责将状态重新设置为2,以确保不会遗漏其他等待线程。这也是为什么compare_exchange操作不能放在锁定函数的while循环中。

需要注意的是,每当一个线程在锁定时调用了wait(),它在解锁时也会调用wake_one(),即使这并非总是必要的。然而,更重要的是,在无竞争的情况下——即理想情况下,线程没有同时尝试获取锁——wait()wake_one()调用会被完全避免。

内存顺序与性能优化

在两个线程并发尝试锁定互斥锁的情况下,操作和先行关系如下:

  • 第一个线程通过将状态从0更改为1成功锁定互斥锁。
  • 此时,第二个线程无法获取锁,因此将状态从1更改为2后进入休眠。
  • 当第一个线程解锁互斥锁时,它将状态设置回0。由于之前的状态为2,表明存在等待线程,它调用wake_one()唤醒第二个线程。

需要注意的是,我们并不依赖唤醒操作和等待操作之间的任何先行关系。尽管唤醒操作很可能是唤醒线程的原因,先行关系实际上是通过获取操作(Acquire)观察到释放操作(Release)存储的值来建立的。这种设计确保了实现的内存安全性和高效性。

image.png

进一步优化

到目前为止,我们的互斥锁在无竞争的情况下已经没有系统调用,仅剩两个非常简单的原子操作。然而,我们仍可以尝试进一步优化。

自旋锁的结合

虽然自旋通常非常低效,但它确实避免了系统调用的潜在开销。在某些情况下,自旋可能更高效,例如当当前持有锁的线程在另一个处理器核心上运行,并且只会短暂持有锁时。这种情况实际上非常常见。

我们可以结合两种方法的优点:在调用wait()之前先短暂自旋。如果锁很快被释放,就不需要调用wait(),同时也避免了消耗大量处理器时间。

实现这一点只需要修改锁定函数:

impl<T> Mutex<T> {
    …

    pub fn lock(&self) -> MutexGuard<T> {
        if self.state.compare_exchange(0, 1, Acquire, Relaxed).is_err() {
            // 锁已被其他线程持有。
            lock_contended(&self.state);
        }
        MutexGuard { mutex: self }
    }
}

fn lock_contended(state: &AtomicU32) {
    …
}

lock_contended 的实现

lock_contended中,我们可以重复执行几百次比较交换操作(compare_exchange),然后再进入等待循环。然而,compare_exchange操作通常会尝试获取相关缓存行的独占访问权限(参见“MESI协议”),频繁执行可能比简单的加载操作更昂贵。

因此,我们改用以下实现:

fn lock_contended(state: &AtomicU32) {
    let mut spin_count = 0;

    while state.load(Relaxed) == 1 && spin_count < 100 {
        spin_count += 1;
        std::hint::spin_loop();
    }

    if state.compare_exchange(0, 1, Acquire, Relaxed).is_ok() {
        return;
    }

    while state.swap(2, Acquire) != 0 {
        wait(state, 2);
    }
}

实现说明

  1. 短暂自旋:
    首先,我们使用一个计数器自旋最多100次,同时利用std::hint::spin_loop()提示处理器优化。这部分代码仅在互斥锁被锁定(状态为1)且没有等待线程的情况下执行。如果已经有线程在等待,说明自旋可能不适合当前线程。
  2. 尝试再次锁定:
    自旋结束后,我们再次尝试将锁的状态设置为1(锁定)。如果成功,则直接返回。如果失败,则表明需要等待。
  3. 调用wait()
    如果锁定仍未成功,则调用wait()阻塞线程,直到状态发生变化。由于调用了wait(),后续无法通过设置状态为1来锁定,因为这样可能会遗漏其他等待线程。

自旋次数的选择

自旋100次的设定主要是任意选择的,具体的执行时间和系统调用的耗时高度依赖于平台。通过详尽的基准测试可以帮助选择合适的次数,但没有唯一正确的答案。

Rust标准库中std::sync::Mutex在Linux上的实现(截至Rust 1.66.0)也使用了100次的自旋次数。

冷路径和内联优化

  1. 冷路径标注:
    可以为lock_contended函数添加#[cold]属性,告诉编译器这是一个非常见(无竞争)情况下不会调用的函数,有助于优化lock方法的常用路径。

    #[cold]
    fn lock_contended(state: &AtomicU32) {
        // 函数实现
    }
    
  2. 内联优化:
    MutexMutexGuard的方法添加#[inline]属性,提示编译器将其内联(即将方法的指令直接插入调用位置)。对于像这些非常小的函数,内联通常会提升性能。

    impl<T> Mutex<T> {
        #[inline]
        pub fn lock(&self) -> MutexGuard<T> {
            // 方法实现
        }
    }
    

优化总结

通过结合自旋和阻塞,我们能够显著减少系统调用的使用,特别是在锁只被短暂持有的情况下。同时,通过#[cold]#[inline]等属性,可以帮助编译器优化锁的性能,尤其是在无竞争的理想场景中。

基准测试(Benchmarking)

测试互斥锁实现的性能是一件困难的事情。虽然写一个基准测试并获得一些数字很容易,但很难获得有意义的结果。

优化互斥锁以在特定基准测试中表现优异相对容易,但这并不太有用。毕竟,我们的目标是实现一个在真实世界程序中表现良好的互斥锁,而不仅仅是在测试程序中表现出色。

我们将尝试编写两个简单的基准测试,展示我们的优化至少对某些用例有积极影响,但需要注意,这些结论在不同场景下可能并不适用。


测试1:无竞争的性能

对于第一个测试,我们将创建一个互斥锁,并在同一线程上对其进行数百万次锁定和解锁操作,测量总耗时。这测试了一个简单的无竞争场景,没有其他线程需要被唤醒。希望这能展示我们的两种实现(2状态和3状态)之间的显著性能差异。

fn main() {
    let m = Mutex::new(0);
    std::hint::black_box(&m); // 防止编译器优化掉循环或锁定操作
    let start = Instant::now();
    for _ in 0..5_000_000 {
        *m.lock() += 1;
    }
    let duration = start.elapsed();
    println!("locked {} times in {:?}", *m.lock(), duration);
}

结果分析:

  • 在一台运行Linux、配备最新AMD处理器的计算机上,未优化的2状态互斥锁总耗时约400毫秒,而优化后的3状态互斥锁耗时约40毫秒,性能提升了10倍。
  • 在另一台运行Linux、配备较旧Intel处理器的计算机上,差异更大:大约1800毫秒对比60毫秒。

这表明添加第三种状态确实是一个显著的优化。

然而,在运行macOS的计算机上,结果完全不同:两种版本的互斥锁耗时均约50毫秒,表明性能高度依赖于平台。


平台差异的原因

在macOS上,libc++std::atomic<T>::wake()实现已经独立于内核完成了自身的状态管理,避免了不必要的系统调用。类似地,Windows上的WakeByAddressSingle()也有类似机制。

尽管避免调用这些函数仍能带来略微的性能提升(因为它们的实现较为复杂,无法直接在原子变量中存储信息),但如果目标仅是这些操作系统,添加第三种状态可能并不值得。


测试2:高竞争场景

为了测试自旋优化是否带来积极影响,我们需要一个包含大量竞争的基准测试,即多个线程重复尝试锁定已锁定的互斥锁。

假设我们让四个线程并发尝试对互斥锁进行数百万次锁定和解锁操作:

fn main() {
    let m = Mutex::new(0);
    std::hint::black_box(&m); // 防止编译器优化掉循环或锁定操作
    let start = Instant::now();
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..5_000_000 {
                    *m.lock() += 1;
                }
            });
        }
    });
    let duration = start.elapsed();
    println!("locked {} times in {:?}", *m.lock(), duration);
}

注意:
这是一个极端且不现实的场景。互斥锁仅被短暂持有(仅用于增加整数),线程解锁后会立即尝试重新锁定。其他场景可能会产生完全不同的结果。


结果分析:

  • 在配备较旧Intel处理器的Linux计算机上:

    • 无自旋版本耗时约900毫秒,自旋版本耗时约750毫秒,性能略有提升。
  • 在配备较新AMD处理器的Linux计算机上:

    • 无自旋版本耗时约650毫秒,自旋版本耗时约800毫秒,性能反而有所下降。

结论

对于自旋是否提高性能,答案是“不一定”,即使仅在一个场景中进行测试也是如此。

自旋优化在某些情况下可以显著减少系统调用,从而提高性能,特别是在锁持有时间非常短的高竞争场景中。但在其他场景下,自旋可能浪费处理器时间,导致性能下降。要实现最优结果,需要根据特定平台和实际应用场景进行仔细的基准测试和调整。

条件变量(Condition Variable)

接下来,我们实现一个更有趣的同步原语:条件变量

条件变量简介

如我们在“条件变量”一节中所见,条件变量需要与互斥锁结合使用,用于等待某些受互斥锁保护的数据满足特定条件。它具有一个wait方法,该方法会解锁互斥锁,等待信号,并在返回之前重新锁定相同的互斥锁。

信号通常由其他线程发送,通常是在修改受互斥锁保护的数据之后:

  • 单个信号notify_onesignal)唤醒一个等待线程。
  • 广播信号notify_allbroadcast)唤醒所有等待线程。

虽然条件变量会尽量让等待线程保持休眠状态直到收到信号,但有可能线程在没有收到对应信号的情况下被意外唤醒(称为“虚假唤醒”)。即便如此,条件变量的wait操作在返回之前仍会重新锁定互斥锁。

基本实现思路

条件变量的接口与我们前面实现的类似futex的wait()wake_one()wake_all()函数几乎相同。主要区别在于:

  • 条件变量需要在解锁互斥锁之前开始“监听”信号,以免错过解锁后立即到来的信号。
  • 而futex式wait()依赖对原子变量状态的检查,确保等待是合理的。

为实现这一点,我们可以在每次通知时更改一个原子变量(例如计数器)。在Condvar::wait()方法中,在解锁互斥锁之前检查该变量的值,然后在解锁后将其传递给futex式wait()函数。这样,如果在解锁后收到信号,线程就不会进入休眠。

定义条件变量结构

条件变量可以简单地用一个AtomicU32表示,用零初始化:

pub struct Condvar {
    counter: AtomicU32,
}

impl Condvar {
    pub const fn new() -> Self {
        Self { counter: AtomicU32::new(0) }
    }

    …
}

通知方法的实现

通知方法只需增加计数器的值,并调用相应的唤醒操作通知等待线程:

pub fn notify_one(&self) {
    self.counter.fetch_add(1, Relaxed);
    wake_one(&self.counter);
}

pub fn notify_all(&self) {
    self.counter.fetch_add(1, Relaxed);
    wake_all(&self.counter);
}

等待方法的实现

wait方法需要接收一个MutexGuard,因为它代表了一个已锁定的互斥锁。这也确保只有持有锁的线程才能调用wait。在返回之前,wait还会重新锁定互斥锁,因此它的返回值是一个新的MutexGuard

实现步骤:

  1. 在解锁互斥锁之前加载计数器的当前值。
  2. 解锁互斥锁。
  3. 调用wait(),仅当计数器值未改变时进入休眠。

代码实现如下:

pub fn wait<'a, T>(&self, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> {
    let counter_value = self.counter.load(Relaxed);

    // 解锁互斥锁,但保留对互斥锁的引用以便重新锁定。
    let mutex = guard.mutex;
    drop(guard);

    // 如果计数器值未改变,则等待。
    wait(&self.counter, counter_value);

    // 重新锁定互斥锁并返回新的MutexGuard。
    mutex.lock()
}

内存顺序分析

在互斥锁锁定时,没有其他线程可以更改受保护的数据,因此无需担心解锁之前的通知被遗漏。唯一需要考虑的情况是:

  • 当前线程释放互斥锁。
  • 另一个线程锁定互斥锁,修改数据,然后发送通知(通常是在解锁后)。

在这种情况下,解锁和通知之间存在先行关系(happens-before)。这种关系保证:

  • 解锁前的Relaxed加载操作能观察到解锁后的计数器值。
  • 通知的fetch_add操作发生在加锁之后。

wait()操作与唤醒操作是原子的,因此即使没有严格的顺序关系,也不会错过信号:

  • 如果wait()观察到计数器的最新值,则不会进入休眠。
  • 如果观察到旧值,则会休眠并等待相应的唤醒调用。

计数器溢出问题

计数器的实际值并不重要,只要每次通知后计数器值不同即可。然而,AtomicU32的计数器在超过42亿次通知后会溢出归零,回到之前的值。

极端情况下:
如果线程错过了精确的4,294,967,296次通知(或其倍数),计数器可能回到与其初始值相同。这会导致Condvar::wait()错误地进入休眠状态。

这种极端情况非常罕见,但在需要高度可靠的条件变量实现时,可能需要考虑对溢出进行特殊处理。


实现总结

我们实现了一个基本的条件变量,结合了原子计数器和类似futex的等待与唤醒操作。通过在解锁互斥锁之前检查计数器值,我们确保不会错过任何通知。尽管存在溢出问题,但对于大多数实际场景,这种实现已经足够高效和可靠。

image.png

溢出风险分析

将条件变量的计数器溢出问题视为可以忽略是完全合理的。与我们在互斥锁方法中对状态的多次检查不同,条件变量在被唤醒后不会重新检查状态并重复调用wait(),因此唯一需要担心的是计数器的“回绕”(round-trip)是否会发生在计数器的Relaxed加载和wait()调用之间的瞬间。

如果一个线程在这个时间窗口内被中断足够长,以至于允许(精确)发生42亿次通知,那么说明系统本身已经出现严重问题,程序可能已经无响应。在这种情况下,可以合理认为线程意外保持休眠的微小额外风险已经无关紧要。

提示:使用超时缓解溢出风险

对于支持带时间限制的futex式等待的平台,可以通过为wait()操作设置几秒的超时时间来降低溢出风险。发生42亿次通知所需的时间远超过几秒钟,因此设置超时将完全消除由于线程错误休眠而导致程序锁死的风险。


条件变量测试

接下来,我们通过一个测试来验证条件变量的工作原理:

#[test]
fn test_condvar() {
    let mutex = Mutex::new(0);
    let condvar = Condvar::new();

    let mut wakeups = 0;

    thread::scope(|s| {
        s.spawn(|| {
            thread::sleep(Duration::from_secs(1));
            *mutex.lock() = 123;
            condvar.notify_one();
        });

        let mut m = mutex.lock();
        while *m < 100 {
            m = condvar.wait(m);
            wakeups += 1;
        }

        assert_eq!(*m, 123);
    });

    // 确保主线程实际等待(未忙循环),
    // 同时允许几次虚假唤醒。
    assert!(wakeups < 10);
}

测试说明:

  1. 验证线程实际休眠:
    我们记录条件变量从其wait方法返回的次数,以确保它实际进入休眠。如果这个数字非常高,则可能表明我们意外地进入了自旋循环。
  2. 虚假唤醒的容忍:
    条件变量允许虚假唤醒,因此我们对wakeups的断言中留有余地,比如不超过10次。
  3. 正确性验证:
    在辅助线程通知主线程后,验证受保护的值是否被正确更新为123

测试结果

运行此测试时,编译并通过验证,确认条件变量确实让主线程进入了休眠。

注意:
虽然该测试通过表明实现基本可行,但这并不证明其实现绝对正确。为获得更高的信心,可以使用涉及多个线程的长时间压力测试,理想情况下在一个使用弱排序处理器架构的计算机上运行,以发现可能的隐性问题。


重要性

如果条件变量无法正常进入休眠,它仍然可以表现出“正确”的行为(比如不会造成死锁),但其等待循环会变成自旋循环,这将导致资源浪费并显著降低性能。因此,测试条件变量的实际休眠能力是至关重要的。

避免系统调用

正如我们在“避免系统调用”部分中提到的,优化锁原语的核心在于尽量减少不必要的waitwake操作。

对于条件变量,在Condvar::wait()实现中尝试避免调用wait()意义不大。当线程决定等待条件变量时,它已经检查了等待的条件尚未满足,因此需要进入休眠。如果wait()调用是多余的,线程根本不会调用Condvar::wait()

但是,我们可以像优化互斥锁一样,在没有等待线程时避免调用wake_one()wake_all()


跟踪等待线程数

一个简单的方法是记录当前等待线程的数量。在wait方法中,在进入等待之前递增该计数,在唤醒后递减。然后,通知方法可以在发现没有等待线程时跳过发送信号。

为此,我们在Condvar结构中新增一个字段num_waiters用于跟踪活动等待线程的数量:

pub struct Condvar {
    counter: AtomicU32,
    num_waiters: AtomicUsize, // 新增字段
}

impl Condvar {
    pub const fn new() -> Self {
        Self {
            counter: AtomicU32::new(0),
            num_waiters: AtomicUsize::new(0), // 新增初始化
        }
    }

    …
}

我们使用AtomicUsize来表示num_waiters,这样可以避免溢出问题。usize足够大,可以表示内存中的每个字节。因此,即使每个线程占用至少一个字节的内存,num_waiters的大小也足够记录任意数量的并发线程。


更新通知方法

接下来,我们更新通知方法,使其在没有等待线程时跳过操作:

pub fn notify_one(&self) {
    if self.num_waiters.load(Relaxed) > 0 { // 新增检查
        self.counter.fetch_add(1, Relaxed);
        wake_one(&self.counter);
    }
}

pub fn notify_all(&self) {
    if self.num_waiters.load(Relaxed) > 0 { // 新增检查
        self.counter.fetch_add(1, Relaxed);
        wake_all(&self.counter);
    }
}

更新等待方法

最重要的是,我们在wait方法开始时递增num_waiters,并在线程被唤醒后立即递减:

pub fn wait<'a, T>(&self, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> {
    self.num_waiters.fetch_add(1, Relaxed); // 新增递增操作

    let counter_value = self.counter.load(Relaxed);

    let mutex = guard.mutex;
    drop(guard);

    wait(&self.counter, counter_value);

    self.num_waiters.fetch_sub(1, Relaxed); // 新增递减操作

    mutex.lock()
}

内存顺序分析

我们需要仔细考虑这些原子操作的Relaxed内存顺序是否足够。

  • 避免遗漏唤醒操作的风险:
    通知方法可能在num_waiters值为零时观察到该值,从而跳过唤醒操作,而实际上还有线程需要被唤醒。这种情况可能发生在通知方法读取值时:

    • 线程递增操作之前,或
    • 线程递减操作之后。
  • 风险的缓解:
    等待线程在递增num_waiters时持有互斥锁,因此任何在解锁互斥锁之后发生的num_waiters加载操作,不可能观察到递增操作之前的值。同样,通知线程在观察到递减后的值时,线程已经不再需要被唤醒。

换句话说,互斥锁建立的先行关系(happens-before)仍然提供了我们需要的所有保证。

  • 虚假唤醒:
    当线程因虚假唤醒提前退出wait()时,它会立即递减num_waiters,此时无需担心其他线程未能正确处理唤醒操作。

结论

通过跟踪等待线程数,我们优化了条件变量的实现,在没有线程等待时跳过了不必要的wake_one()wake_all()调用。互斥锁提供的先行关系确保了这一优化的安全性。对于大多数实际场景,这种优化可以显著减少通知的开销,同时保持正确性和高效性。

避免虚假唤醒

另一种优化条件变量的方法是避免虚假唤醒。每次线程被唤醒时,它都会尝试锁定互斥锁,可能与其他线程竞争,这会显著影响性能。

虚假唤醒的成因

底层的wait()操作很少会虚假唤醒,但我们条件变量的实现可能会导致notify_one()唤醒多个线程。这种情况发生在以下场景中:

  1. 一个线程正准备进入休眠状态(wait操作),刚加载了计数器的值,但尚未进入休眠。
  2. 此时另一个线程调用notify_one(),导致计数器更新,阻止第一个线程进入休眠,同时通过wake_one()唤醒另一个线程。
  3. 结果是两个线程同时竞争锁,浪费了宝贵的处理器时间。

这种情况看似少见,但由于互斥锁的同步机制,实际上很容易发生:

  • 通常在调用notify_one()之前,线程会先锁定并解锁互斥锁,以修改等待线程关心的数据。
  • Condvar::wait()方法解锁互斥锁时,可能立即解锁了等待锁的通知线程。
  • 此时,等待线程和通知线程进入竞态:等待线程试图进入休眠,而通知线程试图锁定互斥锁、解锁并调用notify_one()
  • 如果通知线程赢得竞态,则等待线程因计数器值更新而不进入休眠,同时通知线程调用wake_one(),不必要地唤醒了额外的等待线程。
简单解决方案:跟踪允许唤醒的线程数

一种相对直接的解决方案是:

  • 跟踪允许唤醒的线程数。
  • notify_one()中,将该计数增加1。
  • wait()中,如果计数不为零,则尝试将其减少1;如果为零,则进入(或返回)休眠状态。

对于notify_all(),可以使用一个专门计数器,记录需要唤醒的线程总数,而无需递减。


问题与局限性

上述方法虽然有效,但引入了一个更微妙的问题:可能唤醒尚未调用Condvar::wait()的线程,包括自己。这种情况的发生过程如下:

  1. notify_one()增加允许唤醒的线程数并调用wake_one()唤醒一个等待线程。
  2. 在第一个等待线程被唤醒之前,另一个(甚至是相同的)线程调用了Condvar::wait()
  3. 新的等待线程看到还有一个待处理通知,直接将计数器减为零并立即返回。
  4. 第一个等待线程则会重新进入休眠,因为通知已被另一个线程“抢走”。

这种行为可能是可接受的,也可能是严重问题,导致某些线程无法取得进展。


GNU libc 的解决方案

GNU libc 的pthread_cond_t曾经受到上述问题的影响。围绕这种行为是否符合POSIX规范进行了广泛讨论,最终在2017年发布的GNU libc 2.25中,完全重新设计了条件变量实现,解决了这一问题。

新的实现通过以下方式避免了上述问题:

  • 将等待线程分为两组。
  • 仅允许第一组线程消费通知,当第一组没有等待线程时,切换到第二组。

虽然这种方法解决了问题,但它也带来了以下缺点:

  1. 算法复杂度增加。
  2. 条件变量类型的大小显著增加,因为需要记录更多信息。

是否使用优化方法:因场景而异

在许多使用条件变量的场景中,允许一个等待线程“抢走”早前的通知是完全可以接受的。但如果条件变量是为通用用途设计的,而非特定场景,这种行为可能是不允许的。


提示:更复杂但更安全的方法

有一些方法可以在避免虚假唤醒的同时解决上述问题,但这些方法比其他方案复杂得多。
GNU libc 的解决方案是一种选择,但它的实现复杂性和对条件变量类型的额外要求需要在性能和实现成本之间做出权衡。

总之,是否采用优化方案取决于具体的使用场景和条件变量的应用需求。

惊群问题(Thundering Herd Problem)

在使用条件变量时,当使用notify_all()唤醒多个等待相同条件的线程时,可能会遇到性能问题。

问题在于,被唤醒的所有线程都会立即尝试锁定相同的互斥锁。通常只有一个线程会成功,其他线程必须重新进入休眠。这种多个线程同时争抢同一资源导致资源浪费的问题被称为惊群问题。

可以认为Condvar::notify_all()从根本上是一种反模式,不值得优化。条件变量的目的是解锁一个互斥锁并在收到通知时重新锁定它,因此同时通知多个线程可能从来都不会产生好的结果。

即便如此,如果我们想针对这种情况进行优化,可以在支持类似futex重排操作的操作系统上实现,例如Linux的FUTEX_REQUEUE。(参见“Futex Operations”。)

与其唤醒许多线程,让它们在意识到锁已被占用后立即重新进入休眠,不如将除了一个线程外的所有线程重排,这样它们的futex等待操作不再等待条件变量的计数器,而是开始等待互斥锁的状态。

重排一个等待线程并不会唤醒它。实际上,该线程甚至不会知道自己已被重排。不幸的是,这可能会引发一些非常微妙的问题。

例如,记住一个三状态互斥锁在唤醒后必须锁定为正确的状态(“锁定且有等待线程”),以确保其他等待线程不会被遗忘。这意味着我们不能再在Condvar::wait()实现中使用常规的互斥锁锁定方法,因为这可能会将互斥锁设置为错误的状态。

重排的条件变量实现需要存储一个指向等待线程所使用的互斥锁的指针。否则,通知方法将不知道应该将等待线程重排到哪个原子变量(互斥锁的状态)。这就是为什么条件变量通常不允许两个线程等待不同的互斥锁。尽管许多条件变量实现并未使用重排操作,但保留未来版本使用重排操作的可能性是有用的。

读写锁实现

现在是实现一个读写锁的时候了!

回顾一下,和互斥锁(mutex)不同,读写锁支持两种类型的锁:读锁和写锁,有时称为共享锁(shared locking)和独占锁(exclusive locking)。写锁的行为与互斥锁相同,每次只能有一个锁,而读锁允许多个读者同时持有锁。换句话说,它与Rust中的独占引用(&mut T)和共享引用(&T)的工作方式非常相似,允许同时存在一个独占引用,或者任意数量的共享引用。

对于互斥锁,我们只需要跟踪它是否被锁定。而对于读写锁,我们还需要知道当前有多少个(读者)锁被持有,以确保写锁只会在所有读锁释放之后才被加锁。

让我们从一个使用单个 AtomicU32 作为状态的 RwLock 结构体开始。我们将用它来表示当前已获得的读锁数量,值为零表示它是解锁状态。为了表示写锁定状态,我们使用一个特殊值 u32::MAX

pub struct RwLock<T> {
    /// 读者的数量,或者是 `u32::MAX` 如果是写锁定状态。
    state: AtomicU32,
    value: UnsafeCell<T>,
}

对于我们的 Mutex<T>,我们必须将它的 Sync 实现限制为实现了 Send 的类型 T,以确保它不能用于向另一个线程发送例如 Rc 类型的对象。而对于我们新的 RwLock<T>,我们还需要要求 T 实现 Sync,因为多个读者将能够同时访问数据:

unsafe impl<T> Sync for RwLock<T> where T: Send + Sync {}

由于我们的 RwLock 可以以两种不同的方式进行加锁,我们将有两个独立的锁定函数,每个函数有自己类型的守卫:

impl<T> RwLock<T> {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0), // 解锁状态。
            value: UnsafeCell::new(value),
        }
    }

    pub fn read(&self) -> ReadGuard<T> {
        // 省略实现...
    }

    pub fn write(&self) -> WriteGuard<T> {
        // 省略实现...
    }
}
pub struct ReadGuard<'a, T> {
    rwlock: &'a RwLock<T>,
}

pub struct WriteGuard<'a, T> {
    rwlock: &'a RwLock<T>,
}

写锁守卫应该像独占引用(&mut T)一样工作,我们通过为其实现 DerefDerefMut 来实现这一点:

impl<T> Deref for WriteGuard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.rwlock.value.get() }
    }
}

impl<T> DerefMut for WriteGuard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.rwlock.value.get() }
    }
}

然而,读锁守卫只应该实现 Deref,而不是 DerefMut,因为它并没有对数据的独占访问权,使其行为像共享引用(&T):

impl<T> Deref for ReadGuard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.rwlock.value.get() }
    }
}

现在我们已经处理了所有的模板代码,接下来就是重点部分:加锁和解锁。

为了获取读锁,我们必须将状态加一,但前提是它还没有被写锁定。我们将使用一个比较并交换循环("Compare-and-Exchange Operations")来实现这一点。如果状态是 u32::MAX,意味着读写锁已经是写锁定状态,我们将使用 wait() 操作来挂起并稍后重试。

pub fn read(&self) -> ReadGuard<T> {
    let mut s = self.state.load(Relaxed);
    loop {
        if s < u32::MAX {
            assert!(s != u32::MAX - 1, "读者太多");
            match self.state.compare_exchange_weak(
                s, s + 1, Acquire, Relaxed
            ) {
                Ok(_) => return ReadGuard { rwlock: self },
                Err(e) => s = e,
            }
        }
        if s == u32::MAX {
            wait(&self.state, u32::MAX);
            s = self.state.load(Relaxed);
        }
    }
}

写锁定就简单多了;我们只需要将状态从零改为 u32::MAX,如果它已经被锁定,则执行 wait()

pub fn write(&self) -> WriteGuard<T> {
    while let Err(s) = self.state.compare_exchange(
        0, u32::MAX, Acquire, Relaxed
    ) {
        // 如果已锁定,等待。
        wait(&self.state, s);
    }
    WriteGuard { rwlock: self }
}

注意,锁定的 RwLock 的确切状态值是变化的,但 wait() 操作要求我们提供一个精确的值来与状态进行比较。这就是为什么我们使用比较并交换操作的返回值来与 wait() 操作配合使用的原因。

解锁读锁会将状态减一。最终解锁 RwLock 的读者,即将状态从 1 改为 0 的读者,负责唤醒任何等待的写锁。

唤醒一个线程就足够了,因为我们知道此时不可能有正在等待的读者。实际上,如果 RwLock 是读锁定状态,读者就没有理由在等待。

impl<T> Drop for ReadGuard<'_, T> {
    fn drop(&mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 {
            // 唤醒一个等待的写线程(如果有的话)。
            wake_one(&self.rwlock.state);
        }
    }
}

写线程必须将状态重置为零才能解锁,之后它应该唤醒一个等待的写线程或所有等待的读线程。

我们无法知道是读线程还是写线程在等待,也没有办法只唤醒一个写线程或者只唤醒读线程。因此,我们选择唤醒所有线程:

impl<T> Drop for WriteGuard<'_, T> {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        // 唤醒所有等待的读线程和写线程。
        wake_all(&self.rwlock.state);
    }
}

就是这样!我们已经构建了一个非常简单但完全可用的读写锁。

接下来是修复一些问题。

避免忙等待的写线程

我们实现中的一个问题是,写锁可能导致意外的忙等待。

如果我们有一个有大量读者的 RwLock,并且这些读者不断地锁定和解锁它,锁的状态可能会持续波动,迅速地上下变化。对于我们的 write 方法,这会导致 compare-and-exchange 操作和随后的 wait() 操作之间,锁状态发生变化的概率变得非常高,尤其是在 wait() 操作直接实现为(相对较慢的)系统调用时。这意味着,wait() 操作经常会立即返回,尽管锁从未被解锁,锁的状态只是在读者数量上有所不同。

一种解决方法是为写线程等待使用一个不同的 AtomicU32,并且只有当我们真的想唤醒写线程时,才改变这个原子变量的值。

我们可以尝试这种方法,通过在 RwLock 中添加一个新的 writer_wake_counter 字段:

pub struct RwLock<T> {
    /// 读者的数量,或者是 `u32::MAX` 如果是写锁定状态。
    state: AtomicU32,
    /// 增加以唤醒写线程。
    writer_wake_counter: AtomicU32, // 新增!
    value: UnsafeCell<T>,
}

impl<T> RwLock<T> {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0),
            writer_wake_counter: AtomicU32::new(0), // 新增!
            value: UnsafeCell::new(value),
        }
    }

    …
}

read 方法保持不变,但 write 方法现在需要等待新的原子变量。为了确保我们不会错过从读取锁定的 RwLock 到实际进入休眠状态之间的任何通知,我们将使用一个类似于实现条件变量时的模式:先检查 writer_wake_counter,然后再检查是否还需要休眠:

pub fn write(&self) -> WriteGuard<T> {
    while self.state.compare_exchange(
        0, u32::MAX, Acquire, Relaxed
    ).is_err() {
        let w = self.writer_wake_counter.load(Acquire);
        if self.state.load(Relaxed) != 0 {
            // 如果 RwLock 仍然被锁定,且自我们检查后
            // 没有收到唤醒信号,则等待。
            wait(&self.writer_wake_counter, w);
        }
    }
    WriteGuard { rwlock: self }
}

writer_wake_counter 的获取操作将形成一个“先发生关系”(happens-before relationship),它与在解锁状态后立即执行的释放增量操作相关联,这样可以在唤醒等待的写线程之前保证增量操作已经完成:

impl<T> Drop for ReadGuard<'_, T> {
    fn drop(&mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 {
            self.rwlock.writer_wake_counter.fetch_add(1, Release); // 新增!
            wake_one(&self.rwlock.writer_wake_counter); // 改变!
        }
    }
}

这种“先发生关系”确保了写方法在看到未被递减的 state 值之后,不能观察到已递增的 writer_wake_counter 值。否则,写线程可能会误以为 RwLock 仍然被锁定,而错过了唤醒信号。

如之前所述,解锁写锁时应该唤醒一个等待的写线程或所有等待的读线程。由于我们仍然不知道是读线程还是写线程在等待,我们必须同时唤醒一个等待的写线程(通过 wake_one)和所有等待的读线程(通过 wake_all):

impl<T> Drop for WriteGuard<'_, T> {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        self.rwlock.writer_wake_counter.fetch_add(1, Release); // 新增!
        wake_one(&self.rwlock.writer_wake_counter); // 新增!
        wake_all(&self.rwlock.state);
    }
}

提示

在某些操作系统上,wake 操作背后的实现返回它唤醒的线程数。它可能会返回比实际唤醒的线程数少的值(因为某些线程可能会被错误地唤醒),但它的返回值仍然可以作为优化的一部分。

例如,在上述的 drop 实现中,如果 wake_one() 操作表明它确实唤醒了一个线程,我们可以跳过调用 wake_all()

避免写线程饥饿

RwLock 的一个常见用例是在许多频繁的读者和非常少的、不频繁的写者之间共享资源。例如,可能有一个线程负责读取传感器输入或定期下载新数据,而其他多个线程需要使用这些数据。

在这种情况下,我们可能会遇到“写线程饥饿”的问题:即写者(写线程)永远无法获取锁,因为总有读者存在,使得 RwLock 始终处于读锁定状态。

一种解决方案是,当有写线程在等待时,禁止任何新读者获取锁,即使 RwLock 仍然是读锁定状态。这样,所有新的读者将必须等到写线程获取锁后才能获取锁,从而确保读者能够访问写线程希望共享的最新数据。

让我们来实现这个方案。

为了实现这个,我们需要跟踪是否有写线程在等待。为了在状态变量中存储这一信息,我们可以将读锁的计数乘以 2,并且在有写线程等待时加 1。这样,状态值为 6 或 7 都表示有三个活动的读锁:6 表示没有等待的写线程,7 表示有写线程在等待。

如果我们将 u32::MAX(这是一个奇数)作为写锁定状态,那么当状态是奇数时,读者必须等待;当状态是偶数时,读者可以通过将状态加 2 来获取读锁。

pub struct RwLock<T> {
    /// 读锁数量乘以 2,如果有写线程等待,则加 1。
    /// 如果是写锁定,则为 u32::MAX。
    ///
    /// 这意味着,当状态是偶数时,读者可以获取锁;
    /// 当状态是奇数时,读者必须阻塞。
    state: AtomicU32,
    /// 增加以唤醒写线程。
    writer_wake_counter: AtomicU32,
    value: UnsafeCell<T>,
}

我们需要更改 read 方法中的两个 if 语句,不再将状态与 u32::MAX 进行比较,而是检查状态是偶数还是奇数。我们还需要更改 assert 语句中的上限,并确保通过将状态加 2 而不是加 1 来加锁。

pub fn read(&self) -> ReadGuard<T> {
    let mut s = self.state.load(Relaxed);
    loop {
        if s % 2 == 0 { // 偶数。
            assert!(s != u32::MAX - 2, "读者太多");
            match self.state.compare_exchange_weak(
                s, s + 2, Acquire, Relaxed
            ) {
                Ok(_) => return ReadGuard { rwlock: self },
                Err(e) => s = e,
            }
        }
        if s % 2 == 1 { // 奇数。
            wait(&self.state, s);
            s = self.state.load(Relaxed);
        }
    }
}

write 方法需要做更大的改动。我们将使用一个与 read 方法类似的比较并交换循环。如果状态是 0 或 1(表示 RwLock 是解锁状态),我们将尝试将状态改为 u32::MAX 来进行写锁定。否则,我们将必须等待。在等待之前,我们需要确保状态是奇数,以阻止新的读者获取锁。确保状态是奇数后,我们等待 writer_wake_counter 变量,同时确保锁没有在此期间被解锁。

代码实现如下:

pub fn write(&self) -> WriteGuard<T> {
    let mut s = self.state.load(Relaxed);
    loop {
        // 如果解锁,则尝试加锁。
        if s <= 1 {
            match self.state.compare_exchange(
                s, u32::MAX, Acquire, Relaxed
            ) {
                Ok(_) => return WriteGuard { rwlock: self },
                Err(e) => { s = e; continue; }
            }
        }
        // 通过确保状态是奇数来阻止新的读者。
        if s % 2 == 0 {
            match self.state.compare_exchange(
                s, s + 1, Relaxed, Relaxed
            ) {
                Ok(_) => {}
                Err(e) => { s = e; continue; }
            }
        }
        // 如果仍然锁定,则等待。
        let w = self.writer_wake_counter.load(Acquire);
        s = self.state.load(Relaxed);
        if s >= 2 {
            wait(&self.writer_wake_counter, w);
            s = self.state.load(Relaxed);
        }
    }
}

由于我们现在跟踪是否有写线程在等待,因此在读解锁时,我们可以在不必要时跳过 wake_one() 调用:

impl<T> Drop for ReadGuard<'_, T> {
    fn drop(&mut self) {
        // 将状态减 2 以移除一个读锁。
        if self.rwlock.state.fetch_sub(2, Release) == 3 {
            // 如果我们从 3 减到 1,意味着
            // `RwLock` 现在是解锁状态,并且有
            // 一个写线程在等待,我们唤醒它。
            self.rwlock.writer_wake_counter.fetch_add(1, Release);
            wake_one(&self.rwlock.writer_wake_counter);
        }
    }
}

当写锁定(即状态为 u32::MAX)时,我们不会跟踪任何关于线程是否在等待的信息。所以,写解锁的实现保持不变:

impl<T> Drop for WriteGuard<'_, T> {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        self.rwlock.writer_wake_counter.fetch_add(1, Release);
        wake_one(&self.rwlock.writer_wake_counter);
        wake_all(&self.rwlock.state);
    }
}

对于一个针对“频繁读取和不频繁写入”用例优化的读写锁来说,这样的实现是完全可接受的,因为写锁(因此也包括写解锁)发生的频率较低。

然而,对于一个更通用的读写锁,进一步优化是绝对值得的,以使写锁定和解锁的性能接近一个高效的三态互斥锁的性能。这个优化作为一个有趣的练习留给读者。

总结

  • atomic-wait crate 提供了基本的类似 futex 的功能,适用于(最近版本的)所有主要操作系统。
  • 一个最小的互斥锁实现只需要两种状态,像我们在第 4 章中的 SpinLock 一样。
  • 一个更高效的互斥锁会跟踪是否有线程在等待,这样可以避免不必要的唤醒操作。
  • 在某些情况下,先自旋再进入睡眠可能是有益的,但这取决于具体情况、操作系统和硬件。
  • 一个最小的条件变量只需要一个通知计数器,Condvar::wait 需要在解锁互斥锁之前和之后检查这个计数器。
  • 条件变量可以跟踪等待线程的数量,以避免不必要的唤醒操作。
  • 避免从 Condvar::wait 中被虚假唤醒可能很棘手,需要额外的记录工作。
  • 一个最小的读写锁只需要一个原子计数器作为状态。
  • 一个额外的原子变量可以用来独立唤醒写线程,不受读线程的影响。
  • 为了避免写线程饥饿,需要额外的状态来优先处理等待的写线程,而不是新的读线程。