GoLang之[os浅尝]同步的本质

1.并发、并行

并形是的是能够在同一时刻同时运行,真正的并行只能够在多核场景下能够实现,而单核场景下,同一时刻真正执行的线程只有一个,但是由于多任务操作系统的时间片调度机制,每个线程每次只能运行一个时间片便会中断,中断发生时其他线程便有机会得到调度执行,而切换程序的时间间隔很短,短到我们根本感觉不到程序发生过终端,就好像他们一直在并行执行一样。
但是无论是单核还是多核,“真并行”还是“伪”并行,程序执行起来并不像我们感觉到的那样连续,而是会因为硬件中断、IO等原因,而表现为断断续续的执行

在这里插入图片描述

在这里插入图片描述

2.银行取款问题

2.1单核并发(正常)

所以,当多个线程并发执行,并且它们会操作共享数据时,因为不确定线程会在执行哪一步中断,就可能会出现一些意想不到的、玄之又玄的问题。
例如经典的银行部取款问题,假设一个账户,余额为500,A和B都要从这个账户取款300元,我们把对应的两个操作线程记为线程a和线程b,

在这里插入图片描述

先来整理一下它们的主要操作步骤:
第一步,读取账户余额,写入寄存器
第二步,判断账户余额是否充足;
第三步,账户余额充足,计算新余额写入寄存器;
第四步,将新余额写入账户;
线程a和线程b都要按顺序执行上面的步骤,
先来看单核的场景:

在这里插入图片描述

假设线程a先执行,如果a执行完所有的步骤后b再执行,那么B执行到第二步时,会因为余额不足而取款失败,所以不会有任何问题。

在这里插入图片描述

2.2单核并发(中断)

但实际情况是a可能还没有执行完上面的步骤就被打断了。
假设a执行完第三步时被打断,它会保存自己的执行现场,其中就包括这些寄存器的值,

在这里插入图片描述

然后线程B开始执行,将账户余额500写入寄存器,判断余额充足,计算新余额写入寄存器,假设B比较幸运,执行完了第四步,此时账户余额被修改为200,

在这里插入图片描述

接下来线程a恢复执行,继续执行第四步,将金额200写入账户。

在这里插入图片描述

所以最终结果就是一个余额为500的账户,不仅取出了600块,还有200块的余额

2.3多核并行(错误)

再来看多核并行执行的场景,线程A和线程B可能同时执行,分别读取账户余额,写入各自的寄存器,都判断余额大于300可以取款。然后都将新的账户余额200写入账户,最终结果与单核的场景相同

在这里插入图片描述

2.4并发问题

多个线程操作共享数据的代码段就是所谓的临界区,因为多个线程在临界区的操作导致程序出现问题,就是典型的并发问题。出现并发问题的根本原因是:"cpu修改了共享数据之后"到"这一变化反映到内存被其他线程可见"之间是存在时间差的。

在这里插入图片描述

2.5单核并发(中断)–分析

所以在银行取款的例子中,单核场景下,因为线程a对共享数据的修改还没来得及写入内存,线程B就进入了临界区读取了共享数据,所以,所以线程a已经将账户余额修改为200,而刚刚进入临界区的线程b读取到的账户余额却依然是500,这就出现了问题。

2.6多核并行(错误)–分析

再来看多核并行的场景,在多核CPU中,每个核只能访问和操作自己的寄存器,每个核的数据对其他核是不可见的,所以对于银行取款例子中,并行执行在不同核上的的线程a和线程B,若一方不能在对方完成临界区的操作后再进入临界区,就可能看不到对方对共享数据的修改,因而出现并发问题。

3.同步机制

避免出现并发问题的关键就是引入“同步机制”。单核场景下,只要保证一个线程进入临界区后,在临界区操作完成前不会有其它线程进入临界区就可以实现同步,这就需要实现临界区操作的互斥性。

这不可避免地要得到硬件的支持,因为任何软件层面的指令序列都可能因硬件中断而打断。只有在硬件层面的原子性指令才能保障在执行期间不会插入其他指令,例如compare and swap(CAS)、 flash and add以及test and set等等。但是硬件层面不可能对所有临界区的任务都提供相应的原子指令。不过,利用现有的原子指令,我们可以实现对一个整型变量的原子操作,这样就可以实现“锁”,

在这里插入图片描述

4.锁

“锁“本质上就是一个变量,硬件提供的原子指令,让线程可以原子性的改变这个变量到某个状态,这样就算“获得锁"。相应地,释放锁就需要原子性的将该变量恢复到某个状态。如果线程只有在获得锁之后才可以进入临界区执行,即使因为硬件中断等原因导致持有锁的线程中断执行,其他线程也会因无法获得锁而不能进入临界区持有者

在这里插入图片描述

5.单核同步

持有锁的线程,只有在完成临界区的操作后才会释放,那么同一时刻就只有一个线程能够进入临界区,这是单核的场景,只要保证临界区的互斥性就能实现同步。

6.多核同步

但是在多核场景下,仅仅依靠CAS这一类硬件原语与并不能实现同步。要知道,即使是原子指令,底层实现也可能包括多条微指令,而原子指令的原子性是相对于一个CPU核而言的,也就是说,硬件可以把保障在一个CPU核上。原子指令执行期间,中间不会插入其他指令,但是若并行在两个核上的线程a和线程b,同时尝试获得,都要执行原子指令。

在这里插入图片描述

总线这里接收到的指令序列可能是这样的,线程a的读指令、线程b的读指令、线程a的写指令、线程b的写指令,虽然两条原子指令在各自的CPU核上,都是原子执行的,但最终执行的结果却是线程a和线程b都获得了锁,都进入了临界区,

在这里插入图片描述

要解决这个问题,就需在获得锁之前将总线锁住,这样就只能有一个执行体能够获得锁了

在这里插入图片描述

例如在Go语言中Mutex的实现,就会在执行CMPXCHGL指令之前,LOCK总线,

在这里插入图片描述

7.同步的本质

所以说,同步的本质就是实现临界区操作的互斥性,

在这里插入图片描述

而在多核场景下,一旦锁住总线,要进入临界区的多个线程实际上就由并行转为串行了,这必然会影响性能,但是现代CPU都拥有告诉缓存,不再通过锁的方式来实现多核间同步,为了保证多核间告诉缓存的一致性,引入了MES协议(高速缓存一致性协议),对我们而言这和总线的效果是一样的~

在这里插入图片描述