Rust_Atomics_and_Locks

第八章:操作系统原语

英文版本

目前,我们主要聚焦在非阻塞的操作中。如果我们想要实现一些类似互斥锁或者条件变量的内容,也就是能够等待另一个线程去解锁或者通知它的内容,我们需要一种有效地阻塞当前线程的方式。

正如我们在第四章所见到的,我们可以不依赖操作系统,通过自旋,重复地一遍又一遍地尝试某些操作,自己实现阻塞,但这浪费大量的处理器时间。如果我们想要高效地进行阻塞,我们需要操作系统内核的帮助。

内核,或者更具体地说是其中的调度部分,负责决定哪个进程或者线程在何时运行,运行多长时间,并且在哪个处理器核心运行。尽管线程在等待某个事件发生时,内核可以停止,并给它任意的处理器时间,优先考虑其他能更好地利用这个有限资源的线程。

我们将需要一种方式来通知内核我们正在等待某个事件,并要求它将我们的线程置于睡眠状态,直到发生相关的事情。

使用内核接口

英文版本

与内核进行通信的方式很大程度依赖于操作系统,甚至是它的版本。通常,如何工作的细节被一个库或者更多库所隐藏,这些库为我们处理这些细节。例如,使用 Rust 的标准库,我们可以仅调用 File::open() 去打开这个文件,而不必关心任何操作系统内核的细节。类似地,使用 C 标准库(libc)也可以调用标准的 fopen() 函数去打开一个文件。调用这样的函数最终会导致调用操作系统内核,也称为系统调用(syscall),通常通过专门的处理器指令来完成(在某些架构上,该指令甚至直接称为 syscall)。

通常期望程序(有时直接要求)不直接进行系统调用,而是利用操作系统携带的更高级别的库。在 Unix 系统中(例如那些基于 Linux 的),libc 扮演了与内核交换的标准接口的特殊角色。

POSIX1(可移植操作系统接口)标准,包括了在类 Unix 系统上的 libc,以及对其额外的要求。例如,在 C 标准的 fopen() 函数之外,POSIX 还要求存在更低级别的 open()openat() 函数来打开文件,这些函数通常直接对应一个系统调用。由于 libc 在类 Unix 系统上的特殊地位,使用其他语言编写的程序通常仍然使用 libc 来进行与内核的所有交互。

Rust 软件,包括标准库,通常通过相同名称的 libc crate 使用 libc 库。

尤其对于 Linux,系统调用接口被保证稳定,允许我们直接进行系统调用,而不使用 libc。尽管这不是最常见或最推荐的方式,但它正在逐渐变得更受欢迎。

然而,虽然 MacOS 也是一个 Unix 操作系统,跟随 POSIX 标准,但是它的内核系统调用接口并不稳固,并且我们并不建议直接使用它。程序被允许使用的唯一稳定接口是通过系统附带的库(如 libc、libc++)和其他库(用于 C、C++、Objective-C 和 Swift)提供的接口,这些是苹果公司的首选编程语言。

Windows 不遵循 POSIX 标准。它并没有携带一个拓展的 libc 作为主要的内核接口,而是携带了一系列独立的库,例如 kernel32.dll,它提供了 Windows 的特定功能,如用于打开文件的 CreateFileW。与在 macOS 上一样,我们不应使用未记录的较低级别函数或直接进行系统调用。

通过它们的库,操作系统为我们提供了需要与内核进行交互的同步原语,如互斥锁和条件变量。这些实现的哪一部分属于库/内核的一部分,在不同的操作系统中有很大的差异。例如,有时互斥锁的锁定和解锁操作直接对应一个内核系统调用,而在其他系统中,库会处理大部分操作,并且只在需要阻塞或唤醒线程时执行系统调用(后者往往更高效,因为进行系统调用可能较慢)。

POSIX

英文版本

作为 POSIX 线程扩展的一部分,更为人熟知的是 pthread,POSIX 规范了用于并发的数据类型和函数。尽管 libthread 在技术上是作为一个独立的系统库的一部分,但是如今它通常被直接包含在 libc 中。

