【线程(二)】——互斥量的详细解析

   🍎作者:努力学习的少年

 🍎个人简介:双非大二,一个正在自学c++和linux操作系统,写博客是总结知识,方便复习

 🍎目标:进大厂

 🍎 如果你觉得文章可以的话,麻烦你给我点个赞和关注,感谢你的关注!

目录

进程线程间的互斥的相关概念

错误的抢票系统

linux互斥量(锁)

互斥量(锁)的原理探究

互斥量的优点

互斥量缺点

死锁

死锁的概念

死锁的场景

 死锁的4个必要条件

避免死锁

     避免死锁的算法(有兴趣可以查资料进行了解)


进程线程间的互斥的相关概念

  • 临界资源:多线程执行流共享的资源叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码叫做临界区。

例如如下代码:

其中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个步骤:

  1. 创建一把锁, 也就是定义一个pthread_mutex_t的变量,这个变量一般需要定义在全局区,让所有的线程都访问的到
  2. 然后对这把锁初始化。动态初始化,静态初始化。
  3. 将锁放到需要加锁的代码的前面,用pthread_mutex_lock()进行加锁。
  4. 运行加锁后的代码,进行解锁,用pthread_mutex_unlock()进行解锁
  5. 锁用完之后需要对锁的销毁,用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线程(壹)】——初识线程(区分线程和进程,线程创建的基本操作)

下一篇预告:【线程的同步与互斥(二)】——条件变量的详细解析

感谢你观看到这里,如果你觉得文章对你有帮助的话,麻烦给个三连~

  • 17
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值