Go以其易于构建并发程序而闻名。但是,所有这些并发性带来了可怕的数据竞争的可能性——如果您不幸在代码中遇到它,这将是最难调试的bug之一。
在这篇文章中,我们将介绍一个导致数据竞争的示例程序,并使用竞争检测器工具检测竞争条件。然后,我们将研究一些绕过和解决竞态条件的方法,同时仍然保持代码的核心逻辑不变。
数据竞赛
与其解释什么是数据竞争,不如让我们看一段示例代码:
package main
import "fmt"
func main() {
fmt.Println(getNumber())
}
func getNumber() int {
var i int
go func() {
i = 5
}()
return i
}
在这里,我们可以看到 getNumber
函数在一个单独的 goroutine 中设置 i
的值。并从函数返回 i
,但不知道我们的 goroutine 是否执行完成。所以现在,有两个操作正在进行:
i
的值被设置为5
- 函数返回
i
的值
取决于这两个操作中哪一个先完成,打印的值将是 0
(默认整数值)或 5
。
这就是为什么称它为数据竞争的原因:getNumber
返回的值取决于操作 1 或 操作 2 哪一个先完成。
检测数据竞争
我们讨论的代码是一个高度简化的数据竞争示例。在较大的应用程序中,单凭自己很难发现数据竞争。幸运的是,Go 从 v1.1 开始有一个内置的数据竞争检测器,我们可以使用它来检测潜在的数据竞争条件。
使用它很简单,只需向普通的 Go 命令添加 -race
标志。
例如,让我们尝试使用 -race
标志来运行我们刚刚编写的程序:
go run -race main.go
这是我得到的输出:
0
==================
WARNING: DATA RACE
Write at 0x00c000134008 by goroutine 7:
main.getNumber.func1()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:12 +0x30
Previous read at 0x00c000134008 by main goroutine:
main.getNumber()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:15 +0xb8
main.main()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:6 +0x24
Goroutine 7 (running) created at:
main.getNumber()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:11 +0xae
main.main()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:6 +0x24
==================
Found 1 data race(s)
exit status 66
第一个 0
是打印结果(所以我们现在知道操作 2 先完成)。接下来的几行给出了在代码中检测到的数据竞争的信息。
可以看到,关于数据竞争的信息被分为三个部分:
- 第一部分告诉我们,在我们创建的 goroutine 中(这是我们将值
5
赋值给i
的地方)有一次尝试写入 - 接下来告诉我们这是主 goroutine 的同时读取,在我们的代码中,它跟踪 15 行的 return 语句和 第 6 行的 print 语句。
- 第三部分描述了导致 (1) 的 goroutine 是在哪里创建的。
因此,仅通过添加一个 -race
标志,go run
命令就已经准确解释了上面我们说的有关数据竞争的内容。
在 Go 中检测潜在的竞争条件非常容易,我想不出有什么理由在构建 Go 应用程序时不使用 -race
标志。收益远远超过成本(如果有的话),并且有助于构建更健壮的应用程序。
修复数据竞争
Go 提供了多种解决数据竞争问题的方式。
使用等待组进行阻塞
解决数据竞争最直接的方法是在写操作完成之前阻止读访问:
package main
import (
"fmt"
"sync"
)
func main() {
fmt.Println(getNumber())
}
func getNumber() int {
var i int
// 初始化一个等待组变量
var wg sync.WaitGroup
// `Add(1) 表示我们需要等待 1 个任务
wg.Add(1)
go func() {
i = 5
// 调用 `wg.Done` 表示我们已经完成了等待的任务
wg.Done()
}()
// `wg.Wait` 将会阻塞,直到 `wg.Done` 被调用的次数与我们的任务数相同(在本例中为 1 次)
wg.Wait()
return i
}
使用 Channel 进行阻塞
这个方法在原理上与上一个类似,除了我们使用通道而不是等待组:
package main
import (
"fmt"
)
func main() {
fmt.Println(getNumber())
}
func getNumber() int {
var i int
// Create a channel to push an empty struct to once we're done
// 创建一个通道,在 goroutine 执行完成后将一个空结构 push 进来
done := make(chan struct{})
go func() {
i = 5
// 完成后 push 一个空结构
done <- struct{}{}
}()
// 这个语句将会阻塞,直到有数据被 push 到 `done` channel
<-done
return i
}
getNumber
函数内部的阻塞虽然简单,但如果我们想重复调用该函数,就会很麻烦。下一个方法采用了一种更灵活的阻塞方法。
返回 Channel
我们可以返回一个用于 push 结果的 channel,而不是使用 channel 来阻塞函数。与前两个方法不同,此方法本身不进行任何阻塞,而是将阻塞的决定留给调用代码。
// 返回一个整数通道而不是一个整数
func getNumberChan() <-chan int {
// 创建通道
c := make(chan int)
go func() {
// 将结果推送到通道中
c <- 5
}()
// 立即返回通道
return c
}
然后,您可以在调用代码中从通道中获取结果:
func main() {
// 代码被阻塞,直到有东西被推入返回的 channel
// 与之前的方法相反,我们阻塞在主函数中,而不是函数本身
i := <-getNumberChan()
fmt.Println(i)
}
这种方法更灵活,因为它允许更高级别的函数决定它们自己的阻塞和并发机制,而不是将 getNumber 函数视为同步函数。
使用互斥锁
到目前为止,我们的例子都是只在写操作完成后读取 i
的值。现在考虑这样一种情况,我们不关心读写的先后顺序,只要求它们不同时发生,那么应该考虑使用 Mutex
互斥锁:
// 首先,创建一个结构,其中包含我们要返回的值以及互斥量实例
type SafeNumber struct {
val int
m sync.Mutex
}
func (i *SafeNumber) Get() int {
// 如果 mutex 已经锁定,则 `Lock` 方法会阻塞,如果没有,则它会阻塞其他地方的调用,直到调用 `Unlock` 方法后解锁
i.m.Lock()
// 延迟 `Unlock` 直到该方法返回
defer i.m.Unlock()
// 返回值
return i.val
}
func (i *SafeNumber) Set(val int) {
// 类似于 `Get` 方法,我们加锁直到我们完成写入 `i.val`
i.m.Lock()
defer i.m.Unlock()
i.val = val
}
func getNumber() int {
// 创建一个 `SafeNumber` 的实例
i := &SafeNumber{}
// 使用 `Set` 和 `Get` 而不是常规的赋值和读取
// //我们现在可以确保只有在写入完成时才能读取,或者只有读取完成时才能写入
go func() {
i.Set(5)
}()
return i.Get()
}
然后我们可以像处理其他情况一样使用 getNumber
。乍一看,此方法似乎毫无用处,因为我们仍然无法保证 i
的值是多少。
当我们有多个写操作和读操作混杂在一起时,互斥量的真正价值就会显示出来。尽管在大多数情况下不需要互斥对象,因为前面提到的方法工作得足够好了,因此在这种情况下了解它们是有帮助的。
Golang 中的 for
循环陷阱
一个常见例子:
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
go func() {
fmt.Print(i)
}()
}
// 让我们在退出之前等待goroutines执行完成
time.Sleep(100 * time.Millisecond)
}
一个合理的假设是该程序将打印从 1 到 5 的数字。
但是这个假设是错误的;该程序将会打印字符串 55555
(如果您的结果不同,那么请尝试在最后增加 Sleep 时间)。
为什么会这样?这里采用了闭包的写法,i
变量实际上是一个单独的变量,它接受每个数字的值。每个迭代执行的闭包函数都绑定同一个值,在运行这段代码时,因为 goroutine 很有可能是在循环结束后才开始执行,循环结束时 i
的值是 5
,所以看到每次打印最后一个数字,而不是按顺序打印每个值。
数据竞争检测输出结果:
go run -race main.go
2==================
WARNING: DATA RACE
Read at 0x00c0000c0008 by goroutine 7:
main.main.func1()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:11 +0x3a
Previous write at 0x00c0000c0008 by main goroutine:
main.main()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:9 +0x69
Goroutine 7 (running) created at:
main.main()
/home/axiaoxin/go/src/blog.axiaoxin.com/main.go:10 +0x4d
==================
2445Found 1 data race(s)
exit status 66
当我们在每次迭代结束时引入一个小的延迟时,我们可以观察到行为的差异,如下所示:
...
// data race
for i := 0; i<5; i++ {
go func() {
fmt.Print(i)
}()
time.Sleep(50 * time.Millisecond)
}
...
这种情况下的输出将是 01234
,因为每个 goroutine 很可能在 i++
之前执行。
从本质上讲,这种数据竞争(可能导致难以发现的错误)是由相互竞争的并发 goroutine 之间不安全地共享可变状态(在本例中为 i
)引起的,这篇文章对这个问题做了详细解释)。Golang 官方 wiki 甚至将这种情况列为了常见错误。
那么,使用 goroutines 实现在 for 循环中异步处理一系列值的用例的正确方法是什么?答案就是停止共享可变状态,即在每次迭代中复制状态变量(在本例中为 i
)并将其传递给每个 goroutine,如下所示:
...
// no data race
for i := 0; i<5; i++ {
go func(n int) {
fmt.Print(n)
}(i)
}
...
在此实现中,将 i
作为参数 (n
) 按值传递给 goroutine; n
现在是 i
的 goroutine-local 副本,对 i
的后续更改不会反映在 n
上。
除此之外,在循环体内声明的变量不会在迭代之间共享,因此可以在闭包中单独使用。以下代码使用公共索引变量 i
来创建单独的 n
,从而产生预期的行为:
...
// no data race
for i := 0; i<5; i++ {
n := i
go func() {
fmt.Print(n)
}()
}
...
总结
当您运行带有 -race
标志的命令时,上述任何方法都可以防止出现数据竞争警告。每种方法都有不同的权衡和复杂性,因此您必须根据您的实际场景评估优缺点。
对我来说,如果我有任何疑问,使用 WaitGroup 通常会以最少的麻烦解决问题。
本文中解释的所有方法背后的核心原则是防止同时对同一个变量或内存位置进行读写访问,循环中访问共享变量时,记得使用状态副本!
所以,只要记住这一点,就可以快速解决 Go 中的数据竞争问题了。