深刻理解原子操做的本质

原文地址:https://blog.fanscore.cn/p/34/linux

引言

本文以go1.14 darwin/amd64中的原子操做为例,探究原子操做的汇编实现,引出LOCK指令前缀可见性MESI协议Store BufferInvalid Queue内存屏障,经过对CPU体系结构的探究,从而理解以上概念,并在最终给出一些事实。c++

Go中的原子操做

咱们以atomic.CompareAndSwapInt32为例,它的函数原型是:git

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

对应的汇编代码为:github

// sync/atomic/asm.s 24行
TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Cas(SB)

经过跳转指令JMP跳转到了runtime∕internal∕atomic·Cas(SB),因为架构的不一样对应的汇编代码也不一样,咱们看下amd64平台对应的代码:golang

// runtime/internal/atomic/asm_amd64.s 17行
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
	MOVQ	ptr+0(FP), BX // 将函数第一个实参即addr加载到BX寄存器
	MOVL	old+8(FP), AX // 将函数第二个实参即old加载到AX寄存器
	MOVL	new+12(FP), CX // // 将函数第一个实参即new加载到CX寄存器
	LOCK // 本文关键指令,下面会详述
	CMPXCHGL	CX, 0(BX) // 把AX寄存器中的内容(即old)与BX寄存器中地址数据(即addr)指向的数据作比较若是相等则把第一个操做数即CX中的数据(即new)赋值给第二个操做数
	SETEQ	ret+16(FP) // SETEQ与CMPXCHGL配合使用,在这里若是CMPXCHGL比较结果相等则设置本函数返回值为1,不然为0(16(FP)是返回值即swapped的地址)
	RET // 函数返回

从上面代码中能够看到本文的关键:LOCK。它实际是一个指令前缀,它后面必须跟read-modify-write指令,好比:ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG算法

LOCK实现原理

在早期CPU上LOCK指令会锁总线,即其余核心不能再经过总线与内存通信,从而实现该核心对内存的独占。缓存

这种作法虽然解决了问题可是性能太差,因此在Intel P6 CPU(P6是一个架构,并不是具体CPU)引入一个优化:若是数据已经缓存在CPU cache中,则锁缓存,不然仍是锁总线。注意,这里所说的缓存锁实际是缓存一致性的效果,下面咱们先讲下缓存一致性的问题再回头看缓存一致性是如何实现缓存锁的效果的。架构

Cache Coherency

CPU Cache与False Sharing 一文中详细介绍了CPU缓存的结构,CPU缓存带来了一致性问题,举个简单的例子:并发

// 假设CPU0执行了该函数
var a int = 0
go func fnInCpu0() {
    time.Sleep(1 * time.Second)
    a = 1 // 2. 在CPU1加载完a以后CPU0仅修改了本身核心上的cache可是没有同步给CPU1
}()
// CPU1执行了该函数
go func fnInCpu1() {
    fmt.Println(a) // 1. CPU1将a加载到本身的cache,此时a=0
    time.Sleep(3 * time.Second)
    fmt.Println(a) // 3. CPU1从cache中读到a=0,但此时a已经被CPU0修改成0了
}()

上例中因为CPU没有保证缓存的一致性,致使了两个核心之间的同一数据不可见从而程序出现了问题,因此CPU必须保证缓存的一致性,下面将介绍CPU是如何经过MESI协议作到缓存一致的。app

MESI是如下四种cacheline状态的简称:

  • M(Modified):此状态为该cacheline被该核心修改,而且保证不会在其余核心的cacheline上
  • E(Exclusive):标识该cacheline被该核心独占,其余核心上没有该行的副本。该核心可直接修改该行而不用通知其余核心。
  • S(Share):该cacheline存在于多个核心上,可是没有修改,当前核心不能直接修改,修改该行必须与其余核心协商。
  • I(Invaild):该cacheline无效,cacheline的初始状态,说明要么不在缓存中,要么内容已过期。

