目录
一.前置知识
1.为什么要有锁这么一个东西?
2.互斥锁的基本原理是什么?
二.互斥锁
1.锁的库函数接口——原生线程库
a.锁的初始化
b.加锁
c.解锁
d.锁的销毁
e.代码示意
2.互斥锁的底层原理
a.临界区的概念
b.原子性
c.互斥锁的底层原理
3.代码实战
三.线程的同步和环境变量
1.死锁
a.死锁的概念
b.死锁产生的特征
c.避免死锁的方法
2.线程同步
3.条件变量
a.条件变量的底层机理
b.条件变量的函数接口
①条件变量的初始化
②等待条件变量
③唤醒条件变量
④销毁条件变量
一.前置知识
1.为什么要有锁这么一个东西?
在先前的文章中,我们有了解到多线程,我们知道:当同一个进程内,有多个线程同时访问某个公共资源时,就有可能导致得到的数据与预期不一致问题,即线程安全问题!
如:当多线程同时向屏幕打印数据时,屏幕上显示的内容就会十分混乱。因为对线程而言,屏幕也是公共资源,也是线程间竞争的对象。
所以,为避免多线程对公共资源的竞争引发的数据不一致问题,我们需要让多线程有序的访问公共资源,即,确保在同一时刻只有一个线程可以访问共享资源,从而避免数据竞争和不一致性问题。
而实现这种保障机制的手段,就是互斥锁~~
2.互斥锁的基本原理是什么?
互斥锁是如何确保在同一时刻只有一个线程可以访问共享资源的?
举例:当线程A想要访问共享资源时,要先拿到锁,然后对接下来的资源访问步骤进行加锁,当线程A对该共享资源访问完成后再进行解锁操作。在线程A进行资源访问的过程中,若线程B也想要访问这个共享资源,那么线程B就得先拿到锁,但是,由于锁正在被线程A占据,线程B就无法拿到锁,也就无法访问这个共享资源,其它线程亦是如此。
这样就能保证在同一时刻,只有一个线程能够访问共享资源~~
二.互斥锁
1.锁的库函数接口——原生线程库
a.锁的初始化
①pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
②pthread_mutex_t lock; pthread_mutex_init(&lock, NULL);
pthread_mutex_init() 的第二个参数为属性对象,通常传递NULL使用默认属性
b.加锁
①阻塞式获取锁:pthread_mutex_lock(&lock);
若 lock 锁被其它线程占用,则当前线程会阻塞等待。
②非阻塞获取锁:int ret = pthread_mutex_trylock(&lock);
若锁已经被其他线程持有,则不会阻塞当前线程,而是返回错误码。
加锁的本质:使多线程访问代码的方式从并发式访问变成串行式访问,是用时间来换取安全的!!
c.解锁
pthread_mutex_unlock(&lock);
只有当线程主动进行解锁操作后,其它线程才能拿到这个锁,然后去访问共享资源~~
d.锁的销毁
pthread_mutex_destroy(&lock);
注意解锁和销毁锁的区别:解锁后,其它线程还能用;销毁锁,意味着释放锁资源,这个锁就不能再被使用了~~
e.代码示意
搭建场景:创建多线程,并让它们同时去访问同一个共享资源。
锁的使用:保证在同一时刻只能有一个线程能够进行 tickets-- 操作
2.互斥锁的底层原理
a.临界区的概念
在加锁和解锁之间的代码,我们称为临界区,所访问的资源称为“临界资源”,我们要保证临界区的代码尽可能的少。
为什么临界区越短越好?
临界区越短,线程在临界区的执行时间就越短:①可以减少多线程同时访问临界资源的可能性,降低了竞态条件发生的概率;②可以减少其它线程因等待锁而被阻塞的时间,提高程序的吞吐量。
细节:①线程对锁的竞争能力可能会不同,如:若不在循环的最后加一个线程休眠代码usleep(),那么锁就可能一直被某一线程抢到。②纯互斥环境,如果锁分配不够合理,就容易导致其它线程出现线程饥饿问题。
b.原子性
情景:我们知道,线程在访问共享资源前,需要先拿到锁。那么,如果有多个线程,同时去抢占一个处于空闲状态的锁,此时,这个锁就变成了多线程同时访问的共享资源,这种情况下难道不会出现数据不一致(线程安全)问题吗?
答:不会,因为线程对锁的访问(申请锁和释放锁)具有原子性!!
那么,什么是原子性?
简单来说,一个操作一旦执行,在执行过程中不可被中断,即这个操作要么全部执行成功,要么全部不执行,不会出现中间情况,这样的操作就具有原子性。
对其他线程来讲,一个线程要么处于有锁的状态,要么处于没锁状态,所以,当前线程访问临界资源的过程,对其他线程来讲是原子的!!
c.互斥锁的底层原理
图文解读:若线程A先将eax寄存器数据置零,随后让eax数据与内存中的锁资源做交换,这样线程A就拿到了锁,如果此时线程A被CPU切换下来,锁就会被放到A的上下文中,当CPU再次调度线程B时,线程B先将eax寄存器置0,随后与内存中锁资源作交换,但此时内存中已经没锁了,线程B申请不到锁,就被CPU挂起到锁的阻塞队列中排队等待。当线程A任务执行完毕后,会进行解锁操作,即先将锁资源归还,然后唤醒线程B,让线程B再去执行申请锁的操作。
加锁:调度线程把内存中的锁资源,以一条汇编指令的方式将其交换到CPU寄存器中,即把数据(锁)交换到了线程的硬件上下文!!
线程在访问临界区时,CPU也还是会执行线程间切换,但是,由于该线程在被从CPU上切下时,并未执行解锁操作,所以纵然其它线程被CPU调度,也无法访问临界资源!!
在我们的体系结构里,都会存在swap、exchange这样的汇编语句,它们可以以一条汇编语句的方式,将CPU寄存器内的数据和内存中的数据(锁)作交换,由于只有一条简单的汇编语句,所以加锁的过程是原子的。
3.代码实战
*锁的应用 --- 封装 --- 多线程抢票的代码 + 问题现象 + 锁的使用 + 锁的封装
源码链接:https://gitee.com/Coder-Li-YuJie/intelligent-packaging-of-locks
三.线程的同步和环境变量
1.死锁
a.死锁的概念
在多条执行流和多个锁的情况下,当两个各自都拥有一个锁的线程,同时想要申请对方的锁时,会申请失败,进入阻塞状态,由于这两个线程被阻塞的同时都身怀对方需要的锁,所以,这两个线程就会一直处于阻塞状态,这种状态被称为死锁。
例如:线程A持有x锁,线程B持有y锁。若线程A在没有释放x锁的情况下冒然去申请y锁,由于y锁被线程B所持有,线程A会陷入阻塞等待状态。此时,若线程B再想去申请x锁,由于x锁被线程A所持有,故线程B也会陷入阻塞等待状态。由于进入阻塞状态的线程A和线程B都无法释放自身的锁资源,这就导致两者会一直阻塞,这就是死锁~~
b.死锁产生的特征
① 互斥条件 —— 一个资源只能被一个执行流执行
② 请求与保持条件 —— 执行流因请求资源而阻塞时,对已获得的资源保持不放
③ 不剥夺条件 —— 执行流已获得的资源,在未使用之前,无法被剥夺(可以通过接口打破该条件)
④ 循环等待条件 —— 若干执行流之间形成一种头尾相连的循环等待资源的关系
小结:条件①是前提条件,难以改变,条件②和③则可以通过接口来打破,条件④则需我们写代码时刻意关注!!
c.避免死锁的方法
避免死锁:确保每个线程在获取锁后都能最终释放锁,否则会导致死锁问题。
锁的顺序:如果多个锁需要被多个线程以不同顺序获取,可能会导致死锁。确保所有线程以相同的顺序获取锁。
性能考虑:频繁获取和释放锁可能会影响性能,特别是在高并发场景下。尽量减少锁的持有时间和锁的粒度。
2.线程同步
为了避免出现死锁情况,我们得对申请锁资源的线程做出限制:
①所以等待锁的线程必须按照某种规则进行排队。
②刚解锁的线程,不能立马重新申请锁,必须排队到队列的尾部。
在保证数据在安全的情况下,让线程访问资源具有一定的顺序性,我们将这种行为称作线程同步。
那么,如何让线程访问资源具有一定的顺序性?--- 排队,让所有申请锁的线程都到一个队列中排队等待,当锁处于就绪状态时,先唤醒队首线程,让队首线程先拿到锁,循环往复~~
线程排队这种机制怎么实现??—— 条件变量
3.条件变量
a.条件变量的底层机理
--- 当一个线程互斥的访问某块空间上的共享资源时,若这块空间上的资源还未被生产出来,它就只能等待,这时就需要条件变量的存在。
b.条件变量的函数接口
①条件变量的初始化
静态初始化:
pthread_cond_t _cond=PTHREAD_COND_INITALIZER;
动态初始化:
pthread_cond_t cond; pthread_cond_init(&cond, NULL);
与锁一样,动态初始化时,第二个参数通常传递NULL
,表示使用默认属性。
②等待条件变量
等待条件变量时,线程必须持有与条件变量关联的互斥锁。
pthread_cond_wait (pthread_cond_t* cond, pthread_mutex_t* mutex):无条件等待,并自动释放互斥锁,直到被唤醒并重新获得互斥锁。
pthread_cond_timewait:在指定时间内等待,如果超时则返回错误。
③唤醒条件变量
pthread_cond_signal:唤醒一个等待该条件变量的线程,如果有多个线程等待,则队列中第一个线程。
pthread_cond_broadcast:唤醒所有等待该条件变量的线程。
④销毁条件变量
pthread_cond_destroy(&cond);
注意:环境变量不仅能解决“死锁”问题,还能解决“不同线程对锁的竞争能力不同而导致的线程饥饿”问题!!