
一、从互斥锁到无锁思想
在多线程编程中,最常见的并发控制手段是互斥锁。当多个线程同时访问共享资源时,互斥锁可以保证同一时刻只有一个线程进入临界区,从而避免数据竞争。
然而,互斥锁并不是没有代价。线程在等待锁的过程中可能被阻塞,上下文切换也会带来额外开销。在一些对延迟敏感、吞吐要求较高的场景中,开发者会尝试使用无锁编程思路,通过原子操作来协调多个线程对共享状态的访问。
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 问题发生在这样一种场景中:
- 线程 A 准备更新某个共享变量,先读取到值 A ;
- 在线程 A 执行 CAS 之前,线程 B 修改了变量为 B ;
- 随后线程 C 又把变量改回了 A ;
- 这时线程 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 的常见思路
- 增加版本号
解决 ABA 最常见、最直接的方法,是在共享数据之外再维护一个版本号。
每次修改共享状态时,不仅更新数据本身,也递增版本号。这样,CAS 判断就不再只看数据值,而是同时看"数据值 + 版本号"。
例如:
更新逻辑大致如下:
即使数据值从 A 变成 B 再变回 A ,版本号也已经发生变化,CAS 会失败,从而避免 ABA。
这种方法的优点是思路清晰、开销可控,适合状态管理、配置更新、计数器等场景。缺点是需要额外维护版本号,并要求每次状态变化都正确递增版本号。
- 使用带标签的指针
在无锁链表、无锁栈等指针结构中,ABA 经常和节点回收问题一起出现。为了避免普通指针比较带来的 ABA,可以把指针和一个标签组合起来,形成"带标签的指针"。
例如,在 64 位系统中,可以利用指针未使用的高位存储标签:
每次更新指针时,标签都递增。这样即使同一个地址被复用,标签不同,CAS 也会失败。
不过,这种方法依赖具体平台和编译器优化,实现复杂度较高,需要仔细处理指针对齐、类型转换和内存模型。
在 C++ 工程中,更稳妥的做法往往不是手工编码指针标签,而是使用更高级的抽象,或者直接避开某些手工无锁指针结构。
- 延迟回收与 Hazard Pointer
在无锁数据结构中,很多 ABA 问题并不是单纯的 CAS 问题,而是"访问正在被其他线程使用的节点"的问题。
因此,一个重要方向是解决内存安全,而不是只盯着 CAS 本身。
Hazard Pointer 是一种经典方案。它的核心思想是:
- 线程在访问某个节点前,先把该节点地址记录为 hazard pointer;
- 其他线程在释放节点前,先检查该节点是否仍被 hazard pointer 保护;
- 如果仍被保护,则延迟回收。
这样可以避免一个节点被释放后,又被重新分配给新节点,从而减少 ABA 发生的条件。
Hazard Pointer 的优点是可以为无锁结构提供更强的内存安全保证。缺点是实现复杂,对工程能力要求较高,也会带来一定的记录和扫描开销。
- 使用更高级的无锁抽象
对于大多数业务开发者来说,手写无锁链表、无锁队列并不是最佳选择。更现实的工程方案,是使用已经经过验证的无锁结构库或原子抽象。
例如:
- 使用有界队列或阻塞队列,减少手工无锁设计;
- 使用引用计数或智能指针管理生命周期;
- 使用 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 更重要:
- 是否真的有锁竞争瓶颈?
- 是否必须使用无锁数据结构?
- 是否能证明内存回收规则正确?
- 是否考虑了内存序和编译器重排?
- 失败重试是否会导致活锁?
- 测试是否覆盖了不同线程调度顺序?
如果答案不够明确,优先使用更简单的同步方式,比如:
- std::mutex 保护临界区;
- 队列实现生产者---消费者;
- 读写锁保护读多写少数据;
- RCU 或引用计数降低生命周期管理复杂度。
只有在明确存在性能瓶颈,并且能够验证无锁方案的正确性时,才值得引入更复杂的原子抽象。
九、总结
CAS 是无锁编程的基础工具,但它只解决了"原子地比较并更新"的问题,没有天然解决"状态是否被中间修改过"的问题。ABA 正是这种局限性带来的经典陷阱。
要避免 ABA,常见思路包括:
- 引入版本号;
- 使用带标签指针;
- 延迟回收节点;
- 使用更成熟的无锁抽象;
- 必要时放弃纯无锁,改用更易维护的同步结构。
对于 C++ 开发者来说,真正重要的不是学会一个 CAS 接口,而是理解它背后的状态模型、内存语义和生命周期约束。毕竟,无锁编程追求的不只是"不加锁",而是"在不加锁的保证并发正确性"。