🍎作者:努力学习的少年
🍎个人简介:双非大二,一个正在自学c++和linux操作系统,写博客是总结知识,方便复习
🍎目标:进大厂
🍎 如果你觉得文章可以的话,麻烦你给我点个赞和关注,感谢你的关注!
目录
进程线程间的互斥的相关概念
- 临界资源:多线程执行流共享的资源叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码叫做临界区。
例如如下代码:
其中count是临界资源,因为它是全局变量,所以它存储在数据段中,新线程和主线程都可以访问到它,新线程中代码count++和主线程代码printf("%d\n",count);中都访问了count,所以这两句代码是临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
多个线程在访问临界资源时可能会发生冲突,为了避免这种情况而引入线程同步机制。在Linux中,主要通过互斥量,条件变量,信号量,实现线程同步。互斥量可以帮助多线程同时使用共享资源,防止一线程试图访问一共享资源时,另一线程正在对其修改的情况发生;条件变量则是对互斥量不足的补缺,允许线程互相通知共享变量的状态发生了变化;信号量在互斥的基础上,通过其他机制实现访问者对资源的有序访问。
错误的抢票系统
假设有10000张票,让5个线程在同时抢这10000张票,但是最后会出现错误的情况。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int tickets=10000;
void* grabtickets(void* argv)//线程进行抢票
{
int name=(int)argv;
while(1)
{
if(tickets>0)//票数大于9,进行抢票
{
printf("thread[%d],票号: %d\n",name,tickets);
tickets--;
usleep(1000);
}
else{//小于0,跳出循环
break;
}
}
return NULL;
}
int main()
{
pthread_t ids[5];
for(int i=0;i<5;i++)//创建5个新线程
{
pthread_create(&ids[i],NULL,grabtickets,(void*)i);
}
for(int i=0;i<5;i++)
{
pthread_join(ids[i],NULL);
}
return 0;
}
一次抢票的输出结果:
thread[2],票号:10000
.......
thread[1],票号: 5
thread[4],票号: 4
thread[3],票号: 0
thread[0],票号: -1
thread[2],票号: -2
我们的条件设置为tickets>0的时候,线程才能进行抢票,为什么会出现票号为0,-1,-2呢?
其实本质上每个线程中的ticks出现数据不一致的问题。
首先问一下,if(tickets>0)和tickets--这两条语句是否是原子性吗?
结合原子性的概念,答案是这两条语句都不具备原子性,因为它们在执行过程随时都会被调度机制给打断。原因如下:
ticket--:编译生成有三条汇编代码(每一条汇编代码代表一个执行过程),所以执行ticket--这条语句需要三个过程。
- 过程一:从内存中取出tickets值给寄存器。
- 过程二:cpu对该值进行运算--;
- 过程三:将新的ticket值写回到内存中的tickets中
线程在cpu上运行是有时间片,当线程的时间片到时,切换其他线程在cpu上运行,在该线程从cpu剥夺下来之前,线程需要保存该线程在cpu上寄存器的数据。
那么为什么当几个线程都在计算临界资源时,那么临界资源的数据就有可能出现不一致。例如:
当线程一在执行tickets--中的过程二,当线程一的时间片到了,操作系统将该线程一从cpu给剥夺下来,其中寄存器上的数据已经保存到线程一上面,再换其它线程在cpu上运行,等到该线程一再次运行时,线程一就将上次未运行完成的数据和代码再赋值给寄存器上,再继续执行线程一,所以这就造成了线程与线程之间的临界资源数据不一致的问题。
(ps:每个线程都有一组寄存器,这组寄存器保存的是线程保存寄存器上下文代码和数据。每个线程的执行的时候,会将这组寄存器保存的数据拿上来给cpu上的寄存器上)
if(tickets>0)编译生成有两条汇编代码,执行该语句需要两个过程。
- 过程一:从内存中取出tickets的值给cpu的寄存器上
- 过程二:cpu进行判断
那么我们再来分析为什么会出现0,-1,-2这些情况。
假设这时候有线程1和线程2在执行同一份代码,此时的内存中的tickets的值为1,线程1执行if(tickets>0)判断为真,此时线程1的时间片到了后,将上下文数据和代码后保存到线程1上的一组寄存器上,操作系统再切换线程2在cpu上运行,线程2执行if(tickets>0)为判断真后,进行tickets--,此时的tickets的值由1变为0,再到了线程1时间片的时候,线程1再拿出保存的上下文代码和数据进行运行,由于上次if语句判断为真,所以线程1会执行tickets--语句,此时的线程1从内存中拿到tickets的值就为0,执行后就会变为-1.所以线程1抢到的票就为-1,这明显结果是有问题的。
还有一些特别的状况:假设此时内存中的tickets的票数是100,此时线程1执行ticket--的语句,执行到过程2后,此时寄存器上的tickets=99,线程还未将执行过程3(没有将寄存器上的数据保存到内存上的tickets时),时间片到了,将寄存器的tickets=99保存到线程1的一组寄存器上,然后切换到下一个线程,假设切换到线程2,此时线程2也执行tickets--语句,线程2一下子执行了10次后时间片到,此时内存上的tickets的值变为90,轮到线程1的时候,线程1将一组寄存器的数据拿到寄存器继续执行上一次没有执行代码,此时线程1将寄存器tickets为99的值赋值给内存上的tickets,此时内存上tickets又多了9张票,这很显然导致结果错误。
上面这些情况下面的代码是线程并发运行导致的,每个都可以同时执行下面这段代码:
if(tickets>0)//票数大于9,进行抢票
{
printf("thread[%d],票号: %d\n",name,tickets);
tickets--;
usleep(1000);
}
为了解决线程与线程之间数据不一致的问题,线程中引入一个互斥量。如下。
linux互斥量(锁)
互斥量本质是一把"锁",对临界区加锁后,所有的线程进入临界区前,必须先拿到该锁才能进入临界区,如果拿不到锁的线程,将会被阻塞在锁的位置上,直到当前线程释放该互斥锁。这样做保证不会有两个线程同时访问临界区,解决了线程之间数据不一致的问题,保证了临界资源数据的正确性。
锁的函数
#include <pthread.h>
//锁的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//锁的动态初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//锁的静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
函数执行成功则返回0,否则返回错误编号
参数
mutex:要初始化的互斥量或者要销毁的互斥量,所有互斥量等待类型都必须为pthread_mutex_t类型。
attr:初始化锁的属性,默认设置为NULL
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
对代码进行加锁的过程中,我们需要进行5个步骤:
- 创建一把锁, 也就是定义一个pthread_mutex_t的变量,这个变量一般需要定义在全局区,让所有的线程都访问的到。
- 然后对这把锁初始化。动态初始化,静态初始化。
- 将锁放到需要加锁的代码的前面,用pthread_mutex_lock()进行加锁。
- 运行加锁后的代码,进行解锁,用pthread_mutex_unlock()进行解锁
- 锁用完之后需要对锁的销毁,用pthread_mutex_lock()对锁进行销毁。
pthread_mutex_t mutex;//创建一把锁
int tickets=10000;
void* grabtickets(void* argv)
{
int name=(int)argv;
while(1)
{
pthread_mutex_lock(&mutex);//加锁
if(tickets>0)
{
printf("thread[%d],票号: %d\n",name,tickets--);
usleep(1000);
pthread_mutex_unlock(&mutex);//解锁
}
else{
pthread_mutex_unlock(&mutex);//解锁
break;
}
}
return NULL;
}
int main()
{
pthread_mutex_init(&mutex,NULL);//锁的初始化
pthread_t ids[10];
for(int i=0;i<10;i++)
{
//创建线程
pthread_create(&ids[i],NULL,grabtickets,(void*)i);
}
for(int i=0;i<10;i++)
{
pthread_join(ids[i],NULL);
}
pthread_mutex_destroy(&mutex);//将锁销毁
return 0;
}
进行加锁后的代码(临界区),可以保证只有一个线程能访问被锁的临界区原理如下:
没有加锁的情况:不管是临界区还是非临界区,所有线程都可以并发运行
加锁的情况:在非临界区的时候,所有线程是可以并发执行,当线程进入临界区前,线程之间就需要竞争申请锁(注意:每次只能有一个线程竞争到锁),申请到锁的线程才可以进入进入临界区,申请不到锁的线程就只能在锁外面一直等待,当cpu执行到该线程的时候,没有锁只能阻塞在该锁的队列中,不能向下执行代码。当有锁的线程将释放出来后,其他线程才可以去竞争锁,谁竞争到锁,谁就可以进入临界区。这样就可以避免了多个线程在临界区中同时并发执行(如果锁没有释放掉,则外面的线程将会一直等待)。
互斥量(锁)的原理探究
锁本身是定义全局变量,也属于临界资源,锁的存在是为了保护临界资源,那么锁需要被保护吗?
答案是不需要,因为申请锁的过程是原子性的,申请的过程不会被调度机制给打断的,锁的实现原理探究:为了实现互斥锁的操作,大多数体系结构都提供了swap或者exchange指令,该指令是能够直接将寄存器上的值与内存上单元上的数据直接交换,由于只有一条指令,保证了原子性,接下来我们来看一份伪代码:
%al是一个cpu上的寄存器,xchgb是交换指令。
我们创建锁,本质是在内存上创建一个变量,初始化锁是将锁的初始化为一个非0的值。如下:
我们假设这个锁的初始化的值值为1.
执行pthread_mutex_lock函数过程:
申请锁的线程在临界区是否可以进行线程切换?
申请到锁有可能被切换走后,但是其他线程去申请锁申请不到会被挂起等待,因为申请到锁的线程还没有释放锁,其它线程申请不到该锁,所以其它线程就不能进入该临界区。这样保证了只有一个线程进入了临界区。
互斥量的优点
保护临界资源,解决线程与线程之间出现数据不一致的问题,保证一次只能有一个线程进入互斥量保护的区域。
互斥量缺点
加锁是会损耗线程的性能,因为加锁需要申请锁,和释放锁的过程,并且加锁之后,临界资源一次只能有一个线程进行运行,因此,加锁的区域破坏了多线程并发的过程,所以建议在编码的时候,非必要的情况下最后不要加锁。
死锁
死锁的概念
死锁是一组进程中的各个进程均占有不会释放资源,但都需要互相申请其他进程不会释放的资源导致永久的等待的等待。
死锁的场景
如下:
线程一和线程二都需要申请到锁1和锁2,当线程一先申请到了锁1,线程二申请到锁2,当线程一要去申请锁2,此时线程二还未释放锁2,所以线程一申请不到锁2,线程一被挂起等待,当线程二要去申请锁1,此时的线程1还未释放锁1并且线程1被挂起,所以线程二则永远申请不到锁1,也被挂起等待,最终线程一和线程二都申请不到对方的锁,都被挂起等待,这就是死锁。
死锁的4个必要条件
- 互斥条件:一个锁只能被一个执行流申请到,例如线程一和线程二不能同时申请到锁1和锁2.
- 请求与保持条件:一个执行流因申请不到锁而阻塞时,不会释放以获得的锁。例如线程一申请锁2失败被挂起等待时,线程1不会释放以获得的锁1.
- 不剥夺条件:一个执行流以获得的资源,在未使用完之前,不会被强行剥夺,例如线程1不会强行剥夺线程2的锁。
- 循环等待条件:若干个执行流之间形成一种头尾循环等待资源的关系。例如线程一和线程二之间的加锁顺序不一致,线程一先加锁1,再加锁二,然而线程二先加锁2,再加锁1.
避免死锁
- 破坏死锁中必要条件中的其中一个条件。
- 每个线程的顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
避免死锁的算法(有兴趣可以查资料进行了解)
银行家算法
死锁检测算法
上一篇推荐:【linux线程(壹)】——初识线程(区分线程和进程,线程创建的基本操作)
下一篇预告:【线程的同步与互斥(二)】——条件变量的详细解析
感谢你观看到这里,如果你觉得文章对你有帮助的话,麻烦给个三连~