除了线程的 spawn 和 join 功能(pthread_createpthread_join)外,pthread 还提供了最常见的同步原语:互斥锁(pthread_mutex_t)、读写锁(pthread_rwlock_t)和条件变量(pthread_cond_t)。

在 Rust 中包装类型

英文版本

通过方便地将其 C 类型(通过 libc crate)包装在 Rust 结构体中,我们可以轻松地将这些 pthread 同步原语暴露给 Rust,例如:

pub struct Mutex {
    m: libc::pthread_mutex_t,
}

然而,这种方法存在一些问题,因为该 pthread 类型是为 C 设计的,而不是为 Rust 设计的。

首先,Rust 关于可变性和借用有一些规则,通常不允许在共享时进行修改。由于类似 pthread_mutex_lock 这样的函数总是可能对互斥锁进行修改,我们将需要内部可变性来确保这是可接受的。因此,我们需要将其包装在 UnsafeCell 中:

pub struct Mutex {
    m: UnsafeCell<libc::pthread_mutex_t>,
}

一个巨大的问题是关于移动

在 Rust 中,我们所有时间都在移动对象。例如,通过从函数返回对象、将其作为参数传递或者简单地将其分配给新的位置。我们拥有的多所有东西(并且没有被其他东西借用),我们可以自由地移动它们到一个新位置。

然而,在 C 中,这并不是普遍正确的。在 C 中,类型通常依赖它的内存地址保持不变。例如,它可能包含一个指向自身的指针,或者在某个全局数据结构中存储一个指向自己的指针。在这种情况下,移动到一个新的位置可能导致未定义行为。

我们讨论的 pthread 类型不能保证它们是可移动的,在 Rust 中,这会带来很大的问题。即使是一个简单的惯用的 Mutex::new() 函数也是一个问题:它将返回一个 mutex 对象,这将移动到一个内存的新位置。

因为用户可能总是移动任何它们拥有的 mutex 对象到其他地方,我们要么需要承诺它别这么做,要么使接口不安全;或者我们需要取走所有权并且隐藏所有内容到一个包装类型后面(可以使用 std::pin::Pin 来完成)。这些都不是最好的解决方案,因为它们会影响我们的 mutex 类型的接口,使其用起来容易出错和/或不方便使用。

一个可以的解决方案是将 mutex 包装在一个 Box 中。通过将 pthread 的 mutex 放在它自己的内存分配中,即使所有者被移动,它仍然在内存中的相同位置。

pub struct Mutex {
    m: Box<UnsafeCell<libc::pthread_mutex_t>>,
}

这就是 std::sync::Mutex 在 Rust 1.62 之前在所有 Unix 平台上实现的方式。

这个方式的缺点就是开销大:每个 mutex 都有自己的内存分配,为创建、销毁以及使用 mutex 增加了显著的开销。另一个缺点是它阻止了 new 函数编译时执行(const),这妨碍了拥有静态 mutex 的方式。

即使 pthread_mutex_t 是可移动的,const fn new 也可能仅使用默认设置来初始化,这导致了当递归锁定时的未定义行为。没有办法设计一个安全的接口来防止递归锁定,因此这意味着我们要使用 unsafe 标记锁定函数,以使用户承诺他们不会这样做。

当丢弃 mutex 时,在我们的 Box 方法中仍然存在一个问题。看起来,如果设计正确,就不可能在被锁定时丢弃 mutex,因为通过 MutexGuard 借用它时,不可能丢弃它。MutexGuard 必须先被丢弃,解锁 Mutex。然而,在 Rust 中,安全地遗忘(或泄露)一个对象,而不将其丢弃是安全的。这意味着可以编写类似下面的代码:

fn main() {
    let m = Mutex::new(..);

    let guard = m.lock(); // 锁定它 ..
    std::mem::forget(guard); // .. 但是没有解锁它。
}

