文章目录
1 Linux线程概念
1.1 什么是线程
- 在一个进程中的一个执行流叫做线程,即线程是“一个进程内部的控制序列”
- 所有进程至少都有一个线程(执行流)
- 线程在进程内部运行,本质是在进程地址空间中运行
- 在Linux系统中,CPU看到的task_struct要比真正的PCB更加轻量化
- 进程将资源合理分配给每个执行流,就形成了线程执行流
- 站在内核的角度,进程是承担分配系统资源的基本实体。
![[Pasted image 20220209214148.png]]
简单介绍页表映射
在32位平台下,有
2
32
2^{32}
232地址,假设一对映射需要10字节空间的话,那么
2
32
2^{32}
232对映射关系需要
2
32
∗
10
2^{32}*10
232∗10字节,也就是40GB,这实在是太大了,所以系统不是这个设计的。
在32位平台下采用的时候页目录+二级页表的方式。
一个地址有32个比特位,首先根据前10个比特位在有
2
10
2^{10}
210对映射关系的页目录中找到映射地址,然后根据这个地址找到对应的二级页表,根据32位地址中的次10个比特位在有
2
10
2^{10}
210对映射关系的二级页表中找到映射地址。最后根据这个地址在物理内存中到一个页框的首地址(物理内存的基本单位是页框,大小为
2
12
2^{12}
212)然后根据32位地址中的最后12个比特位在这个页框中找到物理内存的偏移位置,这样就可以找到对应的位置。
其中页目录和二级页表所有的映射关系都映射起来最多只有
2
20
2^{20}
220,也就是1MB。主要是由于物理内存页框的特殊特技可以省去使用
2
20
2^{20}
220个有
2
12
2^{12}
212对映射关系的页表了。类似的,64位下使用的是多级页表来进行相似的映射。
而且Linux采用软件页表+硬件MMU(memory manage unit,内存管理单元)来完成虚拟地址到物理地址的映射的。
缺页中断
如果访问期间,目标资源不在内存,则会出发缺页中断,进行内存分配,页表创建,建立映射关系。
比如说在
malloc
的时候,其实系统根本没有给进程分配物理内存,也没有建立页表映射,只不过是在虚拟内存中分配了一块空间而已,只有当用户使用这块空间的时候,才会在物理内存中开辟空间,建立页表中的映射关系。
站在CPU的角度,能否识别task_struct是进程还是线程?
不能,CPU只关心独立的执行流,也就是进程中的线程。
Linux下不存在真正意义上的多线程
当线程足够多的时候,操作系统就需要使用特定的数据结构来管理线程。但是Linux下并没有这样的结构。所以在Linux下的线程是由进程模拟出来的。
在CPU调度的时候,看到的都是
struct thread_info
这个结构体,结构体中包含了一个变量
struct task_struct
。
所以
task_struct
要比进程控制块包含的内容要少。
在Linux中的所有执行流,都叫做“轻量级进程”。
如何使用线程?
因为Linux中没有真正意义上的线程,所以Linux中也就没有真正意义上的线程相关的系统调用。但是提供了创建轻量级进程的接口。
#include<sys/types.h>#include<unistd.h>
pid_t vfork(void);
- 作用 - 创建一个子进程,但是父子进程共享进程地址空间
基于轻量级进程的系统调用,有原生线程库在用户层模拟出一套线程接口。这个原生线程库叫做
pthread
库。
1.2 线程优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 线程之间的切换需要操作系统的做的工作少很多,要比切换一个进程简单
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束时,可以执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现 - 计算密集型:执行流的大部分任务主要以计算为主
- I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作 - IO密集型:执行流的大部分任务主要是以IO为主
1.3 线程的缺点
- 性能损失
- 健壮性降低 - 如果一个线程访问了全局资源会影响其他资源,导致线程不安全。一个进程中的任何一个线程崩溃了,其他所有的线程全部崩溃。
- 缺乏访问控制 - 进程是访问控制的基本单位,线程对函数的访问缺乏控制
- 编程难度提高 - 编写和调试多线程程序比单线程程序困难
1.4 线程异常
- 单个线程如果出现除零,访问野指针,越界等问题导致线程线程崩溃的话,进程也会随着崩溃
- 线程是进程的执行分支,如果线程出现异常,触发了信号机制的话,就会终止进程,回收进程中的所有资源
1.5 Linux进程和线程
- 进程是资源分配的基本实体
- 线程是调度的基本实体
- 线程共享进程数据,但是也拥有自己的一部分数据 - 线程ID- 一组寄存器(线程可以被调度)- 栈(线程会产生自己的数据)- errno- 信号屏蔽字- 调度优先级
进程的多个线程共享
多线程共享如下:
- 同一个地址空间 - 因此代码区和数据区都是共享的。如果定义一个函数,各线程都可以调用;如果有一个全局变量,各线程也都可以访问。此外
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户id和组id
常见的线程和进程关系:
![[Pasted image 20220210094404.png]]
2 Linux线程控制
2.1 POSIX线程库
上文说过,Linux下没有真正意义上的线程库,所以Linux使用引进的第三方库
pthread
库来控制线程。
POSIX
线程库与线程有关的函数构成一个完整的系列,对绝大多数的函数都是以pthread_
打头的- 需要引进头文件
<pthreah.h>
- 链接这些线程函数库时,要是用编译器命令的
-lpthread
选项包含第三方线程库
2.2 线程创建
#include<pthread.h>intpthread_create(pthread_t* thread,const phtread_attr_t *attr,void*(start_routine)(void*),void* arg);
- 作用 - 创建一个新的线程,可以指定新的线程需要执行的任务
- 参数 -
thread
:输出型参数,可以返回线程的线程号-attr
:设置线程的属性-start_routine
:需要指定线程需要执行的任务-arg
:给线程执行的任务传入的参数 - 返回值 - 成功放回0,失败返回错误码
错误检查
- 传统的函数都是成功返回0,失败返回-1,并且对全局errno赋值表示错误
pthread
函数出错不会设置全局的errno(大部分POSIX函数都是这样),而是将错误码直接返回- 每个线程也都有自己的errno来支持其他需要支持设置errno的函数,但是读取返回值要比读取errno的开销小
示例:
**创建一个线程,每隔一秒打印自己的
pid
和
ppid
,同时主线程也每隔一秒打印自己的
pid
和
ppid
。**
注意:在编译文件的时候,需要
gcc -lpthread
带上链接
pthread
库的选项
#include<stdio.h>#include<unistd.h>#include<pthread.h>void*Routine(void* arg){char* msg =(char*)arg;while(1){printf("%s: pid: %d ppid: %d\n",msg,getpid(),getppid());sleep(1);}returnNULL;}intmain(){
pthread_t tid;pthread_create(&tid,NULL, Routine,(void*)"thread 1");while(1){printf("main thread: pid: %d ppid: %d\n",getpid(),getppid());sleep(1);}return0;}
由实验可知:
- ![[Pasted image 20220210101443.png]] 通过试验可以看出,创建的新线程和主线程的
pid
和ppid
都是相同的,因为可以确定线程是在进程中运行的。 - 或者使用
ps
命令查看线程,因为查看的是线程,所以需要带上-L
选项。1. 执行ps -aL | head -1 && ps -aL | grep 文件名
![[Pasted image 20220210101809.png]]2. 其中LWP
(light weight process)是轻量级进程的ID。可知其实操作系统调度的时候,访问的是LWP
而不是PID
3. Linux中,应用层的线程与内核的LWP是一一对应的,可以通过pthread
库来操作应用层的线程,而LWP会随之产生
2.3 线程标识
#include<pthread.h>
pthread_t pthread_self(void);
- 作用 - 返回原生线程库提供的用户级线程ID,与
pthread_create
第一个输出型参数的值是一样的
示例:
**创建3个线程,分别在
main
执行流中和线程执行任务的执行流中打印线程的ID**
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<pthread.h>void*Routine(void* arg){char* msg =(char*)arg;// 使用pthread_self()表示线程IDprintf("%s, ID:%x\n", msg,pthread_self());returnNULL;}intmain(){
pthread_t tid[3];for(int i =0; i <3; i ++){// 将不同的进程写入buffer中char* buff =(char*)malloc(24);sprintf(buff,"thread %d", i);// 创建进程pthread_create(&tid[i],NULL, Routine, buff);// 使用tid表示线程IDprintf("%s, ID: %x\n", tid[i]);}// 让主线程不要退出while(1){sleep(1);}return0;}
注意这里的
pthread_self()
和输出型参数
tid
都是用户级别原生线程库提供的表示线程的ID,而使用
ps -L
查看的
LWP
是内核级别表示线程的ID。
用户级线程ID和内核级线程ID的联系是什么?即
thread_t
和
LWP
的联系是什么?
用户级线程ID就是进程地址空间中的一个地址。
在Linux中有很多的进程,所以也就有很多的线程。而Linux中没有真正意义上的线程而是使用进程模拟出来,所以Linux系统内也就没有专门的数据结构来组织和管理这么多的线程。在Linux中操作系统只需要对LWP内核执行流进行管理。
**而上面我们使用
pthread
库中的函数来操作的线程是
pthread
库中模拟出来的线程并且
pthread
库自己去管理并组织的线程。**
pthread
库是一个第三方动态库,也就是这个库本身就是一个文件。当程序运行的时候,文件加载到内存然后通过页表映射到虚拟地址的堆栈中间的共享区中。当动态库加载到共享区中并运行的时候,动态库中有一套完整地管理和组织线程的逻辑。
![[Pasted image 20220210152118.png]]
因此其实我们操作的线程是第三库模拟出的线程,我们操作也是模拟线程的从生到死。第三库库中的线程最后只需要将数据和代码交给操作系统管理的LWP执行流即可。
而因为动态库是在虚拟地址用户空间的,调度线程不需要进入内核区,只需要在用户区完成,所以线程的ID叫做用户级线程ID,而这个ID其实就是组织线程结构体的起始地址的首地址而已。
所以说用户级线程ID就是进程地址空间中的一个地址(虚拟地址)。
线程终止
线程终止有三种方法:
- 在线程执行任务的
routine
中return
表示线程退出 1. 在main
函数中return
表示整个进程退出2. 使用exit()
函数在任何地方调用表示整个进程退出 pthread_exit
#include<pthread.h>voidpthread_exit(void* retval);
- 作用 - 退出一个线程,并且可以执行线程的退出码,作用和
return
效果一样
需要注意:
pthread_exit
或者
return
返回的指针指向的内存单元一定要是全局的或者是
malloc
出来的,而不能是函数栈上分配的,因为当其他线程得到这个返回指针时,线程函数已经退出了。
pthread_cancel
#include<pthread.h>intpthread_cancel(pthread_t thread);
- 作用 - 指定取消一个线程号为
thread
的线程,相当于终止一个线程。一般用在main
线程取消指定的线程,导致线程执行的过程中突然被取消。如果取消成功,线程的退出码默认就是-1, 取消失败退出码默认是0 - 参数 - 需要被终止的线程的线程号
- 返回值 - 取消成功返回0, 失败返回非0
示例:
创建3个线程,让主线程去主动取消1号2号线程,最后在
main
中回收这3个线程,
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<pthread.h>void*Routine(void*arg){printf("thread has created\n");// 线程退出码为666return(void*)666;}intmain(){
pthread_t tid[3];for(int i =0; i <3; i ++){// 创建进程pthread_create(&tid[i],NULL, Routine,NULL);}// 取消线程pthread_cancel(tid[1]);pthread_cancel(tid[2]);for(int i =0; i <3; i ++){void* ret =NULL;pthread_join(tid[i],&ret);printf("线程%d[%x]退出, exit code:%d\n", i, tid[i],(int)ret);}return0;}
结果:
1号2号线程的退出码是-1,表示主线程主动取消1号2号线程成功。
线程分离
一般情况下,线程必须被等待使用
pthread_join
调用。但是**如果不关系线程的返回值,
join
是一种负担,就可以将线程分离,这样当线程退出时自动地释放资源。分离和等待式冲突的,一个线程不能既被
join
又是
detach
的**。
#include<pthread.h>intpthread_detach(pthread_t thread);
- 作用 - 指定分离一个线程,之后就不用回收这个线程了。一般用于:如果主线程不关心新线程的执行状态(不需要知道退出码)就可以分离线程,这样主线程就可以不用阻塞式地等待回收线程资源了,然后执行主线程自己的任务。一般情况下,主线程要比新线程存在时间长,所以可以让新线程自己分离自己。
示例:
创建3个线程,然后让线程自己分离主线程
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<pthread.h>void*Routine(void*arg){pthead_detach(pthread_self());printf("thread has created\n");}intmain(){
pthread_t tid[3];for(int i =0; i <3; i ++){// 创建进程pthread_create(&tid[i],NULL, Routine,NULL);}return0;}
线程等待
为什么线程要等待?
- 已经退出的线程,其空间还没有释放,还在进程地址空间中
- 新创建的线程不会复用退出线程的地址空间
#include<pthread.h>intpthread_join(pthread_t thread,void**retval);
- 作用 - 默认是阻塞式地等待回收线程资源,即调用该函数的线程挂起等待,直到id为thread的线程终止
- 参数 -
pthread
:要等待线程的ID-retval
:线程退出时的退出码,因为线程执行的任务函数的返回值是void*
,而retval
是输出型参数,所以需要使用void**
才可以将参数输出出来。不同的终止方法得到的retval
是不同的,总结如下- 如果通过return
返回,retval
就是return
的返回值- 如果通过自己调用pthread_exit
返回,则retval
为pthread_exit
函数的参数- 如果通过别人线程调用pthread_cancel
返回,则返回的是PTHREAD_CANCELED
(-1)- 如果对退出码不感兴趣,可以设置为NULL
示例:
**创建3个线程,在
main
中回收这3个线程。**
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<pthread.h>void*Routine(void*arg){printf("thread has created\n");// 线程退出码为666return(void*)666;}intmain(){
pthread_t tid[3];for(int i =0; i <3; i ++){// 创建进程pthread_create(&tid[i],NULL, Routine,NULL);}for(int i =0; i <3; i ++){void* ret =NULL;pthread_join(tid[i],&ret);printf("线程%d[%x]退出, exit code:%d\n", i, tid[i],(int)ret);}return0;}
结果:
线程依次退出,并且退出码都是666。
为什么线程中只有退出码,而没有信号?
retval
是被等待线程的退出码,用来表示代码运行后结果是否正确。如果线程在运行的过程中出现异常崩溃了,那么整个进程都崩溃了,所以进程就会收到信号,而线程就不需要知道信号是怎样的了。
3 Linux线程互斥
互斥相关概念
- 临界资源:多线程执行流共享的资源
- 临界区:每个线程内部,访问临界资源的代码就叫做临界区
- 互斥:任何时刻,都只有一个执行流进入临界区,访问临界资源。通常是对临界资源起到保护作用
- 原子性:不会被任何的调度机制打断的操作
互斥量
引例:
#include<stdio.h>#include<unistd.h>#include<pthread.h>int num =4000;void*Routine(void* arg){int name =(int)arg;while(1){if(num >0){printf("thread%d get a num, num:%d\n", name, num --);}else{break;}}pthread_exit((void*)0);}intmain(){
pthread_t tid[5];for(int i =0; i <5; i ++){pthread_create(&tid[i],NULL, Routine,(void*)i);}for(int i =0; i <5; i ++){pthread_join(tid[i],NULL);}return0;}
运行的结果:
出现
num<0
的结果
其中临界资源num,在num–的时候,这个操作是原子的吗?
A:不是的。
num --
在计算机看来会分为三步,第一步将内存的中的num放到cpu中;第二步cpu进行对num做加法;第三步将cpu中计算的结果写回内存中。其中的每一步操作可能都会打断。
如果在一个线程在执行第一步操作,然后就被中断。紧接着执行第二个线程,它进行了完整的三步过程,将
num
修改成了
3990
(中间线程2多次执行减法),这个时候线程1被切换回来,将
num
修改成了
3999
。这样就因为对临界资源的访问不是原子的,所以导致的数据不一致的问题,即两个线程看到的数据因为有“时差”所以不一样。
同样的,
if (nums > 0)
也不是一个原子操作。
如果想要解决这个问题,就需要做到当一个线程在执行临界区的代码时,不允许其他线程进入临界区。这个问题可以通过加锁的方式解决,Linux中提供的锁就叫做互斥量
![[Pasted image 20220210164546.png]]
互斥量相关的接口
#include<pthread.h>// 初始化锁// 方式1intpthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t* restrict attr);// 方式2
pthread_mutex_t mutex = PHTREAD_MUTEX_INITALIZER;
#include<pthread.h>// 销毁锁intpthread_mutex_destroy(pthread_mutex_t* mutex);
#include<pthread.h>// 加锁intpthread_mutex_lock(pthread_mutex_t* mutex);// 尝试加锁intpthread_mutex_trylock(pthread_mutext_t* mutext);// 解锁intpthread_mutex_unlock(pthread_mutex_t* mutex);
加锁是一个有损于性能的操作,所以在设计加锁的时候尽量减少加锁带来的性能开销。
示例:
**创建5个线程,然后一起对
num
进行
--
操作,其中需要使用互斥量来保证临界资源的安全性。**
#include<stdio.h>#include<unistd.h>#include<pthread.h>int num =4000;
pthread_mutex_t lock;void*Routine(void* arg){int name =(int)arg;while(1){pthread_mutex_lock(&lock);if(num >0){printf("thread %d get a num, num:%d\n", name, num --);pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);break;}}pthread_exit((void*)0);}intmain(){pthread_mutex_init(&lock,NULL);
pthread_t tid[5];for(int i =0; i <5; i ++){pthread_create(&tid[i],NULL, Routine,(void*)i);}for(int i =0; i <5; i ++){pthread_join(tid[i],NULL);}pthread_mutex_destroy(&lock);}
说明:
- 进行临界资源的保护,是所有执行流都必须要遵守的标准
- 加锁之后,对于线程来说临界区中的所有操作都是原子的。因为在锁没有申请的时候,所有的线程都去竞争这把锁。当锁被其中一个线程申请过后,其他竞争失败的线程就会自动挂起,直到锁被释放,然后再次竞争申请锁资源。所以被锁锁住的区域就对于不可见了,也就变成原子操作了。
num--
其实还是非原子操作,同时拿到锁资源的线程也有可能被切换走,但是此时由于其他线程没有锁资源,所以即便该线程被切换走了,其他线程也不能对临界资源造成影响,所以整体来说锁住的区域还是原子的。- 锁是用来保护临界资源的,同时所有线程都共享锁资源,所以锁本身也是临界资源。但是申请锁的过程是原子的,所以申请锁的过程是安全的。
互斥量实现原理
申请锁的过程是原子的,那么申请锁是如何实现的呢?
为了实现互斥锁操作,可以使用
swap
或
exchange
指令,其作用是将寄存器和内存的数据交换。一个处理器上的指令执行时另一个处理器的指令只能等待总线周期,所以一条交换指令是一定不会通知执行的。
下面是利用交换指令的
lock
和
unlock
的伪代码
// mutex为0表示没有锁资源// mutex为1表示有锁资源
lock:
movb 0, 寄存器
xchgb 寄存器,mutex
if(寄存器的内存 >0){return0;}else{
挂起等待
}
unlock:
movb 1, mutex
唤醒等待mutex的线程
return0
![[Pasted image 20220210180354.png]]
因为是通过交换这一条命令完成加锁的,所以
mutex
和寄存器中只有一个1。所以只有一个线程的上下文中寄存器的变量是1,其余的都是0。这样就可以保证只有一个线程可以竞争到锁资源。
就算是中间过程中拥有锁的线程被切换出去了,也不要紧,因为锁资源已经被拿走了,其他的线程拿不到。如图:
![[Pasted image 20220210182728.png]]
当释放锁资源的时候,因为只有一个线程拿着锁,所以可以直接将
mutex
赋值为1,然后唤醒其他的线程继续竞争锁资源。
简单说就是:因为只有一把锁,所以通过一个原子的交换指令只能将这把锁给其中的一个线程
可重入和线程安全
概念
- 线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。即一个线程的运行不会影响另一个线程。 - 对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程不安全的问题
- 重入:同一个函数被不同的执行流进入,称之为“重入”。一个函数在重入的情况下,运行结果不会出现任何不同或者问题,则该函数成为可重入函数,否则,就是不可重入函数。
常见场景
常见的线程不安全的情况
- 不保护共享变量的函数 - 例如:函数中有没有锁保护的全局变量
- 函数状态随着被调用,状态发生变化的函数 - 例如:函数中有一个静态变量,函数每一次调用都会累加变量变化的效果(变化可以是一直加,一直减等等)
- 返回静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限
- 类或者函数对于线程都是原子操作
- 多个线程之间的切换不会导致函数的执行产生二义性
常见的不可重入的情况
- 调用了
malloc/free
函数 - 因为malloc
函数是用全局链表来管理的 - 调用了IO库函数
- 使用了静态的数据结构
常见的可重入的情况
- 不使用全局变量或者静态变量
- 不使用
malloc
开辟空间 - 不调用不可重入函数
- 不返回静态或全局数据
- 只使用局部变量或者对全局数据进行拷贝
可重入与线程安全的联系与区别
可重入与线程安全的联系
- 函数可重入,函数一定线程安全
- 函数是不可重入,函数可能有线程安全
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,但是可重入函数一定是线程安全的
- 如果对临界资源的访问加上锁,则这个函数是线程安全的。但是如果这个重入函数的锁一直不释放则会产生死锁问题,因此是不可重入的
- 单执行流单线程下也有可能出现重入,例如信号捕捉
常见锁概念
死锁
死锁是指进程中因相互申请被其他进程所占用的资源而处于的一种永久等待资源的状态。
当我们看到进程卡住不动称为进程被阻塞了。站在操作系统的角度呢?
当进程在运行的时候,其实是一个一个轻量级进程在运行等待队列中排队等待着CPU资源去运行当前轻量级进程。假如说这个时候运行一个线程,该线程先要去竞争锁资源,但是锁资源已经被占了,那么操作系统就是改变当前线程中的运行状态,然后将该线程放入锁资源等待队列中。当锁资源被释放的时候,操作系统再将这个线程放入运行等待队列中继续排队,当可以运行该线程的时候,才会让该线程去访问锁资源。
所以当一个线程去申请一个不可能被释放的锁资源的时候,那么这个线程就会一直被放在这个锁的资源等待队列中,永远不可能被唤醒了。这就是死锁。
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,不释放已经获得资源
- 不剥夺条件:一个执行流获得了一个资源,在未使用完之前,不能被其他执行流强行剥夺
- 循环等待条件:若干执行流之间形成一种循环等待资源的状态
避免死锁的建议
- 破坏死锁的四个必要条件的 - 破坏互斥条件:建议少申请锁资源- 破坏请求与保持条件:建议一个执行流申请锁的时候,将自己有的锁资源释放掉,避免加锁未释放的场景- 破坏不剥夺条件:可以让一个执行流强行剥夺锁资源- 破坏循环等待条件:多执行流申请多个锁的时候,建议按照顺序申请
- 资源一次性分配
避免死锁算法
- 死锁检测算法
- 银行家算法
Linux线程同步
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够数据一定顺序地访问临界资源,从而有效避免其他线程的饥饿问题,就是同步
- 竞态条件:因为时序问题,而导致程序异常
为什么要存在同步?
个别线程竞争力很强,每一次都能申请到锁资源,但是却不执行特定任务只是单纯地申请锁和释放锁,导致了其他线程的很长时间竞争不到锁,而引起了饥饿问题。这种行为会使得程序运行的效率很低。
所以同步的意义就是:在访问临界资源数据安全的情况下,让多执行流访问资源具有一定的顺序性,从而保证了高效地访问资源。
条件变量
需要实现同步,就需要使用到条件变量。
条件变量是某种临界资源是否就绪的数据化描述。
因为条件变量不能保护数据资源,所以通常需要和互斥锁配合使用。
条件变量相关接口
1. 初始化条件变量
intpthread_cond_init(pthread_cond_t* restrict cond,const pthread_condattr_t* restrict attr);
2. 销毁条件变量
intpthread_cond_destroy(pthread_cond_t* cond);
3. 让线程在条件变量资源等待队列中等待
intpthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);
为什么
pthread_cond_wait
需要互斥量?
当一个线程调用
pthread_cond_wait
的时候,往往该线程自己正在持有锁(条件变量就是用来判断锁资源是否存在的),这个时候如果不将自己拥有的锁释放出去,而自己直接挂起等待,那么这个锁资源就永远不可能被其他线程获得了,也就形成了死锁问题。
所以在调用该函数的时候,需要传入锁资源。做到让线程在特定的条件变量下等待的时候,同时释放自己获得锁资源。当该线程被唤醒的时候,又会自动地获得该锁资源。
4. 唤醒一个/多个在等待队列中的线程
// 唤起多个线程intpthread_cond_broadcast(pthread_cond_t* cond);// 唤起一个线程intpthread_cond_signal(pthread_cond_t* cond);
示例:
创建三个线程,利用条件变量使得主线程可以控制这三个线程。控制规则是在主线程中每按一次回车,就可以唤醒一个线程,并且让其运行。
具体操作:
操控三个线程一个一个执行,这个就不能光是用互斥锁来保护数据资源了,还必须要使用条件变量来具体控制。
一开始让所有的线程在条件变量资源的等待队列中等待资源就绪,然后被唤醒。而主线程需要控制这三个线程,所以主线程需要使用条件变量唤醒函数去唤醒每一个线程。
#include<stdio.h>#include<pthread.h>
pthread_mutex_t lock;
pthread_cond_t cond;void*Run(void* arg){char* name =(char*)arg;printf("%s is running\n", name);while(1){pthread_cond_wait(&cond,&lock);printf("%s is waked\n", name);}pthread_exit((void*)1);}intmain(){
pthread_t t1, t2, t3;pthread_create(&t1,NULL, Run,(void*)"thread 1");pthread_create(&t2,NULL, Run,(void*)"thread 2");pthread_create(&t3,NULL, Run,(void*)"thread 3");while(1){getchar();// 唤醒单个线程pthread_cond_signal(&cond);// 唤醒所有的等待的线程// pthread_cond_broadcast(&cond);}pthread_join(t1,NULL);pthread_join(t2,NULL);pthread_join(t3,NULL);return0;}
运行结果:
![[Pasted image 20220211104952.png]]
可以发现在唤醒线程的时候,是按照一定的顺序一个一个唤醒的。这也就可以印证了使用条件可以让线程在资源等待队列中排队等待,而不是哪个线程的优先级高,哪个线程就运行,而是每一个线程竞争锁资源都需要排队。
可以简单的认为条件变量中有一个数据是否就绪的变量和一个等待资源的队列。
生产者消费者模型
生产者消费者模型是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者之间不直接通讯,而是通过阻塞队列来进行通讯。所以生产者生产完数据不用等待消费者来消费,直接给阻塞队列即可,而消费者不用找生产者要数据而是在阻塞队列中取。其中阻塞队列就是一个缓冲区,平衡了生产者和消费者的处理能力,即通过使用一个阻塞队列就对生产者和消费者进行了解耦。
记忆方法321
- 三种关系 1. 消费者和消费者有竞争互斥关系 1. 消费者在消费的时候需要竞争,然后一个一个进行消费阻塞队列中的数据,所以线程之间需要保持互斥关系2. 生产者和生产者有竞争互斥关系 1. 生产者在生产的时候也需要竞争,然后一个一个往阻塞队列中生产数据,所以线程之间也需要保持互斥关系3. 生产者和消费者有竞争互斥关系同时也有同步协调关系 1. 生产者和消费者在往阻塞队列中生产数据和消费数据的时候,不能同时进行,需要竞争对阻塞队列的操作权力,所以需要保持互斥关系。同时,两者之间需要协调对阻塞队列的使用权,阻塞队列被生产者生产的数据填满的时候需要主动通知消费者来消费;当消费者消费完阻塞队列中的数据的时候,需要通知生产者往阻塞队列中生产数据
- 两种角色 1. 生产者 1. 由线程或者进程承担该角色2. 消费者 1. 由线程或者进程承担该角色
- 一个交易场所 1. 内存中的一段缓冲区,可以有不同的组织形式
在编码的时候,需要时刻维护以上的规则。
为什么要有生产者消费者模型?
可以对代码进行解耦。生产者就只是生产数据,而消费者就只是处理数据的。两者通过一个中间的缓冲区,可以几乎并发执行自己的任务。而不是有很强的关系,导致两者相互牵制,降低执行的效率。
生产者消费者模型优先
- 解耦
- 支持并发
- 支持忙闲不均
![[Pasted image 20220211115048.png]]
基于阻塞队列的生产者消费者模型示例
^BlockQueue
**任务:使用
queue
当做一段缓冲区,创建两个线程分别充当生产者和消费者。生产者在不断向缓冲区中添加一个随机数,消费者不断从缓冲区中获得一个数字。**
// "BlockQueue.hpp"#pragmaonce#include<iostream>#include<cstdlib>#include<ctime>#include<queue>#include<unistd.h>#include<pthread.h>#defineCAP10template<classT>classBlockQueue{public:BlockQueue(int _cap = CAP):cap(_cap){pthread_mutex_init(&lock,nullptr);pthread_cond_init(&full,nullptr);pthread_cond_init(&empty,nullptr);}voidPush(const T& in){pthread_mutex_lock(&lock);while(q.size()== cap){pthread_cond_wait(&full,&lock);}
q.push(in);pthread_cond_signal(&empty);//if (q.size() > cap / 2)//{// pthread_cond_signal(&empty); //}pthread_mutex_unlock(&lock);}voidPop(T& out){pthread_mutex_lock(&lock);while(q.empty()){pthread_cond_wait(&empty,&lock);}
out = q.front();
q.pop();pthread_cond_signal(&full);//if (q.size() < cap / 2)//{// pthread_cond_signal(&full);//}pthread_mutex_unlock(&lock);}~BlockQueue(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&full);pthread_cond_destroy(&empty);}private:
std::queue<T> q;// 阻塞队列int cap;// 阻塞队列的容量
pthread_mutex_t lock;// 互斥锁
pthread_cond_t full;// 阻塞队列已满条件变量
pthread_cond_t empty;// 阻塞队列已空条件变量};
// "main.cc"#include"BlockQueue.hpp"void*Consume(void* arg){auto bq =(BlockQueue<int>*)arg;while(true){//sleep(1);int data =0;
bq->Pop(data);
std::cout <<"comsumer recevie data :"<< data << std::endl;}}void*Produce(void* arg){auto bq =(BlockQueue<int>*)arg;while(true){sleep(1);int data =rand()%100+1;
bq->Push(data);
std::cout <<"productor produce data :"<< data << std::endl;}}intmain(){srand((unsignedint)time(nullptr));
BlockQueue<int>* bq =new BlockQueue<int>();
pthread_t consumer, producter;pthread_create(&consumer,nullptr, Consume, bq);pthread_create(&producter,nullptr, Produce, bq);pthread_join(consumer,nullptr);pthread_join(producter,nullptr);return0;}
为什么需要两个条件变量?
需要两个条件变量,因为虽然只有一般锁,但是生产者和消费者需要等待的条件是不一样的。只有当阻塞队列中的数据满的时候,生产者才需要等待。因为这个如果生产者不等待同时竞争力很强,它就会一直持有锁资源而什么任务页执行不了。(因为当数据满了之后,默认行为就是不做事情)。同样的,消费者之后在阻塞队列中没有数据的时候,才需要等待。因为如果没有数据同时消费者的竞争力很强,也会一直占有锁资源而什么事情也不干,导致执行效率降低。
因此需要不同的条件变量,让多线程在不同的情况下,在各自的条件变量等待队列中排队。
为什么判断阻塞队列是否为满或者为空需要使用循环判断?
pthread_cond_wait
有可能会调用失败,如果使用if
判断的话,当函数调用失败的时候,就会继续指向下面的逻辑,可能会导致错误。- 当多个线程被唤醒的时候,就会先竞争锁,然后从上一次调用
pthread_cond_wait
的地方继续执行。如果使用if
判断的话,那么多个线程就会同时都继续执行if
判断的代码,而此时阻塞队列中的数据不够的话就会导致错误。所以需要使用while
循环判断,使得多个线程会再次检测条件变量被出发的条件是否真的满足,避免了一个线程执行过就使得条件变量的触发条件不满足而其他线程还在执行下面的逻辑而导致错误。
阻塞队列的应用场景之一就是管道。
匿名管道是支持同步与互斥的。当一个进程往管道中一直写数据,当写满管道的时候,写端的进程就自动阻塞住了。而一个进程从管道中一直读取数据,当管道中没有数据的时候,读端级进程就自动阻塞住了。
POSIX信号量
POSIX信号量和SytemV信号量作用相同,都是线程同步操作,达到无冲突访问共享资源目的。
什么是信号量(信号灯)?
- 信号量的本质就是计数器,用于描述临界资源中资源数据的计数器。
信号量可以不仅可以实现同步与互斥也可以实现更细粒度的临界资源的管理,如访问临界资源中的不同区域。
- 申请到信号并不是已经开始使用临界资源中的申请的空间了。而是有了使用这块空间的权限。
- 申请/释放信号量的本质就是对计数器–或++ 1. 申请信号量就是P操作,释放信号量就是V操作
- 信号量是一种多线程共享资源,所以信号量也是临界资源。而信号量的PV操作是原子的,所以信号量可以保护好自己。之前讲过一个全局变量的++或者–不是原子操作,所以不能使用一个全局变量来代替信号量这个计数器。
- P操作有可能申请不到资源,导致线程就会阻塞,进入信号量的资源等待队列中。所以信号量可以简单理解为一个结构体中有一把锁,一个计数器和一个线程等待队列。
- 如果信号量中的值为1,成为二元信号量。可以模拟互斥锁的功能。
操作信号量的接口
初始化信号量
#include<semaphore.h>intsem_init(sem_t* sem,int pshared,unsignedint value);
- 作用 - 初始化信号量,并且设置信号量的初始值
- 参数 -
sem
:传入的信号量-pshared
:设置成0表示线程间共享,非0表示进程间共享-value
:设置信号量的初始值
销毁信号量
intsem_destroy(sem_t* sem);
等待信号量
// P操作intsem_wait(sem_t* sem);
- 作用 - 等待信号量,将信号量的值减1。即P操作
发布信号量
// V操作intsem_post(sem_t* sem);
- 作用 - 发布信号量,表示资源使用完毕,可以归还资源,将信号量加1。即V操作
使用二元信号量模拟互斥锁的功能
^twosem
上文将过使用互斥锁进行对一个全局变量
num
进行
--
,同时需要保证临界资源的数据安全。
我们可以使用二元信号量重新做一遍,其中互斥的功能使用信号量来模拟。
#include<iostream>#include<semaphore.h>#include<pthread.h>classSem{public:Sem(int num){sem_init(&sem,0, num);}voidP(){sem_wait(&sem);}voidV(){sem_post(&sem);}~Sem(){sem_destroy(&sem);}private:
sem_t sem;};
Sem sem(1);int num =4000;void*Routine(void* arg){char* name =(char*)arg;while(true){
sem.P();if(num >0){
std::cout << name <<", num: "<< num << std::endl;
num --;
sem.V();}else{
sem.V();break;}}return(void*)1;}intmain(){
pthread_t t1, t2, t3, t4, t5;pthread_create(&t1,nullptr, Routine,(void*)"thread 1");pthread_create(&t2,nullptr, Routine,(void*)"thread 2");pthread_create(&t3,nullptr, Routine,(void*)"thread 3");pthread_create(&t4,nullptr, Routine,(void*)"thread 4");pthread_create(&t5,nullptr, Routine,(void*)"thread 5");pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);pthread_join(t4,nullptr);pthread_join(t5,nullptr);return0;}
基于环形队列的生产者消费者模型
^RingQueue
示例:
要求:创建两个线程充当生产者和消费者,使用环形队列充当缓冲区。生产者向环形队列中添加数据,消费者从队形队列中取数据。其中使用信号量来保持线程之间的同步与互斥。
分析:环形队列采用数组模拟,使用模运算来模拟环状特性。其中生产者关系数组中的空间,消费者关心数组中中的数据。所以生产者需要一个
blank_sem
信号量,消费者需要一个
data_sem
信号量。
生产者和消费者在各自的信号量中等待,保持了线程之间的互斥。当其中一个信号量的计数器被加满了或者减到0,那么对应的线程就必须要被等待,直到想要获得资源恢复。这样就可以保持线程同步。
遵守的规则
- 生产和消费不能指向同一块空间
- 无论是生产者还是消费者都应该套对方一个圈
// "RingQueue.hpp"#include<iostream>#include<pthread.h>#include<semaphore.h>#include<vector>#include<cstdlib>#defineCAP32template<classT>classRingQueue{private:voidP(sem_t& sem){sem_wait(&sem);}voidV(sem_t& sem){sem_post(&sem);}public:RingQueue(int _cap = CAP):cap(_cap){
q.resize(CAP);sem_init(&blank_sem,0, cap);sem_init(&data_sem,0,0);}voidPush(const T& in){P(blank_sem);
q[p_pos]= in;V(data_sem);
p_pos ++;
p_pos %= cap;}voidPop(T& out){P(data_sem);
out = q[c_pos];V(blank_sem);
c_pos ++;
c_pos %= cap;}~RingQueue(){sem_destroy(&blank_sem);sem_destroy(&data_sem);}private:
std::vector<int> q;int cap;int c_pos;int p_pos;
sem_t blank_sem;
sem_t data_sem;};
// "main.cc"#include"RingQueue.hpp"void*Consume(void* arg){auto rq =(RingQueue<int>*)arg;while(true){int data =0;
rq->Pop(data);
std::cout <<"consumer >>> "<< data << std::endl;}}void*Produce(void* arg){auto rq =(RingQueue<int>*)arg;while(true){int data =rand()%100+1;
rq->Push(data);
std::cout <<"productoe >>> "<< data << std::endl;}}intmain(){srand((unsignedint)time(nullptr));
RingQueue<int>* rq =new RingQueue<int>();
pthread_t consumer, producter;pthread_create(&consumer,0, Consume, rq);pthread_create(&producter,0, Produce, rq);pthread_join(consumer,nullptr);pthread_join(producter,nullptr);return0;}
信号量通过限制线程操作的次数来控制线程的。通过信号量控制的线程不可能会出现数据不一致的问题,因为不同的线程只有两种情况可能访问同一个位置,第一种是环形队列为空的是。第二种是环形队列为满的时候。而在前者
data_sem
为0,消费者线程不能访问,后者
blank_sem
为0,生产者线程不能访问。所以使用信号量控制的线程不可能同时访问队列中同一块资源,因此也就不可能出现出不一致的情况了。
线程池
概念:线程池是一种使用线程使用模式。线程过多会带来调度开销,进程影响线程局部缓存性的整体性能。而线程池中维护着多个线程,等待着管理者分配可并发执行的任务。这避免了处理短时间任务时创建于销毁线程的代价。
线程池的应用场景
- 需要大量线程来完成任务
- 对性能要求苛刻的应用
- 接收突发性的大量请求
示例:
**要求:设置一个线程池,线程池中有一个任务队列,外界可以通过
Push
接口将任务(做一个简单运算的任务)交给线程池的任务队列,而线程池中可以分配多个线程去处理任务队列中的任务。**
分析:
- 为了保证任务队列(临界资源)的数据安全,所以需要使用互斥锁来保护任务队列。
- 为了避免多线程的饥饿问题,所以需要使用条件变量判断当任务队列为空的时候,就让线程在条件变量的等待队列中等待。
- 为了可以短时间中使用多线程处理任务,所以在线程池初始化的时候,需要创建多个线程去运行任务。
#pragmaonce#include<iostream>#include<queue>#include<pthread.h>#defineNUM5template<classT>classThreadPool{public:ThreadPool(int _num = NUM):pthread_num(_num){pthread_mutex_init(&lock,nullptr);pthread_cond_init(&cond,nullptr);}// 线程从任务队列中拿出任务执行staticvoid*Routine(void* arg){pthread_detach(pthread_self());
ThreadPool* self =(ThreadPool*)arg;while(true){
self->LockQueue();// 如果任务队列中没有任务就让线程等待while(IsQueueEmpty()){
self->Wait();}// 从任务队列中拿出任务
T task;
self->Pop(task);
self->UnlockQueue();// 运行任务
task.Run();}}voidInitThreadPool(){
pthread_t tid;for(int i =0; i < pthread_num; i ++){pthread_create(&tid,nullptr, Routine,this);}}voidPush(const T& in){// 向任务队列中放任务LockQueue();
q.push(in);UnlockQueue();// 唤醒线程执行任务WakeUp();}voidPop(T& out){
out = q.front();
q.pop();}~ThreadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}private:boolIsQueueEmpty(){return q.empty();}voidLockQueue(){pthread_mutex_lock(&lock);}voidUnlockQueue(){pthread_mutex_unlock(&lock);}voidWait(){pthread_cond_wait(&cond,&lock);}voidWakeUp(){pthread_cond_signal(&cond);}private:int pthread_num;
std::queue<T> q;
pthread_mutex_t lock;
pthread_cond_t cond;};
#pragmaonce// "Task.hpp"classTask{public:Task(){}Task(int _x,int _y,char _op):x(_x),y(_y),op(_op){}voidRun(){int ans =0;switch(op){case'+':
ans = x + y;break;case'-':
ans = x - y;break;case'*':
ans = x * y;break;case'/':if(y ==0) std::cerr <<"y is zero"<< std::endl;else ans = x / y;break;case'%':if(y ==0) std::cerr <<"y is zero"<< std::endl;else ans = x % y;break;default:
std::cerr <<"operator error"<< std::endl;break;}
std::cout <<"thread ["<<pthread_self()<<"]: "<< x <<" "<< op <<" "<< y <<" = "<< ans << std::endl;}~Task(){}private:int x;int y;char op;};
// "main.cc"#include<iostream>#include"Task.hpp"#include"ThreadPool.hpp"#include<cstdlib>intmain(){srand((unsignedint)time(nullptr));
ThreadPool<Task>* tp =new ThreadPool<Task>();
tp->InitThreadPool();constchar* ops ="+-*/%";while(true){int x =rand()%100+1;int y =rand()%100+1;
Task task(x, y, ops[rand()%5]);
tp->Push(task);}return0;}
注意点:
**在类内
pthread_create
中的
Routine
函数需要设置成
static
函数。并且
Routine
函数传入的参数是
this
。** 因为
Routine
函数值接收一个参数作为形参
void*arg
,而在类内的所有函数的第一个参数都是
this
,而此时我们传不传参数给
Routine
,这个函数的第一个参数都是
this
,第二个参数是
arg
,这就会导致参数个数不匹配。因此可以将
Routine
函数设置成为
static
函数,这样
Routine
函数就没有
this
指针做为第一个参数了,而我们为了可以想要使用类内的接口函数,**可以将
this
作为参数设置为
arg
,从而可以使用
this->
的方式在
static void* Routine(void*arg)
中使用类内的函数。**
线程安全的单例模式
在很多服务器开发的常见中,经常需要让服务器加载很多的数据到内存汇总,此时往往要用一个单例的类来管理这些数据。
饿汉模式实现
template<classT>classSingleton{public:static Singleton*GetInstance(){return&instance;}private:Singleton(){}Singleton(const Singleton& s)=delete;
Singleton&operator(const Singleton&)=delete;static Singleton* instance;};
Singleton* Singleton::instance =new Singleton;
懒汉模式实现
懒汉模式
- 最核心的思想是“延时加载”,从而能优化服务器的启动速度。
- 另一个好处是:任何一个时刻,保证了系统内存的使用率是最高的。有很多数据一开始不会使用到的,所以没有必须要一开始就加载这些数据到内存。
template<classT>classSingleton{public:static Singleton*GetInstance(){// 双重判定空指针,降低锁竞争冲突的问题if(instance ==nullptr){// 使用互斥锁,保证多线程的情况下也只有一个对象
mtx.lock();if(nullptr== instance){returnnew Singleton;}
mtx.unlock();}return instance;}private:Singleton(){}Singleton(const Singleton&s)=delete;
Singleton&operator=(const Singleton&)=delete;volatilestatic Singleton* instance;static std::mutex mtx;};
Singleton* Singleton::instance =nullptr;
std::mutex Singleton::mtx;
注意事项
- 需要通过加锁保证在多线程下也只会
new
出一个对象,并且注意加锁解锁的位置 - 双重if判断,避免不必要的锁竞争
- volatile关键字防止多度优化
STL,智能指针和线程安全
STL中的容器是否是线程安全的?
STL不是线程安全的。因为STL的设计初衷是将性能挖掘到机制,而一旦涉及到加锁保证线程安全,会对性能造成巨大的伤害。并且对于不同的数据结构加锁的方式不同,性能也会不同(例如:哈希表的表锁和桶锁)。因此默认STL是线程不安全的,如果需要在多线程环境下使用STL中的容器需要自己主要保护STL容器。
智能指针是否是线程安全的?
对于
unique_ptr
,由于只是对当前代码块范围生效,因此不涉及线程安全的问题。
对于
share_ptr
,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候,考虑到了这个问题,基于原子操作的方式保证
share_ptr
能够高效,原子的操作引用计数。
其他常见的各种锁
- 悲观锁:每次取数据时,从事担心数据会被其他线程修改,所以会在取数据前加锁,当其他线程想要访问数据的时候,会被阻塞。
- 乐观锁:每次取数据时,总是乐观任务数据不会被其他线程修改,因此不上锁。但是在数据更新前,会怕短其他数据再更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不相等则失败,失败则重试。一般是一个自旋的过程,即不断重试。
- 自旋锁(与挂起等待锁对应)- 挂起等待锁是一个线程因为想要的资源没有目前没有,所以线程被挂起等待了。而自旋锁是一个线程在没有缺失想要资源的情况下,不断地申请紫资源而不是挂起等待。- 什么时候使用自旋锁取决于已经拿到锁的线程,在执行临界区的时候,要占用的时间。如果占用的时间长,那么就使用挂起等待锁,如果占用的时间短,就使用自旋锁。 这个时间的长短需要编写代码的人自己判断,一般如果临界区中需要计算大量的任务,或者读写文件,读写数据库这样的操作就是占用时间比较长的操作。- 所以我们在考虑锁问题的时候有三个因素:是否要加锁,锁加在哪里,临界区被线程执行的效率情况
读者写者问题
记忆方法:321关系
- 3种关系 1. 读者和读者之间(没有关系) 1. 在生产消费模型中:消费需要取走缓冲区中的内容,所以消费者之间是互斥的关系。但是这里读者在读的过程中,只需要进行数据拷贝,所以没有读者时间没有关系。2. 写者和写者之间(互斥关系)3. 读者和写者之间(互斥关系,同步关系)
- 2种角色 1. 读者2. 写者
- 1个交易场所 1. 一段内存缓冲区
使用读者写者问题的场景:
- 数据写入之后,剩下的操作,就是读取。
- 写入操作少,读取操作多。(例如:登录注册,一个注册,之后全都是读取数据库中的数据)
读者优先/写者优先:在多线程的情况下,读写同时到来,这个时候就有两种情况。
- 不让读者进来,等待写者进入并且完成写的任务,这个就是“写者优先”。
- 不让写者进来,等待读者进来并且完成读的任务,这个就是“读者优先”。
版权归原作者 _light_house_ 所有, 如有侵权,请联系我们删除。