编辑
2024-01-24
服务端
0
请注意,本文编写于 267 天前,最后修改于 77 天前,其中某些信息可能已经过时。

目录

内存模型
CPU的高速缓存
内存可见性
乱序
内存屏障
Happens Before
同步
初始化
goroutine
channel
once
Atomic
常见错误
双重检查锁定
循环检查

内存模型

内存模型描述了程序执行的规范。在golang中,这些规范由 goroutine 执行组成,而 goroutine 的执行又由一系列内存操作构成。golang的内存模型规定了多个goroutine读取变量时候,变量的可见性情况。

内存操作可以从以下四个方面进行详细描述:

  • 类型:包括普通数据读取、普通数据写入、同步操作、原子数据读取、互斥操作或通道操作等。
  • 代码中的位置:即操作在程序代码中的具体位置,通常用行号或函数名标识。
  • 内存位置:指正在访问的内存地址或变量。
  • 值:指正在读取或写入的具体数据值。

为了确保多核、多线程环境下的正确性和一致性,内存模型确保了以下几点:

  • 顺序一致性:在单线程情况下,操作的执行顺序与程序代码中出现的顺序一致。这是最简单的模型,假设所有的读写操作按代码中的顺序执行,但实际上现代处理器和编译器都会进行优化,例如乱序执行(Out-of-Order Execution)和指令重排(Instruction Reordering),这使得顺序一致性变得复杂。
  • 可见性:一个线程对变量的修改对于其他线程是可见的。
  • 原子性:保证某些操作(如读写)是不可分割的,即要么完全执行,要么完全不执行。

比较有意思的是,Golang Memory Model 文章作者并不希望读者通过 Memory Model 来理解自己程序的运行方式,如果必须要这样做的话,很可能是程序的编写方式有问题,很可能意味着高昂的维护成本)

CPU的高速缓存

计算机中的所有运算操作都是由CPU的寄存器来完成的,CPU指令的执行过程需要涉及数据的读取和写入,这些数据只能来自于计算机主存(通常指RAM)。CPU的处理速度和内存的访问速度差距巨大,直连内存的访问方式使得CPU资源没有得到充分合理的利用,CPU Cache(CPU缓存)的设计是为了解决CPU处理速度和内存访问速度之间的差距问题。

CPU和内存之间通常通过高速缓存进行数据交换。高速缓存对程序员来说是透明的,也就是说在编程时无法感知CPU缓存的存在。然而,在某些情况下(例如多核多线程),我们不能忽视缓存的影响。 这实际上与缓存设计有关。在多处理器系统中,每个CPU都有自己的缓存,其他CPU无法直接查看这些缓存中的数据。因此,当多个处理器同时访问共享数据时,就需要考虑缓存一致性的问题。

其主要作用是

  • 缓解CPU和内存速度不匹配的问题:
    • CPU的处理速度远远快于主存(RAM)的访问速度。直接从主存读取数据或指令会花费大量的时间,浪费CPU的处理能力。
    • CPU Cache作为位于CPU和主存之间的高速缓存,能够存储CPU经常访问的数据和指令,以提供更快的访问速度。
  • 提高数据访问效率
    Cache的存在可以大大减少CPU等待数据加载的时间。当CPU需要某些数据时,首先会在Cache中查找,如果找到了(命中),则可以快速完成操作;如果没有找到(未命中),才会去主存中获取,然后将数据加载到Cache中,同时CPU继续执行其他指令。
  • 层次化结构:
    • CPU Cache通常采用多级(层次化)结构,如L1、L2、L3等,每一级Cache的容量和访问速度逐级递减,但距离CPU的近度逐级增加。
    • L1 Cache位于CPU核心内部,速度最快但容量最小;L2 Cache则更大些但速度稍慢,位于CPU核心附近;L3 Cache则更大、速度较慢,但可以被多个CPU核心共享,位于多个核心之间。

内存可见性

在多线程/协程环境下,当某个CPU修改了变量x并将其保存在本地缓存中时(缓存是每个核心私有的),其他CPU可能无法立即发现这个变化。这是因为现代CPU通常会对指令进行乱序执行和优化,这可能导致不同CPU之间的操作顺序存在差异。

在多核多线程环境下,读写共享变量要解决的不仅是原子性,还需要保证其内存可见性。现代CPU通常在执行指令时会允许一定程度上的乱序,这使保证在多个CPU缓存的数据一致更是增加了复杂性。

为了保证其他CPU的缓存中持有的变量x的值是最新的,需要通过缓存一致性协议来确保数据的一致性。缓存一致性协议定义了CPU之间如何进行通信和协作,以使得数据的副本在各个CPU的缓存中保持一致。

