Go如何保证内存顺序

内存顺序简介

许多编译器(在编译时)和CPU处理器(在运行时)经常通过指令重排进行一些优化,使得指令的执行顺序可能与代码中显示的顺序有所不同,指令的执行顺序也通常称为内存顺序。

当然,指令重排不能是任意的。在某个goroutine中能受指令重排影响的前提条件是,goroutine与其他goroutine共享数据,否则goroutine本身不能检测到指令重排。换句话说,不与其他goroutine共享数据的goroutine,它的代码执行顺序始终与代码编写顺序相同,即使实际上有指令重排也是如此。

对于这样的goroutine,它可以认为其指令执行顺序始终与代码指定的顺序相同,即使实际上在其中进行了指令重新排序也是如此。

但是,如果某些goroutine共享数据,则一个goroutine可能会观察到另一个goroutine中的指令重排,并影响相关goroutine的行为。在goroutine之间共享数据在并发编程中很常见,如果忽略指令重排造成的结果影响,我们的并发程序的行为可能与编译器和CPU有关,并且容易出异常。

这是一个不严谨的Go程序,它不考虑指令重排。该程序是从文档Go内存模型中的示例扩展而来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
}

该程序的行为像我们预期的那样,会打印hello, world.世界文本。但是,该程序的行为取决于编译器和CPU,如果该程序是使用不同的编译器或更高版本的编译器进行编译 ,或者它运行在不同体系结构CPU上,则可能不会打印hello, world.文本,或者可能会打印出不同的文本。原因是编译器和CPU可能会交换setup()函数中前两行的执行顺序,因此setup()函数的最终执行顺序可能是以下代码顺序。

1
2
3
4
5
6
7
func setup() {
done = true
a = "hello, world"
if done {
log.Println(len(a))
}
}

上述程序中的setup()函数的goroutine无法观察到指令重排,因此log.Println(len(a))行将始终打印12(如果setup()能在主线程退出之前执行),但是主goroutine可能会观察到指令重排,这就是打印的文本可能不是hello, world.的原因。

除了内存重新排序的问题外,程序中还存在数据竞争问题。使用变量adone并没有任何同步措施,因此上面的程序充分展示了并发编程的错误。专业的Go程序员不应犯这些错误。

我们可以使用Go工具链中提供的go build -race命令来构建程序,然后运行提取可执行文件来检查程序中是否存在数据竞争。

Go内存模型

有时,我们需要确保一个goroutine中某些代码行的执行必须在另一个goroutine中某些代码行的执行之前(或之后)执行(从这两个goroutine中任何一个的视角来看),以保持代码逻辑的正确性。在这种情况下,指令重排可能会造成一些麻烦。我们应如何防止某些可能的指令重排?

不同的CPU架构提供不同的栅栏指令以防止不同种类的指令重排。某些编程语言提供将这些栅栏指令插入代码中的相应函数,但是,理解和正确使用栅栏指令提高了并发编程的使用标准。

Go的设计理念是使用尽可能少的特性功能以支持尽可能多的用户场景,同时确保足够好的整体代码执行效率。因此,Go内置和标准包不提供直接使用CPU栅栏指令的方法。实际上,CPU栅栏指令体现在Go支持的各种同步技术中。因此,我们应该使用这些同步技术来确保预期的代码执行顺序。

下文将列出Go中某些保证的(和非保证的)代码执行顺序,包括在Go内存模型文档和其他官方Go文档中提到或未提到的。

在下面的描述中,如果我们说事件A肯定在事件B之前发生,则意味着涉及这两个事件的任何goroutine都会观察到一个现象,在源代码中的事件A之前出现的任何语句,都将在事件B之后出现的任何语句之前执行。对于其他不相关的goroutine,观察到的顺序可能与刚刚描述的顺序不同。

goroutine的创建发生在goroutine的执行之前

在以下函数中,赋值x, y = 123, 789语句将在fmt.Println(x)之前调用,并在调用fmt.Println(y)之前执行调用fmt.Println(x)。

1
2
3
4
5
6
7
8
9
10
var x, y int
func f1() {
x, y = 123, 789
go func() {
fmt.Println(x)
go func() {
fmt.Println(y)
}()
}()
}

但是,以下函数中三个语句的执行顺序不确定,此函数存在数据竞争。

1
2
3
4
5
6
7
8
9
10
11
12
var x, y int
func f2() {
go func() {
// Might print 0, 123, or some others.
fmt.Println(x)
}()
go func() {
// Might print 0, 789, or some others.
fmt.Println(y)
}()
x, y = 123, 789
}

channel操作与顺序保证有关系

Go内存模型文档列出了下面三种channel相关的顺序保证。

  • 不管该channel是有缓冲的还是无缓冲的,向该channel的第n次写入成功,都会在从该channel第n次成功读取之前发生。
  • 从容量为m的channel进行的第n次成功读取发生在向该channel的第(n+m)次成功写入之前,特例是如果该channel无缓冲(m == 0),则从该channel进行的第n次成功读取发生在 该channel上的第n个成功写入之前。
  • 读取完成之前关闭channel,如果发生读取动作,会返回零值,因为channel是关闭状态。

实际上,到一个channel的第n个成功写入和第n个成功读取是同一事件。

