从内存模型到go的内存模型

929 阅读14分钟

内存模型

关于内存模型Memory model的描述(有删减),来源于维基百科:

内存模型允许编译器执行许多重要的优化。编译器优化,如循环融合,会在程序中移动语句,这会影响潜在共享变量的读写操作顺序。读写顺序的改变会导致竞争状况。如果没有内存模型,编译器一般不允许对多线程程序应用这种优化,或者只在特殊情况下应用。

编译器只需要确保在优化和未优化的代码中,同步屏障处(可能共享的)变量的值是相同的。特别是,编译器假定在不包含同步障碍的代码块中对语句进行重新排序是安全的。

内存模型领域的大多数研究都围绕着:

  • 设计一个内存模型,允许最大程度的自由度进行编译器优化,同时对无竞争和(也许更重要的是)包含竞争的程序提供足够的保证。
  • 证明针对这种内存模型的程序优化是正确的。

所以,内存模型是制订了一个规则模型,在这个模型的范围下,给予编译器最大自由度的进行代码优化,并且在优化的同时,内存模型能够在共享变量存在竞争/无竞争的情况下对程序的正确性进行保证。 其中编译器的优化就包括了调整对共享变量读写操作的顺序,也就是下面说的内存排序。

在go101的文章Memory Order Guarantees in Go中也有对此的描述

Many compilers (at compile time) and CPU processors (at run time) often make some optimizations by adjusting the instruction orders, so that the instruction execution orders may differ from the orders presented in code. Instruction ordering is also often called memory ordering.

内存排序

关于内存排序Memory ordering的描述(有删减),来源于维基百科

Memory ordering describes the order of accesses to computer memory by a CPU. The term can refer either to the memory ordering generated by the compiler during compile time, or to the memory ordering generated by a CPU during runtime.

内存排序描述了CPU访问计算机内存的顺序。这个术语可以指编译器在编译时生成的内存顺序,也可以指CPU在运行时生成的内存顺序。

In modern microprocessors, memory ordering characterizes the CPU's ability to reorder memory operations – it is a type of out-of-order execution.

在现代微处理器中,内存排序是CPU对内存重排能力的表现---它就是乱序执行

On most modern uniprocessors memory operations are not executed in the order specified by the program code. In single threaded programs all operations appear to have been executed in the order specified, with all out-of-order execution hidden to the programmer – however in multi-threaded environments (or when interfacing with other hardware via memory buses) this can lead to problems. To avoid problems, memory barriers can be used in these cases.

在大多数现代单处理器上,内存操作不是按照程序代码指定的顺序执行的。在单线程程序中,所有操作似乎都是按照指定的顺序执行的,所有无序的执行对程序员来说都是隐藏的——但是在多线程环境中(或通过内存总线与其他硬件接口时),这可能会导致问题。为了避免出现问题,可以在这些情况下使用内存屏障。

根据内存排序的描述,编译器编译时,生成对内存访问的顺序,并且,对内存访问的顺序并不是按照程序代码指定的顺序执行,而是会对内存访问顺序重排,也就是上面说的乱序执行 Out-of-order execution,通过OoOE (Out-of-order execution)描述,可以相互印证上面内存模型里面说的,最终目的都是为了提升性能

内存排序包括2种,编译器内存排序和运行时内存排序,先看编译器内存排序

编译时内存排序

在通过大神Jeff Preshing Memory Ordering at Compile Time的文章可以看到

The cardinal rule of memory reordering, which is universally followed by compiler developers and CPU vendors, could be phrased as follows:

Thou shalt not modify the behavior of a single-threaded program.

就是说,不管是编译器开发人员还是CPU厂商,对于内存重排的原则,都是不能改变单线程程序的行为,即不能破坏了原有程序的逻辑。

所以,编译时的内存重排在单线程的环境下,不会有问题。

那么,多线程环境下,问题是如何产生的?

可以看go101 Memory Order Guarantees in Go举的一个例子:

package main

import "log"
import "runtime"

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
	if done {
		log.Println(len(a)) // always 12 once printed
	}
}

func main() {
	go setup()
	for !done {
		runtime.Gosched()
	}
	log.Println(a) // expected to print: hello, world
}

上面程序的行为依赖与编译器和CPU,在不同的编译器或者编译器版本,或者是不同的CPU架构下,都有可能产生不一样的结果。 比如,编译器内存排序时就有可能把指令重排成这种情况:

func setup() {
	done = true
	a = "hello, world"
	if done {
		log.Println(len(a))
	}
}

因为主线程main观察到了setup中的重排,并且多线程环境下,setup和main是并行执行的,在

