第 8 章 操作系统原语

来自 《Rust Atomics and Locks》 的翻译文章,英文原文https://marabos.nl/atomics/os-primitives.html在新窗口打开

到目前为止,我们主要集中在非阻塞操作上。如果我们想实现类似于互斥锁或条件变量的东西,即可以等待另一个线程来解锁或通知它的东西,我们需要一种方法来有效地阻塞当前线程。

正如我们在第 4 章中看到的,我们可以在没有操作系统帮助的情况下,通过自旋,一遍又一遍地反复尝试,来完成此操作,这很容易浪费大量处理器时间。然而,如果我们想要有效地阻塞,我们需要操作系统内核的帮助。

内核,或者更具体地说是它的调度程序部分,负责决定哪个进程或线程在何时运行、运行多长时间,以及在哪个处理器内核上运行。当一个线程正在等待某事发生时,内核可以停止给它任何处理器时间,优先考虑其他可以更好地利用这种稀缺资源的线程。

我们需要一种方法来通知内核我们正在等待某些事情,并要求它让我们的线程休眠直到相关的事情发生。

与内核接口

与内核通信的方式在很大程度上取决于操作系统,甚至常常取决于其版本。通常,这些工作的细节都隐藏在一个或多个库的后面,这些库为我们处理这些问题。例如,使用 Rust 标准库,我们只需调用 File::open() 即可打开文件,而无需了解操作系统内核接口的任何细节。类似地,使用 C 标准库 libc ,可以调用标准的 fopen 函数来打开文件。调用这样的函数最终会导致调用操作系统的内核,也称为系统调用,这通常是通过专门的处理器指令完成的。 (在某些体系结构中,该指令字面上称为 syscall 。)

通常期望,有时甚至要求程序不直接进行任何系统调用,而是使用操作系统附带的更高级别的库。在 Unix 系统上,例如那些基于 Linux 的系统, libc 扮演着为内核提供标准接口的特殊角色。

“便携式操作系统接口”标准,通常称为 POSIX 标准,包括对 Unix 系统上的 libc 的附加要求。例如,除了 C 标准中的 fopen() 函数,POSIX 还要求存在用于打开文件的较低级别的 open()openat() 函数,它们通常直接与系统调用相对应。由于 libc 在 Unix 系统上的特殊地位,用 C 以外的语言编写的程序通常仍然使用 libc 来进行与内核的所有交互。

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

特别是对于 Linux,系统调用接口保证稳定,允许我们直接进行系统调用,而无需使用 libc 。虽然这不是最常见或最受推荐的路线,但它正在慢慢变得越来越流行。

但是在macOS这种遵循POSIX标准的Unix操作系统上,内核的syscall接口并不稳定,我们不应该直接使用它。允许程序使用的唯一稳定接口是通过系统附带的库提供的,例如 libclibc++ 以及 C、C++、Objective-C 和 Swift(Apple 的编程语言)的各种其他库选择。

Windows 不遵循 POSIX 标准。它不附带用作内核主要接口的扩展 libc ,而是附带一组单独的库,例如 kernel32.dll ,提供特定于 Windows 的功能,例如用于打开的 CreateFileW 文件。就像在 macOS 上一样,我们不应该使用未记录的低级函数或直接进行系统调用。

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

POSIX

作为 POSIX 线程扩展的一部分,更广为人知的是 pthreads,POSIX 指定了并发的数据类型和函数。虽然在技术上被指定为一个单独的系统库 libpthread 的一部分,但这个功能现在经常被直接包含在 libc 中。

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

  • pthread_mutex_t

    Pthread 的互斥量必须通过调用 pthread_mutex_init() 进行初始化,并通过 pthread_mutex_destroy() 进行销毁。初始化函数采用 pthread_mutexattr_t 类型的参数,可用于配置互斥量的某些属性。

    这些属性之一是它在递归锁定上的行为,当已经持有锁的同一个线程试图再次锁定它时,就会发生这种情况。当使用默认设置(PTHREAD_MUTEX_DEFAULT)时,这会导致未定义的行为,但它也可以配置为导致错误 ( PTHREAD_MUTEX_ERRORCHECK )、死锁 ( PTHREAD_MUTEX_NORMAL ) 或成功的第二个锁 (PTHREAD_MUTEX_RECURSIVE)。

    这些互斥体通过 pthread_mutex_lock()pthread_mutex_trylock() 锁定,通过 pthread_mutex_unlock() 解锁。此外,与 Rust 的标准互斥锁不同,它们还支持通过 pthread_mutex_timedlock() 进行有时间限制的锁定。

    pthread_mutex_t 可以在不调用 1pthread_mutex_init()1 的情况下静态初始化,方法是为它分配值 PTHREAD_MUTEX_INITIALIZER 。但是,这仅适用于具有默认设置的互斥锁。

  • pthread_rwlock_t

    Pthread的读写锁是通过 pthread_rwlock_init()pthread_rwlock_destroy() 来初始化和销毁​​的。与互斥量类似,默认的 pthread_rwlock_t 也可以用 PTHREAD_RWLOCK_INITIALIZER 静态初始化。

    pthread 互斥量相比,pthread 读写锁具有的可通过其初始化函数配置的属性要少得多。最值得注意的是,尝试递归地写锁定它总是会导致死锁。

    然而,递归获取额外读锁的尝试一定会成功,即使有写者在等待。这有效地排除了任何将写入者优先于读取者的有效实现,这就是为什么大多数 pthread 实现优先读取者的原因。

    它的接口几乎与 pthread_mutex_t 的接口相同,包括对时间限制的支持,只是每个锁函数都有两种变体:一种用于读取者 ( pthread_rwlock_rdlock ),一种用于写入者 ( pthread_rwlock_wrlock )。也许令人惊讶的是,只有一个解锁函数 ( pthread_rwlock_unlock ) 可用于解锁任一类型的锁。

  • pthread_cond_t

    pthread 条件变量与 pthread 互斥量一起使用。它通过 pthread_cond_initpthread_cond_destroy 进行初始化和销毁​​,并且有一些可以配置的属性。最值得注意的是,我们可以配置时间限制是使用单调时钟(如 Rust 的 Instant )还是实时时钟(如 Rust 的 SystemTime ,有时称为“挂钟时间”)。具有默认设置的条件变量,例如由 PTHREAD_COND_INITIALIZER 静态初始化的条件变量,会使用实时时钟。

    通过 pthread_cond_timedwait() 来等待这样一个条件变量,也可以选择有时间限制。唤醒一个等待中的线程是通过调用 pthread_cond_signal() 来完成的,或者,如果要一次唤醒所有等待中的线程,可以调用pthread_cond_broadcast()

pthread 提供的其余同步原语是屏障( pthread_barrier_t )、自旋锁( pthread_spinlock_t )和一次性初始化( pthread_once_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>,
}

一个更大的问题是与转移(moving)有关。

在 Rust 中,我们一直在转移对象。例如,通过从函数返回一个对象,将其作为参数传递,或简单地将其分配到一个新地方。我们拥有的一切(并且没有被其他任何东西借用),我们可以自由地转移一个新地方。

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

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

由于用户总是可以在他们拥有的任何互斥对象周围转移,我们要么需要让他们承诺他们不会这样做,方法是将接口设置为 unsafe;或者我们需要取消他们的所有权,并将所有内容隐藏在包装器后面( std::pin::Pin 可用于此)。这些都不是好的解决方案,因为它们会影响我们的互斥锁类型的接口,使其非常容易出错和/或使用起来不方便。

此问题的解决方案是将互斥量包装在 Box 中。通过将 pthread 互斥体放在它自己的分配中,即使它的所有权转移了,它也会留在内存中的相同位置。

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

提示

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

这种方法的缺点是开销:每个互斥量现在都有自己的分配,增加了创建、销毁和使用互斥量的大量开销。另一个缺点是它阻止 new 函数成为 const ,这妨碍了 static 互斥。

即使 pthread_mutex_t 是可转移的, const fn new 也只能使用默认设置对其进行初始化,这会在递归锁定时导致未定义的行为。没有办法设计一个安全的接口来防止递归锁定,所以这意味着我们需要使 lock 函数成为 unsafe , 来让用户承诺他们不会那样做。

我们的 Box 方法仍然存在一个问题,那就是在丢弃一个锁定的 Mutex 的时候。如果设计得当,似乎不可能在锁定状态下丢弃一个 Mutex,因为不可能在它被 MutexGuard 借用时丢弃它。必须先丢弃 MutexGuard,才能解锁Mutex。然而,在Rust中,忘记(或泄露)一个对象是安全的,无需丢弃它。这意味着我们可以这样写:

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

    let guard = m.lock(); // Lock it ..
    std::mem::forget(guard); // .. but don't unlock it.
}

在上面的示例中, m 将在作用域的末尾被删除,而它仍处于锁定状态。根据 Rust 编译器的说法,这很好,因为守卫已经泄露,不能再使用了。

然而,pthread规定,在一个锁定的 Mutex 上调用 pthread_mutex_destroy() 并不保证有效,可能会导致未定义的行为。一个变通的办法是,在丢弃我们的 Mutex 时,首先尝试锁定(和解锁)pthread的 Mutex,当它已经被锁定时,再进行恐慌(或泄露Box),但这增加了更多的开销。

这些问题不仅仅适用于 pthread_mutex_t,也适用于我们讨论的其他类型。总的来说,pthread同步原语的设计对于C语言来说是没有问题的,但对于Rust来说就不是很合适了。

Linux

在Linux系统中,pthread的同步原语都是通过 futex 系统调用实现的。它的名字来自于 "快速用户空间互斥(fast user-space mutex)",因为添加这个系统调用的最初动机是为了让库(比如pthread的实现)包含一个快速有效的互斥锁实现。不过它比这要灵活得多,可以用来构建许多不同的同步工具。

futex 系统调用于 2003 年添加到 Linux 内核中,此后经历了多项改进和扩展。此后,其他一些操作系统也添加了类似的功能,最著名的是 2012 年的 Windows 8 添加了 WaitOnAddress (我们稍后将在“Windows”中讨论)。在 2020 年,C++ 语言甚至在其标准库中添加了对基本的类似 futex 操作的支持,并添加了 atomic_waitatomic_notify 函数。

Futex

在 Linux 上, SYS_futex 是一个系统调用,它实现了所有对 32 位原子整数进行操作的各种操作。主要的两个操作是 FUTEX_WAITFUTEX_WAKE 。 wait 操作使线程进入休眠状态,对同一个原子变量的 wake 操作再次唤醒线程。

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

第 1 章的“等待:停放和条件变量”中,我们看到了阻塞和唤醒线程的其他机制,如何需要一种方法来确保唤醒操作不会在竞争中丢失。对于线程停放,该问题通过使 unpark() 操作也适用于未来的 park() 操作来解决。对于条件变量,这是由与条件变量一起使用的互斥锁处理的。

对于 futex 等待和唤醒操作,使用了另一种机制。 wait 操作接受一个参数,该参数指定我们期望原子变量具有的值,如果不匹配将拒绝阻塞。等待操作相对于唤醒操作以原子方式运行,这意味着在检查预期值和它实际进入睡眠之间不会丢失任何唤醒信号。

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

让我们看一个最小的例子来实践一下。

首先,我们需要能够调用这些系统调用。我们可以使用 libc crate 中的 syscall 函数来做到这一点,并将它们分别包装在一个方便的 Rust 函数中,如下所示:

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

pub fn wait(a: &AtomicU32, expected: u32) {
    // Refer to the futex (2) man page for the syscall signature.
    unsafe {
        libc::syscall(
            libc::SYS_futex, // The futex syscall.
            a as *const AtomicU32, // The atomic to operate on.
            libc::FUTEX_WAIT, // The futex operation.
            expected, // The expected value.
            std::ptr::null::<libc::timespec>(), // No timeout.
        );
    }
}

pub fn wake_one(a: &AtomicU32) {
    // Refer to the futex (2) man page for the syscall signature.
    unsafe {
        libc::syscall(
            libc::SYS_futex, // The futex syscall.
            a as *const AtomicU32, // The atomic to operate on.
            libc::FUTEX_WAKE, // The futex operation.
            1, // The number of threads to wake up.
        );
    }
}

现在,作为一个用法示例,让我们使用它们让一个线程等待另一个。我们将使用一个初始化为零的原子变量,主线程将对其进行 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) 只要变量为零,主线程就会等待,然后继续打印其最终消息。

(4) futex等待操作用于让线程进入休眠状态。非常重要的是,此操作将在进入休眠之前检查 a 是否仍为零,这就是来自衍生线程的信号不会在 3 和 4 之间丢失的原因。要么 1(也就是 2)尚未发生,它就进入睡眠状态,或者 1(也许 2)已经发生,并且它会立即继续。

这里需要注意的是,如果在 while 循环之前 a 已经被设置为 1,则完全可以避免调用 wait。以类似的方式,如果主线程也在原子变量中存储了它是否开始等待信号(通过将其设置为 0 或 1 以外的值),那么如果主线程还没有开始等待,信号线程可以跳过futex唤醒操作。这就是基于 futex 的同步原语如此之快的原因:因为我们自己管理状态,所以我们不需要依赖内核,除非我们确实需要阻塞。

提示

从 Rust 1.48 开始,标准库在 Linux 上的线程停放功能是这样实现的。他们为每个线程使用一个原子变量,具有三种可能的状态:零表示空闲和初始状态,1 表示“未停放但尚未停放”,-1 表示“已停放但尚未停放”。

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

Futex 操作

