线程的互斥,生产消费者模型

在之前的学习种我们知道了在,多线程的情况下,对一个全局变量做++,并不是原子的。

并且在之前的学习中,我们学习到了给临界区加锁,在之前的代码中我们使用的是一个全局的锁。

并且为了保证锁能够保护临界区,所以这里首先就要保证申请锁是一个安全的行为。至于原理之后会说明。

局部锁的初始化

如果你定义的是一个局部的锁,要对锁进行初始化需要使用下面到的函数。

依旧使用destroy来销毁锁。

下面我们来修改一下我们上面写的代码,让其变成一个使用局部锁的代码。

代码如下:首先我们就要创建出一个局部的锁。

然后修改我们之前封装的线程库,将这个锁作为参数传递过去,

然后根据我们实现的Thread类,这个锁就会被传递到Getticket函数处。

最后各个线程在执行临界区的代码时就能保证一定是串行执行的了。

下面我们首先将这些加锁的代码去除了,看是否会复现上次我们遇到的问题(客票被卖到了负数)。

可以看到确实是存在的,下面我们将加锁的代码加上。

多线程导致的数据不一致问题就没有了

但是这样使用锁还是有一点麻烦了,这里我们使用智能指针的思想来完善一下我们的锁。

下面我们在一个新的hpp文件中定义第一个类

然后是我们的第二个类。LockGuard,至于为什么要叫做锁的守护者,写完代码我们就知道了。

然后根据我们新写的两个类,我们来修改一下我们写的代码.:

上面创建了一个LockGuard对象就相当于:

这里相当于在循环中构建了一个临时对象,因为构建临时对象所以每一次都会去调用LockGuard的构造函数去构建一把锁,而每一次循环结束这个临时对象都会自动调用析构函数,而这个对象的析构函数就会自动的调用解锁的代码。

所以这里无论是因为if条件满足导致新循环的开始,还是因为else代码的执行,导致循环结束最后因为析构函数的存在,解锁的代码都会被自动的调用。

这样也完成了加锁和解锁。

我们来运行一下代码,是否能达到加锁的效果呢?

依旧能够达到加锁的效果。

但是这样还有一个问题,如果我们的while循环中存在非临界区的代码,那么这些非临界区的代码也会被加上锁,那么要怎么做呢?这里我们可以增加一个{},来代表临界区。

这里的{}代表LockGuard这个对象只在,这一个{}内有效,出了{}LockGuard对象的生命周期结束,析构自动调用,进入这个{}LockGuard对象调用构造函数创建对象,自动加锁。

这样就能达成在给临界区加锁的同时不让非临界区也加上锁了。

但是上面写的代码还有一点不足,那就是我们不知道这是哪一个线程执行的代码,所以这里我们需要将每一个线程的名字,也传递过去,也就是说现在我们需要将锁和线程的名字一起传递过去。

然后我们再来修改一下我们的代码

最后修改一下Getticket函数:

这样哪一个线程得到了那一张票我们就能知道了。

因为这里我使用了new去创建了ThreadData对象所以最后需要使用delete来对堆上的对象进行销毁。

运行结果:

这样我们的每一个线程都能看到同一把锁同时,每一个线程都有自己独特的数据。

但是从我截图打印出来的信息我们就能看到一个异常点,那就是我截图的这一部分都是由thread1线程完成的。

机出现的下面的问题:

对于这个情况首先说明一下这是合理的并且也是允许的。因为现在我们的要求只是任何一个时刻只能有一个执行流进入到临界区中,并没有规定同一个线程不能一直抢。

而存在有的线程竞争锁的能力就是比较强,就能看到上面的情况,很多的票被同一个线程抢了。

也就是说在现在的这个代码的限制条件中,多个线程抢到的票不均匀/一个线程抢到大部分的票,都是很正常的事情。因为在这里我们并没有给抢票本身做一个限定。

对于这种多线程运行,同一资源,有的线程长时间无法拥有锁,所导致有的线程长时间无法得到资源的问题,我们称之为饥饿问题。

要解决这个问题,单纯靠互斥是无法解决的,

要解决饥饿问题,需要让我们的线程在执行的时候具有一定的顺序性。也就是要结合同步来解决。

线程加锁的本质

首先我们要知道,在实现互斥锁的时候。大多数的体系结构都提供了swap或exchange指令。这是一个纯软件的原子性,当然你要从硬件角度实现一个原子性,也是可以实现的。

首先一个线程为什么会被调度,本质是因为os会以非常快的速度来受理时钟中断。然后每隔很短的时间就会有硬件来给os触发时钟中断,然后os就会被执行,os执行就会调度进程,这里也就是时钟中断催促着os,来让os调度进程的。而要从硬件上实现一个原子性也是可以实现的。比如说将中断关闭。关闭之后os就只会执行我们的进程。在执行进程代码的时候;连os都不会被调度了。没有时钟了,此时在执行这个进程的时候,没有调度,此时就一定是原子的。当然这种做法比较硬核是由os内部去搞的。而我们讲解的互斥锁的实现原理比较简单,大部分的体系结构:比如x86,AMD,大部分的体系结构都会提供swap和exchange指令。

这两个指令的作用是什么呢?

如果我们自己去实现肯定是要从内存上重新开辟一段空间,然后将cpu中的数据放到这个空间中,之后将要交换数据的内存中的数据放到cpu中,再将新内存中的数据拷贝到需要交换数据的内存中。

但是使用这个指令只需要告诉os是哪一个寄存器,以及物理内存的地址,就能够完成交换了

因为这是一条汇编所以我们理解这里就是原子的。

然后我们再来理解什么叫做一把锁呢?

经过我们写的代码我们知道我们定义的一把锁其实本质也就是一个变量。那么对于这个锁我们应该怎么理解呢?

我们可以简单理解一下:

只不过为了保证这个锁的访问安全,我们需要增加一些东西。

下面我们通过一段伪代码来理解一下加锁:

我们说过pthread_lock();这个函数必须是原子的。

而这个函数底层的做法就是下面的这一段伪代码。

看起来这一段代码很多,那么真的能够保证原子吗?答案是可以的。

首先我们要知道所谓的互斥锁其实就是定义出来的一个变量(其实是一个结构体,有pthread库维护)。这里我们就当成一个整数来表示这就是我们传说中的一把锁,这把锁能够申请我们称之为1,不能称之为0,默认情况下这把锁被设置为1。代表这把锁可以被申请。

我们通过画图来表示:

然后我们的第一个线程来申请了,这个线程会将0值写到cpu的寄存器中,

我们这里不谈多cpu的情况。

寄存器硬件在cpu内部只有一套,但是寄存器的内容不只一套。

现在这个线程将0写到了寄存器中,假设这个线程被切换走了,这个寄存器中的内容也会被线程一起带走。也就是说这一步操作,不会对其它的线程产生任何的影响。

而寄存器中的数据我们称之为线程的上下文。

所以每一个线程都有一份,属于自己的上下文。所以写0到寄存器不会对任何其它的线程产生映影响。

然后就是exchange指令,会对寄存器中的内容和内存中的内容进行交换。

交换后寄存器中的内容就变成了1,而内存中的内容就变成了0

这里的xchgb因为只是一条指令所以是原子的。而mutex是被所有的线程共享的(同一个地址空间)。

这个mutex也是内存级别的。

而这里的交换寄存器中的内容和内存中的内容,我们不能简单的理解为交换。

这里的本质作用为:

所以这里的线程1的第一条语句改的是自己的,和其它的线程没有任何的关系,而第二条指令是将内存中的锁交换到自己的寄存器中。这里的本质就是将互斥锁中的1交换到了线程1的上下文中,所以即使在交换完成之后线程1被切换走了,也不需要惧怕。因为线程一旦切换走了,就需要对自己的线程上下文进行保护。这就叫做,线程1在切换的时候将自己的锁带走了。每一个线程在申请锁的时候从那个代码的开头执行。这里线程2来了,线程2首先将0写到寄存器中,然后交换内存中的数据(此时已经是0了),和寄存器中的数据,寄存器发现交换后的数据为0,直接将进程2进行挂起等待。

这就是这段伪代码的理解:

而这个交换内存和寄存器的数据的过程我们就叫做加锁的过程。

而我们加锁的过程是原子的,本质原因就在于这里。记住每一个线程在申请锁的时候,都要从头开始执行这段指令。此时就做到了。即使加锁的线程被切换走了,但是没有锁的线程依旧不能访问临界资源,同时也解释了为什么加了锁的线程还可以被调度切换的原因。

这里我们也就知道了,exchange将内存中的数据放到线程的上下文其实就是将共享的数据修改为某一个线程独占。因为exchange这个指令是原子的,所以上面的lock这一段伪代码无论在哪里被其它的线程切换都是原子的。一个线程将1拿走了,另外一个线程执行这段代码拿到的就是0。即使这个线程在判断的时候被切换走了,在离开的时候也会将寄存器中的数据带走。之后当另外的线程来的时候,首先会写入数据将寄存器中的1覆盖。然后exchange拿到0,让自己被阻塞。

下面我们再来看一下解锁的伪代码:

如果是我自己思考的话,我认为解锁是不是原子的是无所谓的,因为只有持有锁的线程才能完成解锁的功能。

而解锁要做的伪代码:

这里的代码也就是将寄存器中的mutex重新设置为了1,也就是解锁了。

现在我们看到加锁的代码就能够理解为什么加锁的代码是原子的了

可重入和线程安全的区别

在补充新概念之前,我们首先输出一个结论:

关于加锁的一般原则:谁加锁,谁解锁。极度不推荐一个线程加锁另外一个线程解锁。

下面我们来区分一下可重入和线程安全。

首先我们要知道可重入和线程安全是完全不同的两个概念。

这两个概念的实际情况存在交集,但是在概念上这是两个不同的概念。

首先我们要知道在多线程的情况下,是存在多个执行流进入同一个函数的。

之间我们写的代码中Getticket函数很明显就被多个执行流进入了。这里我们将一个函数被多个执行流进入的特点称之为这个函数被重入了。如果在重入的时候出现了问题(没有加锁,导致票数负数),对于这种因为多执行流进入会导致出现问题的函数称之为不可重入函数如果多个执行流一起进入,但是函数没有出现问题,这种函数叫做可重入函数。

所以第一个概念关于可重入和不可重入描述的是函数的特点,和线程是没有关系的。

对于可重入和不可重入没有褒贬之分,只是不同函数的特点而已。我们现在使用的绝大多函数都是不可重入的。包括我们使用过的很多stl容器中的函数。例如我们使用过的push_back等函数,都不是可重入的,因为使用了全局的空间配置器来对资源进行管理的。

那么什么是线程安全呢?

假设现在某一个线程在执行某一段代码的时候,代码要求这个线程对某些资源进行delete,可能影响了其它的线程让其他的线程直接崩溃了,这种导致线程出现问题或者线程进行的某种逻辑出现问题,称之为线程不安全而在多线程进行并发访问的时候,不会影响到其它的线程,或者是崩溃,数据不一致等等的问题。我们就称之为线程是安全的

换句话说所谓的线程安全与否,描述的是线程的特征。

而线程访问了不可重入函数导致出现问题,是线程不安全的情况之一。

以上是两者在概念上的区分。

常见的线程不安全的情况这些情况和函数不可重入的场景非常的像

第一个情况很明确,那么第二个情况是什么意思呢?

假设现在我们写了一个函数,在函数中间定义了一个ststic int cnt = 0;(全局变量,只不过作用域只在本函数)然后每一次执行流进入都让cnt++。

这样我们就完成了一个简单的函数,用于统计每一个函数被调用了多少次。

像这样每一次的调用都会让cnt++(让函数的状态发生了变化),

第三个情况和第二个情况类似。

第四个情况也就是调用了不可重入函数自然可能会造成线程不安全。

常见的不可重入情况:

在上面的情况下再加上一个就是使用stl容器的push函数情况,因为每一个stl容器几乎都有自己的空间配置器(很多的容器都是自动扩容的,自动扩容那么就一定存在一个空间配置器来统一管理空间资源),这个空间配置器是一个全局的变量,所以stl容器中的push函数都是不可重入函数。

对于线程不安全和不可重入函数的理解我们可以这么理解:

如果多线程访问某一个函数导致了线程不安全,因为函数导致了不可重入就可以证明这个函数是一个不可重入函数了。

如果函数不可重入的情况存在,在线程的角度看,我们就可以称为这个线程是不安全的。而从函数的概念看这就是一个不可重入的函数。

常见的线程安全的情况:

这里的情况1的意思也就是,现在我们有一个全局变量count = 10,然后在某一个函数的开始每一次都使用一个int mycount = count;拷贝一份count的值,最后在函数结尾的位置让count = mycount;这样就做到了所有的线程对于这个全局变量都只有可读的权限而没有可写的权限。也就是在多线程的情况下访问这个全局变量都是安全的。

情况2和情况3在之前已经说明过了

常见的可重入函数:

基本上都是不要使用全局的东西。

情况很多我们不需要记忆只需要记住:可重入和不可重入描述的是函数,而线程在执行代码块的时候,出现问题就是线程不安全。因为线程执行的代码块大部分都是函数,所以就可能出现线程调用不可重入函数导致出现线程不安全的情况发生。也可能出现调用可重入函数导致线程是安全的。

最后可重入和线程安全的联系:

可重入和线程安全的区别:

第一个也就是说如果线程调用了可重入函数,那么线程在调用这个函数期间都是线程安全的。线程安全不一定是调用了可重入函数,但是一可重入函数被线程调用,那么这个线程一定是安全的。

最后我们来得到线程安全的概念:

重入的概念:

死锁

首先我们要知道一个概念:什么是死锁问题。

在我们的学习代码中很难出现死锁问题,在一些大型的项目,才可能会存在死锁问题。在我们自己写的学习代码中,只使用了1-2个锁想要出现死锁问题,还是非常的困难的。

只有在一些复杂的项目中,各种锁嵌套的时候才会可能存在死锁的问题。

我们举一个例子来认识一下死锁问题。

这里我们规定存在两把锁(锁1和锁2),然后存在一个资源只有在锁1和锁2都具有的时候才能获取。

现在线程1具有一个锁1,线程2具有一个锁2,但是此时线程1申请锁2的时候,线程2不释放锁2,导致线程1无法得到资源。所以线程1也就不会释放锁1,同时线程1就被阻塞等待在了锁2那里,同理线程2缺少一个锁1,但是线程2申请锁1的时候,线程1不会释放锁1,线程2也就得不到锁1,并且线程2就在锁1那里阻塞等待了。此时两个线程都无法得到对方的锁了,由此就导致了死锁的问题。

那么如果只有一把锁能导致死锁问题吗?

答案是当然可以,如何做到呢?

现在假设我就是一个写代码很粗心的人。我在下面的代码中上了一个锁但是在解锁的部分我又将解锁写成了加锁。这种情况是可能发生的,并且绝大部分死锁问题都是因为这样的粗心导致的。

此时虽然只有一个锁但是依旧造成了死锁问题,运行结果:

此时是因为我将代码写错了所以导致的死锁问题,而死锁的问题一般都是将代码写错了导致的。

这里会出错的原因是:这个线程第一次已经获取到了这个锁,在解锁的时候去申请同一把锁,此时这个线程就申请不到了,这个线程就将自己挂起阻塞了。后面没有人会释放锁了,这就是一个线程的死锁。

避免死锁有一个很好的方法就是,不使用锁,就能够很好的避免死锁了。

我们写一份代码之前不使用锁,让多进程竞争的资源做到每个人一份,如果能做到就不加锁,基本上不加锁的方案都是比加锁的方案优秀的。一般加锁都是被逼的。所以解决死锁的第一个方法就是不使用锁

而因为同一个进程的多个线程,公共资源都是共享的,为了保护资源,才提出了使用锁。如果没有资源保护的需求那就不会使用锁,不使用锁就不会导致死锁的问题。而使用全局变量就是要做到一个简单的线程间的通信。

而如果我们不得不使用锁,要如何解决死锁问题呢?

要形成死锁一共存在四个条件:

首先如果死锁了是一定存在互斥条件的(一个资源只能被一个执行流使用)。这是产生死锁的第一个必要条件

下一个条件是请求与保持条件,我们在上面举的例子,线程1请求线程2的锁2资源。这就是请求对方的锁。而线程1在申请别人的资源(锁2)的时候不释放自己的资源(锁1),这就是保持自己的锁。

这是产生死锁的第二个必要条件。

第三个,依旧是上面的例子,线程1已经具有了锁1,线程1在申请锁2的时候,不会因为线程1的优先级更高/执行的任务更重要,而强行要线程2将自己的锁2释放掉。这就叫 不剥夺,在线程1向线程2申请锁2的时候,是文明的,符合规则的。不会强行让线程2将自己的锁2释放掉。这就是第三个不剥夺条件

最后一个环路等待条件是什么呢?

就是我们上面的例子,线程1向线程2申请锁2,线程2向线程1申请锁1,此时两个下线程之间就形成了一个互相申请对方资源的环路问题。而这也是死锁的最后一个条件:循环等待条件

现在如果有10个锁10个线程只要能形成对应的申请链,依旧可能存在死锁问题。

所谓的必要条件:也就是一旦产生了死锁,那么前面的四个条件是一定会成立的。也就是要产生锁这四个条件必须同时存在,有一个不满足,死锁问题就不会存在。

所以要解决或者避免死锁问题的指导思想都是:破坏四个必要条件中的一个或者多个。

首先我们来破坏互斥条件:如果破坏了互斥条件不就是上面说的不使用锁吗?

下面我们来破坏一下第二个条件:请求与保持条件。

这里就相当于两个小朋友分别只有5毛钱,都攥紧了自己的5毛钱,向对方要对方的5毛钱去卖一个1块的棒棒糖,这就是请求与保持条件,我们怎么破坏这个条件呢?

如果其中一个人,将自己的5毛钱给了对方也就破坏了对已获得的资源保持不放的条件。也就是线程2在向线程1申请锁1的时候线程1不给锁,然后线程1向线程2申请锁2的时候,线程2释放了锁2.这就是破坏请求与保持条件。

也就是如果临界区需要两个锁才能进入,然后拥有一个锁的线程,在申请另外一个锁的时候申请失败了,就直接将自己申请的这个锁一起释放了,这就是破坏请求与保持条件。每一个线程都遵守这个条件就破坏了请求与保持条件。

第三个不剥夺条件要如何破坏呢?

这里的不剥夺条件的破坏举一个例子,依旧是两个小朋友,但是如果一个小朋友不给另外一个小朋友5毛钱,就找人打他一顿。迫于无奈,另外一个小朋友才把自己的5毛钱给了对方(相当于两个小朋友能够凭借谁认识的人多,来决定谁拥有这5毛钱)这就是破坏不剥夺条件。使用线程的例子,现在线程1向线程2申请锁2,线程2不给,然后存在一个管理线程,发现线程1的优先级/其它什么更高,1需要让线程1先执行,就直接强行让线程2释放掉了自己的锁2。让线程1获得了锁2这也就是破坏了不剥夺条件。虽然pthread库不支持,但是是可以通过类似的方法做到的。

对于这个方法是存在一些算法能够做到死锁检测的。

最后一个循环等待条件要如何破坏呢?循环等待条件也就是互相申请对方的锁(你申请我的,我申请你的)。对于线程来说,为什么线程1要先申请1锁再申请2锁呢?而线程2又为什么要先申请2锁再申请1锁呢?我们将申请锁的顺序保持一致不好吗?所以对于破坏循环等待条件,一般是建议按照同样的次序去申请锁,也就是让线程1和线程2都是先申请1锁再去申请2锁。一个线程申请1锁成功了申请2锁大概率也会成功。

对于我们编码而言,尽量的把锁资源,按照顺序一次就给申请线程了。

对于死锁问题,一般只会出现在一些大型的复杂项目,多人开发的时候你使用锁,我也使用锁,就会可能出现死锁问题(每个人都去使用锁)。如果一个项目由一个人来完成,死锁的概率是会降低的。同时在这样的大型项目中,死锁问题是隐藏在各种锁之间的,想要排查还是比较困难的。

总结:要解决死锁,第一个破坏死锁的四个必要条件,第二个加锁顺序保持一致,第三个:避免锁未被释放的情况,第四个:如果有资源尽量一次分配,加一次锁申请一次资源,解一次锁,不要一个资源还申请4,5次每次都加锁,解锁,加锁,解锁的频次越频繁,多线程在高并发的情况下出现竞争锁的情况概率就变大了,由此就建议一次资源分配。

最后还有一些算法:

线程的同步

首先我们来看一下线程同步的概念,为了能够更加深入的理解这个概念,我们先从一个例子出发:

现在这里存在一个自习室,在这个自习室中存在一个位置和桌子,允许你在这里面自习。在这个自习室的外面刮着一把钥匙。

现在有一个同学每天一大早就去到自习室的外面将这个钥匙拿下来,放到自己的口袋中。然后将自习室的门打开,进入到自习室中。

而这个同学在自习的时候,陆陆续续又来了一些人。这些人就站在门口等待着。此时的这个自习室就是临界资源,而这把钥匙也就是锁。因为所只有一把所以进入这个自习室本身就是互斥的。如果再自习的途中你想上厕所,你可以从自习室中出去,然后将门锁上,在带上自习室的钥匙。就可以安全离开自习室了,不会有其他人去到自习室中。

然后经过一上午的学习这个同学饿了,他就离开了自习室然后将钥匙重新挂到了墙上。

但是刚挂上自己就后悔了,因为一旦自己现在离开了,那么下午就多半来不了了。所以他才刚挂上钥匙,就又去拿钥匙,因为这位同学距离挂钥匙的地方很近,所以其它的同学抢不过这个人(其它人距离比较远,竞争钥匙的能力比较差),就又让这个人拿到了钥匙。这个同学进入自习室,只不过几分钟实在是太饿了,又出来刚挂上钥匙,然后又去拿钥匙。

对于线程而言这里就是在不断的申请锁,访问资源(无效),释放锁。

对于这位同学而言在肚子没有饿之前的仔细都还是有效的。但是饿了之后的仔细几乎都是无效的。此时的自习室的规则只有任何一个时刻只有一个人能够进来。此时和自习室的规则是没有冲突的。

但是因为这样频繁的操作,别人又抢不过你。一个小时这个同学啥都没干光顾着放和拿钥匙了,最后导致其他人长时间得不到自习室的资源,导致了其它线程的饥饿问题。

后来自习室就为了防止这样的漏洞增加了规则。

这个规则同时也约束了外面的人不在混乱而是进行排队。

增加了这个规则之后,如果这位同学,在使用外自习室将钥匙放到墙上时,想要再去拿钥匙,就必须去到队尾排队去了。此时就能让在队头排队的人,拿到这个钥匙去到自习室中了。

这样就能保证:

也就是在临界资源使用安全的前提下,让多线程具有一定的顺序性,而这也就是同步。

同步的机制就保证了能够合理的使用资源,有效的解决饥饿问题。

这里我们就能总结一下:

同步的本质就是在资源安全的前提下,让访问资源就有一定的顺序性。

这个顺序性不一定一定是队列。一些相对的顺序也是可以的。

了解了这个之后。

我们先进入下一个学习模块中,我们来认识一下生产消费者模型,最后我们再来写一个同步版的生产消费者模型。

生产消费者模型

在计算机领域这是一个非常重要的模型。

在我们的现实生活中超市就是一个典型的生产消费者模型。

在这个模型当中一定存在很多的人想要去到超市中买东西,也就是超市的客户。

而想要从超市中拿到自己想要的东西的客户就是典型的消费者。

现在已经知道了消费者,那么在超市的这个模型下,生产者又是谁呢?

首先生产者并不是超市,因为超市本身并不生产任何的东西。

在超市背后进行供货的各种各样的供应商才是生产者。

此时有了供应商,就能做到供应商提供商品,而消费者去获得商品了。

而这个供应商我们可以认为是一堆生产线程。所谓的消费者也是线程,只不过是消费线程。

那么生产线程生产出来的线程对应的就是数据(一个线程将一些数据交给另外一个线程)

所以生产消费模型的本质就是:来进行执行流间数据传递的(通信)的。

现在所有的供货商都想将自己的商品交给超市,所以超市对于供货商而言就是一个临界资源。对于消费者而言都去到同一家超市去消费,所以对于消费者而言,超市也是一个临界资源,现在假设这些供货商都是供应同一种商品的。

而要将商品从供应商交给消费者,我么你本质要做的也就是必须要保证生产消费的过程是安全的。

假设这个过程不是安全的,就可能发生下面的情况,一个供货商刚在超市的货架上放了一个火腿肠。正在放的时候一个消费者来了。此时你不能确定这个供货商到底有没有放这个火腿肠。消费者又去拿,因为你不能确定供货商放还是没放,所以消费者,到底拿没有也是无法确定的。也就是导致了生产者和消费者同时访问同一个位置,就会出现数据不一致的问题。

同样的不同的供货商往同一个位置放置不同品牌的火腿肠。不要放了商品是数据,而数据这东西,你先放然后我再放就会导致数据丢失的情况。

所以不管我们如何进行生产和消费,首先要保证生产和消费的过程是安全的。

为了保证这个过程是安全的,就需要理清楚生产者和消费者之间的关系。只有关系理清楚了,我们才能根据我们理清楚的这个关系来想办法让多执行流之间互相加锁,才能实现我们的生产消费者模型。

那么我们的超市又是什么呢?

超市具有的是保存商品的能力。

超市是在为多线程通信提供条件从本质上来理解,超市就是一段内存空间,从应用上来说,我们的超市就是基于特定空间的数据结构或者是容器。所以一个线程从容器中拿数据,一个线程从容器中拿数据这就是生产消费者模型。

为了简化我们的生产消费模型我们这里就认为只有一个商品的货架。

并且所有的供应商提供的也是火腿肠(不同的品牌),而消费者买的也是火腿肠。

1.生产者和生产者之间是什么关系呢?

超市的空间是固定的,其中一种品牌的火腿肠供应多了,其它品牌的火腿肠供应就少了,并且每隔供货商在供货的时候不喜欢被别人打扰(其中一个供货商在放数据的时候,另外一个人来了,也放数据此时对于两个供货商而言都是不舒服的)

所以生产者和生产者之间很明显是一个竞争(线程互斥)的关系

那么消费者和消费者之间又是什么关系呢?

这里假设消费者拿完商品后通过商品上的二维码扫码付钱不需要排队。

然后两个消费者如果都想要拿同一个火腿肠并且这种火腿肠只有一个了,此时很明显,这两个消费者之间的关系就是竞争关系。

生产者和消费者之间又是什么关系呢?

首先互斥关系肯定是要有的,你去货架上买某一种火腿肠,而现在一个供货商也需要去到这个货架上去补充这种火腿肠的货(相当于一个线程正在写,另外一个线程就来读取了,到底读取成功没有,或者说到底写成功没有这是未知的,并且可能发生,写了一半就被读取了,导致出现数据不一致的问题)。

这只是一个关系。

现在有一个消费者,每一天都去到一个货架上访问是否有自己需要的火腿肠。一个月这个消费者就询问了31多次。可是每一次这个消费者过来都是空手而归。在生产消费模型中为了维护生产者消费者的模型是需要加锁的,然后这个消费者在获得锁之后,什么都没有做到,就出去了,然后再次申请锁。这不就是在浪费锁资源吗?

这里就可以通过一个规定来防止这种浪费资源的情况,当某种火腿肠不够了,就让消费者停止申请锁资源。通知供货商来补货,补货完成后,再让消费者去拿货。这样就杜绝了上面消费者反复获取锁资源的情况(并且供货商也是需要这个锁资源去补货的)。如果消费者一直在问就会让生产者无法去完成补货。所以当没有商品时让消费者停止获取锁资源,让供货商去补货。此时的效率才是最高的。而这不就是让消费者和生产者之间产生一定的顺序性,不就是同步吗?

所以生产者和消费者之间的关系如下:

总结:

所以我们要使用代码来完成生产消费模型的本质就是使用维护好上面的关系,例如互斥关系就是使用锁来完成的。至于同步关系使用条件变量来完成。

此时多线程之间就能协同了。

下面来快速的回归一下生产和消费者模型。

一共具有3种关系:

两种角色:生产者和消费者,生产者和消费者都可以是一个或者多个

最后还有一个1:代表着一个交易场所,这个场所就是超市,朴素的理解就是一个内存空间。

需要记住生产消费模型需要遵循上面的三个原则。这里的三个原则只是便于我自己记忆所作的。

那么为什么要有这个模型呢?

首先生产贤妃模型的本质是线程和线程之间进行信息传递的。未来我们可以将数据/任务放到内存空间中,另外一个线程拿到任务进行处理,那么为什么要怎么做呢?

在我们学习单进程的时候模块和模块之间进行数据传递的最典型的现象:函数调用。

例如:

main函数要去执行add函数需要跳转到add函数的函数栈帧中去执行这个函数,传参的过程就是将数据从一个模块传递到另外一个模块。

但是因为我们是单进程所以函数执行是有快有慢的

在执行这个快函数的时候,在执行到add处是没有在往下执行的。需要;另外一个模块将数据计算完毕之后将值从add模块返回过来才会继续往下运行。

所以在单进程的执行环境中,所有执行过程都是串行的。

现在我们已经学习到了多线程那么我们就可以这么做:

将快的函数放到左侧,让一个/多个线程去跑这个代码块。

让另外一个函数调用使用另外一个线程去跑。

但是还是需要右边的线程执行得到的数据交给左边的线程了,如何交付呢?

此时就可以在两个线程中间增加一个中部的交易场所。

一个线程将数据放到这个缓冲区中,而另外一个线程就能通过这个缓冲区获得数据了。

这样在右边的线程在进行数据处理的时候,左边的线程还能继续往下执行,这样就能通过使用一个内存空间实现多执行流之间的一个执行解耦。你跑你的我跑我的

这样即使左边的线程运行的很快也是不怕的,左边的线程可以不断的往内存空间中写入新的参数去交给右边的线程去计算。这样在右边的线程计算的时候左边的线程可以提供更多的数据去给右边的线程计算。这样右边的执行过程并不会阻塞左边线程。所以所谓的内存空间更深入的理解就相当于一段缓存

未来生产者线程跑自己的,而消费者线程也是跑自己的,只要缓存中还有数据和空间就能实现,左边的线程跑自己的,右边的线程也跑自己的。从而有效的实现并发。

所以生产消费模型两个最大的特点:第一个:实现模块和模块之间的解耦。因为这种解耦,就可以支持忙闲不均

第二个:提高处理数据的效率。如何提高效率后面代码更明显

你快的线程不用担心,只要缓存中还有空间,就可以先向缓存中写入数据,慢的线程也不用担心,你慢慢执行你的就可以了。

经过上面的学习我们已经知道了互斥可以通过上锁来完成,那么同步有需要通过什么机制来完成呢?

条件变量

首先来看一下条件变量的概念:

条件变量是一个pthread库提供的一种一个线程向另外一个线程进行事件通知的一种方式,和信号很像,但是还是存在区别的。

这里使用一个例子:

现在这里有一个桌子,桌子上有一个苹果。有人能够往这个桌子上放苹果有人就能从桌子上拿苹果。然后拿苹果的这个人把自己的眼睛直接蒙上了

所以拿苹果的人并不知道桌子上是否有苹果。如果在不加保护的情况下,仿苹果的人正在放苹果的时候,拿苹果的人就来拿了。此时就会导致数据不一致问题。很好理解,两边都是线程中间的苹果就是临界资源。现在要求是写线程往这个里面写完了abcde之后才能让另外一边的线程拿取数据/要么右边的线程就不拿,但是现在写线程只写了abc,右边的线程就来拿了。这就导致了出现数据不一致问题。

现在假设光有互斥,对于拿苹果的人是很没有安全感的,拿苹果的人又不知道桌子上到底有没有苹果,所以拿苹果的人,就不断的轮询申请锁,获取苹果。没有获得苹果就再次申请锁,再次去获取苹果。在这个过程中右边的人实在不断的申请锁释放锁。而在桌子上没有苹果的时候,拿苹果的人去申请锁是没有意义的。更关键的是,拿苹果的人申请和释放锁的次数太频繁了会影响左边放苹果的人放苹果的。从而导致别人的饥饿问题。

这就是光有互斥会有饥饿问题。

那么这里就增加一个规则:

这里增加一个铃铛。

每一次当放苹果的人将苹果放好之后就敲一下这个铃铛。

拿苹果的人,如果检测到此次申请锁后没有获得苹果就不再去申请锁了,而是去铃铛处去等待。当铃铛响了再去申请锁再去拿苹果。

这个铃铛就相当于条件变量。

而拿苹果的人不止一个,如果没有这个铃铛,大量拿苹果的人去竞争这个锁就会导致饥饿问题。

而在引入了铃铛之后,所有拿苹果的人先不着急拿,全部都在铃铛这里排队。

如果有人敲铃铛了,位于队列头部的人,直接去申请锁拿苹果,而拿完苹果的人消费完苹果之后,需要再次回到队列的尾部去重新等待。

有了铃铛的存在就可以让拿苹果的人实现:

1.不做无效的锁申请

2.执行具有一定的顺序性

这就是典型的生产消费模型。

这个条件变量我们可以认为是一个数据类型,而这个数据类型在我认为至少会存在两种东西,第一个就是当前的条件是否就绪的标志位。第二个必须要维护一个线程队列。

这样就能很好的实现生产者和消费者的同步机制。

下面我们来写代码认识一下条件变量。

这里我们先完成一个代码这个代码就是使用调价按变量完成让我们的线程,按照一定的顺序去执行。

这里我们先使用原生的线程库,我们封装的那个暂时不使用。

这里我们使用主线程去控制其它的线程。

首先我们先写出一个多线程执行打印的代码。

这里还有一个注意点那就是nullptr是在c++11才提出的概念所以在使用g++编译的时候需要加上-std=c++11

这里我们可以看到会出现多重打印的情况原因在于:

每一个线程在打印的时候根据Linux中一切皆文件的原则,那么显示器也是文件,并且这个文件是被所有的线程都能访问的,所以显示器也是临界资源。所以这里出现打印混论的情况原因也很明了了。当其中一个线程访问显示器的时候,因为没有加锁,所以被切换了,但是这个线程已经写了一部分的数据了,而下一个线程又开始打印自己的数据,由此就出现了上面无序打印的情况。

解释完混乱打印的原因下面我们再来使用条件变量完成主线程对其它线程的控制。

首先就是条件变量的初始化:使用接口:pthread_cond_init接口

如果你定义的条件变量是一个全局的条件变量:

和互斥锁一样使用一句话去初始化:

如果你定义的条件变量是一个局部的条件变量:

并且局部的条件变量最后还要手动的去销毁:

和互斥锁是很像的,并且条件变量和我们的锁也是强关联的。

下面就是如果线程在申请完锁之后,发现条件不允许,释放锁,那么此时就需要我们的线程不要再去申请锁,而是在条件不允许的时候,在条件变量这里阻塞等待,直到有人去唤醒。

如何等待:使用下面的接口:

调用这个函数的执行流,就去回到cond条件变量处进行等待,并且还需要我们去传递一把锁,并且不可忽略。这把锁暂时不做解释,后面会说明。

所有的pthread函数的返回值成功都是0,不成功返回值代表的是失败的原因。

既然有让线程等待的接口,那么一定也存在对线程进行唤醒的接口。

唤醒分成两种一个让指定的条件变量条件满足唤醒一个线程,一种是一次性唤醒所有等待的线程。

第一种,唤醒一个线程:

第二种:一次性唤醒所有的线程:

现在想让主线程去控制子线程。

也就是让我们刚刚混着打的信息,变成线程按照一定顺序打印信息。

首先因为显示器也是一个临界资源,所以应该加上锁进行保护,然后想要让这些线程按照一定的顺序执行就可以使用条件变量了。

代码:

运行截图:

这里使用的是唤醒一个线程那么如果是一次性唤醒所有的线程呢?

运行结果:

可以看到此时就没有什么固定的顺序了。此时我们就知道如何使用条件变量了。

现在总结一下一些问题:

1.为什么使用条件变量的wait函数那里要有锁啊

2.如果带着锁的线程在这里睡眠了,那么其它的线程不就得不到这把锁了吗?那么其它的线程怎么进入临界区的呢?

3.因为让线程沉睡的函数是在临界区中的,所以当线程苏醒的时候也是在临界区中,这里依旧会有很多的问题

为了解决上面的问题,再来实现一个代码的例子,然后来解释这些问题。

锁是为了保护临界资源的,但是上面的代码没有去保护临界资源(将显示器去除)。

这里做一个临界资源,这里写一个票作为临界资源,现在所有的线程进入到自己的函数中都是要抢票的,而要抢票就需要申请锁。也就是在加锁和解锁之间往往是要访问临界资源的,但是临界资源并不总是满足条件的。所以需要判断。

正如上面说的苹果的例子,对于拿苹果的人来说,如果没有苹果那就需要去铃铛处等待。而有苹果则拿苹果然后离开。如果没有铃铛拿苹果的人一直申请锁,判断释放锁,是非常浪费cpu资源的,所以判断在大部分的场景是无法取消的。正如车票的代码if(tickets<0)这里的判断这样是无法取消的,因为这里需要判断票的资源是否就绪。所以如果票数小于0,就让线程去等待(假设主线程每隔一段时间会放票),如果票数大于0则抢票。

代码如下:

运行截图:

如果没有使用条件变量,以及主线程不会增加票数,那么当票数为0之后,所有的子线程都会疯狂的打印没有票了,而这个疯狂打印的背后就是所有的线程在疯狂的申请锁和释放锁。原因就在于他们知道没有票了,如何知道的不就是申请了锁才知道的吗?这个问题不就是在上面说的蒙着眼睛的人,疯狂的申请锁去看苹果是否就绪吗?也就是在疯狂的浪费锁资源

而上面使用了条件变量的代码,就相当于没有票了之后,所有的线程在申请了一次锁之后,就直接去条件变量那里去等待了,直到主线程让ticket重新增加了,才会去一个一个的唤醒子线程。如果这里我选择的是一次性唤醒所有的线程。

这里总结一下:

单纯的互斥能够保证数据的安全,但是不一定合理和高效!并且加上了条件变量能够有效的方式浪费锁资源。

下面来解决一下上面所说的三个问题:(加深对接口的理解)

1.为什么使用条件变量的wait函数那里要有锁啊

2.如果带着锁的线程在这里睡眠了,那么其它的线程不就得不到这把锁了吗?那么其它的线程怎么进入临界区的呢?

3.因为让线程沉睡的函数是在临界区中的,所以当线程苏醒的时候也是在临界区中,这里依旧会有很多的问题

第一个和第二个问题,因为wait函数在底层让线程在进行等待的时候,会释放锁。这就是为什么要将一把特定的锁传过去。也因为等待的锁最后会被释放,所以其它的线程是可以进入到临界区的。

第三个问题:

当线程被唤醒的时候,因为是在临界区中被唤醒的,所以当线程被唤醒的时候,线程在pthread_cond_wait返回的时候,要重新申请并持有锁也就是被唤醒的线程只有在重新申请了锁之后才能继续往后运行。也就是这个被唤醒的线程也要参与锁的竞争。不是说因为这个线程是被唤醒的这个锁就能直接给你的,这个线程也是要和其它的线程进行竞争锁的。申请锁成功的人就继续运行,申请锁失败的就只奶在锁上继续等待了。

总结:

到这里对于条件变量的学习就暂时差不多了

基于阻塞队列的生产消费模型实现

首先要知道阻塞队列是一种数据结构,这数据结构有一个特点,这个阻塞队列可以为空,也可以为满(存在大小的上限)。如果阻塞队列为空那么就不能让消费线程继续消费了(没有数据了),如果为满就不能再让生产线程继续申请数据了,因为已经没有空间了。

因为这个阻塞队列可以被多个线程看到所以这个阻塞队列也是一个临界资源,需要被保护。所以需要锁。并且因为满了就不能让生产再生产,如果为空就不能让消费再进行消费,很明显就让生产和消费具有了一定的同步属性。

下面先来完成一个单生产者和单消费者的生产消费模型。因为都是单所以不需要关心生产和生产,消费和消费的关系了。此时只需要关心生产和消费的关系了,交易场所就是BlockQueue(阻塞队列)阻塞队列最重要的作用就是当条件不满足的时候,有对应的线程去访问直接将其阻塞住即可。对于阻塞队列使用stl中的队列封装一下就可以完成。

开始写代码

在BlockQueue中完成阻塞队列,然后在Main.cc中测试。

下面我们现在Main.cc中将主要的测试逻辑写一下

那么为什么我们要自己实现一个阻塞队列,而不是使用stl容器中的队列来模拟呢?

因为stl容器中的队列是线程不安全的,而实现的阻塞队列,写代码是可以实现线程安全的

下面来实现阻塞队列,首先因为不知道队列中要存放的数据是什么所以使用模板。

然后为了保证线程的安全,锁肯定是要存在的。同时不要忘了阻塞队列是存在上限的,所以这里为了存在容量在使用一个变量来表示容量。

这里生产者/消费者为了检测这个容量是否满足自己的要求就需要去不断的访问这个BlockQueue,由此就需要不断的去申请锁,所以在条件不满足的时候生产者/消费者就应该在自己的条件变量处等待,为了防止这种不断轮询浪费锁资源的情况,所以对于生产者和消费者都要有一个自己的条件变量。

既然存在了休眠就要考虑谁来唤醒休眠的线程。

很显然对于生产者来说,阻塞队列中资源是否不满的情况消费者肯定是知道的,所以让消费者去通知生产者,当前的阻塞队列中资源是否不满,相对的对于阻塞队列中资源是否是空的情况,生产者肯定是知道的,所以由生产者去通知消费者当前的阻塞队列中的资源是否为空。

到现在为止属性就介绍完成了,下面来完成阻塞队列的成员函数。

首先就是初始化函数了,第一个重要的东西就是阻塞队列的容量了,这里使用一个自定义参数来进行初始化(如果外界没有输入)

然后在进行Push和Pop的时候为了保证线程安全肯定要进行申请锁以及在条件不满足的时候需要在条件变量处等待。所以需要在构造函数处对锁和条件变量进行初始化(因为都是局部的所以需要使用特定的函数)。一般来说局部的锁和条件变量都是使用这种方法去初始化的(初始化列表)。之后对于局部的锁和条件变量而言如果不使用了需要使用特定的函数去销毁(析构函数)。

下面的问题就是要如何设计Push和Pop接口呢?

因为生产者和消费者的关系既有互斥又有同步,所以无论是生产者还是消费者都需要先申请锁。

先来设计生产者的Push。

由生产者去通知消费者

然后是消费者的Pop函数

由消费者去唤醒生产者。而所谓的设有一定条件的意思也就是:

这里让生产者通知消费者可以当生产者将数据生产到容量的2/3了再去通知消费者。

同理当消费者消费数据到1/3了再去通知生产者该生产数据了。

这样就是设定了很多的策略。例如下面这样就是:

然后修改一下唤醒的判断即可。

这里也就是只有生产者才知道是否需要消费了,也只有消费者才知道是否需要生产了。

这个代码还有一个细节,例如现在生产者生产了数据,然后是先唤醒消费者再去解锁。能不能交换一下顺序,先解锁再去唤醒消费者呢?原则上都可以。因为当运行到这里的时候,这个线程已经将自己的工作做完了,对于线程的唤醒是在解锁之前还是之后都是可以的,因为即使将一个线程唤醒了这个被唤醒的线程还是要申请所整个代码至少是安全的。

这里我选择的是先唤醒再去解锁,也许有人有疑问,这里即使唤醒了,但是这个锁还没有被释放啊,这样即使唤醒了也没有用啊。

没关系,即使唤醒了没有锁此时被唤醒的这个线程也不是在条件变量那里等待了,而是在锁那里等待了。唤醒了之后释放锁这个被唤醒的线程如果抢到了锁也是会立即起来的。所以我的做法是先唤醒在释放锁。

这里还有一个问题,如果现在消费者消费了一个数据之后去通知生产者,如果被通知的这个生产者本来就是醒来的,此时会发生什么呢?此时被唤醒的这个线程会直接忽略这个条件是否满足的判断。

现在已经基本写完了,下面来做测试了现在做测试简单一点,让int作为数据即可。

那么int的数据从什么地方来呢?这里就可以使用随机数函数了

然后来完成Producer函数:

下面是消费线程需要执行的函数:

虽然在主线程中生产线程是先创建的,但是这两个线程之间相隔的时间是很短的,所以到底谁先运行,是不清楚的。

但是在上面的代码中一定是生产线程先执行核心的代码,即使消费线程先获取到了锁,但是在没有数据的时候消费线程一定是会被阻塞在条件变量那里的。

这里因为生产者生产数据比较慢,所以这里能够看到的现象就是,消费者很快就将数据消耗了,消费者还要就会直接被在条件变量处阻塞等到,需要等待生产者完成sleep,然后生产数据,再去唤醒消费线程,也就是生产线程生产一个数据,消费线程就消耗一个数据,消费者的步调是跟着生产者的。

到底是不是这样的呢?运行一下:

除了一开始的数据打印出现了一些打印混乱,在下面的运行情况中都是生产了一个数据,消费者马上就消费数据。如果让消费者每隔一秒消费一个数据呢?

可以看到消费者消费的数据都是从队列头部一个一个获取的(也就是消费者获取的是生产者之间生产的数据)。

下面再来解决三个问题:

第一个:对于pthread_wait函数的进一步理解

第二个:将之前封装的锁lockguard使用到上面的代码中,

第三个:重新理解生产消费模型(代码+理论)

第四个:代码整体改成多生产多消费

第一个:线程如果不满足条件,线程调用了pthread_cond_wait那么这个线程就会在条件变量下等待,等待的时候这个线程会释放锁。当这个线程被唤醒的时候重新竞争获取锁。

来看下面的这种场景:现在有三个消费线程在条件变量那里进行等待,然后生产线程往队列中放了一个数据。输入完成之后这个线程就去唤醒在该条件变量处等待的线程。

并且唤醒的时候选择的是直接唤醒多个线程而不是唤醒一个线程,也就是说生产者生产了一个数据但是消费者都唤醒了三个。此时三个消费线程都醒来了,然后第一个线程竞争锁成功了,这个线程就消费了这个生产的数据。此时队列中的数据就没有了,但是这里假设第二个消费线程比生产线程先竞争到了锁(另外的两个消费先线程没有沉睡了,而是处于竞争锁的队列中),此时的第二个线程再去进行pop队列中的数据不就出现问题了吗?

所以在pthread_cond_wait这里存在一种状态也就是伪唤醒。

所谓的伪唤醒:对应的条件并不满足但是线程却被唤醒了。

这里也可以理解成一个线程本来是要调用这个线程去条件变量处等待,但是这个函数调用失败了,导致这个线程没有去条件变量处等待,这也是一种伪唤醒。

一旦存在这种伪唤醒的情况就有可能在条件并不满足的情况下,从事消费/生产活动。

为了解决这个问题,需要将条件判断那里的if替换成为while

此时不管你是伪唤醒还是真的醒来了,这个函数都会回过头重新进行判断只有判断成功了条件满足了,才会去执行下面的代码。

这样就能让写出来的带啊吗具有较强的鲁棒性,健壮性。

第二个问题,对于锁使用之前写的简单的锁封装来替换。

这样再去修改一下之前使用锁的代码。

还有一个Pop函数这里就不截图了。

运行依旧正常:

阻塞队列中使用了模板这也就意味着是可以将任何的数据交给消费者去消费,生产者去生产的。

那么任务也是可以的,下面就来修改代码,让生产线程去生成任务,再让消费线程去执行任务。

然后去修改一下实例化参数为任务即可,这里依旧是将任务写成一个类。

此时就可以将这个队列改名为阻塞式的任务队列。

下面首先要解决的问题就是没有任务所以需要先将任务写出来。这里写一个加减乘除的任务就可以了。

任务代码:

然后为了方便观察运行结果我还增加了两个函数:

下面就让生产线程来生成任务。

为了让生成的过程完全随机,这里还可以准备一张符号表,在符号表中,有一些未定义的符号(模拟错误数据的行为),并且创建的两个需要计算的数据,是有可能生成出0的。

下面就是将随机成功出的任务推送到阻塞任务队列中

然后是消费线程

最后来运行一下:

运行成功

写完了这个代码再回到

第三个:重新理解生产消费模型(代码+理论)

在生产消费模型里面交换的可以是基本数据也可以是类对象。

现在重新理解生产消费模型的代码部分已经没有问题了,但是之前生产消费模型的特点就是实现生产和消费过程的解耦,现在的代码也证明了这一点。在生产的时候可以消费,消费的时候也可以生产,上面的代码主要是不消耗时间,如果计算一个任务非常消耗时间,那么消费者在计算的时候,生产者就可以同步的向缓冲区中继续写任务,并不会因为消费线程在执行任务而来影响生产线程,这也就是忙闲不均,实现解耦。但是生产消费模型能够提高处理数据的高效性,上面的代码并没有解释这个特点。

并且发现了:

与其这样不如交给单线程来进行任务不也一样吗,单线程也是串行的?

要理解这一点:要想清楚,在生产线程生产之前,任务从哪里来,并且在消费线程处理任务的时候,会不会消耗时间呢?

也就是要理解在生产之前任务从哪里来,消费之后任务又要怎么处理,理解了这两个问题,生产消费的高效才能理解。

下面来画张图来理解,首先就是当生产和消费线程在队列中进行数据转递的时候本来就是同步和互斥的。然后要知道生产线程得到的数据从哪里来的呢?以及消费线程将数据拿走之后就完成了自己的任务了吗?

在上面的代码中数据是我伪造的,但是在未来的时候,这些数据是用来解决具体的问题的,所以这里对应的数据是从具体的场景中来的,比如说从网络中拿取数据。

如果这里是从网络中获取的数据,获取到数据之后,将这个数据push到队列中去。

这里就能知道了拿数据本身也是消耗时间的。

同时对于消费线程而言也是,将数据获取之后就完了吗?当然不是取走数据并不是主要目的,处理数据才是主要的目的,而处理数据也是要花费时间的。

所以对于生产消费线程来说也就不要忽略了在生产前和消费后各自还有很多事情要做。

由此就有了一种场景当生产者拿取数据的时候,消费线程有没有可能正在拿数据/处理数据呢?当然是存在这种可能的。也有可能是消费线程在处理数据的时候,生产线程输入数据呢?这些都是有可能的。

所以真正的生产消费模型的并发并不体现在上图中的同/互斥那里,而在于拿数据和处理数据时本身是并发的。

由此也就知道了生产消费模型的高效并不体现在同步/互斥这个交易场所中,高效体现在当生产线程在消耗时间拿取数据的时候,消费线程是可以在局部的时间上同时取走数据(将数据私有成了单个线程的数据),去对数据做处理的。

所以真正的高效就体现在当消费线程处理数据的时候,生产线程能获取或者将数据push到队列中。反之亦然。

那么多生产和多消费的意义在哪里呢?

即便存在多个生产者,但是生产者和生产者之间本身就是互斥的,消费者之间本身也是互斥的,生产和消费之间本身也是互斥且同步的。那么即使是多生产和多消费的生产消费模型,在block queue的场景下,任何一个时刻也只允许一个线程进入队列中。创建的线程再多也只允许一个线程进入queue中,进行数据的push或者pop。而创建多生产和多消费的意义就在于可以同时有很多生产线程能够同时去外部获取数据(或者同时有很多的消费线程去处理数据)。这就提高了获取/处理数据的并发度。

而上面的代码即使不改也是一个多生产和多消费的模型了,因为在上面的代码中已经完成了即使存在多个生产和消费也只有一个生产/消费能够进入到queue中。

所以上面的代码即使不改也是安全的。

这样就可以了.如果你想将线程的名字也写到队列中那么可以专门写一个类用来储存名字最后将结构体对象传递过去即可。

在上面的代码中生产者和消费者使用的是同一把互斥锁,这里能否定义上两把锁,生产者使用生产者的锁,消费 者使用消费者的锁。可以倒是可以但是不是定义两把锁的问题,而是需要定义三把锁,因为除了生产者和生产/消费和消费之间存在互斥,生产和消费之间也是存在互斥的(因为生产和消费使用的也是同一个队列)。但是这种的做法不好,因为这样定义的锁太多了。

到这里使用阻塞队列实现的生产消费模型就完全了。

信号量

信号量是一个计数器是一个用于描述临界资源个数的计数器。

在之前将结果信号量(进程通讯),说过一个电影院的例子,我们看电影的时候,并不是进入电影院然后在一个位置上坐下这个位置就是属于你的,而是只要提前买好票,哪怕电影开场的时候,你没有去,这个位置也是属于你的。

所以买票的本质就是对资源的预定机制。

而这也是信号量的作用:

1.信号量的本质就是一把计数器。在操作之间都需要先申请

2.信号量是用来预定资源的

而当信号量为1时代表资源只有一份,这个信号量也就是二元信号量,也就是锁完成了互斥操作。如果是多元信号量,申请信号量就要做p操作(相当于对信号量计数器做减减)减减成功代表当前的线程已经预定了资源,可以继续往下操作。资源使用完成,使用v操作(相当于对计数器做加加)表示归还资源。

因为现在多个线程要访问资源,就变成了先访问信号量了。为了防止出现问题,所以信号量在做p,v操作的时候就被设计成了原子的。

最后对信号量的理解激素hi有一个计数器和任务队列。以上是在讲解进程时讲解的信号量。

这里需要记住的是以下几点:

下面要更加深入的了解信号量,在了解信号量之前,思考一下,在上面的代码中,生产者和消费者的代码中使用的是互斥锁,来完成互斥的,为什么不使用其它的同步互斥的代码呢?原因就是这里的生产线程和消费线程是将队列中的资源当作了一个整体来看待的。其中一个线程申请锁就相当于得到了整个队列中的资源,整个阻塞队列中的资源这个线程都是可以使用的。

此时就是锁居多:

上面的那个代码是没有问题的,因为stl容器本来就是不允许破开来使用的,因为stl容器在push/pop的时候是可能伴随着一些空间配置的改变的。所以stl本来就不是线程安全的,所以只能整体来使用。

下面我们自己设定一个大数组,然后将这个大数组想象成一个一个的小区域

将整个数组当作一个公共资源的话,每一个线程都可能去访问这个数组。但是原则是我们只需要让不同的线程访问同一个大数组的不同区域(这个数组不会因为访问区域,而产生影响),此时不就相当于实现了不同的执行流并发式的访问同一个数组了。

这里就相当于下面这样的:

a线程在访问大数组的一个区域的时候,b线程可以去到这个大数组的另外一个区域访问,这样也不会导致多线程并发访问的问题。

如何将公共资源进行区域划分呢?这里就需要使用到信号量了。信号量设定为多少就代表这里存在多少份的公共资源可以被使用

下面的信号量就是7:

所以现在的线程操作就变成了先申请信号量,再去访问指定的一个区域,使用完成之后,这个线程再去释放信号量。

申请信号量的本质就是预定资源,只要线程申请信号量成功了,代表公共资源中一定存在一个区域可以给这个线程使用,但是具体的区域由写代码的我们自己去维护。

上面所说的访问某一个区域本质就是在访问临界资源,问题在于这次的访问还需要判断吗?在上面的代码中,是先加的锁,但是线程并不知道阻塞队列中资源的具体的情况,所以需要判断以下阻塞队列中是否存在当前线程需要的资源(空间/数据)。所以无论是生产者还是消费者都是先加锁,加完锁之后,再让生产者/消费者自己去判断资源是否就绪。因为这里所谓的判断也是在访问临界区。

而这里的信号量则不需要判断,因为只要申请了信号量,代表当前的线程已经预定了临界资源中的某一份了。并且这一份特定的资源只有这一个线程去访问

相当于:

也就是上面说的,当一个线程申请信号量成功了代表在这个临界资源中一定是存在属于这个线程的资源的,不需要判断。

之前就相当于一个人买票先冲进电影院询问是否有电影票,然后再买票,而信号量就相当于直接先买票,买了票之后再进入电影院,你就不会再询问是否还有票了。只要票不会卖多。

如果信号量为1代表整个资源只有一份,此时申请信号量成功代表这个资源整体可以交给你一个人去使用。这个东西就是典型的互斥。

通过以下几步来加深理解信号量

信号量的接口

要使用信号量需要包含的头文件:

信号量没有包含在pthread库中,需要重新包含一个库。

第一个参数就是将来定义的信号量,可以定义成全局的,可以定义在类中,也可以定义成静态的。第二个参数代表这个信号量是在进程间共享还是在线程间共享,这里设置为0,代表在线程间共享。因为信号量的本质就是个计数器,第三个参数就是这个计数器的初始值为多少。

然后在使用g++,遍历的时候也要包含-lpthread。

如果不想使用信号量了则需要使用sem_destroy进行销毁。

除了初始化和销毁之外对于信号量来说最重要的就是要有p,v操作。这两个操作自然也是有对应的函数的。

第一个sem_wait就是申请对应信号量的值,如果存在信号量那么这个函数就会返回,如果申请失败了,就会阻塞在这个函数处。当然也有非阻塞的版本上图中的第二个函数,以及按照时间阻塞的函数(第三个函数)。这里就只说明第一个函数了

以上就是p操作,对信号量做--,如果想要做v操作对信号量做++,

使用下面的函数:

这些函数成功返回值为0,不成功返回值就表示失败的原因。

下面来实现一个基于环形队列的生产消费模型,这里不能使用stl容易,需要我们自己去维护这个环形队列。

什么是环形队列?

环形队列是一种特殊类型的队列数据结构,它在内部使用一个固定大小的数组来实现队列的功能。与普通队列不同的是,环形队列在达到数组的末尾时会绕回到数组的开头,形成一个循环。

环形队列的一个主要优点是它可以更有效地利用底层数组的空间。当队列的尾部到达数组的末尾时,如果仍然有空间可用,新的元素可以插入到数组的开头,而不需要移动整个队列的元素。这样可以避免数组中间出现空洞无法利用的情况。

环形队列通常使用两个指针来跟踪队列的头部和尾部。头部指针指向队列中的第一个元素,尾部指针指向队列中最后一个元素的下一个位置。当队列为空时,头部和尾部指针指向同一个位置。

环形队列支持常见的队列操作,如入队(enqueue)和出队(dequeue)。当队列已满时,无法进行入队操作;当队列为空时,无法进行出队操作。为了实现环形特性,需要考虑指针的循环移动。

基于环形队列的生产消费模型

环形队列也就是通过取模的方式实现的,数组下标越界后的重新回到开头。

在这里我们维护环形队列的时候不需要去判空或者判满,因为这里使用的是信号量来进行判断的。

下面我们要知道在什么时候生产者和消费者线程会出现一些需要我们解决的问题。

一开始因为队列中为空所以生产者和消费者指向的是同一个位置

这里做到以下几点:

1.让多线程同时看到这个环形队列

2.让多线程并发式的去访问这个环形队列(一个放数据,一个拿数据)

这里先研究单生产,单消费。后面调整代码变成多生产多消费。也就是说现在只有一个生产者和一个消费者。

一开始队列为空,然后先让生产线程生产数据,让消费线程不能运行,所以生产线程就生产一个数据往后走一个,不断的循环,直到让生产者和消费者再次指向了同一个位置。

此时也就是之前所说的队列为满的情况。此时生产线程已经绕了一圈了还能不能让生产线程继续运行呢?答案当然是不能,因为如果生产线程继续往前走就会将自己之前所写的数据覆盖了,造成历史数据的丢失。如果生产线程非常快,生产线程也不能在将消费线程套完一个圈之后继续去生产,因为会覆盖生产线程历史上的数据。

如果这个环形队列的背后只是指针的话,套完一个圈之后海继续运行的情况几乎是没有的,但是不要忘了这里的生产者和消费者是线程啊,如果不对线程加上限制是会出现套完一个圈了还继续往下运行的情况的。

所以第一个大原则就是:

生产者不能把消费者套一个圈。

下面转换到消费线程的视角,现在生产线程已经准备好了一堆的数据,然后生产线程遵守规则不在生产了而是等待消费线程去消费数据。

然后这里假设将生产者也停下来,然后让消费线程去消费。这里的消费线程就消费一个往后走,消费一个往后走。依次将历史上生产线程生产的数据消费了。

直到变成下面的局面:

此时整个环形队列又进一步变成空了。

此时这个消费线程还能否继续往下消费呢?

答案是不能,因为这些空间内的数据都是临时的错误的数据,这里消费线程去消费错误的数据,得到的结果自然也是错误的。

也就是说在这个过程中消费者必须跟在生产者的后面,消费者不能超过生产者去消费。

由此就能得到第二个结论了:

消费者不能超过生产者

这里为什么是不超过呢?就拿上面的情况,在生产者重新到达消费者的位置的时候,生产者已经超过消费者一圈了,而后面的消费者消费数据回到和生产者相同的位置时,只是追上了生产者。如果超过了生产者拿到的就是废弃的数据了

总结两点:

这里可以将生产者比作一个放苹果的人,环形队列就是一个圆形的桌子,而桌子上每一个空间只能放一个苹果,而消费者就是一个吃苹果的人,一开始生产者和消费者在同一个位置,生产者放苹果在生产者将苹果放满整个桌子回到和消费者同一个位置(消费者不动),就不能再放苹果了,因为没有空间了,而消费者一个一个的吃苹果(生产者不动),再将所有的苹果吃完回到和生产线程的同一个位置时,也不能再吃了,因为已经没有苹果了。但是实际的情况是,在生产者放苹果的时候,消费者也在一边吃苹果,在这场追逐的游戏中,生产者可能很慢,但是消费者也必须在生产者后面吃生产者放的苹果。如果生产者和消费者之间隔了几个苹果,在生产者放最新的苹果的时候,消费者能不能拿最后的苹果呢?答案是能。

在这场追逐的游戏中发现:

而在生产者和消费者没有执行同一个位置的时候,往后执行此时就做到了多线程并发访问临界区

如果队列为空,只能让生产者先跑。

如果队列未满,只能让消费者先跑。

而这里的只能就表现出了互斥,只能某一个线程先跑,就让线程之间出现了一定的顺序性,而这也就体现了同步。

所以在环形队列中同步和互斥能够表现出来,但是在概率上只有在环形队列的局部某些区域才会显现出同步和互斥。而在大部分的情况下生产和消费不会指向同一个位置,就不会体现出互斥和同步。

这样就能实现真正的在较大的概率下,多生产和多消费同时并发在跑。只需要注意一下为空和为满的情况即可。

下面如何使用代码实现上面的逻辑呢?

这里是需要使用信号量来维护资源的多少的,队列为空还是为满也是通过信号量来判定的。

这里就涉及到了对资源的认识,因为信号量的本质也就是对资源的多少进行的计数,申请信号量也就是对资源进行预定。

对于生产者来说认为空间就是资源,而对于消费者来说数据就是资源。当空间资源不够了生产线程就不能在生产了,而如果数据不够了消费线程也就不能消费了。

所以这里需要定义两个信号量,一个就是对空间资源的管理,一个就是对数据资源的管理。

那么一开始队列为空的时候,空间资源就是队列的空间,而数据资源自然就是0。

所以伪代码就是生产者一开始先对空间做申请。

有空间就会继续往下运行,没有空间就会阻塞在这里。

然后就是生产的行为,生产的行为也就是需要在某一个空间上放数据。

当生产完成之后,生产者要做V操作。当生产线程生产完成退出这个位置之后,这个空间依旧是被占用的。但是此时数据资源就多了一个。所以生产线程在V的时候不V自己而是去V(sem_data),这就是生产者的逻辑。

然后消费者要进行消费行为首先也需要对信号量进行申请,申请成功才会继续往下执行,而当消费行为完成了,消费者在V的时候。将某一个空间中的数据取走,此时空间就出来了,所以消费者V的就是sem_space。

消费者的逻辑:

所以当生产线程在生产的时候,如果将空间资源消耗完了,就相当于绕了消费线程一圈了,此时生产线程还想生产就会被阻塞在信号量的申请处,同理消费线程也是这样的只不过消费线程消耗的数据。而生产线程每生产一次就会产生一个数据,相当于让sem_data++,而让sem_space---。消费线程则是让sem_space++,而让sem_data--。同时这也能够做到为满时,生产线程阻塞只能让消费线程运行(数据资源很多),而也能做到为空时,消费线程阻塞(数据资源为0)生产线程运行(空间资源充足),当空间和数据资源都存在时,就能做到让生产和消费线程去并发式的运行了。

实现环形队列的生产消费模型

首先来写测试的代码,定义一个RingQueue的类,暂时不实现。然后来写测试的代码结构。

下面就来实现RingQueue这个类。因为这里的环形队列是使用数组来模拟实现的,所以这里需要一个数组的容器,并且这个容器最好也能和数组一样使用数组的方式去访问数据。这里就可以使用vector来当做数组了。因为并不知道这个数组中保存的数据是什么类型的数据所以需要使用模板。然后为了维护好环形队列最好有一个变量来记录这个环形队列的长度为多少。

下面就是基本的类的接口信息:

然后就是生产线程和消费线程在环形队列中移动,虽然生产和消费线程存在在同一个位置的可能性,但这也是少数,所以在类中还需要给生产和消费线程各自维护一个当前生产/消费线程移动到了哪一个步数的变量。同时这样才能做到在绝大部分情况中生产的同时还能消费。同时这两个变量也决定了当前的生产/消费线程能够访问的区域。然后就是两个信号量了。

增加了以上成员变量后:

下面就是第一个生产函数:

在生产之前,需要进行P操作,申请空间资源,有空间才能生产。申请成功了下面就是你要生产的位置了,位置自然就是在_ringqueue[_p_step]这个位置了,生产完成之后,让_p_step++。为了保证环形的特性还需要模上size。最后再对数组资源进行V操作。

下面现在对P和V操作进行实现:

自然就是使用sem_wait()函数【--信号量】和sem_post()【++信号量】函数了,这两个函数在设计的时候就是原子的,因为每一个线程在访问资源之前就需要访问信号量,为了防止信号量出现问题,所以这两个函数也是原子的。

这样就完成了。然后来完成Push和Pop函数:

此时也就能让生产和消费互斥运行了。这里我将循环队列的默认的大小从10修改为了5。

下面就是测试了首先还是使用整型当作储存的数据。

当运行这个项目的时候,对于生产线程和消费线程,并不清楚再在系统中是谁在运行,但是一开始一定是生产者先开始运行的。

运行结果:

除了一开始出现了一些打印错误,在后面都是生产一个数据消费线程就消费一个数据,因为生产线程生产数据慢一些,而消费线程消费数据是快一些的。就会出现生产一个消费一个的现象。

如果调换一下会发生什么呢?也就是生产线程生产很快,但是消费线程消费很慢。

现象也很容易分辨,一开始生产线程就会生产很多的数据,将队列填满,填满之后,就会让消费者消费数据,出现消费一个生产一个的现象。

这里一开始的这个异常的现象原因就在于,我是将sleep放到了数据push完毕之后,当生产者生产了一个数据之后,消费者会去消费数据,而在完成消费的打印之后,才会进入sleep,所以就出现了上面的打印异常的情况。同理在我将sleep放到生产者时出现的情况也是一样的道理。

将sleep放到一开始就不会出现上面的情况了:

如果将sleep放开在一瞬间数字就会被见到非常小。

这也就实现了生产线程和消费线程在大部分时间中的并发运行。而在环形队列中放的只能是整型吗?当然不是,也可以放一个任务/类对象。

任务就选择之前写的简单计算任务。

依旧是生产一个消费一个。

运行结果:

在这个环形队列的生产消费模型中,生产和消费是可以做到并发去运行的,而之前的阻塞队列的生产消费模型则做不到。

在阻塞队列的代码中,带么不用改就能变成多生产和多消费的,那么这里能否做到呢?

单生产单消费和多生产多消费的区别就在于单生产单消费不需要考虑生产和生产,消费和消费之间的关系,而多生产多消费需要考虑这个关系。

但是上面的代码是做不到让多个生产或者消费做到互斥的。

就拿生产为例子在生产数据的时候一般都是具有多个空间的,此时让多生产线程去申请信号量就会出现问题。因为生产/消费线程用于指定位置的下标只有1个。也就是一个线程在执行生产的时候,另外一个线程也可能会往同一个位置储存数据导致数据丢失。同理消费线程也是这样的。

所以就算你是多生产和多消费的模型,最终也是只有一个生产/消费进入到生产/消费的代码中的。

所以上面的代码要实现多生产多消费就一定需要加锁,那么问题是需要几把锁呢?

如果定义的是一把锁,那么生产和消费就不可能并行跑了,在上面的代码中只有生产和消费在环形队列的同一个位置才会出现互斥和同步现象,在不为空/满时生产和消费指向的就不是同一个位置,不同位置生产和消费是不冲突的也就不需要加锁。

所以不能将环形队列当成一个整体所以一定是两把锁。

下面在类中增加两把锁

然后去修改一下push和pop函数

在修改push和pop函数的时候就有一个问题了,是先申请信号量再申请锁呢?还是先申请锁再申请信号量呢?

如果是先申请锁就会造成一个问题,当一个线程已经获得了锁之后,后面的线程就会无法进入到内部,假设信号量还有很多,那么就会导致后面的线程无法拿到信号量,而如果先申请信号量,那么就会让足够的线程先拿到信号量,然后再让这些线程去竞争锁。很明显是先申请信号量更优秀的。如果先申请锁,会导致在信号量充足的时候后面的线程无法获取到信号量的情况。

而当一个线程使用完锁之后,就不需要让其它的线程先去获取信号量了,就能直接去竞争锁了。而如果先申请锁,一个线程释放完锁之后,后面竞争到锁的线程还需要去申请信号量。先申请信号量能够减少竞争锁的频次,并且锁竞争是有失败的情况的,所以可以先将信号量分割了。对于消费者来说也是这样的。在使用一下之前封装的锁。

下面就是新的push和pop函数:

这样就保证了多生产和多消费的互斥性,信号量则保证了生产和消费的互斥和同步。

最后的测试代码:

如果你想将线程的名字带上可以写一个结构体(结构体中包含循环队列和名字)然后在将这个结构体传递过去即可。

希望这篇博客能对您有所帮助

  • 15
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值