7并行计算机体系结构

同步机制

协调并行

锁信号量屏障原子操作

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

第7章 同步机制(Synchronization Mechanisms for Parallelism)

一、导读

7.1 本章学习目标

理解同步在并行计算中的核心作用,掌握为什么正确的同步是保证并行程序正确性的前提

深入掌握锁机制(自旋锁、互斥锁、读写锁)的工作原理、实现方式和适用场景

理解信号量(Semaphore)的设计思想和使用方法,能够使用信号量解决经典同步问题(生产者-消费者、读者-写者等)

掌握屏障(Barrier)同步的实现方法,理解集中式屏障和分布式屏障的性能差异

深入理解原子操作(Atomic Operations)的类型和实现机制,掌握比较并交换(CAS)、加载链接/存储条件(LL/SC)等关键指令

了解无锁(Lock-Free)和无等待(Wait-Free)数据结构的设计原理,理解其与基于锁的实现的差异

掌握死锁和活锁的产生条件和检测方法,理解死锁避免和死锁恢复的策略

能够根据具体应用场景选择最合适的同步机制,并在正确性和性能之间做出权衡

7.2 为什么本章如此重要

同步机制是并行编程的核心工具,它决定了多个线程或进程如何协调执行顺序以保证程序的正确性。没有正确的同步,并行程序将产生不可预测的结果:数据竞争导致计算错误,竞态条件导致间歇性故障,死锁导致程序永久挂起。可以说,同步机制的正确使用是并行编程中最基本也是最具挑战性的技能。

从理论角度看,同步机制的研究推动了计算机科学多个领域的发展。Dijkstra 在1965年提出的信号量概念开创了操作系统中的并发控制理论。Lamport 的 Bakery 算法展示了如何在没有硬件支持的情况下实现互斥。Herlihy 和 Wing 的无等待数据结构理论为高性能并发编程提供了理论基础。这些理论成果不仅适用于并行计算,也深刻影响了分布式系统、数据库和操作系统的设计。

从实践角度看,同步机制的选择直接影响并行程序的性能。过度同步导致线程等待和并行度降低,同步不足导致数据错误。锁竞争(Lock Contention)是并行程序中最常见的性能瓶颈之一。理解各种同步机制的性能特征,能够帮助程序员选择最优的同步策略。

此外,现代处理器和编程语言提供了丰富的同步原语,从硬件级的原子指令到语言级的内存序选项。正确使用这些工具需要深入理解底层机制。错误的同步使用不仅导致正确性问题,还可能产生难以调试的性能问题和可移植性问题。

7.3 核心问题引导

为什么并行程序需要同步?如果不使用任何同步机制,多线程程序可能出现什么问题?请举出具体的例子。

自旋锁和互斥锁有什么区别?在什么场景下应该使用自旋锁?什么场景下应该使用互斥锁?

读写锁在什么场景下比互斥锁更有优势?读写锁的实现需要考虑哪些公平性问题?

信号量如何实现生产者-消费者问题的同步?如果信号量的初始值设置错误,会导致什么问题?

屏障同步在迭代算法中如何使用?集中式屏障和分布式屏障的性能差异是什么?

CAS(Compare-And-Swap)指令如何实现无锁数据结构?CAS 的 ABA 问题是什么?如何解决?

死锁的四个必要条件是什么?如何通过破坏其中一个条件来避免死锁?

无锁数据结构一定比基于锁的数据结构快吗?在什么场景下无锁实现反而更慢?


二、核心概念详解

7.1 锁机制(约800字)

锁(Lock)是最基本的同步原语,用于保护临界区(Critical Section)免受多个线程的并发访问。当一个线程持有锁时,其他试图获取同一把锁的线程必须等待,直到锁被释放。锁保证了在任意时刻最多只有一个线程执行临界区代码,从而避免了数据竞争。

自旋锁(Spinlock)是最简单的锁实现。线程在尝试获取锁时,如果锁已被占用,就在一个循环中不断检查(自旋)直到锁可用。自旋锁的优点是实现简单、上下文切换开销小(线程不需要被挂起和唤醒)。缺点是自旋过程消耗 CPU 资源,如果锁被长时间持有,自旋的线程浪费了大量 CPU 周期。自旋锁适合临界区很短(几微秒以内)且线程不会被抢占的场景。

自旋锁的优化版本包括测试并设置(Test-and-Set, TAS)锁、测试并设置带回退(Test-and-Test-and-Set, TTAS)锁和票据锁(Ticket Lock)。TAS 锁使用原子的 Test-and-Set 指令,简单但在高竞争下产生大量缓存一致性流量。TTAS 锁先普通读取锁状态,只有在锁可能可用时才执行原子操作,减少了缓存一致性流量。票据锁为每个等待的线程分配一个编号,保证先到先服务的公平性。

互斥锁(Mutex)在锁被占用时将请求线程挂起(放入等待队列),线程让出 CPU 直到被唤醒。互斥锁不消耗 CPU 资源等待,但线程的挂起和唤醒涉及操作系统内核的上下文切换,开销较大(约数微秒)。互斥锁适合临界区较长或锁竞争激烈的场景。

读写锁(Read-Write Lock,也称共享-排他锁)区分读操作和写操作。多个读线程可以同时持有读锁,但写锁是排他的。读写锁在读多写少的场景下比互斥锁性能更好,因为读操作可以并发执行。读写锁的实现需要考虑公平性:如何防止写线程被持续的读线程饿死(Writer Starvation)。常见的策略是当有写线程等待时,阻止新的读线程获取锁。

锁的性能指标包括 uncontended 获取/释放延迟(没有竞争时的开销)、contended 获取延迟(有竞争时的开销)和可伸缩性(随线程数量增加的性能变化)。选择锁类型时需要根据临界区长度、竞争程度和读/写比例综合考虑。

7.2 信号量(约650字)

信号量(Semaphore)由 Dijkstra 于1965年提出,是一种通用的同步机制。信号量维护一个整数计数器和一个等待线程队列。它支持两个原子操作:P(Proberen/Wait/Down)和 V(Verhogen/Signal/Up)。

P 操作将计数器减 1。如果结果小于 0,请求线程被放入等待队列并挂起。V 操作将计数器加 1。如果有线程在等待队列中,唤醒一个等待线程。

计数信号量(Counting Semaphore)的计数器可以是任意非负整数,用于控制同时访问某个资源的线程数量。例如,一个初始值为 N 的信号量可以控制最多 N 个线程同时访问一个资源池。

二值信号量(Binary Semaphore)的计数器只能是 0 或 1,功能类似于互斥锁。但信号量和互斥锁有一个重要区别:信号量没有所有权概念,任何线程都可以执行 V 操作释放信号量,而互斥锁只能由持有者释放。

信号量可以解决多种经典同步问题。生产者-消费者问题使用一个满信号量(初始值 0)和一个空信号量(初始值缓冲区大小)。生产者在放入数据后 V(满),消费者在取出数据后 V(空)。生产者取出数据前 P(空),消费者取出数据前 P(满)。

读者-写者问题使用一个互斥锁和一个读计数器。读线程获取锁后递增计数器,如果是第一个读者则获取写锁。读线程释放锁后递减计数器,如果是最后一个读者则释放写锁。写线程直接获取写锁。

信号量的实现需要原子操作的支持。P 和 V 操作必须是原子的,通常使用 CAS 或硬件提供的原子指令实现。信号量的等待队列需要额外的锁保护,增加了实现的复杂度。

7.3 屏障同步(约600字)

屏障(Barrier)是一种同步原语,确保所有参与的线程都到达屏障点后才能继续执行。屏障在迭代算法中广泛使用:每次迭代结束时,所有线程必须等待彼此完成当前迭代,然后一起进入下一次迭代。

屏障的基本操作是 wait(或 arrive)。每个线程调用 wait 时被阻塞,直到所有线程都调用了 wait。然后所有线程同时被释放,继续执行。

集中式屏障使用一个计数器和一个锁实现。每个线程到达时获取锁,将计数器加 1,释放锁。如果计数器达到 N(线程总数),重置计数器并唤醒所有等待线程。否则,线程在等待队列中休眠。集中式屏障的缺点是中心计数器成为瓶颈,所有线程都需要竞争同一把锁。

分布式屏障(如 Butterfly Barrier 和 Tree Barrier)通过层次化结构减少竞争。在 Tree Barrier 中,线程组织成树形结构。叶子线程先到达局部屏障,然后向父节点报告。父节点等待所有子节点报告后,再向上一级报告。到达根节点后,释放信号沿树向下传播。Tree Barrier 的延迟为 O(log N),远优于集中式屏障的 O(N)。

MCS 屏障(Mellor-Crummer Scott Barrier)使用队列化的方式实现屏障,保证先到先服务的公平性,避免了竞争导致的缓存一致性流量。

屏障的性能指标包括无竞争延迟、可伸缩性和公平性。在选择屏障实现时,需要考虑线程数量、到达时间的分散程度和屏障的使用频率。

7.4 原子操作(约700字)

原子操作(Atomic Operation)是不可中断的操作,在执行过程中不会被其他线程观察到中间状态。原子操作是实现无锁数据结构和高效同步原语的基础。

硬件提供的原子操作包括以下几种。Read-Modify-Write(RMW)操作是一类通用的原子操作,包括原子加(Atomic Add)、原子减、原子与/或/异或等。这些操作在单个内存事务中完成读取、修改和写入,保证原子性。

比较并交换(Compare-And-Swap, CAS)是最重要的原子操作之一。CAS(addr, expected, desired) 原子地执行以下操作:如果 addr 处的值等于 expected,则将其设为 desired 并返回 true;否则不做修改并返回 false。CAS 是实现无锁算法的核心工具,许多复杂的无锁数据结构都基于 CAS 构建。

加载链接/存储条件(Load-Link/Store-Conditional, LL/SC)是另一种原子操作对。LL 加载一个值并标记该地址。SC 尝试存储一个新值,只有当该地址自 LL 以来没有被修改过时才成功。LL/SC 比 CAS 更通用,因为它可以检测任何修改(而 CAS 只检查特定的期望值),但实现更复杂。ARM 和 RISC-V 使用 LL/SC,x86 使用 CAS。

原子操作的实现涉及缓存一致性协议。在支持缓存一致性的系统上,原子操作通过将缓存行设置为独占模式来实现。在 x86 上,带 lock 前缀的指令会锁定缓存行,阻止其他核心的访问。原子操作的性能取决于竞争程度:无竞争时只需一次缓存一致性事务,有竞争时可能需要多次重试。

原子操作的内存序(Memory Order)是一个重要但常被忽视的问题。原子操作可以指定不同的内存序:seq_cst(全序)、acquire(获取语义)、release(释放语义)、relaxed(无顺序约束)。不同的内存序在性能和正确性之间有不同的权衡。使用 relaxed 内存序的原子操作性能最好,但需要程序员手动保证操作顺序。

7.5 无锁编程(约550字)

无锁(Lock-Free)编程是一种不使用互斥锁实现并发数据结构的方法。无锁算法保证在任意时刻,至少有一个线程能够取得进展(即使其他线程被挂起或延迟)。无等待(Wait-Free)是更强的保证:每个线程在有限步内一定能完成操作。

无锁算法的核心思想是使用原子操作(特别是 CAS)来替代锁。以无锁栈的 push 操作为例:线程创建新节点,将新节点的 next 指向当前栈顶,然后使用 CAS 将栈顶指针从旧值更新为新节点。如果 CAS 失败(说明其他线程修改了栈顶),重试整个过程。

无锁算法的优势在于避免了锁的开销(获取/释放锁的延迟、锁竞争导致的等待)和死锁的可能性。在高竞争场景下,无锁算法通常比基于锁的实现性能更好。

然而,无锁算法也有其局限性。首先,无锁算法的设计复杂度远高于基于锁的实现,正确性证明也更加困难。其次,CAS 的重试循环在高竞争下可能消耗大量 CPU 资源(活锁问题)。第三,某些数据结构(如优先队列)的无锁实现非常复杂,甚至不存在已知的无等待方案。

无锁算法的另一个挑战是内存管理。在无锁数据结构中,一个线程可能在读取某个节点时,另一个线程已经释放了该节点。这需要特殊的内存管理技术,如危险指针(Hazard Pointers)或基于引用计数的内存回收。


三、深入分析与讨论

7.1 死锁的分析与避免(约550字)

死锁(Deadlock)是多个线程相互等待对方持有的资源,导致所有线程都无法继续执行的状态。死锁是并行程序中最严重的问题之一,因为它导致程序永久挂起,且通常难以检测和调试。

死锁的产生需要同时满足四个必要条件(Coffman 条件):互斥(Mutual Exclusion,资源一次只能被一个线程持有)、持有并等待(Hold and Wait,线程持有至少一个资源同时等待获取其他资源)、不可抢占(No Preemption,资源只能由持有者主动释放)和循环等待(Circular Wait,存在线程之间的循环等待链)。