在以上示例中,m 将在作用域结束后被丢弃,尽管它仍然被锁定。根据 Rust 的编译器来看,这是好的,因为 guard 已经被泄漏并且不能再使用。

然而,pthread 规定在已锁定的 mutex 调用 pthread_mutex_destroy() 并不能保证工作并且可能导致未定义行为。一种解决方案是当丢弃我们的 Mutex 时,首先试图去锁定(和解锁)pthread mutex,并且当它已经锁定时触发 panic(或泄漏 Box),但这甚至要更大的开销。

这些问题不仅适用于 pthread_mutex_t,还适用于我们讨论的其他类型。总体而言,pthread 的同步原语设计对 C 是好的,但是并不完全适合 Rust。

Linux

英文版本

在 Linux 系统中,pthread 同步原语所有都是使用 futex 系统调用实现。它的名称来自“快速用户区互斥4”(fast user-space mutex),因为增加这个系统调用最初的动机就是允许库(如 pthread 实现)包含一个快速且高效 mutex 实现。它的灵活远不止于此,可以用来构建许多不同的同步工具。

在 2003 年,futex 系统调用被增加到 Linux 内核,此后进行了几次改善和扩展。一些其他的系统调用因此也增加了相似的功能,更值得注意的是,在 2012 年 Windows 8 也增加了 WaitOnAddress(我们将会稍后在“Windows”部分讨论这个)。在 2020 年,C++ 语言甚至把基础的类 futex 操作增加到了标准库,并添加了 atomic_waitatomic_notify 函数。

Futex

英文版本

在 Linux 上,SYS_futex 是一个系统调用,在 32 位的原子整数上它实现了各种操作。主要的两个操作是 FUTEX_WAITFUTEX_WAKE。等待操作会让线程进入睡眠状态,而在同一个原子变量上进行唤醒操作则会将线程唤醒。

这些操作并不会在原子整数中存储任何内容。相反,内核会记住哪些线程正在等待哪个内存地址,以便唤醒操作能够正确地唤醒线程。

第一章的“等待:阻塞和条件变量”中,我们看到其他阻塞和唤醒线程的机制,需要一种方式以确保唤醒操作不会在竞争中丢失。对于线程的阻塞操作,通过将 unpark() 操作应用于未来的 park() 操作,来解决这个问题。并且对于条件变量来说,这是通过与条件变量一起使用的互斥锁来解决的。

对于 futex 的等待和唤醒操作,使用了另一种机制。等待操作接受一个参数,该参数是我们期望原子变量具有的值,如果不匹配,就会拒绝阻塞。等待操作在与唤醒操作的原子性上保持一致,这意味着在检查期望值和实际进入睡眠状态之间,不会丢失任何唤醒信号。

如果我们确保在唤醒操作之前改变原子变量的值,我们就可以确保即将开始等待的线程不会进入睡眠状态,这样就不再关心可能丢失 futex 唤醒操作的问题了。

让我们通过一个简单的例子来实践一下。

首先,我们需要能够调用这些系统调用。我们可以使用 libc crate 中的 syscall 函数来实现,并将每个调用封装在一个方便的 Rust 函数中,如下所示:

#[cfg(not(target_os = "linux"))]
compile_error!("Linux only. Sorry!");

pub fn wait(a: &AtomicU32, expected: u32) {
    // 参考 futex (2) 手册中的系统调用签名
    unsafe {
        libc::syscall(
            libc::SYS_futex, // futex 系统调用
            a as *const AtomicU32, // 要操作的原子
            libc::FUTEX_WAIT, // futex 操作
            expected, // 预期的值
            std::ptr::null::<libc::timespec>(), // 没有超时
        );
    }
}

pub fn wake_one(a: &AtomicU32) {
    // 参考 futex (2) 手册中的系统调用签名
    unsafe {
        libc::syscall(
            libc::SYS_futex, // futex 系统调用
            a as *const AtomicU32, // 要操作的原子
            libc::FUTEX_WAKE, // futex 操作
            1, // 要唤醒的线程数量
        );
    }
}

