第 4 章 构建我们自己的自旋锁

来自 《Rust Atomics and Locks》 的翻译文章,英文原文https://marabos.nl/atomics/building-spinlock.html在新窗口打开

锁定常规互斥体(请参阅第 1 章中的“锁:互斥锁和读写锁”)将使您的线程在互斥体已被锁定时进入休眠状态。这样可以避免在等待锁被释放时浪费资源。 如果一个锁只持有很短的时间,并且锁定它的线程可以在不同的处理器内核上并行运行,那么线程反复尝试锁定它而不真正进入睡眠状态可能会更好。

自旋锁是一种互斥锁,它正是这样做的。试图锁定一个已经锁定的互斥体将导致忙循环或自旋:反复尝试,直到最终成功。这可能会浪费处理器周期,但有时会在锁定时导致较低的延迟。

提示

许多现实世界的互斥锁实现,包括某些平台上的 std::sync::Mutex ,在要求操作系统让线程进入睡眠状态之前,会短暂地表现得像自旋锁。这是一种结合两全其美的尝试,尽管这种行为是否有益完全取决于具体的用例。

在本章中,我们将构建我们自己的 SpinLock 类型,应用我们在第 2 章第 3 章中学到的知识,看看我们如何使用 Rust 的类型系统来为我们的 SpinLock 的用户提供一个安全有用的接口。

一个最小的实现

让我们从头开始实现这样的自旋锁。

最小版本非常简单,如下所示:

pub struct SpinLock {
    locked: AtomicBool,
}

我们只需要一个布尔值来指示它是否被锁定。我们使用原子布尔值,因为我们希望多个线程能够同时与其交互。

然后我们只需要一个构造函数,以及 lockunlock 方法:

impl SpinLock {
    pub const fn new() -> Self {
        Self { locked: AtomicBool::new(false) }
    }

    pub fn lock(&self) {
        while self.locked.swap(true, Acquire) {
            std::hint::spin_loop();
        }
    }

    pub fn unlock(&self) {
        self.locked.store(false, Release);
    }
}

locked 布尔值从 false 开始, lock 方法将它换成 true 并继续尝试(如果它已经是 true ), unlock 方法只是将它设置回 false

提示

除了使用交换操作,我们还可以使用比较和交换操作来自动检查布尔值是否为 false ,如果是,则将其设置为 true

 while self.locked.compare_exchange_weak(
            false, true, Acquire, Relaxed).is_err()

它有点冗长,但根据您的喜好,这可能更容易理解,因为它更清楚地捕捉了可能失败或成功的操作的概念。然而,它也可能导致指令略有不同,正如我们将在第 7 章中看到的那样。

while 循环中,我们使用自旋循环提示,告诉处理器我们正在自旋,同时等待改变。在大多数主要平台上,此提示会生成一条特殊指令,使处理器内核针对这种情况优化其行为。例如,它可能会暂时放慢或优先考虑它可以做的其他有用的事情。然而,与 thread::sleepthread::park 等阻塞操作不同,自旋循环提示不会导致操作系统被调用,使你的线程进入睡眠状态,从而有利于另一个线程。

提示

通常,在自旋循环中包含这样的提示是个好主意。根据情况,在尝试再次访问原子变量之前多次执行此提示甚至可能会更好。如果您关心最后几纳秒的性能并想找到最佳策略,则必须对您的特定用例进行基准测试。不幸的是,此类基准测试的结论可能高度依赖于硬件,正如我们将在第 7 章中看到的那样。

我们使用获取和释放内存顺序来确保每个 unlock() 调用都与随后的 lock() 调用建立先行发生关系。换句话说,为了确保在锁定它之后,我们可以安全地假设上次锁定期间发生的任何事情都已经发生了。这是获取和释放顺序最经典的用例:获取和释放锁。

图 4-1 展示了一种情况,其中我们的 SpinLock 用于保护对某些共享数据的访问,两个线程同时尝试获取锁。请注意第一个线程上的解锁操作如何与第二个线程上的锁定操作形成先行发生关系,从而确保线程不能并发访问数据。

图 4-1。两个线程之间的发生前关系使用我们的 SpinLock 来保护对某些共享数据的访问。

不安全的自旋锁

我们上面的 SpinLock 类型有一个完全安全的接口,因为它本身在被误用时不会引起任何未定义行为。然而,在大多数使用情况下,它将被用来保护对共享变量的改变,这意味着用户仍然必须使用不安全的、未检查的代码。

为了提供一个更简单的接口,我们可以改变锁的方法,为我们提供对受锁保护的数据的独占引用(&mut T),因为在大多数用例中,是锁操作保证了独占访问的安全。

为了能够做到这一点,我们必须改变类型,使其在保护的数据类型上具有通用性,并添加一个字段来保存这些数据。因为即使自旋锁本身是共享的,这个数据也可以被改变(或被专门访问),所以我们需要使用内部可变性(见第一章的 "内部可变性"),为此我们将使用一个 UnsafeCell

use std::cell::UnsafeCell;

pub struct SpinLock<T> {
    locked: AtomicBool,
    value: UnsafeCell<T>,
}

作为预防措施,UnsafeCell没有实现Sync,这意味着我们的类型现在不再可以在线程之间共享,这使得它相当无用。 为了解决这个问题,我们需要向编译器承诺,我们的类型在线程之间共享实际上是安全的。然而,由于锁可以用来将T类型的值从一个线程发送到另一个线程,我们必须将这个承诺限制在可以在线程之间安全发送的类型。所以,我们(不安全地)为所有实现了SendT实现了SpinLock<T>Sync,像这样:

unsafe impl<T> Sync for SpinLock<T> where T: Send {}

请注意,我们不需要要求 TSync ,因为我们的 SpinLock<T> 一次只允许一个线程访问它保护的 T 。仅当我们同时授予多个线程访问权限时,就像读写锁对读者所做的那样,我们才需要要求 T: Sync

接下来,我们的 new 函数现在需要使用 T 类型的值来初始化 UnsafeCell

impl<T> SpinLock<T> {
    pub const fn new(value: T) -> Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }}

然后我们到了有趣的部分: lockunlock 。我们做这一切的原因是能够从 lock() 返回一个 &mut T ,这样用户在使用我们的锁来保护他们的数据时不需要编写不安全、未经检查的代码。 这意味着我们现在必须在 lock 的实现中使用不安全代码。 UnsafeCell 可以通过其 get() 方法为我们提供指向其内容 ( *mut T ) 的原始指针,我们可以在 unsafe 块中将其转换为一个引用,如下所示:

  pub fn lock(&self) -> &mut T {
        while self.locked.swap(true, Acquire) {
            std::hint::spin_loop();
        }
        unsafe { &mut *self.value.get() }
    }

由于 lock 的函数签名在其输入和输出中均包含引用,因此 &self&mut T 的生命周期已被省略,并认为是相同的。 (请参阅 Rust Book 的“第 10 章:泛型类型、特征和生命周期”中的“生命周期省略”在新窗口打开。)我们可以通过手动写出生命周期来明确它们,如下所示:

 pub fn lock<'a>(&'a self) -> &'a mut T {}

这清楚地表明返回引用的生命周期与 &self 的生命周期相同。这意味着我们声称只要锁本身存在,返回的引用就有效。

如果我们假装unlock()不存在,这将是一个非常安全和可靠的接口。一个SpinLock可以被锁定,产生一个&mut T,然后再也不能被锁定,这保证了这个独占引用确实是独占的。

但是,如果我们尝试将 unlock() 方法添加回来,我们将需要一种方法来限制返回引用的生命周期,直到下一次调用 unlock() 。如果编译器能听懂英语,也许这会起作用:

pub fn lock<'a>(&self) -> &'a mut T
   where
       'a ends at the next call to unlock() on self,
       even if that's done by another thread.
       Oh, and it also ends when self is dropped, of course.
       (Thanks!)
   {}

不幸的是,这不是有效的 Rust 语法。与其试图向编译器解释这个限制,不如向用户解释。为了将责任转移给用户,我们将 unlock 函数标记为 unsafe ,并为他们留下一条注释,说明他们需要做什么才能保持正常:

    /// Safety: The &mut T from lock() must be gone!
    /// (And no cheating by keeping reference to fields of that T around!)
    pub unsafe fn unlock(&self) {
        self.locked.store(false, Release);
    }

使用锁守卫的安全接口

为了能够提供一个完全安全的接口,我们需要将解锁操作绑定到 &mut T 的末尾。我们可以通过将此引用包装在我们自己的类型中来做到这一点,它的行为类似于引用,但也实现了 Drop 特性以在它被删除时执行某些操作。

这种类型通常称为 守卫(guard),因为它有效地守卫着锁的状态,并一直对该状态负责,直到它被删除。

我们的 Guard 类型将简单地包含对 SpinLock 的引用,以便它既可以访问它的 UnsafeCell 又可以稍后重置 AtomicBool

pub struct Guard<T> {
    lock: &SpinLock<T>,
}

但是,如果我们尝试编译它,编译器会告诉我们:

error[E0106]: missing lifetime specifier
   --> src/lib.rs
    |
    |         lock: &SpinLock<T>,
    |               ^ expected named lifetime parameter
    |
help: consider introducing a named lifetime parameter
    |
    ~     pub struct Guard<'a, T> {
    |                      ^^^
    ~         lock: &'a SpinLock<T>,
    |                ^^
    |

显然,这不是一个可以省略生命周期的地方。我们必须明确指出引用有一个有限的生命周期,这与编译器的建议完全一致:

pub struct Guard<'a, T> {
    lock: &'a SpinLock<T>,
}

这保证了 Guard 不会比 SpinLock 生命周期长。

接下来,我们更改 SpinLock 上的 lock 方法以返回 Guard

    pub fn lock(&self) -> Guard<T> {
        while self.locked.swap(true, Acquire) {
            std::hint::spin_loop();
        }
        Guard { lock: self }
    }

我们的 Guard 类型没有构造函数并且它的字段是私有的,因此这是用户获得 Guard 的唯一途径。因此,我们可以安全地假设 Guard 的存在意味着 SpinLock 已被锁定。

为了使 Guard<T> 表现得像一个(独占)引用,透明地提供对 T 的访问,我们必须实现特殊的 DerefDerefMut 特征,如下所示:

use std::ops::{Deref, DerefMut};

impl<T> Deref for Guard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        // Safety: The very existence of this Guard
        // guarantees we've exclusively locked the lock.
        unsafe { &*self.lock.value.get() }
    }
}

impl<T> DerefMut for Guard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        // Safety: The very existence of this Guard
        // guarantees we've exclusively locked the lock.
        unsafe { &mut *self.lock.value.get() }
    }
}

作为最后一步,我们为 Guard 实现 Drop ,允许我们完全删除不安全的 unlock 方法:

impl<T> Drop for Guard<'_, T> {
    fn drop(&mut self) {
        self.lock.locked.store(false, Release);
    }
}

就这样,通过 Drop 和 Rust 类型系统的魔力,我们为 SpinLock 类型提供了一个完全安全(有用)的接口。

让我们来试一试:

fn main() {
    let x = SpinLock::new(Vec::new());
    thread::scope(|s| {
        s.spawn(|| x.lock().push(1));
        s.spawn(|| {
            let mut g = x.lock();
            g.push(2);
            g.push(2);
        });
    });
    let g = x.lock();
    assert!(g.as_slice() == [1, 2, 2] || g.as_slice() == [2, 2, 1]);
}

上面的程序演示了我们的 SpinLock 是多么容易使用。感谢 DerefDerefMut ,我们可以直接调用守卫上的 Vec::push 方法。感谢 Drop ,我们无需担心解锁问题。

显式解锁也是可能的,通过调用 drop(g) 来解除保护。如果你尝试过早解锁,你会看到守卫通过编译器错误完成它的工作。例如,如果您在两行 push(2) 之间插入 drop(g); 则第二行 push 将不会编译,因为此时您已经删除了 g

error[E0382]: borrow of moved value: `g`
   --> src/lib.rs
    |
    |     drop(g);
    |          - value moved here
    |     g.push(2);
    |     ^^^^^^^^^ value borrowed here after move

多亏了 Rust 的类型系统,我们可以放心,在我们运行程序之前就可以发现这样的错误。

总结

  • 自旋锁是一种在等待时忙循环或自旋的互斥锁。

  • 自旋可以减少延迟,但也可能浪费时钟周期并降低性能。

  • 自旋循环提示 std::hint::spin_loop() 可用于通知处理器自旋循环,这可能会提高其效率。

  • SpinLock<T> 可以只用 AtomicBoolUnsafeCell<T> 来实现,后者是内部可变性所必需的(请参阅第 1 章中的“内部可变性”)。

  • 解锁和锁定操作之间的先行发生关系对于防止数据竞争是必要的,这会导致未定义的行为。

  • 获取和释放内存顺序非常适合此用例。

  • 当做出必要的未经检查的假设以避免未定义的行为时,可以通过将函数设为 unsafe 将责任转移给调用者。

  • DerefDerefMut 特征可用于使类型的行为类似于引用,透明地提供对另一个对象的访问。

  • Drop 特征可用于在删除对象时执行某些操作,例如超出范围或传递给 drop() 时。

  • 锁守卫是一种特殊类型的有用设计模式,用于表示(安全)访问锁定的锁。由于 Deref 特征,这种类型的行为通常类似于引用,并通过 Drop 特征实现自动解锁。

上次更新: