原文地址:https://blog.fanscore.cn/p/34/linux
本文以go1.14 darwin/amd64中的原子操做为例,探究原子操做的汇编实现,引出LOCK
指令前缀、可见性、MESI协议、Store Buffer、Invalid Queue、内存屏障,经过对CPU体系结构的探究,从而理解以上概念,并在最终给出一些事实。c++
咱们以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
。算法
在早期CPU上LOCK指令会锁总线,即其余核心不能再经过总线与内存通信,从而实现该核心对内存的独占。缓存
这种作法虽然解决了问题可是性能太差,因此在Intel P6 CPU(P6是一个架构,并不是具体CPU)引入一个优化:若是数据已经缓存在CPU cache中,则锁缓存,不然仍是锁总线。注意,这里所说的缓存锁实际是缓存一致性的效果,下面咱们先讲下缓存一致性的问题再回头看缓存一致性是如何实现缓存锁的效果的。架构
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状态的简称:
核心之间协商通讯须要如下消息机制:
这里有个存疑的地方:CPU从内存中读到数据I状态是转移到S仍是E,查资料时两种说法都有。我的认为应该是E,由于这样另一个核心要加载副本时只须要去当前核心上取就好了不须要读内存,性能会更高些,若是你有不一样见解欢迎在评论区交流。
Invalidate ACK
来接收反馈Invalidate
消息,收到Invalidate ACK
后修改状态为M;若是状态为I(包括cache miss)则须要发出Read Invalidate
当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中读。
这却是解决了上面的漏洞,可是还存在另一个问题,咱们看下面这段代码:
a = 0 flag = false func runInCpu0() { a = 1 flag = true } func runInCpu1() { while (!flag) { continue } print(a) }
对于上面的代码咱们假设有以下执行步骤:
从代码角度看,咱们的代码好像变成了
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中,以后再刷后面的条目。
上文经过写屏障解决了伪重排序的问题后,还要思考另外一个问题,那就是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独占
为了解决上面的问题,CPU提出了读屏障指令,linux将其封装为了smp_rwm()函数。放到咱们的代码中就是这样:
... func runInCpu1() { while (!flag) { continue } smp_rwm() print(a) }
当CPU执行到smp_rwm()时,会将Invalid Queue中的数据处理完成后再执行屏障后面的读取操做,这就解决了上面的问题了。
除了上面提到的读屏障和写屏障外,还有一种全屏障,它实际上是读屏障和写屏障的综合体,兼具两种屏障的做用,在linux中它是smp_mb()函数。
文章开始提到的LOCK指令其实兼具了内存屏障的做用。
如今想必你已经理解了缓存一致性,那么咱们梳理一下在遵循了MESI协议有Store Buffer和Invalid Queue的CPU上缓存一致性是如何实现缓存锁的效果的。
假设CPU0 cache中存在i,CPU0要执行i++,那么有如下几种可能性:
答:
read-modify-write 内存
的指令不是原子性的,以INC mem_addr
为例,咱们假设数据已经缓存在了cache上,指令的执行须要先将数据从cache读到执行单元中,再执行+1,而后写回到cache。咱们看一个读取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会保证顺序一致性。
仍然以写一个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的效果,因此仍是原子性并且可见的。
这篇文章花费了我大量的时间与精力,主要缘由是刚开始以为原子性只是个小问题,可是随着不断的深刻挖掘,翻阅无数资料,才发现底下潜藏了无数的坑,面对浩瀚的计算机世界,深感本身的眇小与无知。
因为精力缘由本文还有一些很重要的点没有讲到,好比acquire/release 语义等等。
另外客观讲本文问题不少,较真的话可能会对您形成必定的困扰,这里表示抱歉,建议您能够将本文做为您研究计算机底层架构的一个契机,自行研究这方面的技术。