diff --git a/9_Building_Our_Own_Locks.md b/9_Building_Our_Own_Locks.md index 82fbd64..9accbc9 100644 --- a/9_Building_Our_Own_Locks.md +++ b/9_Building_Our_Own_Locks.md @@ -42,7 +42,7 @@ (英文版本) -在构建 `Mutex` 时,我们将接收来自[第四章](./4_Building_Our_Own_Spin_Lock.md)的 `SpinLock` 类型。在不涉及阻塞的部分,例如守卫类型的设计,将保持不变。 +在构建 `Mutex` 时,我们将参考来自[第四章](./4_Building_Our_Own_Spin_Lock.md)的 `SpinLock` 类型。在不涉及阻塞的部分,例如守卫类型的设计,将保持不变。 让我们从类型定义开始。与自旋锁相比,我们必须做一个更改:而不是将 `AtomicBool` 设置为 `false` 或者 `true`,我们将使用 `AtomicU32`,将其设为 0 或者 1,所以我们可以将其与原子等待和唤醒函数一起使用。 @@ -203,23 +203,23 @@ impl Drop for MutexGuard<'_, T> { 图 9-1 展示了两个线程同时尝试锁定我们的 mutex 操作的情况下的 happens-before 关系。首先线程通过改变 state 从 0 到 1 锁定 mutex。此时,第二个线程将无法获取锁,并且在改变 state 从 1 到 2 后进入睡眠。当第一个线程解锁 mutex 时,它会交换 state 回 0。因为是 2,表示一个等待线程,它调用 `wake_one()` 来唤醒第二个线程。注意,我们不能依赖于唤醒和等待操作之间的任何 happens-before 关系。虽然唤醒操作可能是负责唤醒等待线程的操作,但 happens-before 关系是通过 `acquire` swap 操作建立的,观察 `release` swap 操作存储的值。 ![ ](https://github.com/rustcc/Rust_Atomics_and_Locks/raw/main/picture/raal_0901.png) -*图 9-1。两个线程之间 happens-before 的关系同时试图锁定我们的 mutex。* +*图 9-1。同时试图锁定我们的 mutex 的两个线程之间 happens-before 的关系。* ### 进一步优化 (英文版本) -在这一点上,我们似乎没有什么可以进一步优化的了。在未考虑的情况下,我们执行零系统调用,并且剩下的只是两个非常简单的原子操作。 +在这一点上,我们似乎没有什么可以进一步优化的了。在无竞争的情况下,我们执行零系统调用,并且剩下的只是两个非常简单的原子操作。 -避免等待和唤醒操作的唯一方式是回到我们的旋转锁实现。尽管自旋通常是非常低效的,但它至少避免了系统调用的潜在开销。唯一能提高自旋效率的情况是等待时间很短的情况下。 +避免等待和唤醒操作的唯一方式是回到我们的自旋锁实现。尽管自旋通常是非常低效的,但它至少避免了系统调用的潜在开销。唯一能提高自旋效率的情况是仅等待很短的时间。 -对于锁定一个 mutex,自选等待仅在以下情况有效:当前持有 mutex 锁的线程和想要锁定 mutex 的线程在不同的 CPU 核心上并行运行,并且当前线程只持有锁很短的时间。然而,这是一个非常常见的场景。 +对于锁定一个 mutex,自旋等待仅在以下情况有效:当前持有 mutex 锁的线程和想要锁定 mutex 的线程在不同的 CPU 核心上并行运行,并且当前线程只持有锁很短的时间。然而,这是一个非常常见的场景。 我们可以尝试将两种方法的优点结合起来,在调用 `wait()` 之前进行非常短暂的自旋。这样,如果锁被很快释放,我们根本不需要调用 `wait()`,但我们仍然避免了消耗其它线程更好利用的不合理的处理器时间。 实现这个仅需要改变我们的 lock 函数。 -为了在未考虑的情况下尽可能保持性能,我们将在 lock 函数开始时保留原始的「比较并交换」操作。我们将使自旋等待作为一个单独的功能。 +为了在无竞争的情况下尽可能保持性能,我们将在 lock 函数开始时保留原始的「比较并交换」操作。我们将使自旋等待作为一个单独的函数。 ```rust impl Mutex { @@ -262,20 +262,20 @@ fn lock_contended(state: &AtomicU32) { } ``` -首先,我们自旋到 100 次,这是我们像在[第四章](./4_Building_Our_Own_Spin_Lock.md)时使用 *`spin loop` 提示*。只要 mutex 没被锁定,并且没有等待者,我们就会自旋。如果另一个线程已经在等待,这意味着它放弃了自旋,因为它花费的时间太长,这可能表明自旋对这个线程也不是很有用。 +首先,我们自旋最多 100 次,这是我们像在[第四章](./4_Building_Our_Own_Spin_Lock.md)时使用 *`spin loop` 提示*。只要 mutex 没被锁定,并且没有等待者,我们就会自旋。如果另一个线程已经在等待,这意味着它放弃了自旋,因为它花费的时间太长,这可能表明自旋对这个线程也不是很有用。 -> 一百个周期的自旋时间大多数是任意选择的。迭代锁花费的时间和系统调用期间(我们试图避免)很大程度上取决于平台。大范围的基础测试可以帮助我们选择一个正确的数字,但是不幸地是,没有一个准确的回答。 +> 一百个周期的自旋时间大多数是任意选择的。迭代花费的时间和系统调用的时间长短(我们试图避免)很大程度上取决于平台。大范围的基准测试可以帮助我们选择一个正确的数字,但是不幸地是,没有一个准确的答案。 > ->Rust 标准库的 `std::sync::Mutex` 的 Linux 实现,至少是 Rust 1.66.0,使用至少是 100 次的自旋计数。 +>Rust 标准库的 `std::sync::Mutex` 的 Linux 实现(至少在 Rust 1.66.0 中)使用的是 100 次的自旋计数。 -锁定状态改变后,我们再次尝试将其设定为 1 来锁定它,然后我们再放弃并且开始自旋等待。正如我们之前讨论的,我们调用 `wait()` 后,我们不能在通过设定它的 state 到 1 来锁定 mutex,因为这可能导致其它的等待者被忘记。 +锁定状态改变后,我们再次尝试将其设定为 1 来锁定它,然后我们再放弃并且开始自旋等待。正如我们之前讨论的,我们调用 `wait()` 后,我们不能再通过设定它的 state 到 1 来锁定 mutex,因为这可能导致其它的等待者被忘记。

Cold 和 Inline 属性

- -

你可以增加 #[cold] 属性到 lock_contented 函数定义,以帮助编译器理解在常见(未考虑)情况下不调用这个函数,这对 lock 方法的优化有帮助。

+

你可以增加 #[cold] 属性到 lock_contented 函数定义,以帮助编译器理解在常见(无竞争)情况下不会调用这个函数,这有助lock 方法的优化。

+ +

此外,你也可以增加 #[inline] 属性到 Mutex 和 MutexGuard 方法,以通知编译器将其内联可能是一个好主意:将生成的指令将其放置在调用方法的地方。一般来说,是否能提高性能很难说,但对于这些非常小的函数,通常可以。

-

额外地,你也可以增加 #[inline] 属性到 Mutex 和 MutexGuard 方法,以通知编译器将其内联可能是一个好主意:将生成的指令将其放置在调用方法的地方。一般来说,是否能提高性能很难说,但对于这些非常小的功能,通常如此。

### 基准测试 @@ -284,11 +284,11 @@ fn lock_contended(state: &AtomicU32) { 测试 mutex 实现的性能是很难的。写基准测试和得到一些数字很容易,但是很难去得到一些有意义的数字。 -优化 mutex 的实现以在特定基准测试表现良好是相对容易的,但这并没有很有用。毕竟,关键是去做一些在真实世界良好的东西,而不仅是测试程序。 +优化 mutex 的实现以在特定基准测试表现良好是相对容易的,但这并没有很有用。毕竟,关键是去做一些在真实世界表现良好的东西,而不仅是在测试程序中。 -我们将试图去写两个简单的基础测试,表明我们的优化至少对一些用例产生了一些积极影响,但请注意,任何结论在不同场景都不一定成立。 +我们将试图去写两个简单的基准测试,表明我们的优化至少对一些用例产生了一些积极影响,但请注意,任何结论在不同场景都不一定成立。 -在我们的第一次测试中,我们将创建一个 Mutex 并锁定和解锁它几百万次,所有都在同一线程上,以测量它花费总的时间。这是对琐碎未讨论场景的测试,其中从来没有任何需要唤醒的线程。希望这将向我们展示 2 个 state 和 3 个 state 版本的差异。 +在我们的第一次测试中,我们将创建一个 Mutex 并在同一线程上锁定和解锁它几百万次,测量它所需的总时间。这是对简单无竞争场景的测试,其中永远没有任何需要唤醒的线程。希望这将向我们展示两状态和三状态版本的显著差异。 ```rust fn main() { @@ -305,9 +305,9 @@ fn main() { > 我们使用 `std::hint::black_box`(像我们在[第七章“对性能的影响”](./7_Understanding_the_Processor.md#对性能的影响))去强制编译器假设有更多的代码去访问 mutex,阻止它优化循环或者锁定操作。 -结果因硬件和操作系统不同而不同。在一台配备最新 AMD 处理器的特定 Linux 计算机上尝试,对于我们为优化的 2 个 state 的 mutex 花费时间大约 400ms,对于我们优化过后的 3 个 state 的 mutex 大约 40ms。一个因素获得十倍的性能提升!在另一个有着老式 Intel 处理器 Linux 计算机中,差异甚至更大:大约 1800ms 比上 60ms。这证实了,第三个状态的加入确实是一个非常大的优化。 +结果将因硬件和操作系统不同而不同。在一台配备最新 AMD 处理器的特定 Linux 计算机上尝试,对于我们为优化的两状态的 mutex 花费时间大约 400ms,对于我们优化过后的三状态的 mutex 大约 40ms。一个因素获得十倍的性能提升!在另一个有着老式 Intel 处理器 Linux 计算机中,差异甚至更大:大约 1800ms 比上 60ms。这证实了,第三个状态的加入确实是一个非常大的优化。 -然而,在 macOS 上运行,这回产生一个完全不同的结果:这两个版本大约都是 50ms,这展示了非常高的平台依赖。 +然而,在 macOS 上运行,这会产生一个完全不同的结果:这两个版本大约都是 50ms,这展示了非常高的平台依赖性。 事实证明,我们在 macOS 上使用的 libc++ 的 `std::atomic::wake()` 实现,已经进行了自己的内部管理,独立于内核,以避免不必要的系统调用。Windows 上的 `WakeByAddressSingle()` 也是如此。 @@ -315,7 +315,7 @@ fn main() { 为了看看我们的自旋优化是否有任何积极的影响,我们需要一个不同的基准测试:一个有着大量竞争的测试,多个线程反复尝试去锁定一个已经上锁的 mutex。 -让我们尝试一个场景,四个线程都尝试锁定和解锁 mutex 上万次: +让我们尝试一个场景,四个线程都尝试锁定和解锁 mutex 上百万次: ```rust fn main() { @@ -348,11 +348,11 @@ fn main() { 让我们做一些更有趣的事情:实现一个条件变量。 -正如我们在[第一章“条件变量”](./1_Basic_of_Rust_Concurrency.md#条件变量)中见到的,条件变量与 mutex 一起使用,以等待受 mutex 保护的数据与某些条件匹配。它有一个等待方法解锁 mutex,等待一个信号,并再次锁定相同的 mutex。通常由其它线程发送信号,在修改 mutex 保护的数据后立即发送给一个等待的线程(通常叫做“通知一个”或“单播”)或者通知所有等待的线程(通常叫做“通知所有”或“广播”)。 +正如我们在[第一章“条件变量”](./1_Basic_of_Rust_Concurrency.md#条件变量)中见到的,条件变量与 mutex 一起使用,以等待受 mutex 保护的数据匹配某些条件。它有一个等待方法解锁 mutex,等待一个信号,并再次锁定相同的 mutex。通常由其它线程发送信号,在修改 mutex 保护的数据后立即发送给一个等待的线程(通常叫做“notify one”或“signal”)或者通知所有等待的线程(通常叫做“notify all”或“broadcast”)。 -虽然条件变量试图让等待线程保持睡眠状态,直到它发出一个信号,但等待线程可能没有相应信号的情况下被虚假地唤醒。然而,条件变量的等待操作将仍然是在返回之前重新锁定 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 以来,收到任意通知信号,它将不再睡眠。 @@ -418,22 +418,22 @@ impl Condvar { 我们唯一感兴趣的情况是,我们释放 mutex 后,另一个线程出现并且锁定 mutex,改变受保护的数据,并且向我们发出信号(希望在解锁 mutex 之后)。 -在这种情况下,在 `Condvar::wait()` 解锁 mutex 和在通知线程中锁定 mutex 之间有一个 happens-before 关系。该关系是确保我们的 Relaxed 加载(在解锁之前发生)会观察到通知的 Relaxed 加 1 操作(在锁定之后发生)之前的值。 +在这种情况下,在 `Condvar::wait()` 解锁 mutex 和在通知线程中锁定 mutex 之间有一个 happens-before 关系。该关系是确保我们的 Relaxed 加载(在解锁之前发生)会观察到通知的 Relaxed 自增操作(在锁定之后发生)之前的值。 -我们并不知道 `wait()` 操作是否会在加 1 之前或者之后看到值,因为此时没有任何东西可以保证排序。然而,这并不重要,因为 `wait()` 在相应的唤醒操作中具有原子性行为。要么它看见新值,在这种情况下,它根本不会进入睡眠,或者它看见旧值,在这种情况下,它会进入睡眠,并由来自通知中相应的 `wake_one()` 或者 `wake_all()` 唤醒。 +我们并不知道 `wait()` 操作是否会看到在自增之前还是之后的值,因为此时没有任何东西可以保证排序。然而,这并不重要,因为 `wait()` 在相应的唤醒操作中具有原子性行为。要么它看见新值,在这种情况下,它根本不会进入睡眠,或者它看见旧值,在这种情况下,它会进入睡眠,并由来自通知中相应的 `wake_one()` 或者 `wake_all()` 唤醒。 -图 9-2 展示了操作和 happens-before 关系,在这种情况下,一个线程使用 `Condvar::wait()` 等待一些受 mutex 保护的数据更改,并由第二个线程唤醒,该线程修改数据并且调用 `Condvar::wake_one()`。注意,由于解锁和锁定操作,第一次 load 操作能够保证观察到递增之前到值。 +图 9-2 展示了操作和 happens-before 关系,在这种情况下,一个线程使用 `Condvar::wait()` 等待一些受 mutex 保护的数据更改,并由第二个线程唤醒,该线程修改数据并且调用 `Condvar::wake_one()`。请注意,由于解锁和锁定操作,第一次 load 操作能够保证值递增之前观察到该值。 ![ ](https://github.com/rustcc/Rust_Atomics_and_Locks/raw/main/picture/raal_0902.png) *图 9-2。一个线程使用 `Condvar::wait()` 被另一个使用 `Condvar::notify_one()` 的线程唤醒的操作和 happens-before 的关系。* 我们应该也考虑如果 counter 溢出会发生什么。 -只要每次通知之后计数器是不同的,它的真实值就无关紧要。不幸的是,在超过 40 亿个通知之后,计数器将溢出,并以 0 重新启动,回到之前使用过的值。从技术上讲,我们的 `Condvar::wait()` 实现可能在不应该的时候进入睡眠状态:如果它正好错过了 4,292,967,296 条通知(或者任意它的倍数),它会溢出计数器,直到它之前拥有的值。 +只要每次通知之后计数器是不同的,它的真实值就无关紧要。不幸的是,在超过 40 亿个通知之后,计数器将溢出,并以 0 重新启动,回到之前使用过的值。从技术上讲,我们的 `Condvar::wait()` 实现可能在不应该的时候进入睡眠状态:如果它正好错过了 4,292,967,296 条通知(或者任意它的倍数),它会溢出计数器到它之前拥有过的值。 -认为这种情况发生的可能性可以忽略不计是完全合理的。与我们在 mutex 锁定方法所做的事不同,我们不会在这里唤醒后,重新检查 state 和重复 `wait()` 调用,所以我们仅需要关心在 counter 的 relaxed load 操作和 `wait()` 调用之间的那一刻发生溢出往返(round-trip)。如果一个线程中断太久,以至于(确切地)允许发生许多通知,那么可能已经出现大问题,并且程序已经变得没有响应。此时,人们可能会合理地争辩到:线程保持睡眠的微观额外风险不再重要。 +认为这种情况发生的可能性可以忽略不计是完全合理的。与我们在 mutex 锁定方法所做的事不同,我们不会在这里唤醒后,重新检查 state 和重复 `wait()` 调用,所以我们仅需要关心在 counter 的 relaxed load 操作和 `wait()` 调用之间的那一刻发生溢出往返(round-trip)。如果一个线程中断太久,以至于(确切地)允许发生许多通知,那么可能已经出现大问题,并且程序已经变得没有响应。此时,人们可能会合理地争辩到:线程保持睡眠的微小额外风险不再重要。 ->在支持有时间限制的 futex 式等待的平台上,可以使用几秒钟的等待操作使用超时来降低溢出的风险。发送 40 亿条通知将花费更长的时间,此时,额外的几秒钟的风险将产生非常小的影响。这完全消除了由于等待线程错误地一直待在睡眠状态而导致程序锁定的的任何风险。 +>在支持有时间限制的 futex 式等待的平台上,可以对等待操作使用几秒钟的超时来降低溢出的风险。发送 40 亿条通知将花费更长的时间,此时,额外的几秒钟的风险将产生非常小的影响。这完全消除了由于等待线程错误地一直待在睡眠状态而导致程序锁定的的任何风险。 让我们看看它是否工作! @@ -562,13 +562,13 @@ 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 永远不会**减少**。) -这种方式是有效的,但是有一个新的并且更微妙的问题:通知可能唤醒一个甚至仍没有调用 `Condvar::wait()` 的线程,包括它自身。调用 `Condvar::notify_one()` 将**增加**应该唤醒的线程数量,并且使用 `wake_one()` 去唤醒一个等待线程。然而,如果另一个(甚至相同的)线程在已经等待的线程有机会唤醒之前调用 `Condvar::wait()`,新等待的线程可以看到一个通知待处理,并通过将 counter 递减到 0 来声明它,并立即返回。正在等待的第一个线程将返回睡眠状态,因为另一个线程已经获取了一个通知。 +这种方式是有效的,但是带来一个新的、更微妙的问题:通知可能唤醒一个甚至还没有调用 `Condvar::wait()` 的线程,包括它自身。调用 `Condvar::notify_one()` 将**增加**应该唤醒的线程数量,并且使用 `wake_one()` 去唤醒一个等待线程。然而,如果在已经等待的线程有机会醒来之前,另一个(甚至相同的)线程随后调用 `Condvar::wait()`,新等待的线程可以看到一个通知待处理,并通过将 counter 递减到 0 来认领它,并立即返回。正在等待的第一个线程将返回睡眠状态,因为另一个线程已经获取了一个通知。 -根据用例,这可能完全没有问题,或者有大问题,导致一些线程从未取得进展。 +根据用例,这可能完全没有问题,也可能是一个大问题,导致一些线程永远无法取得进展。 > GNU libc 的 `pthread_cond_t` 的实现曾经受到这个问题影响。后来,经过大量关于 POSIX 规范是否允许的讨论,这个问题最后随着 2017 的 GNU libc 2.25 的发布而最终解决,这包含一个全新的条件变量实现。 @@ -590,15 +590,16 @@ impl Condvar {