避免死锁的策略是破坏四个条件中的至少一个。破坏"持有并等待":要求线程在开始执行前一次性获取所有需要的资源。这种方法实现简单但资源利用率低。破坏"不可抢占":允许系统强制回收线程持有的资源。这种方法在锁的场景中不太实用。破坏"循环等待":对所有资源编号,要求线程按编号递增的顺序获取资源。这种方法在实践中最常用。

死锁检测是另一种策略:允许死锁发生,但定期检测并恢复。检测方法包括构建资源分配图并查找环路。恢复方法包括终止参与死锁的线程或抢占资源。

活锁(Livelock)与死锁不同:线程没有被阻塞,但由于不断重试而始终无法取得进展。例如,两个线程同时尝试获取同一把锁,每次都在对方获取前放弃并重试,形成无限循环。活锁的解决方法是引入随机退避(Random Backoff)或公平排队机制。

7.2 同步机制的性能权衡(约500字)

同步机制的选择涉及正确性和性能之间的权衡。过度同步浪费并行度,同步不足导致错误。理解各种同步机制的性能特征至关重要。

锁竞争是性能的主要来源。当多个线程竞争同一把锁时,失败线程的等待时间随竞争线程数量线性增长。在高竞争场景下,锁可能成为严重的性能瓶颈。减少锁竞争的策略包括:减小临界区范围、使用细粒度锁(如为每个数据结构元素配备独立的锁)、使用锁分离(Lock Striping,将一把锁的功能分散到多把锁上)和读写锁(在读多写少场景下允许并发读)。

同步机制的开销可分为固定开销和可变开销。固定开销包括获取/释放锁的指令开销(无竞争时约 20-50 个周期)和上下文切换开销(有竞争时约数微秒)。可变开销包括等待时间和缓存一致性流量。

不同同步机制的适用场景不同。自旋锁适合短临界区和内核态代码。互斥锁适合长临界区和用户态代码。读写锁适合读多写少的场景。信号量适合控制资源访问数量。屏障适合同步迭代。原子操作适合简单的计数和标志位操作。无锁算法适合高竞争且数据结构简单的场景。

7.3 现代硬件对同步的支持(约450字)

现代处理器提供了丰富的硬件支持来加速同步操作。x86 的 lock 前缀可以将任何读-修改-写指令变为原子操作。ARM 的 LDAXR/STLXR 指令对提供了 LL/SC 功能,同时支持 acquire/release 语义。RISC-V 的 AMO(Atomic Memory Operations)指令集提供了多种原子操作。

事务内存(Transactional Memory, TM)是一种更高层次的同步抽象。程序员标记代码块为事务,硬件自动保证事务的原子性和隔离性。如果事务冲突,硬件自动回滚并重试。Intel 的 TSX(Transactional Synchronization Extensions)是事务内存的商业实现。事务内存简化了编程,但受限于事务大小和冲突率。

硬件辅助的锁加速技术包括锁弹性(Lock Elision,当没有竞争时跳过锁操作)和 speculative lock 优化。这些技术使得现有的基于锁的程序可以在不修改代码的情况下获得性能提升。


四、实践案例

4.1 案例一:自旋锁与互斥锁的性能对比(约400字)

在一个 64 核系统上测试自旋锁和互斥锁在不同临界区长度下的性能。128 个线程同时竞争同一把锁,每次获取锁后执行不同长度的临界区代码。

当临界区为 10 条指令(约 5 纳秒)时,自旋锁的吞吐量为每秒 4500 万次锁操作,互斥锁为每秒 800 万次。自旋锁性能约为互斥锁的 5.6 倍,因为互斥锁的上下文切换开销(约 2 微秒)远大于临界区执行时间。

当临界区为 1000 条指令(约 500 纳秒)时,自旋锁吞吐量为每秒 1200 万次,互斥锁为每秒 1000 万次。两者性能接近,因为临界区执行时间已远大于上下文切换开销。

当临界区为 10000 条指令(约 5 微秒)时,自旋锁吞吐量为每秒 200 万次,互斥锁为每秒 1000 万次。互斥锁性能反超,因为自旋锁在长临界区期间浪费了大量 CPU 资源自旋等待。

结论:临界区短于 1 微秒时使用自旋锁,长于 1 微秒时使用互斥锁。这是选择锁类型的基本经验法则。

4.2 案例二:无锁队列的实现与分析(约400字)

