diff --git a/10_Ideas_and_Inspiration.md b/10_Ideas_and_Inspiration.md index a908d70..bd7b02a 100644 --- a/10_Ideas_and_Inspiration.md +++ b/10_Ideas_and_Inspiration.md @@ -12,7 +12,7 @@ 信号量可以实现为用于计数器的 `Mutex` 以及用于等待操作的 `Condvar` 的组合。然而,有几种方式能更有效地实现它。更值得关注的是,在支持类 futex 操作([第八章“futex”](./8_Operating_System_Primitives.md#futex))的平台上,它可以作为单个 AtomicU32(或者甚至 AtomicU8)更高效地实现。 -最大值为 1 的信号量又是被称为*二进制*信号量,它可以用作构建其他原语的基石。例如,它可以通过初始化计数器、使用锁定的 wait 操作以及释放的 signal 操作来做用于 mutex。通过在 0 处初始化它,它可以被用作信号,就像条件变量一样。例如,在标准库 `std::thread` 的 `park()` 的 `unpark()` 函数可以实现与线程关联的二进制信号量上的 wait 和 signal 操作。 +最大值为 1 的信号量又是被称为*二进制*信号量,它可以用作构建其他原语的基石。例如,它可以通过初始化计数器、使用锁定的 wait 操作以解锁的 signal 操作来做用于 mutex。通过在 0 处初始化它,它可以被用作信号,就像条件变量一样。例如,在标准库 `std::thread` 的 `park()` 的 `unpark()` 函数可以实现与线程关联的二进制信号量上的 wait 和 signal 操作。 > 注意,mutex 可以使用信号量来实现,而信号量可以使用 mutex(或者条件变量)来实现。建议避免使用基于 mutex 的信号量来实现基于信号量的 mutex,反之亦然。 @@ -56,7 +56,7 @@ 从链表列表中分离元素后,你将遇到与之前相同的问题:它会等待,直到你解除分配(或者以其他方式宣称所有权)。在这种情况下,我们讨论的基本的 RCU 模式的相同解决方案也有效。 -总的来说,你可以基于原子指针上的比较并交换操作,构建各种精心设计的无锁数据结构,但是你将总是需要一个号的策略来释放分配或者以其他方式收回分配的所有权。 +总的来说,你可以基于原子指针上的比较并交换操作,构建各种精心设计的无锁数据结构,但是你将总是需要一个好的策略来释放分配或者以其他方式收回分配的所有权。 进一步阅读: diff --git a/1_Basic_of_Rust_Concurrency.md b/1_Basic_of_Rust_Concurrency.md index a89bca5..1259505 100644 --- a/1_Basic_of_Rust_Concurrency.md +++ b/1_Basic_of_Rust_Concurrency.md @@ -73,7 +73,7 @@ fn main() { } ``` -`.join()` 方法等待直到线程结束执行并且返回 `std::thread::Result`。如果线程由于 panic 不能成功地完成它的函数,这将包含 panic 消息。我们试图去处理这种情况,或者在 join panic 线程仅调用 `.unwrap()` 去 panic。 +`.join()` 方法等待直到线程结束执行并且返回 `std::thread::Result`。如果线程由于 panic 不能成功地完成它的函数,这将包含 panic 消息。我们试图去处理这种情况,或者为 join panic 的线程调用 `.unwrap()` 去 panic。 运行我们程序的这个版本,将不再导致截断的输出: @@ -99,7 +99,7 @@ This is my thread id: ThreadId(3)

输出锁定

println 宏使用 std::io::Stdout::lock() 去确保输出没有被中断。println!() 将等待直到任意并发地运行完成后,在写入输出。如果不是这样,我们可以得到更多的交叉输出: -
+  
   Hello fromHello from another thread!
   another This is my threthreadHello fromthread id: ThreadId!
   ( the main thread.
@@ -585,7 +585,7 @@ note: required by a bound in `spawn`
 
 在线程之间共享(可变)数据更常规的有用工具是 `mutex`,它是“互斥”(mutual exclusion)的缩写。mutex 的工作是通过暂时阻塞其它试图同时访问某些数据的线程,来确保线程对某些数据进行独占访问。
 
-概念上,mutex 仅有两个状态:锁定和释放。当线程锁住一个未释放的 mutex,mutex 被标记为锁定,线程可以立即继续。当线程尝试锁住一个已锁定的 mutex,操作将*阻塞*。当线程等待 mutex 释放时,其会置入睡眠状态。释放仅能在锁定的 mutex 上进行,并且应当由锁定它的同一线程完成。如果其它线程正在等待锁定 mutex,释放将导致唤醒其中一个线程,因此它可以尝试再次锁定 mutex 并且继续它的过程。
+概念上,mutex 仅有两个状态:锁定和解锁。当线程锁住一个未解锁的 mutex,mutex 被标记为锁定,线程可以立即继续。当线程尝试锁住一个已锁定的 mutex,操作将*阻塞*。当线程等待 mutex 解锁时,其会置入睡眠状态。解锁仅能在锁定的 mutex 上进行,并且应当由锁定它的同一线程完成。如果其它线程正在等待锁定 mutex,解锁将导致唤醒其中一个线程,因此它可以尝试再次锁定 mutex 并且继续它的过程。
 
 使用 mutex 保护数据仅是所有线程之间的约定,当它们在 mutex 锁定时,它们才能获取数据。这种方式,没有两个线程可以并发地获取数据和导致数据竞争。
 
@@ -593,7 +593,7 @@ note: required by a bound in `spawn`
 
 Rust 的标准库通过 `std::sync::Mutex` 提供这个功能。它对类型 T 进行范型化,该类型 T 是 mutex 所保护的数据类型。通过将 T 作为 mutex 的一部分,该数据仅可以通过 mutex 获取,从而提供一个安全的接口,以保证所有线程都遵守这个约定。
 
-为确保锁定的 mutex 仅通过锁定它的线程释放,所以它没有 `unlock()` 方法。然而,它的 `lock()` 方法返回一个称为 `MutexGuard` 的特殊类型。该 guard 表示保证我们已经锁定 mutex。它通过 `DerefMut` trait 行为表现像一个独占引用,使我们能够独占访问互斥体保护的数据。释放 mutex 通过 drop guard 完成。当我们 drop guard 时,我们我们放弃了获取数据的能力,并且 guard 的 `Drop` 实现将释放 mutex。
+为确保锁定的 mutex 仅通过锁定它的线程解锁,所以它没有 `unlock()` 方法。然而,它的 `lock()` 方法返回一个称为 `MutexGuard` 的特殊类型。该 guard 表示保证我们已经锁定 mutex。它通过 `DerefMut` trait 行为表现像一个独占引用,使我们能够独占访问互斥体保护的数据。解锁 mutex 通过 drop guard 完成。当我们 drop guard 时,我们我们放弃了获取数据的能力,并且 guard 的 `Drop` 实现将解锁 mutex。
 
 让我们看一个示例,实践中的 mutex:
 
@@ -620,9 +620,9 @@ fn main() {
 
 线程完成后,我们可以通过 `into_inner()` 安全地从整数中移除保护。`into_inner` 方法获取 mutex 的所有权,这确保了没有其它东西可以引用 mutex,从而使 mutex 变得不再必要。
 
-尽管增加是逐步地增加的,但是线程仅能够看见 100 的倍数,因为它只能在 mutex 释放时查看整数。实际上,由于 mutex 的存在,这一百次递增称为了一个单一不可分割的原子操作。
+尽管增加是逐步地增加的,但是线程仅能够看见 100 的倍数,因为它只能在 mutex 解锁时查看整数。实际上,由于 mutex 的存在,这一百次递增称为了一个单一不可分割的原子操作。
 
-为了清晰地看见 mutex 的效果,我们可以让每个线程在释放 mutex 之前等待一秒:
+为了清晰地看见 mutex 的效果,我们可以让每个线程在解锁 mutex 之前等待一秒:
 
 ```rust
 use std::time::Duration;
@@ -646,7 +646,7 @@ fn main() {
 
 当你现在运行程序,你将看见大约需要花费 10s 才能完成。每个线程仅等待 1s,但是 mutex 确保一次仅有一个线程这么做。
 
-如果我们在睡眠 1s 之前 drop guard,并且因此释放 mutex,我们将看到并行发生:
+如果我们在睡眠 1s 之前 drop guard,并且因此解锁 mutex,我们将看到并行发生:
 
 ```rust
 fn main() {
@@ -675,7 +675,7 @@ fn main() {
 
 当线程在持有锁时 panic,Rust 中的 mutex 将被标记为*中毒*。当这种情况发生时,Mutex 将不再被锁定,但调用它的 `lock` 方法将导致 `Err`,以表明它已经中毒。
 
-这是一个防止由 mutex 保护的数据处于不一致状态的机制。在我们上面的示例中,如果一个线程将在整数增加不到 100 之后崩溃,mutex 将释放并且整数将处于一个意外的状态,它不再是 100 的倍数,这可能打破其它线程的设定。在这种情况下,自动标记 mutex 中毒,强制用户处理这种可能。
+这是一个防止由 mutex 保护的数据处于不一致状态的机制。在我们上面的示例中,如果一个线程将在整数增加不到 100 之后崩溃,mutex 将解锁并且整数将处于一个意外的状态,它不再是 100 的倍数,这可能打破其它线程的设定。在这种情况下,自动标记 mutex 中毒,强制用户处理这种可能。
 
 在中毒的 mutex 上调用 `lock()` 仍然可能锁定 mutex。由 `lock()` 返回的 Err 包含 `MutexGuard`,允许我们在必要时纠正不一致的状态。
 
@@ -683,7 +683,7 @@ fn main() {
 
 

MutexGuard 的生命周期

- 尽管隐式 drop guard 释放 mutex 很方便,但是它有时会导致微妙的意外。如果我们使用 let 语句授任 guard 一个名字(正如我们上面的示例),看它什么时候会被丢弃相对简单,因为局部变量定义在它们作用域范围的末尾。然而,正如上述示例所示,不明确地 drop guard 可能导致 mutex 锁定的时间超过所需时间。 + 尽管隐式 drop guard 解锁 mutex 很方便,但是它有时会导致微妙的意外。如果我们使用 let 语句授任 guard 一个名字(正如我们上面的示例),看它什么时候会被丢弃相对简单,因为局部变量定义在它们作用域范围的末尾。然而,正如上述示例所示,不明确地 drop guard 可能导致 mutex 锁定的时间超过所需时间。 在不给它指定名称的情况下使用 guard 也是可能的,并且有时非常方便。因为 MutexGuard 保护数据的行为像独占引用,我们可以直接使用它,而无需首先为他授任一个名称。例如,你有一个 Mutex<Vec<i32>>,你可以在单个语句中锁定 mutex,将项推入 Vec,并且再次锁定 mutex: @@ -695,7 +695,7 @@ fn main() { process_item(item); }
- 如果我们的旨意就是锁定 list、弹出 item、释放 list 然后在释放 list 后处理 item,我们在这里犯了一个微妙而严重的错误。临时的 guard 直到完整的 if let 语句结束后才能被 drop,这意味着我们在处理 item 时不必要地持有锁。 + 如果我们的旨意就是锁定 list、弹出 item、解锁 list 然后在解锁 list 后处理 item,我们在这里犯了一个微妙而严重的错误。临时的 guard 直到完整的 if let 语句结束后才能被 drop,这意味着我们在处理 item 时不必要地持有锁。 或许,意外地是,对于类似地 if 语句,这并不会发生,例如以下示例: @@ -717,7 +717,7 @@ if let Some(item) = item { 互斥锁仅涉及独占访问。MutexGuard 将提供受保护数据的一个独占引用(`&mut T`),即使我们仅想要查看数据,并且共享引用(`&T`)就足够了。 -读写锁是一个略微更复杂的 mutex 版本,它能够区分独占访问和共享访问的区别,并且可以提供两种访问方式。它有三种状态:释放、由单个 *writer* 锁定(用于独占访问)以及由任意数量的 reader 锁定(用于共享访问)。它通常用于通常由多个线程读取的数据,但只是偶尔一次。 +读写锁是一个略微更复杂的 mutex 版本,它能够区分独占访问和共享访问的区别,并且可以提供两种访问方式。它有三种状态:解锁、由单个 *writer* 锁定(用于独占访问)以及由任意数量的 reader 锁定(用于共享访问)。它通常用于通常由多个线程读取的数据,但只是偶尔一次。 Rust 标准库通过 `std::sync::RwLock` 类型提供该锁。它与标准库的 Mutex 工作类似,只是它的接口大多是分成两个部分。然而,单个 `lock()` 方法,它有 `read()` 和 `write()` 方法,用于为 reader 或 writer 进行锁定。它还附带了两种 guard 类型,一种用于 reader,一种用于 writer:RwLockReadGuard 和 RwLockWriteGuard。前者只实现了 Deref,其行为像受保护数据共享引用,后者还实现了 DerefMut,其行为像独占引用。 @@ -725,7 +725,7 @@ Rust 标准库通过 `std::sync::RwLock` 类型提供该锁。它与标准库 `Mutex` 和 `RwLock` 都需要 T 是 Send,因为它们可能发送 T 到另一个线程。除此之外,`RwLock` 也需要 T 实现 Sync,因为它允许多个线程对受保护的数据持有共享引用(`&T`)。(严格地说,你可以创建一个并没有实现这些需求 T 的锁定,但是你不能在线程之间共享它,因为锁本身并没有实现 Sync)。 -Rust 标准库仅提供一种通用的 `RwLock` 类型,但它的实现依赖于操作系统。读写锁之间有很多细微差别。当有 writer 等待时,即使当锁已经读取锁定时,很多实现将阻塞新的 reader。这样做是为了防止 *writer 挨饿*,在这种情况下,很多 reader 将共同持有锁而导致锁从不释放,从而不允许任何 writer 更新数据。 +Rust 标准库仅提供一种通用的 `RwLock` 类型,但它的实现依赖于操作系统。读写锁之间有很多细微差别。当有 writer 等待时,即使当锁已经读取锁定时,很多实现将阻塞新的 reader。这样做是为了防止 *writer 挨饿*,在这种情况下,很多 reader 将共同持有锁而导致锁从不解锁,从而不允许任何 writer 更新数据。

在其他语言中的互斥锁

@@ -740,13 +740,13 @@ Rust 标准库仅提供一种通用的 `RwLock` 类型,但它的实现依赖 当数据由多个线程更改时,在许多情况下,它们需要等待一些事件,以便管有数据的某些条件变为真。例如,如果我们有一个保护 Vec 的 mutex,我们可能想要等待直到它包含任何东西。 -尽管 mutex 允许线程等待直到它释放,但它不提供等待任何其它条件的功能。如果我们只拥有一个 mutex,我们不得不持有锁定的 mutex,以反复检查 Vec 中是否有任意东西。 +尽管 mutex 允许线程等待直到它解锁,但它不提供等待任何其它条件的功能。如果我们只拥有一个 mutex,我们不得不持有锁定的 mutex,以反复检查 Vec 中是否有任意东西。 ### 线程阻塞 -一种方式是去等待来自另一个线程的通知,其被称为*线程阻塞*。一个线程可以阻塞它自己,将它置入睡眠状态,阻止它消耗任意 CPU 周期。然后,另一个线程可以释放阻塞的线程,将其从睡眠中唤醒。 +一种方式是去等待来自另一个线程的通知,其被称为*线程阻塞*。一个线程可以阻塞它自己,将它置入睡眠状态,阻止它消耗任意 CPU 周期。然后,另一个线程可以解锁阻塞的线程,将其从睡眠中唤醒。 -线程阻塞可以通过 `std::thread::park()` 函数获得。对于释放,你可以在 `Thread` 对象中调用 `unpark()` 函数表示你想要释放该线程。这样的对象可以通过 spawn 返回的 join 句柄获得,或者也可以通过 `std::thread::current()` 从线程本身中获得。 +线程阻塞可以通过 `std::thread::park()` 函数获得。对于解锁,你可以在 `Thread` 对象中调用 `unpark()` 函数表示你想要解锁该线程。这样的对象可以通过 spawn 返回的 join 句柄获得,或者也可以通过 `std::thread::current()` 从线程本身中获得。 让我们深入研究在线程之间使用 mutex 共享队列的示例。在以下示例中,一个新产生的线程将消费来自队列的项,尽管主线程将每秒插入新的项到队列。线程阻塞被用于在队列为空时使消费线程等待。 @@ -777,30 +777,30 @@ fn main() { } ``` -消费线程进行一个无穷尽的循环,它将项弹出队列,使用 `dbg` 宏展示它们。当队列为空的时候,它停止并且使用 `park()` 函数进行睡眠。如果它得到释放,`park()` 调用将返回,循环继续,再次从队列中弹出项,直到它是空的。等等。 +消费线程进行一个无穷尽的循环,它将项弹出队列,使用 `dbg` 宏展示它们。当队列为空的时候,它停止并且使用 `park()` 函数进行睡眠。如果它得到解锁,`park()` 调用将返回,循环继续,再次从队列中弹出项,直到它是空的。等等。 -生产线程将其推入队列,每秒产生一个新的数字。每次增加一个项时,它都会在 Thread 对象上使用 `unpark()` 方法,该方法引用消费线程来释放它。这样,消费线程就会被唤醒处理新的元素。 +生产线程将其推入队列,每秒产生一个新的数字。每次增加一个项时,它都会在 Thread 对象上使用 `unpark()` 方法,该方法引用消费线程来解锁它。这样,消费线程就会被唤醒处理新的元素。 -这样,我们要作出的一个重要的观测是,如果我们移除**阻塞**,程序将在理论上正确,尽管效率低下。这是重要的,因为 `park()` 不能保证它将由于匹配 `unpark()` 而返回。尽管有些罕见,但它很可能会有*虚假唤醒*。我们的示例处理得很好,因为消费线程将锁定去咧,可以看到它是空的,然后直接释放它并再次阻塞。 +这样,我们要作出的一个重要的观测是,如果我们移除**阻塞**,程序将在理论上正确,尽管效率低下。这是重要的,因为 `park()` 不能保证它将由于匹配 `unpark()` 而返回。尽管有些罕见,但它很可能会有*虚假唤醒*。我们的示例处理得很好,因为消费线程将锁定去咧,可以看到它是空的,然后直接解锁它并再次阻塞。 线程阻塞的一个重要属性是,在线程自己进入阻塞之前,对 `unpark()` 的调用不会丢失。对 unpark 的请求仍然被记录下来,并且下次线程尝试挂起自己的时候,它会清除该请求并且直接继续执行,实际上并不会进入睡眠状态。为了理解这对于正确操作的关键性,让我们来看一下程序可能执行步骤的顺序: 1. 消费线程(让我们称之为 C)锁定队列。 2. C 尝试去从队列中弹出项,但是它是空的,导致 None。 -3. C 释放队列。 +3. C 解锁队列。 4. 生产线程(我们将称为 P)锁定队列。 5. P 推入一个新的项进入队列。 -6. P 再次释放队列。 +6. P 再次解锁队列。 7. P 调用 `unpark()` 去通知 C,有一些新的项。 8. C 调用 `park()` 去睡眠,以等待更多的项。 -虽然在步骤 3 释放队列和在步骤 8 阻塞之间很可能仅有一个很短的时间,但第 4 步和第 7 步可能在线程阻塞自己之前发生。如果 `unpark()` 在线程没有挂起时不执行任何操作,那么通知将会丢失。即使队列中有项,消费线程仍然在等待。由于 unpark() 请求被保存,以供将来调用 park() 时使用,我们不必担心这个问题。 +虽然在步骤 3 解锁队列和在步骤 8 阻塞之间很可能仅有一个很短的时间,但第 4 步和第 7 步可能在线程阻塞自己之前发生。如果 `unpark()` 在线程没有挂起时不执行任何操作,那么通知将会丢失。即使队列中有项,消费线程仍然在等待。由于 unpark() 请求被保存,以供将来调用 park() 时使用,我们不必担心这个问题。 然而,unpark 请求并不会堆起来。先调用两次 `unpark()`,然后再调用两次 `park()`,线程仍然会进入睡眠状态。第一次 `park()` 清除请求并直接返回,但第二次调用通常让它进入睡眠。 这意味着,在我们上面的示例中,重要的是我们看见队列为空的时候,我们仅会阻塞线程,而不是在处理每个项之后将其阻塞。然而由于巨长的(1s)睡眠,这种情况在本示例中几乎不可能发生,但多个 `unpark()` 调用仅能唤醒单个 `park()` 调用。 -不幸的是,这确实意味着,如果在 `park()` 返回后,立即调用 `unpark()`,但是在队列得到锁定并清空之前,`unpark()` 调用是不必要的,单仍然会导致下一个 `park()` 调用立即返回。这导致(空的)队列多次被锁定并释放。虽然这不会影响程序的正确性,但这确实会影响它的效率和性能。 +不幸的是,这确实意味着,如果在 `park()` 返回后,立即调用 `unpark()`,但是在队列得到锁定并清空之前,`unpark()` 调用是不必要的,单仍然会导致下一个 `park()` 调用立即返回。这导致(空的)队列多次被锁定并解锁。虽然这不会影响程序的正确性,但这确实会影响它的效率和性能。 这种机制在简单的情况下是好的,比如我们的示例,但是当东西变得复杂,情况可能会很糟糕。例如,如果我们有多个消费线程从相同的队列获取项时,生产线程将不会知道有哪些消费者实际上在等待以及应该被唤醒。生产者将必须知道消费者正在等待的时间以及正在等待的条件。 @@ -810,9 +810,9 @@ fn main() { 这意味着我们可以为我们感兴趣的事件或条件创建一个条件变量,例如,队列是非空的,并且在该条件下等待。任意导致事件或条件发生的线程都会通知条件变量,无需知道那个或有多个线程对该通知感兴趣。 -为了避免在释放 mutex 和等待条件变量的短暂时间失去通知的问题,条件变量提供了一种*原子地*释放 mutex 和开始等待的方式。这意味着根本没有通知丢失的时刻。 +为了避免在解锁 mutex 和等待条件变量的短暂时间失去通知的问题,条件变量提供了一种*原子地*解锁 mutex 和开始等待的方式。这意味着根本没有通知丢失的时刻。 -Rust 标准库提供了 `std::sync::Condvar` 作为条件变量。它的等待方法采用 `MutexGuard`,以保证我们已经锁定 mutex。它首先释放 mutex 并进入睡眠。稍后,当唤醒时,它重新锁定 mutex 并且返回一个新的 MutexGuard(这证明了 mutex 再次被锁定)。 +Rust 标准库提供了 `std::sync::Condvar` 作为条件变量。它的等待方法采用 `MutexGuard`,以保证我们已经锁定 mutex。它首先解锁 mutex 并进入睡眠。稍后,当唤醒时,它重新锁定 mutex 并且返回一个新的 MutexGuard(这证明了 mutex 再次被锁定)。 它有两个通知方法:`notify_one` 仅唤醒一个线程(如果有),和 `notify_all` 去唤醒所有线程。 @@ -851,7 +851,7 @@ thread::scope(|s| { * 我们必须改变一些事情: * 我们现在不仅有一个包含队列的 Mutex,同时有一个 Condvar 去通信“不为空”的条件。 * 我们不再需要知道要唤醒哪个线程,因此我们不再存储 spawn 的返回值。而是,我们通过使用 `notify_one` 方法的条件变量通知消费者。 - * 释放、等待以及重新锁定都是通过 `wait` 方法完成的。我们不得不稍微重组控制流,以便出阿迪 guard 到 wait 方法,同时在处理项之前仍然 drop 它。 + * 解锁、等待以及重新锁定都是通过 `wait` 方法完成的。我们不得不稍微重组控制流,以便出阿迪 guard 到 wait 方法,同时在处理项之前仍然 drop 它。 现在,我们可以根据自己的需求生成尽可能多的消费线程,甚至稍后生成更多线程,而无需更改任何东西。条件变量会负责将通知传递给任何感兴趣的线程。 diff --git a/2_Atomics.md b/2_Atomics.md index 30387a0..fdb4621 100644 --- a/2_Atomics.md +++ b/2_Atomics.md @@ -332,7 +332,7 @@ fn main() { 在我们的示例中,这两个都不是大问题。最糟糕的情况是向用户提交了不准确的平均值。 -如果我们想要避免这个,我们可以把这三个统计数据放到一个 Mutex 中。然后,在更新三个数字时,我们短暂地锁定 mutex,这三个数字不再是原子的。这有效地转变三次更新为单个原子操作,代价是锁定和释放 mutex,并且可能临时地阻塞线程。 +如果我们想要避免这个,我们可以把这三个统计数据放到一个 Mutex 中。然后,在更新三个数字时,我们短暂地锁定 mutex,这三个数字不再是原子的。这有效地转变三次更新为单个原子操作,代价是锁定和解锁 mutex,并且可能临时地阻塞线程。 ### 示例:ID 分配 @@ -497,7 +497,7 @@ fn allocate_new_id() -> u32 { 有关详细信息,请查看该方法的文档。 我们不会在本书中使用 fetch_update 方法,因此我们可以专注于单个原子操作。 -
+ ### 示例:惰性一次性初始化 diff --git a/3_Memory_Ordering.md b/3_Memory_Ordering.md index ee6dc27..b50ff91 100644 --- a/3_Memory_Ordering.md +++ b/3_Memory_Ordering.md @@ -1,30 +1,688 @@ # 第三章:内存排序[^1] +在[第二章](./2_Atomics.md),我们简要地谈到了内存排序的概念。在该章节,我们将研究这个主题,并探索所有可用的内存排序选项,并且,更重要地是,我们将学习如何使用它们。 + ## 重排和优化 +处理器和编译器执行各种技巧,以便使你的程序运行地尽可能地快。例如,处理器可能会确定你程序中的两个连续指令不会相互影响,并且如果这样更快,就会按顺序执行它们。当一个指令在从主存中获取一些数据被短暂地阻塞了,几个后续地指令可能在在第一个指令结束之前被执行和完成,只要这不会更改你程序的行为。类似地,编译器可能会决定重排或者重写你程序的部分代码,如果它有理由相信这可能会导致更快地执行。但是,同样地,仅有在不更改你程序行为的情况下。 + +让我们来看看一下这个例子: + +```rust +fn f(a: &mut i32, b: &mut i32) { + *a += 1; + *b += 1; + *a += 1; +} +``` + +这里,编译器肯定会明白,操作的顺序并不重要,因为在这三个加法操作之间没有发生任何依赖于 `*a` 或 `*b` 的操作(假设溢出检查被禁用)。因此,编译器可能会重新排序第二个和第三个操作,然后将前两个操作合并为单个加法操作: + +```rust +fn f(a: &mut i32, b: &mut i32) { + *a += 2; + *b += 1; +} +``` + +稍后,在执行优化编译程序的函数时,由于各种原因,处理器可能最终在执行第一次加法操作之前,执行第二次加法操作,可能是 `*b` 在缓存中可用,而 `*a` 在主内存可获取。 + +无论这些优化如何,结果都是相同的: `*a` 递增 2,`*b` 递增 1。它的递增的顺序对于你程序的其余部分完全不可见。 + +验证特定的重新排序或者其他优化并不影响程序的行为的逻辑并不需要考虑其他线程。在我们上面的示例中,这是极好的,因为独占引用(`&mut i32`)保证没有其他线程可以访问这个值。出现问题的唯一情况是,当共享的数据在线程之间发生改变。或者,换句话说,当使用原子操作时。这就是为什么,我们必须明确地告诉编译器和处理器,它们可以和不能使用我们的原子操作做什么,因为它们通常的逻辑忽略了线程之间的交互,并且可能允许的优化,会导致我们程序的结果改变。 + +有趣的问题是我们*如何*告诉它们。如果我们想要准确地阐明什么是可以接受的,什么是不可以接受的,并发程序将变得非常冗长并很容易出错,并且可能特定于架构: + +```rust +let x = a.fetch_add(1, + Dear compiler and processor, + Feel free to reorder this with operations on b, + but if there's another thread concurrently executing f, + please don't reorder this with operations on c! + Also, processor, don't forget to flush your store buffer! + If b is zero, though, it doesn't matter. + In that case, feel free to do whatever is fastest. + Thanks~ <3 +); +``` + +的确,我们仅能从一小部分选项中进行选择,这些选项由 `std::sync::atomic::Ordering` 枚举表示,每个原子操作都将其作为参数。可用选项的部分是非常有限的,但是经过精心挑选,可以适用大部分用例。排序是非常抽象的,并且不能直接反映实际编译器和处理器涉及的机制,例如指令重排。这使得你的并发代码可以脱离架构并且面向未来。它允许在不知道每个当前和未来处理器版本的信息的情况下进行验证。 + +在 Rust 中可用的排序: + +* Relaxed 排序:`Ordering::Relaxed` +* Release 和 acquire 排序:`Ordering::{Release, Acquire, AcqRel}` +* 顺序一致性排序:`Ordering::SeqCst` + +在 C++ 中,有一种叫做 *consume ordering*,它在 Rust 中被省略了,尽管如此,对它的讨论也是很有用的。 + ## 内存模型 +不同的内存排序选项有一个严格的形式定义,以确保我们确切地知道我们允许假设什么,并且让编译器编写者确切知道它们需要向我们提供什么。为了将它与特定处理器架构的细节解耦,内存排序是根据抽象*内存模型*定义的。 + +Rust 的内存模型,它更多的抄自 C++,与任何现有的处理器架构不匹配,而是一个抽象模型,它有一套严格的规则,试图带面当前和未来所有架构的最大公约数吗,同时基于编译器足够的自由去进行程序分析和优化时作出有用的假设。 + +我们已经在[第一章的“借用和数据竞争”]看到内存模型的一部分,我们讨论了数据竞争如何导致未定义行为。Rust 的内存模型允许并发的原子存储,但将并发的非原子存储到相同的变量视为数据竞争,这将导致未定义行为。 + +然而,在大多数处理器架构中,原子存储之间和常规非原子存储之间并没有什么区别,我们将在[第七章](./7_Understanding_the_Processor.md)看到这些。人们可以争辩说,内存模型的限制性比必要性要强,但这些严格的规则使编译器和程序员更容易对程序进行推理,并为未来的发展留下了空间。 + ## Happens-Before 关系 -### 产生和加入 +内存模型定义了操作在 *happens-before 关系*发生的顺序。这意味着,作为一个抽象模型,它不涉及机器指令、缓存、缓冲区、时间、指令重排、编译优化等,而只定一个了一件事情在另一件事情之前保证发生的情况,并将其它一切的顺序都视为未定义的。 + +基础的 happens-before 规则是同一线程内的任何事情都按顺序发生。如果线程线程正在执行 `f(); g();`,那么 `f()` 在 `g()` 之前发生。 + +然而,在线程之间,发生在特定的情况下的 happens-before 关系笔记哦啊有限,例如,在创建和等待线程时,解锁和锁定 mutex,以及使用非 relaxed 的原子操作。Relaxed 内存排序时最基本的(也是性能最好的)内存排序,它本身并不会导致任何跨线程的 happens-before 关系。 + +为了探索这意味着什么,让我们看看以下示例,我们假设 a 和 b 有不同的线程并发执行: + +```rust +static X: AtomicI32 = AtomicI32::new(0); +static Y: AtomicI32 = AtomicI32::new(0); + +fn a() { + X.store(10, Relaxed); // 1 + Y.store(20, Relaxed); // 2 +} + +fn b() { + let y = Y.load(Relaxed); // 3 + let x = X.load(Relaxed); // 4 + println!("{x} {y}"); +} +``` + +正如以上提及的,基础的 happens-before 规则是同一线程内的任何事情都按顺序发生。因在这个示例中,1 发生在 2 之前,并且 3 发生在 4 之前,正如 3-1 图片所示。因为我们使用 relaxed 内存排序,在我们的示例中并没有其它的 happens-before 关系。 + +![ ](./picture/raal_0301.png) +图3-1。示例代码中原子操作之间的 happens-before 关系。 + +如果 a 或 b 的任何一个在另一个开始之前完成,输出将是 0 0 或 10 20。如果 a 和 b 并发地运行,很容易看见输出是 10 0。发生这种操作的方式是,可能以以下顺序写运行:3 1 2 4。 + +更有趣地是,输出可以也是 0 20,尽管导致这个结果的操作不可能有全局地一致性顺序。当 3 被执行,它与 2 之间不存在 happens-before 关系,这意味着它可以加载 0 或 20。当 4 被执行,它与 1 之间不存在 happens-before 关系,这意味它可以加载 0 或 10。因此,输出 0 20 是一种有效的结果。 + +需要理解的重要和反直觉的是操作 3 加载值 20 并不能与 2 操作形成 happens-before 关系,即使这个值是由操作 2 存储的。我们对“之前”的概念的直觉是,在事情不一定按照全局一致性的顺序发生时会被打破,比如涉及指令重排的情况。 + +一个更有用并且直观,但是不太正式的理解是,从执行 b 的线程的视角来看,操作 1 和 2 可能以相反的顺序发生。 + +### spawn 和 join + +产生的线程会创建一个 happens-before 关系,它将发生在 `spawn()` 之前的事件与新线程关联起来。同样地,join 线程创建一个 happens-before 关系,它将发生在 `join()` 调用之后的事件与被 join 的线程关联起来。 + +为了证明,以下示例中的断言不能失败: + +```rust +static X: AtomicI32 = AtomicI32::new(0); + +fn main() { + X.store(1, Relaxed); + let t = thread::spawn(f); + X.store(2, Relaxed); + t.join().unwrap(); + X.store(3, Relaxed); +} + +fn f() { + let x = X.load(Relaxed); + assert!(x == 1 || x == 2); +} +``` + +由于 join 和产生操作形成的 happens-before 关系,我们肯定知道 X 的加载操作在第一个 store 之后,但在随后一个 store 之前,正如在图 3-2 所见。然而,它是否在第二个存储之前或之后观察值是不可预测的。换句话说,它可能是 1 或 2,但不是 0 或 3。 + +![ ](./picture/raal_0301.png) +图 3-2。示例代码中生成、join、存储和加载操作之间的 happens-before 关系。 ## Relaxed 排序 +当原子操作使用 relaxed 内存排序并不会提供任何 happens-before 关系,但是它们仍然保证了每个原子变量的*总的修改顺序*。这意味着,从线程的角度来看,*同一原子变量*的所有修改都是以相同的顺序进行的。 + +为了证明这意味着什么,我们假设 a 和 b 由不同的线程并发执行,考虑以下示例: + +```rust +static X: AtomicI32 = AtomicI32::new(0); + +fn a() { + X.fetch_add(5, Relaxed); + X.fetch_add(10, Relaxed); +} + +fn b() { + let a = X.load(Relaxed); + let b = X.load(Relaxed); + let c = X.load(Relaxed); + let d = X.load(Relaxed); + println!("{a} {b} {c} {d}"); +} +``` + +在该示例中,仅有一个线程修改 X,这使得很轻松地能够看到 X 的修改顺序:0→5→15。它从 0 开始,然后变成 5,最终变成 15。线程并不能从 X 中观察到与此总修改不一致的任何值。这意味着“0 0 0 0”、“0 0 5 15”和“0 15 15 15”是来自另一个线程打印语句的可能的一些结果,而“0 5 0 15”或“0 0 10 15”的输出是不可能的。 + +即使原子变量有多个可能的修改顺序,所有线程也仅同意一个顺序。 + +让我们用两个单独的函数替换 a1 和 a2,我们假设它们分别由一个单独的线程执行: + +```rust +fn a1() { + X.fetch_add(5, Relaxed); +} + +fn a2() { + X.fetch_add(10, Relaxed); +} +``` + +假设这些是唯一修改 X 的线程,现在有两种修改顺序:要么是 0→5→15 或 0→10→15,这取决于哪个 fetch_add 操作先执行。无论哪种情况,所有线程都遵守相同的顺序。因此,即使我们即使我们有数百个额外的线程正在运行我们的 `b()` 函数,我们知道如果其中一个打印出 10,那么顺序必须是 0→10→15,而它们其中的任何一个都不可能打印出 5。反之亦然。 + +在[第二章](./2_Atomics.md),我们看见几个用例示例,其中保证个别变量的总修改顺序就足够了,使用 Relaxed 内存排序足够了。然而,如果我们尝试任何超出这些示例更高级的东西,我们将很快发现,需要比 relaxed 更强的保证。 + +
+

凭空出现的值

+ 在使用 Relaxed 内存排序时,由于缺乏顺序保证,当操作在循环方式下相互依赖时,可能会导致理论上的复杂情况。 + + 为了演示,这里有一个人为的例子,两个线程从一个原子加载一个值,并将其存储在另一个原子中: + +
static X: AtomicI32 = AtomicI32::new(0);
+static Y: AtomicI32 = AtomicI32::new(0);
+
+fn main() {
+    let a = thread::spawn(|| {
+        let x = X.load(Relaxed);
+        Y.store(x, Relaxed);
+    });
+    let b = thread::spawn(|| {
+        let y = Y.load(Relaxed);
+        X.store(y, Relaxed);
+    });
+    a.join().unwrap();
+    b.join().unwrap();
+    assert_eq!(X.load(Relaxed), 0); // Might fail?
+    assert_eq!(Y.load(Relaxed), 0); // Might fail?
+}
+ + 似乎很容易得出 X 和 Y 的值不会时除 0 以外的任何东西的结论,因为 store 操作仅从这项相同的原子中加载值,而这些原子仅是 0。 + + 然而,如果我们严格遵循理论内存模型,我们必须面对循环推理,并得出可怕的结论,我们可能错了。事实上,内存模型在技术上允许出现这样的结果,即最终 X 和 Y 都是 37,或者任意其它的值,导致断言失败。 + + 由于缺饭顺序保证,这两个线程的 load 操作可能都看到另一个线程 store 操作的结果,允许按操作顺序循环:我们在 Y 中存储 37,因为我们从 X 加载了 37,X 存储到 X,因为我们从 Y 加载了 37,这是我们在 Y 中存储的值。 + + 幸运的是,这种*凭空捏造*值的可能性在理论模型中被普遍认为是一个 bug,而不需要你在实践中考虑。如何在不允许这种异常情况的情况下形式化 relaxed 内存排序还是一个未解决的问题。尽管这对于形式化验证来说可能是一个问题,让许多理论家夜不能寐,但是我们其他人可以放心地使用 relaxed,因为在实践中不会发生这种情况。 +
+ ## Release 和 Acquire 排序 -### 示例:「锁」 +*Release* 和 *Acquire* 内存排序通常成对使用,它们用于形成线程之间的 happens-before 关系。`Release` 内存排序适用于 store 操作,而 `Acquire` 内存排序适用于 load 操作。 + +当 acquire-load 操作观察 release-store 操作的结果时,就会形成 happens-before 关系。在这种情况下,store 操作极其之前的所有操作在时间上先于 load 操作和之后的所有操作。 + +当使用 Acquire 进行「获取和修改」或者「比较和交换」操作时,它仅适用于操作的 load 部分。类似地,Release 仅适用于操作的 store 部分。`AcqRel` 用于表示 Acquire 和 Release 的组合,这既能使 load 使用 Acquire,也能使 store 使用 Release。 + +让我们回顾一个示例,看看我们在实践中如何使用它们。在以下示例中,我们将一个 64 位整数从产生的线程发送到主线程。我们使用一个额外的原子布尔类型以指示主线程,整数已经被存储并且已经可以读取: + +```rust +use std::sync::atomic::Ordering::{Acquire, Release}; + +static DATA: AtomicU64 = AtomicU64::new(0); +static READY: AtomicBool = AtomicBool::new(false); + +fn main() { + thread::spawn(|| { + DATA.store(123, Relaxed); + READY.store(true, Release); // Everything from before this store .. + }); + while !READY.load(Acquire) { // .. is visible after this loads `true`. + thread::sleep(Duration::from_millis(100)); + println!("waiting..."); + } + println!("{}", DATA.load(Relaxed)); +} +``` + +当产生的线程完成数据存储时,它使用 release-store 去设置 `READY` 标志为真。当主线程通过它的 acquire-load 操作观察到,在这两个线程之间建立了一个 happens-before 关系,正如图 3-3 所示。此时,我们肯定知道在 release-store 到 READY 之前的所有操作对发生在 acquire-load 之后的所有操作都可见。具体而言,当主线程从 `DATA` 加载时,我们可以肯定它将加载由后台线程存储的值。该程序在最后一行只有一种输出结果:123。 + +![ ](./picture/raal_0303.png) + +图 3-3。示例代码中原子操作之间的 happens-before 关系,展示了通过 acquire 和 release 操作形成的跨线程关系。 + +如果我们在这个示例为所有操作使用 relaxed 内存排序,主线程可能会看到 `READY` 翻转为 true,而之后仍然从 DATA 中加载 0。 + +> “Release”和“Acquire”的名称基于它们最基本用例:一个线程通过原子地存储一些值到原子变量来发布数据,而另一个线程通过原子地加载这个值来获取数据。这正是当我们解锁(释放)互斥体并随后在另一个线程上锁定(获取)它时发生的情况。 + +在我们的示例中,来自 READY 标志的 happens-before 关系保证了 DATA 的 store 和 load 操作不能并发地发生。这意味着我们实际上不需要这些操作是原子的。 + +然而,如果我们仅是为我们的数据变量尝试去使用常规的非原子类型,编译器将拒绝我们的程序,因为当另一个线程也在借用它们,Rust 的类型系统不允许我们修改它们。类型系统不会理解我们在这里创建的 happens-before 关系。一些不安全的代码是必要的,以向编译器承诺我们已经仔细考虑过这个问题,我们确信我们没有违反任何规则,如下所示: + +```rust +static mut DATA: u64 = 0; +static READY: AtomicBool = AtomicBool::new(false); + +fn main() { + thread::spawn(|| { + // Safety: Nothing else is accessing DATA, + // because we haven't set the READY flag yet. + unsafe { DATA = 123 }; + READY.store(true, Release); // Everything from before this store .. + }); + while !READY.load(Acquire) { // .. is visible after this loads `true`. + thread::sleep(Duration::from_millis(100)); + println!("waiting..."); + } + // Safety: Nothing is mutating DATA, because READY is set. + println!("{}", unsafe { DATA }); +} +``` + +
+

更正式地

+ 当 acquire-load 操作观察 release-store 操作的结果时,就会形成 happens-before 关系。但那是什么意思? + + 想象一下,两个线程都将一个 7 release-store 到相同的原子变量中,第三个线程从该变量中加载 7。第三个线程和第一个或者第二个线程有一个 happens-before 关系吗?这取决于它加载“哪个 7”:线程一还是线程二的。(或许一个不相关的 7)。这使我们得出的结论是,尽管 7 等于 7,但两个 7 与两个线程有一些不同。 + + 思考这个问题的方式是我们在[“Relaxed 排序”](#relaxed-排序)中讨论的*总修改顺序*:发生在原子变量上的所有修改的有序列表。即使将相同的值多次写入相同的变量,这些操作中的每一个都以该变量的总修改顺序代表一个单独的事件。当我们加载一个值,加载的值与每个变量“时间线”上的特定点相匹配,这告诉我们我们可能会同步哪个操作。 + + 例如,如果原子总修改顺序是 + + 1. 初始化为 0 + + 2. Release-store 7(来自线程二) + + 3. Release-store 6 + + 4. Release-store 7(来自线程一) + + 然后,acquire-load 7 将与第二个线程的 release-store 或者最后一个事件的 release-store 同步。然而,如果我们之前(就 happens-before 关系而言)见过 6,我们知道我们看到的是最后一个 7,而不是第一个 7,这意味着我们现在与线程一有 happens-before 的关系,而不是线程二。 + + 还有一个额外的细节,即 release-stored 的值可能会被任意数量的「获取并修改」和「比较并交换」操作修改,但仍会导致与 acquire-load 读取最终结果的 happens-before 关系。 + + 例如,想象一个具有以下总修改顺序的原子变量: + + 1. 初始化为 0 + + 2. Release-store 7 + + 3. Relaxed-fetch-and-add 1,改变 7 到 8 + + 4. Relaxed-fetch-and-add 1,改变 8 到 9 + + 5. Release-store 7 + + 6. Relaxed-swap 10,改变 7 到 10 + + 现在,如果我们在这个变量上执行 acquire-load 到 9,我们不仅与第四个操作(存储此值)建立了一个 happens-before 关系,同时也与第二个操作(存储 7)建立了该关系,即使第三个操作使用了 Relaxed 内存排序。 + + 相似地,如果我们在这个变量上执行 acquire-load 到 10,而该值是由一个 relaxed 操作写入的,我们仍然建立了与第五个操作(存储 7)的 happens-before 关系。因为它只是一个普通的存储操作(不是「获取并修改」或「比较并交换」操作),它打破了规则链:我们没有与其他操作建立 happens-before 关系。 +
+ +### 示例:锁定 + +互斥锁是 release 和 acquire 排序的最常见用例(参见[第一章的“锁:互斥锁和读写锁”](./1_Basic_of_Rust_Concurrency.md#锁互斥锁和读写锁))。当锁定时,它们使用 acquire 排序的原子操作来检查是否它已解锁,同时也(原子地)改变状态到“锁定”。当解锁时,它们使用 release 排序设置状态到“解锁”。这意味着,在解锁 mutex 和随后锁定它有一个 happens-before 关系。 + +以下是这种模式的演示: + +```rust +static mut DATA: String = String::new(); +static LOCKED: AtomicBool = AtomicBool::new(false); + +fn f() { + if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() { + // Safety: We hold the exclusive lock, so nothing else is accessing DATA. + unsafe { DATA.push('!') }; + LOCKED.store(false, Release); + } +} + +fn main() { + thread::scope(|s| { + for _ in 0..100 { + s.spawn(f); + } + }); +} +``` + +正如我们在[第二章“比较并交换操作”](./2_Atomics.md#比较并交换操作)简要地所见,比较并交换采用两个内存排序参数:一个用于比较成功且 store 发生的情况,一个用于比较失败且 `store` 没有发生的情况。在 f 中,我们试图去改变 `LOCKED` 的值从 false 到 true,并且只有在成功的情况下才能访问 DATA。所以,我们仅关心成功的内存排序。如果 `compare_exchange` 操作失败,那一定是因为 `LOCKED` 已经设置为 true,在这种情况下 f 不会做任何事情。这与常规 mutex 上的 `try_lock` 操作相匹配。 + +> 观察力强的读者可能已经注意到,比较并交换操作也可能是交换操作,因为在已锁定时将 true 替换为 true 不会改变代码的正确性: +> +> ```rust +> // This also works. +> if LOCKED.swap(true, Acquire) == false { +> // … +> } +> ``` + +归功于 acquire 和 release 内存排序,我们肯定没有两个线程能并发地访问数据。正如在图 3-4 展示的,对 DATA 的任何先前访问都在随后使用 release-store 操作将 false 存储到 LOCKED 之前发生,然后在下一个 acquire-compare-exchange(或 acquire-swap)操作中将 false 更改为 true,然后在下一次访问 DATA 之前发生。 + +![ ](./picture/raal_0304.png) +图 3-4。锁定示例中原子操作之间的 happens-before 关系,显示了两个线程按顺序锁定和解锁。 + +在[第四章](./4_Building_Our_Own_Spin_Lock.md),我们将把这个概念变成一个可重复使用的类型:自旋锁。 ### 示例:使用间接的方式惰性初始化 -## 消费排序 +在[第二章的“示例:惰性一次性初始化”](./2_Atomics.md#示例惰性一次性初始化)中,我们实现一个全局变量的惰性初始化,使用「比较且交换」操作去处理多个线程竞争同时初始化值的情况。由于该值是非零的 64 位整数,我们能够使用 AtomicU64 来存储它,在初始化之前使用零作为占位符。 + +要对不适合单个原子变量的更大的数据类型做同样的事情,我们需要寻找替代方案。 + +在这个例子中,假设我们想保持非阻塞行为,这样线程就不会等待另一个线程,而是从第一个线程中竞争并获取值来完成初始化。这意味着我们仍然需要能够在单个原子操作中从“未初始化”到“完全初始化”。 + +正如软件工程的基本定理告诉我们的那样,计算机科学中的每个问题都可以通过添加另一层间接来解决,这个问题也不例外。由于我们无法将数据放入单个原子变量中,因此我们可以使用原子变量来存储指向数据的*指针*。 + +`AtomicPtr` 是 `*mut T` 的原子版本:指向 T 的指针。我们可以使用空指针作为初始状态的占位符,并使用比较并交换操作将其原子地替换为指向新分配的、完全初始化的 T 的指针,然后可以由其他线程读取。 + +由于我们不仅共享包含指针的原子变量,还共享它所指向的数据,因此我们不能再像[第2章](./2_Atomics.md)那样使用 Relaxed 的内存排序。我们需要确保数据的分配和初始化不会与读取数据竞争。换句话说,我们需要在 store 和 load 操作上使用 release 和 acquire 排序,以确保编译器和处理器不会通过,例如,重新排序指针的存储和数据本身的初始化来破坏我们的代码。 + +对于一些名为 Data 的任意数据类型,这引出了以下实现: + +```rust +use std::sync::atomic::AtomicPtr; + +fn get_data() -> &'static Data { + static PTR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + + let mut p = PTR.load(Acquire); + + if p.is_null() { + p = Box::into_raw(Box::new(generate_data())); + if let Err(e) = PTR.compare_exchange( + std::ptr::null_mut(), p, Release, Acquire + ) { + // Safety: p comes from Box::into_raw right above, + // and wasn't shared with any other thread. + drop(unsafe { Box::from_raw(p) }); + p = e; + } + } + + // Safety: p is not null and points to a properly initialized value. + unsafe { &*p } +} +``` + +如果我们以 acquire-load 操作从 PTR 得到的指针是非空的,我们假设它指向已初始化的数据,并构建对该数据的引用。 + +然而,如果它仍然为空,我们会生成新数据,并使用 `Box::new` 将其存储在新分配中。然后,我们使用 `Box::into_raw` 将此 `Box` 转换为原始指针,因此我们可以尝试使用比较并交换操作将其存储到 PTR 中。如果另一个线程赢得初始化竞争,`compare_exchange` 将失败,因为 PTR 不再是空的。如果发生这种情况,我们将原始指针转回 Box,使用 `dro`p 来解除分配,避免内存泄漏,并继续使用另一个线程存储在 PTR 中的指针。 + +在最后的不安全块中,关于安全性的注视表明我们的假设是指它指向的数据已经被初始化。注意,这包括对事情发生顺序的假设。为了确保我们的假设成立,我们使用 release 和 acquire 内存顺序来确保初始化数据实际上在创建对其的引用之前已经发生。 + +我们在两个地方加载一个潜在的非空(即初始化)指针:通过 load 操作和当 compare_exchange 失败时的该操作。因此,如上所述,我们需要在 load 内存顺序和 compare_exchange 失败内存顺序上都使用 Acquire,以便能够与存储指针的操作进行同步。当 compare_exchange 操作成功时,会发生 store 操作,因此我们必须使用 Release 作为其成功的内存顺序。 + +图 3-5 显示了三个线程调用 `get_data()` 的情况的操作和发生前关系的可视化。在这种情况下,线程 A 和 B 都观察到一个空指针并都试图去初始化原子指针。线程 A 赢得竞争,导致线程 B 的 compare_exchange 调用失败。线程 C 在通过线程 A 初始化之后观察原子指针。最终结果是,所有三个线程最终都使用由线程 A 分配的 box。 + +![ ](./picture/raal_0305.png) +图3-5。调用 `get_data()` 的三个线程之间的操作和发生前关系。 + +## Consume 排序 + +让我们仔细看看上一个示例中的内存排序。如果我们把严格的模型放在一边,从更实际的方面来思考它,我们可以说 release 排序阻止了数据的初始化与共享指针的 store 操作重新排序。这一点非常重要,因为否则其它线程可能会在数据完全初始化之前就能看到它。 + +类似地,我们可以说 acquire 排序为防止重新排序,使得数据在加载指针之前被访问。然而,人们可能合理地质疑,在实践中是否有意义。在地址加载之前,如何访问数据?我们可能会得出结论,若于 acquire 排序的内存顺序可能足够。我们的结论是正确的:这种较弱的内存顺序被称为 consume 排序。 + +consume 排序是 acquire 排序的一个轻量级、更高效的变体,其同步效果仅限于依赖于已加载值的操作。 + +这意味着如果你用 consume-load 从一个原子变量中加载一个通过 release 存储的值 x,那么基本上,这个 store 操作发生在依赖表达式(如 `*x`、`array[x]` 或 `table.lookup(x + 1)`)的求值之前,但不一定发生在独立操作(如读取另一个与 x 无关的变量)之前。 + +现在有好消息和坏消息。 + +好消息是,在所有现代处理器架构上,consume 排序是通过与 relaxed 排序完全相同的指令实现的。换句话说,consume 排序可以是“免费的”,而 acquire 内存排序在某些平台可能不是这样。 + +坏消息是,没有编译器真正实现 consume 排序。 + +事实证明,这种“依赖性”评估的概念不仅难以定义,而且在转换和优化程序时保持这些依赖性也很难。例如,编译器能够优化 x + 2 - x 为 2,有效地消除了对 x 的依赖。对于更复杂的表达式,如 `array[x]`,如果编译器能够对 x 或数组元素的可能值进行逻辑推断,那么可能会出现更微妙的变化。当考虑控制流,如 if 语句或函数调用时,问题讲变得更加复杂。 + +因此,编译器升级 consume 排序到 acquire 排序,仅是为了安全起见。C++20 标准甚至明确地反对使用消费排序,并指出,除了 acquire 排序之外,其他实现被证明是不可行的。 + +将来可能找到一个 consume 排序的有效定义和实现。然而,直到这一天到来之前,Rust 都不会暴露 `Ordering::Consume`。 ## 顺序一致性排序 +一个更强的内存排序时*顺序一致性*排序:`Ordering::SeqCst`。它包含了 acquire 排序(对于 load 操作)以及 release 排序(对于 store 操作)的所有保证,并且*也*保证了全局一致性操作。 + +这意味着在程序中使用 `SeqCst` 排序的每个操作是所有线程都同意的单个总顺序的一部分。这个总顺序与每个变量的总修改顺序一致。 + +严格来讲,由于它比 acquire 和 release 内存排序要强,因此顺序一致性 load 或者 store 操作可以取代一对 release-acquire 中的 acquire-load 或 release-store 操作,形成 happens-before 关系。换句话说,acquire-load 不仅可以与 release-store 形成 happens-before 关系,同时也可以和顺序一致的 store 形成 happens-before 关系,同样 release-store 也是如此。 + +> 仅有当 happens-before 关系的双方使用 SeqCst 时,才能保证与 SeqCst 操作的单个总顺序一致。 + +虽然这似乎是最容易推理的内存排序,但 SeqCst 排序在实践中几乎从来都没有必要。在几乎所有情况下,通常 acquire 和 release 排序就足够了。 + +以下是一个取决于顺序一致的有序操作的示例: + +```rust +use std::sync::atomic::Ordering::SeqCst; + +static A: AtomicBool = AtomicBool::new(false); +static B: AtomicBool = AtomicBool::new(false); + +static mut S: String = String::new(); + +fn main() { + let a = thread::spawn(|| { + A.store(true, SeqCst); + if !B.load(SeqCst) { + unsafe { S.push('!') }; + } + }); + + let b = thread::spawn(|| { + B.store(true, SeqCst); + if !A.load(SeqCst) { + unsafe { S.push('!') }; + } + }); + + a.join().unwrap(); + b.join().unwrap(); +} +``` + +两个线程首先设置它们自己的原子布尔值到 true,以告知另一个线程它们正在获取 S,并且然后检查其它的原子变量布尔值,是否它们可以安全地获取 S,而没有导致数据竞争。 + +如果两个存储操作都发生在任一加载操作之前,则两个线程最终都无法访问 S。然而,两个线程都不可能访问 S 并导致未定义的行为,因为顺序一致的顺序保证了其中只有一个线程可以赢得竞争。在每个可能的单个总的顺序中,单个操作将是 store 操作,这阻止其他线程访问 S。 + +在实际情况中,几乎所有对 SeqCst 的使用都涉及一种类似的存储模式,在随后在同一线程上加载之前必须全局可见。对于这些情况,一个潜在的更有效的替代方案是将 relaxed 的操作与 SeqCst 屏障结合使用,我们接下来将探索。 + ## 屏障(Fence)[^2] +除了对原子变量的额外操作,我们还可以将内存排序应用于:原子屏障。 + +`std::sync::atomic::fence` 函数表示一个*原子屏障*,它可以是一个 Release 屏障、一个 Acquire 屏障,或者两者都是(AcqRel 或 SeqCst)。SeqCst 屏障还参与顺序一致性的全局排序。 + +原子屏障允许你从原子操作中分离内存排序。如果你想要应用内存排序到多个操作这可能是有用的,或者你想要有条件地应用内存排序。 + +本质上,release-store 可以拆分成 release 屏障,然后是(relaxed)store,并且 acquire-load 可以拆分成(relaxed)load,然后是 acquire 屏障: + +
+
+ release-acquire 关系的 store 操作, +
+    a.store(1, Release);
+ 可以由 release 屏障和随后的 relaxed store 组成: +
+    fence(Release);
+    a.store(1, Relaxed);
+
+
+ release-acquire 关系的 load 操作, +
+      a.load(Acquire);
+ 可以由 relaxed load 和随后的 acquire 屏障组成: +
+    a.load(Relaxed);
+    fence(Acquire);
+
+
+ +不过,使用单独的屏障可能会导致额外的处理器指令,这可能会略微降低效率。 + +更重要地是,与 release-store 或者 acquire-load 不同,屏障不会与任意单个原子变量捆绑。这意味着单个屏障可以立刻用于多个变量。 + +从形式上讲,如果 release 屏障在同意线程上的位置紧跟着任何一个原子操作,而该原子操作存储的值被我们要同步的 acquire 操作观察到,那么该 release 屏障可以代替 release 操作,并建立一个 happens-before 关系。同样地,如果一个 acquire 屏障在同一线程上的位置紧接着之前的任何一个原子操作,而该原子操作加载的值是由 release 操作存储的,那么该 acquire 屏障可以替代任何一个 acquire 操作。 + +综上所述,这意味着如果在 release 屏障之后的任何 store 操作被在 acquire 屏障之前的任何 load 操作所观察,那么在 release 屏障和 acquire 屏障之间将建立 happens-before 关系。 + +例如,假设我们有一个线程执行一个 release 屏障,然后对不同的变量执行三个原子 store 操作,另一个线程从这些相同的变量执行三个 load 操作,然后是一个 acquire 栅栏,如下所示: + +
+
+ 线程 1: +
+    fence(Release);
+    A.store(1, Relaxed);
+    B.store(2, Relaxed);
+    C.store(3, Relaxed);
+
+
+ 线程 2: +
+    A.load(Relaxed);
+    B.load(Relaxed);
+    C.load(Relaxed);
+    fence(Acquire);
+
+
+ +在这种情况下,如果线程 2 上的任意 load 操作从线程 1 上的相应 store 操作加载值,那么线程 1 上的 release 屏障和线程 2 上的 acquire 屏障之间将建立 happens-before 关系。 + +屏障不必直接在原子操作之前或者之后。它们可以在原子操作之间,包括控制流。这可以用于使栅栏具有条件行,类似于比较且交换操作具有成功和失败的排序。 + +例如,如果我们从使用 acquire 内存排序的原子变量中加载指针,我们可以使用屏障仅当指针不是空的时候应用 acquire 内存排序: + +
+
+ 使用 acquire-load: +
let p = PTR.load(Acquire);
+if p.is_null() {
+    println!("no data");
+} else {
+    println!("data = {}", unsafe { *p });
+}
+
+
+ 使用条件的 acquire 屏障: +
let p = PTR.load(Relaxed);
+if p.is_null() {
+    println!("no data");
+} else {
+    fence(Acquire);
+    println!("data = {}", unsafe {*p });
+}
+
+
+ +如果指针通常为空,这可能是有益的,在不需要时避免 acquire 内存排序。 + +让我们来看看一个更复杂的 release 和 acquire 屏障的用例: + +```rust +use std::sync::atomic::fence; + +static mut DATA: [u64; 10] = [0; 10]; + +const ATOMIC_FALSE: AtomicBool = AtomicBool::new(false); +static READY: [AtomicBool; 10] = [ATOMIC_FALSE; 10]; + +fn main() { + for i in 0..10 { + thread::spawn(move || { + let data = some_calculation(i); + unsafe { DATA[i] = data }; + READY[i].store(true, Release); + }); + } + thread::sleep(Duration::from_millis(500)); + let ready: [bool; 10] = std::array::from_fn(|i| READY[i].load(Relaxed)); + if ready.contains(&true) { + fence(Acquire); + for i in 0..10 { + if ready[i] { + println!("data{i} = {}", unsafe { DATA[i] }); + } + } + } +} +``` + +> `Std::array::from_fn` 是一种执行一定次数并将结果收集到数组中的简单方法。 + +在这个示例中,10 个线程做了一些计算,并存储它们的结果到一个(非原子)共享变量中。每个线程设置一个原子布尔值,以指示数据已经通过主线程准备好读取,使用一个普通的 release-store。主线程等待半秒,检查所有 10 个布尔值以查看哪些线程已完成,并打印任何准备好的结果。 + +主线程不使用 10 个 acquire-load 操作来读取布尔值,而是使用 relaxed 的操作和单个 acquire 屏障。它在读取数据之前执行屏障,但前提是有数据要读取。 + +虽然在这个特定的例子中,投入任何精力进行此类优化可能完全没有必要,但在构建高效的并发数据结构时,这种节省额外获取操作开销的模式可能很重要。 + +`SeqCst` 屏障既是 release 屏障也是 acquire 屏障(就像 `AcqRel`),同时也是顺序一致性操作的单个总顺序的一部分。然而,只有屏障是总顺序的一部分,但不一定是它之前或之后的原子操作。这意味着,与 release 或 acquire 操作不同,顺序一致性的操作不能拆分为 relaxed 操作和内存屏障。 + +
+

编译器屏障

+ 除了常规的原子屏障,Rust 标准库还提供了编译器屏障std::sync::atomic::compiler_fence。它的签名与我们上面讨论的这些常规 fence() 不同,但它的效果仅限于编译器。与原子屏障不同,例如,它并不会阻止处理器重排指令。在绝大多数屏障的用例中,编译器屏障是不够的。 + + 在实现 Unix 信号处理程序或嵌入式系统上的中断时,可能会出现可能的用例。这些机制可以突然中断一个线程,暂时在同一处理器内核上执行一个不相关的函数。由于它发生在同一处理器内核上,处理器可能影响内存排序的常规方式不适用。(更多细节请参考[第七章](./7_Understanding_the_Processor.md))在这种情况下,编译器屏障可能阻隔,这样可以节省一条指令并且希望提高性能。 + + 另一用例涉及进程级内存屏障。这种技术超出了 Rust 内存模型的范畴,并且仅在某些操作系统上受支持:在 Linux 上通过 membarrier 系统调用,在 Windows 上使用 FlushProcessWriterBuffers 函数。它有效地允许一个线程强制向所有并发运行的线程注入(顺序一致性)原子屏障。这使得我们可以使用轻量级的编译器屏障和重型的进程级屏障替换两个匹配的屏障。如果轻量级屏障一侧的代码执行效率更高,这可以提高整体性能。(请参阅 crates.io 上的 membarrier crate 文档,了解更多详细信息和在 Rust 中使用这种屏障的跨平台方法。) + + 编译器屏障也可以是一个有趣的工具,用于探索处理器对内存排序的影响。 + + 在[第七章“一个实验”](./7_Understanding_the_Processor.md#一个实验)中,我们将故意使用编译器屏障替换常规屏障。这将让我们在使用错误的内存顺序时体验到处理器的微妙但潜在的灾难性影响。 +
+ ## 常见的误解 +围绕内存排序有很多误解。在我们结束本章之前,让我们回顾一下最常见的误解。 + +> 误区:我需要强大的内存排序,以确保更改“立即”可见。 + +一个常见的误解是,使用像 Relaxed 这样的弱内存排序意味着对原子变量的更改可能永远不会到达另一个线程,或者只有在显著延迟之后才会到达。“Relaxed”这个名字可能会让它听起来像什么都没发生,直到有什么东西迫使硬件被唤醒并执行本该执行的操作。 + +事实是,内存模型并没有说任何关于时机的事情。它仅定义了某些事情发生的顺序;而不是你要等待多久。假设一台计算机需要数年时间才能将数据从一个线程传输到另一个线程,这完全无法使用,但可以完美地满足内存模型。 + +在现实生活中,内存排序是关于重新排序指令等事情,这通常以纳秒规模发生。更强的内存排序不会使你的数据传输速度更快;它甚至可能会减慢你的程序速度。 + +> 误区:禁用优化意味着我不需要关心内存排序。 + +编译器和处理器在使事情按照我们预期的顺序发生方面起着作用。禁用编译器优化并不能禁用编译器中的每种可能的转换,也不能禁用处理器的功能,这些功能导致指令重新排序和类似的潜在问题行为。 + +> 误区:使用不重新排序指令的处理器意味着我不需要关心内存排序。 + +一些简单的处理器,比如小型微控制器中的处理器,只有一个核心,每次只能执行一条指令,并且按顺序执行。然而,虽然在这类设备上,出现错误的内存排序导致实际问题的可能性较低,但编译器仍然可能基于错误的内存排序做出无效的假设,导致代码出错。此外,还需要认识到,即使处理器不会乱序执行指令,它仍可能具有其他与内存排序相关的特性。 + +> 误区:Relaxed 的操作是免费的。 + +这是否成立取决于对“免费”一词的定义。的确,Relaxed 是最高效的内存排序,相比其他内存排序可以显著提升性能。事实上,对于所有现代平台,Relaxed 加载和存储操作编译成与非原子读写相同的处理器指令。 + +如果原子变量只在单个线程中使用,与非原子变量相比,速度上的差异很可能是因为编译器对非原子操作具有更大的自由度并更有效地进行优化。(编译器通常避免对原子变量进行大部分类型的优化。) + +然而,从多个线程访问相同的内存通常比从单个线程访问要慢得多。当其他线程开始重复读取该变量时,持续写入原子变量的线程可能会遇到明显的减速,因为处理器核心和它们的缓存现在必须开始协作。 + +我们将在[第7章](./7_Understanding_the_Processor.md)中探讨这种效应。 + +> 误区:顺序一致的内存排序是一个很好的默认值,并且总是正确的。 + +抛开性能问题,顺序一致性内存排序通常被视为默认选择的理想内存排序类型,因为它具有强大的保证。确实,如果任何其他内存排序是正确的,那么 `SeqCst` 也是正确的。这可能让人觉得 `SeqCst` 总是正确的。然而,可能并发算法本身就是不正确的,不论使用哪种内存排序。 + +更重要的是,在阅读代码时,`SeqCst` 基本上告诉读者:“该操作依赖于程序中每个 `SeqCst` 操作的总顺序”,这是一个极其广泛的声明。如果可能的话,使用较弱的内存排序往往会使相同的代码更容易进行审查和验证。例如,Release 明确告诉读者:“这与同一变量的 acquire 操作相关”,在形成对代码的理解时涉及的考虑要少得多。 + +建议将 SeqCst 看作是一个警示标志。在实际代码中看到它通常意味着要么涉及到复杂的情况,要么简单地说是作者没有花时间分析其与内存排序相关的假设,这两种情况都需要额外的审查。 + +> 误区:顺序一致的内存顺序可用于“release-store”或“acquire-load”。 + +虽然顺序一致性内存排序可以替代 Acquire 或 Release,但它并不能以某种方式创建 acquire-store 或 release-load。这些仍然是不存在的。Release 仅适用于存储操作,而 acquire 仅适用于加载操作。 + +例如,Release-store 与 SeqCst-store 不会形成任何 release-acquire 关系。如果你希望它们成为全局一致顺序的一部分,两个操作都必须使用 SeqCst。 + ## 总结 +* 所有的原子操作可能没有全局一致的顺序,因为不同的线程视角可能会以不同的顺序发生。 +* 然而,每个单独的原子变量都有它自己的*总修改顺序*,不管内存排序如何,所有线程都会达成一致意见。 +* 操作顺序是通过 *happens-before* 关系来定义的。 +* 在单个线程中,每个操作之间都会有一个 *happens-before* 关系。 +* 创建一个线程的操作在顺序上发生在该线程的所有操作之前。 +* 线程做的任何事情都会在 join 这个线程之前发生。 +* 解锁 mutex 的操作在顺序上发生在再次锁定 mutex 的操作之前。 +* 从 release 存储中以 acquire 加载值建立了一个 happens-before 关系。该值可以通过任意数量的获取和修改以及比较和交换操作修改。 +* 如果存在的话,consume-load 将是 acquire-load 的轻量级版本。 +* 顺序一致的排序导致全局一致的操作顺序,但几乎从来都没有必要,并且会使代码审查更加复杂。 +* 屏障允许你组合多个操作的内存顺序或有条件地应用内存顺序。 + [^1]: [^2]: diff --git a/9_Building_Our_Own_Locks.md b/9_Building_Our_Own_Locks.md index 9536737..957af89 100644 --- a/9_Building_Our_Own_Locks.md +++ b/9_Building_Our_Own_Locks.md @@ -91,7 +91,7 @@ impl Mutex { } ``` -现在,我们已经完成了,然而还有剩下的两块未完成:锁定(`Mutex::lock()`)和释放(为 `MutexGuard` 的 `Drop`)。 +现在,我们已经完成了,然而还有剩下的两块未完成:锁定(`Mutex::lock()`)和解锁(为 `MutexGuard` 的 `Drop`)。 我们为自旋锁实现的锁(lock)函数,使用了一个原子交换(`swap`)操作以试图去获取锁,如果它成功的将状态从“解锁”更改到“锁定”,则返回。如果未成功,它将立刻再次尝试。 @@ -113,9 +113,9 @@ impl Mutex { 注意,仅有在我们调用它时,状态仍设置为 1(锁定)时,`wait()` 函数才会阻塞,这样我们就不必担心在交换和等待调用之间失去唤醒调用的可能性。 -守卫类型的 Drop 实现是负责释放 mutex。释放我们的自旋锁是简单的:仅需要设置状态到 false(释放锁)。然而,对于我们的互斥体来说,这还不够。如果有一个线程等待锁定 mutex,除非我们使用唤醒操作通知它,否则它不会知道 mutex 已经被释放。如果我们不唤醒它,它将更可能永远保持睡眠。(也许它很幸运,在正确的时间被虚假地唤醒(spurious wake-up)[^2],但是我们不要指望这一点。) +守卫类型的 Drop 实现是负责解锁 mutex。解锁我们的自旋锁是简单的:仅需要设置状态到 false(解锁锁)。然而,对于我们的互斥体来说,这还不够。如果有一个线程等待锁定 mutex,除非我们使用唤醒操作通知它,否则它不会知道 mutex 已经被解锁。如果我们不唤醒它,它将更可能永远保持睡眠。(也许它很幸运,在正确的时间被虚假地唤醒(spurious wake-up)[^2],但是我们不要指望这一点。) -因此,我们不仅将状态设置回 0(释放),而且还会在之后立即调用 `wake_one()`: +因此,我们不仅将状态设置回 0(解锁),而且还会在之后立即调用 `wake_one()`: ```rust impl Drop for MutexGuard<'_, T> { @@ -140,7 +140,7 @@ impl Drop for MutexGuard<'_, T> {

Lock API

如果你正在计划将实现 Rust 锁当作一个新的爱好,那么你可能很快对涉及提供安全接口的样板代码感到厌烦。也就是说,UnsafeCell、Sync 实现、守护类型、Deref 实现等等。 - crate.io 上的 lock_api 可以自动地去处理这些事情。你仅需要制作一个锁定状态的类型,并通过(不安全)lock_api::RawMutex trait 提供(不安全)锁定和释放功能。lock_api::Mutex 类型将根据你的锁实现,提供一个完全安全的和符合人体工学的 mutex 类型作为返回,包括 mutex 守护。 + crate.io 上的 lock_api 可以自动地去处理这些事情。你仅需要制作一个锁定状态的类型,并通过(不安全)lock_api::RawMutex trait 提供(不安全)锁定和解锁功能。lock_api::Mutex 类型将根据你的锁实现,提供一个完全安全的和符合人体工学的 mutex 类型作为返回,包括 mutex 守护。 ### 避免系统调用 @@ -163,7 +163,7 @@ pub struct Mutex { } ``` -现在,对于一个释放的 mutex,我们的 lock 函数仍然需要将状态设置为 1 才能锁定它。然而,如果它已经锁定,我们的 lock 函数需要在睡眠之前将状态设置为 2,以便释放(unlock)函数可以判断这有一个等待线程。 +现在,对于一个解锁的 mutex,我们的 lock 函数仍然需要将状态设置为 1 才能锁定它。然而,如果它已经锁定,我们的 lock 函数需要在睡眠之前将状态设置为 2,以便解锁(unlock)函数可以判断这有一个等待线程。 为了做到这一点,我们首先使用 `compare-and-exchange` 函数,试图改变状态从 0 到 1。如果成功,我们就已经锁定了 mutex,并且我们知道,这没有其它的等待者,因为 mutex 之前没有锁定。如果它失败了,那一定是因为 mutex 当前被锁定(在状态 1 或 2)。在这种情况下,我们将使用一个原子交换操作将其设置为 2。如果交换操作返回 1 或 2 的旧值,那这意味着 mutex 事实上仍然被锁定,并且仅有这样,我们才会使用 `wait()` 去阻塞,直到它改变。如果交换操作返回 0,这意味着我们通过改变它的状态从 0 到 2 已经成功锁定 mutex。 @@ -178,7 +178,7 @@ pub struct Mutex { } ``` -现在,我们释放功能可以在必要时通过跳过 `wake_one()` 调用来利用新信息。我们现在使用交换操作,而不是仅仅存储一个 0 去释放 mutex,这样我们就可以检查它之前的值。仅当值为 2 时,我们将继续唤醒一个线程: +现在,我们解锁功能可以在必要时通过跳过 `wake_one()` 调用来利用新信息。我们现在使用交换操作,而不是仅仅存储一个 0 去解锁 mutex,这样我们就可以检查它之前的值。仅当值为 2 时,我们将继续唤醒一个线程: ```rust impl Drop for MutexGuard<'_, T> { @@ -192,9 +192,9 @@ impl Drop for MutexGuard<'_, T> { 注意,将状态设置回 0 后,它不再指示是否有任何其它的等待线程。唤醒的线程负责将状态设置为 2,以确保没有任何其它的线程忘记。这是为什么在我们的锁定功能中「比较和交换」(`compare-and-exchange`)操作不是我们 while 循环的一部分。 -这确实以为这,每当线程在锁定时不得不 `wait()`,当释放时,它将也调用 `wake_one()`。然而,更重要的是,*在未考虑的情况下*,线程不试图获取锁的理想状况下,完全地避免了 `wait()` 和 `wake_one()` 调用。 +这确实以为这,每当线程在锁定时不得不 `wait()`,当解锁时,它将也调用 `wake_one()`。然而,更重要的是,*在未考虑的情况下*,线程不试图获取锁的理想状况下,完全地避免了 `wait()` 和 `wake_one()` 调用。 -图 9-1 展示了两个线程同时尝试锁定我们的 mutex 操作的情况下的 happens-before 关系。首先线程通过改变状态从 0 到 1 锁定 mutex。此时,第二个线程将无法获取锁,并且在改变状态从 1 到 2 后进入睡眠。当第一个线程释放 mutex 时,它会交换状态回 0。因为是 2,表示一个等待线程,它调用 `wake_one()` 来唤醒第二个线程。注意,我们不能依赖于唤醒和等待操作之间的任何 happens-before 关系。虽然唤醒操作可能是负责唤醒等待线程的操作,但 happens-before 关系是通过 `acquire` 交换操作建立的,观察 `release` 交换操作存储的值。 +图 9-1 展示了两个线程同时尝试锁定我们的 mutex 操作的情况下的 happens-before 关系。首先线程通过改变状态从 0 到 1 锁定 mutex。此时,第二个线程将无法获取锁,并且在改变状态从 1 到 2 后进入睡眠。当第一个线程解锁 mutex 时,它会交换状态回 0。因为是 2,表示一个等待线程,它调用 `wake_one()` 来唤醒第二个线程。注意,我们不能依赖于唤醒和等待操作之间的任何 happens-before 关系。虽然唤醒操作可能是负责唤醒等待线程的操作,但 happens-before 关系是通过 `acquire` 交换操作建立的,观察 `release` 交换操作存储的值。 ![ ](./picture/raal_0901.png) *图 9-1。两个线程之间 happens-before 的关系同时试图锁定我们的 mutex。* @@ -277,7 +277,7 @@ fn lock_contended(state: &AtomicU32) { 我们将试图去写两个简单的基础测试,表明我们的优化至少对一些用例产生了一些积极影响,但请注意,任何结论在不同场景都不一定成立。 -在我们的第一次测试中,我们将创建一个 Mutex 并锁定和释放它几百万次,所有都在同一线程上,以测量它花费总的时间。这是对琐碎未讨论场景的测试,其中从来没有任何需要唤醒的线程。希望这将向我们展示 2-state 和 3-state 版本的差异。 +在我们的第一次测试中,我们将创建一个 Mutex 并锁定和解锁它几百万次,所有都在同一线程上,以测量它花费总的时间。这是对琐碎未讨论场景的测试,其中从来没有任何需要唤醒的线程。希望这将向我们展示 2-state 和 3-state 版本的差异。 ```rust fn main() { @@ -304,7 +304,7 @@ fn main() { 为了看看我们的自旋优化是否有任何积极的影响,我们需要一个不同的基准测试:一个有着大量争用的测试,多个线程反复尝试去锁定一个已经上锁的 mutex。 -让我们尝试一个场景,四个线程都尝试锁定和释放 mutex 上万次: +让我们尝试一个场景,四个线程都尝试锁定和解锁 mutex 上万次: ```rust fn main() { @@ -325,7 +325,7 @@ fn main() { } ``` -注意,这是一个极端和不切实际的场景。mutex 仅能保留一个极短的时间(进去增加一个整数),并且在释放后,线程将立刻试图再次锁定 mutex。不同的场景将导致非常不同的结果。 +注意,这是一个极端和不切实际的场景。mutex 仅能保留一个极短的时间(进去增加一个整数),并且在解锁后,线程将立刻试图再次锁定 mutex。不同的场景将导致非常不同的结果。 让我们像以前一样,在两台相同的 Linux 计算机上运行基准测试。在那台较旧的 Intel 处理器的计算机上,不使用自旋版本大约需要 900ms,而使用自旋版本大约需要 750ms。这是一个改进。而在那台新的 AMD 处理器计算机上,我们得到一个相反的结果:不使用自旋大约 650ms,而使用自旋大约需要 800ms。 @@ -335,13 +335,13 @@ fn main() { 让我们做一些更有趣的事情:实现一个条件变量。 -正如我们在[第一章“条件变量”](./1_Basic_of_Rust_Concurrency.md#条件变量)中见到的,条件变量与 mutex 一起使用,以等待受 mutex 保护的数据与某些条件匹配。它有一个等待方法释放 mutex,等待一个信号,并再次锁定相同的 mutex。通常由其它线程发送信号,在修改 mutex 保护的数据后立即发送给一个等待的线程(通常叫做“通知一个”或“单播”)或者通知所有等待的线程(通常叫做“通知所有”或“广播”)。 +正如我们在[第一章“条件变量”](./1_Basic_of_Rust_Concurrency.md#条件变量)中见到的,条件变量与 mutex 一起使用,以等待受 mutex 保护的数据与某些条件匹配。它有一个等待方法解锁 mutex,等待一个信号,并再次锁定相同的 mutex。通常由其它线程发送信号,在修改 mutex 保护的数据后立即发送给一个等待的线程(通常叫做“通知一个”或“单播”)或者通知所有等待的线程(通常叫做“通知所有”或“广播”)。 虽然条件变量试图让等待线程保持睡眠状态,直到它发出一个信号,但等待线程可能没有相应信号的情况下被虚假地唤醒。然而,条件变量的等待操作将仍然是在返回之前重新锁定 mutex。 -注意,此接口与我们的类 futex `wait()`、`wake_one()` 以及 `wake_all()` 几乎相同。主要的不同是在于防止信号丢失的机制。条件变量在释放 mutex 之前开始“监听”信号,以便不错过任何信号,而我们的 futex 风格的 `wait()` 函数依赖于原子变量状态的检查,以确保等待仍然是一个好的方式。 +注意,此接口与我们的类 futex `wait()`、`wake_one()` 以及 `wake_all()` 几乎相同。主要的不同是在于防止信号丢失的机制。条件变量在解锁 mutex 之前开始“监听”信号,以便不错过任何信号,而我们的 futex 风格的 `wait()` 函数依赖于原子变量状态的检查,以确保等待仍然是一个好的方式。 -这导致了条件变量以下最小实现的想法:如果我们确保每个通知都更改原子变量(例如计数器),那么我们的 `Condvar::wait()` 方法需要做的就是在释放 mutex 之前,检查该变量的值,并且在释放它之后,传递它到 futex 风格的 `wait()` 函数。这样,如果自释放 mutex 以来,收到任意通知信号,它将不再睡眠。 +这导致了条件变量以下最小实现的想法:如果我们确保每个通知都更改原子变量(例如计数器),那么我们的 `Condvar::wait()` 方法需要做的就是在解锁 mutex 之前,检查该变量的值,并且在解锁它之后,传递它到 futex 风格的 `wait()` 函数。这样,如果自解锁 mutex 以来,收到任意通知信号,它将不再睡眠。 我们试试吧! @@ -379,7 +379,7 @@ impl Condvar { 等待方法将采用 `MutexGuard` 作为参数,因为它表示已锁定 mutex 的证明。它将也返回 `MutexGuard`,因为它要确保在返回之前,再次锁定 mutex。 -正如我们之前概述的那样,该方法将首先检查 counter 当前的值,然后再释放 mutex。释放 mutex 之后,如果 counter 仍未改变,它将继续等待,以确保我们不会失去任意信号。一下是作为代码的样子: +正如我们之前概述的那样,该方法将首先检查 counter 当前的值,然后再解锁 mutex。解锁 mutex 之后,如果 counter 仍未改变,它将继续等待,以确保我们不会失去任意信号。一下是作为代码的样子: ```rust pub fn wait<'a, T>(&self, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> { @@ -401,15 +401,15 @@ impl Condvar { 在我们成功的完成我们的条件变量之前,让我们开始考虑一下内存排序。 -当 mutex 锁定时,没有其它线程可以改变受保护的 mutex 数据。因此,我们并不需要担心来自我们释放 mutex 之前的通知,因为只要我们保持 mutex 锁定,数据就不会发生任何变化,这会让我们改变关于想要睡眠和等待的想法。 +当 mutex 锁定时,没有其它线程可以改变受保护的 mutex 数据。因此,我们并不需要担心来自我们解锁 mutex 之前的通知,因为只要我们保持 mutex 锁定,数据就不会发生任何变化,这会让我们改变关于想要睡眠和等待的想法。 -我们唯一感兴趣的情况是,我们释放 mutex 后,另一个线程出现并且锁定 mutex,改变受保护的数据,并且向我们发出信号(希望在释放 mutex 之后)。 +我们唯一感兴趣的情况是,我们释放 mutex 后,另一个线程出现并且锁定 mutex,改变受保护的数据,并且向我们发出信号(希望在解锁 mutex 之后)。 -在这种情况下,在 `Condvar::wait()` 释放 mutex 和在通知线程中锁定 mutex 之间有一个 happens-before 关系。该关系是确保我们的 Relaxed 加载(在解锁之前发生)会观察到通知的 Relaxed 加 1 操作(在锁定之后发生)之前的值。 +在这种情况下,在 `Condvar::wait()` 解锁 mutex 和在通知线程中锁定 mutex 之间有一个 happens-before 关系。该关系是确保我们的 Relaxed 加载(在解锁之前发生)会观察到通知的 Relaxed 加 1 操作(在锁定之后发生)之前的值。 我们并不知道 `wait()` 操作是否会在加 1 之前或者之后看到值,因为此时没有任何东西可以保证排序。然而,这并不重要,因为 `wait()` 在相应的唤醒操作中具有原子性行为。要么它看见新值,在这种情况下,它根本不会进入睡眠,或者它看见旧值,在这种情况下,它会进入睡眠,并由来自通知中相应的 `wake_one()` 或者 `wake_all()` 唤醒。 -图 9-2 展示了操作和 happens-before 关系,在这种情况下,一个线程使用 `Condvar::wait()` 等待一些受 mutex 保护的数据更改,并由第二个线程唤醒,该线程修改数据并且调用 `Condvar::wake_one()`。注意,由于释放和锁定操作,第一次加载操作能够保证观察到递增之前到值。 +图 9-2 展示了操作和 happens-before 关系,在这种情况下,一个线程使用 `Condvar::wait()` 等待一些受 mutex 保护的数据更改,并由第二个线程唤醒,该线程修改数据并且调用 `Condvar::wake_one()`。注意,由于解锁和锁定操作,第一次加载操作能够保证观察到递增之前到值。 ![ ](./picture/raal_0902.png) *图 9-2。一个线程使用 `Condvar::wait()` 被另一个使用 `Condvar::notify_one()` 的线程唤醒的操作和 happens-before 的关系。* @@ -533,7 +533,7 @@ impl Condvar { 我们引入的一个新的潜在风险是,通知方法在 num_waiters 中观察到 0,跳过了它的唤醒操作,实际上却有一个线程需要唤醒。这种情况可能当通知方法在递增操作之前或者递减操作之后观察到值时发生。 -就像从 counter 中的 relaxed 加载操作一样,事实上,在递增 num_waiters 时,等待者将仍然持有 mutex,这确保了在释放 mutex 之后发生的任何 num_waiters 加载操作都不会看到它被递增之前的值。 +就像从 counter 中的 relaxed 加载操作一样,事实上,在递增 num_waiters 时,等待者将仍然持有 mutex,这确保了在解锁 mutex 之后发生的任何 num_waiters 加载操作都不会看到它被递增之前的值。 我们也不需要担心通知线程观察到递减值“太快”,因为一旦执行递减操作,或许在虚假唤醒之后,等待线程不再需要被唤醒。 @@ -545,7 +545,7 @@ impl Condvar { 底层 `wait()` 操作偶尔会虚假唤醒是很罕见的,但是我们的条件变量实现很容易使得 `notify_one()` 导致不止一个线程去停止等待。如果一个线程正在进入睡眠的过程,刚刚加载了 counter 的值,但是仍然没有进入睡眠,那么调用 `notify_one()` 将由于更新的 counter 从而阻止线程进入睡眠状态,但也会因为后续的 `wake_one()` 操作导致第二个线程唤醒。这两个线程将先后竞争 mutex,浪费宝贵的处理器时间。 -这听起来像是一个罕见的现象,但是由于 mutex 最终如何同步线程是未知的,这实际上很容易发生。在条件变量上调用 `notify_one()` 的线程最有可能在此之前立即锁定和释放 mutex,以改变等待线程正在等待的数据的某些内容。这意味着,一旦 `Condvar::wait()` 方法释放了 mutex,那就有可能立刻解除了正在等待 mutex 的通知线程的阻塞。此刻,这两个线程正在竞争:等待线程正在进入睡眠,通知线程正在锁定和释放 mutex 并且通知条件变量。如果通知线程赢得竞争,等待线程将由于 counter 递增而不会进入睡眠,但是通知线程仍然调用 `wake_one()`。这正是上面描述的问题情况,它可能会不必要地唤醒一个额外线程。 +这听起来像是一个罕见的现象,但是由于 mutex 最终如何同步线程是未知的,这实际上很容易发生。在条件变量上调用 `notify_one()` 的线程最有可能在此之前立即锁定和解锁 mutex,以改变等待线程正在等待的数据的某些内容。这意味着,一旦 `Condvar::wait()` 方法解锁了 mutex,那就有可能立刻解除了正在等待 mutex 的通知线程的阻塞。此刻,这两个线程正在竞争:等待线程正在进入睡眠,通知线程正在锁定和解锁 mutex 并且通知条件变量。如果通知线程赢得竞争,等待线程将由于 counter 递增而不会进入睡眠,但是通知线程仍然调用 `wake_one()`。这正是上面描述的问题情况,它可能会不必要地唤醒一个额外线程。 一个相对简单的解决方案是跟踪允许唤醒的线程数量(即从 `Condvar::wait()` 返回)。`notify_one()` 方法会将其增加 1,并且如果它不是 0,等待方法会试图将其减少 1。如果 counter 是 0,它可以进入(返回)睡眠状态,而不是试图重新锁定 mutex 并且返回。(通知添加另一个专门用于 `notify_all` 的计数器来通知所有线程来完成,该 counter 永远不会减少。) @@ -571,7 +571,7 @@ impl Condvar { 问题是,在唤醒后,所有这些线程都将立即尝试锁定相同的 mutex。更可能地是,仅有一个线程将成功,并且所有其它线程都将回到睡眠状态。很多线程都急于宣称相同资源的资源浪费问题被称为惊群问题。 - 认为 Condvar::notify_all() 是从根本上不值得优化的反模式不是没有原因的。条件变量的目的是去释放 mutex 并且当接受通知时重新锁定它,因此也许一次通知多个线程从来不是任何好主意。 + 认为 Condvar::notify_all() 是从根本上不值得优化的反模式不是没有原因的。条件变量的目的是去解锁 mutex 并且当接受通知时重新锁定它,因此也许一次通知多个线程从来不是任何好主意。 甚至,如果我们想针对这种情况进行优化,我们可以在像 futex 这种支持重新排队操作的操作系统上,例如在 Linux 山的 FUTEX_REQUEUE(参见第八章“Futex 操作”) @@ -579,7 +579,7 @@ impl Condvar { 重新排队一个等待线程不会唤醒它。时上升,线程甚至不知道自己已经在重新排队。不幸地是,这可能导致一些非常细微的陷阱。 - 例如,还记得 3 个状态的 mutex 总是在唤醒后必须锁定正确的状态(“有着等待线程的锁定”),以确保其它等待线程不会被遗忘?这意味着我们应该不在我们的 `Condvar::wait()` 实现中使用常规的 mutex 方法,这可能将 mutex 设置到一个错误的状态。 + 例如,还记得 3 个状态的 mutex 总是在唤醒后必须锁定正确的状态(“有着等待线程的锁定”),以确保其它等待线程不会被遗忘?这意味着我们应该不在我们的 Condvar::wait() 实现中使用常规的 mutex 方法,这可能将 mutex 设置到一个错误的状态。 一个重新排队的条件变量实现需要存储等待线程使用的 mutex 的指针。否则,通知线程将不知道等待线程重新排队到哪个原子变量(互斥体状态)。这就是为什么条件变量通常允许两个线程去等待不同的 mutex。尽管许多条件变量的实现并未利用重新排队,但未来版本可能利用此功能的可能性是有用的。 @@ -666,7 +666,7 @@ impl Deref for ReadGuard<'_, T> { } ``` -既然,我们已经摆出了样板代码,那让我们谈谈有趣的部分:锁定和释放。 +既然,我们已经摆出了样板代码,那让我们谈谈有趣的部分:锁定和解锁。 为了读取锁定我们的 `RwLock`,我们必须将 state 递增,但是前提它必须还没有写锁定。我们将使用一个 `compare-and-exchange` 循环([第二章“compare-and-exchange操作”](./2_Atomics.md#compare-and-exchange-操作))去做这些。如果 state 是 `u32::MAX`,这意味着 RwLock 是写锁,我们将使用一个 `wait()` 操作去睡眠并且稍后重试。 @@ -707,7 +707,7 @@ impl Deref for ReadGuard<'_, T> { 注意,锁定的 `RwLock` 确切 state 值是如何变化的,但是 `wait()` 操作希望我们给它一个确切的值去与 state 比较。这是为什么我们将来自 `compare-and-exchange` 操作的返回值用于 `wait()` 操作。 -释放 reader 涉及到递减状态。最终释放 RwLock 的 reader,会将 state 从 1 改变到 0,负责唤醒等待的 writer(如果有的话)。 +解锁 reader 涉及到递减状态。最终解锁 RwLock 的 reader,会将 state 从 1 改变到 0,负责唤醒等待的 writer(如果有的话)。 仅唤醒一个线程就足够了,因为我们知道目前没有任何正在等待的 reader。reader 根本没有理由正在等待一个读锁定的 RwLock。 @@ -722,7 +722,7 @@ impl Drop for ReadGuard<'_, T> { } ``` -writer 必须重设 state 到 0 以释放,之后它应该唤醒一个等待的 writer 或者所有正在等待的 writer。 +writer 必须重设 state 到 0 以解锁,之后它应该唤醒一个等待的 writer 或者所有正在等待的 writer。 我们并不知道 reader 或者 writer 正在等待,我们也没有办法去唤醒一个 writer 或者只唤醒 reader。所以,我们只需要唤醒所有的线程: @@ -744,7 +744,7 @@ impl Drop for WriteGuard<'_, T> { 我们实现的一个问题是写锁可能导致意外地忙碌循环。 -如果我们有一个很多 reader 重复锁定和释放的 RwLock,那么锁定状态可能会持续变化,重复上下波动。对于我们的 `write` 方法,这导致了在 `compare-and-exchange` 操作和随后的 `wait()` 操作之间发生锁定状态的变化可能很大,尤其 `wait()` 操作(相对缓慢地)作为系统调用直接实现。这意味着 `wait()` 操作将立即返回,即使锁从未释放;它只是 reader 数量与预期的不同。 +如果我们有一个很多 reader 重复锁定和解锁的 RwLock,那么锁定状态可能会持续变化,重复上下波动。对于我们的 `write` 方法,这导致了在 `compare-and-exchange` 操作和随后的 `wait()` 操作之间发生锁定状态的变化可能很大,尤其 `wait()` 操作(相对缓慢地)作为系统调用直接实现。这意味着 `wait()` 操作将立即返回,即使锁从未解锁;它只是 reader 数量与预期的不同。 解决方案是使用一个不同的 AtomicU32 让 waiter 去等待,并且仅有在我们真正想唤醒 writer 时,才改变原子的值。 @@ -790,7 +790,7 @@ impl RwLock { } ``` -writer_wake_counter 的 `Acquire` 加载操作将与 `Release` 递增操作形成一个 happens-before 关系,该操作在释放状态后立即执行,然后唤醒等待的 writer: +writer_wake_counter 的 `Acquire` 加载操作将与 `Release` 递增操作形成一个 happens-before 关系,该操作在解锁状态后立即执行,然后唤醒等待的 writer: ```rust impl Drop for ReadGuard<'_, T> { @@ -805,7 +805,7 @@ impl Drop for ReadGuard<'_, T> { happens-before 关系确保 write 方法不能观察到递增的 writer_wake_counter 值,而之后仍然看到尚未减少的状态值。否则,写锁定的线程可能会得出 `RwLock` 仍然被锁定,而错过唤醒通知的结论。 -正如之前的一样,写释放应该唤醒一个 writer 或者所有等待的 reader。由于我们仍然不知道是否有 writer 或者 reader 正在等待,我们不得不唤醒一个等待的 writer(通过 wake_one)和所有等待的 reader(使用 wake_all): +正如之前的一样,写解锁应该唤醒一个 writer 或者所有等待的 reader。由于我们仍然不知道是否有 writer 或者 reader 正在等待,我们不得不唤醒一个等待的 writer(通过 wake_one)和所有等待的 reader(使用 wake_all): ```rust impl Drop for WriteGuard<'_, T> { @@ -873,7 +873,7 @@ pub struct RwLock { } ``` -我们的 write 方法必须经历更大的改变。我们将使用一个 `compare-and-exchange` 循环,就像我们上面的 read 方法那样。如果 state 是 0 或者 1,这意味着 RwLock 是释放的,我们将试图去改变改变状态到 `u32::MAX` 以写锁定它。否则,我们将不得不等待。然而,在这样做之前,我们需要确保 state 是奇数,以停止新的 reader 获取锁。在确保 state 是奇数后,我们等待 `writer_wake_counter` 变量,同时需要确保锁在此期间一直没有释放。 +我们的 write 方法必须经历更大的改变。我们将使用一个 `compare-and-exchange` 循环,就像我们上面的 read 方法那样。如果 state 是 0 或者 1,这意味着 RwLock 是解锁的,我们将试图去改变改变状态到 `u32::MAX` 以写锁定它。否则,我们将不得不等待。然而,在这样做之前,我们需要确保 state 是奇数,以停止新的 reader 获取锁。在确保 state 是奇数后,我们等待 `writer_wake_counter` 变量,同时需要确保锁在此期间一直没有解锁。 在代码中,这看起来像: @@ -910,7 +910,7 @@ pub struct RwLock { } ``` -因为我们现在跟踪是否有任意等待的 writer,读释放现在可以在不需要的时候跳过 `wake_one()` 调用: +因为我们现在跟踪是否有任意等待的 writer,读解锁现在可以在不需要的时候跳过 `wake_one()` 调用: ```rust impl Drop for ReadGuard<'_, T> { @@ -927,7 +927,7 @@ impl Drop for ReadGuard<'_, T> { } ``` -当写锁定(state 是 `u32::MAX`)时,我们并不跟踪任何关于是否有线程正在等待的的信息。所以,我们没有用于用于写释放的的新信息,这些将保持不变: +当写锁定(state 是 `u32::MAX`)时,我们并不跟踪任何关于是否有线程正在等待的的信息。所以,我们没有用于用于写解锁的的新信息,这些将保持不变: ```rust impl Drop for WriteGuard<'_, T> { @@ -940,9 +940,9 @@ impl Drop for WriteGuard<'_, T> { } ``` -对于针对“频繁读和频繁写”用例进行优化的读写锁,这是完全可以接受的,因为写锁定(并且因此写释放)很少发生。 +对于针对“频繁读和频繁写”用例进行优化的读写锁,这是完全可以接受的,因为写锁定(并且因此写解锁)很少发生。 -然而,对于更普遍目的的读写锁定,这绝对是值得进一步优化的,这使写锁定和释放的性能接近于高效的 3 个状态的互斥锁性能。这对读者来说是一个有趣的练习。 +然而,对于更普遍目的的读写锁定,这绝对是值得进一步优化的,这使写锁定和解锁的性能接近于高效的 3 个状态的互斥锁性能。这对读者来说是一个有趣的练习。 ## 总结 @@ -950,7 +950,7 @@ impl Drop for WriteGuard<'_, T> { * 一个最小的实现仅需要两个状态,像我们来自[第四章](./4_Building_Our_Own_Spin_Lock.md)的 `SpinLock`。 * 一个更有效的 mutex 追踪是否有任何的等待线程,所以它可以避免一个不需要的唤醒操作。 * 在进行睡眠之前自旋可能对一些用例是有益的,但这很大程度取决于情况、操作系统和硬件。 -* 一个最小的条件变量的实现仅需要一个通知 counter,`Condvar::wait()` 将不得不在释放 mutex 之前和之后检查。 +* 一个最小的条件变量的实现仅需要一个通知 counter,`Condvar::wait()` 将不得不在解锁 mutex 之前和之后检查。 * 条件变量可能跟踪等待线程的数量,以避免不需要的唤醒操作。 * 避免从 Condvar::wait 虚假唤醒可能很棘手,需要额外的内部管理。 * 一个最小的读写锁仅需要一个原子计数作为状态。 diff --git a/README.md b/README.md index 3d8a010..fadb3f8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# Rust Atomics and Locks - ## [第一章:Rust 并发基础](./1_Basic_of_Rust_Concurrency.md) * [Rust 中的线程](./1_Basic_of_Rust_Concurrency.md#rust-中的线程) @@ -44,9 +42,9 @@ * [产生和加入](./3_Memory_Ordering.md#产生和加入) * [Relaxed 排序](./3_Memory_Ordering.md#relaxed-排序) * [Release 和 Acquire 排序](./3_Memory_Ordering.md#release-和-acquire-排序) - * [示例:「锁」](./3_Memory_Ordering.md#示例锁) + * [示例:锁定](./3_Memory_Ordering.md#示例锁定) * [示例:使用间接的方式惰性初始化](./3_Memory_Ordering.md#示例使用间接的方式惰性初始化) -* [消费排序](./3_Memory_Ordering.md#消费排序) +* [Consume 排序](./3_Memory_Ordering.md#consume-排序) * [顺序一致性排序](./3_Memory_Ordering.md#顺序一致性排序) * [屏障(Fence)](./3_Memory_Ordering.md#屏障fence2) * [常见的误解](./3_Memory_Ordering.md#常见的误解) diff --git a/assets/css/style.scss b/assets/css/style.scss index 3b26d07..6c88a3b 100644 --- a/assets/css/style.scss +++ b/assets/css/style.scss @@ -15,4 +15,9 @@ background: #462e54; } + .markdown-body code { + background: rgb(31, 91, 62); + color: white; + } + } \ No newline at end of file diff --git a/picture/raal_0301.png b/picture/raal_0301.png new file mode 100644 index 0000000..ecc847e Binary files /dev/null and b/picture/raal_0301.png differ diff --git a/picture/raal_0302.png b/picture/raal_0302.png new file mode 100644 index 0000000..ccf0a2e Binary files /dev/null and b/picture/raal_0302.png differ diff --git a/picture/raal_0303.png b/picture/raal_0303.png new file mode 100644 index 0000000..da9c2d1 Binary files /dev/null and b/picture/raal_0303.png differ diff --git a/picture/raal_0304.png b/picture/raal_0304.png new file mode 100644 index 0000000..18a89ef Binary files /dev/null and b/picture/raal_0304.png differ diff --git a/picture/raal_0305.png b/picture/raal_0305.png new file mode 100644 index 0000000..d8a78f9 Binary files /dev/null and b/picture/raal_0305.png differ