【Linux】线程安全篇Ⅰ

文章目录

  • 0、概述
  • 1、线程不安全举例
    • 1.1 前提知识铺垫
    • 1.2 场景模拟
    • 1.3 代码模拟
  • 2、互斥
    • 2.1 什么是互斥
    • 2.2 互斥锁的原理&&特性
    • 2.3 互斥锁的计数器如何保证原子性
    • 2.4 互斥锁的接口
      • 2.4.1 初始化接口
      • 2.4.2 加锁接口
      • 2.4.3 解锁接口
      • 2.4.4 销毁接口

0、概述

本文主要介绍了以下几点内容:
1、线程存在不安全(程序结果二义性)的情况
2、线程的不安全可以通过互斥的思想,利用互斥锁来解决
3、如何使用互斥锁的相关接口&&注意事项

1、线程不安全举例

1.1 前提知识铺垫

1、线程在Linux操作系统中也是用一个task_struct结构体来进行描述的。

2、多个线程之间也是抢占式执行的。对于多核CPU来说,特们可以并行执行,对于单核CPU来说,只能是并发执行。

3、与进程切换一样,每个线程在进行切换的时候,它对应的PCB中也会有程序计数器和上下文信息来保存该线程的执行状态,确保下一次被调用之后能够正确执行

4、原子性:某一过程只会存在两种状态,要么全部执行完毕,要么还没有开始执行。不存在中间状态。

1.2 场景模拟

有一个单核的CPU,有两个线程,分别是线程A和线程B,有一个全局的整型变量num,初始值为10;

线程A 和线程B 执行的代码都是对全局变量进行+1操作。

由于线程A和B是抢占式执行的,那么一定会有以下这种情况的存在:

①我们假设A先拿到CPU资源将num的值从内存读取到寄存器中,此时由于某些原因(CPU调度),线程A被剥离CPU。此时线程A的PCB中的程序计数器和上下文信息记录了线程A的执行状态以及下一条要执行的指令

②然后由线程B拿到CPU资源,将num的值从内存中读取到寄存器,通过CPU计算后回写到寄存器,然后再回写到内存当中。此时内存中num的值为11

③此时线程B的代码执行完毕,线程A重新获得CPU资源。此时线程A并不会从内存中读取num的值,而是通过程序计数器和上下文信息来恢复现场因此线程A 拿到的数据依旧是最开始的10,对其自增后,回写到寄存器,再回写到内存中。此时内存中的num值依旧是11

④此时,统观整个过程来看,线程A和线程B分别对全局变量num进行了+1操作,理应num的值应该为12,但是现在的结果却是11。这就是线程不安全的具体表现,程序的结果产生了二义性。

下面我们通过图解进一步理解上述的过程:
【Linux】线程安全篇Ⅰ_第1张图片
线程不安全的原因本质上就是对临界区的非原子性访问导致的

1.3 代码模拟

下面我们通过代码来模拟以下上面的场景。
【Linux】线程安全篇Ⅰ_第2张图片
【Linux】线程安全篇Ⅰ_第3张图片
注意:这并不是一个必现的结果,也就是说结果有可能正确,有可能错误。并不确定。在单核CPU环境下测试比比较困难,现象不是很明显。但是,线程不安全是一定存在的。也是必须要解决的!

2、互斥

2.1 什么是互斥

互斥是一种控制线程访问时序的手段。具体如下:
【Linux】线程安全篇Ⅰ_第4张图片

2.2 互斥锁的原理&&特性

1、原理
互斥锁本质上是一个0/1计数器,计数器的取值只能是0或1
 计数器值为1:表示当前线程可以获取到互斥锁,从而去访问临界资源
 计数器值为0:表示当前线程不能获取到互斥锁,也就不能访问临界资源
2、特性
【Linux】线程安全篇Ⅰ_第5张图片

2.3 互斥锁的计数器如何保证原子性

为什么计数器当中的值从0变为1,或者从1变为0是原子性的呢?
计数器中值的变换是直接使用寄存器当中的值和计数器内存的值进行交换。该交换过程使用一条汇编指令就可以完成,因此是原子性的。
具体过程如下:

加锁过程:将寄存器中的值设置为0
 情况1:计数器的值为1,说明锁空闲,没有被线程加锁
【Linux】线程安全篇Ⅰ_第6张图片
 情况2:计数器的值为0,说明锁忙碌,被其他线程加锁拿走
【Linux】线程安全篇Ⅰ_第7张图片
交换结束后,判断寄存器中的值,如果值为1,表示加锁成功,如果值为0,表示加锁失败。

解锁过程:

直接使用交换的汇编指令将1和计数器内存中的值进行交换

保证互斥锁计数器原子性的汇编伪码如下:

lock:
       movb $0,%al
        xchgb %al,mutex     交换指令
        if(al 寄存器的内容 > 0){
              return 0;    
         }
        else{
              挂起等待
          }
        goto lock;
unlock:
           movb $1, mutex
            唤醒等待Mutex的线程;
             return 0

2.4 互斥锁的接口

2.4.1 初始化接口

动态初始化
int pthread_mutex_init(pthread_mutex_t* mutex,const pthread_mutexattr_t* attr);
参数

mutex:传递互斥锁对象
attr:互斥锁属性,一般传递为NULL,表示使用默认属性

返回值

初始化成功,返回0
初始化失败,设置errno,并将errno返回

静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
#define PTHREAD_MUTEX_INITIALIZER {{0,0,0,0,0…}}结构体的初始化

2.4.2 加锁接口

int pthread_mutex_lock(pthread_mutex_t *mutex);
参数

mutex:传递互斥锁对象

返回值

加锁成功,返回0
【Linux】线程安全篇Ⅰ_第8张图片

int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数

mutex:传递互斥锁对象

返回值

加锁成功,返回0
【Linux】线程安全篇Ⅰ_第9张图片

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

是一种带有超时时间的加锁接口,具体细节见下图:

【Linux】线程安全篇Ⅰ_第10张图片

,现在我们通过代码验证一下初始化和加锁的接口。
我们发现上面的代码存在结果的二义性,是一种不安全的现象,因此我们需要通过加锁的方式保证对临界区代码访问的原子性,也就是保证了线程的安全。
demo:

#include
#include
#include

int g_ticket = 10000;
pthread_mutex_t g_lock;//互斥锁

void* thread_start(void* arg)
{
    //加锁
    pthread_mutex_lock(&g_lock);
    //修改全局变量
    while(g_ticket > 0)
    {
        printf("I am %p,I got value is %d\n",pthread_self(),g_ticket);
        g_ticket--;
    }
}


int main()
{
    //初始化互斥锁
    pthread_mutex_init(&g_lock,NULL);
    pthread_t tid[2];
    //创建两个线程A和B
    for(int i= 0;i<2;++i)
    {
        int ret = pthread_create(&tid[i],NULL,thread_start,NULL);
        if(ret < 0)
        {
            perror("pthread_create");
            return 0;
        }
    }

    //主线程等待接收
    for(int i=0;i<2;i++)
    {
        pthread_join(tid[i],NULL);
    }

    return 0;
}

【Linux】线程安全篇Ⅰ_第11张图片
分析代码运行结果
【Linux】线程安全篇Ⅰ_第12张图片
目前程序无法退出是因为有一个线程拿不到互斥锁,那应该如何解决呢?
让拿到锁的线程在退出的时候将互斥锁释放就好了!下面我们就来介绍以下互斥锁的解锁接口~

2.4.3 解锁接口

int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数

mutex:要解锁的互斥锁变量

返回值

解锁成功,返回0
解锁失败,设置errno,并将errno返回

好的,我们再次将代码更改如下:
【Linux】线程安全篇Ⅰ_第13张图片
运行结果:
【Linux】线程安全篇Ⅰ_第14张图片
为什么程序不会退出呢?
【Linux】线程安全篇Ⅰ_第15张图片
通过pstack + 进程号 查看堆栈调用情况。
【Linux】线程安全篇Ⅰ_第16张图片

如何解决上面地问题呢?只需要在break之前释放掉互斥锁就可以了
【Linux】线程安全篇Ⅰ_第17张图片
再次运行,查看结果,发现一切正常
【Linux】线程安全篇Ⅰ_第18张图片
综上,我们在解锁接口中得到一个重要的结论:
在线程所有有可能退出的地方都进行解锁,否则就有可能导致死锁。

2.4.4 销毁接口

pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:

mutex:想要销毁的互斥锁

注意:

如果是动态初始化互斥锁,需要调用销毁接口。
如果是静态初始化互斥锁,不需要调用销毁接口。

到这里,互斥锁相关的接口就介绍完了。在最后附上一份完整的测试代码。

#include
#include
#include

int g_ticket = 10;
pthread_mutex_t g_lock;//互斥锁

void* thread_start(void* arg)
{
    //修改全局变量
    while(1)
    {
        sleep(1);
        //加锁
        pthread_mutex_lock(&g_lock);
        if(g_ticket <= 0)
        {
            pthread_mutex_unlock(&g_lock);
            break;
        }
        printf("I am %p,I got value is %d\n",pthread_self(),g_ticket);
        g_ticket--;
        //解锁
       pthread_mutex_unlock(&g_lock);
    }
}


int main()
{
    //初始化互斥锁
    pthread_mutex_init(&g_lock,NULL);
    pthread_t tid[2];
    //创建两个线程A和B
    for(int i= 0;i<2;++i)
    {
        int ret = pthread_create(&tid[i],NULL,thread_start,NULL);
        if(ret < 0)
        {
            perror("pthread_create");
            return 0;
        }
    }

    //主线程等待接收
    for(int i=0;i<2;i++)
    {
        pthread_join(tid[i],NULL);
    }

    //销毁互斥锁
    pthread_mutex_destroy(&g_lock);

    return 0;
}

感谢你看到文末,感觉有所收获的话,还请多多支持~
下篇谈一谈同步存在的意义~我们下篇见!
【Linux】线程安全篇Ⅰ_第19张图片

你可能感兴趣的:(Linux,服务器,后端,运维,linux,多线程)