到目前为止,我们主要关注的是非阻塞操作。如果我们想实现诸如互斥锁(mutex)或条件变量(condition variable)这样的机制,即一种可以等待其他线程解锁或通知它的机制,我们需要一种方法来高效地阻塞当前线程。
正如我们在第4章中所看到的,我们可以不依赖操作系统,通过自旋(spinning)的方式自行实现,即不断重复尝试某种操作。然而,这种方法很容易浪费大量的处理器时间。如果我们想要高效地阻塞线程,就需要借助操作系统内核的帮助。
内核(更具体地说是其调度器部分)负责决定哪个进程或线程在什么时候运行、运行多长时间,以及在哪个处理器核心上运行。当线程等待某些事件发生时,内核可以停止为其分配处理器时间,并优先调度其他能够更有效利用这一稀缺资源的线程。
我们需要一种方法向内核告知我们正在等待某些事件,并请求它将我们的线程置于睡眠状态,直到相关事件发生。
与内核交互
与内核的交互方式在很大程度上取决于操作系统,甚至具体到操作系统的版本。通常,这种交互的细节被隐藏在一个或多个库的后面,这些库会为我们处理这些细节。例如,使用 Rust 标准库时,我们只需调用 File::open() 即可打开文件,而无需了解操作系统内核接口的具体细节。同样,使用 C 标准库(libc),可以调用标准的 fopen() 函数打开文件。调用这些函数最终会导致一次对操作系统内核的调用,这种调用通常被称为系统调用(syscall),通常通过专用的处理器指令实现。(在某些架构上,该指令字面上就叫 syscall。)
一般情况下,程序被期望(有时甚至被要求)不直接进行系统调用,而是使用操作系统附带的高级库。在 Unix 系统(例如基于 Linux 的系统)上,libc 承担了这一特殊角色,作为内核的标准接口。
“可移植操作系统接口”标准(Portable Operating System Interface,简称 POSIX)对 Unix 系统上的 libc 还提出了额外要求。例如,除了 C 标准中的 fopen() 函数,POSIX 还要求存在更低层次的 open() 和 openat() 函数用于打开文件,这些函数通常直接对应系统调用。由于 libc 在 Unix 系统中的特殊地位,即使是用其他语言编写的程序,也通常通过 libc 与内核进行交互。
Rust 软件(包括标准库)经常通过同名的 libc crate 使用 libc。
对于 Linux 系统,系统调用接口的稳定性是有保证的,这使得我们可以直接进行系统调用,而无需使用 libc。虽然这不是最常见或最推荐的做法,但这种方式正在慢慢变得更流行。
然而,在 macOS 上(也是一种遵循 POSIX 标准的 Unix 操作系统),内核的系统调用接口并不稳定,因此我们不应直接使用它。程序被允许使用的唯一稳定接口是通过系统附带的库提供的,例如 libc、libc++ 以及为 C、C++、Objective-C 和 Swift(Apple 的编程语言)提供的各种其他库。
Windows 不遵循 POSIX 标准。它没有附带一个扩展版的 libc 作为与内核交互的主要接口,而是附带了一套独立的库,例如 kernel32.dll,它提供了 Windows 特定的功能,例如 CreateFileW 用于打开文件。与 macOS 类似,我们不应使用未公开的底层函数或直接进行系统调用。
通过这些库,操作系统为我们提供了需要与内核交互的同步原语,例如互斥锁和条件变量。其具体实现的部分是由库完成还是由内核完成,因操作系统而异。例如,有些系统上,互斥锁的加锁和解锁操作直接对应内核的系统调用,而在其他系统上,库会处理大部分操作,仅当线程需要阻塞或唤醒时才执行系统调用。(后者通常更高效,因为进行系统调用可能较慢。)
POSIX
作为POSIX线程扩展的一部分,通常称为pthreads,POSIX规定了用于并发的各种数据类型和函数。尽管这些功能在技术上是通过一个单独的系统库libpthread来指定的,但如今这些功能通常会直接包含在libc中。
除了创建和加入线程(pthread_create和pthread_join)等功能外,pthreads还提供了最常用的同步原语:互斥锁(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_INITIALIZER来静态初始化pthread_mutex_t,而无需调用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_init和pthread_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>,
}
一个更大的问题与移动有关。
在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之前,这是std::sync::Mutex在所有Unix平台上的实现方式。
这种方法的缺点是开销:每个互斥锁现在都有自己的分配,这为创建、销毁和使用互斥锁增加了显著的开销。另一个缺点是它阻止了new函数成为const,这影响了静态互斥锁的使用。
即使pthread_mutex_t是可移动的,const fn new也只能用默认设置初始化它,这在递归锁定时会导致未定义行为。没有办法设计一个安全的接口来防止递归锁定,因此我们需要将锁定函数设计为不安全函数,以便让用户承诺他们不会这样做。
我们的Box方法仍然存在一个问题,即在丢弃锁定的互斥锁时。看起来,按照正确的设计,在锁定时不可能丢弃互斥锁,因为它不可能在仍被MutexGuard借用时被丢弃。MutexGuard必须先被丢弃,解锁互斥锁。然而,在Rust中,忘记(或泄漏)一个对象是安全的,而不丢弃它。这意味着可以写出这样的代码:
fn main() {
let m = Mutex::new(..);
let guard = m.lock(); // 锁定它
std::mem::forget(guard); // ..但不解锁它。
}
在上面的例子中,m将在作用域结束时被丢弃,而它仍然是锁定的。根据Rust编译器的判断,这是可以接受的,因为守卫已经泄漏,不能再使用。
然而,pthread规定,调用pthread_mutex_destroy()时,如果互斥锁仍然被锁定,则不能保证其正常工作,可能会导致未定义行为。一个解决方法是在丢弃Mutex时首先尝试锁定(并解锁)pthread互斥锁,如果它已经被锁定,则panic(或者泄漏Box),但这会增加更多的开销。
这些问题不仅仅适用于pthread_mutex_t,也适用于我们讨论的其他类型。总体而言,pthread同步原语的设计对于C来说是合适的,但对于Rust来说并不是一个很好的匹配。
Linux
在Linux系统上,pthread同步原语都是通过futex系统调用实现的。futex的名称来源于“快速用户空间互斥锁”(fast user-space mutex),因为最初添加这个系统调用的动机是为了让库(如pthread实现)包含一个快速高效的互斥锁实现。不过,它比这更灵活,可以用来构建许多不同的同步工具。
futex系统调用于2003年被加入到Linux内核,并且自那时以来经历了多次改进和扩展。一些其他操作系统也添加了类似的功能,最著名的是Windows 8在2012年添加了WaitOnAddress(我们稍后会在“Windows”一节中讨论)。2020年,C++语言甚至在其标准库中增加了对基本futex操作的支持,增加了atomic_wait和atomic_notify函数。
Futex
在Linux上,SYS_futex是一个系统调用,它实现了多个操作,这些操作都作用于一个32位的原子整数。主要的两个操作是FUTEX_WAIT和FUTEX_WAKE。wait操作使线程进入休眠状态,针对同一个原子变量的wake操作会将线程唤醒。
这些操作并不会将任何数据存储在原子整数中。相反,内核会记住哪些线程在等待哪个内存地址,以便唤醒操作能够唤醒正确的线程。
在“等待:停车和条件变量”一节中,我们看到了其他阻塞和唤醒线程的机制需要一种方法来确保唤醒操作不会在竞争中丢失。对于线程停车,这个问题通过使unpark()操作也应用于未来的park()操作来解决。对于条件变量,这个问题则由与条件变量一起使用的互斥锁来处理。
对于futex的wait和wake操作,使用了另一种机制。wait操作接受一个参数,指定我们期望原子变量的值,并且如果值不匹配,它不会阻塞。wait操作在原子性方面与wake操作是同步的,这意味着在检查预期值与实际进入休眠之间,不会丢失任何唤醒信号。
如果我们确保原子变量的值在wake操作之前发生变化,就可以确保即将开始等待的线程不会进入休眠状态,这样就不再担心可能丢失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, // 唤醒的线程数量。
);
}
}
现在,作为一个使用示例,让我们用这些函数让一个线程等待另一个线程。我们将使用一个初始化为零的原子变量,主线程将等待这个变量的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);
wake_one(&a);
});
println!("Waiting...");
while a.load(Relaxed) == 0 {
wait(&a, 0);
}
println!("Done!");
});
}
解释:
- 被spawn的线程会在几秒钟后将原子变量设置为1。
- 然后它执行futex唤醒操作,唤醒主线程(如果它正在休眠),让主线程看到变量已更改。
- 主线程在变量为零时继续等待,直到它打印出最终的消息。
futex等待操作用于让线程休眠。非常重要的是,这个操作会在进入休眠之前检查a是否仍然为零,这也是为什么由spawn线程发出的信号不会丢失的原因。要么a(因此也是零)还没有变化,线程进入休眠,要么a(也许是零)已经发生变化,线程立即继续。
一个重要的观察是,如果a在while循环之前已经被设置为1,那么wait调用就会完全被避免。类似地,如果主线程也将原子变量设置为其他值(而不是0或1),来表示它是否开始等待信号,信号线程可以跳过futex唤醒操作,如果主线程尚未开始等待。正是这种自管理状态的方式使得基于futex的同步原语如此快速:因为我们自己管理状态,除了在实际需要阻塞时,我们不需要依赖内核。
注意:
自Rust 1.48以来,标准库在Linux上的线程停车函数就是这样实现的。它们为每个线程使用一个原子变量,具有三种可能的状态:零表示空闲和初始状态,1表示“已唤醒但尚未停车”,-1表示“已停车但尚未唤醒”。
在第9章中,我们将使用这些操作实现互斥锁、条件变量和读写锁。
Futex操作
除了wait和wake操作外,futex系统调用还支持其他几种操作。本节将简要讨论这个系统调用支持的每种操作。
futex的第一个参数始终是指向要操作的32位原子变量的指针。第二个参数是一个常量,表示要执行的操作,例如FUTEX_WAIT,可以添加最多两个标志:FUTEX_PRIVATE_FLAG和/或FUTEX_CLOCK_REALTIME,我们将在下面讨论。剩余的参数根据操作的不同而不同,下面会对每种操作进行描述。
FUTEX_WAIT
此操作需要两个额外的参数:原子变量的预期值和一个指向timespec的指针,表示等待的最大时间。
如果原子变量的值与预期值匹配,wait操作会阻塞,直到被wake操作唤醒,或者直到timespec指定的时间过去。如果timespec的指针为null,则没有时间限制。此外,wait操作可能会在没有对应wake操作的情况下无缘无故地唤醒并返回,甚至在超时到达之前。
检查和阻塞操作是与其他futex操作一起作为一个原子操作执行的,这意味着在它们之间不会丢失任何唤醒信号。
timespec指定的持续时间默认表示单调时钟上的持续时间(类似于Rust的Instant)。通过添加FUTEX_CLOCK_REALTIME标志,可以改为使用实时时钟(类似Rust的SystemTime)。
返回值指示预期值是否匹配以及是否达到了超时。
FUTEX_WAKE
此操作需要一个额外的参数:要唤醒的线程数,以i32表示。
它会唤醒指定数量的线程,这些线程在同一个原子变量上阻塞并执行wait操作。(如果等待的线程数不足,也会唤醒少量线程。)通常情况下,这个参数是1,用于唤醒一个线程,或者是i32::MAX,用于唤醒所有线程。
返回值表示唤醒的线程数。
FUTEX_WAIT_BITSET
此操作需要四个额外的参数:原子变量的预期值、一个指向timespec的指针,表示等待的最大时间、一个被忽略的指针和一个32位的“位集”(u32)。
此操作与FUTEX_WAIT相同,但有两个区别。
第一个区别是,它接受一个bitset参数,可以用于等待特定的唤醒操作,而不是所有在同一原子变量上执行的唤醒操作。FUTEX_WAKE操作永远不会被忽略,但如果wait的bitset和wake的bitset没有任何公共的1位,来自FUTEX_WAKE_BITSET的信号将被忽略。
例如,FUTEX_WAKE_BITSET操作使用bitset为0b0101时,会唤醒FUTEX_WAIT_BITSET操作中bitset为0b1100的线程,但不会唤醒bitset为0b0010的线程。
这在实现类似读写锁的功能时可能会很有用,可以在不唤醒任何读者的情况下唤醒写者。但是,值得注意的是,使用两个独立的原子变量可能比使用一个原子变量处理两种不同类型的等待者更加高效,因为内核会为每个原子变量保持一个单独的等待者列表。
另一个区别是,timespec用作绝对时间戳,而不是持续时间。因此,FUTEX_WAIT_BITSET通常会与bitset为u32::MAX(所有位设置)的位集一起使用,从而有效地将其转变为一个常规的FUTEX_WAIT操作,但使用绝对时间戳作为时间限制。
FUTEX_WAKE_BITSET
此操作需要四个额外的参数:要唤醒的线程数、两个被忽略的指针和一个32位的“位集”(u32)。
此操作与FUTEX_WAKE相同,唯一的区别是,它不会唤醒FUTEX_WAIT_BITSET操作中没有重叠的位集的线程。(请参见上文的FUTEX_WAIT_BITSET。)
使用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位参数或一个32位的参数(为2的幂)。比较可以从==、!=、<、<=、>和>=中选择,带有一个12位参数。
有关此参数编码的详细信息,请参阅futex(2)手册页,或使用crates.io上的linux-futex crate,它提供了一种便捷的方法来构造此参数。
此操作返回唤醒的线程总数。
乍一看,这似乎是一个灵活的操作,具有许多使用场景。然而,它仅为GNU libc中的一个特定用例设计,涉及从两个独立的原子变量唤醒两个线程。这个特定的用例已经被一个不再使用FUTEX_WAKE_OP的不同实现所替代。
FUTEX_PRIVATE_FLAG
可以将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系统调用:
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_PI和FUTEX_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的原语。
与其他操作系统的等效锁相比,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”之上的层次:这是一个大部分未记录的与内核交互的接口,我们不应该直接使用它。
Windows API通过Microsoft的官方windows和windows-sys crates提供给Rust程序,这些crate可以在crates.io上找到。
重型内核对象
Windows上许多较旧的同步原语完全由内核管理,使它们非常“重”,并赋予它们类似于其他内核管理对象(如文件)的特性。它们可以被多个进程使用,可以通过名称进行命名和定位,并支持细粒度的权限控制,类似于文件。例如,可以允许一个进程等待某个对象,而不允许它通过该对象发送信号来唤醒其他进程。
这些由内核管理的重型同步对象包括Mutex(可以锁定和解锁)、Event(可以发信号和等待)和WaitableTimer(可以在选定时间后自动发信号,或定期发信号)。创建这样的对象会生成一个HANDLE,就像打开一个文件一样,这个HANDLE可以很容易地传递并与常规的HANDLE函数一起使用;最显著的是一系列的等待函数。这些函数允许我们等待多个对象,包括重型同步原语、进程、线程和各种形式的I/O。
轻量级对象
Windows API中包括的一个轻量级同步原语是“临界区”(critical section)。
“临界区”这个术语指的是程序中的一部分“代码段”,在该部分中,不能由多个线程同时进入。用于保护临界区的机制通常被称为互斥锁。在这种情况下,然而,Microsoft使用了“临界区”这一名称,很可能是因为“互斥锁”(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 Reader-Writer Locks)
从Windows Vista(和Windows Server 2008)开始,Windows API引入了一种非常轻量级的锁原语:精简读写锁(Slim Reader-Writer Lock,简称SRW锁)。
SRWLOCK类型的大小仅为一个指针,可以通过SRWLOCK_INIT进行静态初始化,并且不需要销毁。未使用(未借用)时,我们甚至可以移动它,使其成为一个非常适合在Rust类型中包装的候选者。
它提供独占(写者)锁定和解锁功能,通过AcquireSRWLockExclusive()、TryAcquireSRWLockExclusive()和ReleaseSRWLockExclusive()实现;提供共享(读者)锁定和解锁功能,通过AcquireSRWLockShared()、TryAcquireSRWLockShared()和ReleaseSRWLockShared()实现。通常它被当作常规互斥锁使用,只需忽略共享(读者)锁定函数即可。
SRW锁对写者和读者没有优先级。在没有保证的情况下,它尝试尽可能按顺序处理所有锁请求,而不会降低性能。不得尝试在已经持有共享(读者)锁的线程上获取第二个共享锁。如果这样做,可能会导致永久死锁,特别是当操作排队在另一个线程的独占(写者)锁操作后时,因为第一个线程已持有的第一个共享(读者)锁会导致第二个线程被阻塞。
SRW锁与条件变量一起被引入到Windows API中。与SRW锁类似,CONDITION_VARIABLE的大小也是一个指针,可以通过CONDITION_VARIABLE_INIT静态初始化,并且不需要销毁。只要没有在使用中(未借用),我们也可以移动它。
这个条件变量不仅可以与SRW锁一起使用,通过SleepConditionVariableSRW,还可以与临界区一起使用,通过SleepConditionVariableCS。
唤醒等待线程通过WakeConditionVariable来唤醒单个线程,或者通过WakeAllConditionVariable来唤醒所有等待线程。
注意:
最初,Windows上用于标准库的SRW锁和条件变量被包装在Box中,以避免移动这些对象。直到我们在2020年提出要求,微软才开始文档化移动性保证。从那时起,开始于Rust 1.49,Windows Vista及以后的std::sync::Mutex、std::sync::RwLock和std::sync::Condvar直接包装SRWLOCK或CONDITION_VARIABLE,而无需任何内存分配。
基于地址的等待
Windows 8(和Windows Server 2012)引入了一种新的、更灵活的同步功能,类似于我们在本章前面讨论的Linux FUTEX_WAIT 和 FUTEX_WAKE 操作。
WaitOnAddress函数可以作用于8位、16位、32位或64位的原子变量。它有四个参数:原子变量的地址、持有预期值的变量地址、原子变量的大小(以字节为单位)和在放弃之前最大等待的毫秒数(或者u32::MAX表示无限超时)。
与FUTEX_WAIT操作类似,它将原子变量的值与预期值进行比较,如果匹配,则进入休眠,等待相应的唤醒操作。检查和休眠操作与唤醒操作是原子执行的,这意味着在它们之间不会丢失任何唤醒信号。
唤醒在WaitOnAddress上等待的线程是通过WakeByAddressSingle来唤醒单个线程,或者通过WakeByAddressAll来唤醒所有等待线程。这两个函数仅接受一个参数:原子变量的地址,该地址也被传递给WaitOnAddress。
Windows API的某些同步原语是使用这些函数实现的,但并非所有同步原语都如此。更重要的是,它们是构建我们自己原语的一个很好的基础,我们将在第9章中实现这些原语。
总结
系统调用(syscall)是对操作系统内核的调用,相较于常规函数调用,它的执行速度较慢。
通常,程序不会直接进行系统调用,而是通过操作系统的库(例如libc)与内核进行交互。在许多操作系统中,这是与内核交互的唯一支持方式。
libc crate使得Rust代码能够访问libc。
在POSIX系统中,libc包含了C标准要求以外的内容,以符合POSIX标准。
POSIX标准包括pthreads,这是一个包含并发原语的库,例如pthread_mutex_t。
pthread类型是为C设计的,而不是为Rust设计的。例如,它们不可移动,这可能会成为一个问题。
Linux有一个futex系统调用,支持在AtomicU32上执行多个等待和唤醒操作。wait操作验证原子变量的预期值,用于避免错过通知。
除了pthread,macOS还提供了os_unfair_lock作为一个轻量级的锁原语。
Windows上的重型并发原语总是需要与内核交互,但可以在进程之间传递,并与标准Windows等待函数一起使用。
Windows上的轻量级并发原语包括“精简”读写锁(SRW锁)和条件变量。这些原语易于在Rust中包装,因为它们是可移动的。
Windows还通过WaitOnAddress和WakeByAddress提供了类似futex的基本功能。