3多处理器编程艺术

互斥与锁

同步基础

互斥锁死锁

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第三章 互斥与锁 (Mutual Exclusion and Locks)

一、导读

1.1 本章学习目标

深入理解互斥(Mutual Exclusion)的概念及其在并发编程中的核心地位

掌握锁(Lock)的基本实现方法,从软件算法到硬件原子指令

理解 Peterson 算法、Lamport 面包店算法等经典互斥算法的原理

掌握 Test-and-Set、Compare-and-Swap 等硬件原子操作的使用方法

学会分析锁的性能指标:争用(Contention)、公平性(Fairness)、复杂度

理解自旋锁(Spin Lock)与阻塞锁(Blocking Lock)的区别与适用场景

掌握锁的常见使用模式和反模式

1.2 为什么本章如此重要

互斥是并发编程中最基本的问题之一。当多个线程需要访问共享资源时,必须确保同一时刻只有一个线程在访问,否则可能导致数据不一致或程序错误。锁是实现互斥的最常用工具,理解锁的原理和实现对于编写正确的并发程序至关重要。

互斥问题的研究有着悠久的历史。从 1965 年 Dijkstra 提出信号量和 P/V 操作开始,到 Peterson 算法(1981)、Lamport 面包店算法(1974),再到基于硬件原子指令的现代锁实现,互斥算法的发展反映了并发编程理论和实践的进步。

本章从最简单的两线程互斥算法开始,逐步引入更复杂的解决方案。每个算法都揭示了并发编程中的一个重要概念:原子性、可见性、公平性、等待条件等。通过理解这些算法的设计思路,读者可以深入理解并发编程的本质挑战。

锁虽然是解决互斥问题的基本工具,但也是并发程序中最常见的性能瓶颈和 bug 来源。死锁、活锁、饥饿、优先级反转等问题都与锁的使用不当有关。因此,理解锁的原理不仅是为了实现互斥,更是为了正确使用锁、避免锁的陷阱。

1.3 核心问题引导

如何在没有硬件支持的情况下实现两线程互斥?Peterson 算法是如何工作的?

如何将互斥算法从两线程推广到 n 线程?Lamport 面包店算法的核心思想是什么?

硬件提供了哪些原子操作来简化互斥的实现?它们各自的优缺点是什么?

自旋锁和阻塞锁有什么区别?在什么场景下应该使用哪种?

如何评估一个锁算法的性能?公平性为什么重要?

锁的常见反模式有哪些?如何避免死锁和优先级反转?


二、核心概念详解

2.1 互斥问题的形式化定义

互斥问题(Mutual Exclusion Problem)是并发编程中的基本问题。其形式化定义如下:有 n 个线程,每个线程反复执行以下代码:

do {
  entry_section();   // 进入区
  critical_section(); // 临界区
  exit_section();     // 退出区
  remainder_section(); // 剩余区
} while (true);

互斥算法需要设计 entry_section() 和 exit_section() 的实现,满足以下三个要求:

互斥(Mutual Exclusion / Safety):在任何时刻,最多只有一个线程在临界区内。这是安全属性,保证不会出现两个线程同时操作共享资源的情况。

无死锁(Deadlock Freedom / Liveness):如果两个或多个线程同时试图进入临界区,至少有一个线程能够成功进入。这是活性属性,保证系统不会"卡住"。

无饥饿(Starvation Freedom / Liveness):每个试图进入临界区的线程最终都能成功进入。这是更强的活性属性,保证不会有线程被永远拒绝。

2.2 Peterson 算法

Peterson 算法是 1981 年由 Gary Peterson 提出的一个优雅的两线程互斥算法。它仅使用共享变量和普通的读写操作(不需要硬件原子指令),就实现了互斥。

算法使用两个共享变量:flag[0] 和 flag[1](表示线程是否想进入临界区),以及 turn(表示当两个线程都想进入时,谁应该等待)。

线程 i 的进入区代码:

flag[i] = true;       // 表示我想进入
turn = j;             // 让给对方
while (flag[j] && turn == j); // 等待:对方也想进入,且轮到对方

Peterson 算法的正确性可以通过分析所有可能的执行顺序来验证。关键观察是:turn 的赋值是最后一步,它决定了当两个线程都设置 flag 后的优先级。后设置 turn 的线程会"覆盖"先设置的值,从而获得优先权。

Peterson 算法的局限在于:它只适用于两个线程,且依赖于内存操作的顺序性(在弱内存模型的处理器上可能需要额外的内存屏障)。

2.3 Lamport 面包店算法

Lamport 面包店算法(Bakery Algorithm)是 Leslie Lamport 在 1974 年提出的 n 线程互斥算法。它的灵感来自面包店的取号排队系统:每个顾客进入时取一个号码,号码小的先被服务。

