Linux 多线程
多线程
1. 线程概念
1.1 地址空间和页表的映射关系
地址空间是进程能看到的拥有的资源的大小
页表决定了进程实际拥有资源的大小和位置
所以对进程地址空间和页表进行适当的资源划分,就可以对一个进程的所有资源进行分类
进程地址空间如何与页表和物理内存产生映射的过程
首先进程地址空间有 4G,那么如果要建立一个个对应的页表的话需要建立 2^32 个条目,一个条目里都有权限,是否命中等属性,所以如果要建立
2^32
个的话每个进程都要花费近 20G 资源,几乎不现实,所以,页表的映射是以下过程。
1. 首先对于进程地址空间,每个地址都是 32 位的,那么我们可以将 32 位划分位 3 部分,10 位,10 位,12 位,首先对于前 10 位。
2. 对于前 10 位来说,为每个前 10 为建立一个页目录,即为前 10 位的划分。
3. 对于第二个 10 位,前边创建的页目录即存在这里,每个页目录有 1024 个,对应每个页表项(页表)。
4. 对于后 12 位来说,当我们根据前边两个过程时,到了页表那里,页表里存的就是对应的每个内存块的起始物理,内存块的大小位 4KB 刚好是 2^12 个大小,所以后 12 位即为找到物理起始地址后的偏移量。
整体流程 : 对于前 10 位,每个位建立一个 1024 个页目录,每个页目录建立 1024 个,存储了每个前 20 位在物理内存中的起始地址,这个物理内存的起始地址加上虚拟地址的后 12 位即偏移量,便可真正找到实际物理内存的地址。
1.2 线程概念
进程是资源分配的最小单位,线程是 CPU 调度的最小单位
对于一个进程来说,一个进程创立的过程为:
创建对应的内核数据结构 task_struct 等----->根据进程地址空间创建页表-------->与物理内存建立映射
那么,如果只想执行进程的一部分呢,就引出了线程的概念,我们可以创建多个task_struct
每个 PCB 执行代码的一部分,但是不需要创建新的系统资源,因为就一个页表和地址空间和内存,如图
那么以上绿色的即为线程,线程执行是进程的一个执行流,线程是 cpu 调度的基本单位。
线程也需要被 cpu 执行,根据先描述再组织,所以也需要特定的数据结构和对应的管理办法,让线程执行进程的一部分,但是要不要为进程创建一系列对应的体系结构呢?不需要,既然他和进程 pcb 属性这么像,干脆直接复用 linux 里的进程的一系列系统即可,进行细节改动,这样可以高内聚低耦合。
那么如何看待之间的进程的概念呢?
之前的进程与也是通用的,只不过现在引入了线程,之前的进程写的代码只有一个执行流,只有一个执行流的这个叫做主执行流,进程依靠的是这个主执行流,所以现在进程是系统资源分配的基本实体,之前内部只有一个执行流,但是现在随着线程的引入,进程内部可以存在多个执行流
进程 :承担系统资源分配的基本单位,以下这些的集合统称进程。
既然线程概念已经被引出,那么线程也要跟进程一样,要有独立的 id,保存上下文的寄存器,被调度挂起等,这么说的话 OS 需要再为线程设计单独的一个结构和体系吗? 并不需要,因为线程跟进程的属性很像,所以直接套用之前的 PCB 的结构和体系即可,直接复用进程 PCB,用 PCB 表示 Linux 中的线程。
所以在 Linux 中,没有真正意义的线程,是用 PCB 来模拟线程的。
站在 CPU 的视角,每个线程都是一个轻量级线程。
进程用来申请整体资源,线程用来向进程要资源。
Linux 无法直接提供创建线程的系统调用,只能提供轻量级进程,但是 OS 和用户只认线程,所以 Linux 用第三方软件层来进行桥接。
轻量级进程 lwp,cpu 调度是以 lwp 来调度的,对于进程中的主线程,pid 与 lwp 一样
1.3 示例代码
要使用 pthread_create 的时候,需要手动去动态链接 pthread 库,才能正常编译
使用 ps -aL 查看系统所有线程
1.4 线程的属性
线程一旦被创建,进程内的几乎所有的资源都被线程共享
⭐线程什么是私有的?
(1) PCB 属性私有
(2) 上下文结构私有 ,他是线程动态运行的证据,能体现出线程动态切换的属性
(3) 每一个线程都有独立的栈结构
线程的优点:
线程与进程之间的切换相比,消耗的系统资源比进程少
这是因为在 cpu 里有一个 cache,这个东西会存储一个进程经常用的到的
热点数据
,即为经常访问的数据他的速度比内存快比 cpu 慢所以 cpu 可以直接从这里拿,找不到再去内存中找
那么线程切换,由于线程拥有几乎所有的进程的资源,所以这个 cache 里的东西不需要切换
但是进程如果切换的话,那么就需要切换了,所以进程与线程之间消耗资源的差距主要体现在这一方面
能充分利用多处理器的可并行数量
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
线程的缺点:
健壮性降低:
对于单进程多线程而言,假设有一个线程产生异常,那么通常会给进程发送对应的信号进而导致进程终止,其他线程也随之终止
2. 线程控制
不管是 C 还是 C++在 linux 上编译时都要对原生线程库进行显式链接-lpthread
头文件:#include <pthread.h>
2.1 线程的创建、终止、等待、取消
线程的创建
函数:
参数:
pthread_t *thread: 输出型参数 ,输出参数线程的 tid
const pthread_attr_t *attr:设置线程的优先级等属性,通常设为 nullptr
void (start_routine) (void *): 回调函数,创建的这个线程的执行流的操作即为该回调函数的执行顺序
void *arg:传给线程启动函数的参数,可以是一个线程的名字等参数
eg:
线程的终止
线程的终止不能用exit()函数
,因为该函数是进程的终止函数
线程有自己的终止办法:
*调用函数 void pthread_exit(void retval);
retval 为线程退出信息的起始地址,该类型可以是任意类型
在线程的回调函数中直接 return (void*类型)
线程的等待
参数:
pthread_t thread :要等待进程的 tid
void **retval : 输出型参数,二级指针,通常保存线程退出的信息等内容
对于第二个参数,因为正确进程的终止或者正常退出都会返回一个(void*)类型的东西,
这个指针可以是一个结构体的起始地址或者数组的起始地址或者一个类型的地址,
那么我们可以用一个参数放到这个函数作为输出型参数,然后等待完后我们就可以访问这个输出型参数的内容,只不过这个输出型参数是一个指针类型
eg:
线程的取消(不常用)
参数: pthread_t thread: 要取消的线程的 pid
当该线程取消时,退出信息为-1,如果有线程等待的话,线程被取消,那么此时退出的信息就是-1
综合应用:
eg:
2.2 线程分离
1. 默认情况下,新创建的线程是 joinable 的,必须被 join 的,线程退出后必须对其进行 pthread_join()
操作,进而回收线程的资源,否则将造成系统泄漏
2. 假设主线程不需要关心线程的退出信息和返回结果,那么此时
pthread_join
对主线程来说是一种负担,此时可以让线程脱离主线程的管控,线程结束后告诉操作系统然后自行回收
线程分离的函数:
参数 : thread 为当前线程的 tid
返回值 : 成功返回 0,失败返回对应的错误码
但要线程分离首先得知道当前线程的 tid 才行需要引入函数pthead_self()
:该函数返回当前线程的 tid
函数 pthread_self():
返回值 : 当前线程的 tid,为十进制,可以手动转化为 16 进制
有了获取获取当前线程的函数,对于一个线程我们可以
此时该线程将分离主线程,结束后告诉系统,回收资源
但是这样有坏处,假设后续主线程对他 join 了,由于创建线程后有多个执行流,所以如果要被分离的线程没有赶在主线程前边,那么主线程不知道,将会进行阻塞等待了
eg:
以上代码
预期现象:
创建线程后,线程内部进行分离,主线程将不再等待新线程
实际现象:
创建线程后,线程内部进行分离,但是主线程依旧等待新线程
原因 : 因为创建新线程后,产生了一个新的执行流,两个线程随机独立运行,此时如果主线程先运行了,那么主线程将直接跑到了 pthread_join 等待了,然后新线程去分离了,但是主线程不知道,所以主线程继续等待了,此时就造成了错误
**改进办法 : **
主线程创建完新线程后,由主线程去分离新线程即可避免错误
2.3 线程库的原理
在 Linux 中,只有轻量级进程这一概念,但是程序员只认线程这一概念,所以产生了线程库,对轻量级进行封装,线程库是用户级的
对于一个线程,存在自己的属性,上下文和属于自己的栈结构和 tid 等结构属性
实现过程:
首先将线程库进行动态链接,线程库从磁盘加载到内存,然后进行动态链接,将库加载到共享区中
对于一个新线程,线程库创建一个线程并且创建对应的属性等操作,位于共享区
每个线程都有自己的私有结构,包括栈区等,这在共享区内
所以对于每个线程的 tid,线程的 tid 其实就是该线程在共享区的进程地址空间按的起始地址,拿到这个起始地址就可以访问线程的各种属性了
所以 pthread_t 类型的线程 ID,本质就是一个进程地址空间上的一个地址。
线程局部存储
假设有一个全局属性,每个线程都可以访问他,每个线程都可以对他进行操作,但是只有一份
如果想让这个全局变量,对于每个线程都有一份,那么可以在全局变量的前边加上__thread
关键字
这样每个线程都有自己的一份 val,存在新线程局部存储区域
3. 同步与互斥
线程互斥相关背景概念
临界资源 : 多个线程执行流共享的资源,称为临界资源
临界区 : 每个线程访问临界资源的代码片段,称为临界区
互斥 : 任何时刻,互斥保证只能有一个线程执行流在临界区执行,访问临界资源,对临界资源起保护作用
原子性 : 不会被任何调度机制打断的操作,这个操作只有两个状态,要么不做,要么做就一次性做完
线程同步 : 在保证数据安全的条件下,让线程按照某种特定的顺序访问临界资源,避免饥饿问题,从而造成线程之间的协同,这叫作同步
线程互斥是用来保护临界资源的操作,多个线程不能同时访问临界资源,容易出问题,让多个线程串行执行
多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换
例如以下的抢票程序,定义 6 个线程去抢票。票是临界资源
为什么要对以上代码进行互斥操作呢?
假设有一个线程正在抢票且票=1,那么他此时进来 if(tickets>0)里了,但假设此时刚好时间片用完了,那么轮到别的线程了,因为上一个线程还没减去票,那么当前线程看到的票的数量还是>0 那么此时我--,然后轮到刚刚那个线程了,那么那个线程也减减--了,最后结果就会出现负数的票数量,显然这种情况是不允许发生的,所以需要对线程进行互斥操作
互斥量的本质 : 互斥量是一个有两种状态的变量,加锁和解锁两个状态
3.1 互斥量的接口
初始化互斥量
方式一 :定义静态或者全局的互斥量
其实该方法也可以定义局部的,只需要有办法让其他线程拿到该互斥量的地址即可,用指针来实现
方法二:动态分配
eg:
让其他线程能拿到该地址即可
销毁互斥量 : 对于方法一不需要销毁
⭐互斥量加锁和解锁
当申请加锁失败,该线程将进入阻塞状态等待
如果线程申请锁成功,那么其他线程此时处于阻塞状态。
如果线程申请锁失败,那么该线程将进入阻塞状态,直至锁被打开。
3.2 如何看待锁
锁本身就是一个共享资源
加锁的过程必须是原子的
如果申请成功,则进入临界区,访问临界资源,没有暂时成功,那么将阻塞等待
谁持有锁谁就进入临界区
加锁的汇编底层实现
1.每个线程在寄存器中都有自己的上下文保存,所以假设线程 A 申请了锁,那么如下
2.每个线程起始内部值为 0,内存中初始化的锁值为 1,要申请锁,那么执行汇编语句 exchange 该语句是一条,假设此时交换完被调走,线程拿着上下文走了,保存在寄存器中,所以,只有一个申请成功,交换后的线程的值为 1 此时大于 0,那么代表申请锁成功
3.假设线程 B 此时也申请锁线程 B 的内部值也为 0,现在内存中的 1 已经被线程 A 调走了,所以无论怎么换,都失败,那么此时将阻塞挂起等待
解锁是将 mutex 的值变为 1,没有交换值的过程,然后唤醒等待的线程
3.3 可重入 VS 线程安全
可重入 : 同一个函数能被多个执行流进入,当当前执行流还没执行完,其他执行流再次进入,如果执行是没有出问题那么就是可重入的,如果出问题或者返回结果不同那么就是不可重入的。
线程安全 : 多个线程并发一段代码并且出现的结果都相同,不会出现不同的结果,此时线程是安全的,如果引起数据出错问题,那么线程是不安全的。
可重入是线程安全的子集
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
4. 死锁
**死锁的概念 : **
死锁是两个或多个进程在执行过程中,持有不释放的资源,互相申请被其他进程索占有的不释放的资源而处于的一种永久等待状态。
通俗的讲在多把锁的条件下,我们持有锁,还想要对方的锁,对方也是如此,一直僵持等待,就容易造成死锁
死锁是程序员设计的时候造成的错误,并不是天然存在的,死锁会造成资源浪费和性能损耗等问题。
死锁产生的四个必要条件:
1. 互斥 : 任何时刻保证只有一个执行流在临界区执行。
2. 请求与保持 :进程已经保持了自己的资源,但又提出了新的资源请求,但该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
3. 不剥夺 : 不去强行剥夺其他进程的资源
4. 环路等待 :存在一个环路链,进程集合{P0,P1,P2,···,Pn}中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源 , .....,Pn 正在等待已被 P0 占用的资源。例如 A 申请锁 1-->锁 2,B 申请锁 2--->锁 1,此时假设 A 申请完锁 1 然后被调度 B 申请锁 2,此时造成环路了就
上边的四个条件都满足才会产生死锁所以我们可以根据以上的必要条件去破坏死锁的产生。
破坏死锁的产生
首先互斥是锁的特性,是没办法破坏的
破坏 请求与保持条件:请求与保持就是我自己有锁,要别人的锁要不到,那么此时要失败了,此时释放我曾经所有申请成功的所有锁,不保持与请求
破坏 不剥夺条件:可以设计一个优先级高低的竞争策略,当需要的资源被其他进程占有时可以强制剥夺式的去拿
破坏 环路等待条件:在申请锁的时候设计尽量保持申请锁的顺序一致,这样就可以尽可能的避免环路产生
额外补充:
线程申请锁成功,执行临界区的时候,其他线程此时阻塞等待
线程申请锁成功,正在执行临界区的时候,此时依然可以被切换走,是抱着锁被切走的,所以此时其他线程照样无法进来,等被切换回来继续执行
一个线程申请的锁,可以被另一个线程解锁,因为解锁的汇编语言是将 1 放入 mutex 中,并没有交换,但是这种情况很少发生,代码写的时候基本上不会这样写
5. 条件变量
条件变量是实现线程同步与互斥的一个手段,通常条件变量与互斥锁一起使用,条件变量本身不是锁
条件变量是用来阻塞一个队列,等待某一条件或者事件发生,被唤醒时则取消阻塞
1. 条件不满足,阻塞队列 2.条件满足,通知阻塞的线程开始工作
**操作函数 : **
与互斥量类似
条件变量初始化
销毁
等待条件满足
第一个参数为条件变量的地址,第二个参数为当前锁(互斥量)的地址
为什么等待条件变量的时候要需要互斥量,传入锁的地址呢?
当我条件不满足的时候,我将进入阻塞队列等待条件满足,但是此时是持有锁的,挂起的时候其他线程都拿不到锁了,造成了线程之间的阻塞,严重则一直僵持,所以当要对条件变量进行等待,此时函数将线程挂起然后将当前持有的锁给释放,当再次被唤醒的时候,再申请锁
唤醒等待
6. 生产者消费者模型
生产者和消费者模型是通过一个容器解决两者之间强耦合的关系,在生产者和消费者之间通过一个阻塞队列来进行通信,生产者生产完数据不用去等待消费者处理,消费者不用去找生产者要数据,都是通过中间层来进行交互,阻塞队列就类似一个缓冲区平衡了消费者和生产者间的处理能力,这个阻塞队列就是用来给生产者和消费者解耦的
**生产者消费者模型的特点 : **
生产者和消费者进行解耦
支持生产和消费的一段时间的忙闲不均的问题
⭐提高效率
基于 BlockingQueue 的生产者消费者模型
在生产者和消费者之间添加一个阻塞队列,生产者放数据,消费者取数据
当队列为空时,消费者需要等待,此时可以使用条件变量来限制,等待条件解除,解除等待
当队列为满时,生产者等待,等待消费者取数据
为什么该模型高效呢?
当有多个线程生产以及多个线程消费时,高效之处不在与阻塞队列。
假设生产者放完数据之前需要初始化等操作,要花费一定时间,消费者获取数据之后要花时间处理拿到的数据。
但是当放之前/生产完数据后,此时其他线程也可以在这个时间进行放/取数据,然后切换线程,再回到我刚刚的线程,开始生产或者消费等操作,所以高效率的体现在线程之间可以切换,解决了忙闲不均生产之前消费之后各线程可以让线程并行执行。
7. 信号量
信号量广泛用于进程或线程间的同步和互斥,信号量本质上是⼀个非负的整数计数器,它被⽤来控制对公共资源的访问。
申请信号量的操作叫做 P 操作,释放信号量的操作叫做 V 操作,所以 PV 操作来协助完成同步
信号量头文件 : #include <semaphore.h>
初始化信号量
销毁信号量
等待信号量 (P 操作) : 等待信号量,有的话就取消等待,将信号量减去 1
发布信号量 (V 操作) : 发布信号量,通常将信号量加 1
7.1 基于环形队列的生产消费模型
通过一个信号量来保证环形队列中 :
环形队列采用数组来模拟 , 用模运算来模拟环状特性
在环中只有两种情况,消费者和生产者才会碰头: 1.环形队列没数据 2.环形队列数据满
所以,通过信号量来维护特殊情况时两个位置不会超。
当队列没有数据时 消费者不能超过生产者的位置 也就是没有数据消费者不能继续
当队列数据满时 生产者不能超过消费者的位置 也就是数据满了生产者不能继续
8. 线程池
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着 监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利 用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络 sockets 等的数量。
总的来说在运行是频繁的创建多个线程和销毁会造成速度变慢等,所以线程池是一次性创建好多个线程,供使用,是一种典型的空间换时间的方法
暴露出 push 的接口接受外部任务,处理任务,也可以根据其他要求来创造其他接口
9. 线程安全的单例模式
单例模式是一种常见的设计模式
某些类, 只应该具有一个对象(实例), 就称之为单例.
例如一个人只能有一个妻子. 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百 G) 到内存中. 此时往往要用一个单例的类来管理这 些数据.
饿汉实现方式和懒汉实现方式
饿汉实现方式和懒汉实现方式
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.这就是饿汉方式
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.
懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度.
懒汉就是需要用的时候再去对应的行为操作
其实平常 new 的堆空间,如果只先 new 了一批空间,操作系统实现的方式其实就是用懒汉方式来实现的
new 一部分空间,操作系统首先会在进程地址空间处给你划分好一段区域,但是实际物理内存并没有给你,以及页表也没有建立映射,当检测到你用了这一段空间时,发生缺页中断,放下手里的事情,给你分配物理内存,然后建立页表映射关系,所以这就是一种典型的懒汉方式
文章转载自:有志者事竟成
评论