博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
让我们从一个典型的业务场景说起:为一个账户编写余额扣款函数。假设账户的余额变量被声明为 atomic<int>,业务逻辑要求在每次扣款前先确认当前余额充足,只有余额大于等于扣款金额时才执行扣款。初步的代码实现通常如下:
cpp
#include <atomic>
std::atomic<int> balance{100};
bool Withdraw(int amount) {
if (balance.load() >= amount) {
balance.store(balance.load() - amount);
return true;
}
return false;
}
从代码上看,这段逻辑里的每一步似乎都具备原子性------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 无法提供这种保护。为了解决这类普遍的工程需求,我们需要一种原语,能够将"读取当前值、比较预期旧值、若匹配则写入新值"这三个动作在硬件层面压缩成一条不可分割的指令。
这就是并发编程中核心的 CAS(Compare-And-Swap,比较并交换)机制。在 C++ 标准库中,它对应着 std::atomic 类的两个重要接口:compare_exchange_weak 和 compare_exchange_strong。

CAS 把"比较再写入"压成一个硬件原子动作
为了清晰地理解 CAS 的行为,我们可以用一段伪代码来描述它在底层的语义:
text
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/stxr(Load Exclusive / Store Exclusive)指令来包裹读取和写入。如果在执行 stxr 写入前该缓存行被其他核心修改,这层排他性保护就会破裂,stxr 返回失败,交由上层软件重试。
得益于这种硬件支持,CAS 成为无锁编程中的基础原语。它将"判断前置状态 + 安全修改状态"的常见需求内化为硬件保证。回顾之前的余额扣款问题,只要我们使用 CAS 执行更新,硬件就会替我们核查:"在尝试把余额修改为 current - amount 时,账户的实际余额是否仍为之前的 current?"如果中途被其他线程修改,CAS 会拦截并宣告失败,避免超额扣款。


C++ 的 compare_exchange 接口设计
查看 C++ 标准库的手册,compare_exchange_weak 和 compare_exchange_strong 的函数签名如下:
cpp
bool compare_exchange_weak(T& expected, T desired, ...);
bool compare_exchange_strong(T& expected, T desired, ...);
一个值得注意的细节是,expected 参数是通过左值引用(T&)传递的。我们先看一个失败的示例:
cpp
#include <atomic>
#include <iostream>
std::atomic<int> value{10};
int main() {
int expected = 8;
// 试图把 8 改成 20
bool ok = value.compare_exchange_strong(expected, 20);
std::cout << ok << "\n"; // 打印 0 (false)
std::cout << expected << "\n"; // 打印 10
std::cout << value.load() << "\n"; // 打印 10 (原子的目标值未被改动)
}
在调用 CAS 前,预期值 expected 为 8,而原子变量的当前真实值为 10。CAS 在底层比对发现 8 不等于 10,中断写入并返回 false。同时,作为引用传入的局部变量 expected 被修改为了原子变量当前的真实值 10。
初次接触这种行为,部分开发者可能会觉得不适应:作为一个条件比对的变量,为何在内部被修改了?其实,这是一个注重工程实用性的设计。
在并发编程中,CAS 通常不是只调用一次的接口。它最经典的使用模式是嵌套在一个重试循环中:读取当前值、计算新值、尝试 CAS 写入;如果失败,则基于最新值重新计算并再次尝试。
如果 CAS 在比对失败时只返回 false,调用方就必须额外发起一次 load 调用来获取最新值以开启下一轮循环。这在性能敏感的无锁结构中会带来不必要的开销。既然 CAS 在硬件执行比对时已经获取了当前的真实值,将这个新值通过 expected 引用直接返回给调用方,就能省掉一次内存访问。
在实际代码中,C++ 标准库推崇的 CAS 循环结构显得非常简洁:
cpp
int current = value.load();
int desired = compute_new(current);
// 失败时,底层的最新值会自动覆盖 current
while (!value.compare_exchange_weak(current, desired)) {
// 循环再次跑到这里时,不需要重新 load,直接基于更新过的 current 计算
desired = compute_new(current);
}
当 CAS 失败时,current 会自动刷新为最新值。循环无缝进入下一轮,直接基于新值重新计算 desired。整个重试结构没有冗余的读取操作。

