☰
std::atomic 入门:原子操作、CAS 与它和 volatile 的真正区别
2026/10/7 9:59:14 网站建设 项目流程

多线程里counter++的结果为什么会不对?因为++在机器层面是读、改、写三步,而这三步之间随时可能被另一个线程插进来。std::atomic<T>把这一整套变成不可分割的一步,代价是你要理解它到底保证了什么、没保证什么。这篇从丢更新这个现象切入,把is_lock_free真跑出来看,讲清compare_exchange_weak为什么是无锁数据结构的基石,最后用实测数据回答那个高频问题:std::atomic和volatile到底是不是一回事。

1. 引子:counter++丢在了哪里

先看一段错误的代码。它没有编译错误、没有警告,但结果是错的:

// 反例,不要这么写(数据竞争;Core Guidelines 要求共享可变状态必须同步) #include <thread> #include <vector> int counter = 0; // 普通 int,不是原子类型 void work() { for (int k = 0; k < 100000; ++k) { ++counter; // ① 读 counter ② 加一 ③ 写回 —— 三步,随时可被打断 } } // 真实程序里会这样跑:起 4 个线程各调一次 work(),然后 join。 // 期望得到 400000,实测通常是一个小于 400000 的随机数、每次都不一样 // —— 结果不稳定,所以这里不给输出块;正确版本见第 6 节 void demo() { std::vector<std::thread> workers; for (int t = 0; t < 4; ++t) workers.emplace_back(work); for (auto& t : workers) t.join(); }

它错在++counter不是一步操作:

非原子 ++counter(4 线程同时跑)—— 丢失更新(lost update) 时间 ──► 线程 A ── 读 counter=100 ────────────────────────────── 写 counter=101 ──► 线程 B ── 读 counter=100 ── 写 counter=101 ──► ▲ └── B 的写覆盖了 A 的写: 两次 ++ 只让 counter 增加了 1 原子 ++counter(std::atomic<int>):读-改-写是一个不可分割的 RMW 操作 线程 A ── [ 读 100 → 改 101 → 写 101 ] ─────────────────────────────► 线程 B ── [ 101 → 102 → 102 ] ──► 结果:400000,且每次运行都一样

这段反例的最终值不稳定(取决于调度),所以这篇不给它的输出;它的正确版本在第 5 节,会打印确定的400000。要提醒的是:这段代码不只是「结果可能变小」,在 C++ 里对同一个非原子对象并发读写本身就是未定义行为(undefined behavior),编译器完全有理由把它优化成任何东西,只是它通常看起来「只是少算了一点」,反而更危险。

官方文档:std::atomic — cppreference · C++ Core Guidelines CP.8(「不要用 volatile 做同步」)

2. 原子性保证了什么,is_lock_free又是什么

std::atomic<T>的保证可以浓缩成两条:

  1. 不可分割(atomicity):对同一个原子对象的所有操作在别的线程看来是「要么全发生、要么全没发生」,绝不会出现撕裂读(torn read),读到一半新一半旧的中间状态。
  2. 可见性与定序:默认参数下(memory_order_seq_cst)还提供跨线程的同步与顺序保证。

注意它只保护这一个对象本身。std::atomic<T>不会保护 T 里指针指向的内存,也不会保护「两个原子变量之间的一致性」,那是《memory_order 六种内存序详解》要解决的问题。

第二件事是is_lock_free():它回答「这个原子类型在当前平台上是不是真的用无锁指令实现的」。std::atomic<int>在 x86-64 上就是一条lock xadd,而某些平台/某些宽度上它会退化成内部锁(内部加一把锁来模拟原子性,性能大约等于 mutex)。is_always_lock_free是编译期常量,可以拿来做static_assert。真跑一遍看:

// atomic_lockfree.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_lockfree.cpp -o alf #include <atomic> #include <cstdio> struct Pair16 { long long a; long long b; }; // 16 字节 struct Big { char data[64]; }; // 64 字节 int main() { std::atomic<char> ac{0}; std::atomic<short> as{0}; std::atomic<int> ai{0}; std::atomic<long long> all{0}; std::atomic<double> ad{0.0}; std::atomic<int*> ap{nullptr}; std::printf("char %2zu 字节 is_lock_free=%d\n", sizeof(ac), static_cast<int>(ac.is_lock_free())); std::printf("short %2zu 字节 is_lock_free=%d\n", sizeof(as), static_cast<int>(as.is_lock_free())); std::printf("int %2zu 字节 is_lock_free=%d\n", sizeof(ai), static_cast<int>(ai.is_lock_free())); std::printf("long long %2zu 字节 is_lock_free=%d\n", sizeof(all), static_cast<int>(all.is_lock_free())); std::printf("double %2zu 字节 is_lock_free=%d\n", sizeof(ad), static_cast<int>(ad.is_lock_free())); std::printf("int* %2zu 字节 is_lock_free=%d\n", sizeof(ap), static_cast<int>(ap.is_lock_free())); std::printf("is_always_lock_free(int) = %d\n", static_cast<int>(std::atomic<int>::is_always_lock_free)); std::printf("is_always_lock_free(16B) = %d\n", static_cast<int>(std::atomic<Pair16>::is_always_lock_free)); std::printf("is_always_lock_free(64B) = %d\n", static_cast<int>(std::atomic<Big>::is_always_lock_free)); }
char 1 字节 is_lock_free=1 short 2 字节 is_lock_free=1 int 4 字节 is_lock_free=1 long long 8 字节 is_lock_free=1 double 8 字节 is_lock_free=1 int* 8 字节 is_lock_free=1 is_always_lock_free(int) = 1 is_always_lock_free(16B) = 0 is_always_lock_free(64B) = 0

结论:8 字节及以下的标量类型在 x86-64 上全部无锁(硬件直接支持lock cmpxchg/lock xadd),而 16 字节 和 64 字节的结构体都不是。16 字节本可以靠cmpxchg16b做到,但那需要编译器显式开启对应指令集;64 字节则无论如何都要退化成锁。

类型宽度典型实现is_lock_free()性能特征
1 / 2 / 4 / 8 字节标量、指针单条lock前缀指令true纳秒级,无内核参与
16 字节结构体(默认编译选项)内部锁 /libatomicfalse(实测)有一次内部互斥量竞争
64 字节及更大的结构体内部锁false与 mutex 接近,通常不如直接用锁

有个实测细节很能说明问题:如果你在文章里把std::atomic<Big>::is_lock_free()真的调用出来并链接,会得到

undefined reference to `__atomic_is_lock_free'

因为大类型的实现已经被挪进libatomic库(需要加-latomic),这本身就说明它不再是纯内联的无锁指令,而是一个库函数调用。所以实践结论很干脆:要无锁,就把原子类型控制在 8 字节以内;需要原子地更新一大坨数据,就老老实实用锁 + 值语义。

官方文档:std::atomic::is_lock_free — cppreference · std::atomic::is_always_lock_free — cppreference

3. 常用成员一览

std::atomic<T>的接口不多,但每个都有明确的使用场景:

成员作用适合的场景备注
load()原子读读标志位、读计数器快照相当于operator T()
store(v)原子写设标志位、发布新值相当于operator=(v)
exchange(v)原子交换,返回旧值「取走并清空」、抢令牌一条指令完成
fetch_add(v)/fetch_sub(v)原子加减,返回旧值计数、引用计数、序号分配++/--也是 RMW
fetch_or/and/xor原子位运算位图、标志集合主要用于整数
compare_exchange_weak/strongCAS无锁数据结构、原子更新复杂值无锁编程的基石
is_lock_free()运行时查询性能调优、平台适配见上一节

fetch_add返回的是旧值,这一点常被忽略:想做「我是第几个」的序号分配,auto id = next_id.fetch_add(1);拿到的id就是自己的序号,不需要再加一。

4.compare_exchange:无锁编程的基石

CAS(compare-and-swap)的语义是:「如果当前值等于我期望的旧值,就把它换成新值并返回 true;否则把当前真实值写回我的期望变量并返回 false」。第二半句是它最精妙的地方,失败时顺手帮你刷新了「最新值」,所以循环里不需要重新load:

// CAS 的语义(伪代码) bool compare_exchange(int& expected, int desired) { if (value == expected) { value = desired; return true; } expected = value; // ← 失败时把真实值刷进 expected return false; }

正因为它失败会刷新,CAS循环才写得那么紧凑。下面用它实现一个「原子地更新最大值」,四个线程各报一批数,最终最大值必然确定:

// atomic_cas.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_cas.cpp -o acas #include <atomic> #include <cstdio> #include <thread> #include <vector> void update_max(std::atomic<int>& target, int value) { int current = target.load(); while (current < value && !target.compare_exchange_weak(current, value)) { // 失败时 current 已被刷新成真实值,直接进入下一轮比较 } } int main() { constexpr int kThreads = 4; constexpr int kRounds = 5000; constexpr int kPeak = 1000; std::atomic<int> best{0}; std::vector<std::thread> workers; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&best, t] { for (int k = 0; k < kRounds; ++k) { update_max(best, (t * kRounds + k) % kPeak + 1); // 取值在 1..1000 之间循环 } }); } for (auto& t : workers) t.join(); std::printf("并发更新后最大值 = %d\n", best.load()); best.store(7); int expected = 7; const bool ok = best.compare_exchange_strong(expected, 9); std::printf("CAS(7 -> 9) 成功 = %d,best = %d\n", static_cast<int>(ok), best.load()); int stale = 3; const bool failed = best.compare_exchange_strong(stale, 5); std::printf("CAS(3 -> 5) 成功 = %d,best = %d,stale 被刷新为 %d\n", static_cast<int>(failed), best.load(), stale); }
并发更新后最大值 = 1000 CAS(7 -> 9) 成功 = 1,best = 9 CAS(3 -> 5) 成功 = 0,best = 9,stale 被刷新为 9

看第三行:CAS 期望「当前是 3」,实际是 9,于是失败、返回 0,并且把stale从 3 刷成了 9。这就是上面说的「失败顺手刷新」,也是为什么update_max里不需要在循环里重新load,current已经被刷成最新值了。

weak和strong的区别只有一个:weak允许「值确实相等但依然失败」(伪失败,spurious failure),strong不允许。所以在循环里用weak(省一次指令,失败了重试就行),在「只试一次」的场合用strong。

变体伪失败适用场合
compare_exchange_weak可能(即使值相等)写在循环里,性能略好
compare_exchange_strong不会只尝试一次、或循环体很贵不宜重试

5.std::atomic与volatile的真正区别

这是 C++ 并发里传播最广的误解之一:volatile不提供任何同步,也不保证原子性。它的原意是「这块内存可能被编译器看不见的力量改变」,所以禁止编译器把访问优化掉、禁止跨访问重排,服务的是内存映射 I/O 和信号处理,跟多线程毫无关系。

下面用一次被强行排定顺序的「丢失更新」把它演出来:两个线程都先读到旧值,再一起写回,volatile一点忙都帮不上。

// volatile_lost_update.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread volatile_lost_update.cpp -o vlu // 反例:volatile 不能替代原子操作。这段代码本身也含数据竞争,只为演示「丢失更新」。 #include <atomic> #include <cstdio> #include <thread> volatile int g_counter = 0; // 反例:volatile 不提供原子性(Core Guidelines CP.8) std::atomic<int> g_read{0}; // 用它们来强制出一个确定的交错顺序 std::atomic<bool> g_can_write{false}; int main() { std::thread a([] { const int value = g_counter; // ① 读 g_read.fetch_add(1); while (!g_can_write.load()) { } // ② 等另一个线程也读完 g_counter = value + 1; // ③ 写 }); std::thread b([] { const int value = g_counter; g_read.fetch_add(1); while (!g_can_write.load()) { } g_counter = value + 1; }); while (g_read.load() != 2) { } // 两个线程都读到旧值了 g_can_write.store(true); // 才允许它们写回 a.join(); b.join(); std::printf("两次 +1 后 g_counter = %d(正确应为 2)\n", g_counter); }
两次 +1 后 g_counter = 1(正确应为 2)

两次+1只让计数器动了 1:一次更新被完整地覆盖掉了。把volatile int换成std::atomic<int>,同样被排定的交错下结果是 2,因为g_counter = value + 1这种「先读后写」在原子类型上要写成fetch_add或者 CAS 循环,两步之间无法被插入。

维度volatilestd::atomic<T>
原子性不保证(读改写可被打断)保证(不可分割的 RMW)
跨线程同步 / 内存序完全不提供可指定memory_order,默认seq_cst
禁止编译器优化掉访问是是(因为它有语义,编译器本来就不能删)
是否会引起重排不保证(对非 volatile 访问无约束)通过内存序约束
能不能当跨线程标志位 / 计数器不能能
正当用途内存映射寄存器、信号处理、setjmp缓冲区计数器、标志位、无锁数据结构

一句话记:volatile管的是「编译器别乱优化我的访问」,std::atomic管的是「多个线程别互相打架」。这两件事没有任何重叠。

6.atomic<bool>做停止标志,以及性能对比

最常见的原子用法不是计数器,而是一个停止标志(stop flag):一个线程干活,另一个线程随时叫停。它必须是原子的,否则干活线程可能永远读不到新值(编译器可以把它提升到寄存器里)。

// atomic_stop.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_stop.cpp -o astop #include <atomic> #include <cstdio> #include <thread> #include <vector> int main() { constexpr int kThreads = 4; constexpr int kRounds = 100000; std::atomic<int> counter{0}; std::vector<std::thread> workers; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&counter] { for (int k = 0; k < kRounds; ++k) { ++counter; // 原子读-改-写 } }); } for (auto& t : workers) t.join(); std::printf("std::atomic<int> 自增 %d 次 = %d\n", kThreads * kRounds, counter.load()); std::printf("counter.is_lock_free() = %d\n", static_cast<int>(counter.is_lock_free())); std::atomic<bool> stop{false}; // 停止标志 std::atomic<long long> ticks{0}; std::thread spinner([&stop, &ticks] { while (!stop.load(std::memory_order_relaxed)) { ticks.fetch_add(1, std::memory_order_relaxed); } }); std::thread stopper([&stop] { stop.store(true, std::memory_order_relaxed); }); stopper.join(); spinner.join(); std::printf("停止标志生效 = %d,ticks > 0 = %d\n", static_cast<int>(stop.load()), static_cast<int>(ticks.load() > 0)); }
std::atomic<int> 自增 400000 次 = 400000 counter.is_lock_free() = 1 停止标志生效 = 1,ticks > 0 = 1

ticks的具体值当然不确定(取决于调度),所以只打印ticks > 0这个确定的事实,确定性结果优先是并发示例的写作准则。

原子自增 vs mutex 保护的int

同样 400 万次自增,一个用原子操作、一个用lock_guard保护普通int:

// atomic_vs_mutex.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_vs_mutex.cpp -o avm #include <atomic> #include <chrono> #include <cstdio> #include <mutex> #include <thread> #include <vector> int main() { constexpr int kThreads = 4; constexpr int kRounds = 1000000; std::atomic<long long> atomic_counter{0}; long long mutex_counter = 0; std::mutex mutex; const auto t0 = std::chrono::steady_clock::now(); { std::vector<std::thread> workers; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&atomic_counter] { for (int k = 0; k < kRounds; ++k) { atomic_counter.fetch_add(1, std::memory_order_relaxed); } }); } for (auto& t : workers) t.join(); } const auto t1 = std::chrono::steady_clock::now(); { std::vector<std::thread> workers; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&mutex_counter, &mutex] { for (int k = 0; k < kRounds; ++k) { std::lock_guard<std::mutex> lock(mutex); ++mutex_counter; } }); } for (auto& t : workers) t.join(); } const auto t2 = std::chrono::steady_clock::now(); const auto atomic_ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count(); const auto mutex_ms = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count(); std::printf("atomic 计数 = %lld,耗时 %lld ms\n", atomic_counter.load(), static_cast<long long>(atomic_ms)); std::printf("mutex 计数 = %lld,耗时 %lld ms\n", mutex_counter, static_cast<long long>(mutex_ms)); }
atomic 计数 = 4000000,耗时 ~~ ms mutex 计数 = 4000000,耗时 ~~ ms

在这台机器上实测大概是36 ms vs 121 ms,原子版本快约 3.4 倍。耗时随机器和负载浮动,所以这里用占位符,但量级上的差距是稳定的。

为什么差这么多?不是「原子更省指令」那么简单:

对比项atomic.fetch_add(relaxed)lock_guard+++int
单次操作一条lock xadd(约 20 个周期,锁总线/缓存行)一条lock cmpxchg(加锁)+ 一条lock cmpxchg(解锁)+ 循环判断
是否进入内核不进入不进入(无竞争时),有竞争时会 futex 系统调用
竞争加剧时纯用户态自旋重试,不会睡等待者会在内核里挂起,唤醒要跨上下文切换
内存屏障一条lock前缀自带的顺序约束每次加解锁都带全屏障
语义能力只能操作这一个值能保护一整段临界区、多个变量

关键差异在第 3 行:mutex 无竞争时确实很快,但一旦出现竞争,失败者会被内核挂起,唤醒它要走一次上下文切换,那是微秒级的开销,比一次原子自旋贵两到三个数量级。而fetch_add的失败只是一次缓存行失效后的重试,全程待在用户态。

但别急着把所有锁都换成原子,这张表最后一行才是重点:atomic只能保护「一个值」,一旦你的不变量涉及两个以上的变量(比如「队列非空」加「队列长度」),就必须用锁。选型顺序是:能用一个 8 字节以内的原子量表达 → 用原子;否则 → 用锁(并且优先考虑能不能改成「不共享」)。

7. 完整示例:并发统计一批值

把 sum / max / count 三种统计放在一起:sum和count用fetch_add(天然可加),max没有「原子取最大」指令,只能用 CAS 循环。四线程各处理一万个值,输出全部确定:

// atomic_final.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_final.cpp -o af #include <atomic> #include <cstdio> #include <thread> #include <vector> // 原子取最大值:没有对应指令,只能 CAS 循环 void update_max(std::atomic<int>& target, int value) { int current = target.load(std::memory_order_relaxed); while (current < value && !target.compare_exchange_weak(current, value, std::memory_order_relaxed, std::memory_order_relaxed)) { // 失败时 current 被刷新,继续比较 } } int main() { constexpr int kThreads = 4; constexpr int kRounds = 10000; constexpr int kMod = 1000; // 取值 1..1000 循环 std::atomic<long long> sum{0}; std::atomic<int> max_seen{0}; std::atomic<int> processed{0}; std::vector<std::thread> workers; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&, t] { for (int k = 0; k < kRounds; ++k) { const int value = (t * kRounds + k) % kMod + 1; sum.fetch_add(value, std::memory_order_relaxed); // 可加:直接 RMW update_max(max_seen, value); // 不可加:CAS 循环 processed.fetch_add(1, std::memory_order_relaxed); } }); } for (auto& t : workers) t.join(); std::printf("线程数 = %d,每线程处理 %d 个值\n", kThreads, kRounds); std::printf("处理总数 = %d\n", processed.load()); std::printf("总和 = %lld\n", sum.load()); std::printf("最大值 = %d\n", max_seen.load()); std::printf("is_lock_free: long long=%d, int=%d\n", static_cast<int>(std::atomic<long long>::is_always_lock_free), static_cast<int>(std::atomic<int>::is_always_lock_free)); }
线程数 = 4,每线程处理 10000 个值 处理总数 = 40000 总和 = 20020000 最大值 = 1000 is_lock_free: long long=1, int=1

四个数字都确定:40000 = 4 × 10000;20020000 = 4 × 10 × (1+2+…+1000),每个线程都完整覆盖了 1..1000 十轮;最大值 1000 必然出现。注意update_max里 CAS 的循环条件:外层while判断current < value,只有「最大值确实更小」时才去 CAS,这样绝大多数调用在第一次比较就退出了,完全没有 CAS 的开销。这是compare_exchange的常见优化套路:先用普通负载判断,再 CAS。

官方文档:compare_exchange — cppreference · std::memory_order — cppreference(《memory_order 六种内存序详解:relaxed、acquire、release 到底怎么选》会专门展开)

8. 延伸阅读

  • std::atomic — cppreference —— 模板总览:要求T可平凡复制,列出全部成员函数与特化
  • std::atomic::compare_exchange — cppreference ——weak/strong的差别与「失败时刷新 expected」的正式措辞
  • std::atomic::is_lock_free — cppreference —— 运行时查询,配is_always_lock_free使用
  • std::atomic_flag — cppreference —— 保证无锁的最小自旋锁原语,test_and_set与 C++20 的wait/notify_one
  • C++ Core Guidelines · 并发章节 CP.8 —— 明确写了「不要用 volatile 做同步」,这篇第 5 节的结论就出自这里

本知识库内的相关篇目:

  • 《memory_order 六种内存序详解:relaxed、acquire、release 到底怎么选》 —— 加了 std::atomic 结果还是错的?因为原子性只保证「这一步不可分割」
  • 《mutex 与 lock_guard / unique_lock:共享数据的正确加锁姿势》 —— 多个线程改同一个变量会怎样?这篇先讲清数据竞争(读-改-写被打断)为什么让结果随机
  • 《shared_mutex 读写锁该不该用,以及 volatile 的三大误解》 —— C++17 的 std::shared_mutex 让多个读者并存、写者独占

9. 一句话总结

std::atomic<T>把「读-改-写」变成不可分割的一步,并保证跨线程可见性 —— 它只保护这一个对象,不保护它指向的东西、也不维护多个原子量之间的一致性;8 字节以内的标量在 x86-64 上都是无锁的(is_lock_free()实测为 1),再大就退化成内部锁甚至需要libatomic;compare_exchange失败时会把真实值刷回expected,这是无锁循环能写得那么短的原因;而volatile和它毫无重叠 ——volatile只管「编译器别优化掉我的访问」,不做同步、不保证原子性,拿它当计数器必然丢更新。性能上原子自增比 mutex 保护的++快数倍,差距来自「竞争时 mutex 要把失败者挂进内核、唤醒要走上下文切换,而原子操作全程待用户态自旋重试」。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询