多线程里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>的保证可以浓缩成两条:
- 不可分割(atomicity):对同一个原子对象的所有操作在别的线程看来是「要么全发生、要么全没发生」,绝不会出现撕裂读(torn read),读到一半新一半旧的中间状态。
- 可见性与定序:默认参数下(
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 字节结构体(默认编译选项) | 内部锁 /libatomic | false(实测) | 有一次内部互斥量竞争 |
| 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/strong | CAS | 无锁数据结构、原子更新复杂值 | 无锁编程的基石 |
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 循环,两步之间无法被插入。
| 维度 | volatile | std::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 = 1ticks的具体值当然不确定(取决于调度),所以只打印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 要把失败者挂进内核、唤醒要走上下文切换,而原子操作全程待用户态自旋重试」。