为什么 Go 语言在多个 goroutine 同时访问和修改同一个 map 时,会报出 fatal 错误而不是 panic?我们该如何应对 map 的数据竞争问题呢?
这篇文章将带你一步步了解背后的原理,并引出解决 map 并发问题的方案。
Map 数据竞争
首先,什么是 Map 数据竞争。
当两个或多个 goroutine 在没有适当同步机制的情况下,同时访问同一块数据,且至少有一个 goroutine 在修改这块数据,就会发生数据竞争。这种情况可能导致程序的行为异常,甚至崩溃。
而 map 是 Go 中的一种常用的数据结构,提供了快速的 Key/Value 存储能力。但 Go 默认的 map 并不提供并发安全。这意味着,如果我们没有采取措施来控制 map 同步访问,如果多个 goroutine 同时对一个map进行读写操作,就可能会引发数据竞争。
Map 数据竞争产生 fatal error
在 Go 语言中,处理错误的方式通常是通过返回 Error 或者 panic。然而一旦程序检测到 map 的数据竞争,就会抛出 fatal 错误。而 fatal error 即意味会立刻崩溃。毫无疑问,Go 选择了更严格的处理方式。
通过一个简单的例子演示 fatal 错误是如何被触发的:
package main import ( "sync" ) func main() { m := make(map[int]int) wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 1000; j++ { m[j] = j } }() } wg.Wait() }
这个例子中,我创建了一个map 类型变量
这个代码可能会触发如下的 fatal 错误,输出如下所示:
fatal error: concurrent map writes goroutine 6 [running]: ... 省略 exit status 2
为什么 fatal 错误, 而非 panic?
fatal 错误让我们没有在程序运行时进行补救。我猜测这背后的原因主要是以下两点:
立即暴露问题
这种处理方式确保了一旦发生数据竞争,程序将立即停止运行,迫使我们直面问题,不能逃避。
这不仅有利于我们快速发现解决并发 bug,也促使我们编写代码时,应更注重并发安全,避免发生这类问题。
虽然,这种方式可能会导致程序在运行时突然停止,但长远来看,它有助于提高程序的健壮性和可靠性。
防止数据腐败
数据竞争的后果可能非常严重,尤其是在复杂的并发系统中。
当多个goroutine不协调地访问和修改同一个map时,可能会导致map的内部状态变得不一致,甚至损坏。
这种状态的不确定性不仅会导致程序行为异常,还可能导致难以追踪的bug。
深入源码:map 并发检测
当 Go 检测到 map 的并发写入,会通过
以下是简化后的
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { // 检查是否有其他goroutine正在写入map if h.flags&hashWriting != 0 { throw("concurrent map writes") } // ...其他map赋值逻辑... // 设置标志位,表示有goroutine正在写入map h.flags |= hashWriting // ...执行map的赋值逻辑... // 写入完成,清除写入标志位 h.flags &^= hashWriting return val }
通过这段代码,就能理解 fatal 错误是如何被触发的。对,重点就是
如何避免数据竞争
在 Go 中,最常用的并发控制机制是使用 channel 或 sync 包中的工具。另外,Go 还提供了一个并发安全的 map 类型 - sync.Map。
sync.Mutex
我将通过以下这段代码演示如何使用 sync.Mutex 避免数据竞争:
package main import ( "sync" ) func main() { m := make(map[int]int) var mu sync.Mutex wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 1000; j++ { mu.Lock() m[j] = j mu.Unlock() } }() } wg.Wait() }
在这个例子中,我们使用了
sync.Map
虽然 Go 的标准 map 非并发安全,但 Go 在 1.9 版本中引入了一个并发安全的 map 类型 - sync.Map,专门设计来处理并发场景下的 Key/Value 存储。
sync.Map 有一些特别的特性,它不需要显式的锁操作来保证并发安全,为它内部已经处理好了同步机制,这可简化我们的并发编程。
以下示例使用 sync.Map 改写了之前通过 sync.Mutex 实现的代码。
package main import ( "sync" "fmt" ) func main() { var sm sync.Map wg := sync.WaitGroup{} // 写入数据 for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() sm.Store(n, n*n) }(i) } // 读取数据 for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() if value, ok := sm.Load(n); ok { fmt.Printf("Key: %v, Value: %v ", n, value) } }(i) } wg.Wait() }
这个例子中,sync.Map 的 Store 方法用于存储 Key/Value,Load 方法用于读取数据。我们通过这种方式就可以在多个 goroutine 中安全地使用map,而不必担心数据竞争。
另外,sync.Map 相对于传统的 sync.Mutext + map 的组合,除简化了并发编程,它还针对并发场景做了一些优化,如无锁读、细粒度锁机制(非锁整个 Map)等等。更多细节,可自行研究。
总结
在本文中,我们探讨了 Go 语言处理 map 并发操作数据竞争情况下的处理方式。这种设计突显了 Go 对并发安全的重视。另外,通过 Go 提供的 sync.Mutex 和sync.Map 等工具,可有效避免数据竞争,确保我们构建出稳定和高效的并发应用。
我想说,对这些机制的理解对于我们编写出健壮的Go程序是至关重要的。
博文地址:从 fatal 错误到 sync.Map:Go中 Map 的并发策略