在 Golang 并发编程中,你是否遇到过程序运行结果不一致,难以调试的问题?这很可能是数据竞争导致的。数据竞争是并发编程中常见且棘手的难题,它会导致程序行为不可预测,甚至引发严重错误。本文将深入探讨数据竞争的成因、检测方法以及多种解决方案,帮助你编写更安全、更高效的 Go 程序。
数据竞争:Golang 并发编程的隐形杀手
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 哪一个先完成。
相关阅读推荐:Golang slice append 并发安全指南:避免 goroutine 数据竞争
Go 竞争检测器:快速定位数据竞争问题
我们讨论的代码是一个高度简化的数据竞争示例。在较大的应用程序中,单凭自己很难发现数据竞争。幸运的是,Go 从 v1.1 开始有一个内置的数据竞争检测器,我们可以使用它来检测潜在的数据竞争条件。
使用它很简单,只需向普通的 Go 命令添加 -race
标志。
通过使用 -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
标志。收益远远超过成本(如果有的话),并且有助于构建更健壮的应用程序。
多重方案:彻底解决 Golang 数据竞争问题
Go 提供了多种解决数据竞争问题的方式。
方案一:使用等待组(sync.WaitGroup)进行阻塞
解决数据竞争最直接的方法是在写操作完成之前阻止读访问:
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
函数内部的阻塞虽然简单,但如果我们想重复调用该函数,就会很麻烦。下一个方法采用了一种更灵活的阻塞方法。
相关阅读推荐:Golang Channel 详解:使用方法、底层实现与面试题解读
方案三:返回 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)
}()
}
...
Golang Data Race 常见问题
1. 什么是 Golang 中的 data race(数据竞争)?
在 Golang 中,data race 指的是多个 goroutine 同时访问同一个变量,并且至少有一个 goroutine 在进行写操作,且没有适当的同步机制。当发生数据竞争时,程序的行为可能不可预测,导致错误或异常结果。
2. 为什么会发生数据竞争?
数据竞争通常发生在并发执行的 goroutine 之间,当它们尝试同时读取和修改共享变量时。没有使用同步机制(如锁、channel 等)来控制这些访问顺序,便会产生数据竞争。
3. 如何检测 Golang 中的数据竞争?
Golang 提供了-race
选项,可以帮助检测数据竞争:
- 运行代码时检测:使用
go run -race your_program.go
命令。 - 测试代码时检测:使用
go test -race ./...
命令。它会检测整个测试套件中的数据竞争。 - 构建带检测功能的可执行文件:使用
go build -race
命令来构建包含数据竞争检测的二进制文件。
4. 如何解决 Golang 中的数据竞争?
可以通过以下方式避免或修复数据竞争:
- 使用锁(如
sync.Mutex
和sync.RWMutex
):确保同一时间只有一个 goroutine 访问共享资源。 - 使用 Channel:通过 Channel 来传递数据,以避免直接共享内存,从而减少竞争。
- 只读操作的原子化:对于需要并发访问的简单变量操作(如计数器等),可以使用
sync/atomic
包中的方法,例如atomic.AddInt32
,使得操作具备原子性,避免数据竞争。
5. 如何避免数据竞争?
避免数据竞争的最佳实践包括:
- 尽量减少共享资源的数量,通过传递数据而非共享变量来实现并发。
- 使用 Channel 进行通信,尽量避免多个 goroutine 直接共享状态。
- 对于共享的读写资源使用适当的同步机制,如锁、atomic 操作等。
6. 什么是 goroutine 中的数据竞争?
在 Golang 中,goroutine 是轻量级的线程,当多个 goroutine 并发运行并访问相同的数据时,就可能产生数据竞争。这种情况下,如果没有合适的同步手段来管理访问,多个 goroutine 将无法保证变量的正确性。
7. 如何在微服务中避免数据竞争?
在微服务架构中避免数据竞争的建议:
- 隔离状态:将各微服务状态隔离,避免不同服务访问相同的数据。
- 使用一致性存储:使用分布式锁或事务来确保微服务之间的一致性。
- 借助消息队列:微服务之间可以通过消息队列异步通信,避免直接数据共享。
8. 数据竞争和竞态条件有何不同?
在 Golang 中,数据竞争是竞态条件的一种表现形式,特指对共享变量的非同步访问。竞态条件指的是程序的行为取决于多个操作执行的顺序,若没有同步保证,可能导致不一致的程序结果。
总结
当您运行带有 -race
标志的命令时,上述任何方法都可以防止出现数据竞争警告。每种方法都有不同的权衡和复杂性,因此您必须根据您的实际场景评估优缺点。如果遇到数据竞争,使用等待组通常会以最少的麻烦解决问题。
本文中解释的所有方法背后的核心原则是防止同时对同一个变量或内存位置进行读写访问,循环中访问共享变量时,记得使用状态副本!所以,只要记住这一点,就可以快速解决 Go 中的数据竞争问题了。