线程同步机制之底层原子实现方式

临界区

提到并发编程,首先就想到临界区(critical section)这个概念,临界区是线程中访问临界资源的一段需要互斥执行的代码。

临界资源

临界资源是指线程之间共享的资源,但不同的执行序列结果不确定的,这也叫做竞态条件(race condition)。

线程同步机制之底层原子实现方式

编程基本逻辑封装原子操作-->信号量-->实现临界区-->管程

保证同一时刻只有一个人拿到钥匙(原子性)

多线程的三个特性:原子性、可见性、有序性 (详细参考:多线程的三个特性这篇文章

如果只能一个人在屋子里,那么进去之后就锁上,出来的时候再打开锁;没有锁的人只能在外面等着。怎么保证同一时刻只有一个线程进入临界区,这个时候就需要保证线程的原子性。

  • 禁用硬件中断:

我们知道,系统调用以及执行流程的切换都是依靠软中断。禁用中断之后,进程(线程)就不会被切换出去,从而保证代码段能执行结束。但坏处也很明显,由于中断被禁用,如果临界区代码一直执行,其他进程就没机会执行了。而且,只能禁止单个CPU的中断。

  • 基于软件同步:

即基于代码实现同步互斥,比较有名的是peterson算法,用来解决两个进程对临界区的互斥访问问题。详细参考:实现临界区互斥的算法方法演变这篇文章

  • 基于原子操作原语的方法:

比较常见的原子操作指令包括 test and set、compare and swap。

示例:compare and swap

CAS,compare and swap的缩写,中文翻译成比较并交换,在intel的CPU中,使用cmpxchg指令。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

很多编程语言锁机制都是基于cas封装,如下图。

线程同步机制之底层原子实现方式

更多关于CAS详细参考:CAS详解和应用这篇文章。

话又说回来了,计算机怎么保证这些指令的原子性的呢,继续深挖,另一位登场?

CPU缓存模型

我们知道,CPU和物理内存之间的通信速度远慢于CPU的处理速度,所以CPU有自己的内部缓存,根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。我们假设在一台PC上只有一个CPU和一份内部缓存,那么所有进程和线程看到的数都是缓存里的数,不会存在问题;但现在服务器通常是多 CPU,更普遍的是,每块CPU里有多个内核,而每个内核都维护了自己的缓存,那么这时候多线程并发就会存在缓存不一致性,这会导致严重问题。

线程同步机制之底层原子实现方式

线程同步机制之底层原子实现方式

带有高速缓存的CPU执行计算的流程

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存。

更多详细请参考:cpu模型和JMM模型,jvm内存模型

处理器如何实现原子操作

32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。

线程同步机制之底层原子实现方式

1 处理器自动保证基本内存操作的原子性

首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

2 使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。

3 使用缓存锁保证原子性(缓存一致性协议MESI

这个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

在同一时刻,我们只需要保证对某个内存地址的操作是原子性即可,频繁使用的内存会缓存到处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定(锁定缓存行不锁定总线),那么当它执行锁操作回写到内存时,处理器不在总线上发出LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写被锁定的缓存行的数据时,会使缓存行无效。

但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。

缓存一致性协议MESI

MESI 协议是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

  • M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
  • E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
  • S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
  • I:无效的。本CPU中的这份缓存已经无效。

详细请参考:缓存一致性协议MESI这篇文章

参考

https://www.cnblogs.com/kkkkkk/p/5543799.html

https://blog.csdn.net/liangwenmail/article/details/80832580

https://blog.csdn.net/java1993666/article/details/77880651

https://www.cnblogs.com/yanlong300/p/8986041.html

https://blog.csdn.net/weixin_41835916/article/details/80601373

https://blog.csdn.net/vincent1007/article/details/53999136

https://blog.csdn.net/u014634338/article/details/76092710


分享到:


相關文章: