map中因mutex使用不当导致的数据竞争

Go语言精选

共 2251字,需浏览 5分钟

 ·

2022-07-09 03:10

今天跟大家分享一个使用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))}

在这种实现方案中,一旦我们完成了深度拷贝,就将锁给释放。同时,迭代的逻辑在临界区外实现。

总之,当我们使用互斥锁时一定要格外注意临界区。今天的分享就到这里了。


推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报