【Linux】多线程 --- POSIX信号量+懒汉模式的线程池+其他常见锁

简介: 【Linux】多线程 --- POSIX信号量+懒汉模式的线程池+其他常见锁

Linux system sprinkle flowers

a76817910d2a4b4792532c400cce6fa4.jpeg


一、POSIX信号量

1.阻塞队列实现的生产消费模型代码不足的地方(无法事前得知临界资源的就绪状态)

在先前我们的生产消费模型代码中,一个线程如果想要操作临界资源,也就是对临界资源做修改的时候,必须临界资源是满足条件的才能修改,否则是无法做出修改的,比如下面的push接口,当队列满的时候,此时我们称临界资源条件不就绪,无法继续push,那么线程就应该去cond的队列中进行wait,如果此时队列没满,也就是临界资源条件就绪了,那么就可以继续push,调用_q的push接口。


但是通过代码你可以看到,如果我们想要判断临界资源是否就绪,是不是必须先加锁然后再判断?因为本身判断临界资源,其实就是在访问临界资源,既然要访问临界资源,你需不需要加锁呢?当然是需要的!因为临界资源需要被保护!


所以我们的代码就呈现下面这种样子,由于我们无法事前得知临界资源的状态是否就绪,所以我们必须要先加锁,然后手动判断临界资源的就绪状态,通过状态进一步判断是等待,还是直接对临界资源进行操作。


但如果我们能事前得知,那就不需要加锁了,因为我们提前已经知道了临界资源的就绪状态了,不再需要手动判断临界资源的状态。所以如果我们有一把计数器,这个计数器来表示临界资源中小块儿资源的数目,比如队列中的每个空间就是小块儿资源,当线程想要对临界资源做访问的时候,先去申请这个计数器,如果这个计数器确实大于0,那不就说明当前队列是有空余的位置吗?那就可以直接向队列中push数据。如果这个计数器等于0,那就说明当前队列没有空余位置了,你不能向队列中push数据了,而应该阻塞等待着,等待计数器重新大于0的时候,你才能继续向队列中push数据。

17050d18404b4d23baf1fac1418f2d74.png


17050d18404b4d23baf1fac1418f2d74.png

2.信号量的理解


1.

信号量究竟是什么呢?他其实本质是一把计数器,一把衡量整体的临界资源中小块儿临界资源数目多少的计数器。所以如果有这把计数器的话,我们在重新访问公共资源之前,就不需要先加锁,在判断临界资源的状态,再根据状态对临界资源进行操作了。而是直接申请信号量,如果信号量申请成功,那就说明临界资源条件是就绪的,可以进行相应的生产消费活动。


2.

而由于信号量是临界资源中小块儿临界资源的数目,每个线程申请到的小块儿临界资源是各不相同的,那其实多个线程就可以并发+并行的访问公共资源的不同区域。

至于并发+并行,实际这两个是不冲突的,尤其是公司的服务器,他一定是并发+并行运行的,你这个线程在申请到信号量后进行操作,并不影响其他线程也申请信号量进行操作,当然这里说的并发+并行还是对于生产者和消费者之间在对临界资源进行操作时的关系,因为只有生产者和消费者之间访问的才是不同的小块儿资源。


3.

所以在有了信号量之后,我们就能提前得知临界资源的就绪情况,进而能够决定对临界资源进行什么操作。

每一个线程想要访问临界资源中的小块儿资源时,都需要先申请信号量,申请信号量成功后,才可以访问小块儿资源。那其他线程可不可以申请信号量呢?如果可以的话,信号量是不是共享资源呢?如果想要访问共享资源,共享资源本身是不是需要被保护呢?

如果信号量只是简单的++或- -操作来衡量小块儿临界资源的数目的话,那肯定是不对的,因为++和- -的操作不是原子的,信号量的申请和释放就会有安全问题。所以实际信号量的申请和释放并不是简单的++或- -,他的申请和释放操作应该是原子的,信号量- -实际对应的是P操作,信号量++对应的是V操作,所以信号量的核心操作是PV操作,或者叫做PV原语。


3.初步看一下信号量的操作接口


1.

信号量的操作接口并不难,PV操作对应的就是sem_wait和sem_post接口,作用分别是申请信号量和释放信号量,而sem_t和以前接触的pthread_mutex_t等类型一样,都是pthread库给我们维护的一种数据类型。


34d8810b423542fca3e0ce262f7d83a2.png


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


1.

上面我们一直在说信号量的原理以及作用,但信号量的应用场景是什么呢?如果用信号量来实现生产消费模型,又该如何实现呢?

在对临界资源进行操作时,有时并不需要对整个临界资源进行操作,而是只需要对某一小块儿资源进行操作,那如果生产线程和消费线程都各自对小块儿资源操作的话,这一小块儿资源就只有一个线程在访问,此时就不会由于多线程访问临界资源而产生安全问题了,那生产线程和消费线程就可以并发或并行的去各自访问自己的小块儿临界资源了,互不干扰,临界资源不会出现安全问题。


2.


像这样使用小块儿资源的场景,就适合用环形队列来实现生产消费模型,p向空的位置放数据,c从有数据的空间位置中拿数据,而且我们保证p和c的操作位置不同,也就是说,p一直向前跑,向每个空位置放数据,你c不能超过我p,因为你超过的话没啥用,前面的位置p还没有放数据呢,你就算拿数据拿的也是无效的数据。而p也不能套c一个圈,因为你套了的话,就会出现某一个位置上的数据c还没拿走呢,你p又过来生产数据了,此时就会发生数据覆盖的问题。


所以大部分情况下,p和c他们操作的都是不同的位置,如果操作的是不同的位置,p和c就可以并发+并行的生产和消费数据,本质原因就是p和c操作的是不同的小块资源,互相之间并不影响,而原来的阻塞队列是作为整体被使用的,p和c直接用的就是这个整体资源,你生产的时候,我就不能消费,我消费的时候,你就不能生产,因为一旦同时生产和消费,临界资源是作为整体被使用,就会出现安全问题,不过今天我们不用担心,因为p和c操作的是不同的小块儿资源。


但除大部分情况外,还有小部分情况,比如刚开始环形队列为空的时候,p和c指向的是同一个队列位置,此时他们使用的就是同一个小块儿资源。或者当环形队列为满的时候,p和c也会指向同一个位置,他们使用的也是同一个小块资源。那对于这种情况的话,就不能并发+并行的访问了,而是只能互斥的访问。当ringqueue为空时,必须p先生产,c此时阻塞。当ringqueue为满时,必须c先消费,p此时阻塞。

479852164d984f93bda6a9c1290daba5.png


3.

所以想要维护环形队列的生产消费模型,主要的核心工作就是维护三条规则,一是消费者不能超过生产者,二是消费者不能套生产者一个圈,三是当队列为空或为满的时候,我们要保证生产和消费的互斥与同步关系,互斥指的是哪个线程去单独访问,同步指的是两个线程都要去访问,不能有饥饿问题产生。

我们说过信号量是用来衡量临界资源中资源数目的计数器,那对于生产者而言,他最看重什么小块儿资源呢?就是空间资源。对于消费者而言,他最看重的是数据资源。所以我们可以给空间资源定义一个信号量,给数据资源定义一个信号量。

84a90b13d60948afb56a7eb17dd02b90.png

5.环形队列的代码编写(维持生产之间,消费之间,生产消费之间的三种关系)


1.

我们写环形队列的代码,实际就是维护上面所说的三条规则,维护前两条很简单,因为有信号量管着呢,当信号量为0的时候,P操作就无法满足,那就会阻塞。对应的其实就是前两条规则,例如,队列为空的时候,spaceSem是大于0的,而dataSem是为0的,那么消费者的P操作就无法成功,那他一定是无法消费数据的,所以此时c就不会超过p。反过来也一样,队列为满的情况,大家自己想一下。而维护生产和消费之间的互斥与同步关系靠的是,刚开始信号量的差异,刚开始设定信号量的时候,就把spaceSem设为队列大小,dataSem设为0,那刚开始的时候,一定是p先走,生产者的P操作会成功,满的时候,自然dataSem就变成了队列大小,而spaceSem变为0,所以此时一定就是c先走,消费者的P操作会成功。这样设定初始信号量的不同就可以在队列为空和为满的时候,保证消费者和生产者之间的互斥与同步关系。


2.

原本的讲解逻辑其实是先给大家搞一个单生产单消费的代码,也就是大部分是生产和消费之间的并发访问,小部分是生产和消费在队列为空和为满时的互斥与同步。因为上面所说的全部话题都是在单生产单消费的模型下讲述的,所以按照321原则来看,现在只有生产和消费的关系,还缺少生产之间和消费之间的关系。


想着是先搞一个存储int数据的环形队列,然后搞成单生成单消费的模型,进阶一点,我们把int数据换为CalTask任务,也就是让ringqueue存储任务对象,但依旧是单生产单消费。最后实现存储任务对象的多生产多消费模型代码。


但是上面那样讲解太繁琐了,毕竟上一篇博客也有了阻塞队列的生成消费模型的基础,所以下面我们也就不那么啰嗦了,直接上存储CalTask任务对象的ringqueue的多生产多消费模型代码。我会将代码的细节讲述清楚的。


3.

我们将底层的代码一般称为设计模式,上层调用的代码称为业务逻辑,所以设计模式一定要和业务逻辑进行解耦,设计模式一定是基于业务逻辑产生的。所以下面我们先来谈上层调用的代码,假设环形队列已经写好了,谈完上层调用的代码后,再根据上层的需求,来回头实现底层的RingQueue.hpp的代码。


在上层中,我们创建出一批生成线程和消费线程,让他们分别执行ProductorRoutine和ConsumerRoutine,生产者构造和获取计算任务,我们通过生成随机数来实现计算任务的构造。


而消费者拿任务和执行任务,也是通过输出型参数的方式来解决。所以其实你可以看到,无论是环形队列还是阻塞队列,上层我们测试的逻辑都是相同的,所以上层这里没什么好说的,关键在于底层的设计模式,底层使用的数据结构也就是321原则中交易场所的差别,让生产消费模型的实现有了差别。

49d6821acc734327858fb636a4795288.png


下面是任务类CalTask的类实现,其实也没什么好说的,这个任务类在阻塞队列的时候我们就已经见到过了,这里也就不过多赘述了。


5a8f645d05c64fb89a794fa8d6d7ebb2.png


4.

还是来谈谈关键的地方吧。环形队列虽然在逻辑结构上是环形的,但实际是通过模运算+数组来实现的环形队列,所以类成员变量,需要一个vector。为了方便更改vector的大小,也就是存储任务的上限,我们搞一个_cap也就是容量,来表示vector最大存储数据的个数。除此之外就是信号量了,生产者或是消费者在生产消费之前都需要申请各自的信号量,如果信号量申请成功,才能继续向后运行,所以信号量的作用其实也就是挂起等待锁的作用。所以还需要两个信号量来分别给生产者和消费者来申请。同时我们前面也说过,生产者和消费者在大部分情况下,访问的小块儿资源都是不同的,如何保证访问的小块儿资源不同呢?实际就是通过数组下标来完成的,所以我们定义出两个下标分别对应生产位置和消费位置。再通过321原则看一下生产消费模型,我们还需要维护生产之间和消费之间的互斥关系,所以我们需要两把锁,保证进入环形队列的只能有一个生产者和一个消费者。这就是基本的类成员变量的设计。

可能有人会有疑问,为什么我要搞成两个信号量呢?一个spaceSem信号量表示空间资源,另一个数据资源,我直接用_cap减去spaceSem不就可以了吗?干嘛要定义两个信号量啊!你说的确实没错!但存在安全隐患,因为减去的操作就不是原子的了,你用_cap减去spaceSem这个过程是线程不安全的。因为只有信号量原生的PV操作才是原子的,才是安全的,如果我们自己徒增许多操作,极大可能是非原子的,所以既然我们都有信号量了,并且人家信号量的操作本身就是原子的,操作起来是线程安全的,那何不多定义几个信号量呢?有利无害啊!


5.

在初始化信号量的时候,我们刚开始就将spaceSem设置为环形队列大小,dataSem设置为0,sem_init的第二个参数代表线程间共享,也就是说生产线程之间共享spaceSem信号量,消费线程之间共享dataSem信号量。

最为重要的两个接口就是Push和Pop,拿Push来说,首先我们进行P操作,申请spaceSem信号量,申请成功之后要进行加锁操作,因为我们需要保证生产者之间是互斥访问ringqueue的,然后就是在_productorStep的位置进行任务对象的插入,_productorStep有可能会超过_cap,所以还需要%=_cap,然后就需要释放锁,最后V操作的时候,需要注意的是V操作的是dataSem信号量,因为你生产数据之后,数据资源不就有了吗?那dataSem就应该变多。Pop的操作正好是和Push的操作反过来的,先申请dataSem信号量,最后释放spaceSem信号量。


下面这个问题是当初实现接口时遇到的问题,图片中放的代码已经是优化好之后的代码了。

c085a6ad672e41d7a37127b9ccc760d9.png


c56dae9193994888a0405680eed32d34.png


6.

下面是单生产单消费下的运行情况,可以看到如果是单生产单消费,他的运行结果和条件变量非常非常的相似,当生产者在sleep(1)时,打印出来的结果非常的有顺序性,那这是为什么呢?

其实信号量的实现原理和条件变量是一样的,只不过条件变量是通过wait和signal来实现线程间同步与互斥的,,而信号量是通过wait和post来实现线程间同步与互斥的,wait和post实际就是信号量的PV操作,也是PV原语。

所以信号量其实就是条件变量+手动判断资源就绪状态,条件变量解决饥饿问题就是通过唤醒其他线程来实现的,而信号量解决饥饿问题其实也是间接通过唤醒其他线程来实现的,只不过信号量这里不是唤醒,而是释放其他线程的信号量,也就是V操作其他线程的信号量,一旦V操作了其他线程的信号量,那么只要其他P操作还在阻塞的线程,立马就不会阻塞了,他们立马就可以申请信号量成功,然后竞争锁,进入临界区。

不过与之前条件变量实现的阻塞队列不同的是,之前的阻塞队列用的是一把锁,所以无论什么时候都只能串行访问,而今天的环形队列用的是两把锁,生成和消费之间是互不影响的,他们没有理由同时使用一把锁,所以他们效率就会高一些,生产和消费之间是可以并发+并行的运行的,也就是消费在竞争到锁cmutex,进入临界区拿走数据的同时,生产者也可以竞争到pmutex,进入临界区生产数据。唯一需要互斥的就只有生产之间和消费之间需要互斥。

40ae48b2085b487d911edbc0be8c8e24.png

7.

下面是多生产多消费情况下的打印结果,当然什么也看不出来哈,只能看到一堆消费线程和生产线程在疯狂打印着自己的生产和消费提示信息。

但我们心里能够清楚的意识到,生产之间他们被_pmutex锁住了,所以生产之间是互斥访问的,消费同样如此,另外我们通过信号量能够实现单个生产和单个消费之间的同步与互斥关系,能够避免出现数据竞争,死锁等问题。


(其实我自己当时有一些问题产生,例如当生产者之间互相竞争锁的时候,不会产生饥饿问题吗?实际是有可能出现的,但出现饥饿问题的概率很小,我们可以不考虑这个饥饿问题,因为我们所写的代码并不能完全保证生产者线程之间是公平调度的,因为操作系统的调度策略可能导致某些线程获得更多的执行时间,但这并不是由这段代码直接导致的。换句话说,我们所写的代码不太可能出现生产者线程的饥饿问题。但是如果你对线程调度的公平性有严格的要求,可以使用条件变量或其他更为高级的同步机制来实现,条件变量实际上算是一种很公平的同步机制了,他能让所有线程都去排队式的来条件变量中进行等待,直到其他线程将其唤醒,然后被唤醒的线程会去申请锁,而不会出现饥饿问题。但在我们上面所写的代码中暂时不用考虑生产线程之间或者是消费线程之间的饥饿问题。)


b803e3ab539341e48c03f9adcf9cfae6.gif


8.

最后一个话题就是老套路了,和当时阻塞队列实现的生产消费模型最后提出的问题一样,我们这里就相当于再回顾一下。那既然进入环形队列的线程大部分情况下也就只能进入一个生产一个消费,那我们创建多生产多消费的意义是什么呢?其实道理还是类似的,放任务和拿任务并没有那么耗时,真正在多任务处理的情况中,获取任务和执行任务才是非常耗时的!而对于计算机来说,多任务处理的场景又非常的常见,所以很需要多线程之间的协调工作。而生产消费模型高效在,获取任务和执行任务的线程之间在协调处理多任务的时候,不会出现数据竞争,死锁等安全问题,同时某个线程在消费或生产任务的同时,并不会影响其他线程获取或执行任务,所以总体来看,多线程之间还是并发+并行的获取和执行任务,但为了保证多线程的安全性,我们加了一个交易场所,保证共享资源的安全,维持多线程的互斥与同步关系,让多线程能够更好的适用于多任务处理的场景。

a228e729fd5c4697801f1dc768942359.png



二、线程池

1.池化技术和线程池模型


1.

实际线程池并不难理解,因为大部分时间内,计算机都面临着多任务处理的难题,而多线程协调处理多任务的场景也就司空见惯了,当任务的数量比较多,并且要求迅速响应任务处理的情况下,如果现去创建多线程,现去处理任务,那就比较晚了,因为创建线程那不就是执行pthread库的代码吗?而在linux中,pthread库的代码又是封装了底层的系统调用,所以还需要将页表切换为内核级页表,将代码跳转到内核空间执行内核代码,处理器级别的切换等等工作,这些不都需要花时间吗?如果客户对性能要求苛刻,要求你迅速响应的话,那上面那种现创建线程的方式就有点晚了!所以像线程池这样的技术本质其实就是提前创建好一批线程,让这些线程持续检测任务队列中是否有任务,如果有,那就唤醒某个线程,让他去拿这个任务并且执行,如果没有,那就让线程挂起等待,我操作系统就一直养着你,等到有任务的时候再唤醒你,让你去执行。

那这样池化的技术本质还是为了应对未来的某些需求,能够提升任务处理的效率。


实际生活中也不乏这样的池化技术,例如疫情期间,大家都屯物资,这是为什么呢?这不也是为了应对将来疫情封控严重,大家都出不了门,到时候没人卖日常的生活用品了,我们能够拿出来自己屯的物资吗?那如果我们不屯物资,等到疫情封控最严重的时候,再出去买菜买肉什么的,这是不就晚了啊?或者说你去某些饭店吃饭,你和老板说我要吃西红柿炒鸡蛋,老板说没问题,你先等一会儿,我去村口的菜园里摘点儿西红柿,然后再去养鸡场蹲母鸡,等她下出来蛋后,我拿着西红柿和鸡蛋给你做,那要是等老板做完菜,你是不早就饿过头了啊!所以老板这样的方式是不也有些晚了啊?正确的做法应该是老板提前屯一些西红柿和鸡蛋,你点菜的时候,老板能够直接拿出来给你做,这些其实都是我们生活中的池化技术。

7592a7535b9542d9937a7f18c9ef5e5f.png


2.

而内存池也是一种池化技术的体现,当我们在调用malloc或new申请堆空间的时候,实际底层会调用诸如brk,mmap这样的系统调用,而执行系统调用是要花时间的,所以内存池会预先分配一定数量的内存块并将其存储在一个池中,以便程序在需要的时候能够快速分配和释放内存,这能够提高程序的性能和减少内存碎片的产生。

c1a221f380c2400e9e54d718d28291c3.png


3.

线程池模型实际就是生产消费模型,我们会在线程池中预先准备好并创建出一批线程,然后上层将对应的任务push到任务队列中,休眠的线程如果检测到任务队列中有任务,那就直接被操作系统唤醒,然后去消费并处理任务,唤醒一个线程的代价是要比创建一个线程的代价小很多的。

而实际下面线程池的模型不就是我们一直学的生产消费模型吗?那些任务线程就是生产者,任务队列就是交易场所,处理线程就是消费者。所以听起来高大上的线程池本质还是没有脱离开我们一直所学的生产消费模型,所以实现线程池顶多在技巧和细节上比以前要求高了一些,但在原理上和生产消费模型并无区别。6f883fc85f5045ae9ded7c60cdf36bec.png


6f883fc85f5045ae9ded7c60cdf36bec.png6f883fc85f5045ae9ded7c60cdf36bec.png


2.饿汉与懒汉两种单例模式


1.

