跳到主要内容

第 8 章 操作系统原语(Operating System Primitives)

到目前为止,我们主要关注的是非阻塞操作。如果我们想要实现像互斥锁或条件变量这样的东西,即能够等待另一个线程解锁或通知它,我们需要一种有效阻塞当前线程的方法。

正如我们在第4章中看到的,我们可以通过自旋(反复尝试某件事)在没有操作系统帮助的情况下自己实现这一点,但这很容易浪费大量处理器时间。然而,如果我们想要高效地阻塞,就需要操作系统内核的帮助。

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

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

与内核交互(Interfacing with the Kernel)

与内核通信的方式在很大程度上取决于操作系统,甚至通常取决于其版本。通常,其工作方式的细节隐藏在一个或多个为我们处理此事的库背后。例如,使用Rust标准库,我们可以直接调用 File::open() 来打开文件,而无需了解操作系统内核接口的任何细节。类似地,使用C标准库 libc,可以调用标准的 fopen() 函数来打开文件。调用这样的函数最终会导致对操作系统内核的调用,也称为系统调用(syscall),这通常通过专门的处理器指令完成。(在某些架构上,该指令字面意思就是 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操作系统)上,内核的系统调用接口并不稳定,我们不应该直接使用它。程序允许使用的唯一稳定接口是通过系统附带的库提供的,例如 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赋值为PTHREAD_MUTEX_INITIALIZER来静态初始化它,而无需调用pthread_mutex_init()。然而,这仅适用于具有默认设置的互斥锁。

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中包装(Wrapping in 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()函数也是一个问题:它将返回一个互斥锁对象,这会将其移动到内存中的新位置。

由于用户总是可以移动他们拥有的任何互斥锁对象,我们需要要么让他们承诺不会这样做(通过使接口不安全),要么我们需要剥夺他们的所有权并将所有东西隐藏在一个包装器后面(std::pin::Pin可以用于此)。这两种都不是好的解决方案,因为它们会影响我们互斥锁类型的接口,使其非常容易出错和/或使用不便。

这个问题的一个解决方案是将互斥锁包装在Box中。通过将pthread互斥锁放在它自己的分配中,即使其所有者被移动,它也会保持在内存中的同一位置。

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

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

这种方法的缺点是开销:每个互斥锁现在都有自己的分配,这给创建、销毁和使用互斥锁增加了显著的开销。另一个缺点是它阻止了new函数成为const,这阻碍了拥有静态互斥锁的可能性。

即使pthread_mutex_t是可移动的,const fn new也只能用默认设置初始化它,这导致递归锁定时出现未定义行为。无法设计一个安全的接口来防止递归锁定,所以这意味着我们需要使锁定函数不安全,让用户承诺他们不会这样做。

我们的Box方法在删除锁定的互斥锁时仍然存在问题。似乎通过正确的设计,不可能在锁定时删除Mutex,因为当它仍被MutexGuard借用时无法删除它。必须先删除MutexGuard,解锁Mutex。然而,在Rust中,忘记(或泄漏)一个对象而不删除它是安全的。这意味着可以写出这样的代码:

fn main() {
let m = Mutex::new(..);
let guard = m.lock(); // 锁定它..
std::mem::forget(guard); // ..但不解锁它。
}

在上面的示例中,m将在作用域结束时被删除,而它仍被锁定。根据Rust编译器,这是可以的,因为guard已被泄漏并且不能再使用。

然而,pthread规定在锁定的互斥锁上调用pthread_mutex_destroy()不能保证工作,并可能导致未定义行为。一种解决方法是,在删除我们的Mutex时首先尝试锁定(然后解锁)pthread互斥锁,并在它已经被锁定时panic(或泄漏Box),但这增加了更多的开销。

这些问题不仅适用于pthread_mutex_t,也适用于我们讨论的其他类型。总体而言,pthread同步原语的设计对于C来说是可以的,但对于Rust来说并不太适合。

Linux系统

在Linux系统中,pthread同步原语均通过futex系统调用实现。其名称源于"快速用户空间互斥锁",因为添加该系统调用的最初动机是允许库(如pthread实现)包含快速高效的互斥锁实现。然而它的功能远比这更灵活,可用于构建多种不同的同步工具。

futex系统调用于2003年加入Linux内核,此后经历了多次改进和扩展。其他一些操作系统随后也添加了类似功能,最值得注意的是2012年Windows 8增加的WaitOnAddress(我们将在第175页的"Windows"章节稍作讨论)。到2020年,C++语言甚至在其标准库中添加了对基础类futex操作的支持,新增了atomic_wait和atomic_notify函数。

Futex机制

在Linux中,SYS_futex是一个对32位原子整数执行各种操作的系统调用。两个主要操作是FUTEX_WAIT和FUTEX_WAKE:等待操作使线程进入休眠,而对同一原子变量执行唤醒操作则会再次唤醒线程。

这些操作并不在原子整数中存储任何信息。相反,内核会记录哪些线程正在等待哪个内存地址,从而允许唤醒操作精确唤醒目标线程。

"等待:线程挂起与条件变量"中,我们了解到其他阻塞和唤醒线程的机制需要解决唤醒操作在竞态条件下丢失的问题。对于线程停放,该问题通过使unpark()操作同时作用于后续park()操作来解决。而对于条件变量,则由与条件变量配合使用的互斥锁处理。

futex等待和唤醒操作采用了另一种机制:等待操作接受一个参数来指定原子变量的期望值,如果不匹配则拒绝阻塞。等待操作相对于唤醒操作具有原子性,这意味着在检查期望值和实际进入休眠状态之间不会丢失任何唤醒信号。

如果我们确保在唤醒操作前立即改变原子变量的值,就能确保即将开始等待的线程不会进入休眠状态,从而使得可能错失futex唤醒操作的情况不再重要。

让我们通过一个最小示例来实际观察这一机制。

首先,我们需要能够调用这些系统调用。可以使用libc库中的syscall函数,并将每个调用封装为便捷的Rust函数:

#[cfg(not(target_os = "linux"))]
compile_error!("仅支持Linux系统!");

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) {
unsafe {
libc::syscall(
libc::SYS_futex, // futex系统调用
a as *const AtomicU32, // 操作的原子变量
libc::FUTEX_WAKE, // futex操作类型
1, // 要唤醒的线程数
);
}
}

