文章目录
- 前言
- 1. 线程概念
- 1.1 什么是线程
- 1.2 线程比进程更加轻量化
- 1.3 虚拟地址到物理地址的转化
- 物理内存的管理
- 页表
- 1.4 线程的优点
- 1.5 线程的缺点
- 1.6 线程异常
- 1.7 线程用途
- 2. 进程 vs 线程
- 3. 线程控制
- 3.1 线程创建
- 3.2 线程退出
- 3.3 线程等待
- 3.4 分离线程
- 3.5 线程取消
- 4. 线程管理
- 4.1 使用原生线程库进行简单的封装
- 5. 线程的互斥
- 5.1 进程线程间的互斥相关背景概念
- 5.2 互斥量mutex
- 5.3 线程不互斥会发生什么样的错误
- 5.4 互斥量(加锁)
- 5.5 可重入和线程安全
- 5.5.1 概念
- 5.5.2 常见的线程不安全的情况
- 5.5.3 常见的线程安全的情况
- 5.5.4 常见不可重入的情况
- 5.5.5 常见可重入的情况
- 5.5.6 可重入与线程安全联系
- 5.5.7 可重入与线程安全区别
- 6. 死锁
- 6.1 概念
- 6.2 死锁的四个必要条件
- 6.3 避免死锁
- 6.4 避免死锁的算法
- 6.5 小问题
- 7. 线程同步
- 7.1 条件变量
- 7.2 同步概念与竞态条件
- 7.3 条件变量函数
- 7.4 为什么 pthread_cond_wait 需要互斥量?
- 8. 生产者消费者模型
- 8.1 为何要使用生产者消费者模型
- 8.2 生产者消费者模型优点
- 8.3 基于BlockingQueue的生产者消费者模型
- 9. POSIX信号量
- 9.1 认识接口
- 9.2 基于环形队列的生产者消费者模型
- 10. 线程池
- 11. 线程安全的单例模式
- 11.1 什么是单例模式
- 11.2 什么是设计模式
- 11.3 单例模式的特点
- 12. STL,智能指针和线程安全
- 12.1 STL中的容器是否是线程安全的吗?
- 12.2 智能指针是否是线程安全的?
- 13. 其他常见的各种锁
前言
本章所讲的线程原理,仅仅是Linux下的线程。
1. 线程概念
1.1 什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“ 一个进程内部的控制序列 ”。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
课本:线程是比进程更加轻量级的一种执行流 / 线程是在进程内部执行的一种执行流。
我们:线程是CPU调度的基本单元 / 进程是承担系统资源的基本实体。
左边是我们之前学习的进程PCB,右边就是线程,什么意思呢?就是将一个进程的PCB内容,进行切割,将其中的数据分成若干份,分别由一个线程来维护,也就是说,线程是通过进程将数据分割成多份形成的,多个线程组成了一个进程,那么必然的,线程相较于进程,更加轻量级。
当第一个线程被CPU调度时,后面的线程其实只是参与了资源的分配,也就是哪一部分的数据是由你这个线程维护的,相当于仅仅只是声明,实际上并没有在物理内存中开辟具体的资源,因此在最开始初始化的时候,它并不会一下子将所有进程的数据都初始化,才开始进行调度,只初始化一个线程大小的数据就可以开始调度了。相较于初始化完整个进程再调度优秀了很多。
而进程都有可能有多个,那么线程只会比进程更多,进程都需要被维护起来,那么线程的维护也是必然的。OS如果真的支持线程,那么就必须管理线程,先描述再组织,TCB、Thread。但是一个进程的相关数据描述在PCB中已经存在,难度还要再重新创建一个结构体来重新再描述一遍吗?那岂不是很麻烦。
并不需要的,只需要对PCB进行复用就可以了,这就是Linux中描述线程的实现方案。对于之前学习的进程,我们的看法是,它的内部只有一个执行流的进程,现在的看法,是内核中有多个执行流的进程。
我们通过代码来看一看:
ps -aL指令是用来查看线程的。我们发现两个线程的pid是一样的,这也验证了我们之前所说的线程是由进程分割而来的。
CPU的调度其实不是根据pid来调度的,而是通过LWP来进行调度。也就是说,CPU调度的并不是进程,而是进程细化下的线程!!!
我们再来看一个代码:
我们发现我们新建的线程将gcnt进行减减操作,主线程中的gcnt也进行了减减,这就更近一步说明了线程是进程分化而来的,每一个线程都共享了进程的大部分数据。在前面的进程章节中,在父子进程中,如果子进程将一个全局变量修改,那么就会发生写时拷贝,并不会影响父进程中的变量。
1.2 线程比进程更加轻量化
创建线程很简单,只需要创建一个PCB就可以, 因为创建线程的前提一定是先有进程。它并不需要像进程一样还需要初始化很多数据才可以被调度,因此使用线程调度比进程更加快速。
还有另外一个重大原因,在CPU中处理寄存器,其实还有一块比较大的缓存cache,里面缓存的是你当前访问的代码的附近的代码,这是根据局部性原理,原理的意思是,当你访问当前代码时,你大概率会继续访问当前代码附近的代码,所有它会预加载,避免多次访问。cache缓存是以进程单位的,线程级的切换并不会影响cache的内容, 而如果进程间切换,就需要重新对数据进程缓存。
同时由于线程是共享进程的大部分代码的,所有线程在切换时,只需要更改少量寄存器中的数据,大部分是不需要更改的。
1.3 虚拟地址到物理地址的转化
物理内存的管理
在文件系统中,进行IO的基本单位是4KB,也就是磁盘向物理内存中写入读出数据时,都是以4KB大小为进本单位进行访存的。
物理内存有4GB,换算下来一共有1048576个页框,这么多的页框一定是需要被先描述再组织的。
页表
我们知道虚拟地址以16进制表示是全0到全F的,也就是有2的32次方个地址,而每一个虚拟地址映射到一个物理地址都需要至少实际字节的空间,那么有那么多的虚拟地址,如果都映射完,光是页表的大小就都有几十GB了,一点也不现实。
所以页表肯定不是像之前所画的那中样子,那么是什么样的呢?
这样我们只需要维护2^20次方的空间就可以了,这样页表的体积就大大减小了。而这个页表项仅仅只是找到了对应的页框(也就是该页框的起始地址),但是页框有4KB大小,如何才能找到我们具体的数据地址呢?
那么就用到上面的还未使用的12位了。找到了页框的起始地址,再以低12位充当偏移量,以偏移量加上起始地址就可以找到我们需要的数据了(2^ 12 = 2^ 10 * 2^ 2 = 1024 * 4 = 4KB)。所以通过低12位的偏移量再加上页框的起始地址,是一定能找到一个页框4KB大小中的任意一个地址的。
从上图不难看出,其实虚拟地址到物理地址的转化在CPU中就已经完成了。
所以线程的本质划分页表,划分进程地址空间。
1.4 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.5 线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
1.6 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
1.7 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率。
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
2. 进程 vs 线程
- 进程是资源分配的基本单位。
- 线程是调度的基本单位。
- 线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器(属于自己的硬件上下文)
栈(独立的栈)
errno
信号屏蔽字
调度优先级
进程的多个线程共享同一地址空间,因此Text Segment(代码段)、Data Segment(数据区)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
3. 线程控制
在Linux中,是不存在真正的线程的,只有轻量级进程的概念。所以Linux OS只会提高轻量级进程创建的系统调用,不会直接提供线程创建的接口。
3.1 线程创建
线程创建:
功能:创建一个新的线程
原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
参数
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
第一个参数是线程id,第二个是线程的状态,目前不考虑,写为nullptr即可,第三个参数是函数指针,用来说明新线程用来做什么工作,第四个参数就是函数指针的参数。
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。
- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID: pthread_t pthread_self(void);
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
3.2 线程退出
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数:
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
3.3 线程等待
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。
功能:等待线程结束。
原型
int pthread_join(pthread_t thread, void **value_ptr); 参数
thread:线程ID。
value_ptr:它指向一个指针,后者指向线程的返回值。
返回值:成功返回0;失败返回错误码。
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED — >((void*)-1)。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
3.4 分离线程
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
分离之后pthread_join函数的返回值是22,代表着这个线程是分离的。
3.5 线程取消
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
当一个线程一直在运行,可以通过这个函数在主线程中取消它,取消之后该线程的返回值是-1。如果一个线程先被分离,再被取消,那么它的返回值就是0。
4. 线程管理
在系统中是没有线程的概念的,只有轻量级进程的概念,所以我们上面使用的都不是系统直接提供的接口,而是原生线程库pthread提供的接口。
这也就意味这,线程的管理并不是由系统进行管理的,而是由线程库来管理的。也就是说在用户和系统之间,是需要通过线程库来当中间商的,
线程要有独立的属性,其中最重要的是上下文数据和各自独立的栈,上下文数据还好说,每一个线程都有自己的PCB,各自可以独立进行维护,但是在进程地址空间中,栈只有一个,而每一个线程又都要有自己独立的栈,这该怎么办呢?
在前文的动静态库中,我们有说过对于C语言中的FILE结构体,它内部是自己维护了一个缓存区的,是由C语言维护的,实际上也就是库维护的,也就是说库是有维护一段空间的能力的,而pthead库也是库,自然也有这个能力。
对于pthread库,它其实也是维护了一块地址空间,是从堆上申请而来的,也就是说pthead库是维护了一块堆上的空间。对于每个新线程的栈空间,都是经由这块对空间分割而来的。也就是说对于每个线程独立的栈空间,实际上是进程地址空间中的堆区的空间,而进程地址空间上的栈区,则交由主线程来使用。(其实也可以使用静态的空间)
如何理解pthread管理线程:
每个线程的 pthread_t (线程id)就是线程属性集合在库中的地址。
站在语言角度来理解pthread:
其底层就是进行了封装,如果是Linux系统,就封装我们上面所学的,而如果是windows系统,如果windows系统支持真线程的概念,那么就会封装windows系统中的接口。但是在语言层面是不变的,都是上面的代码,只不过在不同系统中,底层封装的东西不一样而已。
C++11内部的多线程,本质上就是对原生线程库的封装。
如何理解线程的局部存储:
当一个全局变量被一个线程修改时,由于众多线程是共享一个进程地址空间的,所以其他线程也是可以看到一个全局变量变化的。
我们加一个选项:
我们发现主线程所看到的全局变量并没有发生变化。说明当我们加上__thread,每一个线程就会私有化一份这个变量,这个就是线程的局部存储, 但是__thread只针对内置类型。
4.1 使用原生线程库进行简单的封装
5. 线程的互斥
5.1 进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
5.2 互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
5.3 线程不互斥会发生什么样的错误
我们这里举个例子,以抢票为例,我们可以创建多个线程进行抢票:
我们再来看结果:
我们发现抢票竟然出现了负数,这显然是不合理的,这是因为每个线程在访问这个函数,那么就会造成重入现象。就比如线程1走到了printf函数的位置,此时线程1还没有运行ticket- - 的操作,但是此时ticket的值已经为1,但是此时发生了线程切换,线程2、3、4都再次进入了这个函数,但是此时票数其实已经为1了,相当于票其实已经卖光了,但是线程2、3、4依旧会继续被调度执行,造成票数变成负数的情况。
数据再内存中,本质上是被线程共享的,但是如果数据被读入到寄存器中,本质上就变成了线程上下文,属于线程私有的数据了!!!
- - 操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中。
- update : 更新寄存器里面的值,执行-1操作。
- store :将新值,从寄存器写回共享变量ticket的内存地址。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
5.4 互斥量(加锁)
创建锁:
加锁、解锁(使用PTHREAD_MUTEX_INITIALIZER初始化):
示例:
ticket是全局变量,我们上面创建的锁也是全局变量,为什么就可以进行保护了呢???
申请锁本身是安全的,它是原子的,在后面会再次进行细说。
原子到底是什么意思呢?拿上面的 - - ticket 为例,上面说这个操作实际上是在汇编层面是分三步走的,在第一步完成后,有可能发生线程切换,所以 - - 操作并不是原子的,因为它中间会被打断。而单个的汇编操作就是原子的,它只有成功或者不做两种状态。个人理解:一个操作会被划分为多个步骤来完成,那么它可能就不是原子的,因为在执行期间可能会被打断,而如果一个操作无法被继续划分更细小的步骤,那么它就是原子的,只有成功和不做两种状态。
根据互斥的定义,任何时刻,只允许一个线程申请锁成功。多个线程申请锁失败,失败的进程会在mutex上进行阻塞,本质就是等待!
这个函数就是用来尝试申请锁,如果成功就会返回0,如果失败就会返回错误信息。
一个线程在临界区中访问临界资源的时候,可不可能发生线程切换???答案是可能的,是完全允许的。
加锁的本质:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
//都是汇编语言,都是原子的噢
lock:movb $0, %alxchgb %al, mutexif(al寄存器的内容>0){return 0;}else{挂起等待;}goto lock;unlock:movb $1, mutex唤醒等待Metux的线程;return 0;
我们知道寄存器是只有一套的,但是寄存器的内容是有多套的,它被保存在PCB中。同时众多线程是会共享大部分数据的。
当交换之后,al寄存器中的值就变为了1,内存中的值变为0(注意:多个线程是共享内存数据的),然后进行判断,如果al寄存器中的值为1,就允许加锁,否则就挂起等待。
解锁的过程与其相似。
关于加锁的原则:谁加锁谁解锁。
5.5 可重入和线程安全
5.5.1 概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。(函数层面)
5.5.2 常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数
5.5.3 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
5.5.4 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
5.5.5 常见可重入的情况
- 不使用全局变量或静态变量。
- 不使用用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
5.5.6 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
5.5.7 可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
6. 死锁
6.1 概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
6.2 死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
6.3 避免死锁
- 破坏死锁的四个必要条件。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
6.4 避免死锁的算法
- 死锁检测算法(了解)
- 银行家算法(了解)
6.5 小问题
多个锁可能会出现死锁,那么一个锁会出现死锁问题吗???
7. 线程同步
7.1 条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
7.2 同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
7.3 条件变量函数
初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁:
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
7.4 为什么 pthread_cond_wait 需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
- wait会将等待的线程进行解锁,唤醒时再加锁。
8. 生产者消费者模型
在其中生产者和生产者之前是竞争关系,也就是互斥的。因为超市的空间是有限的,如果多个生产者放入商品,情况就类似于我们上面所讲的抢票,都在争先恐后的放入商品,很容易造成商品溢出的情况。
消费者和消费者之间也就是互斥的,比如如果某一个商品只剩一个,而有多个消费者需要,那么该给谁呢?所以是互斥关系。
生产者和消费者之间既有互斥,又有同步。 互斥是因为,我们以hello world为例,生产线程(生产者)就是向内存空间(超市)中写入hello world,消费线程(消费者)从内存空间(超市)中读hello world,但是如果此时生产线程只写了hello,消费线程就开始读取,就会只读取hello,而不是需要的hello world,就会出错,所以需要互斥。 同步是因为,如果一个商品目前还没有生产出来,是缺货的,但是有许多消费者来拿这个商品,由于缺货,消费者每次都是无功而返的,而消费者每次进入超市拿商品时,都是需要加锁的。由于没货,一个个的消费线程每次来都会无功而返,但是每次都要加锁。而此时如果这个消费线程的优先级很高,每次这个线程都优先被调度,进行加锁然后无功而返,由于前面那个线程优先级很高,其他线程就会一直申请不到锁资源,那么就会产生饥饿问题。所以就需要生产者先生成商品,消费者再来消费。(还有一种理解同步的方式:还是消费线程优先级非常高,疯狂的在内存空间种访问有没有这个商品,访问就会加锁,而由于加锁了,并且消费线程优先级还比生成线程高,那么生产线程就一直无法向内存空间种放入商品,进而就会产生饥饿问题,所以需要同步)
记忆方式:321原则:
3:3种关系
2:2个角色
1:一个交易场所,内存空间
8.1 为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
8.2 生产者消费者模型优点
- 解耦
- 支持并发
- 支持忙闲不均
生产者消费者之间有互斥,也有同步,而同步不是会将效率降低吗?为什么会说它会加快效率。我们不能只从局部来看,如果不是生产者消费者模型,就是平时的代码,是不是只有生产了一个数据,才能处理,而只有处理完了,才能继续生产。
但是如果在生产者消费者模型中,生产者和消费者之间是互不干涉的,你在生产的同时,我就可以进行消费,我还没有消费完,生产者一样可以继续生产(内存空间充足的情况下),这就是它所高效的地方。(还可以更进一步加快,就是基于环形队列来实现,原理就是由于临界资源并不是一个整体,是可以支持多个消费线程同时访问的,相较于下面的阻塞队列,它一次只允许一个消费线程访问临界资源,因为它是将一个队列当成了一个整体)
8.3 基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
9. POSIX信号量
- 信号量本质是一把计数器。
- 申请信号的本质就是预定资源。
- PV操作是原子的。
上面我们写的是将公共资源当一个整体使用,因此同一时间只允许一个线程访问,但是如果公共资源是多份呢?也就是不将公共资源当作一个整体,比如有一个数据,开辟了7块空间,每一个空间都是临界资源的不同区域。
此时最多就支持7个线程在同一时间进行访问,但是最多也只能支持7个线程,怎么避免发生7个以上的线程同时访问呢?那就是信号量,每次访问临界资源都要先申请信号量,再访问临界资源的特定位置,再释放信号量。
也就是说,只要信号量申请成功了,那么临界资源中一定就会有你的一份,并不需要再次判断内存空间中还有没有资源(一定会有的,不然信号量就不可能申请成功)。
9.1 认识接口
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
初始化信号量:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量:
int sem_destroy(sem_t *sem);
等待信号量:
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量:
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
9.2 基于环形队列的生产者消费者模型
10. 线程池
11. 线程安全的单例模式
11.1 什么是单例模式
单例模式是一种 “经典的,常用的,常考的” 设计模式。
11.2 什么是设计模式
IT行业这么火,涌入的人很多。俗话说林子大了啥鸟都有,大佬和菜鸡们两极分化的越来越严重,为了让菜鸡们不太拖大佬的后腿,于是大佬们针一些经典的常见的场景,给定了一些对应的解决方案,这个就是设计模式。
11.3 单例模式的特点
某些类,只应该具有一个对象(实例),就称之为单例。
例如一个男人只能有一个媳妇。在很多服务器开发场景中,经常需要让服务器加载很多的数据 (上百G) 到内存中,此时往往要用一个单例的类来管理这些数据。
对于单例模式,在我写的另一篇C++设计特殊类中有更加详细的介绍,此处就不进行过多解释了。
12. STL,智能指针和线程安全
12.1 STL中的容器是否是线程安全的吗?
不是,原因是, STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
12.2 智能指针是否是线程安全的?
对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。但是它指向的对象可能会存在线程安全问题。
对于shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效,原子的操作引用计数。
13. 其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁?