ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入解析原子操作:从TAS、TTAS到CAS、FAA的原理与应用

深入解析原子操作:从TAS、TTAS到CAS、FAA的原理与应用 1. 从一把锁的困惑说起为什么需要原子操作最近在排查一个线上服务的性能问题时遇到了一个典型的并发计数场景。需求很简单一个全局的计数器多个线程会频繁地对其进行加一操作。一开始我图省事直接用了synchronized关键字来保护这个计数器。上线后在低并发下一切正常但随着流量上来这个服务的吞吐量直线下降RT响应时间却飙升。用性能分析工具一看好家伙大量的线程都阻塞在等待这把“锁”上CPU并没有打满但线程上下文切换的开销却大得惊人。这个场景让我重新审视了“锁”这把武器。锁如synchronized或ReentrantLock提供了一种强互斥的保障它简单、安全但代价也高当一个线程持有锁时其他所有试图获取同一把锁的线程都会被挂起进入阻塞状态等待操作系统调度唤醒。这个“挂起-唤醒”的过程涉及到用户态到内核态的切换开销巨大。对于我那个只是“加一”的简单操作来说用一把大锁无异于用高射炮打蚊子绝大部分时间都浪费在了排队和调度上而不是实际的计算上。那么有没有一种更轻量级的机制能让多个线程安全地操作一个共享变量又不会引入阻塞和上下文切换的开销呢答案就是原子操作。原子操作的核心思想是“无锁”Lock-Free它利用现代CPU提供的特殊指令保证一个或一系列操作在执行过程中不会被其他线程打断从而在多线程环境下实现安全访问。今天我们就来深入聊聊几种经典的原子操作原语TAS、TTAS、CAS和FAA。理解它们不仅是应对面试更是写出高性能并发代码的基石。2. 硬件基石CPU如何支持原子性在深入软件层面的原子操作之前我们必须先了解硬件提供了什么。原子性并非凭空而来它最终依赖于CPU指令集的支持。如果没有硬件的保证我们在软件层面设计的任何“原子”逻辑都可能被线程切换打断。现代多核CPU主要提供了几种关键的机制来支持原子操作1. 总线锁定这是最原始、最粗暴的方式。早期CPU通过芯片组的一条引线LOCK#发出信号当某个核心执行带有LOCK前缀的指令时它会通知内存控制器在指令执行期间“锁住”整个系统总线或特定的内存区域禁止其他核心或DMA控制器访问。这就好比为了修改图书馆里的一本书而把整个图书馆的大门给锁了其他人都进不来。这种方式能保证原子性但代价是严重的性能损耗因为它阻塞了所有其他核心对内存的访问。2. 缓存一致性协议与缓存行锁定现代CPU架构普遍采用了更精细的MESIModified, Exclusive, Shared, Invalid或其变种缓存一致性协议。每个核心有自己的高速缓存L1/L2内存中的数据以“缓存行”通常64字节为单位在核心间同步。当核心需要原子地修改某个内存位置时它不再锁定整个总线而是利用缓存一致性协议。核心会先以“独占”模式获取包含目标内存地址的整个缓存行。在独占状态下其他核心的缓存中该行的副本会失效。然后核心在自己的缓存中完成修改。由于缓存行是协议同步的最小单位且独占状态保证了修改过程的排他性从而实现了原子性。这就像只锁住了图书馆里存放那本书的那个书架而不是整个图书馆效率高得多。3. 特定的原子指令CPU指令集直接提供了一些读-修改-写Read-Modify-Write原子指令。这些指令在硬件层面被设计为不可分割的操作。常见的包括CMPXCHG(Compare-and-Swap): x86架构的CAS指令。LOCK INC/LOCK XADD: 带锁前缀的增量、交换加指令可用于实现FAA。LL/SC(Load-Linked / Store-Conditional): 在ARM、PowerPC、MIPS等RISC架构上常见的原子操作原语对。LL标记一个内存地址SC尝试写入但仅当该地址自LL之后未被其他线程修改过时才成功。有了这些硬件基础操作系统和编程语言运行时库如JVM、Glibc才能封装出我们常用的原子操作API例如Java中的java.util.concurrent.atomic包或者C中的std::atomic。注意我们常说的“CPU指令是原子的”通常指的是指令本身的执行不会被中断如单条INC指令在单核时代是原子的。但在多核时代即使单条指令如果涉及内存访问也需要上述的缓存一致性协议或总线锁定来保证在多核视角下的原子性。所以在并发编程中谈论原子性必须考虑多核并发访问的场景。3. TAS最基础的原子“试探”TAS全称Test-and-Set可以理解为原子操作家族里的“老祖宗”。它的语义非常简单检查某个内存位置的值如果它是0或某个预期值就把它设置为1或一个新值并返回操作前的旧值。整个过程必须是原子的。我们可以用一个布尔变量lock来模拟一个自旋锁// 伪代码描述TAS指令的行为 int TestAndSet(int *lock) { int old_value *lock; // 读取旧值 *lock 1; // 无条件设置为1上锁 return old_value; // 返回旧值 }关键点在于读取-判断-设置这三个步骤在CPU硬件层面是一条不可分割的指令完成的。如何使用TAS实现一个自旋锁// 一个基于TAS思想的自旋锁简化示例 public class TASSpinLock { private volatile int lock 0; // 0表示锁空闲1表示锁被占用 public void lock() { // 循环调用TAS这里用CAS模拟TAS行为如果lock是0就原子地设为1 while (compareAndSet(0, 1) ! true) { // 自旋等待什么也不做或者可以加入Thread.yield()让出CPU } // 成功将0设置为1表示获取到了锁 } public void unlock() { lock 0; // 释放锁这里需要保证对其他线程立即可见所以lock变量需要用volatile修饰 } // 模拟CAS操作实际中由Unsafe类或CPU指令实现 private boolean compareAndSet(int expect, int update) { // 原子操作如果lock当前值等于expect则设置为update返回true否则返回false // 这是一个简化示意真实CAS包含内存屏障保证可见性和有序性 } }当一个线程调用lock()时它会在一个循环里不断地尝试执行TAS操作检查lock是否为0如果是就原子地把它变成1并成功获得锁如果不是说明锁已被其他线程占用它就继续循环“自旋”等待。TAS的致命缺陷总线风暴与可扩展性灾难TAS的实现虽然简单但其性能在多核系统上非常糟糕尤其是在锁竞争激烈时。问题就出在它的“无条件写”上。每次执行TAS指令无论当前锁是否空闲它都会无条件地向内存实际上是缓存行发起一个写操作。根据我们前面讲的缓存一致性协议MESI一个核心的写操作会导致其他所有核心缓存中对应的缓存行副本失效。当下一个线程再来尝试获取锁时它必须从主内存或另一个核心的缓存中重新加载这个已经失效的缓存行。想象一下有10个线程在激烈竞争一把锁。每个线程都在循环执行TAS每一次TAS操作都会导致一次全局的缓存行失效和同步。这会产生巨大的总线通信流量就像所有核心在总线上“吵架”一样这种现象被称为“总线风暴”。大量的系统资源被浪费在了缓存一致性维护上而不是有用的计算上导致系统的可扩展性随着核心数增加而急剧下降。因此纯TAS自旋锁在实际的高性能并发编程中几乎不会被直接使用它更多是作为一种理解原子操作和自旋锁原理的教学模型。4. TTAS一次重要的性能优化为了克服TAS带来的总线风暴问题人们提出了TTAS全称Test-and-Test-and-Set。这个名字很直观先测试Test再测试并设置Test-and-Set。它的核心改进在于在尝试进行昂贵的原子写操作TAS之前先进行一次普通的、非原子的读操作来检查锁的状态。只有读操作发现锁可能空闲时才去执行原子操作。TTAS自旋锁的工作流程public class TTASSpinLock { private volatile int lock 0; public void lock() { while (true) { // 第一阶段本地自旋读取Test while (lock 1) { // 锁被占用继续本地循环读取这是一个纯读操作 // 可以加入一些优化如短暂暂停pause指令以减少总线压力 } // 第二阶段尝试获取锁Test-and-Set if (compareAndSet(0, 1)) { break; // 成功获取锁 } // CAS失败说明在“读”和“写”之间锁被其他线程抢走了回到第一阶段继续 } } public void unlock() { lock 0; } }TTAS为何比TAS好关键在于第一阶段的自旋是本地读取。线程在while (lock 1)这个循环里反复读取的是自己CPU缓存中的lock变量副本。只要锁没有被释放即没有其他线程执行unlock()写入0这个值就一直会是1读取操作不会触发缓存一致性协议不会产生总线流量。所有等待的线程都在自己的缓存里安静地“空转”对系统总线几乎没有压力。只有当持有锁的线程调用unlock()将lock写为0时这个写操作会使其他所有核心缓存中的该缓存行失效。等待的线程会发现本地缓存失效于是从主存或持有最新数据值为0的核心缓存中重新加载。此时所有等待线程的本地读循环条件lock 1不再成立它们会跳出第一阶段的循环进入第二阶段开始竞争执行CAS操作。TTAS的局限性释放锁时的“惊群效应”TTAS大大减少了竞争时的总线流量但它并非完美。当锁被释放lock从1变为0的瞬间所有在本地自旋等待的线程几乎同时检测到缓存行失效然后同时去争夺执行CAS操作。这会导致一瞬间的总线流量激增和激烈的竞争被称为“惊群效应”Thundering Herd Problem。虽然这比TAS持续性的总线风暴要好得多但在线程数非常多的情况下这瞬间的竞争依然可能成为瓶颈。TTAS是实践中常用的自旋锁优化基础后来的许多高级锁如排队自旋锁、CLH锁、MCS锁都是为了进一步解决公平性和惊群效应而设计的。5. CAS无锁编程的基石CAS全称Compare-and-Swap可能是并发编程中最著名、应用最广泛的原子操作。它的语义比TAS更通用比较并交换。CAS操作接受三个参数内存位置V期望值A新值B它的执行是原子的它先比较内存位置V的当前值是否等于期望值A。如果相等处理器会自动将该位置值更新为新值B并返回true或旧值。如果不相等说明在此期间V已经被其他线程修改过了则不做任何操作返回false或当前值。无论哪种情况它都会返回V当前的值。CAS的典型应用实现无锁计数器我们开篇提到的那个计数器问题用CAS可以优雅解决import java.util.concurrent.atomic.AtomicInteger; public class CASCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { int oldValue; int newValue; do { oldValue count.get(); // 读取当前值期望值A newValue oldValue 1; // 计算新值B } while (!count.compareAndSet(oldValue, newValue)); // CAS操作如果当前值还是oldValue就设置为newValue // 如果CAS失败说明有其他线程修改了count循环重试 } public int getCount() { return count.get(); } }在这个increment方法中线程不需要阻塞。它在一个循环里读取当前值计算加一后的新值然后尝试用CAS原子地更新。如果更新成功方法结束如果失败意味着在“读”和“写”之间值被其他线程改了它就重新读取最新值再次计算并尝试CAS直到成功为止。这种模式被称为“乐观锁”或“无锁循环”。CAS的“ABA”问题CAS操作有一个经典的风险ABA问题。线程1读取变量V值为A。线程1被挂起。线程2将V从A改为B。线程3或线程2自己又将V从B改回A。线程1恢复执行CAS(V, A, X)。此时它发现V的值确实是A于是CAS成功将V更新为X。对于线程1的CAS操作来说它“感觉”变量没有被改变过。但在某些场景下这种“A-B-A”的变化可能是重要的。例如如果V是一个链表的头指针A指向节点Node1。线程1想将头指针从A改为X。在线程1挂起期间线程2移除了Node1头指针变为B然后又将Node1重新插入头指针变回A但Node1的next指针可能已经变了。线程1恢复后CAS成功但此时链表的状态可能已经不一致了。ABA问题的解决方案版本号/时间戳最常见的解决方案。不直接比较值而是为每次修改增加一个版本号。Java中的AtomicStampedReference和AtomicMarkableReference就是为此设计的。AtomicStampedReferenceInteger atomicStampedRef new AtomicStampedReference(100, 0); int[] stampHolder new int[1]; int oldRef atomicStampedRef.get(stampHolder); // 同时获取引用和版本戳 int oldStamp stampHolder[0]; // 尝试更新同时检查引用值和版本戳 boolean success atomicStampedRef.compareAndSet(oldRef, newValue, oldStamp, oldStamp 1);使用具有唯一性的对象对于指针或引用确保被修改的对象是“唯一的”一旦被修改过就不会再被复用例如在无锁链表中每次修改都创建新节点。尽管有ABA问题CAS因其通用性和高性能仍然是构建无锁数据结构如无锁队列、无锁栈、实现原子类AtomicInteger等以及众多并发工具如ReentrantLock内部基于AQSAQS大量使用CAS的核心技术。6. FAA专为计数而生的原子指令FAA全称Fetch-and-Add有时也叫Fetch-and-Increment。它的语义非常专一原子地获取一个内存位置的当前值并将其增加一个指定的量通常是1然后返回该内存位置原来的值。它的操作也是原子的但逻辑比CAS更简单直接old *ptr; *ptr *ptr delta; return old;。FAA实现计数器简洁高效用FAA来实现我们开篇的计数器代码将异常简洁// 伪代码展示FAA语义 public class FAACounter { private int count 0; public void increment() { // 一条原子指令获取count的当前值并将其加1返回旧值。 // 我们这里不关心返回值只关心加1这个操作完成了。 fetchAndAdd(count, 1); } }在Java中AtomicInteger的incrementAndGet()、getAndIncrement()等方法在底层很可能就是利用CPU的FAA类指令如x86的LOCK XADD实现的或者用循环CAS实现其语义。FAA vs CAS 在计数器场景下的对比对于简单的原子递增/递减操作FAA相比CAS有显著优势指令更少语义更直接FAA是一条指令完成“读-改-写”而CAS循环在竞争激烈时可能需要多次重试读-计算-比较-写指令更多。避免循环开销FAA总是成功从指令层面没有“失败-重试”的循环。而CAS在竞争下需要循环可能浪费CPU周期在无用的计算和比较上。硬件优化CPU可以对FAA这类固定模式的原子指令进行深度优化。因此对于纯粹的计数器场景FAA是比CAS更优的选择。这也是为什么Java的AtomicInteger的incrementAndGet()性能通常优于我们自己用compareAndSet实现的循环。当然FAA的局限性在于它的功能比较单一主要用于加减操作。而CAS的通用性更强可以用于实现各种复杂的无锁更新逻辑。7. 实践中的选择与陷阱理解了这些原子操作的原语我们在实际开发中该如何选择和使用呢1. 优先使用高级抽象而非直接操作原语除非你在编写极底层的系统库如JVM、操作系统内核或高性能中间件否则绝大多数情况下你应该使用编程语言提供的线程安全的高级抽象。Java优先使用java.util.concurrent.atomic包下的类AtomicInteger,AtomicReference,LongAdder等以及ConcurrentHashMap,CopyOnWriteArrayList等并发容器。LongAdder在高并发统计场景下比AtomicLong性能更好因为它采用了分段累加的思想减少了CAS竞争。C使用std::atomic模板类。Go使用sync/atomic包。这些库已经用最优的方式可能是CAS、FAA或平台特定的内联汇编实现了原子操作并处理了内存顺序等复杂问题。2. 理解“无锁”不等于“更快”无锁编程Lock-Free通过CAS等操作避免了线程阻塞减少了上下文切换在低到中度竞争下通常能提供比锁更好的性能。但是在极高竞争下CAS的重试循环可能导致大量的CPU空转忙等待性能可能反而不如一个设计良好的、会让线程适当挂起的阻塞锁如ReentrantLock。3. 关注内存顺序与可见性原子操作不仅仅是关于操作的原子性还关乎内存可见性和指令重排序。这就是volatile关键字和std::memory_order所解决的问题。一个简单的原子写操作需要确保在其他线程的原子读操作中能立即看到。在Java中Atomic类的方法已经保证了最强的内存语义相当于volatile的读写。在C中你需要根据场景选择合适的memory_order如memory_order_seq_cst,memory_order_acq_rel等。4. 自旋等待的优化如果你确实需要实现一个自旋锁例如在临界区极短的场景基于TTAS的模式是一个好的起点。但还可以进一步优化加入退避Backoff在CAS失败后不要立即重试而是等待一小段时间指数增长或随机这能显著减少激烈竞争下的总线流量和CPU缓存同步压力。使用CPU暂停指令在自旋循环中插入Thread.onSpinWait()Java或_mm_pause()x86汇编等指令。这可以告诉CPU当前处于忙等待循环CPU可以优化功耗和执行减少对内存子系统的压力并避免内存顺序违规Memory Order Violation导致的管道清空从而提升整体性能。5. 避免错误的“无锁”设计最常见的错误是“复合操作”问题。原子操作只能保证对单一变量的单一操作是原子的。如果你需要基于某个原子变量的值去更新另一个变量或者更新多个变量这本身不是一个原子操作。你需要通过锁或者更复杂的无锁算法通常基于CAS循环来保证整体一致性。例如检查某个AtomicBoolean是否为true如果为true则执行一段复杂逻辑这个“检查-执行”序列不是原子的需要用锁保护。原子操作是构建高性能、高并发系统的利器但它是一把锋利的双刃剑。正确理解TAS、TTAS、CAS、FAA这些基础原语的原理、代价和适用场景能帮助我们在“锁”与“无锁”之间做出更明智的架构选择写出更高效、更稳健的并发代码。从那次线上计数器性能问题之后我对于并发工具的选择变得更加审慎核心原则就是用最简单的、能满足需求的工具。对于计数器AtomicInteger或LongAdder足矣对于复杂的共享状态变更一把清晰的锁其可维护性往往优于晦涩难懂的无锁算法。
返回列表