在IT行业里,大佬们和菜鸡的两极分化比较严重,牛逼的是真牛逼,垃圾的是真垃圾,所以大佬们对于一些经典的常见的应用场景,做出解决方案的总结,这样针对性的解决方案就是设计模式。

而单例模式就是大佬总结出来的一种经典的,常用的,常考的设计模式。

单例模式就是只能有一个实例化对象的类,这个类我们可以称为单例。而实现单例的方式通常有两种,分别就是懒汉实现方式和饿汉实现方式。

举一个形象化的例子,懒汉就是吃完饭,先把碗放下,然后等到下一顿饭的时候再去洗碗,这就是懒汉方式。而饿汉就是吃完饭,立马把碗洗了,下一顿吃饭的时候,就不用再去洗碗了,而是直接拿起碗来吃饭,这就是饿汉实现方式。

虽然生活中懒汉还是不太好的,因为生活比较乱和邋遢。但在计算机中,懒汉方式还是不错的,懒汉最核心的思想就是延迟加载,这样的方式能够优化服务器的速度,即为你需要的时候我再给你分配,你现在还用不着,那我就先不给你分配,这就是延迟加载。


2.

像饿汉这样的方式,实际是非常常见的,因为延时加载这样的管理思想对于软硬件资源管理者OS而言,实际是很优的一种管理手段。就比如平常的malloc和new,操作系统底层在开辟空间的时候,实际并不是以饿汉的方式来给我们开辟的,而是以懒汉的方式来给我们开辟的。等到程序真正访问和使用要申请的内存空间时,会触发缺页中断,操作系统此时知晓之后才会真正给我们在物理内存上开辟相应申请大小的空间,重新构建虚拟和物理的映射关系,返回对应的虚拟地址。

9e6a04c9d1f749149d18e2531eed4ca0.png


3.

实现饿汉遵循的一个原则就是加载时即为开辟时,什么意思呢?就是在类加载的时候,类的单例对象就已经存在于虚拟地址空间中了,并且物理内存中也有单例对象所占用的内存空间。实现起来也比较简单,即在类中提前私有化的创建好一个静态对象,当然这个静态对象也是这个单例类唯一的对象,要实现对象的唯一还需要私有化构造函数,delete掉拷贝构造和赋值重载成员函数。类外使用单例对象时,即通过类名加静态方法名的方式得到单例对象的地址,从而访问其他类成员方法。

实现懒汉遵循的一个原则就是需要时即为开辟时,什么意思呢?就是在类加载的时候,类的单例对象并不会给你创建,而是当你调用GetInstance()接口的时候,才会真正分配单例对象的堆空间,这就是典型的懒汉实现方式。(右边的懒汉方式实现单例模式是线程不安全的,解决这种不安全的话题放到实现懒汉版本的线程池那里,我会详细说明线程安全版本的懒汉是如何实现的。)

6959386c70654c9ea9ea270143496318.png

3.单例模式的线程池代码(线程安全的懒汉实现版本)


1.

下面我们实现的线程池,实际是一个自带任务队列的线程池,其内部创建出一大批线程,然后外部可以通过调用Push接口来向线程池中的任务队列里push任务,线程在没有任务的时候,会一直在自己的条件变量中进行等待,当上层调用push接口push任务时,线程池所实现的push接口在push任务之后会调用signal唤醒条件变量下等待的线程,当线程被唤醒之后,就会pop出任务队列中的任务并执行他,这实际就是消费过程。而且由于我们要实现单例版本的线程池,所以还需要提供getInstance接口来获取单例对象的地址,外部就可以通过对象指针来调用ThreadPool类的push接口,进行任务的push。

我们通过vector来管理创建出的线程,通过queue来作为任务队列,由于任务队列是消费者和生产者共同访问的,所以任务队列也需要被保护,所以我们通过互斥锁mutex来保证任务队列的安全,另外我们再定义出一个变量num表征线程池中线程的个数,线程在没有任务的时候需要等待,所以还需要一个cond,为了实现线程安全的懒汉单例模式,不仅需要定义出静态指针tp,还需要一把互斥锁singleLock来保证静态指针的安全性,因为可能多个线程同时进入getInstance创建出多个对象的实例。不过这个互斥锁我们不再使用pthread原生线程库的互斥锁,而是用C++11线程库的mutex来定义互斥锁。


2.


A. 对于构造函数来说,需要初始化好线程个数,以及创建出对应个数的线程,并将每个线程对象的地址push_back到vector当中,除此之外还要初始化好cond和mutex,因为他们是局部的。需要注意的是,我们用的是之前封装好的RAII风格的线程类来像C++11那样管理每个线程对象,所以一旦线程池对象被构造,那每个线程对象也就会被构造出来,在构造线程对象的同时,线程就会运行起来,执行对应的线程函数。这就是RAII风格的线程创建,当对象被创建时线程跑起来,当对象销毁时线程就会被销毁,即为在对象创建时资源被获取初始化,在对象销毁时资源被释放回收。


B. 对于析构函数来说,当线程池对象被销毁时,要销毁destroy cond和mutex,其他成员变量编译器会调用他们各自的析构函数,我们不用担心。


C. 所以紧接着我们就应该实现线程函数,因为一旦线程池对象被初始化,线程就会跑起来执行线程函数,我们的线程函数实际就是来执行任务的,所以线程函数命名为handler_task,实现handler_task需要解决的第一个问题其实就是传参,如果handler_task是类成员函数,那么他的参数列表会隐含一个this指针,所以在调用RAII风格的线程构造函数时,会发生参数不匹配的错误,解决方式也很简单,只要将handler_task设置为static成员函数即可解决传参的工作。


实现handler_task第一件事实际就是加锁,因为我们需要保证访问任务队列的安全性,所以就需要加锁,并且为了实现任务线程和处理线程之间的同步我们还需要在条件变量中wait,等到被唤醒时再去拿任务队列中的任务并执行,但是上面所说的一切操作都需要访问类成员变量,而handler_task是一个静态方法,静态成员无法访问非静态成员,线程对象的内部还有返回线程名的接口叫做threadname(),线程在执行任务的时候我还想看到是哪个线程在执行任务,所以在执行任务前我想调用threadname()接口,想要实现上面的操作,我们不得不传一个结构体threadText到线程函数里面,结构体中包含线程对象

指针和线程池对象指针,通过传递包含这两个指针的结构体就能完成上面我们所说的一系列操作。我们要保证临界区的粒度足够小,所以执行任务,也就是调用可调用任务对象CalTask的()重载函数,就应该放在临界区外面,因为临界区是保护任务队列的,既然任务已经取出来了,那其实没必要继续加锁保护,所以t()应该放在临界区外面。至于加锁的操作,除我们自己在类内封装一系列接口的使用方式外,还可以直接调用LockGuard.hpp里面同样是RAII风格的加锁,即在对象创建时初始化所,对象销毁时自动释放锁。


D. 然后就是Push接口,可以看到在Push接口里面,我便使用了RAII风格的加锁,当离开代码块儿的时候锁对象lockGuard会被销毁,此时互斥锁mutex会自动释放,将任务push到队列之后,便可以唤醒处理线程,线程会从cond的等待队列中醒来并重新被调度去执行生产者生产的任务


E. 最后需要实现的接口就只剩单例模式了,因为getInstance()可能会被多个线程重入,有可能会构建出两个对象,这样就不符合单例模式了,并且在析构的时候还有可能产生内存泄露的问题,所以我们要对getInstance()接口进行加锁,保证只有一个线程能够进入getInstance实例化出单例对象,当某一个线程实例化出单例对象之后,之后剩余的所有线程进入getInstance时,if条件都不会满足,但是这样的效率有点低,因为后面的线程如果进入getInstance时,还需要先申请锁,然后才能判断if条件,那我们就直接双重判断空指针,提高判断的效率,后面的线程不用申请锁也可以直接拿到单例对象的地址,这样效率是不是就高起来了呢?


除此之外在声明单例对象的地址时,我们应该用volatile关键字修饰,我们直到volatile关键字是用来保持内存可见性的,因为在某些编译器优化的场景下,可能会由于只读取寄存器的值,不读取内存的值而造成判断失误,从而产生一系列无法预知的问题,所以为了避免这样的问题产生,我们选择用volatile关键字来修饰单例对象的静态指针。(假设10个线程都想获取单例对象的地址,代码中_tp一直没有被使用,所以编译器可能直接将_tp开始为nullptr的值加载到寄存器中,也就是加载到当前CPU线程的上下文中,如果之前某个线程已经new过了单例对象,那么当前CPU在判断_tp是否为nullptr的时候,他不拿物理内存的值,而是选择判断寄存器的值时,就会发生第二次实例化对象,所以我们要用volatile关键字来修饰_tp.)


除此之外还要delete掉成员函数,例如拷贝构造和拷贝赋值这两个成员函数,避免潜在的第二次实例化单例对象发生。

f2fdea9bbb2e48f7936df111d38e2896.png


3.

下面就是RAII风格的封装线程create,join,destory的小组件Thread.hpp,在调用pthread_create的时候,也遇到了this指针传参不匹配的问题,我们依旧是通过static修饰类成员方法来解决的,当然在静态方法里还是会遇到相同的问题,那就是没有this指针无法调到其他的类成员函数,所以还是老样子,定义一个结构体保存this指针和线程函数的参数,将结构体指针传递给线程函数,线程函数内实际就是解包一下,拿出this指针,回调包装器_func包装的线程池中处理线程执行的handler_task方法。


除了构造和start_routine有点绕之外,其他函数都是简单的对pthread库中原生接口的封装,大家简单看一下就好,这个RAII风格的线程管理小组件实现起来还是比较简单c628a86146834b218fb26b9db44fde9f.png4.

下面已经是老熟人了,我们实现的阻塞队列版本和环形队列版本的生产消费模型一直在用这个任务组件,这个任务组件无非就是构造好一个任务对象,然后在实现一个返回任务名的函数,以及一个可调用对象的()重载,我们不再赘述,大家看一下就行。

5723a7ca1b0f4c0fb6f6678288ee7c4b.png


c628a86146834b218fb26b9db44fde9f.pngc628a86146834b218fb26b9db44fde9f.pngc628a86146834b218fb26b9db44fde9f.png

5.

下面是RAII风格加锁和解锁的小组件LockGuard.hpp,由外部传进来一把锁,组件负责做加锁和解锁的工作,下面实现的时候做了多层封装,其实没啥用,只做一层封装也可以实现加锁和解锁的RAII风格的操作。


cebdee676a5848668e782b75d900d318.png


6.

下面就是上层调用逻辑,获取单例对象地址,然后通过地址来调用Push接口去push任务,没什么好说的,只不过我们实现了一种用命令行式来构建任务的方式。1077edb9d29242ea8e1d143666107c38.png


三、自旋锁

1.自旋锁vs挂起等待锁



1.

除我们之前讲的互斥锁,信号量,条件变量这样的互斥和同步机制外,还有很多其他的锁,例如悲观锁,乐观锁,但这样的锁只是对锁的作用的一种概念性的统称,是非常笼统的。另外悲观锁的实现方式:CAS操作和版本号机制,这些其实稍微知道一下就行,我们主要使用的还是互斥锁信号量以及条件变量这样的方式,有这些其实目前已经够用了。

但还需要深入知道一些的是自旋锁和读写锁,这样的锁平常我们不怎么用,但属于我们需要掌握的范畴,了解自旋锁和读写锁之后,基本上就够用了。a4d2e1d4d3b947c1ba9d3393a2ace748.png


a4d2e1d4d3b947c1ba9d3393a2ace748.png


2.

以前我们学到的互斥锁,信号量这些,一旦申请失败,线程就会被阻塞挂起,我们称这样的锁为挂起等待锁,因为线程需要去PCB维护的等待队列中进行wait,直到锁被释放。

而自旋锁如果申请失败,线程并不会挂起等待,它会选择自旋,循环检查锁的状态是否被释放,这种方式可以减少线程上下文切换时所带来的性能开销,但同时也会带来CPU资源的浪费,因为你这个线程一直霸占CPU不断轮询锁的状态,CPU无法调度其他线程了就。

所以使用挂起等待锁和自旋锁,主要依据就是线程需要等待的时间长短,或者说成是申请到锁的线程在临界区中待的时间长短,如果时间较长,那么选择挂起等待锁来进行加锁保护临界资源的方案就比较合适,如果时间较短,那么选择自旋锁不断轮询锁的状态,用自旋锁来进行临界资源的保护方案就比较合适。


3.

紧接着带来的问题就是,我们该如何衡量时间的长短呢?又该如何选择更加合适的加锁方案呢?

时间长短其实没有答案,因为时间长短是需要比较才能得出的结论,而选择什么样的加锁方案,实际还是要看具体的场景需求。

一般来说临界区内部如果要进行复杂计算,IO操作,等待某种软件条件就绪,大概率我们是要使用挂起等待锁的。如果只进行了特别简单的操作,例如抢票逻辑,临界区的代码很快就能被执行完,那使用自旋锁就会更加的合适。

但其实大部分情况下,我们还是用挂起等待锁,因为别看自旋锁看起来好像要快一些,一旦时间评估失误,那申请自旋锁的线程就会大量的消耗CPU资源,在多任务处理的情景下,效率就会降低。除此之外,自旋锁出现死锁的时候,问题要比挂起等待锁更为严重!如果一个线程申请互斥锁时出现了死锁,那大不了就是执行流阻塞不再运行了,但CPU没啥事啊!而自旋锁出现死锁时,则会永久性的自旋轮询锁的状态,并且不会从CPU上剥离下去,那么CPU资源就会被一直占用着,无法得到释放,问题很严重!

当然如果你实在不知道选择哪种方案的话,可以先默认使用挂起等待锁,然后比较挂起等待锁和自旋锁的效率谁高,哪个高就选择哪个方案即可。


4.

自旋锁的操作也并不难,因为因为这些锁用的都是POSIX标准,所以使用起来很简单,直接man手册即可。

b79c85d8ad0c4512b1a17d8344870b88.png



2.智能指针和STL容器是否是线程安全的呢?


8b6bdcc8854740898ba33a91791d405a.png


四、读写锁

1.读者写者模型(321原则)

1.

除生产消费模型之外,还有非常经典的一个模型,就是读者写者模型,实现读者写者模型的本质其实也是维护321原则,即读者之间,读者与写者,写者之间,以及1个交易场所,这个交易场所一般都是数组,队列或者是其他的数据结构等等。

读者就相当于消费者,写者就相当于生产者,但读者之间并不是互斥的了,因为他与消费者最根本的区别就是读者不会拿走数据,也就是不会消费数据,读者仅仅是对数据做读取,不会进行任何修改操作,那么共享资源也就不会因为多个读者来读的时候出现安全问题,都没人碰你共享资源,你能出啥子问题嘛!所以读者之间没有任何的关系,不想消费者之间是互斥关系,因为每个消费者都要对共享资源做出修改,但我读者不会这么做,我只读不改。

而写者之间肯定是互斥的关系,因为都对共享资源写了!那他们之间不得互斥啊!要不然共享资源出了问题咋办!读者和写者之间也是互斥与同步的,当读者读的时候,你写者就不要来写了,要不然读者读到的数据都被你写者给覆盖掉了!当写者来写的时候,你读者就不要来读了,你读到的数据都是不完整的,读个啥嘛!所以他们之间是互斥的,但当读者写完的时候,如果想要数据更新,那就应该让写者来写了,同样当写者写完的时候,那你读者就应该来读了。所以读者和写者之间是互斥与同步的关系,既要互斥保证临界资源的安全,又要同步协调完成整个任务的处理!


