前文
在这一篇博客(信号量博客)中我曾经提及过信号量的知识,而当对信号量进行提炼总结时,大致是以下三点:
1. 信号量本质是一个计数器(代表资源的数量)
2. 申请信号量本质就是对资源的一种预定机制
3. 信号量的PV操作是原子的
我也在这篇博客(生产消费者模型的实现博客)中介绍过生产消费者模型,这种模型使用了多执行流以生产者负责接受并派发任务,消费者负责执行生产者派发的任务,这种方式能够一定程度上提高代码的执行效率,其中的实现方式根据消费者与消费者之间、生产者与生产者之间、还有消费者与生产者之间的互斥同步关系完成的。
在本文章中我将对信号量进行较深入的介绍,以及使用信号量重新实现一份新的生产消费者模型。
信号量
在实现生产消费者模型博客的过程中,我使用的是阻塞队列来作为一种生产消费模型的基础加以实现的。在这其中,阻塞队列一直是被当作一个整体的资源来被访问,即因为生产者和消费者彼此间互斥,所以同一时刻只能有一个执行流访问阻塞队列,但是我们知道对于队列来说头出尾进看着是生产者生产数据并不会影响消费者消费数据的过程,但其实对于C++中的STL容器来说,容器有可能随时扩容,所以队列本身是线程不安全的,也需要互斥。
那基于这样的想法,我们可不可以自己开辟一段空间,就将这段空间分成多个块,每个块都是一个基本资源,而每个线程都独自访问属于自己的块资源,那么这么来看的话,这种访问方式本身就是线程安全的不需要使用互斥来控制(因为本质上没有共享资源)。而现在资源的控制就可以转化为使用信号量来表示这段空间中有多少个数据块是可用的,然后当线程需要资源的时候,先申请信号量,然后通过一定操作访问到此时属于自己的资源进行处理,然后释放信号量。
我们在多执行流并发访问共享资源时,都是通过加锁的方式保证线程安全,然而当执行流进入临界区访问共享资源时,一般还要确定该共享资源是否已经准备好了。
然而对于信号量所管理的资源,我们还需要在申请信号量成功之后再次确认资源是否已经准备好了吗?很明显是不用了,因为信号量的申请成功就意味着此时一定是有资源的。这是与单纯的互斥保护临界资源不同的一点。
这样的话我们的共享资源好像因为信号量的存在变得能多执行流并发访问了一样。
信号量的接口
现在我来简单的介绍一下信号量的接口:
首先是信号量的初始化函数,第一个参数就是信号量类型为sem_t,第二个参数是表示该信号量是线程内共享还是进程内共享(0是线程内共享),第三个参数就是资源的数量。
这个是信号量的销毁函数。
这个函数对应信号量的P操作,即申请信号量,当申请信号量失败时,就会被阻塞在对应的等待队列中。
这个就是信号量的V操作,即释放信号量。
生产消费模型
现在我会介绍如何使用信号量来重新实现一个生产消费者模型,然后这里的生产消费者模型使用的数据机构是循环队列,并且刚开始是单生产单消费。
我们来想象一下循环队列中的生产消费者模型是什么样的:
我们知道,生产者负责传递数据,消费者负责接收数据,我们现在来假设这么一种极端情况:生产者一直在生产,而消费者不消费,那么当生产者再次碰到消费者时意味着队列中的数据已经满了,生产者已经不能再生产数据了,这也意味着环形队列中,生产者不能超过消费者一圈。
而对于消费者而言,假如此时生产者已经将队列中填满数据,此时消费者开始消费数据,当消费者再次碰到生产者时,意味着队列中已经没有有效数据了,也意味着消费者不能超过生产者,要始终在消费者之后。
我们知道,当线程启动时,操作系统对于执行流的调度是不确定的,但是对于上述模型来说,刚开始也就是队列中没有数据的时候,我们的消费者线程是不能运行的,是要被阻塞的。此时要等待生产者生产数据之后,消费者才能开始执行,而队列中充满数据时,生产者线程是要被阻塞的,直到消费者线程消费了数据之后,生产者线程才能开始执行。这段话中我们发现两种现象:那就是当队列中为空或为满的时候只能由生产者或者消费者访问共享资源。这其中,只能单一执行流访问共享资源体现了互斥,执行执行流访问体现了同步,所以这是需要注意的点。
但是,除了以上两种极端情况外,惊奇的发现生产者线程和消费者线程是可以并发访问共享资源的,因为它们访问的是共享资源的不同位置。
假如使用代码实现生产者生产和消费者消费的逻辑该怎么实现呢?
有的人可能就有疑问了,为什么两个线程申请和释放的信号量不是一个而是交叉的啊?
这其实不难理解,关于两个线程的申请各自的信号量很正常,而对于释放对方的信号量,当生产者生产数据之后,直观的能够看到数据资源增加了一份所以应该对数据信号量进行V操作,而当消费者线程消费数据之后,那个数据的空间就闲置了,空间资源增加了一份所以应该是对空间信号量进行V操作。
而对于“通过一定操作访问到此时属于自己的资源进行处理” 这个步骤,体现在环形队列中就是,每个元素的位置了,假如环形队列是一个数组的话那就是数组的下标了。由于生产者和消费者在大部分情况下是并行访问队列的,所以生产者和消费者应该有着自己的下标管理。
关于更多的细节,我们需要在代码中体现了。
CP模型的实现
现在我们就来简单实现一下,CP模型:
大致框架与阻塞队列的CP模型一致,但是我们这里使用的不再是互斥量和条件变量,而是信号量,所以我们现在就应该思考一下应该有几个信号量呢?在这里由于生产者和消费者对于“资源”的认识是不同的,生产者所认识的“资源”是队列中的空间,而消费者认识中的“资源”是数据,所以这里我们应该使用两个信号量来表示资源,而对于空间信号量来说初始值应该是队列的大小,资源信号量的初始值是0:
接下来是生产者生产数据和消费者消费数据的具体逻辑:
至此我们的生产消费者模型就完成了,以下是测试代码:
在CP模型中还是老生常谈,它不止能传输整形,还能传输任意类型的对象,因为我们使用了模板。
并且我们发现信号量它很好的作为循环队列的一部分特性而存在,如果没有信号量的话,我们还需要考虑循环队列中是空的还是满的,这需要我们使用额外变量或者空出队列中一个元素格子的代价来实现。
除此之外我们发现这样由信号量实现的CP模型更加的高效,因为它允许生产者和消费者在大部分时刻下并发访问共享资源。
多生产消费
现在就该讨论一下,关于多生产多消费了。
我们在阻塞队列中的由单生产单消费过渡到多生产多消费时,什么都不用做,因为保护的资源只有一个,而那一个资源已经被保护好了。而在这里,对于消费者和生产者而言它们俩之间的共享资源有信号量和队列,信号量是原子的,而队列又被信号量所保护着,所以这个关系可以还能好的维护,而对于消费者和消费者之间、生产者和生产者之间,它们的共享资源除了原子性的信号量和队列外,还有一个指向队列下标的变量_p_index、_c_index,这两个变量如果不加以保护的话,由会产生数据不安全的问题。所以我们还需要两个锁来保护这两个不同的共享资源。我曾经提过锁的数量一般是与共享资源的而数量挂钩的:
头文件SmartMutex.hpp:
加入了互斥锁之后,就又出现问题了:到底是先加锁再申请信号量呢,还是先申请信号量再加锁呢?
当先加锁时,以Push函数为例,多个线程进入Push函数之后,首先要竞争锁资源,竞争到锁资源的线程再进行信号量的申请。
而先申请信号量时,多个线程先是申请信号量,我们要知道信号量可是有可能有多个的,所以存在多个线程申请信号量成功,然后这些线程只需要竞争锁资源就可以了。
前者的多个线程是先竞争后单个申请信号量,后者的多个线程前半部分申请信号量可能会存在一步到位的情况,后者再全部进行锁的竞争,竞争到之后直接开始资源的访问。
这么一看明显是先申请信号量再加锁比较好,所以:
这样我们就可以实现多生产多消费的CP模型了:
这里我防止打印错误,对打印的过程加了个锁。