认为 Condvar::notify_all() 是从根本上不值得优化的反模式不是没有原因的。条件变量的目的是去解锁 mutex 并且当接受通知时重新锁定它,因此也许一次通知多个线程从来不是任何好主意。

-

甚至,如果我们想针对这种情况进行优化,我们可以在像 futex 这种支持重新排队操作的操作系统上,例如在 Linux 上的 FUTEX_REQUEUE(参见第八章“Futex 操作”

+

即便如此,如果我们想针对这种情况进行优化,我们可以在支持类似 futex 重新排队 这种操作的操作系统上,例如在 Linux 上的 FUTEX_REQUEUE(参见第八章“Futex 操作”

-

与其唤醒许多线程,一旦它们意识到锁已经被占用,除一个线程外,其它线程都将立刻回到睡眠状态,我们可以重新排队除一个线程外的其它所有线程,以便它们的 futex 等待操作不再等待条件变量的 counter,而是开始等待 mutex 的状态。

+

与其唤醒许多线程,除一个线程外的其它线程一旦意识到锁已被占用都将立刻回到睡眠状态,我们可以将除一个线程外的其它所有线程重新排队,以便它们的 futex 等待操作不再等待条件变量的 counter,而是开始等待 mutex 的状态。

重新排队一个等待线程不会唤醒它。事实上,线程甚至不知道自己已经在重新排队。不幸地是,这可能导致一些非常细微的陷阱。

-

例如,还记得 3 个 state 的 mutex 总是在唤醒后必须锁定正确的状态(“有着等待线程的锁定”),以确保其它等待线程不会被遗忘?这意味着我们应该不在我们的 Condvar::wait() 实现中使用常规的 mutex 方法,这可能将 mutex 设置到一个错误的状态。

+

例如,还记得三状态 mutex 总是在唤醒后必须锁定到正确的状态(“有着等待线程的锁定”),以确保其它等待线程不会被遗忘?这意味着我们应该不在我们的 Condvar::wait() 实现中使用常规的 mutex 方法,这可能将 mutex 设置到一个错误的状态。

+ +

一个重新排队的条件变量实现需要存储等待线程使用的 mutex 的指针。否则,通知线程将不知道等待线程重新排队到哪个原子变量(互斥状态)。这就是为什么条件变量通常不允许两个线程去等待不同的 mutex。尽管许多条件变量的实现并未利用重新排队,但为未来版本保留利用此功能的可能性是有用的。

-

一个重新排队的条件变量实现需要存储等待线程使用的 mutex 的指针。否则,通知线程将不知道等待线程重新排队到哪个原子变量(互斥状态)。这就是为什么条件变量通常允许两个线程去等待不同的 mutex。尽管许多条件变量的实现并未利用重新排队,但未来版本可能利用此功能的可能性是有用的。

## 读写锁 @@ -627,7 +628,7 @@ pub struct RwLock { unsafe impl Sync for Rwlock where T: Send + Sync {} ``` -因为我们的 RwLock 可以两种不同的方式锁定,我们将有两个单独的锁功能,每个都有属于自己的守卫: +因为我们的 RwLock 可以两种不同的方式锁定,我们将有两个单独的锁定函数,每个都有属于自己的守卫: ```rust impl RwLock { @@ -765,7 +766,7 @@ impl Drop for WriteGuard<'_, T> { 我们实现的一个问题是写锁可能导致意外地忙碌循环。 -如果我们有一个很多 reader 重复锁定和解锁的 RwLock,那么锁定状态可能会持续变化,重复上下波动。对于我们的 `write` 方法,这导致了在「比较并交换」操作和随后的 `wait()` 操作之间发生锁定状态的变化可能很大,尤其 `wait()` 操作(相对缓慢地)作为系统调用直接实现。这意味着 `wait()` 操作将立即返回,即使锁从未解锁;它只是 reader 数量与预期的不同。 +如果我们有一个很多 reader 重复锁定和解锁的 RwLock,那么锁定状态可能会持续变化,上下快速波动。对于我们的 `write` 方法,这导致了在「比较并交换」操作和随后的 `wait()` 操作之间发生锁定状态的变化可能很大,尤其 `wait()` 操作作为(相对缓慢的)系统调用直接实现。这意味着 `wait()` 操作将立即返回,即使锁从未解锁;它只是 reader 数量与预期的不同。 解决方案是使用一个不同的 AtomicU32 让等待者去等待,并且仅有在我们真正想唤醒 writer 时,才改变原子的值。 @@ -824,7 +825,7 @@ impl Drop for ReadGuard<'_, T> { } ``` -happens-before 关系确保 write 方法不能观察到递增的 writer_wake_counter 值,而之后仍然看到尚未**减少**的状态值。否则,写锁定的线程可能会得出 `RwLock` 仍然被锁定,而错过唤醒通知的结论。 +happens-before 关系确保 write 方法不能观察到递增的 writer_wake_counter 值,而之后仍然看到尚未**减少**的状态值。否则,写锁定的线程可能认为 `RwLock` 仍然被锁定,而错过唤醒通知。 正如之前的一样,写解锁应该唤醒一个 writer 或者所有等待的 reader。由于我们仍然不知道是否有 writer 或者 reader 正在等待,我们不得不唤醒一个等待的 writer(通过 wake_one)和所有等待的 reader(使用 wake_all): @@ -839,25 +840,25 @@ impl Drop for WriteGuard<'_, T> { } ``` -> 在一些操作系统中,唤醒操作背后的操作会返回它唤醒的线程数量。它可能表示低于唤醒线程实际数量的个数(由于虚假地唤醒),但是它的返回值仍然可以用于优化。 +> 在一些操作系统中,唤醒操作背后的操作会返回它唤醒的线程数量。它可能表示低于唤醒线程实际数量的个数(由于虚假唤醒),但是它的返回值仍然可以用于优化。 > -> 在以上的 `drop` 实现中,例如,如果 `wake_one()` 操作将表示它却是能唤醒一个性能,我们可能跳过 `wake_all()` 调用。 +> 在以上的 `drop` 实现中,例如,如果 `wake_one()` 操作表明它实际唤醒了一个线程,我们可以跳过 `wake_all()` 调用。 ### 避免 writer 陷入饥饿 (英文版本) -RwLock 的一个通常用例是频繁使用 reader 的情况,但是非常少,通常仅有一个,不经常的 writer 的情况。例如,一个线程可能负责读取一些传感器输入或者定期下载许多其它线程需要使用的新数据。 +RwLock 的一个通常用例是频繁使用 reader 的情况,但是非常少(通常仅有一个)不经常的 writer 的情况。例如,一个线程可能负责读取一些传感器输入或者定期下载许多其它线程需要使用的新数据。 -在这种情况下,我们可以快速地遇到一个叫做 *writer 饥饿*的问题:一种情况是,writer 从未得到一个机会去锁定 RwLock,因为周围总是有 reader 保持 `RwLock` 读锁定。 +在这种情况下,我们很快就会遇到一个叫做 *writer 饥饿*的问题:一种情况是,writer 从未得到一个机会去锁定 RwLock,因为周围总是有 reader 保持 `RwLock` 读锁定。 一个解决方式是去防止任何新的 reader 在有 writer 时取得锁,即使 RwLock 仍然是读锁定。这样,所有新的 reader 都将等待直到轮到 writer,这确保了 reader 将获取到 writer 想要共享的最新的数据。 让我们实现这个。 -为了完成这个,我们需要跟踪是否有任意的等待 writer。为了在 state 变量中为这些信息腾出空间,我们可以将 reader 的数量乘以 2,并且有 writer 等待的情况下加 1。这意味着 6 或者 7 的 state 表示有 3 个激活的 read 锁定的情况:6 没有一个等待的 writer,7 有一个等待的 writer。 +为了完成这个,我们需要跟踪是否有任意的等待 writer。为了在 state 变量中为这些信息腾出空间,我们可以将 reader 的数量乘以 2,并且有 writer 等待的情况下加 1。这意味着 6 或者 7 的 state 都表示有 3 个激活的 read 锁定的情况:6 没有一个等待的 writer,7 有一个等待的 writer。 -如果我们保持 `u32::MAX`(这是一个奇数)保持为写锁定的状态,那么如果 state 是奇数,那么 reader 将不得不等待。但是如果 state 是偶数,reader 就可以通过递增 2 它来获取一个读锁。 +如果我们将 `u32::MAX`(这是一个奇数)保持为写锁定的状态,那么如果 state 是奇数,那么 reader 将必须等待。但是如果 state 是偶数,reader 就可以通过递增 2 它来获取一个读锁。 ```rust pub struct RwLock { @@ -873,7 +874,7 @@ pub struct RwLock { } ``` -我们必须更改我们 `read` 方法中的两个 `if` 语句,不再将 state 与 `u32::MAX` 进行比较,而是检查 state 是否是偶数还是奇数。我们也需要改变断言语句中的上界,以确保我们**增加**两个而不是一个来锁定。 +我们必须更改我们 `read` 方法中的两个 `if` 语句,不再将 state 与 `u32::MAX` 进行比较,而是检查 state 是否是偶数还是奇数。我们还需要以确保我们**增加** 2 而不是 1 来锁定。 ```rust pub fn read(&self) -> ReadGuard { @@ -965,7 +966,7 @@ impl Drop for WriteGuard<'_, T> { 对于针对“频繁读和频繁写”用例进行优化的读写锁,这是完全可以接受的,因为写锁定(并且因此写解锁)很少发生。 -然而,对于更普遍目的的读写锁定,这绝对是值得进一步优化的,这使写锁定和解锁的性能接近于高效的 3 个 state 的互斥锁性能。这对读者来说是一个有趣的练习。 +然而,对于更普遍目的的读写锁定,这绝对是值得进一步优化的,这使写锁定和解锁的性能接近于高效的三状态的互斥锁性能。这对读者来说是一个有趣的练习。 ## 总结