现在,作为一个使用示例,让我们用这些让一个线程等待另一个线程。我们将使用一个原子变量,我们用 0 为它初始化,主线程将在该变量上进行 futex 等待。第二个线程会将变量更改为 1,然后在上面运行 futex 唤醒操作以唤醒主线程。

就像线程阻塞和等待一个条件变量,futex 等待操作可能甚至在没有任何发生的情况下虚假唤醒。因此,通常在循环中使用它,如果我们等待的条件尚未满足,就会重复它。

让我们来看一下下面的示例:

fn main() {
    let a = AtomicU32::new(0);

    thread::scope(|s| {
        s.spawn(|| {
            thread::sleep(Duration::from_secs(3));
            a.store(1, Relaxed); // 1
            wake_one(&a); // 2
        });

        println!("Waiting...");
        while a.load(Relaxed) == 0 { // 3
            wait(&a, 0); // 4
        }
        println!("Done!");
    });
}
  1. 在几秒钟后,创建的线程将设置原子变量的值为 1。
  2. 然后,它执行一个 futex 唤醒操作去唤醒主线程,以防止它正在睡眠,这样可以看到变量已经发生了变化。
  3. 主线程将会等待直到变量是 0,然后继续打印最终的消息。
  4. futex 的 wait 操作用于将线程置入睡眠状态。非常重要的是,在进入睡眠之前,此操作将检查变量是否仍然是 0,这是在步骤 3 和步骤 4 之间不能丢失来自产生线程的信号的原因。要么 1(并且因此 2)尚未发生,它将进入睡眠状态,要么 1(并且可能 2)已经发生,线程将立即继续执行。

在这里一个重要的观察是,如果 a 已经在 while 循环之前设置为 1,那么就可以完全避免等待调用。以类似地方式,如果主线程还在原子变量中存储了它是否开始等待的信号(通过将其设置为除了 0 或 1 之外的值),如果主线程尚未开始等待,发送信号的线程可以跳过 futex 的等待操作。这就是基于 futex 的同步原语如此快速的原因:由于我们自己管理状态,除非我们真正的需要阻塞,否则我们不需要依赖内核。

自 Rust 1.48 以来,在 Linux 上,标准库的线程阻塞(park)函数是这样实现的。它们每个线程使用一个原子变量,有三种可能的状态:0 表示空闲和初始状态、1 为“已释放但尚未阻塞”,-1 为“已阻塞但尚未释放”。

第九章,我们将使用这些操作实现互斥锁、条件变量以及读写锁。

Futex 操作

英文版本

接下来到等待和唤醒操作,futex 系统调用还支持其他几个操作。在该章节,我们将简要地讨论此系统调用的每个支持的操作。

futex 的第一个参数始终是指向要操作的 32 位原子变量的指针。第二个参数是一个表示操作的常量,例如 FUTEX_WAIT,还可以添加最多两个标识:FUTEX_PRIVATE_FLAG 和/或 FUTEX_CLOCK_REALTIME,我们将在下面进行讨论。剩余的参数取决于具体的操作,我们将在每个操作的描述中进行说明。

可以添加 FUTEX_PRIVATE_FLAG 到其中的任何一个操作,以启用可能的优化。(通常情况下,如果对同一原子变量的所有相关 futex 操作来自同一进程,则可以利用此标识)。为了使用该标识,每个相关的 futex 操作都必须包括相同的标识。通过允许内核假设不会与其他进程发生交互,它可以跳过执行 futex 操作中的一些可能高开销的步骤,从而提高性能。

除了 Linux,NetBSD 也支持上述所有的 futex 操作。OpenBSD 也有一个 futex 系统调用,但仅支持 FUTEX_WAIT、FUTEX_WAKE 和 FUTEX_REQUEUE 操作。FreeBSD 没有原生的 futex 系统调用,但包含一个名为 _umtx_op 的系统调用,其中包含与 FUTEX_WAIT 和 FUTEX_WAKE 几乎相同的功能:UMTX_OP_WAIT(用于 64 位原子变量)、UMTX_OP_WAIT_UINT(用于 32 位原子变量)和 UMTX_OP_WAKE。Windows 也包含与 futex 等待和唤醒操作非常相似的函数,我们将在本章后面讨论。

