【C++ 面试真题】32. 聊聊 C++ 的原子变量与无锁编程

【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_addcompare_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 读到的已是垃圾。对策:指针带版本号 tagcmpxchg16b 双宽 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------先正确,再无锁。多线程篇到此收官,下期进入模板元篇,敬请关注 👋

相关推荐
不会就选b1 小时前
Linux之线程池(一)
java·开发语言
wabs6661 小时前
关于栈【力扣150.逆波兰表达式求值的思考】
数据结构·c++·算法·leetcode··代码随想录
IT毕设实战小研1 小时前
基于大数据的二手车数据分析与预测
java·大数据·后端·爬虫·python·算法·课程设计
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<十七>:点运算与灰度变换概述
c++·人工智能·opencv·计算机视觉
吠品1 小时前
OkHttp 多文件上传的一个实现
java·服务器·前端
牛油果子哥q1 小时前
C++序列式容器深度精讲:vector/list/deque底层实现、扩容原理、迭代器失效、性能对比、工程选型避坑
开发语言·c++·list
lingguangbuzhi1 小时前
【JAVA教程】java课程java学习编程
java·学习·教程
啦啦啦啦啦zzzz1 小时前
升序链表的定时器
linux·服务器·网络·数据结构·c++·链表
WBluuue1 小时前
数据结构与算法:带权并查集
数据结构·c++·算法