KAIST-CS431-Rust-并发编程笔记-全-

KAIST CS431 Rust 并发编程笔记(全)

1:配置Visual Studio Code远程开发环境 🛠️

在本节课中,我们将学习如何安装Visual Studio Code,并配置其远程开发环境以连接到指定的服务器(例如Arimo服务器),从而为后续的Rust并发编程课程作业搭建工作环境。

概述

我们将分步完成以下任务:下载并安装Visual Studio Code,安装必要的远程开发插件,配置SSH连接到远程服务器,以及在远程环境中安装Rust开发所需的特定插件。最后,我们将验证环境配置,包括构建和调试一个Rust项目。

安装Visual Studio Code

首先,请访问Visual Studio Code的官方网站。下载适用于您操作系统的安装程序并完成安装。这是一个简单的步骤。

安装完成后,您将看到Visual Studio Code的主窗口界面。

配置远程开发环境

为了设置远程工作环境,您需要安装一些扩展插件。

插件可以在左侧活动栏的扩展市场中进行安装。以下是需要安装的第一个插件包。

您需要安装的插件列表如下。

首先,请搜索并安装名为“Remote Development”的扩展包。点击“Remote Development”然后进行安装。

安装过程可能需要一些时间。安装完“Remote Development”插件后,您可以在窗口左下角找到一个类似“><”形状的按钮。

点击该按钮后,将弹出远程连接选项。请点击“Remote-SSH: Connect to Host...”按钮。

随后,系统会列出您配置文件中已配置的SSH主机。例如,在我的设置中已经配置了名为“CS420”的主机。点击该主机名,将会弹出一个新窗口,显示正在连接到远程桌面。

此时正在打开远程连接,并会在远程主机上安装一些基本的服务器端功能。

在远程服务器上安装插件

成功连接到服务器后,您需要在远程机器内部安装一些插件。点击扩展按钮,然后搜索所需的插件。

您需要安装的第一个插件是Rust语言支持插件,其名称为“rust-analyzer”。

找到后,请点击安装。

然后完成安装过程。

您需要安装的第二个插件是“CodeLLDB”,这是一个调试器扩展。同样地,点击并安装它。

安装完成后,系统可能会要求您重新加载窗口。

验证环境:构建与调试Rust项目

现在,您可以打开一个Rust项目进行验证。这里以名为“crossbeam”的Rust项目为例。

例如,您可以打开crossbeam仓库的源代码,并查看某个文件。然后尝试构建项目。

通过选择运行cargo build命令来构建项目。您需要安装Rust工具链,系统会引导您完成安装。同时,构建过程也就开始了。

实际上,您正在构建这个项目的历史版本。同时,系统也会安装一些必要的依赖。让我们再试一次,运行一些构建任务。

再次构建后,可以观察构建过程中的输出。构建完成后,状态显示正常。

与此同时,您可以点击调试按钮开始调试。系统会使用LLDB调试器,并自动为您创建一些调试配置文件,使您能够立即开始调试。

例如,您可以在此处设置一个断点。我打算在这里设置一个断点。

然后开始调试。哦,这里似乎没有成功设置断点。

断点本应设置在这里。但是,如果我检查内部代码,会发现下面有一些测试代码。在那里设置断点,然后开始调试。

它没有在断点处暂停。您可以检查并选择调试配置文件。例如,在此时,我需要选择crossbeam_epoch

您需要选择测试配置:Debug unit tests in library ‘crossbeam_epoch’。然后开始调试游戏。调试器会尝试执行代码。

是的,它成功在代码的指定位置中断了。然后,您可以执行“单步跳过”到下一行,或“单步进入”函数调用内部等操作。

这就是在配置文件内部进行调试的过程。

总结

本节课中,我们一起学习了如何连接到名为“CS420”的服务器(我将在另一个视频中提供具体连接方法),如何在VS Code IDE内部进行连接,如何执行Rust代码,以及如何调试Rust代码。

我希望大家都已经为完成课程作业设置好了工作环境。

2:课程介绍与欢迎 👋

在本节课中,我们将学习课程的基本介绍,了解讲师背景以及本课程的核心目标。

大家好,欢迎来到韩国科学技术院CS492课程《并发程序设计与分析》。我是你们的讲师,Qhu Kang。

我很高兴有这么多同学选修这门课程。根据我上次查看的数据,大约有110人注册了这门课。

同时教授这么多学生,我既感到荣幸,也感受到一些压力。

因此,我希望你们能发现这门课程既有趣又富有深度。

我一直对构建大规模系统很感兴趣。

有一天我意识到,并发是这类大规模系统的核心所在。自那时起,

并发便成为了我整个职业生涯中的主要研究领域。在这门课程中,

我想将我迄今为止所学到的并发知识的精髓传授给你们。

人们常说并发编程非常困难,只有天才才应该学习它。

我想对此坚决地说“不”。一旦你理解了基本原理,

并发就不再是神秘的事物。是的,它有时确实困难,但至少,

我们拥有完善的理论来理解这些困难的主题,

因此你无需因并发编程的“恶名”而过度担忧。尽管去学。

我希望这门课程能充分揭开并发主题的神秘面纱,从而对你们未来的职业生涯有所帮助。


本节课中我们一起学习了课程的欢迎致辞、讲师的研究背景以及本课程旨在破除并发编程学习恐惧的核心目标。我们了解到,掌握基本原理是理解并发的关键。

3:课程介绍与概述

在本节课中,我们将学习《Rust并发编程》课程的基本信息、课程结构,并探讨并发编程的核心概念、挑战以及学习路径。我们将从课程主页的使用说明开始,逐步深入到并发编程的本质。

课程主页与要求

首先,我们来了解课程的后勤安排。课程主页是 github.com/k-cp/this。请仔细阅读主页上的README文件,其中包含了完成本课程所需了解的所有信息。

所有课程公告和问题讨论都应在GitHub的Issue Tracker中进行。请关注课程仓库,以便新的公告和问题能自动发送到您的邮箱。

本课程不设固定的线下答疑时间。我们要求您先在Issue Tracker中提问。请前往仓库查看现有问题,如有新问题,请创建一个新的Issue。

此外,每节课后都需要提交一份测验答案,以确保您确实观看了视频。我会针对每节课的视频内容提出几个问题,您需要将答案提交到 xjikac.kr 这个网站。您的账户已经创建,请前往仓库的README和公告中查看详情。

同时,请签署荣誉准则。这是计算机学院的标准要求,旨在确保您遵守学术诚信规则。请阅读并签署该准则。

总而言之,请仔细阅读课程主页,并完成考勤和荣誉准则相关的任务。

接下来,我们来看看评分标准。本课程的作业非常重要,占总成绩的60%。如果您未能按时完成作业,将无法获得好成绩。

此外,我们会有期中和期末考试。但由于COVID-19疫情的不确定性,考试安排可能会有所调整,评分方案也可能随之变化。

考勤方面,每节课后都需要在指定网站提交测验答案。如果您参加了大部分课程或回答了大部分测验,则没有问题。如果您错过了大量测验,则会影响成绩。考勤的具体评分方式尚未最终确定,但请务必观看视频并尽可能多地提交测验答案。

以上是课程的基本信息。如果您有任何疑问,请先仔细阅读主页的README。如果仍有问题,请在Issue Tracker中提问。

并发编程的时代背景

现在,让我们开始介绍并发编程。这是21世纪著名的场景之一:李世石与AlphaGo的对弈。这场对局是人工智能时代的开端。

李世石落下了第一子。众所周知,最终结果是AlphaGo以4:1获胜。这是一场一边倒的胜利,震惊了所有人。当时,大多数人都认为李世石会赢,因为他是围棋界的顶尖大师。我也曾认为AlphaGo不会如此强大。

然而,与大多数人的预期相反,AlphaGo赢得了胜利。2016年,我作为一名研究生,在实验室观看了所有比赛,并意识到一个新时代已经来临。人工智能确实将改变人类历史,而战胜李世石正是这一变革的标志之一。

如今,人工智能应用已无处不在,并在某些领域表现出色。例如,最近的GPT-3模型在语言理解方面非常出色,可用于需要理解语言的应用程序。每天都有大量AI应用涌现,它们正在改变我们的生活。

在AlphaGo与李世石对弈的背后,是庞大的计算集群。你可以看到许多重复的机架,内部的计算单元也被大量复制,并通过网络解决方案连接起来。

这种资源冗余和复制的原因在于,为了战胜李世石,AlphaGo需要进行大量的并行搜索。对于并行搜索,这些并行机器将非常高效。我们可以将搜索任务分发到大量相同的机器上,从而获得大约100倍的加速。击败李世石的AlphaGo版本就是一个大规模并行机器。四年后的今天,支撑AlphaGo的TPU(张量处理单元)正变得越来越并行和密集。

因此,在人工智能时代的背后,也是并行计算的时代。如今,我们正处在并行化的时代。为了迎接人工智能和物联网时代,我们需要越来越多的计算能力。

趋势是计算机正变得越来越并行。这里的“并行”含义广泛,包括CPU、内存、I/O资源、GPU、神经处理单元、FPGA等各种并行部署的资源。

我们需要更多并行性的原因主要有两个。第一个原因大约在2005年出现,即“登纳德缩放定律”的终结。这意味着我们无法在固定面积的芯片上不断增加晶体管数量,因为功耗会持续上升。因此,我们无法在单个计算单元中简单地塞入更多晶体管。

为了应对缩放定律的终结,我们引入了多核系统。我们开始设计双核、四核、八核系统。如今,在2020年,桌面CPU甚至有16核,服务器级CPU甚至有64核。据我所知,拥有118个核心的CPU正在设计中,并将于明年交付客户。因此,核心数量正在快速增长。

第二个趋势是“摩尔定律”的放缓。这意味着我们无法保证单位面积内晶体管数量能像过去那样持续高速增长。虽然光刻技术越来越精细,但晶体管数量的增长已变得非常缓慢。

为了应对这个问题,主要策略之一是引入“加速器”。加速器是专门化且极度并行的硬件。CPU是通用处理器,可以执行几乎所有计算任务。而加速器则不同,它们只执行一小部分操作,但效率极高。它们针对特定工作负载进行了专门优化,因此可以成为极其并行和高效的硬件。

GPU就是最重要的加速器类型之一。最初用于图形处理,如今也专门用于AI应用、视觉应用,甚至数据分析等。支撑AlphaGo的TPU也是一种著名的加速器。许多初创公司也在尝试开发更专注于AI应用的NPU。

这两个趋势迫使我们思考多核系统和异构资源。我们不仅拥有CPU,还有GPU、NPU、TPU,甚至SSD和FPGA等。如今,我们拥有丰富的并行和异构资源,未来这一趋势将继续。为了满足人类对更多计算的需求,将引入越来越多的并行性和异构性。

现在的问题是:我们拥有了强大的并行资源,这很好。但如何协调这些共存的并行资源,以实现更高的性能?我们需要以某种方式协调这些并行资源,让它们协作而非竞争,以实现统一的计算目标。例如,我们希望使用并行资源计算AI图,它们需要协调以实现该目标。

并行与并发的挑战

通常,实现高性能并行计算被认为有些困难。理论上,并行计算应该像这样工作:有多只狗在同时吃饼干,它们并行工作。

然而,实际情况通常是这样的:它们在竞争相同的资源,有些资源甚至完全没有被利用。理论上我们希望前者,但大多数时候我们只能实现后者这种混乱状态。

让我解释为什么会发生这种情况,以及如何安全高效地同步这些并行资源。首先,让我介绍“并发”的概念。

“并行性”基本上定义为存在多个资源。如果存在许多资源,我们可以说这些资源之间存在并行性。

而“并发”则关乎共享的可变状态。它被定义为共享的可变状态或共享的可变资源。例如,CPU、GPU、内存都被多个进程共享。服务器被多个进程或多个虚拟机共享,数据库被多个连接共享,甚至文件系统也被多个用户共享。所有这些资源都是可变的。显然,CPU是可变的,它们的寄存器值不断变化;数据库也可以不断更改。

并行性和并发性就是这样定义的:多个资源和共享的可变资源。问题在于,共享的可变资源是利用多个资源的关键。

你可以想象,没有并发的并行性并不那么有趣。例如,如果资源是共享的但不可变,那么资源就是常量,不会产生任何困难或有趣的事情。它们都是常量,在开始时定义,无法更改。因此,在计算方面并不那么有趣。

另一方面,独占的可变资源是顺序的,不涉及并行性。我们无法共享这些资源,因此无法利用这些资源提高性能,因为它被单个代理专门拥有,无法并行使用。

因此,为了通过并行性实现高性能,我们确实需要处理并发性,处理共享的可变资源。如何安全高效地使用这些并发的共享可变资源,是实现并行性高性能的关键。

到目前为止,我们讨论了并发性本质上是关于共享的可变资源。有一种处理并发的简单方法。并发性众所周知是困难的,是编程中所有困难的根源。

但有一种处理并发的简单方法:在任何时刻都不共享任何资源。这意味着,在任何时刻,如果一个资源只被单个代理拥有或使用,那么即使资源可以被共享,但在任何时刻它都被代理独占,就不会产生并发带来的困难。

它在名义上是并行的,因为许多代理可以访问此资源,但在时间上是顺序的,因为在任何时刻它都被单个代理独占,没有并行性。这显然是安全的,因为没有共享可变访问带来的问题,但通常效率低下,因为在任何时刻资源都不能被共享,只能被分时复用,而不能同时使用,因此可能效率低下。

另一方面,我们真正想要实现的是可扩展的并发性,即同时实际访问资源。例如,我们希望设计一个并发作业队列,支持每秒100万次操作。

在现实世界中,这样的系统以Redis或RabbitMQ等形式部署。例如,RabbitMQ是一个作业队列。如果Web服务器想要请求执行作业,它只需将作业抛给作业队列。这个队列不断被工作线程拉取,工作线程从队列中获取作业并执行。

我们同时有大量并发连接,因此这样的并发作业队列必须尽可能快。这就是为什么我们希望并发作业队列能够支持每秒100万次操作。这大致相当于当今单台服务器可以处理的并发连接数。

对于这种可扩展的并发性,多个代理实际上可以同时访问同一个作业队列。从这个意义上说,它是真正共享的可变资源。它被例如100万个句柄共享,并且是可变的,因为作业可以添加到队列或从队列中移除。

为了支持这种真正可扩展的并发性,问题在于通常难以推理。它肯定是高效的,正如我所说,它可以支持每秒许多许多次操作。但通常这种可扩展的并发性在安全性和正确性方面有点难以推理,尤其是如何确保队列的正确性,即它确实像一个队列一样工作,在存在共享可变访问的情况下。这通常很困难。

并发困难的核心:非确定性

让我解释为什么它很困难。困难的本质在于非确定性。并发之所以困难,是因为它引入了组合爆炸式的非确定性。“组合爆炸”基本上意味着非常非常多的非确定性。

第一个非确定性来源通常称为“交错执行”。假设有两个线程。一个是 x = 1,另一个是 x = 2。问题在于,根据线程执行的顺序,最终内存中的值可能是1或2。

如果 x = 1x = 2 之前执行,那么最终内存应该是 x = 2。另一方面,如果 x = 2x = 1 之前执行,那么最终内存应该是 x = 1。这种交错的可能性数量基本上与指令数量的阶乘相对应,是组合爆炸的。在这个例子中,我们只有两种交错,但如果我们有很多线程和很多指令,可能性很容易达到数十亿甚至更多。

第二个非确定性来源是硬件和编译器的优化。例如,仍然看这段代码。有两个线程,假设小写字母 ab 是寄存器,大写字母 XY 是共享内存位置。并假设在执行开始时 XY 都等于0。

在左侧线程中,先向 X 写入1,然后从 Y 读取。在右侧线程中,先向 Y 写入1,然后从 X 读取。问题是:在真实硬件中,是否可能观察到 ab 都等于0?令人惊讶的是,答案是肯定的,在真实硬件中是可以观察到的。

您可能天真地认为,要么 X = 1 先执行,那么 b 应该是1;要么 Y = 1 先执行,那么 a 应该是1。但这种推理在存在优化的情况下会被打破。

特别是,如果左侧或右侧线程中发生了重排序,那么 a = b = 0 是可能的。假设硬件或编译器足够智能,能够推断出左侧线程中的两条指令彼此不依赖,那么它可能先执行第二条指令,即先从 Y 读取。同样,编译器或硬件可能认为右侧线程中的两条指令彼此独立,因此可能先执行从 X 读取,然后再向 Y 写入1。如果是这种情况,那么 ab 同时为0是可能的,因为它们被重排序并在写入操作之前执行。

这种优化和额外的非确定性确实发生在硬件和编译器中,我们需要以某种方式处理,并学习如何应对这种非确定性。

这基本上就是本课程的主题。整个课程致力于理解并发性,理解由并发性产生的非确定性,并且我们想要“驯服”这种非确定性,以确保它实际上有益于我们的目的。

驯服非确定性的两种方法

基本上有两种方法来驯服非确定性。第一种方法是关于API。我们将非确定性封装在一个安全的API内。

想想锁、条件变量、线程或其他存在于操作系统实现和系统编程中的抽象。例如,锁有一个非常简单的API:你可以获取锁,可以释放锁。指令交错等问题被封装在锁的实现内部,不再是我们关注的重点。使用锁只需要了解锁的规范:锁的规范是,两个线程不能同时获取同一个锁,即锁一次只能被一个代理持有。条件变量有自己的API,其他并发对象,如并发数据结构或并发垃圾收集器,也都有安全的API。

因此,人们需要知道安全的API以及如何安全地使用它,而不是理解API的所有实现细节。这是驯服非确定性的最重要方法之一。一旦非确定性被封装在API内,我们就可以忘记实现细节,只使用API来使用并发对象。

再举一个例子:考虑一个并发栈。它是一个栈,但是并发的,因此多个线程可以同时访问这个栈。这样的栈的API应该隐藏底层的非确定性。例如,栈的实现内部会包含许多指令(如x86指令),它们可能被重排序和交错执行。但我们不再对如此低级的非确定性感兴趣,因为API会封装这些低级细节。相反,我们只想知道API暴露的高级非确定性。例如,如果两个线程试图向同一个并发栈推送值,那么操作会发生交错:要么线程A先推送值,要么线程B先推送值。但这是预期的非确定性,我们希望通过这种非确定性获得性能优势,这就是为什么我们通常希望向程序员暴露高级非确定性。

这是驯服非确定性的第一种方法。

第二种驯服非确定性的方法是关于实现。如果我们知道安全的API,我们可以忽略实现。但需要有人来实现以满足安全的API。他们应该怎么做才能实际实现这样的并发对象呢?

这里的关键思想或方法是,我们只使用经过充分研究的同步模式,而不是使用任意代码。我们只写下非常成熟的同步模式。例如,锁使用所谓的“释放-获取”同步,而一些并发数据结构使用所谓的“栅栏”同步。

只有少数几种同步模式,可以组合起来以实现必要或期望的协调或同步。我们将学习这些在实现中必需的同步模式。幸运的是,我们只需要了解两三种同步模式,不需要学习成千上万种。

因此,在本课程中,我们将学习这两个方面:现有并发库的安全API是什么(例如锁、条件变量、并发栈、并发工作窃取队列等),以及如何实现并发库(如何实现这样的锁、条件变量、并发栈、队列等,以及如何实现并发垃圾收集器,这基本上是本课程的主要主题)。

课程结构:基于锁与无锁并发

我说过我们将学习并发库的API和实现,并观察到并发有两个层次:一个简单,一个困难。简单的通常称为“基于锁的并发”,我在之前的幻灯片中称之为“简单并发”。

这意味着锁基本上规定,在某一时刻,资源应被单个代理拥有。因此,它消除了单个资源的所有并行性。但可能存在多个资源,每个资源都由自己的锁保护,在这种情况下,多个资源可以同时被访问。基于锁的并发消除了并行性,但对于某些情况,它可能足以满足我们的性能目标。这就是为什么我们要学习基于锁的简单并发。我们将学习锁、条件变量或其他与基于锁的并发相关的内容。

这样做的好处是它非常简单,因为我们消除了并行性,不再需要思考非确定性,因为不存在非确定性。它非常简单。但是,由于我们消除了很多非确定性,它可能具有非常低的可扩展性。如果我们用锁保护一个很大的资源,那么这个大资源一次只能被一个线程访问。

因此,我们可能会转向“无锁并发”或困难并发,研究社区的一些作者通常这样称呼。无锁并发基本上不使用锁,而是允许同时并发访问同一个对象,真正允许对同一对象的并发访问。

为了驯服由这种困难并发产生的非确定性,我们将学习无锁并发的理论、工具和实践。理论将包括描述非确定性的数学和推理原则。工具将基于同步模式,即无锁并发的构建块。正如我所说,只有少数几种同步模式,这是好事。我们只需要学习少数几种同步模式。此外,我们还将学习无锁并发的实践:实际无锁对象(如栈、队列、列表等数据结构)的API和实现。我们还将学习一点关于垃圾收集的知识。

并发编程的一般建议

以上是对本课程将要学习内容的介绍。总结来说,我们将学习并发库的API和实现,这些并发库通常可以分为两类:简单的和困难的。我们将同时处理两者,学习简单并发的API和实现,以及困难并发的API和实现。

以下是一些适用于大多数并发编程场景的一般性建议。如果课程太长,只需记住这些。

第一个建议是:始终从简单并发开始。当您开始处理共享可变状态时,始终使用锁,始终用锁保护您的资源。只有当锁成为瓶颈,并且锁引入了大量瓶颈时,您才应该转向困难并发。如果简单并发就足够了,就使用它,因为它更简单,更容易推理。高德纳有一句名言:“过早优化是万恶之源。”所以不要过早优化。从简单并发开始。

第二个建议是关于困难并发:一旦理解了理论,它实际上并不那么困难。如果您理解了基本原理和理论,将其应用于困难并发是系统性的,您不需要担心那些神奇的事情。这就是为什么如果您理解了理论,它就不再那么困难了。但另一方面,我必须承认理解理论可能有点困难,需要一些系统的学习,这正是本课程的目标。困难并发的关键在于驯服适当数量的非确定性。您不应该消除太多的非确定性,因为这会引入可扩展性问题;但您也不应该消除太少的非确定性,因为这会引入正确性问题。为了同时实现可扩展性和正确性,您需要消除或驯服适当数量的非确定性。这是困难并发的关键标准。

第三个建议是:您的并发代码可能不会很大。因为大多数事情都可以按顺序完成。并发通常用于高度访问的代码资源,通常很小,通常很简单。所以不会有太多代码要写,但您必须知道其中有很多门道。

在并发程序中引入错误真的很容易,比顺序程序容易得多。这就是为什么调试是并发编程中最重要的活动。但幸运的是,我们有很多工具支持调试:消毒器、压力测试器、断言和逐行调试器等。您将学习如何在调试并发程序时使用这些工具。此外,数学和证明性思维在调试此类问题时非常有帮助,因为它们迫使我们从逻辑上思考程序的安全性。在证明程序正确性的过程中,我们需要处理所有边界情况,思考所有边界情况。因此,这将帮助我们调试代码。

我设想这门课程面向高年级学生,你们中的大多数人已经修过编程语言课程,但这门课并非必需。不过,如果您已经修过编程语言课程,学习本课程会更容易。

本节课到此结束,也是第一天的结束。我希望您能继续注册本课程,以便在本学期剩余的时间里学习并发编程。我希望这门课程既有趣又深入。

3:基于锁的并发编程入门 🧠

在本节课中,我们将要学习并发编程的基础,从基于锁的并发开始。我们将了解共享内存模型、锁的基本概念、传统锁API的缺陷,以及为何需要更安全的高级API。

概述:共享内存并发模型

在深入基于锁的并发之前,我们需要先理解共享内存并发这一基础概念,这是我们本课程中所有并发学习的背景。

我们假设程序中有多个线程,这些线程同时共享同一块内存。例如,有线程1、2、3……N,它们都共享内存。

这是一个简化的视图,忽略了实际CPU和内存实现中的缓存、存储等复杂细节。但为了本课程当前的目的,我们暂时只关注线程和共享内存,其他细节将在后续讨论。

不过,缓存一致性等优化在理解并发中扮演着关键角色,我们稍后会学习它们。但现在,让我们聚焦于线程和内存。

这就是我们将要使用的并发模型——共享内存并发,因为多个线程同时共享同一内存。

线程本质上是一个执行自身程序的代理。它通常拥有一个程序计数器,程序计数器不断递增,线程执行当前的指令。

线程拥有一个本地存储,通常称为寄存器或寄存器文件。它独立更新寄存器,并时不时地访问位于栈或堆中的共享内存。它向共享内存发起读写请求,并从中获取响应。在此过程中,它独立执行,并可能执行一些I/O操作,使其效果对外部世界可见。这就是线程的基本概念。

如果你已经学习过操作系统课程,可能对这些概念很熟悉。我在这里重申这些概念,是因为我想强调线程的一个方面:线程是访问共享内存或共享资源的代理。另一方面,共享内存是一种资源,它是一个共享的数据存储资源,数据被写入和读取。因此,它是一个被多个线程共享的资源。我想强调共享内存的这一方面。

但就目前而言,让我们只关注内存和线程。这是我们当前学习的背景。

什么是基于锁的并发?🔒

现在,让我介绍基于锁的并发

“基于锁”意味着在任何时刻,一个内存位置只能被一个代理访问。也就是说,在基于锁的并发中,多个线程不会同时访问同一位置。这基本上就是“基于锁”的含义。

这对应于上一讲中介绍的“简单并发”。正如上一讲所说,这种方法的主要好处是简单性。我们基本上消除了所有对同一资源的共享可变访问所带来的有趣并发。因此,它很简单,只有一个代理访问资源,没有实际的并发。

然而,这种方法的缺点是它可能且经常是低效的,因为我们消除了所有并发,结果只有一个代理可以访问单个资源。因此,我们无法同时并行执行多个代理或线程。这是基于锁并发的缺点。

但无论如何,我们从基于锁的并发开始,因为它简单。在我们掌握了基于锁并发的概念之后,我们将学习无锁并发。

实现或编程这种基于锁并发的主要机制,毫不意外地,就是

锁是一个对象,同一时间只能被一个线程持有。锁基本上就像一个门前的物体,只有一个代理可以持有它。如果一个代理持有锁,另一个试图获取它的代理必须等待前者完成使用。这有效地消除了所有并发。

一个并发问题的例子

这里有一个例子。假设有两个线程,双竖线表示线程分隔。左边是左线程,右边是右线程。我们假设这些大写字母从现在起代表共享内存位置,小写字母代表寄存器或线程本地寄存器。

这个程序基本上是从 X 读取值,加1,然后将新值赋回 X。同时,右线程做同样的事情:从 X 读取,加1,然后存回 X。另外,按照惯例,我们假设所有共享内存位置在执行开始时都是零。

你可能会猜测,最终 X 应该是2,因为它在左线程中递增了1,在右线程中也递增了1,所以总共递增了两次,应该是2。然而,这个推理是错的,实际上不会发生。我的意思是,在真实系统中它并不成立,因为线程执行的不幸交错可能产生例如 X 等于1的结果。

让我们看看为什么。假设这些线程以交错方式执行,意味着它们一次执行一条指令,轮流执行。

假设在一次执行中,左线程执行第一条指令,从 X 读取0并赋值0给 r1。然后假设右线程轮换,它再次从 X 读取0。现在左线程轮换,将1赋值给 X。然后右线程取1,可能 X 再次被赋值为1。在这个特定的指令执行调度中,X 在两个线程中被读取为0,并在两个线程中被赋值为1。如果是这种情况,最终 X 可能不是2,而是1。

这种异常或不幸的非确定性源于 X 这个共享内存位置实际上是以共享可变的方式被访问的,因为它们同时访问该位置并修改其内容。这是对同一资源的共享可变访问,它导致了问题,超出了我们对程序应如何工作的思考或想象范围。

锁的引入与作用

为了纠正这个问题,我们需要保护对 X 的访问。最简单的方法是插入一个锁。

假设 L 是两个线程共享的锁,L 有两个接口(方法)。第一个是 acquire,用于在锁未被持有时获取锁;另一个API是 release,如果你持有锁,释放它后就不再持有锁。

现在,在这种保护下,不幸的交错不会发生。因为假设左线程执行这里的第一个指令并获取锁,然后它可以读写位置 X。但如果这个线程获取了锁并持有它,右线程就无法进入这一行,因为它必须等待左线程释放锁。它会在第一行等待,acquire 函数在成功获取锁之前不会退出。因此,左线程对 X 的所有访问必须发生在右线程对 X 的所有访问之前。结果,我们的想象或意图得以实现。X 最终总是2,因为锁防止了这种不幸的交错。

锁是如此有用的构造,它允许我们进行这样的推理,因为同一时间只有一个代理访问单个资源,我们不再需要推理不幸的交错以及由这些交错产生的非确定性。

锁的基本API

到目前为止一切顺利。如前所述,锁有两个API,另外还有一个API try_acquireacquire 会阻塞直到获取锁,即等待其他线程释放锁。try_acquire 返回是否获取了锁,当另一个线程已经持有锁时,它可能立即返回 false(表示尝试获取锁但失败了),或者返回 true(表示成功获取了锁)。第三个API是 release,用于释放之前已经获取的锁。

这些是锁的基本API,这是所有并发编程中最重要的对象——锁的最重要的API。

如果你能很好地使用这些API,那么就能保证不再有对资源的共享可变访问,我们可能更容易推理并发程序。

传统锁API的缺陷 🚨

但是有一个陷阱:这些API极其容易出错,非常容易被误用,导致系统挂起或环境被破坏。

例如,如果你在这里不写 release,系统就会挂起,因为另一个线程无法获取锁。另一方面,如果你忘记在这里插入 acquire,那么最终 X 等于2的不变量可能不成立。你需要非常聪明地插入这些API调用,只有成功且正确地插入,系统才能按你的意图工作。

但这样做极其容易出错,原因有二:

  1. 缺乏显式关联:从API本身来看,使用 L 保护 X 是有些巧合或隐含的。我的意思是,代码中没有 LX 之间的显式关系。我们程序员只是知道“哦,X 是由 L 保护的”,因此我们可以安全地在代码中使用 X。但由于没有这种显式标记,我们可能会获取一个并非用于保护 X 的锁,或者使用一个并非由这个锁 L 保护的资源。如果发生这种情况,所有的不变量都会被破坏,不幸的结果就会出现。

  2. 需要精确匹配:你需要很好地匹配 acquirerelease。不能忘记释放锁,也不能忘记获取锁,并且必须释放你已经获取的锁。如果你释放了不同的锁,那就有问题。

由于这些原因,我认为这些API真的很容易出错。我知道很多程序(如Linux)都是这样实现的,并且维护得很好,但对于初学者和大型代码库来说,仍然非常容易出错。大型代码库在用这种方式编写程序时通常会有bug,因为没有人理解整个代码库,不变量变得极其复杂。如果API处于低级层面,我们无法静态地保证整个程序编写良好。

对于并发编程来说,这个API问题尤其成问题。因为程序员需要时刻关注API,成本很高,并发程序通常比顺序程序有更复杂的不变量,因此程序员需要时刻考虑这些复杂的不变量,这给程序员带来了非常高的认知负荷。即便如此,即使程序员非常关注这些bug,大型系统中通常仍然存在剩余的bug。我见过数百万或千万行代码的并发程序,它们通常有很多bug,因为没有人理解整个不变量,而且这种bug也很难测试,因为它们通常是隐藏的,可能每十亿次才出现一次,因此很难为这类并发程序实现高测试覆盖率。

这就是为什么我们希望为这些锁使用高级API,因为我们想要易于使用且安全的API。如果一个锁API总是安全的,并且防错,那么对程序员来说就容易得多,因为程序员不再需要担心安全性。如果程序使用这种高级API编译,那么就没问题,编译器保证锁会按预期工作。这是一个巨大的好处。如果没有这种好处,程序员必须时刻关注不变量;但如果编译器保证了这个不变量,你就可以忘记它,直接使用高级API,自动就不会有bug。

高级API的需求与C++的尝试

具体来说,我们想要一个能自动匹配 acquirerelease 函数的高级API,并且这个高级API应该显式地将锁和相应的内部资源关联起来。例如,LX 之间的关系应该在程序中显式标记。这样,就不会混淆哪个锁保护哪个数据。

如前所述,这种高级API成本低,引入的bug少,因为所有bug都会因高级API和编译器而自动消除。

对于锁,已经有很多种高级API,最著名的是来自C++及其资源获取即初始化(RAII) 的能力。

这个API的高级思想有两点:

  1. 为了自动匹配 acquirerelease 调用,我们引入一个RAII类型——锁守卫(lock_guard)。当你获取一个锁时,它返回一个 lock_guard,而不是什么都不返回。当这个 lock_guard 被销毁时,相应的锁会自动释放。因此,只要这个锁守卫存在(只要没有故意忘记它),创建该锁守卫的对应锁就会自动释放。acquirerelease 函数被自动匹配。这基本上是锁守卫的意图。

  2. 为了以显式的方式关联锁和资源,我们引入一个新类型 Lock<T>。这基本上是一个锁和类型 T 的数据的配对。它显式地标记了一个锁与 T 类型数据的关联。因此,我们不再混淆哪个数据由哪个资源保护。

让我展示这两个思想的例子。这是C++标准库中 lock_guard 的API示例。

std::mutex g_i_mutex; // 这是一个锁
int g_i = 0; // 共享数据

void safe_increment() {
    std::lock_guard<std::mutex> lock(g_i_mutex); // 获取锁,创建锁守卫
    ++g_i; // 安全地访问共享数据
    // lock_guard 在函数结束时自动销毁,释放锁
}

在这个函数中,通过创建 lock_guard 来获取锁。lock_guard 是一个RAII类型,它证明我已经获取了 g_i_mutex 这个锁。之后,我递增共享资源 g_i,这是安全的,因为我持有锁 g_i_mutex。在函数结束时,lock_guard 被销毁,g_i_mutex 被自动释放。这就是RAII背后的思想:你使用初始化器初始化这个资源(锁守卫),当它被销毁时,会自动运行一些代码(在这里是释放锁)。这样,我们就不会忘记释放锁,因为锁守卫会自动释放锁。

第二个思想是关联数据和锁,好处是它强制显式化哪个数据由哪个锁保护。类型看起来像这样:Lock<T> 类型由两个数据组成:一个具有 acquirerelease 函数的低级锁类型,以及一个数据类型 T。当你通过调用 lock 函数获取锁时,你实际上是在获取锁,然后返回一个锁守卫,这标志着你已经获取了它。当你持有锁守卫时,你可以解引用内部数据,因为这是你已获取锁的证明,因此你有权访问内部数据。这就是为什么我们实现这个操作符的原因。当锁守卫被使用时,它可以自动转换为内部数据的引用。这是可能的,因为我们显式地关联了锁和数据。现在,在完成数据引用后,我们可以通过销毁锁守卫来释放锁,在锁守卫的析构函数中,我们在这里释放锁。因此,我们自动释放了锁。

总之,这个API同时实现了两个目标:自动匹配获取和释放,并且显式地关联了锁和数据。

C++高级API的安全漏洞

到目前为止一切顺利,这是一个相当好的API,我们可以认为它是C++编程语言内部基于锁API的典范,但实际上它并非100%安全。它更安全,但仍然存在大量可能破坏系统的情况。

让我们看看这里发生了什么。假设有一个 Lock<int> 类型。你在这里获取锁并得到守卫 guard。假设你解引用守卫并得到指向数据的指针 data_ptr。这在C++中是允许的,因为你获取了守卫,守卫可以转换为指向数据的指针。

现在假设守卫在这里被丢弃或销毁,锁被自动释放。但问题是,指针 data_ptr 仍然存在,我们仍然可以在这里解引用数据指针。但如果是这样,就非常不安全,它破坏了锁提供的所有不变量。因为它可以在不持有锁的情况下访问内部数据,因为锁已经在这里被释放了。可能其他线程已经获取了锁并同时访问相同的数据,那么不幸的交错就可能从那里发生。这不再符合预期。

如前所述,这里的根本原因是这个指向数据的指针在守卫被销毁和锁被释放后泄漏了。所以 data_ptr 在守卫被丢弃后泄漏了,这是根本原因。相反,我们必须强制规定数据指针的生存期不应超过锁守卫的生存期。我的意思是,当守卫被销毁且锁被释放后,数据指针从现在起就不应再被使用。但它却被使用了,而API实际上允许这样做。

你可能认为争论这个API的不安全性是一种学究式的做法,你可能会说这在现实世界中不会发生。但根据我的经验,它确实在现实世界的生产代码中发生,并导致了很多麻烦。它在现实世界代码中发生的原因是,如果允许,程序员会做他们想做的任何事情。如果可以从中获取指针,他们会为了以后的目的而获取。可能是出于好意,他们在这里获取指针并在守卫被丢弃前使用它。但后来,他们忘记了这个不变量,在守卫被丢弃后使用了指针。因此,如果有很多程序员在一个大型代码库上工作,很难保证API按预期使用。所以在我看来,这种API非常容易出错,并且确实在现实世界的生产代码中造成了麻烦。

Rust的解决方案:基于所有权和生命周期的类型系统

我在这里提出的解决方案是使用基于所有权和生命周期的Rust编程语言类型系统。

Rust专门设计用于避免所有此类问题,所以这基本上是一个生命周期问题:数据指针的生命周期不应长于数据守卫的生命周期,这应该在类型系统中得到某种保证。这基本上是Rust类型系统的目的。

防止库和程序的生存期问题。在Rust类型系统中,我们实际上可以保证这一点。这就是为什么Rust可以解决此类问题。

这也是为什么在本课程中,我们将使用Rust而不是在并发编程中最广泛使用的C++。因为我相信类型系统是非常好的API。它为学习和实现并发库提供了非常好的API。通过学习这个,我相信你可以成为更好的并发程序员。即使你将来在烹饪编程中使用C++,学习Rust并用Rust学习并发也将非常有帮助。

这里有一个具有安全API的锁的安全实现,这个API的安全性已在一篇研究论文中得到证明。这个API将在本课程的后续阶段讨论。

总结与预告

总之,这就是为什么C++中的这个API不安全,以及我提到这个API在Rust中可以变得安全。

在深入研究Rust中的安全API之前,我们确实需要先学习Rust。所以在下一个视频中,我们将快速学习Rust,然后回到并发,继续学习基于锁的并发及其核心概念和API。

在本节课中,我们一起学习了:

  1. 共享内存并发模型的基本概念。
  2. 基于锁的并发如何通过限制同一时间只有一个线程访问资源来简化问题。
  3. 传统锁API(acquire/release)的缺陷:缺乏显式关联和需要精确匹配,导致容易出错。
  4. 对更安全、高级API的需求,特别是能自动管理锁生命周期并显式关联数据与锁的API。
  5. C++通过RAII模式(如 lock_guard)和 Lock<T> 模板向这个方向努力,但仍存在指针泄漏导致的安全漏洞。
  6. 介绍了Rust的所有权和生命周期系统如何从根本上解决这类安全问题,为学习安全的并发编程提供了更好的基础。

下一节,我们将开始Rust的快速入门课程。

4:并行Web服务器 🚀

在本节课中,我们将学习如何完成作业1:实现一个并行的Web服务器。我们将了解服务器的基本功能、提供的骨架代码结构,以及需要你亲自实现的三个核心组件:缓存可取消的TCP监听器线程池

概述

作业要求实现一个支持并发的Web服务器。该服务器能够处理HTTP请求,对耗时计算的结果进行缓存以提升性能,并能优雅地关闭并打印访问统计信息。我们已为你提供了大部分骨架代码,你的任务是完成三个关键部分的实现。

服务器功能演示

homework1 目录下,你可以使用命令 cargo run -- hello server 来运行服务器。

启动服务器后,它会打印一些日志。当你在浏览器中访问 localhost:7878 时,服务器会返回一个错误页面。如果你访问一个有效路径,例如 /alice,服务器会先执行一个耗时的计算(导致几秒钟的延迟),然后返回页面结果。

关键在于,计算结果会被缓存。因此,首次访问 /alice 会有延迟,但刷新页面时,结果会立即从缓存中返回。同样,首次访问 /bob 会再次触发耗时计算和延迟,但随后的刷新会立即得到响应。

当你想终止服务器时,可以按下 Ctrl+C。此时,服务器会打印出访问统计信息,例如各个页面的访问次数。

代码结构解析

上一节我们了解了服务器的外部行为,本节中我们来看看其内部的代码架构。骨架代码主要位于 src/lib.rs 文件中。

main 函数是程序的入口点,它负责协调各个组件:

  1. 创建一个包含7个工作线程的线程池
  2. 建立用于在线程间传递任务和报告的信道。
  3. 创建一个可取消的TCP监听器,绑定到 localhost:7878
  4. 当按下 Ctrl+C 时,会触发监听器的取消操作,从而清理所有资源。

服务器主要包含以下几种并行执行的线程:

  • 监听器线程:接受新的TCP连接。
  • 工作线程(属于线程池):处理具体的HTTP请求。
  • 报告器线程:收集并统计访问报告。
  • 主线程:协调以上所有线程。

这些线程通过 crossbeam crate 提供的无界和有界信道进行通信,共同实现了一个真正的并行Web服务器。

需要实现的组件

了解了整体架构后,现在我们将聚焦于你需要完成的三个核心部分。

1. 缓存实现

缓存是一个键值存储,核心方法是 get_or_insert_if。其逻辑是:根据键查询值,如果存在则立即返回;如果不存在,则执行提供的闭包函数来计算值,将结果存入缓存后返回。

这个闭包函数可能非常耗时(例如需要几秒钟),因此缓存结果对性能提升至关重要。

以下是该方法的签名:

fn get_or_insert_if<F>(&self, key: K, f: F) -> V
where
    F: FnOnce() -> V,

你需要实现这个结构体和方法,确保在并发访问下的正确性。

2. 可取消的TCP监听器

我们需要在标准库 TcpListener 的基础上封装一个可取消的版本 CancelableTcpListener

它需要提供与底层监听器相同的API,但额外增加一个 cancel 方法。cancel 方法需要设置一个取消标志,并唤醒可能被阻塞在 accept 操作上的监听器线程

next 方法(用于获取下一个连接流)中,需要检查取消标志。如果标志为真,则返回 None 表示连接流已终止;否则,返回底层监听器的下一个连接流。

这里涉及到原子操作与内存顺序:

  • cancel 方法中写入标志时,使用 Release 顺序。
  • next 方法中读取标志时,使用 Acquire 顺序。
    内存顺序的详细含义将在后续课程中讨论,目前请按此要求实现。

3. 线程池实现

线程池管理一组工作线程。其核心API包括:

  • new(size): 创建一个指定大小的线程池。
  • execute(job): 向线程池提交一个任务(闭包)来执行。
  • join(): 等待所有已提交的任务执行完毕。

实现思路是,在线程池和工作线程之间建立一个信道execute 方法将任务发送到信道中,每个工作线程则循环从信道中接收任务并执行。

为了实现 join 方法,你需要一个机制来跟踪正在进行的任务数量。当这个数量降为0时,join 方法才可以返回。

本课程最终的期末项目也是关于实现线程池,你可以从那里借鉴许多代码。

总结

本节课中,我们一起学习了第一个作业:并行Web服务器的实现。我们分析了服务器的功能、骨架代码的整体结构,并明确了需要你完成的三个核心组件:缓存可取消的TCP监听器线程池。实现这些组件后,你就能运行一个如演示所示的、支持并发处理和结果缓存的高效Web服务器了。

6:Rust语言基础与所有权模型 🦀

在本节课中,我们将学习Rust语言,它将成为我们进行并发编程的安全基础。Rust提供了一种方法,可以在安全的API内部封装所有不安全的操作。我们将学习如何做到这一点。

概述:为什么选择Rust?

Rust是一种安全的系统编程语言。它让程序员能够在无需担心底层实现细节的情况下,实现绝对的安全性。

其核心动机在于,C++是不安全的,这正是Rust诞生的原因。Rust的目标是同时实现安全控制

  • 安全意味着:如果你编写的Rust程序满足某些条件,那么编译器保证,当你将其编译为汇编程序时,该程序不会出错。这是由编译器和类型系统保证的。
  • 控制意味着:它支持所有必要的低级编程特性,例如操作系统开发。在我们的实验室中,我们也在Rust之上构建操作系统。它支持操作系统、数据库管理系统或所有其他类型的系统编程。

为了实现这一目标,现有的技术是C和C++,但它们非常不安全。正如我们在之前的视频中讨论的,你可以很容易地“搬起石头砸自己的脚”,例如,在自旋锁内部泄漏一个指针。C/C++无法检测到这种不安全的API使用方式。这是C/C++与Rust之间的主要区别。

Rust非常适合本课程,因为它的所有权生命周期概念抓住了并发的本质。回想一下,并发从根本上讲是关于共享可变状态的。为了推理这些共享可变状态,所有权生命周期的概念非常有用,因为它们从根本上关乎如何安全地共享资源。Rust提供了这样的工具,我们将用它来编写并发程序。

课程安排与准备 📚

以下是本课程的一些阅读和编程作业安排。

阅读作业:

  • 阅读《The Rust Programming Language》一书。该书包含约20章,每章不长。读完所有章节后,最后一章是关于构建一个并行Web服务器的。
  • 作业一是关于扩展这个并行Web服务器,实现线程池、缓存和并发请求处理器。为了完成作业一,你可能需要阅读整本书。
  • 第二个阅读作业是阅读《Rust by Example》一书,它列举了许多Rust程序示例。这些示例让你能获得关于Rust编译器和类型系统的实践经验,它们比《The Rust Programming Language》更实用。

编程作业:
本课程中预计有五到六个编程作业。所有编程作业都将使用Rust完成,因此你需要为作业编写一些Rust程序。请设置好你的编程环境(我们提供的服务器或你自己的机器)。我们仅支持Ubuntu 20.04,任何运行该版本的机器都可以,但你可能希望使用具有更多核心的机器,这也是我们提供服务器的原因。无论如何,请设置编程环境,并确保你可以编译和运行示例(例如,一个“Hello World”程序或作业零的Web服务器)。

Rust的核心示例:所有权与借用

现在,让我们通过一个示例来了解Rust的强大之处。以下是一个展示Rust所有权系统如何工作的例子。

fn main() {
    let mut v = vec![1, 2, 3]; // V 是大小为3的向量的所有者
    let p = &v[0]; // P 不可变地借用向量的第一个元素
    v.push(4); // 尝试可变地借用 V 以推送值4
    println!("{}", p); // 尝试读取 P 指向的值
}

这段代码在C++中可以编译(尽管是糟糕的代码),但在Rust中无法编译。编译器会报错:cannot borrow v as mutable because it is also borrowed as immutable

原因分析:
push操作可能导致底层向量重新分配内存。如果发生这种情况,指向第一个元素的指针p可能会失效,因为它指向的是旧的内存区域。这可能导致解引用未使用的内存区域,从而引发错误。

Rust编译器通过所有权规则在编译时检测到了这种潜在冲突:

  1. v是向量的所有者。
  2. p在第3行不可变地借用了向量v(获取了引用)。
  3. 在第4行,v.push(4)试图可变地借用同一个向量v
  4. 从第3行到第5行,p的生命周期与v.push(4)的生命周期在第4行重叠
  5. 根据Rust规则:一个资源要么可以被多个代理不可变地借用(只读),要么只能被一个代理可变地借用(读写),但不能同时存在可变借用和不可变借用。
  6. 这里p(不可变借用)和v.push(可变借用)试图同时访问v,违反了规则,因此编译器拒绝编译。

这种推理是静态可靠的。在编译时,你可以在不遗漏任何冲突的情况下,检测出所有冲突的共享可变访问。虽然它可能因为无法分析某些复杂关系而拒绝一些实际上是安全的程序(即不完全),但在实践中,通过稍微调整代码结构来满足所有权规则通常并不困难。

Rust的所有权原则 🧱

上一节我们通过示例看到了所有权冲突。本节中,我们来深入理解其背后的核心概念。

Rust的关键概念是所有权。所有权是一个代理访问和销毁资源的能力。如果你拥有一个资源(例如一个向量)的所有权,那么你可以安全地销毁它,或者向其中推送/弹出值。

默认情况下,所有权是独占的。这意味着如果我拥有一个向量,那么其他人就不能拥有它。该向量只归我所有。

但为了允许共享,所有权可以被借用。即使对一个向量的所有权是独占的,它也可以被借用。

  • 它可以被可变地借用给一个单独的代理。
  • 或者被不可变地借用给多个代理。

这个想法非常适合并发,因为每个线程都可以被视为一个代理。通过借用规则,你可以相当确定一个资源不能同时被共享和修改。因为一个资源要么只能被一个代理可变地借用,要么可以被多个代理不可变地借用。

Rust编译器静态地强制执行这一规则:默认情况下,不允许对底层资源进行共享可变访问。类型系统在这里强制规定:要么可变地借给单个代理,要么不可变地借给多个代理。

这很容易使用,因为如果你违反了任何所有权规则,编译器会报告每一个违规。你可以立即知道问题出在哪里,并轻松定位和修复。正如所说,它在静态意义上是正确的,因为如果一个程序通过了Rust编译器的类型检查,那么它保证在任何情况下都不会出错。

内部可变性:安全地打破规则 🔧

到目前为止,我们解释了所有权原则。但对于并发编程,我们特别需要第三个概念:内部可变性,它允许我们在安全的前提下“弯曲”规则。

其动机在于,“无共享可变访问”这条规则对于并发编程来说太强了。在实际的并发程序中,它永远不会被满足。例如,如果你有一个在多个线程间共享的栈,那么这个栈资源显然会被多个线程同时修改。这里确实存在共享的可变访问。我们需要驯服它,但不能简单地移除它,因为这是共享栈的本质。

我们在Rust中可以期望的是:我们可以系统地“弯曲”规则,允许对栈进行共享可变访问,但以一种系统安全的方式进行。共享可变访问在并发中是不可避免的,但你可以驯服它。

核心思想是:将可能不安全的实现封装在安全的API之内。
例如,一个并发栈的实现可能使用了非常复杂、不安全的底层操作,Rust编译器无法分析其绝对安全性。但我们可以做的是,确保其API是完全安全的。栈的用户无需了解实现的所有细节,也能编写出安全的程序。用户只知道API,只要按照API使用,就能保证程序不会出错。任何这些API调用的组合都不会导致错误。

这就是内部可变性的意义。本质上,内部可变性所强制实施的是:从使用API的用户角度来看,并发栈的工作方式就好像没有共享可变访问一样。API给人的感觉是没有共享可变访问。

示例:RefCell

RefCell是内部可变性的第一个例子。它基本上包含一个值,你可以借用或可变地借用它。但与通常的引用不同,你可以在同一时间(通过运行时检查)可变地借用和借用它。

考虑以下场景:有两个函数f1f2,根据某些复杂的不变量,它们不会同时返回true。Rust编译器在编译时无法分析f1f2的具体值,因此它会保守地认为可能存在冲突,从而拒绝程序。但作为程序员,你知道根据不变量,程序实际上是安全的。

这时,你可以使用RefCellRefCell运行时检查借用规则。try_borrow_mut方法会尝试可变地借用底层值,如果当前已被借用(无论是可变还是不可变),它会失败(返回Err)。这样,程序就可以在满足运行时条件的情况下安全执行。

总结: RefCell提供了一种内部可变性,它在安全的类型中封装了共享可变访问(这些访问在编译时无法被分析)。其API之所以安全,是因为在API层面,它表现得好像没有共享可变访问。try_borrow_mut表现得像是在不可变地借用底层值,即使它实际上是在可变地借用。这个实现本身可能是不安全的,但Rust无法推理其实现。关键在于,如果实现经过程序员检查确认正确,那么所有可能的API使用方式都是安全的。

为了实现内部可变性,你需要使用unsafe关键字来包装实现。unsafe是连接“无共享访问的API”和“有共享可变访问的实现”之间的桥梁。unsafe就像一个标签,表明程序员需要手动检查所有unsafe代码块的安全性。Rust编译器不保证这些unsafe块的安全性,但用户只需要检查这些标记出来的片段即可。

并发示例:安全的锁 🛡️

最后,我们来看一个与并发相关的例子。如前所述,C++的锁是不安全的,因为它可以解引用底层值,并且其指针可能被泄漏。但这不应该发生,因为在释放锁之后,根本不应该再访问底层值。

为了在Rust中禁止这种情况,它实现了所谓的Deref trait。你可以从锁保护对象(MutexGuard)中解引用出底层值。但解引用得到的引用有一个生命周期,它不能超过保护对象本身。这是由Rust类型系统保证的。

例如,以下代码试图让一个引用(data_ref)的生命周期超过其来源的保护对象(guard),Rust编译器会检测到这种所有权/生命周期问题并拒绝编译。

// 假设的伪代码,展示概念
let guard = mutex.lock().unwrap();
let data_ref: &i32 = &*guard; // 从guard解引用得到引用
drop(guard); // 提前释放guard
println!("{}", data_ref); // 错误!data_ref的生命周期不能超过guard

正如你所见,所有像这样的并发难题都可以用Rust类型来推理。这就是为什么我们将使用Rust作为本并发课程的实现语言。

总结与下节预告 🎯

本节课我们一起学习了Rust的所有权类型系统。Rust的动机是在存在共享可变资源的情况下,同时实现安全控制

Rust的关键思想是:

  1. 首先强制执行所有权规则:默认情况下,这意味着不应该有共享可变访问。
  2. 同时允许通过内部可变性来“弯曲”规则:这允许你以非常受控的方式管理共享可变访问。

其好处是:

  • 你可以安全地分析可变访问的安全性。如果内部可变性在一个类型(例如锁或RefCell)中安全地实现,那么该库的用户就不再需要担心程序的安全性,他们可以安全地使用而无需任何顾虑,因为API是安全的。如果底层不安全的实现是正确的,那么所有可能的API使用方式都是安全的,这是由Rust编译器保证的。
  • 其次,存在不安全的实现,但它们被明确标记了出来。你只需要手动检查那些代码,而不用担心其余代码的安全性。

这正是我们将使用Rust进行并发编程(其核心就是共享可变访问)的两个主要原因。

在下一讲中,我们将继续学习基于锁的并发,但这次是使用安全的API。你们许多人已经知道线程、原子变量、互斥锁或条件变量,但这次你们将使用安全的API重新学习这些概念。这将是下一个视频的主题。

7:基于锁的并发与std库API 🧵

在本节课中,我们将要学习Rust标准库(std)中提供的一些核心并发原语。这些库的关键在于,虽然其内部实现可能涉及不安全代码,但它们通过安全的API封装起来。这意味着用户在使用这些API时,完全无需担心其内部实现的安全性,只要正确使用API,就不会引发未定义行为或其他不安全行为。这是Rust提供的关键抽象。

接下来,我们将通过一个具体的示例程序来逐步解析这些API。

示例程序解析

以下是一个使用std::threadArc(原子引用计数)的简单并发程序:

use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
    let five = Arc::new(5);
    for _ in 0..10 {
        let five = Arc::clone(&five);
        thread::spawn(move || {
            println!("{}", five);
        });
    }
    thread::sleep(Duration::from_secs(1));
}

这个程序创建了一个值为5的Arc,然后循环10次,每次克隆这个Arc并生成一个新线程。在每个线程中,打印出Arc持有的值。最后,主线程休眠1秒等待所有子线程完成。

上一节我们介绍了程序的目标,本节中我们来看看其内部工作原理。

Arc(原子引用计数)的工作原理

Arc<T> 是一个线程安全的引用计数指针。它的核心作用是允许多个线程安全地共享同一数据的所有权。

  • 初始状态:当 let five = Arc::new(5); 执行时,创建了一个指向整数5的Arc,其引用计数为1。
  • 克隆(Clone):在循环中,let five = Arc::clone(&five);增加底层数据的引用计数(从1变为2),并返回一个新的Arc实例。这个新实例指向同一个整数5。
  • 移动(Move)到线程move关键字表示闭包将获取(移动)其捕获变量的所有权。这里,新克隆的Arc实例被移动到新生成的线程中。
  • 线程结束与丢弃(Drop):当每个线程执行完毕,其作用域内的Arc变量会被丢弃。ArcDrop实现会减少引用计数。
  • 最终清理:循环结束后,主线程中原始的five变量(引用计数为1)在main函数结束时被丢弃,引用计数减为0,此时底层数据5才被真正释放。

因此,整个过程中引用计数的变化是:初始1 -> 克隆10次后变为11 -> 10个子线程结束各减1 -> 主线程结束减1 -> 最终为0并清理数据。

核心API详解

理解了示例的流程后,我们来深入看看其中用到的几个核心API及其约束。

1. std::thread::spawn API

spawn函数用于生成一个新线程。它的函数签名精确定义了安全使用的条件:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

以下是该函数签名中各个约束条件的解释:

  • F: FnOnce() -> T:参数 f 必须是一个可调用一次(FnOnce)的闭包或函数,它不接受参数并返回一个类型为 T 的值。
  • F: Send + 'static:闭包 f 必须满足两个特质(Trait)约束。
  • T: Send + 'static:闭包的返回值 T 也必须满足同样的约束。

那么,Send'static 生命周期具体意味着什么呢?我们通过两个例子来说明。

Send 特质的意义

Send 标记一个类型可以安全地跨线程边界转移所有权。我们用一个反例来说明:

use std::rc::Rc; // 非原子引用计数,非 Send
use std::thread;

fn main() {
    let five = Rc::new(5);
    thread::spawn(move || { // 编译错误!
        println!("{}", five);
    });
}

这段代码无法编译。错误信息会指出 Rc<i32> 不能安全地在线程间发送(Rc<i32> cannot be sent between threads safely)。因为 Rc 的引用计数操作是非原子的,不能承受多线程并发修改,所以它没有实现 Send 特质。而 spawn 要求闭包是 Send 的,因为它要被移动到另一个线程执行,因此编译器拒绝了此代码。与之相对,Arc 使用了原子操作管理计数,实现了 Send,因此可以用于多线程。

'static 生命周期的意义

'static 生命周期要求数据在整个程序运行期间都有效。这防止了悬垂指针。看另一个反例:

use std::thread;

fn main() {
    let five = 5;
    let ref_to_five = &five;
    thread::spawn(move || {
        println!("{}", ref_to_five); // 编译错误!
    });
}

这段代码也会编译失败。错误信息提示 five 的生命周期不够长。因为 ref_to_five 是对局部变量 five 的引用,其生命周期仅限于 main 函数。而新线程的执行时间是不确定的,它可能在 main 函数结束、five 被销毁后才尝试访问这个引用,从而导致悬垂指针。'static 约束强制要求闭包捕获的数据必须拥有静态生命周期(例如直接拥有所有权的数据、Arc 等),从而避免了此类问题。

总结一下spawn API 通过 Send'static 约束,在编译期就保证了线程间数据传输的安全性和数据的有效性,将常见的并发错误扼杀在编译阶段。

2. Arc<T> 的约束

Arc<T> 本身也有约束,它要求其内部类型 T 满足特定条件,才能保证整体的线程安全:

impl<T> Arc<T> {
    pub fn new(data: T) -> Arc<T>
    where
        T: Send + Sync + 'static,
    { ... }
}
// 实际上,Arc<T> 实现 Send 和 Sync 的条件是:
// T: Send + Sync + 'static

这意味着:

  • 只有当 TSend 时,将 Arc<T> 移动到另一个线程才是安全的。
  • 只有当 TSync 时,通过 Arc 的不可变引用(&Arc<T>)在多线程间共享访问 T 才是安全的。
  • 'static 约束通常与 T 本身是否包含引用相关。

例如,i32 实现了 SendSync,所以 Arc<i32> 也自动实现了 SendSync,可以安全地用于我们的示例程序。

关键概念:SendSync 特质

SendSync 是Rust并发编程的基石标记特质(Marker Trait)。

  • Send:如果一个类型 T 实现了 Send,意味着将 T 的所有权从一个线程转移到另一个线程是安全的。绝大多数Rust类型都是 Send 的。反例是包含非原子引用计数(Rc)或裸指针(*mut T)而没有特殊同步的类型。
  • Sync:如果一个类型 T 实现了 Sync,意味着通过不可变引用(&T)在多线程间共享访问是安全的。这通常意味着 T 的内部状态要么是不可变的,要么是通过线程安全的方式(如互斥锁 Mutex)保护的。

它们之间存在一个重要的理论关系,这个关系也体现在标准库的源码中:

// 如果 &T 是 Send 的,那么 T 就是 Sync 的。
// 这意味着可以安全地将 &T 的引用跨线程传递。
unsafe impl<T: ?Sized + Sync> Send for &T {}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/09c92e290f820bf38997580ee22fd04c_67.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/09c92e290f820bf38997580ee22fd04c_69.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/09c92e290f820bf38997580ee22fd04c_71.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/09c92e290f820bf38997580ee22fd04c_73.png)

// 这个关系是定义性的:T 是 Sync 当且仅当 &T 是 Send。
// 用代码表示逻辑关系,并非直接实现。
// `Sync` 的定义本质上等价于要求 `&T: Send`。

简单记忆

  • Send 关乎所有权移动(线程间传递值)。
  • Sync 关乎引用共享(线程间共享 &)。
  • 一个类型是 Sync 的,等价于它的不可变引用是 Send 的。

理解并正确应用 SendSync,是设计和实现安全Rust并发库与数据结构的关键。

总结

本节课中我们一起学习了Rust标准库中基于锁的并发编程基础。我们通过一个具体的多线程示例,深入剖析了:

  1. std::thread::spawn:用于创建新线程,其 Send + 'static 约束在编译期保障了线程安全。
  2. std::sync::Arc:原子引用计数智能指针,用于多线程间安全共享数据所有权。
  3. SendSync 标记特质:并发安全的类型系统基石,Send 允许跨线程转移所有权,Sync 允许跨线程共享不可变引用。

Rust并发库的核心哲学是:将潜在不安全的实现用安全的API封装起来。编译器通过类型系统和特质约束(如 Send, Sync, 'static),在编译阶段就排除了数据竞争、悬垂指针等大量经典并发错误,使得开发者能够以更高的信心编写并发程序。这些概念不仅是Rust特有的,也深刻反映了系统级并发编程的通用原则。

8:基于锁的并发与parking_lot库API 🧠

在本节课中,我们将继续学习Rust中的并发库,重点介绍一个名为parking_lot的库。parking_lot提供了几个非常基础的并发原语,包括互斥锁、条件变量和读写锁。虽然它提供了更多功能,但本节我们只学习这三个核心部分。

Mutex(互斥锁)🔒

上一节我们介绍了Arc,本节我们来看看如何使用parking_lot中的Mutex来保护共享数据。Mutex允许你通过不可变引用可变地访问其内部数据,其关键在于访问数据前必须获取锁。锁的获取是独占的,因此在持有锁期间,其他线程无法访问内部数据,从而保证了线程安全。

以下是Mutex的一个典型使用示例,该示例复制自parking_lot的官方文档:

use parking_lot::Mutex;
use std::sync::{Arc, mpsc::channel};
use std::thread;

const N: usize = 10;

let data = Arc::new(Mutex::new(0));
let (tx, rx) = channel();

for _ in 0..N {
    let (data, tx) = (Arc::clone(&data), tx.clone());
    thread::spawn(move || {
        let mut data = data.lock();
        *data += 1;
        if *data == N {
            tx.send(()).unwrap();
        }
    });
}

rx.recv().unwrap();

让我们分析这段代码:

  1. 创建受保护的数据Mutex::new(0)创建了一个初始值为0、受互斥锁保护的数据。随后用Arc将其包装,使得这个被保护数据的引用可以被安全地复制并跨线程共享,而无需担心其生命周期。
  2. 创建线程:程序创建了10个线程。每个线程都获得一个指向共享数据的Arc引用和一个通道的发送端。
  3. 获取锁与修改数据:在每个线程中,data.lock()尝试获取锁。成功后会返回一个MutexGuard(在代码中命名为data),它是已持有锁的证明。通过这个守卫,可以安全地修改底层数据(*data += 1)。
  4. 发送信号:最后一个将计数器增加到N的线程,会通过通道发送一个信号。
  5. 自动释放锁MutexGuard在作用域结束时(即}处)会被自动丢弃(drop),锁也随之自动释放。这是Rust所有权系统带来的优势,避免了手动释放锁可能导致的错误。
  6. 主线程接收:主线程通过通道接收信号,得知所有线程已完成工作。

parking_lotMutex与自旋锁(spinlock)的主要区别在于阻塞行为:自旋锁在无法获取锁时会循环忙等待;而Mutex在无法获取锁时会让线程休眠,不消耗CPU时间。

关于Mutex的更多API,请查阅其官方文档。

Condvar(条件变量)⏳

接下来,我们学习parking_lot的第二个API:条件变量(Condvar)。条件变量用于让线程等待某个特定条件发生,在等待期间线程会进入休眠状态,不消耗CPU资源。条件变量几乎总是与Mutex配合使用。

以下是条件变量的一个示例:

use parking_lot::{Condvar, Mutex};
use std::sync::Arc;
use std::thread;

let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);

thread::spawn(move || {
    let (lock, cvar) = &*pair2;
    let mut started = lock.lock();
    *started = true;
    cvar.notify_one();
});

let (lock, cvar) = &*pair;
let mut started = lock.lock();
while !*started {
    cvar.wait(&mut started);
}

让我们分析其工作流程:

  1. 关联锁与条件变量:创建一个元组,包含一个Mutex<bool>和一个Condvar,并用Arc包装以便共享。
  2. 线程设置条件:新线程获取锁,将布尔值设为true,然后通过cvar.notify_one()通知(唤醒)一个正在等待此条件变量的线程。
  3. 主线程等待条件:主线程获取锁后,检查条件(*started)。如果条件为假,则调用cvar.wait(&mut started)进行等待。
    • 关键点wait方法接收一个MutexGuard的可变引用。在调用wait时,它会自动释放传入的锁,并让线程休眠。
    • 当其他线程调用notify_one()时,等待的线程被唤醒。在wait方法返回前,它会自动重新获取锁。
  4. 条件保证:当wait返回后,由于是在被通知后唤醒并重新持有锁,因此可以保证此时*started一定为true

条件变量API的一个安全特性体现在wait函数签名上:它要求一个MutexGuard的可变引用(&mut MutexGuard)。这防止了在等待前后持有对受保护数据的普通引用,因为等待期间锁会被释放和重新获取,数据可能被其他线程修改,持有旧引用是不安全的。Rust的类型系统在此确保了安全。

parking_lot的条件变量主要提供安全API,足以应对大多数场景。

RwLock(读写锁)📖✏️

最后,我们学习读写锁(RwLock)。它与Mutex类似,但区分了读操作和写操作:

  • 读锁:可以被多个线程同时获取,用于只读访问。
  • 写锁:是独占的,同一时间只能有一个线程持有写锁,用于写入访问。

这对于“读多写少”的数据访问模式非常高效。以下是示例:

use parking_lot::RwLock;

let lock = RwLock::new(5);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/e00c7c1043418153f8c6b04611165f3d_14.png)

// 多个线程可以同时获取读锁
{
    let r1 = lock.read();
    let r2 = lock.read();
    assert_eq!(*r1, 5);
    assert_eq!(*r2, 5);
} // 读锁在这里自动释放

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/e00c7c1043418153f8c6b04611165f3d_16.png)

// 同一时间只能有一个线程获取写锁
{
    let mut w = lock.write();
    *w += 1;
    assert_eq!(*w, 6);
} // 写锁在这里自动释放

总结 🎯

本节课我们一起学习了parking_lot库提供的三个核心、安全的并发原语:

  1. Mutex:提供独占访问,通过MutexGuard的自动释放来安全管理锁。
  2. Condvar:与Mutex配合,用于线程间等待和通知条件达成,其API设计避免了等待期间的数据引用安全问题。
  3. RwLock:区分读写访问,允许多个读锁或一个写锁,适用于读多写少的场景。

这些原语的API设计充分利用了Rust的所有权和类型系统,在编译期就能防止许多常见的并发错误。在下一节课中,我们将继续探索其他并发库。

9:基于锁的并发与Spinlock/Rayon API 🧵

在本节课中,我们将学习课程仓库中实现的自旋锁(Spinlock),以及构建在其他并发库之上的并行库 Rayon 的 ParallelIterator。在大多数情况下,人们希望使用并行库,因为它将并发细节抽象到库中。我们将展示使用 Rayon 的并行迭代器是多么简单。

自旋锁(Spinlock)介绍 🔄

首先,我们来介绍课程仓库中的自旋锁。你可以在 log 文件夹和 source 文件夹下搜索 spinlock.rs 找到它。它定义了一个自旋锁,其特点是在一个循环中不断尝试获取锁,这也是它被称为“自旋”锁的原因。

自旋锁的核心是一个布尔值,但这个布尔值可以被多个线程访问,这是它与普通布尔值的唯一区别。它是一个原子布尔类型(AtomicBool)。

这个布尔值表示锁是否被持有。如果为 true,则表示锁已被某个线程持有;如果为 false,则表示锁未被任何人持有。这就是自旋锁内部布尔值的含义。

自旋锁的默认值与初始化 📝

Default trait 为自旋锁定义了默认值。在这个实现中,默认值是 false,因为在执行开始时,自旋锁没有被任何人持有。

impl Default for Spinlock {
    fn default() -> Self {
        Spinlock { inner: AtomicBool::new(false) }
    }
}

如果你已经阅读过 Rust 的相关书籍,可以立即看出这段代码的语法成分。Default trait 定义在标准库中,用于指定一个类型的默认值。这里的 self 指的是 Spinlock 类型本身,它创建了一个 Spinlock 实例,其内部值初始化为 false

自旋锁的加锁与解锁操作 🔐

接下来,我们定义自旋锁的加锁和解锁操作。

lock 函数被定义在这里。它需要一个类型参数 TokenToken 基本上是由 lock 函数返回的一个值。lock 函数不仅获取锁,还会返回一个类似收据或证明的东西,表明锁被你持有。Token 就扮演了这个角色。对于自旋锁,Token 只是一个没有实际意义的唯一值;但对于其他类型的锁,可能会有更复杂的 Token。因此,对于自旋锁,你可以安全地忽略 Token 的内容。

让我们先忽略 Token,看看 lockunlock 函数。

lock 函数中,我们定义了一个名为 backoff 的结构。backoff 是一种数据结构,它让处理自旋循环变得更容易。在自旋循环中,不鼓励尽可能快地自旋,因为当某些条件未满足(特别是锁未被获取)时,在短时间内你很可能再次无法获取锁。因此,如果你没有获取到锁,等待一小段时间是有益的。backoff 正是做这件事的:当调用 backoff.spin() 时,它会等待一小会儿(可能是微秒或毫秒级)。它还会指数级增加等待延迟。一开始,它可能立即返回,希望锁只是暂时被其他人持有;但如果你尝试了一次又一次,多次调用 backoff,它会认为“我可能不会很快获取到锁”,因此会等待更久,甚至可能将 CPU 时间让给其他线程(yield)。这就是自旋循环中指数退避的机制。

以下是自旋循环的代码:

let mut backoff = Backoff::new();
loop {
    if self.inner.compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed).is_ok() {
        return Token { _private: () };
    }
    backoff.spin();
}

它尝试原子地将 false 值交换为 true。这些操作的确切含义将在后续视频中讨论,但目前只需记住:lock 函数尝试原子地将 false 替换为 true。如果成功,则返回。这是合理的,因为这个函数原子地将 false 替换为 true,意味着锁被某人持有,而那个人就是我。

unlock 函数非常相似,它只是尝试将 false 值存储到原子布尔中。

unsafe fn unlock(&self, _token: Token) {
    self.inner.store(false, Ordering::Release);
}

这意味着:“我完成了对内部数据的访问,因此我通过存储 false 值来释放锁。” 这有效地向其他线程表明,锁从现在起由我释放,可以被你获取。

这个函数被标记为 unsafe,因为它的函数签名并不总是能保证 API 的安全使用。例如,你不应该解锁一个未被你获取的自旋锁。同时,你必须提供与 lock 函数返回的相同的 Token。这些并不是由 Rust API 保证的,而是需要 API 使用者来满足的契约。这就是它被标记为 unsafe 的原因。其实现并非 100% 安全,因为它有一个契约需要 API 使用者满足,即:由 lock 函数给出的 Token 应该作为参数传递给这里的 unlock 函数。

尝试获取锁 🎯

还有其他函数尝试获取锁,而不是在这里自旋循环。例如 try_lock 函数:

fn try_lock(&self) -> Option<Token> {
    if self.inner.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed).is_ok() {
        Some(Token { _private: () })
    } else {
        None
    }
}

它与上面的实现几乎相同,因此在本课程中不详细解释。compare_exchange 的含义以及 AcquireRelease 排序的含义将在后续视频中讨论。

高级锁 API 的引入 🚀

上一节我们研究了自旋锁的 API 以及它们需要被标记为 unsafe 的原因。现在,我将解释一个更高级的自旋锁 API,它消除了使用 unsafe 标记的必要性。

这是更高级的 API,它定义了一个 trait RawLock,所有原始锁都应该满足这个 trait。

pub unsafe trait RawLock {
    type Token;
    fn lock(&self) -> Self::Token;
    unsafe fn unlock(&self, token: Self::Token);
}

这里我们定义了 Token 类型和 lock 函数(返回 Token),以及 unlock 函数。正如之前解释的,unlock 函数在这里被标记为 unsafe,并且只有在与对应 lock 函数给出的 Token 一起使用时才是安全的。这一点在这里有文档说明,但不由 Rust 保证,期望用户满足这个安全条件。

高级 API 的优势:满足锁的保证 ✅

回忆一下之前的讲座,我们讨论过锁必须满足两个保证:

  1. 加锁和解锁必须匹配。这在这里体现为:lock 函数返回的 Token 应该提供给 unlock 函数,并且必须匹配。这在这个 API 的安全保证中有所体现。
  2. 用户应满足的另一个保证是:受自旋锁保护的数据应该与保护它的锁相关联。这两个方面的保证或契约在使用这个高级 API 时会自动得到满足。

让我解释一下这里的 struct,它由锁和数据组成。正如我解释的,锁和数据应该成对出现,它们是一枚硬币的两面,所以我们需要将它们放入一个 struct 中。

pub struct Lock<L: RawLock, T> {
    lock: L,
    data: UnsafeCell<T>,
}

这里我把 T 包装在 UnsafeCell 中,这意味着它具有内部可变性。这是完全可以的,因为在使用锁时,你将仅通过共享引用来可变地访问内部数据。这就是为什么我们需要将 T 放入 UnsafeCell 中,以表明它是内部可变的数据。在 Rust 中,当你想要定义内部可变性时,你会将数据放入 UnsafeCell 中。目前只需记住这一点,如果你对技术细节感兴趣,请在 Google 上搜索 “unsafe cell interior mutability”。

锁类型的 Send 与 Sync 标记 📦

这是标记锁类型为 SendSync 的必要代码行。

unsafe impl<L: RawLock + Send, T: Send> Send for Lock<L, T> {}
unsafe impl<L: RawLock + Sync, T> Sync for Lock<L, T> {}

底层类型 T 必须是 Send(可发送的),因为它被多个线程访问。然而,T 不需要是 Sync(可同步的),这意味着不存在多个线程同时访问数据 T 的情况。这是由自旋锁或其他锁的实现保证的。在自旋锁中,当一个线程获取锁时,它是唯一持有锁的线程,因此其他线程不可能同时访问 T 的相同数据。这就是为什么它不需要是 Sync 的原因。

此外,它们被标记为 unsafe,因为正如我所说,这些保证是由锁的实现保证的,而不是由 Rust 类型系统自动保证的。它是由我们自己的、需要手动检查的实现来保证的。这就是为什么这些行应该被标记为 unsafe,以表明“我需要手动检查锁类型实际上是 SendSync 的”。

锁的创建、销毁与守卫 🛡️

当你使用一个原始锁类型(例如自旋锁)和一个数据类型 T 来创建锁时,你会得到一个类型为 T 的数据,它是受此锁保护的初始值。锁将是默认值(对于自旋锁是 false)。此外,数据也被初始化,它被包装在 UnsafeCell 中,并被赋予数据值。

你可以销毁锁并获取其内部数据 T。这是实现该功能的 API。当你拥有这个锁的完全所有权时,你可以直接销毁锁并获取内部数据,这样它就不再受自旋锁或其他类型锁的保护。

这是此类锁类型最有趣的 API 之一。当你调用 lock 方法时,它不仅获取内部锁,还会返回一个 LockGuard(锁守卫)。LockGuard,正如我们在上一讲中讨论的,是你已获取锁的证明。因此,它持有锁本身和 TokenTokenlock 返回,它部分证明了你已获取锁,并且应该被提供给 unlock 函数,所以它应该被记住在这个锁守卫内部。

到目前为止一切顺利。这是一种保证或证明锁已被获取的类型。我们如何使用它呢?在锁类型中,它有一个静态保证的范围,用于限定这个 LockGuard 的生命周期。回忆一下 Rust 书中的内容:当作用域在这里使用时,这个结构体被保证不会超出给定的生命周期 's。锁守卫应该在生命周期 's 结束之前被丢弃。这就是这个作用域的含义。

这个作用域在这里,因为你使用一个生命周期来获取锁。这是锁的生命周期,它在这里被不可变地借用。这个作用域在这里,锁守卫的作用域只是被复制并粘贴到这个锁守卫中。它是省略的,但仍然保证这个锁守卫不会比这里的锁的生命周期活得更久。这是非常符合预期的,因为锁守卫不应该比锁本身活得更久。它由这里描述的生命周期来保证。

锁守卫还有一个对锁的引用,这基本上就是为什么 's 出现在这个锁守卫的实现中。你还有一个 Token,它保证应该被提供给解锁函数。

这里有很多函数,但代码中最有趣的部分是这个 Drop 实现。

impl<'s, L: RawLock, T> Drop for LockGuard<'s, L, T> {
    fn drop(&mut self) {
        unsafe {
            self.lock.unlock(self.token);
        }
    }
}

当锁守卫被丢弃或销毁时,正如我们讨论的,底层的锁应该被自动释放。我们如何做到这一点?我们通过使用 Token 调用解锁函数来实现。这就是全部,我们只是解锁它。这是我们在锁守卫结束时唯一需要做的事情。

注意,这里的解锁调用被包装在 unsafe 块中,因为正如我们讨论的,内部锁的 unlock 函数是 unsafe 的,它需要保证 Token 应该与 lock 函数返回的那个相同。但这由我们的实现保证。这里的 Token 实际上就是由 lock 函数返回的那个。这就是为什么我们满足了原始锁 API 设定的保证或契约。这就是为什么我们不需要将这个函数标记为 unsafe。丢弃一个锁守卫总是安全的,因为底层数据结构的不变式是由我们自己保证的。

因此,你需要看到这里的 unsafe 包装器,这正是我们需要手动检查是否满足保证的地方。

使用锁守卫访问数据 🔓

那么如何使用这个锁守卫呢?这两个 Deref 实现是问题中最重要和有趣的部分。

impl<'s, L: RawLock, T> Deref for LockGuard<'s, L, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.lock.data.get() }
    }
}
impl<'s, L: RawLock, T> DerefMut for LockGuard<'s, L, T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.lock.data.get() }
    }
}

当你有一个锁守卫时,你可以解引用内部数据类型。正如我所说,锁守卫保证你已经获取了锁,因此你可以可变或不可变地访问内部数据。这由这两个函数保证。所以,当你有一个锁守卫时,你可以解引用内部数据。其实现是 unsafe 的,因为你可以访问内部数据这一事实是由锁的实现保证的。同样,你可以可变地解引用内部数据,其实现也是 unsafe 的,原因相同:你是唯一访问数据的人,这个不变式是由自旋锁的实现保证的,而不是由 Rust 的类型系统自动保证的。

它还有其他类型的 API,使我们的生活更轻松。请在你愿意的时候也看看其他代码。这基本上就是自旋锁的 API 和锁的高级 API 的一些实现细节。

从底层并发到高级并行 ✨

到目前为止,我们讨论了许多并发库的低级 API,它们非常底层,涉及锁、线程和条件变量等。现在,让我展示一个非常高级的并行库 API 的示例。

Rayon 的 ParallelIterator 就是这种范例的缩影。从现在开始,我根本不想讨论并发。我只有一个从 0 到 100 的包含 100 个元素的区间,我只想并行地对其执行一个操作。这就是我想在这段代码中实现的想法。如何实现?

如果要用低级 API 实现,我们需要以某种方式同步多个线程并将工作分配给多个线程。但这是非常底层的细节。我希望库能替我处理这些,而不是我自己来做。

这行代码就做到了这一点:

(0..100).into_par_iter().for_each(|i| {
    println!("{}", i);
});

这行代码自动将工作并行化到多个线程。如果你的 CPU 有,例如,6 个线程,那么它会将工作分配到 6 个线程上。它会自动检测你拥有的 CPU 核心和线程数,并自动将工作分配给多个线程。

这就是全部,这就是并行化你的工作负载所需的全部代码。

以下是运行示例中复制粘贴的代码。它实际上是并行执行的。从这里到这里,你看不出它是否是真正并行的。但这里有一些稍后执行的值,这恰恰是并行性生效的证据。范围从 50 到 74 的元素在不同的线程中执行,但它们被调度得更晚,这就是为什么它们没有在 75 之前执行。所以这基本上证明了这段代码是并行化的,并且在多个线程中执行,而无需了解任何底层细节。它是非常高级的,甚至没有提到线程,但工作自动分布在多个线程中。

进一步的好处是,这个 API 是 100% 安全的。当你使用 into_par_iter 时,你不需要担心这个库的正确性,库作者应该已经在他们的实现中考虑了安全性。

Rayon 在底层是建立在 crossbeam 之上的,crossbeam 是 Rust 的一个重量级并发库。在下一个视频中,我们将学习 crossbeam 及其一些 API。

总结 📚

本节课中,我们一起学习了:

  1. 自旋锁(Spinlock) 的基本概念与底层实现,包括其基于原子布尔值的状态管理、指数退避的自旋循环,以及需要手动匹配的 lock/unlock 操作(标记为 unsafe)。
  2. 高级锁抽象 API 的设计,它通过 RawLock trait 和 Lock 结构体将锁与其保护的数据绑定,并利用 LockGuard 和 Rust 的生命周期机制,自动管理锁的获取与释放,从而提供了安全、易用的接口。
  3. 并行库 RayonParallelIterator 展示了如何通过高级抽象(如 into_par_iter().for_each(...))轻松实现数据并行,而无需直接处理线程创建、任务分配等底层并发细节,极大地简化了并行编程。

我们从底层的锁原语开始,逐步上升到高级的并行迭代器,看到了 Rust 并发编程中从手动控制到自动管理的抽象层次。理解底层机制有助于我们编写正确高效的并发代码,而利用高级抽象则能让我们专注于业务逻辑,提升开发效率。在接下来的课程中,我们将深入探讨支撑这些高级抽象(如 Rayon)的底层并发库。

10:基于锁的并发与Crossbeam API 🛠️

在本节课中,我们将学习Rust中广泛使用的并发库——Crossbeam。它是许多Rust程序(例如Firefox)中部署的关键库。作为该库的维护者之一,我将分享其内部实现细节,这也是我们在此研究它的原因。

Scoped Thread API 🧵

上一节我们介绍了Rust标准库中的线程。本节中我们来看看Crossbeam如何通过scoped thread来放宽标准库线程的某些限制。

在Rust标准库中,std::thread::spawn函数要求闭包F和其返回类型必须是'static的。这意味着数据必须能够移动到程序的整个生命周期。但这有时限制过强。例如,我们可能希望声明栈变量并在多个线程间共享它。标准库线程不允许这样做,因为函数类型和返回类型必须是静态作用域的。

相反,Crossbeam的scoped thread放宽了此约束,允许以安全的方式共享作用域内的变量。以下是一个来自Crossbeam文档的示例:

use crossbeam::thread;

let greeting = String::from("Hello, world!");

thread::scope(|s| {
    s.spawn(|_| {
        println!("Thread 1 says: {}", greeting);
    });
    s.spawn(|_| {
        println!("Thread 2 says: {}", greeting);
    });
}).unwrap();

此代码创建了一个字符串greeting,并在两个线程中共享它。每个线程都不可变地借用greeting变量并将其打印到屏幕。使用标准库的thread::spawn函数无法实现此操作,因为它要求闭包是'static的。

为什么scoped thread是安全的?原因在于作用域s在生成线程之前定义,并且scope函数保证在其闭包结束时,所有在其内部生成的线程都将被等待(join)。因此,在作用域结束后,不再存在对greeting变量的引用。这确保了所有线程在greeting被丢弃之前都已退出。

在C++等语言中,这种共享通常很难实现,因为对生命周期和作用域的微小误解就会导致程序错误。然而,在Rust中,scoped thread API提供了安全的抽象,使我们能够以安全的方式在多个线程间共享数据,而无需担心潜在的不安全行为。

Cache Padding API 🧱

在并发编程中,伪共享是一个常见问题。本节中我们来看看Crossbeam如何通过CachePadded类型来缓解此问题。

伪共享发生在多个处理器(或线程)访问同一缓存行中的不同数据时。例如,假设有三个线程P1、P2、P3,它们分别访问自己的数据,但这些数据位于同一缓存行中。如果P2向其数据写入值,则其他线程的缓存行将因此失效,即使它们访问的是不同数据。这会严重影响性能,因为线程的操作会相互干扰。

因此,在并发编程中,应确保多个线程访问不同的缓存行。CachePadded类型通过将底层数据T填充到缓存行边界来实现这一点。如果底层数据T的大小为8字节,CachePadded<T>的大小将是64字节或128字节(取决于架构),即在类型T周围填充零以使其大小为缓存行的倍数。

例如,在并发队列的实现中,队头指针和队尾指针需要被并发访问。如果它们位于同一缓存行中,对队头的操作和对队尾的操作会相互干扰。通过使用CachePadded分别包装这两个指针,可以确保它们位于不同的缓存行中,从而避免伪共享。

use crossbeam::utils::CachePadded;

struct Queue<T> {
    head: CachePadded<AtomicPtr<Node<T>>>,
    tail: CachePadded<AtomicPtr<Node<T>>>,
}

当怀疑存在伪共享时,请使用CachePadded。它易于使用,并能有效提升并发数据结构的性能。

Channel API 📨

我们已经对通道有所了解。本节中我们来看看Crossbeam通道提供的更通用功能。

Rust标准库提供了MPSC(多生产者单消费者)通道。Crossbeam通道则是MPMC(多生产者多消费者)的,这是最通用的通道形式。这意味着多个线程可以同时向通道发送数据,也可以同时从通道接收数据。

你可以通过unbounded函数创建一个无界通道(容量无限),也可以通过bounded函数创建一个有界通道。当有界通道已满时,尝试发送数据将返回错误。

use crossbeam::channel;

let (sender, receiver) = channel::unbounded();

// 多个线程可以持有 sender 的克隆并发送数据
let sender2 = sender.clone();
std::thread::spawn(move || {
    sender2.send(42).unwrap();
});

// 多个线程也可以接收数据
std::thread::spawn(move || {
    let msg = receiver.recv().unwrap();
    println!("Received: {}", msg);
});

通道的使用非常简单,但其内部实现较为复杂,我们将在课程后期讨论。

Crossbeam通道一个非常有趣的功能是提供了select!宏。它可以同时尝试从多个通道接收(或发送)数据,比循环检查所有通道更高效。

use crossbeam::channel::{self, select};

let (s1, r1) = channel::unbounded();
let (s2, r2) = channel::unbounded();

// ... 在其他线程中向 s1 和 s2 发送数据 ...

select! {
    recv(r1) -> msg => println!("Received from r1: {:?}", msg),
    recv(r2) -> msg => println!("Received from r2: {:?}", msg),
}

在实现Actor模型或其他库时,通道的select功能将非常有用,我们将在课程后期讨论这一方面。

总结 📝

本节课中我们一起学习了Crossbeam库的三个关键API:

  1. Scoped Thread:允许安全地在线程间共享作用域内数据,通过确保线程在数据丢弃前结束来保证安全。
  2. Cache Padding:通过填充数据到缓存行边界来避免伪共享,提升并发数据结构的性能。
  3. Channel:提供了MPMC(多生产者多消费者)通道,以及强大的select!宏,用于高效处理多个通道。

Rust的并发库通常都有完善的文档,清晰地解释了各种概念和用法。请阅读相关文档以深入学习。如果遇到问题,可以在项目的issue跟踪器中提问。

11:共享内存并发的非确定性

在本节课中,我们将学习共享内存并发编程中的非确定性行为,特别是由指令重排引起的“宽松行为”。我们将通过示例理解这些问题,并学习如何使用内存排序原语(如“获取”和“释放”)来约束这些行为,从而编写出正确且高效的并发程序。


概述:共享内存的挑战

在共享内存并发编程中,我们面临两个主要的非确定性来源:

  1. 线程交错:多个线程的指令以不确定的顺序交错执行。
  2. 指令重排:单个线程内的指令可能被编译器或处理器重新排序。

线程交错是并发编程的固有特性,而指令重排则是为了优化性能,但在并发环境下可能破坏程序的正确性。本节课我们将重点探讨后者。


自旋锁示例:为何需要内存排序

在上一节中,我们讨论了自旋锁需要使用“获取”和“释放”排序进行标注。让我们通过一个简化的例子来理解其必要性。

以下是自旋锁 lockunlock 函数的伪代码:

// lock 函数
fn lock(lock: &AtomicBool) {
    while lock.compare_and_swap(false, true, Ordering::Acquire) != false {
        // 自旋等待
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/3d9859334b93c3a50952732df984ab76_8.png)

// unlock 函数
fn unlock(lock: &AtomicBool) {
    lock.store(false, Ordering::Release);
}
  • lock 函数尝试原子地将锁变量从 false 改为 true,使用 Acquire 排序。
  • unlock 函数将锁变量存回 false,使用 Release 排序。

这个算法的逻辑是清晰的:只有一个线程能成功将锁从 false 改为 true,从而获得锁。释放锁时,将值设回 false

那么,AcquireRelease 排序的含义是什么?如果没有它们会发生什么?


问题根源:指令重排

为了理解内存排序的作用,我们将自旋锁保护共享数据的场景简化为一个“消息传递”示例。

假设有两个线程(Thread 1 和 Thread 2)和一个共享变量 data(初始值未定义),以及一个作为信号量的 flag 变量(初始为 false)。

// Thread 1
data = 42;
flag.store(true, Ordering::Relaxed); // 类似于 unlock 中的 store(false)

// Thread 2
while !flag.load(Ordering::Relaxed) { // 类似于 lock 中的循环
    // 自旋等待
}
assert!(data == 42); // 断言:此时 data 应该为 42

这个代码的问题在于,指令可能会被重排。

  • Thread 1 中,data = 42flag.store(true) 是向不同地址的两次存储。编译器和处理器可能会为了效率将这两条指令重排,导致 flag 先被设为 true
  • Thread 2 中,flag.load()assert!(data == 42) 是一次加载和一次读取。它们也可能被重排,导致先执行断言。

如果发生任何一种重排,Thread 2 的断言就可能失败,因为它可能在 data 被正确写入之前就读取了它的值。

AcquireRelease 排序的作用正是禁止这类重排:

  • Release 排序:确保在 Release 存储之前的所有内存操作(如 data = 42),都不会被重排到该存储之后
  • Acquire 排序:确保在 Acquire 加载之后的所有内存操作(如 assert!(data == 42)),都不会被重排到该加载之前

这样,当 flag 作为同步点时,就能保证 data = 42 的逻辑一定在 flag 被设置之前完成,并且在 flag 被读取到为 true 之后data 的值一定是 42


非确定性的两大来源

现在,让我们系统地总结共享内存并发中的非确定性。

1. 线程交错

这是最直观的非确定性。多个线程的指令以无数种可能的方式交错执行。

示例:非原子计数器
考虑一个简单的共享计数器,两个线程都想将其增加 1。

// 非原子操作
let mut counter = 0;

// Thread A
let tmp = counter; // 读取
tmp = tmp + 1;     // 计算
counter = tmp;     // 写入

// Thread B (执行相同的操作)
let tmp = counter;
tmp = tmp + 1;
counter = tmp;

如果执行顺序不幸地交错如下:

  1. A 读取 counter 为 0。
  2. B 读取 counter 为 0。
  3. A 写入 counter 为 1。
  4. B 写入 counter 为 1。

最终结果是 1 而不是 2。这是因为“读取-修改-写入”这三个步骤不是原子操作,线程可以在其间被切换。

解决方法:原子读-修改-写指令
使用原子指令(如 fetch_add)可以确保整个“读-改-写”操作不可分割。

use std::sync::atomic::{AtomicI32, Ordering};

let counter = AtomicI32::new(0);

// Thread A
counter.fetch_add(1, Ordering::Relaxed);

// Thread B
counter.fetch_add(1, Ordering::Relaxed);

fetch_add 是原子操作,调度器无法在其执行过程中中断,从而保证了最终结果为 2。

2. 指令重排(宽松行为)

这是更隐蔽的非确定性来源。编译器和处理器会对单线程内的指令进行重排优化,前提是不影响该线程的串行执行结果。但在多线程环境下,其他线程可能观察到这种重排,导致违反直觉的行为。

这种仅因指令重排而可能出现的行为,在并发编程文献中常被称为 “宽松行为”

以下是两个经典的宽松行为示例:

a) 加载上浮

// 初始状态: x = 0, y = 0

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/3d9859334b93c3a50952732df984ab76_37.png)

// Thread 1           // Thread 2
x = 1;                y = 1;
r1 = y;               r2 = x;

问题:r1r2 是否可能同时为 0?

  • 在纯线程交错模型中,不可能。因为总有一个 x=1y=1 先执行。
  • 在允许重排的模型中,可能。因为 r1 = y 可能被重排到 x = 1 之前执行,r2 = x 也可能被重排到 y = 1 之前执行。这样两个线程都先读取了初始值 0。

b) 存储下沉

// 初始状态: x = 0, y = 0

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/3d9859334b93c3a50952732df984ab76_43.png)

// Thread 1           // Thread 2
r1 = x;               r2 = y;
y = 1;                x = 1;

问题:r1r2 是否可能同时为 1?

  • 在纯线程交错模型中,不可能。因为读取操作必须发生在任一写入操作之前或之后。
  • 在允许重排的模型中,可能。因为 y = 1 可能被重排到 r1 = x 之前执行,x = 1 也可能被重排到 r2 = y 之前执行。这样两个读取操作都可能读到对方线程写入的值 1。

这些行为在真实的 ARM 等架构上确实会发生(尽管概率很低)。正是这种罕见性使得由此引发的并发 Bug 极难发现和复现。


如何约束宽松行为:内存顺序

为了编写正确的并发程序,我们需要有能力禁止那些会导致错误的重排。主要方法有两种:

1. 使用获取-释放排序

正如在自旋锁示例中看到的:

  • Release:用于存储操作。保证该操作之前的所有内存访问,都不会被重排到该存储之后
  • Acquire:用于加载操作。保证该操作之后的所有内存访问,都不会被重排到该加载之前

当一条 Release 存储与一条 Acquire 加载配对成功(即 Acquire 加载读到了 Release 存储写入的值)时,它们之间就建立了一种“同步”关系。这保证了 Release 之前的所有写操作,对执行 Acquire 的线程来说都是可见的。

2. 使用内存栅栏

内存栅栏是一条独立的指令,它在代码中建立一个屏障。

use std::sync::atomic::{fence, Ordering};

// Thread 1
data = 42;
fence(Ordering::Release); // 释放栅栏
flag.store(true, Ordering::Relaxed);

// Thread 2
while !flag.load(Ordering::Relaxed) {}
fence(Ordering::Acquire); // 获取栅栏
assert!(data == 42);
  • 释放栅栏:保证在它之前的所有操作,都不会被重排到它之后
  • 获取栅栏:保证在它之后的所有操作,都不会被重排到它之前

栅栏提供了比单个操作排序更全局的约束。Release/Acquire 排序可以看作是特定于某个原子变量的、更轻量级的栅栏。


对宽松行为建模

为了构建大型、正确的并发系统,我们需要精确理解不同内存顺序(如 Relaxed, Acquire, Release, SeqCst)的语义,知道哪些行为被允许,哪些被禁止。

学术界主要有三种建模方法:

  1. 禁止共享内存访问:通过互斥锁等高级抽象完全隐藏共享内存。这虽然安全,但可能牺牲性能,且不够灵活。
  2. 公理语义:通过一组数学公理来定义所有合法执行轨迹的集合。它严谨但不够直观,且可能允许一些现实中不存在的“怪异”行为。
  3. 操作语义:通过定义抽象机器的逐步执行规则来建模。这更符合程序员的直觉,是当前研究的热点方向(如“Promise语义”),我们将在后续课程中深入探讨。

总结

本节课我们一起学习了共享内存并发编程中的非确定性挑战:

  1. 线程交错是固有的,可通过原子读-修改-写指令来确保关键操作的不可分割性。
  2. 指令重排会导致宽松行为,这是编译器和处理器优化的副作用。它可能破坏程序的逻辑正确性。
  3. 我们可以使用内存排序原语来约束重排:
    • 获取-释放排序:在配对成功的存储和加载操作之间建立同步,保证可见性。
    • 内存栅栏:在代码中建立更全局的重排屏障。
  4. 理解并正确使用这些工具,是编写高效且正确的底层并发数据结构的关键。为了精确理解这些机制,我们需要借助形式化的模型,如操作语义。

在接下来的课程中,我们将深入探讨这些内存模型,为构建复杂的并发程序打下坚实基础。

12:承诺语义(第一部分)

在本节课中,我们将要学习一种称为“承诺语义”的并发程序语义模型。承诺语义旨在为C、C++和Rust等语言中的宽松内存并发行为提供精确的建模,它是我们后续分析并发对象(如锁、数据结构、垃圾收集器)正确性的基础。

上一节我们介绍了并发程序中非确定性的两个主要来源:线程交错执行和指令重排序。本节中我们来看看如何通过承诺语义来精确地定义和约束这些行为。

承诺语义概述

承诺语义是一种交错操作语义模型。线程被逐个执行,每个线程轮流获得执行权。好消息是,它仍然基于交错执行这一并发程序的基本特性。坏消息是,为了对宽松内存行为和重排序进行建模,它比简单的顺序一致性语义要复杂一些。

承诺语义引入了四个核心思想来建模这些复杂行为。

以下是承诺语义的四个核心思想:

  1. 多值内存:用于建模“加载提升”。
  2. 内存邻接性:用于建模“读-改-写”指令的排他性。
  3. 视图:用于建模可能的指令排序和宽松行为。
  4. 承诺:用于建模“存储提升”。

这四个思想共同构成了承诺语义,它精确地定义了并发程序中哪些行为是允许的,哪些是不允许的。需要强调的是,承诺语义是对硬件和编译器实际发生的优化行为的一种抽象建模,其内部工作机制并非实际执行,但已被证明能够涵盖硬件和编译器的行为。其目的是为程序员提供一个比硬件语义更简单、更易于推理的模型。

核心思想一:多值内存

在顺序一致性语义中,内存是从地址到值的简单映射。而在承诺语义中,内存变得更加复杂。

内存被定义为从地址到消息列表的映射。一个消息由一个和一个时间戳组成。时间戳本质上是一个计数器,代表了该消息被写入的时间。

引入多值内存的主要目的是允许“加载提升”,即允许后面的加载指令在更早的存储指令之前执行。为了建模这一点,我们需要一个能追踪所有写入历史的内存,这样加载操作就可以读取一个旧值。

让我们通过一个例子来理解。考虑以下程序,我们想知道结果 r1 = 0r2 = 0 是否可能。

线程1: x = 1; r1 = y;
线程2: y = 1; r2 = x;

在顺序一致性语义中,这是不可能的,因为 x=1y=1 总有一个先执行,那么另一个线程的读取结果就应该是1。但由于指令重排序,这个结果在实际的宽松内存模型中是被允许的。

在承诺语义中,我们可以这样执行来产生该结果:

  1. 执行 x = 1。在内存中地址 x 的消息列表末尾追加一个新消息 (值=1, 时间戳=5)。初始消息 (值=0, 时间戳=0) 仍然保留。
  2. 执行 y = 1。在地址 y 的消息列表末尾追加一个新消息 (值=1, 时间戳=8)。初始消息 (值=0, 时间戳=0) 仍然保留。
  3. 线程1执行 r1 = y。它可以选择读取 y 的初始消息 (值=0),而不是最新的消息 (值=1)
  4. 线程2执行 r2 = x。它同样可以选择读取 x 的初始消息 (值=0)

这样,我们就得到了 r1 = 0r2 = 0 的结果。多值内存通过保留历史消息,允许线程读取旧值,从而有效地建模了加载提升行为。

核心思想二:内存邻接性

然而,多值内存本身过于宽松,它允许了太多本应被禁止的行为。例如,对于“读-改-写”指令,我们需要保证其原子性和排他性。

考虑两个线程都对同一个变量进行“获取并加一”操作:

线程1: r1 = fetch_and_add(&x, 1); // 原子操作
线程2: r2 = fetch_and_add(&x, 1); // 原子操作

我们不可能得到 r1 = 0r2 = 0 的结果,因为该操作应该原子地递增变量。为了约束这类行为,承诺语义引入了内存邻接性

我们将消息从 (值, 时间戳) 扩展为 (值, 时间戳范围)。一个时间戳范围表示该消息在时间线上占据了一段区间。

“读-改-写”操作的核心规则是:新生成的消息必须与它所读取的旧消息在时间戳范围上相邻,即中间不能有空隙。这保证了每个旧消息最多只能被一个RMW操作成功读取和修改。

让我们用例子说明:

  1. 初始时,内存中 x 有一个消息 (值=0, 范围=[0, 5])
  2. 线程1执行 fetch_and_add(&x, 1)。它读取消息 (值=0),然后必须生成一个与之相邻的新消息。假设新消息为 (值=1, 范围=[5, 10])r1 = 0
  3. 现在线程2执行 fetch_and_add(&x, 1)。它不能再去读取 (值=0, 范围=[0,5]) 这个消息了,因为它的“后面”位置 [5,10] 已经被线程1的新消息占据,不再“相邻”。线程2只能读取当前最新的消息 (值=1, 范围=[5,10]),并生成一个与之相邻的新消息 (值=2, 范围=[10, 15])r2 = 1

这样,通过内存邻接性,我们确保了RMW操作的排他性,不可能出现两个线程都读到初始值0的情况。

核心思想三:视图

多值内存和邻接性仍然允许过多行为。我们需要引入“视图”来进一步约束,以建模一致性同步

一致性是指,单个线程对同一地址的多次访问必须遵循某种顺序。例如,连续两次读,后一次读到的值不应该比前一次读到的更旧。
同步是指,通过 release/acquireSC 栅栏等机制,在线程间建立顺序约束。

视图是一个从地址到时间戳的映射,它代表了一个线程“已知晓”或“已观察到”的消息状态。承诺语义中有几种视图:

  1. 每线程视图:每个线程都有自己的视图,用于维护单线程内的一致性。
  2. 每消息视图:某些消息(如 release 写入)会附带一个视图,用于实现 release/acquire 同步。
  3. 全局SC视图:用于实现顺序一致性栅栏的强同步。

用每线程视图建模一致性

每个线程的视图记录了该线程对各个地址最新知晓的消息时间戳。线程的后续访问(读或写)都必须发生在该视图“之后”。读取或写入一个新消息后,线程的视图会更新到该消息的时间戳。

这如何保证一致性呢?以“读-读”一致性为例:

// 假设其他线程执行了 x = 1
r1 = x; // 读到 1
r2 = x; // 可能读到 0 吗?

  1. 执行 r1 = x 读到消息 (值=1, 时间戳=5)。线程视图更新,记录 x 的时间戳为5。
  2. 执行 r2 = x 时,线程的视图指出它已知晓 x 在时间戳5的消息。因此,它不能去读取一个更旧的(时间戳小于5)、已被覆盖的消息 (值=0)。它只能读取时间戳 >=5 的消息,因此 r2 也必须读到1。

类似地,“写-读”、“读-写”、“写-写”一致性都可以通过约束访问必须发生在当前视图之后,并在访问后更新视图来保证。

用每消息视图建模 Release/Acquire 同步

release 存储和 acquire 加载可以同步线程。在承诺语义中,这是通过将释放线程的视图附加到它写入的消息上,然后在获取线程读取该消息时,将这个附加的视图合并到获取线程的视图中来实现的。

考虑以下例子,我们断言 assert(r2 == 1) 必须成功。

线程1: x = 1; y.store(1, Release);
线程2: r1 = y.load(Acquire); r2 = x; assert(r2 == 1);

  1. 线程1执行 x = 1,视图更新(知晓 x@时间戳5)。
  2. 线程1执行 y.store(1, Release)。它写入消息 (值=1)y,并将执行此存储时线程1的当前视图(包含 x@时间戳5 的知识)作为“每消息视图”附加到这个新消息上。
  3. 线程2执行 r1 = y.load(Acquire)。它读取到附带视图的消息 (值=1)。因为是 acquire 加载,它将这个消息附带的视图合并到自己的视图中。现在,线程2的视图也包含了 x@时间戳5 的知识。
  4. 线程2执行 r2 = x。由于它的视图已知晓 x@时间戳5,它就不能去读取更旧的初始值0,而必须读取时间戳 >=5 的消息,即值1。断言成功。

通过这种方式,x=1 的“知识”从线程1传递给了线程2。

用全局SC视图建模顺序一致性栅栏

顺序一致性栅栏是最强的同步原语。在承诺语义中,通过一个全局SC视图来建模。所有线程在执行SC栅栏时,都必须:

  1. 将自己当前的每线程视图“发布”到全局SC视图中(取并集)。
  2. 将自己的每线程视图更新为当前全局SC视图的内容(即获取所有已发布的知识)。

这强制所有在SC栅栏之前完成的操作,都能被所有在SC栅栏之后执行的操作“看到”,从而建立了全序关系。

总结

本节课我们一起学习了承诺语义的前三个核心思想:

  1. 多值内存:通过保存历史消息来建模加载提升。
  2. 内存邻接性:通过要求RMW操作生成相邻消息来保证其原子排他性。
  3. 视图:通过每线程视图维护一致性,通过每消息视图实现 release/acquire 同步,通过全局SC视图实现最强同步。

这些机制共同作用,为宽松内存并发程序提供了一个精确且相对易于推理的形式化模型。在下一节课中,我们将探讨承诺语义的最后一个核心思想:承诺,它用于建模“存储提升”。

13:承诺语义(2/2) 🧠

在本节课中,我们将要学习承诺语义的最后一个核心思想——承诺。这是该语义模型命名的由来,因为它本质上支持“承诺”这一概念。我们将通过一系列例子,理解承诺如何用于建模存储提升这一复杂行为,并区分语法依赖与语义依赖。

概述:什么是存储提升?

存储提升是指一个较晚的存储操作可能被重排序到更早的指令之前执行。在并发编程领域,如何精确地为此类行为建模,一直是编程语言语义学中的一个主要开放性问题。

存储提升的挑战

以下是理解存储提升复杂性的三个关键示例。

示例一:允许的提升(无依赖)

考虑以下程序:

// 线程1
r1 = x.load(); // 读取 x
y.store(r1);   // 将读取值写入 y

// 线程2
r2 = y.load(); // 读取 y
x.store(1);    // 向 x 写入 1

问题:是否可能观察到 r1 == 1r2 == 1
答案:是。因为线程2中的两条指令访问不同内存位置,编译器或架构可能对它们进行重排序。如果 x.store(1) 被提升到 y.load() 之前执行,就可能产生此结果。顺序一致性或交错语义允许此行为。

示例二:禁止的提升(凭空出现值)

现在考虑一个略有不同的程序:

// 线程1
r1 = x.load();
y.store(r1); // 将 x 的值复制到 y

// 线程2
r2 = y.load();
x.store(r2); // 将 y 的值复制到 x

问题:是否可能观察到 r1 == 1r2 == 1
答案:否。所有变量初始值为0,此行为意味着值1“凭空出现”,违反了任何合理并发语义的基本原则,必须被禁止。

示例三:允许的提升(存在语法依赖但无语义依赖)

这是最微妙的情况:

// 线程1
r1 = x.load();
y.store(r1);

// 线程2
r2 = y.load();
if (r2 == 1) {
    x.store(r2); // 依赖于 r2
} else {
    x.store(1);  // 写入常数 1
}

问题:是否可能观察到 r1 == 1r2 == 1
直觉:由于 if 分支中的 x.store(r2) 与示例二相同,似乎应被禁止。
现实:此行为应该被允许。原因在于编译器优化(如常量传播和分支消除)可以将此程序转换为示例一,从而允许该行为。这揭示了语法依赖(代码中的控制/数据流)与语义依赖(实际执行时的真正依赖)之间的关键区别。

承诺语义的核心思想

承诺语义的目标是:允许示例一和三,但禁止示例二。其核心机制是引入“承诺”来建模存储提升。

关键概念:语义独立的写操作

一个写操作是语义独立的,如果它在未来的执行中总是可写的,无论当前程序计数器的状态如何。

  • 示例一x.store(1) 总是可写的。
  • 示例二x.store(r2) 不是总是可写的,因为写入的值取决于读取的 r2
  • 示例三x.store(1) 是总是可写的。因为无论 r2 为何值,线程最终都会执行 x.store(1)(在 then 分支被优化后,或在 else 分支中)。

承诺与兑现机制

  1. 承诺:一个线程可以“承诺”在未来写入一个特定的值(例如 x = 1)。这对应于在语义上将该存储操作提升。
  2. 兑现:该线程必须在未来某个时刻“兑现”这个承诺,即实际执行该写操作。
  3. 读取承诺值:其他线程可以读取已被承诺但尚未兑现的值,就像它已经存在于内存中一样。
  4. 认证:线程在做出承诺时,必须能够证明(认证)在任何可能的内存干扰下,它都能独自兑现这个承诺。并且在承诺兑现前的每一步,都需要重新认证。

用承诺语义分析示例

上一节我们介绍了承诺语义的核心思想,本节中我们来看看它如何应用于三个示例。

示例一分析(允许)

以下是承诺语义下的可能执行序列:

  1. 线程2 承诺 写入 x = 1。它认证此承诺:即使读取 y = 0,它最终也能执行 x.store(1)
  2. 线程1 读取被承诺的值 x = 1
  3. 线程1 写入 y = 1
  4. 线程2 读取 y = 1
  5. 线程2 兑现 承诺,实际写入 x = 1
    此执行产生了 r1 = 1, r2 = 1,建模了存储提升。

示例二分析(禁止)

线程2尝试承诺 x = 1。但在认证时失败:如果它读取 y = 0,则未来必须执行 x.store(0),而非 x.store(1)。因此 x = 1 并非“总是可写”,无法做出此承诺,行为被禁止。

示例三分析(允许)

执行序列与示例一类似:

  1. 线程2 承诺 x = 1。认证时,它考虑 else 分支(x.store(1)),因此承诺有效。
  2. 线程1 读取 x = 1,写入 y = 1
  3. 线程2 读取 y = 1。此时需要重新认证承诺。重新认证时,它进入 then 分支,执行 x.store(r2),其中 r2=1,效果仍是写入 x = 1
  4. 线程2 兑现 承诺(通过执行 then 分支)。
    关键点在于,两次认证走了不同的代码路径(else 分支 vs then 分支),但产生了相同的内存效果 x = 1。这精准捕捉了“语法依赖存在但语义依赖不存在”的情形。

重新认证的重要性

考虑一个额外示例,说明为何每一步都需要重新认证:

// 线程1
r1 = x.load();
y.store(r1);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/c9dae6cee649c71c400db374ec1e673b_11.png)

// 线程2
r2 = y.load();
r3 = x.load(); // 额外读取 x
x.store(1);

问题r3 能否读到 1?
答案:应被禁止,以保持读-写一致性。在承诺语义中,线程2在读取 x 后,其“视图”前移。此时它无法重新认证最初的 x.store(1) 承诺,因为该写入将发生在其当前视图“之前”的位置,这是不允许的。因此该行为被禁止。

总结与延伸

本节课中我们一起学习了承诺语义的最后一块拼图——承诺机制。我们了解到:

  1. 承诺用于建模存储提升
  2. 其核心是区分语法依赖语义依赖,通过判断一个写操作是否“总是可写”来实现。
  3. 机制包括承诺、读取承诺值、认证、重新认证和兑现
  4. 承诺语义与编译器优化(如常量传播、分支消除)的行为一致。

承诺语义是一个基于视图和承诺的操作语义,它整合了四大思想来建模宽松内存行为:

  • 多值内存:建模加载提升。
  • 内存邻接:建模读-改-写操作。
  • 视图:建模一致性与同步。
  • 承诺:建模存储提升。

该语义是活跃的研究领域,已衍生出针对 ARM、RISC-V 架构的变体,并应用于持久内存、验证编译等前沿方向。

14:基于锁的并发,自旋锁的实现

在本节课中,我们将学习锁的实现。在上一节关于基于锁的并发的课程中,我们了解了锁的用例和接口。在本节中,我们将深入探讨具体的实现,理解其正确性以及它们满足哪些属性。

锁的作用回顾

锁用于将顺序数据结构当作并发数据结构来使用。例如,向量是最具代表性的顺序数据结构之一,它是一个元素数组,你可以向向量末尾添加元素等。

使其并发的最简单方法是使用锁来保护这个向量。当一个向量与锁配对时,在访问底层向量之前,你需要先获取锁。获取锁之后,你可以可变地访问向量。释放锁之后,其他线程才能获取锁并访问向量。

锁会序列化所有线程的所有操作,因此它完全禁止了并发操作。所有操作都被序列化,同一时间只有一个线程(例如)在访问向量。

锁之所以非常重要,是因为大多数所谓的并发编程实际上都是由这些锁来处理的。在大多数情况下,并发编程试图利用多个资源来测量相同的资源,但它并不特别关注优化或为高性能计算提供可扩展性。在大多数情况下,正确的同步才是目的。

例如,在操作系统中,如果一个数据结构(例如时钟)很少被多个线程访问,那么你可以安全地使用锁来保护这个时钟。如果多个线程试图同时访问同一个时钟,可能会降低性能,但我们的假设是这种情况不常发生,所以锁是足够的。在性能要求不高的服务中,甚至用锁来保护时钟也是可以的。

但是,如果你真的想使你的数据结构具有可扩展性,并希望利用程序允许的并行性机会,你真的需要使用其他方法。因为锁完全禁止并发操作,但在某些情况下,你实际上希望允许多个线程同时访问同一个数据结构。

这基本上是锁的权衡:它易于使用,但不可扩展。

锁的API与特性

本仓库包含了锁的实现,你可以看到Rust中的安全API以及实际的锁接口(即实际的锁实现,包括自旋锁、票据锁等)。我们将在课程的后期处理这类锁,今天我们将重点关注这里的API、自旋锁及其实现。

锁有很多种,这些锁之间也存在权衡。人们希望一个锁能同时具备简单、快速、紧凑、可扩展、公平、节能等所有优良特性,但不幸的是,不同的锁具有不同的特性,它们之间存在根本性的权衡。例如,自旋锁是最简单的锁实现,在无竞争时速度相当快,但它不可扩展、不公平,并且通常不节能等。因此,你需要为正确的场景选择合适的锁。

Send与Sync特性

首先,让我们回顾一下Rust中锁的API。我们已经讨论过Rust中锁的API,包括Send、Sync特性以及包含lockunlock方法的API。但为了给自旋锁的实现提供一个具体的背景,我想重新概述一下锁的安全API。

特性是类型的类或谓词。一个特性意味着“这个类型满足某些属性”或“这个类型不满足这些属性”。为了表达某些类型满足某些属性的想法,我们使用特性。

例如,Rust标准库提供了一个Send特性。如果一个类型TSend,意味着数据T可以被转移到其他线程。例如,你可以发送一个旨在并发访问的原子字(AtomicUsize)到其他线程。同样,usize也可以发送到其他线程。但是,如果你有一个线程本地存储,它不应该被发送到其他线程,因为它特定于某个线程(例如,持有线程ID等),这种资源不能被其他线程使用,这就是为什么这类类型不应该实现Send

另一方面,有一个Sync特性。如果类型T实现了Sync特性,意味着类型T可以被多个线程并发访问。也就是说,线程A和线程B可以并发访问数据。例如,旨在并发访问的原子字AtomicUsize肯定是Sync的,因为这种类型的目的是允许多个线程同时访问它。另一方面,一个不旨在并发的简单字不是Sync的,因为你没有特别标记该类型为Sync。显然,线程本地存储也不是Sync的,因为它旨在由单个线程使用,不应被其他线程访问。

这里有一个非常有趣的属性:TSync的,当且仅当其引用类型是Send的。这意味着,T可以被并发访问,当且仅当对T的引用可以发送到其他线程。这基本上解释了Sync的含义:引用是Send的,意味着类型T可以被并发访问。

请记住这里写的内容,因为SendSync特性将贯穿本课程。如果你有兴趣在Rust中实现并发程序,你将一直使用这些特性。

RawLock特性

除了标准库提供的两个特性外,我们将使用另一个称为RawLock的特性。RawLock意味着它具有lockunlock函数。我们已经讨论过这个接口,但我想重新表述一下。它有一个lock和一个unlock。当你对一个RawLock调用lock时,你将获得一个令牌。该令牌将用于解锁锁。你必须保证由你的lock提供的令牌会被返回到unlock函数。这是你必须确保满足的保证。

此外,RawLock本身也是数据。例如,自旋锁包含一个布尔值,指示锁是否被持有,所以它也是一个资源,并且在锁类型内部包含信息。因此,它也需要实现这个特性。要初始化锁,你需要实现Default特性,这意味着你可以创建一个新的RawLock类型的默认值。

此外,这个RawLock应该是SendSync的,因为这是一个锁,旨在从多个线程访问,这就是为什么它应该同时实现SendSync:它应该可以发送到其他线程,并且应该可以被多个线程同时访问。

这个RawLock有许多假设。例如,为了实现自旋锁并声称自旋锁实现了RawLock特性,你需要保证自旋锁的一些属性。其中最重要的是关于令牌的,令牌已经讨论过,但你想为自旋锁确保的最重要的属性是:一次只有一个代理获取了锁

锁的目的是互斥。没有两个代理同时持有锁。这是锁的基本属性,你必须确保它得到保证。我们将实现保证这种互斥属性的自旋锁、票据锁和其他类型的锁。

锁类型与锁守卫

这是API的描述。使用这三个特性(SendSyncRawLock),我们实现锁类型。回顾一下,使用锁,你想在Rust中保证两件事:第一,你想将RawLock与数据关联起来;第二,当你完成对底层数据的访问时,你想自动释放锁。这两个结构保证了这一点。

首先,Lock类型有两个组件:L是一个RawLockT是数据。它拥有数据T,类型为T的对象受L锁保护。例如,L是自旋锁,那么这就是受自旋锁保护的T。如果L是票据锁,那么这就是受票据锁保护的T,等等。这种类型再次保证了RawLock特性的保证:一次只有一个代理获取锁。这个Lock类型保证T对象完全不被并发访问。因此,只有一个代理可以访问底层数据,这是由这些锁类型保证的。

回顾一下,你想将锁与数据关联,而不是代码区域。你不应该从代码区域的角度来考虑并发编程的安全性,而应该从数据的角度来考虑:哪个资源或数据受哪个锁保护。这要求我们以这种方式思考,因为我们明确地将锁和数据关联起来,这就是为什么这个API自动保证了这种推理方式。

例如,我们有这个类型:Lock<SpinLock, Vec<usize>>。这是一个锁保护的数据,锁是自旋锁,数据是一个字向量。或者这个类型:Lock<CHLock, &TLS>。这是一个锁保护的数据,锁是我们稍后将学到的CH锁,保护的数据是对某些线程本地存储的引用。在Rust中,我们也可以表达这个想法。这个数据实际上不应该发送到其他线程。但你仍然可以在这里用锁保护它,并且你必须确保,即使它不受保护,因为底层数据不是Send/Sync的,它不能被其他线程访问。你必须确保这一点。

这种约束将由锁守卫实现。这些是特性约束。这个Lock类型是SendSync的,当且仅当底层类型TSend的。这个类型不是Send的,对吧?这是一个TLS,即使有一个对TLS的引用,你可以立即看到,哦,这个类型不是Send的,因为它可以访问不能被其他线程访问的线程本地存储。这就是为什么整个类型,这种锁保护的数据,不是Send的,也不是Sync的。因为Lock类型被定义为Send/Sync仅当底层类型TSend/Sync的。这实际上意味着,锁保护的数据只有在底层类型TSend的情况下才有意义。这基本上是这个约束的想法。

另一方面,在左边的类型中,向量是Send的。即使你创建了一个向量,你也可以安全地将向量发送到其他线程,所以从API文档中可以很明显地看出,T在这种情况下是Send的,因此这个Lock类型也是SendSync的。这就是类型约束或特性约束。这种想法在Rust中使用一种巧妙的方式实现,请阅读代码,看看那里发生了什么,以及如何在这个Lock类型中表达这个想法。

锁守卫

锁实现了它的第一个目标:它将锁与数据关联起来。第二个结构,锁守卫,实现了这里的第二个目标:当你不再访问底层数据时,你想自动释放锁。

为了实现这一点,你定义了一个新类型LockGuard,它基本上是对底层值的访问器。它是你已获取锁的证明。当你为此锁类型调用acquire时,你将获得锁守卫,这意味着你已获取并持有锁。它旨在证明锁已被获取。正如我们在上一讲中讨论的,它是一个RAII类型,当它被丢弃时会释放锁,所以这是自动完成的。如果你不故意忘记这个锁守卫,当锁守卫被丢弃时,锁将被自动释放。所有对象最终都会被丢弃,这就是为什么底层锁会被自动释放。

到目前为止,一切顺利,而且非常方便。你不需要跟踪锁,也不需要为所有锁的实现插入解锁函数。通常在C++中,有很多与此相关的错误。你忘记释放锁。这种情况经常发生,并且它使程序的控制流变得非常复杂。在C++或Rust中使用RAII,问题将大大简化。因此,这是使用自旋锁更安全、更简单的API。

此外,这个锁守卫可以可变地解引用为类型T。这意味着它是类型T的可变访问器。如果你有一个锁守卫,那么你可以获取对与锁关联的底层值的引用或可变引用。如果你有一个锁守卫,它可以被转换为对类型T的可变引用。这非常符合预期,因为你持有锁,所以你也应该能够访问内部数据。

此外,如果底层类型TSend的,这个锁守卫就是Send的;如果底层类型TSync的,它就是Sync的。换句话说,锁守卫在Send/Sync特性方面是透明的。让我这样说:如果TSend的,那么你可以将锁守卫发送到其他线程。这意味着你可以获取一个锁,然后将你已获取锁的知识或所有权转移到其他线程。然后其他线程也可以访问数据,因为它实现了DerefMut。这就是为什么你必须要求类型TSend的,只有当类型是Send的,你才能安全地将锁守卫发送到其他线程,以便其他线程可以安全地访问底层数据。其他线程在传递这个锁守卫后,可以丢弃这个锁守卫并释放锁。因此,如果这个锁守卫被发送到其他线程,它实际上意味着锁被一个线程获取,然后被其他线程释放。这是可能的,因为这里的TSend的。

同样,如果你获取了一个锁,那么你可以将对数据的引用共享给其他线程。为了做到这一点,你必须确保TSync的。如果T可以被多个线程访问,那么锁守卫也可以被多个线程访问并解引用到它们的类型。原因在于,如果T不是Sync的,那么共享锁守卫是不安全的,因为其他线程可能引用底层数据。为了让其他线程安全地解引用底层数据,类型T也应该是Sync的。

到目前为止的解释总结如下:LockGuard是一个透明的类型包装器,关于SendSync特性。

我已经讨论了许多关于API的保证,这些API和安全性实际上已经针对Rust的所有权类型系统进行了形式化证明。因此,我可以相当确信,只要你满足API的保证,底层实现将按预期工作,无需担心任何问题。这是形式化证明的,意味着有一个证明并且由机器检查。所以这个证明不可能出错。这就是Rust相对于C++的力量所在。

自旋锁的实现

以上是关于Rust中安全API的内容。现在我们将专注于RawLock的实现。我们想实现一个自旋锁,然后我们必须确保自旋锁满足RawLock的保证或规范。我们将从自旋锁开始。

自旋锁在这个文件中实现。这是该文件的摘录。要查看自旋锁实现的每一个细节,请访问此站点并阅读代码。

现在让我们看看自旋锁的结构以及lockunlock函数。

我们已经简要讨论了实现,但我想重复一遍,以便解释这里的排序。我们学习了排序:获取和释放排序。这里我们想使用获取-释放排序来推理自旋锁的安全性,基于承诺语义。

自旋锁基本上是一个布尔值,布尔值指示锁是否被锁定。true意味着自旋锁被锁定,false意味着它被解锁。此外,这个布尔值应该是原子的,因为多个线程试图同时获取或释放锁,所以它应该以某种方式是Sync的。为了使布尔值Sync,你必须使用这个AtomicBool类型。

到目前为止,一切顺利。这是一个原子布尔值。你将像这样实现一个lock函数。为了获取锁,你想确保在你获取锁之前,锁是false(即解锁状态)。在你获取锁之后,你必须确保锁包含true值,因为你刚刚获取了锁。这意味着lock函数应该原子地将false值替换为true值。这就是这个compare_and_swap函数的目的。compare_and_swap函数原子地将值从false替换为true。这基本上是锁的目的。

如果这个compare_and_swap成功,意味着你成功地将false值替换为true,那么你就知道你已获取了锁,然后你可以从这个lock函数返回,因为你已获取锁。另一方面,你的compare_and_swap可能会失败,因为那里的值已经是true,你无法将false替换为true,因为该值不是false。如果是这种情况,你必须再次循环。你必须回到循环的开头,并再次尝试compare_and_swap。这就是为什么这被称为自旋锁。它在while循环中自旋。只有当它成功地将值从false替换为true时,它才会退出循环并从函数返回。

另一方面,在unlock函数中,你只是将false值存储到底层值中。你已经获取了锁,所以你知道内部值已经是true。你也知道你是唯一知道该值是true的人,因为你获取了锁,这意味着其他人根本不持有锁。这就是为什么你可以直接存储false值。你不需要将true替换为false值,因为你已经知道该值是true。你可以直接将false值存储到内部变量中。这个存储可以帮助其他线程获取锁,因为它们正在等待值变为false,而false被替换为true。在这个线程将false值存储到内部变量后,其他线程就可以将false值替换为true。这就是为什么这可以实现为一个存储。unlock函数可以实现为存储false

这里的语法与代码中的略有不同,为了将其放在一张幻灯片中,我做了很多简化,但请访问此站点查看那里的情况。它包含完整的、有效的Rust实现。

此外,你可以注意到这里有获取和释放排序。你可能想知道这是什么意思,为什么需要这个。因为互斥似乎已经实现了,因为它是原子地将false替换为true,所以不可能有多个线程同时持有锁。并且你可以存储false值,因为你肯定知道你已获取了锁。那么,为什么这些排序(获取和释放)甚至是必要的?它们的目的是什么?我将在下一张幻灯片中解释这些排序的必要性。

承诺语义下的解释

这是上一张幻灯片的简短表示法。我将解释在持有和未持有锁时,承诺语义中发生了什么。锁旨在保护数据,所以我想向你展示数据将如何演变,以及数据如何通过此实现受到锁的保护。

假设这个亮绿色线程(线程1)和深绿色线程(线程2)试图获取锁。锁在开始时是false,假设锁的这个消息知道这个数据的最新值。它有一个视图,一个释放视图,它的视图指向这个数据的最新值。

假设D也是数据,并且一些值被写入到这个位置D。在访问D结束时,锁应该是false,并且这个false消息的释放视图应该指向这个D的最新值。

线程1获取了锁。回顾一下,为了在这个compare_and_swap中成功,你必须将一条相邻的消息放到前一个值。它是false,现在变成true,因为你正在比较并交换falsetrue。这就是为什么我们在这里放置T消息,紧接在这个F消息之后,因为它们应该是相邻的。

锁的目的是,T1应该能够访问D的最新值。换句话说,T1不应该访问D的陈旧值。因此,T1的视图应该更新为指向D的最新值。这就是为什么它应该是获取的。为了总是观察到位置D的最新值,你必须获取这个compare_and_swap。当这是获取的时,你将获取前一条消息的释放视图,该视图旨在指向这个D的最新值。它应该被这个lock函数获取。

现在,在T1获取锁之后,T2可能想获取锁,但它会失败,因为它无法将false值替换为true,因为这里唯一的false值已经被一条T消息附加,并且这条T消息不能被转换,因为它要求前一个值是false。这意味着T2无法成功比较并交换该值,它将始终进入错误分支并一次又一次地循环,直到T1完成访问并释放锁。

现在T1已经获取了锁,这很好,它将能够访问数据D。它甚至可以按照自己的意愿修改数据,因为这是由锁API提供的。你获取了锁,所以你可以任意修改数据D。假设视图已更新为此,并且它包含了此时D的最新值。

现在T1试图解锁锁,以便T2稍后可以获取锁。回顾一下,内部值存储为false。所以你将...哦,对不起,我想先解释一下这里的T2情况。T2也试图调用lock,但它转换失败。它只是读取到最新值是true。所以你知道,哦,内部是true,所以我无法成功转换,所以你将进入错误分支。因为它是获取的,你知道你在这里获取了视图,因为这条消息释放了它。但你不打算获取锁,所以你基本上在这里自旋,它无法获取锁,并通过一次又一次地读取这条消息在这里自旋。

现在,T1解锁锁。回顾一下,你将false值存储到内部值中。所以你创建了一条包含false值的新消息。但在此时,你将在这里释放。这意味着保证false消息的不变量:false消息应该指向被保护位置的最新值。这就是为什么你在这里释放它。在此之前,它的视图指向D。但为了将这里的知识(你已经获取了这个视图并且D的最新值在这里的知识)转移出去,你需要将视图释放到这条消息。这就是为什么这个释放的视图指向这里D的最新值。同样,这就是为什么它应该是释放的。对D的视图应该在这里释放。

之后,以同样的方式,T2可以获取锁。在这种情况下,通过附加第四条消息,并且这个视图也被获取。因此,T2也可以看到最新值。因为最新值在这里,并且它被T1释放到这条消息,并且这条消息的视图也被T2获取。T2应该观察到这个D的最新值。它不能读取或写入之前的区域。

这基本上是释放和获取同步的目的。总结一下,所有被T1修改的信息应该在这里释放到消息中。另一方面,消息视图或信息应该被下一个成功的锁调用获取。因此,T1的所有访问都先于T2在这里的操作。因此,T1T2之间应该没有并发操作。这是通过从unlock到消息再到lock函数的释放-获取同步来保证的。

因此,这基本上意味着锁和unlock函数之间的事件通过释放-获取同步进行转移。

T2可以访问值D,它可以更新视图,稍后它可以用这里D的最新值解锁它的锁。所以这条消息也包含了这里D的最新消息。在持有锁时,它是不变量,就像D的最新值一样,并且false消息在此时持有最新值。

这基本上就是为什么lockunlock满足互斥保证的原因,并且这个事实在这个承诺语义图中进行了解释。

总结

在本节课中,我们重新审视了Rust中锁的安全API,查看了自旋锁的实现,以及这些自旋锁实现在承诺语义下的执行情况。在这种承诺语义下,你可以以这种方式推理自旋锁的安全性和保证。因此,T1在这里的访问严格先于T2在这里的访问,这要归功于从unlock到这条消息再到获取的释放-获取同步。

15:基于锁的并发,Ticket锁与CLH锁的实现 🧠

在本节课中,我们将学习自旋锁之外的其他锁类型,并探讨它们的核心思想。具体来说,我们将深入研究两种锁的实现:Ticket锁CLH锁

概述

锁是并发编程中实现互斥访问的关键工具。我们已经了解了简单的自旋锁。本节将介绍两种更高级的锁,它们通过不同的设计解决了自旋锁在公平性可扩展性上的不足。我们将分析它们的关键思想、实现细节以及各自的优缺点。


核心思想回顾

在深入具体实现之前,我们先回顾锁机制的两个核心思想。

上一节我们介绍了自旋锁,本节中我们来看看构建更高级锁的两个基础理念。

  1. 互斥的保证:通过释放-获取同步实现。

    • 在自旋锁中,我们通过锁变量(如 locked)的原子操作,利用释放-获取语义来确保临界区之间的互斥。当一个线程释放锁时,它会将其对受保护数据的最新视图“发布”出去;下一个获取锁的线程会“获取”这个最新视图。这保证了每个进入临界区的线程都能看到前一个临界区修改后的最新数据。
    • 这个同步发生在 unlock 操作的释放与下一个 lock 操作的获取之间。视图的传递是从一个临界区的结束到下一个临界区的开始
    • 在自旋锁实现中,使用单个共享内存位置(如 AtomicBool)来完成此同步。
  2. 公平性的保证:通过在不同位置进行排序和等待实现。

    • 自旋锁缺乏公平性。当多个线程竞争时,可能出现某个线程反复获得锁,而其他线程被“饿死”的情况。
    • Ticket锁和CLH锁通过将“排序”(决定谁下一个进入)和“等待”(自旋检查自己是否轮到)这两个功能分离到不同的变量或位置上,并结合底层硬件的公平原子指令,从而实现了公平性。

Ticket锁的实现 🎫

现在,让我们具体看看Ticket锁是如何工作的。

Ticket锁模仿了银行或服务大厅的排队叫号系统。它使用两个原子计数器:next_ticket(发号机)和 current_ticket(当前服务号)。

数据结构

以下是Ticket锁的核心数据结构:

use std::sync::atomic::{AtomicUsize, Ordering};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/a155ee5f78d272aaf638bef92ec960f6_9.png)

pub struct TicketLock {
    next_ticket: AtomicUsize, // 下一个可用的票号
    current_ticket: AtomicUsize, // 当前正在服务的票号
}

算法步骤

以下是Ticket锁 lockunlock 操作的逻辑:

  1. 初始化next_ticketcurrent_ticket 都设置为0。
  2. 获取锁 (lock)
    • 线程执行 fetch_add(1, Ordering::Relaxed) 操作在 next_ticket 上,获取自己的票号 my_ticket。这个操作是公平的,确保了多个线程获取的票号是顺序递增且不重复的。
    • 然后,线程在一个循环中不断读取 current_ticket(使用 Ordering::Acquire 语义),直到 current_ticket == my_ticket,这意味着轮到自己了,然后退出循环。
  3. 释放锁 (unlock)
    • 持有锁的线程在离开临界区后,将 current_ticket 增加1(即 store(my_ticket + 1, Ordering::Release)),通知下一个等待的线程。
    • 这里需要注意整型溢出回绕的问题。

关键分析

  • 互斥如何保证?
    • 互斥通过 current_ticket 变量上的释放-获取同步来保证。unlock 中的 store(释放)与后续 lock 中读取 current_ticket 的循环(获取)同步,从而传递了数据的最新视图。
  • 公平性如何保证?
    • 公平性通过分离“排序”和“等待”实现。
    • 排序:由 next_ticket 通过 fetch_add 指令完成。该指令在硬件层面是公平的,确保了线程获取票号的顺序。
    • 等待:每个线程在 current_ticket 上自旋,但只关心它是否等于自己的票号 my_ticket
    • 由于票号分配是公平且顺序的,并且 current_ticket 严格递增,每个线程最终都会等到自己的票号被叫到。

Ticket锁的优点是实现了公平性。缺点是API稍复杂(lock需要返回票号),并且所有等待线程都在同一个 current_ticket 变量上自旋,在竞争激烈时会导致严重的缓存一致性流量,影响扩展性。


CLH锁的实现 🔗

接下来,我们研究CLH锁,它采用了一种基于隐式链表的队列方式来解决自旋位置共享的问题。

CLH锁的核心思想是每个线程在其前驱线程的节点上自旋,从而将自旋操作分散到不同的内存位置。

数据结构

以下是CLH锁的核心数据结构:

use std::sync::atomic::{AtomicBool, Ordering};
use std::ptr;

struct Node {
    locked: AtomicBool, // true表示该节点对应的线程正在持有或等待锁
}

pub struct CLHLock {
    tail: *mut Node, // 指向队列尾部的指针
}

每个等待锁的线程都有一个自己的 Nodelocked 字段为 true 表示该线程正在持有锁或等待锁(即后继线程需要等待它);为 false 则表示该线程已释放锁,后继线程可以继续。

算法步骤

以下是CLH锁 lockunlock 操作的逻辑:

  1. 初始化:创建一个 lockedfalse 的虚拟节点,tail 指向它。
  2. 获取锁 (lock)
    • 线程创建一个新的 Node,其 locked 初始化为 true
    • 线程通过原子操作将 tail 指针指向自己的新节点,并同时获取旧的 tail 指针(即其前驱节点 pred)。
    • 然后,线程在 pred.locked 上自旋等待,直到其值变为 false
    • 一旦 pred.locked 变为 false,当前线程便获得锁,可以进入临界区。此时,前驱节点的 Node 可以被回收(在MCS锁中会优化此点)。
  3. 释放锁 (unlock)
    • 线程将自己的 Node.locked 设置为 false(使用 Ordering::Release 语义)。
    • 这样,正在其 locked 上自旋的后继线程就能观察到变化并获取锁。

关键分析

  • 互斥如何保证?
    • 互斥通过每个节点的 locked 字段上的释放-获取同步保证。一个线程在 unlock 时将 locked 设为 false(释放),等待它的后继线程在自旋循环中读取到这个 false(获取),从而实现了视图同步。
  • 公平性如何保证?
    • 排序:由原子交换 tail 指针的指令(如 swap)完成。这个操作也是公平的,确保了线程入队的顺序。
    • 等待:每个线程在其前驱节点的 locked 字段上自旋。这是一个每临界区一个的自旋位置,与Ticket锁中所有线程监视同一个 current_ticket 不同。
  • 优势与局限
    • 优势(可扩展性):由于每个线程在不同的内存位置(前驱节点的 locked)上自旋,大大减少了缓存失效和总线争用,在高竞争场景下性能更好。
    • 局限(空间开销与分配):每个锁操作都需要分配一个新的 Node,并且在释放后,这个 Node 通常由后继线程来释放(在示例代码的 lock 中释放了 pred)。这种跨线程的内存分配与释放操作代价较高,是CLH锁的一个主要性能瓶颈。

总结与对比

本节课中,我们一起学习了Ticket锁和CLH锁这两种重要的锁实现。

它们的核心思想一脉相承:

  1. 都使用释放-获取同步来保证临界区之间的互斥与内存可见性。
  2. 都通过将排序等待分离到不同的内存位置,并利用公平的原子指令fetch_add, swap)来实现公平性。

它们的区别在于解决扩展性问题的思路:

特性 Ticket锁 CLH锁
等待方式 所有线程在同一个 current_ticket 变量上自旋。 每个线程在其前驱节点locked 字段上自旋。
公平性 通过公平的 fetch_add 分配票号保证。 通过公平的 swap 操作入队保证。
可扩展性 较差。高竞争下对 current_ticket 的争用严重。 较好。自旋位置分散,缓存友好。
空间开销 固定,两个计数器。 每个线程/临界区需要一个节点,有动态分配开销。
API复杂度 稍高,lock 需返回票号。 通常需要传递节点指针作为令牌。
关键瓶颈 共享自旋变量导致的缓存一致性流量。 跨线程的节点内存分配与释放

CLH锁的跨线程内存管理问题引出了下一节课的主题——MCS锁。MCS锁在CLH锁的基础上进行了优化,确保了节点的分配和释放都在同一个线程内完成,从而进一步提升性能。我们将在下节课详细探讨。

16:MCS锁与MCS Parking锁

在本节课中,我们将继续学习锁的实现。我们将研究MCS锁及其变体MCS Parking锁,了解它们如何解决之前锁(如CH锁)的性能问题,并探讨其核心思想与实现细节。

概述

在之前的课程中,我们学习了自旋锁、票锁和CH锁。今天,我们将学习名为MCS锁和MCS Parking锁的变体。MCS锁是CH锁的一个改进版本,它试图解决NUMA(非统一内存访问)架构下的性能问题,特别是内存分配与释放的局部性问题。MCS Parking锁则在MCS锁的基础上,让未获得锁的线程进入睡眠状态,以节省CPU时间和能耗。

MCS锁的核心思想

MCS锁与CH锁类似,也使用链表来管理等待锁的线程。其关键改进在于:每个线程创建并操作的节点,最终由同一个线程负责释放。这与CH锁不同,在CH锁中,一个线程分配的节点可能由下一个线程释放,这可能影响内存分配器的性能。

MCS锁的基本数据结构如下:

struct Node {
    locked: AtomicBool,
    next: AtomicPtr<Node>,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/c601eb537c652afd698b09f60225460d_17.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/c601eb537c652afd698b09f60225460d_18.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs431-rs-prll-prog/img/c601eb537c652afd698b09f60225460d_20.png)

struct McsLock {
    tail: AtomicPtr<Node>,
}

每个尝试获取锁的线程都会创建一个新的Nodelocked字段表示该线程是否可以获得锁(false表示可以),next指向链表中的下一个节点。锁本身(McsLock)仅维护一个指向链表尾部的指针tail

MCS锁的获取与释放流程

以下是MCS锁工作流程的逐步分析。

场景一:第一个线程(A)获取锁

初始时,锁的tail指针为null

  1. 线程A调用lock函数。
  2. 它创建一个新节点node_a,其locked字段初始化为true(表示尚未获得锁),nextnull
  3. 线程A通过原子交换操作,将锁的tail指针从null设置为指向node_a。交换操作返回之前的tail值(即null),赋值给变量prev
  4. 由于prevnull,线程A意识到自己是第一个请求锁的线程,因此它成功获取锁,函数返回。此时,node_a.locked仍为true,但这不影响持有锁的线程A。

场景二:第二个线程(B)在A持有锁时尝试获取

此时,tail指针指向node_a

  1. 线程B调用lock函数。
  2. 它创建新节点node_blocked = true, next = null)。
  3. 线程B交换tail指针:tail从指向node_a变为指向node_bprev变量获得之前的tail值,即指向node_a的指针。
  4. 由于prev不为null,线程B知道自己需要等待。它执行 prev.next.store(node_b, Release),将node_anext指针指向node_b,从而将自己加入等待队列。
  5. 接着,线程B在一个循环中自旋,等待自身的node_b.locked变为falsewhile node_b.locked.load(Acquire) { }

场景三:线程A释放锁,线程B获取锁

  1. 线程A调用unlock函数,传入它自己的令牌node_a
  2. 它首先读取node_a.next。如果next不为null(即指向node_b),则说明有线程在等待。
  3. 线程A将等待线程(node_b)的locked字段存储为falsenode_b.locked.store(false, Release)
  4. 这个操作使得正在自旋的线程B的while循环条件变为假,线程B得以退出循环,从lock函数返回,从而成功获取锁。
  5. 线程A随后可以安全地释放(drop)其节点node_a

场景四:线程B释放锁时无等待者

  1. 线程B调用unlock,传入node_b
  2. 它读取node_b.next,发现其为null
  3. 线程B尝试使用比较并交换操作,将tail指针从指向node_b重置为null。如果操作成功,说明在检查next之后、执行CAS之前,没有新的线程加入等待队列,锁完全空闲。
  4. 线程B随后释放其节点node_b

如果在步骤3的CAS操作执行时,恰好有新线程C尝试获取锁并成功将tail指向了自己,那么线程B的CAS操作会失败。此时,线程B会回到步骤2,重新加载node_b.next(此时已被线程C的lock操作设置为指向node_c),然后执行场景三中的步骤,将node_c.locked设为false以唤醒线程C。

MCS锁与CH锁的关键区别

MCS锁的主要优势在于内存管理的局部性。在CH锁中,一个线程分配的节点可能被下一个线程释放。而在MCS锁中,分配节点和释放节点的始终是同一个线程。这通常能带来更好的性能,因为许多内存分配器针对“分配与释放在同一线程”的场景进行了优化。

其代价是实现稍显复杂,需要仔细处理next指针的设置和tail指针的原子比较交换操作。

MCS Parking锁

MCS Parking锁是MCS锁的一个变体,其核心思想是:当线程无法立即获得锁时,不是进行忙等待(自旋),而是让线程进入睡眠状态。

以下是MCS Parking锁与MCS锁的主要区别:

  1. 节点结构:节点中包含一个指向当前线程的句柄(例如std::thread::Thread),用于后续唤醒。
    struct ParkingNode {
        locked: AtomicBool,
        next: AtomicPtr<ParkingNode>,
        thread: Thread, // 用于唤醒的线程句柄
    }
    
  2. 等待机制:在lock函数中,如果线程需要等待(即prev != null),它不会自旋,而是调用park()方法使当前线程进入睡眠状态。
    while self.locked.load(Acquire) {
        park(); // 线程在此睡眠
    }
    
  3. 唤醒机制:在前驱线程的unlock函数中,当它确定有后继等待者时,除了将后继节点的locked设为false,还必须调用unpark()方法来唤醒正在睡眠的后继线程。
    next.locked.store(false, Release);
    next.thread.unpark(); // 唤醒等待的线程
    

一个重要的顺序问题:唤醒(unpark)操作必须在存储locked = false之后进行。如果顺序颠倒,可能会发生以下情况:

  1. 先执行unpark(),唤醒线程B。
  2. 线程B被调度运行,检查自己的locked字段,发现仍为true(因为store操作还未执行),于是再次调用park()进入睡眠。
  3. 此时,store(false)才被执行,但线程B已经再次睡眠,可能无人唤醒,导致死锁。

因此,正确的顺序保证了被唤醒的线程一定能看到lockedfalse的状态。

内存顺序详解

在MCS锁的实现中,内存顺序保证了同步的正确性。主要涉及以下同步关系:

  1. 锁获取的同步lock函数末尾的Acquire加载(或等待循环中的Acquire)与unlock函数中store(false, Release)的释放操作同步。这保证了持有锁的线程在临界区内的所有写操作,对下一个成功获取锁的线程是可见的。
  2. 节点传递的同步:当一个线程在lock中设置prev.next = my_node(使用Release顺序)时,这个操作与后继线程在unlock中加载self.next(使用Acquire顺序)同步。这确保了节点指针的安全传递。
  3. 尾指针的顺序:对tail指针的交换操作通常使用Relaxed顺序,因为它不直接承载线程间的数据同步语义,仅用于确定线程获取锁的顺序。真正的同步发生在每个节点的locked字段上。

各类锁的权衡总结

以下是本课程涉及的几种锁的权衡对比:

  • 核心思想
    • 互斥:通过释放-获取语义保证。
    • 公平性:通过排序和在不同地址上等待来保证(票锁、CH锁、MCS锁)。
  • 自旋锁:简单,但在高争用下性能差,不公平。
  • 票锁:通过排队保证公平性,API稍复杂(需要返回票号),所有线程仍在同一地址上自旋。
  • CH锁:通过链表实现每个临界区一个自旋地址,提高了可扩展性(减少缓存争用),但需要为每个临界区分配节点(空间开销),且节点可能被其他线程释放。
  • MCS锁:改进了CH锁,确保节点在同一线程分配和释放,性能更优,但实现更复杂,且多了一次CAS操作。
  • MCS Parking锁:在MCS锁基础上,让等待线程睡眠,节省CPU和能耗。但线程的停放与唤醒本身有开销,在争用程度不高时可能降低性能。

选择哪种锁取决于具体的应用场景(争用程度、性能要求、能耗考虑等),通常需要进行基准测试。

总结

本节课我们一起学习了MCS锁和MCS Parking锁。MCS锁通过确保节点的分配与释放位于同一线程,优化了CH锁在NUMA系统上的性能。MCS Parking锁则在此基础上引入了线程睡眠机制,以降低能耗。我们详细分析了它们的算法流程、关键代码逻辑以及内存顺序如何保证正确性。最后,我们回顾并比较了目前已学的各种锁的优缺点,为在实际并发编程中选择合适的同步原语提供了依据。

在接下来的课程中,我们将开始学习并发数据结构,这是构建高效并发程序的核心内容。

17:锁耦合链表 🧠

在本节课中,我们将要学习并发数据结构的关键思想,以及第一种实现策略——锁耦合。我们将详细讨论锁耦合链表的具体实现。

概述

并发编程的核心是并发数据结构,因为多个线程会尝试访问同一资源,而这些资源本质上就是数据结构。因此,并发数据结构是并发编程的心脏,也是本课程的关键目标。

并发数据结构有三个主要标准:安全性可扩展性进度保证。接下来,我们将逐一详细探讨。

安全性 🔒

并发数据结构需要满足特定的规范。其中之一是顺序一致性。这意味着一个并发队列的行为应该与一个顺序队列完全相同。例如,如果两个线程同时向队列推送和弹出值,那么操作返回的结果应该与它们在顺序队列上操作的结果一致。如果返回随机值,就不能称之为一个正确的并发队列。

此外,还需要同步保证。如果线程A推送一个值,线程B弹出同一个值,那么在推送者和弹出者之间必须存在某种形式的同步。这是因为队列可以作为通信通道使用,发送值以传递信号。为了实现这一点,推送和弹出操作之间需要建立同步。

可扩展性 📈

安全性是并发数据结构的最低要求,但使用并发数据结构的主要目的是可扩展性。我们希望它比顺序数据结构更能适应并行架构。

可扩展性意味着随着CPU核心数量的增加,性能会变得更好。理想情况下,我们希望实现线性扩展,即CPU核心数量翻倍,性能也翻倍。但在现实中,尤其是在超过16个线程时,实现线性扩展非常困难。因此,我们需要考虑性能下降的幅度,目标是尽可能减少性能损失。

进度保证 🚦

最后,我们需要保证并发数据结构操作的进度,即确保操作最终能够完成。

关于进度有多种概念:

  • 无锁:如果多个线程尝试执行相同操作,至少有一个线程能成功完成其操作。例如,多个线程尝试获取锁时,至少有一个能成功。
  • 无等待:这是一个更强的条件。它意味着即使存在竞争和许多其他线程,每个线程最终都能轮流成功完成操作。例如,自旋锁是无锁的,但不是无等待的;而票据锁和MCS锁是无等待的,因为它们保证最终能成功获取锁。

实现策略 🛠️

为了实现上述理想属性,有几种策略。

上一节我们介绍了并发数据结构的目标,本节中我们来看看实现这些目标的具体策略。

全局锁保护

第一种策略是使用一个全局锁来保护整个顺序数据结构。这是最简单的方法,可以将顺序数据结构转变为受锁保护的并发数据结构。许多实现都采用此策略,但它的可扩展性不如其他选项。

细粒度锁保护

另一种选择是使用多个细粒度锁来保护数据结构。每个锁保护整个数据结构的一个子集。这样做可以大大减少竞争,因为访问数据结构不同部分的多个操作可以同时进行。

自定义同步协议

一种更具可扩展性的方法是使用自定义的同步协议来保护数据结构。在本学期后期,我们将学习这种同步协议以及细粒度并发和细粒度并发数据结构。