下面例子展示了使用无缓冲的channel如何保证一些代码的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func f3() {
var a, b int
var c = make(chan bool)

go func() {
a = 1
c <- true
if b != 1 { // impossible
panic("b != 1") // will never happen
}
}()

go func() {
b = 1
<-c
if a != 1 { // impossible
panic("a != 1") // will never happen
}
}()
}

在这里,对于两个新创建的goroutine,以下顺序可以保证:

  • 赋值b = 1的执行绝对在条件b != 1的判断之前结束
  • 赋值a = 1的执行绝对在条件a != 1的判断之前结束

因此,上面示例中的两次panic调用将永远不会执行,但是以下示例中的panic调用可能会执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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
}()

// Many data races are in this goroutine.
// Don't write code as such.
go func() {
if x == 1 {
if a != 1 { // possible
panic("a != 1") // may happen
}
if b != 1 { // possible
panic("b != 1") // may happen
}
}

if y == 1 {
if a != 1 { // possible
panic("a != 1") // may happen
}
if b != 1 { // possible
panic("b != 1") // may happen
}
}
}()
}

在这里,对于第3个goroutine,与channel c上的操作无关。不能保证观察到的顺序和前两个新创建的goroutine所观察到的顺序一致,因此,可能会执行四个panic调用中的任何一个。

实际上,大多数编译器实现的确保证了上面示例中的四个panic调用永远不会执行,但是Go官方文档从未做出这样的保证。因此上面示例中的代码在交叉编译器或交叉编译器版本是不兼容的(会有顺序问题)。我们应该遵循Go官方文档来编写专业的Go代码。

这是一个使用有缓冲的channel的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func f5() {
var k, l, m, n, x, y int
c := make(chan bool, 2)

go func() {
k = 1
c <- true
l = 1
c <- true
m = 1
c <- true
n = 1
}()

go func() {
x = 1
<-c
y = 1
}()
}

可以保证以下顺序:

  • k = 1的执行在y = 1的执行之前结束。
  • x = 1的执行在n = 1的执行之前结束。

但是,不能保证x = 1的执行在l = 1和m = 1的执行之前发生,并且l = 1和m = 1的执行不能保证在y = 1的执行之前发生。

以下是通道关闭的示例。在此示例中,保证k = 1的执行在y = 1的执行之前结束,但不保证在x = 1的执行之前结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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中互斥锁保证顺序的场景。

  • 对于同步标准库中的Mutex或RWMutex类型的引用变量m,第n个m.Unlock()方法的成功调用发生在第(n+1)个m.Lock()方法调用返回之前
  • 对于RWMutex类型的引用变量rw,如果返回了第n个rw.Lock()方法调用,则其第n个rw.Unlock()方法的成功调用,发生在rw.RLock()方法调用返回之前,并且发生在第n个rw.Lock()方法调用之后
  • 对于RWMutex类型的引用变量rw,如果返回了第n个rw.RLock()方法调用,则其第m次rw.RUnlock()方法的成功调用(其中m <= n),发生在任何rw.Lock()方法调用之前,发生在第n个rw.RLock()方法调用之后

后面的示例中,保证以下顺序:

  • a = 1的执行在b = 1的执行之前结束。
  • m = 1的执行在n = 1的执行之前结束。
  • x = 1的执行在y = 1的执行之前结束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func fab() {
var a, b int
var l sync.Mutex // or sync.RWMutex

l.Lock()
go func() {
l.Lock()
b = 1
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的执行之前结束,尽管大多数编译器确实提供这种保证。

1
2
3
4
5
6
7
8
var p, q int
func fpq() {
var l sync.Mutex
p = 1
l.Lock()
l.Unlock()
q = 1
}

sync.WaitGroup保证顺序

在给定的时间,假设计数器由一个sync.WaitGroup的引用变量wg维护,并且不为零。如果在指定时间之后,调用一组wg.Add(n)方法,我们可以确保最后一次wg.Done()调用会将wg维护的计数器修改为零,然后保证wg.Add(n)和wg.Done()都在wg.Wait方法调用返回之前发生,该调用在指定逻辑之后调用。

注意,wg.Done()等同于wg.Add(-1)。

请阅读有关sync.WaitGroup类型的说明,以及如何使用sync.WaitGroup。

sync.Once保证顺序

请阅读sync.Once类型的说明以获取sync.Once的值以及如何使用sync.Once保证顺序。

sync.Cond保证顺序

很难清楚地描述sync.Cond的顺序保证。请阅读sync.Cond类型的说明以及如何使用sync.Cond值。

atomic原子变量保证顺序

Go的官方文档都没有提到原子变量同步技术保证的内存顺序。但是,在标准Go编译器的实现中,原子变量操作确实存在一些内存顺序保证。标准包在很大程度上依赖于原子操作提供的保证。

如果使用标准Go编译器1.14进行编译,以下程序将始终打印1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 {
// The following line always prints 1.
fmt.Println(atomic.LoadInt32(&a))
break
}
runtime.Gosched()
}
}

在这里,主goroutine总是会观察到a的修改要早于b的修改。但是,原子操作所提供的顺序保证不会在Go规范和任何其他官方Go文档中列出。但是,跨编译器体系和跨编译器版本兼容的Go代码,安全的建议是,在普世Go编程中,不要依赖于atomic原子变量来保证内存顺序。有一个关于如何对普通编码人员开放使用atomic原子变量的issue。但是,到目前为止( 转到1.14),尚未做出决定。

欢迎阅读本文以了解如何保证代码执行的内存顺序。