核心之间协商通讯须要如下消息机制:

  • Read: CPU发起数据读取请求,请求中包含数据的地址
  • Read Response: Read消息的响应,该消息有多是内存响应的,有多是其余核心响应的(即该地址存在于其余核心上cacheline中,且状态为Modified,这时必须返回最新数据)
  • Invalidate: 核心通知其余核心将它们本身核心上对应的cacheline置为Invalid
  • Invalidate ACK: 其余核心对Invalidate通知的响应,将对应cacheline置为Invalid以后发出该确认消息
  • Read Invalidate: 至关于Read消息+Invalidate消息,即当前核心要读取数据并修改该数据。
  • Write Back: 写回,即将Modified的数据写回到低一级存储器中,写回会尽量地推迟内存更新,只有当替换算法要驱逐更新过的块时才写回到低一级存储器中。

手画状态转移图

image.png

这里有个存疑的地方:CPU从内存中读到数据I状态是转移到S仍是E,查资料时两种说法都有。我的认为应该是E,由于这样另一个核心要加载副本时只须要去当前核心上取就好了不须要读内存,性能会更高些,若是你有不一样见解欢迎在评论区交流。

一些规律

  1. CPU在修改cacheline时要求其余持有该cacheline副本的核心失效,并经过Invalidate ACK来接收反馈
  2. cacheline为M意味着内存上的数据不是最新的,最新的数据在该cacheline上
  3. 数据在cacheline时,若是状态为E,则直接修改;若是状态为S则须要广播Invalidate消息,收到Invalidate ACK后修改状态为M;若是状态为I(包括cache miss)则须要发出Read Invalidate

Store Buffer

当CPU要修改一个S状态的数据时须要发出Invalidate消息并等待ACK才写数据,这个过程显然是一个同步过程,但这对于对计算速度要求极高的CPU来讲显然是不可接受的,必须对此优化。
所以咱们考虑在CPU与cache之间加一个buffer,CPU能够先将数据写入到这个buffer中并发出消息,而后它就能够去作其余事了,待消息响应后再从buffer写入到cache中。但这有个明显的逻辑漏洞,考虑下这段代码:

a = 1
b = a + 1

假设a初始值为0,而后CPU执行a=1,数据被写入Store Buffer尚未落地就紧接着执行了b=a+1,这时因为a尚未修改落地,所以CPU读到的仍是0,最终计算出来b=1。

为了解决这个明显的逻辑漏洞,又提出了Store Forwarding:CPU能够把Buffer读出来传递(forwarding)给下面的读取操做,而不用去cache中读。
image.png

这却是解决了上面的漏洞,可是还存在另一个问题,咱们看下面这段代码:

