01
介绍
Go 内存模型可以保证一个 goroutine 可以读取在不同 goroutine 中修改同一指定变量的值。
02
建议
程序中的一个 goroutine 修改的数据,如果同时有其它 goroutine 读取该数据,则需要保证程序串行化执行。
为了保证程序串行化执行,我们需要使用 channel 通道操作或其他同步原语(例如 sync 和 sync/atomic 包中的原语)来保护数据。
03
先行发生(Happens Before)
在单个 goroutine 中,读取和写入的行为必须按照程序指定的顺序执行。也就是说,仅当重新排序不会改变语言规范所定义的该 goroutine 中的运行结果时,编译器和处理器才可以对单个 goroutine 中执行的读取和写入进行重新排序。因为此重新排序,一个 goroutine 查看到的执行顺序可能与另一个 goroutine 查看到的执行顺序不同。
例如,如果一个 goroutine 执行 a = 1;b = 2;另一个可能会在 b 的更新值之前查看 b 的更新值。
为了说明读取和写入的要求,Go team 定义了「先行发生(Happens Before)」原则,在 Go 程序中执行内存操作的偏序。如果事件 e1 发生在事件 e2 之前,那么我们说 e2 发生在 e1 之后。同样,如果 e1 不在 e2 之前发生并且在 e2 之后也没有发生,那么我们说 e1 和 e2 并发。
在单个 goroutine 中,先行发生顺序是程序表示的顺序。
如果同时满足以下两个条件,则允许对变量 v 的读操作 r 查看对变量 v 的写操作 w:
- r 在 w 之前不会发生。
- 在 w 之后且在 r 之前没有发生对 v 的其他写操作。
为了保证变量 v 的读取操作 r 查看到对 v 的特定写入操作 w,请确保 w 是唯一允许 r 查看的写入操作。也就是说,如果同时满足以下两个条件,则保证 r 查看到 w:
- w 发生在 r 之前。
- 对共享变量 v 的任何其他写操作都发生在 w 之前或 r 之后。
这对条件比第一对要更加严格。它要求没有其他写入操作与 w 或 r 并发。
在单个 goroutine 中,没有并发性,因此这两个定义是等效的:读取操作 r 查看最近写入操作 w 写入到 v 的值。当多个 goroutine 访问共享变量 v 时,它们必须使用同步事件来建立先行发生条件,确保读取操作可以看到所需的写入操作。
用 v 的类型的零值初始化变量 v 的行为与在内存模型中的写操作相同。
对大于单个机器字的变量的读取和写入,将如同以未指定顺序的多个机器字大小的变量的操作。
04
同步
初始化:
程序初始化在单个 goroutine 中运行,但是该 goroutine 可能会创建其他并发执行的 goroutine。
如果包 p 导入了包 q,则 q 的 init 函数执行完成,先行发生在任何 p 的 init 函数开始执行之前。
函数 main.main 的开始执行发生在所有 init 函数执行完成之后。
创建 goroutine:
go 关键字启动新 goroutine 先行发生在该 goroutine 的执行开始之前。
例如,在此程序中:
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
调用 hello 函数,会在之后的某个时间(也许在 hello 返回之后)打印 “ hello,world”。
销毁 goroutine:
不能保证 goroutine 的退出会先行发生在程序中的任何事件发生。例如,在此程序中:
var a string func hello() { go func() { a = "hello" }() print(a) }
未使用任何同步事件限制对变量 a 的赋值操作,因此不能保证任何其他 goroutine 都会看到变量 a 的赋值。实际上,激进的编译器可能会删除整个 go 语句。
如果需要保证一个 goroutine 的执行结果,可以通过另一个 goroutine 来查看到,请使用同步机制(例如锁或 channel 通道通信)来建立程序执行的相对顺序。
channel 通信:
channel 通道通信是 goroutine 之间同步的主要方法。通常在不同的 goroutine 中,将特定 channel 通道上的每个发送与该 channel 通道上的相应接收进行匹配。
channel 通道上的发送操作先行发生在该 channel 通道上的相应接收操作完成。
该程序:
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 的写操作先行发生在对 channel 通道 c 的发送,先行发生在相应的 channel 通道 c 接收完成,先行发生在 print 操作。
channel 通道关闭先行发生在由于 channel 通道关闭而返回零值的接收。
在前面的示例中,用 close(c) 替换 c <- 0 将产生具有相同运行结果的程序。
来自未缓冲通道的接收先行发生在该通道上的发送完成。
该程序(如上所述,但是交换了 send 和 receive 语句并使用了未缓冲的通道):
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 的写操作先行发生在 c 的接收,先行发生在相应的 c 的发送完成,先行发生在 print 操作。
如果通道有缓冲(例如,c = make(chan int,1)),则不能保证程序会打印 “ hello,world”。(它可能会打印空字符串,崩溃或执行其他操作。)
在容量为 C 的通道上的第 k 个接收先行发生在该通道的第 k + C 个发送完成。
该规则将前一个规则推广到缓冲通道。它允许通过缓冲的 channel 通道对计数信号量进行建模:channel 通道中的元素数量对应于活动使用的数量,channel 通道的容量对应于同时使用的最大数量,发送一个元素获取信号量,以及接收元素会释放信号量。这是限制并发性的常见用法。
该程序为 work 列表中的每个条目启动一个 goroutine,但是 goroutine 使用限制通道进行协调,以确保一次最多运行三个 work 函数。
var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} }
锁:
sync 包实现了两种锁定数据类型,即 sync.Mutex 和 sync.RWMutex。
对任何的 sync.Mutex 或 sync.RWMutex 变量 l 和 n < m,n 次调用 l.Unlock()先行发生在 m 次 l.Lock() 返回。
该程序:
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 中)先行发生在第二次调用 l.Lock()(在 main 中),先行发生在 print 操作。
对于 sync.RWMutex 变量 l,任意的函数调用 l.RLock 满足第 n 次 l.RLock 后发生于第 n 次调用 l.Unlock,对应的 l.RUnlock 先行发生于第 n+1 次调用 l.Lock。
Once:
sync 包为 Once 类型的多个 goroutine 提供了一种安全的初始化机制。多个线程可以对于一个特定的 f 执行 Do(f),但是只有一个线程将运行 f(),而其它线程调用将阻塞直到 f() 返回。
一个调用 Once.Do(f) 的返回先行发生在其它调用 Once.Do(f) 的返回。
在此程序中:
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 将只调用一次 step。setup 先行发生在两次调用 print 操作。结果是 “ hello,world” 将被打印两次。
05
同步的错误使用示例
注意,读取操作 r 可能会查看到并发执行的写入操作 w 写入的值。即使这样,也不意味着在 r 之后发生的读取操作将查看到在 w 之前发生的写入操作。
在此程序中:
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
g 可能会先打印 2,然后再打印 0。
这个事实证明一些常见的习惯用法是不正确的。
双重检查锁定是为了避免同步的资源开销。例如,twoprint 程序可能被错误地编写为:
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”。
另一个不正确的习惯用法是忙等待,如:
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 会始终执行写入操作。不能保证 main 中的循环完成。
此示例有一些微妙的改变,例如该程序。
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 的初始化值。
在所有这些示例中,解决方案都是相同的:显式使用同步。
06
总结
本文介绍了Golang 语言的内存模型,介绍了 Happens Before 原则,并给出了一些关于同步的最佳实践和错误示例。文章内容较长,可能会有部分内容翻译不准确,建议读者再阅读一遍英文原文。如果您对 Go 的并发编程还不是很了解,建议阅读文末「推荐阅读」列表的相关文章。