6.3、std::atomic(原子操作)
什么是std::atomic,为啥要用它?
如果,你在写一个多线程程序,多个线程同时操作一个共享变量,比如一个计数器。如果不加任何保护措施,线程 A 可能刚读到值还没来得及更新,线程 B 就又改了这个值,结果就是数据混乱,程序行为不可预测。这种问题叫“数据竞争”(Data Race),是多线程编程的大敌。
在 C++11 之前,解决这个问题通常得用锁,比如 std::mutex
,但锁的开销不小,尤其是在高并发场景下,频繁加锁解锁会严重拖慢性能。std::atomic
的出现就是为了解决这个痛点。它是一个模板类,定义在 <atomic>
头文件中,提供了一种“无锁”的方式来保证对变量的操作是“原子”的。啥叫原子?简单说,就是操作要么全做完,要么啥也没做,不会被其他线程打断。
std::atomic
的核心优势在于效率。它直接利用底层硬件支持的原子指令(比如 Compare-and-Swap,简称 CAS),或者编译器的内置函数,在不加锁的情况下确保线程安全。对于简单的共享变量操作,比如自增、自减、赋值等,用 std::atomic
比用锁快得多。
std::atomic的基本用法和底层原理
先来看看怎么用。假设我们要实现一个多线程计数器,用 std::atomic<int>
包裹一个整数变量:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0); // 定义一个原子整数,初始值为 0
void increment() {
for (int i = 0; i < 1000000; ++i) {
counter++; // 原子自增操作,线程安全
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "最终计数: " << counter << std::endl; // 预期输出 2000000
return 0;
}
这段代码中,counter++
是原子操作,即使两个线程同时执行,也不会出现数据竞争。最终结果一定是 2000000,而如果用普通 int
变量,结果会是随机的,因为普通变量的自增操作会被分解成多个步骤(读取、修改、写入),容易被其他线程干扰。
再来说说底层原理。std::atomic
的实现通常依赖于硬件提供的原子指令,比如 x86 架构上的 lock
前缀指令,或者 ARM 架构上的 Load-Linked/Store-Conditional 指令。这些指令保证操作的不可分割性。此外,编译器(如 GCC)也提供了内置函数,比如 __atomic_add_fetch
,来支持原子操作。std::atomic
实际上是对这些硬件和编译器能力的封装,让开发者不用直接面对底层细节,就能写出线程安全的代码。
除了自增自减,std::atomic
还支持其他操作,比如 load()
(原子读取)、store()
(原子写入)、exchange()
(原子交换)和 compare_exchange_weak/strong()
(比较并交换,简称 CAS)。这些操作都支持内存序(Memory Order)参数,可以进一步优化性能,但默认情况下用最严格的顺序一致性(std::memory_order_seq_cst
)就够了。
深度案例:用std::atomic实现无锁栈
为了让你更深刻地理解 std::atomic
,咱们来看一个有深度的案例:实现一个无锁栈(Lock-Free Stack)。无锁数据结构是并发编程中的高级话题,std::atomic
在这里能大显身手。咱们的目标是多个线程可以同时向栈中压入和弹出元素,而不需要用锁。
先上代码:
#include <atomic>
#include <thread>
#include <iostream>
#include <memory>
template<typename T>
struct Node {
T data;
Node* next;
Node(const T& val) : data(val), next(nullptr) {}
};
template<typename T>
class LockFreeStack {
private:
std::atomic<Node<T>*> head; // 栈顶指针,原子变量
public:
LockFreeStack() : head(nullptr) {}
void push(const T& val) {
Node<T>* newNode = new Node<T>(val); // 创建新节点
Node<T>* oldHead = head.load(); // 原子读取当前栈顶
do {
newNode->next = oldHead; // 新节点的 next 指向当前栈顶
} while (!head.compare_exchange_weak(oldHead, newNode)); // CAS 操作,直到成功
}
bool pop(T& result) {
Node<T>* oldHead = head.load(); // 原子读取当前栈顶
do {
if (oldHead == nullptr) {
return false; // 栈为空
}
result = oldHead->data; // 暂存数据
} while (!head.compare_exchange_weak(oldHead, oldHead->next)); // CAS 操作,直到成功
delete oldHead; // 释放旧栈顶节点
return true;
}
~LockFreeStack() {
T dummy;
while (pop(dummy)) {} // 清空栈
}
};
void pushTask(LockFreeStack<int>* stack) {
for (int i = 0; i < 1000; ++i) {
stack->push(i);
}
}
void popTask(LockFreeStack<int>* stack) {
int val;
for (int i = 0; i < 500; ++i) {
while (!stack->pop(val)) {} // 忙等待,直到弹出成功
}
}
int main() {
LockFreeStack<int> stack;
std::thread t1(pushTask, &stack);
std::thread t2(pushTask, &stack);
std::thread t3(popTask, &stack);
std::thread t4(popTask, &stack);
t1.join();
t2.join();
t3.join();
t4.join();
std::cout << "无锁栈操作完成" << std::endl;
return 0;
}
案例解析:底层细节与设计思路
这个无锁栈的核心在于 std::atomic<Node<T>*>
类型的 head
,它代表栈顶指针。咱们重点分析 push
和 pop
操作的实现。
在 push
操作中,我们先创建一个新节点,然后用 head.load()
原子读取当前栈顶指针。接着用 compare_exchange_weak
进行 CAS 操作:如果 head
还是我们读取时的值(说明没被其他线程改过),就把它更新为新节点;如果失败(说明其他线程抢先改了 head
),就更新 oldHead
为最新值,重新尝试。这个过程是无锁的,靠 CAS 保证线程安全。
pop
操作类似,也是用 CAS 尝试把 head
从当前栈顶更新为下一个节点。如果成功,就返回弹出的值;如果失败,说明其他线程抢先操作了栈顶,就重试。注意这里用的是 compare_exchange_weak
,因为它在某些架构上性能更好,虽然可能有“虚假失败”(即即使值没变也返回失败),但在循环中重试可以解决问题。
从底层看,CAS 操作依赖硬件指令,比如 x86 的 cmpxchg
。每次 CAS 成功,意味着我们原子地完成了栈顶更新;失败则意味着有竞争,但无锁设计的好处是不会阻塞线程,只是重试而已。相比用 std::mutex
保护整个栈,std::atomic
让竞争集中在栈顶指针上,减少了锁争用,提升了并发性能。
这个案例的价值在于,它展示了 std::atomic
如何在复杂数据结构中实现无锁并发。这不仅是技术上的挑战,也是对并发编程思维的锻炼。理解了这个案例,你对原子操作的适用场景和性能优势会有更深的体会。
常见错误使用:别踩这些坑
虽然 std::atomic
很强大,但用不好也容易出问题。以下是几个常见误区,我在实际项目和代码审查中经常遇到:
- • 误以为所有操作都原子:
std::atomic
只保证特定操作(如++
、--
、load
、store
)是原子的。如果你写myAtomic = myAtomic.load() + 1;
,读取是原子的,但整个表达式不是,因为读取和写入之间可能被其他线程干扰。正确做法是用fetch_add
这样的原子操作。 - • 忽略内存序的影响:
std::atomic
的操作可以指定内存序,比如std::memory_order_relaxed
性能更高,但可能导致操作重排,破坏逻辑。初学者建议用默认的顺序一致性,避免复杂问题。 - • 用在不适合的场景:
std::atomic
适合简单变量操作,如果你要保护一个复杂数据结构或多行代码,还是得用锁。无锁编程虽然高效,但调试难度大,容易引入微妙 bug。
面试中可能遇到的问题
在面试中,std::atomic
是个高频考点,尤其是在并发编程相关岗位。以下是几个典型问题和我的建议:
- • 什么是原子操作?std::atomic和std::mutex的区别是什么?
回答时要突出原子操作的不可分割性,强调std::atomic
是无锁的,适合变量级别的保护,效率高;而std::mutex
是锁机制,适合代码段保护,但开销大。可以结合计数器例子说明两者的适用场景。 - • 解释compare_exchange_weak和compare_exchange_strong的区别。
重点是weak
版本可能有虚假失败(即使值没变也返回失败),适合循环重试,性能更好;strong
版本只有值真不同才失败,但性能稍差。结合无锁栈的push
操作举例,说明weak
版本的适用性。 - • 如何用std::atomic实现一个简单的自旋锁?
可以写一个用std::atomic<bool>
实现的自旋锁,核心是用exchange
或compare_exchange_weak
尝试获取锁,失败就循环重试。记得提到自旋锁适合短时间等待的场景,否则浪费 CPU。
std::atomic的哲学与未来
我认为 std::atomic
的设计不仅是技术上的突破,更是并发编程哲学的体现。它鼓励开发者从“锁思维”转向“无锁思维”,用硬件能力直接解决问题,减少系统开销。但我也想提醒大家,无锁编程不是银弹,它对代码的正确性要求极高,一个小失误可能导致难以调试的 bug。因此,我的建议是:用 std::atomic
时,先从简单场景入手,熟练后再挑战复杂无锁数据结构。
展望未来,随着硬件架构的演进,原子操作的支持会更强大,C++ 标准也在不断完善内存模型和原子操作的语义。学习 std::atomic
不仅是掌握一个工具,更是理解现代并发编程的核心思想,这对你的职业发展大有裨益。
本文首发于【讳疾忌医-note】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)





赶快来坐沙发