现在让我们通过使用示例演示如何让一个线程等待另一个线程。我们将使用初始化为零的原子变量,主线程将对其执行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!("等待中...");
while a.load(Relaxed) == 0 { // 3
wait(&a, 0); // 4
}
println!("完成!");
});
}

1、被创建的线程将在几秒后将原子变量设置为1,然后执行futex唤醒操作以唤醒可能正在休眠的主线程,使其能够观察到变量的变化。主线程在变量为零时持续等待,直到条件满足后打印最终消息。

2、这里的关键在于:futex等待操作在使线程休眠前会检查变量a是否仍为零,这正是子线程发出的信号不会在检查点和休眠点之间丢失的原因。要么(进而)尚未发生而进入休眠,要么(可能)已经发生而立即继续执行。

3、需要重点观察的是:如果变量a在while循环前已被设为1,wait调用将完全跳过。类似地,如果主线程也通过原子变量存储了是否开始等待信号的状态(通过设置为0或1之外的值),那么当主线程尚未开始等待时,信号线程可以跳过futex唤醒操作。这正是基于futex的同步原语如此快速的原因:我们自主管理状态,仅在真正需要阻塞时才依赖内核。

4、自Rust 1.48起,标准库在Linux上的线程停放函数正是这样实现的。每个线程使用一个原子变量,包含三种可能状态:0表示空闲初始状态,1表示"已解除停放但尚未停放",-1(二进制补码表示)表示"已停放但尚未解除停放"。在第9章中,我们将使用这些操作实现互斥锁、条件变量和读写锁。

Futex 操作(Futex Operations)

除了等待和唤醒操作之外,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 行为相同,但有两个差异。

第一个差异是它接收一个位集参数。这个参数可用于只等待特定的唤醒操作,而不是同一原子变量上的所有唤醒操作。FUTEX_WAKE 操作永远不会被忽略,但如果等待位集和唤醒位集没有任何共同的 1 位,那么来自 FUTEX_WAKE_BITSET 的信号会被忽略。

例如,位集为 0b0101FUTEX_WAKE_BITSET 操作会唤醒位集为 0b1100FUTEX_WAIT_BITSET 操作,但不会唤醒位集为 0b0010 的等待操作。

这在实现类似读写锁的东西时可能有用,例如唤醒一个写者而不唤醒任何读者。不过请注意,对两类等待者使用两个独立原子变量,可能比在一个原子变量上使用位集更高效,因为内核会为每个原子变量维护单独的等待者列表。

FUTEX_WAIT 的另一个差异是,timespec 被用作绝对时间戳,而不是一段持续时间。因此,FUTEX_WAIT_BITSET 经常搭配 u32::MAX(所有位都置一)的位集使用,实际效果类似常规 FUTEX_WAIT,但其时间限制使用绝对时间戳。

FUTEX_WAKE_BITSET

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

该操作与 FUTEX_WAKE 相同,区别是它不会唤醒位集不重叠的 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 位进行编码。操作可以是以下几种之一:赋值、加法、按位或、按位与非、按位异或;参数可以是 12 位参数,也可以是一个作为 2 的幂的 32 位参数。比较可以从 ==!=<<=>>= 中选择,并使用一个 12 位参数。

关于该参数编码的细节,请参阅 Linux 的 futex(2) 手册页,或者使用 crates.io 上的 linux-futex crate,它提供了构造该参数的便捷方式。

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

乍看之下,这似乎是一个灵活且拥有许多用例的操作。然而,它其实是为了 GNU libc 中一个非常具体的用例设计的:需要从两个独立原子变量唤醒两个线程。这个特定场景后来已经被另一种不再使用 FUTEX_WAKE_OP 的实现替代。

FUTEX_PRIVATE_FLAG 可以加到上述任意操作上。如果同一原子变量上的所有相关 futex 操作都来自同一进程中的线程(通常确实如此),这会启用一个可能的优化。要使用它,每个相关 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 操作(New Futex Operations)

从 2022 年发布的 Linux 5.16 开始,Linux 增加了一个新的 futex 系统调用:futex_waitv。这个新系统调用允许一次等待多个 futex,方法是向它提供一个要等待的原子变量列表(以及它们的预期值)。阻塞在 futex_waitv 上的线程可以被任意一个指定变量上的唤醒操作唤醒。

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

优先级继承 Futex 操作(Priority Inheritance Futex Operations)

优先级反转是这样一种问题:高优先级线程被低优先级线程持有的锁阻塞。高优先级线程的优先级实际上被“反转”了,因为它现在必须等待低优先级线程释放锁之后才能继续前进。

这个问题的一种解决方案是优先级继承:阻塞线程会继承等待它的最高优先级线程的优先级,在低优先级线程持有锁期间临时提升其优先级。

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

前面讨论的通用 futex 操作对原子变量的具体内容没有任何要求。我们可以自行决定这 32 位代表什么。然而,对优先级继承互斥锁来说,内核需要理解该互斥锁是否已被锁定;如果已锁定,还需要知道哪个线程锁定了它。

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

作为额外功能,如果持有锁的线程在未解锁的情况下终止,并且存在等待者,内核会设置第二高位。这允许互斥锁具备健壮性(robust):这个术语用来描述能够优雅处理“拥有者”线程意外终止情况的互斥锁。

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

这里不会详细讨论这些操作。其细节可以参阅 Linux 的 futex(2) 手册页,或 crates.io 上的 linux-futex crate。

macOS

macOS 使用的内核支持多种有用的、与低层并发相关的系统调用。不过,和大多数操作系统一样,内核接口并不被认为是稳定的,我们也不应当直接使用它。

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

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

与其他操作系统上的等价实现相比,macOS 上的 pthread 锁往往相对较慢。原因之一是 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” 之上;后者是与内核交互的大多未记录接口,我们不应直接使用。

Rust 程序可以通过 Microsoft 官方的 windowswindows-sys crate 使用 Windows API,它们都可以在 crates.io 上获得。

重量级内核对象(Heavyweight Kernel Objects)

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