通常,这种规范被称为线性化,它结合了顺序规范与同步保证。这个概念在此链接中有解释,如果你感兴趣,可以前往查看。

可扩展性的关键思想 💡

正如前面提到的,实现可扩展性的第一个关键思想是通过缩小锁的保护范围来减少竞争。使用多个锁,让每个锁保护数据结构的更小子集,从而减少竞争。

第二个关键思想是通过避免写入操作来减少缓存失效。写入操作通常对性能不利,因为它需要获取缓存行,并使其他线程的缓存失效。因此,我们应尽可能避免写入。这种方法通常被称为乐观并发控制。你先读取数据以进行某些保证,但在读取后,你无法确定不变量是否仍然成立,需要在稍后再次检查。如果检查成功,则操作成功;否则,可能需要放弃操作。之所以称为“乐观”,是因为它乐观地假设值在一段时间内不会改变。如果假设成立,你将获得性能提升;否则,需要回退。

作为一个案例研究,我们将在学习锁耦合链表后,研究乐观锁耦合链表。

进度保证的关键思想 🎯

实现无锁的关键思想是将单个读-修改-写指令指定为竞争点,这样至少有一个CPU核心能成功执行该指令。例如,在自旋锁中,有一个锁变量,多个线程通过某些原子指令尝试更新它来获取锁。硬件架构保证至少有一个线程能成功执行该指令。通过这种方式,我们实现了无锁性:至少有一个正在进行的操作最终会完成。

实现无等待的关键思想是每个线程都发布其正在进行的操作,以便其他线程可以看到。你需要保证你的操作最终会完成。如何做到呢?你发布你的操作,并让其他线程代表你完成操作。通常,无锁意味着有一个“获胜”线程赢得了竞争并成功执行了原子指令。这个获胜者将负责处理其他线程的操作,这就是无等待的含义。

例如,YMCQ队列是无等待队列的一个主要例子。由于其复杂性,我们本学期不会学习它,但如果你感兴趣,可以阅读相关论文。此外,有一篇论文详细定义和分类了无锁、无等待等各种进度保证,非常有趣。如果你对这些保证感兴趣,建议阅读。

不过,本学期我们将只学习无锁性,因为其他进度保证更难实现和理解。

锁耦合链表案例 📚

作为并发数据结构的第一个例子,我们将学习锁耦合链表

这是一个单链表,与顺序链表的唯一区别在于每个节点都有一个锁。这个锁在访问节点的next指针时必须被持有。换句话说,锁保护着next指针。为了保证安全,在释放当前节点的锁之前,必须先获取下一个节点的锁。

以下是遍历链表的步骤:

  1. 首先读取头指针(head是共享且不可变的,此时无需加锁)。
  2. 在读取第一个节点的next指针之前,必须先获取该节点的锁。
  3. 获取下一个节点的锁。
  4. 只有在获取了下一个节点的锁之后,才能释放前一个节点的锁。

这样做的原因是确保在遍历过程中,当前节点不会被分离或释放。只要持有节点的锁,就能保证该节点不会被释放。通过“手握”至少一个锁,可以保证遍历路径上的节点是稳定的。

之所以称为“锁耦合”或“手递手锁定”,是因为在遍历过程中,总是同时持有两个锁(耦合),然后释放前一个,获取下一个,如此反复,就像锁在手中传递一样。

链表操作实现 ⚙️

以下是链表基本操作的实现逻辑:

插入操作

假设要在节点A和C之间插入新节点B。

  1. 获取节点A的锁。
  2. 读取A的next指针(指向C)。
  3. 分配新节点B,并将其next指针指向C。
  4. 将A的next指针修改为指向B(此修改必须在持有A的锁的情况下进行)。
  5. 释放A的锁。

删除操作

假设要删除节点A和C之间的节点B。

  1. 获取节点A的锁。
  2. 读取A的next指针(指向B)。
  3. 获取节点B的锁。
  4. (可选)再次读取B的next指针(指向C)以确认。
  5. 将A的next指针修改为指向C(此修改必须在持有A的锁的情况下进行)。
  6. 释放B的锁。
  7. 释放A的锁(或按顺序释放),然后可以安全地释放节点B的内存。因为此时你是唯一持有B引用的线程(其他线程要访问B必须先获取A或B的锁,而这些锁正被你持有)。

优缺点分析 ⚖️

锁耦合链表有其优点和缺点。

优点

  • 实现相对简单:比许多细粒度并发数据结构更容易实现。
  • 具有一定可扩展性:在链表中,线程通常只同时持有两个锁。因此,多个线程有可能通过访问链表的不同部分来同时进行操作。

缺点

  • 缓存失效:即使是为了读取而遍历链表,也需要获取锁。锁获取涉及写入操作(设置锁变量),会导致缓存行失效,可能降低性能。
  • 需注意锁顺序:虽然此实现通过按顺序获取锁(从头到尾)避免了死锁,但开发者必须仔细管理锁的获取顺序以确保不会发生死锁。
  • 非无锁:这是一种“有锁”的实现。作为替代方案,还有无锁数据结构,它们完全不使用锁。无锁结构的优点是更具可扩展性,并且没有因锁获取引起的缓存失效或死锁问题。但缺点是实现起来复杂得多。

在本学期剩余的时间里,我们将继续研究这类有锁数据结构及其属性,以及相关的内存分配器。

总结

本节课我们一起学习了并发数据结构的核心目标:安全性、可扩展性和进度保证。我们深入探讨了锁耦合链表这一具体实现,了解了其通过为每个节点配备锁并使用“手递手”锁定协议来保证安全遍历和修改的原理。我们还分析了这种方法的优缺点,并将其与更简单的全局锁方案以及更复杂的无锁方案进行了对比。锁耦合是在简单性与一定程度的并发性能之间取得平衡的一个经典案例。

18:无锁数据结构与Treiber栈

概述

在本节课中,我们将开始学习无锁数据结构。我们将了解无锁数据结构的核心概念、它们如何避免传统锁带来的问题(如死锁和活锁),并通过一个经典示例——Treiber栈——来具体理解其实现原理。我们将重点关注如何通过单指令提交点来保证并发操作的进展。


从锁到无锁

在之前的课程中,我们学习了并发数据结构如何比受锁保护的顺序数据结构提供更好的可扩展性,并以锁跳表为例进行了分析。

从现在起,我们将专注于所谓的无锁数据结构

“无锁”的字面意思是没有锁,即数据结构不受锁保护。但它还有更深层次的技术含义,我们稍后会定义。本节课将介绍无锁数据结构的概念和一些代表性示例。

在本学期(2024年)的作业中,作业5将要求实现一个名为“分离有序链表”的无锁数据结构,它本质上是一个哈希表。作业6将实现用于无锁数据结构的垃圾收集器。因此,你将能够亲手实现最重要的无锁数据结构之一——哈希表。


锁带来的问题

让我们思考一下锁。锁只允许一个线程访问共享数据,结果导致其他线程可能暂停或停滞。

如果一个线程持有锁并执行昂贵的计算、I/O操作,甚至崩溃而永不返回,那么其他线程就无法获取该锁,也就无法继续执行自己的操作。

因此,可能会发生死锁。死锁是指线程持有使进展变得不可能的资源。例如:

  • 线程A持有锁L1,并试图获取锁L2。
  • 线程B持有锁L2,并试图获取锁L1。

在这种情况下,A和B都无法继续执行,因为A无法获取L2(L2被B持有),B也无法获取L1(L1被A持有)。结果就是死锁,它们完全无法进展。

在并发编程中,死锁很常见。一种应对措施是,即使发生死锁,我们也可以检测到它并终止其中一个操作。例如,B持有L2导致了死锁,我们可以选择B作为牺牲品,直接丢弃其操作。这样L2就被释放,A就能获取L2并继续执行。从整个系统来看,这保证了某种进展(至少A能进展)。

但这并非完美的解决方案,因为可能存在活锁的情况。活锁与死锁形成对比:线程确实在执行操作,但系统不断终止操作,导致没有线程能取得有意义的进展。

假设有三个线程A、B、C,它们以特定顺序竞争锁L1、L2、L3。死锁检测器可能不断选择并终止某个线程来打破僵局,但随后又可能立即陷入新的类似僵局,导致线程被循环终止。从整个执行过程来看,没有线程取得实际进展,因为它们不断被死锁检测器终止。

这类问题是无锁技术要对抗的敌人。无锁的基本目标是彻底避免此类情况,确保至少有一个操作能够取得进展。

因此,无锁作为一个技术术语,不仅意味着没有锁,还意味着进展保证——至少有一个操作应该能进展。如果你使用锁,那么它几乎总不是无锁的。有趣的是,即使你不使用锁,也可能无法保证无锁。为了提供这种进展保证,我们需要比简单地不用锁付出更多关注。


核心思想:单指令提交点

实现无锁的关键思想是,确保操作在一个称为提交点的单点上取得进展。

假设我们要设计一个栈,并实现其pushpop操作。我们的目标是确保单条架构指令负责将更改提交到数据结构。通过这样做,我们可以利用架构本身提供的进展保证。

硬件架构保证,如果有多个CPU核心和多个线程,那么至少有一个线程或核心能够执行下一条指令。此外,如果下一条指令是像compare-and-swap这样的“读-改-写”指令,架构保证至少有一个核心的该指令会成功。

通过利用架构的保证,我们可以设计出无锁软件。使用单条RMW指令作为提交点,我们可以看到,即使多个线程同时访问这个栈(一些在push,一些在pop),至少有一个操作会成功,因为它们的RMW指令中会有一个成功。

这基本上就是几乎所有无锁数据结构的高级核心思想。提交点通常必须是读-改-写操作,因为单纯的“读”不改变内存状态,不能作为提交点;而单纯的“写”也几乎不可能作为提交点,因为写入的值可能被任意覆盖,写指令本身不提供足够的保证。

实际上,RMW指令在历史上就是为了支持这种单指令同步点而被引入到大型机中的。

这个单指令提交点的想法确实能击败无锁的敌人:

  • 线程暂停:单条指令要么完全执行,要么完全不执行,不会被中断或干扰。
  • 死锁与活锁:粗略地说,因为没有锁,所以我们没有死锁和活锁问题。

如果我们设计好这个单指令提交点,就可以保证不会出现死锁或活锁问题,因为至少有一个操作会成功。

作为额外的益处或副作用,我们还实现了可扩展性。因为与锁不同,多个线程之间的争用只发生在单条指令上。在使用锁保护的并发数据结构中,锁内会执行多条指令,在此期间其他线程无法进行。而在使用单指令提交点的无锁数据结构中,我们有效地将争用减少到仅一条指令,因为其他指令只是在为提交做准备或进行提交后的清理,它们根本不发生争用。这就是为什么通过这种思想可以实现更好的可扩展性。


示例:Treiber栈

现在,让我们看看如何在一个具体示例——Treiber栈——中实现上述思想。Treiber栈是最古老的并发无锁数据结构之一。

Treiber栈本质上是一个单向链表,其中头指针head指向栈顶。下图展示了这种栈的结构:

head -> [42] -> [37] -> [666] -> null

当你push一个值时,你会在链表头部插入一个新节点。当你pop一个值时,你会移除链表头部的节点。这样就实现了后进先出的栈行为。

pop操作流程

假设线程A试图从栈顶pop一个值:

  1. 读取头指针:读取当前的head指针(指向包含42的节点)。
  2. 读取next指针:读取头节点(42)的next指针(指向包含37的节点)。因为我们要移除42节点,新的head应该指向37。
  3. 执行compare-and-swap:尝试原子地将head指针从指向42改为指向37。
  4. 处理结果
    • 如果CAS成功,head现在指向37,42节点被移出栈。可以安全地返回42。
    • 如果CAS失败,意味着head指针已被其他线程改变。需要回到步骤1重试。

push操作流程

假设线程B试图向栈中push一个新值(例如99):

  1. 读取头指针:读取当前的head指针(指向37)。
  2. 创建新节点:创建一个新节点,其value为99,next指针指向读取到的head(37)。
  3. 执行compare-and-swap:尝试原子地将head指针从指向37改为指向新节点(99)。
  4. 处理结果
    • 如果CAS成功,新节点(99)被插入到链表头部,栈变为 head -> [99] -> [37] -> [666] -> null
    • 如果CAS失败,意味着head指针已被其他线程改变。需要释放或重用新节点,并回到步骤1重试。

这里的compare-and-swap操作就是我们之前讨论的单指令提交点。如果这个CAS成功,整个操作就成功;否则,操作失败。这条指令决定了操作的成功与否。


代码解析

现在让我们查看课程仓库中Treiber栈的实现代码。它使用crossbeam库提供的原子类型和内存管理工具。

以下是核心结构定义:

pub struct Stack<T> {
    head: Atomic<Node<T>>, // 原子化的头指针
}

struct Node<T> {
    data: ManuallyDrop<T>, // 手动管理drop的数据
    next: Atomic<Node<T>>, // 原子化的下一个节点指针
}
  • Atomic<Node<T>> 是一个可以安全进行原子操作(如compare_and_swap)的指针。
  • ManuallyDrop<T> 包装数据,防止Rust自动调用析构函数,这对于无锁数据结构中的延迟回收至关重要。

push 方法

pub fn push(&self, t: T) {
    // 1. 为本次操作创建一个“防护”(guard),与垃圾回收相关,目前可忽略其细节。
    let guard = pin();
    // 2. 创建新节点,其next指针暂为空。
    let mut n = Owned::new(Node {
        data: ManuallyDrop::new(t),
        next: Atomic::null(),
    });
    // 3. 循环尝试,直到push成功。
    loop {
        // 4. 读取当前头指针(Acquire顺序,用于同步)。
        let head = self.head.load(Acquire, &guard);
        // 5. 将新节点的next指针设置为读取到的head。
        n.next.store(head, Relaxed);
        // 6. 尝试提交:原子地将head从旧值换成新节点(Release顺序)。
        match self.head.compare_and_set(head, n, Release, &guard) {
            Ok(_) => {
                // 7. CAS成功,push操作完成。
                return;
            }
            Err(e) => {
                // 8. CAS失败,head已变。恢复n(旧节点)用于下次尝试。
                n = e.new;
            }
        }
    }
}

pop 方法

pub fn pop(&self) -> Option<T> {
    // 1. 创建防护。
    let guard = pin();
    // 2. 循环尝试,直到pop成功或栈为空。
    loop {
        // 3. 读取当前头指针(Acquire顺序)。
        let head = self.head.load(Acquire, &guard);
        // 4. 将裸指针转换为引用,以便访问节点内容。
        let h = unsafe { head.as_ref() }?; // 如果head为null,栈空,返回None。
        // 5. 读取头节点的下一个节点指针。
        let next = h.next.load(Relaxed, &guard);
        // 6. 尝试提交:原子地将head从当前头节点换成下一个节点(Release顺序)。
        match self.head.compare_and_set(head, next, Release, &guard) {
            Ok(_) => {
                // 7. CAS成功,头节点已移除。
                // 8. 安全地读取并返回头节点的数据。
                //    使用ManuallyDrop::take来取得所有权,防止后续自动drop。
                let data = ManuallyDrop::into_inner(unsafe { ptr::read(&h.data) });
                // 9. 标记原头节点可被安全回收(延迟销毁)。
                unsafe { guard.defer_destroy(head); }
                // 10. 返回数据。
                return Some(data);
            }
            Err(_) => {
                // 11. CAS失败,head已变,重试。
                continue;
            }
        }
    }
}

关键细节与同步

内存顺序:Release与Acquire

在代码中,push操作的compare_and_set使用Release顺序,而pop操作中加载head使用Acquire顺序。这是为了实现线程间的同步,类似于消息传递。

考虑以下场景:

  1. 线程A写入一个值 x = 42
  2. 线程A push 一个信号到栈中。
  3. 线程B pop 到这个信号。

我们希望确保线程B在pop之后,能看到 x == 42。为了实现这种“先写后读”的可见性保证,需要在push(写入/发布)和pop(读取/获取)之间建立同步关系。

  • Release顺序:保证该操作之前的所有写操作(如x = 42)的结果,对于其他线程中后续Acquire顺序读取同一位置的线程是可见的。
  • Acquire顺序:保证该操作之后的所有读/写操作,都能看到其他线程中之前Release顺序写入同一位置的所有写操作的结果。

因此,push中的Releasepop中的Acquire配对,确保了数据(如x = 42)能正确地从生产者线程传递到消费者线程。

ManuallyDrop的作用

ManuallyDrop用于包装节点中的数据。它的作用是防止Rust编译器自动插入对数据的drop调用。

pop操作中,我们将节点的数据返回给了调用者。这个数据的所有权已经转移。然而,节点本身(包含next指针的壳)可能还需要在内存中保留一段时间(因为其他线程可能仍在访问它),直到垃圾收集器安全地回收它。

如果我们不使用ManuallyDrop,当节点最终被回收时,Rust会尝试再次drop其中的数据,这会导致双重释放或逻辑错误。使用ManuallyDrop后,我们明确表示:“这个数据的生命周期由我手动管理,你不要自动drop它。” 在pop中,我们使用ManuallyDrop::into_inner手动取得了数据的所有权并返回。

延迟销毁 (defer_destroy)

guard.defer_destroy(head) 并不会立即释放head节点占用的内存。它只是通知垃圾收集器(crossbeamepoch机制)这个节点已经不再被数据结构引用,可以安排在未来某个安全的时刻(当没有线程可能再访问它时)进行回收。这是实现安全无锁内存管理的关键。


总结

本节课我们一起学习了无锁数据结构的基础。我们首先探讨了锁带来的死锁和活锁问题,引出了无锁设计对进展保证的需求。其核心思想是利用硬件的原子操作(如compare-and-swap)作为单指令提交点,来保证至少有一个并发操作能够成功。

我们深入分析了Treiber栈这一经典无锁数据结构的实现,包括其pushpop操作的算法流程、使用crossbeam库的代码实现、以及关键的同步细节(Release/Acquire内存顺序)和内存管理机制(ManuallyDrop与延迟销毁)。

Treiber栈是最基本且有趣的无锁数据结构之一。在接下来的课程中,我们将学习更复杂的无锁数据结构,如队列。而在本学期的作业中,你们将有机会亲手实现一个更复杂的无锁数据结构——分离有序链表,它将作为一个高性能的并发哈希表。

19:Michael-Scott无锁队列 🧠

在本节课中,我们将要学习Michael和Scott提出的无锁队列。这是第一个无锁队列实现,由Michael和Michael Scott两位作者提出。巧合的是,两位作者的姓氏分别是Michael和Scott,而名字都是Michael。这个队列与Treiber栈类似,都是一个单向链表。它由一个头指针指向。队列从头部弹出元素,并向尾部推入元素。这与栈不同,在Treiber栈中,你从同一个头指针进行推入和弹出操作,而在这个队列数据结构中,你从头部弹出,向尾部推入。作为一个优化,队列维护了一个尾指针。尾指针是一个物理位置,试图指向队列的尾部。这样做的原因是,当你向队列推入一个元素时,你不想遍历整个队列。相反,你可以加载尾指针,它被假定指向链表的靠后部分,然后从尾指针开始尝试减少遍历次数。但尾指针不一定指向实际的尾部节点,因此需要谨慎处理。尾指针在队列中,但它不需要是实际的尾部,它仍然指向某个节点。你可以在此处的代码中看到实现。在本视频中,我将简要解释这个队列的一些实现细节。

与Treiber栈类似,我们使用CAS操作来同步队列中的操作。对于弹出操作,你将对头指针执行比较并交换操作,这与我们在Treiber栈中的做法相同。另一方面,对于推入操作,你将从尾指针开始遍历以找到尾部节点,然后执行CAS操作。因此,最后一个节点的next指针指向这个新节点。你正在执行从该节点指针到新节点的CAS。

可视化操作流程 📊

让我用动画来可视化这个想法。为了执行弹出操作,你首先遍历头指针,实际上你需要遍历两次,然后执行一个CAS操作。这基本上就是使用循环来实现Michael-Scott队列弹出操作的过程。需要注意的一点是,你将不是从这个节点,而是从下一个节点获取值。原因是这个节点是虚拟节点。它是一个哑元,不包含任何有用信息。它只是占位,而实际的头节点值是37。因此,当你在此阶段执行弹出函数时,我们将检索值37并返回它。

另一方面,为了执行推入操作,你首先检索尾指针,并通过遍历next指针来找到实际的尾部节点。你可以通过检查某个节点的next指针是否为null来判断它是否是尾部节点。然后你创建一个新节点并执行比较并交换操作。在执行从该节点指针到新节点指针的比较并交换之前,你创建节点并将其next指针设置为null,然后比较并交换该节点的next指针,从null指针交换为新节点。这有效地在链表(即队列)的末尾插入了一个新节点,从而实现了先进先出。

核心设计与挑战 ⚙️

这基本上是主要的设计选择,但还有一些实现细节需要考虑。这里的主要设计选择是尾指针。我们指定了这个尾指针。但你可以看到,在推入一个新节点后,尾指针就变得过时了,因为尾指针实际上指向的是旧的尾部,而不是实际的尾部。因此,我们需要在队列末尾推入一个值后,通过将尾指针从旧节点更改为新节点来修复尾指针。这里的挑战是尾指针可能已经过时。我们对此挑战的解决方案是放宽了对尾指针的不变式要求。因此,尾指针实际上不需要是实际的尾部,正如你在推入一个值后所看到的,它不再是实际的尾部。但我们希望建立一个不变式:尾节点指针可以从头指针到达。这在执行开始时是正确的,头在这里,现在头变成了这里。这里的尾指针可以从虚拟节点或37节点到达。为了维持这个不变式,我们需要处理头指针和尾指针之间的关系,但我们仍然可以轻松地建立这个不变式:尾指针可以从头指针到达。

让我在下一张幻灯片中详细解释如何建立这个不变式。

算法概述 📝

这是Michael-Scott队列在一张幻灯片中的算法。让我先解释一下,然后我们去看代码,看看那里发生了什么。

在推入操作中,你首先找到实际的尾部。回想一下并发线程,例如线程C。假设C也试图推入一个值。但尾指针是666,实际上可能的情况是,实际的尾部是由另一个线程新插入的。因此,尾指针是过时的,它包含一个旧值。如果是这种情况,我们将通过从这个尾指针开始遍历来找到实际的尾部。这是第一个节点,我们读取它的next指针,由于next指针不是null,这个节点不是尾部。因此,我们继续遍历,在这里我们将读取next指针,这是节点指针。这就是为什么这个节点是链表的实际尾部。通过这种方式,我们将找到实际的尾部。同时,我们也尝试更新尾指针。现在我们假设我们在这里检索到了实际的尾部,并尝试通过执行尾指针上的比较并交换操作,将尾指针从原始尾部更新为新的实际尾部。如果成功,我们就在帮助其他线程,因为我们正在将尾指针更新为最新值。这将帮助其他线程更有效地遍历到实际的尾部。但它可能会失败,因为可能另一个线程已经将尾指针更新为其他值。如果是这种情况,我可能会失败,但这没关系。这意味着尾指针已被其他线程更新。这和我更新了尾指针一样好。我保证我或其他人更新了尾指针。这可能是实现同步所需的一切。因此,更新尾指针成功与否并不重要,重要的是尾指针被某人更新了。通过这种方式,我们通过执行尾指针上的比较并交换操作来更新尾指针,从原始尾指针值更新为新节点。

作为第二步,我们尝试通过执行尾指针的next指针上的比较并交换操作来追加一个新节点。如果我们成功,那么我们就有效地成功插入了一个新节点,操作成功。如果CAS不成功,那么我们需要重试。通过检索新的尾部,CAS失败的原因是某个其他线程已经在尾部执行了CAS。因此,我们需要遍历刚刚被并发线程更新的新尾部,然后在那里追加节点。所以如果失败,你应该从头开始重试,如果成功,那么整个操作就成功了。之后,如果通过CAS追加新节点成功,那么你就为其他线程更新尾指针。

在这张幻灯片中,线程B成功地将一个新节点推入到这里。但尾指针变得过时了。因此,线程B通过将尾指针从666更新到这里的新节点来帮助其他线程。这有助于尾指针不那么过时,这是一种优化方式,让其他线程能更有效地遍历到实际的尾部节点。正如我所说,在这一步中,我们同样执行从旧尾节点到新尾节点的CAS。在这里CAS失败也是可以的,因为这意味着其他线程中的某个线程成功地更新了尾指针。因此,这里重要的是尾指针被某人更新了。所以,我是否成功执行CAS并不重要。

弹出操作流程 🔄

另一方面,弹出操作是这样进行的。它首先读取尾指针,并检查尾指针是否指向虚拟节点。在这里,如果尾指针指向这个虚拟节点,这意味着在更新头指针后,头指针跑到了尾指针的前面。头指针和尾指针的顺序颠倒了。这对队列来说是不好的。如果尾指针指向虚拟指针,为了防止头指针超过尾指针,我们尝试将尾指针从这个虚拟节点更新到下一个节点37。因此,如果这里的CAS成功,那很好;如果CAS不成功,那也没关系,因为这意味着尾指针不再指向虚拟节点。因此,通过执行CAS(如果尾指针指向虚拟节点),我们确保尾指针不再指向虚拟节点。之后,我们通过执行从第一个节点到第二个节点的CAS来更新头指针。如果它失败,那么你可以从头开始重试;如果你成功,那么意味着弹出操作成功完成。正如我所说,如果尾指针指向虚拟节点,则更新尾指针。通过这样做,我们可以建立这里的不变式:尾指针必须可以从头指针到达。在将尾指针从虚拟节点更新到37之后,即使在我们弹出一个值、从队列中移除虚拟节点之后,我们也可以建立尾指针可以从头指针到达的不变式。因此,建立“尾指针可以从头指针到达”这个不变式很重要。你可以看到,在弹出这个虚拟节点后,这个37节点成为新的虚拟节点,因为它是第一个节点。在Michael-Scott队列中,第一个节点始终是虚拟节点,它可能包含值,但这没有意义。它只是虚拟节点。

这就是一张幻灯片中的算法,现在我想去看看实现,并逐行阅读。这个算法大约在25年前发表,所以它是Michael和Scott提出的相当古老的算法,但当需要无锁队列时,它仍然被广泛使用。

代码结构解析 💻

首先,我们再次有CrossbeamEpochPinCrossbeamEpochMutT,它们是缓存填充的。让我逐行解释其余代码中发生了什么。

队列基本上指向两个节点。所以有两个指针,headtail,它们指向一个节点。一个节点通常由数据和下一个节点指针组成。我们将其标记为MaybeUninit,这意味着数据可能未初始化,这特别有用,因为我们有一个虚拟节点。在队列开始时,虚拟节点的值是未初始化的,所以我们希望在类型中明确标记节点中的数据可以是未初始化的,例如由于虚拟节点。它还包含next指针。所以和往常一样。节点应该有一个指向下一个节点的next指针。队列指向两个节点,一个是头,另一个是尾。

在队列开始时,它将在这里分配队列。所以你将创建一个队列,并且这将是被返回的队列。但我们不返回这个节点指针。在队列生命周期开始时,我们需要插入一个虚拟节点。在任何时候,队列都有一个虚拟节点。这就是为什么我们要放置虚拟节点。它是一个虚拟节点,我们创建一个哨兵节点。这里的哨兵意味着我们正在创建初始的虚拟节点。它不包含数据。我们正在将next指针设置为null指针。所以它是具有null指针的未初始化数据,这就是初始的虚拟节点。这个哨兵值现在存储在头指针和尾指针中。

让我们看看那里发生了什么。哨兵有未初始化的值,没有next指针。在队列生命周期结束时,头指针指向哨兵节点。同时,尾指针也指向哨兵节点。这个哨兵是在堆上分配的。所以你可以看到你正在创建一个Owned,在crossbeam术语中,这意味着在堆上分配数据。你正在创建一个堆分配,并让头指针和尾指针指向堆中的哨兵节点。

这就是初始的内存布局。到目前为止一切顺利。

现在我们返回这个队列。在设置了头指针和尾指针之后,现在让我们看看如何向队列推入一个值。

你被给予一个对守卫的引用,这意味着守卫证明你可以访问共享内存中的数据结构。它基本上是一个证明,表明你可以访问共享内存中的数据。你首先要做的是创建一个新节点。回想一下,你需要创建一个带有新数据和null指针的新节点。你在堆上创建它,这样你就可以将这个新指针、新节点附加到队列的尾部。

首先,你在这里创建一个新节点。这个into_shared意味着这个新节点有两种类型:OwnedSharedOwned意味着没有其他线程可以访问这个节点,而Shared意味着其他线程可以访问这个节点。通过调用into_shared,我们有效地释放了对这个节点的部分所有权。到目前为止,我们拥有这个新节点,但从现在开始,我们释放了独占所有权,并承认这个新节点可以在多个线程之间共享。

我们使用循环,因为如果我们没有成功将新值插入到队列末尾,我们应该用同一个节点重试。我们首先加载尾指针。正如我所说,尾指针是乐观的,所以它实际上不需要指向真正的尾部,但它实际上指向队列中的某个节点。我们加载尾指针,解引用它,并读取尾节点的next指针。如果尾节点的next指针不是null,那么尾节点实际上不是真正的尾部。因此,我们将尾指针移动到下一个节点。我们执行尾指针上的比较并交换操作,将尾指针从旧的尾节点交换为尾节点的next指针。这就是这里所做的第一步。正如我所说,它不需要成功。如果成功,那很好,我移动了尾指针;如果不成功,我未能移动尾指针,但通过失败,我知道其他某个线程成功地将尾指针移动到了某个下一个节点。所以尾指针现在被更新了。因此,你想从这里重新开始尝试。

否则,如果next指针实际上是null指针,那么尾节点实际上是真正的尾部。现在,我们尝试通过执行比较并交换操作来追加一个新节点。这就是这里的比较并交换操作。我们在尾节点的next指针上执行比较并交换操作,有效地将尾节点的next指针从null指针交换为新节点。如果成功,那就太好了,我们以与上述相同的方式帮助移动尾指针,然后返回。否则,如果不成功,那么我们知道我们没有成功推入一个新值,操作失败。所以我们再次从头开始重试。

这基本上就是推入函数的实现。

让我们去看看try_pop函数的实现。

