Linux - 第9节 - Linux多线程(一)

目录

1.Linux线程概念

1.1.线程的概念

1.2.进程概念新的理解

1.3.页表

1.4.线程的优缺点

1.5.线程异常

1.6.线程用途

2.Linux 进程VS线程

3.线程控制

3.1.POSIX线程库

3.2.Linux原生线程库中线程相关接口

3.3.线程代码示例

3.3.1.线程相关接口使用的代码

3.3.2.线程相关问题验证的代码

4.线程独立栈结构、线程id、线程局部存储

4.1.补充知识

4.2.线程的独立栈结构和线程id

4.3.线程局部存储

5.线程分离

6.Linux线程互斥

6.1.进程线程间的互斥相关背景概念

6.2.互斥量与加锁

6.2.1.互斥量相关接口

6.2.2.互斥量的使用

6.3.互斥量实现原理

6.4.互斥量封装使用相关代码

7.可重入VS线程安全

7.1.可重入和线程安全的概念

7.2.可重入和线程安全常见情况

7.3.可重入和线程安全的关系

8.死锁

8.1.死锁的概念

8.2.死锁四个必要条件

8.3.避免死锁


1.Linux线程概念

1.1.线程的概念

书本中对线程的描述:

1.在进程内部运行的执行流

2.线程比进程粒度更细,调度成本更低

3.线程是CPU调度的基本单位

注:上面的说法都没有问题,这里我们以Linux内核的角度切入来讲解线程。

线程的概念:

Linux内核中进程的基本框架如下图所示。fork之后,父子是共享代码的,可以通过if else判断,让父子进程执行不同的代码块,因此不同的执行流可以做到进行对特定资源的划分。

要创建一个进程,操作系统需要创建进程对应的task_struct、mm_struct和页表,每个进程的task_struct、mm_struct和页表都是私有的,进程具有独立性。如果今天我们要创建一个进程,只创建进程的task_struct,mm_struct和页表共用另一个进程的,那么这两个“进程”看到的所有的资源都一样(执行的代码、访问的堆栈区等都相同)。

如果创建n-1个task_struct共享同一个进程的mm_struct和页表,将虚拟地址空间mm_struct的各区域(代码区、数据区、堆区栈区等)拆分成n份,每一份对应一个task_struct。创建的n-1个task_struct中的一个task_struct,该task_struct占有对应进程的一小份代码、数据,且使用部分页表,我们将这样的一个task_struct所对应的执行流称为线程。

\bullet 在一个程序里的一个执行路线就叫做线程。更准确的定义是:线程是“一个进程内部的控制序列”  。
\bullet 一切进程至少都有一个执行线程。
\bullet 线程在进程内部运行,本质是在进程地址空间内运行。
\bullet 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
\bullet 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

了解了线程的概念,我们再对书本中线程的描述做解释:线程是在进程内部运行的,即在进程的地址空间中运行,因此线程是在进程内部运行的执行流。线程执行的代码和数据更少了,因此线程的执行力度更细。CPU内有很多寄存器,寄存器中存储着当前执行进程或线程的地址空间和页表,当调用该进程或该进程对应线程的时候,地址空间和页表不用切换了,因此调度成本变得更低了。CPU调度只看task_struct,而线程具有独立的task_struct,是CPU调度的基本单位。

因为线程是在进程内部运行的,所以线程:进程=n:1。操作系统要对进程进行管理,那么比进程更多的线程也需要进行管理,怎么管理呢?答案是先描述再组织。在操作系统内核中对进程的描述是PCB(Linux下具体为task_struct结构体),对线程的描述是TCB。

在真正意义上实现多线程的操作系统中(例如windows),需要分别针对进程和线程来设计描述功能的结构体(PCB和TCB),而很多线程在一个进程的内部,所以还要维护线程TCB和进程PCB之间的关系,这样写出来的代码耦合度高,PCB和TCB复杂,维护成本高。真正意义上实现多线程的操作系统设计者认为进程和线程在执行流层面是不一样的,而Linux操作系统设计者认为进程和线程没有概念上的区分,即PCB和TCB没有区别,只有一个叫做执行流,Linux的线程是用进程模拟的,即Linux的TCB是用PCB模拟的,Linux下的TCB和PCB都是task_struct。

注:Linux下也是有TCB概念的,只不过Linux下的TCB实现是复用task_struct的。

在CPU的角度,所有的task_struct没有区别,调度的时候统一以task_struct为单位进行调度,但是task_struct可能是PCB也可能是TCB,如果是TCB那么该task_struct背后挂靠的mm_struct和页表是某个PCB对应task_struct背后挂靠的mm_struct和页表的一小部分,所以CPU执行该task_struct其实执行的是某个进程的一小部分代码和数据。

因为时间片的原因,在一个时间段内,执行该TCB对应task_struct的同时并不妨碍执行相应PCB对应task_struct,这样就将本身应该串行执行的代码转变为在CPU上并发或并行的在一个时间段内同时推进。

注:我们之前的理解是,CPU看到的所有task_struct都是一个进程,学了线程后应该知道,CPU看到的所有task_struct都是一个执行流(进程或线程)。

1.2.进程概念新的理解

进程概念的重新定义:

旧的理解:进程=内核数据结构+进程对应的代码和数据

新的理解(内核视角):承担分配系统资源的基本实体(进程的基座属性)

我们之前学习的进程其实是内部只有一个执行流的进程,即单执行流进程。现在学习了线程就应该知道,进程内部可以有多个执行流,内部有多个执行流的进程称为多执行流进程。

进程是向系统申请资源的基本单位,进程最大的意义不是执行进程,而是为了执行该进程向系统申请的一大批资源(虚拟地址空间mm_struct、页表及映射关系、物理内存中申请的数据和代码等)。

注:

1.Linux中的task_struct量级 <= 传统进程的PCB量级,当task_struct对应进程的PCB时则等号成立。

2.Linux下没有真正意义上的线程,而是用进程task_struct模拟实现的,因此Linux下的“进程”(既包括进程也包括线程)小于其他操作系统的进程概念,称为轻量级进程。

3.进程是承担分配系统资源的基本单位,线程(或执行流)是CPU调度的基本单位。当调度内部只有一个线程(或执行流)的进程时,该进程可以看作是一个线程。

1.3.页表

补充内容:

前面讲过,物理内存其实是以4KB为单位进行划分的,每一个4KB称为一个page页框。可执行程序在编译时是根据虚拟地址编译的,编译的同时也将虚拟地址空间划分成了4KB,每一个4KB称为一个页帧。

可执行程序加载到内存中进行IO,前面讲过IO的基本单位是块,块大小一般默认是4KB,因此在加载的时候,可执行程序以4KB页帧为单位加载到物理内存中。

如果物理内存是4GB,那么物理内存中有2^{20}个页框,大约一百万个页框,操作系统管理这一百万个页框需要先描述再组织,操作系统内有struct page来描述页框,有struct page mem[1024×1024]数组组织页框,这样对内存的管理就变成了对数组的增删查改。

页表讲解:

页表结构不是单纯的做映射,当虚拟地址要做映射时,页表会确认虚拟地址是否命中(根据虚拟地址要访问的内容是否在物理内存中),如果命中则直接去内存读取数据,如果没有命中则该进程暂时不再被CPU调度,页表触发缺页中断,将外设硬件中的资源载入到内存中,页表增加新的物理地址后面是否命中改为是,然后再调度该进程继续刚刚的映射工作,此时页表确认命中去内存读取数据。

在c语言中下面的代码是不对的,代码区(字符串常量区)的内容不可被修改,如果代码区的内容不可被修改,那么是如何将内容加载进来的呢?代码区的内容不可被修改,是在虚拟地址转化为物理地址时遭到了拦截。当我们要进行修改时,页表中有读写权限栏会限制用户的读写,如果是正常的数据内容权限是RW,如果是只读的数据内容权限是R,代码区的地址在页表中就是只读的,只能通过页表读取物理内存中的内容,不能通过页表修改物理内存中的内容。

如果只读的内容用户去修改,即超越了页表内的权限,那么内存管理单元MMU就会发生异常,操作系统识别这个异常并解释成信号,发给目标进程终止该进程。

页表有用户级页表和内核级页表,页表中有U/K权限栏来确定当前指向的内容是内核代码和数据还是用户代码和数据。

char *msg = "hello word";
*msg = 'H';

如果计算机是32位机,那么虚拟地址空间为2^{32}字节,即4G空间,如果页表每一个条目(每一栏)占8字节,那么光页表就要占用2^{32}\times 8字节,即32G空间,这已经超过了绝大部分内存的大小。

虚拟地址在被转化的过程中不是直接转化的,32位机下地址是32位的,地址转化过程中被分成了10+10+12三批进行转化。

页表结构介绍:页表真实的状态并不像下图一所示,页表其实分为多级,在32位机下,页表是一个2级页表。32位机下第一级页表称为页目录,页目录的左边栏是虚拟地址的高10个比特位(第一批),页目录的右边栏指向了一个个二级页表。32位机下第二级页表称为页表项,页表项的左边栏是虚拟地址的中间10个比特位(第二批),页表项的右边栏指向了page的起始地址(物理内存)。

映射流程:虚拟地址首先被分成了10+10+12三批,第一批虚拟地址通过一级页目录锁定对应的二级页表项,通过第二批虚拟地址在锁定的二级页表项中锁定对应的page的起始地址,找到了物理内存中对应的页框,再将第三批虚拟地址作为页内偏移量,找到物理内存中页内的某个地址,这样就在虚拟地址与物理地址间建立了映射关系。

注:

1.第三批低12个比特位对应2^{12}字节就是4096字节,即4KB ,刚好与物理内存中一个页框的大小相匹配,因此将第三批低12个比特位作为偏移量刚好能够覆盖页内的所有地址。

2.前面介绍的页表会确认虚拟地址是否命中,这里确认是否命中就是以页为单位的,虚拟地址转化时,如果二级页表页表项右边栏为NULL,就说明对应页还没有被加载,即程序还没有将对应代码或数据加载到物理内存中,操作系统再以页为单位将对应代码或数据进行加载。

页表这么设计的好处:

1.进程虚拟地址管理和内存管理通过页表+page进行解耦。

进程虚拟地址管理只关心页表中对应page在不在内存中,如果在就使用如果不在就停下来交给内存管理以页为单位从外设加载数据。

2.我们将页表分离了,32位机下分成了一二级页表,同时页表也可以实现按需创建 (没有用到的页表条目不进行创建)(32位机下一级页表肯定要创建,这里按需创建页表主要说的是二级页表)。分页机制+按需创建页表=节省空间。

页表只需要映射虚拟地址32个比特位中的第一批和第二批20个比特位,剩下的12个比特位作为偏移量不需要映射,如果一个页表条目占用10字节,那么页表总共占用2^{32}/2^{12}×10=2^{20}×10字节,即10MB,且页表是按需创建的,页表的大小只会小于这个10MB。

1.4.线程的优缺点

线程的优点:

\bullet 创建一个新线程的代价要比创建一个新进程小得多  
在Linux中创建一个线程只需要创建一个PCB,然后把已经创建好的进程资源分配给线程即可。已经创建好的进程资源分配给线程并不代表进程资源在内存中,这些资源是需要的时候再加载到内存的。
\bullet 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

不需要切换地址空间和页表。

\bullet 线程占用的资源要比进程少很多
线程本身使用的就是进程内资源的一部分。

\bullet 能充分利用多处理器的可并行数量(进程也有该优点)

\bullet 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(进程也有该优点)

\bullet 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

