try ai
科普
编辑
分享
反馈
  • Peterson 算法

Peterson 算法

SciencePedia玻尔百科
关键要点
  • Peterson 算法通过使用一个共享的 turn 变量和一个 flag 数组来声明意图和打破僵局,从而为两个进程实现互斥。
  • 该算法保证了互斥、前进和有界等待,确保没有进程会饿死或等待超过一轮。
  • 由于编译器优化和弱硬件内存模型,其理论正确性在现代系统上会失效,需要使用 volatile 和内存栅栏等工具。
  • 实现 Peterson 算法所面临的挑战揭示了并发中的基本问题,如伪共享、原子性,以及正确性与性能之间的权衡。

引言

在并发编程的世界里,两个进程无冲突地共享一个资源这一看似简单的行为,却带来了一个根本性的挑战。我们如何设计规则来保证有序访问,既避免冲突又避免僵局?这就是临界区问题的本质,一个揭示了我们代码表面之下深层复杂性的谜题。虽然存在许多解决方案,但很少有像 Peterson 算法那样优雅简洁且具有强大教学意义的。对它的研究揭示了抽象算法的正确性与现代计算系统中混乱、优化的现实之间的一道关键鸿沟。

本文深入探讨这一经典算法,不仅视其为历史产物,更将其作为理解软件与硬件之间复杂互动的透镜。首先,在“原理与机制”一节中,我们将剖析其旗标和轮转的美妙逻辑,以及它所提供的形式化保证。然后,在“应用与跨学科联系”一节中,我们将从理论走向现实世界,探索该算法的假设如何受到编译器、CPU乃至云规模系统的挑战,从而解锁关于并发本质的深刻见解。

原理与机制

想象两个人,我们称他们为进程 P0P_0P0​ 和进程 P1P_1P1​,试图通过一个一次只能容纳一人的狭窄门口。这个门口就是我们的“临界区”——一段一次只能有一个进程使用的代码或资源。他们如何协调?如果他们只是一味地往前冲,就会发生碰撞。如果他们都过于礼貌——“您先请”,“不,您先请!”——他们可能会陷入无休止的谦让循环中,这种状态我们称之为​​活锁​​(livelock)。设计一个协议来解决这个看似简单的问题所面临的挑战,正是并发编程的核心所在。这是一门在充满竞争的世界中协调行动的艺术。

对于这个难题,早期最优雅、最具启发性的解决方案之一是 ​​Peterson 算法​​。它是简约的杰作,仅使用两个共享概念或变量,就实现了优雅且可证明正确的协调。

旗标与轮转的优雅之舞

Peterson 算法为每个进程提供了两种工具:一个个人旗标和一个共享的“轮转”标记。

flag:“我有意进入”

第一个工具是一个共享的旗标数组 flag[0] 和 flag[1]。当进程 P0P_0P0​ 想要进入临界区时,它首先通过设置 flag[0] := true 来升起自己的旗标。这是一种公开的意图声明,就像走到门口并示意:“我想进去。”

如果我们只使用旗标会怎样?假设我们的规则是:“只要你的旗标升起,我就会等待。”考虑这个场景:P0P_0P0​ 升起它的旗标。在它检查 P1P_1P1​ 的旗标之前,系统迅速切换到 P1P_1P1​,P1P_1P1​ 也升起了它的旗标。现在,P0P_0P0​ 看着 P1P_1P1​,看到它的旗标是升起的,所以它等待。然后 P1P_1P1​ 看着 P0P_0P0​,看到它的旗标也是升起的,所以它也等待。它们现在陷入了一种数字对峙,各自等待对方放下那永远不会被放下的旗标。这是一个经典的活锁,正是我们想要避免的问题。单靠旗标是不够的。我们需要一种打破僵局的方法。

turn:“不,您先请”

