Linux 线程 - 生产消费模型 / 线程池
![Linux线程-生产消费模型/线程池](https://static001.geekbang.org/infoq/14/144fabf42d709c4cacc1ab1cbf1ad324.png)
@TOC
零、前言
本章主要讲解学习 Linux 线程章节的后一部分,主要介绍生产消费者模型以及信号量的学习
一、生产消费者模型
什么是生产消费者模型:
三种关系:生产者和生产者(互斥关系);消费者和消费者(互斥关系);生产者和消费者(互斥关系、同步关系)
两种角色:生产者和消费者(通常由进程或线程构成)
一个交易场所:通常指的是内存中的一段缓冲区,或者某种数据的组织方式
主要过程:生产者将生产的数据或者任务放入到交易场所中,消费者从交易产所拿取数据或者任务
生产者和生产者/消费者和消费者/生产者和消费者的互斥关系:
交易产所是被多个生产者和消费者共同所见的,即被多个执行流同时访问,为了避免交易产所中的数据混乱,我们需要将该临界资源用互斥锁保护起来。其中,所有的生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系
生产者和消费者之间的同步关系:
由于交易产所的容量有限,如果让生产者一直生产,那中当生产者生产的数据将空间塞满后,生产者再频繁访问交易场所也会是没有实际的效用;如果让消费者一直消费,那么当容器当中的数据被消费完后,消费者再频繁的访问交易产所也会是没有实际的效用
虽然这样没什么问题,但是不合理,是非常低效的。我们需要应该让生产者和消费者访问交易产所按照一定的顺序,当没有数据时,让消费者等待,生产者进行生产;当容量满了,让生产者等待,消费者进行消费
注:互斥关系保证的是数据的访问正常,而同步关系是为了让多线程(生产和消费者)之间协同起来
为何要使用生产者消费者模型:
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个阻塞队列就是用来给生产者和消费者解耦的
生产者消费者模型优点:
解耦(阻塞队列的作用)
支持并发(解耦后,消费和生产可以各自运行)
支持忙闲不均
示图:
![](https://static001.geekbang.org/infoq/d1/d181db57f716d3383f9aead5efb3a94b.png)
二、阻塞队列生产消费模型
BlockingQueue:
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构
其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
示图:
![](https://static001.geekbang.org/infoq/c8/c8d546fa0a217ab7fcba1515343682e9.png)
示例:单生产者-单消费者
效果:
![](https://static001.geekbang.org/infoq/64/6408389dfa494b41fe3a55612f76373f.gif)
注意:
对于单生产者、单消费者的生产者消费者模型只需要维护生产者和消费者之间的同步与互斥关系;阻塞队列是会被生产者和消费者同时访问的临界资源,进行访问时需要申请互斥锁
生产者线程要向阻塞队列当中 Push 数据,前提是阻塞队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要进行等待,直到阻塞队列中有空间时再将其唤醒;消费者线程要从阻塞队列当中 Pop 数据,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要进行等待,直到阻塞队列中有新的数据时再将其唤醒
因此在这里我们需要用到两个条件变量进行描述临界资源的状态,一个条件变量用来描述队列是否有空间,另一个条件变量用来描述是否有数据:当阻塞队列满了的时候,要进行生产的生产者线程就应该在 space 条件变量下进行等待;当阻塞队列为空的时候,要进行消费的消费者线程就应该在 data 条件变量下进行等待;当放入数据时就可以进行唤醒 data 下等待的线程,当取出数据是就可以唤醒 space 下等待的线程
不论是生产者线程还是消费者线程,它们都是先申请到锁进入临界区后再判断是否满足生产或消费条件的,如果对应条件不满足,那么对应线程就会被挂起。但此时该线程是拿着锁的,为了避免死锁问题,在调用
pthread_cond_wait
函数时就需要传入当前线程手中的互斥锁,此时当该线程被挂起时就会自动释放手中的互斥锁,而当该线程被唤醒时又会自动获取到该互斥锁
示例:多生产者-多消费者
注:当然可以只使用一把互斥锁,这里为了更好表示生产与消费者们之间的关系使用了三个互斥锁
效果:
![](https://static001.geekbang.org/infoq/f7/f76c6bc534ae7569e261a0d59d29f14d.gif)
三、环形队列生产消费模型
概念:
环形队列采用数组模拟,用模运算来模拟环状特性
示图:
![](https://static001.geekbang.org/infoq/b8/b827225caa97a18e4b00282937942e9c.png)
注意:
在这个生产消费者模型中,我们依旧是需要维护三个关系:生产者和生产者(互斥关系);消费者和消费者(互斥关系);生产者和消费者(互斥关系、同步关系)
对于生产和消费两个角色,我们使用两个变量记录下标位置;而这下标变量对于生产者们和消费者们各自来说是临界资源,对下标变量的操作并不是原子的,需要使用互斥锁进行保护
对于生产者关注的是队列的空间资源,而消费者关注的是数据资源;对于生产和消费之间,使用信号量进行描述空间和数据资源的数量,维护生产和消费的同步与互斥
申请信号量是申请资源的使用的权限,相当于预定;申请互斥锁保证多线程的生产者或者消费者之间访问的安全,相当于进行排队
示例:
效果:
![](https://typoracole.oss-cn-guangzhou.aliyuncs.com/img/RingQueue生产消费.gif)
四、线程池 threadpool
线程池概念:
线程池是一种线程使用模式
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价
线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络 sockets 等的数量
线程池的应用场景:
需要大量的线程来完成任务,且完成任务的时间比较短
示例:WEB 服务器完成网页请求这样的任务,使用线程池技术是非常合适的,因为单个任务小,而任务数量巨大;但对于长时间的任务,比如一个 Telnet 连接请求,线程池的优点就不明显了,因为 Telnet 会话时间比线程的创建时间大多了
对性能要求苛刻的应用,但不至于使服务器因此产生大量线程的应用
示例:要求服务器迅速响应客户请求接受突发性的大量请求,突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误
线程池示例:创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执行任务对象中的任务接口
效果:
![](https://typoracole.oss-cn-guangzhou.aliyuncs.com/img/ThreadPool.gif)
为什么设置例程函数为静态:
例程函数需要设置成静态的成员函数,因为线程创建的执行函数的类型是返回值和参数都是 void *,对于普通成员函数来说,每个函数的参数列表都带有一个 this 指针类型,参数类型不一致,所以设置成静态成员
由于静态成员函数只能调用静态属性的成员或者通过对象调用的方式访问内部方法,由此创建线程池后将线程池对象的地址传入线程执行函数的参数中,便于在例程中直接使用对象进行调用函数进行访问任务队列
多线程在访问任务队列时需要维护同步与互斥,所以需要使用条件变量与互斥锁接口,为了更方便在静态例程函数中使用条件变量和互斥锁,我们需要进一步封装接口便于调用
五、线程安全的单例模式
设计模式的概念:
设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结
使用设计模式的目的:
为了代码可重用性、让代码更容易被他人理解、保证代码可靠性;设计模式使代码编写真正工程化
单例模式:
一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享
比如:
在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理
单例模式有两种实现模式:
饿汉模式和懒汉模式
1、饿汉模式
当程序启动时就创建一个唯一的实例对象
示例代码:
解释:
类里面的成员变量只是声明,而静态成员对象需要在类外进行定义,并且不能在.h 文件中定义,如果多个.cpp 文件包含该头文件,那么则会报重复定义的错误
优势:
实现简单
劣势:
如果单例对象构造十分耗时或者占用很多资源,比如加载插件啊, 初始化网络连接啊,读取文件啊等等,而有可能该对象程序运行时不会用到,那么也要在程序一开始就进行初始化,就会导致程序启动时非常的缓慢
对于多个单例类的如果具有依赖关系的话,则无法进行控制定义顺序(静态变量)
2、懒汉模式
懒汉模式则是需要的时候在第一次调用的时候进行创建
示例代码:
解释:
对于懒汉模式需要注意的是要保证线程安全,当多个进行调用 GetInstance()/DelInstance()时,可能多次进行 new 和 delete,可能造成数据的丢失
优势:
无启动负载;可以自由控制多个单例类的定义顺序
劣势:
实现复杂
注意事项:
加锁解锁的位置
双重 if 判定,避免不必要的锁竞争
volatile 关键字防止过度优化
单例模式的线程池:
效果:
![](https://typoracole.oss-cn-guangzhou.aliyuncs.com/img/image-20220407232229677.png)
六、STL 智能指针和线程安全
STL 中的容器不是线程安全的
原因:
STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响而且对于不同的容器,加锁方式的不同,性能可能也不同(例如 hash 表的锁表和锁桶),因此 STL 默认不是线程安全
如果需要在多线程环境下使用,往往需要调用者自行保证线程安全
智能指针是否是线程安全的:
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数
七、其他常见的各种锁
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和 CAS 操作
CAS 操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试
自旋锁:对于占用互斥锁的时间长短来决定是否选择使用自旋锁,如果占用锁时间长那么不用自旋锁,让线程进行挂起等待就好;如果占用时间短,使用自旋锁进行间断性获取锁申请,也就是自旋
八、读者写者问题
读写锁概念:
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多
通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。读写锁可以专门处理这种多读少写的情况
示图:
![](https://typoracole.oss-cn-guangzhou.aliyuncs.com/img/image-20220407220502238.png)
注:写独占,读共享,读锁优先级高
读写锁接口:
设置读写优先:
pref 共有的选择 :
注:读者优先:读者和写着一起来,优先读者进入临界区;写者优先:写者来了之后,等之前的读者出临界区,后面来的读者进行等待
初始化:
销毁:
加锁和解锁:
版权声明: 本文为 InfoQ 作者【可口也可樂】的原创文章。
原文链接:【http://xie.infoq.cn/article/5d4c3381791fc432f8730df71】。文章转载请联系作者。
评论