【Linux】线程安全:互斥 & 同步

线程知识合集:
【Linux】线程概念 && 线程控制(接口详谈)&& 僵尸线程的产生和解决



线程安全

【问】什么是线程不安全?

  • 线程之间抢占式执行;

  • 单核CPU,线程只能并发执行;多核CPU,线程可并行执行;

  • 由于线程操作是一个非原子操作(线程操作可以被打断),因此多线程并发或并行运行时,有可能会导致程序结果二义性

    原子性:某一操作只会有两种状态:要么没有执行,要么执行完毕。

下面是一个线程不安全的例子:两线程,单核系统,操作同一个全局变量

假设有一个单核CPU机器中运行中两个线程A、B,两线程想同时对全局变量g_i = 10进行+1操作;


  • 由于是单核系统,线程A、B独立且抢占执行;
  • 每个线程对变量g_i = 10进行操作并不是一个原子操作:内存的g_i —》线程寄存器 —》CPU处理;
  • 完全有可能当线程A先获取到g_i = 10的值至寄存器还未进行++操作,系统就线程切换至B,这时B取到的仍为g_i = 10,进行CPU++操作后,g_i = 11
  • 但我们会以为B是在A对g_i = 10先++的基础上再++,应该是g_i = 12,这就是结果二义性

代码模拟:
线程执行很快,为了观察现象,我们将数值设定很大;
在这里插入图片描述

为了方便观察,我们将输出结果重定向至一个文件,而不是打印至屏幕:
在这里插入图片描述

结果:产生二义性(单核CPU这种情况很难观察,现象并不是每次都会出现)
在这里插入图片描述


【问】什么是线程安全?

一个安全的多线程进程一般有如下特征:

  • 每次运行结果和单线程运行的结果无二义性,且其他的变量的值也和预期的是一样的;
  • 每个线程中对全局变量、静态变量只有读操作,而无写操作(若多线程需写操作,一般都需要考虑线程同步);
  • 一个类或者程序所提供的接口对于线程来说是原子操作;
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性,

【问】如何解决线程不安全?

  • 线程安全问题都是因为访问临界资源(全局变量及静态变量)引起的;
  • 多线程互斥访问临界资源:对于临界资源进行加锁处理,确保同一时间只由一个线程占用;
  • 多线程同步使用临界资源:为每个线程添加同步变量,保证线程对临界资源的访问时机都是合理的;

互斥机制——互斥锁

互斥是什么

在这里插入图片描述


互斥锁原理

  • 互斥锁的本质就是0/1计数器(取值只能是 1 / 0);
    在这里插入图片描述
  • 加锁是一个主动行为,加锁、解锁的本质上是操作计数器,且操作必须是一个原子操作;
  • 注意:不是说一个线程不获取互斥锁就不能访问临界资源,而是程序员需要在多个线程代码中添加同一个互斥锁,以此约束多个线程对这个临界资源的访问;

互斥锁的计数器为何能够保证原子性?

  • 加锁、解锁 直接使用寄存器中的值和计数器内存的值交换(交换是一条汇编指令即可完成);

  • 加锁:不管锁有无被占用,先初始化一个值为0的寄存器;

    锁空闲:
    在这里插入图片描述

    锁忙碌:
    在这里插入图片描述

  • 解锁:解锁时肯定只有一个线程,不存在像加锁一样的抢占;

    直接初始化一个值为 1 的寄存器:
    在这里插入图片描述

互斥锁计数器原子性的汇编伪码:

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

互斥锁的接口

  • 初始化锁:一般采用动态初始化锁(静态锁就是一个宏,而不是函数);
    在这里插入图片描述
    -

  • 加锁:常用接口1;
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

  • 解锁
    在这里插入图片描述

  • 销毁锁
    在这里插入图片描述


互斥锁的应用

若为上述线程不安全的例子增加互斥锁机制,会是什么结果呢?

【错误示范】
在这里插入图片描述
在这里插入图片描述

【正确加锁】

// 两线程,单核系统,操作同一个全局变量
// 加入互斥锁
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>

int g_i = 10;  //临界资源

// 互斥锁
// 一般一个临界资源一个互斥锁
// 互斥锁一般都定义为全局变量
pthread_mutex_t g_lock;

// 工作线程入口函数
void* thread_start(void* arg)
{
    (void)arg;
    // 修改全局变量
    while(1)
    {
        // 加锁(进入临界区前)
        pthread_mutex_lock(&g_lock);
        if(g_i <= 0)  //非法访问
        {
            // 1.先解锁
            // 在任何有可能退出临界区的情况前一定要解锁!!!
            pthread_mutex_unlock(&g_lock);

            // 2.再退出
            break;
        }
        printf("this is work:%p, g_i is %d\n",pthread_self(), g_i);
        g_i--;
        // 解锁(退出临界区前)
        pthread_mutex_unlock(&g_lock);

        sleep(1);  //让程序结果更明显
    }
    return NULL;
}
int main()
{
    // 初始化锁
    pthread_mutex_init(&g_lock, NULL);
    // 创建两个线程
    pthread_t tid[2];  //线程标识符数组
    int i = 0;
    for(; i < 2; ++i)
    {
        int ret = pthread_create(&tid[i], NULL, thread_start, NULL);
        if(ret < 0)
        {
            perror("pthread_create ERROE");
            return 0;
        }
    }
    // 主线程进行线程等待
    for(i = 0; i < 2; ++i)
    {
        pthread_join(tid[i], NULL);
    }
    // 销毁互斥锁
    pthread_mutex_destroy(&g_lock);
    return 0;
}

在这里插入图片描述

【总结】:

  • 一定要找访问互斥资源前加锁;
  • 一定要在任何可能退出临界区的地方解锁;

同步机制——条件变量

【问】有了互斥,为什么还要有同步?

某些情况,不同线程对于临界资源的访问是收到其他线程影响,比如吃面-做面模型

吃面-做面模型中,临界资源是g_bowl碗,有且仅有一个;
对于吃面人来说,只有当g_bowl = 1碗里有面时,才能执行(吃面);
对于做面人来说,只有当g_bowl = 0碗里没面时,才能执行(做面);


对于这种情况,如果仅对临界资源g_bowl增加一个互斥锁,肯定达不到要求,因为做面线程有可能一次做好多碗面,超出g_bowl;吃面线程有可能一次吃好多碗面,甚至吃到g_bowl为负;

基于这种情况,如果只是简单的为每个工作线程增加一个条件判断,当线程A条件判断后,如果条件不满足,完全有可能出现线程A一直在抢到锁 -》条件不满足 -》继续强锁。。。的死循环中,直至时间片结束,让线程B执行,但线程B也有可能陷入这种循环;非常消耗CPU资源


我们需要对多线程进程增加同步机制,让每个线程对临界资源的访问时机合理;

因此,同步机制的存在的意义是:

  • 多个线程有了互斥机制,保证了各线程能够独占访问临界资源;但并不是说,各线程访问临界资源是时机都是合理的;
  • 同步机制保证了每个线程对临界资源的访问都是合理的;

条件变量

  • 原理:本质是一个PCB等待队列,存放着等待的线程的PCB;

  • 同步机制:条件变量,使用在加锁之后,用于判断临界资源是否满足使用条件;

    当某一线程获得锁后,先判断条件变量是否满足,若不满足,则将自己添加至这种条件的条件变量队列中,并通知另一个线程的条件变量队列;


条件变量接口

  • 初始化接口
    在这里插入图片描述

  • 等待接口
    在这里插入图片描述

  • 唤醒接口
    在这里插入图片描述

  • 销毁接口
    在这里插入图片描述


条件变量的原理

  • 条件变量的本质就是一个线程PCB等待队列;
  • 条件变量的等待接口就是将当前线程添加至PCB等待队列,等待条件满足时被唤醒;
  • 条件变量的唤醒接口就是当条件满足时,唤醒对应条件下的PCB等待队列中正在等待的线程;

“吃面-做面模型”的初步代码:
在这里插入图片描述
根据条件变量原理分析结果:
在这里插入图片描述
在这里插入图片描述


【问】条件变量关于pthread_cond_wait的夺命追问:


1、条件变量等待接口的第二个参数为什么有互斥锁?

  • 在线程访问临界资源前,一定是加锁访问的,来保证互斥性;
  • 为等待接口传递互斥锁参数,就是要让进程进入PCB等待队列时也要解锁;
  • 如果不解锁,该条件变量无意义,且会发生死锁;

2、pthread_cond_wait 内部针对互斥锁做了什么操作?先释放互斥锁还是先将线程放入PCB等待队列?

  • 第一步:将线程放入PCB等待队列;
  • 第二步:释放互斥锁;
  • 顺序反了可能出现死锁;

3、线程被唤醒后会执行什么代码?需要再次获取互斥锁吗?

  • pthread_cond_wait函数在阻塞结束后,让PCB等待队列里的线程出队前,一定会先进行加锁操作;
  • 且加锁的权限和其他不在PCB等待队列中的线程是一样;
  • 若抢锁成功:pthread_cond_wait执行完毕,函数返回;
  • 强锁失败:继续抢锁,直到成功后返回;

条件变量的应用

对于上面的“吃面-做面模型”,我们需注意:

1、使用while循环判断条件:

  • pthread_cond_single()接口有可能将多个PCB等待队列的线程均唤醒;
  • 而从PCB等待队列唤醒的线程,相当于pthread_cond_wait函数阻塞完毕,从该函数退出,会继续执行下面的代码;
  • 因此从pthread_cond_wait函数阻塞退出的线程,必须重新判断条件是否满足,因此我们需将进入该接口的条件判断改为while循环判断;

2、模型有几个条件关系,就创建多少个条件变量;

  • 若只有一个条件变量,在线程通知PCB等待队列中的线程的时候,可能会将同种类的线程通知出来;
  • 然后刚出来的线程,由于条件不满足,会继续进入PCB等待队列,最终有可能引发死锁;
  • 因此需要将吃面线程和做面线程的条件变量分开;

吃面-做面模型

// [吃面-做面模型]
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>

#define THREAD_COUNT 2

int g_bowl = 0;  //临界资源,0:空 1:有面
pthread_mutex_t g_lock;  //临界资源互斥锁
pthread_cond_t g_cond_eat;  //吃面线程条件变量
pthread_cond_t g_cond_make;  //做面线程条件变量

// 吃面线程入口函数
void* eat_thread_start(void* arg)
{
    while(1)
    {
        // 加锁
        pthread_mutex_lock(&g_lock);
        while(g_bowl == 0)
        {
            printf("吃面人:没面,等待~\n");
            // 吃面线程进入等待队列
            pthread_cond_wait(&g_cond_eat, &g_lock);
        }
        printf("EAT: 有g_bowl:%d,吃一碗\n", g_bowl--);
        // 解锁
        pthread_mutex_unlock(&g_lock);
        // 通知做面等待队列中的做面线程做面
        pthread_cond_signal(&g_cond_make);
    }
}

// 做面线程入口函数
void* make_thread_start(void* arg)
{
    while(1)
    {
        // 加锁
        pthread_mutex_lock(&g_lock);
        while(g_bowl == 1)
        {
            printf("做面人:仍有面,等待~\n");
            // 做面线程进入等待队列
            pthread_cond_wait(&g_cond_make, &g_lock);
        }
        printf("MAKE: g_bowl:%d碗面\n", ++g_bowl);
        // 解锁
        pthread_mutex_unlock(&g_lock);
        // 通知吃面等待队列中的做面线程做面
        pthread_cond_signal(&g_cond_eat);
        
    }
}

int main()
{
    // 初始化互斥锁
    pthread_mutex_init(&g_lock, NULL);
    // 初始化条件变量
    pthread_cond_init(&g_cond_eat, NULL);
    pthread_cond_init(&g_cond_make, NULL);

    pthread_t eat_thread[THREAD_COUNT];
    pthread_t make_thread[THREAD_COUNT];

    // 创建线程
    int i = 0;
    for(; i < THREAD_COUNT; ++i)
    {
        int ret = pthread_create(&eat_thread[i], NULL, eat_thread_start, NULL);
        if(ret < 0)
        {
            perror("pthread_create_eat");
            return 0;
        }
        ret = pthread_create(&make_thread[i], NULL, make_thread_start, NULL);
        if(ret < 0)
        {
            perror("pthread_create_make");
            return 0;
        }
    }
    // 主线程等待所有工作线程
    for(i = 0; i < THREAD_COUNT; ++i)
    {
        pthread_join(eat_thread[i], NULL);
        pthread_join(make_thread[i], NULL);
    }
    // 销毁互斥锁、条件变量
    pthread_mutex_destroy(&g_lock);
    pthread_cond_destroy(&g_cond_eat);
    pthread_cond_destroy(&g_cond_make);

    return 0;
}

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值