这就引出了第二个神奇的要素:一个名为 turn 的单一共享变量。turn 变量是打破僵局的关键。它体现了至关重要的礼貌行为。当一个进程 PiP_iPi​ 升起它的旗标后,它会做出一个谦恭的姿态:将 turn 变量设置为有利于另一个进程。所以,P0P_0P0​ 设置 turn := 1,P1P_1P1​ 设置 turn := 0。

现在,进程 PiP_iPi​ 的完整等待条件是:while (flag[j] && turn == j)。让我们为 P0P_0P0​(其中 i=0i=0i=0,j=1j=1j=1)分析一下这个条件:“我,P0P_0P0​,只有在 P1P_1P1​ 也升起了它的旗标,并且我刚刚慷慨地把轮次让给了它时,我才会等待。”

这如何打破僵局?让我们观察一场竞逐的展开。假设 P0P_0P0​ 和 P1P_1P1​ 几乎同时决定进入。

  1. P0P_0P0​ 设置 flag[0] := true。
  2. P1P_1P1​ 设置 flag[1] := true。(现在两者都有意进入)。
  3. P0P_0P0​ 礼貌地设置 turn := 1。
  4. P1P_1P1​ 礼貌地设置 turn := 0。

因为这些操作是原子的,对 turn 的写操作中必定有一个是最后发生的。假设 P1P_1P1​ 的写操作是最后完成的。那么 turn 的最终值是 000。现在,它们都检查等待条件。

  • ​​P1P_1P1​ 检查:​​ flag[0] 是 true 吗?是的。turn == 0 吗?是的。条件 (true && true) 为真,所以 ​​P1P_1P1​ 等待​​。
  • ​​P0P_0P0​ 检查:​​ flag[1] 是 true 吗?是的。turn == 1 吗?不,它是 000。条件 (true && false) 为假,所以 ​​P0P_0P0​ 继续​​进入临界区。

互斥得以实现!最后写入 turn 的那个进程,正是那个礼貌地坚持让对方先行的进程。这个简单、确定性的机制保证了它们永远不会陷入僵局,也永远不会同时进入。

保证:算法之美

Peterson 算法之所以备受赞誉,不仅因为它有效,还因为它提供了一套强有力的保证,构成了同步正确性的“三大支柱”。

  • ​​互斥(Mutual Exclusion):​​ 正如我们所见,这是有保证的。由于 turn 一次只能有一个值,当两个进程都升起旗标时,while 条件不可能对两者同时为假。

  • ​​前进(Progress):​​ 此属性确保系统不会陷入停滞。如果只有一个进程想要进入,它的 while 条件将为假(因为另一个进程的旗标是放下的),于是它将继续执行。如果两个进程都想进入,turn 变量确保其中一个会继续执行。不存在死锁。

  • ​​有界等待(Bounded Waiting)或无饥饿(Starvation-Freedom):​​ 这是最深刻的保证。它不仅承诺你最终会得到机会,而且承诺你的等待时间有严格的上限。一旦进程 P0P_0P0​ 升起它的旗標,在 P0P_0P0​ 获得机会之前,P1P_1P1​ 可以进入临界区多少次?惊人的答案是:​​至多一次​​。

    为什么?一旦 P0P_0P0​ 处于等待状态,这意味着 flag[1] 为 true 并且 turn 必须为 111。这允许 P1P_1P1​ 进入。但当 P1P_1P1​ 退出时,它会将 flag[1] 降为 false。这立即打破了 P0P_0P0​ 的等待条件。即使 P1P_1P1​ 争着重新进入,它也会设置 turn := 0。这种礼让行为给了 P0P_0P0​ 优先权,确保 P0P_0P0​ 是下一个进入的。这种相互谦让是实现公平的关键。这就是为什么 turn 必须能被两个进程写入;如果只有一个能写入,另一个就可能被无限期地饿死。这种有界等待的强有力保证确保了算法不会发生​​饥饿​​。

象牙塔的裂缝:当现实世界介入时

Peterson 算法的美妙逻辑建立在一些几乎看不见的假设之上。它假设我们在理想的机器上运行。但硬件、编译器和操作系统的现实世界是混乱的。当算法遇到这个现实时,裂缝开始出现。

不公平的调度器

前进性的证明依赖于一个等待中的进程在被解除阻塞后最终能再次检查其 while 循环。但如果操作系统的调度器极度不公平呢?想象一下,P0P_0P0​ 执行了 flag[0] := true 然后被调度器无限期地暂停了。它永远没有机会执行 turn := 1。现在,当 P1P_1P1​ 试图进入时,它会设置 turn := 0 并检查其条件:while(flag[0] && turn == 0)。由于 flag[0] 是 true 并且 turn 现在是 000,P1P_1P1​ 将永远等待 P0P_0P0​ 改变 turn 变量,而 P0P_0P0​ 永远不会这样做。整个系统都冻结了。这表明 Peterson 算法隐含地依赖于至少一个​​弱公平调度器​​——一个不会永远忽略一个可运行进程的调度器。

欺骗性的编译器

现代编译器是一个激进的优化者。当它看到像 while(flag[j] && ...) 这样的代码时,它会进行单线程分析,并可能认为:“这个循环不会改变 flag[j],所以它的值必定是常量。我可以通过只读取 flag[j] 一次,将其存储在一个超快的寄存器中,然后在循环中只检查这个寄存器来提高效率。”

这种“优化”是灾难性的。一个进程可能会读取 flag[j] 为 true,将其缓存,然后基于这个陈旧的缓存值永远旋转,完全无视另一个进程早已完成并把内存中真实的 flag[j] 设置回 false 的事实。这揭示了另一个隐藏的假设:我们的共享变量必须不受此类优化的影响。我们需要明确告诉编译器这些变量是特殊的,它们随时可能改变。在编程语言中,这通常通过使用 volatile 关键字或更稳健的语言级​​原子类型​​来完成。这些是我们给编译器的指令:“不要相信任何东西。每一次都从主内存中读取。”

叛逆的硬件

最深层、最微妙的假设是硬件本身遵守规则。我们假设当你写入内存时,该写操作会立即以你写入的顺序对其他所有处理器可见。这种理想模型被称为​​顺序一致性(Sequential Consistency, SC)​​。现代处理器为了追求速度,并不遵循这个模型。它们使用像​​存储缓冲区​​(store buffers)这样的技巧。

想象一下,存储缓冲区是你桌上的一个私人发件箱。当你写入 flag[0] 时,你不是直接把它放入主内存(中央邮局);你只是把它放在你的发件箱里。然后,你立即进行下一步,也就是读取 flag[1] 。你可能会向窗外看,发现 P1P_1P1​ 的旗标在主内存中没有升起,因为 P1P_1P1​ 的写操作也正躺在它自己的私人发件箱里!因此,两个进程都可能读取到对方旗标的旧值 false,断定对方不感兴趣,然后同时闯入临界区。互斥性被彻底破坏。

这是一个深刻而令人不安的认识:我们软件的逻辑正确性可能因硬件的物理行为而失效。为了解决这个问题,我们需要称为​​内存栅栏​​(memory fences)或内存屏障(memory barriers)的特殊指令。内存栅栏是给 CPU 的一个命令:“在所有你发件箱里的写操作都送达主内存之前,不要越过此点执行后续操作。”在 Peterson 算法的关键位置插入这些栅栏可以恢复其正确性,但这会带来性能成本。

务实的工程师:效率与能耗

即使是正确的,忙等待循环也像是在红灯前空踩油门。它能工作,但浪费大量燃料,噪音也大。一个在紧凑循环中空转的 CPU 会消耗大量电力,并阻止其他线程使用该处理器核心。

