C++ CAS与ABA问题详解,无锁编程的经典陷阱怎么解决?

一、从互斥锁到无锁思想

在多线程编程中,最常见的并发控制手段是互斥锁。当多个线程同时访问共享资源时,互斥锁可以保证同一时刻只有一个线程进入临界区,从而避免数据竞争。

然而,互斥锁并不是没有代价。线程在等待锁的过程中可能被阻塞,上下文切换也会带来额外开销。在一些对延迟敏感、吞吐要求较高的场景中,开发者会尝试使用无锁编程思路,通过原子操作来协调多个线程对共享状态的访问。

CAS,也就是 Compare-And-Swap,是无锁编程中最基础、最重要的原子操作之一。它的核心思想是:先比较某个内存位置的值是否等于预期值,如果相等,就把它更新为新值;整个比较和更新过程必须是原子的。

在 C++ 中,CAS 通常通过 std::atomic 提供的成员函数来完成,例如:

std::atomic<int> value{0};

int expected = 0;

int desired = 1;

if (value.compare_exchange_strong(expected, desired)) {

// 更新成功

} else {

// 更新失败,value 的当前值已被其他线程修改

}

compare_exchange_strong 会执行一次原子的"比较---交换"操作。如果 value 当前值等于 expected ,就把它替换为 desired ;否则,会把 expected 更新为 value 的当前值,并返回失败。

这看起来很适合实现无锁结构,比如无锁栈、无锁队列、计数器、状态机等。但正是这种"只看值,不看版本"的更新方式,带来了一个经典陷阱:ABA 问题。

二、ABA 问题到底是什么

ABA 问题发生在这样一种场景中:

  1. 线程 A 准备更新某个共享变量,先读取到值 A ;

​

  1. 在线程 A 执行 CAS 之前,线程 B 修改了变量为 B ;

​

  1. 随后线程 C 又把变量改回了 A ;

​

  1. 这时线程 A 继续执行 CAS,发现值仍然是 A ,于是误以为数据没有被别人修改过,成功完成更新。

从结果上看,变量的值确实从 A 开始,又回到了 A 。但中间其实已经经历过其他线程的修改。如果业务逻辑依赖的是"数据是否从未被改变",而不是"值是否恰好等于某个常数",那么 CAS 就会给出错误的判断。

一个经典例子是无锁栈中的节点回收问题。

假设栈顶指针如下变化:

线程 A 准备弹出栈顶元素,先记录栈顶为 A 。在线程 A 执行 CAS 前,其他线程可能已经把 A 出栈,再把一个新节点 C 入栈,之后又有线程把 A 重新入栈。这时栈顶又变成:

线程 A 继续执行 CAS,发现栈顶仍然是 A ,于是认为自己操作的是最初那个节点,完成弹出。但实际上,中间的 B 、 C 节点可能已经被错误访问或回收,程序可能出现内存安全问题。

因此,ABA 问题的本质不是"值变化了又变回来",而是:

CAS 只能判断值是否相等,不能判断中间是否发生过其他状态变迁。

在普通整数计数场景中,ABA 未必每次都会造成严重错误;但在指针操作、节点回收、状态机、资源管理等场景中,ABA 很可能导致野指针、重复释放、逻辑错误等问题。

三、为什么普通 CAS 解决不了 ABA

普通 CAS 的判断条件非常简单:

它只关心当前内存中的值是否等于预期值,并不关心这个值是怎么来的。

只要最终值仍然是 A ,CAS 就会认为"没有人改过它"。但从并发语义上看,这是一种过于弱的判断。尤其是当多个线程可以对同一个位置反复写入时,CAS 无法区分:

  • 该值从未被修改;

​

  • 该值被修改过,但最后又恢复成旧值。

这也是为什么在无锁数据结构设计中,ABA 常常被看作比普通数据竞争更隐蔽的问题。普通数据竞争至少表现为明显的数据错乱,而 ABA 可能在表面值正确的情况下,引发深层逻辑错误。

四、解决 ABA 的常见思路

  1. 增加版本号

解决 ABA 最常见、最直接的方法,是在共享数据之外再维护一个版本号。