a = 0
flag = false
func runInCpu0() {
    a = 1
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

对于上面的代码咱们假设有以下执行步骤:

  1. 假定当前a存在于cpu1的cache中,flag存在于cpu0的cache中,状态均为E。
  2. cpu1先执行while(!flag),因为flag不存在于它的cache中,因此它发出Read flag消息
  3. cpu0执行a=1,它的cache中没有a,所以它将a=1写入Store Buffer,并发出Invalidate a消息
  4. cpu0执行flag=true,因为flag存在于它的cache中而且状态为E,因此将flag=true直接写入到cache,状态修改成M
  5. cpu0接收到Read flag消息,将cache中的flag=true发回给cpu1,状态修改成S
  6. cpu1收到cpu0的Read Response:flat=true,结束while(!flag)循环
  7. cpu1打印a,因为此时a存在于它的cache中a=0,因此打印出来了0
  8. cpu1此时收到Invalidate a消息,将cacheline状态修改成I,但为时已晚
  9. cpu0收到Invalidate ACK,将Store Buffer中的数据a=1刷到cache中

从代码角度看,咱们的代码好像变成了

func runInCpu0() {
    flag = true
    a = 1
}

好像是被从新排序了,这实际上是一种 伪重排序,必须提出新的办法来解决上面的问题

写屏障

CPU从软件层面提供了 写屏障(write memory barrier) 指令来解决上面的问题,linux将CPU写屏障封装为smp_wmb()函数。写屏障解决上面问题的方法是先将当前Store Buffer中的数据刷到cache后再执行屏障后面的写入操做。

SMP: Symmetrical Multi-Processing,即多处理器。

这里你可能好奇上面的问题是硬件问题,CPU为何不从硬件上本身解决问题而要求软件开发者经过指令来避免呢?其实很好回答:CPU不能为了这一个方面的问题而抛弃Store Buffer带来的巨大性能提高,就像CPU不能由于分支预测错误会损耗性能增长功耗而放弃分支预测同样。

仍是以上面的代码为例,前提保持不变,这时咱们加入写屏障:

a = 0
flag = false
func runInCpu0() {
    a = 1
    smp_wmb()
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

当cpu0执行flag=true时,因为Store Buffer中有a=1尚未刷到cache上,因此会先将a=1刷到cache以后再执行flag=true,当cpu1读到flag=true时,a也就=1了。

有文章指出CPU还有一种实现写屏障的方法:CPU将当前store buffer中的条目打标,而后将屏障后的“写入操做”也写到Store Buffer中,cpu继续干其余的事,当被打标的条目所有刷到cache中,以后再刷后面的条目。

Invalid Queue

上文经过写屏障解决了伪重排序的问题后,还要思考另外一个问题,那就是Store Buffer size是有限的,当Store Buffer满了以后CPU仍是要卡住等待Invalidate ACK。Invalidate ACK耗时的主要缘由是CPU须要先将本身cacheline状态修改I后才响应ACK,若是一个CPU很繁忙或者处于S状态的副本特别多,可能全部CPU都在等它的ACK。

CPU优化这个问题的方式是搞一个Invalid Queue,CPU先将Invalidate消息放到这个队列中,接着就响应Invalidate ACK。然而这又带来了新的问题,仍是以上面的代码为例

a = 0
flag = false
func runInCpu0() {
    a = 1
    smp_wmb()
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

咱们假设a在CPU0和CPU1中,且状态均为S,flag由CPU0独占

  1. CPU0执行a=1,由于a状态为S,因此它将a=1写入Store Buffer,并发出Invalidate a消息
  2. CPU1执行while(!flag),因为其cache中没有flag,因此它发出Read flag消息
  3. CPU1收到CPU0的Invalidate a消息,并将此消息写入了Invalid Queue,接着就响应了Invlidate ACK
  4. CPU0收到CPU1的Invalidate ACK后将a=1刷到cache中,并将其状态修改成了M
  5. CPU0执行到smp_wmb(),因为Store Buffer此时为空因此就往下执行了
  6. CPU0执行flag=true,由于flag状态为E,因此它直接将flag=true写入到cache,状态被修改成了M
  7. CPU0收到了Read flag消息,由于它cache中有flag,所以它响应了Read Response,并将状态修改成S
  8. CPU1收到Read flag Response,此时flag=true,因此结束了while循环
  9. CPU1打印a,因为a存在于它的cache中且状态为S,因此直接将cache中的a打印出来了,此时a=0,这显然发生了错误。
  10. CPU1这时才处理Invalid Queue中的消息将a状态修改成I,但为时已晚

为了解决上面的问题,CPU提出了读屏障指令,linux将其封装为了smp_rwm()函数。放到咱们的代码中就是这样:

...
func runInCpu1() {
    while (!flag) {
   	continue
    }
    smp_rwm()
    print(a)
}

当CPU执行到smp_rwm()时,会将Invalid Queue中的数据处理完成后再执行屏障后面的读取操做,这就解决了上面的问题了。

除了上面提到的读屏障和写屏障外,还有一种全屏障,它实际上是读屏障和写屏障的综合体,兼具两种屏障的做用,在linux中它是smp_mb()函数。
文章开始提到的LOCK指令其实兼具了内存屏障的做用。

回头看LOCK指令

如今想必你已经理解了缓存一致性,那么咱们梳理一下在遵循了MESI协议有Store Buffer和Invalid Queue的CPU上缓存一致性是如何实现缓存锁的效果的。

假设CPU0 cache中存在i,CPU0要执行i++,那么有如下几种可能性:

  • cacheline状态为E,CPU0直接从cache中读到i而后+1后写回到cache,cacheline状态修改成M。这种状况下i为CPU0独占,其余要修改势必要付出Invalidate消息,可是不会获得ACK的,因此这个过程不受其余核心影响,因此是“原子性”的。
  • cacheline状态为M,与上面相同
  • cacheline状态为S,CPU0要执行i++,可是读到了LOCK指令,它有写屏障的做用,因此不会写到Store Buffer而是直接发出Invalidate i消息,这时若是其余核心虽然有Invalid Queue可是由于LOCK指令具备读屏障的做用因此也不会写入到Invalid Queue中,而是直接将本身cache中的i状态置为S,而后返回Invalidate ACK。CPU0收到ACK执行i=0+1,将i写回cache中,状态修改成M。其余核心若是也要修改的话会被总线给ban掉,因此这个过程也不会被其余核心干扰,因此也是“原子性”的。
  • cacheline状态为I,状态I等价于cache miss,因此不需考虑。

几个问题

问题1: CPU采用MESI协议实现缓存同步,为何还要LOCK

答:

  1. MESI协议只是一个协议,有些CPU可能没有遵循这个协议,或者没有实现强一致性只实现了最终一致性,就好比上文提到的Store Buffer、Invalid Queue的引入就致使由强一致性变成了最终一致性,所以须要经过LOCK指令告知CPU在这里必须给我放弃性能考虑来保证强一致性。
  2. MESI协议只管缓存,可能还有其余的组件影响了执行顺序,好比由于CPU流水线气泡的问题指令会乱序执行。

问题2: 一条汇编指令是原子性的吗

  1. read-modify-write 内存的指令不是原子性的,以INC mem_addr为例,咱们假设数据已经缓存在了cache上,指令的执行须要先将数据从cache读到执行单元中,再执行+1,而后写回到cache。
  2. 对于没有对齐的内存,读取内存可能须要屡次读取,这不是原子性的。(在某些CPU上读取未对齐的内存是不被容许的)
  3. 其余未知缘由...

问题3: Go中的原子读

咱们看一个读取8字节数据的例子,直接看golang atomic.LoadUint64()汇编:

// uint64 atomicload64(uint64 volatile* addr);
1. TEXT runtime∕internal∕atomic·Load64(SB), NOSPLIT, $0-12
2.	MOVL	ptr+0(FP), AX // 将第一个参数加载到AX寄存器
3.	TESTL	$7, AX // 判断内存是否对齐
4.	JZ	2(PC) // 跳到这条指令的下两条处,即跳转到第6行
5.	MOVL	0, AX // crash with nil ptr deref 引用0x0地址会触发错误
6.	MOVQ	(AX), M0 // 将内存地址指向的数据加载到M0寄存器
7.	MOVQ	M0, ret+4(FP) // 将M0寄存器中数据(即内存指向的位置)给返回值
8.	EMMS // 清除M0寄存器
9.	RET

第3行TESTL指令对两个操做数按位与,若是结果为0,则将ZF设置为1,不然为0。因此这一行实际上是判断传进来的内存地址是否是8的整数倍。

第4行JZ指令判断若是ZF即零标志位为1则执行跳转到第二个操做数指定的位置,结合第三行就是若是传入的内存地址是8的整数倍,即内存已对齐,则跳转到第6行,不然继续往下执行。

关于内存对齐能够看下我这篇文章:理解内存对齐

虽然MOV指令是原子性的,可是汇编中貌似没有加入内存屏障,那Golang是怎么实现可见性的呢?我这里也并无彻底的理解,不过大概意思是Golang的atomic会保证顺序一致性。

问题4:Go中的原子写

仍然以写一个8字节数据的操做为例,直接看golang atomic.LoadUint64()汇编:

TEXT runtime∕internal∕atomic·Store64(SB), NOSPLIT, $0-16
	MOVQ	ptr+0(FP), BX
	MOVQ	val+8(FP), AX
	XCHGQ	AX, 0(BX)
	RET

虽然没有LOCK指令,但XCHGQ指令具备LOCK的效果,因此仍是原子性并且可见的。

总结

这篇文章花费了我大量的时间与精力,主要缘由是刚开始以为原子性只是个小问题,可是随着不断的深刻挖掘,翻阅无数资料,才发现底下潜藏了无数的坑,面对浩瀚的计算机世界,深感本身的眇小与无知。
s70KdH.png

因为精力缘由本文还有一些很重要的点没有讲到,好比acquire/release 语义等等。

另外客观讲本文问题不少,较真的话可能会对您形成必定的困扰,这里表示抱歉,建议您能够将本文做为您研究计算机底层架构的一个契机,自行研究这方面的技术。

参考资料

相关文章
相关标签/搜索