通过最大值更新演示 CAS 循环
为了更好地理解这种循环机制,我们来看一个简单的实例:并发环境下的最大值更新。由于它仅维护一个独立数值,没有复杂的指针操作,是分析 CAS 的合适用例。
cpp
#include <atomic>
std::atomic<int> global_max{0};
void UpdateMax(int candidate) {
int current = global_max.load(std::memory_order_relaxed);
// 只要候选值大于当前记录的最大值,就尝试更新
while (candidate > current) {
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。硬件原子改写为 50,CAS 返回 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/SC(Load-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 修复余额扣款逻辑
理解了这些原理后,我们可以用标准的方法重写开头的余额扣款代码:
cpp
#include <atomic>
std::atomic<int> balance{100};
bool Withdraw(int amount) {
int current = balance.load();
// 只要账户余额充足,就尝试扣款
while (current >= amount) {
int desired = current - amount;
// 发起 CAS 操作,成功写入才表示大功告成
if (balance.compare_exchange_weak(current, desired)) {
return true;
}
}
// 循环退出表示余额确实不足
return false;
}
这段重构后的代码,核心变化是:原本分离的"事前检查"和"事后写入",通过 compare_exchange_weak 无缝融合为一个原子事务。
再次推演之前的并发场景。账户余额 100,线程 A 和 B 分别尝试扣除 80。
两个线程几乎同时执行 load,都拿到 current = 100。接着都做出了 100 >= 80 的判断,准备发起 CAS。
由于 CAS 底层有总线锁护体,硬件调度器会使它们排队执行。假设线程 A 率先执行,其 CAS 指令试图将余额从 100 修改为 20。硬件比对确认当前值为 100,写入成功,余额变为 20,CAS 返回 true,线程 A 成功退出。
线程 B 紧随其后执行 CAS,同样试图将余额从 100 改为 20。但硬件比对发现当前余额已变为 20,写入被拒绝,CAS 返回 false,同时将 20 返回给 current。线程 B 退回到 current >= amount 时,发现 20 >= 80 已不成立,循环退出,函数返回 false。
在整个并发过程中,判断逻辑和实际的写入动作被 CAS 绑定在一起。即便线程 B 的判断基于过期的状态,CAS 机制的最后一刻核对,确保了基于过时信息的修改不会发生。
这种绑定"读取检查 + 更新"的能力,使 CAS 成为解决单变量并发冲突的有效手段。
需要补充的是,这段代码仅用于演示 CAS 的机制。在真实的金融系统中,交易引擎需要重做日志、审计追踪、事务回滚以及分布式强一致性保障。这些需求远不是一个 atomic<int> 加 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 指向栈顶:
cpp
struct Node {
int data;
Node* next;
};
std::atomic<Node*> head{nullptr};
Node* Pop() {
Node* old_head = head.load(std::memory_order_acquire);
while (old_head != nullptr) {
Node* new_head = old_head->next;
if (head.compare_exchange_weak(old_head, new_head, std::memory_order_release)) {
return old_head;
}
}
return nullptr;
}
这段代码思路顺畅:读取当前栈顶,记录其 next 节点,使用 CAS 尝试将栈顶替换为 next 节点。如果中途没人修改栈顶,替换成功;否则重试。
然而在多线程并发时,由于内存地址复用的特性,会发生意想不到的逻辑崩塌。以下是发生 ABA 问题的典型时间线:
假设栈的当前元素顺序为:A -> B -> C,head 指向 A 节点。
Thread 1 发起 Pop 操作,读到 old_head 为 A,记下 new_head 为 B。在即将发起 CAS(head, A, B) 之前,Thread 1 因操作系统的调度被暂停。
此时 Thread 2 介入,顺利执行 Pop 将 A 弹出,栈变成了 B -> C,head 指向 B。紧接着,被弹出的 A 节点的内存被回收归还给了内存分配器。
随后 Thread 3 发起 Push 操作压入新数据 X。它向分配器申请内存,恰好复用了刚刚被回收的节点 A 的物理地址。Thread 3 在该地址构造了新节点 A',将其 next 指向当前的栈顶 B,并更新 head 指向 A'。
此时栈的结构变为了 A' -> B -> C,head 指向 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::atomic<shared_ptr<T>> 这样的机制,使对象的生命周期严格绑定引用计数。只要引用计数不为零,对象内存就不会被释放和复用。其代价是高昂的底层控制块原子维护成本。
这些解决方案都伴随着不同的实现复杂度和性能权衡。无锁数据结构之所以被视为深水区工程,不仅在于掌握 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 能够真正带来硬件开销的节省,以及为何在大多数普通开发场景中,我们应该谨慎对待这一强大的工具。
码字不易,欢迎大家点赞,关注,评论,谢谢!