文章目录一、进程线程间的互斥相关背景概念二、互斥量mutexticket减到负数现象分析ticket--代码分析解决思路互斥量的接口初始化互斥量销毁互斥量互斥量加锁和解锁利用互斥量解决问题衍生出的3个问题从软件层面的原子性理解互斥锁互斥锁使用的最佳实践三、互斥的硬件/软件实现及原理四、互斥锁的封装
一、进程线程间的互斥相关背景概念临界资源:多线程执⾏流共享的资源就叫做临界资源临界区:每个线程内部,访问临界资源的代码,就叫做临界区互斥:用来保护临界区,任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,进而对临界资源起保护作⽤。当多个执行流向同一个共享资源做写入并且没有加保护,数据会发生写入错乱、覆盖等问题时就叫做数据写入不一致。(这也是多线程代码一定会面临的问题,因为多线程大部分资源都是共享的)原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。(原子性在多线程场景中并不是一种特性,而是一种结果,正是有了互斥的存在,故而有了访问资源的原子性)补充:1、互斥保护资源的本质是把多线程访问资源由并发执行转化为串行执行,所以互斥是可能会降低程序运行效率的,2、多线程共享资源不会发生数据不一致问题,多线程并发访问共享资源才会发生数据不一致问题。所以多线程并不是所有共享资源都需要被保护,被访问的共享资源才需要被保护。二、互斥量mutex为了实现互斥,所以需要引入一种新的数据类型:互斥量(互斥锁),它是完成互斥保护的数据对象和配套的方法。我们头一次接触互斥量很可能不知所云,所以小编先提出一个不用互斥的异常现象,然后介绍一种解决方案,自然就会引出互斥量了。我们看下面这个多线程并发访问一个全局变量的代码,它是模拟的一个抢票逻辑,每一个线程代表一个用户:#include
首先我们看访问共享变量ticket的核心代码,如上图红框框起来的部分,我们称之为临界区,理解区中右两个访问共享资源的地方:ticket > 0 和 ticket–,无论是ticket > 0这样判断真假的逻辑运算还是ticket–这样的算术运算都需要由CPU来完成。(重要补充:同一进程的多个线程是共享进程地址空间和页表的,所以多线程访问ticket时本质都是访问的同一个在物理内存中的tickct变量)所以对于代码中的逻辑运算和算术运算都需要经过三个步骤:1、从物理内存中取数据、取汇编指令拷贝到CPU的寄存器中。2、分析指令,是算术运算还是逻辑运算,是加运算还是减运算…3、CPU中的算逻运算器执行指令。4、(部分情况有该步骤)将运算结果覆盖式拷贝回物理内存,针对要修改变量本身的指令,如++,–。操作系统视角:
上面我们只是站在计算机组成原理的角度来分析问题,下面我们站在操作系统视角在分析一下整个过程,首先我们要知道CPU执行上面的取指令、分析指令、执行指令的过程都会以进程或线程为载体执行,也就是CPU取的指令,取的数据是线程的数据,CPU执行的这些操作本质是线程的代码要求CPU这样做的。所以当CPU把物理内存中的数据拷贝到CPU的寄存器,本质就是把共享资源拷贝到线程硬件上下文,我们知道,线程的硬件上下文是线程私有的,所以CPU拷贝数据本质就是把共享数据变成线程的私有数据,这是数据不一致的基础,也就是单个线程在CPU中对共享数据做修改时是不会影响共享数据本身的。当ticket被减到1后,多个线程同时进入该临界区,判断ticket > 0条件成立,多个线程同时进入if条件判断,然后多个线程串行执行if内部代码逻辑,第一个线程把物理内存中ticket位置的变量1拷贝到CPU中,完成ticket–后把结果0拷贝回物理内存的ticket位置,接着第一个线程被切走,然后把自己的硬件上下文带走,然后OS调度第二个线程执行if内部代码逻辑,首先它把物理内存中已经被改为0的ticket变量拷贝到CPU寄存器中,执行ticket–后把结果-1拷贝回物理内存的ticket位置,然后调用printf就打印出-1了。所以票数被减到负数本质原因if判断导致的,就是多线程并发访问临界区并且多个线程进入if判断条件中。ticket–代码分析现在我们把票数减到负的原理谈清楚了,现在还有一个问题,ticket–这个操作是线程安全的吗?答案是否定的,因为ticket–并不是原子的,它经过汇编之后会转化成三条指令,如下图所示:假设当线程a第一次执行ticket–,刚刚执行到第二条汇编指令sub就因某种原因被切走了,这时操作系统会执行上下文保存,把线程 a 当前所有寄存器(包括存着1000的%eax)、程序计数器(PC/RIP)等状态,完整保存到该线程的线程控制块(TCB)中(实际是轻量级进程),然后OS调度线程b执行代码逻辑,假设又因为某种原因线程b一直执行代码把ticket减到0了,并把0写回了物理内存的ticket中,接着线程b被切走,线程a被切回,此时OS会将线程a被暂停时保存的寄存器状态恢复到CPU寄存器中,那么此时CPU不会去读取物理内存中ticket的最新值(0),而是直接恢复 “线程 a 暂停时的旧值1000”,这时CPU在执行 ticket–就得到了999,并把999写到物理内存的ticket中。通过上面的现象我们能得出一个结论:类似ticket这样的全局变量式的计数器不是线程安全的,会有并发问题。解决思路所以要解决上述问题,本质就需要对红框代码进行保护,形成临界区,需要我们用代码的形式来保护,因为我们既然能写访问共享资源的代码,就一定也能写保护共享资源的代码。为了解决这一问题pthread库为我们提供了互斥锁的概念,并且它一定是可编程的,下面我们先见一见它的接口,后面再讲原理。互斥量的接口1、使用互斥量相关接口需要包含pthread头文件,pthread_mutex_t是pthread为我们提供的一种数据类型。2、以pthread开头的函数大部分返回值都是整型,为0表示成功,其他整数表示出错原因。初始化互斥量定义的互斥量是静态的或全局的:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 在栈上、堆上动态开辟的互斥量:intpthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);第二个参数是设置互斥锁属性,我们不关心,置为nullptr即可。销毁互斥量使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的全局互斥量不需要销毁。不要销毁⼀个已经加锁的互斥量。已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁。intpthread_mutex_destroy(pthread_mutex_t *mutex); 互斥量加锁和解锁intpthread_mutex_lock(pthread_mutex_t *mutex);intpthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误号利用互斥量解决问题衍生出的3个问题1、gmutex本身不就是全局变量吗?它要保证别人是线程安全的,它自己怎么保证自己是线程安全的呢?答:让加锁和解锁操作本身具有原子性就可以保证互斥锁本身是线程安全的。2、所有进入临界区线程都要加锁吗?可以有例外吗?答:不能有例外,只要进入是临界区的线程必须全部加锁!!线程申请锁成功后能继续向后执行代码,若线程申请锁失败就会在申请锁的位置被阻塞。3、如果一个线程在申请了互斥锁之后进入临界区,当它在临界区执行代码时可以被OS切走吗?会因为切换导致其他线程进入临界区造成并发问题吗?答:线程可以被切走,但是切走后其他线程因申请该锁时会被阻塞进而无法进入临界区造成并发问题,因为线程是在持有锁的状态被切走的,它并没有释放锁,所以其他线程无法申请锁自然无法进入临界区。这也是为什么加锁后程序运行效率较慢的原因之一。从软件层面的原子性理解互斥锁对于一个申请了互斥锁的线程来讲,它对外本质就只有两种状态:未执行临界区代码和执行临界区代码完毕,因为在访问期间该线程是不可被打扰的,所以该线程访问临界区的过程就在逻辑上具有原子性。这里的原子性是在加锁之后产生的结果。这就是软件层面的原子性。互斥锁使用的最佳实践因为锁会降低程序效率,所以加锁和解锁时囊括的临界区尽量是最小集。三、互斥的硬件/软件实现及原理互斥本质就是要让线程的每个操作具有原子性,对于互斥的实现一般有硬件和软件两个层面的实现方案:1、硬件实现:互斥需要保证线程操作具有原子性,要解决这一问题就要在线程运行时避免外界因素中断/干扰线程的执行,除了线程调用如read系统调用阻塞等待时主动出让CPU资源以外,外界会频繁干扰线程的执行因素就是各种中断如时钟中断,外设中断,所以硬件实现互斥的方案之一就是关闭中断。2、软件实现:互斥锁的软件实现需要基于下面这个基本点:一条汇编语句本身是原子的。为了实现互斥锁操作,⼤多数体系结构(如X86,arm等等)都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,下面是加锁和解锁操作的汇编指令:虽然pthread库对互斥锁做了封装,我们先简单把互斥锁mutex当成一个整型变量,值为1,加锁第一步线程会先将cpu中的al寄存器中的值置为1,然后将al寄存器中的值和互斥锁mutex值做交换,这样1就换到了al寄存器中,内存中的mutex变量交换后就变成0了,我们要理解这里交换操作的本质:以非拷贝的形式把1这个数字由共享变为私有,因为这样1就会变成申请锁线程的硬件上下文。线程继续执行后面的if判断,判断成立返回0申请锁成功。线程在申请锁的过程中是可能随时被切走的,但是不用担心会发生并发问题,因为如果一个线程已经将“1”交换进了al寄存器,当该线程被切走时会将“1”一并带走,其他线程被OS调度切回时也会执行申请锁操作,但此时的mutex值为0,即使交换后al寄存器的内容仍为0,if判断不成立就会被OS挂起等待,所以线程是否申请锁成功就在于它是否成功执行了exchange指令把“1”换进自己的硬件上下文。数据的交换而非拷贝保证了“1”只有一份,所以我们可以把这个“1”看作是访问临界区的钥匙或者令牌。四、互斥锁的封装下面我们来对pthread的互斥锁做一下面向对象化的封装,类似于C++的互斥锁。但是小编不会只做简单的封装,而是实现一个RAII分割的互斥锁。//Mutex.hpp#pragmaonce#include