务实的工程师主要有两种方法来改善这一点:

  1. ​​pause 指令:​​ 这是给 CPU 的一个温和提示。在循环内部,你插入一条 pause 指令。这告诉处理器:“我正在等待内存中的某些东西改变。你或许可以放松一下,减慢你的推测执行(speculative execution),并节省一些电力。”这个简单的补充可以在不影响其正确性的情况下显著降低自旋锁的能耗。

  2. ​​yield() 系统调用:​​ 这是 CPU 礼貌行为的极致体现。线程不是空转,而是调用 yield(),实际上是告诉操作系统:“我被阻塞了。请让另一个线程在这个核心上运行。稍后再唤醒我。”对于长时间的等待,这在能源效率上要高得多,因为核心可以进入低功耗空闲状态。然而,它也伴随着高昂的代价:一次到操作系统内核的​​上下文切换​​(context switch)再返回,比单条 CPU 指令慢上数千倍。

在空转和让出之间的选择是一个经典的工程权衡。如果预期的等待时间非常短(少于两次上下文切换的时间),那么空转更快。如果等待时间长,那么对整个系统来说,让出 CPU 效率要高得多。这种在延迟与吞吐量之间,在个人效率与系统全局效率之间的权衡,是设计高性能并发系统时一个永恒的主题。

因此,Peterson 算法不仅仅是一个巧妙的算法。它是并发编程的一堂完整课程:一个逻辑优雅的模型,一个关于计算中隐藏假设的案例研究,以及一个工程权衡的实践练习。它教导我们,要真正掌握协调的艺术,我们必须理解从抽象逻辑一直到叛逆的硅芯片的整个技术栈。

应用与跨学科联系

在我们经历了 Peterson 算法精妙机制的旅程之后,人们可能会倾向于将其归档为一个巧妙但仅具学术意义的好奇之物。毕竟,现代系统为我们提供了强大的原子指令和高级同步库。但这样做将完全错失其要点。Peterson 算法不仅仅是一个算法,它是一面透镜。通过它,现代计算中那些无形而令人困惑的复杂性变得清晰锐利。它是一把钥匙,能解锁对我们编写的软件与赋予其生命的硬件之间复杂舞蹈的深刻、直观的理解。让我们带着这把钥匙踏上征程,从微型计算机的心脏出发,向外扩展到全球云。

代码与现实的鸿沟:编译器与简单 CPU

想象一下,我们受命在一个简单的单核嵌入式微控制器上实现 Peterson 算法——那种你可能在微波炉或恒温器中找到的芯片。这个世界似乎很简单:没有需要担心的缓存,硬件也承诺按我们给出的顺序执行指令。能出什么问题呢?

事实证明,问题很多。第一个障碍不是硬件,而是编译器。编译器是一个聪明但 relentlessly pragmatic 的优化器。当它看到你的代码时,它的目标是在保持单线程语义的情况下,生成最快的机器指令。当你写一个像 while(flag[j] && turn == j); 这样的自旋循环时,编译器可能会想:“啊哈!这些变量在循环内部没有改变。我可以在循环开始前把它们加载到超快的寄存器中一次,然后一遍遍地检查寄存器。”在单线程世界里,这是一个绝妙的优化。在我们的并发世界里,这是一场灾难。另一个进程可能会改变内存中的实际值,但我们这个空转的进程,专注地盯着自己的寄存器,将永远不会注意到。它会永远空转下去,陷入死锁。

这就是我们必须向编译器传达我们意图的地方。我们必须告诉它:“这个变量是特殊的。它随时可能被外部力量改变,所以你每次都必须从内存中读取它。”在像 C 或 C++ 这样的语言中,volatile 关键字是我们进行这场对话的工具。它是一条命令,弥合了我们代码的抽象逻辑与共享内存的物理现实之间的鸿沟,迫使编译器尊重并发修改的可能性。

但即使有一个顺从的编译器,一个更深层次的硬件问题依然潜伏着。一个操作是“原子的”意味着什么?我们想当然地认为写入一个数字是一个单一、不可分割的行为。但在一个 8 位微控制器上,试图写入一个 16 位整数(比如我们的 turn 变量可能是)就像试图把一个大沙发搬过一扇小门——你必须分块进行。处理器写入第一个字节,然后是第二个。如果一个硬件中断恰好在这两次写入之间抢占了我们的进程怎么办?另一个进程可能会醒来并读取 turn 变量,看到一个“撕裂”的值——一半旧,一半新。这个损坏的数据摧毁了算法的逻辑基础。这教给我们一个深刻的教训:原子性不是理所当然的。它是一个与处理器的物理“字长”(word size)和数据在内存中的对齐方式相关的属性。

