目录
那么对于代码异常这种情况,pthread_join能或者需要处理吗?
我们现在讨论之前遗留的问题:我们发现线程id和LWP是不一样的,这又是为什么呢?
用户层有很多线程属性结构,内核中也有很多的LWP,我怎么知道某一个线程和某一个LWP是对应关系呢?
这里还存在一个问题,就是我们看到加锁之后,是只有一个线程在进行抢票的,解锁之后反而是多个线程进行抢票,这是为什么呢?
我要访问临界资源的时候,需要先访问互斥锁,前提是所有线程都必须得看到它,那么锁本身,是不是也是临界资源呢?你如何保证锁也是安全的呢?
两个场景中,决定李四是在楼下等,还是在网吧玩游戏等的核心原因是什么?
一、Linux线程概念
进程和线程本质都是执行流
什么是线程?
线程的官方概念:是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更加细和轻量化。貌似懂了也貌似啥也没明白。
类比进程的宏观理解
一个进程内可能存在多个线程,进程:线程 基本是 1:n的。OS内存有可能存在更多的线程。OS自然就要去管理线程->先描述,再组织。所以线程也应该要有线程控制块,叫做TCB。因为linux是C语言写的,所以这个TCB是一个结构体。以上这些是常规OS的做法,比如windows。但是Linux思路与它类似,但是并不采取这样的方式,linux实现是有些不一样的。
在CPU的角度,当它调度的时候,拿着一个PCB在跑,可能这个PCB是属于一个进程的一部分,也有可能是另外一个进程的一部分,也有可能就是一个单独的进程。
CPU此时看到的PCB <= 博主之前讲的PCB的概念。在CPU看来,它拿的PCB可能是一个进程的一部分,而不是之前的一个PCB代表一个进程。但是CPU不关心,CPU认为一个PCB就是一个需要被调度的执行流。此时,这样的每一个PCB,在我们的linux中就可以称之为线程。
linux中没有专门为线程设计的TCB,而是用进程的PCB来模拟线程。这样做的好处就是不用维护复杂的进程和线程关系,不用单独为线程设计任何算法。而是直接使用进程的一套相关方法,OS只需要聚焦在线程间的资源分配上就可以。
像windows这种OS,遵照的是第一种思路,设计线程,它的源代码一定非常的复杂。所以linux的健壮性非常强,OS跑好几年不关机,原因就在于这些细节上。越简单的东西就越可靠,这就是linux的设计比其他系统优秀的点。
今天的进程vs之前的进程
- 之前的进程,内部只有一个执行流的进程。
- 今天的进程,内部可以具有多个执行流。
- 创建进程要创建PCB,地址空间,页表,维护映射关系,加载数据和代码,维护进程文件,进程信号。创建线程不用创建地址空间,页表,不用维护映射,只需要创建PCB,把资源分配给线程就可以。
- 创建进程的“成本”非常高,成本:时间+空间。
- 创建进程要使用的资源是非常多的(从0到1)
内核视角:进程是承担分配系统资源的基本实体!创建进程就要创建一大堆PCB,最少一个。
线程是CPU调度的基本单位,承担进程资源的一部分基本实体。进程划分资源给线程。
再次理解线程的概念
linux中不存在真正意义上的线程,它是用进程模拟的线程。准确说:在linux中我们用进程的pcb充当线程控制块,在CPU看来,它看到的pcb的量级要么等于一个进程,要么比一个进程量级更轻,所以线程就可以称之为轻量级进程。
执行分支:当前的pcb地址空间内部包含若干个pcb,每个pcb都可以被CPU调度进而被执行,所以我们可以保证在任意一个时间段引起同一个进程内的代码和数据可以被CPU因为多执行流存在而在一个时间段内都得以推进 。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
- 性能损失;一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低;编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制;进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高;编写与调试一个多线程程序比单线程程序困难得多
linux线程与接口关系的认识
linux的PCB <= 传统意义上(windows)的进程PCB。比其他OS更加轻量化一些。linux线程,也叫做轻量级进程。
linux因为是用进程pcb来模拟的线程,所以CPU看到pcb时无法区分这是一个进程的执行流,还是一个进程的子执行流叫做线程,所以linux下不会给我们提供直接操作线程的接口,而是给我们提供,在同一个地址空间内创建PCB的方法,分配资源给指定的PCB的接口。就意味着,我创建一个线程和我的父进程挂接到同一份地址空间上,然后在分配资源,分配完还要管理,管理完进行释放。所以对用户特别不友好!这就是linux用进程去模拟线程的缺点。如果linux自己设置一套线程的控制接口也是可以的,但是它并不想这么干,因为在linux中是不会去区分线程和进程的,都叫做执行流,所以它交给了用户去干。这个用户是一些应用级的工程师,让他们去解决。而windows有原生的各种线程控制块,所以它直接就有线程的系统调用接口,直接就可以创建线程,这就是windows的优点。
所以,系统级别的工程师,在用户层对linux轻量级进程接口进程封装,给我们打包成库,让用户直接使用库接口,这个库就叫做原生线程库,它在用户层,原生就是离OS最近。
linux中vfork系统调用,它的作用就是创建一个进程,但是这个进程和父进程共享地址空间。
进程和线程类比现实
现实生活中,地球是一个OS,地球上承担分配地球资源的实体是以家庭为单位的,每个家庭都有一套房子, 家庭和家庭之间互不影响独立的,偶尔串门进行通信,但是彼此之间都是私有的,一个家庭内部可能包含各种成员,这些成员虽然任务不同,但最终目的是将日子过好,这就叫做每个进程内部有多个执行流,每个执行流各司其职,最终这些执行流都要达成一个整体目标。在家庭里面,客厅,电视,阳台等大部分资源都是被共享的,但是你的房间,你的房间里的笔记本都是属于私有的东西。
线程的公有资源和私有资源
linux用进程模拟线程统一叫做轻量级进程,这里所谓的线程可以站在用户层去理解。所有的轻量级进程(可能是“线程”)都是在进程的内部运行(地址空间:标识进程所能看到的大部分资源),每个线程都能做到从用户态到内核态来回切换。
进程,独立性,可以有部分共享资源(管道,IPC资源)
线程,大部分资源是共享的,可以有部分资源是“私有”的:
- 栈:体现的就是每个线程在运行形成的临时数据是可以被压栈入栈的,线程和线程之间临时数据不会互相干扰。
- 上下文:调度上下文,因为一个线程是调度的基本单位,所以一定会形成自己在CPU寄存器中的临时数据,线程是调度的基本单位,必须要有独立的上下文。
- 线程ID
- errno
- 信号屏蔽字
- 调度优先级
如何验证?
pthread_create,创建一个新的线程,在编译链接的时候需要引入pthread这个库 。
- 第一个参数:线程id
- 第二个参数:线程属性
- 第三个:线程的回调属性,意味着你要让你的线程执行你代码的哪一部分
- 第四个参数:给这个回调函数传入的参数
eg:创建线程 ,我们这有两个死循环,以前我们的单进程代码是绝对不会出现执行两个死循环的情况的,但是现在就可以了。
编译的时候必须指明链接pthread库
执行结果:
此时我们看到两个线程都跑起来了,如果只有一个执行流是绝对不会发生这种情况的。主线程和新线程的pid是一样的,所以说明此时依旧只有一个进程,但是进程内部一定具有两个执行流。
当我们杀死这个进程的时候发现两个线程都被干掉了,所以确实只有一个进程,而且信号发生是给进程发的,不是给线程发的。
这里有两个执行流,系统中就存在两个轻量级进程,如何查看
ps -aL 查看轻量级进程
我们看到,此时有两个mythread,也就是两个执行流,他们的pid是一样的,证明是同一个进程内部,LWP叫做轻量级进程,他俩在内核中的id是不一样的。所以linux中,OS调度线程的时候看的是LWP。LWP就证明他俩是不同的执行流。
如何理解我们之前单独一个进程的情况?
之前说的是进程调度的时候是按照pid去进程调度的,按pid选择不同的进程。并没有错。因为之前单独的进程pid=LWP。如果只有一个执行流的情况下,在系统中用LWP相当于还是用的pid。LWP与pid相等的线程一定是主线程,一定是先有他的。多线程与进程完全兼容,我们之前的进程是一个进程内包含一个执行流,如今则是一个进程内有多个执行流,所以OS调度的时候看的不是pid而是LWP,我们一般把属于同一个进程的这一批线程我们一般叫做同一个线程组,这个线程组的组id我们就称之为当前进程的pid,也就是主线程的pid值
- 创建一个新线程的代价要比创建一个新进程小得多。创建线程只需要分配资源即可。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多;只需要将线程和线程的上下文切换就可以了,不需要切换页表和更新缓存,因为数据实际上都是有效的
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。(属于线程和进程的共同优点)
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。(属于线程和进程的共同优点)
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
计算密集型应用最典型的比如说:加密,大数据运算,主要使用的是CPU资源。
对于计算密集型是不是线程越多越好吗?
不一定!多线程的根本目的是为了提高并发连,是为了让多个线程同时去计算,同时计算必须得保证每个线程必须都能捕获CPU资源,如果你是单CPU单核,你创建再多的线程也无济于事,最高效率就是让一个线程跑。
ps:我是单个CPU但我是8核,我们保证线程数不要超过你的CPU的核数,如果线程太多,会导致线程间被过度切换(切换也是有成本的)。
比如:我是单核单CPU,有200G数据要我加密,我创建200个线程去计算,一个线程计算1G,最终一个线程刚被压缩了500M就被切走了,在压缩另一个线程,本来一个线程从头用到尾没有任何切换的成本,现在要花大量时间来回调度各个线程,反而使效率降低。如果你是双CPU ,每个CPU都是8核的,我就可以创建10几个线程去调度,我不能保证每个线程同时都被执行,但是有较大概率都去被执行。所以线程的多少是看具体场景的。
ps:即使在单个CPU单核系统上,创建线程也可能是有用的,因为线程可以使程序并发执行,从而提高程序的响应性能和处理能力。具体来说,如果应用程序中存在可以并行执行的任务,那么通过创建线程来执行这些任务,可以将这些任务分配到不同的线程中,并让它们同时执行,从而缩短程序的执行时间。
但是,在单个CPU单核系统上,线程的并发执行只能通过在单个CPU上的时间片轮转来模拟,这可能会导致线程之间的竞争和调度开销,从而减少性能提升的效果。因此,在单个CPU单核系统上,应该谨慎地选择创建线程的情况,确保创建线程是有意义的,并且线程的数量不会超出系统的处理能力。
- I/O密集型应用,为了提高性能,将等I/O操作就绪的时间重叠(比如你找女朋友,大部分时间你都在等,你追一个女孩可能花了3,4个月甚至更久,你做的动作就是IO密集型计算。但是还有一种人叫做渣男,他可以一次找20个女朋友,他找女朋友的效率就比你高的多,不是说他到女孩面前就直接成男女朋友了,而是他将等多个女孩的时间进行了重叠,所以提高了效率。但是这个女朋友也不是越多越好,太多就顾不过来了)线程可以同时等待不同的I/O操作。
I/O密集型应用典型的有:网络下载,云盘,ssh,在线直播,看电影,主要使用内存和外设的IO资源。实际上大部分应用是CPU+IO密集型的应用,比如:网络游戏。
对于I/O密集型应用是不是线程越多越好吗?
不一定!不过,IO允许多一些线程。如果我们不知道我们要创建多少线程,那就可以记录下CPU有几个,CPU是几核,算一下,用它来做线程的个数,至少不会出错。在IO的场景中,大部分时间是在等IO就绪的。所以线程的多少是看具体场景的。
IO操作是没有办法重叠的,当你做IO操作的时候,你的带宽是多少实际就是多少,带宽是一定的,IO是有限的,创建再多的线程也没有用,所以IO效率的提升主要在于让多个线程在等待时间上做重叠。eg:磁盘只有一个,实际上在读的时候还是得一个一个读。所以IO提高效率不在于带宽和磁盘的多少,虽然这是一种方案,但是我们不考虑。
- 性能损失;一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。(进程和线程共有缺点)
- 健壮性降低;编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 进程间是有独立性的,但是对于线程一个崩了就全部都崩溃了,进而导致进程崩溃。
- 缺乏访问控制;进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 进程和进程的资源是不可能访问的除非进行通信。线程是可以访问全局数据的,甚至可以访问另一个线程,它在实际多线程编程可能导致误修改的操作。
- 编程难度提高;编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
二、多线程编程之线程控制
pthrea_create
pthread标准的多线程都是成功返回0,失败返回错误码。
创建线程功能:创建一个新的线程参数
- thread:返回线程ID,pthread_t代表无符号整数类型(不同平台含义是有差别的)
- attr:设置线程的属性,attr为NULL表示使用默认属性
- start_routine:是个函数地址,线程启动后要执行的函数
- arg:传给线程启动函数的参数
- 返回值:成功返回0;失败返回错误码
pthread_self
获取一个id,谁调用这个函数就获取谁的线程id 。
我们写一个如下的程序,主线程打出来新线程的线程id,新线程打印出自己id
注意编译这个程序的Makefile要指明平thread库
执行结果:主线程和新线程打印的线程id确实是一样的
但是我们用ps -aL 查看下LWP
我们发现线程id和LWP是不一样的,这又是为什么呢?我们稍后回答这个问题。
我们在观察下主线程自己的线程id
证明此时我们有了两个线程。
创建多个线程
代码如下:我们用循环创建多线程
执行结果:此时就一共存在6个线程
线程的健壮性是有问题的,一个线程崩溃了,整个进程就崩溃,我们通过代码验证。
执行结果:
程序一直在跑,创建了6个线程,过了10秒后,线程3发生野指针问题,进而收到信号,而导致进程崩溃。所以其他线程也都跟着崩溃了。这就是线程的健壮性不强。
线程等待
一般而言,线程也是需要被等待的,如果不等待,可能会导致类似于僵尸进程的问题!
线程等待,我们调用的接口pthread_join 等待一个终止进程。成功返回0,失败返回错误码。
- 第一个参数: 你要等那个线程,传的是线程id。
- 第二个参数:输出型参数,用来获取新线程退出的时候,函数的返回值。因为你的线程执行函数的返回值是void*,我要以参数的形式把你的返回值拿出来,我就必须是void**。比如:如果我的返回值是个整数,你想通过一个函数的输出型参数,把函数里面的一个整数拿出去,传进去的时候就不能是一个整数变量,而应该是一个一级指针,传的时候把外部的地址传进去,到时候在函数内部才能获取到它。(原因就是C语言经典的交换问题)
线程处理函数的返回值是void*,
一个执行流执行就有三种情况,代码跑完结果对,代码跑完结果不对,代码异常。
在进程那里,对于前两种情况,我们有退出码。在多线程这里,我们是要通过返回值void*的,void*在返回的时候会被实例化成指针变量,它也有存储空间。
eg:主线程进程等待线程
执行结果:主线程拿到了创建线程的返回值, 就可以根据status的返回值判定线程执行任务的情况
那么对于代码异常这种情况,pthread_join能或者需要处理吗?
根本不需要,因为线程出现异常是进程的问题。线程出现问题,主线程根本管不了。由父进程决定多线程的单进程到底是由什么原因退出的。
我的线程返回的时候只能返回一个整数吗?
不要认为这里的返回值只能是整数,也可以是其他变量,或者是对象的地址,前提条件不能是临时的。
我们是用for循环创建的多个线程,如果我们想等待多个线程也只能通过for循环的方式,一个一个等待。
线程终止
线程终止的方案
- 从线程函数return.(a.main函数退出return的时候代表进程退出(进程退出又叫主线程退出)b.其他线程函数return ,只代表当前线程退出)
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数
功能:线程终止 (谁调用它谁就终止)
参数:就是你要设置的返回值,等价于return的返回值。
eg:
执行结果:
我们可以再次明确下实验现象,我们让主线程不着急退出,看到新线程退出。我们借助监控脚本观察
监控脚本
while :; do ps -aL | head -1 && ps -aL | grep mythread |grep -v grep; sleep 1; echo "##################################"; done
执行结果:我们可以看出开始刚开始有2个线程,等待成功以后就剩主线程了,新线程已经不在了
那我们可以用exit终止线程吗?
执行结果:我们发现终止的时候两个线程就直接都退出了,进程退出码是123
所以exit是终止进程,不要再其他线程中调用,如果你就想终止一个线程的话 。
pthread_cancel
功能:取消一个执行中的线程 . 直接传入你要取消线程的id就行了。
eg:
执行结果:这里我们发现线程被取消以后退出码是-1。
这里的退出码-1是什么呢?
这里的-1就是PTHREAD_CANCELED。所以以后我们发现一个线程的退出码是-1,就证明当前线程是被取消的 。
我们可以通过该函数在新线程中取消主线程吗?
eg:
执行结果:我们发现主线程确实是被取消了,新线程还在跑,因为没有人join主线程所以导致主线程存在类似僵尸进程的问题(我们一般不叫僵尸线程,因为linux没线程概念)
我们可以用其他线程干掉主线程,但是取消仅仅是让主线程退出了,但是进程并没有退出。虽然进程已经不存在。
我们查看下这个进程,发现此时它是僵尸了,但是它里面还有个线程在跑。
但是我们一般不建议新线程取消主线程这种操作。
上面三种我们推荐前两种。
线程有程序替换吗?
线程也可以调用程序替换,但是多线程中所有的代码和数据都是被线程共享的,如果其中有一个线程执行了程序替换,就直接影响到了其他的线程,所以在大部分情况下,很少让线程去调用程序替换,除非你让线程创建子进程再程序替换。一般程序替换和进程强关联。所以不考虑线程的程序替换。
线程分离
以上等待线程是阻塞等待,如果我们不想等呢?
线程分离,分离之后的线程不需要被join,运行完毕之后,会自动释放Z状态的pcb,不需要我们等了。这个功能类比于进程中signal(SIGCHLD,SIG_IGN)直接忽略掉。所谓的分离只是设置线程的一种状态表示它不需要被等。如果把进程表示成一个家庭,线程就是家庭内部的成员,分离的线程就是好比是同一个屋檐下的陌生人(需要在这个家住,但是我的死活与你们无关)。
线程分离函数
eg: 我们设置一个ret接受join的返回值,成功返回0,失败返回错误码
执行结果: 我们发现join的返回值是22而不是0说明等待失败,而status也没拿到线程的退出结果
一个线程被设置为分离后,绝对不能在进行join了。使用场景:主线程不退出,新线程处理业务处理完毕后再退出。
我们现在讨论之前遗留的问题:我们发现线程id和LWP是不一样的,这又是为什么呢?
我们查看到的线程id是pthread库的线程id,不是linux内核中的LWP,pthread库的线程id是一个内存地址。这个内存地址是一个虚拟地址。
我们如何去理解线程id?
我们实际上是先有进程,然后在你的代码里调用了pthread_creat才创建的线程。我们编写的线程函数依赖于pthread库,它就是一个磁盘上的文件。这个库要被进程访问就必须被加载到物理内存中,然后经过页表,映射到地址空间中去(第三方库是被映射到栈和堆之间的,叫做共享区,共享内存,动态库都是在这个区)。如果现在我有多个进程想使用这个库,物理内存中只要有一份,就都可以通过自己的地址空间映射到共享区就可以了,所以这个库只需要在物理内存中存在一份就行了。
每个线程都要有运行时的临时数据,要求每个线程都要有自己的私有栈结构!线程是要被管理的,线程状态等各方面的信息虽然在内核中有LWP,但是在用户层也要获得线程相关的属性,就需要有描述线程的用户级控制块。每个线程都有自己的私有栈结构,但是地址空间只有一个栈结构,难道让线程共用这一个栈吗?肯定不会,这个栈是主线程栈,是main函数用的。线程使用的栈在库里面,由库维护它的结构。我们只要拿到这个共享区域的虚拟地址就可以快速的找到某个线程的所有东西,所有线程id是一个被映射到当前进程地址空间的pthread库内部的一个地址数据,用它充当线程id,在用户层这个线程包含的线程栈,线程局部存储,描述这个线程的各种结构,全部在这个线程里面保存。只要拿到线程id就拿到了线程在库里面的地址,然后就能拿到该线程的所以运行时的用户级数据。
用户层的描述线程的属性和数据结构和pcb中对应的LWP是具有一个1:1的对应关系,在库里面创建一个这种结构,一定会在内核里面对应的创建一个LWP,你的临时数组在这个结构里保存,而线程要执行被CPU调度由LWP说了算。这叫做用户级线程1:1式的和内核轻量级进程进行1:1对应,这就是linux实现线程的方案。
用户层有很多线程属性结构,内核中也有很多的LWP,我怎么知道某一个线程和某一个LWP是对应关系呢?
如同曾经FILE里面封装了一个fd,用户层的struct_pthread它里面就需要包含LWP,也就是线程id。
实际上还会存在1:n的线程,也就是一个上层的用户级线程,可能对应OS里面是多个执行流。
线程的崩溃的影响一定是有限的,因为它在进程内部,而进程又具有独立性。
三、线程的同步与互斥
为什么要进行后续的访问控制(互斥与同步)?
线程的大部分资源是共享的,比如定义全局变量是可以被多个线程进行同时访问的(刚刚取消主线程证明这一点),而且大部分资源都是被共享的。就有可能会出现一种问题,因为多个线程是共享地址空间的,也就是很多资源都是共享的。
- 优点:通信方便
- 缺点:缺乏访问控制。比如一个线程申请了块堆空间,线程是共享这块空间的,可能一个线程正在使用,另外一个线程把它释放了。
因为一个线程的操作问题,给其他线程造成了不可控或者引起崩溃,异常,逻辑不正确等,这种现象我们叫做线程安全。
创建一个函数没有线程安全问题的话,不要使用全局,stl,malloc,new等会在全局内有效的数据;如果非要使用就需要有访问控制。我们之前的代码没问题就是因为我们使用的全是局部变量。我们虽然重入了某一个函数,但是也不会有影响就是因为线程有自己的独立栈结构。
专业名词解释
1.临界资源:凡是被线程共享访问的资源都是临界资源。比如:多线程,多进程打印数据到显示器。多个执行流同时向显示器上打印数据是会发生互相干扰的,因为显示器是一个临界资源。
2.临界区:我的代码中访问临界资源的代码(在我的代码中,不是所有的代码都是进行访问临界资源的。而访问临界资源的代码区域称为临界区)
3.对临界区进行保护的功能,本质:就是对临界资源的保护。方式:同步或者互斥
4.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),就可以称之为互斥!比如:多进程,多线程向显示器上打印消息,我可以printf打印消息,也可以cout打印消息,我们两个在逻辑上不影响。但是在结果上,两者可能互相干扰,这叫做多个执行流没有互斥。如果我们互斥了,意思就是在我打印消息的时候,你不可以打印消息,保证我把消息打印完后,你在打印消息。
5.基于互斥,在举个栗子,比如:向显示器上打印消息,我们通过某种方式实现互斥的功能,在互斥期间,将来是通过加锁实现的。
一个事情,要么不执行,要么就执行完毕,这个过程就称为原子性。
6.同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥和原子的),让访问资源具有一定的顺序性!同步让我们访问资源具有合理性。
同步与互斥的感性认识
比如好多人同时去吃饭,但是食堂大妈一次只能给一个人打饭,只有打完饭后才会理其他人,人们吃饭就需要竞争(假设当前的竞争规则是看谁块头大)。在你打饭的时候,大妈是不理别人的,对别人来讲有意义的状态就是要么你还没来,要么你已经走了。在你自己打饭期间没有人能影响你, 这个就叫做互斥。同样如果别人打饭的时候,你看别人要么他还没来,要么他已经走了,你看他的动作就是原子性。如果你吃的就非常多,块头很大,你刚打完饭以后,你当这大妈的面立马就吃完了,吃完后你又打饭,大妈不管,就再给你打饭,你就一直重复这样的动作,在这站了2小时,吃了2小时,这样导致的就是大部分时间都在给你打饭,其他在等待的人长时间打不到饭,这就是一种饥饿问题。但是这样并没有错,大妈确实每次给一个人打饭,但是这样是不合理的,餐厅就规定每次打完饭后,即使你站着吃完你也必须到队列尾部重新排队。立下规矩后,你又来了,你又是打完饭后,立马就吃完了,但是大妈看见还是你,就不会再给你打饭了,而是让你重新写去排队。此时大妈还是一次只给一个人打饭,我们也照样符合互斥,每个人之间照样存在竞争(现在的竞争变成了先来后到)。每个人就相当于线程,大妈相当于一把锁,我们吃的饭菜就是临界资源。
同步与互斥的理性认识
在多线程的场景下,大部分资源是共享的,但是有些资源是私有的,比如在线程函数里定义局部变量,因为每个线程都有自己的局部栈结构,即便是多线程访问同一个函数,但是函数内定以的变量都叫做局部变量,该局部变量一定会压入对应执行流的私有栈结构当中。栈是被线程独享的,所以凡是在栈里面的所有数据都只属于该线程。
我们用一个售票系统代码帮助大家理解
eg:抢票逻辑,1000票,5线程同时在抢
代码中的一些优化:
之前我们创建多线程这个地方传&i,但这样写是存在问题的,当你实际创建线程的时候,一旦创建成功,主线程向下走,新线程执行ThreadRoutine方法,但是你传入的是main函数里一个变量的地址,就有可能在ThreadRoutine方法内部还没有使用i的时候,主线程把这个i值做修改了。所以我们采用给每个线程创建堆空间的方式避免这个问题。
执行结果:
我们观察到抢票最后出现了负数,但是我们实际买票的时候是不允许出现负数的情况的。所以tickets不是安全的。
为什么tickets不是原子的(不是安全的)?
tickets--,在C++是一行代码,但是在汇编级别,它是多行代码。我们用这三个方法表示汇编的多行代码。有可能第一次执行--的是线程A,他要执行这3个工作,先加载,再--,最后写回。我们的线程在执行这里的--时,要做这三个工作。并且在执行任何一种方法时都有可能被切换。线程A刚刚执行完第一步的时候,线程A就被切走了,这个CPU内的寄存器的数据叫做当前执行流的上下文,当一个执行流被切换的时候,要保存它的上下文,所以线程A带着1000这个数字就被剥离下来了,然后他就在等待队列里等了。而线程B来了,他也执行这个三个方法,假如它的优先级很高,竞争能力非常强,他就不断的进行while循环,他就把这个1000张票减成了10张票,一个线程B就抢了900张票,当他再次准备把10变成9的时候,线程B被切走了,此时线程B要将CPU内寄存器的数据作为自己执行流的上下文保存到线程B内部,此时CPU就让线程A来了,线程A是带着自己的上下文来的,他就要做自己当时没有做完的事情,它要对1000做--,然后就变成999,然后进行写回。可是这个票数好不容易被线程B抢到了10,线程A一来就改成了999。相当于多个线程在操作tickets--时,可能因为一个线程对数据做修改时影响了另外一个线程。就有可能造成本来有1000张票,却卖出了2000张票。
所以tickets--不是安全的也就不是原子的。
以上我们只分析了只有tickets--的这个过程,对于我们自己的代码,不仅有tickets--,而且还有判断检查,假如当前票数是1,线程1进来了判断完大于0后,还没执行后面的--,另一个线程2来了,线程1就被切走了,然后线程2进行执行,线程2此时判断tickets>0,然后执行--,tickets变成了0.然后又切换成线程1,因为它是在判断完后切换走的,他就向下继续执行--,最终导致出现负数。如果剩1张票时不止来两个线程进来,就会导致负数的绝对值变大。我们结果出现负数的根本原因就是在判断和--之间线程被切换走了。 最最根本的原因就是当前的tickets不是原子的。
tickets就是临界资源。
只有这部分代码叫做访问临界资源,这部分代码叫做临界区。所以凡是在代码中,我们定义的tickets就叫做临界资源,访问tickets临界资源的代码就叫做临界区。
如何解决这个问题呢?
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。 Linux 上提供的这把锁叫互斥量。初始化的方案有两种,1种是用函数做初始化,直接将你定义锁的地址传进去,属性设置成空就可以。第二种,如果你的锁是全局的锁,或者是static的锁,你就可以直接使用PTHREAD_MUTEX_INITIALIZER这个宏来对他初始化。
eg:相当于一次创建5个线程,我不断的等待这5个线程,然后在最开始对票进行构建对象,这个票被封装进一个类里面,专门搞了一个互斥锁,然后有封装一个GetTicket接口,在这个接口里面,ticket就是安全的。
执行结果:至此我们就写出来一个安全的抢票代码
锁有了,但是我们把加锁和解锁去掉
执行结果:我们发现就有问题了,所以必须加锁才能解决
目前我们使用的C++中C++11已经是支持了互斥锁的
执行结果:此时也可以完成抢票
语言级别的锁一定封装的是系统级别的锁。当然在C++中也存在线程。
这里还存在一个问题,就是我们看到加锁之后,是只有一个线程在进行抢票的,解锁之后反而是多个线程进行抢票,这是为什么呢?
主要是因为没有同步,一个线程处于唤醒状态,竞争能力强,如果票数多了,就可以看到多个线程在抢了,不要小看一个线程的时间片,加锁之后在自己的时间片内,能抢到很多,所以这个时候是一个线程抢一批,另一个线程抢另一批。
不加锁效率是块的,加锁了效率比较慢,因为加锁了,临界区那部分代码是串行的,不加锁是并行的,所以加锁可能会引起效率的减少。 不加锁是多人干活,加锁是一个人干活,其他人看。
我要访问临界资源的时候,需要先访问互斥锁,前提是所有线程都必须得看到它,那么锁本身,是不是也是临界资源呢?你如何保证锁也是安全的呢?
我要访问临界区,就要先申请锁,所有线程都在竞争锁,所有线程都得先想看到锁,必须保证加锁和解锁是安全的,才能保护别人,所以加锁和解锁是原子的,在我加锁期间,要么我就没加,要么我加就直接加完了 ,不可能有中间人竞争我,所以我是安全的。
互斥量实现原理探究
多线程申请锁是原子性的原理。一行代码是原子的:只有一行汇编的情况。
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题。所以我们不能通过定义全局变量充当一把锁。
提供了swap和exchange指令目的是用一条汇编,完成内存和CPU内寄存器数据的交换!
假设有两个线程A和B,前后的去申请锁,首先movb 这句语句意思是把0放到%al这个CPU寄存器中。当CPU执行线程A的代码的时候,CPU内存寄存器内的数据,是线程A私有的,这就是执行流的上下文。线程一旦被切换成B,之前CPU内的数据是要被保存到线程A的上下文。当CPU执行线程A的代码。那么这个%al就是线程A的私有数据;如果切换成线程B,在movb的时候,也同样是向%al里写,%al里面可能会残留A是数据,但是不影响,B会直接进程覆盖。所以第一句movb的本质是线程A和B都在设置自己的上下文数据,也就是他们俩在这句代码上是不冲突的,这个寄存器里保存的是他们各自的上下文数据。因为这个%al是俩线程私有的互不影响,但对于第二行代码xchgb我们并不知道该被谁先执行,假设是线程A先来了,执行xchgb这行代码,把mutex的值1和%al的值0,做交换。这句话的本质是使用一行汇编,原子性的完成共享的内存数据mutex,交换到线程A的上下文,从而实现私有的过程!假设A刚要执行if语句的时候,别切换成线程B了,线程B就继续执行xchgb,它做的动作就是把mutex的0和%al里面的0,做了交换,相当于啥也没做。然后B也做if判断,可是只有线程A能if判断成功,进入到里面,只有A的%al里面的数据是1,其他线程的寄存器的值都是0,换言之,就叫做A竞争锁成功,Areturn0就代表A申请锁成功。B这种就进入else被挂起等待,一旦B被挂起了,它就没办法执行了,它必须等到A释放锁的时候B才会被唤醒。
mutex的本质:其实是通过一条汇编,将数据交换到自己的上下文中。这就叫做mutex互斥锁。一旦线程A释放锁,线程B被唤醒的时候,B线程goto lock回到最开始,把%al里的数据置成0,重新竞争锁,如果竞争能力强,就能交换到,反之就交换不到。所以这就是为啥刚才我们的买票例子,加锁之后只有一个线程在跑,线程A在它的时间片内申请锁成功后,它一直是活跃的状态,其他线程一直是被挂起的,只有A的时间片到了,才会自动的从竞争行列退出给其他人机会。
线程A和B做竞争,A成功,但是在A return 0;之前可能是被切换走的, 就好比我们明白申请锁是安全的,释放锁也是安全的,可是中间临界区有很多行代码,例如以下场景
线程A在加锁和解锁之间,这段临界区是有可能被切换的,我申请锁是为了保证临界资源的安全,而不是说保证在这临界区内线程不被切换。 线程被切走的时候,要进行上下文保护,而锁数据也是在上下文,拥有锁,被切走的线程是抱着锁走的。相当于我现在被切走了,其他人休想申请锁,即便是我被挂起,其他线程在此期间休想申请锁成功,休想进入临界区。相当于我是把锁拿走后人才不在的,后来的线程在我不在时申请锁也无法成功,所有人必须等我回来,把我的动作做完,然后把锁释放掉,其他人才能被唤醒去竞争锁。
类比生活:这有一间自习室,一次只允许有一个人进去,我作为第一个来的人,我把自习室的钥匙从墙上拿上,把门一开,在自习室里自习,我学了20分钟,我想上厕所了,我就走了,我走的时候,把门锁了,并且拿走了要是,其他人在我不在的期间想要进来自习室不可能的,所有人必须等我自习完,把钥匙挂墙上才能进来自习。
所以站在其他线程的视角,对其他线程有意义的状态,就是A线程要么没有申请,要么线程A使用完锁,只有这俩状态对其他线程才有意义。此时就实现了线程A访问临界区的原子性。
虽然线程A有这么一大批代码要跑,不过在A持有锁期间没有人能打扰我,即便我被切走了,其他人也申请不来,进不来。对我来讲,要么我压根没来,要么我已近用完锁把锁释放掉了。这就是原子性。
我们的锁可以有两把么,一个线程申请A锁,一个线程申请B锁?
这就和有两个自习室一样,一个去A,一个去B,两人根本不冲突。代码是程序员写的,线程A创建出来了,我让他申请锁,线程B,不让它申请锁,就让他直接进来,也就是有一份临界资源,有一个线程我让他申请锁,有一个线程我不让它申请锁,这个代码跑的话是存在问题的,而且是程序员的问题。所以代码是程序员写的,为了保证临界区的安全,必须保证每个线程都必须遵守相同的“编码规范”(A线程要申请锁,其他线程的代码也必须申请锁)。换言之,我们如果判断一个资源是临界资源,多个线程同时在申请时,A,B,C每个线程在执行临界区都需要申请锁,而且如果这三个是竞争关系必须申请同一把锁。
总结一下:如果多个线程之间不存在竞争关系,每个线程自己都可以申请锁,但是是否申请锁都不会相互影响。如果多个线程之间存在相互竞争的关系,就必须只能申请同一把锁,才能保证临界区的安全。
直接用宏进行初始化锁
对于全局或者static修饰的锁,我们就可以直接用宏初始化。
可重入VS线程安全
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 可重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数,若锁还未释放则会产生死锁,因此是不可重入的。
- 线程安全描绘的是线程之间互相影响的一种状态或者可能性。而重入描述的是一个函数可不可以被重复进入。
线程安全不一定是可重入的,
eg:
这个insert是加锁的,所以多个线程访问的时候是安全的,比如main函数里insert执行到第二句的时候,信号来了,导致它处理信号去了,但是main函数执行流是申请锁了,它抱着锁,信号递达的时候执行信号捕捉的方法,执行handler,handler里面也有一个insert,那么insert就重入了,可是insert函数进来的时候,信号捕捉执行流要进行申请锁。此时就出现,主线程申请锁成功了正在访问临界资源, 然后信号来了,执行了信号处理函数,此时又进行申请锁了,也就是说同一个进程申请了两次锁,第一次我成功申请了锁,第二次我又去申请锁,但是锁没了(其实是被你自己申请了),此时你就被挂起了。可最尴尬的是你是抱着锁被挂起的,你在等别人释放锁唤醒你,可是锁被你拿着呢,没有人释放,也就没有人唤醒。所以你这个进程就被永远的挂起了,这就叫做一个线程是安全的,但不一定是可重入的。
只有一个执行流,一把锁的时候也可能出现死锁。
eg:进行申请两次锁,第一次申请成功了,第二次申请失败,自己抱着锁把自己给挂起了。所以最终程序跑也不跑。
四、常见锁概念
死锁
死锁是指在一组进程(或者一组线程或者一组执行流)中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
类比生活:A,B各有5毛,棒棒糖1元,A和B说,你给我5毛,买个棒棒糖,B和A说,你咋不给我呢?所以他们两个各自拿着自己的5毛钱,问对方要钱,此时他俩争执半天,互相僵持的场景就叫做死锁。
死锁四个必要条件
一旦产生死锁,一定存在这四个条件。我们想避免死锁,就要破坏这个四个条件之中的一个。
- 互斥条件:一个资源每次只能被一个执行流使用。绝对不能破坏这个,一般我们不管。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。A拿着自己的5毛,并向B要就叫请求并保持,如果A给了B,就叫请求不保持,就不会产生死锁。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。如果破坏该条件,就允许一个线程去竞争另一个线程的资源,比如A不给B5毛,B就打A,向A把5毛抢过来。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法
-
死锁检测算法(了解)
-
银行家算法(了解)
五、Linux线程同步
这有一间自习室,一次只允许有一个人进去,我作为第一个来的人,我把自习室的钥匙从墙上拿上,把门一开,进去后又把门一反锁,在自习室里自习,因为钥匙字啊你口袋,而且你还把门锁了,后面来的同学只能在教室门口只能很无序的在教室门口等,等你从教室出来了,你直接把钥匙挂在墙上,之后,在门口等的这批人就开始抢钥匙,,可是当你钥匙还没放下来的时候,你突然一想,我不能松懈,还需要学习,,所以你又伸手把锁拿下来了,因为你离锁最近,而且你处于唤醒状态,竞争锁的能力比别人强,所以你拿着锁又进去自习室,继续在那学习,甚至是你坐了1分钟,就跑出去,然后又跑回来自习,一直反复做这种动作,跑了一天,知识没学习下,身体反而更好了,所以对你来讲大部分时间做的都是无用功,对其他人来讲,他们抢不过你,长时间在那里等着,长时间享受不到教室资源,而导致饥饿问题。你没错,但是这样并不合理。所以我们需要我们的多线程在保证资源安全的情况下,要让他访问临界资源具备一定的顺序性。所以我们做个规定,凡是刚从自习室出来的人,不能立马拿钥匙,所有后来者必须先排队,从自习室出来的人必须排在队列的尾部。所以我们加了这样的策略就能保证大家访问临界资源的过程是按照一定的顺序进行的,这个多线程执行的过程我们称之为同步到的过程。
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
eg:比如我们抢票,票数空了,可能12306还会定期的放票,但是当票数为0的时候,其他线程什么也做不了。因为在票数的数据没有被从0变成更大的某个值之前,所有抢票的线程只能在那里等。
eg:一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。就好比你去超市,问售货员有没有手机卖,他说目前没有,然后你就回去了,当超市的状态没有改变之前,也就是没有进货手机之前,你什么也做不了。目前的关键问题就是你怎么知道超市的状态改变了还是没改变呢,也就是你怎么知道手机是否进货回来了?现实生活中,想要知道无非就两种策略:
- 1.你第二天跑过来问,手机到了没?又隔一天,你又来问,手机到了内没?知道手机到的那一天,你才终止动作。这个过程就是一种轮询检测的过程,如果它的状态一直没变,你就得一直过来。所以这会浪费你的大量资源。
- 2.加一个售货员的微信,等手机到货了,让他微信通知我,这样就避免浪费我的大部分资源。
一般而言,只有锁的情况,我们其实比较困难的知道临界资源的状态。因此我们就引入条件变量,它是一个一方可以通知另外一方的事件已经就绪的场景。
条件变量初始化与销毁
初始化第一个参数是你定义的条件变量,第二个参数是属性。如果定义一个静态的全局变量或者全局的条件变量,你就可以用宏是初始化它。这一套和互斥锁一模一样。
核心函数
在某个条件变量下等待 pthread_cond_wait
今天我去问售货员有没有手机,他说没有,然后你就把他微信一加,等手机到了让售货员用微信通知你。当你回去以后,你非常想要手机,你就每天盯着微信的消息看。其中就相当于你会在售货员的微信上进行等待。其中你也可以称之为我们在条件变量下进行等待,所以当条件不成熟时,我们可以将这个执行流放在某个条件变量下进行等待就可以。ps:第二个参数的这把锁我们一般都要求其存在。
唤醒在该条件变量下等待的线程
到底有没有手机,你不清楚,售货员最清楚,售货员收到手机到来的信息时,就可以通过他的微信通知你。
利用一个线程控制其他线程启停的程序
eg:
程序大逻辑:创建一个线程进行控制,3个线程进行等待,这三个线程都必须等别人的统一号令,别人每隔1秒发一个signal,我在该条件变量下等的一个线程就可能被唤醒一次。
执行结果:
我们可以看到确实是一个线程控制一个线程(可以将boos线程睡眠的时间改成5秒更加可以直观体现)。并且我们还发现打出来的结果是具有一定的顺序性的。我们创建三个线程,谁先执行我们并不确定,我们的线程是按照2,0,1的顺序跑的。当2号线程跑完了,就去条件变量等了,1号线程跑完也去条件变量等了,0号跑完也去等了。条件变量内部一定存在一个等待队列!
条件变量可以理解成一个结构体,里面有一个status衡量某个状态是否就绪,还存在一个PCB的队列。所以如果一个线程被创建出来了,你要去等,先把你放进等待队列里去,然后另一个人通过signal去唤醒时,实际上就是把status由未就绪改为就绪,然后从队列中拿出一个线程,把他的状态改成R 状态,然后CPU就开始调度。
条件变量就是衡量条件是否就绪的功能。
pthread_cond_broadcast
signal是一次唤醒一个线程,唤醒的是在条件变量里等待队列里等待的第一个线程。
pthread_cond_broadcast是一次唤醒一批线程,把所有线程全部唤醒。
eg:
执行结果:第一次值唤醒了2号线程,很可能是其他线程还没有被创建出来,之后我们观察到一次就唤醒了3个线程。
为什么pthread_cond_wait 需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
六、生产消费模型
代码级别的例子
我们调用A的时候,执行到FunctionB的时候,必须停下来进入FunctionB,执行完B函数,返回的时候,A才能继续向后执行。就相当于A把数据传递给函数B,必须等B调用完才能拿结果,这是一个串行化的过程。
抽象一下:
函数和函数之间交互的本质:其实也是数据通信。你调用我这个函数就得把参数给我,我处理完把结果给你,你不需要关心我怎么加工你的数据,最终只要你把数据给我,我把数据给你,所以函数和函数之间调用本质就是数据通信。(我们不讨论不需要传参,没有返回值的函数,只讨论带参带返回值的)最典型的特征就是在单进程下只能串行执行。
我现在想把Function的功能交给线程A去跑,FunctionB的功能交给线程B去跑,此时我们至少有一定的能力可以让两个线程同时跑,然后因为函数和函数之间交互的本质:其实也是数据通信,所以我就是在两个线程之间建立一段缓冲区,A把数据交给缓冲区,B在从缓冲区中把数据拿到,然后线程A向缓冲区中写数据,B拿数据,线程B就可以进行加工了,因为我们俩中间有了缓冲区的存在,有了两个执行流的存在,我们此时就有可能让函数A和函数B并行运行。我们把这种场景就叫做生产者消费者模型。FunctionA就是工厂,FunctionB就是消费者
生活中的生产者消费者模型
超市:比如说一个超市里只卖各种牌子的矿泉水,超市里普通老百姓就是消费者,超市背后有一大批的供货厂商,这些供货商是生产者。
为什么要有超市?
因为人类生活中效率是优先的。工厂的功能是生产的,它不具备售卖的功能,工厂一天生产上百万的矿泉水,如果工厂具有售卖功能的话,每天的出货量只有几百,这显然是得不偿失的。超市的本质是收集需求,也就是意味着超市对商品的需求量是非常大的,它可不想普通老百姓每天买1瓶水,1袋方便面...超市需要5000箱水,1000袋面等,所以超市多商品的需求量就可以养活工厂,而且超市是客户自然而来的,供货商也不会只向一个超市供货,超市也不止一个供货商。所以超市的存在是减少交易成本,一个消费者跑到工厂买东西的成本太高了,工厂一般在偏僻的,工价低,成本低的地方,你不可能坐车到100公里外的工厂去买东西,所以超市的存在是减少交易成本。超市存在的根本意义就是提高效率。
生产消费者模型的最大的好处之一也是提高效率,我们刚刚函数串行化调用的场景就类似,消费者直接去工厂找供货商去买一个东西。第二个好处就是将生产环节和消费环节进行了“解耦”,意思就是我现在正在吃火腿肠,正在喝水,我在做这一对动作的同时工厂也正在生产火腿和水,所以我们两个可以做到并行,同时任意一方出现问题都不会相互影响,这叫做解耦。效率提高也是因为有解耦的特性。
生产消费者模型的两个核心特点:1.提高效率 2.解耦
任何的消费者都可以去超市,任何的供货商也都可以给超市提供商品,所以超市本身就是一份临界资源。
供货商和供货商之间的关系?
竞争关系,这个答案显而易见,就是互斥
消费者和消费者之间的关系?
竞争关系,比如就剩1瓶水了,你和其他人都要买,也是互斥
供货商和消费者之间的关系?
某种意义上也是竞争关系,比如超市只有一个展示柜台能放一瓶水,当我想去拿水的时候,供货商想要去放水,我们俩对于这个展架就是竞争关系,供货商看做的是展架,我看中的是水,当供货商向展架上放水的时候,我会说:先别着急,我先看看展架上有没有水,我俩就针对谁先谁后的问题,就是一种竞争关系。但是单纯的互斥是不应该的,当生产者把货物生产出来,消费者才能消费,当消费者把货物消费完以后,生产者才能进行生产,所以也存在同步的过程。
生产者消费者模型需要维护这三种关系。
所以生产者消费者模型思考模式可以认为是321原则(实际上并没有这个原则,只是方便记忆)
基于BlockingQueue的生产者消费者模型
进程间通信本质就是生产者消费者模型,所以管道内部自带同步互斥机制,管道文件和共享内存就叫做交易场所。
一个线程写单生产者模型,一个线程写单消费者模型,一个线程从队列里放数据,另外一个线程从队列里拿数据,blockqueue就是交易场所。
完整代码演示
关于Push与Pop
如果单纯的这样做肯定是不可取的,因为有可能会存在多个线程同时进行push和pop,这样做保证不了线程安全。
目前在push的时候还存在一个问题,加锁和解锁已经可以保证生产的安全了,但是当队列生产满的时候,带来的结果就是申请锁-检查-条件不满足-返回;也就是生产者一直做着大量无用的工作,就如同当年的你一直问售货员手机到了没。此时你不断的做着无用功,而且此时你占有着锁资源,而导致别人无法进行消费。
所以我们可以进行如下修改
当我竞争这个锁,检查条件不就绪,直接把自己挂起,挂起也不用担心,会自动释放锁,就给了别人申请锁的机会。
我现在被挂起了,我怎么知道我当前可以进行生产了呢?
只有生产者才能清楚的知道消费者什么时候可以消费,只有消费者清楚的知道生产者什么时候可以生产。eg:我向超市放了10瓶水的时候,我立马就意识到消费者可以来消费了。
我们让生产者和消费者知道什么时候该生产和消费,这不是交易场所的任务,而是对方线程的任务
程序运行结果
执行结果:速度非常快
我们让生产者生产的慢一点,如果没有任何的保护,生产者在疯狂的生成,消费者在疯狂的消费,但是我们今天加了同步,主节奏必须按producter来,消费者即使很着急也必须按照producter的节奏来。
执行结果:生产者生产一个数据,消费者消费一个数据
我们让消费者慢一点,当前你的生产者可能生产的很快,但因为我消费的慢,所以你的主节奏必须以消费者来,我们看到的现象是:因为生产者没有sleep,所以一瞬间,生产者就把缓冲区打满了,然后就是消费者消费一条,生产者生产一条。
执行结果:
我们现在的队列是生产一个唤醒一个,我们现在把队列设置成当队列中的数据超过1/2的时候,就通知消费者来进行消费。当队列中的数据小于1/2的时候,通知生产者进行生产。
执行结果:
管道:读端不读,让生产者一直生产,最后生产者把数据生产慢的时候,OS就不让生产者生产了,不让生产的本质就是把生产者挂起了。我们在等消费者消费,所以管道本质就是固定大小的生产者消费者模型。进程间通信的本质就是生产消费者模型。管道或者共享内存的缓冲区在进程间是由OS提供的,也就是缓冲区的使用需要你自己写,而在多线程这里可以直接使用stl,就不用处理进程间通信交易场所的结构问题(共享内存的结构就需要你维护)。
目前程序存在的问题
对于push接口,当队列满了的时候,我们进行挂起,上面我们是什么也没有就行处理的,直接挂起。可是会存在两种情况:
- 1.如果我挂起失败了,该怎么办呢(是函数调用,就有可能调用失败)?
- 2.如果我被伪唤醒了呢(我被挂起了,可是队列是空的)?
因为这里是if判断,所以当生产者被唤醒的时候这里默认就是向下执行,有可能因为这两种情况,让我们的生产条件不具备,有可能当前队列还是满的,你向下继续走,你就向它里面插入了一条不属于它的数据。所以使用if来进行判断是不太完善的。
由于存在这两种情况,所以当我醒来的时候,我不应该先着急的向下执行:把数据push到队列里面,正确的做法是只要我醒来,我应该再做一次判断,只要IsFull这个条件是满足的,就说明它还是满的,我们就继续尝试将自己挂起,挂起失败了我也会不断的挂起。如果我被为唤醒了,我也会重新投入到等待队列里,如果检测到队列不满了,我再继续向里面放数据。我们只需要将if改成while(pop也一样)。我们需要进行条件检查的时候,这里需要使用循环方式,来保证退出循环一定是条件不满足导致的;因为条件不满足,走到push和pop,这个数据一定是可以被放进或者拿走的。
什么是挂起失败?
挂起失败就是函数调用失败,你就不能把自己放到等待队列里,继续向后走。
什么是伪唤醒?
条件可能并没有满足,但是我这个线程被唤醒了。比如很多线程在条件变量下等,等的时候 (在多CPU的情况下被唤醒),虽然大家会排队,但是在多CPU下执行代码的时候,有可能向目标条件变量发送条件就绪这样的指令,最后可能导致当前的线程被伪唤醒 。
再比如:我们现在的代码是只要别人生产一条数据,就把消费者唤醒了。有可能生产了一批数据,生产10个就唤醒消费者10次,当生产10次的时候,消费者正在挂起,等待生产,当消费者被唤醒的时候可能才处理掉一个wakeupconsumer这样的信息,当我不断处理的时候,我也在wakeupproducter,压根生产者就是不生产,这对生产者是无效的。当我们无脑进行生产的时候,可能我自己在消费的时候压根就不是因为生产者把我唤醒了,而是我本来就醒着了,我就一种在这里消费,但是生产者每次都会唤醒我,这里发送的通知信息量就是过量的东西,当你进行等待的时候可能会收到历史上的一些通知导致伪唤醒。
为什么要用blockqueue呢?
生产和消费传输数据只是第一步。
- 1.生产者的数据是怎么来的?耗时吗?
- 2.消费者拿到数据怎么处理?耗时吗?
- 我们的生产消费者模型,就是生产把数据push到blockqueue里面,然后消费从blockqueue里面拿出来。这里存在生产者和消费者等待的问题,可这里为什么使用blockqueue呢?我们直接用函数调用不就行了?
这是因为生产消费者模型不光考虑传送数据的问题,还要考虑数据是怎么来的,数据处理怎么处理。数据来源比较慢,数据处理比较快,我们就可以让生产者多生产一批数据后,让消费者消费,此时生产者继续拿数据,消费者继续消费数据,所以我们就能够缓存一部分数据后,让生产和消费同时进行。这个同时必须体现在一个场景---任务处理。
我想把我的任务通过交易场所交给消费者消费。消费者消费不是说把数据拿到它的上下文里就续消费完了,消费者消费:1.把数据拿走 2.做数据处理。比如:你在超市买了一包方便面,买回去后只是消费的第一步,之后你还要把他消费掉,你就在宿舍把它吃了。
生产任务的例子
生产一批任务。比如:就是+,-,*,/,%这样的操作,还有参与运算的数据:x,y。我要把这批数据封装成一个任务,把这个整体放进任务队列里,然后让另一个线程,从任务队列里把任务拿到,放进自己的上下文里,然后对x,y进行相关操作。
eg:
我们添加一个Task.hpp
对CpTest.cc进行修改
至此就完成了生产者不断的生成任务,消费者不断的从队列拿到任务,处理任务
执行结果:
消费者处理任务需要时间,就有可能出现消费者在消费任务的同时生产者在生成,此时就实现了二者的并行。
目前我们的生成消费者模型是按照单生成者进行的,同样支持多生产者。因为我们的互斥锁就维护了。
执行结果:生产者派发任务,消费者自然而然的就帮助消费了
如果此时我们的生产者是你的登录任务,注册任务,是你要请求某个网页或者数据的任务,我们都可以进行处理,相当于我们用一个producter获取数据,它不做处理,而是扔到任务队列里,自然而然有消费者帮他消费。
生产消费者模型中,谁把数据放到队列里,谁把数据拿到,不是主要矛盾,处理数据需要多长时间,获取数据需要多长时间,这才是主要矛盾,生产消费者模型,主要解决的是在你制造,获取数据的时候,如果比较慢,如果队列里还有数据,消费者就可以同步的进行消费数据,这就提高了并发性。之后我们就可把算法设计成任务,你想让机器帮你跑各种任务时,就可以套用生产消费者模型。每个任务都可以是特定的一种算法。
七、POSIX信号量
回顾信号量的概念
a.信号量本质就是一把计数器,描述临界资源中资源数目的大小(最多能有多少资源分配给线程)。
b.买票的本质是:预定资源。临界资源如果可以被划分为一个一个的小资源,如果处理得当,我们也有可能让多个线程同时访问临界资源的不同区域,从而实现并发。如果能被拆分的话,我们就需要一种多线程预定资源的手段。我们可以结合信号量在应用层设计出能让不同执行流访问临界资源的不同区,从而设计出临界资源的预定机制。
b.电影院例子,--不能多卖票
每个线程想访问临界资源,都得先申请信号量资源。只要申请到了信号量,一定是会有你的小块资源的。(类似你在电影院买了票,就一定有你的位置)
信号量本质是一把计数器,合理使用信号量能够达到对临界资源进行预定的目的。
认识信号量对应操作的函数
此时多个线程可能同时多count--,本质就是对计数器做相关运算,--本质是经过三个步骤的(在计算机的层面),它本身就不是安全的。所以我们为了保证安全,可以进行加锁。
P与V操作就是安全的对计数器进行--或者++操作。
初始化信号量
#include <semaphore.h>int sem_init(sem_t *sem, int pshared, unsigned int value);参数:pshared:0 表示线程间共享,非零表示进程间共享,我们这里直接设成0即可value :信号量初始值是多少,你想让信号量是多少,这里填几就可以返回值:成功就是0,失败就是-1
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量(P操作)
功能:申请信号量,成功会将信号量的值减 1,继续向后执行,失败就挂起等待int sem_wait(sem_t *sem); //P()
发布信号量(V操作)
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加 1 。int sem_post(sem_t *sem);//V()
P,V底层就是刚刚的伪代码逻辑。
认识环形队列
环形队列任何一个位置都可以当作是起点,当我把环形队列放满的时候,会继续放,如果一个格子只能放一个数据,那么第二次放的时候就会覆盖掉原来的数据。
环形队列什么时候为空,什么时候为满?
开始为空的时候,拿和放是同一个位置,现在为满了,拿和空指向的还是同一个位置。
如何判断有数据?
放的位置!=拿的位置,就说明有数据存在。但是当前为空或者为满的时候很难判断为空还是满。
1.计数器解决。 放的时候++,拿的时候--。计数器=0就是空,计数器=队列能容量的最大数据就是满
2.镂空一个位置。 我们放数据还是进行放数据向后进行移动。但是这次在放之前,先做判断。如果当前的位置+1 != 拿,可以放;否则,不放。
此时当为空的时候,拿和放是同一个位置,为满的时候,放的下一个位置就是拿的位置。
- 放 != 空 ,有数据;
- 放 == 空 ,队列为空
- 放+1 == 空 , 队列为满
环形队列我们采用的是数组模拟的。我们的数组通过模运算模拟环形结构
我们接下来要做的就是,多线程情况,来进行环形队列的并发访问,实现一个基于环形队列的生产消费者模型。
基于环形队列的生产消费模型
基本原理
生产者和消费者队列为空的时候,指向的是同一个位置!生产者和消费者在队列满的时候,也指向同一个位置!此时不能让生产和消费同时进行(此时要有互斥特性)。队列为空应该让生产者生产,队列为满应该让消费者消费。
那么,当队列不为空,不为满的时候,生产者和消费者一定指向的不是同一个位置!此时生产和消费可以并发执行。
基本实现思想
生产者,最关心什么资源呢?
环形队列中,空的位置。
消费者,最关心什么资源呢?
环形队列中,数据。
制定规则
- 规则1:生产者不能把消费者套一个圈(生产者把队列放满以后,不能在继续放一圈数据)。
- 规则2:消费者不能超过生产者 。
- 规则3:当指向同一个位置的时候,要根据空,满的状态,来判定让谁先执行(满了只能让消费者走,空了只能让生产者走)。
- 其他:除此之外,消费和生产可以并发执行。
如何实现呢?(伪代码)
信号量是描述临界资源中资源数目多少的一个计数器,生产者看中的是空位置所以是10,消费者看中的是数据,起初数据是没有的所以为0。
开始生产者生产的巨快,消费者消费的巨慢,所以生产者进行不断的生产,因为每次生产都必须得申请信号量,信号量资源刚开始是10,生产者不断向环形队列里放,最后放了10个资源后,在去进行申请格子资源,就不给你了,因为你的信号量计数器就减为0了,此时只能让消费者来消费了,消费者疯狂消费,每一次消费都申请data资源,data资源是生产者加的,最终把data资源消费完,data减到0,此时队列又空了,此时就不能让消费者消费了,应该让生产者生产;依次轮询。这就完成了环形队列的生产者消费者模型。
单生产者单消费者
结合sem+唤醒队列编写生产消费者模型(此时这里是单生产者,单消费者)
此时生产者生产一个sleep1秒,最终结果就是生产一个,消费一个
如果让消费者慢一点,就是一瞬间队列就慢了,然后消费一个生产一个
管道:如果将管道内部的数据生产满了,如果你不消费,那么管道的写端就会挂起,如果你取一部分数据,管道才会写入一部分数据,管道的底层也是基于环形队列完成的。
多生产多消费者
我们需要再维护生产者和生产者之间,消费者和消费者之间的互斥关系,现在我们不允许两个及两个以上的生产者同时生产或者消费者同时消费,因为所有的生产者共用所有的生产下标,消费者共享所有的消费下标,我们可以引入两把互斥锁维护生产者和消费者各种内部的互斥关系。
在任何时候,生产者可以进入一个,消费者可以进入一个,但是生产和消费却可以同时进行
加锁的过程应该在申请信号量之前还是之后 ?
信号量的P操作本身是原子的,但是信号量的值可以是5,6,7,8...如果用多个线程执行push的时候,多个生产线程可能同时都申请限号量成功了。当你申请成功的时候,一定有你对应的位置。
如果放在申请信号量前加锁,只有申请锁成功的人才能进来申请信号量,就相当于虽然你的信号量资源设置成10,但是在申请信号量资源的时候还得一个一个申请。对我们来讲,我们把锁放在之前不会出错,但并不是我们想要的。这样效率偏低,在任何时刻只允许一个执行流访问信号量,这样写到前面和只有一个生产者本质上没有区别。
写到后面,我们可以保证,进入生产区域包括更新下标的时候,只有一个执行流进入,如果是多生产者,所有人有资格竞争锁的前提是你必须得先申请信号量,就相当于我们把所有的信号量可以预先分配给所有的生产者,然后当锁一旦释放了,其他人就立马申请锁,在你进入到生产区域的时候,别人就可以并行的先把信号量准备好。
当有一个线程进入Push,申请锁成功了,信号量有了锁也有了,你就可以保证资源我一定有,我在访问的时候我可以和我同类的生产者保持互斥关系,在我进行生产的内部操作也没有人打扰我。当我正在进行生产的内部操作时,其他线程也进来了,虽然他们申请不到锁,但是可以提前申请信号量,这样的话就能保证有人释放锁,我立马就申请到,随后进入生产内部进行操作。这样的效率是会高一些的。
多生产和多消费的优势不在于所谓的放数据拿数据,而在于并发的获取和处理任务(当我放数据之前我是怎么获得的,拿到数据后我是怎么处理的)。
这个区域就能保证任何一个时刻只允许一个生产者进入到生产区进行生产,更新下标。
因为我们是分开的使用两把锁,所以此时我们就维护了生产者和生产者之间的互斥关系,消费者和消费者之间的互斥关系,他们两套互斥互不影响,任何时刻,只允许一个执行流进入生产者生产,任何时刻也只允许一个执行流进入消费者消费。进入临界资源的生产和消费(生产者和消费者之间)用信号量保证。
执行结果:我们看到不同的线程在消费和生产
让生产消费者模型进行处理任务
ring_queue.hpp同上
执行结果:
八、线程池
内存池
stl容器的扩容就是一次申请一批空间,可以不用,但是我会提前准备好,需要的时候直接拿就可以,这个时候的给你就不需要用户和OS交互了。
但是也会衍生两个问题:
- 1.大块内存在用户空间,需要用户进行管理
- 2.内存池的核心目的是提高效率
线程池的概念
一个任务到来的时候,我们通常的做法是创建一个线程,让线程帮助我们处理任务。当我任务到来的时候才去申请线程,就相当于当我需要内存的时候,才去向OS要。创建线程也是有成本的。我们一般可以预先创建出一把线程。
对于某种服务,其中有很多客户请求,当客户请求过来的时候,每来一个请求我创建一个线程,难道不应该是来了一个请求线程立马提供服务吗?
就好比你吃海底捞,你去了以后老板和你说,先等等,我先给你去招聘员工,培养一下,完成之后,大半年过去了,你再来吃饭,这样显然是不行的,所以请求来了,线程必须提前转备好,当任务没来时,预先创建一批线程。所以一旦有任务的时候,我只需要把任务指派给某一个线程,让某个线程去运行就可以。其中提前转备好的线程,用来随时处理任务,就称之为线程池!线程池也是为了提高效率。
按照以下的思路实现一个线程池
这种写法可行吗?
在类中,要让线程执行类内成员方法是不可行的! Routine是类内的成员函数,每个成员函数都隐含的传入了一个this指针,如果你想创建线程,那么这个Rounine不仅传递了this指针,而且还包含了args,那么这个方法在语法上就直接报错了。
如何解决呢?
类内中要执行线程处理逻辑,必须让线程执行静态方法。因为一个类的成员方法被改成静态,那么它是没有this指针的,那么它也没有办法直接去访问类内的非static成员。所以要被线程成功调用必须得是静态的才能被成功执行 。
如何避免线程的饥饿问题呢?
作为一个线程池,不是为了创建一批线程而创建,而是将来我想有一个线程池,有外部的线程不断的向我的线程池里塞任务,其他的线程可以竞争式的从我的任务队列里拿任务,然后在自己的线程上下文中消化任务。也就是将来主线程可以不断向线程池里push任务。这也就是典型的生产消费者模型。此时类内的task_queue_就是临界资源
如果线程池里的线程检测到任务队列为空,正常情况下,线程把锁释放掉,此时这个线程不休眠,下次还是要竞争锁...同时这也就是在抢票的逻辑中,即使票数再多,也还是一个线程在抢。实际上还是因为在代码中,一个线程在不断的进行抢票,就是在不断的征用我们的锁,有票还好,没有票就相当于在轮询做检测,竞争锁失败的人就一定被挂起了,如果我是竞争胜利的一方,我一直在申请释放,申请释放,最后导致的结果就是可能有大部分时间都是同一个线程在帮你申请锁,处理任务,释放锁。所以这种方式,没有错,但是不合理。
这种是没有问题的,如果没有任务的时候,线程大部分时间都在申请锁,检测有么有任务,没有就解锁,再进行循环,下次再申请锁,再检测,再解锁。如果今天就没有任务,那么这些线程就在疯狂的竞争锁,检测有没有任务,没有任务,在开锁。这就是不合理的。所以这种方式没错,但是不合理。
引入条件变量
这个线程先申请一个是它拿到了锁,然后检测队列是否为空,为空就执行wait方法,这个wait方法就把锁释放了,然后将自己挂起了。
两种写法比较
写法一: 正确写法
写法二:不合适
对于写法一,线程A和线程B在进入
Routine
后,都会尝试获取互斥锁。假设线程A先获取到互斥锁,但发现任务队列为空,于是它调用pthread_cond_wait
进行等待,同时释放互斥锁。此时线程A被挂起。接着线程B进入
Routine
,也尝试获取互斥锁,但由于线程A持有锁,线程B必须等待线程A释放锁。一旦线程A释放锁,线程B可以获取到锁并进入临界区。当任务队列中有任务时,线程B可能会被唤醒并执行任务。线程A也可能在某个时刻被唤醒。在这种情况下,线程A和线程B会竞争互斥锁,谁先获取到锁谁就执行任务。
对于写法二,就是多个线程在竞争互斥锁。一旦线程A进入
Routine
并获取到互斥锁,线程B就必须等待线程A释放锁才能获取锁并进入临界区。这两种写法的区别在于线程等待的方式和触发唤醒的时机。写法一中,线程在等待时使用条件变量进行等待,并在任务队列非空时接收到唤醒信号。而写法二中,线程在等待时使用互斥锁进行等待,只有当获取到互斥锁时才能继续执行。
写法一更适合多个线程竞争任务的场景,而写法二更适合多个线程竞争互斥锁的场景。选择使用哪种写法取决于具体的应用需求和线程间的竞争关系。
如果使用写法二,并且存在一个线程(比如线程A)的竞争力特别强,那么它可能会连续获取到互斥锁并执行任务,导致其他线程无法获得锁并执行。
这种情况下,线程A会占据大部分的执行时间,而其他线程可能会被长时间地阻塞。这可能导致其他线程的响应性变差,甚至可能出现饥饿现象,即其他线程无法得到执行的机会。
这是写法二的一个潜在问题,即可能出现不公平的情况。线程的调度和运行是由操作系统决定的,如果线程A一直获取到互斥锁并运行,其他线程无法获得锁,就会导致其他线程无法得到公平的执行机会。
在写法一中,当多个线程被唤醒时,它们也确实会竞争互斥锁。这可能导致某个线程获取到锁并开始执行任务,而其他线程则需要等待下一次被唤醒的机会。
因此,在写法一中,仍然存在某些线程在竞争锁的过程中占据较多的执行时间,可能导致其他线程的等待时间增加,响应性降低的问题。
然而,相对于写法二,写法一使用条件变量进行等待和唤醒,更加灵活和公平。条件变量的机制允许线程在适当的时机被唤醒,而不是简单地依赖于锁的释放。这可以减少某个线程连续占据锁的概率,提高其他线程获取锁的机会。
简单来说:
在写法一中,所有线程都在等待条件变量满足,一旦条件满足并且有线程被唤醒,所有线程都有机会竞争执行任务。因此,可以看作是所有线程在竞争被唤醒的机会。这种方式可以提高线程的公平性,避免某个线程长时间占据资源而导致其他线程等待的时间过长。
而在写法二中,所有线程都在竞争互斥锁。只有一个线程能够获得锁并执行一系列操作,其他线程必须等待该线程释放锁才能有机会获得锁并执行。这种方式下,如果某个线程竞争力较强,其他线程可能会被长时间地阻塞,导致饥饿问题的发生。
因此,写法一更适合多个线程竞争任务的场景,而写法二更适合多个线程竞争互斥锁的场景。在选择使用哪种写法时,需要根据具体的应用需求和线程之间的竞争关系来进行决策,以确保线程的公平性和避免饥饿问题的发生。
完整代码
执行结果:
查看线程也确实有6个线程,第一个是主线程 ,其余5个是我们创建的线程
如果我们创建10个线程
执行结果:此时就会有10个线程帮我们处理
如果我们不进行sleep,线程就会疯狂的处理任务。
执行结果:OS会对线程做约束,线程就被Killed掉了,就是因为创建的线程过多,导致系统资源紧缺,就把线程干掉了。被杀掉很正常。就比如:访问某些网站发现突然挂掉了,要么就是网站有bug,要么就是网站负载太大,负载过多的话,OS为了保护自己就把消耗资源过多的任务干掉了。
九、线程安全的单例模式
什么是设计模式?
世界上比较成熟的行业都是有自己固定的套路的,比如盖房子,什么房子透光性号,什么房子社和居住,什么样的结构是比较稳定的...设计模式其实就是一些固定的套路。这些固定的套路在编码当中被设计出来能够很快的的让我们写出高质量的代码。比如:IT行业这么火, 涌入的人很多. 俗话说林子大了啥鸟都有. 大佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖大佬的后腿, 于是大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是设计模式。所谓的设计模式在计算机中本质就是一种编程经验,编程经验高度提炼出的一种思想总结。比如飞机,桥梁的设计,不同的行业都有自己的方式,哪种方式是比较好的,我们就可以把经验提炼出来,设计出一种模式。目前最典型的就是抗疫中的“中国模式”,这种模式是可以传播到其他国家的,不过有的地方太菜了,即使你爸经验总结的再好,它也抄不了作业。
单例模式
最典型的特定是这个类,我只允许它有一个对象,我就把他称之为单例。
比如:在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据.也就是这个数据在内存中只有一份。
我们向内存中加载的各种动态库,在内存中也只需要一份,每个进程只需要把动态库映射到自己的地址空间,就可以访问动态库了,把动态库看做成一段代码和数据的集合,他也是符合单例的特点。也就是说将来我们的服务当中我就是想让这部分数据在我们的系统当中只出现一份,这就叫做单例模式。
饿汉实现方式和懒汉实现方式
洗碗的例子
饿汉方式实现单例模式
懒汉方式实现单例模式
针对懒汉模式实现一个单例
线程池只需要存在一个就可以,线程池内部也存在大量的空间,因为要保存各种任务。我们这里实现一个单例版本的线程池。
首先将构造函数设置成私有
当类被加载到内存中时,因为没有对象,num_,task_queue,mtx_,cond_都不会被创建,只有一个静态指针ins才会被创建.
获取单例的函数方法,至此就能使用这个单例
此时如果我们还想创建对象就会报错,因为构造函数是私有的
所以我们要通过GetInstance使用它
完整代码:
执行结果:因为没有都要调用GetInstance,但是只有第一次输出首次加载对象,说明我们用的是单例
我们还可以打印下地址,确实是单例方式
目前的懒汉存在的问题
单例本身会在任何场景,任何环境下被调用,那么GetInstance()这个函数方法,会被对线程重入,进而导致线程安全的问题,比如第一个线程判断ins为所谓的nullptr,那么它正准备创建对象,就被切走了,剩余的多个线程同时检查ins,每一个线程都创建了对象,此时就出现了问题,所以现在的代码是有线程安全的。
如何解决呢?
1.我们让创建对象和检查的方式是原子的
当我加锁解锁后,我就不担心多线程同时进来的时候,检查ins是否为空创建出多个对象了。但是仍然存在个小问题,当4,5个线程进来的时候,哪怕这个单例已经被创建出来了,但是每个线程都得竞争锁,竞争以后在锁内部做检测,检查它已经被创建了,不等于nullptr,然后释放锁,争锁的过程就是串行化的过程,成本太高。
2.使用双判断的方式
第一次进来,一定存在线程安全的问题,但是没关系,继续申请锁,创立单例完成的那个线程,然后释放锁,从此以后,其他线程只需要判断ins是否等于nullptr,ins不等于nullptr,直接返回ins,就不会再像之前一样,已经创建好单例后还进行竞争锁。双判断,减少锁的争用,提高获取单例的效率
十、STL,智能指针和线程安全
STL中的容器是否是线程安全的?
智能指针是否是线程安全的?
十一、其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁?
十二、读者写者问题
基本原理
出黑板报,就是一个人在写,多个人在读,就是一个最经典的读者写者模型。
写者和写者:竞争关系,也就是互斥关系(我们不考虑把资源细致划分,只是整体划分),只有一块黑板,我在写我的内容的时候,其他人是不能来写的。
读者和写者:互斥关系和同步关系;当我作为吃瓜群众看人家出黑板报,其他人也陆续来看,A说人家画的蛇真好,我是人家是画的龙,C说人家明明画的是兔子,最后画完的时候,发现人家画的是乌龟。这说明别人正在写入时,我们三个对数据读取的不同阶段,不同内容最后得到的结果是不一样的,也就是别人正在写的时候,我来读了,导致我读到的数据不一致的情况。所以读者和写者的关系一定是有互斥的,当别人正在写的时候,我就不要来读,我读到是数据就不是完整的。当黑板报出完,我在看,此时看到的信息就是完整的。同样的,当我在看的时候,突然来个人,把黑板报擦完了,我才看了一半,就没了,然后他又写了其他的信息。这就是我还没看完就给我修改了,这就是因为我正在操作时你也来操作,我不允许这样干,也就是当你看到我还在看,你就别着急操作,等我看我你在操作,这就叫做互斥。
光有互斥是不可以的,你们的黑板报三年都没换过,因为互斥,陆陆续续都有人看,这显然是不行的,正确的做法是,读的人读完了就让写者来写,写的人写完了就让读者来读,所以需要有一个同步关系。
读者和读者:没有关系;生活中是不存在排好队一个一个来读黑板报的情况的,你在读的时候,别人也再读。
类比生产消费者模型
生产和消费者 vs 读者和写者
消费者和消费者之间是互斥关系,但是读者和读者之间是没有关系的,根本原因就是读者不会取走资源,而消费者会拿走数据。
使用代码完成读者写者模型,本质就是使用锁,维护上面三种关系。读者和写者的角色由线程承担
生活中适合读者写者模型:新闻发布
基本操作
创建读写锁
以读者身份加锁
try是非阻塞加锁,申请锁,默认失败挂起,trylock默认不挂起,而是出锁返回
以写方式加锁
如何理解上面的这几种接口及读者写者模型呢?
我们用伪代码来帮助大家理解。
我以读方式加锁,就能维护读者和读者之间的没有关系。我以写方式加锁,就可以写者和写者之间的关系。他们是如何做到的呢?
只有读者把个数减完,写者才能进入。读者进来计数器++,此时写者就进不来了,只有当读者读完,减为0后,写者才能进来访问临界资源,假设此时没有读者,当写者来的时候发现要加锁,如果有读者就挂起等待,如果没有就解锁,访问临界区。当我正在访问的时候,如果其他写者也进来了,就先申请锁,发现没读者就解锁访问临界区。两个写者就都进来了,当写者进入临界区做修改的时候,读者同时可以来,readers++,此时写者正常进行操作。
所以我们可以做一个优化
现在读者和写者竞争锁,读者先进来,对readers++(原子性++),在我读者正在进行读的时候,写者来了,写者是有可能申请到锁的,它检测到到期读者不为0,就去休眠了,并把锁释放掉了,当读者全部完成之后,写者醒来检测到读者全部为0了,然后写者就进入临界区,但是此时写者是拿着锁的(wait()唤醒后重新拿到锁),这时候其他写者来了就不怕,其他写者申请锁是抢不过我的,读者来了也不怕,因为读者要更新自己的readers计数器也要申请锁。所以只要写者完成了加锁,后序只有一个写者可以完成修改,修改完释放锁之后,才允许其他写者或者读者进来。
我们两个人用一把锁,读者用这把锁多计数器做更新,写者对readers做判断,一旦判断到没读者了,就进入临界区做修改,在此修改的过程中是其他的写者和读者是进不来的,这就叫做维护了写者和写者的互斥,读者和写者的互斥,第二个就是读者和读者没关系,只要读者申请锁成功了,意味着没有人写,读者只需要把计数器增加,然后读者尽情的读,没有人打扰它(写者会因为条件判断进不来)就维护了读者和写者之间的互斥关系。readers也可以称之为临界资源。
优先级的问题:
我们上面的伪代码是看不出来谁优先的,因为开始两个是竞争的,正经的读写锁在实现的时候是存在两种策略的,就好比我今天是看黑板报和写黑板报的,谁先谁后呢?
生产消费者模型没有考虑过谁是优先的是因为:1.他俩地位是对等的 2.其实考虑了优先,只不过优先的策略是队列为空时,应该让谁先跑,队列为满时,又应该让谁先跑,生产消费模型中我们的依据是空和满来决策生产消费谁先运行的问题,所以不存在一定谁先运行。
读写锁采用的策略:
- 读者优先:让读者和写者同时到来的时候,我们让读者先进入访问
- 写者优先: 当读者和写者同时到来的时候,比当前写者晚来的所有读者,都不要进入临界区访问了,等临界区中没有读者的时候,让写者先写入;
默认我们采用的是读者优先。
读者写者模型本来就是适合读者多,写者少,如果读者优先的话,读者和写者同时到来,读者优先,那么读者不断来,不断来,不间断,那么写者是不是就没有机会写了,这就导致了写者出现饥饿问题。所以,目前是存在写者饥饿问题的。但是那又怎么样呢?
有饥饿问题并不代表永远不让你写,而是数据让别人都读完之后,再让你修改。所以这个饥饿问题是一个中性词。
读者写者实例
#include <vector>
#include <sstream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
volatile int ticket = 1000;
pthread_rwlock_t rwlock;
void *reader(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_rwlock_rdlock(&rwlock);
if (ticket <= 0)
{
pthread_rwlock_unlock(&rwlock);
break;
}
printf("%s: %d\n", id, ticket);
pthread_rwlock_unlock(&rwlock);
usleep(1);
}
return nullptr;
}
void *writer(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_rwlock_wrlock(&rwlock);
if (ticket <= 0)
{
pthread_rwlock_unlock(&rwlock);
break;
}
printf("%s: %d\n", id, --ticket);
pthread_rwlock_unlock(&rwlock);
usleep(1);
}
return nullptr;
}
struct ThreadAttr
{
pthread_t tid;
std::string id;
};
std::string create_reader_id(std::size_t i)
{
// 利用 ostringstream 进行 string 拼接
std::ostringstream oss("thread reader ", std::ios_base::ate);
oss << i;
return oss.str();
}
std::string create_writer_id(std::size_t i)
{
// 利用 ostringstream 进行 string 拼接
std::ostringstream oss("thread writer ", std::ios_base::ate);
oss << i;
return oss.str();
}
void init_readers(std::vector<ThreadAttr> &vec)
{
for (std::size_t i = 0; i < vec.size(); ++i)
{
vec[i].id = create_reader_id(i);
pthread_create(&vec[i].tid, nullptr, reader, (void *)vec[i].id.c_str());
}
}
void init_writers(std::vector<ThreadAttr> &vec)
{
for (std::size_t i = 0; i < vec.size(); ++i)
{
vec[i].id = create_writer_id(i);
pthread_create(&vec[i].tid, nullptr, writer, (void *)vec[i].id.c_str());
}
}
void join_threads(std::vector<ThreadAttr> const &vec)
{
// 我们按创建的 逆序 来进行线程的回收
for (std::vector<ThreadAttr>::const_reverse_iterator it = vec.rbegin(); it !=vec.rend();++it)
{
pthread_t const &tid = it->tid;
pthread_join(tid, nullptr);
}
}
void init_rwlock()
{
#if 1 // 写优先
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
pthread_rwlock_init(&rwlock, &attr);
pthread_rwlockattr_destroy(&attr);
#else // 读优先,会造成写饥饿
pthread_rwlock_init(&rwlock, nullptr);
#endif
}
int main()
{
// 测试效果不明显的情况下,可以加大 reader_nr
// 但也不能太大,超过一定阈值后系统就调度不了主线程了
const std::size_t reader_nr = 1000;
const std::size_t writer_nr = 2;
std::vector<ThreadAttr> readers(reader_nr);
std::vector<ThreadAttr> writers(writer_nr);
init_rwlock();
init_readers(readers);
init_writers(writers);
join_threads(writers);
join_threads(readers);
pthread_rwlock_destroy(&rwlock);
}
写者优先
读者优先
读者优先会造成写者的饥饿问题。
挂起等待特性的锁vs自旋锁
你到了一个考试的时间点,但是之前学校的课程你并没有好好的学过,你现在非常着急,你就跑到你班的学霸张三的宿舍楼下,比如你叫李四,那么就是李四在楼下等张三。因为你特别想让张三辅导你以下,你就跑到楼下给张三打电话, 和他说你现在可以下来给我辅导辅导数学吗,但是张三说不好意思,我正在复习英语,还需要一个小时,一个小时之后再辅导你高数。然后你就和张三说,我现在去学校跟前的网吧打会游戏,等你好了以后你给我打电话,我到时候过来咋俩一起去自习室。大概过了一个礼拜,又要考英语了,你又来了张三宿舍楼下,给张三打电话,询问能否给自己辅导英语。这次张三立马就说可以啊,1分钟就下来。这次李四是绝对不会说“我现在先去门口网吧打会游戏,等你好了之后,你再给我打电话到时候咋俩一起去自习室” 。所以李四就在楼下等一分钟,但是等了1分钟后,张三还没来,你就给他打电话,他说马上,马上我刚才接了个电话。然后你就继续等,过来1分钟张三来了,然后你俩就一起去了自习室。
两个场景中,决定李四是在楼下等,还是在网吧玩游戏等的核心原因是什么?
这取决于张三还需要多长的时间才下来。张三如果需要的时间久,就去网吧(这个动作叫做将自己挂起)。张三是一个线程,李四是一个线程,如果李四等张三等待的时间久了,李四就不应该在楼下等,挂起要在路上花时间,唤醒又要从网吧把他唤醒回来。所以挂起是有成本的。
张三需要的时间短,我们就应该在楼下等待,在等待时候如果发现他没下来,我就给他打个电话,我们给他不断打电话本质就是在检测张三的状态,我们叫做自旋的过程。
有很多的线程都能申请到锁,然后进入临界区完成工作。如果有7,8个线程也只能是拿到锁的那个线程才能进来。申请锁成功进入临界资源,申请锁失败,进行挂起等待。直到别人释放锁,等到锁条件就绪了,然后你们几个人再竞争。
可是我们并没有考虑过线程访问临界资源花费的时长问题。
将线程挂起等待是有成本的,你把一个线程挂起,说白了就是把这个线程的pcb放在某个等待队列里,然后OS觉得条件就绪了,然后就会再从等待队列里把你这线程找到,再把你唤醒回来。一定会涉及到一个状态变换,数据结构做调整。就像李四等张三,如果张三需要的时间长,李四就挂起去网吧,但是去网吧和回学校的时候在路上是要花时间的。所以挂起等待是需要成本的。
线程在访问临界资源的时候,如果花费的时间非常短呢?
一个线程进入临界资源内,很快就完了,占用临界资源的时间特别短,我们就比较适合自旋锁。自旋锁本质:就好比线程A申请锁成功了,其他线程不断的去循环问锁你好了没,你好了没...锁说我没好,我没好...但是每个线程一直都在问,这就叫做自旋锁。因为线程A很快就能释放锁,所以其他线程不用进行挂起等待,自旋过程之中就有可能的尽快的得到锁,进而达到了提高效率的目的。本质:不断的通过循环,检测锁的状态。
线程在访问临界资源的时候,如果花费的时间非常长?
此时比较适合挂起等待锁。
线程如何得知自己会在临界资源中待多长时间呢?
线程是不知道的,但是程序员知道。曾经抢票的逻辑就是和自旋锁。抢票就只是对计数器做++或者--,在临界资源待的时间特别短,就适合用自旋锁。
自旋锁的基本调用
初始化与销毁
加锁
申请锁,比如申请不成功,它在底层帮你while循环,不用我们自己手动写个while循环进行检测