算法的核心思想:每个线程在进入临界区之前获取一个"号码"(一个递增的整数),然后等待所有号码更小的线程先进入临界区。如果两个线程的号码相同(可能同时获取),则通过线程 ID 来打破平局。

获取号码的过程需要保证原子性——不能有两个线程获取相同的号码(除非它们确实同时尝试)。Lamport 的解决方案是:先设置一个 choosing[i] 标志为 true,然后取号码,再设置 choosing[i] 为 false。其他线程在读取号码时,如果发现 choosing[j] 为 true,就等待直到 choosing[j] 为 false。

面包店算法满足互斥、无死锁和无饥饿(FIFO 公平性)。但它的时间复杂度较高:获取号码需要 O(n) 时间(读取所有其他线程的号码),等待也可能需要 O(n) 时间。

2.4 硬件原子操作

现代处理器提供了一些硬件原子操作来简化同步的实现。这些操作在硬件层面保证原子性,无法被中断。

Test-and-Set(TAS):原子地读取一个内存位置的值,并将其设置为 1(或 true)。返回原来的值。如果原来的值为 0,表示成功获取锁;如果为 1,表示锁已被其他线程持有。

Compare-and-Swap(CAS):原子地比较一个内存位置的值与预期值,如果相等则将其更新为新值。返回原来的值(或布尔值表示是否成功)。CAS 比 TAS 更强大,可以实现无锁数据结构。

Fetch-and-Add(FAA):原子地读取一个内存位置的值,并将其增加一个增量。返回原来的值。可以用于实现公平的队列锁。

Load-Linked / Store-Conditional(LL/SC):LL 读取一个内存位置并标记它;SC 写入一个内存位置,但仅当该位置自 LL 以来没有被修改过。LL/SC 可以实现 CAS 的功能,在某些架构(如 ARM、PowerPC)上使用。

2.5 自旋锁与阻塞锁

自旋锁(Spin Lock):当线程无法获取锁时,它在一个循环中不断重试("自旋")。自旋锁适用于锁持有时间很短的场景,因为线程不会放弃 CPU,一旦锁被释放就可以立即获取。但在锁持有时间较长时,自旋会浪费大量的 CPU 时间。

阻塞锁(Blocking Lock):当线程无法获取锁时,它将自己放入等待队列并让出 CPU(阻塞)。当锁被释放时,操作系统唤醒等待队列中的一个线程。阻塞锁适用于锁持有时间较长的场景,因为它不会浪费 CPU 时间。但在锁持有时间很短时,阻塞和唤醒的开销(涉及操作系统内核调用)可能比自旋更大。

现代操作系统通常提供两种锁的实现:用户态的自旋锁(如 pthread_spinlock)和内核态的互斥锁(如 pthread_mutex)。一些高级的锁实现(如 Linux 的 futex)结合了两者的优点:先自旋一段时间,如果仍然无法获取锁,再切换到阻塞模式。


三、深入分析与讨论

3.1 锁的性能指标

评估锁的性能需要考虑多个维度。吞吐量(Throughput):单位时间内成功获取和释放锁的次数。在高争用场景下,锁的吞吐量可能成为系统的瓶颈。

公平性(Fairness):锁是否保证每个等待的线程最终都能获取锁。FIFO 锁保证按请求顺序获取;.ticket 锁是一种典型的 FIFO 锁。非公平锁可能导致某些线程长期饥饿。

可伸缩性(Scalability):随着线程数增加,锁的性能如何变化。基于 TAS 的简单锁在高争用下可伸缩性很差(所有线程同时竞争同一个缓存行)。队列锁(如 MCS 锁)的可伸缩性更好,因为每个线程只在自己的缓存行上自旋。

延迟(Latency):从请求获取锁到成功获取的时间。在无争用情况下,理想的锁延迟应该只有几个原子操作的开销。

3.2 锁的优化技术

为了减少锁的争用和开销,研究者提出了多种优化技术。

队列锁(Queue Lock):如 MCS 锁和 CLH 锁,使用一个隐式或显式的队列来管理等待的线程。每个线程只在队列中自己的节点上自旋,减少了缓存行的争用。MCS 锁的可伸缩性优于 TAS 锁,因为它消除了"缓存行弹跳"。

读写锁(Read-Write Lock):允许多个读者同时获取锁,但写者需要独占。在读多写少的场景下,读写锁可以显著提高吞吐量。但写者可能饥饿(持续的读者流阻止写者获取锁)。

锁消除(Lock Elimination):编译器分析发现某个锁只在单线程上下文中使用,可以自动消除该锁。Java 的逃逸分析(Escape Analysis)可以实现锁消除。

锁粗化(Lock Coarsening):将多个连续的加锁-解锁操作合并为一个大的临界区,减少锁操作的次数。

3.3 死锁与优先级反转

死锁(Deadlock):两个或多个线程互相等待对方持有的锁,导致所有线程都无法继续。死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。打破其中任何一个条件就可以防止死锁。常见的策略包括:锁排序(所有线程按相同顺序获取锁)、超时重试、死锁检测。

优先级反转(Priority Inversion):高优先级线程等待低优先级线程持有的锁,而中优先级线程又抢占了低优先级线程的执行,导致高优先级线程被间接阻塞。经典的解决方案是优先级继承(Priority Inheritance):当高优先级线程等待低优先级线程的锁时,临时提升低优先级线程的优先级。


四、实践案例

4.1 基于 TAS 的自旋锁实现

使用 Test-and-Set 指令实现一个简单的自旋锁:

java
class TASLock {
  private AtomicBoolean locked = new AtomicBoolean(false);
  
  public void lock() {
    while (locked.getAndSet(true)) {
      // 自旋等待
    }
  }
  
  public void unlock() {
    locked.set(false);
  }
}

这个实现的问题是:在解锁时,所有自旋的线程同时尝试获取锁,导致大量的 CAS 失败和缓存行弹跳。改进方案是 Test-and-Test-and-Set(TTAS):在自旋时先读取(不修改),只有当锁看起来可用时才尝试获取。

4.2 ticket 锁的实现

ticket 锁是一种简单的 FIFO 锁,使用 Fetch-and-Add 实现:

java
class TicketLock {
  private AtomicInteger next = new AtomicInteger(0);
  private AtomicInteger now = new AtomicInteger(0);
  
  public int lock() {
    int myTurn = next.getAndIncrement();
    while (now.get() != myTurn) {
      // 自旋等待
    }
    return myTurn;
  }
  
  public void unlock(int myTurn) {
    now.set(myTurn + 1);
  }
}

ticket 锁保证了 FIFO 公平性:每个线程获取一个唯一的"号码",按号码顺序获取锁。但它的可伸缩性有限:解锁时 now 的更新会导致所有自旋线程的缓存行失效。


五、常见误区与注意事项

5.1 在临界区内执行耗时操作

临界区应该尽可能短。在临界区内执行 I/O 操作、网络请求或其他耗时操作会严重降低锁的吞吐量,因为其他线程必须等待。

5.2 忽视锁的粒度

锁的粒度(granularity)直接影响并发度。粗粒度锁(如整个数据结构一把锁)简单但并发度低;细粒度锁(如每个节点一把锁)并发度高但开销大且容易死锁。选择合适的粒度需要根据具体的访问模式来决定。

5.3 忘记释放锁

如果线程在临界区内抛出异常或提前返回,但忘记释放锁,会导致其他线程永远无法获取锁。使用 try-finally 模式或 RAII(Resource Acquisition Is Initialization)模式可以确保锁总是被释放。

5.4 在高争用场景使用简单自旋锁

在大量线程竞争同一把锁时,简单的 TAS 自旋锁会导致严重的性能问题。应该使用可伸缩性更好的锁实现(如队列锁)或考虑无锁算法。


六、本章小结

本章深入探讨了互斥与锁的实现原理。互斥问题要求满足安全性(互斥)和活性(无死锁、无饥饿)。从 Peterson 算法到 Lamport 面包店算法,展示了如何用纯软件方法解决互斥问题。硬件原子操作(TAS、CAS、FAA)大大简化了锁的实现。自旋锁适用于短临界区,阻塞锁适用于长临界区。锁的性能评估需要考虑吞吐量、公平性、可伸缩性和延迟。死锁和优先级反转是锁使用中的常见问题,需要通过锁排序、优先级继承等技术来避免。


七、思考题与练习

实现 Peterson 算法,并通过手动分析所有可能的线程交错来验证其正确性。

实现 Lamport 面包店算法,分析其时间复杂度和空间复杂度。

比较 TAS 锁和 TTAS 锁在不同争用程度下的性能差异。用实验验证你的分析。

实现一个基于 CAS 的自旋锁。它与基于 TAS 的锁有什么区别?

实现 ticket 锁和 MCS 锁,比较它们的公平性和可伸缩性。

设计一个场景来演示优先级反转问题,并实现优先级继承协议来解决它。

分析以下代码是否存在死锁风险,如果有,如何修复?

实现一个读写锁,支持多个读者同时获取锁,但写者独占。分析其在读多写少和写多读少场景下的性能。

3/10互斥与锁
0%