Rust原子和锁——想法和灵感

184 阅读11分钟

并发相关的话题、算法、数据结构、轶事以及其他潜在章节的数量是无限的,它们本可以成为本书的一部分。然而,我们已经来到最后一章,离别的时刻即将到来,希望我们能给你带来一种激动人心的新可能性,并且准备好将新知识和技能应用到实践中。

本章的目的,是通过向你展示一些可以自己研究、探索和构建的想法,激发你自己的创造力和未来的工作灵感。

信号量

信号量实际上就是一个计数器,具有两个操作:信号(也叫做 "up" 或 "V")和等待(也叫做 "down" 或 "P")。信号操作将计数器递增到某个最大值,而等待操作则将计数器递减。如果计数器为零,等待操作将阻塞,直到收到匹配的信号操作,从而防止计数器变为负值。信号量是一个灵活的工具,可以用来实现其他同步原语。

image.png

信号量可以通过将一个 Mutex<u32> 用于计数器,并使用一个 Condvar 来处理等待操作的方式来实现。然而,有几种方法可以更高效地实现它。最值得注意的是,在支持类似 futex 操作的平台上(即“Futex”),它可以通过一个单独的 AtomicU32(甚至是 AtomicU8)来更高效地实现。

最大值为 1 的信号量有时被称为二进制信号量,可以作为构建其他原语的构建块。例如,可以通过将计数器初始化为 1,使用等待操作来锁定,使用信号操作来解锁,从而将其用作互斥锁。通过将计数器初始化为 0,它还可以像条件变量一样用于信号传递。例如,std::thread 中的标准 park()unpark() 函数可以通过在与线程相关的二进制信号量上执行等待和信号操作来实现。

注意

请注意,互斥锁可以通过信号量实现,而信号量也可以通过互斥锁(和条件变量)来实现。建议避免使用基于互斥锁的信号量来实现基于信号量的互斥锁,反之亦然。

深入阅读

  • Wikipedia 上的信号量文章
  • 斯坦福大学关于信号量的课程笔记

RCU(读复制更新)

如果你希望允许多个线程(主要是)读取和(有时)修改一些数据,可以使用 RwLock。当数据只是一个单一的整数时,可以使用原子变量(如 AtomicU32)来避免锁,这样更高效。然而,对于较大的数据块,例如具有多个字段的结构体,没有可用的原子类型可以对整个对象进行无锁的原子操作。

像计算机科学中的其他问题一样,这个问题可以通过增加一层间接性来解决。你可以使用一个原子变量来存储指向结构体的指针,而不是直接使用结构体本身。这样虽然无法对结构体整体进行原子修改,但可以原子地替换整个结构体,这几乎是同样有效的。

这种模式通常称为 RCPU(读复制更新),即“read, copy, update”——这三个步骤是替换数据所必需的。读取指针后,结构体可以复制到一个新的分配区域,在不担心其他线程的情况下进行修改。当准备好时,可以使用比较并交换操作("Compare-and-Exchange Operations")更新原子指针,只有在此期间没有其他线程替换数据时,操作才会成功。

image.png

RCU 模式中最有趣的部分是最后一步,尽管它在缩写中没有字母:释放旧数据。在成功更新后,其他线程可能仍然在读取旧的副本,如果它们在更新之前读取了指针。你必须等待所有这些线程完成后,才能释放旧副本。

解决这个问题有很多可能的方案,包括引用计数(例如 Arc)、内存泄漏(忽视问题)、垃圾回收、危险指针(让线程告诉其他线程它们当前正在使用哪些指针)、以及静默状态追踪(等待每个线程达到一个明确不再使用任何指针的状态)。最后一种方法在某些条件下可以非常高效。

Linux 内核中的许多数据结构都是基于 RCPU 的,关于它们实现细节的演讲和文章也很多,可以为你提供很多灵感。

深入阅读

  • Wikipedia 上的读复制更新模式文章
  • LWN 文章《What is RCPU, Fundamentally?》

无锁链表

在基本的 RCPU 模式基础上,你可以向结构体中添加一个原子指针,用来指向下一个元素,从而将其转换为一个链表。这允许线程在不需要为每次更新都复制整个链表的情况下,原子地添加或移除链表中的元素。

要在链表的开头插入一个新元素,你只需分配该元素并将其指针指向链表中的第一个元素,然后原子地更新初始指针,使其指向你新分配的元素。

image.png

类似地,删除一个元素可以通过原子地更新其前一个元素的指针,使其指向该元素之后的元素来实现。然而,当多个写线程同时操作时,必须小心处理相邻元素的并发插入或删除操作。否则,你可能会意外地删除一个并发插入的元素,或者撤销一个并发删除的元素。

提示

为了简化操作,你可以使用常规的互斥锁来避免并发修改。这样,读取仍然是无锁操作,但你不必担心并发修改的处理。

在将元素从链表中分离后,你会遇到与之前相同的问题:需要等待,直到可以释放该元素(或以其他方式声明其所有权)。我们之前讨论的基本 RCPU 模式的解决方案在这种情况下也同样适用。