我们读取头指针,然后它有一个next指针。再次使用循环。如果这个next指针是一个null指针,那么as_ref返回None。如果next不是null指针,那么它返回对next指针的引用。如果nextnull指针,那么我们返回None。因为这意味着队列看起来像这样:头指针指向某个节点,而该节点指向null指针,这意味着队列只包含虚拟节点,即队列为空。所以这意味着此时我们应该返回None

我们尝试在必要时更新尾指针。我们读取尾指针,如果它与头指针相同,这意味着尾指针和头指针都指向虚拟节点。就像这里一样。可能的情况是,它的next指针可能是其他东西,但尾指针仍然指向哨兵节点。如果是这种情况,则满足此条件:尾指针和头指针同时指向虚拟节点。那么,如果是这种情况,我们像往常一样更新尾节点。在更新尾节点之后,无论我们是否成功,我们都知道队列的尾指针不再指向虚拟节点,因为它已经被更新了。现在我们可以执行头指针上的CAS操作。我们执行头指针上的CAS操作,将头指针从原始头指针交换为下一个节点。如果成功,我们通过执行此操作销毁头节点,然后返回下一个节点的值。所以我们不从虚拟节点检索值。虚拟节点在这里是头节点,我们从下一个节点检索值。正如我之前解释的,虚拟节点的值没有意义。它已经被一个弹出操作取走了,所以我们需要从下一个节点检索值。我们对虚拟节点进行垃圾回收,因为头指针不再被队列的头指针指向,所以我们需要销毁它。但是,可能的情况是,一个并发线程可能同时引用同一个头指针并引用它的next指针。这就是为什么我们不应该立即释放这个头指针、这个虚拟指针。相反,我们依赖crossbeam,我们只是说我们延迟销毁头指针。crossbeam的epoch机制将在没有线程当前访问同一节点时自动处理并稍后释放这个堆分配。目前,让我们假设你可以使用这里给出的守卫来延迟销毁。

drop实现中,我们只是再次循环,弹出节点并释放分配的节点。这基本上是队列最简单的实现。

关键问题解答 ❓

这是关键的实现。现在我们回到解释算法的幻灯片。这些步骤实际上在Rust代码中相当直接地实现了。请阅读列表,并请阅读队列实现,你可以立即将这些步骤与Rust实现联系起来。与Treiber栈一样,我为Michael-Scott队列准备了一些问题集,你可以在期末考试中看到。我将逐一回答这些问题。

问题1:为什么尾指针可能过时?
这已经在之前的幻灯片中回答了。在插入一个新节点后,你可以看到尾指针变得过时。这就是为什么尾指针可能不指向实际的尾部节点。

问题2:为什么需要虚拟节点?为什么不直接指向队列中的第一个节点、第一个值?
主要原因是,通过这样做,你可以保证尾指针有地方可指。回想一下,在队列生命周期开始时,哨兵虚拟节点被头指针和尾指针同时指向。但如果没有这样的节点,并且头指针指向null指针,你必须将null指针分配给尾指针,因为没有节点可指。如果是这种情况,我们就无法建立“尾指针可以从头指针到达”这个不变式。因此,为了建立尾指针可以从头指针到达的不变式,我们指定了一个虚拟节点。仅仅是为了建立这个不变式。通过使用虚拟节点,特别是对于Michael-Scott队列,我们有效地建立了可达性不变式。

问题3:为什么头指针和尾指针是缓存填充的?
这与之前的Treiber栈实现类似。我们希望尽可能地将headtail变量分开,这样它们就不会被错误地共享,因为tailhead是并发操作的点,你可以并发地在tailhead上工作。为了实现更高的性能,我们需要将它们分隔在不同的缓存行中,以防止共享。这就是为什么我们用缓存填充来填充头指针和尾指针,以确保头指针和尾指针驻留在不同的缓存行中。

问题4:内存顺序(Ordering)的解释
与Treiber栈一样,我们试图理解为什么某些顺序是Release,而某些是Acquire。我们想要建立一些不变式。为了建立这些不变式,这些顺序必须是ReleaseAcquire。Michael-Scott队列保证了一个指针,例如这个指针指向这里的节点A。这个指针值具有Release视图。这个值,即指向A的指针,在promising语义中应该具有Release视图。它的视图应该大于或等于A的值和A指向B的next指针。这就是不变式:它的指针的Release视图大于或等于所指向节点A的值和A的next指针。这就是我们想要建立的不变式,以证明这个Michael-Scott队列的功能正确性和安全性。让我们看看如何通过Release顺序来维持,以及如何通过Acquire顺序来利用它。

现在我们可以看这里的代码示例。例如,这里有Release顺序。我们想要比较并交换尾指针,从旧指针到新指针。这里我们使用Release顺序。它应该是Release的原因是,这个尾指针将指向某个节点,我们需要建立这样一个事实:这个尾指针的Release视图大于或等于所指向节点(这里的next)的值和next指针。实际上,这是由这里建立的。这个next指针是Acquire的,因此,这个next指针的构造视图大于或等于next指针的值和next指针的视图。所以这里Release了。因此,尾指针的Release视图将大于或等于这个下一个节点的值和指针。这是通过这个Acquire和这个Release建立的。

同样的情况也发生在这里。新节点在这里创建,我们希望Release它,以建立不变式。同样,我们也希望在这里建立事件。新节点成为新的尾节点的next指针,所以我们需要Release它,因为我们希望将关于这个新节点的视图知识转移到尾节点的next指针。通过比较并交换。这就是为什么它必须是Release的,并且新节点被写入该位置,必须用Release完成。同样的情况在这里。下一个节点被写入尾指针,所以它必须是Release的。知识本身在这里被Acquire,而Acquire的知识在这里被Release。这两个是匹配的。这两个Release与这里的知识匹配。堆分配的知识在这里和这里被Release。这就是为什么它应该是Acquire的,而它应该是Release的。

此外,这也必须是Acquire的,因为我们试图在这里解引用next指针。你知道,为了做到这一点,我们需要在这里Acquire尾指针,以便我们总是看到next指针的最新值。否则,我们可能会看到一些过时的next指针,它甚至可能是null指针。为了防止这种情况,我们应该Acquire这个尾指针,以便对next指针的解引用将看到最新值。同样,在这里,我们使用Acquire读取这个头指针,以便从头指针检索最新的next指针。我们也在这里Acquire它,以便检索下一个节点的最新值。回想一下,这里我们基本上是在检索虚拟节点。在这里,我们从next指针检索下一个节点,我们将从该节点返回值。这就是为什么我们需要Acquire这个以获取写入next指针的最新值。所以,值在这里写入。为了让这里读取最新值,这必须是Acquire的。为了将虚拟节点的next节点读取为最新的,它必须是Acquire的。这就是为什么这两个是Acquire的。

这个可以是Relaxed的,因为如果读取尾指针只是为了检查它是否与头指针相同,并且如果相同,我们已经在这里读取了头指针。因此,这里没有道德理由去Acquire尾指针。如果我们进入这一行,如果tail等于head,那么我们实际上已经Acquire了尾指针,因为我们在这里Acquire读取了头指针。此外,出于同样的原因,它必须是Release的,所以我们必须Release关于next指针的信息,这些信息在这里被Acquire,所以线程的当前视图拥有关于下一个节点的最新信息,并且在这个时间点被Release。当next指针被写入时。同样的情况发生在这里,next指针被写入头指针,我们需要使用Release顺序来完成。

到目前为止一切顺利。这基本上就是顺序中发生的事情。我希望你能理解幻灯片中解释的Release-Acquire顺序的实际推理。这就是Release-Acquire顺序的发生。ReleaseAcquire的原因是为了建立和利用这个不变式。只有满足这个条件,我们才能安全地返回值。否则,如果不满足这个不变式,那么从弹出操作返回的值可能是错误的。如果Release-Acquire没有像这样正确使用,它可能读取一个非常过时的值,这个值不是队列中应有的值。如果这个不变式没有建立,弹出操作可能会返回一个非常奇怪的值。这就是为什么我们在这个时候进行Release-Acquire

总结 📚

到目前为止,我们学习了Michael-Scott队列,就像在前一个视频中一样,我们学习了它的操作、顺序和同步不变式。我希望这能为你解释一些东西,我强烈鼓励你在Github问题跟踪器中提问。我知道这相当棘手,有时很难理解这里的一些同步问题。这就是为什么我敦促你如果有任何疑虑、问题或评论,就提出问题。通过提问是有效学习的方式。如果不提问,我几乎可以肯定你将无法掌握前一张和这张幻灯片的内容。它们相当密集。所以我希望你能在Gi跟踪器中提问。

20:无锁链表 🧠

在本节课中,我们将要学习无锁单链表。它大致有三种变体,我们将以一种统一的方式来学习这三种变体。

链表的内存布局

首先,我们从解释链表的内存布局开始。

链表有一个头指针,指向一个节点。节点包含一个值(例如 37)和一个指向下一个节点的指针。下一个节点可能包含值 42,并指向另一个包含值 101 的节点,以此类推,直到遇到空指针。

这是一个无锁单链表示例的内存表示。

链表的基本操作

链表支持三种基本操作:

  • 插入:向链表中添加一个值。
  • 删除:从链表中移除一个值。
  • 查找:检查给定的值是否存在于链表中。

我们假设链表是有序的。例如,要查找值 43,我们从头指针开始遍历:37 < 43,继续;42 < 43,继续;101 > 43。由于链表有序,我们可以立即得出结论:链表中不存在 43,因为 43 应该出现在 42 和 101 之间,但实际并不存在。

所有这三种操作都有相似的实现模式。它们都包含两个阶段:

  1. 遍历阶段:遍历链表,找到目标位置(例如,对于值 43,目标位置在节点 42 和 101 之间)。
  2. 操作阶段:在找到的位置执行插入、删除或查找操作。

因此,所有链表操作都共享相同的遍历算法。

三种遍历策略

有三种主要的遍历策略:

  1. Harris 策略
  2. Harris-Michael 策略
  3. Harris-Holy Shabbat 策略(注:原视频发音可能不准确)

结合三种操作(插入、删除、查找)和三种遍历策略,我们总共可以得到 9 种不同的组合(例如 Harris插入、Harris删除、Harris查找等)。

在接下来的内容中,我们将学习遍历策略以及如何执行这些操作。

操作详解

首先,我们来看操作阶段,因为它相对容易理解。

假设通过遍历,我们找到了两个节点 prev(例如包含 42)和 curr(例如包含 101),我们想要操作的值应该位于这两个节点之间。

插入操作

插入操作如下:

  1. 创建一个新节点,其 next 指针指向 curr 节点。
  2. prev 节点的 next 指针执行 比较并交换 操作,尝试将其从指向 curr 改为指向新节点。

代码描述

// 伪代码示意
let new_node = Node { value: 43, next: curr };
if compare_and_swap(&prev.next, curr, new_node) {
    // 插入成功
} else {
    // 插入失败,可能其他线程已修改
}

如果多个线程尝试在相同位置插入,CAS 操作会解决竞争条件。只有一个线程的 CAS 会成功,从而协调了多个线程的并发访问。

删除操作

删除操作需要更多考虑,以处理与插入操作的并发。假设我们要删除 curr 节点(例如包含 42)。

简单的想法是:将 prev.next 从指向 curr 直接 CAS 为指向 curr.next。但这可能与一个正在 curr 之后插入新节点的操作产生冲突,导致插入的节点丢失。

为了解决这个问题,删除操作分为两步:

  1. 逻辑删除:首先,通过某种方式(例如,将 curr.next 指针的最低有效位设置为 1)将 curr 节点标记为“逻辑上已删除”。
  2. 物理删除:然后,对 prev.next 执行 CAS 操作,将其从指向 curr 改为指向 curr.next

代码描述

// 伪代码示意:逻辑删除
let next = curr.next;
let marked_next = next.with_mark_bit(1); // 标记最低位
if compare_and_swap(&curr.next, next, marked_next) {
    // 逻辑删除成功
    // 接着进行物理删除...
} else {
    // 逻辑删除失败,重试
}

关键点:逻辑删除(修改 curr.next)和插入操作(修改 prev.nextcurr.next)现在可能对同一个指针位置(curr.next)进行原子修改。CAS 操作保证了它们不会同时成功,从而协调了删除和插入之间的竞争。

  • 如果逻辑删除先发生,则插入操作的 CAS 会失败。
  • 如果插入先发生,则逻辑删除操作的 CAS 会失败(因为 curr.next 已指向新插入的节点)。

查找操作

查找操作相对简单:遍历链表,如果找到目标值且该节点未被逻辑删除,则返回成功;否则,返回失败。

遍历策略详解

现在,让我们深入了解三种遍历策略。它们的主要区别在于如何处理遍历过程中遇到的逻辑删除节点

在遍历过程中,我们始终维护一对指针:prevcurr。基本步骤是读取 curr.next,然后前进 prevcurr。当遇到逻辑删除节点时,策略开始分化。

1. Harris 策略

在 Harris 策略中,如果发现 curr 节点被逻辑删除,遍历不会立即修复链表,而是继续前进,直到找到一个非逻辑删除的节点。然后,它通过一次 CAS 操作,将 prev.next 直接指向这个找到的非逻辑删除节点,从而一次性物理删除了中间所有连续的逻辑删除节点。

特点:延迟修复,但一次修复多个节点。

2. Harris-Michael 策略

在 Harris-Michael 策略中,一旦发现 curr 节点被逻辑删除,它会立即尝试修复:通过 CAS 操作将 prev.next 从指向 curr 改为指向 curr.next。如果成功,则物理删除了 curr 节点。然后,它使用 curr.next 更新 curr 指针,并继续遍历。

特点:立即修复,但一次只修复一个节点。

3. Harris-Holy Shabbat 策略

在 Harris-Holy Shabbat 策略中,即使遇到逻辑删除节点,也完全忽略,不进行任何修复,继续正常遍历。这种策略通常用于需要快速执行的操作(如只读查找)。查找操作在找到值后,需要额外检查该节点是否被逻辑删除。

特点:不修复,追求遍历速度。

代码实现概览

源代码中定义了 Node 结构,包含键、值和原子的 next 指针。List 是一个指向头节点的原子指针。

遍历过程使用一个 Cursor(游标)结构来抽象维护 prevcurr 指针对。

查找函数(如 find_harris)实现了上述遍历策略。在 Harris 策略的查找中,当循环结束时,如果 prevcurr 不相邻(说明中间有逻辑删除节点),它会尝试执行一次 CAS 来修复链表,物理删除这些节点。

插入和删除操作则基于查找函数返回的游标进行:

  • 插入:在游标位置创建新节点,并尝试 CAS 更新 prev.next
  • 删除:首先尝试逻辑删除目标节点(标记 curr.next),成功后尝试物理删除(CAS 更新 prev.next)。

总结

本节课中,我们一起学习了无锁链表及其操作。核心内容包括:

  • 三种基本操作:插入、删除、查找。
  • 三种遍历策略:Harris、Harris-Michael、Harris-Holy Shabbat。它们的核心区别在于对逻辑删除节点的处理时机和方式。
  • 关键同步机制:删除操作必须分两步(逻辑删除 -> 物理删除),以 CAS 操作为核心,与插入操作正确同步。

通过组合不同的遍历策略和操作,可以得到多种无锁链表的实现变体。在接下来的课程中,我们将探讨为何需要在代码中设置特定的内存顺序(如 AcquireRelease),这是保证这些算法正确性的另一个关键层面。

21:无锁链表(问题解析)🎯

在本节课中,我们将继续学习无锁单链表。上一节我们介绍了该数据结构的基本算法和同步机制的高层解释。本节中,我们将深入探讨几个关键的设计选择,并解释为何这些同步操作是安全且正确的。

设计选择解析

为何需要逻辑删除?🔍

在删除操作中,我们首先标记一个节点,表示它已被“逻辑删除”,然后才实际更新指针以“物理删除”它。为何要采用这种两步走的方式?

考虑一个并发场景:一个线程正在删除节点2,同时另一个线程试图在节点2和节点3之间插入一个新节点X。

错误实现:如果删除操作直接更新节点1的next指针,使其从指向节点2变为指向节点3(CAS(&node1.next, node2, node3)),而插入操作同时执行了CAS(&node2.next, node3, new_node_x),那么最终结果可能是:

  • 节点1的next直接指向了节点3。
  • 新插入的节点X虽然被链入了节点2之后,但节点2本身已从链表中移除(不可达)。这导致节点X被“遗忘”,违反了链表的基本规范。

正确实现(逻辑删除):删除节点2时,首先将其next指针标记为“已删除”(例如,通过设置指针的最低有效位为1)。这个标记后的指针值(记为 node3_tagged)与原始指针值(node3)不同。

  • 如果逻辑删除先成功,那么插入操作的CAS(&node2.next, node3, new_node_x)会失败,因为node2.next的当前值是node3_tagged,而非预期的node3。插入失败。
  • 如果插入先成功,那么node2.next变成了new_node_x。此时,删除操作尝试的CAS(&node2.next, node3, node3_tagged)也会失败,因为当前值不是node3。逻辑删除失败。

通过逻辑删除,我们协调了并发删除和插入操作,防止了节点被遗忘的情况。逻辑删除后,节点在物理上仍可能存在于链表中,因此后续需要由某个线程(可能在遍历时)完成物理删除(即更新前驱节点的指针)。

以下是三种物理删除策略的动机:

  • Harris策略:在遍历时,可以一次性物理删除多个连续的逻辑删除节点(跳过它们)。这通常性能更高。
  • Harris-Michael策略:每次只物理删除一个逻辑删除节点。其动机与危险指针(Hazard Pointers) 这种广泛使用的内存回收方案兼容。危险指针(我们将在课程后期详细学习)不支持Harris策略,但支持Harris-Michael策略。
  • Harris-Herlihy-Shavit策略:在遍历(如查找)过程中,完全不进行物理删除,直接跳过逻辑删除的节点。这使得查找操作非常快速,延迟极低。物理清理工作可以交给低优先级的后台线程执行。这种策略适用于查询性能至关重要的场景。

同步正确性证明 🛡️

现在我们来探讨,在宽松内存模型下,为何这些算法是正确的。与栈和队列类似,我们需要建立并维护一个不变式(Invariant)

核心不变式:链表中的每个指针(头指针或节点的next指针)都有一个与之关联的释放视图(release view)。该指针的释放视图必须大于或等于(≥) 它所指向节点的key值和next指针所对应消息的时间戳。

公式化表示: 对于任意指针 ptr 指向节点 N,有:
release_view(ptr) ≥ timestamp(N.key) ∧ release_view(ptr) ≥ timestamp(N.next)

操作原则

  • 写入(写指针):通常使用 Release 语义,以确保写入操作能维护上述不变式——将当前所知关于指向节点的信息“发布”出去。
  • 读取(读指针):通常使用 Acquire 语义,以便利用不变式——安全地获取指针所指向节点的最新内容。

让我们结合代码中的内存顺序(Ordering)来分析:

  1. 遍历中的读取(Acquire)

    // 在 `find` 函数中
    let mut next = unsafe { (*curr).next.load(Ordering::Acquire) };
    

    这里以Acquire读取curr.next。因为在下一次循环迭代中,next将成为新的curr,我们需要确保能安全地解引用它并访问其keynextAcquire语义保证了我们能观察到该指针所指向节点(即*next)的最新值。

  2. 指针更新(Release)

    // 在物理删除(`tidy`)或插入时更新前驱节点的 `next` 指针
    prev_next.store(new_next, Ordering::Release);
    

    这里以Release更新prev.next。因为我们将该指针指向了一个新的节点(可能是跳过了被删除节点,或指向新插入的节点)。通过Release写入,我们建立并发布了关于这个新指向节点(其keynext信息,这些信息在之前可能通过Acquire读取获得)的不变式,使得其他线程后续的Acquire读取能看到一致的状态。

  3. 仅检查标记(Relaxed)

    // 检查指针是否被标记(逻辑删除)
    if next_tagged(next) { ... }
    

    这里读取指针可能仅用于检查其标记位,而不需要立即解引用它指向的节点。因此,可以使用Relaxed语义,因为此时不涉及与节点内容相关的同步。

总结:通过精心安排的Acquire(读取)和Release(写入)内存顺序,算法确保了“指针释放视图 ≥ 节点内容时间戳”这一关键不变式在并发操作下始终得以维持。这使得线程在遍历链表时,总能基于一致的状态视图做出正确的决策,从而保证了插入、删除和查找操作的安全性。


本节课总结:我们一起深入探讨了无锁链表的关键设计选择,特别是逻辑删除的必要性及其如何协调并发操作,并分析了三种物理删除策略的不同动机。最后,我们从内存模型和不变式的角度,剖析了算法中同步操作(Acquire/Release)的正确性原理,理解了为何这些操作能保证并发环境下的数据一致性。

22:无锁链表的内存排序分析 🧠

在本节课中,我们将学习如何分析无锁链表中原子操作的内存排序(Memory Ordering)。我们将通过具体的代码示例,理解为何某些操作必须使用 AcquireRelease 排序,而另一些操作可以使用 Relaxed 排序。核心在于理解这些排序如何帮助建立和维护数据结构的正确性不变量。


概述与背景

上一节我们介绍了无锁链表的基本结构。本节中,我们将深入分析其实现代码中原子操作的内存排序注解。理解这些排序是确保并发操作正确性和高效性的关键。


析构操作中的 Relaxed 排序

在链表的析构函数(drop)中,我们不需要使用 AcquireRelease 排序。

原因:当一个数据结构被销毁时,当前线程完全拥有这个链表。我们不需要与其他线程进行任何同步。因此,这里的原子操作可以使用 Relaxed 排序,它只保证原子性,不提供同步保证。

// 示例:析构中的 Relaxed 操作
impl Drop for LinkedList {
    fn drop(&mut self) {
        // ... 遍历并释放节点 ...
        // 此处的 load/store 可以使用 Ordering::Relaxed
    }
}

遍历操作中的 Acquire 排序

现在,我们来看 Harris 查找算法中的遍历部分。理解为何读取当前节点的 next 指针必须使用 Acquire 排序至关重要。

光标(Cursor)结构:遍历时,我们维护一个由两个指针组成的“窗口”:一个指向当前节点(current),一个指向前驱节点(previous)。

在每次迭代中,这个窗口会向前移动。当前节点的 next 指针被读取,并在下一次迭代中成为新的 current 指针。

必须使用 Acquire 的原因

  1. 读取的 next 指针值将在下一次迭代中被解引用,用于访问其 keyvalue
  2. 为了能看到关于这个 next 节点(包括其 key 和后续指针)的最新信息,本次读取必须使用 Acquire 排序。这确保了当前线程能“获取”到之前其他线程对该节点所有 Release 写入的结果,从而维护了数据结构的正确性不变量。
// 示例:遍历中必须使用 Acquire 读取
let next = unsafe { current.next.load(Ordering::Acquire) }; // 必须为 Acquire
// 在下次迭代中,`next` 将成为新的 current 并被访问

Harris-Michael 策略的 find 函数中,情况几乎相同。我们读取 next 指针,并在下一次迭代中解引用它,因此也必须使用 Acquire 排序。


写操作中的 Release 排序

另一方面,我们在写入指针值时需要使用 Release 排序。

场景:当我们覆盖前驱节点(previous)的 next 指针,使其指向新的当前节点(current)时。

必须使用 Release 的原因

  1. 我们正在修改共享状态(前驱节点的 next 指针)。
  2. 我们需要保证,这次写入(Release)所“释放”的信息,其版本号大于或等于当前线程所“获取”到的关于 current 节点的最新信息版本。
  3. 在之前的遍历循环中,我们已通过 Acquire 读取获取了 current 节点的最新信息。
  4. 现在通过 Release 写入,我们相当于将 current 节点的最新信息“发布”给了指向它的 previous.next 指针。这有效地重新建立了“所有指向某个节点的指针,其 Release 视图都晚于或等于该节点最新信息”的不变量。
// 示例:写入指针时必须使用 Release
previous.next.store(current, Ordering::Release); // 必须为 Release

Harris 算法的删除操作中,类似的指针覆盖写操作也必须使用 Release 排序,原因同上。


无写操作与 Relaxed 排序

Hazard Pointer(或类似无锁回收)策略的遍历中,算法不会通过更新链表指针来“修复”链表。

关键区别:该策略不覆盖任何指针值,因此整个遍历过程中没有 Release 写操作。这也是它被称为“无等待(wait-free)”的原因之一——它甚至不包含 CAS 操作,因此遍历总能成功完成,性能可能更高。

在某些情况下,读取操作也可以使用 Relaxed 排序。

场景:当我们仅需要读取指针值本身(特别是其标记位 tag),而不打算解引用这个指针去访问目标节点的 keyvaluenext 指针时。

可以使用 Relaxed 的原因:我们不需要获取目标节点的任何最新信息,只关心指针的原始值(及其标记)。因此,不需要同步,只需保证原子性即可。

// 示例:仅读取指针值/标记时可用 Relaxed
let next_ptr = node.next.load(Ordering::Relaxed); // 仅检查标记位,不解引用
let is_marked = get_tag(next_ptr);

另外,当线程完全独占(拥有)某个节点时,对该节点的读写也无需与其他线程同步,可以使用 Relaxed 排序。


总结与练习

本节课中,我们一起学习了无锁链表实现中内存排序的详细分析:

  • Acquire 排序用于读取将在后续解引用的共享指针,以确保获得最新信息。
  • Release 排序用于写入共享指针,以发布最新信息并维护指针与节点之间的不变量。
  • Relaxed 排序可用于无需同步的场景,例如仅读取指针值、操作线程私有数据或析构时。

为了巩固理解,建议进行以下练习:

  1. 列出代码中所有的 AcquireReleaseRelaxed 排序。
  2. 对于每个 AcquireRelease,理解其为何必须使用该排序(基于不变量的理由)。
  3. 对于每个 Relaxed,理解其为何可以放松排序要求。
  4. 如果对任何排序的设定有疑问,请尝试精确地描述问题。

通过这样的分析,你可以深入理解并发代码中内存排序的设计逻辑,并能够自行验证其正确性。

23:其他无锁数据结构

概述

在本节课中,我们将学习几种其他类型的无锁数据结构。我们已经学习了队列和链表,它们是研究无锁并发的基础。本节将介绍更多有趣的数据结构,包括环形缓冲区和工作窃取队列,并解释它们的设计原理与应用场景。

环形缓冲区/环形队列 🌀

上一节我们介绍了基础的队列结构,本节中我们来看看一种特殊的队列——环形缓冲区。

环形缓冲区使用一个固定大小的数组,并有两个端点。一端用于推送值,另一端用于弹出值。你可以将其视为一个固定长度的队列。

当缓冲区因推送所有值而被用尽时,它将从头开始循环使用。本质上,缓冲区数组被用作一个环,最后一个元素的下一个元素是第一个元素,因此得名环形缓冲区或环形队列。

环形缓冲区在通信中应用非常广泛,例如:

  • CPU与加速器(如GPU、FPGA)之间的通信。
  • 加速器之间的通信。
  • 操作系统内核空间与用户空间之间的异步通信。用户程序只需将数据项添加到队列中即可。

环形缓冲区在许多涉及多个代理之间异步通信的场景中都有应用。

工作窃取队列 ⚙️

接下来,我们探讨一种名为“工作窃取队列”的数据结构。

工作窃取队列本质上是一个栈。单个线程可以从一端推送和弹出数据项。然而,其独特之处在于另一端:多个线程可以从另一端窃取数据。因此,一个线程将其用作栈,而其他线程可以从另一端窃取最旧的任务。

这种设计在任务管理中应用广泛。其设计在相关论文中有描述,其中也包含了该队列正确性的证明。

其他无锁数据结构

除了上述结构,还存在许多其他无锁并发数据结构,例如:

  • 哈希表:例如,在作业5中,你将实现一个可调整大小的并发哈希表(分离链表法)。
  • 二叉树:例如,并发二叉搜索树、并发B树、并发事务树、并发写时复制数据结构等。

所有这些并发算法都有一个共同的目标:允许对数据结构的不同部分进行更多的并发访问。如果你感兴趣,可以进一步搜索和研究这些数据结构。

深入理解工作窃取队列

正如之前提到的,工作窃取队列本质上是一个栈。只有一个线程(所有者)可以向栈推送和弹出数据项。栈是后进先出的,因此所有者弹出的是最新的数据项。

有趣之处在于另一端:最旧的数据项可以被其他线程窃取。这个概念之所以重要,是因为它可以非常高效地在多个线程之间动态分配工作负载。

例如,假设有多个线程,每个线程都有一个工作列表。某些线程可能没有剩余工作,而其他线程可能有很多工作。没有工作的线程可以从工作负载较多的线程那里窃取任务。通过这种方式,我们可以高效、动态地在多个CPU核心之间分配工作,从而获得比静态调度更好的性能。

这种方案能保证没有线程会空闲,只要有工作要做,它就会自动分配到多个CPU上,从而提高吞吐量或利用率。

为了使此方案快速工作,需要确保几个属性:

  1. 工作划分:我们需要将工作划分得足够细,以便能够均匀地分配到多个线程。如果一个工作非常大且无法分割,即使应用此方案,也只有一个线程能处理它,利用率会很差。然而,如果工作划分得过细,多个CPU之间的同步成本将主导处理时间。因此,我们需要适当地分配工作,既不能太大,也不能太小。
  2. 访问不对称性:我们需要保证队列的所有者线程能够快速访问其队列。工作窃取事件相对罕见,通常只发生在某个线程没有更多工作可做时。因此,在设计中存在不对称性:所有者线程的推送和弹出操作必须非常快,而其他线程的窃取操作可以稍慢一些。

工作窃取队列正是试图通过算法设计来利用这种不对称性,使得推送和弹出操作非常快,而窃取操作稍慢一些。如果你感兴趣,请阅读相关的实现和论文。

总结

本节课我们一起学习了几种其他无锁数据结构。我们介绍了环形缓冲区及其在通信中的应用,并深入探讨了工作窃取队列的原理、优势以及实现时需要考虑的关键属性(如工作划分和访问不对称性)。工作窃取队列是无锁实现中最有趣的例子之一。此外,还有许多其他无锁数据结构的例子,如果你感兴趣,可以进一步研究相关的博客文章或论文。

24:线性一致性(Linearizability) 🧵

在本节课中,我们将学习并发数据结构的一种核心规范,即线性一致性。我们将探讨其定义、关键性质以及如何在实际并发实现中证明线性一致性。


什么是线性一致性? 🤔

线性一致性意味着,对于一个并发数据结构的所有操作,都可以被线性化,即存在一个所有操作的全序关系,并且这个顺序与每个操作的实际发生顺序一致。

上一节我们介绍了并发数据结构比顺序数据结构更复杂,因为多个线程可能同时访问同一个数据结构。一个理想的规范应该尽可能简单,其复杂性只应体现在操作顺序上,而不应涉及具体的底层指令执行顺序。因此,线性一致性的高级思想是:并发数据结构的实现,其行为应该如同一个抽象的、顺序的数据结构。


线性一致性的三个核心性质 📝

为了满足线性一致性,对于一个执行过程,必须存在一个所有操作的全序关系 R,并且 R 必须满足以下三个性质:

  1. 保持“先于发生”顺序:如果一个操作 O1 在“先于发生”关系上早于另一个操作 O2,那么在顺序 R 中,O1 也必须排在 O2 之前。

    • 公式:如果 O1.end_view <= O2.begin_view,则 O1R 中先于 O2
  2. 满足顺序规范:按照顺序 R 执行这些操作时,其行为必须符合该数据结构的顺序语义

    • 示例:如果 R 中一个 push(42) 操作后紧跟着一个 pop() 操作,那么 pop() 必须返回 42
  3. 同步要求:如果一个 push 操作写入的值被一个 pop 操作读取,那么这两个操作之间必须存在同步,类似于“释放-获取”同步。这意味着 push 操作必须“先于发生”这个对应的 pop 操作。

如果一个执行过程存在满足以上三个性质的顺序 R,则该执行是线性化的。如果一个数据结构的所有可能执行都是线性化的,那么这个数据结构本身是线性一致的。


如何证明线性一致性? 🔍

证明了线性一致性,就很容易推导出更高级的“上下文精化”规范。那么,如何为一个具体的并发数据结构实现证明其线性一致性呢?这是一个活跃的研究领域,但有一些通用的思路。

以下是证明线性一致性的两个关键思路:

思路一:根据“提交点”排序写操作

许多并发数据结构(如栈、队列)的写操作(如 push, pop)都有一个关键的原子指令(如 compare-and-swap),它决定了操作是否成功“提交”。这个点被称为提交点

  • 核心方法:根据这些提交点指令的执行顺序,来对相应的写操作进行线性化排序。
  • 示例:在栈的实现中,成功修改头指针的 CASpush 的提交点;成功移除头节点的 CASpop 的提交点。哪个 CAS 先执行,其对应的操作就在线性化顺序中排在前面。

思路二:定位读操作的顺序

对于不修改数据的纯读操作(如 isEmpty()),无法使用提交点方法。常用的启发式方法是:

  • 核心方法:如果一个读操作读取的值是由某个写操作的提交点写入的,那么可以将该读操作线性化在该写操作之后
  • 原理:读操作的结果由它读到的值决定,而这个值是由之前最近的某个写操作产生的。

实例分析 📊

上一节我们介绍了证明思路,本节中我们来看看这些思路如何应用于具体的并发数据结构。

  • Treiber 栈:线性化顺序就是所有成功 CAS 操作(作用于头指针)的执行顺序。
  • Michael-Scott 队列:线性化顺序由作用于头指针或 next 指针的成功 CAS 顺序决定。更新尾指针的 CAS 不是提交点,不影响操作顺序。
  • Harris 链表:情况更复杂。写操作(插入、删除)的顺序仍由相关指针上的成功 CAS 决定。然而,对于并发遍历(读操作)的线性化,目前仍是研究难点,因为它可能读到已逻辑删除但尚未物理分离的节点。

对于链表等复杂结构,如何为所有操作(尤其是读操作)确定一个一致的线性化顺序,仍然是活跃的研究课题。


总结与展望 🎯

本节课中我们一起学习了并发数据结构的核心规范——线性一致性

我们了解到:

  1. 线性一致性要求所有操作可以被排列成一个全序,并且这个顺序需满足“先于发生”保持、顺序语义和同步三个性质。
  2. 证明线性一致性的常见思路是:首先根据原子写指令(提交点)的执行顺序确定写操作的线性化顺序,然后根据数据依赖关系将读操作插入到这个顺序中。
  3. 对于不同的数据结构(栈、队列、链表),应用这些思路的复杂程度不同,一些复杂情况(如链表遍历)仍是前沿的研究方向。

证明线性一致性是确保并发数据结构正确性的关键步骤。虽然我们介绍了基本框架,但完整的证明通常需要更形式化的逻辑。希望本教程能帮助你建立起对线性一致性及其证明方法的直观理解。

25:安全内存回收(风险指针)🔐

在本节课中,我们将开始学习并发编程中的垃圾回收问题。在前几节关于并发数据结构的课程中,我们已经提到,在并发环境下,我们需要考虑多个线程在回收不再使用的内存时可能产生的冲突。从本节课开始,我们将聚焦于并发中的内存回收这一特定问题,并介绍几种具有代表性的并发垃圾回收算法。

并发数据结构与内存回收挑战 🧩

上一节我们介绍了并发数据结构的基本概念,本节中我们来看看其面临的内存管理挑战。

并发数据结构(例如链表)允许多个线程同时操作同一数据结构,从而优化性能。其操作是非阻塞的,只要线程间的操作不冲突,每个线程都能完成自己的工作。

示例:并发链表删除
假设有两个线程 T1 和 T2 同时对一个链表执行删除操作。T1 删除值为 0 的节点,T2 删除值为 20 的节点。由于它们修改的是链表的不同部分,因此可以并行执行。操作完成后,我们需要将这两个已从链表中摘除的节点所占用的内存释放掉,否则会导致内存泄漏。

然而,问题在于我们不能立即释放这些内存块。因为可能还有其他线程正在并发地访问这些即将被释放的节点。如果立即释放,正在访问的线程可能会触发“释放后使用”错误,这是一种严重的未定义行为,必须避免。

核心问题:如何安全回收? ❓

为了避免“释放后使用”错误,我们需要解决两个核心问题:

  1. Q1(访问线程视角):如何保护一个内存块 B,使其在我访问期间不被其他线程释放?
  2. Q2(释放线程视角):何时可以安全地释放一个已从数据结构中移除的内存块 B?

只有当所有可能访问 B 的线程都确认不再需要它时,才能安全释放 B。解决这两个问题的算法,通常被称为安全内存回收算法。在并发编程语境下,这特指并发垃圾回收。

安全内存回收算法概览 📚

学术界和工业界针对此问题已提出了多种解决方案,它们大致可分为两类:

  • 风险指针:一种直观的基于线程本地“保护列表”的算法。
  • 基于纪元的回收:另一种广泛使用的、基于全局“纪元”概念的算法。

许多后续提出的算法都是这两种基础方案的变体或混合体。本节课我们将重点学习风险指针算法。

风险指针算法详解 ⚙️

风险指针为上述两个核心问题提供了简洁的答案。

回答 Q1:如何保护内存块?
线程在解引用一个指针(访问对应内存块)之前,必须先将该指针注册到自己的保护指针列表(也称为风险指针列表)中。这个操作通过 protect(ptr) API 完成。它相当于宣告:“我将要访问这个内存块,请勿释放它。”

回答 Q2:何时安全释放?
当一个线程将某个内存块从数据结构中移除后,它不应立即 free,而是调用 retire(ptr) API 将其放入一个“待回收列表”,表明该块已退役,但暂不释放。

决定何时真正释放内存的任务由 collect() 函数完成。该函数会扫描所有线程的保护指针列表,并检查待回收列表。只有那些既存在于待回收列表中,又不存在于任何线程保护指针列表中的内存块,才是安全的,可以被真正释放。

关键操作流程:

  1. 保护protect(cur) -> 确保 cur 指向的节点不被释放。
  2. 验证:重新读取 cur 以确保它仍然指向有效的节点(防止在保护操作发生的瞬间,节点已被其他线程移除)。
  3. 访问:安全地解引用 cur(例如 cur->next)。
  4. 取消保护unprotect(cur) -> 访问完毕,移出保护列表。
  5. 退役retire(cur) -> 节点已从数据结构中移除,加入待回收列表。
  6. 回收collect() -> 在适当的时机调用,安全释放那些无人保护且已退役的内存块。

实战:改造并发遍历栈 🛠️

让我们看一个将风险指针应用于“并发遍历栈”的例子。原始代码中,在解引用 cur 访问下一个节点(第16行)和释放节点(第18行)之间存在安全隐患。

以下是应用风险指针改造后的关键步骤:

改造步骤:

  1. 在解引用 cur 之前,插入保护与验证逻辑。
  2. 将直接的 free 调用替换为 retire
  3. 在访问完 cur 后,调用 unprotect

核心代码逻辑:

// ... 循环内 ...
do {
    // 1. 保护当前指针
    protect(cur);
    // 2. 验证:重新加载cur,确保它未被其他线程修改
    if (cur != load(&head)) { // 假设head是全局头指针
        unprotect(cur);
        continue; // 如果变了,重试
    }
    // 3. 安全访问
    next = cur->next; // 原先不安全的第16行,现在安全了
    // 4. 取消保护
    unprotect(cur);
    // ... 其他业务逻辑 ...
} while (...);

// 5. 需要删除cur时
retire(cur); // 替换原先的 free(cur);

通过以上改造,我们确保了线程在访问节点时,该节点不会被意外释放。

风险指针的优缺点与局限性 ⚖️

尽管风险指针概念清晰,但它也存在一些缺点:

优点:

  • 原理简单直观,易于理解。
  • 内存回收的决策是精确的(基于即时保护状态)。

缺点与局限性:

  1. API 易错:程序员必须牢记 protectunprotect 和验证步骤的调用顺序和时机,容易遗漏或出错。
  2. 性能开销:每次 protect 操作通常需要一个内存屏障,在遍历长链表时,这会带来显著的开销。实证表明,其性能可能比某些替代方案慢数倍。
  3. 链式回收问题:这是更严重的局限性。考虑一个场景:线程T1保护着节点B0,并即将访问B1。同时,线程T2原子性地移除了B0和B1两个节点,并将它们都 retire。由于T1只保护了B0,collect() 函数可能认为B1是安全的(已退役且未被保护)并将其释放。当T1随后尝试访问B1时,便会触发“释放后使用”错误。风险指针无法安全处理这种一次性移除多个相邻节点(链式删除)的情况,这限制了其在许多复杂并发数据结构中的应用。

总结与下节预告 📝

本节课我们一起学习了并发编程中安全内存回收的重要性,并深入探讨了风险指针这一解决方案。我们了解了它通过维护线程本地的保护指针列表和全局的待回收列表,来协调内存的访问与释放。同时,我们也分析了其在易用性、性能和适用性方面的局限性。

正因为风险指针存在这些不足,人们提出了另一种强大的算法——基于纪元的回收。在下一节课中,我们将学习 EBR 算法。它将解决风险指针面临的链式回收等问题,提供更易用的 API 和更好的性能,同时我们也会对比分析这两种核心算法的优劣。敬请期待!

26:基于纪元的回收(EBR)

概述

在本节课中,我们将学习一种称为基于纪元的回收的安全内存回收算法。上一节我们介绍了危险指针算法,本节我们将探讨EBR如何解决危险指针的一些局限性,并理解其工作原理、优势与不足。

从危险指针到基于纪元的回收

在上一节中,我们学习了危险指针算法,用于安全地回收并发数据结构中的内存。然而,危险指针有几个显著的缺点:

  1. 难以使用:其API较为复杂。
  2. 速度较慢:每次迭代都需要昂贵的内存屏障。
  3. 不支持某些有用的同步模式:例如,它不支持链式退休模式。

基于纪元的回收算法旨在解决所有这些问题。它速度更快,支持链式退休等模式,并且API更易于使用。因此,在本课程中,所有实现并发数据结构的作业都将使用EBR,而不是危险指针或其他方案。

EBR的核心思想

所有SMR算法都需要回答两个问题:

  1. 如何从访问者的角度保护一个内存块B不被释放?
  2. 从修改者的角度看,何时可以安全地释放内存块B?

这两个方面相互同步。如果处理正确,我们就能在所有访问都完成后安全地回收内存。

问题一:如何保护内存?

在EBR中,线程通过调用一个名为 set_active 的函数来保护内存。这个函数提供了一种全面保护:它并非保护单个指针,而是保护从此刻起该线程将要访问的所有指针。

以下是一个示意图,说明了这个过程:

线程T1通过调用set_active保护所有指针。
由于是全面保护,T1可以安全地从头指针遍历到B0、B1、B2等节点。

这种机制之所以快速,是因为我们不需要在每次迭代时都发出昂贵的内存屏障。我们可能需要在 set_active 开始时发出屏障,但在此之后,遍历链表中的所有节点都不会产生额外成本。

问题二:何时可以安全释放内存?

从修改者的角度看,例如,在将内存块B从数据结构中分离后,需要调用 free 函数。何时可以安全执行此操作?答案是:当没有任何处于活跃状态的线程可能访问B时。

我们称一个线程在调用 set_active 后处于活跃状态。如果没有活跃线程正在访问B,那么B就不再受任何人保护,因此可以安全地释放。

假设线程T1通过将头指针从B0更新为B2,从链表中移除了B0和B1两个节点。它随后会调用 retire 函数,因为B0和B1已从链表分离,必须在稍后释放。目前,我们只是标记这些节点为“已退休”,以便在执行的后期阶段处理它们。

现在,负责回收的 collect 函数想要释放那些已退休且不受保护的节点。目前,B0和B1虽然已退休,但仍受到T1活跃状态的全面保护。T1并没有特别保护B0和B1,它只是保护了所有将要访问的指针,因此B0和B1也受到保护。

当T1完成对链表的遍历后,它会调用 set_quiescent 函数。这标志着T1活跃状态的结束,意味着T1不再访问共享内存块。

set_quiescent 被调用后,collect 函数便可以释放B0和B1,因为它们已退休,且不再受T1保护。因此,collect 函数可以安全地释放这两个内存块。

EBR的优势总结

综上所述,EBR具有以下优势:

  • 速度快:在活跃状态期间,无需为每次迭代调用昂贵的内存屏障。
  • 适用性广:特别适用于链式退休等同步模式。即使B0和B1已被链式退休,T1仍然可以安全地遍历它们,因为它们受到 set_active 的全面保护。
  • 易于使用:其API比危险指针更简单。我们只需要标记并发访问的开始和结束,并将 free 替换为 retire 即可。

深入原理:纪元共识

现在,我们需要更深入地回答:其他线程如何推断出内存块B不能被任何活跃状态访问?

EBR的高层思想基于纪元共识。以下是其核心规则:

  1. 活跃状态:每个线程通过调用 set_activeset_quiescent 来标记其开始和结束访问共享内存块的范围。指针不应在同一线程的不同活跃状态之间共享。
  2. 纪元标注:每个活跃状态都被标注一个纪元。纪元本质上是一个时间戳,是一个从0开始的自然数(如10,11,100)。新活跃状态的纪元由算法自动分配。
  3. 纪元共识规则并发活跃状态的纪元最多只能相差1。例如,如果线程T1的活跃状态纪元为E,那么与其并发的线程T2的活跃状态纪元必须是E或E+1,不能是E+2或更大。

这个共识规则是EBR算法安全性的核心。没有它,我们就无法保证算法的安全性。

何时释放内存?纪元+3规则

在理解了纪元共识规则后,就很容易回答第二个问题了:何时可以安全释放内存块B?

假设内存块B在一个纪元为E的活跃状态中被退休。那么,在纪元E+3时,可以安全地释放B

这个“+3”看似神奇,但可以通过下图解释:

假设B在纪元E的活跃状态中被退休。
- 在纪元E或E+1,其他线程仍可能引用B,因为B被移除的信息可能尚未传播。
- 在纪元E+2,根据纪元共识规则,标注为E+2的活跃状态必须发生在标注为E的活跃状态之后(因为它们相差2,所以不并发)。因此,E+2的活跃状态不应再引用B。
- 在纪元E+3,所有对B的引用都必须发生在E+3开始之前。并且,所有并发线程的纪元至少为E+2,它们都不应引用B。因此,此时可以安全释放B。

因此,只要满足纪元共识规则,在退休纪元加3的时刻释放内存就是安全的。不同的EBR变体可能有不同的具体规则,但核心思想是相似的。

如何将EBR应用到并发数据结构

将EBR应用到并发数据结构(如栈的pop操作)非常简单。与危险指针相比,所需的改动更少。

以下是需要插入的指令:

  1. 在开始访问共享内存之前,调用 set_active。例如,在pop操作中,在进入循环访问栈顶节点之前调用。
  2. 在结束所有共享内存访问之后,调用 set_quiescent。例如,在pop操作完成并离开循环之后调用。
  3. 将直接的 free 调用替换为 retire。这样,EBR算法会在确保所有线程都完成访问后,再实际释放内存。

相比之下,危险指针要求在使用前保护每个单独的指针,并在保护后验证指针是否仍然有效,这更容易出错。而EBR只需标记并发访问的边界,大大简化了使用。

EBR的缺点:缺乏健壮性

然而,EBR有一个显著的缺点:它不够健壮。如果某些线程行为不当,可能会无限期地延迟内存的释放。

考虑一个场景:一个“坏”线程T3进入活跃状态(纪元E+1)后,永远不调用 set_quiescent 来结束其活跃状态。根据纪元共识规则,所有后续线程的纪元最多只能是E+2(因为与E+1并发)。因此,纪元无法推进到E+3。

回想一下,在纪元E退休的内存块B,需要等到纪元E+3才能被释放。由于纪元被T3卡住,B将永远无法被回收。这意味着,单个行为不当的线程就可以阻塞整个系统的内存回收。

总结:危险指针与EBR的权衡

本节课我们一起学习了基于纪元的回收算法。我们来总结一下危险指针与EBR的主要特点:

特性 危险指针 基于纪元的回收
速度 较慢(每次迭代需屏障) 较快(全面保护,减少屏障)
适用性 有限(不支持链式退休等) 广泛(支持多种模式)
易用性 复杂(需保护单个指针并验证) 简单(标记开始/结束即可)
健壮性 健壮(个别线程不影响回收) 不健壮(坏线程可阻塞回收)

这两种算法代表了不同的权衡。后续的许多研究试图结合两者的优点,但通常难以同时满足所有理想属性(快速、广泛适用、健壮、紧凑、可移植等)。

课程最后简要介绍了一项新研究——指针与纪元结合的重声明,它试图同时满足所有这些属性。但鉴于其复杂性,本课程不深入讨论。希望本节课能帮助你理解危险指针和基于纪元的回收这两种最基本、最古老的安全内存回收算法。

27:安全内存回收(crossbeam-epoch)🧠

概述

在本节课中,我们将学习 crossbeam-epoch 库,这是 Rust 并发编程中实际上的标准库,被广泛应用于包括 Firefox 在内的许多产品中。我们将了解其基于 epoch 的内存回收机制,并学习如何使用其 API 安全地构建并发数据结构。

crossbeam-epoch 是 epoch 内存回收算法的一个实现。如果你已经学习了关于 hazard pointers 和 epoch 回收的视频,那么理解这个库的文档和实现将会比较容易。

该库的文档包含了所有具体细节,你可以去那里阅读。它不仅描述了库的实现细节,还解释了 API 以及一些关于 epoch 算法的细节。请务必去阅读该文档。例如,它提供了 API,你可以点击查看 Atomic 及其 API 函数和示例。

我们将查看示例并了解其中的内容。但现在,我们只需要认识到存在一份包含每个函数示例的文档。

库中有 GuardPinAtomicShared 等概念,它们是 crossbeam-epoch 库的主要 API。请仔细阅读关于 SharedAtomicGuardPin 的内容。

该库由 Aaron Turon 于 2015 年创建,至今已有 6 年历史。Aaron 启动这个项目是为了为并发编程的垃圾回收工作提供一个优秀的库。我强烈建议你也阅读这份文档。

它不仅实现了垃圾回收算法和基于 epoch 的回收算法,还提供了一个强制你安全使用该库的 API,并在本节中解释了一些相关的工作。同时,在前面的章节中,它也描述了基于 epoch 回收的算法。

你可以将这份文档与我之前创建的视频进行比较,看看其中的关联。

正如我所说,这个 Rust API 强制你安全地使用它。因此,它创建了 GuardPinOwnedShared Atomic 等类型,它们是 crossbeam-epoch 的基础 API。为了完成你的作业 5 和 6,我相信你需要掌握这些 API 和列表。因此,我强烈建议你也阅读这份文档。

阅读完这两份文档后,你现在可以理解示例中的内容了。这是我们仓库中的一个栈实现示例。在本视频的剩余部分,我们假设你已经阅读了 Aaron 的文档和特性文档。现在,我们将再次阅读并发栈、队列和链表的实现,看看在垃圾回收方面发生了什么。

栈实现分析

这是一个栈的实现,它本质上是一个节点链表。

入栈操作

push 操作中,不需要考虑垃圾回收,因为我们没有从链表中移除任何节点,也没有释放任何内存。我们只是创建一个新节点,然后将其添加到链表的头部。

这里仍然有一个关于 crossbeam-epoch 的有趣方面。你创建了一个 Owned 指针,这基本上是在堆上创建了一个对象,但它完全由我自己独占拥有,其他线程完全不共享。这就是为什么这种类型被称为 Owned。它是一个指向堆的独占指针。

我们尝试对头指针执行一个 compare_and_set 操作。我们将头指针从原始指针替换为新创建的节点。

有趣的是,这个新创建的指针的所有权被转移给了这个函数。这个函数接收并获得了新指针的所有权。

如果这个 compare_and_set 操作成功,那么我们就完成了。如果操作不成功,有趣的是,你刚刚传入的指针的所有权会被返回。因此,为了在下一次循环迭代中使用这个新指针,你需要将这个所有权保存到用于保存新指针所有权的变量中。

请记住,指向堆对象的所有权是在这里通过实际在堆上分配对象而创建的。这个所有权被转移到这里,当 compare_and_set 操作不成功时,它会被返回。如果操作成功,那么所有权实际上被转移到了共享内存中。因此,你不需要再关心这个对象的所有权。

出栈操作

有趣的事情将发生在 pop 函数中。在 pop 函数中,你将读取一些指针,最后对头指针执行一个 compare_and_set 操作。你将头指针替换为下一个指针。这有效地从链表中移除或分离了原始头指针。

因此,我们希望释放这个节点。但正如我们在上一个视频中讨论的,我们不能不加考虑地这样做。我们需要“退休”这个节点,而不是立即释放它。这里的“延迟销毁”基本上是“退休”的同义词。我们使用 Guard 来延迟销毁这个节点。这基本上是我们对上一个视频中的示例所做的第二个主要更改。

我说过,我们需要界定线程访问共享内存的代码范围,这个范围由 pin 函数和此处 guard 的析构来界定。因此,有效地说,当你在此处 pin 这个 guard 时,活动状态开始。

活动状态在此 guard 被释放时结束,可能是在这一行,guard 被丢弃。当 guard 被丢弃时,活动状态自动结束。

你可以将这个 API 视为围绕 set_activeset_inactive 函数的包装器,它保证一旦创建了活动状态,它必须在之后的某个时间点被停用。这基本上是一个围绕 set_activeset_inactive 函数的 RAII 类型包装器。

这里有趣的是,这个 retire 函数是 guard 的一个方法。因此,这个 defer_destroy 必须在活动状态内部调用,因为它是 guard 的一个方法,而 guard 只存在于活动状态内部,这要归功于这个 guard 的 RAII 类型 API。

所以,这里是活动状态。这个 guard 证明了我们在第 76 行处于活动状态,因为我们有一个对 guard 的引用。这基本上确保了 retire 函数只在活动状态内部被调用。

此外,这些 compare_and_setload 函数被赋予了一个对 guard 的引用,这也证明了读取全局内存的操作只在活动状态内部执行,这个事实由函数被赋予了一个指向 guard 的指针来保证,这证明了我们处于活动状态内部。

同样的事情也发生在这个 push 函数中。我的意思是,我们在这里创建了一个 guard。在这一点上我们不需要 guard,因为我们只是在堆中创建一个新节点,并且没有访问共享内存。直到这里,这个 n 节点完全由我自己独占拥有,所以在创建这个新节点之前,我们不需要 pin 这个 guard

在此处 pinguard 之后,我们可以安全地读取指针,因为它们受到基于 epoch 回收实现的有效保护。

关于这个 store 函数有趣的是,与 loadcompare_and_set 不同,它不接受 guard 参数,因为我们没有主动创建对共享内存的新引用。因此,当执行 store 时,我们不需要被赋予一个 guard

如果你在这里有一个头指针,并且这个头指针有效地用 guard 的作用域(即活动状态的持续时间)进行了标注,这证明了 store 函数是在活动状态内部执行的。这就是为什么我们不需要特别要求额外的 guard 引用参数。

到目前为止,在较高层次上,pushpop 函数需要做一些与垃圾回收相关的事情,那就是用 guard 保护代码行,guard 限定了活动状态的开始和结束。它也是使用 guard 来限定活动状态的开始和结束。此外,我们不是立即释放头指针,而是在此时延迟销毁或退休头指针。

这些是与没有垃圾回收的朴素栈算法以及带有基于 epoch 回收的朴素栈算法的区别。

现在,我们可以看到,除了解释几种类型外,这就是我需要使用这个栈来讨论的所有内容。Stack 是一个指向 NodeAtomic 指针,而 Node 包含一个指向下一个节点的 Atomic 指针。因此,Atomic 基本上是一种可以被并发访问的类型。这是可以被多个线程并发访问的指针值。这就是 Atomic 的目的。

它与 Owned 不同,因为 Owned 是独占指针,没有其他人访问这个指针。这就是为什么我们在这一行代码中创建一个新的堆对象。

此外,还有另一种不同类型的指针,即 Shared。这个 headload 函数的结果,这个 head 的类型必须是 SharedShared 指针基本上是对全局内存的引用。你可以认为这个本地指针 Shared 是线程本地的。当一个线程从全局内存读取指针时,该指针会临时存储在栈中,而 Shared 就是用来保存指向全局内存的指针的。

因此,有三种类型的指针:可以被并发访问的 Atomic,指向堆对象的独占指针 Owned,以及临时保存指向全局内存的指针的 Shared 指针。

这个 API 的设计方式使得你很难破坏 epoch 回收的规则。你可以通过阅读我在本视频开头展示的两份文档来看到这一点。

队列实现分析

现在,让我们继续看队列。队列比栈稍微复杂一点。但在垃圾回收方面,它们与栈并没有太大不同。

入队操作

push 函数也创建一个 Owned 指针,并立即将其转换为 Owned 指针。它通过在此处传递 guard 来访问共享内存。guard 作为引用被给出。因此,你可以相当确定这个 push 函数是在活动状态内部执行的。因此,你可以安全地将临时指针加载到栈中。作为这个 load 的结果,你可以得到一个 Shared 指针,它临时保存着指向并发内存的指针。

这意味着所有对并发内存的访问都是在活动状态内部执行的。到目前为止一切顺利。和往常一样,push 在分配方面不是很有趣,因为 push 函数不释放任何东西。

出队操作

另一方面,这个 pop 函数有点不同。它做同样的事情。它被赋予一个 guard,这证明了这个 pop 函数是在活动状态内部执行的,并且使用这个 guard,它可以加载并发内存指针以便遍历到下一个指针。

首先,一切顺利。有趣的是这里也一样。如果你成功更新了头指针,你实际上从队列中分离了第一个节点。因此,你需要在某个时间点释放它。我们不是立即释放指针,而是打算延迟销毁或退休这个指针,以等待其他线程完成对同一节点的访问。

在调用 defer_destroy 之后,我们读取指针的值并在此处返回值。这基本上就是栈中发生的情况,在垃圾回收方面与栈非常相似。

你用 guard 保护函数,这证明函数是在活动状态内部执行的。此外,你延迟销毁或退休块,而不是立即释放它。道理相同。

Drop 实现

这里有一点有趣的事情。在 drop 函数中,你试图弹出值,直到队列为空。这个函数必须在活动状态内部调用。因此,我们将给它一个 guard,但这个 guard 是一个“不受保护的” guard,意味着即使你有一个 guard,你实际上并不在活动状态内部。

但这实际上是正确的实现。乍一看可能看起来是错误的,但实际上是正确的,因为在队列被释放和 drop 函数被调用时,你可以完全确定只有你知道这个队列。没有其他线程试图访问这个队列,因为他们丢失了对这个队列的所有引用。这就是为什么可以调用队列的 drop 函数。

因此,你是唯一访问这个队列内部节点的人。因此,你根本不需要保护你的访问。这就是为什么我们要调用 unprotected 而不是 pin,这个 unprotected 函数意味着你实际上并不在活动状态内部,但我仍然会给你一个 guard,因为你需要它。但由于这个 drop 函数的独占性质,它仍然是安全的。

链表实现分析

链表也是一样的。你被赋予一个 guard。和往常一样,在 drop 函数中,你不需要一个表示活动状态的真正的 guard

这里你有很多事情要做。同样的事情发生在迭代开始时,你需要通过 pin 来获取一个 guard,因为你需要保护你的遍历。

在遍历结束时,你需要丢弃 guard 以标记活动状态的结束或活动状态的结束。代码的其余部分也发生同样的事情。

对于作业 5,你需要实现一些数据结构,这些数据结构共同实现了一个并发哈希表。我希望你现在明白如何做到这一点,如何在并发哈希表内部保护内存的释放。当你开始访问并发内存时,你通过 pin 创建一个 guard。在你完成对共享内存的访问后,你丢弃 guard,这实际上调用了 set_inactive 函数。只有在这些活动状态内部,你才能安全地解引用或引用共享内存。如果你想释放一个节点,那么你不是立即释放它,而是需要在活动状态内部延迟销毁或退休相关的块。

这基本上就是使用基于 epoch 的回收来保护数据结构的较高级别方法。我希望你现在已经很好地理解了如何为并发哈希表做到这一点。

总结

本节课中,我们一起学习了 crossbeam-epoch 库的核心概念和使用方法。我们了解到该库通过 GuardPinOwnedShared 等类型,强制程序员在安全的边界内进行并发内存访问和回收。关键点包括:使用 pin() 进入活动状态并获取 Guard;在 Guard 的作用域内安全地访问共享指针;使用 defer_destroyretire 来延迟释放内存,而不是立即 free;以及理解 AtomicOwnedShared 三种指针类型的区别和用途。掌握这些模式是使用 crossbeam-epoch 构建正确、安全的并发数据结构的基础。

posted @ 2026-03-29 09:18  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报