Golang以构建高并发容易、性能优异而闻名。但是,伴随着并发的使用,可能发生可怕的数据争用data race问题。 而一旦遇到data race问题,由于其不知道什么时候发生,这将会是难以发现和调试的错误之一。
数据竟态示例
下面是一个发生数据竟态的示例:
func main() {
fmt.Println(getNumber())
}
func getNumber() int {
var i int
go func() {
i = 5
}()
return i
}
在上面的示例中,getNumber先声明一个变量i,之后在goroutine中单独对i进行设置,而这时程序也正在从函数中返回i,由于不知道goroutine是否已完成对i值的修改,因此,将会有两种操作发生:
(1)goroutine先完成对i值的修改,最后返回的i值被设置为5;
(2)变量i的值从函数返回,结果为默认值0。
现在,根据这两个操作中的哪一个先完成,输出的结果将是0(默认整数值)或5。
这就是为什么将其称为数据竟态:从返回的值getNumber根据(1)或(2)哪个操作先完成而得名。
检查竟态
Go(从v1.1开始)具有内置的数据竞争检测器,可以使用它来查明潜在的数据竞争条件。
使用它就像-race在普通的Go命令行工具中添加标志一样简单。
运行时检查竟态的命令:go run -race main.go 构建时检查竟态的命令:go build -race main.go 测试时检查竟态的命令:go test -race main.go
所有避免产生竟态背后的核心原则是防止对同一变量或内存位置同时进行读写访问。
避免竟态的方式
一旦您最终发现烦人的数据竞赛,您将很高兴知道Go提供了许多解决方案。所有这些解决方案都有助于确保如果我们正在写入变量,则该变量的访问将被阻止。
(1)WaitGroup等待
解决数据竟态的最直接方法是阻止读取访问,直到完成写操作为止,这时可以使用
func getNumber() int {
var i int
// 初始化一个WaitGroup变量
var wg sync.WaitGroup
// Add(1) 表示有一个任务需要等待,等待任务数增加一个
wg.Add(1)
go func() {
i = 5
// 调用wg.Done表示完成一个处于等待队列中的任务,出入等待中的任务减少一个
wg.Done()
}()
// 调用wg.Wait阻塞等待,直到wg.Done与通过wg.Add添加到任务队列中的的任务数一样,也即一直等到等待队列中的任务数减为0
wg.Wait()
return i
}
(2)用channel阻塞等待
该方法在原则上与最后一种方法类似,除了我们使用通道而不是等待组:
func getNumber() int {
var i int
// 创建一个类型为结构体的channel,并初始化为空结构体
done := make(chan struct{})
go func() {
i = 5
// 一旦完成前面修改i值的工作,就推送一个空结构体到done中
done <- struct{}{}
}()
// 该方式使程序处于阻塞状态,直到从channel类型变量done中获取到推送的值
<-done
return i
}
如果想重复调用getNumber这个函数,在函数内部进行阻塞虽然很简单,但却会带来麻烦。下一种方法遵循更灵活的阻塞方法。
(3)返回channel通道
下面的方式是代替上面的第(2)种使用通道来阻塞函数的方式,我们可以返回一个channel,一旦获得结果,就可以通过该通道推送结果。与前两种方法不同,此方法本身不会进行任何阻塞。相反,它保留了阻塞调用代码的时机。
// 返回一个int型的channel来代替返回int
func getNumberChan() <-chan int {
// 创建一个int型channel
c := make(chan int)
go func() {
// 推送一个int值到channel
c <- 5
}()
// 立即返回channel变量
return c
}
之后,在需要使用的时候可以从调用代码中的通道获取结果:
func main() {
// 代码被阻塞直到从被推入的返回channel中取出值,与前面的方法相反,在main函数中阻塞,而不是函数本身
i := <-getNumberChan()
fmt.Println(i)
}
这种方法更加灵活,因为它允许更高级别的功能决定自己的阻塞和并发机制,而不是将getNumber功能视为同步功能。
(4)使用互斥锁
上面3种方式解决的是i在写操作完成后才能读取的情况。现在有以下情况:不管读写顺序如何,只要求它们不能同时发生。针对这种场景,应该考虑使用互斥锁:
// 首先,创建一个结构体,其中包含我们想要返回的值以及一个互斥实例
type SafeNumber struct {
val int
m sync.Mutex
}
func (i *SafeNumber) Get() int {
// The `Lock` method of the mutex blocks if it is already locked
// if not, then it blocks other calls until the `Unlock` method is called
// Lock方法
// 调用结构体对象的Lock方法将会锁定该对象中的变量;如果没有,将会阻塞其他调用,直到该互斥对象的Unlock方法被调用
i.m.Lock()
// 直到该方法返回,该实例对象才会被解锁
defer i.m.Unlock()
// 返回安全类型的实例对象中的值
return i.val
}
func (i *SafeNumber) Set(val int) {
// 类似于上面的getNumber方法,锁定I对象直到写入“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。
当有多个写入与读取操作混合在一起,使用Mutex互斥可以保证读写的值与预期结果一致。
结论
当运行带有-race标志的命令时,以上方法都可以防止出现数据竟态的警告。每种方法都有不同的权衡和复杂性,因此在使用之前需要根据实际场景权衡利弊之后再做决定。
通常来说,使用WaitGroup可以以最少的麻烦解决问题,但使用时需要小心,必须保证Add和Done方法出现的次数一致,最后调用Wait等待添加的任务都执行完毕。如果Add和Done数量不一致,就会一直阻塞程序,无限制地消耗内存等资源,直到资源耗尽服务宕机。
以上解决数据竟态的几种方法背后的核心原则是防止对同一变量或内存位置同时进行读写访问。