自学内容网 自学内容网

linux线程 | 全面理解同步与互斥 | 同步

        前言:本节内容主要讲解linux下的同步问题。 同步问题是保证数据安全的情况下,让我们的线程访问具有一定的顺序性。 线程安全就规定了它必须是在加锁的场景下的!!那么, 具体什么是同步问题, 我们加下来看看吧!

        ps:建议学习了线程的互斥再来观看哦。

目录

同步与条件变量

条件变量的接口

生产消费问题

结藕 

快速实现生产消费者模型

创建文件

配置makefile        

阻塞队列的实现

主函数的实现

运行结果

生产消费者模型为什么是高效的​编辑

伪唤醒


同步与条件变量

       我们先来理解一下这句话——同步问题是保证数据安全的情况下, 让我们的线程访问资源具有一定的顺序性。

        这句话我们知道保证线程安全其实就是加锁,然后线程访问资源具有顺序性说让线程排队访问资源。 问题是, 排队访问资源就已经保证了线程安全了。 为什么还要加锁这个前提呢?这是因为, 我们这里所说的线程排队访问资源, 不是说的线程一来申请访问资源就能够自觉排队的!而这个线程先去申请访问资源访问失败, 碰壁了, 然后才过来排队的!!!——就比如一个VIP自习室, 只允许一个人进入。 现在里面已经有一个人了。 现在有一个人想进去,这个人看到旁边有一群人都在排队等着开门。 但是他不会去排队,因为他想验证一下里面有没有人, 所以他先去开一下这个门, 打不开,然后他才知道里面有人, 才来排队!!!

        然后这样做有什么用呢? 还是上面开门的例子。 如果人们不排队,那么当里面的人出来后。因为他刚出来, 他现在离着门的钥匙最近, 所以他想要再进去就非常的容易, 他这样反反复复进进出出, 那么别人是不是就会抗议, 是不是就有问题。再或者如果一个人早就来了, 它是第一个来的。 但是他腿脚不好, 那么当一个人出来后,其他后来的人抢先一步进去了, 是不是也不太公平。 但是如果排队呢? 我们规定里面的人出来后必须从队伍末尾开始排队!规定先来的就在队伍的前边!那么是不是就能服众, 就能让每一个人都能很好的进入这个自习室! ——而这,其实就是饥饿问题以及其解决方法!

        不要认为互斥是问题, 互斥是一种解决方案。 只是互斥在某些场景下有一些问题。 

        那么, 我们如何快速实现同步呢?——使用条件变量。 这是我们第一次提出这个概念, 所以我们要来认识一下linux中的条件变量。 看下面这张图:

        上图是一个vip自习室。 每个人进入这个vip自习室都需要拿到门口的钥匙。 但是这个钥匙只有一把, 也就是说一次只能有一个人能够进入这个自习室, 即vip自习室只允许一个人进入!!!

        此时, 我们提供了一个铃铛, 和一个等待队列。 当我们的新来的人去进入自习室, 找不到钥匙了, 他就要来到等待队列这里等待。 然后等到自习室的人出来, 铃铛就会“唤醒”这些等待队列的人, 让他们进入吧, 然后队列的第一个人就会去拿到钥匙进入vip自习室。 ——这里面的等待队列和这个铃铛, 就是我们口中的条件变量!条件变量至少为我们提供两件东西:第一个东西就是我们的简单的通知机制用来唤醒线程。 第二个就是等待队列, 为了线程去排队。 

        另外一个点就是问题不是我们去条件变量下排队了, 问题是为什么会去条件变量下排队。 不就是因为线程申请锁失败了,然后来到条件变量下排队吗? 所以, 条件变量的存在必须配合互斥锁!必须依赖于锁的使用!!因为我们的条件变量只是让我们的线程去等待队列, 而条件必须就绪才能唤醒我们的等待队列!

条件变量的接口

        下面我们看一下同步的接口:

        首先看一下初始化和销毁。 

        初始化的第一个参数是条件变量的类型。 这个第一个参数就是条件变量的对象。 整个函数就是要给这个对象进行初始化。 这个初始化属性由第二个参数控制, 可以不管传送nullptr。 

        然后销毁的参数就是条件变量的地址。 

然后再来看一下我们的等待和唤醒

        等待函数就是当线程申请锁失败的时候, 将线程放到等待队列里面。

        然后唤醒函数有两个, broadcast是唤醒整个条件变量下的线程, signal是唤醒一个条件变量下的线程。      

        ps:这里我们先提一下这几个接口, 先不用。 后面我们会讲生产消费模型, 然后就会带友友们实现以下生产消费模型。到时就会用到这些函数了!

生产消费问题

        生产消费问题,也叫做生产消费者模型。

        什么是生产消费者模型呢, 我们看下面这个图片:

        上面的图中有生产者,有消费者, 还有一个超市——这个就可以看成一个生产消费者模型。  其中, 我们的生产者是来生产数据的, 然后数据放到了超市里面消费者就到超市里面去拿数据。 虽然听起来很简单, 但是我们要考虑的是:超市, 是不是生产者? 

        其实, 超市并不是生产者, 但是为什么要存在超市呢?这是因为对于生产者来说, 它生产物品不需要再听用户的, 不需要听到用户要一包,他就生产一包这样了。 而是我们的超市有多大内存, 生产者就一次性生产很多包。 所以, 这个超市可以让我们的整个的商品的流动效率变高。可以节省生产者以及消费者的时间。

        因为超市的存在, 他无论对供货商来讲, 还是对于消费者来讲, 他都是可以提高效率的。超市在这里起的作用就是一个临时的缓存, 因为数据被缓存起来了。 所以我们对应的消费者拿数据的时候就不需要再从生产者那里拿, 而是直接从缓存里面直接拿我们的商品。 所以, 为什么效率高? 其实就是因为超市是一个大号的缓存!!!

        这个缓存能够支持忙闲不均, 让生产和消费的行为, 进行一定程度的结藕!什么意思? 意思就是说我们的生产者不需要在考虑消费者干什么 他自己做自己的, 消费者也一样。 

        在我们的实际的系统当中, 我们的生产者一定是我们的线程, 我们的消费者也一定是线程。 超市就是特定结构的内存空间!而中间的商品就是数据。 

        所以, 这个生产消费模型其实本质就是执行流在做通信——》 所以, 我们今天讨论的就是如何安全高效的通信!!!而这个超市的资源本质是什么资源?是不是就是一种共享资源——》所以, 他就一定有并发问题!!!而要谈并发问题的本质, 其实就是在谈生产者与生产者, 消费者与消费者,生产者与消费者。 三者之间的关系。那么三者的关系是什么?

  •         生产者VS生产者:互斥关系, 就比如生产糖果的和生产糖果的, 它们肯定只想有它们一家卖糖果的, 这不就是互斥关系? 当然只有一家卖糖果的是不可能的。
  •         消费者VS消费者:本质上其实也是互斥关系, 就比如今天这家只有一个橡皮了, 你也想买我也想买。那我俩肯定要商量一下看看这块橡皮到底是谁的了。
  •         生产者VS消费者: 本质是互斥加同步, 因为生产和消费之间应该有一定的顺序性的, 就比如应该先生产, 再消费!!!

         所以, 综上, 我们就可以总结到: 生产消费模型里面会有三种关系 两种角色一个交易场所(特定数据结构的内存空间)

        所以, 生产消费者模型就是多线程下互斥的一种场景。 它有两种角色, 三个关系, 一个特定的数据结构的交易场所。 优点是:支持忙闲不均, 生产和消费进行结藕。 

结藕 

        如果不结藕是什么情况呢? 加入今天有一个main函数, 里面有一个ADD函数, 我们一般调用函数, 都是进行传参进行实例化。 在mian函数传参add函数的时候, 这个过程其实就是生产数据交给add函数了。 而add函数拿到这个数据把这个数据做加法, 做各种运算, 这不就是叫做消费这个数据了吗? 那么我们消费完这个数据后, 结果给main函数返回, 但是当我们调用add函数时,main函数在干什么? 按照我们之前的代码, 我们调用add函数时, main函数是在那里等的。 他并没有向后执行, 他在等add函数执行完再往后运行。 因为为我们平时写的main函数和add函数都是单执行流, 是串行的。 这其实就是耦合度比较高的一种表现。 

        要解决的话该怎么办呢? 我们把main函数拆成主函数, 我们的add函数拆成工作线程, 我们的参数不通过传参的方式, 而是通过交易场所来进行。 意思就是说我们可以创建一个数据结构, 然后在这个数据结构里面缓存上一大批参数, 然后add线程定期从这里面拿数据。 这样就能让它变成两个线程, 一个生产一个消费了。 这种方式, 就是完成了一次结藕的操作!!!
 

快速实现生产消费者模型

        我们首先要写一个生产者, 一个消费者。 然后它们采用一个Blockqueue作为交易场所进行资源传送。 然后这个Blockqueue(阻塞队列)需要我们自己实现。 那么什么叫做阻塞队列?——就是一定要有自己的上限, 一旦队列放满, 生产者就不能再放了, 必须去休眠。 一旦队列空了, 消费者就不能消费了, 也必须去休眠。 

创建文件

        首先创建三个文件:

配置makefile        

        然后配置我们的makefile:

main.exe:main.cpp
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -rf main.exe

阻塞队列的实现

        我们实现阻塞队列, 首先要想一下阻塞队列里面要有什么? 

        我们的阻塞队列, 首先一定要有一个队列, 而且为了这个队列能够有最大容量, 我们还要定义一个变量来作为最大的容量; 其次还要有条件变量, 我们这里就是使用的条件变量:pthread_cond_t对象。 这里我们要定义两个pthread_cond_t对象, 一个用来表示生产者的等待队列, 一个用来表示消费者的等待队列。(注意, 这里说的是等待队列, 也就是条件变量里面的等待队列。不是阻塞队列, 阻塞队列是我们用来充当交易场所的数据结构)然后为了能够让阻塞队列安全, 还要定义一把锁。 最后还要有两个水位线, 当我们高出上边的水位线, 就唤醒我们的消费者, 让消费者赶紧消费。 当低出下方的水位线, 就让生产者赶紧生产。

所以, 我们的简单的初步定义如下:

#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<queue>




template<class T>
class BlockQueue
{
    static const int defaultnum = 20; //用来初始化_maxcap

public:

private:
    queue<T> _q;     //访问多线程的,共享资源
    int _maxcap;     //队列的极致, 最大能够到多少。
    
    pthread_mutex_t _mutex;  //共享资源, 那么就要保护, 所以定义一把锁来保护我们的共享资源。
    pthread_cond_t c_cond;  //为了让资源有序, 防止饥饿问题, 我们再定义两个_cond,用来同步
    pthread_cond_t p_cond;

    int low_water_;      //定义两个水位线    
    int high_water_;

}; 

        然后我们内部的方法可以有四个: 其中用来初始化和析构的构造析构函数。 另外两个是用来生产者生产数据push函数消费者用来消费数据pop函数。 

        _q成员不需要管, 会调用stl的默认构造。 然后就是_maxcap,_mutex, c_cond, p_cond, 这几个我们需要初始化。 其中锁和条件变量专门用来初始化的函数和专门用来销毁的函数。 所以我们就可以直接调用这些函数进行初始化和销毁。所以下面是构造和析构:

    BlockQueue(int maxcap = defaultnum)
        :_maxcap(maxcap)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&c_cond, nullptr);
        pthread_cond_init(&p_cond, nullptr);
        low_water_ = _maxcap / 3;
        high_water_ = _maxcap * 2 / 3;    

    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex); 
        pthread_cond_destroy(&c_cond);
        pthread_cond_destroy(&p_cond);
    }

         然后就是生产函数和消费函数。 对于两个函数来说,我们的线程进入的第一件事都是先拿到锁。 然后就开始判断, 当到达最大容量不能生产, 当容量为零不能消费。同时判断成功之后才能进行_q变量的push和pop:

    //谁来唤醒呢? 
    T pop()
    {
        pthread_mutex_lock(&_mutex);
        if (_q.size() == 0)
        {
            //条件不满足, 去等待
            pthread_cond_wait(&p_cond, &_mutex); 
        }
        //

        T out = _q.front();   //你想要消费就能直接进行消费吗? 肯定不可能, 
        _q.pop();
        if (_q.size() < low_water_) pthread_cond_signal(&c_cond);

        pthread_mutex_unlock(&_mutex);   
        return out;
    }

    void push(const T& in)
    {
        pthread_mutex_lock(&_mutex);
        //先确保生产条件是否满足
        if (_q.size() == _maxcap)  //为什么这个判断不放在外边? 因为我们的判断本身就是临界资源, 所以必须放到加锁之后!!!
        {
            pthread_cond_wait(&c_cond, &_mutex); //持有锁期间让我们去等待, 我一旦被挂起就没有人能够消费了。 所以, pthread_cond_wait需要传送一个锁的参数。 这是因为他会自动给我们释放锁。
        }
        //1、队列没有满。2、被唤醒(这个的本质还是队列没有满了)
        _q.push(in);
        if (_q.size() > high_water_) pthread_cond_signal(&p_cond);

        pthread_mutex_unlock(&_mutex);
    }

        当我们判断失败时候就让线程去等待队列, 这个等待队列有两个参数, 第一个参数是等待队列的地址, 第二个参数是锁的地址。 为什么要有锁的地址? 因为我们想一下, 因为我们的线程进入等待队列的时候是拿着锁的, 如果不释放这个锁, 是不是就没人能够进入临界区了? 所以我们的等待队列拿到锁的地址就是为了能够自动释放锁!!!

主函数的实现

        首先是主框架, 就是创建生产者, 消费者, 交易场所。 然后生产者去生产, 消费者去消费:

int main()
{
    //创建等待队列——交易场所
    BlockQueue<int>* bq = new BlockQueue<int>();

    //创建生产者消费者
    pthread_t c, p;

    //生产者生产, 消费者消费
    pthread_create(&c, nullptr, Comsumer, bq);
    pthread_create(&p, nullptr, Productor, bq);


    //等待释放两个线程
    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    //释放两个队列
    delete bq;
    return 0;
}

        重要的是生产者如何生产, 消费者如何消费。 这里就要定义两个线程要去执行的函数了。 

如下:


void* Comsumer(void* args)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    while (true)
    {
        //消费
        // sleep(2);
        auto data = bq->pop(); 
        cout << "消费了一个数据: " << data << endl;
  
    
    }
}

void* Productor(void* args)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    int data = 0;
    while (true)
    {
        //生产
        data++;
        bq->push(data);

        cout << "生产了一个数据: " << data << endl;
        sleep(1);
    } 
}

运行结果

        可以看到, 当我们的生产到了2/3的时候, 就消费了。(其实这里因为消费太快了,所以直接全部消费没了。本质其实是消费到了1/3的时候就去唤醒生产者了)

生产消费者模型为什么是高效的

        生产者对应的数据从哪来呢? 我们的生产者, 生产了产品然后放到仓库中, 这没有问题。 问题是, 我们的生产者对应需要的数据从哪来呢?要知道, 用户的数据, 网络等数据等等都是给我们的数据, 而生产者生产数据就是要获取这些数据的, 而获取这些数据也是要花时间的。 所以, 生产者在生产之前还有一个更加前置的条件, 就是获取数据!!!

        同样的, 消费者拿到数据后并不算消费了!后面拿到数据后去干了什么, 也是容易被人忽略的, 那么消费者拿到了数据要不要做一下加工? 也是要的, 所以, 生产者拿到数据也是要花时间的。 

  • 生产者要做的: 获取数据、生产数据。
  • 消费者要做的: 消费数据、加工处理数据。

所以, 为什么生产消费模型是高效的?

        虽然我们的生产者生产的时候消费者不能消费。 消费者消费的时候生产者不能生产。 但是我们的生产者拿到锁的时候去生产, 消费者有没有可能正在处理数据? 我们消费者拿到锁去消费的时候,生产者有没有可能正在接收数据? 所以, 如果我们生产消费模型当中接收数据和消费数据都要花费时间, 那么我们就有可能产生类似我们生产者正在生产, 但是我们的消费者正在处理数据这,就是并发访问吗? 说白了就是一个正在访问临界区的代码, 一个正在访问非临界区的代码。 这样的话, 两个线程就高效并发的运行起来了吗? 所以, 生产和消费模型所谓的高效性, 体现的不是生产者和生产者互磕访问临界区, 而是有概率其中一个访问临界区, 另一个访问非临界区的时候是并发的!!在这种并发条件下, 我们的生产者和消费的动作就是同时跑的。 所以我们的生产和消费才是高效的。 

伪唤醒

        这里我们要讲解一下什么是伪唤醒, 这个是为了后面讲解信号量做准备。 首先我们要知道伪唤醒其实是对应的多个生产者和多个消费者的情况。 

        如果只有一个生产者, 有三个消费者。 假设此时生产者只生产了一条数据, 也就是说现在数据结构里面只有一条数据。 但是我们唤醒机制使用的是broadcast, 所以三个线程就全部被唤醒了。全部被唤醒之后, 三个线程就对这个锁展开了竞争假设第一个线程把锁持有了, 然后他就消费, 然后他消费完了之后, 就解锁了。 可是解锁之后, 我们的生产者没有拿到这个锁, 而是另一个消费线程拿到了这个锁。 可是此时数据结构种已经空了,注意,此时消费者已经处于临界区里面了, 所以消费者拿到锁后继续向后执行pop就发生错误了所以就产生了一种伪唤醒或者叫做误唤醒的情况。 

——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!    


原文地址:https://blog.csdn.net/strive_mianyang/article/details/143081689

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!