拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门