每次修改共享状态时,不仅更新数据本身,也递增版本号。这样,CAS 判断就不再只看数据值,而是同时看"数据值 + 版本号"。

例如:

更新逻辑大致如下:

即使数据值从 A 变成 B 再变回 A ,版本号也已经发生变化,CAS 会失败,从而避免 ABA。

这种方法的优点是思路清晰、开销可控,适合状态管理、配置更新、计数器等场景。缺点是需要额外维护版本号,并要求每次状态变化都正确递增版本号。

  1. 使用带标签的指针

在无锁链表、无锁栈等指针结构中,ABA 经常和节点回收问题一起出现。为了避免普通指针比较带来的 ABA,可以把指针和一个标签组合起来,形成"带标签的指针"。

例如,在 64 位系统中,可以利用指针未使用的高位存储标签:

每次更新指针时,标签都递增。这样即使同一个地址被复用,标签不同,CAS 也会失败。

不过,这种方法依赖具体平台和编译器优化,实现复杂度较高,需要仔细处理指针对齐、类型转换和内存模型。

在 C++ 工程中,更稳妥的做法往往不是手工编码指针标签,而是使用更高级的抽象,或者直接避开某些手工无锁指针结构。

  1. 延迟回收与 Hazard Pointer

在无锁数据结构中,很多 ABA 问题并不是单纯的 CAS 问题,而是"访问正在被其他线程使用的节点"的问题。

因此,一个重要方向是解决内存安全,而不是只盯着 CAS 本身。

Hazard Pointer 是一种经典方案。它的核心思想是:

  • 线程在访问某个节点前,先把该节点地址记录为 hazard pointer;

​

  • 其他线程在释放节点前,先检查该节点是否仍被 hazard pointer 保护;

​

  • 如果仍被保护,则延迟回收。

这样可以避免一个节点被释放后,又被重新分配给新节点,从而减少 ABA 发生的条件。

Hazard Pointer 的优点是可以为无锁结构提供更强的内存安全保证。缺点是实现复杂,对工程能力要求较高,也会带来一定的记录和扫描开销。

  1. 使用更高级的无锁抽象

对于大多数业务开发者来说,手写无锁链表、无锁队列并不是最佳选择。更现实的工程方案,是使用已经经过验证的无锁结构库或原子抽象。

例如:

  • 使用有界队列或阻塞队列,减少手工无锁设计;

​

  • 使用引用计数或智能指针管理生命周期;

​

  • 使用 RCU 思想,在读多写少场景中降低锁开销;

​

  • 在合适场景中直接使用 std::mutex 、 std::condition_variable ,而不是强行无锁。

很多时候,"比锁更快"并不是首要目标,"正确性可维护"才更重要。

五、C++ 内存模型对 CAS 的影响

讨论 CAS 时,不能只看功能是否成功,还要看内存序是否正确。

C++ 的 compare_exchange_weak 和 compare_exchange_strong 可以指定不同内存序,例如:

这里:

  • memory_order_release :当前线程之前的写入,不会重排到这个 CAS 之后;

​

  • memory_order_acquire :当前线程之后的读取,不会重排到这个 CAS 之前。

如果内存序使用不当,即使 CAS 成功,也可能出现可见性问题。比如,其他线程可能还没有看到新状态对应的完整数据,就已经看到了更新后的指针。

因此,CAS 编程不是"把 if 改成原子操作"这么简单。它同时涉及:

  • 原子操作语义;

​

  • 内存可见性;

​

  • 指令重排;

​

  • 指针生命周期;

​

  • 失败后的重试策略。

这也是为什么很多无锁 bug 难以复现、难以调试。它们不一定每次都出现,可能只在特定编译器优化、特定 CPU 架构、特定线程调度下才暴露。

六、一个带版本号的 CAS 示例

下面给出一个简化示例,展示如何用版本号避免 ABA。

这个示例中,线程每次读取的是一个组合状态: value + version 。即使 value 恢复为旧值,只要 version 不同,CAS 就会失败。

注意, std::atomic<State> 是否适用,取决于 State 是否满足原子操作的要求。在实际工程中,可能需要使用特定平台原子操作、联合类型或版本号独立管理方式。

七、ABA 问题的排查难点

ABA 问题之所以危险,是因为它不像普通死锁那样容易观察。死锁通常表现为程序卡住,而 ABA 可能表现为:

  • 偶发崩溃;

​

  • 节点被重复访问;

​

  • 业务状态回退;

​

  • 数据结构逻辑异常;

​

  • 多线程测试中无法稳定复现。

在复杂系统中,共享状态往往不只由一个变量构成,还可能涉及资源句柄、对象生命周期、连接状态、任务队列等。此时,问题表面上是 CAS 失败或重试,本质上却可能是状态模型没有覆盖完整。

对于长期维护的 C++ 服务端程序,尤其是涉及高频状态更新、异步任务处理和并发资源回收的模块,对这类问题进行系统的并发场景问题定位,往往比单次功能调试更有价值。它要求开发者把"原子操作是否成功"和"状态变迁是否合法"分开检查,而不是把所有逻辑都压在一个 CAS 上。

八、工程建议:不要为了无锁而无锁

很多开发者接触 CAS 后,会有一种倾向:把所有共享变量都改成原子操作,把所有临界区都改成无锁结构。

这通常不是一个好选择。

无锁编程最大的代价不是性能,而是理解成本和调试成本。在真实项目中,以下问题往往比 ABA 更重要:

  1. 是否真的有锁竞争瓶颈?

​

  1. 是否必须使用无锁数据结构?

​

  1. 是否能证明内存回收规则正确?

​

  1. 是否考虑了内存序和编译器重排?

​

  1. 失败重试是否会导致活锁?

​

  1. 测试是否覆盖了不同线程调度顺序?

如果答案不够明确,优先使用更简单的同步方式,比如:

  • std::mutex 保护临界区;

​

  • 队列实现生产者---消费者;

​

  • 读写锁保护读多写少数据;

​

  • RCU 或引用计数降低生命周期管理复杂度。

只有在明确存在性能瓶颈,并且能够验证无锁方案的正确性时,才值得引入更复杂的原子抽象。

九、总结

CAS 是无锁编程的基础工具,但它只解决了"原子地比较并更新"的问题,没有天然解决"状态是否被中间修改过"的问题。ABA 正是这种局限性带来的经典陷阱。

要避免 ABA,常见思路包括:

  • 引入版本号;

​

  • 使用带标签指针;

​

  • 延迟回收节点;

​

  • 使用更成熟的无锁抽象;

​

  • 必要时放弃纯无锁,改用更易维护的同步结构。

对于 C++ 开发者来说,真正重要的不是学会一个 CAS 接口,而是理解它背后的状态模型、内存语义和生命周期约束。毕竟,无锁编程追求的不只是"不加锁",而是"在不加锁的保证并发正确性"。

相关推荐
Zelman9451 小时前
第06章-交互与观察动作空间
人工智能
冬奇Lab1 小时前
开源项目第258期:Harness² —— Google Research 的「递归式 Agent Harness 优化」,模型权重不动,只改 Agent 的外壳
人工智能·开源
字节跳动视频云技术团队2 小时前
每两家头部手机厂商就有一家选它:火山引擎如何成为 AI 手机助手的“隐形底座”?
人工智能
泯泷3 小时前
像项目经理一样指挥 AI 团队:管理者模式
人工智能·llm·agent
京东云开发者3 小时前
双11囤Token火了:京东搜索量暴涨7倍,AI生产力成新刚需
人工智能
ahccqw3 小时前
OpenCV 车辆数量统计
人工智能·opencv·计算机视觉
人工智能AI技术3 小时前
LangGraph 本地 build 频频翻车,最终在服务器跑通流式对话
人工智能
Flynn Liang4 小时前
同一需求三套写法:用 Spring AI、LangChain4j、AgentScope Java 各实现一个带工具调用的 RAG 助手(实战对比)
java·人工智能·大模型·架构设计·spring ai·langchain4j·agentscope
汤姆宝库4 小时前
构建一个能用的RAG系统:切块、嵌入、混合检索与融合排序
人工智能