系列综述:
💞目的:本系列是个人整理为了秋招面试
的,整理期间苛求每个知识点,平衡理解简易度与深入程度。
🥰来源:材料主要源于多处理器编程的艺术
进行的,每个知识点的修正和深入主要参考各平台大佬的文章,其中也可能含有少量的个人实验自证。
🤭结语:如果有帮到你的地方,就点个赞和关注一下呗,谢谢🎈🎄🌷!!!
🌈【C++】秋招&实习面经汇总篇
文章目录
- 一、绪论
- 锁的底层构建
- 常见基本原子操作指令
- 二、锁的分类及特点
- 按加锁失败后的处理方式
- 自旋锁
- 互斥锁 / 阻塞锁
- 是否给同步资源上锁
- 悲观锁
- 乐观锁
- 多线程能否共享一把锁
- 其他分类
- 是否可以中断
- 同一个线程是否可以获取同一把锁
- 多线程竞争时,是否按序排队
- 三、锁的应用
- 参考博客
😊点此到文末惊喜↩︎
一、绪论
锁的底层构建
- 并发问题的原因
- 独立瞬时的读写操作导致写前和读后对于状态的未知性
- Test读:看一眼就把眼睛闭上,即读只能读到一瞬间的状态,读的是一个history(过去值)
- Set写:闭着眼睛改变,即写入只能覆盖,无法知道写入时的状态
- 独立瞬时的读写操作导致写前和读后对于状态的未知性
- 基本架构
- 在
共享内存的(异步)多核处理器
下,并发程序的特点是宏观并行、微观串行 - 微观串行是指并发程序的对共享的内存单元操作可以形成一个线性的历史操作序列(可线性化)
- 在
- 寄存器的安全性(这里的寄存器是指内存单元,是并发访问的最小单位,可看作一个D触发器)
- 安全寄存器safe:顺序计算的单线程程序总是在D触发器的输出电平稳定后才执行下一条指令,所以单线程的读指令可以正确读到最近写的新值。
- 不安全寄存器unsafe:在多个线程同时访问一个寄存器的情况下,由于线程访问寄存器的时机是无法控制的,会出现某个线程改变了寄存器的值后,在电平还未稳定的情况下,另一个线程就开始读这个寄存器的值。
- 寄存器满足的性质划分
- 安全的safe
- 读写操作不重叠:读操作可以给出最近的写入值
- 读写操作重叠:会读出寄存器可表示范围内的任意值。
- 正则的regular:
- 读写操作不重叠:读操作可以给出最近的写入值
- 读写操作重叠:会读出寄存器前一次写操作或当前写操作的值。
- 原子的atomic:
- 读写操作不重叠:读操作可以给出最近的写入值
- 读写操作重叠:当前一个读操作读到新值后,后续的读操作不再被允许读到旧值
- 安全的safe
- 寄存器读写升级
- 安全布尔单读单写 (SRSW safe Boolean)→ 安全布尔多读单写(MRSW safe Boolean)
- 原理:写线程依次向所有寄存器数组元素写入新值,读线程读取所对应数组元素的寄存器值
- 安全布尔多读单写(MRSW safe Boolean) → 正则布尔多读单写(MRSW safe Boolean)
- 原理:通过
预存所写的最近一个值
来避免安全寄存器在连续写入A值却返回其他值的随机情况
- 原理:通过
- 正则布尔多读单写 → 正则多值多读单写(MRSW regular)
- 原理:用单读单写寄存器数组构建一个多读单写寄存器
- 正则单读单写 → 原子单读单写
- 原理:使用
新旧值的时间戳
保证正则寄存器读写重叠的顺序(可线性化)
- 原理:使用
- 原子单读单写 → 原子多读单写
- 原理:
扩充成二维数组
。每个读操作读取自己对应索引的整个行,然后比较哪个是最新的,返回最新的值。写操作写入自己对应的整个列,保证后续的读操作在其自己的行内一定能读到最近写操作的值。
- 原理:
- 原子多读单写 → 原子多读多写
- 原理1:每个写线程在写入值之前先读取寄存器数组的所有时间戳,然后选出最大值并加 1 作为本次写操作的时间戳,写入本线程对应的数组位置。如果两个写线程写入的时间戳大小相同,那么可以按照字典排序的方法确定两个时间戳的相对大小。
- 原理2:通过简单快照,
比较前后两次收集信息是否相同,来判断是否出现更新
。但可能出现ABA问题
,所以使用带时间戳的简单快照,可以判断两次收集的信息是否相同。但 收集(scan) 期间如果被连续打断,会导致无法scan完成。无等待快照,使用助人为乐机制
,写线程会帮助其他被中断的线程完成scan操作。
- 安全布尔单读单写 (SRSW safe Boolean)→ 安全布尔多读单写(MRSW safe Boolean)
常见基本原子操作指令
- TAS(test-and-set)
- 原理:读取
旧值
,写入新值
,返回旧值
- 优点
- TAS指令操作的原子性是硬件保证的
- 缺点:
- 可拓展性差:TAS锁的延迟却随着线程数的增加而急剧增长,TTAS可以提升TSA的延展性,但仍有限。
- 代码
// 使用TAS实现自旋锁 bool val = false;// 初始化共享变量 // 原子操作指令 bool TestAndSet(bool new_val) { bool prior_val = val; // 读取旧值 val = new_val; // 设置新值 return prior_val; // 返回新值 } // 自旋锁 void lock() {// TAS形式 // 一直尝试上锁,当返回值为false停止自旋(while循环),执行临界区代码 while (getAndSet(true)) {} } void lock() {// TTAS形式 while (true) { while (get()) {} //空转读,直到锁被释放 if (TestAndSet(true)) return; //加锁成功 } void unlock() { // 临界区结束释放锁,其他线程运行加锁时,↓ // getAndSet会返回false,中断自旋而获得锁运行。 getAndSet(false); }
- 原理:读取
- CAS(compare-and-set)
- 原理:当且仅当
预期原值E
和内存值V
相同时,将内存值V修改为新值N
,否则什么都不做 - 优点:CAS可以支持任意有穷线程的并发
- 缺点:
- ABA问题:若共享资源从A状态变为B状态,然后又变回A状态,则CAS会认为没有发生改变,可以使用
带时间戳的CAS
进行区分相同值的新旧状态 - 竞争消耗:若线程竞争大,自旋的比较尝试会对CPU的消耗量比较大
- 多变量原子性:只能保证一个共享变量的原子操作,通过
封装成锁
可以保证多变量的原子性
- ABA问题:若共享资源从A状态变为B状态,然后又变回A状态,则CAS会认为没有发生改变,可以使用
- 代码
bool CompareAndSet(int expected, int new_val) { int prior = value; // 读取旧值 if (value == expected) { // 将旧值与预测值比较 value = new_val; // 写入新值 return true; // 写入成功 } return false; // 其他情况表示写入失败 }
- 原理:当且仅当
- get-and-increment(原子增加)
- get-and-decrement(原子减少)
二、锁的分类及特点
按加锁失败后的处理方式
自旋锁
- 定义:自旋锁(spinlock)加锁失败后,线程会忙等待,直到获取到锁。加锁开销小,适合锁保护的临界区短或锁竞争不激烈的情况。
- 忙等待:获取锁失败的线程会一直执行
Test操作(查看锁状态)
,但是不执行其他有效工作。 - 加锁开销小:上锁只需要执行一条原子交换指令,并且没有线程切换开销。
- 适合情况:锁保护的临界区短,或竞争不激烈,表示其他线程忙等的概率较低,所以在这种情况下,性能较高。
- 忙等待:获取锁失败的线程会一直执行
- 特点
- 快的时候很快:只需要一条原子指令的开销即可获得锁
- 慢的时候很慢:在时间片轮转算法下,若获得自旋锁的线程切换或睡眠,而其他线程获得CPU会空转,能实现了100%的资源浪费
- TAS和CAS实现自旋锁的比较(
先思后做
,因为调研思考的开销小,而做的开销大)- TAS差的原因:每次获取锁都会使用原子操作修改标志位,并在总线上发送广播消息,使得其他CPU缓存中的标志位失效,这会导致总线流量增加,缓存命中率降低,以及其他线程的指令延迟。
- CAS好的原因:只有在预期值与当前值相等时才会使用原子操作修改当前值,并在总线上发送广播消息,否则只会读取当前值,并不会修改它,这会减少总线流量和缓存失效的次数,提高性能。
- 自旋锁示例
int xchg(volatile int *addr,int newval){ int res; asm volatile ( // 内联汇编声明:volatile表示不允许编译器优化 "lock xchg %0,%1" // 汇编模板代码:lock表示锁定内存总线,xhcg原子交换两个数的值 :"+m"(*addr),"=a"(res) // 输出操作数%0:+m表示操作内存地址*addr,=a表示将内存值赋值给res :"1"(newval) // 输入操作数%1:将newval作为输入操作数,输入到%0中 :"cc"); // 其他:破坏描述符cc告诉编译器修改了条件码寄存器 return res; } // TODO:lock xchg指令访问了内存,为什么不会发生用户态到内核态的切换,因为有硬件对于原子指令的支持? // TODO:cmpxchg实现cas的汇编锁写法 int locked = 0;// 初始化 // 加锁:每个进程都在自己的时间片不停的自旋尝试 void spin_lock(){ while (xchg(&locked,1));// 直到持锁进程释放锁,即将*addr置为0,结束自旋 } void spin_unlock(){ xchg(&locked,0); }
互斥锁 / 阻塞锁
- 定义:互斥锁(mutex)加锁失败后,线程会被阻塞,让出cpu资源给其他线程。使用开销较大,适合锁保护的临界区较长的情况。
- 阻塞:互斥锁加锁失败后,会陷入
内核态
阻塞线程,并切换其他线程运行。当持锁线程执行解锁操作时,也会陷入内核态
,解锁并唤醒正在等待该锁的线程。 - 使用开销大:用户态下,在上锁和释放锁的过程中,会发生
两次上下文切换
。 - 适合情况:锁保护的临界区执行开销比较大,其他线程不会忙等待而造成cpu资源的浪费,但是如果临界区开销比较小,那么互斥锁的开销会占比比较大,不如使用自旋锁。
- 阻塞:互斥锁加锁失败后,会陷入
- 对自旋锁进行吸优去慢(工程上的优化,通常只优化常见的80%或者最短的木桶板)
- Fast path:一条原子指令,上锁成功立即返回,执行结束唤醒阻塞的进程
- Slow path:上锁失败,立即执行系统调用,阻塞进程,从而减少cpu资源的占用
- 缺点:阻塞操作需要内核态进行,所以用户态下的自旋锁性能较差
- 互斥锁的执行
#include <pthread.h> #include <stdio.h> // 声明一个互斥锁变量,并静态初始化为默认属性 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void *thread_function(void *arg) { // 获取锁 pthread_mutex_lock(&mutex); // 没获取到就阻塞 // 临界区代码 TODO; // 释放锁 pthread_mutex_unlock(&lmutex); // 释放后唤醒阻塞线程 } // 主函数 int main() { // 定义两个线程ID pthread_t tid1, tid2; // 创建两个线程 pthread_create(&tid1, NULL, increase, (void*)1); pthread_create(&tid2, NULL, increase, (void*)2); // 等待两个线程结束 pthread_join(tid1, NULL); pthread_join(tid2, NULL); // 销毁互斥锁 pthread_mutex_destroy(&mutex); return 0; }
是否给同步资源上锁
悲观锁
- 定义:悲观锁认为共享资源的竞争比较激烈,所以访问共享资源前要先上锁
- 特点:悲观锁的特点是先获取锁再进行业务操作,可以避免脏读和不可重复读的问题,但是会降低并发性和性能,可能造成死锁和阻塞。
- 使用场景:悲观锁适合于写入操作比较频繁的场景,因为它可以避免频繁的重试和回滚,保证数据的一致性和安全性。
乐观锁
- 定义:乐观锁认为数据一般不会发生冲突,所以不需要加锁,但是需要在更新时判断数据是否被修改过,通常使用带时间戳的CAS算法来实现。
- 适合场合:乐观锁适合于读取操作比较频繁的场景,因为它可以减少加锁的开销,提高系统的并发性和吞吐量。
多线程能否共享一把锁
- 定义:读写锁(read-write lock)是一种同步机制,用于解决多个线程同时对同一资源进行读或写操作的问题。
- 读锁/共享锁(shared lock):允许多个线程同时对共享资源进行读操作,但不允许写操作
- 写锁/排他锁(exclusive lock):只允许一个线程对共享资源进行写操作,但不允许其他线程进
- 规则:读读不互斥,读写互斥,写写互斥
- 对于共享资源的互斥原理
- 写锁是独占锁:共享资源加
写锁
后,其他线程无法进行读写操作 - 读锁是共享锁:共享资源加
读锁
后,其他线程无法进行写操作,但可以加读锁,进行读操作。
- 写锁是独占锁:共享资源加
- 类型
- 读优先
- 原理:当有线程持有读锁时,其他线程可以继续加读锁,但不能加写锁;当没有线程持有任何锁时,如果有线程请求加读锁和写锁,那么优先满足加读锁的请求。
- 优点:适合于读操作远多于写操作的场景,可以提高系统的吞吐量和响应速度。
- 缺点:可能导致写饥饿
- 写优先
- 原理:当有线程持有读锁时,其他线程不能加读锁,也不能加写锁;当没有线程持有任何锁时,如果有线程请求加读锁和写锁,那么优先满足加写锁的请求。(读读互斥?)
- 优点:适合于写操作远多于读操作的场景,可以提高系统的实时性和一致性。
- 缺点:写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」
- 公平读写锁
- 用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。
- 读优先
- 适合场景:任何操作读写锁适应于能够明确区分读操作和写操作的场景
其他分类
是否可以中断
- 可中断锁
- 概述:在等待获取锁的过程中能够响应中断请求
- 优点:可以避免死锁或饿死
- 缺点:在临界区执行时发生中断可能导致的数据不一致或错误,通常需要进行回滚操作
- 不可中断锁
- 概述:非可中断锁则会忽略中断请求,一直等待直到获取锁或者超时
- 优缺点与上面相反
同一个线程是否可以获取同一把锁
- 可重入锁
- 概述:允许同一个线程在已经获取锁的情况下,再次获取同一个锁,而不会发生死锁
- 原理:在获取锁时判断是否是已经持有该锁的线程,如果是,则增加持有次数;在释放锁时判断是否持有次数为零,如果是,则释放该锁。(类似shared_ptr)
- 优点:可以避免一些死锁情况,如调用的两个同步方法都使用的同一把锁
- 缺点:维护额外的状态信息会增加开销
- 不可重入锁
- 概述:如果同一个线程已经获取锁,再次请求锁时,会被阻塞,导致死锁。
- 原理:获取时锁状态,锁被占用则等待,释放时判断锁状态,锁未释放则释放
- 优缺点与上面相反
多线程竞争时,是否按序排队
- 公平锁
- 概述:多个线程按申请锁的先后顺序获取锁,先判断自己是否为等待队列队首,如果是则尝试抢占锁,如果不是则加入等待队列队尾
- 优点:所有线程都有机会获取锁,避免饥饿现象
- 缺点:维护有序等待队列具有性能开销
- 非公平锁
- 概述:多个线程不按照申请顺序获取锁,而是先尝试抢占锁,如果失败,再进入等待队列
- 优点:减少线程切换开销,提高系统吞吐量
- 缺点:可能导致某些线程饥饿
三、锁的应用
🚩点此跳转到首行↩︎
参考博客
- 南京大学jyy老师操作系统
- 小林coding图解锁算法
- 深入理解互斥锁 自旋锁的实现机制
- 锁的分类
- 待定引用
- 待定引用
- 待定引用
- 待定引用