这些由内核管理的重量级同步对象包括 Mutex(可以锁定和解锁)、Event(可以发出信号并等待)以及 WaitableTimer(可以在选定时间或周期性地自动发出信号)。创建此类对象会得到一个 HANDLE,就像打开文件一样。这个句柄可以很容易地传递,并与常规 HANDLE 函数一起使用,尤其是等待函数族。这些函数允许我们等待一个或多个不同类型的对象,包括重量级同步原语、进程、线程以及各种 I/O。

更轻量的对象(Lighter-Weight Objects)

Windows API 中包含的一个更轻量同步原语是 “critical section”。

术语 critical section 指的是程序中的一段代码,即一个“区段”,它不能被多个线程并发进入。用于保护临界区的机制通常被称为互斥锁。不过,在这种情况下,Microsoft 将该机制命名为 “critical section”,很可能是因为 “mutex” 这个名字已经被前面讨论的重量级 Mutex 对象占用了。

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

Slim 读写锁(Slim Reader-Writer Locks)

从 Windows Vista(以及 Windows Server 2008)开始,Windows API 包含了一个更好的轻量锁原语:slim reader-writer lock,简称 SRW lock。

SRWLOCK 类型只有一个指针大小,可以用 SRWLOCK_INIT 静态初始化,并且不需要销毁。当它没有被使用(借用)时,甚至允许移动它,这使它非常适合包装成 Rust 类型。

它通过 AcquireSRWLockExclusive()TryAcquireSRWLockExclusive()ReleaseSRWLockExclusive() 提供独占(写者)锁定与解锁;并通过 AcquireSRWLockShared()TryAcquireSRWLockShared()ReleaseSRWLockShared() 提供共享(读者)锁定与解锁。它也经常被当作常规互斥锁使用,只需忽略共享(读者)锁定函数即可。

SRW 锁既不优先考虑写者,也不优先考虑读者。虽然没有保证,但它会尽可能在不降低性能的前提下按顺序服务所有锁请求。一个线程已经持有共享(读者)锁时,不应尝试获取第二个共享锁。如果该操作排在另一个线程的独占(写者)锁操作之后,可能导致永久死锁,而该独占操作又会被第一个线程已经持有的共享锁阻塞。

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

这个条件变量不仅可以通过 SleepConditionVariableSRW 与 SRW 锁配合使用,也可以通过 SleepConditionVariableCS 与 critical section 配合使用。

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

历史说明 最初,标准库中使用的 Windows SRW 锁和条件变量都被包装在 Box 中,以避免移动这些对象。Microsoft 直到我们在 2020 年提出请求后,才记录了相关的可移动性保证。从那以后,自 Rust 1.49 起,在 Windows Vista 及更高版本上,std::sync::Mutexstd::sync::RwLockstd::sync::Condvar 都直接包装 SRWLOCKCONDITION_VARIABLE,不再需要任何分配。

基于地址的等待(Address-Based Waiting)

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 章这样做。

总结(Summary)

  • 系统调用是对操作系统内核的调用,与常规函数调用相比相对较慢。
  • 通常,程序不会直接执行系统调用,而是通过操作系统库(例如 libc)与内核交互。在许多操作系统上,这是唯一受支持的内核交互方式。
  • libc crate 让 Rust 代码能够访问 libc
  • 在 POSIX 系统上,为了符合 POSIX 标准,libc 包含的内容超过了 C 标准要求。
  • POSIX 标准包含 pthreads,这是一个带有并发原语的库,例如 pthread_mutex_t
  • Pthread 类型是为 C 设计的,而不是为 Rust 设计的。例如,它们不可移动,这可能成为问题。
  • Linux 有一个 futex 系统调用,支持在 AtomicU32 上执行若干等待和唤醒操作。等待操作会验证原子变量的预期值,这用于避免错过通知。
  • 除了 pthread 之外,macOS 还提供 os_unfair_lock 作为轻量锁原语。
  • Windows 的重量级并发原语始终需要与内核交互,但可以在进程之间传递,并与标准 Windows 等待函数一起使用。
  • Windows 的轻量并发原语包括 “slim” 读写锁(SRW lock)和条件变量。它们很容易用 Rust 包装,因为它们是可移动的。
  • Windows 还通过 WaitOnAddressWakeByAddress 提供基础的类 futex 功能。