在多核处理器普及之前很久,操作系统就已经允许单台计算机同时运行多个程序。这通过快速在多个进程之间切换来实现,每个进程都能逐步地、一点一点地向前推进。如今,几乎所有的计算机,甚至手机和手表,都配备了多核处理器,可以真正地并行执行多个进程。
操作系统尽可能地将进程彼此隔离,允许一个程序执行其任务,而完全不关心其他进程在做什么。例如,一个进程通常无法直接访问另一个进程的内存,也无法与其进行任何形式的通信,除非首先请求操作系统内核的帮助。
然而,一个程序可以生成额外的执行线程,作为同一进程的一部分。属于同一进程的线程彼此之间并不隔离,线程可以共享内存并通过共享内存进行交互。
本章将解释如何在 Rust 中生成线程,以及围绕线程的所有基本概念,例如如何在多个线程之间安全地共享数据。本章解释的概念是本书其余部分的基础。
注意 如果你已经熟悉 Rust 中这些部分的内容,可以跳过本章。然而,在继续后续章节之前,请确保你对线程、内部可变性、Send
和 Sync
有深入的理解,并了解什么是互斥锁、条件变量和线程挂起。
Rust 中的线程
每个程序都从一个线程开始:主线程。主线程执行 main
函数,并在必要时用于生成更多线程。
在 Rust 中,可以使用标准库的 std::thread::spawn
函数来生成新线程。这个函数接收一个参数,即新线程要执行的函数。线程将在这个函数返回后停止运行。
让我们来看一个例子:
arduino
use std::thread;
fn main() {
thread::spawn(f);
thread::spawn(f);
println!("Hello from the main thread.");
}
fn f() {
println!("Hello from another thread!");
let id = thread::current().id();
println!("This is my thread id: {id:?}");
}
我们生成了两个线程,它们都会将函数 f
作为其主函数来执行。这两个线程都会打印一条消息并显示它们的线程 ID,同时主线程也会打印自己的消息。
线程 ID
Rust 标准库为每个线程分配一个唯一的标识符。这个标识符可以通过 Thread::id()
获取,并且类型为 ThreadId
。你只能对 ThreadId
进行复制或检查其是否相等。没有保证这些 ID 会按顺序分配,但每个线程的 ID 都会不同。
如果多次运行上面的示例程序,你可能会注意到输出在每次运行时有所不同。以下是我在我的机器上得到的某次运行的输出:
python
Hello from the main thread.
Hello from another thread!
This is my thread id:
令人惊讶的是,部分输出似乎丢失了。
发生这种情况的原因是主线程在新生成的线程完成执行之前就结束了 main
函数的执行。main
返回将导致整个程序退出,即使其他线程仍在运行。
在这个例子中,其中一个新生成的线程有足够的时间执行到第二条消息的中途,随后主线程终止了程序。
如果我们想在返回 main
之前确保线程已经完成执行,可以通过等待它们来实现。为此,我们需要使用 spawn
函数返回的 JoinHandle
:
ini
fn main() {
let t1 = thread::spawn(f);
let t2 = thread::spawn(f);
println!("Hello from the main thread.");
t1.join().unwrap();
t2.join().unwrap();
}
.join()
方法会等待线程完成执行,并返回一个 std::thread::Result
。如果线程在执行其函数时发生 panic,这个结果会包含 panic 消息。我们可以尝试处理这种情况,或者直接调用 .unwrap()
来在遇到 panic 线程时也发生 panic。
运行这个版本的程序将不会导致输出被截断:
python
Hello from the main thread.
Hello from another thread!
This is my thread id: ThreadId(3)
Hello from another thread!
This is my thread id: ThreadId(2)
唯一仍然在每次运行时发生变化的是消息的打印顺序:
python
Hello from the main thread.
Hello from another thread!
Hello from another thread!
This is my thread id: ThreadId(2)
This is my thread id: ThreadId(3)
输出锁定
println!
宏使用 std::io::Stdout::lock()
确保其输出不被打断。println!()
表达式会等待其他并发运行的 println!()
完成后再输出。如果不是这样,我们可能会得到更交错的输出,例如:
python
Hello fromHello from another thread!
another This is my threthreadHello fromthread id: ThreadId!
( the main thread.
2)This is my thread
id: ThreadId(3)
与上面示例不同,传递给 std::thread::spawn
的不是函数名,而是更常见地传递一个闭包。这使我们可以捕获值并移动到新线程中:
rust
let numbers = vec![1, 2, 3];
thread::spawn(move || {
for n in &numbers {
println!("{n}");
}
}).join().unwrap();
在这里,由于我们使用了 move
闭包,numbers
的所有权被转移到了新生成的线程。如果我们没有使用 move
关键字,闭包将捕获 numbers
的引用,这会导致编译器错误,因为新线程可能比该变量的生命周期更长。
由于线程可能运行到程序执行的最后,spawn
函数对其参数类型有 'static
生命周期约束。换句话说,它只接受那些可以永久存在的函数。闭包通过引用捕获局部变量无法永久保留,因为局部变量一旦停止存在,其引用将变得无效。
从线程中获取返回值可以通过从闭包中返回这个值来实现。可以从 join
方法返回的 Result
中获得这个返回值:
ini
let numbers = Vec::from_iter(0..=1000);
let t = thread::spawn(move || {
let len = numbers.len();
let sum = numbers.iter().sum::<usize>();
sum / len
});
let average = t.join().unwrap();
println!("average: {average}");
这里,线程闭包返回的值通过 join
方法返回到主线程中。如果 numbers
为空,线程将在尝试除以零时发生 panic,而 join
将返回该 panic 消息,从而导致主线程因 unwrap()
发生 panic。
线程生成器
std::thread::spawn
函数实际上只是 std::thread::Builder::new().spawn().unwrap()
的简写。
std::thread::Builder
允许你在生成线程之前设置一些线程的配置项。你可以使用它来配置新线程的堆栈大小并为新线程命名。线程的名称可以通过 std::thread::current().name()
获取,将在 panic 消息中使用,并且在大多数平台上的监控和调试工具中可见。
此外,Builder
的 spawn
函数返回一个 std::io::Result
,允许你处理生成新线程失败的情况。例如,操作系统内存耗尽,或你的程序已达到资源限制。std::thread::spawn
函数在无法生成新线程时会直接 panic。
有作用域的线程
如果我们确信生成的线程不会超出特定作用域的生命周期,那么这个线程可以安全地借用那些不永远存在的事物,比如局部变量,只要它们的生命周期长于作用域即可。
Rust 标准库提供了 std::thread::scope
函数,用于生成有作用域的线程。这使得我们可以生成无法超出传递给该函数的闭包作用域的线程,从而安全地借用局部变量。
让我们通过一个例子来展示它的工作原理:
ini
let numbers = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| {
println!("length: {}", numbers.len());
});
s.spawn(|| {
for n in &numbers {
println!("{n}");
}
});
});
我们使用 std::thread::scope
函数并传递一个闭包。我们的闭包会立即执行,并获取一个表示作用域的参数 s
。
我们使用 s
来生成线程,这些线程的闭包可以借用局部变量 numbers
。
当作用域结束时,所有尚未完成的线程会自动被加入(joined)。
这种模式保证了在作用域中生成的线程无法超出该作用域的生命周期。因此,这种有作用域的生成方法对其参数类型没有 'static
约束,只要在作用域内存在,任何引用都可以使用,比如 numbers
。
在上面的例子中,两个新线程并发地访问 numbers
,这是可以的,因为它们(以及主线程)都没有修改它。如果我们修改第一个线程去改变 numbers
,如下所示,编译器将不允许我们生成另一个使用 numbers
的线程:
ini
let mut numbers = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| {
numbers.push(1);
});
s.spawn(|| {
numbers.push(2); // 错误!
});
});
具体的错误信息取决于 Rust 编译器的版本,因为它们通常在改进诊断,但尝试编译上述代码会得到如下类似的错误:
lua
error[E0499]: cannot borrow `numbers` as mutable more than once at a time
--> example.rs:7:13
|
4 | s.spawn(|| {
| -- first mutable borrow occurs here
5 | numbers.push(1);
| ------- first borrow occurs due to use of `numbers` in closure
|
7 | s.spawn(|| {
| ^^ second mutable borrow occurs here
8 | numbers.push(2);
| ------- second borrow occurs due to use of `numbers` in closure
内存泄漏危机(The Leakpocalypse)
在 Rust 1.0 发布之前,标准库中有一个名为 std::thread::scoped
的函数,它可以直接生成线程,就像 std::thread::spawn
一样。它允许非 'static
捕获,因为它返回的是 JoinGuard
,而不是 JoinHandle
。当 JoinGuard
被丢弃时,它会自动将线程加入。因此,任何被借用的数据只需要比 JoinGuard
活得更久。这看起来是安全的,只要 JoinGuard
最终被丢弃。
然而,在接近 Rust 1.0 发布时,逐渐发现无法保证某些东西一定会被丢弃。存在很多方法,例如创建循环引用计数节点,可以让我们忘记某些对象,或者使其泄漏,而不被丢弃。
最终,在一些人称为"内存泄漏危机(Leakpocalypse)"的事件中,得出了一个结论:设计一个(安全的)接口时,不能依赖对象在生命周期结束时一定会被丢弃的假设。泄漏一个对象可能合理地导致更多对象泄漏(例如,泄漏一个 Vec
也会导致其元素泄漏),但不能导致未定义行为。因此,std::thread::scoped
被认为不再安全,并从标准库中移除。此外,std::mem::forget
从不安全函数升级为安全函数,以强调忘记(或泄漏)对象是始终可能的。
直到后来,Rust 1.63 才引入了一个新的 std::thread::scope
函数,采用了一种新的设计,不再依赖 Drop
来保证正确性。
共享所有权与引用计数
到目前为止,我们已经看到了使用 move
闭包将值的所有权转移到线程("Rust 中的线程")和从更长生命周期的父线程借用数据("有作用域的线程")的方法。当在两个线程之间共享数据且无法保证其中一个线程会比另一个线程更长寿时,它们都不能成为该数据的所有者。共享的数据需要具有足够长的生命周期,以适应最长存活的线程。
静态变量
创建不被单个线程拥有的数据的最简单方法是使用静态值,这种值"归整个程序所有",而不是单个线程。在以下示例中,两个线程都可以访问静态变量 X
,但它们都不是 X
的所有者:
css
static X: [i32; 3] = [1, 2, 3];
thread::spawn(|| dbg!(&X));
thread::spawn(|| dbg!(&X));
静态项具有常量初始化器,不会被丢弃,并且在程序的 main
函数开始执行之前就已经存在。每个线程都可以借用它,因为它保证始终存在。
内存泄漏
另一种共享所有权的方法是泄漏分配内存。通过 Box::leak
,可以释放 Box
的所有权,并承诺永远不丢弃它。从那时起,该 Box
将永远存在,没有所有者,允许它被任何线程借用,直到程序结束。
rust
let x: &'static [i32; 3] = Box::leak(Box::new([1, 2, 3]));
thread::spawn(move || dbg!(x));
thread::spawn(move || dbg!(x));
move
闭包可能让我们看起来是在将所有权移动到线程中,但仔细查看 x
的类型会发现,我们只是将数据的引用传递给了线程。
提示
引用是 Copy
,意味着当你"移动"它们时,原始引用仍然存在,就像整数或布尔值一样。
请注意,'static
生命周期并不意味着值自程序开始以来就一直存在,而只是意味着它存在到程序结束。过去的时间并不重要。
泄漏 Box
的缺点是会导致内存泄漏。我们分配了一些东西,但从未丢弃和释放它。这在有限的情况下可能没问题,但如果我们一直这样做,程序最终会耗尽内存。
引用计数
为了确保共享的数据能够被丢弃和释放,我们不能完全放弃它的所有权。相反,我们可以共享所有权。通过跟踪所有者的数量,我们可以确保仅当没有任何所有者时,值才会被丢弃。
Rust 标准库通过 std::rc::Rc
类型提供了这种功能,Rc
是"引用计数"的缩写。它与 Box
非常相似,但克隆它不会分配新内存,而是增加存储在包含值旁边的计数器。原始 Rc
和克隆后的 Rc
都指向相同的分配;它们共享所有权。
css
use std::rc::Rc;
let a = Rc::new([1, 2, 3]);
let b = a.clone();
assert_eq!(a.as_ptr(), b.as_ptr()); // 相同的内存分配!
丢弃一个 Rc
将减少计数器。只有最后一个 Rc
,即计数器减少到零的那个,才会丢弃并释放包含的数据。
但是,如果我们尝试将 Rc
发送到另一个线程,我们会遇到以下编译器错误:
arduino
error[E0277]: `Rc` cannot be sent between threads safely
8 | thread::spawn(move || dbg!(b));
| ^^^^^^^^^^^^^^^
事实证明,Rc
不是线程安全的(关于这一点会在"线程安全:Send 和 Sync"中详细介绍)。如果多个线程拥有指向相同分配的 Rc
,它们可能会同时尝试修改引用计数器,从而产生不可预测的结果。
相反,我们可以使用 std::sync::Arc
,它是"原子引用计数"的缩写。它与 Rc
相同,只是它保证对引用计数的修改是不可分割的原子操作,使得它在多个线程中使用时是安全的。
css
use std::sync::Arc;
let a = Arc::new([1, 2, 3]);
let b = a.clone();
thread::spawn(move || dbg!(a));
thread::spawn(move || dbg!(b));
- 我们将一个数组与引用计数一起放入新分配中,引用计数初始为 1。
- 克隆
Arc
增加引用计数至 2,并为我们提供一个到相同分配的新Arc
。 - 两个线程各自获得一个
Arc
,可以通过它们访问共享的数组。每个线程在丢弃Arc
时会减少引用计数。当最后一个线程丢弃Arc
时,计数器将变为零,并由它负责丢弃并释放数组。
命名克隆
每个 Arc
克隆体必须具有不同的名称,这可能会使代码变得杂乱难懂。虽然每个克隆的 Arc
是单独的对象,但它们都表示相同的共享值,不需要不同的名字来体现这一点。
Rust 允许(并鼓励)通过定义一个具有相同名称的新变量来"遮蔽"变量。如果在相同作用域中进行遮蔽,原始变量将不可命名。但通过打开一个新作用域,可以使用类似 let a = a.clone();
的语句在该作用域内重用相同的名称,同时在作用域外保留原始变量。
通过将闭包包装在一个新作用域(用 {}
)中,我们可以在将变量移动到闭包之前克隆变量,而不必重命名它们。
css
let a = Arc::new([1, 2, 3]);
let b = a.clone();
thread::spawn(move || {
dbg!(b);
});
dbg!(a);
在相同作用域内,Arc
的克隆具有不同的名称,每个线程都获得其各自的克隆。
css
let a = Arc::new([1, 2, 3]);
thread::spawn({
let a = a.clone();
move || {
dbg!(a);
}
});
dbg!(a);
在不同作用域内,Arc
的克隆可以使用相同的名称。
由于所有权是共享的,引用计数指针(Rc<T>
和 Arc<T>
)具有与共享引用(&T
)相同的限制。它们不允许对包含的值进行可变访问,因为该值可能同时被其他代码借用。
例如,如果我们尝试对 Arc<[i32]>
中的整数切片进行排序,编译器将阻止我们这样做,提示我们不允许对数据进行可变修改:
css
error[E0596]: cannot borrow data in an `Arc` as mutable
6 | a.sort();
| ^^^^^^^^
借用与数据竞争
在 Rust 中,值可以通过两种方式被借用:
不可变借用
使用 &
借用某个值可以得到一个不可变引用。这样的引用可以被复制。对该引用指向的数据的访问是共享的,因为所有副本可以同时访问数据。正如其名,编译器通常不允许通过不可变引用修改数据,因为这可能会影响到其他代码对相同数据的借用。
可变借用
使用 &mut
借用某个值可以得到一个可变引用。可变借用保证它是该数据唯一的活动借用。这确保了对数据的修改不会改变其他代码正在查看的内容。
这两种借用方式完全避免了数据竞争的发生,即在一个线程修改数据的同时,另一个线程并发访问数据。数据竞争通常是未定义行为,这意味着编译器不需要考虑这些情况。它会简单地假设这些情况不会发生。
为了更好地理解这一点,让我们看一个示例,说明编译器如何使用借用规则进行有用的假设:
ini
fn f(a: &i32, b: &mut i32) {
let before = *a;
*b += 1;
let after = *a;
if before != after {
x(); // 永远不会发生
}
}
在这个例子中,我们获得了一个指向整数的不可变引用,并存储了该整数在增加前后的值。编译器可以假设关于借用和数据竞争的基本规则被严格遵守,这意味着 b
不可能引用与 a
相同的整数。事实上,只要 a
借用了这个整数,程序中没有任何地方可以对这个整数进行可变借用。因此,编译器可以很容易地得出 *a
不会改变的结论,从而可以完全移除调用 x()
的代码作为优化。
除非使用 unsafe
块来禁用编译器的一些安全检查,否则不可能编写违反编译器假设的 Rust 程序。
未定义行为
像 C、C++ 和 Rust 这样的语言有一组规则,必须遵守这些规则以避免未定义行为。例如,Rust 的一个规则是,任何对象在同一时间不能有多个可变引用。
在 Rust 中,只有在使用不安全代码(unsafe
)时才有可能违反这些规则。"不安全"并不意味着代码不正确或绝对不安全,而是意味着编译器不会为你验证代码的安全性。如果代码确实违反了这些规则,就称为不健全(unsound)。
编译器被允许假设这些规则从未被打破,而无需检查它们。当这些规则被违反时,就会产生所谓的未定义行为,这是我们需要尽量避免的。如果允许编译器做出实际上不正确的假设,这很容易导致编译器对代码的其他部分做出错误的结论,进而影响整个程序。
下面我们来看一个具体的例子,代码中使用了切片的 get_unchecked
方法:
ini
let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };
get_unchecked
方法类似于 a[index]
,可以根据索引获取切片的元素,但它允许编译器假设索引总是合法的,而无需进行任何检查。
这意味着在这段代码中,由于 a
的长度为 3,编译器可以假设索引小于 3。因此,我们必须确保这个假设成立。
如果违反了这个假设,例如 index
等于 3,可能会发生任何事情。它可能会从内存中读取 a
之后的字节,可能导致程序崩溃,甚至可能执行与程序完全无关的代码。这可能导致各种问题。
或许令人惊讶的是,未定义行为甚至可以"倒退",导致之前的代码出现问题。为了理解这一点,想象一下我们在前面的代码片段之前有一个 match
语句,如下所示:
ini
match index {
0 => x(),
1 => y(),
_ => z(index),
}
let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };
由于不安全代码的存在,编译器被允许假设 index
只能是 0、1 或 2。它可以逻辑地得出结论,match
语句的最后一个分支只会匹配 2
,因此 z
只会作为 z(2)
被调用。这一结论不仅可以用来优化 match
,还可以用来优化 z
本身,这包括丢弃未使用的代码部分。
如果在执行中 index
为 3,我们的程序可能会尝试执行已经被优化掉的代码部分,导致完全不可预测的行为,甚至在执行到最后一行的不安全块之前就出现问题。就这样,未定义行为可以传播到整个程序中,无论是向前传播还是向后传播,而且往往是以非常出人意料的方式发生。
因此,当调用任何不安全函数时,一定要仔细阅读其文档,并确保充分理解其安全要求:即作为调用方,你需要遵守哪些假设来避免未定义行为。
内部可变性
前一节介绍的借用规则虽然简单,但在实际使用中,特别是涉及多个线程时,可能显得相当有限。遵循这些规则会使线程之间的通信极为有限,几乎不可能实现,因为多个线程可以访问的数据是不可变的。
幸运的是,Rust 提供了一种"出口":内部可变性。具有内部可变性的数据类型可以稍微绕开借用规则。在特定条件下,这些类型可以通过"不可变"引用进行修改。
在"引用计数"一节中,我们已经看到了涉及内部可变性的一个微妙例子。Rc
和 Arc
都会修改引用计数,即使可能有多个克隆同时使用相同的引用计数器。
一旦涉及到内部可变类型,将引用称为"不可变"或"可变"就变得混乱且不准确,因为某些数据可以通过两者进行修改。更准确的术语是"共享"和"独占":共享引用(&T
)可以被复制并与其他引用共享,而独占引用(&mut T
)保证它是唯一对该 T
的独占借用。对于大多数类型,共享引用不允许修改,但有一些例外。由于本书中我们主要使用这些例外情况,接下来的内容中我们将使用更准确的术语。
注意
请记住,内部可变性只是绕开共享借用的规则,允许在共享的情况下进行修改。它不会改变独占借用的任何规则。独占借用仍然保证没有其他活动借用。如果不安全代码导致同一对象存在多个活动的独占引用,将总是导致未定义行为,无论是否有内部可变性。
接下来,让我们来看看几种具有内部可变性的类型,以及它们如何允许通过共享引用进行修改而不会导致未定义行为。
Cell
std::cell::Cell<T>
只是简单地包装了一个 T
,但允许通过共享引用进行修改。为了避免未定义行为,它只允许您复制出值(如果 T
实现了 Copy
),或整体替换为另一个值。此外,它只能在单个线程内使用。
让我们来看一个类似于前一节的例子,但这次使用 Cell<i32>
替代 i32
:
ini
use std::cell::Cell;
fn f(a: &Cell<i32>, b: &Cell<i32>) {
let before = a.get();
b.set(b.get() + 1);
let after = a.get();
if before != after {
x(); // 可能发生
}
}
与上次不同,这次 if
条件可能为真。由于 Cell<i32>
具有内部可变性,编译器不再能够假设其值在有共享引用时不会改变。a
和 b
可能引用相同的值,因此通过 b
进行修改可能会影响 a
。然而,编译器仍然可以假设没有其他线程并发访问这些 Cell
。
Cell
的限制使得它不总是容易使用。由于它不能直接让我们借用持有的值,我们需要将值移出(留下一些占位),修改它,然后再将其放回,以修改其内容:
scss
fn f(v: &Cell<Vec<i32>>) {
let mut v2 = v.take(); // 用空的 Vec 替换 Cell 中的内容
v2.push(1);
v.set(v2); // 将修改后的 Vec 放回
}
RefCell
与普通的 Cell
不同,std::cell::RefCell
确实允许您借用其内容,代价是增加了一些运行时开销。RefCell<T>
不仅持有一个 T
,还持有一个计数器来跟踪所有未完成的借用。如果您在它已被可变借用的情况下再次尝试借用(或相反),它将触发 panic
,以避免未定义行为。与 Cell
一样,RefCell
只能在单个线程内使用。
通过调用 borrow
或 borrow_mut
可以借用 RefCell
的内容:
rust
use std::cell::RefCell;
fn f(v: &RefCell<Vec<i32>>) {
v.borrow_mut().push(1); // 我们可以直接修改 `Vec`
}
虽然 Cell
和 RefCell
非常有用,但当我们需要处理多个线程时,它们就变得无用。因此,让我们继续探讨适用于并发的类型。
Mutex
和 RwLock
RwLock
(读写锁)是 RefCell
的并发版本。RwLock<T>
持有一个 T
并跟踪所有未完成的借用。然而,与 RefCell
不同的是,它不会在借用冲突时触发 panic
。相反,它会阻塞当前线程------将其置于休眠状态------直到冲突的借用消失。当其他线程处理完数据后,我们只需耐心等待轮到我们。
借用 RwLock
的内容称为锁定。通过锁定,我们可以暂时阻止并发冲突的借用,从而借用数据而不会引起数据竞争。
Mutex
与 RwLock
非常相似,但概念上稍微简单一些。它不像 RwLock
那样跟踪共享和独占借用的数量,而是只允许独占借用。
我们将在"锁定:Mutex
和 RwLock
"部分详细探讨这些类型。
原子类型
原子类型是 Cell
的并发版本,并且是第 2 章和第 3 章的主要内容。像 Cell
一样,它们通过强制我们整体复制值进出,避免未定义行为,而不让我们直接借用其内容。
然而,与 Cell
不同的是,它们不能是任意大小的。因此,没有适用于任何 T
的通用 Atomic<T>
类型,只有特定的原子类型,例如 AtomicU32
和 AtomicPtr<T>
。哪些原子类型可用取决于平台,因为它们需要处理器的支持以避免数据竞争。(我们将在第 7 章深入探讨。)
由于原子类型的大小有限,它们通常不会直接包含需要在线程之间共享的信息。相反,它们通常作为一种工具来使线程之间共享其他(通常更大)的数据。当原子类型用于描述其他数据时,事情可能变得非常复杂。
UnsafeCell
UnsafeCell
是内部可变性的原始构建块。
UnsafeCell<T>
包装了一个 T
,但没有任何条件或限制来避免未定义行为。相反,它的 get()
方法只返回一个指向包装值的原始指针,这只能在不安全块中有意义地使用。它将正确使用的责任交给用户,以确保不会引起未定义行为。
UnsafeCell
最常见的使用方式并不是直接使用,而是将其包装在其他类型中,通过受限的接口提供安全性,例如 Cell
或 Mutex
。所有具有内部可变性的类型------包括上面讨论的所有类型------都是基于 UnsafeCell
构建的。
线程安全:Send
和 Sync
在本章中,我们已经看到了几种不是线程安全的类型,例如只能在单个线程上使用的类型 Rc
、Cell
等。由于这种限制是为了避免未定义行为,因此编译器需要了解并为您检查这些类型,以便您在使用这些类型时无需使用不安全的代码块。
语言使用了两个特殊的特性来跟踪哪些类型可以跨线程安全地使用:
Send
一个类型是Send
,如果它可以被发送到另一个线程。换句话说,如果该类型的值的所有权可以转移到另一个线程。例如,Arc<i32>
是Send
,而Rc<i32>
则不是。Sync
一个类型是Sync
,如果它可以与另一个线程共享。换句话说,只有当一个类型T
的共享引用&T
是Send
时,类型T
才是Sync
。例如,i32
是Sync
,但Cell<i32>
不是(不过,Cell<i32>
是Send
)。
所有的原始类型,例如 i32
、bool
和 str
,都是 Send
和 Sync
。
这两个特性都是自动特性 ,这意味着它们会根据类型字段的类型自动为您的类型实现。如果结构体的所有字段都实现了 Send
和 Sync
,那么这个结构体本身也会是 Send
和 Sync
。
如果想要选择退出 Send
或 Sync
,可以为您的类型添加一个没有实现该特性的字段。为此,特殊的 std::marker::PhantomData<T>
类型通常非常有用。该类型在编译器中被当作 T
,但在运行时并不存在。它是一个零大小的类型,占用空间为零。
让我们来看以下结构体的例子:
rust
use std::marker::PhantomData;
struct X {
handle: i32,
_not_sync: PhantomData<Cell<()>>,
}
在这个例子中,如果 handle
是唯一的字段,那么 X
既是 Send
也是 Sync
。但是,我们添加了一个零大小的字段 PhantomData<Cell<()>>
,该字段被视为 Cell<()>
。由于 Cell<()>
不是 Sync
,因此 X
也不是 Sync
。然而,由于所有字段都实现了 Send
,因此 X
仍然是 Send
。
原始指针(*const T
和 *mut T
)既不是 Send
也不是 Sync
,因为编译器对它们所代表的内容了解不多。
要选择加入 Send
或 Sync
,与实现其他特性的方法相同,可以使用 impl
代码块来为您的类型实现该特性:
rust
struct X {
p: *mut i32,
}
unsafe impl Send for X {}
unsafe impl Sync for X {}
注意,实现这些特性需要使用 unsafe
关键字,因为编译器无法为您检查其正确性。这是您向编译器做出的承诺,编译器只能相信它。
如果您尝试将一个不是 Send
的类型移动到另一个线程,编译器会礼貌地阻止您这样做。下面是一个小例子来演示这一点:
css
fn main() {
let a = Rc::new(123);
thread::spawn(move || { // 错误!
dbg!(a);
});
}
在这里,我们尝试将 Rc<i32>
发送到一个新线程中,但 Rc<i32>
与 Arc<i32>
不同,并没有实现 Send
。
如果我们尝试编译上述例子,将会遇到类似以下的错误:
rust
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> src/main.rs:3:5
|
3 | thread::spawn(move || {
| ^^^^^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
|
= help: within `[closure]`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
--> src/main.rs:3:19
|
3 | thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
thread::spawn
函数要求其参数为 Send
,只有当闭包捕获的所有变量都是 Send
时,该闭包才是 Send
。如果我们尝试捕获一个不是 Send
的值,编译器会捕捉到我们的错误,从而保护我们免于发生未定义行为。
锁:Mutex
和 RwLock
在多线程之间共享(可变)数据时,最常用的工具是互斥锁,简称为 "mutex"(互斥)。互斥锁的作用是通过暂时阻止其他线程同时访问数据,确保线程对某些数据的独占访问。
从概念上讲,互斥锁只有两种状态:已锁定和未锁定。当一个线程锁定未锁定的互斥锁时,互斥锁被标记为已锁定,线程可以立即继续执行。当一个线程尝试锁定已经锁定的互斥锁时,该操作将被阻塞,线程会在等待互斥锁解锁时进入睡眠。解锁操作只能在已锁定的互斥锁上执行,且应由最初锁定它的线程完成。如果有其他线程正在等待锁定互斥锁,解锁操作将唤醒这些线程中的一个,以便它可以再次尝试锁定互斥锁并继续执行。
通过互斥锁保护数据,是所有线程之间达成的一种约定:它们只有在拥有互斥锁时才可以访问数据。这样,任何两个线程都不能同时访问该数据,从而避免了数据竞争。
Rust 中的 Mutex
Rust 标准库通过 std::sync::Mutex<T>
提供了互斥锁的功能。它是一个泛型类型 T
,即互斥锁保护的数据类型。通过将 T
作为互斥锁的一部分,数据只能通过互斥锁访问,从而提供一个安全的接口,保证所有线程遵守互斥约定。
为了确保只有锁定互斥锁的线程可以解锁它,Mutex
没有 unlock()
方法。相反,它的 lock()
方法返回一个称为 MutexGuard
的特殊类型。这种 Guard 表示我们已经锁定了互斥锁的保证。它通过 DerefMut
特性行为类似于独占引用,允许我们独占访问互斥锁保护的数据。解锁互斥锁是通过丢弃 Guard 完成的。当我们丢弃 Guard 时,我们放弃了访问数据的能力,Guard 的 Drop
实现会解锁互斥锁。
让我们通过一个示例来看看互斥锁的实际应用:
ini
use std::sync::Mutex;
fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}
在这里,我们有一个 Mutex<i32>
,即保护一个整数的互斥锁。我们生成十个线程,每个线程递增该整数一百次。每个线程首先锁定互斥锁以获取一个 MutexGuard
,然后使用该 Guard 访问和修改整数。该 Guard 在变量超出其作用域时自动丢弃。
在线程完成后,我们可以通过 into_inner()
安全地取消对整数的保护。into_inner
方法接管了互斥锁的所有权,这保证了没有其他引用存在,从而使得锁定变得不再必要。
尽管递增操作是一步步进行的,但一个线程观察整数时只能看到 100 的倍数,因为它只能在互斥锁解锁时查看整数。实际上,由于互斥锁的存在,一百次递增操作现在成为一个单独的、不可分割的原子操作。
为了更清楚地看到互斥锁的效果,我们可以让每个线程在解锁互斥锁前等待一秒:
rust
use std::time::Duration;
fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
thread::sleep(Duration::from_secs(1)); // 新增代码:等待一秒
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}
当您现在运行该程序时,您会发现它大约需要 10 秒才能完成。每个线程只等待一秒,但互斥锁确保只有一个线程一次可以这样做。
如果我们在等待一秒之前丢弃 Guard,从而解锁互斥锁,那么可以看到并行的效果:
ini
fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
drop(guard); // 新增代码:在等待之前丢弃 Guard!
thread::sleep(Duration::from_secs(1));
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}
通过这个修改,程序只需要大约一秒钟,因为现在 10 个线程可以同时执行它们的一秒等待。这表明保持互斥锁锁定的时间尽量短的重要性。保持互斥锁比必要的时间更长可能会完全抵消并行处理的任何好处,实际上会迫使一切顺序地进行。
锁中毒(Lock Poisoning)
前面示例中的 unwrap()
调用与锁中毒有关。
在 Rust 中,当一个线程在持有互斥锁期间发生 panic 时,Mutex
会被标记为"中毒"(poisoned)。当这种情况发生时,Mutex
将不再保持锁定状态,但调用它的 lock
方法将返回一个 Err
,以指示互斥锁已中毒。
这是为了防止在持有互斥锁期间发生 panic 时留下不一致的数据状态。例如在上面的示例中,如果某个线程在递增整数未满 100 次时发生 panic,互斥锁将解锁,而整数将保持在一个不期望的状态,可能不再是 100 的倍数,这可能会破坏其他线程所依赖的假设。在这种情况下,自动将互斥锁标记为中毒,迫使用户处理这种可能性。
对已中毒的互斥锁调用 lock()
仍然会锁定互斥锁。lock()
返回的 Err
包含 MutexGuard
,让我们在必要时可以纠正不一致的状态。
虽然锁中毒看起来是一种强大的机制,但在实际中很少有人真正从潜在的不一致状态中恢复。大多数代码要么忽略中毒,要么使用 unwrap()
在锁中毒时 panic,从而将 panic 传播到互斥锁的所有用户。
MutexGuard
的生命周期
虽然隐式丢弃 Guard 来解锁互斥锁很方便,但有时会导致微妙的意外。如果我们使用 let
语句为 Guard 赋予一个名字(就像前面的示例中那样),那么看到它何时被丢弃是相对简单的,因为局部变量会在它们定义的作用域结束时被丢弃。然而,不显式丢弃 Guard 可能导致互斥锁保持锁定的时间比必要的时间更长,就像前面的示例中展示的那样。
在不为 Guard 分配名称的情况下使用它也是可能的,有时这非常方便。因为 MutexGuard
的行为类似于对受保护数据的独占引用,我们可以直接使用它而无需先为 Guard 分配一个名字。例如,如果你有一个 Mutex<Vec<i32>>
,你可以在一条语句中锁定互斥锁、向 Vec
中添加一个项,然后解锁互斥锁:
scss
list.lock().unwrap().push(1);
在更大的表达式中产生的临时值(如 lock()
返回的 Guard)将在语句结束时被丢弃。虽然这看起来显而易见且合理,但通常会导致一个常见的陷阱,通常涉及 match
、if let
或 while let
语句。以下是一个示例,它遇到了这种陷阱:
scss
if let Some(item) = list.lock().unwrap().pop() {
process_item(item);
}
如果我们的意图是锁定列表,弹出一个项,解锁列表,然后在解锁列表后处理该项,那么我们在这里犯了一个微妙但重要的错误。临时的 Guard 直到整个 if let
语句结束时才会被丢弃,这意味着我们在处理该项时不必要地持有锁。
令人惊讶的是,这种情况不会发生在类似的 if
语句中,例如以下示例:
scss
if list.lock().unwrap().pop() == Some(1) {
do_something();
}
在这里,临时的 Guard 会在 if
语句的主体执行之前被丢弃。原因是常规 if
语句的条件总是一个简单的布尔值,它不能借用任何内容。因此,没有理由将条件中的临时值的生命周期延长到整个语句结束。而对于 if let
语句,则可能不然。例如,如果我们使用 front()
而不是 pop()
,item
会从列表中借用,这就需要保留 Guard。由于借用检查器只是一个检查器,不会影响何时或以何种顺序丢弃内容,因此即使我们使用 pop()
,也会发生同样的情况,尽管这并不是必要的。
我们可以通过将 pop
操作移到一个单独的 let
语句中来避免这种情况。这样 Guard 会在该语句结束时被丢弃,然后再执行 if let
:
scss
let item = list.lock().unwrap().pop();
if let Some(item) = item {
process_item(item);
}
读写锁
互斥锁(mutex)只关心独占访问。当使用 MutexGuard
时,即使我们只是想查看数据,使用共享引用(&T
)就足够了,但它还是会给我们提供独占引用(&mut T
)来访问受保护的数据。
读写锁(reader-writer lock)是一种稍微复杂一点的互斥锁,它能够区分独占访问和共享访问,并可以提供两者之一。它有三种状态:未锁定、被一个写者独占锁定(独占访问),以及被任意数量的读者共享锁定(共享访问)。读写锁通常用于多个线程频繁读取,但偶尔更新的数据。
Rust 标准库通过 std::sync::RwLock<T>
提供了读写锁。它的工作方式类似于标准的 Mutex
,但其接口主要分为两部分。与单一的 lock()
方法不同,它提供了 read()
和 write()
方法,分别用于读者或写者锁定。它带有两种 Guard 类型,一种用于读者(RwLockReadGuard
),一种用于写者(RwLockWriteGuard
)。前者仅实现 Deref
,表现为对受保护数据的共享引用,而后者还实现了 DerefMut
,表现为独占引用。
它实际上是 RefCell
的多线程版本,通过动态跟踪引用的数量来确保遵守借用规则。
Mutex<T>
和 RwLock<T>
都要求 T
实现 Send
,因为它们可以用于将 T
发送到其他线程。RwLock<T>
还要求 T
实现 Sync
,因为它允许多个线程持有对受保护数据的共享引用(&T
)。(严格来说,你可以为一个未实现这些要求的 T
创建锁,但你将无法在线程之间共享它,因为锁本身不会实现 Sync
。)
Rust 标准库仅提供了一种通用的 RwLock
类型,但其实现依赖于操作系统。在读写锁实现之间有许多微妙的差异。大多数实现会在有写者等待时阻止新读者,即使锁已经被读者锁定。这是为了防止写者饥饿(writer starvation),即多个读者共同占用锁,导致写者无法更新数据。
其他语言中的互斥锁
Rust 标准库的 Mutex
和 RwLock
类型与 C 或 C++ 等其他语言中的互斥锁有一些区别。
最大的区别在于,Rust 的 Mutex<T>
包含了它保护的数据。例如,在 C++ 中,std::mutex
不包含它保护的数据,甚至不知道它保护的是什么。这意味着用户需要记住哪些数据受哪个互斥锁保护,并确保每次访问"受保护"数据时锁定正确的互斥锁。在阅读其他语言中涉及互斥锁的代码时,或者与不熟悉 Rust 的程序员沟通时,了解这一点是很有帮助的。Rust 程序员可能会谈到"互斥锁内部的数据",或者说"把它包装在互斥锁中",这对那些只熟悉其他语言中的互斥锁的人来说可能会感到困惑。
如果你真的需要一个不包含任何内容的独立互斥锁,例如用于保护某些外部硬件,可以使用 Mutex<()>
。但即使在这种情况下,最好还是定义一个(可能是零大小的)类型来与硬件交互,然后将其包装在 Mutex
中。这样,你仍然需要锁定互斥锁,才能与硬件进行交互。
等待:线程暂停和条件变量
当多个线程对数据进行修改时,经常会出现它们需要等待某个事件发生的情况,即等待数据达到某个条件。例如,如果我们有一个 Vec
被互斥锁(mutex)保护,我们可能希望等待直到它包含某些元素。
虽然互斥锁允许线程等待直到它解锁,但它并不提供等待其他条件的功能。如果我们只有一个互斥锁,我们就不得不反复锁定它,以检查 Vec
中是否有任何新元素。
线程暂停
一种等待其他线程通知的方法是线程暂停(thread parking)。线程可以暂停自身,这会使它进入睡眠状态,停止消耗 CPU 资源。另一个线程可以取消暂停(unpark)这个被暂停的线程,将它唤醒。
线程暂停可通过 std::thread::park()
函数实现。要取消暂停,可以对表示要取消暂停的线程的 Thread
对象调用 unpark()
方法。这样的对象可以从 spawn
返回的 join 句柄中获取,也可以通过线程本身调用 std::thread::current()
获得。
让我们看一个使用互斥锁在两个线程之间共享队列的例子。在以下示例中,新生成的线程将从队列中消费项目,而主线程每秒向队列中插入一个新项目。线程暂停用于使消费线程在队列为空时等待:
rust
use std::collections::VecDeque;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
fn main() {
let queue = Mutex::new(VecDeque::new());
thread::scope(|s| {
// 消费线程
let t = s.spawn(|| loop {
let item = queue.lock().unwrap().pop_front();
if let Some(item) = item {
dbg!(item);
} else {
thread::park();
}
});
// 生产线程
for i in 0.. {
queue.lock().unwrap().push_back(i);
t.thread().unpark();
thread::sleep(Duration::from_secs(1));
}
});
}
消费线程运行一个无限循环,从队列中弹出元素,并使用 dbg!
宏显示它们。当队列为空时,它停止并使用 park()
函数进入睡眠状态。如果被取消暂停,park()
调用返回,循环继续,从队列中弹出元素,直到它再次为空。
生产线程每秒产生一个新数字,将其推入队列。每次添加一个元素时,它使用 unpark()
方法取消暂停指向消费线程的 Thread
对象,这样消费线程就可以被唤醒以处理新元素。
这里需要注意的是,即使移除线程暂停,这个程序在理论上仍然是正确的,只是效率较低。这一点很重要,因为 park()
并不保证仅因为匹配的 unpark()
才返回。虽然较为罕见,但它可能会有虚假唤醒(spurious wake-up)。我们的例子很好地处理了这种情况,因为消费线程会锁定队列,看到它为空时直接解锁并再次暂停自身。
线程暂停的一个重要属性是,对尚未暂停的线程调用 unpark()
不会丢失该请求。取消暂停的请求会被记录下来,下次线程尝试暂停自身时,会清除该请求,直接继续而不实际进入睡眠。为了理解这点的重要性,我们来看看两个线程执行的步骤可能的顺序:
- 消费线程(我们称它为
C
)锁定队列。 C
尝试从队列中弹出一个项目,但队列为空,结果为None
。C
解锁队列。- 生产线程(我们称它为
P
)锁定队列。 P
将一个新项目推入队列。P
再次解锁队列。P
调用unpark()
通知C
队列中有新项目。C
调用park()
进入睡眠状态,等待更多项目。
虽然在步骤 3 释放队列和步骤 8 进入暂停之间可能只有一个非常短的时刻,但步骤 4 到 7 可能恰好发生在那个时刻。如果 unpark()
在线程未暂停时不起作用,那么通知将会丢失。消费线程仍会在队列有项目的情况下继续等待。幸好,由于取消暂停的请求会保存到以后的 park()
调用中,我们不必担心这个问题。
然而,取消暂停的请求不会堆叠起来。连续调用两次 unpark()
后再调用两次 park()
,线程还是会进入睡眠。第一次 park()
会清除请求并直接返回,但第二次则会正常进入睡眠。
这意味着在上面的例子中,只有在看到队列为空时才暂停线程是很重要的,而不是在每处理完一个项目后就暂停。虽然在这个例子中,因为有 1 秒的休眠时间,这种情况极不可能发生,但多个 unpark()
调用可能只唤醒一个 park()
调用。
不幸的是,这也意味着如果在 park()
返回后但在队列被锁定和清空之前调用 unpark()
,这次取消暂停是多余的,但它仍然会导致下一个 park()
调用立即返回。这会导致队列被额外地锁定和解锁一次,虽然这不会影响程序的正确性,但会影响它的效率和性能。
这种机制在像我们例子中的简单情况中表现良好,但当情况变得更复杂时就不太适用了。例如,如果我们有多个消费者线程从同一个队列中取项目,生产者线程就无法知道哪个消费者线程实际上在等待,应该被唤醒。生产者必须确切知道消费者何时在等待以及等待的条件。
条件变量
条件变量是用于等待受互斥锁保护的数据发生某种变化的更常用选项。它们有两个基本操作:等待(wait)和通知(notify)。线程可以在条件变量上等待,当另一个线程通知该条件变量时,它们可以被唤醒。多个线程可以在同一个条件变量上等待,通知可以发送给一个等待线程,或者发送给所有等待的线程。
这意味着我们可以为感兴趣的特定事件或条件(如队列不为空)创建一个条件变量,并等待该条件的满足。任何导致该事件或条件发生的线程随后可以通知该条件变量,而不必知道有多少线程对该通知感兴趣或是哪一个线程在等待。
为了避免在解锁互斥锁和等待条件变量之间的短暂时刻错过通知的问题,条件变量提供了一种原子地解锁互斥锁并开始等待的方法,这样就没有可能错过通知的时刻。
Rust 标准库通过 std::sync::Condvar
提供了条件变量。其 wait
方法接受一个 MutexGuard
,这证明我们已经锁定了互斥锁。它首先解锁互斥锁并进入睡眠状态。稍后被唤醒时,它重新锁定互斥锁并返回一个新的 MutexGuard
(证明互斥锁再次被锁定)。
条件变量有两个通知函数:notify_one
唤醒一个等待线程(如果有的话),notify_all
则唤醒所有等待线程。
让我们修改使用线程暂停的例子,以便使用 Condvar
:
rust
use std::collections::VecDeque;
use std::sync::{Condvar, Mutex};
use std::thread;
use std::time::Duration;
let queue = Mutex::new(VecDeque::new());
let not_empty = Condvar::new();
thread::scope(|s| {
// 消费线程
s.spawn(|| {
loop {
let mut q = queue.lock().unwrap();
let item = loop {
if let Some(item) = q.pop_front() {
break item;
} else {
q = not_empty.wait(q).unwrap();
}
};
drop(q);
dbg!(item);
}
});
// 生产线程
for i in 0.. {
queue.lock().unwrap().push_back(i);
not_empty.notify_one();
thread::sleep(Duration::from_secs(1));
}
});
我们需要做一些修改:
- 现在我们不仅有一个包含队列的互斥锁,还有一个
Condvar
来表示"非空"条件。 - 我们不再需要知道要唤醒哪个线程,因此不再存储
spawn
的返回值。而是通过notify_one
方法通过条件变量通知消费者。 - 解锁、等待和重新锁定都由
wait
方法完成。为了将守护对象传递给wait
方法,并在处理项目之前将其丢弃,我们需要对控制流进行一些重构。
现在我们可以生成任意数量的消费者线程,甚至可以稍后再生成更多消费者线程,而无需进行任何更改。条件变量负责将通知传递给感兴趣的线程。
如果我们有一个更复杂的系统,其中线程对不同的条件感兴趣,我们可以为每个条件定义一个 Condvar
。例如,我们可以定义一个表示队列非空,另一个表示队列为空。然后,每个线程将等待与其操作相关的条件。
通常,Condvar
只会与单个互斥锁一起使用。如果两个线程尝试使用不同的互斥锁并发地等待同一个条件变量,可能会导致程序崩溃。
Condvar
的一个缺点是它只能与互斥锁一起使用,但对于大多数使用场景来说这完全没有问题,因为这正是用于保护数据的方式。
thread::park()
和 Condvar::wait()
也有带时间限制的变体:thread::park_timeout()
和 Condvar::wait_timeout()
。它们接受一个 Duration
作为额外的参数,这是在等待通知之后应无条件唤醒的时间限制。
总结
- 在同一个程序中可以运行多个线程,并且可以随时生成这些线程。
- 当主线程结束时,整个程序也会结束。
- 数据竞争是未定义行为,Rust 的类型系统在安全代码中完全防止了这种情况。
- 可以发送到其他线程的数据需要实现
Send
,而可以在线程之间共享的数据需要实现Sync
。 - 常规线程可能会运行到程序结束,因此它们只能借用
'static
生命周期的数据,比如静态数据和泄露的分配。 - 引用计数(
Arc
)可用于共享所有权,以确保数据在至少一个线程使用它时存活。 - 作用域线程(scoped threads)非常有用,因为它们限制了线程的生命周期,使其可以借用非
'static
生命周期的数据,如局部变量。 &T
是共享引用,&mut T
是独占引用。常规类型不允许通过共享引用进行修改。- 某些类型由于
UnsafeCell
而具备内部可变性(interior mutability),允许通过共享引用进行修改。 Cell
和RefCell
是单线程内部可变性的标准类型,而原子类型(atomics)、互斥锁(Mutex
)和读写锁(RwLock
)是它们在多线程中的等价物。Cell
和原子类型只允许整体替换值,而RefCell
、Mutex
和RwLock
则可以通过动态执行访问规则来直接修改值。- 线程暂停(thread parking)可以是一种等待某些条件发生的便捷方式。
- 当条件与由
Mutex
保护的数据有关时,使用条件变量(Condvar
)比线程暂停更方便,也可能更高效。