除了等待和唤醒操作之外,futex 系统调用还支持其他几个操作。在本节中,我们将简要讨论此系统调用支持的每个操作。

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

  • FUTEX_WAIT

    此操作需要两个额外的参数:原子变量预期具有的值和指向表示最长等待时间的 timespec 的指针。

    如果原子变量的值与预期值匹配,等待操作将阻塞,直到被一个唤醒操作唤醒,或者直到 timespec 指定的持续时间已经过去。如果指向 timespec 的指针为空,则没有时间限制。此外,在达到时间限制之前,等待操作可能会在没有相应唤醒操作的情况下虚假唤醒并返回。

    检查和阻塞操作相对于其他 futex 操作作为单个原子操作发生,这意味着它们之间不会丢失唤醒信号。

    timespec 指定的持续时间默认表示单调时钟上的持续时间(如 Rust 的 Instant )。通过添加 FUTEX_CLOCK_REALTIME 标志,改为使用实时时钟(如 Rust 的 SystemTime )。

    返回值表明预期值是否匹配,以及是否达到了超时。

  • FUTEX_WAKE

    此操作需要一个额外的参数:要唤醒的线程数,是一个 i32

    这会唤醒指定数量的线程,这些线程在同一原子变量的等待操作中被阻塞。 (如果没有那么多等待线程,则更少。)最常见的是,此参数要么是唤醒一个线程,要么是 i32::MAX 以唤醒所有线程。

    返回唤醒线程的数量。

  • FUTEX_WAIT_BITSET

    这个操作需要四个额外的参数:原子变量的预期值,一个指向代表最大等待时间的 timespec 的指针,一个被忽略的指针,以及一个32位的 "位集"(是一个 u32)。

    此操作与 FUTEX_WAIT 的行为相同,但有两点不同。

    第一个区别是,它需要一个 bitset 参数,该参数可用于只等待特定的唤醒操作,而不是对同一个原子变量的所有唤醒操作。 FUTEX_WAKE 操作永远不会被忽略,但是如果等待位集和唤醒位集没有任何共同的 1 比特,则忽略来自 FUTEX_WAKE_BITSET 操作的信号。

    例如,带有 0b0101 位集的 FUTEX_WAKE_BITSET 操作将唤醒带有 0b1100 位集的 FUTEX_WAIT_BITSET 操作,但不会唤醒带有 0b0010 位集的 FUTEX_WAIT_BITSET 操作。

    这在实现读写锁之类的东西时可能很有用,可以在不唤醒任何读取者的情况下唤醒写入者。但是,请注意,使用两个单独的原子变量可能比对两种不同类型的服务员使用单个变量更有效,因为内核将为每个原子变量保留一个服务员列表。

    FUTEX_WAIT 的另一个区别是, timespec 用作绝对时间戳,而不是持续时间。正因为如此, FUTEX_WAIT_BITSET 通常与 u32::MAX 的bitset一起使用,有效地将其变成常规的 FUTEX_WAIT 操作,但用一个绝对的时间戳来表示时间限制。

  • FUTEX_WAKE_BITSET

    此操作需要四个附加参数:要唤醒的线程数、两个被忽略的指针,以及一个 32 位“位集”( 是一个 u32 )。

    此操作与 FUTEX_WAKE 相同,但它不会唤醒具有不重叠位集的 FUTEX_WAIT_BITSET 操作。 (见上面的 FUTEX_WAIT_BITSET 。)

    对于位集 u32::MAX (所有位集),这与 FUTEX_WAKE 相同。

  • FUTEX_REQUEUE

    此操作需要三个附加参数:要唤醒的线程数( i32 )、要重新排队的线程数( i32 )以及辅助原子变量的地址。

    此操作会唤醒给定数量的等待线程,然后将给定数量的剩余等待线程重新排队,以等待另一个原子变量。

    重新排队的等待线程继续等待,但不再受对主原子变量的唤醒操作的影响。相反,它们现在由辅助原子变量上的唤醒操作唤醒。

    这对于实现类似于条件变量的“通知全部”操作之类的操作非常有用。我们只能唤醒一个线程,然后将所有其他线程重新排队以直接等待互斥锁,而不是唤醒所有线程,这些线程随后会尝试锁定互斥锁,很可能使除一个线程之外的所有线程都立即等待该互斥锁。

    就像 FUTEX_WAKE 操作一样, i32::MAX 的值可用于重新排队所有等待的线程。 (为要唤醒的线程数指定 i32::MAX 的值不是很有用,因为这会使此操作等同于 FUTEX_WAKE 。)

    返回唤醒线程的数量。

  • FUTEX_CMP_REQUEUE

    此操作需要四个附加参数:要唤醒的线程数( i32 )、要重新排队的线程数( i32 )、辅助原子变量的地址以及主原子变量预期的值。

    此操作几乎与 FUTEX_REQUEUE 相同,但如果主原子变量的值与预期值不匹配,则将拒绝该操作。对值的检查和重新排队的操作是以原子方式进行的,与其他Futex操作相比。

    FUTEX_REQUEUE 不同,它返回唤醒线程数和重新排队线程数的总和。

  • FUTEX_WAKE_OP

    此操作需要四个附加参数:在主原子变量( i32 )上唤醒的线程数、在第二个原子变量( i32 )上可能唤醒的线程数,第二个原子变量的地址,以及一个编码操作和要进行比较的32位值。

    这是一个非常特殊的操作,它修改第二个原子变量,唤醒一些等待主原子变量的线程,检查原子变量的先前值是否匹配给定条件,如果匹配,还会唤醒第二个原子变量上的多个线程。

    换句话说,它与下面的代码相同,只是整个操作相对于其他futex操作来说是原子化的:

    let old = atomic2.fetch_update(Relaxed, Relaxed, some_operation);
    wake(atomic1, N);
    if some_condition(old) {
        wake(atomic2, M);
    }
    

    要执行的修改操作和要检查的条件都是由系统调用的最后一个参数指定的,并以32位编码。操作可以是下列之一:赋值、加法、二进制or、二进制and-not、二进制xor,参数可以是12位的,也可以是32位的,是2的幂。比较可以从==, !=, <, <=, >, 和 >=中选择,参数为12位。

    有关此参数编码的详细信息,请参阅 futex(2) Linux 手册页,或使用 crates.io 上的 linux-futex crate,其中包含构造此参数的便捷方法。

    此操作返回唤醒线程的总数。

    乍一看,这似乎是一个具有许多用例的灵活操作。然而,它只是为GNU libc中的一个特定用例而设计的,即两个线程必须从两个独立的原子变量中被唤醒。这个特定的案例已经被一个不同的实现所取代,不再使用FUTEX_WAKE_OP

