Skip to main content

第 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章中,我们将使用这些操作实现互斥锁、条件变量和读写锁。