翻译自Go官方的Go Memory Model ,文档版本为 Jun 6, 2022。

Introduction

Go内存模型指明了为实现在一个goroutine中读取到在其它goroutine中修改的相同变量的最新值而需要具备的条件。

Advice

  • 如果若干goroutine需要并发读写共享数据,那么程序需要使这些并发读写按某种顺序串行执行。
  • 要使读写串行化执行,需要使用channel或sync、sync/atomic包中的其它原语保护共享数据。
  • If you must read the rest of this document to understand the behavior of your program, you are being too clever.
  • Don’t be clever.

Informal Overview

Go 使用与其他语言几乎相同的方式处理其内存模型,旨在保持语义简单、可理解并且实用。本节概述了该方法,应该足以满足大多数程序员的需要。下一节将更正式地给出内存模型的定义。

Data race定义为对某个内存地址的写入与对同一地址的另一次读取或写入同时发生,除非涉及的所有访问都是 sync/atomic 包提供的原子数据访问。如前所述,强烈建议程序员使用适当的同步原语来避免出现data race。在没有data race的情况下,Go程序的行为就好像所有的goroutines都被多路复用到一个处理器上。此属性有时称为DRF-SC:无数据竞争(Data-Race-Free)的程序以顺序一致(Sequentially Consistent)的方式运行。

程序员应该编写没有data race的Go程序,但针对Go程序运行时遇到data race时的处理方式,也是有约束的。Go实现可能总是通过报告data race并终止程序来对应对data race。其它没有data race的情况下,每次读取single-word-size大小或sub-word-size大小的内存地址时都必须观察到实际写入该地址的值(可能由并发执行的 goroutine写入)且尚未被覆盖。

这些实现约束使 Go 更像 Java 或 JavaScript,在发生data race后的结果类似(程序退出),而不像 C 和 C++。在 C 和 C++ 中,任何具有data race的程序的含义都是完全未定义的,编译器可能会做任何事情。Go 的做法旨在让出错的程序更可靠、更容易调试,同时仍然坚持认为data race是程序错误,并提供工具诊断和报告它们。

Memory Model

以下 Go 内存模型的正式定义严格遵循 Hans-J. Boehm 和 Sarita V. Adve 在Foundations of the C++ Concurrency Memory Model (PLDI 2008) 中提出的方法,data-race-free 程序的定义和 race-free 程序的顺序一致性保证和该论文等同。

内存模型描述了对程序执行的要求,而程序的执行由goroutine的执行组成,而goroutine的执行又由若干内存操作组成。

内存操作从四个细节上建模:

  • 内存操作的种类,即是普通读取、普通写入还是同步操作,例如原子数据访问atomic、互斥操作mutex或通道操作channel,
  • 内存操作在程序中的位置,
  • 正在访问的内存地址或变量,
  • 内存操作读取或写入的值。

一些内存操作是读相关的,包括读取、原子读取、互斥锁和通道接收。有些内存操作是写相关的,包括写、原子写、互斥锁解锁、通道发送和通道关闭。还有些内存操作既像读又像写,如原子的CAS操作。

一个goroutine的执行被建模为单个goroutine上执行的一组内存操作。

要求 1:单个 goroutine 上的内存操作(内存的读取和写入)和该 goroutine 的正确执行顺序对应,该执行顺序必须与 sequenced before 关系一致,即Go语言规范为程序控制流以及表达式的求值顺序规定的偏序要求。

Go程序的运行被建模为一组 goroutine的执行,以及一个映射 W,它指定了每个读相关操作读到的值是从哪个写相关操作中写入的。(同一个程序的多次运行可以有不同的具体运行过程。)

要求 2:对于给定的程序运行过程,在仅使用同步操作(synchronizing operations)时,必须能通过同步操作的某个隐含全序(implicit total order)以及这些操作读取或写入的值来推导出映射W的取值。

synchronized before 关系是同步内存操作(synchronizing memory operations)的偏序,从 W 派生。如果同步读相关内存操作 r 观察到同步写相关内存操作 w(即,如果 W(r) = w),则w is synchronized before r。通俗地讲,synchronized before 关系 是上一段中提到的隐含全序的子集,仅限于 W 直接观察到的信息。

happens before 关系被定义为 sequenced beforesynchronized before 关系联合的传递闭包。(The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations.)

