map中因mutex使用不当导致的数据竞争
今天跟大家分享一个使用mutex在对slice或map的数据进行保护时容易被忽略的一个案例。
众所周知,在并发程序中,对共享数据的访问是经常的事情,一般通过使用mutex对共享数据进行安全保护。当对slice和map使用mutex进行保护时有一个错误是经常被忽略的。下面我们看一个具体的示例。
我们首先定义一个Cache结构体,该结构体用来缓存客户的银行卡的当前余额数据。该结构体使用一个map来存储,key是客户的ID,value是客户的余额。同时,有一个保护并发访问的读写锁变量。如下:
type Cache struct {
mu sync.RWMutex
balances map[string]float64
}
接下来我们定义个AddBalance方法,该方法使用写锁来保护balances能被并发访问。如下:
func (c *Cache) AddBalance(id string, balance float64) {
c.mu.Lock()
c.balances[id] = balance
c.mu.Unlock()
}
同时,我们还实现了一个求所有客户平均余额的函数。下面是其中的一种实现:
func (c *Cache) AverageBalance() float64 {
c.mu.RLock()
balances := c.balances
c.mu.RUnlock()
sum := 0.
for _, balance := range balances {
sum += balance
}
return sum / float64(len(balances))
}
在该实现中,我们将c.balances拷贝到了一个本地变量中,然后就释放了锁。然后通过循环本地变量balances来计算所有客户的总额。最后返回客户的平均余额。以下是main中的代码:
func main() {
cache := &Cache{
balances : make(map[string]float64),
}
go cache.AverageBalance()
go cache.AddBalance("ID-10", 100)
}
那么,这种实现方式有什么问题吗?如果我们使用-race运行,则会提示导致数据竞争。所以这里的问题处在哪里呢?
实际上,我们在之前讲过map的底层数据结构实际上是一些元信息加上一个指向buckets的数据指针。因此,当使用balances := c.balances时并没有拷贝实际的数据。而只是拷贝了map的元信息而已。如下图:
这里只列出了map底层结构体的关键字段,若想了解map底层的详细结构可以参考我之前的那篇 map的底层实现原理。由上图可以看到两个变量底层指向的数组实际上是同一个内存地址。在并发中,两个协程同时操作一个内存地址的数据,而且其中一个是写入操作,因此就造成了数据竞争。
那我们应该如何避免该数据竞争呢?我们有两种方式。
一种方式是当迭代的逻辑如果耗时不是很大的话,可以扩大临界区。如下:
func (c *Cache) AverageBalance() float64 {
c.mu.RLock() defer c.mu.RUnlock()
sum := 0
for _, balance := range c.balances {
sum += balance
}
return sum / float64(len(c.balances))
}
在该实现中,整个函数都是临界区,这样也就避免了数据竞争。
第二种方式是将原来的map数据深度拷贝一份到本地变量。这种方式适用于迭代循环逻辑比较重(也就是耗时比较大)的场景。比如在迭代逻辑中会涉及到网络IO(数据库的读写等)。如下:
func (c *Cache) AverageBalance() float64 {
c.mu.RLock()
m := make(map[string]float64, len(c.balances))
for k, v := range c.balances {
m[k] = v
}
c.mu.RUnlock()
sum := 0
for _, balance := range balances {
sum += balance
}
return sum / float64(len(c.balances))
}
在这种实现方案中,一旦我们完成了深度拷贝,就将锁给释放。同时,迭代的逻辑在临界区外实现。
总之,当我们使用互斥锁时一定要格外注意临界区。今天的分享就到这里了。
推荐阅读