两个线程同时对一个int变量做一万次自增,最后结果往往不是两万,而是一个小于两万的数字。原因很简单:i++实际上分三步执行——读取、加一、写回。线程A刚读完值,线程B也读了同一个值,两个线程各自加一写回,两次自增就丢了一次。传统解法是加互斥锁,但锁的开销和死锁风险让人头疼。C++11引入的std::atomic正是为此而生:它让这些操作在硬件层面变得不可分割,也就是所谓的原子操作,从而实现不依赖锁的线程安全,即无锁编程。

原子操作的底层原理:CPU如何保证不可分割
要理解std::atomic,得先明白编译器和CPU会对普通代码做哪些“破坏”。编译器为了优化性能,可能把变量缓存在寄存器里,多个循环内的读写合并成一次;CPU为了提速,又会乱序执行指令、让每个核心持有独立的缓存行。这些手段在单线程下天衣无缝,到了多线程环境下就会导致一个核心的修改迟迟不被另一个核心看见。
原子操作的本质,是借助CPU提供的特殊指令在硬件层面“锁定”执行路径。x86平台上的LOCK前缀指令(如lock add、lock cmpxchg)会在执行期间锁定缓存行或总线,保证同一时刻只有一个核心能操作该内存位置。ARM平台则采用LL/SC(Load-Linked/Store-Conditional)机制,通过标记监视地址来实现条件写入。std::atomic就是对这些底层指令的C++封装,同时告诉编译器:这个变量不许优化、不许重排相关指令。
一个关键概念是std::atomic<T>是否“无锁”(lock-free)。可以用is_lock_free()成员函数检测:如果类型大小不超过CPU原生支持的字长(通常8字节),基本都能映射到单条硬件指令,属于真正的无锁;如果塞进一个很大的结构体,标准库可能退化为内部加锁实现,此时性能优势荡然无存。std::atomic<int>、std::atomic<pointer>是最常用也最放心的选择。
std::atomic常用API与基本用法
先看最基本的用法。把共享变量声明为atomic类型后,普通的读写、自增自减、加减赋值都能自动获得原子性,不需要手动调用任何函数:
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 10000; ++i) {
counter++; // 原子自增,等价于 fetch_add(1)
counter += 2; // 原子加法
}
}
int main() {
std::vector<std::thread> ts;
for (int i = 0; i < 4; ++i) ts.emplace_back(worker);
for (auto& t : ts) t.join();
std::cout << counter.load() << std::endl; // 稳定输出 120000
return 0;
}除了运算符重载,几个核心成员函数必须掌握:load()原子地读取值;store(v)原子地写入值;exchange(v)原子地换成新值并返回旧值;compare_exchange_weak/strong(expected, desired)是比较交换(CAS)操作,如果当前值等于expected就写入desired并返回true,否则把当前值写回expected并返回false。CAS是无锁算法的基石,后面会详细讲。
注意一个细节:std::atomic<bool>可以用作开关标志,std::atomic<T*>可以原子地修改指针,但atomic类型不支持拷贝构造和拷贝赋值,因为它内部可能被锁保护或需要特殊指令,复制语义没有意义。如果要把值传出来,用load();要传进去,用store()。
内存顺序:无锁编程最容易踩的坑
原子操作解决的是“单个操作不可分割”,但多线程正确性还依赖另一个维度——内存顺序,即一个线程的写操作何时对其他线程可见。C++11定义了六种内存顺序,从最宽松到最严格分别是memory_order_relaxed、consume、acquire、release、acq_rel和seq_cst。
默认的seq_cst(顺序一致性)最安全也最慢,它保证所有线程看到的全局操作顺序一致。而relaxed只保证操作本身的原子性,不提供任何同步语义,适合纯粹的计数器——比如统计打点次数,不关心它与其他数据的先后关系,此时用relaxed能拿到最好的性能:
std::atomic<int> hits{0};
void on_request() {
hits.fetch_add(1, std::memory_order_relaxed); // 只需要计数,不需要同步
}release和acquire是一对配合使用的顺序:写线程在store时用memory_order_release,读线程在load时用memory_order_acquire,就能建立“同步于”关系——写线程在store之前的所有写操作,对读线程load之后的代码全部可见。这是实现自旋锁、发布数据的标准手法。典型例子如下:
std::atomic<bool> ready{false};
int payload = 0; // 普通变量,靠atomic的内存顺序保护
void producer() {
payload = 42; // 先写数据
ready.store(true, std::memory_order_release); // 再发布标志
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { // 自旋等待
std::this_thread::yield();
}
// 此处读 payload 一定能看到 42
assert(payload == 42);
}如果把上面的release和acquire都换成relaxed,断言就可能失败,因为CPU和编译器都可能把payload = 42重排到store之后。实践经验是:除非明确知道自己在做什么(比如纯计数),否则老老实实用默认的seq_cst;性能敏感且逻辑清晰时,再精确选release/acquire组合,这是性能与安全的最佳平衡点。
实战:用CAS实现自旋锁和无锁栈
有了CAS,就能构造更复杂的无锁结构。先看一个简单的自旋锁——忙等待形式的锁,适合临界区极短的场景:
class SpinLock {
std::atomic<bool> locked_{false};
public:
void lock() {
// exchange 返回旧值,false 说明之前没被锁,成功拿到锁
while (locked_.exchange(true, std::memory_order_acquire)) {
// 拿锁失败就继续自旋,真实项目中可加 pause 指令降低功耗
}
}
void unlock() {
locked_.store(false, std::memory_order_release);
}
};
SpinLock mtx;
int shared_data = 0;
void add_one() {
mtx.lock();
++shared_data;
mtx.unlock();
}再进一步,实现一个无锁栈。核心思路是用compare_exchange_weak在头节点上做“乐观并发”:读出当前头指针,基于它构造新状态,然后尝试CAS替换,失败说明别的线程抢先修改了,就重读重试。
template <typename T>
class LockFreeStack {
struct Node {
T value;
Node* next;
Node(const T& v) : value(v), next(nullptr) {}
};
std::atomic<Node*> head_{nullptr};
public:
void push(const T& v) {
Node* node = new Node(v);
node->next = head_.load(std::memory_order_relaxed);
// CAS 失败则 head 已被其他线程修改,重读 next 后重试
while (!head_.compare_exchange_weak(node->next, node,
std::memory_order_release,
std::memory_order_relaxed)) {
// 循环体内不需要额外代码,compare_exchange_weak 已更新 node->next
}
}
T pop() {
Node* old = head_.load(std::memory_order_acquire);
while (old &&
!head_.compare_exchange_weak(old, old->next,
std::memory_order_acquire,
std::memory_order_acquire)) {
}
if (old == nullptr) throw std::runtime_error("stack empty");
T result = std::move(old->value);
delete old; // 注意:多线程下 delete 存在 ABA 与悬垂风险
return result;
}
};这段代码在push上是正确的无锁实现,但pop里注释指出了真问题:无锁编程的深水区是内存回收。假设线程A刚读完old指针,线程B把old弹出并delete了,线程A再访问old->next就是未定义行为;即使改成延迟回收,还会遇到经典的ABA问题——指针地址被新节点复用,导致CAS误判“没变过”。工业级方案通常借助风险指针(hazard pointer)、.epoch回收(RCU思想)或直接换用带引用计数的std::shared_ptr配合std::atomic<std::shared_ptr>(C++20起支持)。这印证了一条铁律:无锁不等于简单,每一个“看起来没问题”的无锁结构,都需要经过严谨的正确性论证。
无锁编程的适用边界与选型建议
无锁不是银弹。互斥锁的代价主要是线程挂起唤醒的上下文切换(微秒级)以及可能的缓存污染,而atomic操作的代价是缓存行争用——多个核心频繁抢同一个缓存行时,硬件维护一致性的开销会急剧上升,甚至比加锁更慢。经验法则是:临界区只有一两条指令且争用极高时(如计数器、标志位、简单指针替换),atomic几乎总是赢;临界区涉及复杂数据结构修改时,std::mutex更简单也更不容易出错。
还有两个实用建议。第一,警惕“伪共享”:两个不相关的atomic变量如果落在同一缓存行(通常64字节),核心间会互相拖累,可以按缓存行对齐:alignas(64) std::atomic<int> a;。第二,compare_exchange_weak在循环里使用比strong更好,因为weak版本允许伪失败(spurious failure),某些平台上开销更低;而strong版本保证不伪失败,适合不用循环的一次性判断。掌握这些细节,std::atomic才能真正成为你高并发工具箱里那把锋利又安全的刀。
C++无锁编程std::atomic原子操作修改时间:2026-09-14 06:50:50