概念以及实现方法
1.概念:多线程对同一临界资源的访问是安全的。因为对于临界资源,多个线程同时对其进行修改,就会出现数据的二义性。
例如:
这段程序运行的时候,两个进程对变量a进行操作,我们想的的出来的结果应该是a是按顺序加的,但是并不是,是会出现错误的,如下:
我们发现并没有按顺序去进行,这是为什么呢,这就是多线程的安全问题了,对于像全局变量这种临界资源,如a,不同的线程对其操作,他都会改变,并且,我们知道,每个线程在pcb上运行的时间是相同,如果第一个线程在运行到a++时刚好切换了,而此时另外一个进程也运行到a++了,而此时这个两个进程所运行的a都是相同的,那么在此情况下就会出错,两个进程本来运行两次a++,a要加两次,结果a只加了一次,这样会造成严重的后果,所以我们对临界资源的访问,必须保证它的安全性。
2.解决办法:
①:互斥:保证执行流在同一时间对临界资源的访问是唯一的。
②:同步:通过一些条件的判断,实现对资源获取的合理操作。
互斥
互斥的实现方法主要是互斥锁,也就是我们将这个数据被操纵的时候将他锁住,只能让其被进行一次操作,其他线程要操作它的时候,只能等待上一个线程将他操作完成后,才能进行操作,保证了在对这个数据进行修改的时候只被修改一次,并且第一个线程修改完成后,立即解锁,让第二个线程去操作。
其中互斥的背景如下:
- 临界资源:多线程执行时共享的资源就是临界资源。例如:全局数据等等。
- 临界区:每个线程内部,访问临界资源代码,就叫做临界区。
- 在任何时刻,互斥会保证只有一个执行流进入临界区,去访问临界资源,通常对临界资源起着保护作用。
- 原子性操作:不对任何调度机制所打断,它的操作只有两种情况,要么成功,要么失败。
互斥锁
互斥锁也叫互斥量(mutex)。
1.本质:就是一个0/1计数器,主要用于标记这个资源此时的状态,是否可以让进程此时进行访问。(其中 0表示不可访问,1表示可以访问)
而对于我们开始写的代码,出错的原因就是因为,++不是一个原子操作,
对于++他一共有三个操作:
- load : 将共享变量a从内存加载到寄存器里面。
- update:更新寄存器里面的值,进行+1操作。
- store:将新的值从寄存器写回到共享变量a中。
所以解决上面问题的根本就是:
- 代码必须要有互斥行为:当进程进入到临界区执行的时候,不允许其他进程进来执行。
- 如果同时有多个进程要求执行临界区代码,并且此时临界区没有进程在执行,那么只能运行一个进程进来执行,其他进程暂且等候。
- 如果线程不在临界区执行,那就不能阻止其他进程进入临界区。
2.操作方法:加锁和解锁
其中:
- 加锁:是将临界区状态设置为不可访问状态。
- 解锁:是将临界区状态设置为可访问状态。
其中加锁方法是:一个执行流在访问临界资源之前进行加锁操作,如果不能加锁则阻塞,然后在访问临界资源之后解锁
互斥锁实现的是互斥,本质上自己也是一个临界资源(是因为同一个资源所有线程在访问的时候必须加同一把锁(那么锁就只有一把,也是所有进程都访问的,所以他是一个临界资源))。
因此,互斥锁必须先保证自己是安全的,所以互斥锁的操作就是一个原子操作。
由于锁的状态的改变其实也是和寄存器有关的,但是我们知道,在寄存器上进行操作不是一个原子操作,所以锁和寄存器的关系是交换的关系。
如下图:
交换是一步完成的,此时我们就之间与寄存器上保存的锁的数量进行交换即可。
3.函数接口:
- 定义互斥锁变量:
pthread_mutex_t mutex;
//由于锁只有一个,所以锁应该定义为全局锁。 - 初始化互斥锁:
int pthread_mutex_init(pthread_mutex_t *mutex , pthread_mutexattr_t *attr);
其中:
①:mutex:定义的互斥锁变量。(将其地址传进去)
②:attr:互斥锁属性,通常置为NULL。
返回值:成功返回0;失败返回错误编号。 - 加锁:
①:int pthread_mutex_lock(pthread_mutex_t *mutex);
—阻塞等待。(意思为当前要访问的临界区已经有进程进去了,则阻塞等待,等待该进程对临界区访问完解锁后进入)
②:int pthread_mutex_trylock(pthread_mutex_t *mutex);
—非阻塞等待。(意思为当前要访问的临界区已经有进程进去了,那么就之间不等待,直接退出) - 解锁:
接口:int pthread_mutex_unlock(pthread_mutex_t* mutex);
—进程访问完临界资源后进行解锁操作。 - 释放销毁:
接口:int pthread_mutex_destory(pthread_mutex_t* mutex);
—对锁进行释放和销毁。
其中,对文章开始a进行加锁操作,对代码进行如下操作:
然后运行结果就不会出现我们意料之外的事情了。
注意:
- 在使用锁过程中,加锁后,在任何有可能退出的位置都要解锁。
- 锁只能保证安全操作,不能保证操作合理。
死锁
1.产生原因:死锁的产生就是我们在使用锁的过程中,我们加锁和解锁不规范所造成的。
2.死锁的概念:是指在一组进程中各个进程都会占有不会释放的资源,但因为互相申请被其他进程所占有的不会释放的资源而处于一种永久等待的状态。
3.产生死锁的四个必要条件:
- 互斥条件:一个资源同时只能被一个执行流(进程)操作。
- 请求与保持条件:一个个执行流(进程)因请求另一个资源而陷入堵塞时,对已经获得的资源保持不放手。
- 不剥夺条件:一个执行流(进程)已获得的资源,在未操作完成时,不能被其他执行流(进程)强行获取。
- 循环等待条件:若干个执行流形成了头尾相连的一种循环等待资源关系。
4.避免死锁的方法:
- 破坏死锁四个必要条件。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
5.解决死锁的方法:
银行家算法:
①关于我们在了解银行家算法之前,我们必须了解一个安全序列。
- 所谓安全序列,就是如歌系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找到一个安全序列,那么系统就处于安全状态。
- 而如果上述的安全序列没找到(一个安全序列都没有),那么系统就处于非安全状态。
②安全状态和非安全状态与死锁的关系:
- 如果系统处于安全状态,那么就一定不会发生死锁。而如果系统处于不安全状态,就有可能发生死锁,而如果系统发生了死锁,就一定处于不安全状态。
③:对上述情况,用一个图来解释:
同步
1.概念:在保证数据安全的情况下,让进程能够按照特定的顺序访问临界资源,从而有效避免饥饿访问的问题。
2.同步的实现条件:
- 条件变量。
- 信号量。
条件变量
1.条件变量的概念:提供一个pcb等待队列以及一个线程阻塞和唤醒的接口。
如下图:
如上图就是一个学生在食堂打饭的流程,其中,这个碗就是一个临界资源,我们通过对碗的操作,最终实现同步。
2.条件变量函数接口:
- 定义条件变量:
pthread_cond_t cond;
:其中,这个cond就是定义出来的条件变量。 - 初始化条件变量:
int pthread_cons_init(pthread_cond_t* cond,pthread_condattr_t* attr);
:初始化条件变量,其中第一个参数就是我们定义的条件变量,第二个参数其实是条件变量的属性,我们一遍置为NULL。 - 使线程阻塞:
①:int pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex);
:使线程阻塞,并且第一个接口是我们定义的条件变量,第二个接口是锁,因为我们阻塞一个进程时候,必须解锁。其实这个操作一共进行了三个操作:开始使用时是解锁+堵塞,然后被唤醒后会直接加锁。其中陷入阻塞的时候先解锁再阻塞,这两步操作必须是一个原子操作,所以是同时完成的。而被唤醒后加锁,是等待进程被唤醒的时候加锁的。
②:int pthread_cond_timewait(pthread_cond_t* cond,pthread_mutex_t* mutex,struct timespec__abstime time);
:其中这个函数接口也是完成线程阻塞的,不过这个函数有个堵塞时间time,此时如果超过了堵塞时间,那么就直接退出,不玩了。
其中:将线程陷入阻塞状态其实是,将pcb状态设置为可中断休眠态,并且指定一个唤醒条件。但是对于一个进程,它的所有线程都会接收到这个信号,那么肯定会出现异常行为,所以条件变量是这样做的,他会将这些线程的状态先修改为可中断休眠态,然后将这些线程加入到cond的pcb队列中,此时我们唤醒的时候,只唤醒在cond的pcb队列上就可以了。 - 唤醒阻塞的进程:
①:int pthread_cond_signal(pthread_cond_t* cond);
:其中参数为我们定义的条件变量。(并且唤醒这个阻塞的进程,我们至少唤醒一个,因为同一的cond队列上会有多个阻塞进程,我们无法判断唤醒的是那些进程)
②:int pthread_cond_broadcast(pthread_cond_t* cond);
:其中它和上面的操作是相同的,但是它唤醒的所有cond队列上的pcb。 - 销毁条件变量:
int pthread_cond_destory(pthread_cond_t* cond);
:释放条件变量的资源,销毁条件变量。
注意:对于信号只是提供了使线程堵塞,和唤醒的接口,对于什么时候堵塞,什么时候唤醒,是我们人为的操作。
3.实现出来的代码如下:
操作结果如下:
程序有顺序的进行,不会发生不好的情况。
在写代码的时候一定要注意:
- 是否满足条件的判断使用循环操作。
- 多个线程等待同一个资源时,应该使用while循环。多种角色的线程等待应该分开等待,分开唤醒,防止唤醒角色错误。
- 多种角色,应该设置多种条件变量。
生产者与消费者模型
1.为什么要使用生产者与消费者模型:
生产者和消费者就是通过一个容器来解决了它俩直接的强耦合问题的。生产者与消费者之间不直接通信,而是通过一个阻塞队列进行间接通信,生产者将生产的东西不需要等待消费者去使用,而是直接放入到队列中,而消费者也不用去生产者那边去拿东西,而是直接通过从队列中拿东西,得到自己需要的东西,阻塞队列就像是一个缓冲区,平衡了生产者与消费者的处理能力。而这个阻塞队列就是给生产者与消费者解耦合的。
2.生产者与消费者模型的优点:
- 解耦合
- 支持忙闲不均(就是通过阻塞队列实现)
- 支持并发(缓冲队列必须线程安全)
- 针对大量数据的产生与处理的场景
其中,生产者消费者模型的模拟图如下:
不同的生产者给阻塞队列中放数据,而不同的消费者从阻塞队列中提取自己所需的数据。
3.生产者消费者模型的模拟实现:
但是对于这段程序的执行结果,会有点不尽人意,打印的顺序会不同,因为打印和加入和取出数据不是一个原子操作,所以会造成这样的结果。
信号量
1.本质:就是一个计数器,用于实现进程或者线程间的同步与互斥。
2.操作:
- P操作:计数-1,判断计数器是否>=0,如果是,那么就返回;如果不是,那么就堵塞。
- V操作:计数+1,并且唤醒一个阻塞的进程。
其实,对于P和V的操作也就是对一个临界值进行操作,和阻塞队列的性质差不多。
3.实现:
- 同步的实现:通过计数器对资源数量进行计数,在获取资源之前进行P操作,产生释放之后进行V操作。通过这种方式是实现对资源的合理获取。
- 互斥的实现:计数器的初始值为1,在访问之前进行P操作,在访问之后进行V操作。
4.函数接口:
头文件为#include <semaphore.h>。
- 定义信号量:
sem_t sem;
//定义一个sem的信号量。 - 初始化信号量:
int sem_init(sem_t* sem,int pthread,unsigned int value)
//初始化信号量。其中:
①:sem为我们定义的信号量。
②:pthread为在进程间还是线程间的操作:0表示线程间,1表示进程间。其实在一个进程中,在线程间与在进程间表示这个信号量是全局信号量还是局部信号量。
③:value为信号量初值,有多少资源初值就定义多少。 - 等待信号量:这里一共有三个接口
①:int sem_wait(sem_t* sem);
//阻塞等待信号量,会将信号量的数量-1。
②:int sem_trywait(sem_t* sem);
//非阻塞等待信号量。
③:int sem_timewait(sem_t* sem,struct timespec* s)
//阻塞等待s的时间。
这也是P操作的内容。 int sem_post(sem_t* sem)
;//发布信号量,表示资源可以使用完毕,可以归还资源了,将信号量加1。这也是V操作的内容。int sem_destory(sem_t* sem);
//销毁信号量。
5.用信号量实现生产者与消费者模型:
注意:用信号量实现的模型,它的底层其实是一个循环队列,这就是对资源的最大化利用。