使用 CAS 实现一个 Michael-Scott 无锁队列。队列使用链表结构,维护 head 和 tail 两个指针,以及一个哨兵节点。

入队操作:创建新节点,使用 CAS 更新 tail->next 从 null 到新节点。如果 CAS 成功,再使用 CAS 将 tail 移动到新节点。如果 CAS 失败,重试。

出队操作:读取 head->next,如果为 null 则队列为空。否则使用 CAS 将 head 从当前值更新为 head->next。如果 CAS 成功,返回 head->next 的数据。如果失败,重试。

性能测试:在 32 核系统上,32 个线程同时进行入队和出队操作。基于互斥锁的队列吞吐量为每秒 300 万次操作。无锁队列吞吐量为每秒 1800 万次操作,是锁版本的 6 倍。

无锁队列的优势在于避免了锁竞争。每个线程独立尝试 CAS,成功者继续,失败者重试。在高竞争下,虽然重试次数增加,但不会导致线程挂起和唤醒的开销。


五、常见误区与注意事项

误区:使用锁就一定能保证线程安全。锁只保护被锁覆盖的临界区。如果某些共享数据的访问没有被锁保护,仍然会出现数据竞争。此外,锁的粒度不当(过大或过小)都可能导致问题。需要仔细分析所有共享数据的访问路径。

误区:无锁算法一定比加锁算法快。无锁算法在高竞争、短操作场景下通常更快,但在低竞争场景下,锁的开销可能低于 CAS 重试的开销。此外,无锁算法的设计复杂度和验证难度远高于加锁实现。不要为了"无锁"而"无锁"。

误区:死锁只会发生在多锁场景。单锁也可能导致死锁,例如递归获取同一把不可重入的锁。此外,锁与信号量、条件变量等不同步原语的组合使用也可能导致死锁。

误区:原子操作不需要内存屏障。原子操作保证单个操作的原子性,但不保证多个操作之间的顺序。如果多个原子操作之间存在顺序依赖,需要使用适当的内存序(如 acquire/release)来保证。

注意事项:信号量的 P/V 操作顺序至关重要。在生产者-消费者问题中,先 P(空) 再 P(互斥) 是正确的,反过来可能导致死锁。信号量操作的顺序是并发编程中最容易出错的地方之一。


六、本章小结

本章系统讲解了并行计算中的同步机制。锁是最基本的同步原语,自旋锁适合短临界区,互斥锁适合长临界区,读写锁在读多写少场景下性能更优。信号量是通用的同步机制,可以解决生产者-消费者、读者-写者等经典问题。屏障同步确保所有线程到达同步点后才能继续,分布式屏障比集中式屏障具有更好的可扩展性。原子操作是不可中断的硬件级操作,CAS 和 LL/SC 是实现无锁算法的基础。无锁编程避免了锁的开销和死锁问题,但设计复杂且并非在所有场景下都更快。死锁的四个必要条件是互斥、持有并等待、不可抢占和循环等待,通过破坏其中任一条件可以避免死锁。选择合适的同步机制需要综合考虑正确性、性能、复杂度和可维护性。


七、思考题与练习

实现一个自旋锁(使用 C++ 的 std::atomic),并实现 TTAS 优化版本。在多线程环境下测试两种实现的性能差异,分析缓存一致性流量对性能的影响。

使用信号量解决生产者-消费者问题。编写代码实现,并分析如果 P 操作的顺序颠倒(先 P(互斥) 再 P(空))会发生什么。

实现一个 Tree Barrier,分析其时间复杂度。与集中式屏障相比,在 64 线程场景下的性能差异是多少?

使用 CAS 实现一个无锁的单向链表(支持插入和删除操作)。讨论 ABA 问题在你的实现中是否会出现,以及如何解决。

分析以下场景中的死锁风险:线程 A 持有锁 L1 等待锁 L2,线程 B 持有锁 L2 等待锁 L1。提出至少两种避免该死锁的方法。

比较 x86 的 CAS 指令和 ARM 的 LL/SC 指令在实现无锁算法时的差异。为什么 ARM 需要 LL/SC 而不是 CAS?LL/SC 的 spurious failure 是什么意思?

设计一个读写锁的实现方案。如何处理写者饿死问题?如何在读线程和写线程之间实现公平性?

研究一种实际的无锁数据结构(如无锁哈希表、无锁队列或无锁跳表),分析其设计原理和性能特征。与基于锁的实现相比,在什么场景下无锁版本更有优势?

7/7同步机制
0%