在并发编程里,C++ 多线程原子操作 算是一个“看着简单、用起来处处是坑”的领域。很多人一开始觉得无非就是给变量加个锁、或用 std::atomic<int> 替代普通 int,但真正落到多核场景,面对数据竞争、内存序、伪共享这些问题时,才发现自己只是“能跑起来”,离“跑得对”还差不少。
这篇文章我打算用几个可以直接编译运行的实例,把原子操作从“是什么”讲到“为什么要这么写”,再分享一些我实际踩过的坑。内容不追求多高深,但尽量把每个关键点背后的为什么讲透,让刚接触多线程的朋友能少走几趟弯路,也让想写无锁代码的开发者有一点可参考的抓手。
1. 为什么需要原子操作:从并发失效的计数器说起
1.1 一段会产生数据竞争的代码
先看一个非常典型的场景。四个线程同时往一个整型变量上加数,每个线程累加 25 万次,预期结果是 100 万:
cpp复制#include <cstdio>
#include <thread>
#include <vector>
int counter = 0;
void worker(int times) {
for (int i = 0; i < times; ++i) {
++counter;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(worker, 250000);
}
for (auto& t : threads) {
t.join();
}
printf("counter = %d\n", counter);
return 0;
}
我在一台 8 核机器上跑了几次,结果分别是 647231、718992、552874,偶尔也能接近 90 万,但从来不会稳定输出 1000000。原因并不玄乎:++counter 这行代码在机器层面不是一个动作,而是“读入寄存器、寄存器加 1、写回内存”三步。线程 A 刚把值读成 100,还没写回,线程 B 也读到 100,两个线程同时加 1,最后写回变成 101,而不是 102。这就丢了一次更新。
在 C++ 的术语里,这种“多个线程同时访问同一内存位置,并且至少有一个线程在写”的情形叫做数据竞争。数据竞争的可怕之处不在于结果不对,而在于它是未定义行为。未定义行为意味着编译器可以做任何事:把循环优化掉、调整指令顺序、甚至在你原本以为安全的代码里插入奇怪的访问。我甚至见过一次 Debug 正常运行、Release 直接崩溃的怪事,根源就是这种竞争被优化放大成了非法内存访问。
1.2 原子操作的本质:一条不可分割的动作
原子操作要解决的就是这种“读改写三步被拆开”的问题。一个原子操作在逻辑上是一个整体:要么还没发生,要么已经完成,其它线程永远看不到中间状态。拿生活中的记账举例,普通写法相当于几只手同时去改同一本账簿,有人看到旧数字就往上加,写回时自然互相覆盖。原子操作相当于前台只有一支专用笔,谁拿到笔才能改,改完别人才看到最新数字,中间不可能出现“被人读到加了一半”的状态。
硬件层面,原子性主要靠两类能力实现。一是 CPU 提供的原子指令,比如 x86 上的 lock xadd、lock cmpxchg,它们会在执行期间锁住总线或缓存行;二是 C++ 语言标准里对原子类型做出的承诺——只要你用 std::atomic 并且通过它提供的接口操作,编译器就会生成对应的硬件指令或调用合适的运行时机制,保证操作不被撕碎。
这里有个很容易误解的点:原子操作 ≠ 为一段代码加锁。加锁( mutex )保护的是“多条语句组成的临界区”,原子操作保护的是“单个内存操作”。锁可以保护一个链表插删,原子操作做不到;但如果只需要让计数加一、让标志位翻转这类单点读写,原子操作比锁轻量得多,没有上下文切换,也没有锁等待。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事先掌握的基础:std::atomic 提供了什么
2.1 原子类型与不可拷贝的约束
C++11 起,标准库提供了 std::atomic<T> 模板,最常用的实例化是 std::atomic<int>、std::atomic<bool>、std::atomic<unsigned long long>、std::atomic<void*>。基本用法很直白:
cpp复制#include <atomic>
std::atomic<int> counter{0}; // 初始化
counter.store(10); // 原子写入 10
int v = counter.load(); // 原子读取当前值
需要特别注意一个约束:原子变量不可拷贝、不可赋值。但 counter = 10; 这种写法为什么能编译通过?因为 std::atomic 重载了 operator=,它内部实际执行的是一个原子写操作,而拷贝构造没有对应的原子语义,所以被删除了。同理,你不能把原子变量放进 std::vector<std::atomic<int>> 之后直接 vec.push_back(x),因为 push_back 需要拷贝构造。这时候通常得用 std::unique_ptr<std::atomic<int>> 管理,或者改用固定数组。
之所以禁止拷贝,是因为拷贝一个原子变量本身做不到原子:读两遍、写两遍,中间都可能被其它线程打断。标准库里干脆把这种能力去掉,逼迫开发者在设计时就避开“无锁地拷贝原子状态”这种伪需求。
2.2 读改写操作:fetch_add 与 compare_exchange
除了 load / store,真正让原子操作发挥威力的是读改写类接口。最常用的是 fetch_add:
cpp复制std::atomic<int> counter{0};
int old = counter.fetch_add(1); // 返回旧值,并将 counter 加 1
fetch_add 等价于“读取旧值、加上增量、写回新值”的三步,但整个过程中间不会泄露半成品。和 ++counter 的区别在于,++counter 是载入后修改再写入,语义上也是原子的,但 fetch_add 明确告诉你“我关心的是旧值还是新值”,写代码的人不会搞混。fetch_sub 是减法版本,exchange 用来“无条件写入并返回旧值”。
更复杂一点的场景会用到 compare_exchange_strong 和 compare_exchange_weak,它们共同的名字叫 CAS(Compare-And-Swap)。语义如下:
cpp复制std::atomic<int> value{10};
int expected = 10;
bool ok = value.compare_exchange_strong(expected, 20);
// 如果 value 当前等于 expected,就把它改成 20,返回 true;
// 如果不等,把 expected 更新为 value 的实际值,返回 false。
CAS 是无锁数据结构的基石。很多无锁队列、无锁栈、无锁哈希表的更新操作,本质都是“读出旧状态、计算新状态、用 CAS 尝试写回,失败则重读重算”的循环。
compare_exchange_weak 和 compare_exchange_strong 的区别在于,前者在少数平台下即使值匹配也可能“假失败”返回 false,但它的优点是生成的代码在某些架构上更高效。只要把 weak 放进循环里,假失败无非是再转一圈,不影响正确性。因此无锁代码里最常见的写法是:
cpp复制while (!value.compare_exchange_weak(expected, new_value)) {
// expected 已被更新为最新值,重新计算 new_value
}
2.3 atomic_flag:最轻量的原子布尔量
std::atomic<bool> 已经能满足大多数标志位需求,但标准库还提供了一个更原始的 std::atomic_flag。它只有 clear 和 test_and_set 两个操作,不支持读取当前值,设计上只服务于“自旋锁”这种极简场景。
一个用 atomic_flag 实现自旋锁的模板:
cpp复制#include <atomic>
class SpinLock {
public:
SpinLock() : flag_(ATOMIC_FLAG_INIT) {}
void lock() {
// 一直旋转,直到把 false 换成 true
while (flag_.test_and_set(std::memory_order_acquire)) {
// 空转
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_;
};
这里 test_and_set 会原子地把标志位设为 true,并返回之前的旧值。如果旧值是 false,说明锁处于空闲状态,当前线程获得了锁;如果旧值是 true,说明锁被别人持有,继续旋转等待。这种锁在临界区很短的场景下比 mutex 快,因为不需要操作系统介入,纯粹是用户态忙等。但要注意:如果临界区经常发生阻塞或耗时较长,自旋锁会浪费大量 CPU 时间,不如直接上 std::mutex。
我第一次写自旋锁时踩过一个坑:忘记把 unlock 里的 clear 用 memory_order_release,导致解锁操作在别的线程看来可能滞后。这个问题放到后面内存序部分细说。
3. 实战:三个可以直接抄的实例
3.1 实例一:原子计数器
把前面出过错的计数器改用原子变量,是最直观的对比。
cpp复制#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
std::atomic<int> atom_counter{0};
void atom_worker(int times) {
for (int i = 0; i < times; ++i) {
atom_counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(atom_worker, 250000);
}
for (auto& t : threads) {
t.join();
}
printf("atomic counter = %d\n", atom_counter.load());
return 0;
}
编译命令:
bash复制g++ -std=c++11 -O2 -pthread -o atom_demo atom_demo.cpp
这个程序在同样 8 核机器上,稳定输出 1000000。这里我用了 std::memory_order_relaxed,因为计数器的“加多少次”并不关心和其它内存操作的先后关系,只需要保证单个 fetch_add 原子,不需要额外的顺序约束。松弛序在这里是安全的,也是性能最优的。
有朋友可能会问,直接用 atom_counter++ 不是也行吗?行,std::atomic<int> 重载了自增运算符。但用 fetch_add 更明确,因为自增运算符在标准里等价于 fetch_add(1),并不会多出别的语义。我建议团队代码里统一用 fetch_add,减少“这里到底是不是原子操作”的疑问。
3.2 实例二:线程就绪标志
很多场景需要“线程 A 准备好数据,通知线程 B 开始使用”。用锁当然可以,但会阻塞。用原子变量加轮询,能写一个不阻塞的版本:
cpp复制#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
std::atomic<bool> ready{false};
int payload = 0;
void producer() {
// 模拟耗时准备
std::this_thread::sleep_for(std::chrono::milliseconds(200));
payload = 42;
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
// 等待,可以在这里睡觉或做别的事情
std::this_thread::yield();
}
// 这里能安全地读取 payload
printf("payload = %d\n", payload);
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
这个例子引出了一个很关键的细节:为什么 ready 用 release 置位,用 acquire 读取?因为普通变量的写入顺序是“先写 payload,再置 ready=true”。如果使用默认的 seq_cst,一切都没问题;但如果使用 relaxed,那么理论上其它线程有可能看到 ready 变成 true,却还没看到 payload 的写入,最终读到 payload 的旧值。
release/acquire 就是为了解决这种“发布-消费”模型而设计的:store(..., release) 会保证该语句之前的所有写入,在其它线程执行 load(..., acquire) 成功后全部可见。消费者一旦观察 ready 为 true,就相当于拿到了生产者发布的所有先前写入。
实际运行中,因为 x86 平台本身不会让普通 store 乱序到 store 之后,这类问题在 Intel/AMD 上未必现形,但跨到 ARM 平台可能就翻车了。这也是我一直强调“不要因为在 x86 上跑得好就以为并发代码没问题”的原因。
3.3 实例三:CAS 实现一个无锁栈
CAS 是很多无锁结构的基石,这里给出一个最小可运行的顶部入栈/出栈示例。为了简洁,我直接使用 std::shared_ptr 管理节点,把生命周期问题简化掉:
cpp复制#include <atomic>
#include <cstdio>
#include <memory>
template <typename T>
class LockFreeStack {
public:
LockFreeStack() : head_(nullptr) {}
void push(const T& value) {
Node* node = new Node(value);
Node* old_head = head_.load(std::memory_order_relaxed);
do {
node->next = old_head;
} while (!head_.compare_exchange_weak(
old_head, node,
std::memory_order_release,
std::memory_order_relaxed));
}
std::shared_ptr<T> pop() {
Node* old_head = head_.load(std::memory_order_relaxed);
while (old_head) {
if (head_.compare_exchange_weak(
old_head, old_head->next.get(),
std::memory_order_acquire,
std::memory_order_relaxed)) {
std::shared_ptr<T> result(old_head->value, [old_head](T*) {
delete old_head;
});
return result;
}
}
return nullptr;
}
private:
struct Node {
T value;
Node* next = nullptr;
Node(const T& v) : value(v) {}
};
std::atomic<Node*> head_;
};
这段代码里最值得琢磨的是 push。它的逻辑是:先读出当前栈顶指针 old_head,让新节点的 next 指向它,然后尝试用 compare_exchange_weak 把栈顶换成新节点。如果过程中有别的线程抢先入栈或出栈,CAS 会失败,同时把 old_head 更新为最新的栈顶,循环继续。
这里有个新手常见的困惑:为什么 compare_exchange_weak 的 first argument old_head 既是输入又是输出?因为这是 CAS 的设计——执行失败时,系统会把内存中的当前值写回这个引用,省去你手动再 load 一次的功夫,从而缩短竞争窗口。
pop 同样用 CAS 把栈顶从 old_head 换成 old_head->next。这段代码在生产环境其实还有不少问题,最严重的就是 ABA 问题:线程 A 读到的 old_head 被线程 B pop 掉之后又被另一个节点以相同地址 push 回来,CAS 会误以为没变化,从而把早已释放的节点当作有效节点。完整解决 ABA 需要引入带版本号的指针或者延迟回收机制,那就超出单篇博文范围了。我这里用 shared_ptr 托管节点只是让示例能跑,认真做无锁容器时,生命周期管理往往是比 CAS 本身更难的部分。
4. 内存序:原子操作的隐藏开关
4.1 为什么代码会乱序
很多初学者第一次接触内存序时都很懵:我写代码明明是“先写 payload,再置 ready”,凭什么别的线程可能看到反过来的顺序?
答案隐藏在编译器和 CPU 的行为里。编译器有优化器,它会重新排列不相关的指令以更好地利用流水线;CPU 也有乱序执行能力,在保证“单线程可见行为不变”的前提下,调整指令的执行顺序。对单线程来说,这些重排是透明的,因为最终结果一致。但多线程共享数据时,这种优化就可能把“先写 A 后写 B”的次序暴露成“B 已经可见,但 A 还没有”。
为了让程序在多线程下仍然符合直觉,C++ 标准引入了内存序(memory_order)的概念。每个原子操作都可以指定一个内存序,它告诉编译器和 CPU:这个操作允许重排到什么程度。
4.2 三种常用内存序
C++ 标准定义了六种枚举值,但日常用到最多的其实是下面三种:
| 内存序 | 含义 | 典型使用场景 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证任何顺序约束 | 计数器、唯一 ID 生成、只关心单值更新的场景 |
memory_order_acquire / memory_order_release |
形成“发布-消费”屏障:release 之前的写入不能被看到 release 之后;acquire 之后的读取不能提前到 acquire 之前 | 标志位同步、生产者-消费者模型中的状态发布 |
memory_order_seq_cst |
全序一致性,所有线程观察到的全局顺序一致,最安全也最严格 | 默认选择,适合逻辑复杂或一时想不清楚的场景 |
这里最需要记住的是 relaxed 不等于“没有原子性”,它只是对重排没有约束。回到 3.2 的就绪标志例子,如果用 relaxed,理论上消费者可能看到 ready 变为 true 时,payload 还是旧值。这个理论风险在 x86 上难以复现,但在弱内存序架构(比如 ARM)上真可能发生。
4.3 什么时候敢用 relaxed
我给团队定的规则很简单:先用默认的 seq_cst 把逻辑写对,再根据实测性能瓶颈决定是否降级。seq_cst 在 x86 上几乎开销可以忽略,因为 x86 本身提供了较强的一致性;到了 ARM 上,seq_cst 往往需要更重的同步指令,但只要不是每纳秒都在执行原子操作,这点开销通常无感。
真正应该用 relaxed 的条件很苛刻:你只关心某个值自身的读改写结果,完全不在乎它与其它内存操作的前后关系。经典例子就是 3.1 的计数器,以及用来生成唯一编号的 fetch_add:
cpp复制std::atomic<unsigned long long> sequence{0};
unsigned long long next_id = sequence.fetch_add(1, std::memory_order_relaxed);
这里每个线程拿到一个不重复的编号,编号之间没有顺序要求,也不需要基于其它共享变量做判断,relaxed 已经足够。如果硬要用 seq_cst,也没有正确性问题,纯粹是白付一点性能代价。
我在实际项目里见过滥用 relaxed 导致难排查 bug 的例子:有人为了让“读写更高效”,在状态机跳转里用了 relaxed,结果线程 B 明明看到状态变成了 RUNNING,却没看到状态切换前写入的配置项。这种问题隐蔽性极强,因为不是每次运行都会触发,偶尔失败很难自查。所以我的观点是:不经过 profile 确认这段代码确实是瓶颈,就不要动内存序。
5. 常见问题与排查技巧实录
5.1 只在 Release 下现形的妖怪
并发缺陷有一个共同特征:Debug 下编译慢、优化少,线程交错被认为是“碰巧”,跑 100 次可能都不出错;Release 开 -O2 之后,编译器重排、寄存器分配变化,问题立刻频繁暴露。
我建议团队里所有多线程代码,必须在 Release 构建下跑压力测试,至少要跑几十轮循环,每次都要验证结果。不要迷信“Debug 下没事”,那是假象。再进一步,可以用线程消毒器直接去抓数据竞争。
GCC 和 Clang 都支持 -fsanitize=thread。编译参数就是多一行:
bash复制g++ -std=c++11 -O1 -g -fsanitize=thread -pthread -o tsan_demo tsan_demo.cpp
跑起来后,只要发生数据竞争,TSan 会打印详细的“线程 A 在哪行写、线程 B 在哪行读”的报告,还能指出冲突的变量地址。注意 TSan 在这里可能和某些优化模式冲突,所以建议用 -O1 而不是 -O2,减少误报。
我用 TSan 抓到过一个隐藏最深的 bug:一个静态全局变量在没有锁的情况下被两个线程初始化。单看代码,它似乎只是“读一次、写一次”,没有任何竞争,但 TSan 明确报告这是未初始化读与写入的冲突——因为 C++11 规定,同一非原子变量的并发读写哪怕只有一次,也是未定义行为。改成函数局部静态变量后问题消失,因为 C++11 保证了函数内 static 变量的线程安全初始化。
5.2 原子变量也要小心“伪共享”
原子操作比锁轻量,但并不意味着原子变量之间不会互相拖累。CPU 读写数据是以缓存行为单位的,常见缓存行大小是 64 字节。假如两个线程频繁更新两个相邻的原子变量,而它们恰好落在同一个缓存行里,那么每个线程改自己那个变量时,都会导致整个缓存行在多个核之间反复“抢所有权”,性能可以掉到一个数量级。
解决方法是把经常被不同线程访问的原子变量按缓存行大小对齐,并填充到独立缓存行。C++11 里可以用 alignas:
cpp复制struct alignas(64) AlignedAtomicCounter {
std::atomic<int> counter{0};
};
AlignedAtomicCounter counter_a;
AlignedAtomicCounter counter_b;
如果不想手工计算,GCC 和 Clang 还提供了 __attribute__((aligned(64))),MSVC 对应 __declspec(align(64))。这类优化的前提是你已经确认确实存在伪共享导致的性能问题,先用 perf 或其余 profiling 工具确认 cache miss 再动手,不要每个变量都去对齐,平白浪费内存。
5.3 原子操作和 volatile,别再混为一谈
volatile 的语义是“告诉编译器,这个内存位置可能被外部因素修改,不要将其读写优化掉”。它不提供:
- 原子性:volatile int 的 ++ 仍然可能被拆成三步;
- 内存序约束:不阻止编译器和 CPU 重排;
- 线程间可见性保证:不加任何同步屏障。
很多从嵌入式或 C 语言转过来的朋友习惯用 volatile 做线程通信标志,这在理论上是不安全的。正确作法是:要么用 std::atomic,要么用 mutex。只有在写驱动或操作硬件映射寄存器时,volatile 才有它的本来意义。
5.4 选择合适的原子类型和编译选项
不同厂商的编译器对原子操作的支持略有差异。GCC 和 Clang 在 Linux 上对 std::atomic<int> 已经高度优化,x86 平台基本能直接映射到单条指令;MSVC 在 Windows 上需要注意 /volatile:ms 旧语义与 C++ 标准内存模型的区别,老项目如果开启兼容模式,volatile 变量会带有一点“伪原子”特性,容易掩盖问题——建议新代码一律显式使用 std::atomic,不要依赖 MSVC 对 volatile 的特殊处理。
另外,我在实际开发中很少直接使用 std::atomic<Struct> 这样实例化自定义结构体。标准库要求原子类型必须满足“可平凡复制”等条件,结构体稍大就容易退化成内部加锁的实现,表面上写着原子操作,底层其实包着一把锁,性能还不如直接上 mutex 来得好理解。只有像 std::atomic<int>、std::atomic<void*>、std::atomic<std::shared_ptr<T>> 这类标准库明确支持的类型,才值得放心使用。
写在最后的实践建议
这里没有硬生生的“总结”,只说几条我自己踩坑换来的体会。
先求正确,再求性能。并发代码的第一目标是让结果确定,不是让代码显得黑科技。能用 std::mutex 解决的,不要为了让别人觉得厉害而硬写无锁;真到了性能瓶颈,用原子操作也要先选默认的 seq_cst,验证完全正确后再尝试降级到 acquire/release 或 relaxed。
没有一种并发工具是银弹。原子操作适合计数器、标志位、状态切换这类轻量场景;复杂数据结构的共享还是要靠 mutex 或读写锁;自旋锁只在极短临界区才有性能优势,长临界区会白白灼烧 CPU。
最后,永远不要只在一个平台上验证并发正确性。x86 的强内存模型会掩盖很多重排问题,ARM 上可能一跑就崩。如果手里没有多架构机器,至少跑 TSan,再考虑能不能用静态分析工具扫一遍。这些工具不能证明程序没有 bug,但能帮你发现那些“偶现一次就够头疼”的竞争和乱序。
原子操作只是多线程工具箱里的一件工具,用对地方是性能利器,用错地方是维护噩梦。希望这篇文章里的实例和避坑经验,能让你在遇到真正生产问题时少揉一阵子太阳穴。
