启动锁中毒调查

2020 年 12 月 11 日 · Ashley Mannix 代表 Libs 团队

Libs 团队正在研究如何改进 std::sync 模块,可能将其拆分为新的模块,并在此过程中对 API 进行一些更改。我们正在考虑的 API 更改之一是非中毒实现 MutexRwLock。为了找到最佳前进方向,我们正在进行一项调查,以更清楚地了解标准锁在现实世界中的使用情况。

调查是一个 Google 表格。 您可以在此处填写

这项调查的目的是什么?

这项调查旨在回答以下问题

  • 何时在 MutexRwLock 上故意使用中毒。
  • MutexRwLock(及其保护类型)是否出现在库的公共 API 中。
  • 从中毒 MutexRwLock 锁切换到非中毒锁(例如来自 antidoteparking_lot)的摩擦程度。

这些信息将用于告知 RFC,该 RFC 将制定在标准库中使用非中毒锁的路径。它也可能为我们提供一个起点,让我们研究与 UnwindSafeRefUnwindSafe 特性相关的恐慌安全问题。

这项调查针对谁?

如果您编写使用锁的代码,那么这项调查适合您。这包括标准库的 MutexRwLock,以及来自 crates.io 的锁,例如 antidoteparking_lottokio::sync

那么中毒到底是什么呢?

假设您有一个 Account 可以更新其余额

impl Account {
    pub fn update_balance(&mut self, change: i32) {
        self.balance += change;
        self.changes.push(change);
    }
}

假设我们还有 balance == changes.sum() 的不变式。我们将称之为余额不变式。因此,在与 Account 交互的任何时候,您始终可以依赖其 balance 是其 changes 的总和,这得益于余额不变式。

在我们的 update_balance 方法中有一个点,在那里余额不变式没有得到维护

impl Account {
    pub fn update_balance(&mut self, change: i32) {
        self.balance += change;
//      self.balance != self.changes.sum()
        self.changes.push(change);
    }
}

这似乎没问题,因为我们正在一个方法的中间,该方法对我们的 Account 具有独占访问权,并且在我们返回时一切都会恢复正常。没有 Result? 可见,因此我们知道在恢复余额不变式之前,不可能出现提前返回。或者我们这样认为。

如果 self.changes.push 没有正常返回怎么办?如果它在没有实际执行任何操作的情况下就恐慌了怎么办?然后,我们将在没有恢复余额不变式的情况下提前从 update_balance 返回。这似乎也没问题,因为恐慌将开始展开调用它的线程,不会留下它拥有的任何数据的痕迹。忽略 Drop 特性,没有数据就意味着没有破坏的不变式。问题解决了,对吧?

如果我们的 Account 不是由恐慌的线程拥有的怎么办?如果它作为 Arc<Mutex<Account>> 与其他线程共享怎么办?展开一个线程不会保护其他可能仍然访问 Account 的线程,而且它们不会知道它现在无效。

这就是中毒发挥作用的地方。标准库中的 MutexRwLock 类型使用一种策略,使恐慌(以及由此产生的可能破坏的不变式)变得可观察。锁的下一个使用者(例如另一个没有展开的线程)可以在那时决定如何处理它。这是通过在锁本身中存储一个开关来完成的,当恐慌导致线程通过其保护程序展开时,该开关会被翻转。一旦该开关被翻转,锁就被认为是中毒的,并且下一次尝试获取它将收到错误而不是保护程序。

处理中毒锁的标准方法是将恐慌传播到当前线程,方法是解开它返回的错误

let mut guard = shared.lock().unwrap();

这样,没有人能够观察到我们共享的 Account 上可能被违反的余额不变式。

听起来很棒!那么我们为什么要删除它呢?

锁中毒有什么问题?

中毒本身没有问题。它是一种处理可能留下不可用状态的故障的优秀模式。我们真正要问的问题是,它是否应该由标准锁使用,即 std::sync::Mutexstd::sync::RwLock。我们想知道,实现中毒是否是标准锁的职责。为了避免任何混淆,我们将中毒模式与标准锁的 API 区分开来,将前者称为中毒,后者称为锁中毒。我们只是在谈论锁中毒。

在上一节中,我们以一种方式激发了中毒,即保护我们免受可能被破坏的不变式。锁中毒实际上并不是以您可能认为的方式实现此目的的工具。通常,中毒的锁无法判断任何不变式是否实际上被破坏。它假设锁是共享的,因此很可能比可以访问它的任何单个线程存活更久。它还假设,如果恐慌留下了任何数据,那么它更有可能处于意外状态,因为恐慌不是 Rust 中正常控制流的一部分。在恐慌之后,一切都可能正常,但标准锁无法保证这一点。由于没有保证,因此存在一个逃生舱口。我们始终可以仍然访问由中毒锁保护的状态

let mut guard = shared.lock().unwrap_or_else(|err| err.into_inner());

所有 Rust 代码都需要在存在恐慌的情况下保持不受任何可能的未定义行为的影响,因此忽略恐慌始终是安全的。Rust 不会尝试保证所有安全代码都免受逻辑错误的影响,因此不会严格地将不会导致未定义行为的破坏的不变式视为不安全。由于忽略锁中毒也始终是安全的,因此它并没有真正为您提供一个可靠的工具来保护状态免受恐慌的影响。您始终可以忽略它。

因此,锁中毒不会为您提供一个工具来保证在存在恐慌的情况下安全。它为您提供的是一种将这些恐慌传播到其他线程的方法。实现此功能所需的机制会增加使用标准锁的成本。在必须调用 .lock().unwrap() 时存在一个人体工程学成本,并且在必须实际跟踪恐慌状态时存在一个运行时成本。

使用标准锁,无论您是否需要,您都要支付这些成本。这通常不是标准库中 API 的工作方式。相反,您将成本组合在一起,这样您只为需要的东西付费。标准锁的职责应该是同步访问传播恐慌吗?我们不太确定。如果不是,那么我们应该怎么办?这就是调查的意义所在。我们想更好地了解您在项目中如何使用锁和中毒,以帮助决定如何处理锁中毒。 您可以在此处填写