2.

那一般什么场景适合用读者写者模型呢?例如一次发布数据,很长时间都不会对数据做修改,大部分时间都是被读取的场景,例如生活中的写blog,我写blog是不是大部分时间都在被别人读取呢?只有blog出错的时候,我可能才会重新去修改blog,但大部分时间blog都是被读取的。又或是媒体发布新闻,当新闻被发布的时候,大部分时间新闻也都是被读取的,较小部分时间才会对发布的新闻做修改,或者都有可能不做修改。那么对于这样的场景,使用读者写者模型就比较合适了。


3.

像实现生产消费模型时,我们一般都会通过cond mutex semaphore这样的方式实现blockqueue又或是ringqueue的生产消费模型。

那实现读者写者模型时,是不是也应该有对应的机制呢?当然是有的,pthread库为我们实现了读写锁的初始化和销毁方案,同时也实现了分别用于读者线程间和写者线程间的加锁实现,以及读者写者统一的解锁实现。5f0670ac915d4b24858c324b92106c4c.png


2.读锁写锁申请的原理(读锁共享,写锁互斥)

1.

下面的表格总结了读锁以及写锁被请求时,其他线程的行为。

值得注意的是,多个读者之间可以同时获取读锁,并发+并行的进行读操作,在设计读写锁语义的时候就是这么设计的,它允许多个读者之间共享读写锁,并发+并行的进行读操作。这是读写锁的设计语义。

而对于写锁来讲,那就是典型的互斥锁语义。616783d6bd1040af8d0aab94175ecc41.png


616783d6bd1040af8d0aab94175ecc41.png

2.


读写锁实现的原理如下,当只要出现一个读者申请到锁之后,它会抢写者的锁wrlock,所以在多个读者进行读取的时候,reader_count这个计数器就会一直增加,并且在读者读取数据期间,写者由于无法申请到wrlock就会一直处于阻塞状态,写者无法执行写入的代码逻辑,会阻塞在自己的lock(&wrlock)代码处。但读者之间是可以共享rdlock的,等到所有的读者都读完之后,也就是reader_count变为0的时候,读者线程才会释放wrlock,此时写者才能申请到wrlock进行写入。


如果是写者先申请到锁的话,读者能进行读取吗?当然不能!当写者申请到锁执行写入代码的时候,第一个来的读者会阻塞在lock(&wrlock)代码处,因为此时wrlock已经被写者拿走了,你读者想抢的时候,是抢不到的,你只能阻塞。


但是wrlock和rdlock不一样,wrlock是不共享的,所以如果有写着想要申请wrlock的话,和读者下场一样,都会阻塞!


这就是我们所说的,读者读取的时候,写者不能写入,读者可以共享读锁。写者写入的时候,读者和其他写者都无法继续执行自己的代码。

b725a14b574f482b95f2953b77d883f9.png

c0cc05d72380461c8445e4e5af6556d9.png


3.读者/写者优先(默认读者优先)


1.

最后需要谈论的就是读者和写者优先的话题。我们上面所实现的伪代码默认其实是读者优先的。

那有人会问,假设读者特别多的话,由于第一个读者执行代码逻辑的时候,就已经把写锁抢走了,那后面无论来多少读者,我的写者都无法执行写入,因为写者无法申请到rwlock,那就无法进入自己代码的临界区,那是不是就有可能造成写者线程的饥饿问题呢?

当然是有可能的!但出现写者线程饥饿的问题是很正常的事情,因为读者写者模型本身就是大部分时间在读取小部分时间在写入,那出现写者线程饥饿本来就很正常嘛!

所以我们默认读者优先,如果读者一直来,那你写者就一直等!


2.

那如果我就想让写者优先呢?他其实是可以实现的,比如10个读者要读,现在已经5个读者在执行读取的临界区代码了,那写者线程此时就阻止后面的5个线程继续执行读者临界区的代码,等到前面5个读完,reader_count变为0了,此时我写者要申请rwlock了,我先写,等我写完,你们后面5个读者再来读。

原理大概就是上面那样,但写者优先的策略比较难写出来,我们就不写了,知道有读者写者优先这个话题就行!


下面是设置读写优先的接口pthread_rwlockattr_setkind_np(),np指的是non-portable不可移植的。

bae5474162b94be8aaf9d9274ee3be66.png










相关文章
|
9天前
|
Linux 数据库
Linux内核中的锁机制:保障并发操作的数据一致性####
【10月更文挑战第29天】 在多线程编程中,确保数据一致性和防止竞争条件是至关重要的。本文将深入探讨Linux操作系统中实现的几种关键锁机制,包括自旋锁、互斥锁和读写锁等。通过分析这些锁的设计原理和使用场景,帮助读者理解如何在实际应用中选择合适的锁机制以优化系统性能和稳定性。 ####
26 6
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
43 1
C++ 多线程之初识多线程
|
24天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
18 3
|
24天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
16 2
|
24天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
28 2
|
24天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
28 1
|
24天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
34 1
|
24天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
24 1
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
45 6
|
1月前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
40 1