一、生产者消费者模型(重点)
如图,在生活中,学生就是消费者角色,工厂是真正的生产者角色,那么超市是什么呢?为什么需要超市?超市是交易场所。我们的家附近不一定有工厂,而且工厂的定位是大规模生产,我们也不可能找工厂生产 5 包方便面,如果工厂也承担了超市的角色,它就不仅要考虑生产的任务,还要考虑并收集消费者的需求,实际对工厂是一种负担,有了超市就可以将生产环节和消费环节进行了解耦(这里的解耦是一种松耦合关系,比如你找工厂生产 5 包方便面,工厂立马生产,预计 1 个小时,此时你正在等待,当工厂生产好后,你拿到了方便面,此时当没有人来消费时,工厂正在等待,这是串行。换而言之,工厂永远是学生来了需求就满足需求,没有需求就等待不生产,工厂和学生互相等待,这就叫做强耦合关系。而超市出现后,工厂就一点也不关心学生要几包方便面了,而只要把方便面生产出来放进超市任务就完成了,学生要方便面的时候直接去超市买就行了。也就是说,就算没有学生消费,工厂也会生产;就算没有工厂生产,学生也会消费;学生也不用在等工厂生产了;工厂也不用在等学生消费了;工厂生产的很快,学生消费的很慢,超市就可以让工厂生产的慢些;学生消费的很快,工厂生产的很慢,超市就可以让工厂生产的快些,此时消费和生产就可以同时进行了,这叫并行,此时学生和工厂就叫做松耦合关系)。
生产者消费者模型遵守 321 原则(仅仅为了方便记忆):
-
3 种关系:
- 生产者和生产者 —— 竞争或互斥
- 消费者和消费者 —— 竞争或互斥
- 生产者和消费者 —— 同步和互斥
超市可以被生产者和消费者同时访问,所以超市是一种临界资源。消费者和生产者都可以有多个,所以多个生产者之间一定是竞争关系,多个消费者之间也是竞争关系(假设世界末日当天超市只有一包方便面,你和你的室友都想买,这就是竞争关系,而在计算机中表述竞争关系就是互斥。工厂和学生是同步和互斥的关系(比如学生去超市问售货员买方便面,售货员说卖完了,过了十分钟又去问售货员买方便面,售货员又说卖完了,反反复复,因为没有人主动通知学生,所以学生只能通过轮询检测的方式去检测超市里有没有方便面,这样没错,但是不合理。相反,工厂生产的特别快,去超市补货,因为工厂并不知道超市还有没有空间,所以就反反复复的检测,这样没问题,只是不合理。一定是有了商品让消费者过来,有了空间让生产者过来。所以最合理的状态是工厂供货完,让学生过来消费,学生消费完,让工厂过来供货,这就是同步。当然光有同步还不够,因为有可能出现工厂正在供货,学生就过来消费了,此时工厂可能是供货前 / 中 / 后,所以同前面苹果的例子,可能会出现二义性问题,所以还需要互斥)。
-
2 种角色:
- 生产者
- 消费者
一般进行生产和消费的是线程或进程。
-
1 种交易场所:超市。在编码上通常指的是一段内存空间,它是临界资源(它一定是在内存中开辟的,一个线程往其放数据,另一个线程往其拿数据,所以它可能是你自己定义的数组、队列、链表、管道…)。
1、为何要使用生产者消费者模型?
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
2、生产者消费者模型优点
- 解耦。
- 支持并发。
- 支持忙闲不均。
3、基于 BlockingQueue 的生产者消费者模型
BlockingQueue 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。
Thread1 是生产者,Thread2 是消费者。如果在生活中来回搬运的是方便面产品,那么在计算机中被进行搬运最多的一定是数据,而这里只有一个生产者和一个消费者,所以不用维护生产者和生产者、消费者和消费者之间的互斥关系。而所谓的阻塞队列,队列是有上限的,当队列不满足生产或者消费条件的时候,对应的线程就应该阻塞,具体在多线程编程中阻塞队列是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出。简单来说就是 BlockingQueue 空就不让消费者消费,满就不让生产者生产。 (以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时也会被阻塞)。
4、C++ queue 模拟阻塞队列的生产消费模型【代码】
.hpp 文件我们在 C++ 模板中谈过,它是 C++ 程序的头文件。可以认为它既是 .h,也是 .cpp,其中就包含了声明和实现,这种文件通常用于模板类的头文件。
对于 push 和 pop 的参数,这里有一个命名规则,通常如果是 const Type& 就是输入型参数;如果是 Type* 就是输出型参数;如果是 Type& 就是输入输出型参数,并简单实现了生产的过程和消费的过程。
至此代码的基本结构就搭建好了:
- makefile 实现好了自动化构建项目。
- BlockQueue.hpp 文件中封装了一个 BlockQueue 类,并使用了类模板,bq_ 是这个队列(使用 STL 下的 queue 定义),capacity 是 bq_ 的容量,显示定义了构造函数(初始化列表初始化 capacity)和析构函数。ConProd.cc 文件是主函数的实现,new 好 bqeueu 空间后,创建了两个线程,去执行对应的 consumer 和 producter 方法,并把 bqueue 队列传入,然后等待两个线程,最后 delete bqueue 空间。
前面已经完成了互斥,但是这时程序一运行,可能 producter 一瞬间就把阻塞队列写满了,consumer 没时间读取,或者 consumer 一瞬间就把阻塞队列读完了,producter 没时间写入。根本原因是如果队列满了,就不应该再写入了,队列空了,就不应该再读取了,而应该阻塞住,所以这里出现了段错误是因为没有对它的满和空做处理,所以需要条件变量,不过在此之前先实现 isQueueEmpty() 和 isQueueFull(),先让这里的互斥操作是可以正常跑的,就是让队列不满时才可以生产,不空时才可以消费,否则就阻塞。
生产一批数据, 再消费数据:
让消费的慢一些:
实现条件变量:生产者应该关注 Empty_ 条件变量,消费者应该关注 Full_ 条件变量,然后在构造函数内初始化,析构函数内销毁。这里对生产和消费的过程修改:生产过程中若队列满就不应该生产,而应该在 Empty_ 条件变量下等待,而一旦等待就会被挂起阻塞,可是当前是在临界区内部,消费线程不可能去消费,更不可能去唤醒生产线程中等待的线程(帮助理解 pthread_cond_wait 为什么要设计第二个参数互斥锁 mutex),因为它是带着锁挂起阻塞的,那么如果这样执行,程序被会卡住在这,所以 pthread_cond_wait 在设计时就加上了互斥锁,这样阻塞挂起时会自动释放锁,唤醒时会自动获取锁。也就是说 pthread_cond_wait 会阻塞挂起的同时,然后把锁释放,当你唤醒时,会自动帮你竞争上锁,这也就是为什么 pthread_cond_wait 第二个参数需要带一把锁的原因。
再往下看,if 外一定是有空间,要么是 IsFull() 为假,要么是被唤醒了。同样,对于消费过程若队列空就不应该消费,而应该在 have_data 条件变量下等待。if 外一定是有数据的,要么是 IsEmpty() 为假,要么是被唤醒了。
此时生产过程或消费过程中条件满足就阻塞挂起等待,那么是由谁来唤醒呢?
当然不能是自己唤醒自己,因为你并不知道自己还要不要生产或者消费,而应该是当消费者挂起时,只有生产者才知道有没有数据;当生产者挂起时,只有消费者才知道有没有空间。所以还需要 pthread_cond_signal 来唤醒对方,那么同步最理想的状态就应该是生产完后唤醒消费者,消费完后唤醒生产者。对于 pthread_cond_signal 写在锁内锁外都可以(比如生产者中在锁内唤醒消费者,消费者就从条件变量下醒来 (pthread_cond_wait(&have_data, &lock)),这样它就会直接在锁内等待了,然后会自动释放锁,唤醒时又自动获取锁。同理锁外也是一样)
ConProd.cc 中设计测试用例:阻塞队列的容量是 gDefaultCap = 10,消费者中消费的很慢,1 秒消费 1 次,生产者中生产的很快(由调度器决定,没有加以限制);生产者生产的很慢,1 秒生产 1 次,消费者消费的很快(由调度器决定,没有加以限制)。所以这里的现象是生产者一瞬间就把数据生产完放在阻塞队列中,1 秒内生产和消费都不进行,后来消费者每 1 秒消费一次,生产者又生产一次,反反复复;生产者生产一条,消费者才消费,反反复复。这里可以让生产者生产数据慢点,如 sleep 1,此时的现象就是前 2 秒生产者一直在生产,2 秒后,消费者一瞬间把所有数据都消费走,反反复复。这里还要说明的是 pthread_cond_wait 可能会伪唤醒,比如说生产者中队列是满的,却因为某些原因被唤醒,此时往下执行 push 就会出错,换言之,在唤醒时,条件不一定会满足。此时就将 if 改成 while 就可以预防伪唤醒了,因为 while 本身也有判断的能力,此时当这里被唤醒时,就会再检查一次这里是否为满,如果为满,则继续等待,如果不为满,就生产。所以加上 while 后,不管是正常唤醒还是异常唤醒都会多做一次条件检测,从而让代码更健壮。伪唤醒可以理解:pthread_cond_wait 是系统调用函数,当然可能会失败,所以失败后就返回继续往下走。一般单核处理器出现伪唤醒的可能性比较低,大多数都是多核处理器中,大家下来也可以找找原生线程库中关于这块的官方描述。
多生产者,多消费者,还需要额外维护多生产者之间和多消费者之间的互斥关系,需要提前于生产者和消费者之间竞争临界资源。实际这份代码体现不出来并行执行的过程和生产和消费的目的,我们费这么大劲把数据通过缓冲区从一个线程跑到另一个线程,这不闲着蛋疼嘛,根本原因是队列里写的是 1 2 3 这样的整数数据用于测试。换而言之,如果 Blockqueue 内不是原生数据,而是某种任务,而后面还要对这个任务进行计算,比如消费者在执行某种任务时,生产者同时也在生产数据,这会在信号量环形队列中体现。
也有可能对方调用的是 pthread_cond_broadcast(),可能生产者只生产了一条数据,但是却唤醒了所有的消费者线程,比如一个线程比我先被唤醒,它竞争锁成功了,然后去消费,而我竞争锁失败了,此时我可能就不在条件变量下等待,而是在锁中等待,然后当它释放锁时,这个锁被我拿到了,此时队列已经为空了,那么就可能会出错。
这段代码类似于管道:一个进程往管道里写数据。管道满时,进程就会被阻塞;一个进程往管道里读数据,管道空时,进程就会被阻塞。管道的实现类似阻塞队列。
5、POSIX 信号量
POSIX 信号量和 SystemV 信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但 POSIX 可以用于线程间同步。
前面我们说到信号量的本质是一个计数器,是用来描述临界资源数目的计数器。互斥锁能保护临界资源,条件变量能帮我们知道临界资源的状态,上面也说临界资源必须被互斥访问,只允许一个执行流在特定的时刻访问临界资源。但其实临界资源并不是一定只能一个线程同时访问的,只是之前的临界资源(阻塞队列)是被当做一个整体来看待的,而这里讲的临界资源是包含很多区域的,所以只要不同线程访问临界资源的区域时不重叠就可以多个线程同时访问。此时,最担心的问题是有多个线程访问同一区域,和多出来的线程进入,比如临界区中有 10 个区域,但是却进来了 20 个线程,就像电影院有 100 个位置,却进来了 200 个人,所以为了解决这个问题,就引出了电影票,也就是信号量,电影票是用来描述电影院的座位的,就像信号量就是用来描述临界资源数目的计数器的,所以任何线程如果想访问临界资源中的某一个,必须先申请信号量,使用完毕必须释放信号量,申请信号量后一定能使用临界资源中的某一个,就像电影院买票后一定有位置给你预订着。
如下面的伪代码,申请信号量资源(sem--,预订资源)叫做 p 操作,释放信号量资源(sem++,释放资源)叫做 v 操作。既然每个进程都得先申请信号量,前提是每个进程都得先看到信号量,此时信号量就是一种临界资源,而系统在设计的时候当然有考虑到,所以 pv 操作其本身就是原子的,所以它就被称为 pv 原语。所以系统也提供了一个信号量 sem_t 类型,我们用的也是 POSIX 下 pthread 库提供的信号量。
(1)初始化信号
- sem:初始化的信号量。
- pshared:即线程之间共享,还有其它选项,一般设置为 0。
- value:信号量中计数器的初始值。
(2)销毁信号量
- sem:释放的信号量。
(3)等待信号量
等待信号量会将信号量的值减 1。
sem:p 操作就相当于对值进行 -- 操作,如果信号量的值 >0,程序就立即返回,如果信号量的值 <=0,sem_wait 将会阻塞,直到值大于 0。
(4)发布信号量
发布信号量表示资源使用完毕,可以归还资源了,将信号量值加 1。
sem:v 操作就相当于对值进行 ++ 操作。
6、基于环形队列的生产消费模型
- 环形队列采用数组模拟,用模运算来模拟环状特性。
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空(另外也可以预留一个空的位置作为满的状态)。
- 现在有了信号量这个计数器,就可以很简单的进行多线程间的同步过程。
游戏规定小明永远在小红的前面,且不能超过小红,小明顺时针放苹果,小红顺时针拿苹果。最开始,盘子全是空的,必须让小明安全的执行。后来盘子满了,必须让小红安全的执行,可以看到当盘子为空或者为满时,小明和小红指向的是同一盘子,也就是说盘子不空或者不满时小明和小红指向的是不同的盘子。
环状结构的逻辑结构(左)和物理结构(右):
生产和消费要有互斥和同步问题。因为环形队列为空为满时都指向同一个位置,那么如何是空还是满?(想让生产和消费者指向同一个位置,具有互斥和同步关系就可以了;而让生产和消费者不指向同一个位置,想让他们并发执行)
- consumer == producter 为空。
- 牺牲一个空间,来判断满的情况,即 producter + 1 == consumer 为满。
上面所说的游戏规定小明不能超过小红第二圈是为了防止数据还没有被 consumer 消费就被 producter 覆盖了。实际在内存中环形队列并不是真正的环,而是线性的空间,而要实现头尾相接,除了在每个节点中加上一个后驱指针之外,还可以使用模运算。
这里不考虑牺牲一块空间,consumer 关注的是格子里的数据,producter 关注的是空格子。所以我们定义好信号量,此时消费者 P 操作申请信号量,申请失败就会被挂起,然后生产者 P 操作申请信号量成功,sem_space 就被 - - 了,然后生产数据于队列,此时格子是有数据的,所以 V 操作释放信号量,sem_data 就被 + + 了,此时消费者 P 操作申请信号量,sem_data 就被 - - 了,此时格子没有数据了,所以 V 操作释放信号量,sem_space 就被 + + 了。假设生产者一直生产,消费者不消费,最后 sem_space = 0, sem_data = 8;又假设消费者一直消费,生产者不生产,最后 sem_sapce = 8, sem_data = 0。
上面的生产者消费者模型的案例其实并没有太大意义,实际更多的是生产和消费各种任务。
多生产多消费的意义在哪?
不要狭隘的认为,把任务或者数据放在交易场所就是生产和消费。将数据或者任务在生产前和拿到之后进行处理才是最耗费时间的。
- 生产的本质:私有的任务 -> 公共空间中
- 消费的本质:公共空间中的任务/数据 -> 私有的
信号量本质是一把计数器,那计数器的意义是什么?
计数器使用来表征数据的临界资源的。
申请锁 -> 判断与访问 -> 释放锁 --> 本质是我们并不清楚临界资源的情况。
信号量要提前预设资源的情况,而且在 pv 变化过程中,我们能够在外部就能知晓临界资源的情况。
可以不用进入临界区就可以得知资源情况,甚至可以减少临界区内部的判断。
信号量是资源的预订机制 ,它用来表明当前环形队列空间的情况。只要能够成功申请信号量,内部不需要做判断,就一定能够保证可以访问到对应的资源。换而言之,如果条件不满足被挂起,是在加锁之外被挂起的,并不会受影响。
二、线程池(重点)
1、概念
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络 sockets 等的数量。
现实中也有很多池:如人才池,你去面试,面试官觉得你有点不太适合公司,同时又觉得你技术有很大的潜力,所以就把你的简历放在了人才池中,面试官可能跟 100 个人也是这样对待,人才池存在的根本原因是:如果没有人才池,公司就必须每次都在社会上发布招聘信息,等别人投简历,筛选简历,再面试,这样做成本太高了。备胎池,女神在朋友圈说这个包好漂亮,身为备胎的你就去买包给你的女神,女生就不用再去找备胎了,备胎池存在的根本原因是:如果没有备胎池,女生就需要去加备胎的微信,然后发好人卡,这样成本太高了,所以现在就能明白拥有备胎池的人找男女朋友效率是最高的,所谓渣男渣在哪,就是他可以一次联系到多个,这个时候效率是最高的。以前我们申请空间是需要了就 malloc 空间,又需要了就又 malloc 空间,反反复复,而直接一次性申请反反复复的这些 malloc 的空间,就叫做内存池。申请内存是有成本的,它们的区别在于后者相对前者而言,后者只有一次用户空间到内核空间的切换,所以内存池的作用就是:不用频繁的进行用户到内核,内核到用户的切换,进而提高效率。
同样的,线程池就是一次预先创建一大批线程,让这些线程处于 “待机” 状态,一旦有数据或者任务,就可以直接交给线程去处理。预先创建一大批线程的原因是因为创建线程是有成本的。
为什么要有线程池?
为了以空间换时间来预先申请一批线程,当有任务到来时可以指派线程。
2、线程池的应用场景
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB 服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个 Telnet 连接请求,线程池的优点就不明显了。因为 Telnet 会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
3、线程池的种类
4、线程池示例
- 创建固定数量线程池,循环从任务队列中获取任务对象。
- 获取到任务对象后,执行任务对象中的任务接口。
5、代码
(1)makefile 自动化构建项目
(2)Task.hpp 实现任务
Task.hpp 中的任务是完成一个具有两个整数操作数和一个处理函数的任务。
(3)thread_pool.hpp 实现线程池
thread_pool.hpp 中定义好了一个 ThreadPool 类,并使用了类模板,g_thread_num 是指线程池中线程的个数,task_queue_ 是指放取任务的地点,它就是临界资源,所以需要锁来维护互斥关系(一般互斥量不建议命名 mutex,而是命名 lock)。
注意:Task 类中还需要显示的定义一个无参默认构造函数,因为 ThreadPool 类中取任务时定义了一个 T task,此时就需要无参默认构造函数。扩展一下 C++ 中的仿函数(在 C 语言可以通过回调函数处理),我们在 Task 中实现了一个 void operator() 仿函数,其中调用 logMessage(),此时在 ThreadPool 中处理任务时就不用再 task.logMessage() 了,直接 task() 即可。
(4)testMain.cc 用于测试
(5)其它
运行结果:
testMain.cc 中 new 好 ThreadPool 对象,使用 Task 类作为模板。这里在类内创建的线程,并执行类中的 routine() 方法,其中线程分离,这样线程结束不用等待,也会自动释放资源,其中检测任务、取任务和处理任务,我们都知道会隐藏的在创建类对象时会传入这个对象的地址以区分其它对象,而类方法中也会隐藏的使用一个 this 指针接收,这个指针的类型就是类*,即 ThreadPool*,可是 pthread_create() 方法中调用 routine 时传入的参数只有一个,所以就会报错,所以 routine 不能是写成成员函数,而应该 static 修饰为静态成员函数,此时它就没有 this 指针了。然而新的问题是 static 修饰 routine 后,类中静态成员函数就不能访问任何非静态成员,只能通过对象的方式来进行内部方法或属性的访问。实际想在内中 static 修饰的成员方法中去访问成员方法,首先需要把 this 指针传给静态成员方法。后来拿到任务后就不需要在临界区中处理了,因为一个线程拿到了任务就代表这个线程已经访问了临界资源了,也就是拿到了队列,这个队列就已经从多线程共享变成了这个线程私有了,而此时这个线程在处理任务期间,其它线程就可以继续获取任务并处理任务了。
三、线程安全的单例模式
1、单例模式
单例模式是一种 “经典的,常用的,常考的” 设计模式。
2、设计模式
设计模式代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。它是软件开发人员在软件开发过程中面临的一般问题的解决方案,这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。设计模式是前辈们对代码开发经验的总结,是解决特定问题的一系列套路,它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。
IT 行业这么火,涌入的人很多。俗话说林子大了啥鸟都有,大佬和菜鸡们两极分化的越来越严重。为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定了一些对应的解决方案,这个就是 设计模式。
3、单例模式的特点
某些类只应该具有一个对象(实例),这个对象被保存于一个静态变量中,并提供一个全局访问点来获取这个对象,称之为单例。例如,一个男人只能有一个妻子。
在很多服务器开发场景中,经常需要让服务器加载上百 G 的数据到内存中,此时往往要用一个单例的类来管理这些数据,否则没有单例,就可能出现大量冗余的数据,所以一般单例对象是很大的。单例模式的实现有多种,其中最常见的就是懒汉式和饿汉式。
4、饿汉实现方式和懒汉实现方式
饿汉和懒汉的本质区别就是你这个单例什么时候被创建,也就是什么时候加载单例对象到内存,一般一个单例对象是很大的,是当服务一启动就创建单例,还是等需要的时候再创建单例,这就是饿汉和懒汉存在的意义。
- 吃完饭后立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
- 吃完饭后先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。
最常用的是懒汉,懒汉方式最核心的思想是 “延时加载”,从而能够优化服务器的启动速度。
5、饿汉方式实现单例模式
class Singleton
{
private:
Singleton(){} //构造函数私有化,防止外部new对象.
Singleton(const Singleton&) = delete; //禁止拷贝构造
Singleton& operator = (const Singleton&) = delete; //禁止赋值重载
public:
static Singleton* GetInstance() //生成唯一的实例对象.
{
static Singleton instance; //成功创建单例
return instance;
}
};
6、懒汉方式实现单例模式
class Singleton
{
private:
static Singleton* instance; //全局访问点
Singleton(){} //构造函数私有化,防止外部new对象.
Singleton(const Singleton&) = delete; //禁止拷贝构造
Singleton& operator = (const Singleton&) = delete; //禁止赋值重载
public:
static Singleton* GetInstance() //根据全局访问点,生成唯一的实例对象.
{
if(nullptr == instance)
instance = new Singleton(); //成功创建单例
return instance;
}
};
Singleton* Singleton::instance = nullptr; //定义初始化静态成员变量
存在一个严重的问题:线程不安全。
第一次调用 GetInstance 的时候,如果两个线程同时调用,可能会创建出两份对象的实例,但是后续再次调用就没有问题了。
7、懒汉方式实现单例模式(线程安全版本)
class Singleton
{
private:
static Singleton* instance; //全局访问点
static pthread_mutex_t lock; //互斥锁
Singleton(){} //构造函数私有化,防止外部new对象.
Singleton(const Singleton&) = delete; //禁止拷贝构造
Singleton& operator = (const Singleton&) = delete; //禁止赋值重载
public:
static Singleton* GetInstance() //根据全局访问点,生成唯一的实例对象.
{
if(nullptr == instance)
{
pthread_mutex_lock(&lock);
if(nullptr == instance) //双重if判定,避免不必要的锁竞争.
{
instance = new Singleton();//成功创建单例
}
pthread_mutex_unlock(&lock);
}
return instance;
}
};
Singleton* Singleton::instance = nullptr; //定义初始化静态成员变量
pthread_t lock = PTHREAD_MUTEX_INITIALIZER; //定义初始化静态成员变量
注意:
- 加锁解锁的位置。
- 双重 if 判定避免不必要的锁竞争。
- volatile 关键字防止过度优化。
8、代码
四、STL、智能指针和线程安全
1、STL 中的容器是否是线程安全的?
不是。
STL 中的容器并不是线程安全的,因为 STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,就会对性能造成巨大的影响,而且对于不同的容器,加锁方式的不同,性能可能也不同,例如 hash 表的锁表和锁桶。因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
2、智能指针是否是线程安全的?
智能指针的线程安全性因类型而异:
- 对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全的问题。
- 对于 shared_ptr,其读操作是线程安全的,即多个线程可以同时读取同一个 shared_ptr 指向的对象,这是安全的。然而,其写操作(修改 shared_ptr 指向)和引用计数的加减操作则是非线程安全的。这就意味着当多个线程尝试写入同一个 shared_pt r 或同时对一个对象的引用计数进行加减操作时,可能会引发竞态条件和数据竞争。因此,在使用智能指针时,需要特别注意并发访问和修改的情况,避免出现线程安全问题。若需在多线程环境下使用,可以考虑使用互斥锁或其他同步机制来保护共享数据。
五、其他常见的各种锁
一般在数据库中会经常的听到这些词,而在 Linux 中就是具体的一把锁,之前学过的二元信号量、互斥量其实都是悲观锁。
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和 CAS 操作(版本号机制就是一个线程对应一个版本,你修改,我也不怕你修改,这样通过版本来区分每一个线程的修改)。
- CAS 操作: CAS 操作是 JAVA 上面的概念,当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等,则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,读写锁、公平锁,非公平锁。
自旋锁的核心机制是利用 CPU 的自旋(忙等待)来实现加锁和解锁。当一个线程想要获取已经被其它线程持有的锁时,该线程不会进入阻塞状态,而是持续检测锁是否可用。只有在能够获取到锁的情况下,线程才会停止自旋并继续执行。一旦锁被释放,正在自旋的线程会最优先获得该锁。比如,你打电话叫小丽吃饭,小丽说我正在写作业,你先在楼下等着,此时若小丽只需要 5 分钟就可以把作业写完,那你一定会等,期间可以不断的轮询检测小丽的状态,这就是自旋锁自旋检测的过程。但若小丽需要 1 个小时才能写完作业,你一定不可能等,而是先去打打篮球,直到小丽做完时才打电话叫你去吃饭,这样也是有成本的,本来肚子就饿,对应锁在阻塞时也是有成本的,阻塞时线程要被放在等待队列中,而当条件满足时还需要把这个线程从等待队列放到运行队列中,所以也不是任何场景都适合阻塞的。此时小丽需要花多长时间写完作业才去吃饭,决定了你要不要干等。
什么时候使用自旋锁 / 互斥锁?
这取决于资源就绪的时间问题。换而言之,就是当多个线程竞争同一份资源时,看竞争成功的这个线程在临界区会待多长时间。如果时间长可以采用挂起等待锁,时间短可以采用自旋锁。
所以锁是要阻塞还是要自旋取决于持有锁的线程在临界区中执行的时长,而究竟是使用阻塞还是自旋,是取决于用户的,因为只有用户知道他自己写的代码,像之前的抢票,采用自旋会更适合。所以自旋锁适用于短时间内的资源互斥,可以避免线程阻塞和消耗,从而具有较高的效率。然而锁被长时间持有或者资源冲突频繁,自旋锁可能会导致 CPU 资源的浪费,甚至可能引发系统崩溃的风险。因此,自旋锁通常用于锁持有时间较短的场景。
可以看到这几个接口和上面所认识的接口很类似。实际当我们在调用 spin 自旋锁时,如果没有申请到,虽然它在自旋,但在调用者看来还是在阻塞的现象。
六、读者写者问题(了解)
1、读写锁
在编写多线程的时候,有一种情况是十分常见的。有些公共数据修改的机会比较少,相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率,所以读写锁就可以专门处理这种多读少写的情况。
那么是否有一种方法可以专门处理这种多读少写的情况呢?
有,那就是读写锁。
注意:写独占,读共享,写锁优先级高。
通常写者有一个特征是读者特别多,写者特别少。在生活中的黑板报,它也符合 “321” 原则:
- 三种关系:读者和读者(没有关系)、写者和写者(互斥)、读者和写者(互斥与同步)。
相比之前的生产者消费者模型,这里很意外的是读者和读者是没有关系,因为在写者出完黑板报时,不可能说只能你一个读者看,其它读者都闭上眼睛,想看的后面排队去,其实本质是因为读者和读者不会把数据取走,而消费者和消费者会把数据取走,这也就是这两种模型的区别,所以读者和读者没有互斥和同步关系。
- 两种角色:读者和写者。
- 一个交易场所:黑板。
(1)读写锁接口
A. 设置读写优先
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
B. 初始化
C. 销毁
D. 加锁和解锁
(2)伪代码实现
首先读者可能有多个,实际需要有一个 reader_cnt 计数器,而写者之间是互斥关系,所以不需要计数器,多了一个读者就 ++,然后访问临界资源,少了一个读者就 --,++ -- 都需要锁来保护。写者中加锁判断是否有读者,如果有就解锁,然后 goto 继续加锁判断是否有读者,如果没有读者就访问临界资源,此时并没有解锁,访问结束后再解锁。这样就维护了写者与写者、读者与写者之间的关系。
上面说读者很多,写者很少,这是根据场景的,这就意味着读者长时间在读,写者可能会写饥饿了,相反写者长时间在写,读者也可能读饥饿了。实际读写锁在处理这种问题时,有两种策略:读者优先和写者优先。
上面在谈生产者消费者模型时并没有提及谁优先的原因是生产者和消费者它们的地位是对等的、信赖的,就是说生产者把缓冲区生产满了就不能生产了,必须依靠消费者,同样,消费者把缓冲区消费完了就不能再消费了,必须依靠生产者。生产者消费者模型没有优先的问题本质上是因为不管是谁优先,节奏一定会被对方拖慢,所以一定是保证生产和消费的节奏一致,效率才是最高的,所以不谈优先。而读者写者模型就不一样了,读者和写者之间没有像生产者消费者之间那样太大的信赖性,读者和写者本身是互斥的,那么你想让读者读到老的数据还是新的数据,比如写者写好文章后,文章中有错误,然后想让写者赶快更新文章,让读者看到正确的文章,这叫做写者优先。当然也有一些场景想让读者看到老的数据,比如公司中一些新代码不太稳定,所以想让读者先读老的数据,这叫做读者优先。所以读者优先是指读者和写者一起到来时优先让读者申请到锁,注意,“一起到来” 很重要,因为读者早就比写者到来了,你不可能让写者还优先,一定要明白的是优先的本质是不是总是让你先,而是在某一件事情上有冲突,就像常说的女士优先一定是男士和女士在某一件事情上有冲突;而写者优先是指当写者到来的时候,后续读者就暂时不能进入临界资源进行读取了,所有正在读取的线程执行完毕,写者再进入。