竞态问题可能是程序员面临的最困难最隐蔽的问题之一,作为一名Go开发人员,我们必须要了解数据竞争和竞争条件的关键点,出现了会产生什么影响以及如何避免。本节内容将首先讨论数据竞争问题以及竞争的条件,然后将深入研究Go内存模型,并分析它的重要性。
先来看一个数据竞争的实例,当两个goroutine同时访问同一个内存位置并且至少有一个正在写入时,就会产生数据竞争。下面的代码中两个goroutine对一个共享变量进行增加操作:
i := 0
go func() {
i++
}()
go func() {
i++
}()
上面的代码保存在dataRace.go文件中,在执行的时候加上-race参数,得到的输出结果如下:
go run -race dataRace.go
==================
WARNING: DATA RACE
Read at 0x00c00001c0e8 by goroutine 7:
main.main.func2()
../100-go-mistakes-code/58/dataRace.go:11 +0x30
Previous write at 0x00c00001c0e8 by goroutine 6:
main.main.func1()
../100-go-mistakes-code/58/dataRace.go:7 +0x44
Goroutine 7 (running) created at:
main.main()
../100-go-mistakes-code/58/dataRace.go:10 +0x118
Goroutine 6 (finished) created at:
main.main()
../100-go-mistakes-code/58/dataRace.go:6 +0xb0
==================
Found 1 data race(s)
exit status 66
此外,运行上述程序,得到i的值是不确定的,有可能是1,也有可能是2.
产生上述结果的原因是什么呢?因为i++操作是非原子操作,它可以分解为下面的三条指令:
如果第一个goroutine在第二goroutine之前执行并完成,会出现如下情况:
goroutine1 | goroutine2 | operation | i |
---|---|---|---|
0 | |||
read | ← | 0 | |
increment | 0 | ||
write back | → | 1 | |
read | ← | 1 | |
increment | 1 | ||
write back | → | 2 |
第一个goroutine先读取i的值,然后自增,最后值写入i中。然后是第二个goroutine执行相同的指令,所以最后i的值为2。
然而,在上面的例子中,不能保证第一个goroutine会在第二goroutine之前执行完成. 可能存在交错执行的情况,两个goroutine同时运行并竞争访问i的情况。下面是另一种可能的场景:
goroutine1 | goroutine2 | operation | i |
---|---|---|---|
0 | |||
read | ← | 0 | |
read | ← | 0 | |
increment | 0 | ||
increment | 0 | ||
write back | → | 1 | |
write back | → | 1 |
上面的执行流程是,两个goroutine同时从i获取值0,然后都开始自增,最后都将增加后的值1赋值给i,最后i的值为1,这个不是我们期望的结果。这是数据竞争的可能影响,如果两个goroutine同时访问一个内存位置,并且至少有一个写入该内存位置,则可能会导致危险。更糟糕的是,在某些情况下,内存位置甚至可能最终保存一个包含无意义的位(bit)组合的值。
那如何防止数据竞争的产生呢?有哪些技术手段能够解决这里的竞争问题。下面讨论主要的解决方法,不会面面俱到的呈现所有可能的解决方法。
第一个方法是可以使增量操作原子化,也就是在单个操作中完成,这样可以防止多个goroutine交错的执行。
goroutine1 | goroutine2 | operation | i |
---|---|---|---|
0 | |||
read and increment | ↔ | 1 | |
read and increment | ↔ | 2 |
即使上面的第二个goroutine在第一个之前运行,最后的结果仍然是2.
在Go语言中原子操作可以使用标准库中提供的atomic包。下面是使用atomic包实现原子自增的实例。
var i int64
go func() {
atomic.AddInt64(&i, 1)
}()
go func() {
atomic.AddInt64(&i, 1)
}()
上面程序中两个goroutine都以原子方式更新i.原子操作不会被中断,因此可以防止同时进行两次访问,不管goroutine执行顺序如何,最终i的值都为2.
第二个方法是对多个goroutine竞争访问的资源加互斥锁。mutex可以确保最多有一个goroutine访问临界区,在Go语言中,sync包提供了互斥锁mutex类型。
i := 0
mutex := sync.Mutex{}
go func() {
mutex.Lock()
i++
mutex.Unlock()
}()
go func() {
mutex.Lock()
i++
mutex.Unlock()
}()
在上面的例子中,增加i的操作放在临界区内,不管goroutine的执行顺序如何,i的值为确定的2.
原子操作和mutex操作哪种效果更好?很容易判断,原子操作只适用于特定的类型(像int,int32,int64等),如果操作的是其他结构,例如切片、map等,就不能使用atomic了。
另一种可能的选择是不共享相同的内存位置,而是goroutine直接的通信来共享内存。例如,我们可以为每个goroutine创建一个通道来产生增量值。示例代码如下,每个goroutine通过通道通知应该将i的值加1,父goroutine收集通知并递增值,因为它是唯一写入i的goroutine,所以下面的程序也是无数据竞争的。
i := 0
ch := make(chan int)
go func() {
ch <- 1
}()
go func() {
ch <- 1
}()
i += <-ch
i += <-ch
现在来对上述内容做一个总结,当多个goroutine同时访问一个内存位置,例如同一个变量,并且至少一个正在写入时,就会发送数据竞争。我们提到了3种解决方法:
在上面的三种方法中,i的值都是2,不管两个goroutine之间的执行顺序如何。然而,无论如何进行操作,结果总是相同的吗?没有数据竞争的应用程序是否一定意味着确定性的结果?下面通过一个例子进行说明。
i := 0
mutex := sync.Mutex{}
go func() {
mutex.Lock()
defer mutex.Unlock()
i = 1
}()
go func() {
mutex.Lock()
defer mutex.Unlock()
i = 2
}()
上面的程序不是让两个goroutine都对变量i进行自增操作,而是每个goroutine都对i进行赋值操作,并使用互斥锁的方法来防止数据竞争。第一个goroutine将1赋值给i,第二个goroutine将2赋值给i. 这个示例代码中是否存在数据竞争?不,不存在。两个goroutine都访问同一个变量,但是通过互斥锁保护i不能同时访问。然而,这个例子中i的值最后是确定的吗?不,不是。
依赖于goroutine的执行顺序,i的值最终要么是1要么是2,上述代码虽然没有数据竞争,然而,它有一个竞争条件。当程序的行为取决于无法控制的事件的顺序或时间时,就会出现竞争条件,这里事件发生的时间是goroutine的执行顺序。
确保goroutine之间的特定顺序是一个协调和编排的问题。如果我们想确保首先从状态0到状态1,然后再从状态1到状态2,需要找到一种方法来保证goroutines是按顺序执行的。channel是解决该问题的一种方法。此外,如果协调和编排了goroutine的执行,还可以确保了特定部分只有一个goroutine访问,也就是说上面程序中的mutex可以被移除。
总结,当我们在编写并发程序时,必须知道数据竞争和竞争条件是不同的。当多个goroutine同时访问同一个内存位置,并且至少有一个对其进行写入时,就会产生数据竞争,数据竞争意味着程序的输出是不确定的。但是,没有数据竞争的程序并不一定意味着程序的结果是确定的。事实上,一个程序没有数据竞争,但它的行为仍然取决于不受控制的事情,例如goroutine的执行顺序,向channel发送消息的速度。对database调用持续的时间等,这是一个竞争条件。了解清楚数据竞争和竞争条件对编写并发的应用程序至关重要。
在前面一小节讨论了同步goroutine的三种方法:原子操作、互斥锁和通道。但是作为一名Go程序员,我们还需要了解一些核心原则。例如,对于channel,缓冲通道和无缓冲通道之间的保证是不同的。为了避免因对语言核心规范缺乏了解而导致的意外竞争,有必要深入研究Go内存模型。
Go内存模型是一种规范,它规定了在不同的goroutine中写入同一个变量之后,可以保证读取一个goroutine中变量的条件。开发人员需要记住这些规范和强制性输出保证,避免数据竞争。
在单个goroutine中,不会导致不同步的访问的问题,事实上,happens-before顺序由程序的代码顺序保证。然而,在多个goroutine中,应该记住定义的一些保证,下面将使用符合A<B表示事件A发送在事件B之前,现在来看这些保证。
i := 0
go func() {
i++
}()
i := 0
go func() {
i++
}()
fmt.Println(i)
如果我们想防止上面程序存在的数据竞争,应该同步它们的操作。
i := 0
ch := make(chan struct{})
go func() {
<-ch
fmt.Println(i)
}()
i++
ch <- struct{}{}
i := 0
ch := make(chan struct{})
go func() {
<-ch
fmt.Println(i)
}()
i++
close(ch)
i := 0
ch := make(chan struct{}, 1)
go func() {
i = 1
<-ch
}()
ch <- struct{}{}
fmt.Println(i)
上面的程序存在数据竞争,让我们直观的看它是怎么产生的。
可以观察到,对变量i的读取和写入可能同时发送,因为没有同步保证。现在,将上述的有缓冲通道改为无缓冲通道,就不存在数据竞争了,这是Go内存模型保证的。
i := 0
ch := make(chan struct{})
go func() {
i = 1
<-ch
}()
ch <- struct{}{}
fmt.Println(i)
从两幅图的对比中,可以看到主要区别:保证写入发生在读取之前,注意,箭头不代表因果关系,它代表的是Go内存模型的排序保证。由于来自无缓冲通道的接收发生在发送之前,因此对i的写入将始终发生在读取之前。
总结,本节中介绍了Go内存模型的一些保证,在编写并发代码时,理解这些保证是我们必须掌握的知识。它可以防止我们做出可能导致数据竞争或者竞争条件的错误想法假设。