新的 Futex 操作

发布在 2022 年的 Linux 5.16,引入了一个新的系统调用:futex_waitv。这个新的系统调用通过向它提供一个包含待等待的原子变量(及其期望值)的列表,允许一次等待多个 futex。在 futex_waitv 上被阻塞的线程可以通过在任意指定的变量上进行唤醒操作来被唤醒。

这个新的系统调用还为未来的扩展留出了空间。例如,可以指定待等待的原子变量的大小。虽然最初的实现只支持 32 位原子变量,就像原始的 futex 系统调用一样,但在未来可能会扩展为支持 8 位、16 位和 64 位原子变量。

优先继承 Futex 操作

英文版本

优先级反转5是指高优先级线程在低优先级线程持有的锁上被阻塞的问题。高优先级线程实际上“反转”了它的优先级,因为它现在必须等待低优先级线程释放锁才能继续执行。

解决这个问题的方法是优先级继承,即阻塞的线程继承等待它的最高优先级线程的优先级,在持有锁期间临时提高低优先级线程的优先级。

除了我们之前讨论过的七个 futex 操作外,还有六个专门用于实现优先级继承锁的优先级继承 futex 操作。

我们之前讨论过的通用 futex 操作对于原子变量的具体内容没有任何要求。我们可以自己选择 32 位的表示方式。然而,对于优先级继承 mutex,内核需要能够理解 mutex 是否被锁定,如果锁定了,则需要知道哪个线程锁定了它。

为了避免在每个状态变化上进行系统调用,优先级继承 futex 操作指定了 32 位原子变量的确切内容,以便内核可以理解它:最高位表示是否有任何线程正在等待锁定 mutex,最低的 30 位包含持有锁的线程 ID(Linux 的 tid,而不是 Rust 的 ThreadId),当解锁时为零。

作为额外的功能,如果持有锁的线程在未解锁的情况下终止,内核将设置次高位,但前提是没有任何等待着。这使得 mutex 具有鲁棒性6:这是一个术语,用于描述 mutex 在“拥有”线程意外终止的情况下能够正常处理的能力。

优先级继承 futex 操作与标准 mutex 操作一一对应:FUTEX_LOCK_PI 用于锁定,FUTEX_UNLOCK_PI 用于解锁,FUTEX_TRYLOCK_PI 用于非阻塞锁定。此外,FUTEX_CMP_REQUEUE_PI 和 FUTEX_WAIT_REQUEUE_PI 操作可用于实现与优先级继承 mutex 配对的条件变量。

我们将不详细讨论这些操作。有关详细信息,请参阅 futex(2) Linux 手册页或 crates.io 上的 linux-futex crate。

macOS

英文版本

macOS 部分的内核支持各种有用的低级并发相关的系统调用。然而,就像大多数操作系统一样,内核接口并不是稳定的,并且我们应该直接地使用它。

软件与 macOS 内核交互的唯一方式是通过系统携带的库。这些库包含它对 C(libc)、C++(libc++)、Objective-C 和 Swift 的标准库实现。

作为符合 POSIX 标准的 Unix 系统,macOS C 标准库包含一个完整的 pthread 实现。其他语言中的标准库锁通常在底层使用 pthread 原语。

在 macOS 上,与其他系统对比,Pthread 的锁相对低较慢。原因之一是 macOS 上的锁默认情况下是公平锁(Fair Lock),这意味着几个线程试图去锁定相同的 mutex 时,它们会按照到达的顺序一次获得锁定,就像一个完美的队列。尽管公平性可能是值得拥有的属性,但它会显著降低性能,特别是在高竞争的情况下。

os_unfair_lock

英文版本

除了 pthread 原语,macOS 10.12 引入了一种新的轻量级平台特定的互斥锁,它是不公平的:os_unfair_lock。它的大小仅有 32 位,可以使用 OS_UNFAIR_LOCK_INIT 常来那个静态地初始化,并且不需要销毁。它可以通过 os_unfair_lock_lock()(阻塞)或 os_unfair_lock_trylock()(非阻塞)来锁定它,并且通过 os_unfair_lock_unlock() 来解锁。

不幸的是,它没有条件变量,也没有 reader-writer 变体。

Windows

英文版本

Windows 操作系统携带了一系列库,它们一起形成了 Windows API,通常称之为“Win32 API”(甚至在 64 位系统也是)。它构成了一个在“Native 之上”的层:大部分是与内核没有交互的接口,我们不建议直接使用它。

通过微软官方提供的 windows 和 windows-sys crate,Windows API 可以为 Rust 程序所用,这在 crates.io 上是可获得的。

重量级内核对象

英文版本

在 Windows 上可用的许多旧的同步原语完全由内核管理,这使得它们非常重量,并赋予它们与其他内核管理对象(例如文件)类似的属性。它们可以被多个进程使用,可以通过名称进行命名和定位,并且支持细粒度的权限,类似于文件。例如,可以允许一个进程等待某个对象,而不允许它通过该对象发送信号来唤醒其他进程。

这些重量级的内核管理同步对象包括 Mutex(可以锁定和解锁)、Event(可以发送信号和等待)以及 WaitableTimer(可以在选择的时间后或定期自动发送信号)。创建这样的对象会得到一个句柄(HANDLE),就像打开一个文件一样,可以轻松地传递并与常规的 HANDLE 函数一起使用,特别是一系列的等待函数。这些函数允许我们等待各种类型的一个或多个对象,包括重量级同步原语、进程、线程和各种形式的 I/O。

轻量级对象

英文版本

在 Windows API 中,一个轻量级的同步原语包括是“临界区7”(critical section)。

临界区这个术语指的是程序的一部分,即代码的“区段”,可能不允许超过一个线程进入。这种保护临界区段机制通常称之为互斥锁。然而,微软为这种机制使用“临界区”的名称,可能因为之前讨论的重量级 mutex 对象已经采用了“互斥锁”这个名称。

Winodows 中的 CRITICAL_SECTION 实际上是一个递归互斥锁,只是它使用了“enter”(进入)和“leave”(离开)而不是“lock”(锁定)和“unlock”(解锁)。作为递归互斥锁,它仅被设计用于保护其他的线程。它允许相同的线程多次锁定(或者“进入”)它,也要求该线程也必须相同的次数解锁(“离开”)。

当使用 Rust 包装该类型时,有些东西值得注意。成功地锁定(进入)CRITICAL_SECTION 不应该导致对其数据保护的独占引用(&mut T)。否则,线程可以使用此来创建对同一数据的两个独占引用,这会立即导致未定义行为。

CRITICAL_SECTION 使用 InitializeCriticalSection() 函数来初始化,使用 DeleteCriticalSection() 函数来销毁,并且不能被移动。通过 EnterCriticalSection() 或者 TryEnterCriticalSection() 来锁定,并且使用 LeaveCriticalSection() 解锁。

在 Rust 1.51 之前,Windows XP 上的 std::sync::Mutex 基于(Box 的内存分配)CRITICAL_SECTION 对象。(Rust 1.51 放弃了对 Windows XP 的支持。)

精简的读写(SRW)锁8

英文版本

从 Windows Vista(和 Windows Server 2008)开始,Windows API 包含了一个非常轻量级的优秀锁原语:精简读写锁,简称 SRW 锁

SRWLOCK 类型仅是一个指针大小,可以用 SRWLOCK_INIT 静态初始化,并且不需要销毁。当不再被使用(借用),我们甚至允许移动它,使它成为 Rust 类型的理想选择。

它通过 AcquireSRWLockExclusive()TryAcquireSRWLockExclusive()ReleaseSRWLockExclusive() 提供了独占(writer)锁定和解锁,并通过 AcquireSRWLockShared()TryAcquireSRWLockShared()ReleaseSRWLockShared() 提供了共享(reader)锁定和解锁。通常可以将其用作普通的互斥锁,只需忽略共享(reader)锁定函数即可。

