Linux线程互斥与互斥量(锁)线程同步与条件变量

1、线程互斥的概念

        一个进程,在多个线程交叉执行的过程中,调度器会频繁地发生线程的调度和切换,可能会对全局变量的访问出现问题。线程一般在什么时候发生切换呢?有几种典型的情况,时间片到了来了优先级更高的线程线程等待的时候,在线程等待的时候,时间片没到,也没有优先级更高的线程,cpu也要进行线程的调度切换,因为当前进程在等待的时候不执行代码。从内核态返回用户态的时候,系统(内核态可以理解为操作系统的运行级别)会对线程的调度状态进行检测,如果可以,就直接发生线程切换。下面看一段代码感受一下会出现什么问题。我们在主线程中创建四个线程,并且让4个线程都跑下面这段代码,其中tickets是一个能被所有线程都访问到的全局变量。执行结果如下。

int tickets = 10000;//假设有10000张票
void* getTicket(void* args)
{
	std::string username = static_cast<const char*>(args);
	while (true)
	{
		
		
		if (tickets > 0)
		{
			usleep(1234);//模拟抢票的时间
			std::cout << username << "正在进行抢票:" << tickets-- << std::endl;	
		}
		else
		{
			break;
		}
		
	}
	return nullptr;
}

        明明在判断语句里,tickets不大于0就直接break,为什么会出现这种情况?下面做一下解释。先设想一种极端的情况,tickets此时等于1,这时多个线程都进入了while循环中(这是可以的),多个线程能同时进行if里的判断语句吗?如果只有一个cpu,那么任何时刻只允许一个线程运行,我们只考虑这种情况。假设线程A在执行if(tickets > 0),并进入了if内,然后在进行usleep()时,线程A被切走了,后续线程B和C都进行同样的操作,在usleep()时被切走,当线程A被切回来时,tickets还是1,然后进行tickets--,变成0,后续的B和C在if内也进行tickets--,就有了后面的-1和-2。在对tickets进行判断和更改之间,发生了大量的线程切换,就导致了很多线程都进入了if内部,发生了我们不想看到的现象。

        对上述现象解释之后,又有一个问题,如果我们不进行usleep()操作,这段代码是安全的吗,tickets会不会出现问题,换言之,对一个全局变量进行多线程更改时安全的吗?当我们对变量进行++或者--操作时,在c/c++上看起来只有一条语句,但是汇编之后至少是三条语句:(1)从内存中读取数据到CPU寄存器中,(2)在寄存器中让CPU进行对应的算数逻辑运算。(3)将新的结果写回到内存中变量的位置。设想当tickets等于1000,此时线程A要对tickets--,它先是将tickets的值读取到寄存器中,再在寄存器中将将1000修改为999,在执行下一条写回指令之前,线程A被切走了,线程B运行,此时tickets的值还是1000,假设线程B运气比较好,在线程内部执行了多次的tickets--,此时tickets的值已经被更改为200,然后线程B被切走,执行线程A,注意,CPU内的寄存器只有一份,但是寄存器内的数据属于当前进程的上下文,所以在之前A被切走时,在它的上下文里,它要执行的下一条语句是将999的值写回tickets,执行完之后tickets又回到了999。这就是多线程在运行时发生了干扰。

        所以说我们定义的全局变量,在没有保护的时候,往往是不安全的,像上述的多个线程在交替执行时的数据安全问题,称之为数据不一致问题。解决方案是什么?加锁!

        在讲锁之前,先来认识几个基本概念。(1)多个执行流进行安全访问的共享资源称为临界资源,如上述的tickets全局变量。(2)多个执行流中,访问临界资源的代码称为临界区,临界区往往是线程代码很小的一部分,如上述if中的判断语句tickets > 0,还有输出tickets和tickets--。(3)数据不一致问题一般是因为多个线程并行访问临界资源导致的,因此我们需要让多个线程串行访问共享资源,这种做法叫互斥。(4)对一个资源进行访问的时候,要么不做,要么彻底做完,这叫做原子性。像上述的tickets--操作,分为三步,将数据读取到寄存器,将寄存器内数据更改,再将数据写回内存,这三步操作,可能因为线程切换而打断后续操作,因此tickets--不具有原子性。如果一个对资源的操作仅用一条汇编语句就能完成,那么这个操作具有原子性。

        对资源加锁的操作要用到数据类型pthread_mutex_t,这就是我们锁,也成为互斥量,互斥量有以下接口用于完成对其的基本操作,用于初始化的pthread_mutex_init(),第一个参数传锁的指针,第二个参数设置为null即可。用于销毁的pthread_mutex_destroy(),参数为锁的指针。使用锁之前必须对其进行初始化,如果定义的是一个全局的锁,可以使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;语句对其直接进行初始化,也不用调用销毁函数了。锁初始化完毕之后,使用锁的两个接口,如下一个是加锁pthread_mutex_lock(),一个是解锁pthread_mutex_unlock(),加锁解锁之间的区域就是用户希望保护的临界区。

        如果一个锁是全局变量,那么每个线程都可以直接使用,如果一个锁是在main()内定义的话,那么在给新线程传递参数时要将锁传入进去才可以使用。下面这段代码是一个全局锁的代码。运行结果如下,这次抢票就是正常的。这次的抢票速度比不加锁时慢多了,因为加锁之后我们的多个线程变成串行的了。

        可以看到,在上述代码的运行过程中,往往在相当长的一段时间内,都是线程2来对临界资源进行访问,也就是线程2长时间进行着加锁解锁。锁只规定互斥访问,没有规定必须让谁优先执行,代码运行结果是多个执行流竞争的结果。当我们在线程代码内,抢票之后再休眠一段时间,这段时间,就会有当前线程之外的线程竞争到了锁。看看代码和运行结果(多加了一条usleep()语句,其余代码不变)此时每一个线程抢完票之后,下一次抢票的都是不同的线程。

2、如何看待锁

        无论锁是定义在全局还是main()内,由于多个线程都要对其进行访问,所以锁本身就是一个共享资源,这个变量是要被保护的。锁是用来保护全局资源的,锁的安全谁来保护?pthread_mutex_lock(),也就是加锁的过程必须得是安全的,加锁的过程其实是原子的。

        如果线程申请锁(pthread_mutex_lock)成功,就计息向后执行,如果申请不成功,执行流会如何呢?将上面的线程代码稍作修改。可以看到当申请锁后紧接着再次申请锁,我们的程序就不运行了,使用“ps -aL”指令查看线程的运行情况,发现线程们没有退出,这证明当线程申请锁不成功时,是会阻塞的。用户也可以在申请锁时不适用pthread_mutex_lock()接口而使用pthread_mutex_trylock()接口,使用方式时一样的,不过在申请失败时会直接返回,这是一种非阻塞式的申请锁的方式。

        如果线程1,申请锁成功,进入临界区,在访问临界资源期间,其他线程在做什么呢?在阻塞等待。那么一个线程在申请锁成功,访问临界资源期间,能不能被切换呢?答案是可以的。但是当持有锁的线程被切走的时候,是抱着锁被切走的,即便自己被切走了,其他线程依旧无法申请锁成功,便无法向后执行,直到最终锁被释放。所以当一个线程持有锁了,对于其他线程而言,有意义的状态只有两种,(1)当前线程申请锁前(2)释放锁后。

        在使用锁的时候,一定要尽量保证临界区的力度非常小,及临界区的代码要尽可能少。

        如何理解加锁的过程是原子性的?从底层原理来解释一下。为了实现互斥锁的操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。现在我们看看lock和unlock的伪代码。%al是CPU内的一个寄存器,mutex是我们初始化之后的锁,位于内存中(在地址空间的栈上或者全局区),在执行lock语句时,先将%al的值设置为0,再将%al和mutex的值进行交换(mutex的值为1),这样以来%al为1,而mutex为0,后续对%al的值进行判断,如果大于0则return 0,代表申请锁成功,执行后续的代码,如果为0则执行流挂起等待。unlock时则是直接将mutex的值置1即可。在之前讲过一个概念,CPU内的寄存器只有一套,为多个线程共有,CPU寄存器的内容,是每个执行流私有的运行上下文。当线程1执行完exchange指令,将%al的值置1后,发生了线程切换,此时线程1被切下去的时候,是带着自己的上下文一起走的,也就是说在上下文中%al为1,也就是说它是抱着锁走的,而此时内存中的mutex还是0,导致了别的线程无法申请锁成功。exchange的本质操做是通过仅仅一条指令,将共享的资源(锁)交换到当前进程的上下文中,exchange汇编指令的原子性,达成了lock操作的原子性。unlock的操作,就是直接将内存中mutex的值置1.

3、死锁

        在加锁的场景下,如果有两个线程,一个线程持有自己的锁,还要另一个线程的锁,另一个线程也是如此,那么就容易造成死锁。一把锁也是有可能死锁的,就如上面的写的pthread_mutex_lock()两次,就是死锁的一种情况。

        死锁的四个必要条件。(1)互斥,这属于互斥量的基本特性,这里不做解释。(2)请求与保持,意思是,一个线程在持有锁的情况下,还继续申请别的线程的锁。(3)不剥夺,线程不会强硬地剥夺另一个线程正在持有的锁,而是温和地等待式的申请别的线程的锁。(4)环路等待,在所有线程内,不同的锁的申请先后顺序不是一致的,而是成环状的,比如线程A先申请锁1,再申请锁2,线程B先申请锁2,再申请锁1,在申请逻辑上构成环路。想要避免死锁,破坏其中一个条件即可。

4、线程同步和条件变量

        多个线程访问临界区时,因为有锁的存在,一个线程持有锁时,其他线程只能阻塞式地等待,如果像上面的在长时间内只有一个线程对临界区进行访问的话,说明其他线程在长时间内进行阻塞,我们称这种长时间阻塞的情况为线程饥饿。那么在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。

        生产消费者模型。我们可以通过锁来解决共享资源访问的安全性问题,但是在保证安全的基础上,还有可能出现线程饥饿这样的不合理的情况,所以我们既要保证数据安全,也要保证线程在实际工作的时候还按照特定的顺序来访问。这种顺序可以不是一种绝对的顺序,但一定要有顺序,尽可能保证每一个线程都能合理均衡地访问共享资源,所以科学家们从生活中抽象出一种叫做生产消费者模型的多线程的工作模式。

        在生活中,顾客、超市、供货商,三者就构成了一个典型的生产消费者模型。顾客是消费者,供货商是生产者,超市是一个交易场所,两方是通过超市来间接地进行交易的。顾客区超市消费的时候,供货商可能不在生产,在放假,供货商在生产的时候,顾客也不一定在消费,二者的生产过程和消费过程互不干扰,并不需要保持严格的一致性,这个过程用计算机的术语来讲就叫做解耦,也正是因为有超市这样临时保存产品的场所的存在,才能实现我们生产和消费的解耦,临时保存产品的场所用计算机的术语来讲就是缓冲区。进一步来讲,超市可能被顾客访问,也可能被供货商访问,在代码层面上,超市就是一份共享资源,因此这份共享资源首先要被保护起来。在代码层面,生产者和消费者都是一个个线程,都会对我们共享资源进行访问,这套系统总共就三种关系,生产者和生产者、生产者和消费者、消费者和消费者之间的关系。生产者和生产者、消费者和消费者之间都是简单的互斥(竞争)关系,这个很简单。那么生产者和消费者之间呢?在现实世界中,商品是不会被覆盖的,但在计算机世界里,商品是数据,是可以被覆盖的,因此二者之间首先是互斥的,其次,超市是需要具有通知顾客店里有货来让顾客进行消费(促销等等传递信息)以及通知店里缺货来让供货商进行生产这样的行为的,能有效地提高系统的运行效率,这种简单的通知策略能够保证生产过程和消费过程远程协同起来,在此基础上维护了一种生产者和消费者之间的同步关系。

        总结一下,生产消费者模型遵守着简单的“321”原则:3种关系,生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥和同步)。2种角色,生产者线程和消费者线程。1个交易场所,一段特定结构的缓冲区。生产者消费者模型的特点为:生产线程和消费线程进行解耦,解决了生产和消费在一段时间内的忙闲不均的问题,从而提高了效率。

        基于上面个所讲的,生产者和消费者之间互斥关系的维持肯定是依赖于锁的,设想一种情况,消费者比生产者弱势,在访问共享资源上竞争不过生产者,此时生产者向缓冲区写数据,但是缓冲区满了,因此消费者线程持续进行着“加锁->访问缓冲区->什么都没做->解锁”的操作,而消费者由于竞争不过生产者也处于饥饿状态,无法获取资源。此时系统虽然依旧是互斥安全的访问,但这是严重不合理的行为,效率低下。解决方式要用到条件变量。条件变量在这里目的是让线程进行“加锁->访问缓冲区(判断)->不满足条件->挂起”,这样就不会出现一个执行流连续访问而什么都不做的情况。

        条件变量的基本接口:条件变量是一种数据类型,它的使用类似于上面所讲的互斥量,首先要定义一个pthread_cond_t,使用前要初始化(如果是全局的可以在定义时直接初始化,而不需要调用init()),使用完了要销毁(destroy)。将当前线程挂起的接口pthread_cond_wait(),两个 参数,一个条件变量指针,另一个互斥量指针。将指定条件变量下的线程唤醒的接口pthread_cond_signal(),参数是条件变量指针。

        在条件变量的存在下,我们的生产消费者模型可以遵守如下的运行模式,如果超市(缓冲区)满了,生产者线程此时依旧在对缓冲区进行访问,进行是否需要进货的判断,此时超市已满,不需要进货,通过条件变量将自身线程挂起,也就不在重复对缓冲区进行访问,此时消费者也有机会对缓冲区访问,进行消费,消费完了之后,再唤醒生产者条件变量下的挂起线程,此时超市不为满,生产者能进行后续生产。同理如果是消费者更强势的情况,在超市无货的情况下,消费者进行访问后也会将自己挂起到消费者条件变量下,等待生产者生产完毕后将消费者条件变量下的挂起线程唤醒。

        可以理解为,条件变量作为一种类型,内部是有一个链表来维护挂起线程的,类似于等待队列。在上面的接口中,有唤醒当前条件变量下的所有线程的,也有只唤醒一个线程的。

        基于BlockingQueue的生产者消费者模型,在多线程变成中阻塞队列是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作会被阻塞,直到队列中放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出。下面根据生产消费者模型和互斥量和条件变量的多种接口实现一下基于阻塞队列的生产消费者模型。一个头文件blockqueue.hpp,和一个maincp.cc。

#pragma once                                                                                                                                                                                                  
#include<iostream>
#include<queue>
#include<pthread.h>
static const int gmaxcap = 5;
template<class T>
class BlockQueue
{
public:
    BlockQueue(const int& maxcap = gmaxcap):_maxcap(maxcap)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_pcond, nullptr);
        pthread_cond_init(&_ccond, nullptr);
    }
    void push(const T& in)
    {
        pthread_mutex_lock(&_mutex);
        //判断阻塞队列是否已满
        while(is_full())//细节1
        {
            pthread_cond_wait(&_pcond, &_mutex);//阻塞队列已满,无法生产,此时生产者进行等待
        }
        _q.push(in);
        pthread_cond_signal(&_ccond);//唤醒消费者条件变量的队列
        pthread_mutex_unlock(&_mutex);
    }
    void pop(T* out)
    {
        pthread_mutex_lock(&_mutex);
        //判断
        while(is_empty())
        {
            pthread_cond_wait(&_ccond, &_mutex);
        }
        //走到这里,阻塞队列一定不为空
        *out = _q.front();
        _q.pop();
        pthread_cond_signal(&_pcond);//唤醒生产者条件变量队列
        pthread_mutex_unlock(&_mutex);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_pcond);
        pthread_cond_destroy(&_ccond);
    }
private:
    bool is_empty()
    {
        return _q.empty();
    }
    bool is_full()
    {
        return _q.size() == _maxcap;
    }
private:
    std::queue<T> _q;
    int _maxcap;//队列中元素的上限
    pthread_mutex_t _mutex;
    pthread_cond_t _pcond;//生产者对应的条件变量
    pthread_cond_t _ccond;//消费者对应的条件变量
};

#include"blockqueue.hpp"
#include<ctime>
#include<sys/types.h>
#include<unistd.h>
void* consumer(void* bq_)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(bq_);
    while(true)
    {
        //消费活动
        int data;
        bq->pop(&data);
        std::cout << "消费数据:" << data << std::endl;
        sleep(1);
    }
    return nullptr;
}

void* productor(void* bq_)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(bq_);

    while(true)
    {
        //生产活动
        int data = rand() % 10 + 1;
        bq->push(data);
        std::cout << "生产数据:" << data << std::endl;
        //sleep(1);                                                                                                                                                                                           
    }
    return nullptr;
}

int main()
{
    srand((unsigned long)time(nullptr) ^ getpid());
    BlockQueue<int> *bq = new BlockQueue<int>();

    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, (void*)bq);
    pthread_create(&p, nullptr, productor, (void*)bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    delete bq;
    return 0;
}

        需要注意三个细节, (1)pthread_cond_wait()这个函数的第二个参数,必须是互斥锁,在调用该函数时,会以原子性的方式将锁释放,并将当前线程挂起,在线程被唤醒的时候,会自动重新获取传入的锁。(2)在push和pop里的判断必须用while而不是if,如果唤醒线程使用的是pthread_cond_broadcast()接口,可能会有多个线程被同时唤醒,此时阻塞队列里也只有一个位置,它们都向后执行push的话可能会造成队列溢出,因此要使用while进行循环式地判断,一次只有一个线程向后执行。在push和pop里地pthread_cond_signal()和pthread_mutex_unlock()地位置是可以互换的。

        上面的代码由于生产者和消费者进入共享区之前都是必须先加锁的,所以是可以支持多生产者多消费者的,不论外部的生产者和消费者再多,真正访问临界资源的线程只有一个。

        有了上面的生产消费者模型和代码,现在又回归一个问题,既然代码是串行的,同一时刻只有一个线程对临界资源进行访问,那么这种工作模式的高效体现在哪里?生产者线程负责向阻塞队列里投放任务,消费者线程负责从阻塞队列里获取任务,在投放任务之前,生产者线程是要从外部获取到任务,在获取任务之后,消费者线程是要执行任务的。在多生产多消费的模式下,生产者线程的获取任务以及消费者线程执行任务的这部分工作是可以并行的,因为并没有访问到临界资源,所以不需要互斥访问。所以在多生产多消费的模式可以支持多个生产者同时获取任务以及多个消费者同时执行任务,这就是高效的体现。

5、信号量与环形队列

pthread_mutex_lock(&_mutex);
        //判断阻塞队列是否已满
        while(is_full())//细节1
        {
            pthread_cond_wait(&_pcond, &_mutex);//阻塞队列已满,无法生产,此时生产者进行等待
        }
        _q.push(in);
        pthread_cond_signal(&_ccond);//唤醒消费者条件变量的队列
        pthread_mutex_unlock(&_mutex);

        在上面的代码中,我们的线程在操作临界资源的时候,必须要在临界资源是满足条件的时候再进行后续操作,就像上面,必须在阻塞队列未满的时候进行生产。可是公共资源是否满足生产或者消费条件,我们无法在访问公共资源之前就得知(像上面一样,我们必须得在加锁之后,调用阻塞队列的is_full()接口才能得知是否满足条件),因此步骤只能是先加锁,再检测(检测的本质也是访问临界资源),再操作,再解锁。

        只要我们对资源进行了整体加锁,就默认了我们对这个资源整体使用。但实际情况可能是,一份公共资源,允许同时访问不同的区域(程序员编码保证不同的线程可以并发访问公共资源的不同区域)。这里引入信号量的概念,信号量的本质是一个计数器,衡量临界资源中资源数量多少的计数器,将信号量与临界资源做绑定,申请信号量就是对临界资源中特定小块资源的预定机制,只要申请到了信号量,在未来就能够拥有临界资源的一部分。通过信号量的计数器特性,只要我们在访问真正的临界资源之前先申请信号量,就能够实现,我们在访问临界资源之前就提前知道临界资源的使用情况。只要申请信号量成功的,在未来就一定能够访问这块资源,申请失败的,就只能等有线程释放资源了,当前线程才能进来。而在访问信号量的时候,线程并没有访问公共资源

        信号量是一种反映公共资源使用情况的资源,所有线程都可以看到信号量,因此信号量本身就是一种公共资源。上边说道信号量本质是一个计数器。申请资源对应计数器的--操作,将其称之为P操作,归还资源对应计数器的++操作,将其称之为V操作,由于是对公共资源的操作,因此这两种操作都必须保证原子性

       看一下信号量的基本接口使用,信号量是senaphore.h库中的一种类型,叫做sem_t。

        讲讲基于环形队列的生产者消费者模型的运行模式。关于环形队列,最重要的是考虑队列中为满或者为空的时候生产者和消费者该如何运行。当队列为空时,生产者和消费者的位置相同,这时要让生产者先运行。当队列为满时,生产者和消费者的位置也相同,这时要让消费者先运行。在其他的情况生产者和消费者指向的是不同的位置。总结一下,消费者不能超过生产者,生产者不能把消费者套一个圈以上。

        信号量是用来衡量临界资源中资源数量的,对于生产者而言,看中的是队列中的剩余空间,因此要为空间资源定义一个信号量。对于消费者而言,看中的是放入队列中的数据。对于数据资源也要定义一个信号量。

        假设在环形队列的场景下,环形队列可容纳十个元素(或者任务),一开始队列为空,定义两个信号量,生产者信号量(productor_sem)和消费者信号量(consumer_sem),此时生产者信号量为10,消费者信号量为0,在生产之前,生产者要先申请生产者了信号量,生产活动完成之后,归还的是消费者的信号量,因为消费者信号量代表队列中的数据量,数据量在生产完之后要++。消费者在消费前要申请消费者信号量,消费完成之后归还的是生产者信号量,因为消费完之后,队列中的位置被腾了出来。

        当队列为空时,此时一定是生产者线程能申请信号量成功,而消费者线程申请信号量不成功(此时消费者信号量为0),队列为满时,一定是消费者线程申请信号两成功而生产者申请信号量不成功(生产者信号量为0)。当队列既不为空也不为满是,两个生产者线程和消费者线程是可以并行访问队列的,因为不是指向队列的同一位置。

        下面来看一段基于环形队列的生产者消费者模型代码,一个RingQueue.hpp头文件和一个main.cc文件。生产者和消费者的位置其实由在环形队列中的下标决定,因此有两个下标,一个生产下标,一个消费下标,当队列为空或者为满时,下标相同。

#pragma once
#include<iostream>
#include<vector>
#include<cassert>
#include<semaphore.h>
static const int gcap = 5;

template<class T>
class RingQueue
{
private:
    void P(sem_t& sem)//对申请信号量做一个封装
    {
        int n = sem_wait(&sem);
        assert(n == 0);
        (void)n;
    }
    void V(sem_t& sem)//对归还信号量做一个封装
    {
        int n = sem_post(&sem);
        assert(n == 0);
        (void)n;
    }
public:
    RingQueue(const int& cap = gcap): _queue(cap), _cap(cap)
    {
        int n = sem_init(&_spaceSem, 0, _cap);
        assert(n == 0);
        n = sem_init(&_dataSem, 0, 0);
        assert(n == 0);

        _productorStep = _consumerStep = 0;

        pthread_mutex_init(&_pmutex, nullptr);
        pthread_mutex_init(&_cmutex, nullptr);
    }
    void Push(const T& in)
    {
        pthread_mutex_lock(&_pmutex);
        P(_spaceSem);//申请到了空间信号量,意味着能进行正常的生产
        _queue[_productorStep++] = in;
        _productorStep %= _cap;                                                                                                                                                                               
        V(_dataSem);
        pthread_mutex_unlock(&_pmutex);

    }
    void Pop(T* out)
    {
        pthread_mutex_lock(&_cmutex);
        P(_dataSem);
        *out = _queue[_consumerStep++];
        _consumerStep %= _cap;
        V(_spaceSem);
        pthread_mutex_unlock(&_cmutex);
    }
    ~RingQueue()
    {
        sem_destroy(&_spaceSem);
        sem_destroy(&_dataSem);
        pthread_mutex_destroy(&_pmutex);
        pthread_mutex_destroy(&_cmutex);
    }
private:
    std::vector<T> _queue;
    int _cap;
    sem_t _spaceSem; //生产者想生产,看中的是空间资源
    sem_t _dataSem; //消费者想消费,看中的是数据资源
    int _productorStep;//生产者下标
    int _consumerStep;//消费者下标
    pthread_mutex_t _pmutex;//生产者锁
    pthread_mutex_t _cmutex;//消费者锁
};

#include"RingQueue.hpp"                                                                                                                                                                                       
#include<pthread.h>
#include<ctime>
#include<cstdlib>
#include<sys/types.h>
#include<unistd.h>

void* ProductorRoutine(void* rq)
{
    RingQueue<int>* ringqueue = static_cast<RingQueue<int>*>(rq);
    while(true)
    {
        int data = rand() % 10 + 1;
        ringqueue->Push(data);
        std::cout << "生产完成,生产的数据是:" << data << std::endl;
    }
}

void* ConsumerRoutine(void* rq)
{
    RingQueue<int>* ringqueue = static_cast<RingQueue<int>*>(rq);
    while(true)
    {
        int data;
        ringqueue->Pop(&data);
        std::cout << "消费完成,消费的数据是:" << data << std::endl;
    }
}
int main()
{
    srand((unsigned int)time(nullptr) ^ getpid() ^ pthread_self());
    RingQueue<int>* rq = new RingQueue<int>();

    pthread_t p, c;
    pthread_create(&p, nullptr, ProductorRoutine, rq);
    pthread_create(&c, nullptr, ConsumerRoutine, rq);

    pthread_join(p, nullptr);
    pthread_join(c, nullptr);
    return 0;
}

         这样就是实现了一个基于环形队列的生产消费模型,可以实现生产者和消费者在队列非空非满的情况下并行对对队列进行访问,在多生产多消费的场景下,生产者之间依旧是互斥关系,消费者之间依旧是互斥关系,生产者之间需要通过一把生产者锁来竞争出一个生产者来生产,消费者之间需要通过一把消费者锁来竞争出一个消费者来消费,两个线程再根据队列是否为空为满的情况来进行后续动作。

        注意一点,在Push和Pop操作里面,是可以先申请信号量再申请锁的。这种做法的逻辑是,一来信号量的申请本身就是原子的,对其不需要做加锁保护,二来线程在执行被锁保护的代码期间,其他线程也可以申请信号量,提前预定好资源。这是一种比先申请锁再申请信号量效率更高的做法(上面的代码可以进行这样的优化)。

单例模式。

7、两种其他锁

        自旋锁。自旋锁的作用和操作接口与普通的pthread_mutex_t类似,属于互斥量的一种。

        区别是,使用普通锁时,当线程申请锁不成功,当前线程会被挂起等待,而如果使用的是自旋锁,在线程申请锁不成功时,线程会在等待一段时间之后重新申请锁,这种现象在也被称为自旋。自旋锁使用于多线程场景下线程占用临界资源时间较短的场景。如果多线程场景下线程占用临界资源的时间较长,却使用自旋锁,那么CPU的负担会很大。

        读写锁。了解一种读者写者模型。类似于上面讲过的生产者消费者模型。在这种模型下,写者的角色就是生产者,负责向公共数据区写入或者修改,读者只读不写。写者与写者之间是互斥关系,写者与读者之间是互斥关系,最关键的是,读者和读者之间是没有任何关系的,它们是可以并发的读共享区的内容的,但是不可以写入或修改,跟消费者不同(消费者会拿走数据,读者不会)。在这种应用场景下,介绍读写锁的接口。在访问共享区时,读者线程调用pthread_rwlock_rdlock()来进行加锁,写者线程调用pthread_rwlock_wrlock()来进行加锁。不论是读者还是写者,在解锁时都调用pthread_rwlock_unlock()。

        在任何一个时刻,只允许一个写者写入,但是可能允许多个读者读取(写者阻塞)。下面这段伪代码可以帮助我们理解读加锁和写加锁的底层原理。pthread_rwlock_t作为一个结构体,我们可以理解为它的里面有两把锁rdlock和wrlock。

        读加锁和解锁

        写加锁解锁

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值