1 Linux线程概念
1.1 什么是线程1.2 线程优点1.3 线程的缺点1.4 线程异常1.5 Linux进程和线程 2 Linux线程控制
2.1 POSIX线程库2.2 线程创建2.3 线程标识线程终止线程分离线程等待 3 Linux线程互斥
互斥相关概念互斥量
互斥量相关的接口互斥量实现原理 可重入和线程安全
概念常见场景可重入与线程安全的联系与区别 常见锁概念
死锁死锁的四个必要条件 避免死锁的建议
避免死锁算法 Linux线程同步
同步概念与竞态条件条件变量
条件变量相关接口 生产者消费者模型 POSIX信号量线程池线程安全的单例模式STL,智能指针和线程安全其他常见的各种锁读者写者问题
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#include 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_打头的需要引进头文件 作用
创建一个新的线程,可以指定新的线程需要执行的任务 参数
thread:输出型参数,可以返回线程的线程号attr:设置线程的属性start_routine:需要指定线程需要执行的任务arg:给线程执行的任务传入的参数 返回值
成功放回0,失败返回错误码
错误检查 传统的函数都是成功返回0,失败返回-1,并且对全局errno赋值表示错误pthread函数出错不会设置全局的errno(大部分POSIX函数都是这样),而是将错误码直接返回每个线程也都有自己的errno来支持其他需要支持设置errno的函数,但是读取返回值要比读取errno的开销小
示例: 注意:在编译文件的时候,需要gcc -lpthread带上链接pthread库的选项 由实验可知: ![[Pasted image 20220210101443.png]] 或者使用ps命令查看线程,因为查看的是线程,所以需要带上-L选项。 作用
返回原生线程库提供的用户级线程ID,与pthread_create第一个输出型参数的值是一样的
示例: 注意这里的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就是进程地址空间中的一个地址(虚拟地址)。 线程终止有三种方法: 作用
退出一个线程,并且可以执行线程的退出码,作用和return效果一样
需要注意:pthread_exit或者return返回的指针指向的内存单元一定要是全局的或者是malloc出来的,而不能是函数栈上分配的,因为当其他线程得到这个返回指针时,线程函数已经退出了。 作用
指定取消一个线程号为thread的线程,相当于终止一个线程。一般用在main线程取消指定的线程,导致线程执行的过程中突然被取消。如果取消成功,线程的退出码默认就是-1, 取消失败退出码默认是0 参数
需要被终止的线程的线程号 返回值
取消成功返回0, 失败返回非0
示例: 结果: 一般情况下,线程必须被等待使用pthread_join调用。但是如果不关系线程的返回值,join是一种负担,就可以将线程分离,这样当线程退出时自动地释放资源。分离和等待式冲突的,一个线程不能既被join又是detach的。 作用
指定分离一个线程,之后就不用回收这个线程了。一般用于:如果主线程不关心新线程的执行状态(不需要知道退出码)就可以分离线程,这样主线程就可以不用阻塞式地等待回收线程资源了,然后执行主线程自己的任务。一般情况下,主线程要比新线程存在时间长,所以可以让新线程自己分离自己。
示例: 为什么线程要等待? 已经退出的线程,其空间还没有释放,还在进程地址空间中新创建的线程不会复用退出线程的地址空间
作用
默认是阻塞式地等待回收线程资源,即调用该函数的线程挂起等待,直到id为thread的线程终止 参数
pthread:要等待线程的IDretval:线程退出时的退出码,因为线程执行的任务函数的返回值是void*,而retval是输出型参数,所以需要使用void**才可以将参数输出出来。不同的终止方法得到的retval是不同的,总结如下
如果通过return返回,retval就是return的返回值如果通过自己调用pthread_exit返回,则retval为pthread_exit函数的参数如果通过别人线程调用pthread_cancel返回,则返回的是PTHREAD_CANCELED(-1)如果对退出码不感兴趣,可以设置为NULL
示例: 结果: 为什么线程中只有退出码,而没有信号? retval是被等待线程的退出码,用来表示代码运行后结果是否正确。如果线程在运行的过程中出现异常崩溃了,那么整个进程都崩溃了,所以进程就会收到信号,而线程就不需要知道信号是怎样的了。 临界资源:多线程执行流共享的资源临界区:每个线程内部,访问临界资源的代码就叫做临界区互斥:任何时刻,都只有一个执行流进入临界区,访问临界资源。通常是对临界资源起到保护作用原子性:不会被任何的调度机制打断的操作
互斥量
引例: 运行的结果: 其中临界资源num,在num–的时候,这个操作是原子的吗? A:不是的。num --在计算机看来会分为三步,第一步将内存的中的num放到cpu中;第二步cpu进行对num做加法;第三步将cpu中计算的结果写回内存中。其中的每一步操作可能都会打断。 如果在一个线程在执行第一步操作,然后就被中断。紧接着执行第二个线程,它进行了完整的三步过程,将num修改成了3990(中间线程2多次执行减法),这个时候线程1被切换回来,将num修改成了3999。这样就因为对临界资源的访问不是原子的,所以导致的数据不一致的问题,即两个线程看到的数据因为有“时差”所以不一样。 同样的,if (nums > 0)也不是一个原子操作。 如果想要解决这个问题,就需要做到当一个线程在执行临界区的代码时,不允许其他线程进入临界区。这个问题可以通过加锁的方式解决,Linux中提供的锁就叫做互斥量 ![[Pasted image 20220210164546.png]] 加锁是一个有损于性能的操作,所以在设计加锁的时候尽量减少加锁带来的性能开销。 示例: 创建5个线程,然后一起对num进行--操作,其中需要使用互斥量来保证临界资源的安全性。 说明: 申请锁的过程是原子的,那么申请锁是如何实现的呢? 为了实现互斥锁操作,可以使用swap或exchange指令,其作用是将寄存器和内存的数据交换。一个处理器上的指令执行时另一个处理器的指令只能等待总线周期,所以一条交换指令是一定不会通知执行的。 下面是利用交换指令的lock和unlock的伪代码 ![[Pasted image 20220210180354.png]] 因为是通过交换这一条命令完成加锁的,所以mutex和寄存器中只有一个1。所以只有一个线程的上下文中寄存器的变量是1,其余的都是0。这样就可以保证只有一个线程可以竞争到锁资源。 就算是中间过程中拥有锁的线程被切换出去了,也不要紧,因为锁资源已经被拿走了,其他的线程拿不到。如图: 当释放锁资源的时候,因为只有一个线程拿着锁,所以可以直接将mutex赋值为1,然后唤醒其他的线程继续竞争锁资源。 简单说就是:因为只有一把锁,所以通过一个原子的交换指令只能将这把锁给其中的一个线程 线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。即一个线程的运行不会影响另一个线程。
对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程不安全的问题 重入:同一个函数被不同的执行流进入,称之为“重入”。一个函数在重入的情况下,运行结果不会出现任何不同或者问题,则该函数成为可重入函数,否则,就是不可重入函数。
常见场景
常见的线程不安全的情况 不保护共享变量的函数
例如:函数中有没有锁保护的全局变量 函数状态随着被调用,状态发生变化的函数
例如:函数中有一个静态变量,函数每一次调用都会累加变量变化的效果(变化可以是一直加,一直减等等) 返回静态变量指针的函数调用线程不安全函数的函数
常见的线程安全的情况 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限类或者函数对于线程都是原子操作多个线程之间的切换不会导致函数的执行产生二义性
常见的不可重入的情况 调用了malloc/free函数
因为malloc函数是用全局链表来管理的 调用了IO库函数使用了静态的数据结构
常见的可重入的情况 不使用全局变量或者静态变量不使用malloc开辟空间不调用不可重入函数不返回静态或全局数据只使用局部变量或者对全局数据进行拷贝
可重入与线程安全的联系与区别
可重入与线程安全的联系 函数可重入,函数一定线程安全函数是不可重入,函数可能有线程安全如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全的区别 可重入函数是线程安全函数的一种线程安全不一定是可重入的,但是可重入函数一定是线程安全的如果对临界资源的访问加上锁,则这个函数是线程安全的。但是如果这个重入函数的锁一直不释放则会产生死锁问题,因此是不可重入的单执行流单线程下也有可能出现重入,例如信号捕捉
常见锁概念
死锁
死锁是指进程中因相互申请被其他进程所占用的资源而处于的一种永久等待资源的状态。 当我们看到进程卡住不动称为进程被阻塞了。站在操作系统的角度呢? 当进程在运行的时候,其实是一个一个轻量级进程在运行等待队列中排队等待着CPU资源去运行当前轻量级进程。假如说这个时候运行一个线程,该线程先要去竞争锁资源,但是锁资源已经被占了,那么操作系统就是改变当前线程中的运行状态,然后将该线程放入锁资源等待队列中。当锁资源被释放的时候,操作系统再将这个线程放入运行等待队列中继续排队,当可以运行该线程的时候,才会让该线程去访问锁资源。 所以当一个线程去申请一个不可能被释放的锁资源的时候,那么这个线程就会一直被放在这个锁的资源等待队列中,永远不可能被唤醒了。这就是死锁。 互斥条件:一个资源每次只能被一个执行流使用请求与保持条件:一个执行流因请求资源而阻塞时,不释放已经获得资源不剥夺条件:一个执行流获得了一个资源,在未使用完之前,不能被其他执行流强行剥夺循环等待条件:若干执行流之间形成一种循环等待资源的状态
避免死锁的建议
破坏死锁的四个必要条件的
破坏互斥条件:建议少申请锁资源破坏请求与保持条件:建议一个执行流申请锁的时候,将自己有的锁资源释放掉,避免加锁未释放的场景破坏不剥夺条件:可以让一个执行流强行剥夺锁资源破坏循环等待条件:多执行流申请多个锁的时候,建议按照顺序申请 资源一次性分配
避免死锁算法
死锁检测算法银行家算法
Linux线程同步
同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够数据一定顺序地访问临界资源,从而有效避免其他线程的饥饿问题,就是同步竞态条件:因为时序问题,而导致程序异常
为什么要存在同步? 个别线程竞争力很强,每一次都能申请到锁资源,但是却不执行特定任务只是单纯地申请锁和释放锁,导致了其他线程的很长时间竞争不到锁,而引起了饥饿问题。这种行为会使得程序运行的效率很低。 所以同步的意义就是:在访问临界资源数据安全的情况下,让多执行流访问资源具有一定的顺序性,从而保证了高效地访问资源。 需要实现同步,就需要使用到条件变量。 因为条件变量不能保护数据资源,所以通常需要和互斥锁配合使用。 1. 初始化条件变量 2. 销毁条件变量 3. 让线程在条件变量资源等待队列中等待 为什么pthread_cond_wait需要互斥量? 当一个线程调用pthread_cond_wait的时候,往往该线程自己正在持有锁(条件变量就是用来判断锁资源是否存在的),这个时候如果不将自己拥有的锁释放出去,而自己直接挂起等待,那么这个锁资源就永远不可能被其他线程获得了,也就形成了死锁问题。 所以在调用该函数的时候,需要传入锁资源。做到让线程在特定的条件变量下等待的时候,同时释放自己获得锁资源。当该线程被唤醒的时候,又会自动地获得该锁资源。 4. 唤醒一个/多个在等待队列中的线程 示例: 具体操作: 一开始让所有的线程在条件变量资源的等待队列中等待资源就绪,然后被唤醒。而主线程需要控制这三个线程,所以主线程需要使用条件变量唤醒函数去唤醒每一个线程。 运行结果: 可以发现在唤醒线程的时候,是按照一定的顺序一个一个唤醒的。这也就可以印证了使用条件可以让线程在资源等待队列中排队等待,而不是哪个线程的优先级高,哪个线程就运行,而是每一个线程竞争锁资源都需要排队。 可以简单的认为条件变量中有一个数据是否就绪的变量和一个等待资源的队列。 生产者消费者模型是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者之间不直接通讯,而是通过阻塞队列来进行通讯。所以生产者生产完数据不用等待消费者来消费,直接给阻塞队列即可,而消费者不用找生产者要数据而是在阻塞队列中取。其中阻塞队列就是一个缓冲区,平衡了生产者和消费者的处理能力,即通过使用一个阻塞队列就对生产者和消费者进行了解耦。 记忆方法321 在编码的时候,需要时刻维护以上的规则。 为什么要有生产者消费者模型? 可以对代码进行解耦。生产者就只是生产数据,而消费者就只是处理数据的。两者通过一个中间的缓冲区,可以几乎并发执行自己的任务。而不是有很强的关系,导致两者相互牵制,降低执行的效率。 生产者消费者模型优先 解耦支持并发支持忙闲不均
![[Pasted image 20220211115048.png]] 基于阻塞队列的生产者消费者模型示例 ^BlockQueue 任务:使用queue当做一段缓冲区,创建两个线程分别充当生产者和消费者。生产者在不断向缓冲区中添加一个随机数,消费者不断从缓冲区中获得一个数字。 为什么需要两个条件变量? 需要两个条件变量,因为虽然只有一般锁,但是生产者和消费者需要等待的条件是不一样的。只有当阻塞队列中的数据满的时候,生产者才需要等待。因为这个如果生产者不等待同时竞争力很强,它就会一直持有锁资源而什么任务页执行不了。(因为当数据满了之后,默认行为就是不做事情)。同样的,消费者之后在阻塞队列中没有数据的时候,才需要等待。因为如果没有数据同时消费者的竞争力很强,也会一直占有锁资源而什么事情也不干,导致执行效率降低。 因此需要不同的条件变量,让多线程在不同的情况下,在各自的条件变量等待队列中排队。 为什么判断阻塞队列是否为满或者为空需要使用循环判断? 阻塞队列的应用场景之一就是管道。 匿名管道是支持同步与互斥的。当一个进程往管道中一直写数据,当写满管道的时候,写端的进程就自动阻塞住了。而一个进程从管道中一直读取数据,当管道中没有数据的时候,读端级进程就自动阻塞住了。 POSIX信号量和SytemV信号量作用相同,都是线程同步操作,达到无冲突访问共享资源目的。 什么是信号量(信号灯)? 信号量可以不仅可以实现同步与互斥也可以实现更细粒度的临界资源的管理,如访问临界资源中的不同区域。 操作信号量的接口 初始化信号量 作用
初始化信号量,并且设置信号量的初始值 参数
sem:传入的信号量pshared:设置成0表示线程间共享,非0表示进程间共享value:设置信号量的初始值
销毁信号量 等待信号量 作用
等待信号量,将信号量的值减1。即P操作
发布信号量 作用
发布信号量,表示资源使用完毕,可以归还资源,将信号量加1。即V操作
使用二元信号量模拟互斥锁的功能 ^twosem 上文将过使用互斥锁进行对一个全局变量num进行--,同时需要保证临界资源的数据安全。 我们可以使用二元信号量重新做一遍,其中互斥的功能使用信号量来模拟。 基于环形队列的生产者消费者模型 ^RingQueue 示例: 要求:创建两个线程充当生产者和消费者,使用环形队列充当缓冲区。生产者向环形队列中添加数据,消费者从队形队列中取数据。其中使用信号量来保持线程之间的同步与互斥。 分析:环形队列采用数组模拟,使用模运算来模拟环状特性。其中生产者关系数组中的空间,消费者关心数组中中的数据。所以生产者需要一个blank_sem信号量,消费者需要一个data_sem信号量。 生产者和消费者在各自的信号量中等待,保持了线程之间的互斥。当其中一个信号量的计数器被加满了或者减到0,那么对应的线程就必须要被等待,直到想要获得资源恢复。这样就可以保持线程同步。 遵守的规则 信号量通过限制线程操作的次数来控制线程的。通过信号量控制的线程不可能会出现数据不一致的问题,因为不同的线程只有两种情况可能访问同一个位置,第一种是环形队列为空的是。第二种是环形队列为满的时候。而在前者data_sem为0,消费者线程不能访问,后者blank_sem为0,生产者线程不能访问。所以使用信号量控制的线程不可能同时访问队列中同一块资源,因此也就不可能出现出不一致的情况了。 概念:线程池是一种使用线程使用模式。线程过多会带来调度开销,进程影响线程局部缓存性的整体性能。而线程池中维护着多个线程,等待着管理者分配可并发执行的任务。这避免了处理短时间任务时创建于销毁线程的代价。 线程池的应用场景 示例: 要求:设置一个线程池,线程池中有一个任务队列,外界可以通过Push接口将任务(做一个简单运算的任务)交给线程池的任务队列,而线程池中可以分配多个线程去处理任务队列中的任务。 分析: 注意点: 在很多服务器开发的常见中,经常需要让服务器加载很多的数据到内存汇总,此时往往要用一个单例的类来管理这些数据。 饿汉模式实现 懒汉模式实现 懒汉模式 注意事项 STL中的容器是否是线程安全的? STL不是线程安全的。因为STL的设计初衷是将性能挖掘到机制,而一旦涉及到加锁保证线程安全,会对性能造成巨大的伤害。并且对于不同的数据结构加锁的方式不同,性能也会不同(例如:哈希表的表锁和桶锁)。因此默认STL是线程不安全的,如果需要在多线程环境下使用STL中的容器需要自己主要保护STL容器。 智能指针是否是线程安全的? 对于unique_ptr,由于只是对当前代码块范围生效,因此不涉及线程安全的问题。 对于share_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候,考虑到了这个问题,基于原子操作的方式保证share_ptr能够高效,原子的操作引用计数。 悲观锁:每次取数据时,从事担心数据会被其他线程修改,所以会在取数据前加锁,当其他线程想要访问数据的时候,会被阻塞。乐观锁:每次取数据时,总是乐观任务数据不会被其他线程修改,因此不上锁。但是在数据更新前,会怕短其他数据再更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不相等则失败,失败则重试。一般是一个自旋的过程,即不断重试。自旋锁(与挂起等待锁对应)
挂起等待锁是一个线程因为想要的资源没有目前没有,所以线程被挂起等待了。而自旋锁是一个线程在没有缺失想要资源的情况下,不断地申请紫资源而不是挂起等待。什么时候使用自旋锁取决于已经拿到锁的线程,在执行临界区的时候,要占用的时间。如果占用的时间长,那么就使用挂起等待锁,如果占用的时间短,就使用自旋锁。 这个时间的长短需要编写代码的人自己判断,一般如果临界区中需要计算大量的任务,或者读写文件,读写数据库这样的操作就是占用时间比较长的操作。所以我们在考虑锁问题的时候有三个因素:是否要加锁,锁加在哪里,临界区被线程执行的效率情况
读者写者问题
记忆方法:321关系 使用读者写者问题的场景: 读者优先/写者优先:在多线程的情况下,读写同时到来,这个时候就有两种情况。#include
创建一个线程,每隔一秒打印自己的pid和ppid,同时主线程也每隔一秒打印自己的pid和ppid。#include
2.3 线程标识
通过试验可以看出,创建的新线程和主线程的pid和ppid都是相同的,因为可以确定线程是在进程中运行的。执行ps -aL | head -1 && ps -aL | grep 文件名![[Pasted image 20220210101809.png]]其中LWP(light weight process)是轻量级进程的ID。可知其实操作系统调度的时候,访问的是LWP而不是PIDLinux中,应用层的线程与内核的LWP是一一对应的,可以通过pthread库来操作应用层的线程,而LWP会随之产生
#include
创建3个线程,分别在main执行流中和线程执行任务的执行流中打印线程的ID#include
在线程执行任务的routine中return表示线程退出
在main函数中return表示整个进程退出使用exit()函数在任何地方调用表示整个进程退出
pthread_exit#include
pthread_cancel
#include
创建3个线程,让主线程去主动取消1号2号线程,最后在main中回收这3个线程,#include
1号2号线程的退出码是-1,表示主线程主动取消1号2号线程成功。#include
创建3个线程,然后让线程自己分离主线程#include
线程等待
#include
创建3个线程,在main中回收这3个线程。#include
线程依次退出,并且退出码都是666。
#include
出现num<0的结果
#include
#include
#include
#include
进行临界资源的保护,是所有执行流都必须要遵守的标准加锁之后,对于线程来说临界区中的所有操作都是原子的。因为在锁没有申请的时候,所有的线程都去竞争这把锁。当锁被其中一个线程申请过后,其他竞争失败的线程就会自动挂起,直到锁被释放,然后再次竞争申请锁资源。所以被锁锁住的区域就对于不可见了,也就变成原子操作了。num--其实还是非原子操作,同时拿到锁资源的线程也有可能被切换走,但是此时由于其他线程没有锁资源,所以即便该线程被切换走了,其他线程也不能对临界资源造成影响,所以整体来说锁住的区域还是原子的。锁是用来保护临界资源的,同时所有线程都共享锁资源,所以锁本身也是临界资源。但是申请锁的过程是原子的,所以申请锁的过程是安全的。
互斥量实现原理
// mutex为0表示没有锁资源
// mutex为1表示有锁资源
lock:
movb 0, 寄存器
xchgb 寄存器,mutex
if (寄存器的内存 > 0) {
return 0;
} else {
挂起等待
}
unlock:
movb 1, mutex
唤醒等待mutex的线程
return 0
![[Pasted image 20220210182728.png]]
条件变量是某种临界资源是否就绪的数据化描述。int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t* restrict attr);
int pthread_cond_destroy(pthread_cond_t* cond);
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);
// 唤起多个线程
int pthread_cond_broadcast(pthread_cond_t* cond);
// 唤起一个线程
int pthread_cond_signal(pthread_cond_t* cond);
创建三个线程,利用条件变量使得主线程可以控制这三个线程。控制规则是在主线程中每按一次回车,就可以唤醒一个线程,并且让其运行。
操控三个线程一个一个执行,这个就不能光是用互斥锁来保护数据资源了,还必须要使用条件变量来具体控制。#include
![[Pasted image 20220211104952.png]]
三种关系
消费者和消费者有竞争互斥关系
两种角色
消费者在消费的时候需要竞争,然后一个一个进行消费阻塞队列中的数据,所以线程之间需要保持互斥关系
生产者和生产者有竞争互斥关系
生产者在生产的时候也需要竞争,然后一个一个往阻塞队列中生产数据,所以线程之间也需要保持互斥关系
生产者和消费者有竞争互斥关系同时也有同步协调关系
生产者和消费者在往阻塞队列中生产数据和消费数据的时候,不能同时进行,需要竞争对阻塞队列的操作权力,所以需要保持互斥关系。同时,两者之间需要协调对阻塞队列的使用权,阻塞队列被生产者生产的数据填满的时候需要主动通知消费者来消费;当消费者消费完阻塞队列中的数据的时候,需要通知生产者往阻塞队列中生产数据
生产者
一个交易场所
由线程或者进程承担该角色
消费者
由线程或者进程承担该角色
内存中的一段缓冲区,可以有不同的组织形式
// "BlockQueue.hpp"
#pragma once
#include
// "main.cc"
#include "BlockQueue.hpp"
void* Consume(void* arg)
{
auto bq = (BlockQueue
pthread_cond_wait有可能会调用失败,如果使用if判断的话,当函数调用失败的时候,就会继续指向下面的逻辑,可能会导致错误。当多个线程被唤醒的时候,就会先竞争锁,然后从上一次调用pthread_cond_wait的地方继续执行。如果使用if判断的话,那么多个线程就会同时都继续执行if判断的代码,而此时阻塞队列中的数据不够的话就会导致错误。所以需要使用while循环判断,使得多个线程会再次检测条件变量被出发的条件是否真的满足,避免了一个线程执行过就使得条件变量的触发条件不满足而其他线程还在执行下面的逻辑而导致错误。
信号量的本质就是计数器,用于描述临界资源中资源数据的计数器。
申请到信号并不是已经开始使用临界资源中的申请的空间了。而是有了使用这块空间的权限。申请/释放信号量的本质就是对计数器–或++
申请信号量就是P操作,释放信号量就是V操作
信号量是一种多线程共享资源,所以信号量也是临界资源。而信号量的PV操作是原子的,所以信号量可以保护好自己。之前讲过一个全局变量的++或者–不是原子操作,所以不能使用一个全局变量来代替信号量这个计数器。P操作有可能申请不到资源,导致线程就会阻塞,进入信号量的资源等待队列中。所以信号量可以简单理解为一个结构体中有一把锁,一个计数器和一个线程等待队列。如果信号量中的值为1,成为二元信号量。可以模拟互斥锁的功能。
#include
int sem_destroy(sem_t* sem);
// P操作
int sem_wait(sem_t* sem);
// V操作
int sem_post(sem_t* sem);
#include
生产和消费不能指向同一块空间无论是生产者还是消费者都应该套对方一个圈
// "RingQueue.hpp"
#include
// "main.cc"
#include "RingQueue.hpp"
void* Consume(void* arg)
{
auto rq = (RingQueue
需要大量线程来完成任务对性能要求苛刻的应用接收突发性的大量请求
为了保证任务队列(临界资源)的数据安全,所以需要使用互斥锁来保护任务队列。为了避免多线程的饥饿问题,所以需要使用条件变量判断当任务队列为空的时候,就让线程在条件变量的等待队列中等待。为了可以短时间中使用多线程处理任务,所以在线程池初始化的时候,需要创建多个线程去运行任务。
#pragma once
#include
#pragma once
// "Task.hpp"
class Task
{
public:
Task(){}
Task(int _x, int _y, char _op):x(_x), y(_y), op(_op)
{}
void Run()
{
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
在类内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
最核心的思想是**“延时加载”,从而能优化服务器的启动速度**。另一个好处是:任何一个时刻,保证了系统内存的使用率是最高的。有很多数据一开始不会使用到的,所以没有必须要一开始就加载这些数据到内存。
template
需要通过加锁保证在多线程下也只会new出一个对象,并且注意加锁解锁的位置双重if判断,避免不必要的锁竞争volatile关键字防止多度优化
STL,智能指针和线程安全
3种关系
读者和读者之间(没有关系)
2种角色
在生产消费模型中:消费需要取走缓冲区中的内容,所以消费者之间是互斥的关系。但是这里读者在读的过程中,只需要进行数据拷贝,所以没有读者时间没有关系。
写者和写者之间(互斥关系)读者和写者之间(互斥关系,同步关系)读者写者
1个交易场所
一段内存缓冲区
数据写入之后,剩下的操作,就是读取。写入操作少,读取操作多。(例如:登录注册,一个注册,之后全都是读取数据库中的数据)
不让读者进来,等待写者进入并且完成写的任务,这个就是“写者优先”。不让写者进来,等待读者进来并且完成读的任务,这个就是“读者优先”。



