互斥锁和同步变量实现线程安全模型

本文探讨线程安全和线程不安全的概念,详细讲解互斥锁及其特性,包括原子性和非繁忙等待。同时,分析了互斥锁可能导致的低效率和死锁问题,并介绍同步变量在解决这些问题上的应用,如条件变量在生产者-消费者模型中的作用。此外,文章还讨论了可重入函数和线程安全函数的关系。
摘要由CSDN通过智能技术生成

在了解同步和互斥之前,先总结一下线程安全和线程不安全的概念和场景

线程不安全

操作一段代码,比如修改全局变量或者静态变量,在多线程模式下和单线程模式下运行的结果不一样,那么就是线程不安全.

比如:两个线程对一个全局变量进行循环 10 次的操作,全局变量初始化为 0,每个线程访问的时候,对全局变量加 1,循环结束后,我们希望得到的是值 20,但是却可能小于 20 ,那是因为我们在进行操作变量的时候,并不是一个原子操作,而且也没有加锁.

原子操作指的是:不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成.

临界资源指的是:多个线程执行流访问的公共资源,在上面的案例中,全局变量就是临界资源.

由于线程都是抢占式执行,所以在临界区中使用互斥可以解决对临界资源的不安全现象,使用同步保证线程在访问临界资源的合理性

互斥

通过互斥锁来实现互斥.保证临界资源的安全

步骤

  1. 先加锁 pthread_mutex_init(&mutex,MULL)

  2. 执行临界区代码

  3. 释放锁 pthread_mutex_destroy(&mutex)

原理:互斥锁的底层是一个互斥量,互斥量是一个计数器,该计数器只有 0 和 1 两个值,0 代表的是当前资源不可访问,1 代表当前资源可以被访问

例:互斥量的初始值为1,当一个线程抢到锁资源是会将互斥量设为1,也就意味着加锁,那么其他线程来进行加锁的时候就会进入等待状态,只有当该线程释放锁后(互斥量进行 +1 操作),其他的线程才可以争夺锁资源

互斥锁的特性

互斥锁相当于一个锁定命令,它可以用来锁定共享资源使得其他线程无法访问

特性1:原子性/唯一性,如果一个线程获取到互斥锁,则没有其他线程可以上锁和解锁

特性2:非繁忙等待,如果一个线程已经获取到互斥锁,第二个线程又试图去锁定这个互斥锁,则第二个线程将被挂起(不占用CPU资源),直到第一个线程解锁后,第二个线程才被唤醒

互斥量进行 0/1 的操作是原子操作吗?

是的,如果不是原子操作,那么也就无法实现锁的功能,互斥量是通过寄存器和内存的交换指令来实现 0,1 互换的,由于交换指令只有一条且不可被操作系统打断,所以它是原子操作.

例,寄存器有一个初值 0,内存有两个值 0 和 1,当抢到锁资源会进行交换,交换之后,如果寄存器的值为 1,也就意味着可以访问该资源,如果寄存器的值经过交换后还是 0,则意味着该资源不可被访问

互斥锁的缺陷

  • 低效率
  • 可能导致死锁

互斥锁虽然能保证线程的安全,但是会较低程序的效率,除此之外,还可能造成 死锁 的问题

死锁情况 1: 程序当中的一个执行流,没有释放锁资源,导致其他执行流无法获取锁资源的情况

死锁情况 2:程序当中各个执行流都占有一把锁,但是在执行流占有锁的情况下,还想申请对方的锁,也不释放自己的锁,这样僵持不下的情况也称为死锁.

上面只是简单介绍了死锁,具体可参见以下文章
https://blog.csdn.net/qq_43763344/article/details/90574833

同步

利用条件变量实现同步,保证了对临界资源的访问合理性,从而有效的避免饥饿

定义条件变量

pthread_cond_t cond;

初始化条件变量

pthread_cond_init(&cond,NULL)

等待接口 (重要)

pthread_cond_wait(&cond,&mutex)

当一个线程调用 pthread_cond_wait 函数后,该线程会陷入阻塞状态,在这个过程中,有三步

步骤一:将线程(执行流)的 PCB 放到等待队列中

步骤二:对互斥锁进行解锁

步骤三:当等待队列中的线程被唤醒后,先获取互斥锁,然后访问临界资源.

特别注意:步骤一和步骤二是不可交换的,如果先进行了解锁,而没有加载到等待队列中,那么线程可能会错过信号,因为解锁和等待并不是一个原子操作,所以为了保证原子性,需要在线程未被解锁前,就要等待在队列里面,这样就不会错过信号了

为什么要引入互斥锁?

由于线程间同步需要改变共享变量,一旦有多个线程,那么必然会导致线程不安全,拿生成者和消费者模型来说,引入互斥锁可以保证消费者和消费者之间的互斥,生产者和生成者之间的互斥,当一个线程生产好一个资源,会唤醒等待队列中的消费线程,消费线程需要先获取互斥锁,只有得到锁的线程才可以访问临界资源.

如下图所示:

在这里插入图片描述

第二步骤为什么需要解锁?

解锁是 pthread_cond_wait 函数内部做的事情,如果不解锁,则生成者线程无法获取到临界资源,并且无法唤醒等待队列中的线程,也就意味着,等待对列中的线程会一直等待.

唤醒等待,发送一个信号

// 至少唤醒一个等待变量,和操作系统调度有关
pthread_cond_signal(&cond)
// 唤醒所有等待变量
pthread_cond_broadcast(pthread_cond_t *cond);

销毁条件变量

pthread_cond_destroy(&cond)

吃面的案列

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

#define PTHREADCOUNT 4
pthread_mutex_t g_mutex;
pthread_cond_t g_eatcond;
pthread_cond_t g_makecond;

int g_middle = 0;

void* EatStart(void* arg){
    while(1){
        pthread_mutex_lock(&g_mutex);
        while(g_middle == 0){
            pthread_cond_wait(&g_eatcond,&g_mutex);
        }
        g_middle--;
        printf("i am a pthrad:[%p], eat a [%d] middle\n",
                pthread_self(),g_middle);
        pthread_mutex_unlock(&g_mutex);
        pthread_cond_signal(&g_makecond);
    }
}
void* MakeStart(void* arg){
    while(1){
        pthread_mutex_lock(&g_mutex);
        while(g_middle == 1){
            pthread_cond_wait(&g_makecond,&g_mutex);
        }
        g_middle++;
        printf("i am a pthrad:[%p],make a [%d] middle\n",
                pthread_self(),g_middle);
        pthread_mutex_unlock(&g_mutex);
        pthread_cond_signal(&g_eatcond);
    }
}

int main(){
    pthread_t eatpid[PTHREADCOUNT],makepid[PTHREADCOUNT];
    pthread_mutex_init(&g_mutex,NULL);
    pthread_cond_init(&g_makecond,NULL);
    pthread_cond_init(&g_eatcond,NULL);

    for(int i = 0;i < PTHREADCOUNT; ++i){
        pthread_create(&eatpid[i],NULL,EatStart,NULL);
        pthread_create(&makepid[i],NULL,MakeStart,NULL);
    }
    for(int i = 0;i < PTHREADCOUNT; ++i){
        pthread_join(eatpid[i],NULL);
        pthread_join(makepid[i],NULL);
    }
    pthread_mutex_destroy(&g_mutex);
    pthread_cond_destroy(&g_eatcond);
    pthread_cond_destroy(&g_makecond);
    return 0;
}
利用互斥锁和条件变量模拟生产者和消费者模型

借助队列来减弱消费者和生产者的一个耦合性,生产者往队列里面放元素,消费者从队列里面取元素,但是必须要有一个制约关系

队列是一个临界资源,同一时刻只能有一个执行流去操作

参考代码

#include <stdio.h>
#include <iostream>
#include <pthread.h>
#include <queue>

#define PTHREADCOUNT 4
pthread_mutex_t mutex_;
pthread_cond_t ProCond_;
pthread_cond_t ConCond_;

class RingQueue{
public:
    RingQueue(int Capacity){
        Capacity_ = Capacity;
        pthread_mutex_init(&mutex_,NULL);
        pthread_cond_init(&ConCond_,NULL);
        pthread_cond_init(&ProCond_,NULL);
    }
    ~RingQueue(){
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&ConCond_);
        pthread_cond_destroy(&ProCond_);
    }
    void Pop(int* data){
        pthread_mutex_lock(&mutex_);
        while(Que_.empty()){
            pthread_cond_wait(&ConCond_,&mutex_);
        }
        *data = Que_.front();
        Que_.pop();
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&ProCond_);
    }
    void Push(int &data){
        pthread_mutex_lock(&mutex_);
        while(Que_.size() == Capacity_){
            pthread_cond_wait(&ProCond_,&mutex_);
        }
        Que_.push(data);
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&ConCond_);
    }
private:
    size_t Capacity_;
    std::queue<int>Que_;
};

void* ProStart(void* arg){
    RingQueue* rq = (RingQueue*)arg;
    int i = 0;
    while(1){
        rq->Push(i);
        printf("i am [%p] thread,i Porspect a [%d]\n",
                pthread_self(),i);
        i++;
    }
    return NULL;
}
void* ConStart(void* arg){
    RingQueue* rq = (RingQueue*)arg;
    int data;
    while(1){
        rq->Pop(&data);
        printf("i am [%p] thread,i Con a [%d]\n",
                pthread_self(),data);
    }
    return NULL;
}

int main(){
    pthread_t ProPid[PTHREADCOUNT],ConPid[PTHREADCOUNT];
    RingQueue* rq = new RingQueue(5);
    for(int i = 0; i < PTHREADCOUNT;++i){
        pthread_create(&ProPid[i],NULL,ProStart,(void*)rq);
        pthread_create(&ConPid[i],NULL,ConStart,(void*)rq);
    }
    for(int i = 0; i < PTHREADCOUNT;++i){
        pthread_join(ProPid[i],NULL);
        pthread_join(ConPid[i],NULL);
    }
    delete rq;
    return 0;
}

线程安全的函数

可重入函数

同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入

一个函数在重入的情况下,运行结果没有任何问题,则该函数被称为可重入函数,否则是不可重入函数

常见的不可重入函数

  • 调用了malloc/free 的函数,因为 malloc 函数是用全局链表来管理堆内存的

  • 调用了标准 I/O 的库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构

  • 可重入函数体内使用了静态的数据结构

常见的可重入函数

  • 不使用全局变量或静态变量

  • 不使用 malloc 或者 new 开辟出的空间

  • 不调用不可重入函数

  • 不返回静态或全局数据,所有数据都有函数的调用者提供

  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

线程安全函数和可重入函数的关系

如果一个函数是可重入的则线程一定安全,但是如果一个函数是线程安全的不一定是可重入函数**

常见的线程不安全情况

  • 不保护共享变量的函数

  • 函数状态随着被调用,状态发生变化的函数

  • 返回指向静态变量指针的函数

  • 调用线程不安全函数的函数

常见的线程安全情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的

  • 类或者接口对于线程来说都是原子操作

  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序猿的温柔香

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值