【C++ 面试真题】聊聊 C++ 的原子变量与无锁编程
两个线程同时
counter++,结果丢了一次更新------这就是数据竞争,而且是未定义行为。加锁能解,但锁贵;atomic 用一条 CPU 指令解决同一问题。背得出"atomic 是原子的"只是及格,真考你的是"++ 为什么不原子、memory_order 六级怎么选、spinlock 怎么手写、无锁栈的 push/pop 怎么写"。本文是多线程篇收官。
一、开场:为什么需要 atomic?
❓ atomic 解决什么问题?
✅ 解决单个变量的数据竞争 。先看 counter++ 为什么不安全------它其实是三步:
text
counter++ 展开为:
① load:读 counter 到寄存器
② add:寄存器 +1
③ store:写回内存
两个线程交错执行,①②③互相穿插,两次自增可能只涨 1。C++ 的规矩很硬 :多线程读写同一变量、至少一个是写、且不同步------数据竞争,直接未定义行为(不是"偶尔算错"这么温柔)。
解法对比:
| 方案 | 适用 | 开销 |
|---|---|---|
| mutex | 多个变量的复合操作 | 锁等待,微秒级 |
| atomic | 单个变量的读写 | 一条 CPU 指令,纳秒级 |
cpp
atomic<int> counter{0};
counter++; // 原子的 RMW,永不错乱
counter.fetch_add(1, memory_order_relaxed);
二、原子操作与 is_lock_free
❓ atomic 的操作有哪些?"原子"到底怎么实现?
✅ 三类原子操作:
- load / store:原子读、原子写;
- exchange / compare_exchange:带条件的原子换值(CAS);
- fetch_add / fetch_and / ...:原子"读改写"(RMW)。
实现靠 CPU 指令:x86 的 lock 前缀、ARM 的独占加载/存储(LL/SC)。但不是所有类型都能无锁------大对象只能退化为"内部加锁":
cpp
atomic<int> ai;
ai.is_lock_free(); // 通常 true
struct Big {
char data[64];
};
atomic<Big> ab;
ab.is_lock_free(); // 通常 false:
// 内部有把小锁
💡 is_lock_free() 是无锁代码的前提检查 ------拿到 false 的 atomic 性能未必好于 mutex,还限制了用法(比如不能用于信号处理)。经典结论:标量和小结构通常无锁,超过机器字长就悬。
三、memory_order:六级的现实选法
❓ memory_order 有哪几种?实际怎么选?
✅ 六级,但日常只用三档,先给结论再解释:
| 顺序 | 强度 | 语义 | 使用 |
|---|---|---|---|
| seq_cst | 最强 | 全局全序,"顺序一致" | 默认,不确定就用它 |
| acquire/release | 中 | 配对同步(见下) | 发布/消费模式 |
| relaxed | 最弱 | 只保证单个操作原子 | 纯计数器 |
两个经典模式:
① 纯计数 → relaxed(只关心原子性,不关心顺序):
cpp
atomic<int> hits{0};
void worker() {
hits.fetch_add(1,
memory_order_relaxed);
// 少一次同步栅栏,
// 计数场景完全够
}
② 发布标志 → release + acquire(写完数据再立旗,读旗前数据必可见):
cpp
atomic<bool> ready{false};
int data; // 普通变量
// 生产者
data = 42;
ready.store(true,
memory_order_release); // 发布
// 消费者
while (!ready.load(
memory_order_acquire))
; // 接收
cout << data; // 保证 42,
// 不是垃圾
💡 理解口诀:release 是"之前的写不许沉到这后面",acquire 是"后面的读不许漂到这前面"------两者一握手,发布侧的数据对消费侧完整可见。relaxed 什么都不断言,只有原子性。
⚠️ 实战建议 :默认 seq_cst(写ready = true不带参数就是它);确认是纯计数再 relaxed;读得到发布/消费的收益才用 acquire/release。先正确后花哨------乱序 bug 的排查成本是性能收益的一百倍。
❓ 标准明明定义了六种 memory_order,还有两个呢?
✅ consume 和 acq_rel------不是不存在,是轮不到你用:
① memory_order_consume(依赖顺序) ------理论上只同步"有数据依赖"的访问:顺着拿到的指针往下读的内容有保证,比 acquire 更轻量。但依赖关系在优化器面前太难保持 (寄存器提升、指令重排都会悄悄切断它),现实是主流编译器一律把 consume 当 acquire 实现 ,[C++17] 起标准干脆不建议使用------写它没有性能收益,语义还是空中楼阁,直接写 acquire。
cpp
// consume 的理想场景:
Node* p = head.load(
memory_order_consume);
p->data; // 理论上"顺着 p 的读
// 有保证"------实际编译器
// 全按 acquire 处理
② memory_order_acq_rel(获取 + 释放) ------一次操作既是 acquire 又是 release ,只对 RMW(读改写)操作有意义------fetch_add、compare_exchange 这种"又读又写"的动作才有资格配它;单独的 load/store 用不上。日常少见是因为:计数场景 relaxed 就够,发布/接收走 store 的 release 和 load 的 acquire 配对------真正落在 RMW 上需要"既取又发"的,典型就是 CAS 循环,那种地方多数人直接用默认 seq_cst 省心。
💡 口径归一 :六种枚举,实战决策就三档------relaxed / acquire-release / seq_cst。consume 是历史遗留(编译器按 acquire 处理),acq_rel 是 acquire/release 在 RMW 上的自然合体。面试能说出这两条"为什么不单独用",比背全六种定义更见功力。
四、手写 spinlock(高频题)
❓ 用 atomic 手写一个自旋锁怎么写?
✅ 十行以内,CAS + 自旋:
cpp
class Spinlock {
atomic_flag f_ = ATOMIC_FLAG_INIT;
public:
void lock() {
while (f_.test_and_set(
memory_order_acquire))
/* 空转等待 */;
}
void unlock() {
f_.clear(memory_order_release);
}
};
三个讲解点:
atomic_flag是保证无锁的最小原子类型,test_and_set "置 1 并返回旧值"------旧值 1 说明别人持锁,接着转;- acquire/release 的用法和上节发布模式同构:锁的获取是 acquire、释放是 release,临界区的读写就关进栅栏里;
- 纯空转烧 CPU------生产版会在循环里加
this_thread::yield()(配合 #29 讲的 yield)或用指数退避。
⚠️ 适用边界:临界区极短 + 竞争不激烈才用自旋;临界区一长就是灾难(等锁的核全在空烧)。标准 mutex 内部就是"先自旋几次,不行再睡眠"的混合策略。
五、无锁数据结构:手写无锁栈
❓ 无锁编程的核心套路是什么?
✅ CAS(compare-and-swap)循环 ------"乐观并发":不锁,改之前先验证没被别人动过,失败就重试。compare_exchange_weak(expected, desired):当前值 == expected 则换成 desired 返回 true;不等则把当前值写回 expected 返回 false ------失败即"自动刷新 + 重试"。weak 版可能伪失败(值相等也返回 false),必须配循环用,但某些平台上比 strong 快。
无锁栈是入门标配,先看结构------一个原子头指针 + 链表:
cpp
template<class T>
class Stack {
struct Node {
T data;
Node* next;
};
atomic<Node*> head{nullptr};
};
push:新节点先挂旧栈顶,再把栈顶 CAS 成自己:
cpp
void push(T v) {
Node* n =
new Node{std::move(v)};
// ① 先指向当前栈顶
n->next = head.load();
// ② CAS 换栈顶
// 失败会把最新栈顶
// 写回 n->next,直接再试
while (!head
.compare_exchange_weak(
n->next, n)) {
}
}
pop:反方向------把栈顶 CAS 成第二个节点:
cpp
optional<T> pop() {
Node* old = head.load();
while (old) {
if (head
.compare_exchange_weak(
old, old->next)) {
// 抢到了:取数据
T v = std::move(
old->data);
delete old; // ⚠️ 有讲究
return v;
}
// 失败:old 已被刷新,
// 接着试
}
return nullopt; // 空栈
}
两个操作同构:读旧值 → 准备新状态 → CAS 提交,失败重试------这就是一切无锁算法的骨架。
⚠️ pop 的两个深水区(能讲出来就是高级分):
① 何时 delete ------CAS 成功那一刻,别的线程可能还握着 old、正要读
old->next参与下一次 CAS;立刻 delete 就是 UAF(Use-After-Free)。工业解法:风险指针 (hazard pointer,登记"我正在用这个节点")、epoch 回收 (宽限期内不释放),或干脆atomic<shared_ptr<Node>>让引用计数管寿命。② ABA 问题 ------old 被弹出释放后,新 push 恰好分配回同一地址 :head 的值"看着没变",CAS 误判成功,但
old->next读到的已是垃圾。对策:指针带版本号 tag (cmpxchg16b双宽 CAS 一并比较计数)。面试提到 ABA,说明你真读过无锁代码。
六、atomic 的边界:别越界使用
❓ atomic 能解决所有并发问题吗?
✅ 不能,两个硬边界:
① 只守单个变量------两个 atomic 变量的组合操作不原子:
cpp
atomic<int> a{0}, b{0};
// ❌ "同时"把 a、b 各加 1:
// 中间可能被打断,
// 别人看到 a=1,b=0 的中间态
a++; b++; // 各自原子,
// 组合不原子
// 要原子性:一把 mutex
② 复合业务不变量要锁 ------转账、改两个关联字段、"检查再更新"跨多变量,都是 mutex 的地盘。atomic 的公式化边界:单个标量的读、写、RMW。
🎯 false sharing(伪共享) ------两个线程各写各的 atomic,却因**挤在同一缓存行(64 字节)**而互相弹缓存,性能塌方。解法:
[C++17]alignas(64)或std::hardware_destructive_interference_size把热点变量隔开。多计数器数组的经典优化,面试冷门高分点。
七、面试高频追问
❓ Q1:atomic<int> 和 volatile int 的区别?
✅ 完全两回事。atomic 保证操作的原子性和可见性顺序,用于多线程;volatile 只禁止编译器优化掉读写(每次真的访问内存),不保证原子、不保证线程同步------它为 MMIO 等特殊内存而生(关键字篇详聊过)。多线程共享变量用 volatile 是经典误用。
❓ Q2:compare_exchange_weak 和 strong 怎么选?
✅ weak 可能伪失败(LL/SC 架构上循环重试的成本换来的高效),必须配循环;strong 不伪失败但循环外单次尝试更贵。套路固定:CAS 循环里用 weak,一次性尝试用 strong。
❓ Q3:seq_cst 和 acquire/release 差在哪?
✅ seq_cst 让所有 seq_cst 操作有一个全局统一顺序,所有线程看到的操作顺序都相同;acquire/release 只约束"配对之间的偏序"------开销更小,但跨多变量的全局一致性没了。多变量复杂同步时用错了 acquire/release 可能出现"各线程顺序观感不同"的微妙 bug。
❓ Q4:无锁一定比加锁快吗?
✅ 不一定。无锁在低竞争 下省了睡眠/唤醒;高竞争下 CAS 循环反复失败重试,比排队睡眠更烧 CPU。无锁代码还难写难验证(ABA、内存回收)。工程默认 mutex,profile 证明锁是瓶颈、且场景匹配,才上无锁。
❓ Q5:atomic 智能指针是怎么回事?
✅ shared_ptr 本身不是原子的------并发拷贝/析构同一 shared_ptr 实例仍是竞争。标准提供 atomic<shared_ptr<T>> [C++20](C++11~17 是 free function atomic_load(&sp) 那一套):把"读指针 + 计数 +1"整体原子化。高频追问点:atomic<shared_ptr> 保护的是指针副本的操作,指向的对象依旧不保护。
❓ Q6:内存序为什么存在?CPU 不是顺序执行的吗?
✅ 两层重排:编译器 指令重排 + CPU 乱序执行与写缓冲------单线程看无感(有依赖分析保证正确),多线程之间重排就暴露了。内存序是给程序员**声明"哪些顺序必须保"**的合同:全保是 seq_cst,只保关键配对是 acquire/release,不保是 relaxed。
八、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 数据竞争 | 不同步的并发读写 = UB |
| ++ 不原子 | load/add/store 三步 |
| 三类操作 | load/store、RMW、CAS |
| is_lock_free | 无锁前提,超字长退化带锁 |
| 纯计数 | relaxed |
| 发布/消费 | release + acquire 配对 |
| consume | 编译器按 acquire 处理,勿用 |
| acq_rel | RMW 专属的合体,少单独用 |
| 不确定 | 默认 seq_cst |
| spinlock | atomic_flag + acquire/release |
| 无锁栈 | push/pop 同构:CAS 失败自动刷新重试 |
| ABA | 值回来 ≠ 没变过,版本号解 |
| 单变量边界 | 组合操作不原子,找 mutex |
| 伪共享 | 同缓存行互弹,alignas 64 |
一句话回顾
atomic 用一条指令守住单个变量的读写 (RMW、CAS),配 memory_order 表达顺序承诺------计数 relaxed、发布 release/acquire、拿不准 seq_cst ;spinlock 十行手写,无锁栈的 push/pop 都是"CAS 失败自动重试"的同构循环 ,但 pop 的 delete 时机和 ABA 是深水区;atomic 只守单个变量、组合不变量仍归 mutex------先正确,再无锁。多线程篇到此收官,下期进入模板元篇,敬请关注 👋