文章目录
- 前言
- 一、线程
- 1、线程概念
- 2、线程使用
- 2.1 pthread_create
- 2.2 线程共享数据和私有数据
- 2.3 为什么线程切换的成本更低?
- 3、线程性质
- 3.1 线程的优点
- 3.2 线程的缺点
- 3.3 线程异常
- 3.4 线程用途
- 4、vfork接口
- 二、线程控制
- 1、线程创建
- 2、线程等待
- 3、线程退出
- 4、线程取消
- 5、线程id
- 6、线程分离
- 7、c++中的线程
- 三、线程互斥
- 1、进程线程间的互斥相关背景概念
- 2、互斥锁
- 2.1 全局或静态的互斥锁
- 2.2 局部互斥锁
- 3、互斥锁原理
- 4、可重入VS线程安全
- 4.1 常见的线程不安全的情况
- 4.2 常见的线程安全的情况
- 4.3 常见不可重入的情况
- 4.4 常见可重入的情况
- 4.5 可重入与线程安全联系
- 4.6 可重入与线程安全区别
- 5、死锁
- 5.1 死锁四个必要条件
- 5.2 避免死锁
- 5.3 避免死锁算法
- 四、线程同步
- 1、条件变量
- 2、生产者消费者模型
- 2.1 生产者消费者模型介绍
- 2.2 BlockingQueue
前言
一、线程
1、线程概念
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
下面我们先来看一下之前我们学习的进程地址空间。我们知道当在程序中使用malloc或new动态申请空间时,是申请的堆区的空间,当我们使用free或delete时,会将原来申请的空间释放掉。那么我们在程序中可能多次动态申请空间,那么这些申请的空间操作系统在释放时是怎么知道它们的大小的呢?操作系统是怎样管理这些动态申请的空间的呢?
所以操作系统为了管理这些动态申请的内存,也构造了一个struct vm_area_struct的结构体,这个结构体里面记录了堆区中动态申请空间的起始地址和结束地址,这样当操作系统释放这片空间时,只需修改这片空间的struct vm_area_struct内核数据结构的数据即可。每当使用malloc或new在堆区申请一个空间时,操作系统都会创建一个新的vm _ area _ struct结点,这个结点中记录了这一次在堆区申请的空间的起始地址和结束地址。这说明了操作系统是可以做到让进程进行资源的细粒度划分的。
我们学习了linux的IO后,知道了磁盘中的文件都是以4KB为单位进行存放的,当程序从磁盘加载到物理内存时也是以4KB为单位的,CPU想要读取虚拟地址空间的数据时,需要通过MMU将虚拟地址空间转换为物理内存的地址,MMU是集成在CPU里面的,CPU读取的虚拟地址,经过MMU之后去物理内存中取数据。那么一个进程的虚拟地址空间如果有4GB的话,那么就有100多万个4KB大小的内存块,如果我们使用页表将这100多万个内存块都一一映射到物理内存中,假设页表中的一行存两个地址,一个状态位,一共9字节,那么100多万个内存块需要100多万行页表,这样页表占的空间会非常多。
所以操作系统采用了二级页表的方法,一级页表中存了虚拟地址前10位,二级页表存了虚拟地址中间10位,然后另一边存了一页的起始地址和页内偏移量,这样就可以减少页表所占的空间了。
下面我们来正式学习线程,我们知道在操作系统中的进程都具有独立性,即一个进程一个task_struct和一个mm_struct。而在linux中,通过一定的技术手段,将当前进程的“资源”,以一定的方式划分给不同的task_struct,这样这些task_struct就相当于有了同一个mm_struct,这里的每一个task_struct就可以称之为线程。这是Linux特有的方案,以进程来实现线程。
什么叫做进程?
在我们用户的视角,进程就是内核数据结构+该进程对应的代码和数据。而在内核视角,进程就是承担分配系统资源的基本实体,因为操作系统是将资源分配给了进程,然后进程又将这些资源分配给线程,所以当操作系统将分配给进程的资源回收时,那么线程的资源也会被回收。
在CPU视角,CPU是不怎么关心当前是进程还是线程这样的概念,CPU只认task_struct。CPU调度的基本单位是线程,那么我们之前的CPU调度进程的结论就错了吗?其实不是的,我们之前的进程中只有一个执行流,这一个执行流即时进程也是线程,task_struct就是进程内部的一个执行流,当一个进程中有多个线程时,这个进程就是具有多个执行流的进程,每一个执行流就是一个线程。
Linux下和windows下的线程
在windows下有真正意义上的进程和线程。而linux下没有真正意义上的线程,linux是用进程pcb模拟线程的。所以linux下的进程PCB <= 其它OS内的PCB的,因为linux下的PCB可以是线程PCB,故linux下的进程统一称之为轻量级进程。那么我们学习操作系统这门课程时,都是按照线程和进程区分开来学习的,而linux中将线程当作进程来管理,这样怎么具体来区分进程和线程呢?虽然linux并不能直接给我们提供线程相关的接口,只能提供轻量级进程的接口,因为linux下的线程就是使用进程来实现的,但是linux在用户层实现了一套用户层多线程方案,以库的方式提供给用户进行使用。这个库就是pthread(线程库),这个库是每一个linux系统都自带的原生库。
2、线程使用
2.1 pthread_create
pthread_create的第一个参数会返回一个线程pid,是一个输出型参数。第二个参数为线程属性,不需要管。第三个参数为一个函数指针,就是以后线程要执行的程序的入口函数,第四个参数就是执行第三个参数函数指针对应的函数时的参数。创建线程成功返回0,失败就返回错误码。
下面我们使用pthread_create函数创建5个线程,此时这个程序的主控制流就是主线程,其它控制流就是线程。
然后我们写出makefile来编译这个程序。
但是我们发现编译出现了错误,这是因为前面我们说了pthread是一个库,我们在编译时需要手动链接到这个库。
我们手动链接到pthread库,然后再进行编译时就可以成功编译了。
我们运行程序看到创建了5个线程,并且这些线程具有相同的pid,我们通过ps axj命令看到此时只有一个进程。
我们前面说了CPU只认PCB,而现在这几个线程的PCB里面的PID都相同,操作系统怎么区分要执行哪个线程呢?
下面我们使用ps - aL看到的就是轻量级进程。可以看到这些轻量级进程的PID一样,但是LWP不相同。PID和LWP相同的一个轻量级进程就是主进程。所以实际操作系统进行调度的时候看的就是LWP,因为这样才能区分出来每个轻量级进程。
ps -aL
如果我们将主进程杀掉,其他的线程也会跟着被杀掉。因为操作系统分配资源是给主进程分配的资源,而线程使用的资源是主进程分配给它们的,如果主进程的资源被回收了,那么线程也就没有资源了,所以也会跟着退出。
2.2 线程共享数据和私有数据
线程共享数据
进程的多个线程共享同一地址空间,因此Text Segment(代码段)、Data Segment(数据段)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,即全局区和共享区都是共享的。除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
从下面的结果中我们可以看到每个线程都可以访问到全局变量,并且都可以调用全局函数。
线程私有数据
进程是资源分配的基本单位。
线程是调度的基本单位。
线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器(即每个线程都有自己的寄存器上下文数据,用来记录线程运行到了哪里)
- 栈(在每个线程中执行函数时都会创建一个函数栈帧)
- errno
- 信号屏蔽字
- 调度优先级
2.3 为什么线程切换的成本更低?
因为在CPU内部有L1~L3cache缓存,cache缓存里面缓存了当前进程的代码和数据,如果进程之间切换的话cache里面的数据就立即失效,新进程的代码和数据需要重新缓存到cache中,而线程切换并不会将cache缓存里面的数据失效。而且在进行线程切换时,地址空间和页表不需要切换。
3、线程性质
3.1 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
3.2 线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
3.3 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
3.4 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率。
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
4、vfork接口
linux中提供了一个clone接口,clone系统调用接口可以创建轻量级进程,并且可以设置轻量级进程之间是否共享地址空间。我们前面学习的fork接口的底层其实就调用了这个clone接口。
linux还提供了一个vfork接口,这个接口的使用和fork接口类似,不过使用vfork创建出来的子进程会和父进程共享地址空。
当我们使用fork接口创建子进程时,可以看到父进程和子进程的信息都会同时打印出来,而且子进程改变了全局变量的值后,父进程没有受到影响。
但是当我们使用vfork接口时,创建出来子进程后,此时只有子进程打印信息,当子进程退出后父进程才打印信息,而且子进程改变全局变量的值后,父进程也受到了影响。通过这两个接口我们可以体会到,使用linux系统调用接口是可以实现父子进程共享同一个地址空间的,这就说明当我们不适用pthread原生库时,使用linux提供的系统调用接口也是可以实现线程的。但是pthread库里面提供的接口可以让用户更方便的使用。
二、线程控制
1、线程创建
我们使用pthread库中的pthread_create函数来创建线程。
下面我们使用pthread_create函数来创建一个线程。,可以看到进程和线程都正常运行,并且此时我们查看时,只有一个进程。
下面我们让线程中出现异常,我们看到进程也被终止了。即线程一旦异常,可能会导致整个进程整体退出。并且线程谁先运行与调度器相关。
2、线程等待
在学习进程时,我们知道了当父进程想要获取子进程的运行结果时,可以通过进程等待的方式来获取,并且如果父进程不等待子进程的话,那么当子进程退出后就会变为僵尸进程,僵尸进程就会导致内存泄漏。其实当我们创建了线程后,如果主线程想要获取新线程的运行结果时,也需要通过线程等待来获取。并且如果主线程不等待新线程的话,那么新线程运行完后也会变成类似于僵尸进程的线程,这样也会导致内存泄漏。所以我们创建新线程后应该让主线程等待这个线程,我们通过pthread_join 函数来进行线程等待。pthread_join函数的第一个参数为线程id,第二个参数为线程的返回值。pthread_join函数等待成功后返回值为0,等待失败的返回值为错误码。需要注意的是主线程使用pthread_join函数是阻塞式的等待新线程的。
下面我们让主线程调用pthread_join函数来等待新线程,然后我们再使用ps命令查看当前线程,我们看到有两个线程,但是我们也看到只有当新线程退出后,主线程才会执行后面的代码,这说明了主线程默认是阻塞式的等待新线程退出的。
while :; do ps -aL | head -1 && ps -aL | grep mythread; sleep 1; done
我们在前面介绍线程创建时知道了pthread_create函数的第三个参数为函数指针,当创建线程后,该线程会通过回调函数的方式调用我们传过去的函数指针,并且这个函数指针是一个参数为void *,返回值为void *的函数,我们也知道pthread_create函数的第四个参数就是这个回调函数的参数,那么这个回调函数的返回值会给谁呢?其实这个返回值就是给主线程的,那么主线程如何获得这个返回值呢?其实就是通过pthread_join函数的第二个参数来获得。pthread_join函数的第二个参数也是一个返回型参数,即我们创建一个void *类型的指针变量,然后将这个指针变量的地址当作第二个参数,所以pthread_join函数的第二个参数为二级指针,当线程执行完毕后,返回值就会被带回。
下面我们来创建一个void *类型的ret指针变量,然后使用ret来接收线程的返回值,因为ret为指针变量,所以里面存的是一个地址,我们直接打印ret显示的就是一个地址,即为十六进制的地址。
而我们想要将这个地址当作整数打印出来就需要将这个void *类型的变量强转为int型,并且因为指针类型在64位计算机下为8字节,而int类型为4字节,所以会有精度损失,所以我们需要在编译时加上忽略精度丢失的选项,这样才可以成功编译,或者我们直接将ret强转为long long类型。然后我们看到主线程就成功拿到了新线程的返回值。
并且因为新线程执行的回调函数的返回值为void *类型,所以新线程可以返回任何类型的数据,下面我们让新线程向主线程返回一个数组。
我们在学习进程时,当子进程退出后父进程会获取到子进程的退出码等信息,但是当线程出现异常退出时,为什么pthread_join函数不返回一个线程出现异常的数据呢?这是因为当线程出现异常后,进程也会直接崩溃了。所以线程等待不需要知道线程的退出是否异常。
3、线程退出
我们知道在一个函数中执行return语句会直接跳出这个函数,在一个函数中执行exit语句会直接退出当前进程。上面我们学习了在线程回调函数中执行return语句会退出这个线程,那么我们在线程回调函数中执行exit语句会退出当前线程还是会直接退出进程呢?
下面我们在线程回调函数中执行exit语句。我们看到在线程回调函数中执行exit语句后,主线程也会直接退出,即直接退出了这个进程,并且我们看到这个进程的退出码为线程调用exit时设置的退出码。
所以当我们想让线程退出回到主线程时,不能通过调用exit来实现。我们可以使用pthread_exit函数来退出线程并且回到主线程中。pthread_exit函数的参数就是线程的返回值。
我们看到使用pthread_exit函数也可以退出当前线程回到主线程,并且pthread_exit函数也将线程的返回值返回给主线程。
4、线程取消
当我们在主线程中创建了一个新线程后,我们想让这个线程退出时,我们可以在主线程中使用pthread_cancel函数来将新线程取消。pthread_cancel函数的参数就是要取消的线程的线程id,当取消线程成功时返回0,失败时就返回错误码。
下面我们创建一个新线程,然后让主线程执行5秒后使用pthread_cancel函数来取消这个新线程,我们看到pthread_cancel的返回值为0,即成功取消了线程,然后我们看到被取消的线程的返回值为-1。
上面我们在线程中并没有执行return语句或pthread_exit函数,但是主线程收到了新线程的返回值。这是因为当一个线程是被取消的时,它所对应的退出结果就是PTHREAD_CANCELED这个宏。被取消的线程在取消时相当于在threadRoutine函数中自动执行了一句return PTHREAD_CANCELED。所以在主线程中打印新线程的返回值时为-1。
当我们执行pthread_cancel函数取消线程时,是需要有前提的,即首先已经创建了这个线程,其次这个线程已经在运行了,此时调用pthread_cancel函数来取消这个线程才是有意义的。而当如果没有这个线程时就调用pthread_cancel函数来取消这个线程,这种情况的结果是未定义的。
5、线程id
前面我们学习的pthread_create、pthread_join、pthread_cancel等函数都需要传入线程id,下面我们打印线程id,然后看到线程id是一个非常大的值。那么线程id和进程的id有什么不同呢?
我们将线程id按照地址的形式打印出来。我们看到线程id其实是一个地址。那么为什么线程id是一个地址呢?这是因为我们现在用的是pthread库中的接口,在pthread库中为了使每个线程都有自己的空间,就将每个线程的起始地址作为该线程的id。
主线程和新线程的栈区都是独立的,因为主线程和新线程中都可能会建立函数的栈帧,如果主线程和新线程的栈区为一个的话,那么主线程和新线程的函数栈帧就会混乱,所以主线程和新线程都有自己的栈区。但是一个进程的地址空间中只有一个栈区,操作系统是如何保证每一个线程都有自己独立的栈区的呢?
我们知道pthread是一个库,而库都在进程地址空间中的共享区内。其实pthread库中在共享区为每一个新创建的线程都分配了一个该线程独有的空间,而线程id就是每个线程空间的起始地址,所以线程id为一个地址。在每一个线程的空间中有该线程的栈区和线程局部存储区,这样每一个线程就都有了自己的栈区。主线程用的是内核级的栈结构,新线程用的是共享区中pthread提供的栈结构。所以就保证了每个线程都有自己独立的栈结构。
那么pthread库中是怎样做到为每一个线程分配的栈区在共享区内的呢?其实前面我们介绍的clone系统调用接口就可以做到,clone接口可以创建一个轻量级进程,并且clone接口的第二个参数可以设置这个轻量级进程的栈区为指定的空间。
主线程和新线程可以通过pthread_self()函数来获取自己的线程id。
下面我们定义一个全局变量,我们知道主线程和新线程共享这个全局变量,所以主线程和新线程都可以访问这个全局变量。然后我们让新线程对全局变量进行修改。可以看到主线程中读取全局变量时也发现全局变量的值变了。
如果想让每个线程都有自己的全局变量,即将这个变量进行私有化,使多个线程之间不会互相影响,就需要加一个__thread修饰。此时可以看到新线程修改全局变量,但是主线程的全局变量不受影响,并且主线程和新线程的全局变量的地址也不一样。这就是线程的局部存储。给全局变量加上__thread,创建几个线程,就在编译时,在这些线程的局部存储空间中开辟空间,将这个数据拷贝过去。以后这个线程做任何访问这个数据的事情,访问的都是自己的线程局部存储空间里的数据。
我们在学习进程时学到了进程可以使用exec系列函数来进行进程替换,那么线程可以进行线程替换吗?
下面我们在新线程中使用execl函数来进行进程替换,我们看到在线程中进行进程替换后,进程的数据和代码都会被替换掉了,此时就不会执行原来主线程和新线程的代码了,而是执行替换的进程的代码。我们也可以在新线程里面调用fork接口创建子进程,这就和正常的父进程创建子进程一样。此时子进程的ppid是父进程中主线程的pid和tid。
6、线程分离
在主线程等待新线程时,是阻塞式等待的,并且pthread_join接口也没有设置可以非阻塞式等待新线程的选项,所以主线程等待新线程都是阻塞式等待的。当主线程不想阻塞式等待新线程时,就可以分离线程。即默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。而如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
下面我们让新线程自己将自己分离,然后让主线程等待新线程。我们看到报出了错误。这是因为joinable和分离是冲突的,一个线程不能既是joinable又是分离的,所以当一个线程被分离后,就不能使用pthread_join函数来等待这个线程了。
那么如果一个线程进行了分离,但是这个线程里面出现了异常。此时会影响主线程吗?
下面代码中分离的线程中出现了异常,可以看到虽然线程被分离出去了,但是线程异常后还是会影响到主线程。因为虽然线程被分离了,但是线程没有自己的地址空间,使用的还是进程的地址空间,所以出现异常后还会影响到进程。
那么什么时候使用分离线程的场景呢?
当主线程或进程一直不退出时,即以一个服务器在运行时,此时如果主线程收到了一个用户请求,那么主线程会新生成一个线程去处理这个请求,这个线程就将自己分离出去处理用户的请求,然后主线程继续等待用户的其它请求。
7、c++中的线程
我们上面学习的是linux系统提供的线程和与线程相关的接口,那么我们在使用c++语言编写程序时,怎样创建一个线程呢?
在c++中进行线程操作时,可以引入thread库。下面我们使用thread库中提供的函数生成了线程。
并且在编译进行了线程操作的c++程序时,我们需要链接上pthread库,因为这些语言级别支持的多线程就是在底层调用了原生线程库。即语言级别的多线程库其实就是对原生线程库的封装,这样就可以让用户更方便使用了。
三、线程互斥
1、进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
我们前面了解了线程间是没有访问控制的,这虽然方便了线程间的通信,但是也会带来其它的问题。下面我们通过一个多线程抢票的案例来体会线程间的没有访问控制带来的问题。
我们创建一个全局变量tickets来记录剩余总票数,然后创建三个线程来执行抢票操作。我们看到在执行结果中,当票数tickets为0了时还进行了抢票,最后还显示了tickets为-1。我们检查后发现我们的代码是没有问题的,那么问题出在了哪里呢?其实这就是线程之间没有访问控制带来的问题。
我们先来思考下面的一种情况。
当多个线程都访问tickets时。此时线程1上CPU访问tickets,读取到tickets为999,并将tickets的数据保存到寄存器中,然后准备将tickets–时,此时线程1的时间片到了,然后线程1将自己的寄存器上下文数据保存,然后退出CPU。此时线程2上CPU,线程2也访问tickets,并且也是将tickets从内存中读取,然后存到寄存器中,然后将tickets–到0,然后再将tickets写入到内存中,此时tickets的数据已经被改写为0了,然后线程2时间片到了,线程2下CPU。线程1又上CPU,先根据寄存器上下文数据先恢复运行环境,然后发现存tickets值得寄存器中tickets的值还为999,然后线程1也进行tickets–操作,然后线程1将ticktes–到500时,线程1时间片又到了,此时线程1将tickets写入到内存中并且保存寄存器上下文数据,然后线程1退出CPU。然后再来一个线程2或线程3上CPU从内存中读取tickets的值时,发现tickets的值为500。这样就出现了tickets已经为0但是又被修改了的情况。
在if判断时切换线程
下面我们再来分析上面的抢票tickets到0了后tickets还- -的原因:当tickets为1时,线程1取tickets发现为1,然后进行if(tickets>0)的if判断,发现符合,于是进入if内,但是到if内后,线程1时间片到了,此时将线程1的数据保存后线程1下了CPU。然后线程2上CPU,线程2取tickets判断,发现此时tickets还为1,因为线程1还没有执行tickets- -就退出CPU了,所以线程2也进入if内,此时线程2打印tickets值为1后,然后将tickets- -过后线程2时间片到了,此时线程2将tickets值为0写入到内存中,下CPU。然后线程1再次上CPU,此时线程1先从内存中取出tickets的值0打印出来,然后执行tickets- -操作,此时tickets已经为0了,然后线程1再将tickets- -,tickets就变为-1了,然后线程1将tickets为-1的值写入到内存中,线程1下CPU。然后可能还有个线程3已经做好了if判断,但是还没有执行下面的代码,此时线程3上CPU,从内存中取tickets的值,此时ticktes已经为-1了,线程3将tickets为-1打印出来,然后将tickets- -,此时tickets就为-2了,然后线程3再将tickets值为-2写入到内存中。这就是线程之间没有访问空间,在并发访问同一个数据时,导致了数据不一致的问题。
在进行tickets–时切换线程
除了上面我们分析的在if判断后出现线程切换的情况,其实在tickets- -时,也可能会出现线程切换。
–操作并不是原子操作,而是对应三条汇编指令:
load:将共享变量ticket从内存加载到寄存器中
update: 更新寄存器里面的值,执行-1操作
store:将新值,从寄存器写回共享变量ticket的内存地址
我们知道计算机只能执行一个一个的指令,而我们代码中的tickets- -操作对应了多条指令,那么这样就有可能在某一个线程执行tickets - -操作的某一条指令时进行线程切换,此时会保存这个线程相应的上下文数据,然后这个线程下CPU。
下面我们来看这样一种情况。
线程1上CPU执行tickets- -操作,当线程1执行完第1和第2条指令后,此时寄存器中的tickets已经被减为9999,还没有执行第3条指令将寄存器中的tickets写回内存。但是此时发生了线程切换,那么操作系统会将线程1的寄存器相关数据都保存,然后线程1下CPU。
此时线程2上CPU,线程2执行tickets- -操作,线程2执行第1条指令从内存中读取tickets的值到寄存器,此时内存中tickets的值还为10000,然后线程2执行第2条指令将寄存器的数据- -,然后再执行第3条指令将寄存器中的数据写入到内存中,这样tickets就被-1了。当线程2重复执行上面的操作,直到线程2将tickets- - 到5000时,此时线程2刚好执行完tickets- -操作的第3条指令,即将寄存器中的tickets的值5000写到内存中,然后操作系统将线程2的寄存器上下文数据保存,然后线程2退出CPU。此时可以看到内存中tickets的值已经为5000。
然后此时线程1又上CPU,操作系统先恢复线程1的执行环境,即将寄存器的数据都恢复,所以可以看到线程1的寄存器数据被恢复为9999,然后线程1继续执行tickets- -操作的第3条指令,即将寄存器中的数据写入到内存中,此时就可以看到内存中tickets的值又被线程1改为9999了。这样就引发了数据紊乱。即在多个线程都可以访问同一个公共资源时,如果不对这个公共资源进行访问控制,那么线程不断的切换也可能会导致这个公共资源出现问题。
2、互斥锁
那么当出现上面的情况时,我们应该怎么解决呢?
此时我们可以通过互斥锁来解决上面的问题,即通过锁来保证临界资源的安全。在linux的原生库pthread中提供了互斥锁。
2.1 全局或静态的互斥锁
当我们想要创建一个全局或者静态的互斥锁时,我们可以通过下面的代码来创建一把互斥锁并且对其进行初始化。
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //pthread_mutex_t 就是原生线程库提供的一个数据类型
然后我们再使用pthread_mutex_lock函数来将访问临界资源的临界区代码进行加锁。我们可以看到还有一个pthread_mutex_trylock函数,该函数是非阻塞式的申请锁,当线程使用pthread_mutex_trylock函数申请锁时,如果锁还没有释放,那么该线程并不会在pthread_mutex_trylock函数中阻塞式等待锁释放,而是直接退出pthread_mutex_trylock函数。如果pthread_mutex_trylock函数成功申请到锁后也会直接退出。
这样每一个线程都会执行这个加锁语句,这个锁在同一时间只会被一个线程所得到,而只有拿到锁的线程才可以执行临界区代码访问临界资源,即就保证了同一时间只有一个线程可以执行锁后面的临界区代码,而没有拿到锁的线程会在这里阻塞式等待,直到拿到锁的线程将锁释放掉。
我们给临界区代码加锁后,就要在执行完临界区代码的地方进行解锁,即拿到锁的线程将锁还回去,这样其它线程才可以再得到锁进行临界资源的访问。我们通过pthread_mutex_unlock函数来进行解锁。
需要注意的是,我们如果在下面的地方执行解锁语句,那么有可能线程会执行else里面的break语句直接跳出循环,那么就不会再执行解锁语句了,即造成了只加锁不解锁的情况。这就会导致其它阻塞式等待锁的线程一直都在阻塞式等待。
所以我们需要在每一个可能退出的地方都执行解锁语句,以确保可以执行解锁语句。加锁和解锁之间的代码就叫做临界区,访问的tickets就叫做临界资源。
下面我们将每一个线程都进行命名,然后再次执行抢票程序。然后我们看到抢票程序就不会出现tickets为-1的情况了,并且我们可以明显的感受到,当使用了互斥锁之后,抢票程序执行的慢了,这是因为同一时刻只能有一个线程进行抢票操作,而其它线程就只能在申请锁的地方进行阻塞式等待。所以我们在加锁的时候,一定要保证加锁的粒度越小越好,即只有访问临界资源的代码我们进行加锁,这样每一个得到锁的线程都只用执行最关键的访问临界资源的代码,这样就能减少其它线程等待锁的时间。我们在运行抢票程序时可能会遇到一个线程将票抢完的情况,这其实是和操作系统的调度算法有关,操作系统根据调度算法选择哪一个线程上CPU,那么哪一个线程就执行抢票操作。
2.2 局部互斥锁
如果我们创建的锁不是一个全局或静态的锁时,而是一个局部锁时,但是我们还想要将这个局部锁给其它线程使用,那么就需要使用pthread_mutex_init函数来对锁进行初始化。并且在使用完后还需要用pthread_mutex_destroy函数对锁进行销毁。
下面我们在主线程中创建一个局部锁,然后将这个锁通过线程的回调函数的参数传递给线程使用。线程的回调函数的参数为void * 类型,所以主线程不只是可以传整型或数组给新线程,也可以传类或者自定义类型。下面我们就写一个类用来记录主线程要传递给新线程的信息,当然也包括主线程创建的互斥锁,然后我们将这个自定义的类作为新线程的回调函数的参数,即主线程将这个类传递给新线程。
我们看到经过上面的操作,主线程创建出来的新线程就可以使用主线程创建的锁来进行互斥访问临界资源tickets了。
我们需要知道一个程序中并不是线程越多,抢票的效率越高。如果线程过多的话,那么线程之间的切换就会增加开销,反而使程序的效率变低。
下面我们来证明上面的结论。
我们使用下面的系统调用接口来获取抢票程序执行的时间。
3、互斥锁原理
经过上面对于互斥锁使用后,我们解决了抢票程序出现的问题。下面我们来看几个关于互斥锁的问题。
加锁就是串行执行了吗?加了锁之后,线程在执行临界区中的代码时,是否会进行线程切换呢?
加了锁之后,各个线程在执行被加锁的临界区代码时就只能串行访问了,即同一时间只能有一个线程执行临界区的代码。并且线程在执行临界区代码时也有可能会被切下CPU,但是此时这个线程是持有锁被切换的,这个线程并没有将锁还回去,而其它线程要执行临界区代码是需要申请到锁后才能执行的,但是此时锁还没有被还回去,所以其它线程进入不了临界区执行代码,这样就保证了临界区中数据一致性。并且因为有了互斥锁锁的存在,在这些要访问临界资源的线程看来只有两种情况,第一种是自己没有持有锁,不能执行临界区代码,需要阻塞式等待。第二种情况是拿到锁,执行临界区代码。这样在线程看来,执行临界区代码就变得不可拆分了,即自己的状态只能是能执行临界区代码或不能执行临界区代码,这样就使访问临界资源变得具有原子性了。
如果多个线程都要访问临界资源就需要先申请锁,那么每一个线程就都可以看到锁这个共享资源。而锁保证了临界资源的线程安全,那么谁来保证锁的安全呢?
前面的问题我们也说到了只有将线程访问临界资源变得具有原子性,即不可中断,那么同一时刻就只能有一个线程访问临界资源,这个临界资源就变得线程安全了。而如果要保证锁的安全,就必须保证申请锁和释放锁都是原子的。
下面我们来看锁是如何实现的。
首先我们需要知道,如果我们在汇编的角度,只有一条汇编语句,那么我们就认为该汇编语句的执行是原子的。在操作系统中通常都有swap或exchange这样一条指令,这个指令就是以一条汇编指令的方式将内存和CPU内寄存器中的数据进行交换。
其次我们还需要知道在执行流视角是如何看待CPU上面的寄存器的?
CPU内部的寄存器,本质叫做当前执行流的上下文寄存器数据。CPU中的寄存器的空间是被所有的执行流共享的,但是寄存器中的内容是每一个执行流私有的,即执行流的上下文寄存器数据是私有的。
下面是线程申请锁时执行的pthread_mutex_lock()函数和线程释放锁时pthread_mutex_unlock()函数需要执行的指令。
下面是线程a申请锁的过程,即先执行第一条指令将0放入到寄存器al中,然后执行第二条指针将al寄存器和mutex中的数据互换,然后执行第三条指令判断al寄存器的内容是否大于0,如果大于0就退出pthread_mutex_lock函数,就表示线程a申请锁成功了。
此时线程b也执行pthread_mutex_lock函数来申请锁,线程b先执行第一条指令将0放入到寄存器al中,然后执行第二条指令将al寄存器和mutex中的数据互换。因为刚刚线程a已经将1换走了,即线程a将锁拿走了,所以线程b换到al寄存器中的数据还是0,然后线程b执行if指令发现不符合,于是就执行else的语句被挂起等待。
当线程a访问完临界资源后,线程a执行pthread_mutex_unlock函数,先执行第一条指令将1放入到mutex中,这就相当于线程a将锁归还了。然后唤醒在pthread_mutex_lock函数中挂起的线程,然后线程a退出pthread_mutex_unlock函数。
当锁被线程a归还后,被阻塞的线程b就会被唤醒,然后线程b执行goto lock指令再次回到lock指令,然后继续执行将0放入al寄存器,然后执行交换al寄存器和mutex中的数据指令,此时mutex中的值为1,所以当线程b执行完xchgb指令后就相当于申请到了锁。上面的线程a申请锁后又释放锁,然后线程b再申请锁,这几个过程中执行的都是计算机指令,而计算机指令都是具有原子性的,即执行期间不会被打断,所以在申请锁和释放锁的过程中并不会出现线程安全问题。
4、可重入VS线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
4.1 常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
4.2 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
4.3 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
printf函数不是线程安全的,所以才会出现打印数据出现交叉的现象。
4.4 常见可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
4.5 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
4.6 可重入与线程安全区别
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
5、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
如果只有一把锁的时候也有可能会死锁,即只申请锁而忘记释放锁的情况。
5.1 死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
5.2 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
5.3 避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解)
四、线程同步
我们上面写的抢票程序中,虽然我们使用互斥锁已经将线程安全问题解决了。但是这个程序还是存在不合理的地方。
有的时候当票数tickets被抢完后,我们并不想让这些线程直接退出,因为票数tickets可能一段时间后又有了,然后这些线程就又可以申请锁然后抢票了。所以我们想让抢票的线程在没有票的时候就进行挂起等待,而当有票了时就都开始申请锁然后进行抢票。这样我们就不用每次都创建新线程和释放旧线程了,这样节省了创建新线程和释放旧线程的开销。但是我们的代码中,如果我们想让线程在tickets为0时也不退出,就需要将else语句里面的break去掉,这样当tickets为0时线程也不会退出。但是这就造成了新的问题,这些线程在tickets为0时,还是会一直进行申请锁操作,然后申请到锁了,执行临界区代码判断tickets不大于0,然后执行else语句里面的释放锁,然后下一个进程继续申请锁,执行临界区代码发现tickets不大于0,然后执行else语句的释放锁。可以看到我们的代码中当tickets为0时,线程并不会挂起等待,而是继续执行上面的这些已经无效的判断。这样的程序虽然不会出现问题,但是这些线程没有挂起而是一直循环执行无效的判断,这样就增加了程序的运行开销,即让这些线程一直运行太浪费资源了。所以操作系统就引入了同步的概念。
1、条件变量
Linux操作系统引入同步主要就是为了解决访问临界资源合理性问题,即如果线程要访问的临界资源没有达到某种条件时,就先让线程等待临界资源达到某种条件,而不是直接让线程访问临界资源。使用线程同步还可以按照一定的顺序进行临界资源的访问。要是按照我们的设想,那么当我们申请临界资源前要先做临界资源是否存在或达到某种条件的检测,而检测临界资源是否满足条件也是需要访问临界资源的,这就说明对临界资源的检测也一定要在加锁和解锁之间的。而常规的方式检测临界资源是否满足条件时,就需要频繁的申请锁和释放锁,那么有没有什么办法让我们的线程检测到临界资源不就绪时就不再频繁的检测临界资源了,而是将线程挂起等待,等到临界资源满足条件后,再将对应的线程唤醒,然后线程再进行申请锁和访问临界资源。这就需要使用到Linux操作系统提供的条件变量了。即当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
下面我们来看条件变量的接口函数。我们看到pthread_cond_t 条件变量也是Linux操作系统提供的一个类型,而且条件变量的接口函数的使用和我们前面学到的锁类似。
//创建并且初始化一个全局条件变量或静态条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//创建一个局部条件变量
pthread_cond_t cond;
//初始化一个局部条件变量
pthread_cond_inin(&cond,nullptr);
//销毁一个局部条件变量
pthread_cons_destroy(&cond);
pthread_cond_wait函数就会让线程进行挂起等待条件变量。并且该函数的第二个参数为一个锁。
pthread_cond_signal函数就是从等待条件变量的挂起队列中唤醒一个线程,pthread_cond_broadcast函数就是将等待条件变量的挂起队列的线程都唤醒一次。
下面我们通过一个案例来体会条件变量的作用。
我们在前面创建线程都是执行的同一个回调函数,下面我们定义多个不同的函数,然后让每一个线程执行不同的函数。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
#define TNUM 4 //线程数
typedef void (*func_t)(const string& args); //定义func为一个函数指针类型
//主线程给新线程传递过去的数据
class ThreadData
{
public:
ThreadData(const string& name, func_t func)
:name_(name),func_(func)
{}
public:
string name_;
func_t func_;
};
void func1(const string& name)
{
while(true)
{
cout<<name<<" running -- a"<<endl;
sleep(1);
}
}
void func2(const string& name)
{
while(true)
{
cout<<name<<" running -- b"<<endl;
sleep(1);
}
}
void func3(const string& name)
{
while(true)
{
cout<<name<<" running -- c"<<endl;
sleep(1);
}
}
void func4(const string& name)
{
while(true)
{
cout<<name<<" running -- d"<<endl;
sleep(1);
}
}
void* Entry(void* args)
{
//让每一个线程执行自己的函数
ThreadData* td = (ThreadData*)args;
td->func_(td->name_);
delete td; //释放申请的传递数据的空间
return nullptr;
}
int main()
{
pthread_t tids[TNUM];
func_t funcs[TNUM] = {func1 , func2, func3, func4};
//循环创建TNUM个线程,并且每个线程名字不相同,
for(int i = 0; i<TNUM; ++i)
{
string name = "Thread";
name += to_string(i+1);
ThreadData* td = new ThreadData(name,funcs[i]);
//每个线程都调用Entry函数,但是给每个线程传过去的td对象中的函数指针指向不同的函数
pthread_create(tids+i,nullptr,Entry,(void*)td);
}
//循环等待每个线程
for(int i = 0; i<TNUM; ++i)
{
pthread_join(tids[i], nullptr);
cout<<"thread: "<<tids[i]<<" quit"<<endl;
}
return 0;
}
我们看到1、2、3、4线程执行的顺序完全是随机的,即操作系统根据调度算法选择哪一个线程,那么哪一个线程就执行。
下面我们使用线程同步控制线程的顺序运行,即按照一定的顺序控制线程执行。我们需要注意的是pthread_cond_wait函数需要写在加锁和解锁之间。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
#define TNUM 4 //线程数
typedef void (*func_t)(const string& args, pthread_mutex_t* pmtx, pthread_cond_t* pcond); //定义func_t为一个函数指针类型
//主线程给新线程传递过去的数据
class ThreadData
{
public:
ThreadData(const string& name, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
:name_(name),func_(func),pmtx_(pmtx),pcond_(pcond)
{}
public:
string name_;
func_t func_;
pthread_mutex_t* pmtx_; //条件变量指针
pthread_cond_t* pcond_; //锁指针
};
void func1(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
while(true)
{
pthread_mutex_lock(pmtx); //申请锁
pthread_cond_wait(pcond,pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即阻塞
cout<<name<<" running -- a"<<endl;
pthread_mutex_unlock(pmtx); //释放锁
}
}
void func2(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
while(true)
{
pthread_mutex_lock(pmtx); //申请锁
pthread_cond_wait(pcond,pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即阻塞
cout<<name<<" running -- b"<<endl;
pthread_mutex_unlock(pmtx); //释放锁
}
}
void func3(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
while(true)
{
pthread_mutex_lock(pmtx); //申请锁
pthread_cond_wait(pcond,pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即阻塞
cout<<name<<" running -- c"<<endl;
pthread_mutex_unlock(pmtx); //释放锁
}
}
void func4(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
while(true)
{
pthread_mutex_lock(pmtx); //申请锁
pthread_cond_wait(pcond,pmtx); //默认该线程在执行的时候,wait代码被执行,当前线程会被立即阻塞
cout<<name<<" running -- d"<<endl;
pthread_mutex_unlock(pmtx); //释放锁
}
}
void* Entry(void* args)
{
//让每一个线程执行自己的函数
ThreadData* td = (ThreadData*)args;
td->func_(td->name_,td->pmtx_,td->pcond_);
delete td; //释放申请的传递数据的空间
return nullptr;
}
int main()
{
//创建锁和条件变量
pthread_mutex_t mtx;
pthread_cond_t cond;
//对锁和条件变量进行初始化
pthread_mutex_init(&mtx,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_t tids[TNUM];
func_t funcs[TNUM] = {func1 , func2, func3, func4};
//循环创建TNUM个线程,并且每个线程名字不相同,
for(int i = 0; i<TNUM; ++i)
{
string name = "Thread";
name += to_string(i+1);
ThreadData* td = new ThreadData(name,funcs[i],&mtx,&cond);
//每个线程都调用Entry函数,但是给每个线程传过去的td对象中的函数指针指向不同的函数
pthread_create(tids+i,nullptr,Entry,(void*)td);
}
sleep(5);
//通过条件变量每秒唤醒线程一个线程
while(true)
{
pthread_cond_signal(&cond);
sleep(1);
}
//循环等待每个线程
for(int i = 0; i<TNUM; ++i)
{
pthread_join(tids[i], nullptr);
cout<<"thread: "<<tids[i]<<" quit"<<endl;
}
//销毁锁和条件变量
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
上面的代码中我们让主线程每过1秒就通过条件变量从挂起队列中唤醒一个线程,然后我们看到线程的执行顺序都是一定的。这是因为线程是排队等待运行的。即这些线程都是在cond的队列中排队等待的,当cond条件变量每生效一次,就从队列中唤醒一个线程进行运行。所以线程执行顺序是一定的。。
下面我们使用pthread_cond_broadcast函数,当cond条件变量生效时就会将等待队列中的所有线程都唤醒。
下面我们让主线程唤醒10次每个线程,然后主线程改变全局变量quit的值,让4个线程退出。
但是我们看到程序并没有退出,而是卡在了这里。
这是因为虽然主线程将quit改为true,但是此时4个线程都卡在wait等待那里,等待cond条件变量生效将线程唤醒,所以这4个线程不会执行到while判断那里,故此时需要使用broadcast函数来将所有线程再重新执行一次,这样才会执行到while判断那里。然后线程判断不符合while条件,就跳出循环,然后线程执行完后序代码就退出了。
我们在写pthread_cond_wait函数时,为什么要先申请一个锁呢?如果我们不申请锁就直接运行pthread_cond_wait函数会有什么问题呢?
下面我们来不申请锁就调用pthread_cond_wait函数。
然后我们看到程序运行时,当thread 1线程退出后,其它线程并没有退出。那么这是为什么呢?这其实就和我们没有将pthread_cond_wait函数写在申请锁和释放锁之间有关。但是如果将pthread_cond_wait函数写在临界区,那么线程要执行pthread_cond_wait函数时,必须先获得锁,而如果线程获得锁之后执行pthread_cond_wait函数时被阻塞等待的话,那么这个线程不就是拿着锁阻塞等待了吗?其实线程不会拿着锁阻塞等待的,因为pthread_cond_wait函数中如果检测到条件变量不生效,那么会将这个线程放到条件变量的阻塞队列,然后将这个线程调用pthread_cond_wait函数传进来的第二个参数的锁释放,这也就是为什么pthread_cond_wait函数的第二个参数为什么是一把锁的原因。而如果当条件变量生效时,会唤醒被阻塞的线程,然后pthread_cond_wait函数会重新申请锁,将线程继续向后执行下去。那么为什么我们将锁去掉,就会出现下面的情况呢?这是因为当线程中还满足while循环时,当条件变量生效,让每个线程唤醒一次,thread 1线程执行完之后,因为还满足while循环,所以会继续执行pthread_cond_wait函数然后将锁释放,这样其它线程的pthread_cond_wait函数才可以申请到锁执行。而因为下面的代码中我们改变quit后,让线程中while循环条件不满足后,我们又唤醒了一次每个线程,此时thread 1的pthread_cond_wait函数会申请到锁,然后执行下面的代码,但是thread 1执行完代码后,因为不满足while循环了,所以不会再执行pthread_cond_wait函数将锁还回去了,而是thread 1直接拿着锁退出了,这样的话其他的线程的pthread_cond_wait函数还都在申请锁,而thread 1线程拿着锁退出了,这就是线程为什么会卡在最后的原因。而当我们将每个线程的pthread_cond_wait函数都写在申请锁和解锁之间时,如果出现上面的情况,虽然thread 1线程不满足while循环条件了,无法通过pthread_cond_wait函数来释放锁,但是可以通过执行解锁语句来释放锁,这样就不会造成线程拿着锁退出的情况了。
2、生产者消费者模型
2.1 生产者消费者模型介绍
为何要使用生产者消费者模型 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
生产者消费者模型优点
- 解耦
- 支持并发
- 支持忙闲不均
下面就是生产者消费者模型,设想一下,如果一个生产者线程向仓库放一半数据后,此时发生线程切换,然后另一个生产者线程上CPU又向仓库放数据,那么此时仓库中的数据就紊乱了。所以同一时刻只能有一个生产者线程向仓库中放数据,而且只有这个生产者线程放完数据之后下一个生产者线程才可以向仓库中放数据。这就体现了生产者线程和生产者线程之间的互斥关系。那么有很多生产者线程时,哪个生产者线程先放数据呢?这些生产者线程就互相竞争的想要先放数据,这就体现了生产者线程之间的竞争关系。消费者线程和消费者线程之间的互斥关系和竞争关系也类似。下面我们来看生产者线程和消费者线程之间的互斥、同步关系。如果生产者线程向仓库放了一半数据后,发生了线程切换,此时消费者线程上CPU从仓库中取数据,但是此时生产者线程还没有将数据放完,所以消费者线程取到的数据是不完整的,这就需要保证生产者和消费者线程之间必须保持互斥关系,即同一时刻只能一个线程访问仓库,例如生产者线程生产完数据之后,才可以允许消费者线程访问仓库进行取数据。如果当生产者线程还没有向仓库中放数据时,消费者线程就从仓库中取数据,那么是肯定取不到数据的,而如果当仓库中满了时,消费者没有取数据,那么生产者线程是不能向仓库中放数据的。这就需要生产者线程和消费者线程之间保持同步关系,即当生产者线程生产数据放入仓库后消费者才可以取数据,当仓库满了时,消费者线程取出数据后,生产者线程才能继续写入数据。
2.2 BlockingQueue
下面我们通过基于BlockingQueue的生产者消费者模型的案例来体会生产者消费者模型。
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
下面我们创建一个生产者线程,一个消费者线程,这两个线程调用不同的回调函数。
然后我们给阻塞队列中放一个锁,这个锁用来保证阻塞队列的线程安全,即为了避免上面我们分析的一些情况,有了这个锁,那么同一时刻就只能有一个线程来访问阻塞队列,并且这个线程没有执行完任务就不会将锁还回去,那么下一个线程拿不到锁,也就无法访问阻塞队列。然后我们设置了两个条件变量,一个Empty条件变量用来表示阻塞队列是否为空,如果阻塞队列为空,那么就要通过这个条件变量来让消费者线程进行阻塞等待,如果阻塞队列中有数据了,再通过这个条件变量将消费者线程唤醒。另一个Full条件变量用来表示阻塞队列是否为满,如果阻塞队列为满,那么就要通过这个条件变量来让生产者线程进行阻塞等待,如果阻塞队列中有空间了,再通过这个条件变量将生产者线程唤醒。
然后我们让主线程中创建一个阻塞队列,并且将这个阻塞队列传给生产者线程和消费者线程,生产者线程中调用阻塞队列的push方法向阻塞队列中写入数据,消费者线程中调用阻塞队列的pop方法从阻塞队列中取数据。
然后我们在阻塞队列的构造函数函数中完成锁和条件变量的初始化,在阻塞队列的析构函数中完成锁和条件变量的释放。
下面我们就开始实现阻塞队列的push和pop函数了,但是在这之前,我们为了避免当阻塞队列为空时,消费者线程还从阻塞队列中取数据;当阻塞队列为满时,生产者线程还向阻塞队列中写数据,我们需要写两个方法分别来判断阻塞队列为空或为满。
然后我们在阻塞队列的push函数和pop函数中都进行加锁和解锁,这是因为阻塞队列是临界资源,我们为了保证线程安全,需要在访问临界资源时进行加锁。我们在push函数的临界区中需要判断阻塞队列是否为满,如果阻塞队列已满,那么生产者线程就不能再向阻塞队列中写入数据,而应该调用pthread_cond_wait函数将生产者线程在Full条件变量的阻塞队列中进行阻塞等待。但是我们发现因为pthread_cond_wait函数是在临界区的,如果生产者线程在这里进行阻塞式等待的话,那么生产者线程就是拿着锁进行阻塞式等待了,那么消费者线程就申请不到锁了,这就造成了死锁现象。但是pthread_cond_wait函数中并不是只将生产者线程放到Full条件变量的阻塞队列中进行阻塞等待,而且还会将生产者线程申请到的锁先进行释放。这样其它线程还可以申请到锁,就不会造成死锁问题了。这就是为什么pthread_cond_wait函数的第二个参数是一个锁的原因。而且当Full条件生效后,当前生产者线程会在被阻塞的地方被唤醒,然后继续执行临界区代码,但是因为现在生产者线程还在临界区,如果没有锁就执行临界区代码,那么不就是线程不安全了吗?这其实就又要靠pthread_cond_wait函数了,pthread_cond_wait函数在检测到Full条件变量生效后,就开始申请锁,如果申请到锁后,就将生产者线程唤醒,然后生产者线程就是持有锁执行下面的临界区代码了。消费者线程执行pop函数时也是和生产者线程执行push函数的情况类似。
前面我们分析了当阻塞队列为空时通过Empty条件变量将消费者线程阻塞等待,当阻塞队列为满时通过Full条件变量将生产者线程阻塞等待。那么当什么时候将条件变量设置为有效唤醒生产者线程和消费者线程呢?
我们应该想到当阻塞队列为空时,消费者线程被阻塞等待,然后生产者线程向阻塞队列中写入数据后,此时阻塞队列就不为空了,那么此时就可以将消费者线程唤醒了,所以我们可以分析出来消费者线程由生产者线程向阻塞队列中写入数据后唤醒。下面我们再来分析当阻塞队列为满时,此时生产者线程被阻塞,然后消费者线程从阻塞队列中取数据,此时阻塞队列就不满了,那么此时就可以将生产者线程唤醒继续写入数据了,所以我们也知道了生产者线程是由消费者线程从阻塞队列中取出数据后唤醒。
下面我们让生产者线程生产数据快,消费者线程消费数据慢,然后进行测试。
我们看到测试结果有两种情况,第一种情况为生产者线程先生产一个数据,然后消费者线程消费,然后消费者线程睡眠1秒,在这1秒内生产者线程将阻塞队列中写满数据,然后下面就是消费者读取一个数据,生产者生产一个数据,这种情况产生的原因是CPU第一个调用的是生产者线程,然后生产者线程先向队列中写一个数据,然后调用消费者线程,消费者线程读取一个数据后睡眠1秒,此时生产者将队列写满数据。第二种情况是CPU先调用消费者线程,消费者线程发现此时阻塞队列中没有数据,于是进行挂起等待。然后生产者将队列中数据写满,然后唤醒消费者线程。后面就是消费者线程取走一个数据,生产者线程生产一个数据。
而如果是生产者线程生产数据比较慢,消费者消费数据比较快,那么就会是生产者线程生产一个数据,消费者线程消费一个数据。
我们还可以在生产者线程唤醒消费者线程或消费者线程唤醒生产者线程之前设置策略,例如当生产者线程向阻塞队列中写入一半以上的数据时,再唤醒消费者线程来取数据。或者当消费者线程将阻塞队列中的数据取走一半时,再唤醒生产者线程写入数据。唤醒语句写在解锁语句前或者解锁语句后都没有问题,因为如果写在解锁语句之前将线程唤醒,那么被唤醒的线程还是会在pthread_cond_wait函数中进行阻塞等待申请锁,直到pthread_cond_wait函数为线程申请到了锁,这个线程才可以向下执行。但是为了规范,还是写在解锁语句之前比较好。
下面我们设置让生产者线程生产的数据大于等于阻塞队列的一半时再唤醒消费者线程,我们可以看到当生产者线程生产两个数据后消费者线程才开始消费,并且后面是生产者生产一个数据,消费者消费一个数据。
我们上面的代码其实还会出现问题,例如当在push函数或pop函数中执行pthread_cond_wait函数失败时,那么线程就不会被阻塞等待,而是会继续向后执行,然后可能就会出现阻塞队列为满还向队列中写数据或者阻塞队列为空还读取数据的情况。还有一种可能,如果有其它线程在阻塞队列已满的情况下通过让Full条件变量生效来唤醒生产者线程,或者在阻塞队列为空的情况下通过让Empty条件变量生效来唤醒消费者线程,那么也会出现阻塞队列为满还向队列中写数据或者阻塞队列为空还读取数据的情况。所以我们应该在push和pop函数中使用while循环来判断阻塞队列为空还是为满。这样如果出现了pthread_cond_wait函数调用失败的情况,那么因为是while循环,而且队列还满,满足while的条件,所以还会进入while循环内部来重新执行pthread_cond_wait函数。如果是有其它线程在队列已满的情况下使用pthread_cond_signal函数唤醒了生产者线程,那么代码向后执行,会再次判断while循环条件是否满足,发现循环条件满足,就会继续进入循环执行pthread_cond_wait函数让生产者线程进行阻塞等待。这样使用while循环来判断就保证了当线程访问临界资源时,临界资源一定是就绪的。
通过上面的分析,我们就知道了条件变量的使用一定要遵循规范使用。
使用条件变量的基本规范如下。
//等待条件代码
pthread_mutex_lock(&mutex);
while(条件为假)
{
pthread_cond_wait(cond,mutex);
}
//修改条件
进行临界资源访问,或者唤醒其它线程。
pthread_mutex_unlock(&mutex);
//给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
下面我们再来修改上面的代码,我们让阻塞队列中不放int型的数据了,而是放任务。
我们实现一个Task类,这个类中有两个操作数和一个操作函数,当我们创建这个类对象后就是创建一个任务,然后就可以根据提供的方法来执行这个任务了。
下面我们让生产者线程向阻塞队列中写入的是一个Task任务,消费者线程从阻塞队列中取出来的也是一个Task任务。
下面我们让生产者线程生产的慢,消费者线程消费的快,我们可以看到生产者线程生产一个任务,消费者线程执行一个任务。
我们还可以让生产者线程生产数据时从键盘中获取数据。
上面我们的代码中只创建了一个生产者和一个消费者,下面我们还可以创建多个生产者和多个消费者。并且我们不需要担心线程安全的问题,因为我们的阻塞队列中使用了锁,所以同一时刻只能一个生产者线程或者消费者线程访问阻塞队列。
然后我们使用下面的命令来检测线程。我们可以看到有两个生产者线程来生产任务,并且有两个消费者线程来执行任务。而且我们看到同一个生产者线程生产的任务会被同一个消费者线程所执行,这是因为线程同步的原因,即生产者线程1和生产者线程2都在Full条件变量的阻塞队列中排队,当执行完生产者线程1后就会执行生产者线程2;而消费者线程1和消费者线程2都在Empty条件变量的阻塞队列中排队,当执行完消费者线程1后就会执行消费者线程2。所以顺序才是固定的。
下面我们再来完善一下我们的代码,我们在使用锁时经常会忘记将锁释放,所以下面我们来写一个锁守卫,用来防止我们申请锁后忘记释放锁。
然后我们在push函数和pop函数中就不需要进行申请锁和释放锁了,我们只需要在申请锁时创建一个lockGuard类型的lockguard对象,然后就会自动调用lockguard对象的构造函数来进行申请锁了,然后当退出push和pop函数时,因为lockguard出了作用域,所以会自动调用lockguard对象的析构函数来进行释放锁。
重新理解生产者消费者模型
通过上面的代码,我们不应该简单的将生产者消费者模型理解为生产者将数据放到仓库,消费者将数据取走。应该理解为生产者线程获取数据和消费者线程处理数据都是花时间的,当消费者将数据从仓库拿出后,消费者就开始花时间处理数据,而此时生产者可以继续从用户那里获取数据或者向仓库放数据。这样生产者线程和消费者线程就可以并发的执行自己的任务了,生产者消费者模型效率的提升就体现在这里。 即例如生产者还在从用户那里获取数据时,而消费者可以不需要等生产者获取数据,而直接从仓库中拿取生产者之前放进去的数据,这样生产者和消费者就可以并发的执行自己的任务了,而不需要生产者等待消费者进程,或者消费者等待生产者进程。这就是生产者和消费者效率提升的关键点。
多生产者和多消费者模型的意义
让生产之前的获取数据和消费之后的处理数据的过程中可以有多个执行流。即可以有多个生产者并发的从不同的用户中获取数据,但是多个生产者向仓库写数据时,同一时间只能有一个生产者可以向仓库写数据。也可以有多个消费者可以并发的处理数据,但是同一时间只能有一个消费者从仓库中取数据,这些消费者排队从仓库取完数据之后就可以并发的处理拿到的数据了。即有多个执行流同时在进行生产数据或数据处理。例如当生产数据或消费数据比较废时间时,就可以用多生产者和多消费者的模型。拿任务或者放任务时不能并发,即同一时间只能有一个生产者或者消费者来访问仓库。而生产任务或者处理任务时可以并发。