Linux——多线程(二):线程安全,线程不安全现象,线程互斥-互斥锁(原理,接口,使用),死锁


1.线程安全

线程安全:多个执行流,访问临界资源,不会导致程序产生二义性

  • 执行流:理解为线程
  • 访问:指的是对临界资源进行操作
  • 临界资源:指的是多个线程都可以访问到的资源
    eg:全局变量,某个结构体(不能是定义在某个线程入口函数内),某个类的实例化指针
  • 临界区:代码操作临界资源的代码区域称之为临界区
  • 二义性:结果会有多个

2. 如何描述线程不安全的现象

2.1 正常变量操作的原理

正常情况,假设我们定义一个变量 i 这个变量 i 一定是保存在内存当中的,当我们要对这个变量 i 进行计算的时候,是CPU(两大核心功能:算术运算和逻辑运算)来计算的,假设要对变量 i = 10 进行 +1 操作,首先要将内存中的 i 的值为 10 告知给寄存器,此时,寄存器中就有一个值 10,然后让CPU对寄存器中的这个 10 进行 +1 操作,CPU +1 操作完毕后,将结果 11 回写到寄存器当中,此时寄存器中的值被改为 11,然后将寄存器中的值回写到内存当中,此时 i 的值为 11

图解如下:
在这里插入图片描述

2.2 线程不安全现象的描述

此处线程不安全现象的描述是基于 2.1 正常变量操作的原理描述的,分以下几步描述:
(1)假设场景,有几个线程,每个线程想做什么事

假设有两个线程,线程A和线程B,线程A和线程B都想对全局变量 i 进行++

(2)分线程去描述,体现出:线程切换,上下文信息,程序计数器

假设全局变量 i 的值为 10,线程A从内存中把全局变量 i = 10 读到寄存器当中,此时,线程A的时间片到了,线程A被切换出来了,线程A的上下文信息中保存的是寄存器中的i = 10,程序计数器中保存的是下一条即将要执行的 ++ 指令,若此时线程B获取了CPU资源,也想对全局变量 i 进行 ++ 操作,因为此时线程A并未将运算结果返回到内存当中,所以线程B从内存当中读到的全局变量 i 的值还是10,然后将 i 的值读到寄存器中,然后再在CPU中进行 ++ 操作,然后将 ++ 后的结果 11,回写到寄存器,寄存器再回写到内存,此时内存当中 i 的值已经被线程B进行 ++ 后改为了 11,然后线程B将CPU资源让出来,此时线程A再切换回来的时候,它要执行的下一条指令是程序计数器中保存的对 i 进行 ++ 操作 ,而线程A此时 ++ 的 i 的值是从上下文信息中获取的,上下文信息中此时的 i = 10 ,此时线程A在CPU中完成对 i 的 ++ 操作,然后将结果 11 回写给寄存器,然后由寄存器再回写给内存,此时内存中的 i 被线程B改为了 11,虽然 ,线程A和线程B都对全局变量 i 进行了 ++ ,按理说最终全局变量 i 的值应该为12,而此时全局变量 i 的值却为11

(3)总结

线程A对全局变量 i 加了一次,线程B也对全局变量 i 加了一次,而此时,全局变量的值为 11 而不是 12,由此就产生了多个线程同时操作临界资源的时候有可能产生二义性问题(线程不安全现象)

图解如下:
在这里插入图片描述

2.3 代码模拟线程不安全现象(模拟黄牛抢票)

假设我们600张票,让两个黄牛抢,即定义一个全局变量为600,创建两个线程,并在两个线程的线程入口函数中对这个全局变量进行修改(大于等于0减一),最终,我们有可能会看到两个线程拿到了同一张票,代码如下所示:

    1 #include<stdio.h>                                                                                                                     
    2 #include<unistd.h>
    3 #include<pthread.h>
    4 
    5 int get_ticket = 600;
    6 
    7 void* Mythreadstart(void* arg)
    8 {
    9     while(1)
   10     {
   11         //当票大于0,抢到一张票,总数减一
   12         if(get_ticket > 0)
   13         {
   14             printf("i have %d,i am %p\n",get_ticket,pthread_self());
   15             get_ticket--;
   16         }
   17         //票数小于等于0,结束
   18         else
   19         {
   20             pthread_exit(NULL);
   21         }
   22     }
   23     return NULL;
   24 }
   25 
   26 int main()
   27 {
   28     pthread_t tid[2];
   29     //创建两个工作线程        
   30     for(int i = 0;i < 2;++i)
   31     {       
   32         int ret = pthread_create(&tid[i],NULL,Mythreadstart,NULL);
   33         if(ret < 0)                      
   34         {                 
   35             perror("pthread_create");
   36             return 0;                                               
   37         }                
   38     }    
   39                              
   40             
   41     //线程等待
   42     for(int i = 0;i < 2;++i)   
   43     {    
   44         pthread_join(tid[i],NULL);
   45     }           
   46  
   47     printf("pthread_join is end...\n");
   48     return 0;
   49 }                                                                               

在这里插入图片描述
在这里插入图片描述
如上图所示,我们可以看到两个线程都拿到了第360张票,这就产生了二义性,即线程不安全现象

此时存在如下两个问题:

  • 线程在取票的时候,多个线程可能会拿到同一张票,,若CPU多的话有可能会拿到负数(互斥锁解决此问题)
  • 线程拿票不合理,可能一个线程A拿了所有的票,而另一个线程B只拿了一张票还与线程A相同

3. 线程互斥

3.1 互斥锁的原理

(1)什么是互斥锁

  互斥锁的底层是一个互斥量,而互斥量的本质就是一个计数器,计数器的取值只有两种情况,一种是 1 ,一种是 0 ;

  • 1:表示当前临界资源可以被访问
  • 0:表示当前临界资源不可以被访问

(2)获取 / 释放互斥锁的逻辑

  • 调用加锁接口,枷锁接口内部判断计数器的值是否为 1 ,若为 1 ,则能访问;当枷锁成功之后,就会将计数器的值从 1 变成 0 ;如果为 0 ,则不能访问
  • -调用解锁逻辑,计数器的值从 0 变成 1 ,表示资源可以使用

假设有一块临界资源,有一个线程A和一个线程B,按之前的黄牛抢票的思路,只要线程拥有时间片就可以去访问这块临界资源,现在我们给线程 A 和线程 B 都加上互斥锁,假设此时线程A要去访问临界资源,它首先得获取互斥锁,而此时互斥锁中的值为1,表示当前可以访问,线程 A 去访问临界资源然后将互斥锁中的 1 改为 0 ,此时如果线程B如果想要访问临界资源之前先要获取互斥锁,而此时互斥锁中的值为0,所以线程 B 此时不能访问临界资源,等线程 A 访问完毕后,就会将锁释放,此时所中的值就会从 0 变为 1 , 此时线程 B 判断互斥锁中的值变为 1 可以访问了,就可以去访问临界资源了;互斥锁保证了当前临界资源在同一时刻只能被一个执行流访问

加锁和解锁的图解如下:
在这里插入图片描述
注意:若要多个线程访问临界资源的时候是互斥访问的属性,一定要在多个线程中进行同一把锁的加锁操作,这样每个线程在访问临界资源之前都要获取这把锁,若锁中的值为 1 就能访问,为 0 则不能访问;若只给线程 A 枷锁线程 B 不加锁,那么线程 A 判断锁中的值为 1 ,则访问临界资源并将锁中的值改为 0 ,而线程 B 未加这把锁,则不需要获取锁并判断锁中的值是否为 1 就可以直接对临界资源进行访问,会出现线程不安全现象

(3)加锁逻辑

加锁的时候会提前在寄存器的计数器中保存的一个值 0,而不管内存的计数器中保存的值为多少,都会将寄存器中保存到 0 和内存计数器中保存的值进行交互,然后对寄存器中的值进行判断是否为 1 ,如果为 1 ,则能加锁,如果不为 1 ,则不能加锁

图解如下:
在这里插入图片描述

3.2 互斥锁的接口

(1) 初始化互斥锁

互斥锁的类型:pthread_mutex_t

1.1 静态初始化

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

  • PTHREAD_MUTEX_INITIALIZER 这是一个宏定义,本质包含多个值

在vim /usr/include/pthread.h路径下就可以查看这个宏定义,这个宏中的 0,_PTHREAD_SPINS 是初始化 pthread_mutex_t 这个变量的,如下图所示:
在这里插入图片描述
而 pthread_mutex_t 实际是一个联合体,可在 vim /usr/include/bits/pthreadtypes.h 路径下查看,而静态初始化实际就是用上面那个宏初始化这个联合体,如下图所示:
在这里插入图片描述

1.2 动态初始化

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

  • mutex:该参数为出参,由调用者传递一个互斥锁变量的地址,由pthread_mutex_init这个函数进行初始化
  • attr:互斥锁的属性信息,一般设置为NULL,采用默认属性

    注意:动态初始化互斥锁变量的情况需要动态销毁互斥锁,否则就会造成内存泄漏

(2)加锁

2.1 阻塞加锁接口

int pthread_mutex_lock(pthread_mutex_t *mutex);

  • 如果互斥锁变量当中的计数器的值为1,调用该接口,则加锁成功,该接口调用完毕,函数返回
  • 如果互斥锁变量当中的计数器的值为0,调用该接口,则调用该接口的执行流阻塞在当前接口内部
2.2 非阻塞加锁接口

int pthread_mutex_trylock(pthread_mutex_t *mutex);

  • 不管有没有加锁成功,都会返回,所以需要对加锁返回的结果进行判断,判断是否加锁,如果加锁成功,则操作临界资源。反之,则需要循环获取互斥锁,直到拿到互斥锁
2.3 带有超时时间的加锁接口

int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,const struct timespec *restrict abs_timeout);

  • struct timespace结构体中有两个成员,一个是秒,一个是纳秒,所以最高精度是纳秒,如下图所示:
    在这里插入图片描述
  • 超时时间内,如果还没有获取到互斥锁,则返回;超时时间内,如果获取了互斥锁也直接返回;所以,最终要对返回结果进行判断,判断是否加锁成功

(3) 解锁接口

int pthread_mutex_unlock(pthread_mutex_t *mutex);

  • 不管是阻塞加锁 / 非阻塞加锁 / 带有超 时时间的加锁,加锁成功的互斥锁,都可以使用该接口进行解锁

(4)销毁接口

int pthread_mutex_destroy(pthread_mutex_t *mutex);

  • 释放动态开辟的互斥锁资源

3.3 互斥锁的使用(对黄牛抢票代码优化)

在使用互斥锁的时候会存在以下几个问题:

(1)什么时候初始化互斥锁?

   在线程创建之前,进行初始化互斥锁

(2)什么时候进行加锁?

   在执行流访问临界资源之前,进行加锁操作

   注意:如果一个执行流加锁成功之后,再去获取互斥锁,该执行流也会阻塞(即加锁之后没有解锁,再去访问临界资源),加锁之后一定要记得解锁,否则就会导致死锁

(3)什么时候进行解锁?

   在执行流所有可能退出的地方都要进行解锁

(4)什么时候释放互斥锁资源?

   在所有使用该互斥锁的线程全部退出之后,就可以释放该互斥锁了

(5)验证一:加锁之后不解锁

如下代码,我们对之前写的黄牛抢票代码进行优化,在其正确的位置进行,初始化互斥锁,加锁,释放互斥锁资源,而不对其进行解锁操作,代码如下:

    1 #include<stdio.h>
    2 #include<unistd.h>
    3 #include<pthread.h>
    4 
    5 pthread_mutex_t My_lock;
    6 
    7 int get_ticket = 600;
    8 
    9 void* Mythreadstart(void* arg)
   10 {
   11     while(1)
   12     {
   13         //加锁
   14         pthread_mutex_lock(&My_lock);
   15 
   16         //当票大于0,抢到一张票,总数减一
   17         if(get_ticket > 0)
   18         {                                                                                                                  
   19             printf("i have %d,i am %p\n",get_ticket,pthread_self());
   20             get_ticket--;
   21         }
   22         //票数小于等于0,结束
   23         else
   24         {
   25             pthread_exit(NULL);
   26         }
   27     }
   28     return NULL;
   29 }
   30 
   31 int main()
   32 {
   33     //初始化互斥锁
   34     pthread_mutex_init(&My_lock,NULL);
   35     pthread_t tid[2];
   36     //创建两个工作线程
   37     for(int i = 0;i < 2;++i)
   38     {
   39         int ret = pthread_create(&tid[i],NULL,Mythreadstart,NULL);
   40         if(ret < 0)
   41         {
   42             perror("pthread_create");
   43             return 0;
   44         }
   45     }
   46 
   47                                                                                                                            
   48     //线程等待
   49     for(int i = 0;i < 2;++i)
   50     {
   51         pthread_join(tid[i],NULL);
   52     }
   53 
   54     //释放互斥锁资源
   55     pthread_mutex_destroy(&My_lock);
   56 
   57     printf("pthread_join is end...\n");
   58     return 0;
   59 }                           

让程序跑起来此时我们可以看到如下结果,只有一个线程拿到了一张票,然后程序就不往下跑了
在这里插入图片描述
此时,我们可以通过pstack查看,我们可以看到,是线程3抢到了票, 线程3能抢到票证明线程3已经加锁成功了,但通过pstack查看到线程3还在进行加锁,如下图所示
在这里插入图片描述
造成如上错误的原因是,线程3在执行完毕后,未解锁,再次去获取锁时,锁中计数器中的值还是0,所以证明了之前的结论:加锁之后一定要记得解锁,否则就会导致死锁
在这里插入图片描述

(6)通过gdb调试验证:加锁不解锁,导致的死锁现象

首先让程序跑起来,然后通过gdb attach [pid] 命令(用gdb调试一个正在运行的进程)进入gdb调试界面,如下图所示:
在这里插入图片描述
通过 thread apply all bt 命令查看当前程序的所有线程调用堆栈
在这里插入图片描述
然后通过 t [线程编号] 跳转到某一个线程的堆栈
在这里插入图片描述

然后通过 bt 查看线程的调用栈
在这里插入图片描述
f 跳转到某一个具体的堆栈里如上图所示,我们可以看到只有#3 我们可以进行调试
在这里插入图片描述
再打印互斥锁变量 My_lock,而在互斥锁变量中我们可以看到一个 __owner(互斥锁的拥有者) = 15037,而这个15037就是线程3,线程3此时要干的事情就是再去加锁,但当它获取这把锁的时候,它就会阻塞在加锁逻辑中(即线程3第一次加锁成功了,第二次再去获取这把锁的时候就会阻塞在加锁逻辑中)
在这里插入图片描述
在这里插入图片描述

(7)验证三:正确使用互斥锁对黄牛抢票代码进行优化

代码如下:

    1 #include<stdio.h>                                                                                                          
    2 #include<unistd.h>   
    3 #include<pthread.h>
    4                               
    5 pthread_mutex_t My_lock;
    6             
    7 int get_ticket = 600;
    8               
    9 void* Mythreadstart(void* arg)       
   10 {
   11     while(1)                             
   12     {                     
   13         //加锁
   14         pthread_mutex_lock(&My_lock);                               
   15                          
   16         //当票大于0,抢到一张票,总数减一
   17         if(get_ticket > 0)   
   18         {   
   19             printf("i have %d,i am %p\n",get_ticket,pthread_self());
   20             get_ticket--;
   21         }
   22         //票数小于等于0,结束  
   23         else
   24         {     
   25             //解锁                     
   26             pthread_mutex_unlock(&My_lock);
   27             pthread_exit(NULL);
   28         }
   29         //解锁
   30         pthread_mutex_unlock(&My_lock);
   31     }
   32     return NULL;
   33 }
   34 
   35 int main()
   36 {
   37     //初始化互斥锁
   38     pthread_mutex_init(&My_lock,NULL);
   39     pthread_t tid[2];
   40     //创建两个工作线程
   41     for(int i = 0;i < 2;++i)
   42     {
   43         int ret = pthread_create(&tid[i],NULL,Mythreadstart,NULL);
   44         if(ret < 0)
   45         {
   46             perror("pthread_create");
   47             return 0;                                                                                                      
   48         }
   49     }
   50 
   51 
   52     //线程等待
   53     for(int i = 0;i < 2;++i)
   54     {
   55         pthread_join(tid[i],NULL);
   56     }
   57 
   58     //释放互斥锁资源
   59     pthread_mutex_destroy(&My_lock);
   60 
   61     printf("pthread_join is end...\n");
   62     return 0;
   63 }                                                           

让程序跑起来,此时就解决了两个线程拿到一张票的问题,部分截图如下:
在这里插入图片描述

4. 死锁

4.1 死锁的定义及现象

简单的定义:当一个执行流获取到互斥锁之后,并没有进行解锁,就会导致其他执行流由于获取不到锁资源而进行阻塞,我们将这种现象称之为死锁

复杂定义:当线程A获取到互斥锁1 ,线程B获取到互斥锁2的时候,线程A和线程B同时还想获取对方手里的锁(线程A还想获取互斥锁2,线程B还想获取互斥锁1),此时就会导致死锁

图解如下:
在这里插入图片描述

4.2 死锁代码验证

创建两个线程A,B,加两把锁A,B让线程A拿A锁,线程B拿B锁,等待2s后,让线程A去拿B锁,线程B去拿A锁,此时,就会出现死锁

    1 #include<stdio.h>                                                                                                          
    2 #include<pthread.h>
    3 #include<unistd.h>
    4                            
    5  
    6 pthread_mutex_t My_lockA;         
    7 pthread_mutex_t My_lockB;
    8              
    9 void* MypthreadA(void* arg)
   10 {                                 
   11     //加锁
   12     pthread_mutex_lock(&My_lockA);
   13  
   14     //防止线程B没拿到B锁之前线程A拿到B锁
   15     sleep(2);
   16  
   17     pthread_mutex_lock(&My_lockB);
   18                                        
   19     return NULL;                       
   20 }
   21 void* MypthreadB(void* arg)
   22 {
   23     pthread_mutex_lock(&My_lockB);
   24                                                           
   25     sleep(2);  
   26 
   27     pthread_mutex_lock(&My_lockA);
   28 
   29     return NULL;                                                                                                           
   30 }
   31 
   32 int main()
   33 {
   34     //锁初始化
   35     pthread_mutex_init(&My_lockA,NULL);
   36     pthread_mutex_init(&My_lockB,NULL);
   37 
   38     pthread_t tid_A,tid_B;
   39 
   40     //创建两个线程
   41     int ret = pthread_create(&tid_A,NULL,MypthreadA,NULL);
   42     if(ret < 0)
   43     {
   44         perror("pthread_create");
   45         return 0;
   46     }
   47     pthread_create(&tid_B,NULL,MypthreadB,NULL);
   48     if(ret < 0)
   49     {
   50         perror("pthread_create");
   51         return 0; 
   52     }                                                     
   53                
   54      
   55     //线程等待                   
   56     pthread_join(tid_A,NULL);
   57     pthread_join(tid_B,NULL);
   58                                                 
   59     return 0;  
   60 }                                                        

让程序跑起来效果如下:
在这里插入图片描述
此时,我们要验线程A拿着A锁,阻塞在拿B锁处,线程B拿着B锁,阻塞在拿A锁处,通过gdb调试如下:
在这里插入图片描述

4.3 死锁的必要条件

  • 不可剥夺(不能改):执行流获取了互斥锁之后,除了自己主动释放锁,其他执行流不能解该互斥锁
  • 循环等待:线程A等待线程B拿的锁,线程B等待线程A拿的锁
  • 互斥条件(不能改):一个互斥锁,只能被一个执行流在同一时刻拥有
  • 请求与保持:线程A拿着 1 锁还想请求 2 锁,线程B拿着 2 锁还想请求 1 锁

4.4 预防死锁

(1)破坏必要条件:循环等待或者请求与保持
(2)加锁顺序一致
(3)避免锁没有被释放
(4)资源一次性分配

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值