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

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

两个线程同时 counter++,结果丢了一次更新——这就是数据竞争,而且是未定义行为。加锁能解,但锁贵;atomic 用一条 CPU 指令解决同一问题。背得出"atomic 是原子的"只是及格,真考你的是"++ 为什么不原子、memory_order 六级怎么选、spinlock 怎么手写、无锁栈的 push/pop 怎么写"。本文是多线程篇收官。


一、开场:为什么需要 atomic?

❓ atomic 解决什么问题?

✅ 解决单个变量的数据竞争。先看 counter++ 为什么不安全——它其实是三步:

counter++ 展开为:
① load:读 counter 到寄存器
② add:寄存器 +1
③ store:写回内存

两个线程交错执行,①②③互相穿插,两次自增可能只涨 1。C++ 的规矩很硬:多线程读写同一变量、至少一个是写、且不同步——数据竞争,直接未定义行为(不是"偶尔算错"这么温柔)。

解法对比:

方案适用开销
mutex多个变量的复合操作锁等待,微秒级
atomic单个变量的读写一条 CPU 指令,纳秒级
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)。但不是所有类型都能无锁——大对象只能退化为"内部加锁":

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(只关心原子性,不关心顺序):

atomic<int> hits{0};
void worker() {
    hits.fetch_add(1,
        memory_order_relaxed);
    // 少一次同步栅栏,
    // 计数场景完全够
}

② 发布标志 → release + acquire(写完数据再立旗,读旗前数据必可见):

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。

// 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 + 自旋:

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 快。

无锁栈是入门标配,结构就一个原子头指针——nullptr 是天然的哨兵:

template<class T>
class Stack {
  struct Node {
    T data;
    Node* next;
  };
  atomic<Node*> head{nullptr};

public:
  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)) {
    }
  }

  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(双宽 CAS 一并比较计数)。面试提到 ABA,说明你真读过无锁代码。


六、atomic 的边界:别越界使用

❓ atomic 能解决所有并发问题吗?

✅ 不能,两个硬边界:

① 只守单个变量——两个 atomic 变量的组合操作不原子:

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_relRMW 专属的合体,少单独用
不确定默认 seq_cst
spinlockatomic_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——先正确,再无锁。多线程篇到此收官,下期进入模板元篇,敬请关注 👋

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码工许师傅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值