C++原子操作详解:深入理解多线程并发编程的基石
一、原子性与数据竞争
原子性(atomicity):一个操作要么全部完成、要么全部不发生 —— 对并发线程来说,其他线程不会看到“中间状态”。
数据竞争(data race):两个或更多线程并发访问同一内存位置,并且至少有一个写操作,且这些访问没有被某种同步机制(原子操作或互斥)序列化 —— 根据 C++ 标准,这会导致未定义行为。
简言之:想避免数据竞争,就需要同步——原子操作或(mutex/condition_variable 等)。
1.1、原子操作与锁的比较
| 特性 | 原子操作 (std::atomic) | 锁 (std::mutex, std::shared_mutex) |
|---|---|---|
| 同步粒度 | 单个变量级别 | 任意代码块或多变量 |
| 实现机制 | 硬件原子指令(如 x86 的 LOCK CMPXCHG) | 内核/用户态互斥机制(可能导致线程切换) |
| 性能特征 | 轻量、高速(无锁/低延迟) | 较重、涉及线程调度(但可避免忙等) |
| 阻塞特性 | 非阻塞(busy-wait 或循环重试) | 阻塞等待(由 OS 调度唤醒) |
| 适用场景 | 计数器、标志位、无锁队列、引用计数 | 数据结构保护、复杂状态同步、资源管理 |
| 可见性保证 | 内存序(memory_order)控制 | 自动保证可见性和顺序 |
| 可组合性 | 差(多变量时复杂) | 强(可锁住多个对象或范围) |
| 调试与维护难度 | 高,容易出 subtle bug | 低,更易理解和维护 |
在实际工程中:
- 原子操作 适合计数器、引用计数、自旋锁等;
- 互斥锁(mutex) 适合复杂逻辑、跨多步操作的同步。
二、为什么需要原子操作?
考虑以下简单的计数器示例:
#include <iostream>
#include <thread>
#include <vector>
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 非原子操作
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
理论上,最终结果应该是 1,000,000,但由于 ++counter 不是原子操作,实际运行结果往往小于这个值。
如果加锁的话只是对一个整数计数器 进行读写,每次操作只是一条简单的赋值、加减,如果还要为此加锁、解锁,显然显得过于“笨重”,既影响性能,又让代码更复杂。为了解决这种 “简单场景却要用锁”的尴尬,在C11中引入了std::atomic,使得代码更加高效。
三、C++原子类型
C++11 引入了 <atomic> 头文件,提供了跨平台、无锁的原子操作接口:
3.1、基本原子类型
#include <atomic>
struct CTest
{
int a;
int b;
};
//基本数据类型
std::atomic<char> ac('C');
std::atomic<int> ai(0);
std::atomic<bool> ab(0);
std::atomic<double> ad(6.66);
//指针类型
std::atomic<char*> ptr1(nullptr);
std::atomic<void*> ptr2(nullptr);
//自定义类型
std::atomic<CTest> test;
3.2、原子操作示例
#include <iostream>
#include <thread>
#include <vector>
#include <atomic>
std::atomic<int> atomic_counter{0};
void atomic_increment() {
for (int i = 0; i < 100000; ++i) {
atomic_counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(atomic_increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final atomic counter value: " << atomic_counter << std::endl;
return 0;
}
这次,无论运行多少次,结果都是正确的 1,000,000。
四、常用的原子操作
4.1、加载和存储
std::atomic<int> value{0};
// 存储值
// atomic 支持通过 store 函数或直接赋值运算符来为原子变量设置值
value.store(42, std::memory_order_release);
value = 42;
// 读取值
int loaded = value.load(std::memory_order_acquire);
// 读取旧值,设置新值,返回旧值
int old_value = value.exchange(20);
4.2、读-修改-写操作
std::atomic<int> x{0};
// 原子加法
x.fetch_add(5); // 等同于 x += 5
// 原子减法
x.fetch_sub(3); // 等同于 x -= 3
// 原子与操作
x.fetch_and(0x0F); // 等同于 x &= 0x0F
// 原子或操作
x.fetch_or(0xF0); // 等同于 x |= 0xF0
// 原子异或操作
x.fetch_xor(0xFF); // 等同于 x ^= 0xFF
4.3、比较交换(Compare-and-Swap)
CAS(Compare-And-Swap 或 Compare-And-Exchange)是多线程编程里最核心的原子操作,用来实现无锁同步。CAS 的操作逻辑是:
原子执行
if(原子变量当前值 == 预期值expected)
{
原子变量当前值 = 新值desired;
return true;
}
else
{
原子变量当前值 = 预期值expected;
return false;
}
示例:
std::atomic<int> data{0};
int expected = 0;
int desired = 42;
// 如果 data == expected,则 data = desired
bool success = data.compare_exchange_strong(expected, desired);
bool success = data.compare_exchange_weak(expected, desired);
- strong:只要原子变量值和
expected相等,就一定返回true,更新成功。 - weak:即使原子变量值和
expected相等,也可能因为硬件或优化原因返回false(虚假失败)。
两个版本的使用场景与原因:
- weak 版本:在条件满足时也可能返回
false,这种情况称为 虚假失败(spurious failure)。出现虚假失败的原因通常是底层硬件指令的限制。由于实现更简单,weak在某些平台上往往比strong更高效,常用于循环重试的场景。 - strong 版本:只要条件满足,就一定返回
true,不会出现虚假失败。不过实现上可能更复杂,在部分平台上性能略低于weak。
注意:在一些平台上,两者的实现完全相同,不存在性能差异。
五、内存顺序(Memory Order)
内存顺序指定了原子操作周围的内存访问如何排序,是理解原子操作的关键:
需注意的问题:
- 指令重排:编译器或 CPU 为了优化性能,可能调整代码执行顺序。在单线程里没问题,但在多线程中,别的线程可能看到的执行顺序和代码写的顺序不一致,从而出错。
- 修改顺序一致性:即使所有修改操作都是原子的,不同线程看到的修改先后次序也可能不一样,就像“历史版本”不统一。如果程序依赖于这个顺序,就可能出错。
5.1、六种内存顺序
#include <atomic>
std::atomic<int> x{0};
std::atomic<int> y{0};
int data = 0;
void memory_order_demo() {
// 最宽松的顺序,只保证原子性
x.store(1, std::memory_order_relaxed);
// 释放操作:保证之前的读写不会重排到之后
data = 42;
x.store(2, std::memory_order_release);
// 获取操作:保证之后的读写不会重排到之前
int tmp = y.load(std::memory_order_acquire);
// 顺序一致性:最严格的顺序,所有线程看到的操作顺序一致
x.store(3, std::memory_order_seq_cst);
}
5.2、内存顺序使用场景
#include <atomic>
#include <thread>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // 准备数据
ready.store(true, std::memory_order_release); // 发布数据
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { // 等待数据就绪
// 忙等待或 yield
}
std::cout << "Data: " << data << std::endl; // 安全读取数据
}
六、原子标志和自旋锁
6.1、使用原子标志实现简单的自旋锁
#include <atomic>
class SpinLock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 自旋等待
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
SpinLock spinlock;
int shared_data = 0;
void critical_section() {
spinlock.lock();
++shared_data; // 临界区
spinlock.unlock();
}
七、原子指针
#include <atomic>
struct Node {
int data;
Node* next;
};
std::atomic<Node*> head{nullptr};
void push_front(int value) {
Node* new_node = new Node{value, nullptr};
new_node->next = head.load();
while (!head.compare_exchange_weak(new_node->next, new_node)) {
// CAS 失败,重试
}
}
八、使用限制
8.1、为什么会有限制?
原子性(atomicity)在底层是由 CPU 指令直接支持的。
CPU 要实现“原子操作”,必须确保:
对目标内存的访问(读或写)不会被中断,也不会与其他线程/核心的访问交叉。
这意味着:
- 操作必须在 单条 CPU 指令 内完成;
- 操作的数据必须能 在总线事务中一次性完成读写。
👉 所以:
原子操作的字节数必须在 CPU 硬件支持的“原子访问粒度”范围内。
8.2、不同 CPU 架构对原子访问的支持
| 架构 | 原子访问支持的典型字节数 | 说明 |
|---|---|---|
| x86 / x64 | 1、2、4、8 字节 | LOCK 前缀保证总线锁 |
| ARMv8 (AArch64) | 1、2、4、8、16 字节(部分情况) | 依赖 LDXR/STXR 指令对齐要求 |
| RISC-V | 通常 4、8 字节 | AMO 指令对齐访问 |
| PowerPC / SPARC | 通常 4、8 字节 | 指令对齐访问 |
注意:
- 未对齐的访问(unaligned access) 即使是支持的字节数,也可能无法原子完成。
- 超过支持字节数(例如 16 字节的结构体)将无法使用无锁原子操作,C++ 库会自动使用锁模拟。
8.3、C++ 层面对原子操作字节数的规定
C++ 标准([ISO/IEC 14882])中并没有硬编码规定“多少字节必须原子”。
但它要求实现必须提供 std::atomic<T>,前提是该类型 可以在该平台上原子访问。
1. 关键判断函数
std::atomic<T> a;
bool lockfree = a.is_lock_free();
- 返回
true→ 平台可用无锁原子指令 - 返回
false→ 实现内部会使用锁(例如std::mutex)来模拟原子性
2. C++ 标准保证的最小支持
所有 指针类型 和 整型类型(1、2、4字节) 都必须可作为原子类型。
也就是说:
std::atomic<bool>std::atomic<char>std::atomic<int>std::atomic<long>std::atomic<void*>
这些在任何实现上都是可用的。
8.4、实际编译器/平台支持差异(以 GCC/Clang + x86 为例)
| 类型 | 大小 | 是否 lock-free |
|---|---|---|
std::atomic<bool> | 1 字节 | ✅ |
std::atomic<char> | 1 字节 | ✅ |
std::atomic<short> | 2 字节 | ✅ |
std::atomic<int> | 4 字节 | ✅ |
std::atomic<long> | 8 字节(x64) | ✅ |
std::atomic<long long> | 8 字节 | ✅ |
std::atomic<void*> | 8 字节 | ✅ |
std::atomic<__int128> | 16 字节 | ⚠️ 仅部分平台支持 |
std::atomic<struct {...}> | 任意 | ❌ 通常需锁实现 |
如果类型超过 8 字节,C++ 可能仍允许定义 std::atomic<T>,但其实现会自动退化为“带锁版本”。
8.5、对齐(Alignment)要求
1. 对齐要求的原因
原子操作必须在单个 CPU 总线事务中完成。
若变量的地址跨越缓存行或总线宽度边界,则可能无法原子化。
因此:
所有原子类型都必须是自然对齐(naturally aligned)的。
2. 举例说明
#include <atomic>
#include <iostream>
#include <cstddef>
struct alignas(8) MyData {
int a;
int b;
};
std::atomic<MyData> data; // 要求 8 字节对齐
如果编译器检测到 MyData 未满足对齐要求,可能报错或退化为锁实现。
8.6、超出硬件限制的数据怎么办?
当你的数据结构超过硬件原子大小(例如一个 64 字节的结构体),
std::atomic<T> 仍然可以声明,但实现将退化为“带锁”的形式。
例如:
struct BigData {
int a[16]; // 64 字节
};
std::atomic<BigData> big; // 合法,但非 lock-free
在这种情况下:
big.is_lock_free()→false- 编译器会在内部使用一个全局锁(
std::mutex)来保证线程安全。
8.7、如何在工程中判断与使用
1. 检查是否 lock-free
std::atomic<int64_t> x;
if (x.is_lock_free())
std::cout << "无锁原子操作\n";
else
std::cout << "退化为锁实现\n";
2. 使用对齐控制
struct alignas(8) Counter {
long value;
};
std::atomic<Counter> counter;
确保 alignas() 与 CPU 总线宽度(通常是 8)一致。
3. 控制结构体大小
如需保证 lock-free:
- 仅包含基本类型(不超过 8 字节)
- 避免嵌套数组或字符串
- 明确对齐
九、最佳实践
-
选择合适的原子类型:对于简单数据类型,使用对应的原子类型
-
使用恰当的内存顺序:在保证正确性的前提下使用最宽松的内存顺序
-
避免虚假共享:
struct alignas(64) PaddedAtomic { // 缓存行对齐 std::atomic<int> value; }; -
谨慎使用 CAS 循环:避免在高竞争场景下的活锁
总结
C++原子操作为编写高效、正确的并发程序提供了强大的工具。理解原子性、内存顺序以及各种原子操作的使用场景,是掌握现代C++并发编程的关键。通过合理使用原子操作,我们可以在保证线程安全的同时,获得比传统锁机制更好的性能。
记住,原子操作虽然强大,但并不是所有并发问题的银弹。在设计并发系统时,应该根据具体需求选择最合适的同步机制。
更多推荐



所有评论(0)