4多处理器编程艺术

无锁编程

超越锁的限制

无锁CAS原子操作

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

第四章 无锁编程 (Lock-Free Programming)

一、导读

1.1 本章学习目标

理解无锁(Lock-Free)和无等待(Wait-Free)编程的概念及其与基于锁的编程的区别

掌握 Compare-and-Swap(CAS)操作的原理及其在无锁算法中的核心作用

学会设计和分析简单的无锁数据结构(如无锁栈、无锁队列)

理解无锁编程中的内存管理问题(如 ABA 问题)及其解决方案

掌握无锁编程的正确性标准:线性一致性(Linearizability)

理解无锁编程的优势(避免死锁、优先级反转)和劣势(实现复杂、ABA 问题)

了解无锁编程在实际系统中的应用场景

1.2 为什么本章如此重要

基于锁的并发编程虽然直观,但存在几个根本性的问题:死锁(多个线程互相等待对方持有的锁)、优先级反转(高优先级线程被低优先级线程间接阻塞)、convoy 效应(大量线程排队等待一把锁)。这些问题不仅影响程序的正确性,还严重影响性能和可伸缩性。

无锁编程提供了一种根本性的替代方案:不使用锁,而是通过原子操作(如 CAS)来实现线程间的协调。无锁程序不会发生死锁(因为没有锁可以等待),不会发生优先级反转(因为没有线程被阻塞),也不会发生 convoy 效应(因为线程不需要排队等待)。

然而,无锁编程也带来了新的挑战:算法设计更加复杂,正确性证明更加困难,内存管理问题(如 ABA 问题)需要特殊处理。无锁编程不是万能的——在某些场景下,基于锁的方案可能更简单、更高效。

Herlihy 和 Shavit 在本章中系统地介绍了无锁编程的概念、技术和工具。他们从无等待和锁自由的定义出发,通过具体的数据结构实现来展示无锁编程的方法。这些内容对于理解现代并发数据结构的设计至关重要。

1.3 核心问题引导

什么是无锁(Lock-Free)和无等待(Wait-Free)?它们之间有什么区别?

CAS 操作如何实现?它在无锁算法中扮演什么角色?

如何实现一个无锁的栈或队列?关键的设计挑战是什么?

什么是 ABA 问题?如何解决它?

无锁编程一定比基于锁的编程更快吗?在什么场景下不是?

无锁编程的正确性如何验证?线性一致性意味着什么?


二、核心概念详解

2.1 无锁与无等待的定义

无锁(Lock-Free)和无等待(Wait-Free)是并发对象的两个重要属性,它们描述了系统在没有单个线程失败或暂停时仍能取得进展的能力。

一个并发对象是锁自由的(Lock-Free),如果无论各个线程的执行速度如何,总有一些线程能够在有限步内完成操作。换句话说,系统作为一个整体总是能取得进展,即使某些个别线程可能"饥饿"。

一个并发对象是无等待的(Wait-Free),如果每个线程都能够在有限步内完成操作,无论其他线程的执行速度如何。无等待是比锁自由更强的保证:它不仅要求系统整体能取得进展,还要求每个线程都能取得进展。

两者的关系是:无等待 ⟹ 锁自由 ⟹ 线程安全。无等待的对象一定是锁自由的,但锁自由的对象不一定是无等待的。在无锁(锁自由)的栈中,某些线程的操作可能因为不断重试而延迟,但总有一些线程能成功。在无等待的栈中,每个线程的操作都能在有限步内完成。

2.2 Compare-and-Swap(CAS)

Compare-and-Swap(CAS)是无锁编程中最核心的硬件原语。CAS(addr, expected, new) 原子地执行以下操作:读取 addr 处的值,如果等于 expected,则将 addr 处的值更新为 new;返回原来的值(或布尔值表示是否成功)。

CAS 的强大之处在于它提供了一种"乐观并发控制"的机制:线程先假设没有冲突,修改数据,然后通过 CAS 来检查是否真的有冲突。如果没有冲突(CAS 成功),修改生效;如果有冲突(CAS 失败),线程重试。

CAS 的典型使用模式是"读-改-写"循环:

do {
  old = *addr;
  new = modify(old);
} while (!CAS(addr, old, new));

CAS 在不同架构上有不同的名称:x86 上的 cmpxchg,Java 中的 AtomicReference.compareAndSet(),C++ 中的 atomic::compare_exchange_strong()。