done = true
a = "hello, world"

这2个语句执行中间, 可能主线程main就已经执行完了log.Println(a)。 所以,主线程main函数中log.Println(a)打印的结果是不可预测的。

因此,我们需要确保一个goroutine中某些代码行的执行必须发生在另一个goroutine中某些代码行的执行之前,以保持程序的正确性。

在这种情况下,因为还有编译器的指令重排,如果要保证代码的执行顺序,还必须要阻止指令重排的情况。

那么,要如何去解决这个问题呢?如何保证,在任何CPU的架构上,我们所有的交互都能够按照我们想要的顺序去执行?

Jeff Preshing大神的文章Memory Ordering at Compile Time提出, 我们可以使用内存屏障指令,例如,C++里面,可以使用volatile关键字,但是,go里面并没有提供这样的指令让我们显示通过各种指令去建立内存屏障,防止编译时内存重排。

那么, go是如何防止编译时内存重排的呢?

运行时内存排序

对于运行时内存重排,Jeff Preshing也有文章进行描述Memory Barriers Are Like Source Control Operations

首先是现代的CPU都有乱序执行 Out-of-order execution的特性。 其次,CPU执行指令的数据需要从memory中获取,但是CPU执行的速度要比从memory中获取的速度快很多,这就导致,CPU大部分的时间并不是在运算,而是在用在了和memory进行数据I/O的过程中。

为此,在CPU到主存之间,加入了缓存的设计,比如L1-L2-L3这种三级缓存,还有每个核心有各自的store buffer和Invalidate Queue

如下所示,现在CPU的架构简图:


 ┌─────────────┐                ┌─────────────┐   
 │    CPU 0    │                │    CPU 1    │   
 └───────────┬─┘                └───────────┬─┘   
   ▲         │                     ▲        │     
   │         │                     │        │     
   │         │                     │        │     
   │         │                     │        │     
   │         ▼                     │        ▼     
   │    ┌────────┐                 │    ┌────────┐
   │◀───┤ Store  │                 │◀───┤ Store  │
   ├───▶│ Buffer │                 ├───▶│ Buffer │
   │    └────┬───┘                 │    └───┬────┘
   │         │                     │        │     
   │         │                     │        │     
   │         │                     │        │     
   │         │                     │        │     
   │         ▼                     │        ▼     
┌──┴────────────┐               ┌──┴────────────┐ 
│               │               │               │ 
│     Cache     │               │     Cache     │ 
│               │               │               │ 
└───────┬───────┘               └───────┬───────┘ 
        │                               │         
        │                               │         
        │                               │         
 ┌──────┴──────┐                 ┌──────┴──────┐  
 │ Invalidate  │                 │ Invalidate  │  
 │    Queue    │                 │    Queue    │  
 └──────┬──────┘                 └──────┬──────┘  
        │                               │         
        │         Interconnect          │         
        └───────────────┬───────────────┘         
                        │                         
                        │                         
                        │                         
                        │                         
                ┌───────┴───────┐                 
                │               │                 
                │    Memory     │                 
                │               │                 
                └───────────────┘                 

他们的目的只有一个,就是提高CPU的执行效率,最大化的利用CPU的性能。

因为有了缓存再加上CPU的乱序执行(OoOE), 那么如何保证数据在多个CPU cache之间的一致性,就成为了一个需要解决的问题。 并且这个问题,没办法在软件层面进行解决,需要在硬件层面的支持。

例如:

thread 1      |     thread 2 
A = 1         |     B = 1 
print B       |     print A

因为有store buffer的存在,导致2个线程都在去主存中获取数据的时候,都没有及时获取到最新的数据,导致print 00的情况出现。 为此,有了缓存一致性协议Cache coherence CC的产生,例如,因特尔的MESI协议就是CC的一种实现。

不过,CPU虽然可以支持实现缓存一致性,但CPU并不知道什么时候优化是允许的,而什么时候并不允许。 所以,CPU将这个任务丢给了写代码的人,所以,写代码的人需要通过内存屏障(Memory Barriers)来实现。

内存屏障,也称为membar、memory fence或fence指令,是一种屏障指令,它使中央处理器(CPU)或编译器对在屏障指令前后发出的内存操作实施排序约束。 这通常意味着在屏障之前发出的操作保证在屏障之后发出的操作之前执行。

终上所述,内存屏障不仅可以解决编译时的内存重排问题,还能解决运行时内存重排的问题。

go的内存模型

  • The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.
  • Go内存模型通过指定的条件,来保证在一个goroutine可以观察到另外的goroutine对这个相同变量的写操作。

