博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站让我们从一个典型的业务场景说起为一个账户编写余额扣款函数。假设账户的余额变量被声明为atomicint业务逻辑要求在每次扣款前先确认当前余额充足只有余额大于等于扣款金额时才执行扣款。初步的代码实现通常如下#includeatomicstd::atomicintbalance{100};boolWithdraw(intamount){if(balance.load()amount){balance.store(balance.load()-amount);returntrue;}returnfalse;}从代码上看这段逻辑里的每一步似乎都具备原子性——load是一次原子读取随后的store也是一次原子写入。这往往会让初学者产生一种错觉认为既然每一个操作都是原子的组合起来的逻辑自然也是线程安全的。然而在多线程环境下运行这段代码账户余额很容易被错误地扣成负数。设想这样一个并发场景账户当前的真实余额是 100此时有 A 和 B 两个独立线程分别试图扣除 80。线程 A 率先执行了balance.load()读到了 100判断100 80成立准备执行扣款。但在执行store之前操作系统的调度器将 CPU 切换给了线程 B。线程 B 同样执行了load操作也读到了 100并做出了同样的判断准备扣款。随后两个线程各自执行了store(100 - 80)。它们双双把余额改写成 20并返回扣款成功。最终结果是账户实际上扣除了 160但系统余额却显示为 20。这个并发问题的根源并不在于load和store自身的原子性。问题在于“检查状态”和“写入新状态”是两个独立的动作它们之间存在一个时间差。在这个时间差内其他线程可以介入并修改状态导致之前检查的先决条件失效。如果将最初的load读取也计算在内这段扣款逻辑在底层其实被拆分成了三个独立的原子动作读出余额、条件判断、基于旧值计算并写入新余额。业务逻辑要求这三个步骤作为一个不可分割的整体执行但基础的load/store无法提供这种保护。为了解决这类普遍的工程需求我们需要一种原语能够将“读取当前值、比较预期旧值、若匹配则写入新值”这三个动作在硬件层面压缩成一条不可分割的指令。这就是并发编程中核心的 CASCompare-And-Swap比较并交换机制。在 C 标准库中它对应着std::atomic类的两个重要接口compare_exchange_weak和compare_exchange_strong。CAS 把“比较再写入”压成一个硬件原子动作为了清晰地理解 CAS 的行为我们可以用一段伪代码来描述它在底层的语义CAS(atomic_var, expected, desired): 原子地执行以下逻辑 如果 atomic_var 当前的真实值 expected: atomic_var desired 返回 true 表示写入成功 否则: expected atomic_var 当前的真实值 返回 false 表示预期不符写入放弃这段包含内存访问和条件分支的逻辑作为一个不可分割的硬件级事务一次性发生。操作系统的调度器和其他核心既不能在“比对成功”和“写入新值”的间隙插入写操作也不能在“比对失败”和“回读当前真实值”的过程中进行干预。对外部视角而言这些操作要么全部完成要么完全不发生。这种硬件级别的担保依赖于多核处理器的缓存一致性协议如 MESI 协议。当 CPU 执行 CAS 操作时它必须获取目标内存地址所在缓存行的独占权MESI 协议中的 Modified 状态。只要该核心持有独占权其他试图访问该缓存行的核心请求都会被暂缓。在 x86 架构上对应的底层指令是lock cmpxchg其中的lock前缀会锁住目标总线或缓存行直至整条交换指令执行完毕。在 ARM 这类精简指令集架构上则使用一对ldxr/stxrLoad Exclusive / Store Exclusive指令来包裹读取和写入。如果在执行stxr写入前该缓存行被其他核心修改这层排他性保护就会破裂stxr返回失败交由上层软件重试。得益于这种硬件支持CAS 成为无锁编程中的基础原语。它将“判断前置状态 安全修改状态”的常见需求内化为硬件保证。回顾之前的余额扣款问题只要我们使用 CAS 执行更新硬件就会替我们核查“在尝试把余额修改为current - amount时账户的实际余额是否仍为之前的current”如果中途被其他线程修改CAS 会拦截并宣告失败避免超额扣款。C 的 compare_exchange 接口设计查看 C 标准库的手册compare_exchange_weak和compare_exchange_strong的函数签名如下boolcompare_exchange_weak(Texpected,T desired,...);boolcompare_exchange_strong(Texpected,T desired,...);一个值得注意的细节是expected参数是通过左值引用T传递的。我们先看一个失败的示例#includeatomic#includeiostreamstd::atomicintvalue{10};intmain(){intexpected8;// 试图把 8 改成 20boolokvalue.compare_exchange_strong(expected,20);std::coutok\n;// 打印 0 (false)std::coutexpected\n;// 打印 10std::coutvalue.load()\n;// 打印 10 (原子的目标值未被改动)}在调用 CAS 前预期值expected为 8而原子变量的当前真实值为 10。CAS 在底层比对发现 8 不等于 10中断写入并返回 false。同时作为引用传入的局部变量expected被修改为了原子变量当前的真实值 10。初次接触这种行为部分开发者可能会觉得不适应作为一个条件比对的变量为何在内部被修改了其实这是一个注重工程实用性的设计。在并发编程中CAS 通常不是只调用一次的接口。它最经典的使用模式是嵌套在一个重试循环中读取当前值、计算新值、尝试 CAS 写入如果失败则基于最新值重新计算并再次尝试。如果 CAS 在比对失败时只返回false调用方就必须额外发起一次load调用来获取最新值以开启下一轮循环。这在性能敏感的无锁结构中会带来不必要的开销。既然 CAS 在硬件执行比对时已经获取了当前的真实值将这个新值通过expected引用直接返回给调用方就能省掉一次内存访问。在实际代码中C 标准库推崇的 CAS 循环结构显得非常简洁intcurrentvalue.load();intdesiredcompute_new(current);// 失败时底层的最新值会自动覆盖 currentwhile(!value.compare_exchange_weak(current,desired)){// 循环再次跑到这里时不需要重新 load直接基于更新过的 current 计算desiredcompute_new(current);}当 CAS 失败时current会自动刷新为最新值。循环无缝进入下一轮直接基于新值重新计算desired。整个重试结构没有冗余的读取操作。通过最大值更新演示 CAS 循环为了更好地理解这种循环机制我们来看一个简单的实例并发环境下的最大值更新。由于它仅维护一个独立数值没有复杂的指针操作是分析 CAS 的合适用例。#includeatomicstd::atomicintglobal_max{0};voidUpdateMax(intcandidate){intcurrentglobal_max.load(std::memory_order_relaxed);// 只要候选值大于当前记录的最大值就尝试更新while(candidatecurrent){if(global_max.compare_exchange_weak(current,candidate,std::memory_order_relaxed,std::memory_order_relaxed)){// CAS 更新成功return;}}}我们可以拆解多线程竞争时的执行路径假设线程进入UpdateMax(50)先load出当前的峰值为 30。判断条件50 30成立进入循环发起 CAS 调用试图将global_max从 30 更新为 50。情况一CAS 成功在发起调用期间没有其他线程修改变量真实值仍为 30。硬件原子改写为 50CAS 返回 true线程直接退出。情况二CAS 失败但候选值仍有效假设在调用 CAS 前另一线程已将global_max修改为 40。CAS 比对发现当前值并非预期的 30判定写入失败返回 false同时将current刷新为 40。线程回到while(candidate current)评估条件——50 40依然成立。于是进行下一轮尝试此时 CAS 的预期值变成了 40。若中途无人干预CAS 将成功写入 50。情况三CAS 失败且候选值失效如果另一线程直接将global_max更新到了 60。我们的 CAS 失败current被刷新为 60。回到candidate current时发现50 60不成立。循环结束函数返回。虽然没有成功写入但在业务逻辑上这是正确的——既然已有更大的值满足“全局最大值”语义50 作为候选值理应被淘汰。在这个例子中使用memory_order_relaxed是合理的。因为global_max仅记录数字峰值不承担为其他共享数据做同步指示的职责。如果global_max还需同步其他相关内存状态则必须使用release/acquire组合。此外C 的 CAS 接口接受两个独立的内存序参数前一个代表 CAS 成功执行写入时的要求后一个代表 CAS 失败只做读取时的要求。标准规定失败时的内存序不能强于成功时的内存序。即使写入失败底层仍完成了一次原子读取操作这个读取到的值在跨线程可见性上的要求由第二个内存序参数决定。weak 与 strong 的区别与取舍为什么 C 会提供compare_exchange_weak和compare_exchange_strong两个接口这是基于底层硬件物理限制做出的设计折中。compare_exchange_strong的语义明确仅当目标原子变量的真实值不等于expected时才会返回 false。相比之下compare_exchange_weak多了一种被称为“伪失败”Spurious Failure的状态——即使传入的预期值与真实值完全一致CAS 也可能返回 false并将expected原样写回中止此次本该成功的写入。这并非代码缺陷而是硬件微架构的实现细节。在基于 LL/SCLoad-Linked / Store-Conditional原语的平台如 ARM上执行最终写入的stxr指令有着严格的成功条件不仅要求目标地址上的数据未发生改变还要求该内存地址所在的缓存行未被其他操作干扰。这意味着即使数据完好无损如果期间发生了一次操作系统的中断、线程上下文切换或者其他核心访问了该缓存行内的其他字段底层的硬件监视器就会将该区域标记为“已改变”导致stxr指令失败。这种由环境引发的失败在硬件层面上难以避免如果在编译器内部封装循环兜底来掩盖可能会影响指令流水的性能。因此compare_exchange_weak将这一硬件现实暴露给调用方既然无锁逻辑通常包裹在重试循环中那么允许偶发的伪失败可以省去一层内部封装的开销。compare_exchange_strong则是另一种设计它在内部使用循环保障确保不出现伪失败。在 LL/SC 平台上这会生成额外的判定指令。而在 x86 这类原生的lock cmpxchg指令本身就不存在伪失败的平台上编译器生成的 weak 和 strong 汇编代码其实是一模一样的。在工程实践中通常遵循以下原则若 CAS 在while循环中重试优先使用 weak。循环自然能够消化偶发的伪失败并且在部分平台上能带来轻量化的性能优势。若 CAS 逻辑只尝试一次不重试请使用 strong。例如竞争初始化某个标志位只有第一个线程能成功写入其余线程走另一业务分支。在这种一次性判定下strong 的确定性能减少心智负担。不要因为名字中的“weak”而认为其在线程安全性或原子性上有任何折扣。weak 在应对并发冲突时的保障强度与 strong 相同它只是允许偶发的硬件级伪失败这纯粹是为了性能做出的工程权衡。使用 CAS 修复余额扣款逻辑理解了这些原理后我们可以用标准的方法重写开头的余额扣款代码#includeatomicstd::atomicintbalance{100};boolWithdraw(intamount){intcurrentbalance.load();// 只要账户余额充足就尝试扣款while(currentamount){intdesiredcurrent-amount;// 发起 CAS 操作成功写入才表示大功告成if(balance.compare_exchange_weak(current,desired)){returntrue;}}// 循环退出表示余额确实不足returnfalse;}这段重构后的代码核心变化是原本分离的“事前检查”和“事后写入”通过compare_exchange_weak无缝融合为一个原子事务。再次推演之前的并发场景。账户余额 100线程 A 和 B 分别尝试扣除 80。两个线程几乎同时执行load都拿到current 100。接着都做出了100 80的判断准备发起 CAS。由于 CAS 底层有总线锁护体硬件调度器会使它们排队执行。假设线程 A 率先执行其 CAS 指令试图将余额从 100 修改为 20。硬件比对确认当前值为 100写入成功余额变为 20CAS 返回 true线程 A 成功退出。线程 B 紧随其后执行 CAS同样试图将余额从 100 改为 20。但硬件比对发现当前余额已变为 20写入被拒绝CAS 返回 false同时将 20 返回给current。线程 B 退回到current amount时发现20 80已不成立循环退出函数返回 false。在整个并发过程中判断逻辑和实际的写入动作被 CAS 绑定在一起。即便线程 B 的判断基于过期的状态CAS 机制的最后一刻核对确保了基于过时信息的修改不会发生。这种绑定“读取检查 更新”的能力使 CAS 成为解决单变量并发冲突的有效手段。需要补充的是这段代码仅用于演示 CAS 的机制。在真实的金融系统中交易引擎需要重做日志、审计追踪、事务回滚以及分布式强一致性保障。这些需求远不是一个atomicint加 CAS 循环所能覆盖的。无锁编程的误区与工程权衡在技术讨论中“无锁编程”有时被夸大为提升性能的万能钥匙。真实的工程实践远比这种理想化情况复杂。我们需要厘清两个常被混用的学术概念在计算机科学中Lock-free无锁 的定义是在系统的运行过程中作为一个整体至少保证有一个线程能够在有限步骤内推进其操作。这表明在无锁系统中某个特定线程可能会遭遇反复重试但整个系统不会陷入停滞的死锁状态。因此lock-free 的底线承诺是避免死锁。更高阶的概念 Wait-free无等待 则要求更加严格它保证每一个线程都能在有限步骤内完成操作不存在任何线程被饿死的情况。无等待算法虽然在理论上存在但在工业界的实现难度极高且为了维持这种绝对公平往往需要付出可观的常数级开销导致在多数并发负载下整体运行速度可能不及常规的 lock-free 实现。大多数基于 CAS 循环编写的代码通常只满足 lock-free 的要求。比如之前的更新最大值的循环在极端高压的竞争下一个线程每次算好的值都可能被其他线程抢先修改导致它反复重试。从宏观上看系统仍在推进但对该线程而言执行效率极低。基于这种观察盲目应用无锁编程容易陷入以下工程误区高占用率与低推进速度。 高竞争下的 lock-free 并不意味着无等待。由于 CAS 循环可能反复失败线程会在重试中消耗大量 CPU 资源导致占用率飙升而系统的实际有效吞吐量却不高。性能表现不如预期。 在低竞争状态下传统的std::mutex仅需少量原子操作即可快速获取锁权开销很低只有在发生争抢引发线程挂起和调度时才会带来明显延迟。而 CAS 循环虽然在低压力下表现良好但在高并发竞争时大量的徒劳重试所消耗的算力可能比线程排队上锁更为低效并造成能源浪费。缓存乒乓效应。 CAS 需要获取目标缓存行的独占权。当多个核心同时对同一原子变量进行 CAS 操作时该缓存行会在各核心之间频繁转移这就是缓存乒乓效应Cache Ping-Pong。这会产生大量的缓存一致性协议流量可能成为系统的瓶颈。实现复杂度陡增。 构建高效的无锁数据结构如无锁队列、无锁字典远不止使用 CAS 那么简单。开发者必须应对复杂的并发异常如 ABA 问题、安全的内存回收、以及精细的内存序屏障设计。这些综合工程挑战往往超过了使用 mutex 带来的简单性。在成熟的性能调优工程中合理的策略是初期使用传统的锁来确保业务逻辑正确。只有当系统确实遭遇性能瓶颈且通过专业的性能分析工具profiler确认瓶颈正源于特定锁的激烈争用时再考虑使用 CAS 对关键路径进行定向改造。ABA 问题隐藏在 CAS 比较下的风险CAS 在设计上存在一个局限它仅能判断目标变量的当前值与expected是否相等却无法感知该变量在观察期间是否经历过被修改然后又恢复原值的过程。这个盲点就是著名的 ABA 问题。ABA 问题最容易在无锁栈等数据结构中显现。以无锁栈的pop操作为例其核心逻辑是通过原子指针head指向栈顶structNode{intdata;Node*next;};std::atomicNode*head{nullptr};Node*Pop(){Node*old_headhead.load(std::memory_order_acquire);while(old_head!nullptr){Node*new_headold_head-next;if(head.compare_exchange_weak(old_head,new_head,std::memory_order_release)){returnold_head;}}returnnullptr;}这段代码思路顺畅读取当前栈顶记录其 next 节点使用 CAS 尝试将栈顶替换为 next 节点。如果中途没人修改栈顶替换成功否则重试。然而在多线程并发时由于内存地址复用的特性会发生意想不到的逻辑崩塌。以下是发生 ABA 问题的典型时间线假设栈的当前元素顺序为A - B - Chead 指向 A 节点。Thread 1 发起 Pop 操作读到old_head为 A记下new_head为 B。在即将发起 CAS(head, A, B) 之前Thread 1 因操作系统的调度被暂停。此时 Thread 2 介入顺利执行 Pop 将 A 弹出栈变成了 B - Chead 指向 B。紧接着被弹出的 A 节点的内存被回收归还给了内存分配器。随后 Thread 3 发起 Push 操作压入新数据 X。它向分配器申请内存恰好复用了刚刚被回收的节点 A 的物理地址。Thread 3 在该地址构造了新节点 A’将其 next 指向当前的栈顶 B并更新 head 指向 A’。此时栈的结构变为了 A’ - B - Chead 指向 A’。当 Thread 1 恢复执行并提交 CAS(head, A, B) 时底层硬件比对发现head 依然指向地址 A虽然此时已经是 A’。CAS 判定条件吻合成功将 head 修改为之前记下的 B。这样一来Thread 3 刚刚 Push 进去的 A’ 节点被错误地踢出了数据结构导致内存泄漏。如果期间压入了更多节点丢失的数据会更多。学术界将此称为 ABA 问题状态从 A 变更为 B随后又变回 A因地址复用。CAS 纯粹比对数值或地址无法察觉中途的变更历史。对于单纯的数值运算ABA 可能不会带来业务错误但在涉及生命周期管理的指针操作中两次地址 A 之间原对象结构已被破坏关联关系失效这会引发程序运行崩溃。为了防范 ABA 问题业界探索出了多种解决方案版本号捆绑 / Tagged Pointer。 将指针与一个递增的版本计数器打包为一个原子单元如 128 位宽。即使指针地址因复用而回到 A累加的版本号也会表明这已是一个新的生命周期。x86-64 架构提供了类似cmpxchg16b这样的特殊指令用于支持对这种 16 字节超宽标记的 CAS 操作。Hazard Pointer风险指针。 要求每个正在读取某节点的线程对外声明其持有的“风险指针”。负责回收内存的线程在真正释放物理地址前必须扫描所有挂出的风险指针确保无人引用后才进行回收从而防止地址被过早复用。Epoch-Based Reclamation基于纪元的回收。 将程序的运行时间划分为一个个纪元epoch。进入无锁临界区的线程会注册当前的纪元标签。负责内存清扫的线程只有确认旧纪元内的所有活动线程均已退出后才会安全释放该纪元中被淘汰的对象内存。引用计数与 shared_ptr。 使用如std::atomicshared_ptrT这样的机制使对象的生命周期严格绑定引用计数。只要引用计数不为零对象内存就不会被释放和复用。其代价是高昂的底层控制块原子维护成本。这些解决方案都伴随着不同的实现复杂度和性能权衡。无锁数据结构之所以被视为深水区工程不仅在于掌握 CAS 的语法更在于如何处理“对象何时能够安全释放”这一更为复杂且独立的生命周期管理难题。这往往占据了整个无锁实现大部分的开发和排错难度。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传从 CAS 走向 fence 屏障通过前面的内容我们看到 CAS 实际上将“精准比对再写入”的原子动作以及内存可见性保护结合在了一次调用中。通过参数列表提供的两个memory_order参数可以分别控制 CAS 写入成功或失败时的内存序强度。在绝大多数工程场景中这种“原子动作自带内存序”的设计模式已经足够使用在末尾使用带有 release 参数的 CAS 调用可以保证稳定的 release 内存屏障语义配置带有 acquire 标记的 CAS则能确保扎实的 acquire 拦截。然而在极少数特定的性能优化场景下这种将内存序拦截效果绑定在单一原子动作上的模式可能会暴露出一些局限性。假设某段解析代码在极短时间内为了追求极致速度连续执行了多次带有relaxed标记的加载操作将大量状态数据吸入高速缓存。在所有读取结束后代码希望在某一时间节点上统一且强制地执行一次彻底的 acquire 全局获取语义以此来修补之前松散读取带来的潜在可见性滞后问题。这种需求如果试图通过compare_exchange或普通的带参原子接口实现会显得较为勉强因为常规操作的内存序影响通常局限于单次原子动作无法直接对大片连续代码块形成宏大的可见性断层保护。为了应对这类特殊的同步需求C 在标准库的底层提供了一个独立的原语std::atomic_thread_fence内存栅栏屏障。fence 在物理层面上不依附于任何具体的原子状态变量它本身作为一道独立的屏障横亘在物理寄存器和多核缓存总线之间。它可以将代码块靠前的零碎内存操作和靠后的混杂读写动作通过 acquire、release 或 seq_cst 的指令约束切分成明确的两半。不过尽管机制独特但在日常的多数工程项目中直接使用 fence 的机会其实非常少。相比起清晰易懂的 release/acquire load/store 配对模型fence 抽象的拦截语义使得代码审查变得极为困难——它脱离了具体的原子变量对象缺乏明确的配对目标。要正确使用 fence 并避免对性能造成不必要的负面影响完全依赖于开发者对 C 底层内存模型细节的深入理解。在接下来的章节中我们将对 fence 和 barrier 这两个概念展开详细探讨。了解它们与常规自带内存序原语的关系探讨诸如 release fence 和 relaxed store 所形成的典型战术配合并厘清在何种情况下使用 fence 能够真正带来硬件开销的节省以及为何在大多数普通开发场景中我们应该谨慎对待这一强大的工具。码字不易欢迎大家点赞关注评论谢谢