总的来说,你可以基于原子指针上的比较并交换操作构建各种复杂的无锁数据结构,但你始终需要一个好的策略来释放或以其他方式回收分配的内存。

深入阅读

  • Wikipedia 上的非阻塞链表文章
  • LWN 文章《Using RCPU for Linked Lists—A Case Study》

基于队列的锁

对于大多数标准的锁原语,操作系统内核会跟踪被阻塞在锁上的线程,并负责在需要时唤醒一个线程。一种有趣的替代方法是通过手动跟踪等待线程的队列来实现互斥锁(或其他锁原语)。

这种互斥锁可以通过一个单一的 AtomicPtr 来实现,该指针可以指向一个(或多个)等待线程的列表。

该列表中的每个元素需要包含一些内容,用于唤醒相应的线程,例如一个 std::thread::Thread 对象。原子指针的一些未使用位可以用来存储互斥锁本身的状态,以及管理队列状态所需的其他信息。

image.png

有许多可能的变体。队列可以通过自己的锁位来保护,也可以实现为(部分)无锁结构。队列中的元素不一定要分配在堆上,它们可以是等待线程的局部变量。队列可以是一个双向链表,不仅包含指向下一个元素的指针,还包含指向前一个元素的指针。第一个元素还可以包含指向最后一个元素的指针,以便高效地在末尾追加元素。

这种模式允许仅使用能够阻塞和唤醒单个线程的机制(例如线程停车)来实现高效的锁原语。

Windows 的 SRW 锁(“Slim reader-writer locks”)就是使用这种模式实现的。

深入阅读

  • Windows SRW 锁的实现笔记
  • 一个 Rust 实现的基于队列的锁

基于停车场的锁

为了创建一个高效且尽可能小的互斥锁,你可以基于队列锁的思路,将队列移动到一个全局数据结构中,只在互斥锁内部保留一到两个比特位。这样,互斥锁只需要一个字节。你甚至可以将它放入指针的一些未使用位中,从而实现几乎没有额外开销的细粒度锁定。

这个全局数据结构可以是一个 HashMap,它将内存地址映射到在该地址上等待互斥锁的线程队列。这个全局数据结构通常被称为“停车场”,因为它是一个线程停放的集合。

image.png

这个模式可以通过不仅跟踪互斥锁的队列,还可以跟踪条件变量和其他原语的队列来进行推广。通过跟踪任何原子变量的队列,它实际上为在不原生支持 futex 功能的平台上实现类似 futex 的功能提供了一种方式。

这个模式最著名的实现是在 2015 年 WebKit 中,当时它被用来锁定 JavaScript 对象。它的实现启发了其他实现,例如流行的 parking_lot Rust crate。

深入阅读

  • WebKit 博客文章,《Locking in WebKit》
  • parking_lot crate 的文档

序列锁

序列锁是解决原子更新(较大)数据问题的另一种方法,无需使用传统的(阻塞)锁。它使用一个原子计数器,当数据正在更新时计数器为奇数,当数据准备好供读取时计数器为偶数。

写线程必须先将计数器从偶数增量为奇数,然后再修改数据,修改完成后,它必须再次将计数器增量,保持为另一个偶数值。

任何读取线程都可以在任何时候、无需阻塞地读取数据,只需读取计数器的前后两个值。如果计数器的两个值相等且为偶数,说明没有并发修改,意味着你读取的是有效的数据副本。否则,你可能读取了正在被并发修改的数据,在这种情况下,你应该重新尝试读取。

image.png

这是一个非常适合使数据可供其他线程访问的模式,同时避免读取线程阻塞写线程的情况。它常用于操作系统内核和许多嵌入式系统中。由于读取线程仅需要对内存的读取访问,并且不涉及指针,因此这可以成为一个在共享内存中安全使用的数据结构,尤其是在进程之间共享内存时,无需信任读取者。例如,Linux 内核使用这种模式,通过提供只读访问(共享内存)来非常高效地向进程提供时间戳。

一个有趣的问题是,这种模式如何适应内存模型。对相同数据的并发非原子读取和写入会导致未定义的行为,即使读取的数据被忽略。这意味着,严格来说,读取和写入数据都应该仅使用原子操作来完成,尽管整个读写操作不必是单个原子操作。

深入阅读

  • Wikipedia 上关于 Linux 的 Seqlock 文章
  • Rust RFC 3301, AtomicPerByte
  • seqlock crate 的文档

总结

花费数小时——甚至数年——发明新的并发数据结构并设计它们的 Rust 实现是非常有趣的。如果你希望将你在 Rust、原子操作、锁、并发数据结构以及并发编程方面的知识用于其他方面,创造新的教学材料并与他人分享你的知识也会非常有成就感。

目前,面向初学者的资源非常匮乏。Rust 在使系统编程变得更加易于访问方面发挥了重要作用,但许多程序员仍然回避低级并发。原子操作通常被认为是一个有些神秘的话题,最好留给少数专家,这实在是一种遗憾。

我希望这本书能够带来显著的影响,但关于 Rust 并发的书籍、博客文章、视频课程、会议讲座以及其他材料仍然有很大的空间需要填补。