在多线程编程中,当我们需要在不加锁的情况下修改共享数据,CAS(Compare And Swap)几乎是绕不开的核心技术。C++11引入的std::atomic系列原子类型为CAS提供了语言层面的支持,其中std::atomic_compare_exchange_weak和std::atomic_compare_exchange_strong是两个最常用也最容易用错的函数。这篇文章将系统讲解它们的用法、区别以及实际工程中需要注意的细节。

CAS的基本原理与函数签名
CAS的思想非常直观:先读取一个内存位置的当前值作为期望值,然后在后续某个时刻尝试把这个内存位置的值从期望值改成新值。如果在此期间没有其他线程修改过这块内存,修改成功;否则说明值已经变了,修改失败,通常需要重新读取并重试。整个过程由硬件指令(如x86的cmpxchg)保证原子性,中间不会被打断。
C++标准库中,CAS相关的函数有两个变体,它们的函数签名如下:
bool atomic_compare_exchange_weak(std::atomic<T>* obj, T* expected, T desired); bool atomic_compare_exchange_strong(std::atomic<T>* obj, T* expected, T desired); // 也经常以成员函数形式调用: bool std::atomic<T>::compare_exchange_weak(T& expected, T desired); bool std::atomic<T>::compare_exchange_strong(T& expected, T desired);
这三个参数的含义需要特别留意,尤其是第二个参数expected,它既是输入也是输出。执行逻辑是:原子地比较*obj与expected的值,如果相等,就把desired写入*obj并返回true;如果不相等,就把*obj的当前实际值写回expected,并返回false。也就是说,函数返回false时,expected已经被更新成了内存中的最新值,这一点在编写重试循环时非常方便,可以直接用更新后的expected继续参与下一轮计算,省去一次额外的load操作。
weak与strong的区别及正确选择
很多初学者第一次接触这两个函数时都会困惑:既然有strong版本,为什么还要存在weak版本?关键区别在于允许的伪失败(spurious failure)。weak版本即使当前值与期望值相等,也可能返回false,也就是所谓“莫名其妙地失败了”。而strong版本保证只要值相等就一定成功。
weak版本的存在是为了性能。在某些硬件平台(典型的如ARM)上,CAS底层是通过LL/SC指令对(load-linked/store-conditional)实现的,而SC指令天然可能在任意时刻失败,例如线程在两次指令之间被中断、或其他核碰巧写入了同一缓存行。在这些平台上,weak版本可以直接映射到一条原生指令循环之外的路径,性能更好。
使用准则可以简单归纳为一句话:在循环中做CAS就用weak,单独调用一次且不重试就用strong。看下面这个典型的无锁累加例子:
std::atomic<int> value{0};
void add_ten()
{
int old = value.load(std::memory_order_relaxed);
// weak版本可能伪失败,但在循环中无所谓,失败会自动重试
while (!value.compare_exchange_weak(old, old + 10,
std::memory_order_acq_rel,
std::memory_order_relaxed)) {
// 循环体为空:old已经被函数更新为最新值,直接进入下一轮
}
}
注意上面代码中失败时循环体是空的,这正体现了expected参数会被回写的特性。如果用strong版本也能得到正确结果,只是在不支持硬件级强CAS的平台上会多付出一些开销。反过来,如果你写的是“只尝试一次、失败就走别的分支”的逻辑,用weak就可能出错,因为伪失败会导致程序误以为发生了竞争。
内存序的选择策略
完整的成员函数签名还支持两个内存序参数,分别是成功时和失败时使用的内存序:
bool compare_exchange_strong(T& expected, T desired,
std::memory_order success,
std::memory_order failure);
默认情况下两个内存序都是memory_order_seq_cst,这是最强的顺序一致性保证,正确性最容易推理,但性能也最差。在性能敏感的场景中,合理降低内存序可以显著减少内存屏障带来的开销。常见的搭配方案有:纯计数器场景用relaxed(只要求原子性,不涉及同步关系);实现锁或发布数据时成功路径用acq_rel或acquire,失败路径用acquire或relaxed。
有一条约束必须记住:failure的内存序不能强于success,且不能是release或acq_rel。这是因为失败的CAS本质上只是一次load操作,不存在“发布”语义。例如写成成功用acquire、失败用seq_cst就是编译错误。
一个实用的自旋锁实现可以说明内存序的用法:
class SpinLock {
std::atomic<bool> locked_{false};
public:
void lock()
{
// exchange成功获取锁,release保证临界区写入对解锁方可见
while (locked_.exchange(true, std::memory_order_acquire)) {
// 自旋等待,可插入pause指令降低总线争用
}
}
void unlock()
{
locked_.store(false, std::memory_order_release);
}
};
如果把CAS换成compare_exchange_weak的写法(期望false、写入true),配合acquire/release内存序,逻辑同样是成立的。这里成功获取锁用acquire是为了保证后续临界区内的读写不会重排到加锁之前,解锁用release则保证临界区的修改对下一个加锁线程可见。内存序一旦用错,即使测试通过也可能在特定架构上出现难以复现的诡异bug,所以拿不准时先用默认的seq_cst,确认正确后再针对性优化。
实战中的常见坑:ABA问题与伪共享
无锁算法最著名的陷阱是ABA问题。CAS只比较值是否相等,而无法感知值是否“变过”。假设线程1读到指针A,线程2把A改成B又改回A,线程1的CAS依然会成功,但它操作的可能已经是一个被回收重用的对象,在带垃圾回收的语言里问题不大,在C++中手动管理内存时就是悬垂指针的灾难。常见的应对方案包括:给数据附加版本号tagged pointer,每次修改递增版本,比较时同时比较值和版本;或使用hazard pointer、epoch-based reclamation等延迟回收机制。
另一个容易被忽视的坑是伪共享(false sharing)。多个线程频繁对位于同一缓存行的不同atomic变量做CAS,会导致缓存行在核间不停弹跳,性能急剧下降,有时甚至比加锁还慢。排查方法是观察多线程下的性能不随核数扩展,解决思路是用alignas(64)把高频写的原子变量按缓存行大小对齐,让每个变量独占一个缓存行:
struct alignas(64) PaddedCounter {
std::atomic<uint64_t> count{0};
// 填充至64字节,避免与其他计数器共享缓存行
char pad[64 - sizeof(std::atomic<uint64_t>)];
};
此外还有两点建议:第一,CAS重试循环在高竞争下会造成大量CPU空转,写入冲突严重时 Mutex 反而更划算,不要为了“无锁”而无锁;第二,使用TSan(ThreadSanitizer)或helgrind工具对并发代码做检测,能在开发阶段暴露大部分内存序错误。CAS是把双刃剑,理解清楚硬件语义和内存模型之后,它才能发挥出真正的威力。
std::atomic_compare_exchangeCAS原子操作C++无锁编程修改时间:2026-09-12 22:26:47