【译注】个人理解三者的关系是这样的:

  • sequenced before指语言规范指定的,程序控制流和表达式求值等语句的偏序关系,考虑的主体是单个goroutine上的语句。
  • synchronized before指使用同步原语后限制的某些语句的先后关系,考虑的主体是多个goroutine上的语句。
  • happens before是由两者导出的一种偏序关系,不考虑主体是单个还是多个goroutine上的语句,只用于描述顺序。

要求 3:对于内存地址 x 上的普通(非同步)数据读取 r,W(r) 必须是一个对r可见的写入w,其中可见意味着以下两项均成立:

  • w happens before r
  • 如果w' happens before r,则w不会happends before w'

内存地址 x 上的data race由 x 上的读内存操作 r 和 x 上的写内存操作 w 组成,其中至少有一个是非同步的(non-synchronizing),它们没有happens before关系 (即,r不happens before w,w也不happens beforer)。

内存地址 x 上的写-写data race由 x 上的两个写相关的内存操作 w 和 w' 组成,其中至少一个是非同步的,它们没有happens before关系。

请注意,如果内存地址 x 上没有读-写或写-写data race,则 x 上的任何读取 r 都只有一个可能的 W(r):在happens before顺序中紧靠在r前面的那个相应w。

更一般地说,可以证明任何没有data race(读-写,写-写冲突)的 Go 程序,可以只存在这样的运行结果,这些结果可以用goroutine执行的一些顺序一致(SC)的交错来解释。(证明与上面引用的 Boehm 和 Adve 论文的第 7 节相同。)此属性称为DRF-SC

正式定义的目的是匹配其他语言(包括 C、C++、Java、JavaScript、Rust 和 Swift)为无data race程序提供的 DRF-SC 保证。

某些 Go 语言操作,如 goroutine 创建和内存分配,可以充当同步操作。这些操作对 synchronized-before 偏序的影响记录在下面的“同步”部分中。各个软件包负责为自己的操作提供类似的文档。

Implementation Restrictions for Programs Containing Data Races

上一节给出了无data race程序执行的正式定义。本节非正式地描述了Go语言实现必须为含有data race的程序提供的语义。

首先,任何Go实现都可以在检测到data race时报告并停止程序的执行。使用 ThreadSanitizer(通过"go build -race"访问)的实现正是这样做的。

否则,对不大于机器字的内存地址 x 的读取 r 必须观察到一些写入 w,使得 r 不会happens before w,并且没有写入 w' 使得 w happens before w' 且 w' happens before r。也就是说,每次读取都必须观察到先前写入或并发写入所写入的值。

此外,不允许观察到非因果和“凭空”写入。

在观察单个允许的写入w时,鼓励读取大于单个机器字的内存地址,但不要求满足读取字大小内存地址时的语义。出于性能原因,Go实现可能会将字长较大的操作视为一组未指定顺序的单个机器字大小的操作。这意味着多字数据结构上的data race会导致与单次写入的值不一致。当这些值代表内部实现的<pointer, length> 或 <pointer, type> 类型的数据对时,例如大多数 Go 实现中的接口值、映射、切片和字符串,这种race反过来会导致内存数据损坏。

下面的“不正确的同步”部分给出了不正确同步的示例。

下面的“不正确的编译”部分给出了Go实现需要遵循的限制的示例。

Synchronization

Initialization

程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建并发运行的其他 goroutine。

如果包 p 导入包 q , q 的 init 函数的完成happens before任何 p 的开始。

所有 init 函数的完成synchronized before函数 main.main的开始。

Goroutine creation

启动新 goroutine 的 go 语句synchronized before 该 goroutine 执行。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var a string

func f() {
	print(a)
}

func hello() {
	a = "hello, world"
	go f()
}

调用 hello 将在将来的某个时间打印 “hello, world” (可能在 hello 返回之后)。

Goroutine destruction

不保证 goroutine 的退出synchronized before程序中的任何事件。

1
2
3
4
5
6
var a string

func hello() {
	go func() { a = "hello" }()
	print(a)
}

a 的赋值之后没有任何同步事件,因此不能保证任何其他 goroutine 都观察到它。事实上,激进的编译器可能会删除整个 go 语句。

