我明白标题可能有些令人困惑,因为一般来说,Go被认为在并发方面有很好的内置支持。然而,我并不认为在Go中编写并发软件是容易的。让我向您展示我是什么意思。
使用全局变量
第一个例子是我们在项目中遇到的问题。直到最近,sarama库(用于Apache Kafka的Go库)中包含了以下代码(位于sarama/version.go
):
package sarama
import "runtime/debug"
var v string
func version() string {
if v == "" {
bi, ok := debug.ReadBuildInfo()
if ok {
v = bi.Main.Version
} else {
v = "dev"
}
}
return v
}
乍一看,这看起来没问题,对吧?如果版本没有在全局设置中,它要么基于构建信息,要么被分配为静态值(dev
)。否则,版本将按原样返回。当我们运行这段代码时,它似乎按预期工作。
然而,当并发调用version
函数时,全局变量v
可能会被多个goroutine同时访问,导致潜在的数据竞争。这些问题很难跟踪,因为它们只在运行时在恰当的条件下才会发生。
解决方案
这个问题在#2171中得到修复,通过使用sync.Once
,根据文档的解释,它是“执行一次且仅执行一次操作的对象”。这意味着我们可以使用它来设置版本,以便后续对version
函数的调用将返回结果。修复代码如下所示:
package sarama
import (
"runtime/debug"
"sync"
)
var (
v string
vOnce sync.Once
)
func version() string {
vOnce.Do(func() {
bi, ok := debug.ReadBuildInfo()
if ok {
v = bi.Main.Version
} else {
v = "dev"
}
})
return v
}
尽管我认为在这种情况下,也可以在不使用sync
包的情况下通过使用init
函数来设置变量v
一次来进行修复。由于在Go运行init
函数后变量v
不会改变,所以应该是没问题的。
如何预防
您可以在测试期间或在使用go run
时使用data race detector(自Go 1.1起可用)。当它检测到潜在的数据竞争时,它会打印一个警告。为了展示这是如何工作的,我稍微修改了一下代码来触发数据竞争:
package main
import (
"fmt"
"runtime/debug"
)
var v string
func version() string {
if v == "" {
bi, ok := debug.ReadBuildInfo()
if ok {
v = bi.Main.Version
} else {
v = "dev"
}
}
return v
}
func main() {
go func() {
version()
}()
fmt.Println(version())
}
现在我们可以使用-race
标志来启用数据竞争检测器来运行它:
➜ go run -race .
==================
WARNING: DATA RACE
Write at 0x000104a16b90 by main goroutine:
main.version()
main.go:14 +0x78
main.main()
main.go:27 +0x30Previous read at 0x000104a16b90 by goroutine 7:
main.version()
main.go:11 +0x2c
main.main.func1()
main.go:24 +0x24Goroutine 7 (finished) created at:
main.main()
main.go:23 +0x2c
==================
(devel)
Found 1 data race(s)
exit status 66
正如你所看到的,检测到了数据竞争。如果我们分析输出,可以看到我们同时对变量v
进行读写操作。这就是我们所说的数据竞争。之所以称为数据竞争,是因为两个goroutine正在"竞争"访问相同的数据。
从sync包中复制结构体
我在GitHub上找到了一些实际的例子,但没有一个足够重要以至于在这里提及。相反,我将基于我制作的一个示例来解释。所以,下面是例子的说明:
package main
import "sync"
type User struct {
lock sync.RWMutex
Name string
}
func doSomething(u User) {
u.lock.RLock()
defer u.lock.RUnlock()
// do something with `u`
}
func main() {
u := User{Name: "John"}
doSomething(u)
}
User
结构体包含两个属性:读/写锁和一个字符串。当调用doSomething
函数时,变量u
会被复制到栈上(也称为按值传递),包括其字段。这是一个问题,因为sync包的文档中指出:
sync包提供了基本的同步原语,如互斥锁。除了Once和WaitGroup类型外,大多数都是为低级库例程使用的。更高级的同步最好通过通道和通信来完成。
不应复制包含此包中定义的类型的值。
当评估doSomething
函数时,运行RLock
/RUnlock
不会影响User
结构体中的原始锁,这个锁无效。
解决方案
改用锁的指针。指针会被复制,并指向相同的值。更新后的版本如下所示:
type User struct {
lock *sync.RWMutex
Name string
}
读锁验证
使用copylock分析器来在复制sync
包中的类型时显示警告。最简单的方法是在发布代码之前运行go vet
。在原始代码上运行这个命令会得到以下输出:
➜ go vet .
# data-synchronization
./main.go:10:20: doSomething passes lock by value: data-synchronization.User contains sync.RWMutex
./main.go:20:14: call of doSomething copies lock value: data-synchronization.User contains sync.RWMutex
使用 time.After
在GitHub上搜索时,我发现了Hashicorp的Raft实现中的一个pull request,我们可以使用它来演示以下问题。让我们首先展示代码(位于api.go
文件中):
var timer <-chan time.Time
if timeout > 0 {
timer = time.After(timeout)
}
// Perform the restore.
restore := &userRestoreFuture{
meta: meta,
reader: reader,
}
restore.init()
select {
case <-timer:
return ErrEnqueueTimeout
case <-r.shutdownCh:
return ErrRaftShutdown
case r.userRestoreCh <- restore:
// If the restore is ingested then wait for it to complete.
if err := restore.Error(); err != nil {
return err
}
}
这段代码来自Restore
方法。select
语句等待以下情况之一发生:计时器(用于定义超时)、关闭通道或还原操作完成时。看起来很简单,那问题在哪里呢?
time.After
函数的工作原理如下:
func After(d Duration) <-chan Time {
return NewTimer(d).C
}
因此,它只是time.NewTimer
的简写形式,但它“泄露”了计时器(因为没有调用timer.Stop
)。文档对此的说明如下:
After等待持续时间过去,然后在返回的通道上发送当前时间。它等价于NewTimer(d).C。直到计时器触发后,底层计时器才会被垃圾回收器回收。如果效率是一个问题,可以使用NewTimer并在不再需要计时器时调用Timer.Stop。
我真的不明白为什么一个有意“泄露”计时器的函数(可能会导致潜在的长期分配,取决于持续时间)最终出现在标准库中…
解决方案
我们可以手动创建计时器,而不是使用time.After
。具体如下所示:
var timerCh <-chan time.Time
if timeout > 0 {
timer := time.NewTimer(timeout)
defer timer.Stop()
timerCh = timer.C
}
// Perform the restore.
restore := &userRestoreFuture{
meta: meta,
reader: reader,
}
restore.init()
select {
case <-timerCh:
return ErrEnqueueTimeout
case <-r.shutdownCh:
return ErrRaftShutdown
case r.userRestoreCh <- restore:
// If the restore is ingested then wait for it to complete.
if err := restore.Error(); err != nil {
return err
}
}
当函数执行完毕时,即使计时器没有触发,它也会被清理。
如何预防
我不会在任何代码库中使用time.After
。除了节省一两行代码外,它没有实质性的优势,而且可能会引发很多问题,特别是当它在代码的热点路径中使用时。
结论
使用Go的内置并发支持可以快速编写并发软件。然而,它将确保数据正确同步和正确使用标准库中的工具的责任留给用户。这加上Go的简洁性,使得编写稳定、无bug的并发软件变得困难。
如果你喜欢我的文章,点赞,关注,转发!