Go中的内存顺序保证
很多编译器优化(在编译时刻)和CPU处理器优化(在运行时刻)会常常调整指令执行顺序,从而使得指令执行顺序和代码中指定的顺序不太一致。 指令顺序也称为。
当然,指令执行顺序的调整规则不是任意的。 最基本的要求是发生在一个不与其它协程共享数据的协程内部的指令执行顺序调整在此协程内部必须不能被觉察到。 换句话说,从这样的一个协程的角度看,其中的指令执行顺序和代码中指定的顺序总是一致的,即使确实有一些指令的执行顺序发生了调整。
然而,如果一些协程之间共享数据,那么在其中一个协程中发生的指令执行顺序调整将有可能被剩余的其它协程觉察到,从而影响到所有这些协程的行为。 在并发编程中,多个协程之间共享数据是家常便饭。 如果我们忽视了指令执行顺序调整带来的影响,我们编写的并发程序的行为将依赖于特定编译器和CPU。这样的程序常常表现出异常行为。
下面是一个编写得非常不职业的Go程序。此程序的编写没有考虑指令执行顺序调整带来的影响。 此程序扩展于官方文档Go 1 内存模型一文中的一个例子.
此程序的行为很可能正如我们所料,hello, world
将被打印输出。 然而,此程序的行为并非跨编译器和跨平台架构兼容的。 如果此程序使用一个不同的(但符合Go规范的)编译器或者不同的编译器版本编译,它的运行结果可能是不同的。 即使此程序使用同一个编译器编译,在不同的平台架构上的运行结果也可能是不同的。
编译器和CPU可能调整setup
函数中的前两条语句的执行顺序,使得setup
协程中的指令的执行顺序和下面的代码指定的顺序一致。
func setup() {
done = true
a = "hello, world"
if done {
log.Println(len(a))
}
}
setup
协程并不会觉察到此执行顺序调整,所以此协程中的log.Println(len(a))
语句将总是打印出12
(如果此打印语句在程序退出之前得到了执行的话)。 但是,此执行顺序调整将被主协程觉察到,所以最终的打印结果有可能是空,而不是hello, world
。
除了没有考虑指令执行顺序调整带来的影响,此程序还存在数据竞争的问题。 变量a
和done
的使用没有进行同步。 所以此程序是一个充满了各种并发编程错误的不良例子。 一个职业的Go程序员不应该写出这样的使用于生产环境中的代码。
我们可以使用Go官方工具链中的go build -race
命令来编译并运行一个程序,以检查此程序中是否存在着数据竞争。
Go内存模型(Memory Model)
有时,为了程序的逻辑正确性,我们需要确保一个协程中的一些语句一定要在另一个协程的某些语句之后(或者之前)执行(从这两个协程的角度观察都是如此)。 指令执行顺序调整可能会给此需求带来一些麻烦。 我们应如何防止某些可能发生的指令执行顺序调整呢?
不同的CPU架构提供了不同的栅栏(fence)指令来防止各种指令执行顺序调整。 一些编程语言提供对应的函数来在代码中的合适位置插入各种栅栏指令。 但是,理解和正确地使用这些栅栏指令极大地提高了并发编程的门槛。
Go语言的设计哲学是用尽量少的易于理解的特性来支持尽量多的使用场景,同时还要尽量保证代码的高执行效率。 所以Go内置和标准库并没有提供直接插入各种栅栏指令的途径。 事实上,这些栅栏指令被使用在Go中提供的各种高级数据同步技术的实现中。 所以,我们应该使用Go中提供的高级数据同步技术来保证我们所期待的代码执行顺序。
在下面的叙述中,如果我们说事件A
保证发生在事件B
之前,这意味着这两个事件涉及到的任何协程都将观察到在事件A
之前的语句肯定将在事件B
之后的语句先执行。 对于不相关的协程,它们所观察到的顺序可能并非如此所述。
一个协程的创建发生在此协程中的任何代码执行之前
在下面这个函数中,对x
和y
的赋值保证发生在对它们的打印之前,并且对x
的打印肯定发生在对y
的打印之前。
var x, y int
func f1() {
x, y = 123, 789
go func() {
fmt.Println(x)
go func() {
fmt.Println(y)
}()
}()
}
然而这些顺序在下面这个函数中是得不到任何保证的。此函数存在着数据竞争。
通道操作相关的顺序保证
下面列出的是通道操作做出的基本顺序保证:
- 一个通道上的第n次成功发送操作的开始发生在此通道上的第n次成功接收操作完成之前,无论此通道是缓冲的还是非缓冲的。
- 一个容量为m通道上的第n次成功接收操作的开始发生在此通道上的第n+m次发送操作完成之前。 特别地,如果此通道是非缓冲的(
m == 0
),则此通道上的第n次成功接收操作的开始发生在此通道上的第n次发送操作完成之前。 - 一个通道的关闭操作发生在任何因为此通道被关闭而从此通道接收到了零值的操作完成之前。
事实上, 对一个非缓冲通道来说,其上的第n次成功发送的完成的发送和其上的第n次成功接收的完成应被视为同一事件。
下面这段代码展示了一个非缓冲通道上的发送和接收操作是如何保证特定的代码执行顺序的。
func f3() {
var a, b int
var c = make(chan bool)
go func() {
a = 1
c <- true
if b != 1 {
panic("b != 1") // 绝不可能发生
}
}()
go func() {
b = 1
<-c
if a != 1 {
panic("a != 1") // 绝不可能发生
}
}()
}
对于函数f3
中创建的两个协程,下列顺序将得到保证:
- 赋值语句
b = 1
肯定在条件b != 1
被估值之前执行完毕。 - 赋值语句
a = 1
肯定在条件a != 1
被估值之前执行完毕。
所以,上例代码中两个协程中的panic
调用将永不可能得到执行。 做为对比,下面这段代码中的panic
调用有可能会得到执行,因为上述通道操作相关的顺序保证对于不相关的协程是无效的。
func f4() {
var a, b, x, y int
c := make(chan bool)
go func() {
a = 1
c <- true
x = 1
}()
go func() {
b = 1
<-c
y = 1
}()
// 一个和上面的通道操作不相关的协程。
// 这是一个不良代码的例子,它造成了很多数据竞争。
go func() {
if a != 1 {
panic("a != 1") // 有可能发生
}
if b != 1 {
panic("b != 1") // 有可能发生
}
}
if y == 1 {
if a != 1 {
panic("a != 1") // 有可能发生
}
if b != 1 {
panic("b != 1") // 有可能发生
}
}
}()
}
这里的新创建的第三个协程是一个和通道c
上的发送和接收操作不相关的一个协程。 它所观察到的执行顺序和其它两个新创建的协程可能是不同的。 条件a != 1
和b != 1
的估值有可能为true
,所以四个panic
调用有可能会得到执行。
事实上,大多数编译器的实现确实很可能能够保证上面这个不良的例子中的四个panic
调用永远不可能被执行,但是,没有任何Go官方文档做出了这样的保证。 此不良例子的执行结果是依赖于不同的编译器和不同的编译器版本的。 我们编写的Go代码应该以Go官方文档中明确记录下来的规则为依据。
下面是一个缓冲通道的例子。
在此例子中,下面的顺序得以保证:
- 赋值语句
k = 1
的执行保证在赋值语句y = 1
的执行之前结束。 - 赋值语句
x = 1
的执行保证在赋值语句n = 1
的执行之前结束。
下面是一个通道关闭的例子。在这个例子中,赋值语句k = 1
的执行保证在赋值语句y = 1
执行之前结束,但不能保证在赋值语句x = 1
执行之前结束。
func f6() {
var k, x, y int
c := make(chan bool, 1)
go func() {
c <- true
k = 1
close(c)
}()
go func() {
<-c
x = 1
<-c
y = 1
}()
}
互斥锁相关的顺序保证
Go中和相关的顺序保证:
- 对于一个可寻址的
sync.Mutex
类型或者sync.RWMutex
类型的值m
,第n次成功的m.Unlock()
方法调用保证发生在第n+1次m.Lock()
方法调用返回之前。 - 对一个可寻址的
RWMutex
类型值rw
,如果它的第n次rw.Lock()
方法调用已成功返回,并且有一个rw.RLock()
方法调用保证发生在此第n次rw.Lock()
方法调用返回之后,则第n次成功的rw.Unlock()
方法调用保证发生在此rw.RLock()
方法调用返回之前。 - 对一个可寻址的
RWMutex
类型值rw
,如果它的第n次rw.RLock()
方法调用已成功返回,并且有一个rw.Lock()
方法调用保证发生在此第n次rw.RLock()
方法调用返回之后,则第m次成功的rw.RUnlock()
方法调用(其中m <= n
)保证发生在此rw.Lock()
方法调用返回之前。
在下面这个例子中,下列顺序肯定得到保证。
- 赋值语句
a = 1
的执行保证在赋值语句b = 1
的执行之前结束。 - 赋值语句
m = 1
的执行保证在赋值语句n = 1
的执行之前结束。 - 赋值语句
x = 1
的执行保证在赋值语句y = 1
的执行之前结束。
func fab() {
var a, b int
var l sync.Mutex // or sync.RWMutex
go func() {
l.Lock()
l.Unlock()
}()
go func() {
a = 1
l.Unlock()
}()
}
func fmn() {
var m, n int
var l sync.RWMutex
l.RLock()
go func() {
l.Lock()
n = 1
l.Unlock()
}()
go func() {
m = 1
l.RUnlock()
}()
}
func fxy() {
var x, y int
var l sync.RWMutex
l.Lock()
go func() {
l.RLock()
y = 1
l.RUnlock()
}()
go func() {
x = 1
l.Unlock()
}()
}
注意,在下面这段代码中,根据Go官方文档,赋值语句p = 1
的执行并不能保证在赋值语句q = 1
的执行之前结束,尽管多数编译器确实能够做出这样的保证。
sync.WaitGroup
值做出的顺序保证
假设在某个给定时刻,一个可寻址的sync.WaitGroup
值wg
维护的计数不为0,并且有一个wg.Wait()
方法调用在此给定时刻之后调用。 如果有一组wg.Add(n)
方法调用在此给定时刻之后调用,并且我们可以保证这组调用中只有最后一个返回的调用会将wg
维护的计数修改为0, 则这组调用中的每个调用保证都发生在此wg.Wait()
方法调用返回之前。
注意:调用wg.Done()
和wg.Add(-1)
是等价的。
请阅读来获取如何使用sync.WaitGroup
值。
sync.Once
值做出的顺序保证
请阅读来获取sync.Once
值做出的顺序保证和如何使用sync.Once
值。
sync.Cond
值做出的顺序保证
sync.Cond
值出的顺序保证有些难以表达清楚。所以这里就只可意会不可言传了。 请阅读来获取如何使用sync.Cond
值。
原子操作相关的顺序保证
没有任何官方文档提到了原子操作所保证的内存顺序。 然而,事实上,对于标准编译器来说,原子操作确实保证了某些特定的内存顺序。 标准库的实现大量地依赖于这些原子操作做出的内存顺序保证。
下面这个程序如果使用标准编译器1.17编译的话,它在运行时肯定打印出1
。
package main
import "fmt"
import "sync/atomic"
import "runtime"
func main() {
var a, b int32 = 0, 0
go func() {
atomic.StoreInt32(&a, 1)
atomic.StoreInt32(&b, 1)
}()
for {
if n := atomic.LoadInt32(&b); n == 1 {
fmt.Println(atomic.LoadInt32(&a)) // 1
break
}
runtime.Gosched()
}
在此程序中,主协程总是会观察到对a
的修改在对的修改之前结束。 但是,这种保证没有记录在任何Go官方文档中。 如果你希望你编写的Go代码能够跨编译器和跨编译器版本兼容,那么安全的建议是不要依赖于原子操作做出的内存顺序保证。 有建议Go官方文档将目前原子操作做出的内存顺序保证记录下来。 但是到目前为止(Go 1.17),此建议还未被采纳。