如果一个 goroutine 的效果必须被另一个 goroutine 观察到,则使用同步机制(例如锁或通道通信)来建立相对顺序。

Channel communication

channel通信是 goroutine 之间同步的主要方式。特定通道上的每个发送都与来自该通道的相应接收相匹配,它们通常在不同的 goroutine 中。

向一个channel上的发送动作synchronized before从该channel上完成接收。(A send on a channel is synchronized before the completion of the corresponding receive from that channel.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

程序保证能打印 “hello, world” 。对 a 的写入sequenced before c 上的发送,在c上的发送synchronized before在 c 上的接收完成,c上的接收完成sequenced before对a的打印。

通道的关闭synchronized before从通道上接收到因通道关闭返回的零值。

在前面的示例中,将 c <- 0 替换为 close(c) 可以起到同样的效果。

从无缓冲通道的接收动作synchronized before在该通道上的相应发送动作完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

该程序(交换了发送和接收语句并使用无缓冲通道)也保证打印 “hello, world” 。对 a 的写入sequences beforec 上的接收,在 c 上的接收动作synchronized beforec上的发送动作完成,后者sequences before对a的读取。

如果通道带缓冲(例如 c = make(chan int, 1) ),那么程序将不能保证打印 “hello, world” 。 (它可能会打印空字符串、崩溃或执行其他操作。)

容量为 C 的通道上的第 k 个接收synchronized before该通道的第 k+C 个发送的完成。

该规则将之前的规则推广到缓冲通道。它允许通过缓冲通道对计数信号量进行建模:通道中的项目数量对应于获取的信号量的数量,通道的容量对应于允许同时使用的最大数量。向通道发送一个项目表示获取信号量,从通道接收一个项目表示释放信号量。这是限制并发的常用习惯用法。

该程序为工作列表中的每个条目启动一个 goroutine,但 goroutine 使用 limit 通道进行协调以确保一次最多有三个正在运行的goroutine。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func(w func()) {
			limit <- 1
			w()
			<-limit
		}(w)
	}
	select{}
}

【译注】再详细解释下channel通信这部分的保证。

channel的发送send、发送完成send finished、接收receive、接收完成receive finished的synchronized before关系如下:

  1. 第n个send一定synchronized before第n个receive finished,无论是缓冲还是非缓冲型的channel
  2. 对于容量为m的缓冲型channel,第n个receive一定synchronized before第n+m个send finished
  3. 对于非缓冲型channel,第n个receive一定synchronized before第n个send finished
  4. channel close一定synchronized beforereceiver得到通知

第1条,send不一定synchronized beforereceive,因为有时候先receive,然后goroutine被挂起,之后被sender唤醒。但不管怎样,要想完成接收,一定要先有发送。

第2条,如果第n个receive还没发生,那么第n+m个send会被阻塞挂起,当第n个receive发生时,sender会被唤醒继续发送。如果第n个receive已经发生,那该条自然成立。

第3条,如果第n个sender被阻塞挂起,第n个receiver到来,此时receive先于send finish。如果第n个sender没有阻塞,说明第n个receive早就在阻塞等了,这时receive甚至先于send的开始。

第4条,源码中先设置完closed=1,再唤醒等待的goroutine,并将零值复制给receivers。

针对第1条和第3条,这里再给出两个等价的例子。它们都能实现synchronized before关系约束。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "hello"
    done <- true
}

// 使用第1条约束
func main() {
    go aGoroutine()
    <- done
    print(msg)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "hello"
    <- done
}

// 使用第3条约束
func main() {
    go aGoroutine()
    done <- true
    print(msg)
}

Locks

sync 包实现了两种锁数据类型, sync.Mutex 和 sync.RWMutex 。

对于任何 sync.Mutex 或 sync.RWMutex 变量 l 和n < m, 第n次的l.Unlock()调用 synchronized before第m次的l.Lock()返回。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

程序保证打印 “hello, world” 。对 l.Unlock() (在 f 中)的第一次调用synchronized before对 l.Lock() (在 main 中)的第二次调用返回,后者sequenced beforeprint。

对于在 sync.RWMutex 变量 l 上对 l.RLock 的任何调用,都有一个 n 使得对 l.Unlock 的第 n 次调用synchronized before l.RLock调用返回,并且对应的l.RUnlock 的调用synchronized before第 n+1次 l.Lock调用返回。