现代 CPU 的迷宫:内存模型与缓存

当我们从简单的微控制器转向现代多核处理器时,世界变得 vastly more complex。在这里,线程不仅是在时间上交错的;它们是在物理上分离的核心上并行运行的。为了管理这种复杂性并榨取每一滴性能,硬件设计师构建了一个由缓存、缓冲区和重排序机制组成的迷宫。

第一个冲击是,为了速度,现代 CPU 并不遵守我们程序的命令序列。它们有所谓的“弱内存模型”。处理器可能会决定在一个较早的内存写入对其他核心可见之前,执行一个较晚的内存读取。对于 Peterson 算法来说,这是致命的。一个线程可能在它自己对 flag[i] 的写入对系统可见之前,就读取了 flag[j] 和 turn。它可能会错误地断定进入临界区是安全的,从而导致竞争条件。

为了 navigating this labyrinth,硬件为我们提供了“栅栏”。内存栅栏(如 RISC-V 上的 fence 或 x86 上的 MFENCE)是一种充当交通警察的指令。它告诉 CPU:“不要跨越此点重排序内存操作。确保此栅栏前的所有写入对所有人都可见,然后才能执行此栅栏后的任何读取。”通过仔细放置栅栏,我们可以恢复 Peterson 逻辑所依赖的顺序。但这种安全性是有代价的。栅栏会阻碍处理器 relentless 的优化,性能成本可能非常巨大——在某些情况下,添加必要的栅栏会使一个操作减慢近 40%。这就是并发编程的基本权衡:正确性与性能。

但操作的重排序只是故事的一部分。另一部分是 CPU 的缓存系统。每个核心都有自己的小型、快速的缓存,用于保存主内存的副本。为了保持这些缓存的一致性,处理器使用一种一致性协议,如 MESI(修改-独占-共享-无效)。一致性的单位不是单个变量,而是一个“缓存行”(cache line),通常是 64 字节的内存。现在,想象一下我们的 flag[0]、flag[1] 和 turn 变量很小,并且在内存中彼此相邻,都放在一个缓存行内。

运行在核心 0 上的线程 0 写入 flag[0]。为此,核心 0 必须声明对该缓存行的独占所有权,使核心 1 缓存中的副本失效。瞬间之后,核心 1 上的线程 1 写入 flag[1]。它也必须声明独占所有权,使核心 0 的副本失效。这会引发缓存行在内存总线上的疯狂“乒乓”,尽管线程正在写入逻辑上不同的变量!这种现象,被称为​​伪共享​​(false sharing),是一个臭名昭著的性能杀手。解决方案是反直觉的:我们必须在我们的数据结构中添加“填充”(padding),有意地将变量隔开,使每个变量都位于自己的私有缓存行上,就像给争吵的邻居各自独立的房子,而不是让他们共享一堵墙。

资源争用的主题在具有同时多线程(SMT,Simultaneous Multithreading),通常称为超线程(Hyper-Threading)的系统中再次出现。在这里,两个逻辑线程在单个物理核心上运行,共享 L1 缓存等资源。当一个线程在临界区时,另一个在空转,不断地从共享缓存中读取。这种对缓存访问的争用减慢了两个线程的速度。虽然 Peterson 算法保持正确,但系统的性能和公平性直接受到这种微观硬件层面干扰的影响。

最后,即使是在自旋循环中等待这个看似简单的行为也隐藏着深意。一个激进的、紧凑的自旋循环不断地用读请求冲击内存系统,加剧了我们讨论过的缓存一致性流量。一种更“礼貌”的方法是使用​​指数退避​​(exponential backoff)策略。每次检查失败后,线程等待一个 progressively longer 的时间。这个简单的改变极大地减少了总线争用,提高了整体系统吞吐量,并通过给予另一个线程一个更清晰的机会窗口来获取和释放锁,从而带来更好的实践公平性。这在计算上等同于从拥挤的门口后退一步,而不是反复尝试挤进去。

