烤面筋 Day 01
烤面筋 Day 01
原子操作与内存模型:
1. 原子操作是什么?Atomic 跟原子操作的关系是什么?
答:原子操作就是要么不执行,要么全部执行成功,且中途不能被中断的操作。Atomic 就是 C++ 11 提供的标准库模板类,是原子操作在语言层面的封装和工具。
2. Atomic 底层是怎么实现的?
答:底层主要依赖硬件层面处理器提供的原子指令,在 x86 架构下,通常会使用带有 LOCK 前缀的指令,其能锁定缓存行,确保执行执行期间不会被中断。
编译器层面也会根据不同的 CPU 架构,将 atomic 操作映射为相应的机器指令,根据指定的内存序,插入必要的 Memory Barrier ,防止指令重排。
3. 能简单讲讲 C++ 的内存序(Memory Order)吗?
内存序的作用是约束编译器和 CPU 的指令重排优化,C++11 定义了 6 种内存序,但常用的是 memory_order_relaxed 和 获取/释放语义和序列一致性。
宽松顺序:只保证当前操作的原子性,不保证任何顺序(没有同步关系)。
释放(release)语义:持有 release 的线程是发布者,,逻辑是 “做完手头的事情了,现在发布表示数据准备好了”
获取(acquire)语义:持有 acquire 的线程是获取者,逻辑是 “看到发布的通知了,说明我可以放心去用数据了”
序列一致性:atomic 的默认参数,要求所有线程看到的执行顺序都是一模一样的,就像所有操作都排在一个全局队列里依次执行。
- 那为什么不能全用默认的
seq_cst序列一致性的内存序?
高性能场景下(如无锁队列、底层驱动),默认的强顺序会导致大量的内存屏障指令,强制刷新 CPU 缓存,这会极大限制现代多核处理器的并行能力。
4. CAS 指令是什么?
答:CAS 是实现原子操作的核心指令,包含三个操作数:
1 | |
只有内存地址的值还是我之前读到的那个预期值,我才把它修改成新值。否则说明别人改过它,那我就放弃这次修改。整个过程是原子性的
5.CAS 的优缺点是什么?
答:优点就是无锁,避免了传统互斥锁导致的线程上下文切换、挂起和恢复,性能开销更小。
缺点是有自旋开销,如果在高竞争环境下,CAS 一直失败,会导致 CPU 一直空转,浪费资源。同时还存在 ABA 问题。
6. ABA 问题是什么?如何解决这个问题?
就是一个线程将数值 A 改成了 B,随后另一个线程又将 B 改回了 A,此时第三个线程进行检查,发现值依然是 A,认为没有改变,从而误操作。
目前解决的主流办法是版本号,实现方法就是记录值的同时维护一个版本号,每次更新的时候,不仅更新值,还让版本号 +1。这样遇到 ABA 问题的时候版本号不同,就知道值已经发生改变了。
7. C++ atomic 是否自带版本号机制?
不自带。标准的 std::atomic
如果需要解决这个问题的话,开发者通常需要配合 std::atomic<std::pair<T, int>> 或者一些带有 Tag 的指针技术。
8. Volatile 关键字的作用是什么?能解决线程安全吗?
主要的作用是禁止编译器优化:告诉编译器,这个变量的值可能随时被外部(如硬件中断、另一个进程)修改,因此每次使用它都必须从内存中读取,而不能使用寄存器里的缓存。
不能,因为他不保证原子性,同时不禁止指令重排,只针对编译器的,但不提供针对 CPU 的内存屏障,无法解决多核环境下的可见性和有序性问题。
9. 手撕一下:写一下 CAS 实现的无锁队列
1 | |
多线程与锁
1. 多线程最常见的问题是什么?
主要是三个问题:
线程安全问题:多个线程同时读写同一个块内存,导致结果不可预期
活跃性问题:包括死锁,活锁和饥饿,其中死锁是最致命的,会导致程序完全卡死
性能开销:过渡的上下文切换和锁竞争导致性能下降
2. 死锁怎么避免?
避免死锁的核心逻辑是:防止循环等待链的形成。常用的解决办法如下:
- 固定加锁顺序:规定所有的线程必须按照相同的顺序获取锁(先拿 A 锁,再拿 B 锁)
- 尝试锁(Try-lock):使用
std::unique_lock的try_lock,如果拿不到锁就立即释放已占用的所并且回退 - 使用高级抽象:使用其他无锁的机制(比如消息传递等等)来替代底层锁
3. 哪些设计方式/技巧能破坏死锁的 4 个条件?
互斥性:资源一次只能被一个线程占用。这个是无法破坏(锁的本意)。
占用且等待:线程持有一个资源的同时,还在等待另一个资源。采用一次性申请的方式解决: 线程启动前一次性申请所有需要的锁;或者在无法获取下一个锁时,释放已占有的所有锁。
不可剥夺:资源不能被抢占,只能由持有者主动释放。采用抢占机制的方式解决:如果线程申请新锁失败,主动释放手中资源,过段时间再重试
循环等待:存在一个线程等待环路:T1 等 T2,T2 等 T1。采用资源线性排序的方式解决: 给所有锁分配唯一序号,强制规定必须按序号从小到大(或从大到小)获取锁。
4. 实际开发中,你会如何实现避免死锁的发生?
我会使用 std::scoped_lock 一次性添加多个锁利用其内部的机制来避免
1 | |
其内部的机制
- 避免循环等待:内部并不简单地按顺序调用
lock(),而是采用了一种 “重试机制”:
1. 它会尝试锁住第一个互斥量。
2. 接着尝试 try_lock 后面的互斥量。
3. 如果中间任何一个锁失败了,它会立即释放已经持有的所有锁,然后重新开始。
- 原子性加锁:保证了所有传入的互斥量要么全部锁住,要么一个都不锁。