CAS 的一个限制是它只能原子地操作一个内存位置。如果需要同时原子地更新多个位置,需要使用更复杂的机制(如事务内存或多字 CAS)。

2.3 无锁栈的实现

无锁栈(Lock-Free Stack)是无锁数据结构的经典案例。它使用 CAS 来实现无锁的 push 和 pop 操作。

栈使用链表实现,top 指针指向栈顶节点。

push 操作:

void push(T value) {
  Node* newNode = new Node(value);
  newNode->next = top.load();
  while (!top.compare_exchange_weak(newNode->next, newNode));
}

pop 操作:

T pop() {
  Node* oldTop = top.load();
  while (true) {
    if (oldTop == nullptr) throw EmptyException();
    Node* next = oldTop->next;
    if (top.compare_exchange_weak(oldTop, next)) {
      T value = oldTop->value;
      delete oldTop;
      return value;
    }
  }
}

这个实现的关键观察是:push 操作只需要修改 top 指针,而新节点的 next 在 CAS 失败时自动更新为最新的 top(这就是为什么 newNode->next 作为 expected 参数传入 CAS)。

这个简单实现存在一个问题:在 pop 操作中,读取 oldTop->next 和 CAS 之间,另一个线程可能已经 pop 了 oldTop 并将其释放(delete),导致 oldTop 成为悬空指针。这是无锁数据结构中的经典内存管理问题。

2.4 ABA 问题

ABA 问题是无锁编程中最棘手的挑战之一。它发生在 CAS 操作中:线程 1 读取地址的值为 A,准备将其更新为 B;在线程 1 执行 CAS 之前,线程 2 将值从 A 改为 B,然后又改回 A;线程 1 的 CAS 成功(因为值仍然是 A),但实际上值已经发生了变化。

在链表数据结构中,ABA 问题可能导致严重的错误。例如,在线程 1 读取 oldTop->next 和 CAS 之间,另一个线程可能 pop 了节点 A,push 了新的节点,然后又 push 了一个节点(其地址恰好是被释放的 A 的地址被重新分配)。线程 1 的 CAS 成功,但 oldTop->next 可能已经指向了无效的位置。

解决 ABA 问题的常见方法包括:

标记-清除(Tagged Pointer):在指针中附加一个版本号(tag),每次修改指针时递增版本号。CAS 同时比较指针和版本号,即使指针值回到 A,版本号也不同。

延迟释放(Hazard Pointer):使用 hazard pointer 来标记正在使用的节点,确保这些节点不会被其他线程释放。

无分配(Allocation-Free)设计:使用数组代替动态分配的节点,避免地址重用。

2.5 无锁队列的实现

Michael-Scott 队列是无锁队列的经典实现。它使用一个哑节点(dummy node)来简化边界条件的处理,维护 head 和 tail 两个指针。

入队操作:创建一个新节点,通过 CAS 将 tail->next 从 null 更新为新节点,然后通过 CAS 将 tail 更新为新节点。如果第一个 CAS 失败(说明另一个线程也在入队),重试。

出队操作:读取 head->next,通过 CAS 将 head 更新为 head->next。如果 CAS 失败,重试。

Michael-Scott 队列是锁自由的,但不是无等待的:在高争用下,某些线程可能因为 CAS 不断失败而长时间重试。但它保证了系统的整体进展——总有一些线程能成功完成操作。


三、深入分析与讨论

3.1 无锁 vs 有锁:性能对比

无锁编程并不总是比基于锁的编程更快。在低争用场景下,基于锁的方案可能更快,因为锁的获取(无争用时)只需要一次 CAS 操作,而无锁算法可能需要多次 CAS 重试。在高争用场景下,无锁方案的优势才明显——它避免了锁争用导致的线程阻塞和上下文切换。

无锁方案的另一个优势是可伸缩性:基于锁的方案在高线程数下性能可能急剧下降(所有线程竞争同一把锁),而无锁方案的性能下降通常更平缓。

但无锁方案的劣势是实现的复杂性:无锁算法需要处理重试逻辑、内存管理(ABA 问题)和内存序(Memory Ordering)等问题,代码复杂度远高于基于锁的方案。

3.2 内存管理在无锁编程中的挑战

在基于锁的编程中,内存管理相对简单:在持有锁时,可以安全地访问和释放数据结构中的节点。但在无锁编程中,一个线程可能在访问某个节点时,另一个线程已经释放了该节点。

