操作系统层面以下(硬件、软件)对解决并发问题——可见性、有序性、原子性的支持

并发编程基础:线程安全问题的源头一文中介绍了多线程下产生一系列线程安全的问题源头——可见性、有序性、原子性。既然产生了线程安全问题,那么就要解决问题。本篇文章就介绍一下操作系统以下(Java是建立在操作系统以上)对解决这些问题提供的支持。

首先我们先说一下带有高速缓存的CPU执行计算的过程:

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

一、缓存一致性协议——解决CPU层面缓存一致性问题

缓存一致性协议指的是,在同一个指令周期中保证数据只在一个缓存中被修改,并同步回主内存,从而保证多CPU从内存读取到缓存中的数据一致。常见的缓存一致性协议有MSI、MESI、MOSI、Firefly、Synapse及DragonProtocol等。其中最出名的就是MESI(MESI属于窥探协议),该协议中最重要的内容有两部分:缓存行的状态以及消息通知机制。

窥探协议的基本思想

所有cache与内存,cache与cache(是的,cache之间也会有数据传输)之间的数据传输都发生在一条共享的总线上,而所有的cpu都能看到这条总线,同一个指令周期中,只有一个cache可以读写内存,所有的内存访问都要经过仲裁(arbitrate)。窥探协议的思想是,cache不但与内存通信时和总线打交道,而且它会不停地窥探总线上发生的数据交换,跟踪其他cache在做什么。所以当一个cache代表它所属的cpu去读写内存时,其它cpu都会得到通知,它们以此来使自己的cache保持同步。

缓存行的状态

缓存行有四种状态Modified、Exclusive、Shared、Invalid,而MESI 命名正是以这4中状态的首字母来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

缓存行(Cache Line)缓存的基本单位是缓存行,缓存一致性协议基于缓存行。
状态描述监听任务
I(Invalid):失效表明该缓存行已失效,它要么已经不在缓存中,要么它的内容已经过时。处于该状态下的缓存行等同于它从来没被加载到缓存中
S(Shared):共享表明该缓存行是主内存中某一段数据的拷贝,处于该状态下的缓存行只能被cpu读取,不能写入,因为此时还没有独占。不同cpu的缓存行都可以拥有这段内存数据的拷贝该缓存行必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
E(Exclusive):独占(互斥)和 Shared 状态一样,表明该缓存行是内存中某一段数据的拷贝。区别在于,该缓存行独占该主内存地址,其他处理器的缓存行不能同时持有它,如果其他处理器原本也持有同一缓存行,那么它会马上变成“Invalid”状态该缓存行必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
M(Modified):修改表明该缓存行已经被修改,缓存行只有处于Exclusive状态才能被修改。此外,已修改cache line如果被丢弃或标记为Invalid,那么先要把它的内容回写到内存中该缓存行必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回主内存,并更改状态为S。

缓存的一致性消息通知机制

我们发现,CPU有读取数据的动作,有独占的动作,有独占后更新数据的动作,有更新数据后回写内存的动作,根据”窥探协议“的规范,每个动作都需要广播通知到其他CPU,于是有以下的消息通信机制:

  1. Read(读取数据),cpu发起读取数据请求,请求中包含需要读取的数据地址;
  2. Read Response,作为Read消息的响应,该消息可能是内存响应的,也可能是某cpu响应的(比如该地址在某cpu cache Line中为Modified状态,则该cpu必须返回该地址的最新数据);
  3. Invalidate(独占),cpu发起 "我要独占一个cache line,其他cpu请失效对应的cache line" 的消息,消息中包含了内存地址,所有的其它cpu需要将对应cache line置为Invalid状态;
  4. Invalidate ACK,收到Invalidate消息的cpu在将对应cache line置为Invalid后,返回Invalid ACK;
  5. Read Invalidate(读取并独占数据),相当于Read消息+Invalidate消息,即取得数据并且独占它,将收到一个Read Response和所有其它cpu的Invalidate ACK;
  6. Write back(修改并回写数据),写回消息,即将状态为Modified的cache line写回到内存,通常在该行将被替换时使用。现代cpu cache基本都采用”写回(Write Back)”而非”直写(Write Through)”的方式。

