POSIX信号量(包含通过POSIX信号量模拟实现的生产线程和消费线程并发运行的生产者消费者模型)

目录

初识POSIX信号量

POSIX信号量的用途

POSIX信号量的创建

POSIX信号量的初始化函数

申请POSIX信号量(或者说等待信号量)的函数

释放POSIX信号量(或者说发布信号量)的函数

销毁POSIX信号量的函数

环形队列

通过环形队列和POSIX信号量模拟实现生产线程和消费线程并发运行的生产者消费者模型(包括对加锁和申请信号量的顺序的说明、包括对多生产多消费的意义的讲解)

以单生产者线程、单消费者线程为例模拟实现【生产线程和消费线程并发运行的生产者消费者模型】

testMain.cc文件的整体代码

RingQueue.h文件的整体代码

测试以单生产者线程、单消费者线程为例模拟实现的【生产线程和消费线程并发运行的生产者消费者模型】

通过目前版本的单生产单消费模型进一步说明为什么【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】

以多生产者线程、多消费者线程为例进行【生产线程和消费线程并发运行的生产者消费者模型】的模拟实现

testMain.cc文件的整体代码

RingQueue.h文件的整体代码

加锁和申请信号量的顺序

目前版本的多生产多消费模型的意义

通过目前版本的多生产多消费模型进一步说明为什么【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】

模拟实现【基于任务派发的生产线程和消费线程并发运行的多生产者多消费者模型】

我们说信号量是一个计数器,计数器(信号量)的意义是什么?信号量能平替条件变量吗?


初识POSIX信号量

(tips:如果对于线程的同步操作感到陌生,请回顾<<线程的互斥与同步>>一文)

POSIX信号量和SystemV信号量作用相同,都是用于同步操作(让访问者按照一定顺序访问资源),达到无冲突的访问共享资源目的,但注意SystemV信号量主要用于控制进程的同步、而POSIX信号量除了可以用于进程间同步外,还可以用于线程间同步。然后要说的是不要把SystemV信号量和SystemV共享内存混淆了,二者是完全不同的两个东西。

  1. System V共享内存(System V Shared Memory): 共享内存是一种允许多个进程在它们之间共享一块内存区域的机制。这些进程可以读取和写入共享内存区域,从而实现高效的数据共享。共享内存通常用于需要快速数据传输和共享的情况。不同进程可以通过连接到同一块共享内存来实现数据共享。

  2. System V信号量(System V Semaphores): 信号量是一种用于进程间同步的机制,它允许多个进程协调对共享资源的访问。信号量可以用于避免竞态条件和实现互斥访问共享资源,从而确保数据的一致性。通常,信号量用于控制对共享资源(如共享内存区域)的并发访问。
  3. 虽然System V共享内存和System V信号量都属于System V IPC机制,但它们的功能和用途是不同的。System V共享内存用于共享数据,而System V信号量用于控制对共享资源的访问。在需要同时共享数据和控制对数据访问的情况下,可以同时使用这两种机制来实现完整的进程间通信。

信号量本质就是一个计数器,假如共享资源有3个,那么信号量就为3。假如只访问一个临界资源,那么就必须先申请一个信号量,也就是让计数器(信号量)减1,临界资源使用完毕时,还得归还信号量,也就是让计数器加1。申请信号量本质是在预定资源,就相当于看电影之前要买票,买票后座位就暂时属于你了,即使你不去看电影,别人也不能坐你的位置,所以即使你申请完信号量后不去访问临界资源,那也没有问题,因为别人访问不了属于你的临界资源。所以申请信号量成功后,当前线程一定拥有一个资源,该资源暂时只能被当前线程使用,但注意申请信号量只能保证一定拥有一个资源,但具体是哪一个资源是未知的,需要程序员结合场景,自定义编码完成指定。

这里额外补充一点:PV操作是一种实现进程(或者线程)互斥与同步的有效方法。PV操作与信号量的处理相关,P表示通过的意思,V表示释放的意思,申请信号量就是一种P操作,释放或者说归还信号量就是一种V操作。注意、P和V操作都是原子的,这一点很重要,至于为何重要会在下文讲解【加锁和申请(或者释放)信号量的先后顺序】的部分中体现出来。

POSIX信号量的用途

如果一个共享资源,可以不被当做一个整体,可以让不同的执行流(线程)访问不同的区域,那么就可以让线程并发的执行了,什么意思呢?举个例子,假如有一个数组作为多个线程的共享资源,如果我们很明确地知道不会有相同的线程访问同一个下标上的元素,那么不同的线程在访问这一个数组时,就不需要将这个数组当作临界资源,访问这个数组时也就不需要加锁以及后序的解锁,直接让所有线程并发地执行代码即可;只有当存在【多个线程访问同一个下标上的元素】这种可能时,才需要将整个数组当作临界资源,才需要让不同的线程在访问数组的任意一个元素时都带上锁,才需要让所有线程在访问数组的任意一个元素时去串行化的访问、即在同一时间内只能有一个线程去访问数组上的元素。

从上一段我们能得到一个启示:多个线程(或者是进程)只有在访问同一个资源的时候,我们才需要进行同步或者互斥,而一个东西的整体是否能被当作一个临界资源是需要分场合的,就像上一段所讲的数组,在那种场合下,数组这个整体就不适合被当作一个临界资源,所有线程是可以并发的访问这个数组的;而对于<<生产消费者模型的介绍以及其的模拟实现>>一文中所说的阻塞队列BlockQueue,阻塞队列这个整体就必须被当作临界资源。

结合上面的思路,我们在代码层面上实现让多个线程并发地访问临界资源的不同区域时,会遇到一些问题,如下。

问题:你怎么知道一共有多少个资源?资源目前还剩多少个?我怎么知道我这个线程一定可以具有一个共享资源呢?你怎么保证这个资源就是给你这个线程的?

答案:通过POSIX信号量。假如信号量初始化成8,那么一共就有8个资源。在信号量加减的过程中,信号量目前的值暂时为3,那资源目前就还剩3个。只要某个线程申请信号量成功,该线程就一定可以具有一个共享资源(因为信号量具有预定机制,并且信号量本质还是个计数器,所以该线程申请信号量成功后,该线程一定能预定到一个资源,即使这个资源该线程不使用,其他线程也别想拿到)。通过程序员编码控制可以保证资源是给你这个进程的,怎么编码控制呢?拿数组举个例子,假如有一个数组a【8】作为多个线程的共享资源,此时我最多可以让8个线程并发地访问数组a,即给每个线程分配一个不同下标上的元素,这样一来就不可能发生多个线程访问同一个下标上的元素,咱们作为编码者,让哪个线程管理哪个下标上的元素是很容易的,所以之前才说通过程序员编码控制可以保证资源是给你这个进程的。

POSIX信号量的创建

sem_t类型的变量就是信号量对象,比如语句sem_t s即可定义出一个信号量对象,定义在全局或者局部都行,不管是全局的信号量还是局部的信号量,操作它们的函数都是统一的,详情请继续往下看。

POSIX信号量的初始化函数

738728b3adc34abb81e49c15a27e8add.png

第一个参数sem就是信号量的地址;pshared为0表示线程间共享(即表示当前信号量用于控制线程间的同步),非零表示进程间共享(即表示当前信号量用于控制进程间的同步);value设置的值表示设置信号量的初始值。

申请POSIX信号量(或者说等待信号量)的函数

da7548ce9bed4284a3d64baa5df29f44.png

信号量本质是一个计数器,信号量的数据类型为sem_t,是个结构体,可以认为结构体中有一个成员的字段就表示这个计数器。那么wait函数的功能就是申请信号量,如果申请成功,则该函数会将信号量减1,并且能够保证未来一定让该线程成功地访问临界区完成任务。当信号量为0时,再次调用函数申请信号量就会让当前线程陷入阻塞,直到信号量不为0,即至少有1个信号量时陷入阻塞的当前线程才能自动醒来。

问题:上一段凭什么说【如果申请信号量成功,则该函数能够保证未来一定让该线程成功地访问临界区完成任务】呢?

答案:注意,到这里其实还不能完全把这个问题说清楚,需要结合下面的单生产者单消费者模型和多生产者多消费者模型进行更深一步的理解,在下文中还会把这个问题拿来说的。

sem_wait和sem_trywait的区别在于:前者如果申请信号量失败,则立即阻塞;后者如果申请信号量失败,则不会阻塞,直接返回并继续向下执行代码。

这里额外补充一点:PV操作是一种实现进程(或者线程)互斥与同步的有效方法。PV操作与信号量的处理相关,P表示通过的意思,V表示释放的意思,申请信号量就是一种P操作,释放或者说归还信号量就是一种V操作。注意、P和V操作都是原子的,这一点很重要,至于为何重要会在下文讲解【加锁和申请信号量的先后顺序】的部分中体现出来。

释放POSIX信号量(或者说发布信号量)的函数

6ed9fe3f8c1849ec8cec41cd3b8811c3.png

信号量本质是一个计数器,信号量的数据类型为sem_t,是个结构体,可以认为结构体中有一个成员的字段就表示这个计数器。

当前函数的功能:发布信号量,或者说叫释放信号量,表示资源使用完毕,可以归还资源了。用该函数会让信号量加1。当信号量已经满了,即当前信号量的值等于信号量的上限时,再次调用该函数释放信号量则当前线程会陷入阻塞,直到信号量不为满时才恢复当前线程。POSIX 信号量的上限(即可创建的最大信号量数)取决于系统的配置和资源限制。这些资源限制通常由系统管理员或操作系统的默认配置来确定。因此,具体的上限值会因不同的系统而异。

这里额外补充一点:PV操作是一种实现进程(或者线程)互斥与同步的有效方法。PV操作与信号量的处理相关,P表示通过的意思,V表示释放的意思,申请信号量就是一种P操作,释放或者说归还信号量就是一种V操作。注意、P和V操作都是原子的,这一点很重要,至于为何重要会在下文讲解【加锁和申请(或者释放)信号量的先后顺序】的部分中体现出来。

销毁POSIX信号量的函数

c182ab32dcd045a8bb2a322c9b579422.png

该函数用于销毁一个已经初始化的信号量(Semaphore)。它的作用是在不再需要使用信号量时,释放相关的资源,以便系统可以回收这些资源并防止内存泄漏。具体来说,sem_destroy 函数的主要功能包括:释放与信号量相关的资源:这包括释放信号量内部的数据结构、计数器等资源。这样,一旦信号量被销毁,就不能再对其进行操作。防止资源泄漏:通过销毁信号量,可以确保程序不会在不再需要信号量时继续占用相关的系统资源,从而提高系统的效率。请注意,sem_destroy 函数只能用于已经初始化的信号量,如果尝试销毁未初始化的信号量或已经销毁的信号量,可能会导致不确定的行为。一般来说,信号量的生命周期包括创建(初始化)、使用和销毁三个阶段,sem_destroy 用于将信号量从使用阶段切换到销毁阶段,以释放相关资源。

环形队列

384d171fe4c940c2b5639d77c049f6cf.png

如上图所示,环形队列只是一种逻辑上的结构,其底层可以是链表list(即可以让链表首尾相连,此时链表就是一个环形结构),也可以是顺序表vector(假如index表示下标,则index在不断++的过程中,当index等于7了,此时可以通过模运算如index%=7、让index重新变成0,通过这样的方式让vector在逻辑上表现成环状结构)。说一下,在本章中咱们就以环形队列的底层是vector为例,来模拟生产者消费者模型。

通过环形队列和POSIX信号量模拟实现生产线程和消费线程并发运行的生产者消费者模型(包括对加锁和申请信号量的顺序的说明、包括对多生产多消费的意义的讲解)

以单生产者线程、单消费者线程为例模拟实现【生产线程和消费线程并发运行的生产者消费者模型】

为了方便理解,咱们先以单生产者线程、单消费者线程为例模拟实现【生产线程和消费线程并发运行的生产者消费者模型】,等这个实现完毕后,再以多生产者线程、多消费者线程为例模拟实现【生产线程和消费线程并发运行的生产者消费者模型】。

我们在上文讲解POSIX信号量的用途的部分中说如果一个共享资源不被当做一个整体,而让不同的执行流(线程)访问不同的区域的话,就可以让线程并发的执行。这里我们把环形队列当成共享资源的整体,把队列中每个元素当成临界资源的一个区域,只要能够让生产线程和消费线程不同时访问队列中的同一个元素(区域)即可完成并发。

问题:那么如何保证生产线程和消费线程不会同时访问同一个区域(元素)呢?

384d171fe4c940c2b5639d77c049f6cf.png

答案:很简单,如果不想让生产者线程和消费者线程指向同一个位置,让这两个线程对同一个位置上的资源具有互斥和同步关系就可以了(互斥是为了让双方线程不能同时访问这个位置。同步是为了当这个位置有数据时让消费者线程先访问,让生产者线程后访问;当这个位置没有数据即为空时让生产者线程先访问,让消费者线程后访问),除了通过条件变量可以维护好互斥和同步关系外,还可以通过本章所讲的POSIX信号量维护好互斥和同步关系。下面模拟一个场景举例可以通过POSIX信号量维护好互斥和同步关系:

在举例之前,咱们首先要知道一些前置知识点:

  • 在上文中讲解申请和释放信号量的函数中说过,信号量本质就是一个计数器,在下面举例时,我们会给生产者线程和消费者线程各自分配一个信号量对象,表示各自线程当前可以申请到的资源的总数,在当前模型中,生产者线程在意的是空间资源(即有多少个空位置可以让我生产数据),消费者线程在意的是数据资源(即有多少个位置上的数据资源可以让我消费),假设环形队列的容量是8,即队列能存储8个数据,那么在最开始时,因为所有位置上都没有数据,所有位置上都是空,所以当前生产者可以申请到的资源的总数就是8,所以分配给生产者线程的信号量对象A的值就应该是8,在代码层面上就是通过信号量的初始化函数将信号量对象A初始化成8;当前消费者可以申请到的资源的总数就是0,所以分配给消费者线程的信号量对象B的值就应该是0,在代码层面上就是通过信号量的初始化函数将该信号量对象B初始化成0。在后续的过程中,我们还需要通过代码控制两点:
  • 第一,如果生产者通过信号量的申请函数申请信号量成功,即分配给生产者线程的信号量对象的值--了,则在生产者线程函数中就需要通过信号量的释放函数给消费者线程释放信号量,即让分配给消费者线程的信号量对象++(因为对于生产者线程来说,我申请信号量成功后,就代表我已经预订了一个空间资源或者说空位置让我去生产,那对于消费者线程来说,也就多了一个数据资源让它去消费,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我消费者可以申请的数据资源多了一个,那分配给我消费者线程的信号量对象的值当然也应该++)。
  • 第二,如果消费者通过信号量的申请函数申请信号量成功,即分配给消费者线程的信号量对象的值--了,则在消费者线程函数中就需要通过信号量的释放函数给生产者线程释放信号量,即让分配给生产者线程的信号量对象++(因为对于消费者线程来说,我申请信号量成功后,就代表我已经预订了一个数据资源或者说非空位置让我去消费,那对于生产者线程来说,也就多了一个空间资源或者说空位置让它去生产,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我生产者可以申请的空间资源多了一个,那分配给我生产者线程的信号量对象的值当然也应该++)。

前置知识点说明完毕后,咱们回到正题,即模拟一个场景举例可以通过POSIX信号量维护好互斥和同步关系:

384d171fe4c940c2b5639d77c049f6cf.png

(结合上图思考)假设只有一个生产线程和一个消费线程,然后假设刚开始时生产线程和消费线程都【指向】队列的头部(可以把环形队列中的任意一个位置当成头部),因为生产线程还没有开始生产,所以该区域肯定也是没有元素的。假如生产线程生产一个元素就访问下一个区域,消费线程消费一个元素就访问下一个区域,那么就只有三种情况会导致生产线程和消费线程同时访问同一个区域。(注意如果不是以下这3种情况,那生产者线程和消费者线程在同一时间也就访问的不是同一个区域,也就可以让生产者线程和消费者线程并发地访问环形队列了,即各自随意访问)

第一种情况是环形队列为满,即生产线程生产得太快,消费线程消费得太慢,生产线程把环形队列中的元素生产满了,即绕了队列一整圈,此时生产线程才会和消费线程访问同一个元素。

第二种情况就是环形队列为空,即在刚开始时,生产线程和消费线程都【指向】队列的头部,此时生产线程还一个元素都没有生产完毕,处于正在生产的状态。

第三种情况也是环形队列为空,但这里为空的情况和第二种不太一样,是消费线程消费的速度大于生产线程生产的速度,每生产一个任务,消费线程立刻就将任务处理完毕了,这会导致生产线程和消费线程始终【指向】环形队列中的同一块区域。

(再次强调:我们要通过信号量控制生产者线程和消费者线程之间的同步、当环形队列为满时让消费者线程先访问临界资源,让生产者线程后访问临界资源,这点从代码层面上很好做到,比如环形队列为满时,生产者线程申请信号量会直接阻塞,直到分配给生产者线程的信号量不为0再恢复并继续往下执行代码,所以当生产者在阻塞时,消费者就理所应当地先访问临界资源了;当环形队列为空时让生产者线程先访问临界资源,让消费者线程后访问临界资源,这点从代码层面上也很好做到,比如环形队列为空时,消费者线程申请信号量会直接阻塞,直到分配给消费者线程的信号量不为0再恢复并继续往下执行代码,所以当消费者在阻塞时,生产者就理所应当地先访问临界资源了)

如果发生了第一种情况,因为此时环形队列为满,即生产者关注的空间资源为0个、分配给生产者线程的信号量为0,所以生产者线程再次调用sem_wait函数申请信号量时,生产线程就会陷入阻塞,只有数据被消费线程消费掉至少一个后,生产线程才会被消费线程唤醒继续生产((结合下图思考)从代码层面上解释本段话就是说:在消费者线程中调用信号量的申请函数让分配给消费者线程的信号量--后,根据上面的一些前置知识点可知,此时就需要在消费者线程中调用信号量的释放函数将分配给生产者线程的信号量++,++后,该信号量的值不为0,生产者线程自己就醒来了,注意消费者线程调用这个释放函数唤醒生产者线程的时机一定要是在消费者线程访问完【生产线程和消费线程共同指向的位置上的数据】之后,这样才能避免数据紊乱)

如果发生了第二种情况,此时生产线程还一个元素都没有生产完毕,处于正在生产的状态,那么环形队列中为空、即没有数据、消费者关注的数据资源为0个、分配给消费者线程的信号量为0,那么当消费线程试图调用sem_wait函数申请信号量时会陷入阻塞,只要生产线程没有生产出元素,消费线程就一直阻塞,直到生产线程生产出至少一个数据后,消费线程才会被生产者线程唤醒((结合下图思考)从代码层面上解释本段话就是说:在生产者线程中调用信号量的申请函数让分配给生产者线程的信号量--后,根据上面的一些前置知识点可知,此时就需要在生产者线程中调用信号量的释放函数将分配给消费者线程的信号量++,该信号量的值不为0后,消费者线程自己就醒来了,注意生产者线程调用这个释放函数唤醒消费者线程的时机一定要是在生产者线程访问完【生产线程和消费线程共同指向的位置上的数据】之后,这样才能避免数据紊乱)

第三种情况和第二种情况一样,因为消费线程太快,生产线程每生产一个任务后,消费线程立刻就将任务处理完毕了,这会导致无论何时环形队列中都为空、即没有数据、消费者关注的数据资源为0个、分配给消费者线程的信号量为0,那么当消费者线程调用sem_wait函数申请信号量时会陷入阻塞,只要生产线程没有生产出元素,消费线程就一直阻塞,直到生产线程生产出至少一个数据后,消费线程才会被生产者线程唤醒((结合下图思考)从代码层面上解释本段话就是说:在生产者线程中调用信号量的申请函数让分配给生产者线程的信号量--后,根据上面的一些前置知识点可知,此时就需要在生产者线程中调用信号量的释放函数将分配给消费者线程的信号量++,该信号量的值不为0后,消费者线程自己就醒来了,注意生产者线程调用这个释放函数唤醒消费者线程的时机一定要是在生产者线程访问完【生产线程和消费线程共同指向的位置上的数据】之后,这样才能避免数据紊乱)

所以综上所述可以发现:

  1. 只要生产线程和消费线程【指向】同一块区域,此时环形队列一定要么为空,要么为满;
  2. 即使生产线程和消费线程【指向】同一块区域,它们也不能同时访问这块区域中的元素,至少有一个线程正处于阻塞状态,所以即使不加锁,也不必担心两个线程同时访问同一块区域,而当生产线程和消费线程没有【指向】同一块区域时,肯定也不会同时访问同一块区域,所以说通过POSIX信号量可以保证生产线程和消费线程不会同时访问同一个区域(元素)。
  3. 如果不是上面所说的这3种情况,那生产者线程和消费者线程在同一时间也就访问的也就不是同一个区域,就可以让生产者线程和消费者线程并发地访问环形队列了,即各自随意访问。为什么呢?从代码层面上说,如果生产线程和消费线程没有【指向】同一块区域,此时环形队列一定不为空、也一定不为满,那么生产者线程在调用sem_wait函数申请信号量(这里的信号量表示空间资源)时就会成功、消费者线程在调用sem_wait函数申请信号量(这里的信号量表示数据资源)时也会成功,申请成功后就可以在环形队列中放置或者取出数据了,就成功的达到了生产者线程和消费者线程并发运行的目的。

————分割线————

在分割线以上的内容,全是编写单生产者单消费者模型的预备知识点,接下来正式说说单生产者单消费者模型的编写思路,如下:

在主函数中,首先在堆上new创建一个环形队列RingQueue的对象,然后创建两个线程(一个充当生产者线程、另一个充当消费者线程)。

创建线程时需要一些参数,所需参数1:需要把环形队列RingQueue的对象传给两个线程作为两个线程的临界资源(说一下环形队列RingQueue的对象能被两个线程作为临界资源是因为我们把环形队列对象的地址当作参数传给了两个线程。但实际上就算不传参,因为环形队列是在堆上开辟的空间,而堆上的数据是所有线程共享的,所以环形队列对象还是能被两个线程看到,能作为两个线程的临界资源)。所需参数2:一个是生产者线程函数,将该函数传给生产者线程;另一个是消费者线程函数,将该函数传给消费者线程。函数不能凭空而来,所以此时需要创建这两个函数,才能将函数传给生产者和消费者线程。生产者线程函数的逻辑就是无限循环地把一个每次递增1的整数push进环形队列、消费者线程函数的逻辑就是无限循环地从环形队列中读出一个数据。

在创建线程完毕后,立刻对生产者和消费者线程进行线程等待(即调用pthread_join函数)从而防止内存泄漏(类似于防止僵尸进程造成的内存泄漏),立刻编写是为了防止后序遗忘了这个步骤。

testMain.cc文件的整体代码

结合上面思路,包含主函数的testMain.cc文件的整体代码如下。

(说一下,对于在testMain.cc文件中包含的RingQueue.h文件,咱们会在下文中详细讲解它的代码;还有一点需要注意,在Linux中,如果包含了C++的内容,一定要把文件后缀名命名成.cc,而不能是.c,否则编译会报错、甚至在写代码时就有红色波浪线提示说找不到iostream文件,即include<iostream>失败)

#include"RingQueue.h"

//消费者线程函数
void* consumer(void *args)
{
    RingQueue<int> *rq = (RingQueue<int>*)args;
    int x;//输出型参数
    while(1)
    {
        //1、从环形队列中获取数据(或者任务)
        rq->pop(&x);
        /*
            2、在中途进行一定的处理 ————  一般来说这
            个处理的过程才是最耗费时间的,而访问环
            形队列、从中获取数据(或者任务)是不
            会有很大的时间消耗的。
        */
        cout<<"消费者线程消费了数据:"<<x<<endl;
    }
    return nullptr;
}

//生产者线程函数
void* productor(void *args)
{
    RingQueue<int> *rq = (RingQueue<int>*)args;

    /*
        1、构建数据或者任务对象 ———— 一般是从外部来,
        注意这个构建的过程才是最耗费时间的,而访问环
        形队列、往其中放置数据(或者任务)是不会有
        很大的时间消耗的。
    */

    int x=0;//在当前情景中,这个int x就是咱们所构建的数据
    while(1)
    {
        //2、将数据(或者任务)放置进环形队列中
        cout<<"生产者线程生产了数据:"<<x<<endl;
        rq->push(x);
        x++;
    }
    return nullptr;
}

int main()
{
    RingQueue<int> *rq = new RingQueue<int>;
    pthread_t c,p; //线程ID,c表示consumer、p表示productor
    pthread_create(&c,nullptr,consumer,(void*)rq);
    pthread_create(&c,nullptr,productor,(void*)rq);

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

环形队列RingQueue类的编写思路如下: 

首先确定RingQueue类内的成员变量,如下:

  1. _ring_queue就不必解释了,作为环形队列的底层容器,其一定是需要存在的。
  2. _capacity,在当前的消费者生产者模型中,是不允许环形队列扩容的,环形队列满了就需要让生产者线程在_p_sem信号量下等待。
  3. _c_index,表示consumer_index,为vector中的下标,设置这个成员是为了记录消费者线程当前已经遍历到了vector(即环形队列)的哪个位置上,方便消费者线程下次消费数据时找到目标数据。
  4. _p_index,表示productor_index,为vector中的下标,设置这个成员是为了记录生产者线程当前已经遍历到了vector(即环形队列)的哪个位置上,方便生产者线程下次生产数据时找到目标空位置。
  5. _c_sem,表示consumer_sem,为分配给消费者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个数据可以让消费者消费,防止消费者在环形队列中没有数据时还继续消费。

  6. _p_sem,表示productor_sem,为分配给生产者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个空位置可以让生产者生产,防止生产者在环形队列中产满数据后还继续生产。

然后说下BlockingQueue类内的成员函数,如下:

1、(结合下图思考)默认构造需要说明的就是不要忘了信号量需要通过sem_init函数被初始化,最后一个参数表示给信号量的初始值。

为什么把分配给消费者线程的信号量初始化成0、把分配给生产者线程的信号量初始化成capacity呢?答案:消费者刚开始是没有数据可以消费的,所以信号量为0;在刚开始整个队列都是空,所有位置都可以让生产者生产,所以信号量的值应为队列的capacity的值。

2、(结合下图思考)析构函数需要把RingQueue类内的2个成员信号量_c_sem、_p_sem都通过下图的销毁函数销毁,释放相关的资源,以便系统可以回收这些资源并防止内存泄漏。

3、 生产者线程函数push和消费者线程函数pop,说一下,这里push和pop严格意义来讲并不能称为生产者线程函数和消费者线程函数,只是因为push函数在生产者线程函数productor中被调用了,所以把push函数也称为了生产者线程函数;pop被称为消费者线程函数的原因同理。push函数就是用于把push函数的参数传进环形队列(即vector)中,而pop函数就是用于把环形队列(即vector)的某个下标上的元素取出并拿到,关于push和pop函数剩下的实现思路都在注释中,详情请见下文中的代码。

RingQueue.h文件的整体代码

结合上面理论,RingQueue.h的整体代码如下。

#include<iostream>
using namespace std;
#include<vector>
#include<pthread.h>//提供线程以及相关接口的库
#include<semaphore.h>//提供POSIX信号量及相关接口的库

template<class T>
class RingQueue
{
public:
    RingQueue(size_t capacity = 8)
        :_capacity(capacity)//默认让环形队列的容量上限为8
        ,_ring_queue(capacity,T())//直接给vector、即环形队列开8个T类型的空间
        ,_c_index(0)
        ,_p_index(0)
    {
        sem_init(&_c_sem,0,0);//中间的参数为0,表示这是线程间共享(即表示当前信号量用于控制线程间的同步),而不是进程;最后的参数是0,这是给信号量的初始值,消费者刚开始是没有数据可以消费的,所以信号量为0
        sem_init(&_p_sem,0,capacity);//中间的参数为0,表示这是线程间共享(即表示当前信号量用于控制线程间的同步),而不是进程;最后的参数是capacity,这是给信号量的初始值,在刚开始整个队列都是空,所有位置都可以让生产者生产,所以信号量的值应为队列的capacity的值
    }

    ~RingQueue()
    {
        sem_destroy(&_c_sem);//销毁信号量对象
        sem_destroy(&_p_sem);//销毁信号量对象
    }

    //生产者线程函数
    void push(const T& x)
    {
        //1、首先申请信号量让分配给生产者线程的信号量++,申请成功才能继续向下执行代码;申请失败则陷入阻塞。说一下,当下标_p_index和下标_c_index相等时,即生产者线程和消费者线程
        //访问到了同一个下标上的元素,根据文中说法,此时环形队列一定为空或者为满,那么消费者线程函数和生产者线程函数中,一定有一方在调用sem_wait申请信号量时被阻塞,所以不必担心
        //双方同时访问这个下标上的元素;当下标_p_index和下标_c_index不相等时,即生产者线程和消费者线程没有访问到同一个下标上的元素,那么此时环形队列一定不可能为空、也不可能为满,
        //那么生产者线程在调用sem_wait函数申请信号量(这里的信号量表示空间资源)时就会成功、消费者线程在调用sem_wait函数申请信号量(这里的信号量表示数据资源)时也会成功,申请成
        //功后就可以在环形队列中放置或者取出数据了,就成功的达到了生产者线程和消费者线程并发运行的目的。
        sem_wait(&_p_sem);

        //2、从下标_p_index开始,往后挨个往vector中放数据
        _ring_queue[_p_index] = x;
        _p_index++;

        //3、如果下标_p_index等于了容量上限,则让下标_p_index变成0,重新遍历整个vector或者说环形队列(vector是环形队列的底层容器,所以vector就是环形队列)
        _p_index %= _capacity;

        //4、在文章中的预备知识点中说过,对于生产者线程来说,我申请信号量成功后,就代表我已经预订了一个空间资源或者说空位置让我去生产,那对于消费者线程来说,
        //也就多了一个数据资源让它去消费,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我消费者可以申请的数据资源多了一个,那分配给我消费者线程的
        //信号量对象的值当然也应该++,所以需要调用释放信号量的函数让分配给消费者线程的信号量++。
        sem_post(&_c_sem);
    }

    //消费者线程函数
    void pop(T* x)
    {
        //1、首先申请信号量让分配给消费者线程的信号量++,申请成功才能继续向下执行代码;申请失败则陷入阻塞。说一下,当下标_p_index和下标_c_index相等时,即生产者线程和消费者线程
        //访问到了同一个下标上的元素,根据文中说法,此时环形队列一定为空或者为满,那么消费者线程函数和生产者线程函数中,一定有一方在调用sem_wait申请信号量时被阻塞,所以不必担心
        //双方同时访问这个下标上的元素;当下标_p_index和下标_c_index不相等时,即生产者线程和消费者线程没有访问到同一个下标上的元素,那么此时环形队列一定不可能为空、也不可能为满,
        //那么生产者线程在调用sem_wait函数申请信号量(这里的信号量表示空间资源)时就会成功、消费者线程在调用sem_wait函数申请信号量(这里的信号量表示数据资源)时也会成功,申请成
        //功后就可以在环形队列中放置或者取出数据了,就成功的达到了生产者线程和消费者线程并发运行的目的。
        sem_wait(&_c_sem);

        //2、从下标_c_index开始,往后挨个从vector中获取数据
        *x = _ring_queue[_c_index];
        _c_index++;

        //3、如果下标_c_index等于了容量上限,则让下标_c_index变成0,重新遍历整个vector或者说环形队列(vector是环形队列的底层容器,所以vector就是环形队列)
        _c_index %= _capacity;

        //4、在文章中的预备知识点中说过,对于消费者线程来说,我申请信号量成功后,就代表我已经预订了一个数据资源或者说非空位置让我去消费,那对于生产者线程来说,
        //也就多了一个空间资源或者说空位置让它去生产,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我生产者可以申请的空间资源多了一个,那分配给我生
        //产者线程的信号量对象的值当然也应该++,所以需要调用释放信号量的函数让分配给生产者线程的信号量++。
        sem_post(&_p_sem);

    }

    //这个函数不必理会,这是方便笔者调试代码而设置的一个函数
    void debug()
    {
        cerr<<"vector.size:"<<_ring_queue.size()<<"ring_queue._capacity:"<<_capacity<<endl;
    }
private:
    vector<T> _ring_queue;//vector是环形队列的底层容器
    size_t _capacity;//_capacity是环形队列的容量上限,环形队列是不能扩容的
    size_t _c_index;//表示consumer_index,为vector中的下标,设置这个成员是为了记录消费者线程当前已经遍历到了vector(即环形队列)的哪个位置上
    size_t _p_index;//表示productor_index,为vector中的下标,设置这个成员是为了记录生产者线程当前已经遍历到了vector(即环形队列)的哪个位置上
    sem_t _c_sem;//表示consumer_sem,为分配给消费者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个数据可以让消费者消费,防止消费者在环形队列中没有数据时还继续消费
    sem_t _p_sem;//表示productor_sem,为分配给生产者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个空位置可以让生产者生产,防止生产者在环形队列中产满数据后还继续生产
};

测试以单生产者线程、单消费者线程为例模拟实现的【生产线程和消费线程并发运行的生产者消费者模型】

将上面模拟实现部分的testMain.cc文件编译并运行后,得到的结果如下图。说一下,生产和消费数据是从整形1开始的,下图只是运行结果的一小个片段,从45121开始只是因为CPU运行的太快了,一下就刷到了这么多。在CPU分给生产者线程和消费者线程的时间片中,在这个时间片内已经足够让生产者在环形队列中产满数据(代码中设置的容量上限为8,所以产满后就是有8个数据);环形队列被产满后,分给消费者的时间片也足够让消费者线程把环形队列中已经产满的数据全部消费完,所以生产和消费的顺序如下图,是连续生产8个后,再连续消费8个。

如果我想步调一致,即生产一个就立马消费一个,可以通过sleep函数让生产者生产的速度和消费者消费的速度都慢下来但速度相同(注意双方sleep的时间一定是相等的)。只需要在上文的testMain.cc的代码的基础上(下图1就是testMain.c的代码),加上如下图1的两个红框处的代码,这样即可完成步调一致,让生产者每秒生产1个数据、消费者每秒消费1个数据,运行结果如下图2。

图1如下。

图2如下,下图只是运行结果中的一小段片段。

如果我不想步调一致,比如想让生产者生产的速度比消费者消费的速度要快,只需要在上文的testMain.cc的代码的基础上(下图1就是testMain.cc的代码),加上如下图1的红框处的代码即可完成让生产者生产的速度比消费者消费的速度要快,运行结果如下图2;再比如想让生产者生产的速度比消费者消费的速度要慢,只需要在上文的testMain.cc的代码的基础上(下图3就是testMain.cc的代码),加上如下图3的红框处的代码即可完成让生产者生产的速度比消费者消费的速度要慢,运行结果如下图4。

图1如下。

图2如下,下图只是运行结果中的一小段片段。可以看到,因为生产者线程没有sleep,所以一下子就把环形队列给产满了,后序消费者慢悠悠的消费数据,每秒只消费1个,然后消费完毕后生产者又立马重新把环形队列产满,后序轮询【消费者每秒消费1个数据后、生产者又立马把环形队列产满】这样的操作,这是符合我们的预期的。

图3如下。 

图4如下,下图只是运行结果中的一小段片段。可以看到,因为消费者线程没有sleep,而生产者线程sleep了、在慢悠悠的每秒生产1个数据,所以生产者线程每生产1个数据,消费者线程立马就能把这1个数据给消费完,这是符合我们的预期的。

通过目前版本的单生产单消费模型进一步说明为什么【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】

(建议先阅读完上面的以单生产者线程、单消费者线程为例进行的【生产线程和消费线程并发运行的生产者消费者模型】的模拟实现后再阅读这里)

模拟实现完上面的单生产者单消费者模型后,可以发现对于单生产者单消费者模型来说,不管是分配给生产者的信号量还是分配给消费者的信号量,这些信号量都要么为0、要么不为0。现在进程中总共就两个线程,即一个生产者线程和一个消费者线程,此时有两种情况:

情况1、如果分配给生产者线程的信号量不为0,则分配给消费者线程的信号量也不可能为0(换过来也一样,即如果分配给消费者线程的信号量不为0,则分配给生产者线程的信号量也不可能为0),则说明生产者线程和消费者线程此时没有指向同一个位置,环形队列中既不为空,也不为满。既然此时生产者和消费者指向的位置不同,即任意一方都不需要和另一方去竞争,那么只要我线程申请信号量成功,就一定能成功地访问临界区完成任务,所以才说【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】

情况2、如果分配给某个线程的信号量为0,因为同一时间一定只可能有一个线程的信号量为0,并且只要为0,则说明生产者线程和消费者线程此时指向同一个位置,环形队列中要么为空,要么为满。

  • 比如可能是分配给生产者线程的信号量是0、生产者关注的空间资源是0个、环形队列为满的情况,那么此时生产者线程申请信号量就会失败并陷入阻塞,而消费者关注的数据资源则是满的、不为0,那么分配给消费者线程的信号量就不为0,此时消费者线程申请信号量就会成功并继续向下执行代码,生产者线程就没资格和消费者线程去竞争环形队列中的【生产者和消费者共同指向的一个位置上的资源】了,所以才说【如果申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】。(说一下,消费者线程完成消费任务后,需要在消费者线程函数中调用信号量的释放函数让分配给生产者线程的信号量++以此唤醒生产者线程,此时就会从当前的情况2变回上文中的情况1)。
  • 再比如可能是分配给消费者线程的信号量是0、消费者关注的数据资源是0个、环形队列为空的情况,那么此时消费者线程申请信号量就会失败并陷入阻塞,而生产者关注的空间资源则是满的、不为0,那么分配给生产者线程的信号量就不为0,此时生产者线程申请信号量就会成功并继续向下执行代码,消费者线程就没资格和生产者线程去竞争环形队列中的【生产者和消费者共同指向的一个位置上的资源】了,所以才说【如果申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】。(说一下,生产者线程完成生产任务后,需要在生产者线程函数中调用信号量的释放函数让分配给消费者线程的信号量++以此唤醒消费者线程,此时就会从当前的情况2变回上文中的情况1)。

所以综上所述可以发现,不管分配给某线程的信号量属于什么情况,只要该线程申请信号量成功,就一定能成功地访问临界区完成任务。

走到这里,想要再次进一步理解为什么【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】,则需要在讲解完以多生产者线程、多消费者线程为例进行【生产线程和消费线程并发运行的生产者消费者模型】的模拟实现后,所以在下文中还会把这个问题拿来说的。

以多生产者线程、多消费者线程为例进行【生产线程和消费线程并发运行的生产者消费者模型】的模拟实现

注意单生产者单消费者模型是只有一个生产者和一个消费者,并且通过POSIX信号量可以维护好生产者和消费者的互斥与同步关系,所以不需要锁和条件变量。而当有多个生产线程并且还有多个消费线程时,就必须给生产线程之间分配一把锁,然后还得给消费线程之间分配一把锁,因为对于多个生产者线程而言,是不能有多个生产者线程同时进入环形队列中放置数据的,否则可能会造成数据紊乱(数据紊乱的原因:从代码层面上说就是如果没有锁,申请信号量成功的若干生产线程就都会进入环形队列放置数据,在哪放置取决于_p_index这个表示环形队列(即vector)的下标是几,这个下标是所有生产者线程共用的,所以所有生产者线程都期望在这个下标的位置上放置数据,那这个下标指向的位置也就是临界资源,多个线程不加锁的情况下同时访问临界资源也就会造成临界资源的数据紊乱);而对于多个消费者线程而言,也是不能有多个消费者线程同时进入环形队列中获取数据的,否则也可能会造成数据紊乱(理由同上)

所以我们需要在上文以单生产者线程、单消费者线程为例模拟实现的【生产线程和消费线程并发运行的生产者消费者模型】的整体代码的基础上,再往代码中多加上几个生产者线程和消费者线程,然后再加上2把锁以及相关加锁和解锁的逻辑:

  • 于是代码经过修改后,如下图是testMain.cc文件中一些函数的修改前和修改后的对比图。说一下,靠左边的是修改前的代码,靠右边的是修改后的代码。在consumer和productor函数中都有一行cout的代码被红框框住了,表示在这两个函数中,只有红框处的代码被修改了,其他位置没有被修改。
  • 于是代码经过修改后,RingQueue类的成员变量如下图,红框处代码是新增的代码。
  • 于是代码经过修改后,RingQueue类的构造函数如下图,红框处代码是新增的代码。
  • 于是代码经过修改后,RingQueue类的析构函数如下图,红框处代码是新增的代码。
  • 于是代码经过修改后,RingQueue类的push函数如下图,红框处代码是新增的代码。至于为什么是先申请信号量后加锁,会专门在下文中说明。
  • 注意生产者线程【调用信号量的释放函数让分配给消费者线程的信号量++以此唤醒消费者线程】和【解掉分配给生产者线程的锁】的顺序是无所谓先后的。如果先解锁然后再释放信号量,则其优势是能能避免生产者在释放信号量唤醒消费者线程时失败从而抱着锁陷入阻塞,但只要代码的逻辑是正常的,几乎不可能出现这种情况,因为释放信号量失败意味着信号量的值到了系统设置的上限,上限是一个很大的值,只要生产者消费者模型的大逻辑没有问题,信号量的值是不可能达到上限的,所以释放信号量几乎不可能失败,所以这个优势几乎可以忽略;而如果生产者线程先释放信号量,再解锁,那在理论上就能更快的唤醒消费者线程,提高效率,这是它的优势,但提升也微乎其微,所以这个优势也几乎可以忽略。所以之前才说生产者线程【调用信号量的释放函数让分配给消费者线程的信号量++以此唤醒消费者线程】和【解掉分配给生产者线程的锁】的顺序是无所谓先后的。
  • 于是代码经过修改后,RingQueue类的pop函数如下图,红框处代码是新增的代码。至于为什么是先申请信号量后加锁,会专门在下文中说明。注意消费者线程调用信号量的释放函数唤醒生产者线程和解锁的顺序是无所谓先后的。
  • 注意消费者线程【调用信号量的释放函数让分配给生产者线程的信号量++以此唤醒生产者线程】和【解掉分配给消费者线程的锁】的顺序是无所谓先后的。如果先解锁然后再释放信号量,则其优势是能避免消费者在释放信号量唤醒生产者线程时失败从而抱着锁陷入阻塞,但只要代码的逻辑是正常的,几乎不可能出现这种情况,因为释放信号量失败意味着信号量的值到了系统设置的上限,上限是一个很大的值,只要生产者消费者模型的大逻辑没有问题,信号量的值是不可能达到上限的,所以释放信号量几乎不可能失败,所以这个优势几乎可以忽略;而如果消费者线程先释放信号量,再解锁,那在理论上就能更快的唤醒生产者线程,提高效率,这是它的优势,但提升也微乎其微,所以这个优势也几乎可以忽略。所以之前才说消费者线程【调用信号量的释放函数让分配给生产者线程的信号量++以此唤醒生产者线程】和【解掉分配给消费者线程的锁】的顺序是无所谓先后的。

testMain.cc文件的整体代码

结合上面思路,包含主函数的testMain.cc文件的整体代码如下。

#include"RingQueue.h"
#include <unistd.h>

//消费者线程函数
void* consumer(void *args)
{
    RingQueue<int> *rq = (RingQueue<int>*)args;
    int x;//输出型参数
    while(1)
    {
        //1、从环形队列中获取数据(或者任务)
        rq->pop(&x);
        /*
            2、在中途进行一定的处理 ————  一般来说这
            个处理的过程才是最耗费时间的,而访问环
            形队列、从中获取数据(或者任务)是不
            会有很大的时间消耗的。
        */
        cout<<"[ "<<pthread_self()<<" ]"<<"消费者线程消费了数据:"<<x<<endl;
    }
    return nullptr;
}

//生产者线程函数
void* productor(void *args)
{
    RingQueue<int> *rq = (RingQueue<int>*)args;

    /*
        1、构建数据或者任务对象 ———— 一般是从外部来,
        注意这个构建的过程才是最耗费时间的,而访问环
        形队列、往其中放置数据(或者任务)是不会有
        很大的时间消耗的。
    */
   int x=0;//在当前情景中,这个int x就是咱们所构建的数据
    
    while(1)
    {
        //2、将数据(或者任务)放置进环形队列中
        cout<<"[ "<<pthread_self()<<" ]"<<"生产者线程生产了数据:"<<x<<endl;
        rq->push(x);
        x++;
    }
    return nullptr;
}

int main()
{
    RingQueue<int> *rq = new RingQueue<int>;
    pthread_t c[3],p[2]; //线程ID,c表示consumer、p表示productor
    pthread_create(c,nullptr,consumer,(void*)rq);
    pthread_create(c+1,nullptr,productor,(void*)rq);
    pthread_create(c+2,nullptr,productor,(void*)rq);
    pthread_create(p,nullptr,productor,(void*)rq);
    pthread_create(p+1,nullptr,productor,(void*)rq);
    //写法1
    // pthread_join(c[0],nullptr); 
    // pthread_join(c[1],nullptr); 
    // pthread_join(c[2],nullptr); 
    //写法2
    for(int i=0;i<3;i++) pthread_join(c[i],nullptr); 

    pthread_join(p[0],nullptr);
    pthread_join(p[1],nullptr);
    return 0;
}

RingQueue.h文件的整体代码

结合上面思路,RingQueue.h的整体代码如下。

#include<iostream>
using namespace std;
#include<vector>
#include<pthread.h>//提供线程以及相关接口的库
#include<semaphore.h>//提供POSIX信号量及相关接口的库

template<class T>
class RingQueue
{
public:
    RingQueue(size_t capacity = 8)
        :_capacity(capacity)//默认让环形队列的容量上限为8
        ,_ring_queue(capacity,T())//直接给vector、即环形队列开8个T类型的空间
        ,_c_index(0)
        ,_p_index(0)
    {
        sem_init(&_c_sem,0,0);//中间的参数为0,表示这是线程间共享(即表示当前信号量用于控制线程间的同步),而不是进程;最后的参数是0,这是给信号量的初始值,消费者刚开始是没有数据可以消费的,所以信号量为0
        sem_init(&_p_sem,0,capacity);//中间的参数为0,表示这是线程间共享(即表示当前信号量用于控制线程间的同步),而不是进程;最后的参数是capacity,这是给信号量的初始值,在刚开始整个队列都是空,所有位置都可以让生产者生产,所以信号量的值应为队列的capacity的值
        pthread_mutex_init(&_c_lock,nullptr);//初始化互斥锁,该锁是分配给多个消费者线程之间的
        pthread_mutex_init(&_p_lock,nullptr);//初始化互斥锁,该锁是分配给多个生产者线程之间的
    }

    ~RingQueue()
    {
        sem_destroy(&_c_sem);//销毁信号量对象
        sem_destroy(&_p_sem);//销毁信号量对象
        pthread_mutex_destroy(&_c_lock);//销毁互斥锁
        pthread_mutex_destroy(&_p_lock);//销毁互斥锁
    }

    //生产者线程函数
    void push(const T& x)
    {
        //1、首先申请信号量让分配给生产者线程的信号量++,申请成功才能继续向下执行代码;申请失败则陷入阻塞。说一下,当下标_p_index和下标_c_index相等时,即生产者线程和消费者线程
        //访问到了同一个下标上的元素,根据文中说法,此时环形队列一定为空或者为满,那么消费者线程函数和生产者线程函数中,一定有一方在调用sem_wait申请信号量时被阻塞,所以不必担心
        //双方同时访问这个下标上的元素;当下标_p_index和下标_c_index不相等时,即生产者线程和消费者线程没有访问到同一个下标上的元素,那么此时环形队列一定不可能为空、也不可能为满,
        //那么生产者线程在调用sem_wait函数申请信号量(这里的信号量表示空间资源)时就会成功、消费者线程在调用sem_wait函数申请信号量(这里的信号量表示数据资源)时也会成功,申请成
        //功后就可以在环形队列中放置或者取出数据了,就成功的达到了生产者线程和消费者线程并发运行的目的。
        sem_wait(&_p_sem);

        //在多个生产者线程中,每次只能有1个生产者线程进入环形队列放置数据,所以需要加锁;但注意与此同时可能有1个消费者线程也进入了环形队列、在和这个进入了环形队列的生产者线程并发的运行。
        pthread_mutex_lock(&_p_lock);

        //2、从下标_p_index开始,往后挨个往vector中放数据
        _ring_queue[_p_index] = x;
        _p_index++;

        //3、如果下标_p_index等于了容量上限,则让下标_p_index变成0,重新遍历整个vector或者说环形队列(vector是环形队列的底层容器,所以vector就是环形队列)
        _p_index %= _capacity;

        pthread_mutex_unlock(&_p_lock);

        //4、在文章中的预备知识点中说过,对于生产者线程来说,我申请信号量成功后,就代表我已经预订了一个空间资源或者说空位置让我去生产,那对于消费者线程来说,
        //也就多了一个数据资源让它去消费,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我消费者可以申请的数据资源多了一个,那分配给我消费者线程的
        //信号量对象的值当然也应该++,所以需要调用释放信号量的函数让分配给消费者线程的信号量++。
        sem_post(&_c_sem);
    }

    //消费者线程函数
    void pop(T* x)
    {
        //1、首先申请信号量让分配给消费者线程的信号量++,申请成功才能继续向下执行代码;申请失败则陷入阻塞。说一下,当下标_p_index和下标_c_index相等时,即生产者线程和消费者线程
        //访问到了同一个下标上的元素,根据文中说法,此时环形队列一定为空或者为满,那么消费者线程函数和生产者线程函数中,一定有一方在调用sem_wait申请信号量时被阻塞,所以不必担心
        //双方同时访问这个下标上的元素;当下标_p_index和下标_c_index不相等时,即生产者线程和消费者线程没有访问到同一个下标上的元素,那么此时环形队列一定不可能为空、也不可能为满,
        //那么生产者线程在调用sem_wait函数申请信号量(这里的信号量表示空间资源)时就会成功、消费者线程在调用sem_wait函数申请信号量(这里的信号量表示数据资源)时也会成功,申请成
        //功后就可以在环形队列中放置或者取出数据了,就成功的达到了生产者线程和消费者线程并发运行的目的。
        sem_wait(&_c_sem);

         //在多个消费者线程中,每次只能有1个消费者线程进入环形队列获取数据,所以需要加锁;但注意与此同时可能有1个生产者线程也进入了环形队列、在和这个进入了环形队列的消费者线程并发的运行。
        pthread_mutex_lock(&_c_lock);

        //2、从下标_c_index开始,往后挨个从vector中获取数据
        *x = _ring_queue[_c_index];
        _c_index++;

        //3、如果下标_c_index等于了容量上限,则让下标_c_index变成0,重新遍历整个vector或者说环形队列(vector是环形队列的底层容器,所以vector就是环形队列)
        _c_index %= _capacity;

        pthread_mutex_unlock(&_c_lock);

        //4、在文章中的预备知识点中说过,对于消费者线程来说,我申请信号量成功后,就代表我已经预订了一个数据资源或者说非空位置让我去消费,那对于生产者线程来说,
        //也就多了一个空间资源或者说空位置让它去生产,因为信号量是用于计数我当前可以申请到的资源的总数的,现在我生产者可以申请的空间资源多了一个,那分配给我生
        //产者线程的信号量对象的值当然也应该++,所以需要调用释放信号量的函数让分配给生产者线程的信号量++。
        sem_post(&_p_sem);
    }

    //这个函数不必理会,这是方便笔者调试代码而设置的一个函数
    void debug()
    {
        cerr<<"vector.size:"<<_ring_queue.size()<<"ring_queue._capacity:"<<_capacity<<endl;
    }
private:
    vector<T> _ring_queue;//vector是环形队列的底层容器
    size_t _capacity;//_capacity是环形队列的容量上限,环形队列是不能扩容的
    size_t _c_index;//表示consumer_index,为vector中的下标,设置这个成员是为了记录消费者线程当前已经遍历到了vector(即环形队列)的哪个位置上
    size_t _p_index;//表示productor_index,为vector中的下标,设置这个成员是为了记录生产者线程当前已经遍历到了vector(即环形队列)的哪个位置上
    sem_t _c_sem;//表示consumer_sem,为分配给消费者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个数据可以让消费者消费,防止消费者在环形队列中没有数据时还继续消费
    sem_t _p_sem;//表示productor_sem,为分配给生产者线程的信号量,信号量本质是个计数器,在这里就是计数环形队列中还有多少个空位置可以让生产者生产,防止生产者在环形队列中产满数据后还继续生产
    pthread_mutex_t _c_lock;//分配给消费者线程们的锁
    pthread_mutex_t _p_lock;//分配给消费者线程们的锁
};

加锁和申请信号量的顺序

问题:那在进程中有多个生产线程并且还有多个消费线程的情景中,不同生产线程访问环形队列时是先加锁还是先申请信号量呢?不同消费线程访问环形队列时是先加锁还是先申请信号量呢?

答案如下:

  • 生产线程中是先申请信号量然后加锁。假设我们是先加锁再申请信号量,那么每次就只有一个竞争锁成功的生产线程可以申请信号量,此时若申请信号量失败了,比如信号量为0、生产者线程所需的空间资源(即可以让生产者生产数据的空位置)已经没有了 ,此时就会申请信号量失败。申请失败的生产线程会被挂起,注意这个线程是抱着锁挂起的,那么在等到某个消费线程【调用信号量的申请函数将分配给消费者线程们的信号量--、完成消费动作、并调用信号量的释放函数将分配给生产线程们的信号量++以此将持有锁且陷入了阻塞的生产者线程唤醒】之前,所有的生产者线程都无法使用分配给生产者线程们的锁,也就无法生产(即无法往环形队列中放置数据)了,当然这个并不影响效率(因为当生产线程申请信号量失败,则说明没有空间资源了,此时即使顺序是先申请信号量后申请锁,其他线程也会阻塞在申请信号量的函数处,导致所有生产者线程都无法生产),真正导致降低程序效率的是:先申请锁后申请信号量会导致在空间资源很多时,多个生产者线程不能并发地申请信号量,而是串行化地申请,等到一个生产者线程完成整个生产流程后下一个抢到锁的生产者线程才能申请信号量。
  • 消费者线程同理,消费者线程也是先申请信号量然后加锁,因为假设我们是先加锁再申请信号量,那么每次就只有一个竞争锁成功的消费者线程可以申请信号量,此时若申请信号量失败了,比如信号量(本质就是计数器)为0、消费者线程所需的数据资源已经没有了 ,此时申请信号量就会失败,那么该消费线程就会被挂起,它也是抱着锁挂起的,那么在等到某个生产线程【调用信号量的申请函数将分配给生产者线程们的信号量--、完成生产动作、并调用信号量的释放函数将分配给消费者线程们的信号量++以此将持有锁且陷入了阻塞的消费者线程唤醒】之前,所有的消费者线程都无法使用分配给消费者线程们的锁,所有的消费线程也就无法消费了,当然这个并不影响效率(因为当消费者线程申请信号量失败,则说明没有数据资源了,此时即使顺序是先申请信号量后申请锁,其他线程也会阻塞在申请信号量的函数处,导致所有消费者线程都无法消费),真正导致降低程序效率的是:先申请锁后申请信号量会导致在数据资源很多时,多个消费者线程不能并发地申请信号量,而是串行化地申请,等到一个消费者线程完成整个消费流程后下一个抢到锁的消费者线程才能申请信号量。
  • 而如果选择正确的方式(即先申请信号量、后加锁),对于多个生产线程来说,所有生产线程都可以并发地申请分配给生产线程们的信号量,申请信号量成功的若干生产线程就再去竞争分配给生产者线程们锁,而申请信号量失败的若干生产者线程就都阻塞在信号量的申请函数中,没资格参与锁的竞争以及后序的环形队列的访问。所以经过信号量这个裁判的筛选,最后就只剩下了申请信号量成功的若干生产线程可以竞争锁,最后由唯一一个竞争锁成功的生产线程访问环形队列。该生产线程访问完毕并释放锁后,剩下的若干个申请信号量成功的生产者线程再去竞争锁以访问环形队列。可以发现,对比之前先加锁、后申请信号量的错误方式,这里正确的方式是少了一道申请信号量的步骤的,因为在一开始各个生产线程就并发地申请了信号量,(下文中会说明申请和释放信号量是原子操作,多个线程是可以并发的申请信号量的),后序只需要竞争锁即可,而先加锁的错误方式,在一开始就不能让多个线程并发地申请信号量,所以后序的线程就还得多一步申请信号量的步骤,造成了额外的消耗,降低了程序的效率。
  • 对于多个消费线程来说同理,所有消费线程都可以并发地申请分配给消费线程们的信号量,申请信号量成功的若干消费线程就再去竞争分配给消费者线程们锁,而申请信号量失败的若干消费者线程就都阻塞在信号量的申请函数中,没资格参与锁的竞争以及后序的环形队列的访问。所以经过信号量这个裁判的筛选,最后就只剩下了申请信号量成功的若干消费线程可以竞争锁,最后由唯一一个竞争锁成功的消费线程访问环形队列。该消费线程访问完毕并释放锁后,剩下的若干个申请信号量成功的消费者线程再去竞争锁以访问环形队列。可以发现,对比之前先加锁、后申请信号量的错误方式,这里正确的方式是少了一道申请信号量的步骤的,因为在一开始各个消费线程就并发地申请了信号量,(下文中会说明申请和释放信号量是原子操作,多个线程是可以并发的申请信号量的),后序只需要竞争锁即可,而先加锁的错误方式,在一开始就不能让多个消费线程并发地申请信号量,所以后序的消费线程就还得多一步申请信号量的步骤,造成了额外的消耗,降低了程序的效率。
  • 所以既然先加锁、后申请信号量会降低程序的效率,我们就选择先申请信号量、后加锁。

有一个形象的例子可以解释上面的答案,环境为:我们去电影院买票时,电影院的门比较小,一次只能进去一个人。那么此时有两种方案,第一种:先一个人进去,等第一个人买完票后,再进去第二个人,等第二个人买完票后,再进去第三个人。。。第二种:所有人都先买好票,然后第一个人进去,然后第二个人进去。。。先竞争锁后申请信号量就对应第一种方案,而先申请信号量后竞争锁就对应第二种方案,很明显第二种方案的效率要比第一种方案高得多。

有人可能会说【既然申请和释放信号量都是在加锁函数之前,即在临界区外,那么多个线程就可以并发地申请或者释放信号量,那假如还剩最后一个信号量,线程A在申请该信号量的中途(即还没申请完毕,只申请了一半)就被OS调度走了,切换成了线程B,然后线程B申请信号量成功了,那线程A继续申请岂不是造成了数据紊乱?】这里我想说的是:不用担心,申请和释放信号量这些PV操作都是原子的,要么不进行申请,要么申请成功,没有中间态,所以不存在某线程在申请信号量的中途被切换走的情况。

目前版本的多生产多消费模型的意义

问题:在刚刚模拟实现的多生产者多消费者模型中,该程序运行起来变成进程后,在进程中有多个生产线程和多个消费线程,但因为在多个生产线程之间加了锁,所以只有一个先申请了信号量然后获取到锁的生产线程可以访问环形队列;同样的,因为在多个消费线程之间加了锁,所以只有一个申请到信号量然后获取到锁的消费线程可以访问环形队列。也就是说在同一时间只有一个生产者线程能够往环形队列中放置数据、只有一个消费者线程能够从环形队列中拿数据,那这样貌似和单生产单消费的模型没有任何区别,那多生产多消费的意义在哪?

答案:在<<生产消费者模型的介绍以及其的模拟实现>>一文中已经说过了,不要肤浅地认为把数据放在临界区或者把数据从临界区中拿走就是在生产和消费,或者说不要肤浅地认为线程访问临界区资源就是在生产和消费。生产任务本身和拿到任务后处理才是最消耗时间的,把生产出的任务放到临界区和把临界区的任务拿走,即访问临界区资源反而是最简单的。虽然多生产多消费的场景下,在同一时间只有一个生产线程可以访问临界区,但若干生产线程在访问临界区前,即若干生产线程在生产任务时,是可以有多个生产线程并发的生产各自的任务的,只是任务生产完毕后,将任务送到临界区时,只有一个生产线程可以访问临界区,需要一个个进行排队访问。同理,虽然多生产多消费的场景下,只有一个消费线程可以访问临界区,需要一个个进行排队访问,但在访问完临界区后,即若干消费线程拿到临界区的任务后,是可以有多个消费线程并发的执行各自的任务的,这才是多生产多消费的价值。

在同样的情景下,如果是单生产者单消费者模型,假如消费者线程A在接到任务A后没有处理完,那么其他任务就会一直堆积在临界区中,如果堆积满了,生产者线程还会无法往临界区中放置任务,更重要的是这些堆积的任务没法很好地处理,只有等消费者线程A处理完任务A后,再一个个地处理临界区中剩下的任务,没有其他生产者线程帮助生产者线程A缓解一下压力,这就是单生产者线程的弊端。

通过目前版本的多生产多消费模型进一步说明为什么【如果某线程通过信号量的申请函数申请信号量成功,则能够保证未来一定让该线程成功地访问临界区完成任务】

(建议先阅读完上面的以多生产者线程、多消费者线程为例进行的【生产线程和消费线程并发运行的生产者消费者模型】的模拟实现后再阅读这里)

模拟实现完上面的多生产者多消费者模型后,可以发现对于多生产者多消费者模型来说,不管是分配给生产者消费们的信号量还是分配给消费者线程们的信号量,这些信号量都要么为0、要么不为0。现在进程中有多个生产者线程和多个消费者线程,此时有两种情况:

情况1、如果分配给生产者线程们的信号量不为0,则分配给消费者线程们的信号量也不可能为0(换过来也一样,即如果分配给消费者线程们的信号量不为0,则分配给生产者线程们的信号量也不可能为0),则说明生产者线程们和消费者线程们此时没有指向环形队列中的同一个位置,即环形队列中既不为空,也不为满。

  • 既然此时生产者们和消费者们指向的位置不同,即任意一方都不需要和另一方去竞争,那么只要生产者线程们申请信号量成功,然后申请信号量成功的一批生产者线程再去竞争分配给生产者线程们的锁,然后唯一的那个竞争到锁的线程就一定能成功地访问临界区完成任务。完成任务后再释放锁,然后其他申请信号量成功的生产者线程们就再次去竞争锁以及执行后序的操作。可以发现在当前环境下,一种类型的线程对于另一种类型的线程是没有产生任何影响的;而对于最初申请信号量成功了的同一种类型的不同线程对象,想要访问临界区就需要竞争锁,每次只有一个线程能竞争到锁,但不论如何,只要最初申请信号量成功,该线程是一定能竞争到锁的,只不过是先后罢了,既然每次只有一个线程能竞争到锁并访问临界区,那同一种类型的不同线程对象相互之间也不会产生任何影响,所以才说【如果某线程通过信号量的申请函数申请信号量成功,则够保证未来一定让该线程成功地访问临界区完成任务】。
  • 同理,既然此时生产者们和消费者们指向的位置不同,即任意一方都不需要和另一方去竞争,那么只要消费者线程们申请信号量成功,然后申请信号量成功的一批消费者线程再去竞争分配给消费者线程们的锁,然后唯一的那个竞争到锁的线程就一定能成功地访问临界区完成任务。完成任务后再释放锁,然后其他申请信号量成功的消费者线程们就再次去竞争锁以及执行后序的操作。可以发现在当前环境下,一种类型的线程对于另一种类型的线程是没有产生任何影响的;而对于最初申请信号量成功了的同一种类型的不同线程对象,想要访问临界区就需要竞争锁,每次只有一个线程能竞争到锁,但不论如何,只要最初申请信号量成功,该线程是一定能竞争到锁的,只不过是先后罢了,既然每次只有一个线程能竞争到锁并访问临界区,那同一种类型的不同线程对象相互之间也不会产生任何影响,所以才说【如果某线程通过信号量的申请函数申请信号量成功,能够保证未来一定让该线程成功地访问临界区完成任务】。

情况2、如果分配给生产者线程们的信号量为0,则分配给消费者线程们的信号量一定为满(换过来也一样,即如果分配给消费者线程们的信号量为0,则分配给生产者线程们的信号量一定为满)。说一下,因为同一时间一定只可能有一个线程的信号量为0,并且只要为0,则说明生产者线程和消费者线程此时指向同一个位置,环形队列中要么为空,要么为满。注意虽然此时生产者们和消费者们指向的位置相同,但任意一方都不需要和另一方去竞争:

  • 比如可能是分配给生产者线程们的信号量是0、生产者们关注的空间资源是0个、环形队列为满的情况,那么此时任意一个生产者线程申请信号量就会失败并陷入阻塞,而消费者们关注的数据资源则是满的、不为0,那么分配给消费者线程们的信号量就不为0,此时有一批消费者线程申请信号量就能成功,这需要所有消费者线程竞争分配给消费者线程们的信号量,该信号量会作为裁判从所有的消费者线程中选出一批竞争信号量成功的线程,然后这些竞争信号量成功的消费者线程再去竞争分配给消费者线程们的锁,最后选出唯一的一个竞争锁成功的消费者线程继续向下执行代码、访问临界区完成任务。可以发现在当前环境下,一种类型的线程对于另一种类型的线程是没有产生任何影响的,因为一定有一种类型的线程在最初申请信号量时全部失败从而一直被阻塞;而对于最初申请信号量成功了的同一种类型的不同线程对象,想要访问临界区就需要竞争锁,每次只有一个线程能竞争到锁,但不论如何,只要最初申请信号量成功,该线程是一定能竞争到锁的,只不过是先后罢了,既然每次只有一个线程能竞争到锁并访问临界区,那同一种类型的不同线程对象相互之间也不会产生任何影响,所以才说【如果某线程通过信号量的申请函数申请信号量成功,能够保证未来一定让该线程成功地访问临界区完成任务】。(说一下,这里第一个进入临界区的消费者线程完成消费任务后,需要在消费者线程函数中调用信号量的释放函数让分配给生产者线程们的信号量++以此唤醒某个生产者线程,此时就会从当前的情况2变回上文中的情况1)。
  • 再比如可能是分配给消费者线程的信号量是0、消费者关注的数据资源是0个、环形队列为空的情况,那么此时任意一个消费者线程申请信号量就会失败并陷入阻塞,而生产者们关注的空间资源则是满的、不为0,那么分配给生产者线程们的信号量就不为0,此时有一批生产者线程申请信号量就能成功,这需要所有生产者线程竞争分配给生产者线程们的信号量,该信号量会作为裁判从所有的生产者线程中选出一批竞争信号量成功的线程,然后这些竞争信号量成功的生产者线程再去竞争分配给生产者线程们的锁,最后选出唯一的一个竞争锁成功的生产者线程继续向下执行代码、访问临界区完成任务。可以发现在当前环境下,一种类型的线程对于另一种类型的线程是没有产生任何影响的,因为一定有一种类型的线程在最初申请信号量时全部失败从而一直被阻塞;而对于最初申请信号量成功了的同一种类型的不同线程对象,想要访问临界区就需要竞争锁,每次只有一个线程能竞争到锁,但不论如何,只要最初申请信号量成功,该线程是一定能竞争到锁的,只不过是先后罢了,既然每次只有一个线程能竞争到锁并访问临界区,那同一种类型的不同线程对象相互之间也不会产生任何影响,所以才说【如果某线程通过信号量的申请函数申请信号量成功,能够保证未来一定让该线程成功地访问临界区完成任务】。(说一下,这里第一个进入临界区的生产者线程完成生产任务后,需要在生产者线程函数中调用信号量的释放函数让分配给消费者线程们的信号量++以此唤醒某个消费者线程,此时就会从当前的情况2变回上文中的情况1)。

模拟实现【基于任务派发的生产线程和消费线程并发运行的多生产者多消费者模型】

这里笔者偷个懒,只说下思路:我们需要在上文中以多生产者线程、多消费者线程为例模拟实现的【生产线程和消费线程并发运行的生产者消费者模型】的整体代码的基础上进行修改(注意只需要进行修改,不要重头造轮子),如何修改呢?请参考 <<生产消费者模型的介绍以及其的模拟实现>> 一文中标题为【对基于计算任务的生产者消费者模型的模拟实现】的部分,这里的做法和那边是一模一样的。

我们说信号量是一个计数器,计数器(信号量)的意义是什么?信号量能平替条件变量吗?

信号量的意义是什么?

我们说访问临界资源前需要检测临界资源,而检测临界资源本身也是在访问临界资源,所以检测临界资源的代码也要在加锁函数和解锁函数之间。但需要检测临界资源的原因本质是我们不清楚临界资源的情况,如果我们引入了信号量,即计数器,那么在信号量被申请和释放的过程中,我们在加锁函数和解锁函数外部,即临界区外部就可以得知临界资源的情况,即不用进入临界区就可以得知临界区资源的情况,也就压根不需要条件变量的存在了。

  • 拿上文的生产者消费者模型举个例子,如果生产者线程申请分配给生产者线程的信号量成功,则代表生产者关注的空间资源一定是就绪的,在生产者线程函数中完成生产后,继续在生产者线程函数中调用信号量的释放函数释放分配给消费者的信号量,释放成功后,我们心里就清楚了此时消费者线程关注的数据资源至少有1个是就绪的(因为我刚刚才完成了一次数据的生产)。在这里就可以发现不论是生产者还是消费者,都没有进入临界区,但我们通过申请或者释放信号量依然可以知道临界区资源的情况)。

所以只要申请信号量成功,说明临界资源肯定是可以被访问的,所以如果引入了信号量,我们访问临界区前就不用再通过条件变量检测临界区,而可以直接用信号量检测临界区了。

综上所述,信号量或者说计数器的意义为:不用进入临界区就可以得知临界区资源的情况(因为如果临界资源不就序,在临界区外调用信号量申请函数就会失败,则当前线程会在临界区外的信号量的申请函数处阻塞,所以换句话说只要被阻塞了,那临界资源就是不就绪的,反之如果没有被阻塞,那就是就绪的),既然不用进入临界区就能得知其中资源的情况,那么也就不需要使用条件变量确保临界资源就绪了。

信号量能平替条件变量吗?

说一下,不要发现在上文的场景中信号量能平替条件变量,就觉得不论在什么场景下信号量都能平替条件变量,这是不对的。

信号量和条件变量虽然都用于多线程或多进程编程中的同步和互斥,但它们有不同的用途和适用场景,因此不能完全取代彼此。下面是它们之间的主要区别:

  • 信号量:信号量通常用于控制对一组资源的访问,它维护一个计数器,可以用于表示可用资源的数量。线程可以尝试获取或释放信号量,当信号量的计数器大于零时,获取操作会成功,否则线程可能会等待。信号量适用于控制资源的数量,如有限资源池。
  • 条件变量:条件变量用于线程之间的通信和等待特定条件的发生。它通常与锁一起使用,允许一个线程等待某个条件的发生,而其他线程可以在条件满足时通知等待线程。条件变量适用于需要等待特定条件的情况,如线程A等待线程B完成某个任务后才能继续执行。
  • 信号量通常用于控制资源的数量,而条件变量用于线程之间的通信和等待条件的发生。虽然在某些情况下可以使用信号量来模拟条件等待,但它们的主要目的和语义不同。条件变量更适合用于需要线程等待和通信的情况,而信号量更适合用于资源控制。因此,不能简单地将信号量替代条件变量,而是应根据具体问题的要求来选择使用哪种同步工具。在某些情况下,它们可能需要同时使用,以实现复杂的同步逻辑。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值