常见的缓存一致性协议包括:

  • MESI(Modified, Exclusive, Shared, Invalid):
    • Modified(已修改):当前CPU拥有该变量的独占访问权,并且缓存中的副本与主内存不一致。
    • Exclusive(独占):缓存中的副本与主内存一致,但其他CPU没有该变量的副本。
    • Shared(共享):多个CPU都有该变量的副本,并且这些副本与主内存一致。
    • Invalid(无效):缓存中的副本与主内存不一致或者其他CPU正在修改该变量。
  • MOESI(Modified, Owner, Exclusive, Shared, Invalid):在MESI的基础上增加了Owner(所有者)状态,用于标记哪个CPU是拥有该变量的独占访问权的。

这些协议通过在CPU之间发送消息或使用总线嗅探等机制来检测和传播变量的修改。当一个CPU修改了变量x时,它会将其缓存标记为“已修改”,然后通知其他CPU该变量已经被修改。其他CPU在接收到这个通知后,会将缓存中的副本置为无效或更新为最新值,以确保缓存的一致性。

乱序

乱序分两种,分别是编译器的指令重排和CPU的乱序执行。编译器可以在保证单线程语义不变的前提下,对指令进行重排序,以优化代码执行效率。同样地,现代CPU为了提高性能,会对指令进行乱序执行,但依然保证单线程程序的语义是正确的。然而,在多线程环境中,线程之间的操作并不总是有数据依赖关系,这就可能导致指令重排带来的不可预测行为。例如,一个线程写入一个共享变量,另一个线程读取这个变量,由于指令重排序,读取线程可能看不到最新的写入结果。为了维护程序原来的语义,编译器和CPU不会对两个有数据依赖的指令重排(reorder)。

shell
// 指令1 STORE x // 指令2 LOAD y

当CPU 0执行指令1的时候,发现这个变量x的当前状态为Shared,这意味着其它CPU也持有了x。根据缓存一致性协议,CPU 0在修改x之前必须通知其它CPU,需要通知其他CPU并等待确认(ACK)才会执行真正的修改x。但在等待期间,CPU 0可以继续执行后续指令,因为指令1和指令2之间没有数据依赖关系。

现代CPU缓存通常都有一个Store Buffer,允许CPU将要写入内存的操作先暂时缓存起来,而不是立即执行,所以此时并不真的执行Store操作,然后待时机合适的时候再执行实际的Store。

这样提高了效率,但是也有个问题。虽然我们在写程序的时候,是先STORE x再执行LOAD y,但实际上CPU却是先LOAD y再STORE x,这就是CPU乱序执行的一种情况。

总结,现代CPU为了提高性能,会根据指令之间的依赖关系和可执行性,选择合适的时机执行指令。这可能导致在编写程序时看似有序的指令,在实际执行时可能被重新排序。

内存屏障

当我们在编写程序时,有时需要确保某些指令的执行顺序不能被CPU重排序,以保证程序的正确性和逻辑一致性。内存屏障(memory barrier)正是用来解决这个问题的,它会告诉CPU这段指令执行的顺序是不可被重排的。

CPU并不知道指令之间蕴含着什么样的逻辑顺序,在你告诉它之前,它只是假设指令之间都没有逻辑关联,并且尽最大的努力优化执行速度。

// 指令1 STORE x WMB (Write memory barrier) // 指令2 LOAD y

内存屏障是一种指令,用于防止特定类型的重排序,从而确保内存操作的顺序符合程序员的预期。内存屏障可以分为几类,每种类型控制不同范围的重排序行为:

  • Load Barrier(读屏障):确保在屏障之前的所有加载操作(读取内存)在屏障之后的任何加载操作之前完成。
  • Store Barrier(写屏障):确保在屏障之前的所有存储操作(写入内存)在屏障之后的任何存储操作之前完成。
  • Full Barrier(全屏障):同时确保屏障之前的所有存储操作在屏障之后的任何存储和加载操作之前完成,并且屏障之前的所有加载操作在屏障之后的任何加载和存储操作之前完成。这种屏障提供了最强的排序保证。

在多线程编程中,内存屏障常被用来实现同步原语,例如锁、信号量以及其他并发数据结构。以下是一些常见的使用场景:

  • 互斥锁:确保锁的获取和释放操作不会被重排序,从而保护临界区内的代码。
  • 生产者-消费者模型:确保生产者线程写入共享缓冲区后,消费者线程能够正确读取到这些数据,而不会因为重排序导致读取到不完整的数据。

内存屏障是现代多核处理器环境下确保内存操作顺序的关键机制。它允许程序员显式地告诉CPU哪些内存操作必须以特定顺序执行,从而确保并发程序的正确性。理解和正确使用内存屏障,对于编写高效、安全的并发程序至关重要。

Happens Before

在Go语言中,happens-before 是一个重要的概念,它定义了在并发编程中操作执行的顺序。具体来说:如果事件 A happens-before 事件 B,那么事件 A 的效果(写入的值、修改的状态等)对事件 B 是可见的。happens-before 规则确保了程序在不同 goroutine 之间的操作顺序正确性,避免了竞态条件和不确定性结果的产生。

我们知道在一个协程中,对同一个变量的读写必然是按照代码编写的顺序来执行的。对于多个变量的读写,如果重新排序不影响代码逻辑的正常执行,编译器和处理器可能会对多个变量的读写过程重新排序。

比如,对于 a = 1; b = 2 这两个语句,在同一个Go协程里先执行 a=1 还是先执行 b=2 其实是没有区别的。但是,因为重新排列执行顺序的情况的存在,会导致某个Go协程所观察到的执行顺序可能与另一个Go协程观察到的执行顺序不一样。仍然以 a = 1; b = 2 举例,如果在某个协程里依次执行 a = 1; b = 2,由于重新排序的存在可能另一个 Go协程观察到的事实是 b 的值先被更新,而 a 的值被后更新。

为了表征读写需求,我们可以定义“发生在…之前”用来表示 Go 语言中某一小段内存命令的执行顺序。具体说来:

  • 如果事件 e1 发生在事件 e2 之前(即 e1 happens-before e2),那么我们可以保证在程序执行中,e2 所做的修改或者操作不会影响到 e1 之前的状态,从而确保了程序的逻辑正确性。
  • 如果事件 e1 和事件 e2 之间没有 happens-before 关系,那么它们就可以被认为是并发的,它们的执行顺序可能是不确定的,需要通过同步机制(如 channel、mutex 等)来确保它们之间的正确交互和顺序。

Happens Before具有传导性:如果操作e1 happens before 操作e2,e3 happends before e1,那么e3一定也 happends before e2。 由于存在指令重排和多核CPU并发访问情况,我们代码中变量顺序和实际方法顺序并不总是一致的。

go
a := 1 b := 10 c := a + 1

上面代码中是先给变量a赋值,然后给变量b赋值,最后给编程c赋值。但是在底层实现指令时候,可能发生指令重排:变量b赋值在前,变量a赋值在后,最后变量c赋值。对于依赖于a变量的c变量的赋值,不管怎样指令重排,Go语言都会保证变量a赋值操作 happends before c变量赋值操作。

上面代码运行是运行在同一goroutine中,Go语言时能够保证happends before原则的,实现正确的变量可见性。但对于多个goroutine共享数据时候,Go语言是无法保证Happens Before原则的,这时候就需要我们采用锁、通道等同步手段来保证数据一致性。考虑下面场景:

go
var a, b int // goroutine A go func() { a = 1 b = 10 }() // goroutine B go func() { if b == 10 { print(a) } }()

当执行goroutine B打印变量a时并不一定打印出来1,有可能打印出来的是0。这是因为goroutine A中可能存在指令重排,先将b变量赋值2,若这时候接着执行goroutine B那么就会打印出来0。

同步

初始化

程序初始化在单个 goroutine 中运行 (main goroutine),但是 main goroutine 可能会并发运行其他 goroutine。如果包 p 导入包 q,则 q 的 init 函数在 p 的 init 函数之前完成。所有 init 函数在主函数 main.main 开始之前完成。

goroutine

happens-before保证

  • goroutine的创建happens before其执行
  • goroutine的退出(销毁)不保证happens-before任何代码

比如,下面的例子:

go
var a string func f() { print(a) // step3 } func hello() { a = "hello" // step1 go f() // step2 }

调用函数 hello 会在调用后的某个时间点打印 “hello” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后。

goroutine的创建happens before其执行,我们知道step2 happens before step3。因为在同一goroutine中,前置代码一定会happens before后面代码(即使发生了执行重排,其并不会影响happends before),step1 happends before step3。所以,调用函数 hello 会在调用后的某个时间点打印 “hello” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后,但打印内容不可能出现打印空字符串情况。

另一个例子:

go
var a string func hello() { go func() { a = "hello" }() // step1 print(a) // step2 }

step1不happens before step2,他们执行的顺序都有可能。所以 a 的赋值语句没有任何的同步事件,无法保证被其他任意的 Go 协程观察到这个赋值事件的存在,那么最终可能打印出hello,也有可能打印出空字符串。

如果某个 Go协程 里发生的事件必然要被另一个 Go协程 观察到,需要使用同步机制进行保证,比如使用锁或者信道(channel)通信来构建一个相对的事件发生顺序。

channel

信道通信是 Go 语言中协程(goroutine)之间进行事件同步和数据交换的主要方式之一。在 Go 中,通过使用信道(channel),可以实现协程之间的安全数据传输和同步执行,这对于并发编程非常重要。

  • 对于缓冲channel,向channel 的发送数据操作happens before对应的接收操作完成之前

比如,下面这个例子

go
var c = make(chan int, 10) var a string func f() { a = "hello" c <- 1 // step2 } func main() { go f() <-c // step1 print(a) }

step2 happens before step1,所以最终打印出hello。

  • 对于无缓存channel,从channel接收数据的操作happens before向channel发送操作之前。

比如,下面的例子

go
var c = make(chan int) var a string func f() { a = "hello" <-c // step2 } func main() { go f() c <- 0 // step1 print(a) }

step2 happens before step1,所以最终打印出hello。对于这个代码,如果channel c是带缓存的,程序将不能保证会打印出 hello,可能会打印出空字符串。

  • 对于容量为 C 的信道,接收第 K 个元素的事件happens before第 K+C 个元素的发送之前。

这个规则是上面的扩展。它使得带缓存的channel可以模拟出计数信号量。channel中元素的个数表示活跃数,channel的容量表示最大的可并发数。发送一个元素意味着可以接收一个信号量,接收一个元素意味着释放这个信号量。利用这个规则可以用来做并发控制。

下面,这个例子通过使用一个带固定缓存长度的channel,保证了同时最多有10个同时运行的协程。

go
var limit = make(chan int, 10) func hello() { for _, w := range work { go func(w func()) { limit <- 0 w() <-limit }(w) } select{} }
  • chanel 的关闭事件发生在从channel接收到零值(由信道关闭触发)之前。

在上面例子中,可以使用 close(c) 来替代 c <- 1 语句来保证同样的效果。

sync包实现了两类锁类型,分别是 sync.Mutex 和 sync.RWMutex。对于任意的sync.Mutex或者sync.RWMutex,n次Unlock()调用happens before m次Lock()调用,其中n<m。 比如,下面这个例子

go
var l sync.Mutex var a string func f() { a = "hello" l.Unlock() // step2 } func main() { l.Lock() // step1 go f() l.Lock() // step3 print(a) }

step2 happens before step3,所以一定会打印出hello。

once

sync包还提供了 Once 用来保证多协程场景下安全的初始化机制。多个goroutine可以并发执行 once.Do(f) 来执行函数 f,有且只有一个 goroutine 会运行 f(),其他的 goroutine 会阻塞到那单次执行的 f() 的返回。

once.Do(f) 中 f() 会happens before所有的 once.Do(f) 返回之前。 比如,下面这个例子。

go
var a string var once sync.Once func exec() { a = "hello" } func f() { once.Do(exec) // step1 print(a) // step2 } func twoprint() { go f() go f() }

step2 happens before step1。上面的代码中 f 函数只会调用一次 exec 函数,所以只会打印1次hello。

Atomic

Go语言中,Atomic主要保证了三件事,原子性、可见性、有序性。 Go的源码里面Atomic 的API,主要包括Swap、CAS、Add、Load、Store、Pointer几类,在IA64 CPU上对应的汇编指令如下:

关于LOCK prefix和XCHG指令在 英特尔开发人员手册中,我们找到了如下的解释:

For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor. For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area. The I/O instructions, locking instructions, the LOCK prefix, and serializing instructions force stronger orderingon the processor. Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to ensure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory (see Section 8.1.2, “Bus Locking”).

从描述中,我们了解到:LOCK prefix和XCHG 指令前缀提供了强一致性的内(缓)存读写保证,可以保证 LOCK 之后的指令在带 LOCK 前缀的指令执行之后才会执行。同时,我们在手册中还了解到,现代的 CPU 中的 LOCK 操作并不是简单锁 CPU 和主存之间的通讯总线, Intel 在 cache 层实现了这个 LOCK 操作,此因此我们也无需为 LOCK 的执行效率担忧。

从上面可以看到Swap、CAS、Add、Store 都是基于LOCK prefix和XCHG指令实现的,他能保证缓存读写的强一致性。

我们单独来看下Load指令,在IA32、IA64、Arm的CPU架构下就是对应MOV指令。我们写个简单demo验证下。测试代码如下:

go
var num uint32 func main() { num = 1000 fmt.Println(normalLoad()) fmt.Println(atomicLoad()) } func normalLoad() uint32 { a := num return a } func atomicLoad() uint32 { a := atomic.LoadUint32(&num) return a }

我们go build -gcflags "-N -l" atomic.go 编译以后再objdump -d atomic导出对应的汇编代码。我们看到normalLoad()和atomicLoad() 对应的汇编代码是一样的,也印证了,我们上面说的atomic.Load方法在IA64 就是简单的MOV指令。

常见错误

在多协程并发场景中,对某个变量的读操作 r 一定概率可以观察到对同一个变量的并发写操作w,但这并不意味着发生在 r 之后的其他读操作可以观察到发生在 w之前的其他写操作。 比如,下面这个例子:

go
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
go
var a int func f() { a = 1 } func main() { print(a) }

上面的代码里函数 g 可能会先打印 2(b的值),然后打印 0(a的值)。可能大家会认为既然 b 的值已经被赋值为 2 了,那么 a 的值肯定被赋值为 1 了,但事实是两个事件的先后在这里是没有办法确定的。

上面的事实可以证明下面的几个常见的错误。

双重检查锁定

双重检查锁定是一种尝试避免同步开销的尝试。比如下面的例子,twoprint 函数可能会被错误地编写为:

go
var a string var done bool var once sync.Once func setup() { a = "hello" done = true } func doprint() { if !done { once.Do(setup) } print(a) } func twoprint() { go doprint() go doprint() }

在 doprint 函数中,观察到对 done 的写操作并不意味着能够观察到对 a 的写操作。应该注意,上面的写法依然有可能打印出空字符串而不是“hello,”字符串。 下面这个是发生在身边同事的真实案例,我搬运过来。

下面这个代码第一眼看上去好像是标准的Double Check的写法,确没有什么问题,但如果执行 go run -race go_race.go 检查会有Data Race的警告。

go
var ( lock sync.Mutex instance *UserInfo ) func getInstance() (*UserInfo, error) { if instance == nil { lock.Lock() defer lock.Unlock() if instance == nil { instance = &UserInfo{ Name: "xxx", } } } return instance, nil }
shell
================== WARNING: DATA RACE Read at 0x00000120d9c0 by goroutine 8:

警告中指明在多线程执行getInstance这个方法的时候,在if instance == nil { 这一行会发生data race。由上面的Golang Happens Before一致性原语我们知道,instance修改在lock临界区里面,对其他的线程是可见的。那为什么在 if instance == nil 还是会发生Data Race呢? 真正的原因是是在 instance = &UserInfo{Name: "xxx"} 这句代码,这句代码并不是原子操作,这个赋值可能是会有几步指令,比如

  • 先new一个UserInfo
  • 然后设置Name=xxx
  • 最后把了new的对象赋值给instance

如果这个时候发生了乱序,可能会变成

  • 先了new一个UserInfo
  • 然后再赋值给instance
  • 最后再设置Name=xxx

A进程进来的时候拿到锁,然后对instance进行赋值,这个时候instance对象是一个半初始化状态的数据,线程B来的时候判断if instance == nil发现不为nil就直接把半初始化状态的数据返回了,所以会有问题,Name无法设置成功。

可以直接用Atomic.Value来保证可见性和原子性就行了,改造代码如下:

go
var flag uint32 func getInstance() (*UserInfo, error) { if atomic.LoadUint32(&flag) != 1 { lock.Lock() defer lock.Unlock() if instance == nil { // 其他初始化错误,如果有错误可以直接返回 instance = &UserInfo{ Age: 18, } atomic.StoreUint32(&flag, 1) } } return instance, nil }

如果执行 go run -race go_race.go 检查就不会有Data Race的警告。我们主要是通过atomic.store和lock来保证flag和instance的修改对其他线程可见。通过atomic.LoadUint32(&flag)和double check来保证instance只会初始化一次。

循环检查

另一个常见的错误用法是对某个值的循环检查,比如下面的代码:

go
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) }

和上一个例子类似,main函数中观察到对 done 的写操作并不意味着可以观察到对 a 的写操作,因此上面的代码依然可能会打印出空字符串。 还有另外一种情况,如下面的代码所示:

go
type T struct { msg string } var g *T func setup() { t := new(T) t.msg = "hello, world" g = t } func main() { go setup() for g == nil { } print(g.msg) }

上面的代码即使 main 函数观察到 g != nil并且退出了它的 for 循环,依然没有办法保证它可以观察到被初始化的 g.msg 值。 避免上面几个错误用法的方式是一样的:显式使用同步语句。

本文作者:sora

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!