另外,golang还引入了happens-before原语:

  • If event e1 happens before event e2, then we say that e2 happens after e1. Also, if e1 does not happen before e2 and does not happen after e2, then we say that e1 and e2 happen concurrently.
  • 如果事件e1发生在事件e2之前,那么我们说e2发生在事件e1之后。同样,如果e1不发生在e2之前,也不发生在e2之后,那么我们说e1和e2同时发生。

基于happens before原语,golang定义了2组条件,以此来说明在一个goroutine中共享变量对于在另外一个goroutine中是否可见

第一组条件:

当下面2个条件都满足的时候,对变量v的读操作r是允许对v的写入操作w进行监测

  • r does not happen before w. --- r不先行发生于w
  • There is no other write w' to v that happens after w but before r. --- 在w后r前没有对v的其他写操作w'

第二组条件:

为了确保对变量v的读取操作r能够监测到特定的对v的写操作w, 需要确保w是r允许看到的唯一写操作。即当下面条件满足时,则r能保证监测到w

  • w happens before r. --- w先行发生于r
  • Any other write to the shared variable v either happens before w or after r. --- 对共享变量v的其它任何写入操作都只能发生在w之前或r之后

在单个goroutine中,这2组条件的定义是一样的。但是,如果在多goroutine的环境下,第二组条件要求会更严格,为什么第二组要求会更严格呢?

基于happens before的定义, 我们可以画出3种happens的时间轴情况:

e1 happens before e2 
----e1-----e2----->

e1 happens after e2
----e2-----e1----->

e1 and e2 happen concurrently
------e1e2-------->

通过这个图,很容易就知道了,第一组条件里面,不管是条件1,还是条件2, 都没有包含happen concurrently这种情况。 即:

  • r不先行发生于w,那么r是可以happens after w, 也可以h和w happen concurrently
  • 在w后r前没有对v的其他写操作w', 那么w'是可以 happens after w, 也可以happen concurrently w; w'还可以happens before r, 也还可以happen concurrently r

而第二组条件,则是限制了要么是happens before, 要么是happens after,排除了happen concurrently的情况。

因此,第一组条件里面用到了allowed to observe这个词,因为如果是happen concurrently的话,那对于v的w操作,可能是看得到,也可能是看不到的。

而,第二组条件里面用到了guarantee...这个词,因为排除了happens concurrently,所以保证可以看到对v的写入

go的内存模型中,还有个建议

Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.

修改多个goroutine同时访问的数据的程序必须序列化这种访问。

To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages.

要序列化访问,请使用通道操作或其他同步原语(如sync和sync/atomic包中的原语)保护数据。

我们看到,go并没有提供类似于C++、Java中的可以使用volatile这样的指令让我们去防止内存重排,而是建议我们使用通道和同步原语去实现,这是为什么呢? 在go101的文章Memory Order Guarantees in Go有提到:

The design philosophy of Go is to use as fewer features as possible to support as more use cases as possible, at the same time to ensure a good enough overall code execution efficiency. So Go built-in and standard packages don't provide direct ways to use the CPU fence instructions. In fact, CPU fence instructions are used in implementing all kinds of synchronization techniques supported in Go. So, we should use these synchronization techniques to ensure expected code execution orders.

Go的设计理念是使用尽可能少的特性来支持尽可能多的用例,同时保证足够好的整体代码执行效率。因此,Go内置和标准包并没有提供直接的方法来使用CPU围栏指令。事实上,CPU-fence指令用于实现Go支持的各种同步技术。因此,我们应该使用这些同步技术来确保预期的代码执行顺序。

所以,go并没有像C++、Java一样的Volatile修饰符、也没有给出LoadLoad、StoreLoad或者是直接调用汇编指令之类的非常底层同步元语,而是封装了channel、atomic等更高抽象程度的同步方法给我们使用。

那么go的atomic、channel的底层是如何实现的呢?

可以看下源代码, 参考:

// runtime/chan.go

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if c == nil {
        if !block {
            return false
        }
        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

...........

    lock(&c.lock) // <---------------------------- here

    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("send on closed channel"))
    }

    if sg := c.recvq.dequeue(); sg != nil {
        // Found a waiting receiver. We pass the value we want to send
        // directly to the receiver, bypassing the channel buffer (if any).
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }

.................    

}
//runtime internal atomic

TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25
    MOVQ    ptr+0(FP), BX
    MOVQ    old+8(FP), AX
    MOVQ    new+16(FP), CX
    LOCK
    CMPXCHGQ    CX, 0(BX)
    SETEQ    ret+24(FP)
    RET

TEXT runtime∕internal∕atomic·Casuintptr(SB), NOSPLIT, $0-25
    JMP    runtime∕internal∕atomic·Cas64(SB)

可以看到,go的channel里面也是通过加锁来实现,lock()方法,其实调用的是plan9中的lock指令,具体翻译为x86指令也是一个lock。lock其实隐含了屏障功能,lock指令执行之前,会将未完成读写操作完成。

对一些术语的理解

顺序一致性 (SC)

顺序一致性 Sequential_consistency) 顺序一致性是并发计算领域(如分布式共享内存、分布式事务等)中使用的一致性模型之一。

任何执行的结果都是相同的,就像所有处理器的操作都是按某种顺序执行的,每个处理器的操作都是按其程序指定的顺序出现的

即:

  • 每个线程内部的指令都是按照程序规定的顺序(program order)执行的(单个线程的视角)
  • 线程执行的交错顺序可以是任意的,但是所有线程所看见的整个程序的总体执行顺序都是一样的(整个程序的视角)

缓存一致性 (CC)

缓存一致性 Cache-Coherence Protocols CC是Cache之间的一种同步协议,它其实保证的就是对某一个地址的读操作返回的值一定是那个地址的最新值,而这个最新值可能是该线程所处的CPU核心刚刚写进去的那个最新值,也可能是另一个CPU核心上的线程刚刚写进去的最新值。比较常见的就是因特尔的MESI缓存一致性协议。

缓存一致性协议通常是由硬件实现,内存屏障触发

乱序执行 (OoOE)

乱序执行 Out-of-order execution

在计算机工程中,无序执行(或更正式的动态执行)是大多数高性能中央处理器(CPU)中使用的一种范例,用于利用否则会浪费的指令周期。在这种范例中,处理器按照由输入数据和执行单元的可用性控制的顺序执行指令,而不是按照它们在程序中的原始顺序执行指令。这样做,处理器可以避免在等待前一条指令完成时处于空闲状态,同时可以,处理下一条能够立即独立运行的指令。显然,这里的不影响语义依旧只能是保证指令间的显式因果关系,无法保证隐式因果关系。即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。

内存屏障 (memory barrier)

内存屏障 memory barrier

内存屏障,也称为membar、memory fence或fence指令,是一种屏障指令,它使中央处理器(CPU)或编译器对在屏障指令前后发出的内存操作实施排序约束。这通常意味着在屏障之前发出的操作保证在屏障之后发出的操作之前执行。 内存障碍是必要的,因为大多数现代CPU采用性能优化,可能导致无序执行。这种内存操作(加载和存储)的重新排序通常不会在单个执行线程中被注意到,但除非小心控制,否则会在并发程序和设备驱动程序中导致不可预测的行为。排序约束的确切性质取决于硬件,并由体系结构的内存排序模型定义。有些体系结构提供了多个障碍来强制执行不同的排序约束。 当实现在多个设备共享的内存上运行的低级机器代码时,通常使用内存屏障。这些代码包括多处理器系统上的同步原语和无锁数据结构,以及与计算机硬件通信的设备驱动程序。

内存屏障,解决了2个问题:

  • 阻止屏障两侧的指令重排序;
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

MESI协议

MESI协议

Critical section

Critical section 在并发编程中,对共享资源的并发访问可能导致意外或错误的行为,因此需要以避免并发访问的方式保护程序中访问共享资源的部分。此受保护部分是关键部分或关键区域。一次不能由多个进程执行。通常,关键部分访问共享资源,例如数据结构、外围设备或网络连接,这些资源在多个并发访问的上下文中无法正常运行。

参考

preshing.com/20120612/an…

preshing.com/20120625/me…

www.codedump.info/post/201912…

www.parallellabs.com/2010/03/06/…

www.huaweicloud.com/articles/0f…

go101.org/article/con…

go101.org/article/mem…

mp.weixin.qq.com/s/viQp36FeM…

www.youtube.com/watch?v=Vmr…

timelife.me/

yunwang.github.io/volatile/

en.wikipedia.org/wiki/Memory…

en.wikipedia.org/wiki/Sequen…

en.wikipedia.org/wiki/Memory…

en.wikipedia.org/wiki/Synchr…

en.wikipedia.org/wiki/Critic…

en.wikipedia.org/wiki/Barrie…

en.wikipedia.org/wiki/Out-of…

en.wikipedia.org/wiki/Cache_…

en.wikipedia.org/wiki/Memory…

en.wikipedia.org/wiki/Linear…

en.wikipedia.org/wiki/Optimi…