try ai
科普
编辑
分享
反馈
  • 同步原语

同步原语

SciencePedia玻尔百科
核心要点
  • 原子硬件操作,如“测试并设置”(Test-And-Set),是构建可靠同步原语的必要基础,这些原语保证了并发环境中的互斥。
  • 死锁是并发系统中的一个严重风险,其中多个线程因等待彼此持有的资源而陷入冻结,这种情况通常由四个 Coffman 条件引起。
  • 现代硬件和编译器会重排指令,这使得内存栅栏和获取-释放语义(acquire-release semantics)对于确保正确的操作顺序和防止微妙的竞争条件至关重要。
  • 像信号量、管程和 RCU 这样的高级抽象,为管理从生产者-消费者队列到操作系统内核等应用中的复杂并发挑战提供了结构化、健壮的模式。

引言

在并发计算的世界里,多个线程或进程同时执行,若不加以协调,便可能导致混乱,就像两位艺术家试图同时在画布的同一个点上作画一样。为了创作出杰作而非一团污迹,我们需要一套参与规则——一种为并发执行施加秩序的协议。这便是同步原语的角色,它们是允许程序员管理共享资源、协调并行任务间复杂交互的基本工具。本文旨在弥合处理器硬件的原始能力与现代软件的优雅抽象之间的鸿沟,揭示在追求安全高效并发的道路上,既有危险的陷阱,也有精妙的解决方案。

本文的结构旨在引导您从基础理论走向实际应用。第一章 ​​“原理与机制”​​ 深入探讨了同步的核心构件。我们将探索如何使用不可分割的原子操作来构建基本锁,研究死锁和内存重排的微妙危险,并逐步构建如信号量和管程等复杂抽象。第二章 ​​“应用与跨学科联系”​​ 展示了这些原语的实际应用。我们将看到它们如何编排经典的生产者-消费者之舞,驯服现代硬件的混乱天性,并促成健壮、高性能系统的构建,这些系统涵盖了从操作系统内核到驱动计算科学的大规模模拟。

原理与机制

想象两位艺术家在一块画布上合作。如果他们都试图同时在同一个点上作画,结果将是一团毫无意义的污迹。为了创作出杰作,他们需要一个协议。也许其中一位艺术家拿着一根“说话权杖”;只有持有权杖的人才被允许作画。当他们完成一个部分后,便传递权杖。这本质上就是同步的宏大挑战:为并发执行的潜在混乱施加秩序。我们必须用构成计算机的基本逻辑门和存储单元来构建我们的“说话权杖”。这段旅程将带我们从处理器的原始能力走向现代软件的优雅抽象,一路揭示出人意料的陷阱和精妙的解决方案。

追求秩序:原子操作

让我们从最基本的问题开始:如何确保在同一时间只有一个线程能访问一段共享数据?这个原则被称为​​互斥​​(mutual exclusion)。我们的第一直觉可能是使用一个简单的共享变量,一个“标志”。线程检查这个标志。如果标志是放下的,线程就把它举起来,进入“临界区”(我们的画布),并在退出时放下标志。

但这个简单的想法隐藏着一个致命缺陷。“检查并举起”标志的动作并非单一、不可分割的。它包含两个步骤:一次读取,然后一次写入。如果线程 T0T_0T0​ 读取标志并看到它是放下的,但在它能举起标志之前,操作系统暂停了 T0T_0T0​ 并运行线程 T1T_1T1​ 呢?T1T_1T1​ 也读取标志,看到它仍然是放下的,于是举起它,并进入临界区。当 T0T_0T0​ 恢复执行时,它认为它看到的标志是放下的,所以它也举起标志并闯入临界区。现在两个线程同时进入了。一片混乱。

问题就在于读取和写入之间的那个微小间隙。要弥合这个间隙,我们需要硬件的帮助。我们需要一个​​原子操作​​——一条由处理器保证不可分割的指令。没有其他线程或进程可以中断它或看到处于中间状态的数据。一个经典的例子是​​测试并设置​​(Test-And-Set)指令。当你对一个内存位置 LLL 调用 TAS(L) 时,硬件会做一件神奇的事:在一个不可破坏的步骤中,它返回 LLL 的旧值,并将 LLL 的值设置为 111。

有了这个原语,我们就可以构建我们的第一个真正的锁,一个​​自旋锁​​(spinlock)。希望进入临界区的线程在一个紧凑的循环中(它在“自旋”)重复调用 TAS(L)。它不断检查返回值。如果 TAS(L) 返回 000,这意味着锁是空闲的,而我们的线程现在已经原子地声明了它(因为 TAS 同时也将其设置为了 111)。该线程现在可以安全地进入临界区。任何其他前来调用 TAS(L) 的线程将得到返回值 111,这告诉它要继续自旋。要释放锁,线程只需将 000 写回 LLL。我们用一个被赋予了原子性魔力的原语,构建了我们的第一个“说话权杖”。

一种新的陷阱:致命拥抱

随着我们获得了锁定资源的新能力,一种新的、微妙的危险也随之而来:​​死锁​​(deadlock)。死锁是一种冻结的瘫痪状态,其中两个或多个线程被卡住,互相等待对方释放自己所需的资源。

最著名的例子是“致命拥抱”。想象有两个线程 T1T_1T1​ 和 T2T_2T2​,以及两个资源 RAR_ARA​ 和 RBR_BRB​,每个都由一个锁保护。

  • T1T_1T1​ 获取了 RAR_ARA​ 的锁。
  • 与此同时,T2T_2T2​ 获取了 RBR_BRB​ 的锁。
  • 现在,T1T_1T1​ 试图获取 RBR_BRB​ 的锁,但它被 T2T_2T2​ 持有,所以 T1T_1T1​ 等待。
  • 而 T2T_2T2​ 试图获取 RAR_ARA​ 的锁,但它被 T1T_1T1​ 持有,所以 T2T_2T2​ 等待。

它们将永远等待下去。这种情况源于四个条件,通常称为 ​​Coffman 条件​​:

  1. ​​互斥​​:资源(RAR_ARA​,RBR_BRB​)不能被共享。根据我们的设计,这是成立的;它们受锁保护。
  2. ​​持有并等待​​:一个线程在等待另一个资源时,持有了一个资源。T1T_1T1​ 持有 RAR_ARA​ 同时等待 RBR_BRB​。
  3. ​​不可抢占​​:资源不能被强制夺走。操作系统不会就这么从 T1T_1T1​ 手中抢走 RAR_ARA​ 的锁。
  4. ​​循环等待​​:存在一个循环等待链。T1T_1T1​ 等待 T2T_2T2​,而 T2T_2T2​ 等待 T1T_1T1​。

注意,线程如何等待并不重要。无论它们是无用地自旋、消耗 CPU 周期(自旋锁),还是礼貌地进入睡眠状态让操作系统运行其他任务(阻塞式互斥锁),逻辑上的死锁都是一样的。瘫痪是资源依赖关系的属性,而不是等待机制的属性。

一个远为阴险的死锁甚至可能在单个处理器核心上、只用一个锁就发生。想象一个线程 TTT 获取了一个锁 LLL 并进入其临界区。突然,一个紧急事件发生——一个中断——CPU 立即暂停 TTT 以运行一段特殊代码,即中断服务程序(ISR)。现在,如果这个 ISR 也需要获取锁 LLL 呢?ISR 将开始自旋,等待 LLL 被释放。但唯一能释放 LLL 的线程是 TTT,而 TTT 目前正被暂停……等待 ISR 完成!它们陷入了冻结,互相等待。这揭示了系统编程的一条深刻规则:​​一个线程持有的锁,不能被试图竞争同一把锁的代码所抢占​​。解决方案通常是在持有此类锁时禁用中断。

或者,我们可以直接攻击“持有并等待”条件。在某些场景下,比如一个等待硬件操作(DMA)完成的设备驱动程序,我们可以设计代码,在进入漫长的等待之前释放其配置锁。唤醒后,它重新获取锁来完成其工作。这打破了死锁,但也打开了一个新的潘多拉魔盒:“唤醒丢失”(lost wakeup)竞争。如果完成信号恰好在我们释放锁之后、开始等待之前的微小窗口内到达,我们可能会永远等待一个已经来过的信号。稳健的解决方案是使用​​基于谓词的等待​​:我们在一个循环中等待,反复检查一个完成标志,确保我们永远不会错过事件。

机器的背叛:内存中的幽灵

到目前为止,我们一直假设我们的计算机会忠实地按照我们编写的顺序执行我们的指令。这也许是所有假设中最危险的一个。现代处理器和编译器是无情的优化者,它们被允许以对单个线程不可见但对并发程序却是灾难性的方式重排你的代码。

考虑一个简单的生产者-消费者场景:一个生产者线程准备一些数据,然后设置一个标志来表示数据已准备好。

loading

一个消费者线程自旋,等待标志变为 111,然后读取数据。

loading

我们的意图很明确:如果消费者看到 flag == 1,它必须看到 d == 42。但在一个弱序处理器上,硬件可能认为将对 flag 的写入操作先于对 d 的写入操作对其他核心可见会更高效!消费者可能看到 flag == 1,退出循环,并读到 d 的旧值(也许是 000)。这是一个​​内存重排​​问题。

更糟糕的是,编译器也可能耍花招。看到消费者的自旋循环 while (flag == 0),编译器可能会想,“flag 的值在这个循环内部没有改变,所以我可以只读一次,将其存储在寄存器中,并基于该寄存器的值进行循环。” 这种被称为循环不变代码外提的优化,对于单个线程是完全有效的。但对于我们的消费者来说,这是一场灾难。它将永远看不到生产者的更新,并将永远自旋下去。

为了驯服这些“幽灵”,我们需要给编译器和硬件下达更严格的命令。

  • 像 C 这样的语言中的 volatile 关键字是对编译器的一个命令:“这个变量是神奇的。不要优化掉我对它的读写。” 这解决了编译器提升的问题,但对硬件重排无能为力。
  • 为了控制硬件,我们需要​​内存栅栏​​(memory fences)或​​屏障​​(barriers)。一个更优雅的解决方案是使用带有​​获取-释放语义​​(acquire-release semantics)的特殊原子操作。可以把它想象成一个事务日志。一次​​释放存储​​(release store)(比如我们对 flag 的写入)告诉处理器:“确保我在此之前所做的所有内存写入,在这次存储本身变得可见之前,对所有人都可见。” 它发布了这些变更。一次​​获取加载​​(acquire load)(我们对 flag 的读取)告诉处理器:“在此加载完成并且我看到值之前,不要让任何后续的内存操作执行。” 它订阅了已发布的变更。

当一个消费者的获取加载读取到一个生产者的释放存储所写入的值时,一个​​先行发生​​(happens-before)关系就建立了。d 的初始化现在被保证发生在消费者读取它之前。这种释放与获取的优雅配对,是在现代硬件上编写正确、高性能并发代码的基石。

更高层次的构建:抽象的艺术

在理解了这些底层危险之后,我们可以构建更强大、更用户友好的同步工具。

信号量:资源计数器

想象你有一个拥有 NNN 个车位的停车场。你需要一个门卫,只在有空位时才让车进入,如果车位满了就让车等待。一个简单的锁(一个二进制信号量)对此来说是个糟糕的工具;它一次只允许一辆车进入整个停车场,即使有 99 个空位也会迫使车辆排队。

一个​​计数信号量​​是完美的工具。我们将其初始化为 NNN。要进入,一辆车必须执行一个 wait 操作,这会递减计数。如果计数已经是零,车就必须等待。要离开,一辆车执行一个 signal 操作,这会递增计数并可以唤醒一辆正在等待的车。信号量的计数值完美地模拟了可用资源的数量。至关重要的是,信号量有“记忆”;如果一辆车离开并为一个空停车场发出信号,计数值只会加一,记住了现在有一个空位可供下一次到达。这个特性优雅地解决了“信号丢失”问题。

管程:带等候区的私有房间

虽然信号量功能强大,但正确使用它们可能很棘手(例如,搞错 wait 操作的顺序可能导致死锁)。​​管程​​(Monitors)提供了一种更结构化的方法。管程就像一个强制互斥的房间:一次只有一个线程可以在里面。所有共享数据都保存在房间里,访问它的唯一方法是通过房间的程序。

但是,如果房间里的线程需要等待某个条件成立(例如,有界缓冲区的管程中的消费者发现缓冲区为空),该怎么办?它不能只是休眠;它正持有整个房间的锁!这就是​​条件变量​​(condition variables)发挥作用的地方。条件变量就像房间内一个指定的等候区。线程可以通过调用 wait 去这个区域,这会原子性地释放房间的锁并将线程置于休眠状态。这允许另一个线程,比如生产者,进入房间,添加数据,然后对该条件变量调用 signal。

signal 调用是事情变得有趣的地方。主要有两种哲学:

  • ​​Mesa 风格语义​​:signal 只是一个提示。它将一个等待中的线程从等候区移动到“准备进入”队列。发信号的线程保持锁并继续执行。当被唤醒的线程最终回到房间时,状态可能已经再次改变(另一个消费者可能已经溜了进来!)。因此,被唤醒的线程必须在一个 while 循环中重新检查条件。
  • ​​Hoare 风格语义​​:signal 是一次立即且紧急的权力交接。发信号的线程将锁直接交给一个等待中的线程,而它自己则被挂起。被唤醒的线程保证了条件为真,所以一个简单的 if 语句就足够了。

这种区别展示了系统设计中的一个根本权衡:Hoare 保证的效率和简洁性,与 Mesa 基于提示的方法的灵活性和更容易实现之间的权衡。

最后,作为这些思想力量的证明,值得一提的是,即使是这些复杂的原语也可以从头开始构建。像​​可重用屏障​​(reusable barrier)——一个同步点,其中 NNN 个线程必须互相等待,然后才能有任何一个继续前进——这样的复杂工具,可以仅用一个原子 fetch-and-add 指令和一个巧妙的“感知反转”(sense-reversing)算法来实现,该算法使用一个切换标志来分隔几波线程,防止计算的一个阶段与下一阶段之间发生竞争。从混乱到有序的旅程已经完成,它是从机器本身的逻辑一层层构建起来的。

应用与跨学科联系

在我们完成了对同步原理和机制的探索之旅后,你可能会有一种类似于学会了国际象棋规则的感觉。你知道棋子如何移动,将死的条件,以及基本的策略。但这项游戏的真正美妙之处,其无限的多样性和深度,只有在观看大师对弈时才会显现。同样地,同步原语的深邃优雅并非体现在它们的定义中,而是在于它们的应用——在于这些简单的规则如何编排现代计算中那极其复杂的舞蹈。

让我们开始一次巡览,从并发编程中任劳任怨的主力,到操作系统轰鸣的引擎,再到计算科学的宏伟模拟。我们将看到这些原语不仅仅是避免错误的工具,更是构建更快、更智能、更健壮系统的创造性工具。

并发编程的主力:生产者-消费者之舞

并行编程中最基本的模式之一是生产者-消费者关系。一个实体生成工作或数据,另一个实体消费它。想想 Web 服务器的请求队列、视频流缓冲区,或工厂的装配线。挑战在于管理它们之间的共享缓冲区,既要防止生产者使其溢出,也要防止消费者试图从空缓冲区中取物。

一个优美而直接的解决方案是​​阻塞队列​​。想象一条固定大小的传送带。如果一个生产者线程带着新物品到达,但传送带已满,它必须等待。如果一个消费者线程到达,而传送带是空的,它也必须等待。我们如何协调这一切?我们可以使用两个简单的原语:一个互斥锁(mutex)来确保一次只有一个线程可以操作传送带,以及一个​​条件变量​​。

条件变量就像一个神奇的等候室。当消费者到达一个空队列时,它进入等候室,并释放队列的锁,这样生产者才可能进来。当生产者向现在非空的队列中添加一个物品时,它会发出一个“信号”——轻轻拍一下某个等待中的消费者的肩膀,该消费者随后可以醒来,重新获取锁,并取走物品。这种优雅的 wait 和 signal 之“舞”确保了线程不会浪费精力空转;它们耐心休眠,直到它们的条件得到满足。

这同样逻辑的舞蹈也可以与不同的搭档一起表演。我们可以用​​信号量​​来代替锁和条件变量。信号量本质上是一个控制访问的计数器。我们可以使用两个:一个叫 empty,初始化为缓冲区的容量 BBB;另一个叫 full,初始化为 000。生产者必须首先从 empty 中“获取”一个单位(将其递减),这只有在有空槽时才可能。如果没有,生产者就会阻塞。放置物品后,它向 full “释放”一个单位(将其递增),表示有物品可用。消费者则反向操作。这种方法功能强大,不仅可以协调单个程序中的线程,甚至可以协调通过共享内存块进行通信的完全独立的进程。

但这里潜藏着一个微妙的陷阱,一条通往僵局——或​​死锁​​——的经典路径。如果生产者先用互斥锁锁住缓冲区,然后再检查 empty 信号量,当缓冲区满时会发生什么?生产者持有锁,阻止任何消费者访问缓冲区,而它自己则在等待一个消费者永远无法释放的槽位。整个系统都冻结了。解决方案是并发设计的一个基石:总是在获取排他锁之前检查阻塞条件。

我们还能做得更好吗?如果我们对流量模式有更多了解呢?考虑一个单生产者多消费者(SPMC)的场景。由于只有一个实体在添加物品,队列的尾部是它的私有财产!它永远不必与另一个生产者竞争。因此,我们可以设计一个生产者路径完全无锁的队列,从而显著提高吞吐量。这展示了一个更深层次的原则:最优雅的解决方案源于对问题特定约束的深刻理解。

机器中的幽灵:驯服硬件

我们讨论过的简单模型假设了一个行为良好、秩序井然的世界。而现代多核处理器的现实要混乱得多。为了榨干最后一滴性能,CPU 和编译器会重排指令,而复杂的缓存层级意味着不同核心在任何给定时刻对内存可以有不同的视图。这在“机器中创造了一个幽灵”,使得看起来完全正确的代码会以离奇的、不确定的方式失败。

考虑常见的​​读者-写者问题​​:许多线程需要读取一段数据,而偶尔有一个线程需要写入它。使用一个简单的整型计数器来跟踪读者的天真尝试注定会失败。一个写者可能读取计数器为零并决定写入,但在它行动之前的那个极微小的瞬间,一个读者可能溜了进来——这是一个经典的“检查时-使用时”(TOCTOU)竞争。此外,没有明确的指令,硬件不保证写者对数据的更新相对于计数器,会以正确的顺序对读者可见。

要驯服这个幽灵,我们需要说硬件的语言。这就是​​原子操作​​和​​内存排序语义​​的世界。像 C++ 这样的现代语言提供了原子类型,保证了 fetch_add 或 compare_exchange 等操作是不可分割的。它们还提供了内存“栅栏”(如 acquire 和 release 语义)来强制因果关系。写者的 release 操作说:“确保我之前的所有写入,对于任何看到这次存储的人来说都是可见的。” 读者的 acquire 操作说:“如果我看到了那次存储,确保我也看到了它之前发生的所有写入。” 这种 release-acquire 配对建立了一个先行发生(happens-before)关系,这是无锁世界中正确性的基石。

这个思想可以优美地扩展到庞大的分布式系统。想象一个由数千个读者访问的云缓存。让每个读者都获取锁会造成巨大的瓶颈。一个更聪明的解决方案是​​序列锁​​(seqlock)。读者根本不加锁!他们乐观地读取数据,但他们也在读取前后读取一个版本号。写者在更新前,将版本号递增使其变为奇数,写入数据,然后再次递增使其变为偶数。如果一个读者在读取前后看到相同的偶数版本号,它就知道没有写者干扰。如果不同,它就简单地重试。在一个读密集型系统中,这是一个惊人的胜利。它体现了一种从悲观锁(“请求许可”)到乐观并发(“请求原谅”)的哲学转变。

构建健壮的系统:从管程到内核

有了这些强大但锋利的工具,我们如何构建大型、可靠的系统而又不会经常伤到自己?答案在于抽象和有纪律的设计模式。其中最重要的之一是​​管程​​(monitor)。由 Hoare 和 Brinch Hansen 等先驱提出,管程是一种编程构造,它将共享数据与操作它的过程捆绑在一起,自动为所有这些过程提供互斥。这就像把数据和同步逻辑放进一个密封、安全的容器里。

使用管程(或任何锁)时,一个关键规则是,绝不、绝不在持有锁的同时调用未知的、用户提供的代码(如回调)。如果用户代码试图重新进入管程,这可能导致死锁;如果回调耗时很长,它可能拖垮整个系统。解决这个问题的一个简洁架构模式是让管程将“完成事件”放入一个内部队列。然后,一个单独的调度线程可以安全地检索这些事件,并在管程的锁之外调用回调,从而保护共享资源的完整性和性能。

没有什么地方比操作系统内核内部的风险更高了。考虑一个网络接口驱动程序。以极快速度处理传入数据包的“数据平面”运行在一个特殊的、不可休眠的中断上下文中。处理较慢配置更改的“控制平面”则运行在正常的进程上下文中。如何在数据包飞速通过时安全地更新驱动程序的配置?你不能使用标准锁,因为数据平面不允许在等待锁时休眠。停止数据平面是不可接受的。

Linux 内核的答案是同步领域中最优美的思想之一:​​读-复制-更新(RCU)​​。写者不是锁定数据结构来修改它,而是制作一个完整的副本,修改该副本,然后,通过一个单一的原子操作,将一个全局指针切换到新版本。神奇之处在于,旧版本不会立即被删除。它会被保留一个“宽限期”,这个期限足够长,以确保所有正在使用它的读者都能完成他们的工作。

这个比喻非常贴切:就像在博物馆更换展品。你不会锁上大门把所有人都赶出去。你在幕后准备新展品。准备就绪后,你把幕布一拉。已经在那里的参观者可以安然地看完旧展品。RCU 提供了几乎无等待的读取,这对于网络驱动程序的极端性能要求至关重要,并且它完美地解决了在可休眠和不可休眠上下文之间进行协调的问题。

通往科学的桥梁:编排虚拟世界

这些思想的影响远远超出了操作系统和数据库。它们处于现代科学的核心。模拟宇宙——从分子的舞蹈到星系的形成——是我们这个时代的宏大挑战之一,而它完全依赖于并行计算。如何对模拟进行并行化的选择,就是同步和通信策略的选择,这由超级计算机的硬件所决定。

想象一个科学家团队负责模拟一个巨大的空间体积。他们可能会采取以下三种策略之一:

  • ​​共享内存(线程):​​ 整个团队在一个拥有巨大共享黑板(计算机的内存)的实验室里工作。每个人都可以看到并写入黑板的任何部分。这使得协作非常快速、低延迟。但为了避免混乱,他们需要严格的协议——屏障和锁——以确保他们不会擦除或涂抹彼此的工作 [@problem_id:3431931, Option A]。这是像 OpenMP 这样的线程库在单个多核服务器上使用的模型。

  • ​​分布式内存(MPI):​​ 团队被分散在不同城市的多个实验室中。每个实验室都有自己的私有黑板。一个实验室的科学家无法简单地看到另一个实验室的黑板。为了协作,他们必须通过发送消息来显式通信——打电话或发邮件。这是消息传递接口(MPI)的模型,它是为编程大规模分布式内存超级计算机而设的标准 [@problem_id:3431931, Option B]。

  • ​​混合模式(MPI + 线程):​​ 这是当今的主流模型。它集两家之长。你在不同城市有团队(不同节点上的 MPI 进程),但在每个城市的实验室内,一个本地团队使用共享黑板(单个节点内的核心上的线程)进行协作 [@problem_id:3431931, Option C]。

在使用这种混合模型的分子动力学模拟中,一个节点上的线程将使用快速的共享内存访问来计算附近粒子之间的力。当一个粒子从一个节点管理的区域移动到另一个节点时,必须通过网络发送一条显式的 MPI 消息。同步原语的选择不是一个抽象的选择;它是机器物理现实的直接结果。

从一个简单的队列到超级计算机的核心,我们看到同样的根本原则在起作用。同步原语是那些无形的线,将无数独立代理的并行活动编织成一个单一、连贯的计算。它们是优雅、简约的规则,使我们能够构建出复杂度、速度和能力都惊人的系统。

// Producer (Thread P) d = 42; flag = 1;
// Consumer (Thread C) while (flag == 0) { /* spin */ } print(d);