超越 CPU:更广阔的并发世界

Peterson 算法所阐明的原理超出了 CPU 核心的复杂舞蹈。它们适用于任何需要协调的并发实体。

考虑一下操作系统的核心功能:处理中断。如果我们的进程在执行 Peterson 进入协议的过程中,被一个中断服务程序(ISR)抢占了,会发生什么?仅仅是 ISR 在一个中间状态观察共享的 turn 变量这一行为,会破坏逻辑吗?答案是响亮的“不”,前提是 ISR 只是读取数据。该算法的诞生,实际上正是为了处理这种抢占式多任务。它的逻辑结构足够健壮,能够承受其原子步骤的任何交错,这一特性与它的前辈如 Dekker 算法相比,是一个巨大的突破。

这些原则也适用于软件必须与硬件设备通信的情况。这通常通过内存映射 I/O(MMIO)完成,其中设备的控制寄存器看起来就像内存中的位置。然而,这种“设备内存”的行为与普通内存不同;它通常是 un-cached 的,并且有自己的排序规则。如果我们的 flag 变量被实现为 MMIO 寄存器,我们将需要特殊的 I/O 屏障来确保对 flag(设备内存)的写入与对 turn(普通内存)的写入被正确排序。这表明同步不是一个一刀切的问题;它需要对被协调资源的性质有深刻的理解。

横向扩展:从核心到云

也许这些想法最引人入胜的应用是将它们从单个计算机扩展到一个庞大的分布式系统。想象一下两个无状态的云函数试图更新存储在共享键值存储(KVS)中的一个计数器。它们能使用 Peterson 算法,将 KVS 的键作为 flag 和 turn 变量吗?

答案是一个有条件的“是”,而这些条件揭示了惊人的内幕。如果 KVS 保证​​线性一致性​​(linearizability)——一个非常强的一致性模型,它使得分布式存储的行为就像一个单一的、原子的内存库——那么 Peterson 算法完美地工作。抽象得以维持!这是对计算机科学力量的美丽证明。

但现实世界是混乱的。如果 KVS 只是​​最终一致性​​(eventually consistent)的呢?整个算法就崩溃了。一个函数可能没有及时看到另一个函数的旗标更新,导致两者都进入临界区并损坏计数器。互斥性丧失了。

如果一个函数在设置了它的旗标之后但在清除它之前崩溃了怎么办?锁现在被永远卡住了,另一个函数将空转到天荒地老——一个活性失败(liveness failure)。这揭示了对容错机制的需求,如租约(leases)或超时(timeouts),这些都是分布式锁定的标准实践。

如果我们有三个函数,而不是两个呢?Peterson 的双进程逻辑立即失效。我们需要更高级的、通用的算法,如锦标赛树(tournament tree)或过滤器锁(filter lock)。这个限制最终将我们引向解决此问题的现代方式:在计数器本身上使用一个单一、强大的原子原语,如​​比较并交换(Compare-And-Swap, CAS)​​。这种无锁方法(lock-free approach)更健壮、更具扩展性,并且更适合分布式系统这个不可靠的世界。

永恒的教训

我们的旅程始于一个简单的算法,却带我们穿越了编译器理论、CPU 微体系结构、缓存物理学、操作系统设计和分布式系统。Peterson 算法本身可能不是你下一个项目中会编写的代码。但它迫使我们提出的问题是正确的问题。它作为一个绝妙的教学工具,一把看似简单的钥匙,却打开了一个装满深刻见解的宝箱。它真正的遗产是以其鲜明、令人难忘的方式,照亮了我们编写的抽象顺序逻辑与其执行的混乱、并行且奇妙复杂的现实之间那道巨大而险恶的鸿沟。