成功调用 l.TryLock (或 l.TryRLock )等同于调用 l.Lock (或 l.RLock )。不成功的调用根本没有同步效果。

就内存模型而言, l.TryLock (或 l.TryRLock )可以被认为即使在mutex l 处于解锁状态下也会返回 false。

Once

sync 包通过使用 Once 类型提供了在存在多个 goroutine 的情况下进行初始化的安全机制。多个线程可以为特定的 f 执行 once.Do(f) ,但只有一个会运行 f() ,而其他调用将阻塞直到 f() 返回。

来自 once.Do(f) 的单个 f() 调用的完成synchronized before任何once.Do(f) 调用的返回。

在这个程序中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var a string
var once sync.Once

func setup() {
	a = "hello, world"
}

func doprint() {
	once.Do(setup)
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

调用 twoprint 只会调用 setup 一次。 setup 函数将在调用 print 之前完成。结果是 “hello, world” 被打印两次。

Atomic Values

sync/atomic 包中的 API 统称为“原子操作”,可用于同步不同 goroutine 的执行。

如果原子操作 A 的效果被原子操作 B 观察到,那么 A synchronized before B。

程序中执行的所有原子操作的行为就好像以某种顺序一致(sequentially consistent)的顺序执行一样。

前面的定义与 C++ 的顺序一致原子和 Java 的 volatile 变量具有相同的语义。

Finalizers

runtime 包提供了一个 SetFinalizer 函数,它添加了一个终结器,当程序不再可以访问特定对象时调用对象上的该终结器。

对 SetFinalizer(x, f) 的调用synchronized beforef(x)的调用完成。

Additional Mechanisms

sync 包提供了额外的同步抽象,包括条件变量、无锁映射、分配池和等待组。它们各自的文档都指定了它们对同步所做的保证。

其他提供同步抽象的包也应该记录它们所做的保证。

Incorrect synchronization

有data race的程序是不正确的,并且可以表现出非顺序一致的执行。

特别要注意,读取 r 可能会观察到与 r 同时执行的任何写入 w 所写入的值。即使这样,也并不意味着发生在 r 之后的读取会观察到发生在 w 之前的写入。

在这个程序中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

g 可能会先打印 2 然后再打印 0 。(内存重排序,详细参考CPU缓存架构到内存屏障 )

这个事实使一些常见的习语无效。

双重检查锁定是一种避免同步开销(the overhead of synchronization)的尝试。

例如, twoprint 程序可能会被错误地写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func doprint() {
	if !done {
		once.Do(setup)
	}
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

但不能保证在 doprint 中,观察到对 done 的写入就意味着观察到了对 a 的写入。这个版本可以错误地打印一个空字符串而不是 “hello, world” 。(同样是由于内存重排序

另一个不正确的习语是对一个值的忙等待(busy waiting for a value,常用于无锁编程),如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

和前面一样,不能保证在 main 中,观察到对 done 的写入就意味着观察到了对 a 的写入。因此该程序也可以打印一个空字符串。

更糟糕的是,无法保证 main 会观察到对 done 的写入,因为两个线程之间没有同步事件。 main 中的循环不保证能退出。(CPU Store buffer/invalidate queue,详细参考CPU缓存架构到内存屏障

这里有个更微妙的变形,例如这个程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 并退出循环,也不能保证它会观察到 g.msg 的设置的值"hello, world"。(依然是内存重排序问题

在所有这些示例中,解决方案都是相同的:使用显式的同步操作。

Incorrect compilation

Go 内存模型像限制 Go 程序一样限制编译器的优化。一些在单线程程序中有效的编译器优化并非在所有 Go 程序中都有效。特别是,编译器不能引入原程序中不存在的写入,不能允许单次读观察到多个值,不能允许单次写写多个值。

以下所有示例均假定*p*q指的是多个 goroutine 可访问的内存地址。

不将data race引入无data race的程序意味着,不将写入从它们出现的条件语句中移出。

例如,编译器不得反转此程序中的条件:

1
2
3
4
*p = 1
if cond {
	*p = 2
}

也就是说,编译器不得将程序重写为以下程序:

1
2
3
4
*p = 2
if !cond {
	*p = 1
}

如果 cond 为 false 并且另一个 goroutine 正在读取 *p ,那么在原始程序中,另一个 goroutine 只能观察到 *p 和 1 的任何先前值。在改写的程序中,另一个协程可以观察到 2 ,这在之前的程序中是不可能的。

不引入数据竞争也意味着不假设循环终止。例如,编译器通常不得将对 *p 或 *q 的访问移动到该程序的循环之前:

1
2
3
4
5
6
n := 0
for e := list; e != nil; e = e.next {
	n++
}
i := *p
*q = 1

如果 list 指向循环列表,那么原始程序将永远不会访问 *p*q ,但重写后的程序会。 (如果编译器可以证明 *p 不会崩溃,那么向前移动 *p 是安全的;向前移动 *q 也需要编译器证明没有其他 goroutine 可以访问 *q。)

不引入data race也意味着不假设被调用的函数它总是返回或它没有同步操作。例如,编译器不得将对 *p*q 的访问移动到函数调用f之前(至少在不直接了解 f 的精确行为的情况下):

1
2
3
f()
i := *p
*q = 1

如果f调用永远不会返回,那么原始程序将永远不会访问 *p*q ,但重写的程序会。如果f调用包含同步操作,则原始程序可以在访问 *p*q 之前建立 happens befor·的栅栏,但重写的程序不会。

不允许单次读取观察到多个值意味着不从共享内存中重新加载局部变量。例如,在此程序中,编译器不得丢弃 i 并再次从 *p中 重新加载它:

1
2
3
4
5
6
7
i := *p
if i < 0 || i >= len(funcs) {
	panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()

如果复杂代码需要很多寄存器,单线程程序的编译器可以丢弃 i 而不保存副本,然后在 funcs[i]() 之前重新加载 i = *p 。 Go 编译器不能,因为 *p 的值可能已经改变。 (相反,Go编译器可能会将 i (从寄存器)溢出到栈上。)

不允许单次写入写入多个值也意味着,在写入之前不将会写入局部变量的内存空间作为临时存储。例如,编译器不得在该程序中使用 *p 作为临时存储:

1
*p = i + *p/2

也就是说,不能将程序改写成这个:

1
2
*p /= 2
*p += i

如果 i 和 *p 开始时等于 2,则原始代码会执行 *p = 3 ,因此其它线程只能从 *p 读取到 2 或 3。重写的代码先执行 *p = 1 ,然后执行 *p = 3 ,从而允许其它线程也会读取到 1。

请注意,所有这些优化在 C/C++ 编译器中都是允许的:与 C/C++ 编译器共享后端的 Go 编译器必须注意禁用对 Go来说不合法的优化。

请注意,如果编译器可以证明data race不会影响目标平台上的正确执行,则禁止引入data race的要求并不适用。例如,在基本上所有 CPU 上,把代码

1
2
3
4
n := 0
for i := 0; i < m; i++ {
	n += *shared
}

重写为

1
2
3
4
5
n := 0
local := *shared
for i := 0; i < m; i++ {
	n += local
}

会是有效的, 前提是可以证明:“ *shared 不会在访问时出错,因为在这里添加的对shared的读取不会影响任何现有的并发读取或写入”。反过来说,这样的重写在源码到源码的翻译器中是不合法的。

Conclusion

编写无data race程序的 Go 程序员可以依赖于程序的顺序一致执行,就像在所有其他现代编程语言中一样。

当谈到有data race的程序时,程序员和编译器都应该记住这个建议:不要自作聪明。

附:其它同步原语保证

这些保证在它们各自的文档 中给出。

WaitGroup

Done调用synchronized before任何由它阻塞的Wait()调用的返回

Pool

Put(x)操作synchronized before返回相同x值的Get操作

返回x值的New操作synchronized before返回相同x值的Get操作

Map

写操作调用synchronized before任何读到该写操作影响的读操作调用

Cond

Broadcast/Signal操作synchronized before任何由它阻塞的Wait()调用的返回

相关内容参考

鸟窝 - [译]硬件内存模型

鸟窝 - [译]编程语言内存模型

鸟窝 - [译]更新Go内存模型

Tony Bai - Go 1.19中值得关注的几个变化

深入Go语言之旅 - 内存模型

Go内存模型介绍