这就是缓存一致性协议,一个状态机,仅此而已。因为该协议的存在,每个CPU就可以放心操作属于自己的cache,而不需要担心本地cache中的数据会不会已经被其他CPU修改了之类的烦心事。也就是说在多个线程共享变量的情况下,缓存一致性协议已经能够保障一个线程对共享变量的更新对其它处理器上运行的线程来说是可见的。但是需要说明的是缓存一致性协议只能保证数据的可见性,却不能保证数据的有序性和原子性。

MESI协议存在的问题

缓存的一致性消息传递是一个同步操作,是需要时间的,这就导致缓存行进行状态切换时产生延迟。从一个缓存行切换状态时发送请求指令,到其他缓存收到消息完成各自的切换并且发出回应消息,这么一长串的时间内CPU一直出于阻塞状态,直到所有缓存响应完成,才会执行下一条指令。

假如某数据存在于其他cpu的cache中,那自己每次需要修改数据时,都需要发送Read Invalidate消息,除了等待最新数据的返回,还需要等待其他cpu的Invalidate ACK才能继续执行其他指令。这个等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。

MESI优化和他们引入的问题

                                                              

为了解决CPU切换状态时的阻塞问题,避免这种CPU运算能力的浪费,硬件工程师们引入了存储缓存(store buffer)。处理器把它想要写入到主存的值写到存储缓存(store buffer),然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交(写入cache)。

可能有的同学会问,为啥不把它想要写入到主存的值写到缓存(cache)中,而是又引入了一块缓冲区。这是因为如果该数据在其他缓存行中的状态是modified,那么返回的响应数据就有可能把当前缓存行覆盖,丢失数据。

但是引入存储缓存(store buffer)会带来三个问题:

问题一

a = 1;
b = a + 1;

例如CPU0执行上述代码,但是a缓存在CPU1中,此时CPU0把a=1放到存储缓存,然后发起read invalid广播,在收到响应之前,CPU0已经执行到b=a+1;此时CPU0其实想使用的是store buffer中的a=1的值,但是store buffer没有提交到cache,所以CPU0无法使用这个值。

解决方案:因为store buffer可能导致破坏程序顺序的问题,所以为了解决这个问题,硬件工程师在store buffer的基础上,又实现了”store forwarding”技术: cpu可以直接从store buffer中加载数据,即支持将cpu存入store buffer的数据传递(forwarding)给后续的加载操作,而不经由cache。

问题二

a = 3;
void A(){
  a = 10;
  b= 3;
}
void B(){
  if(b==3){
    assert a == 10;
  }
}

CPU0执行A,CPU1执行B。假如b存在于CPU0的缓存中,当CPU0执行A时,对b的写入直接修改本地缓存,但是对a的写入需要与其他CPU或者内存通信,存在延迟,所以b比a先在cache中生效,从而导致CPU1读到b=3时,a还在CPU0的store buffer中。所以导致B中的断言失败。给人的感觉就是A被重排序了。

所以我们可以发现导致”指令重排“的其中一个原因:cpu为了优化指令的执行效率,引入了store buffer(forwarding),而又因此导致了指令执行顺序的变化。

解决方案:要保证这种顺序一致性,靠硬件是优化不了了,需要在软件层面支持,所以cpu提供了写屏障(write memory barrier)指令,Linux操作系统将写屏障指令封装成了smp_wmb()函数,cpu执行smp_mb()的思路是,会先把当前store buffer中的数据刷到cache之后,再执行屏障后的“写入操作”,该思路有两种实现方式: 一是简单地刷store buffer,但如果此时远程cache line没有返回,则需要等待,二是将当前store buffer中的条目打标,然后将屏障后的“写入操作”也写到store buffer中,cpu继续干其他的事,当被打标的条目全部刷到cache line,之后再刷后面的条目,以第二种实现逻辑为例,我们看看以下代码执行过程:

a = 3;
void A(){
  a = 10;
  smp_wmb()
  b= 3;
}
void B(){
  if(b==3){
    assert a == 10;
  }
}

CPU0执行A,CPU1执行B。假如b存在于CPU0的缓存中,当CPU0执行a=10的时侯,因为CPU0的cache中不存在a,所以将a写入到store buffer中,然后继续执行smp_wmb()内存屏障,此时CPU0会将store buffer中的所有条目进行标记;然后继续执行b=3,虽然CPU0的cache中存在b,但是因为store buffer中存在打标的数据,所以b不能被赋值,只能也被写入store buffer中,CPU0继续干其他的事,当被打标的条目全部刷到cache line,之后再刷后面的条目,这样一来就保证A顺序执行,不会被重排序。

问题三

 store buffer的大小是有限的,所有的写入操作发生cache missing(数据不再本地)都会使用store buffer,特别是出现写屏障时,后续的所有写入操作(不管是否cache missing)都会挤压在store buffer中(直到store buffer中屏障前的条目处理完),因此store buffer很容易会满,当store buffer满了之后,cpu还是会卡在等对应的Invalidate ACK以处理store buffer中的条目。因此还是要回到Invalidate ACK中来,Invalidate ACK耗时的主要原因是cpu要先将对应的cache line置为Invalid后再返回Invalidate ACK,一个很忙的cpu可能会导致其它cpu都在等它回Invalidate ACK。

解决方案:化同步为异步,引入失效队列(Invalid Queue),cpu不必处理了cache line之后才响应Invalidate ACK,而是可以先将Invalid消息放到某个请求队列Invalid Queue,然后再响应Invalidate ACK。

                                                   preview

 

引入了失效队列。它们的约定如下:

  • 对于所有收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送;
  • Invalidate并不真正执行,而是被放在失效队列中,在方便的时候才会去执行(其实具体什么时候执行CPU也没法确认);
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate请求。

但是就算引入失效队列,CPU也不知道什么时候才应该去处理Invalidate请求,也就是说通过硬件已经不能继续进行优化了,所以干脆处理器将这个任务丢给了写代码的人,让程序员决定什么时候执行。这就是内存屏障(Memory Barriers)——读屏障、写屏障、全屏障。内存屏障是硬件之上,操作系统之下对一致性的最后一层保障。

写屏障 Store Memory Barrier(SMB, smp_wmb):是一条告诉处理器在执行该指令之后的指令之前,先处理提交所有已经保存在存储缓存(store buffer)中的数据的指令。

理解为该指令是开始将store buffer中的所有数据刷新到Cache的信号,处理完之后再继续下面的操作。

读屏障 Load Memory Barrier (RMB, smp_rmb):是一条告诉处理器在执行任何的加载前,先处理所有已经在失效队列中的失效操作的指令。

理解为该条指令是开始处理invalid queue中所有无效指令的信号,处理完无效队列中的操作后再继续下面的操作

全屏障 Memory Barrier (MB, smp_mb):同时具有读屏障和写屏障功能的指令。

和smp_wmb()类似,cpu执行smp_rmb()的时侯,会先把当前invalidate queue中的数据处理掉之后,再执行屏障后的“读取操作”。

经过这一系列软件和硬件方面的优化,终于对缓存一致性优化完成。

二、内存屏障——解决重排序问题

内存屏障(Memory Barrier)是一类同步屏障指令,用于控制特定条件下的重排序和内存可见性问题。它是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。内存屏障是在软件层面保证指令执行的有序性。

处理器层面的乱序优化节省了大量等待时间,提高了处理器的性能。所谓“乱序”只是被叫做“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。所以重排序在单核时代是非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。

在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。但是,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

通过引入内存屏障,完美的解决了重排序问题,在此不再分析。

三、原子性

CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性,比如Java中提供的synchronized。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值