计算密集型其实就是进程的代码大部分都是计算,当进程对文件进行加解密时,该进程就是计算密集型的。计算密集型的进程主要是使用CPU资源进行计算,可以使用多线程对计算进行拆分,这样可以充分使用多核CPU。这里并不是拆分和创建线程越多越好,在计算密集型应用中,一般创建的线程数等于CPU的核数,执行效率较高。

\bullet I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

I/O密集型其实就是进程的代码大部分都是进行IO的,例如printf打印、读写文件、读写网络等。I/O密集型的进程主要特点是可能会发生阻塞,一旦阻塞那么进程会去对应的地方(磁盘、网卡等)等待,只要等待那么该进程执行效率会很低。I/O密集型的进程可以使用多线程,每一个线程做不同的I/O操作。

I/O密集型的应用,如果是单进程执行,那么就是串行执行的,去每一个I/O地方依次等待,如果是用多线程执行,那么就是并行执行的,不同的线程去不同的I/O地方进行等待,因此多线程的执行效率更高。

线程的缺点:

\bullet 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
\bullet 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
\bullet 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些操作系统函数会对整个进程造成影响。
\bullet 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。

1.5.线程异常

\bullet 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
\bullet 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

1.6.线程用途

\bullet 合理的使用多线程,能提高CPU密集型程序(计算密集型程序)的执行效率。
\bullet 合理的使用多线程,能提高IO密集型程序的执行效率和用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。


2.Linux 进程VS线程

\bullet 进程是资源分配的基本单位

\bullet 线程是调度的基本单位

\bullet 进程的多个线程共享同一地址空间,因此进程的代码和数据都是共享的如果定义一个函数在各线程中都可以调用,如果定义一个全局变量在各线程中都可以访问到除此之外,各线程还共享以下进程资源和环境:

(1)文件描述符表:各线程不用再创建struct file结构了,而是用自己的PCB指向主进程的文件描述符表即可
(2)每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
(3)当前工作目录(当前路径)
(4)用户id和组id
\bullet 线程共享进程数据,但也拥有自己私有的一部分数据:  
(1)线程ID
(2)一组寄存器:PCB中有存储的寄存器数据,每个线程在被调度的时候需要这些寄存器数据对上下文进行保护
(3)栈:每个线程都有自己私有的独立栈结构
(4)errno:全局变量本身应该被主进程下所有线程共享,但errno是个例外,每个线程都有自己私有的errno。
(5)信号屏蔽字
(6)调度优先级
进程和线程的关系如下图:
注:
1.主进程退出,那么进程所对应的所有资源(mm_struct、页表等)会释放,主进程创建的新线程会跟着退出。
2.主进程创建的新线程退出不会影响主进程和其他线程的退出。


3.线程控制

3.1.POSIX线程库

因为Linux下并没有真正意义上的线程,其线程是用task_struct模拟实现的,因此Linux系统中并没有提供线程相关的系统接口,但是Linux操作系统中有POSIX原生线程库(库里的接口不是系统接口)。

注:

1.要使用原生线程库需要在编译时带上-pthread选项。

2.c++中也有对应的线程库,c++的线程库内函数接口其实是封装的Linux原生线程库中的对应接口,因此使用c++线程库中函数接口在编译的时候也需要带上-pthread选项。c++的线程库我们后面进行讲解。

3.2.Linux原生线程库中线程相关接口

pthread_create函数:

pthread_create函数用来创建一个线程。

参数:

thread:输出型参数,输出线程id。

attr:线程属性,设置为NULL即可。

start_routine:返回值为void*参数为void*的回调函数指针,该线程执行某个进程对应回调函数的入口地址。

arg:该线程执行某个进程对应回调函数的参数。

返回值:

如果调用成功返回0,调用失败返回错误码(返回线程私有的errno)。

注:

1.pthread_t类型其实就是无符号长整型,如下图所示。

2.pthread_create函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

3.使用pthread_create函数需要包含<pthread.h>头文件。

pthread_join函数:

pthread_join函数用来等待一个退出的线程。

参数:

thread:等待线程的id。

retval:指向一个指针,后者指向线程的返回值,如果不需要设置为NULL即可。

返回值:

如果调用成功返回0,调用失败返回错误码。

注:

1.如果主进程退出了,那么新线程无论是否执行完都会跟着退出。通过pthread_join函数可以保证新线程先退出主进程后退出。

2.pthread_create函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

3.使用pthread_create函数需要包含<pthread.h>头文件。

pthread_self函数:

pthread_self函数一般用在函数中,用来获取调用本函数的线程id。

返回值:

返回调用本函数的线程id。

注:

1.pthread_self函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

2.使用pthread_self函数需要包含<pthread.h>头文件。

pthread_exit函数:

pthread_exit函数用来终止线程,与return功能相同。

参数:

retval:线程退出的返回值。

注:

1.exit函数是退出进程,如果在任意新线程中使用exit,那么主进程就会退出,所有新线程跟着退出。pthread_exit是用来退出新线程的,不会影响主进程和其他新线程的退出。

2.pthread_exit函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

3.使用pthread_exit函数需要包含<pthread.h>头文件。

pthread_cancel函数:

pthread_cancel函数用来取消一个线程,给对应的线程发送取消请求。

参数:

thread:要取消线程的id。

注:

1.如果线程是被取消的(例如pthread_cancel取消对应线程),那么线程的退出结果为-1。

2.pthread_cancel函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

3.使用pthread_cancel函数需要包含<pthread.h>头文件。

3.3.线程代码示例

3.3.1.线程相关接口使用的代码

代码示例1:

创建mythread.cc文件,写入下图一所示的代码,使用 g++ -o mythread mythread.cc -lpthread -std=c++11 命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,同时使用ps -aL查找所有的轻量级进程,如下图二所示。

可以看到三个执行流(主线程、线程1、线程2)同时在跑,且三个线程的PID相同,LWP是轻量级进程(线程)编号,三个线程的LWP不相同。

注:

1.ps axj是查找所有的进程,ps -aL是查找所有的轻量级进程,即线程。

2.所有PID相等的线程就是属于同一个进程内的线程或执行流。

3.LWP和PID相等的线程是主线程,也就是进程。 

代码示例2:

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写如下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

这里以十六进制的方式打印了创建线程的id,可以看到线程的id值与线程的LWP并不相同,线程的id我们后面会讲到。

代码示例3:

修改mythread.cc文件代码,如下图一所示,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图二所示。

在线程调用的startRoutine函数中使用pthread_self函数获取调用本函数线程的id值。

代码示例4:

修改mythread.cc文件代码,如下图一所示,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序。使用 kill -19 6768给主进程发送暂停信号,使用 ps axj | grep mythread 命令查看主进程状态为Tl,T是暂停状态l表示多线程,使用 kill -18 6768 给主进程发送继续运行信号,使用 ps axj | grep mythread 命令查看主进程状态为Sl,使用 kill -9 6768 给主进程发送终止信号,如下图二所示。

主进程与其线程的信号处理方式是共享的。这里我们给主进程发送暂停信号,主进程暂停其线程也暂停不再打印了。我们给主进程发送继续运行信号,主进程和线程同时运行。我们给主进程发送终止信号,主进程和线程同时终止。

代码示例5:

修改mythread.cc文件代码,如下图一所示,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,同时使用 while :; do ps -aL | head -1 && ps -aL | grep mythread; sleep 1; done 命令作为监控脚本,如下图二所示。

我们发现,当线程退出之后,主进程等待回收线程之前,监视窗口中线程已经没有了,原因是ps命令对于已经退出的线程不再显示。线程退出的时候,必须要进行join等待,如果不进行join,就会造成类似于进程那样的内存泄漏问题(进程在退出后等待前是僵尸状态,线程我们不说僵尸状态,但与僵尸状态类似会内存泄漏)。

3.3.2.线程相关问题验证的代码

1.线程异常了怎么办?(线程健壮性问题)

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写如下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,同时使用 while :; do ps -aL | head -1 && ps -aL | grep mythread; sleep 1; done 命令作为监控脚本,如下图三所示。

从下图三可以看到,线程执行startRoutine函数5秒后出现段错误异常,主进程及该线程一起异常退出了。

结论:

1.线程异常了,主进程及其所有线程整体异常退出。线程异常==进程异常。

2.线程会影响其他线程的运行,因此线程的健壮性(鲁棒性)较低。

2.pthread_join函数第二个参数如何理解?

pthread_join函数的声明如下图所示,其第二个参数是一个二级指针,是一个输出型参数,该参数能够获取新线程退出时的退出码。pthread_create函数创建线程执行的函数是一个参数为void*类型,返回值也为void*类型的函数,因为该函数返回值为void*类型的,想要获取返回值则需要void**类型的变量。

注:

1.void*类型的变量是一个指针,在64位编译器下大小8字节,32位编译器下大小4字节。Linux是64位操作系统,gcc编译器默认是64位编译的。

2.进程退出可以分为三种情况:(1)代码跑完,结果正确(2)代码跑完,结果不正确(3)代码没跑完,异常。进程退出后等待时,得到的进程退出码可以获取进程退出的以上三种情况,而线程退出后等待时,得到的返回值只能表示前两种情况,无法表示异常的情况。主进程为何没有获取新线程退出时的信号?因为线程异常==进程异常。

代码验证:

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写如下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

注:在pthread_join函数等待后,不能直接打印retval的值,因为此时的retval即对应函数的返回值是一个指针类型,需要强转成对应的类型再打印。

3.线程退出的四种方式

线程退出有四种方式:return方式、pthread_exit方式、pthread_cancel方式、线程分离后延后退出(线程分离后延后退出我们在后面线程分离部分讲解)。上面使用的都是return方式,下图一二所示的代码和运行结果使用的是pthread_exit方式,下图二三所示的代码和运行结果使用的是pthread_cancel方式。

注:线程可以使用pthread_exit函数来取消主进程,但这种做法不推荐。


4.线程独立栈结构、线程id、线程局部存储

4.1.补充知识

补充一:主进程和其线程共享进程地址空间

主进程的堆区与其线程是共享的,因此线程结束既可以直接return某个值,还可以通过return堆区地址的方式返回数据,如下图一二所示。主进程的静态区与其线程是共享的,因此全局变量主进程和线程都能够读写,如下图三四所示。

补充二:进程的所有代码都在进程地址空间中执行

在进程的代码区,一般存在三种代码:自己的代码、库中的代码、系统的代码。自己的代码通过页表与加载到内存的可执行程序映射;共享区通过页表与加载到内存的库映射,库的代码通过跳转地址执行共享区中映射的库;系统的代码通过身份切换执行操作系统的代码。代码区中所有的代码执行,都是在进程的地址空间中进行的。

我们使用的线程库是用户级线程库,因此使用线程控制接口时,需要使用libpthread.so动态库,如下图一所示。我们要使用libpthread.so动态库就需要将该动态库加载到内存中,然后动态库会被映射到进程的地址空间的共享区中。当代码执行到线程控制接口时,操作系统会跳转到共享区执行对应的接口(创建线程等),然后将执行结果返回。

4.2.线程的独立栈结构和线程id

线程是一个独立的执行流,线程一定会在自己的运行过程中产生临时数据(调用函数、定义局部变量等),线程一定需要有自己独立的栈结构。

在Linux操作系统中,线程的全部实现,并没有全部体现在操作系统内,而是操作系统提供执行流,具体的线程结构由库来进行管理。

库可以创建多个线程,库要对创建的多个线程管理,要管理就要先描述再组织。在libpthread.so动态库中有struct thread_info结构体对线程进行描述,如下图一所示。struct thread_info结构体中有pthread_t tid记录线程的id,有void* stack记录线程的私有栈。

每当pthread_create创建一个线程时,就会在libpthread.so动态库中创建一个对应的thread_info结构体作为线程控制块,返回线程控制块的起始地址。因此pthread_t类型的线程id就是对应的用户级线程控制结构体的起始地址,操作系统可以通过该地址找到线程控制结构体,进而查找对应线程的各种属性。

主进程的独立栈结构用的就是地址空间中的栈区,新线程的独立栈结构用的是库中维护提供的栈结构。

Linux内核提供的是执行流,即轻量级进程,用户需要的是线程,libpthread.so动态库将轻量级进程封装成线程提供给用户,如下图所示。线程id是用户级线程库对操作系统内核提供的执行流封装后得到的,每一个封装后的线程都有线程id,LWP与操作系统内核提供的执行流对应,因此线程id与LWP是1:1的。

注:libpthread.so动态库将操作系统内核提供的轻量级进程进行了封装,因此我们在代码中只需要知道线程的id即可,不需要使用轻量级进程的LWP,如果一定要获取对应轻量级进程的LWP,需要使用系统调用syscall(SYS_gettid),如下图一二所示。

使用系统调用syscall(SYS_gettid)需要包含<unistd.h>和<sys/syscall.h>头文件。

4.3.线程局部存储

在libpthread.so动态库的线程控制块thread_info结构体中,还有一个线程局部存储。

想要保存每个线程的私有信息,可以给全局变量前面加上__thread,这样操作系统会将对应的全局变量在每个线程的局部存储中都拷贝一份,即每个线程都私有的存一份,每个线程在使用时都用的是各自私有的那个。

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写如下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

每个线程打印的global_value变量地址相同,每个线程对global_value变量进行修改,互相之间会有影响。

注: 这里创建完线程要sleep三秒,是因为如果不sleep,pthread_create创建完线程后,调度器继续往下执行主线程还是去执行新线程是不确定的(大概率会继续往下执行主线程),如果继续往下执行主线程,主进程return退出,主进程退出了新线程也跟着退出,因此新线程没有执行,也不会再死循环的打印。

如果sleep了,那么在sleep期间调度器会去调度新线程,新线程死循环打印,三秒后主进程退出,新线程跟着退出。

修改mythread.cc文件代码,如下图一所示, 给全局变量前面加上__thread,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图二所示。

每个线程打印的global_value变量地址不同,每个线程对global_value变量进行修改,互相之间没有影响。


5.线程分离

\bullet 默认情况下,新创建的线程是joinable的,即可被等待的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
\bullet 如果不关心线程的返回值,那么join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
pthread_detach函数:
pthread_detach函数用来分离目标线程。

参数:

thread:要分离线程的id。

返回值:

如果成功返回0,如果失败返回错误码。

注:

1.要分离的线程可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。我们更倾向于主线程分离其他线程。

2.joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

3.执行pthread_detach函数分离对应线程时,要保证对应线程活着没有退出。

4.新线程分离后如果主进程先退出,那么分离后的线程还是会跟着退出,而分离后的新线程主进程无法等待,因此当我们分离新线程后,对应的主进程一般不要退出(常驻内存的进程)。

5.线程分离,意味着我们不关心后面线程是否退出,分离后的线程会自动被系统回收。所以我们可以将线程分离理解为线程退出的第四种方式——延后推出。

6.pthread_detach函数是原生线程库中的函数,要使用该函数,在编译时要带上-lpthread选项,进行库链接。

7.使用pthread_detach函数需要包含<pthread.h>头文件。

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写如下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

不能对pthread_detach分离后的线程进行pthread_join等待,如果对分离后的线程进行等待则等待失败。

注:这里创建完线程要sleep一秒,是因为如果不sleep,pthread_create创建完线程后,调度器继续往下执行主线程还是去执行新线程是不确定的(大概率会继续往下执行主线程),如果继续往下执行主线程,那么新线程对应函数没有执行,线程还没有被分离就被主进程等待了,那么主进程会一直等待新线程结束,然后新线程再去执行分离和死循环的打印(这里分离是在主进程等待后执行的,pthread_join并不知道新线程已经分离了,所以会一直等待)。

如果sleep了,那么在sleep期间调度器会去调度新线程,新线程分离后再去执行主线程的等待,就会有等待失败的情况,主进程等待失败然后return退出,主进程退出了新线程也跟着退出,因此新线程不会再死循环的打印。


6.Linux线程互斥

6.1.进程线程间的互斥相关背景概念

临界资源:多线程执行流共享的资源就叫做临界资源。  
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
如下面的代码所示,全局变量global_value就是临界资源,startRoutine函数while循环中的

cout << ...... << endl代码就是临界区。

临界资源可能会因为不同线程共同访问,造成数据不一致问题。

发现数据不一致问题:

我们模拟四个用户抢票的场景,用四个线程来表示四个用户,用全局变量tickets来表示票数。

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写入下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

线程频繁切换更容易出现数据不一致问题,创造更多的让线程阻塞的场景就可以让线程频繁切换,usleep函数是按照微秒让线程休眠,休眠期间线程就会阻塞切换下一个线程。下图三所示最后tickets减为了-1、-2,就是因为数据不一致问题。

数据不一致问题的原因:

CPU内的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据。线程被切换的时候,需要保存上下文(保存寄存器中的数据),线程被换回的时候,需要恢复上下文(将数据加载到寄存器中)。

临界区的tickets--语句被编译器编译后至少要翻译成三条语句,第一条语句是将tickets变量值从内存load拷贝到CPU的寄存器中,第二条语句是CPU对寄存器的值做--计算操作,第三条语句是将计算后的结果write写回内存中,即将tickets变量值修改为计算后的值,如下图所示。tickets--翻译成三条语句后,在三条语句的任意地方,线程都可能被切换走。

假如tickets此时等于10000,线程A执行tickets--语句,被编译器翻译成三条语句,当执行完第二条语句CPU执行--操作后得到9999,此时编译器将线程A从CPU剥离开始执行线程B,9999被保存在线程A的上下文数据中。线程B执行tickets--语句,被编译器翻译成三条语句,执行完三条语句后,内存中的tickets变量值为9999。如果线程B多次执行tickets--语句,将内存中的tickets变量值减为50,线程B继续执行tickets--语句,被编译器翻译成三条语句,当执行完第二条语句CPU执行--操作后得到49,此时编译器将线程B从CPU剥离开始执行线程A,49被保存在线程B的上下文数据中。线程A被执行时首先会执行第三条语句,将9999写回内存中,即将tickets变量值修改为99999。这样就出现了数据不一致的问题。

数据不一致问题分析:这里出现数据不一致的原因是因为各线程不互斥,线程A执行tickets--操作访问临界资源tickets,在还没有访问结束时线程B又来访问临界资源tickets,导致问题出现。

数据不一致问题的解决方法:为解决这里的问题,需要保证tickets--代码是原子的,即tickets--执行期间不能被打扰(不存在tickets--翻译成三条语句执行的中间过程)。加锁可以保证各线程互斥,保证相应代码的原子性,即对应代码执行期间不被打扰。

注:这里不仅tickets--代码会访问临界资源且该代码不是原子的,会导致数据不一致问题,if (tickets>0)代码同样会访问临界资源且该代码也不是原子的(if语句也需要CPU来执行),同样会导致数据不一致问题。

发现问题部分最后tickets减为-1就是if (tickets>0)代码的问题。当tickets为1时,线程A执行if判断为成功,在CPU执行完准备返回时(此时没有执行tickets--),线程A被剥离执行线程B,线程B执行if语句判断也为成功,然后线程B执行tickets--,tickets值变为0。后面再继续执行线程A时,线程A首先将上下文数据中的判断成功返回,然后执行tickets--,tickets为-1。

6.2.互斥量与加锁

为解决数据不一致问题,需要一把锁,Linux上提供的这把锁叫互斥量mutex,使用互斥量这把锁就叫加锁。

6.2.1.互斥量相关接口

定义互斥量:

pthread_mutex_t mutex;
初始化互斥量(两种方式):
\bullet 如果互斥锁mutex是全局变量或static修饰的静态变量,既可以直接使用宏进行静态初始化,也可以使用下面的动态初始化。
\bullet  如果互斥锁mutex是局部变量,应采用动态初始化。
方式一:静态初始化
mutex = PTHREAD_MUTEX_INITIALIZER
方式二:动态初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex :要初始化的互斥量
attr :互斥量属性,设置为 NULL即可
返回值:
成功返回 0, 失败返回错误号
销毁互斥量:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
mutex :要销毁的互斥量
返回值:
成功返回 0, 失败返回错误号
注:
1.使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
2.不要销毁一个已经加锁的互斥量。
3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁(两种方式):
方式一:阻塞式加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:
mutex :要加锁的互斥量

返回值:

成功返回 0, 失败返回错误号
注: pthread_mutex_lock是阻塞式加锁,如果对应的互斥量正在加锁使用,那么就会等待对应互斥量使用完才能使用该互斥量加锁。
方式二:非阻塞式加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数:
mutex :要加锁的互斥量

返回值:

成功返回 0, 失败返回错误号
注: pthread_mutex_trylock是非阻塞式加锁,如果对应的互斥量正在加锁使用,那么就会立刻返回,不进行加锁。
互斥量解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
mutex :要解锁的互斥量

返回值:

成功返回 0, 失败返回错误号

6.2.2.互斥量的使用

\bullet 加锁的本质是让线程执行临界区代码串行化。

\bullet 加锁只应对临界区进行,而且加锁的粒度越细越好。

\bullet 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加锁就要所有线程都加锁。如果定义两个函数,两个函数除了加锁其他代码相同,函数A是加锁访问对应临界资源,函数B是不加锁访问对应临界资源,而有的线程调用函数A有的线程调用函数B,这种写法不能保证线程之间互斥,是错误的。

问题1:锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁,那么这把锁本身不就也是临界资源吗?谁来保护锁呢?

答:这个问题锁的设计者早就想到了,pthread_mutex_lock函数竞争和申请锁的过程就是原子的。

问题2:如果某个线程加锁并执行临界区代码访问临界资源,在执行临界区代码的过程中时间片到了,该线程可以被切换吗?加锁是否等于不会被切换?

答:线程A在执行加锁后的临界区代码的过程中时间片到了,该线程可以被切换。因为线程执行的加锁后的临界区代码也是代码,线程可以在任意代码处进行切换。但是线程A被切走后,绝对不会有其他线程进入该临界区,因为每个线程进入临界区都必须先申请锁,当前的锁被线程A申请走了,线程A被切走后是抱着锁走的(锁在A的上下文中),其他线程再申请锁会被阻塞住。

一旦一个线程持有了锁,该线程就不担心任何的切换问题。对于其他线程而言,线程A访问临界区只有没有进入和使用完毕两种状态,线程A的其他状态对于其他线程而言都要被阻塞,因此对于其他线程而言,线程A访问临界区具有原子性。

补充:因为临界区代码在一段时间内只能由一个线程来执行,因此尽量不要在临界区内做耗时的事情。

代码示例1(抢票代码的优化)

创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写入下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

这里的代码是对前面四个用户抢票代码的优化,通过加锁的方式保证各线程互斥。

注:从上图三可以看出,所有的tickets都是被线程2tickets--的,即所有票都被用户2抢走了,这是因为调度器先调度线程2执行,线程2先加锁,其他进程同时也在执行,但是由于线程2先申请到锁了,其他线程加锁时就会被阻塞,阻塞到唤醒是要花时间的,而线程2解锁后再加锁,加锁时没有线程阻塞到唤醒的开销,因此线程2总是能抢到锁。

如果想看到其他线程也在tickets--的情况,可以在解锁后加上usleep(123)代码,如下图一所示,这样线程2或其他线程解锁后就要等待,其余的线程就能够抢到锁了,如下图二所示。实际中在解锁后一般还有其他业务逻辑,这里的usleep(123)也刚好模拟了其他业务逻辑。

代码示例2:

修改代码示例1的mythread.cc文件,如下图一所示,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图二所示。

如果定义的互斥量不是全局变量(局部变量或静态变量),那么线程无法看到这个互斥量,对应函数中无法使用这个互斥量,可以在pthread_create创建线程的时候将这个局部的互斥量传过去。

代码示例3:

修改代码示例2的mythread.cc文件,如下图一所示,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图二所示。

因为定义的互斥量不是全局变量,所以代码示例2中pthread_create创建线程时传了互斥量。代码2中pthread_create创建线程时,除了传递互斥量如果还想传递线程名等更多的属性,那么可以传递创建在堆区的一个结构体(主进程的堆区新线程也是可见的,新线程和主进程之间可以通过堆区来进行数据交互),结构体中包含要传递的所有属性即可。

注:线程执行的函数内部如果想返回多个属性值,也可以在堆区创建结构体,结构体中包含要返回的所有属性,将结构体地址返回。

6.3.互斥量实现原理

问题:线程加锁和解锁具有原子性,是如何实现的?

补充:每个寄存器只有一个,是被所有线程共享的,但每个寄存器里面的内容是被各自线程私有的,凡是在寄存器中的内容,全部都是线程的内部上下文,属于线程本身。

答:单纯的i++或者++i都不是原子的,因为i++或++i在翻译成汇编代码后是由多条语句构成的,要想代码具有保证原子性,只需要保证对应代码在翻译成汇编代码后是由一条语句构成的。

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange汇编指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条汇编指令,保证了原子性。下图一所示是加锁lock和解锁unlock的伪汇编代码,其中%al是CPU的寄存器,mutex是内存中的一个全局变量,属于所有线程。代码 movb $0,%al 是将0写入%al寄存器,代码 xchgb %al,mytex 是将%al寄存器的数据和内存中mutex变量的值做交换。
如下图二所示,CPU中有%al寄存器,内存中有mutex变量,默认值为1。有两个线程要执行加锁语句,线程threadA先被CPU调度,首先执行 movb $0,%al 向%al寄存器写入0;然后线程threadA执行 xchgb %al,mytex 将寄存器中的0和内存中变量值1做交换,这个动作就是加锁,即交换就是完成加锁(只是完成加锁,加锁有没有成功还不确定);最后线程threadA执行 if 语句,寄存器里的值为1大于0,执行 return 0 申请锁成功。
如下图三所示,CPU中有%al寄存器,内存中有mutex变量,默认值为1。有两个线程要执行加锁语句,线程threadA先被CPU调度,首先执行 movb $0,%al 向%al寄存器写入0;然后线程threadA执行 xchgb %al,mytex 将寄存器中的0和内存中变量值1做交换;此时线程threadA被操作系统剥离,线程threadB被CPU调度,寄存器中的1保存在threadA的上下文中;线程threadB执行 movb $0,%al 向%al寄存器写入0;然后线程threadB执行 xchgb %al,mytex 将寄存器中的0和内存中变量值0做交换;线程threadB执行 if 语句,寄存器里的值为0不大于0,执行挂起等待,threadB申请锁失败。
如下图四所示,CPU中有%al寄存器,内存中有mutex变量,默认值为1。有两个线程要执行加锁语句,线程threadA先被CPU调度,首先执行 movb $0,%al 向%al寄存器写入0;此时线程threadA被操作系统剥离,线程threadB被CPU调度,寄存器中的0保存在threadA的上下文中;线程threadB执行 movb $0,%al 向%al寄存器写入0;然后线程threadB执行 xchgb %al,mytex 将寄存器中的0和内存中变量值1做交换;此时线程threadB被操作系统剥离,线程threadA被CPU调度,寄存器中的1保存在threadB的上下文中,线程threadA的上下文加载到寄存器中;线程threadA执行 xchgb %al,mytex 将寄存器中的0和内存中变量值0做交换;线程threadA执行 if 语句,寄存器里的值为0不大于0,执行挂起等待,threadA申请锁失败;此时线程threadA因为被挂机被操作系统剥离,线程threadB被CPU调度,寄存器中的0保存在threadA的上下文中,线程threadB的上下文加载到寄存器中;线程threadB执行 if 语句,寄存器里的值为1大于0,执行 return 0 申请锁成功。
总结:加锁对应的汇编代码就是下图一中的xchgb %al,mytex,将数据1从内存读入寄存器,本质就是将数据1从共享变成线程私有。哪个线程先将寄存器数据和内存单元的数值1做交换,就是将数值1拿到了自己的上下文中,属于该线程独有,即该线程拿到了锁。
注:解锁就是将对应线程上下文中的数值1通过寄存器写回给内存中的mutex全局变量。

6.4.互斥量封装使用相关代码

创建mythread.cc文件,写入下图一所示的代码,创建Lock.hpp文件,写入下图二所示的代码,创建makefile文件,写入下图三所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。

getTickets函数中 LockGuard lockGuard(&mymutex) 代码创建了一个局部的LockGuard结构体对象,编译器自动调用LockGuard结构体的构造函数执行加锁操作,getTickets函数调用完后操作系统自动调用LockGuard结构体的析构函数执行解锁操作,因此这里不用手动解锁。

这里在getTickets函数中 LockGuard lockGuard(&mymutex) 加锁代码放在了 bool ret = false 代码的后面,是因为ret是函数的局部变量,函数的局部变量在栈上保存,线程具有独立的栈结构,每个线程各自一份。


7.可重入VS线程安全

7.1.可重入和线程安全的概念

\bullet 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。  
\bullet 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
注:
1.线程是否安全是一种问题,线程安全没有问题,线程不安全有问题。重入是一种特性,函数是否可重入是函数本身的特性。
2.C++的STL库中绝大部分函数都是不可重入的。
3.函数名后面有_r的函数就代表该函数可重入,函数名后面没有_r的函数就代表该函数不可重入,如下图所示。

7.2.可重入和线程安全常见情况

常见的线程不安全的情况:
\bullet 不保护共享变量的函数
抢票例子中的tickets变量。
\bullet 函数状态随着被调用,状态发生变化的函数
如下图所示,每次调用test函数计数器cnt都要发生变化。
\bullet 返回指向静态变量指针的函数
这样多个线程调用该函数,都可以拿到静态变量的地址,同时对静态变量操作会出现线程不安全问题。
\bullet 调用线程不安全函数的函数
多个线程调用线程不安全函数的函数,相当于多个线程调用线程不安全函数。
常见的线程安全的情况:
\bullet 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
\bullet 类或者接口对于线程来说都是原子操作
\bullet 多个线程之间的切换不会导致该接口的执行结果存在二义性
每个线程都有各自的栈结构,每个线程调用函数修改栈上的变量,是线程安全的。
常见不可重入的情况:
\bullet 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
\bullet 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
\bullet​​​​​​​ 可重入函数体内使用了静态的数据结构
常见可重入的情况:
\bullet​​​​​​​ 不使用全局变量或静态变量
\bullet​​​​​​​ 不使用malloc或者new开辟出的空间
\bullet​​​​​​​ 不调用不可重入函数
\bullet​​​​​​​ 不返回静态或全局数据,所有数据都有函数的调用者提供
\bullet​​​​​​​ 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
如下图所示,errno是全局变量,如果函数中直接使用errno那么就是不可重入的,使用err局部变量拷贝errno的值,函数调用完后再将err的值赋值给errno,那么该函数就是可重入的。

7.3.可重入和线程安全的关系

可重入与线程安全联系:
\bullet 函数是可重入的,那就是线程安全的
\bullet 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
\bullet 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别:
\bullet 可重入函数是线程安全函数的一种
\bullet 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
\bullet 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的


8.死锁

8.1.死锁的概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

如下图一所示的代码,运行结果如下图二所示。两个线程都要先申请mutexA再申请mutexB,然后打印内容,最后先释放mutexA再释放mutexB。这样只要哪个线程先申请到了mutexA,也就能申请到mutexB。
如下图三所示的代码,运行结果如下图四所示。一个线程要先申请mutexA再申请mutexB,然后打印内容,最后先释放mutexA再释放mutexB;一个线程要先申请mutexB再申请mutexA,然后打印内容,最后先释放mutexA再释放mutexB。这样如果一个线程先申请到了mutexA,一个线程先申请到了mutexB,然后两个线程都要再申请对方已经申请到的锁,两个线程都抱着各自的锁挂起了,这就造成了死锁。
注:下图三,两个线程在申请mutexA和申请mutexB之间睡眠了一秒,是为了让一个线程抢到对应的锁后等待,让另一个线程抢到另一个锁。如果不加sleep(1)也能造成死锁,但概率较低。

注:一把锁也会有死锁问题。如下图一所示的代码,运行结果如下图二所示,同一个线程内,在释放对应锁之前,多次(两次及以上)申请同一把锁,那么就会造成死锁。

 

8.2.死锁四个必要条件

\bullet​​​​​​​ 互斥条件:一个资源每次只能被一个执行流使用
需要锁来保证对应资源每次只能被一个执行流执行。
\bullet​​​​​​​ 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
保持我的锁的同时还要请求其他的锁,或者保持我的锁的同时还要请求自己的锁(基于一把锁的请求与保持)。
\bullet​​​​​​​ 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
两个执行流同时保持自己的锁并请求对方的锁,此时一个执行流不能因为自己的优先级或权重而强制剥夺其他执行流的锁。
\bullet​​​​​​​ 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

循环等待可以保证两个执行流在各自占有一个锁之后互相申请等待对方的锁。

注:只要死锁产生了,那么一定满足了上面四个条件。如果有一个条件没有满足,那么死锁便不成立。

8.3.避免死锁

避免死锁就是破坏死锁的四个必要条件:(破坏四个必要条件之一即可避免死锁)
\bullet​​​​​​​ 互斥条件一般无法破坏,如果一定要破坏那就是不用锁。
\bullet​​​​​​​ 破坏请求与保持条件:申请锁的时候不要一直等待着要,如果申请几次锁之后还没有申请到就放弃申请。
\bullet​​​​​​​ 破坏不剥夺条件:允许不同执行流之间互相抢占锁。
\bullet​​​​​​​ ​​​​​​​破坏循环等待条件:直接不让“两个执行流在各自占有一个锁之后互相申请等待对方的锁”的情况发生。
注:使用pthread_mutex_lock函数申请锁是阻塞等待式的申请;使用pthread_mutex_trylock函数申请锁是非阻塞等待式的申请,如果申请锁失败了会立即返回。
避免死锁的建议:
\bullet​​​​​​​ 加锁顺序一致
不要出现一个线程先申请A锁再申请B锁,另一个线程先申请B锁再申请A锁这种交叉情况。
\bullet​​​​​​​ 避免锁未释放的场景
使用锁对临界区做保护,使用完之后应尽快释放锁。
\bullet​​​​​​​ 资源一次性分配
使用锁的频率高会有较大概率产生死锁,将临界资源一次性分配好,进而减少使用锁的频率。

避免死锁的算法:

\bullet​​​​​​​ 死锁检测算法(了解)
\bullet​​​​​​​ 银行家算法(了解)

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

随风张幔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值