如果对同一原子变量的所有相关 futex 操作都来自同一进程的线程(通常是这种情况),则可以将 FUTEX_PRIVATE_FLAG 添加到这些操作中的任何一个,以实现可能的优化。要使用它,每个相关的 futex 操作都必须包含相同的标志。通过允许内核假设不会与其他进程发生交互,它可以跳过一些在执行futex操作时可能很昂贵的步骤,从而提高性能。

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

新的Futex操作

从 2022 年发布的 Linux 5.16 开始,有一个额外的 futex 系统调用: futex_waitv 。这个新的系统调用允许一次等待多个 futex,方法是向其提供要等待的原子变量列表(及其预期值)。阻塞在 futex_waitv 上的线程可以通过对任何指定变量的唤醒操作来唤醒。

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

优先继承 Futex 操作

优先级反转是当高优先级线程被低优先级线程持有的锁阻塞时发生的问题。高优先级线程实际上“反转”了其优先级,因为它现在必须等待低优先级线程释放锁才能取得进展。

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

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

我们之前讨论的一般 futex 操作对原子变量的具体内容没有任何要求。我们必须自己选择 32 位代表什么。然而,对于优先级继承互斥量,内核需要能够了解互斥量是否被锁定,如果是,那么哪个线程锁定了它。

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

作为一项额外功能,如果持有锁的线程在未解锁的情况下终止,则内核将设置第二高位,但前提是有任何等待者。这使得互斥体变得健壮:该术语用于描述互斥体可以优雅地处理其“所属”线程意外终止的情况。

优先级继承futex操作与标准互斥操作一一对应: FUTEX_LOCK_PI 用于锁定, FUTEX_UNLOCK_PI 用于解锁, FUTEX_TRYLOCK_PI 用于锁定而不阻塞。此外, FUTEX_CMP_REQUEUE_PIFUTEX_WAIT_REQUEUE_PI 操作可用于实现与优先级继承互斥体配对的条件变量。

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

macOS

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

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

作为兼容 POSIX 的 Unix 系统,macOS C 库包含完整的 pthread 实现。其他语言中的标准锁倾向于在幕后使用 pthread 的原语。

与其他操作系统上的同等锁相比,Pthread 的锁在 macOS 上往往相对较慢。原因之一是 macOS 上的锁默认表现为公平锁。这意味着当多个线程尝试锁定同一个互斥锁时,它们将按到达顺序提供服务,就像完美的队列一样。虽然公平性可能是一个理想的属性,但它会显着降低性能,尤其是在高争用的情况下。

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() 解锁。

不幸的是,它没有条件变量,也没有读取者-写入者变体。

Windows

Windows 操作系统附带了一组库,这些库共同构成了 Windows API,通常称为“Win32 API”(即使在 64 位系统上也是如此)。这在“Native API”之上形成了一层:与内核的大部分未记录的接口,我们不应该直接使用它。

Windows API 可通过 Microsoft 官方 windowswindows-sys crate 提供给 Rust 程序,这些包可在 crates.io 上找到。

重量级内核对象

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

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

较轻的对象

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

术语“临界区”是指程序的一部分,即代码的“部分”,不能由多个线程同时进入。保护临界区的机制通常称为互斥体。然而,在这种情况下,微软为该机制使用了“临界区”这个名称,很可能是因为上面讨论的重量级 Mutex 对象已经使用了“互斥体”这个名称。

Windows CRITICAL_SECTION 实际上是一个递归互斥体,只不过它使用术语“进入”和“离开”而不是“锁定”和“解锁”。作为递归互斥锁,它的设计目的只是防止其他线程的攻击。它允许同一线程多次锁定(或“进入”)它,要求它也解锁(离开)它相同的次数。

在 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 的支持。)

Slim 读写锁

从 Windows Vista(和 Windows Server 2008)开始,Windows API 包含一个更好的、非常轻量级的锁定原语:slim 读写锁,简称 SRW 锁。

SRWLOCK 类型只有一个指针大小,可以用 SRWLOCK_INIT 静态初始化,并且不需要销毁。当不使用(借用)时,我们甚至可以移动它,使其成为包装在 Rust 类型中的绝佳候选者。

它通过 AcquireSRWLockExclusive()TryAcquireSRWLockExclusive() ReleaseSRWLockExclusive() 提供独占(写入者)锁定和解锁,并通过 AcquireSRWLockShared()TryAcquireSRWLockShared()ReleaseSRWLockShared() 提供共享(读取者)锁定和解锁。它通常被用作常规互斥锁,只需忽略共享(读取者)锁定功能即可。

SRW 锁不区分写入者和读取者的优先级。虽然不能保证,但它会尝试在不降低性能的情况下尽可能按顺序服务所有锁定请求。不得尝试在已持有一个共享(读取)锁的线程上获取第二个共享(读取)锁。如果该操作在另一个线程的独占(写入)锁操作后面排队,则这样做可能会导致永久死锁,而该操作将由于第一个线程已持有的第一个共享(读取)锁而被阻塞。

SRW 锁与条件变量一起引入到 Windows API 中。与 SRW 锁类似, CONDITION_VARIABLE 只是一个大小的指针,可以使用 CONDITION_VARIABLE_INIT 静态初始化,并且不需要销毁。只要它没有在使用(借用),我们也可以移动它。

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

唤醒等待线程可以通过 WakeConditionVariable 唤醒单个线程,或通过 WakeAllConditionVariable 唤醒所有等待线程。

提示

最初,标准库中使用的 Windows SRW 锁和条件变量被包装在 Box 中以避免移动对象。直到 2020 年我们提出要求,微软才记录可移动性保证。从那时起,从Rust 1.49开始, Windows Vista及以后的 std::sync::Mutexstd::sync::RwLockstd::sync::Condvar 直接包装了一个 SRWLOCKCONDITION_VARIABLE ,没有任何分配。

基于地址的等待

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

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

就像 FUTEX_WAIT 操作一样,它将原子变量的值与期望值进行比较,如果匹配则进入睡眠状态,等待相应的唤醒操作。检查和睡眠操作相对于唤醒操作以原子方式发生,这意味着中间不会丢失任何唤醒信号。

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

Windows API 的部分(但不是全部)同步原语是使用这些函数实现的。更重要的是,它们是构建我们自己的基元的重要构建块,我们将在第 9 章中进行构建。

总结

  • 系统调用是对操作系统内核的调用,与常规函数调用相比相对较慢。

  • 通常,程序不会直接进行系统调用,而是通过操作系统的库(例如 libc )与内核进行交互。在许多操作系统上,这是与内核交互的唯一受支持的方式。

  • libc crate允许 Rust 代码访问 libc

  • 在 POSIX 系统上, libc 包括了比 C 标准所要求的更多内容,以符合 POSIX 标准。

  • POSIX 标准包括 pthreads,这是一个具有并发原语(例如 pthread_mutex_t )的库。

  • Pthread 类型是为 C 而设计的,而不是为 Rust 设计的。例如,它们不可移动,这可能是一个问题。

  • Linux 有一个 futex 系统调用,支持 AtomicU32 上的多个等待和唤醒操作。等待操作验证原子的预期值,用于避免丢失通知。

  • 除了 pthread 之外,macOS 还提供 os_unfair_lock 作为轻量级锁定原语。

  • Windows 重量级并发原语始终需要与内核交互,但可以在进程之间传递并与标准 Windows 等待函数一起使用。

  • Windows 轻量级并发原语包括“Slim”读写锁(SRW 锁)和条件变量。它们很容易被 Rust 包裹,因为它们是可移动的。

  • Windows 还通过 WaitOnAddressWakeByAddress 提供基本的类似 futex 的功能。

上次更新: