引言:
北京时间:2023/7/22/14:27,现实和预期往往相差是巨大的,哈哈哈!白天睡不醒,晚上睡不着,就像一个夜猫子一样。熬夜耍手机,我真的是专业的,已经连续好久没有正常睡过觉了。怀念学校里的日子,每天都有人激励我早睡和早起,此处@谢xx,不知他最近的学习状态如何,但我估计没有了我的存在,他应该也不是很积极,看到CSDN里的那600条私信,现在我就头痛,居然有人真的可以天天卷,不服气!收心,收心,今天或者明天能早睡,那我就成功了,本质也就是今天写完这篇博客之后,可以倒头就睡,不看电视,刷视屏,当然也就是早上可以起来码字, 那么咱就有希望实现日更,好了,废话不多说,正式进入该篇博客的主题,有关生产消费模型,当然还有线程互斥与同步相关知识。
线程安全问题和可重入问题
在学习该篇博客的主题,有关生产者消费者模型之前,我们还需要学习有关条件变量相关的知识,不过在学习条件变量之前,此时我们先来谈谈有关线程安全和可重入问题,当然具体为什么要学习条件变量,待会我们会娓娓道来,现在先让我们来看看这部分的知识吧!
这部分知识虽然只是概念问题,但是我觉得无论是后期线程相关的实操,还是以后复习,概念问题都不可以马虎,很多问题之所以深入不了我认为本质还是相关概念的问题,遗忘归遗忘,但是有没有踏踏实实就是另一回事了。首先,此时我们明白什么叫做线程安全,什么又叫做线程安全问题。线程安全指的是在多线程环境下,所有线程访问共享资源时不会出现竞态条件问题,也就是所有线程有序地对共享资源进行访问。同理,反之称为线程安全问题,也就是多个线程对某一共享资源进行访问,由于竞态条件(线程间执行速度和调度顺序不确定,导致线程彼此间的相对速度不同),导致共享资源数据可能被修改,从而造成无法预测的结果。
搞定了上述有关线程安全和线程安全问题相关的知识,该部分还需要将可重入问题了解一下,在之前学习有关信号知识时,我们学习了什么是可重入函数,并且通过链表的例子充分表明了什么是不可重入函数,同理,学习到线程我们就能很好的理解,为什么函数之间分为不可重入函数和可重入函数,因为在多线程环境下,线程间共享数据,如果一个函数接口被多个线程重入出现问题(内存泄露等),那么该接口就是我们说的不可重入函数,反之可重入。所以为了区分这块知识,就有了相关概念,当然面对这一问题肯定是有解决方案的,本质来说不是为了解决不可重入函数的问题,而是解决线程安全问题,也就是对共享资源的一个保护,我们可以通过互斥锁、条件变量、信号量 的使用来保护共享资源,互斥锁相关的互斥概念在上一篇博客中,我们已经详细讲解过了,条件变量相关知识下文重点介绍,信号量相关知识下篇博客重点介绍,总而言之:无论是互斥锁、条件变量、信号量,还是其它七七八八的方法,本质目的只有一个(相信这里有人会以为我要写保护共享资源,哈哈,肤浅,实力博主怎么可能水文)实现多线程间的同步。当然此同步非彼同步,具体详情,条件变量详解。
重点
: 搞定了上述知识,正式进入该部分的重点,可以很好的检测你到底有没有搞定上述知识哦!上述我们说了当一个函数被多个线程同时访问不会出现线程安全问题时,这个函数就被称为可重入函数,反之不可重入函数,不置可否!那么此时,你知道具体什么是可重入函数,什么是不可重入函数了吗?你没有,我也没有,假如你说你明白了,那好我问你,如果一个函数里面有一个全局变量或者是静态变量,那么该函数是可重入函数还是不可重入函数,三秒钟告诉我!错,答案是都有可能,按照正常思路来想,答案应该是不可重入函数,因为如果一个函数中存才全局变量或者静态变量,那么就会导致多个线程在访问该函数时,对同一共享资源(全局变量)进行修改,最终导致线程安全问题。反之,同理上述所说,因为我们可以使用一些同步机制来实现线程间有序访问,从而让共享资源得到保护,让一个使用了全局变量/静态变量的函数被称为可重入函数。明白了这点之后,我再问一个问题看你是否能够答对,可重入函数被多个线程重入会导致线程安全问题吗?面对这个问题,不谈答案,首先直接懵逼,很多同学会想,如果一个可重入函数被多个线程重入会导致线程安全问题,那这个函数不就不是可重入函数吗?这么想肯定是合理的,但是是存在问题的,因为通过上个问题我们知道,可重入函数不一定不可以有全局变量/静态变量,所以我们明白如果一个可重入函数中含有共享资源,那么它就必须包含同步机制,但就算包含了同步机制,该函数也不一定是可重入函数(因为共享资源可能会被修改), 所以对于我们来说,一个函数是不是可重入函数并不重要,重要的是该函数内部是否存在共享资源以及对共享资源的访问方式和是否存在线程安全措施。 也就是说,如果一个函数里面包含了共享资源,但是我采取了线程安全措施(同步机制),并且每个线程对其的访问方式只读(没有修改),那么该函数也能被称为可重入函数。同理,如果一个函数内部只含有局部变量,那么该函数不涉及共享资源,多个线程对该函数的调用也不会导致线程安全问题,该函数也被称为可重入函数。
当然对于上述知识,有的同学可能存在一定的疑问,就是为什么全局变量被称为共享资源,而局部变量则不是共享资源,这里我给你们解惑一下,在之前学习的知识中讲过只是没有重点强调,也就是因为每一个线程都有自己的上下文和栈空间,当一个线程需要访问某个函数接口时,该线程就会根据对应函数的地址找到代码段上对应内存中函数接口的代码,将其加载到自己的上下文中(寄存器),并且根据对应的代码就会将代码中的局部变量拷贝一份到自己的栈空间中,从而实现局部变量之间的互不干扰,当然之前我们说过,对于新线程,也就是刚刚创建的线程,它使用的是线程库提供的栈空间,只有主线程使用的才是地址空间中的栈空间。
死锁问题
搞定了上述有关线程安全的问题之后,此时我们来看看什么是死锁。死锁的概念非常简单,并不值得我们深思,但是与其相关的线程安全知识需要我们探讨。简简单单,我们从为什么会出现死锁问题出发,这个问题对于我们来说并没有成本,因为想要学习多线程,就一定需要解决多线程的并发问题,也就是线程安全问题,为了解决线程安全问题,我们就可以使用加锁的方式(互斥量),使用加锁,那么此时就一定会伴随死锁问题。
死锁的情况有非常多种,这里我们重点强调最经典的,明白了这点,其它死锁情况都是有理可依的,死锁指的是两个或多个线程因争夺系统资源而陷入阻塞状态。 这句话如何理解呢?首先需要明白,每个线程在执行代码时,不仅只需要访问一份共享资源,而是需要同时访问多份共享资源,这也就意味着每个线程需要同时加好几把锁。所以,当多个线程在同时执行代码访问共享资源的时候,就需要竞争这些临界资源,而如果当一个线程需要的临界资源被另一个线程占用的话,此时它就需要等待另一个线程使用完,并且解锁之后,再去获取,从而让线程执行下去。但是,如果此时另一个线程想要继续执行下去,也需要获取等待线程已经获取到的临界资源,那么它们两个此时就会因为互相竞争而导致死锁问题。也就是线程A需要两份临界资源,此时它已经竞争到其中一份,线程B也需要两份临界资源,它也竞争到一份,而它们剩下需要的那份已经被互相占有,那么此时就会导致死锁问题。 所以对于我们来说,发生死锁的必要条件就是:互斥(一份资源只能同时被一个线程访问)、请求与保持(等待资源的同时,保持已拥有的资源)、环路等待(线程间互相等待对方的资源)、不剥夺条件(已拥有资源不被其它线程访问)。
注意: 明白上述有关死锁的概念,此时我们要明白线程加锁之后未解锁并不是死锁,未解锁的本质是让其它需要访问该临界资源的线程拿不到该资源,从而导致对应线程的一个阻塞情况,同理,重复加锁情况,本质也是因为在获取资源的加锁过程,该资源可能已经被其它线程获取,导致该线程获取不到,被阻塞。这两种情况都只是线程阻塞,并不意味着死锁。 所以不能只是把因为某个线程被挂起,导致该线程占用的临界资源不能被其它线程访问,导致挂起阻塞,称之为死锁。
如何解决死锁问题呢?
这个问题本来我不是很想讲的,奈何优质创作者,哈哈哈!当然只是简单讲解一下,因为死锁问题本质我们基本碰不到,概念清晰就行,解决方法对于我们来说就有点多余,本质想要解决死锁问题,就要从死锁的四个必要条件出发,只要可以避免这4个必要条件中的任何一个,死锁问题就能解决。首先是互斥条件,最好的解决方法就是不使用互斥量,也就是不使用加锁,哈哈哈!一个非常聪明的方法,也就是说,如果以后存在加锁和不加锁的两种代码实现方法,我们应该优先考虑不加锁。其次是请求与保持,想要避免这个问题,我们可以采取主动释放锁的方式,不过想要主动释放锁,那么就涉及到pthread_mutex_trylock接口,这个接口的作用就是以非阻塞的形式去尝试访问一份资源,也就是尝试去申请锁,如果该临界资源被占用,那么该接口返回一个EBUSY错误码,表示获取失败,成功则返回0,我们再通过条件判断,判断下一步操作是获取临界资源(锁),还是释放已经占用的临界资源(pthread_mutex_unlock),从而解决请求与保持的情况,避免死锁问题。然后是环路等待,解决这个问题最好的方法就是按照顺序去申请锁,也就是让每一个线程都按照一个特定的顺序去申请锁,这样就可以避免不同线程占用其它线程的资源了,如每个线程都按照先1后2再3的顺序申请,这样一个线程拥有了1和2,就不需要担心另一个线程没有1和2只有3了,也就是不需要担心它们会互相竞争。最后是不剥夺条件,解决这个问题最好的方法就是控制线程统一释放锁,让其它线程来统一释放所有的锁,从而避免死锁问题。当然有关解决死锁问题的方法还有非常多,这里不多赘述,具体如何实操,有待深入。
条件变量
在学习什么是条件变量之前,通过上述的有关描述,此时我们需要明白两点,一点是为什么需要条件变量,一点是条件变量的本质还是在实现线程同步机制。所以此时为了弄清为什么需要条件变量,我们就需要进行额外知识的了解。并且回应上文,这里对同步的概念进行误区纠正,虽然在现实生活中,同步的意思表示同时进行,但是在计算机中或者说对于线程来说,同步指的不是同时运行,而是按照一定的顺序进行,反之异步我们才称为同时进行(信号部分重点讲过)。
为什么需要有条件变量?
首先我们来认识一个新的概念,叫线程饥饿问题,也就是某个线程由于各种原因,导致获取不到对应所需的资源,从而一直处于无法执行状态。一般导致饥饿问题的原因很简单,就是线程的优先级高低不同导致,在人为不对线程TCB中的优先级进行设置的情况下,每个线程被调度的优先级高低都是由操作系统默认随机设置的,然后调度器再根据优先级的高低去执行对应的线程的上下文代码,而此时就有可能会导致线程饥饿问题,也就是当优先级高的那个线程访问完临界资源之后,可能因为该线程的时间片还未结束,导致频繁加锁访问该临界资源(因为其优先级高),从而让那些优先级低的线程访问不到对应的共享资源(抢不到锁)造成线程饥饿问题。当然对于体系结构来说,这个现象是天生就存在的,但是这个现象是不合理的,所以为了解决这一问题,自然而然就有了条件变量的概念。
当然上述原因是条件变量产生的重要原因之一,但是并不能说条件变量就是为了解决线程饥饿问题而产生的,条件变量的概念和互斥量的概念往往是同时出现的。我们通过互斥锁实现了线程间的互斥,保护了共享资源,但是在具体的场景使用下,单单只使用互斥锁并不能很好的将线程控制住,而是需要让互斥锁和条件变量配合使用,这样才能很好的将一份代码按照我们的逻辑(规则)运行下去,这部分知识在生产者消费者模型中着重体现。这里我们只需要明白一个点,也就是一份代码单独使用互斥锁是控制不住的,只有配合条件变量,才能让每一个线程按照我们的预期执行,这也是条件变量产生的重要原因之一。
什么是条件变量呢?
明白了上述有关为什么要有条件变量的知识,此时我们学习条件变量可以说是顺理成章,上述我们说了,条件变量就是为了解决饥饿问题和配合互斥锁使用,那么到底是如何解决,又是怎样配合的呢?此时我们就需要明白条件变量的具体使用规则,其中重要的接口有两个,分别是pthread_cond_wait
和 pthread_cond_signal
,具体使用方式同理互斥锁的使用,这里不重点强调,我们重点明白使用规则就行。在对应的临界资源中,我们可以设置一个判断条件(while/if),当线程获取到锁执行相应临界资源时,按照预期对我们设置的条件进行判断,如果满足/不满足(自己设计)我们就调用pthread_cond_wait接口将该线程阻塞,只有当该线程在符合我们预期的情况下,我们才使用pthread_cond_signal接口将其唤醒,让其继续向后执行。通过这一套操作,也就是条件变量的使用,此时我们就能很好的将饥饿问题给解决,也就是一个线程光光是优先级高(抢锁能力强)已经不管用了,它还需要满足条件变量才能对临界资源进行访问,否则就会阻塞,从而让其它线程也有机会访问到该临界资源。同理第二点配合互斥锁的使用,在生产者消费者模型中体现的淋漓尽致,但是下述有一份简单的demo代码,如下:
注意: 对于条件变量来说一共有4个接口,它们分别是pthread_cond_init、pthread_cond_signal、pthread_cond_wait、pthread_cond_broadcast
,但是其关键作用的只有两个,也就是阻塞接口和唤醒接口,其中pthread_cond_signal接口和pthread_cond_broadcast接口的区别在于前者是一个一个的对线程进行唤醒操作,而后者是一次性唤醒所有阻塞线程。最后,需要格外注意的是pthread_cond_wait接口的调用方式,其它条件变量接口只要有对应的条件变量参数就行,唯独它不同,它不仅要有条件变量参数,还需要有互斥锁,也就是说,我们需要将互斥锁的地址给给该接口,当阻塞某个线程时,由于该线程肯定是先申请锁,所以它已经拥有了该锁,所以此时我们需要拿到锁的地址,将对应的锁给释放掉,否则就会导致其它线程由于拿不到锁而被阻塞问题。所以pthread_cond_wait不仅有阻塞线程的功能,它还有解锁功能。
生产者消费者模型
搞定上述有关条件变量等知识,此时我们就可以正式进入生产消费模型的学习,因为生产消费模型本质就是利用了互斥锁和条件变量这两个同步机制来实现的,同理上述学习条件变量有关知识的风格,此时我们第一点先来了解一下为什么要学习生产者消费者模型,当然也就是为什么有生产者消费者模型这个概念。
为什么要有生产者消费者模型?
首先从名字出发,生产者和消费者对于计算机而言可以说是非常的抽象,所以想要搞懂这块知识,同理万能学习方法,图示或者是类比,此时我们使用类比,对于生产消费模型来说此时我们可以将其类比为通信,生产者向同一份资源(缓冲区)中放数据,消费者从同一份资源(缓冲区)中拿数据,只不过此时的对象不再是进程,而是线程,所以生产消费模型的本质就是一种线程间的通信而已,只不过此时它有了一个更好听的名字,那么为什么要叫生产消费模型呢?具体有什么含义呢?生产消费模型又有什么好处呢?容我一点一点的细细道来,首先我们看一幅图,如下所示:
只要是有一定生活常识的人都知道,这是一幅超市的图片,在超市中包含了非常多的商品,并且这些商品都是从各种工厂中被生产出来的,此时我们就把那些工厂称作生产者,而超市中的顾客我们就称作消费者。从这点来看,此时我们对生产消费模型好像有了更深的认识,因为超市的作用我们都知道,它就是用来存放各种商品,然后供给消费者获取的。所以此时对于生产消费模型来说,其中的同一份资源我们就把它看做是一个超市,其中不同执行方式的线程我们就看做是一个一个的生产者或者是消费者,如下图所示:
此时通过上图,我们来认真的分析一下生产消费模型的含义,以及它的好处。首先我们知道由于缓冲区(超市)受到同步机制保护,但这并不表示缓冲区(超市)每次只允许一个线程进入,类比日常生活中的超市我们也知道,这种情况是不允许的,所以也就是所有线程都能看到且访问该同一份资源(缓冲区),但是可能因为缓冲区中有许多不同的区域,每一个区域被同步机制保护,区域内的资源一次只允许一个线程访问(无论是读取/写入),具体如何控制该缓冲区和缓冲区内不同的区域,都是由设计者自己通过同步机制代码实现的。但是要注意在我们的生产消费模型中,并不存在一边生产,一边消费的情况,类比生活中的超市,由于超市本身可以存储商品,所以不需要一边进货一边卖货,一定是等商品全部买完了之后,再去找生产者生产,所以对于我们来说生产者和消费者之间的关系是一种互斥且同步的关系。所以在我们看来,生产消费模型本质就是存在三种关系:消费者和消费者之间的互斥关系、生产者和生产者的互斥关系、生产者和消费者的同步且互斥关系。也就是类似于超市里,顾客和顾客之间可能会因为同一件商品而处于竞争关系、工厂和工厂之间同理竞争关系、顾客和工厂由于不能同时出现在超市,所以是一种同步且互斥关系,由于同步,所以互斥。
明白了上述的三种关系之后,此时我们对生产消费模型的理解初步完成,也就是说当我们在使用代码构建自己的生产消费模型时,我们就需要按照这三种关系来构建,也就是对缓冲区内的某一块资源进行加锁,让不同的线程互斥,对整个缓冲区使用条件变量,从而实现消费者和生产者的同步机制,本质也就是整个缓冲区由于生产者和消费者的原因,一定需要使用条件变量来控制同步机制,但是整个缓冲区并不一定都需要互斥,只需要对缓冲区内不同区域的资源进行互斥保护就行,具体由设计者决定。最后再来看看生产消费模型的优点,首先第一个优点是实现了不同线程间的解耦,也就是消费者和生产者之间的解耦,解耦也就是让两个线程之间的依赖关系降低,此时一个线程需要从另一个线程获取数据,此时就不需要花费时间去等待另一个线程,而是可以直接从缓冲区内获取,其次当一个线程在等待访问缓冲区时,其他线程可以继续执行其它任务,不需要等待。也就是当某个线程完成对缓冲区的访问之后,其它线程可以立即开始访问该缓冲区,不需要再等待别的共享资源,因为在等待访问该缓冲区资源时,该线程已经通过并发访问的形式,获取到了其余资源,这样可以大幅度的提高系统的响应速度,提高系统效率。
生产消费模型代码
基于BlockintQueue的生产消费模型