在多线程编程中,如果共享的数据结构很复杂(比如链表、map、数据库缓存等),不同线程可能同时对它进行插入、删除等操作。
- 这些操作往往不是一步完成的,而是多个步骤组成的逻辑整体。
- 为了保证操作的完整性和一致性,必须使用互斥锁(
std::mutex
)来保护。
总结1:复杂共享数据 → std::mutex
不可或缺。
但如果只是对一个 整数计数器 进行读写,情况就很不同。
- 每次操作只是一条简单的赋值、加减。
- 如果还要为此加锁、解锁,显然显得过于“笨重”,既影响性能,又让代码更复杂。
总结2:简单共享数据 → std::mutex
太重了。
为了解决这种 “简单场景却要用锁”的尴尬,C++11 引入了 std::atomic
,它比锁更加轻量和高效。
接下来,我们将通过一个小例子来初步认识下 std::atomic 工具。
“启动两个线程,每个线程都对同一个计数器执行一百万次递增操作。”
这是一个简单场景下资源竞争问题。我们接下来使用用两种方式来解决,来观察下哪个代码更简单、性能更好一些:
- 使用
std::mutex
- 使用
std::atomic
#include <iostream> #include <thread> #include <mutex> #if 0 std::mutex my_mutex; int counter = 0; void worker() { for (int i = 0; i < 1000000; ++i) { my_mutex.lock(); ++counter; my_mutex.unlock(); } } #else std::atomic<int> counter(0); void worker() { for (int i = 0; i < 1000000; ++i) { ++counter; } } #endif void demo() { auto start = std::chrono::steady_clock::now(); std::thread t1(worker); std::thread t2(worker); t1.join(); t2.join(); auto end = std::chrono::steady_clock::now(); // 计算持续时间,单位:毫秒 auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); std::cout << "counter = " << counter << " time = " << duration.count() << std::endl; } int main() { demo(); return 0; }
由程序的执行结果,可以得出 2 个小结论:
- 对于简单的场景,std::atomic 比 std::mutex 更加高效
- 对于简单的场景,std::atomic 比 std::mutex 更加简洁
1. 基本使用
std::atomic 是 C++11 提供的原子类型模板,它保证对单个变量的读写操作具有原子性,提供一系列原子操作方法,从而在多线程环境中能够安全地访问和修改共享数据,而无需显式使用互斥锁。
比如一个普通的 counter = counter + 1
实际上需要三步:
- 读取 counter 的值(比如是 5)
- 加 1,变成 6
- 把 6 写回去
如果没有原子操作,线程 A 执行到第 2 步时,线程 B 刚好也开始执行这三步,它读到的还是旧值 5,结果两个线程都写回了 6,虽然本该变成 7。原子操作能把这三步合成一步,不让别的线程插进来,从而保证结果正确。也就是说:原子操作确保一个线程在操作共享变量时,其他线程必须等它完全执行完,才能继续操作。
注意:
std::atomic
只能保证一个变量的原子性,如果需要多个变量保持一致,还得用锁。
下面通过几个小例子,快速了解 std::atomic 提供的原子操作方法。
1.1 创建操作
struct Demo { Demo() { std::cout << "Demo 构造函数" << std::endl; } int a; int b; }; void demo01() { // 基本类型 std::atomic<char> ac('H'); std::atomic<int> ai(0); std::atomic<bool> ab(0); std::atomic<double> ad(3.14); // 指针类型 std::atomic<char*> ap1(nullptr); std::atomic<void*> ap2(nullptr); // 自定义类型 std::atomic<Demo> ao; }
对于自定义类型,如果类中包含以下成员,则无法作为 std::atomic 模板参数。
- 类型自定义的拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数
- 类型包含虚函数
- 包含引用成员
简单来讲:std::atomic
支持的类型必须是 trivially copyable(即可以通过 memcpy 正确拷贝)
#include <iostream> struct Demo { // 包含以下内容,该对象不再是简单的对象,atomic 编译会报错 // Demo(const Demo&) {} // Demo(Demo&&) {} // Demo& operator=(const Demo&) {} // Demo& operator=(Demo&&) {} // virtual void func() {} // ~Demo() {} // int& mem_ref; }; void test() { std::atomic<Demo> demo; } int main() { test(); return 0; }
1.2 基本操作
// 1. 基本操作 void demo01() { // atomic 维护了对 int 变量的原子操作 std::atomic<int> ai(0); // 读取值 // 注意:ai.load() 是原子性操作,但是赋值到 atomic_value 是非原子性的 int atomic_value = ai.load(); std::cout << "atomic_value = " << atomic_value << std::endl; // 设置值 // atomic 支持通过 store 函数,也支持通过赋值运算符来为原子变量设置值 ai.store(20); ai = 20; // 注意: atomic 并没有重载 << 运算符,这里是先将 ai 转换为 int,再 cout 输出 std::cout << "atomic_value = " << ai << std::endl; // 读取旧值,设置新值,返回旧值 // 注意:ai.exchange(30) 是原子性操作,返回值赋值到 old_value 是非原子性的 int old_value = ai.exchange(30); std::cout << "old_value = " << old_value << std::endl; std::cout << "new_value = " << ai << std::endl; } // 2. 不同类型支持的原子操作是不同的 void demo02() { // 2.1 对于 int 类型的原子变量,支持的操作较多 std::atomic<int> ai(0); ai += 10; // 等价于 ai.fetch_add(10); ai -= 10; // 等价于 ai.fetch_sub(10); ai |= 10; // 等价于 ai.fetch_or(10); ai &= 10; // 等价于 ai.fetch_and(10); ai ^= 10; // 等价于 ai.fetch_xor(10); ai++; ++ai; // 2.2 对于 double 类型的原子变量,并不支持上述操作 std::atomic<double> ad(3.14); std::atomic<bool> ab(true); // 2.3 指针类型原子操作 std::atomic<int*> ap(nullptr); ap++; ap--; ap += 2; ap -= 2; ap.fetch_add(2); ap.fetch_sub(2); }
1.3 CAS 操作
CAS(Compare-And-Swap 或 Compare-And-Exchange)是多线程编程里最核心的 原子操作,用来实现无锁同步。CAS 的操作逻辑是:
原子地执行: 如果 原子变量的当前值 == 预期值(expected) 则把原子变量更新为新值(desired),并返回 true 否则 把原子变量当前值写回到 expected,返回 false
函数接口为:
bool compare_exchange_strong(T& expected, T desired); bool compare_exchange_weak(T& expected, T desired);
- strong:只要原子变量值和
expected
相等,就一定返回true
,更新成功。
- weak:即使原子变量值和
expected
相等,也可能因为硬件或优化原因返回false
(虚假失败)。
接下来,通过一个例子,来直观理解 compare_exchange_strong
的用法。
- 程序创建了两个线程,每个线程循环 100 次。
- 每次循环线程都会尝试对共享原子变量
counter
进行如下操作:- 如果
counter == 20
,就重置为 0 - 否则将
counter
自增 1
- 如果
- 为了保证打印输出不混乱,使用
std::mutex
来保护std::cout
。
#include <iostream> #include <thread> #include <chrono> #include <mutex> std::atomic<int> counter(0); std::mutex my_mutex; void worker() { for (int i = 0; i < 100; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); int old_value = counter.load(); int new_value; do { new_value = (old_value == 20) ? 0 : old_value + 1; } while (!counter.compare_exchange_strong(old_value, new_value)); std::lock_guard<std::mutex> lock(my_mutex); std::cout << old_value << "\t" << std::this_thread::get_id() << std::endl; } } void demo() { std::thread t1(worker); std::thread t2(worker); t1.join(); t2.join(); } int main() { demo(); return 0; }
2. 细节探讨
std::atomic 的原子性实现主要依赖两种机制:
- CPU 硬件级别的原子操作:现代 CPU(如 x86、ARM)提供多种原子操作指令,可以直接对某些大小和对齐要求的类型实现高效的无锁原子操作
- 操作系统级别的同步机制:当类型过大、不满足对齐要求,或目标平台/编译器不支持对应硬件原子指令时,std::atomic 可能通过内部互斥锁(如 std::mutex)模拟原子性,以牺牲性能换取正确性
如何判断当前类型使用的是硬件级别的原子操作,还是系统级别的同步机制?
我们可以通过 std::atomic<T>::is_lock_free() 来判断,如果该函数返回 true 表示使用硬件原子指令实现;返回 false 则可能是基于系统级别的同步机制实现。
Windows(MSVC)通常采用类似下面的规则来判断某个类型是否支持 lock-free:
_TypeSize <= 8 && (_TypeSize & (_TypeSize - 1)) == 0;
- 类型大小小于等于 8 字节(64 位)
- 类型型大小为 2 的幂次方(1、2、4、8 字节)。
到这里,有些同学可能会想,C++ 中的类型还有奇数字节大小的?
比如下面的程序中,结构体被设置为 1 字节对齐,导致 Demo 的大小为 5 字节,虽然小于 8 字节,但是不支持 std::atomic
#include <iostream> // 设置 struct 按照 1 字节对齐 #pragma pack(push, 1) struct Demo { int a; char c; }; void test() { // 输出:5 std::cout << sizeof(Demo) << std::endl; std::atomic<Demo> demo; // 输出:false std::cout << std::boolalpha << demo.is_lock_free() << std::endl; } int main() { test(); return 0; }
注意:is_lock_free() 是与平台、CPU 架构、编译器实现以及实例内存对齐情况紧密相关的动态判断。
从这里,可以看到,std::atomic 主要是针对简单数据场景下资源竞争问题的一种方案,如果对象较为复杂,这就不是 std::atomic 的目标了。
3. 内存顺序
在多线程编程中,std::atomic
提供了对共享变量的原子操作,确保单次读写不会被中断,从而避免“写一半”“读脏数据”等问题。这是多线程安全的基础保障。
但是,仅有原子性还不够,还需要注意两个额外的问题:
- 指令重排:编译器或 CPU 为了优化性能,可能调整代码执行顺序。在单线程里没问题,但在多线程中,别的线程可能看到的执行顺序和代码写的顺序不一致,从而出错。
- 修改顺序一致性:即使所有修改操作都是原子的,不同线程看到的修改先后次序也可能不一样,就像“历史版本”不统一。如果程序依赖于这个顺序,就可能出错。
3.1 指令重排
编译器和 CPU 为了让程序跑得更快,有时候会偷偷调整代码执行顺序,只要在单线程里程序逻辑不变就行。例如:
int a = 1; int b = 2;
你写的代码顺序是 a = 1
然后 b = 2
,但编译器或 CPU 可能会为了效率,先执行 b = 2
,再执行 a = 1
,只要这不会影响最终的单线程结果就行。
这些重排在单线程环境下不会影响程序逻辑,但在多线程环境中,可能让代码看起来没问题,但实际上存在严重的问题。这是因为重排会破坏 “我们认为的执行顺序”,导致并发程序中产生不可预测的结果。例如:
std::atomic<bool> ready{ false }; int data = 0; // 线程 A void thread_writer() { // 普通写入 data = 42; // 原子标志位 ready.store(true); } // 线程 B void thread_reader() { // 等待标志位为 true while (!ready.load()); // 可能触发断言失败! assert(data == 42); }
从代码执行来看,线程 B 看到的是 ready 被修改为 true 时,data 肯定已经先被设置为 42 了。但是,由于指令重排的存在,编译器或 CPU 可能会将线程 A 的执行顺序修改:
void thread_writer() { // 原子标志位 ready.store(true); // 普通写入(重排到 ready 写操作之后) data = 42; }
此时,thread_reader 会读取到 ready == true,但是 data == 0 的情况,导致触发断言。再回到 std::atomic,它只能保证操作的原子性,对于指令重排可能导致的隐藏的问题,需要通过设置内存顺序来解决。
C++ 中提供了以下四种内存顺序:
- memory_order_relaxed:当前线程里,编译器和 CPU 可以随便重排这条原子操作的前后指令。
- memory_order_release:保证这条原子写 之前 的所有普通读写操作,都不能被重排到它后面。
- memory_order_acquire:保证这条原子读 之后 的所有普通读写操作,都不能被重排到它前面。
- memory_order_acq_rel:既阻止前面的操作被重排到后面,也阻止后面的操作被重排到前面。
A1; A2; // 原子操作前后的代码任意重排。 atomic_var.store(x, std::memory_order_relaxed); B1; B2;
A1; A2; // 原子操作之前的代码不会被重排到 store 之后。但对 B1、B2 没有约束。 atomic_var.store(x, std::memory_order_release); B1; B2;
A1; A2; // 保证原子操作之后的代码(B1, B2)不会被重排到 load 之前,但对 A1、A2 没有约束。 atomic_var.load(std::memory_order_acquire); B1; B2;
A1; A2; // 保证 A1、A2 不会被重排到 store 之后,B1、B2 不会被重排到 store 之前 atomic_var.fetch_add(x, std::memory_order_acq_rel); B1; B2;
3.2 修改顺序一致性
原子操作虽然能保证单次读写的原子性,但并不保证所有线程看到的修改顺序是一致的。
来看一个例子:
std::atomic<int> x{0}; // 线程 T1 x.store(1, std::memory_order_release); // 线程 T2 x.store(2, std::memory_order_release); // 线程 A int a1 = x.load(); int a2 = x.load(); // 线程 B int b1 = x.load(); int b2 = x.load();
可能出现的情况是:
- 线程 A 读到的结果是
1 → 2
- 线程 B 读到的结果是
2 → 1
这说明:
- 在 A 看来,是 T1 先写了
1
,然后 T2 写了2
。 - 在 B 看来,却是 T2 先写了
2
,然后 T1 写了1
。
换句话说,每个线程都能看到变量被修改过,但“修改的先后顺序”并没有全局统一的共识。
这就是所谓的 全局修改顺序不一致问题。
如果程序逻辑依赖于“到底是谁先写的”,就可能出错。
为了解决这个问题,可以使用更强的内存顺序:
memory_order_seq_cst
(Sequentially Consistent)
它不仅保证原子操作本身的原子性,还额外保证所有线程对这些原子修改的观察顺序是一致的。
这样一来,所有线程都会认为:要么 1 → 2
,要么 2 → 1
,但不会出现 A 和 B 的看法不一样的情况。