SRW 锁既不优先考虑 writer 也不优先考虑 reader。虽然不能保证,但是它试图去按顺序去服务所有锁请求,以减少性能下降。在已经持有一个共享(reader)锁定的线程上不要尝试获取第二个共享(reader)锁定。如果该操作在另一个线程的独占(writer)锁定操作之后排队,那么这样做可能会导致永久死锁,因为第一个线程已经持有的第一个共享(reader)锁定会阻塞第二个线程。

SRW 锁与条件变量一起引入了 Windows API。CONDITION_VARIABLE 仅占用一个指针的大小,可以使用 CONDITION_VARIABLE_INIT 进行静态初始化,不需要销毁。只要它没有被使用(被借用),我们也可以移动它。

条件变量不仅通过 SleepConditionVariableSRW 与 SRW 锁一起使用,还可以通过 SleepConditionVariableCS 与临界区一起使用。

唤醒等待线程要么通过 WakeConditionVariable 唤醒单个线程,要么通过 WakeAllConditionVariable 唤醒所有等待线程。

最初,标准库中使用的 Windows SRW 锁和条件变量被包装在 Box 中,以避免移动对象。直到我们在 2020 年要求之后,微软才记录了这些对象的可移动性保证。自 Rust 1.49 起,std::sync::Mutexstd::sync::RwLockstd::sync::Condvar 在 Windows Vista 及更高版本中直接封装了 SRWLOCK 或 CONDITION_VARIABLE,无需任何额外的内存分配。

基于地址的等待

英文版本

Windows 8(和 Windows Server 2012)引入了一种新的、更灵活的同步功能类型,非常类似于本章前面讨论的 Linux FUTEX_WAITFUTEX_WAKE 操作。

WaitOnAddress 函数可以操作 8 位、16 位、32 位 或 64 位的原子变量。它接收了 4 个参数:原子变量地址、保存期望值的变量地址、原子变量大小(以字节为单位)以及在放弃之前的最大等待最大毫秒数(或者无限超时的 u32::MAX)。

就像 FUTEX_WAIT 操作一样,它将原子变量的值与预期值进行比较,如果匹配则进入睡眠状态,等待相应的唤醒操作。检查和睡眠操作相对于唤醒操作是原子发生的,这意味着没有唤醒信号会在两者之间丢失。

唤醒正在等待 WaitOnAddress 的线程可以通过 WakeByAddressSingle 来唤醒单个线程,或者通过 WakeByAddressAll 来唤醒所有等待的线程。这两个函数只接受一个参数:原子变量的地址,该地址也被传递给 WaitOnAddress

Windows API 的一些(但不是全部)同步原语是使用这些函数实现的。更重要的是,它们是构建我们自己的原始物的绝佳基石,我们将在第九章中这样做。

总结

英文版本

下一篇,第九章:构建我们自己的「锁」

参考:https://dashen.tech/2012/07/17/Wall-Clock与Monotonic-Clock/

  1. https://zh.wikipedia.org/wiki/可移植操作系统接口 

  2. 绝对时间。表示系统(或程序)启动后流逝的时间,更改系统的时间对它没有影响。每次系统(或程序)启动时,该值都归 0 

  3. 挂钟时间,即现实世界里我们感知到的时间,如 2008-08-08 20:08:00。但对计算机而言,这个时间不一定是单调递增的。因为人觉得当前机器的时间不准,可以随意拨慢或调快。 

  4. https://zh.wikipedia.org/wiki/Futex 

  5. https://zh.wikipedia.org/zh-cn/优先转置 

  6. https://zh.wikipedia.org/zh-cn/健壮性_(计算机科学) 

  7. https://zh.wikipedia.org/zh-cn/臨界區段 

  8. https://learn.microsoft.com/zh-cn/windows/win32/sync/slim-reader-writer--srw--locks