Hazard Pointer 是一种优雅的解决方案:每个线程在访问一个节点之前,先将自己的 hazard pointer 指向该节点。释放节点时,先检查所有线程的 hazard pointer,如果没有任何线程指向该节点,才真正释放。这种方法的开销是 O(线程数) 的检查,但避免了 ABA 问题和悬空指针。

Epoch-Based Reclamation(EBR)是另一种方案:将时间划分为若干 epoch,节点在被标记为可回收后,等待两个 epoch 才真正释放(假设在两个 epoch 内,所有对旧节点的引用都已经消失)。EBR 的开销更低,但可能延迟释放。

3.3 无等待 universality

Herlihy 的无等待通用构造(Wait-Free Universality)是一个理论上的重要结果:任何可以被顺序实现的对象,都可以被无等待地实现。这个构造基于一致性数(Consensus Number)的概念:一个对象的一致性数是指它能解决一致性问题的最大线程数。

CAS 的一致性数为 2(可以解决 2 线程的一致性问题),因此基于 CAS 可以构建任意线程数的无等待对象(但效率可能不高)。这个理论结果保证了无等待编程的可行性,但实际的高效实现仍然需要针对具体问题设计专门的算法。


四、实践案例

4.1 无锁计数器

最简单的无锁数据结构:使用 CAS 实现的原子计数器。

java
class AtomicCounter {
  private AtomicInteger count = new AtomicInteger(0);
  
  public void increment() {
    count.incrementAndGet(); // 内部使用 CAS
  }
  
  public int get() {
    return count.get();
  }
}

Java 的 AtomicInteger.incrementAndGet() 在 x86 上编译为 lock xadd 指令,是真正的硬件原子操作。在高争用下,它的性能优于使用 synchronized 的计数器。

4.2 无锁链表集合

一个支持 add、contains 和 remove 操作的无锁有序链表集合。使用乐观排序策略:先不加锁地遍历链表找到目标位置,然后使用 CAS 来修改指针。

这种实现的挑战在于:遍历时节点可能被其他线程删除,需要处理悬空指针。使用 hazard pointer 或标记位(mark bit,在删除时先标记节点为"已删除",然后再安全地释放)可以解决这个问题。


五、常见误区与注意事项

5.1 无锁总是更快

无锁编程在低争用场景下可能比基于锁的方案更慢。CAS 重试循环在争用激烈时会消耗大量 CPU 时间。选择无锁还是基于锁应该根据具体的争用程度和性能需求来决定。

5.2 忽视内存序问题

CAS 操作有不同的内存序(Memory Ordering)选项。在弱内存模型的处理器(如 ARM)上,使用错误的内存序可能导致可见性问题。默认使用 seq_cst(顺序一致性)是最安全的选择,但可能影响性能。

5.3 忽视 ABA 问题

在链表等使用指针的无锁数据结构中,ABA 问题可能导致严重的数据损坏。必须使用 tagged pointer、hazard pointer 或其他机制来解决 ABA 问题。

5.4 无锁代码难以调试

无锁代码的 bug 通常是间歇性的,难以复现和调试。编写无锁代码时,应该通过形式化推理来验证正确性,并使用工具(如 ThreadSanitizer、Relacy)来检测数据竞争。


六、本章小结

本章介绍了无锁编程的概念、技术和挑战。无锁(锁自由)保证系统整体能取得进展,无等待保证每个线程都能取得进展。CAS 是无锁编程的核心硬件原语,通过"读-改-写"循环实现乐观并发控制。无锁栈和队列是无锁数据结构的经典案例,但面临 ABA 问题和内存管理的挑战。无锁编程的优势是避免死锁和优先级反转,劣势是实现复杂且在某些场景下性能不如基于锁的方案。


七、思考题与练习

实现一个基于 CAS 的无锁计数器,比较其与基于锁的计数器在不同争用程度下的性能。

分析无锁栈实现中的 ABA 问题场景,设计一个使用 tagged pointer 的解决方案。

实现 Michael-Scott 无锁队列,验证其锁自由属性。

证明:一个只使用读-写寄存器的对象不可能实现锁自由的共识(consensus)。

实现 hazard pointer 机制,用于安全地释放无锁数据结构中的节点。

比较无锁栈和基于锁的栈在不同线程数和争用程度下的吞吐量。

设计一个无锁的有序集合,支持 add、contains 和 remove 操作。

讨论:在什么场景下,无锁编程是不合适的?给出你的判断标准。

4/10无锁编程
0%