Go 并发安全数据结构——sync.Map vs map+mutex 的场景选择
Go 并发安全数据结构——sync.Map vs map+mutex 的场景选择
适读人群:Go 工程师、想搞清楚并发场景下 Map 选型的开发者 | 阅读时长:约17分钟 | 核心价值:彻底搞懂 sync.Map 的适用场景,以及不同并发 Map 方案的性能差异
一个因为并发 Map 写法错误导致的线上 panic
2022年10月,我转 Go 后大概两个月,有一天线上服务突然崩溃,panic 信息是:
fatal error: concurrent map read and map write我当时完全懵了。Java 里的 HashMap 被并发写顶多数据不一致,怎么 Go 里直接 panic 了?
去查资料才知道:Go 的 map 不是线程安全的,并发读写会触发 fatal error,不是普通的 panic,是直接终止进程,recover 都救不了。
那次事故之后,我系统研究了 Go 并发 Map 的各种方案:原生 map + sync.RWMutex、sync.Map,以及什么时候用哪个。
这篇文章是那次研究的完整总结,配上我做的基准测试数据。
为什么 Go map 并发读写是 fatal error
Java 对比: Java 的 HashMap 并发写,最坏情况是死循环(Java 7)或数据丢失(Java 8+),但不会崩进程。Go 的 map 遇到并发读写,运行时会主动触发 fatal error,因为这个错误太严重,程序继续运行下去数据已经不可信了,不如直接崩掉。
// 这段代码会 panic(fatal error: concurrent map read and map write)
m := make(map[string]int)
for i := 0; i < 100; i++ {
go func(n int) {
m["key"] = n // 并发写
}(i)
go func() {
_ = m["key"] // 并发读
}()
}方案一:map + sync.RWMutex(最通用方案)
package cache
import (
"sync"
)
// SafeMap 线程安全的 Map(适合大多数场景)
type SafeMap struct {
mu sync.RWMutex
m map[string]interface{}
}
func NewSafeMap() *SafeMap {
return &SafeMap{m: make(map[string]interface{})}
}
// Get 读取(使用读锁,允许多个并发读)
func (sm *SafeMap) Get(key string) (interface{}, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
val, ok := sm.m[key]
return val, ok
}
// Set 写入(使用写锁,独占)
func (sm *SafeMap) Set(key string, val interface{}) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[key] = val
}
// Delete 删除
func (sm *SafeMap) Delete(key string) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.m, key)
}
// GetOrSet 原子性的"不存在才写入"操作(check-and-set)
func (sm *SafeMap) GetOrSet(key string, val interface{}) (interface{}, bool) {
// 先用读锁快速检查
sm.mu.RLock()
if existing, ok := sm.m[key]; ok {
sm.mu.RUnlock()
return existing, true // 已存在
}
sm.mu.RUnlock()
// 需要写入,升级为写锁
// 注意:这里有一个 TOCTOU 竞态,需要在写锁里再次检查
sm.mu.Lock()
defer sm.mu.Unlock()
if existing, ok := sm.m[key]; ok {
return existing, true // 在升锁期间被其他 goroutine 写入了
}
sm.m[key] = val
return val, false
}方案二:sync.Map
Go 标准库提供的并发安全 Map,针对特定场景做了优化:
package main
import (
"fmt"
"sync"
)
func demoSyncMap() {
var m sync.Map
// Store(写入)
m.Store("key1", "value1")
m.Store("key2", 42)
m.Store("key3", []string{"a", "b"})
// Load(读取)
if val, ok := m.Load("key1"); ok {
fmt.Printf("key1 = %v\n", val)
}
// LoadOrStore(不存在才写入,原子操作)
actual, loaded := m.LoadOrStore("key1", "new-value")
fmt.Printf("loaded=%v, actual=%v\n", loaded, actual) // loaded=true, actual=value1(已存在,不覆盖)
// LoadAndDelete(读取并删除,原子操作)
val, loaded := m.LoadAndDelete("key2")
fmt.Printf("deleted: val=%v, existed=%v\n", val, loaded)
// Range(遍历,不保证顺序)
m.Range(func(key, value interface{}) bool {
fmt.Printf(" %v = %v\n", key, value)
return true // 返回 true 继续遍历,false 停止
})
// CompareAndSwap(原子的 CAS 操作,Go 1.20+)
swapped := m.CompareAndSwap("key3", []string{"a", "b"}, []string{"x", "y"})
fmt.Printf("CAS swapped: %v\n", swapped)
}注意事项: sync.Map 的 value 类型是 interface{}(或 any),取出来需要类型断言。如果需要泛型版本,可以用泛型封装:
// 泛型版本的 sync.Map(Go 1.18+)
type TypedSyncMap[K comparable, V any] struct {
m sync.Map
}
func (tm *TypedSyncMap[K, V]) Store(key K, val V) {
tm.m.Store(key, val)
}
func (tm *TypedSyncMap[K, V]) Load(key K) (V, bool) {
val, ok := tm.m.Load(key)
if !ok {
var zero V
return zero, false
}
return val.(V), true
}
// 使用
userCache := &TypedSyncMap[int64, *UserInfo]{}
userCache.Store(1001, &UserInfo{Name: "张三"})
user, ok := userCache.Load(1001) // user 直接是 *UserInfo,不需要断言基准测试:sync.Map vs map+RWMutex
package bench
import (
"sync"
"testing"
)
// BenchmarkRWMutexMap_ReadHeavy 读多写少场景
func BenchmarkRWMutexMap_ReadHeavy(b *testing.B) {
var mu sync.RWMutex
m := make(map[int]int)
// 预填充数据
for i := 0; i < 1000; i++ {
m[i] = i
}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
if i%10 == 0 {
mu.Lock()
m[i%1000] = i
mu.Unlock()
} else {
mu.RLock()
_ = m[i%1000]
mu.RUnlock()
}
i++
}
})
}
func BenchmarkSyncMap_ReadHeavy(b *testing.B) {
var m sync.Map
for i := 0; i < 1000; i++ {
m.Store(i, i)
}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
if i%10 == 0 {
m.Store(i%1000, i)
} else {
m.Load(i % 1000)
}
i++
}
})
}我在 Apple M2 上跑的实测结果(8核,100并发,读写比 9:1):
| 方案 | 吞吐量 | 适用场景 |
|---|---|---|
| map + RWMutex | ~28 ns/op | 读写均衡,key 不固定 |
| sync.Map | ~12 ns/op | 读多写少,key 相对稳定 |
| sync.Map(写多) | ~55 ns/op | 不适合高频写 |
什么时候用 sync.Map,什么时候用 map+mutex
sync.Map 文档里明确说了两个适用场景:
- key 只写一次,但读多次(典型:路由注册表、插件注册表)
- 多个 goroutine 读写不相交的 key(典型:每个 goroutine 操作自己的 key)
map+RWMutex 更适合:
- key 频繁增删
- 需要遍历所有 key-value
- 需要获取 Map 的大小(
len(m)) - value 类型强制要求,不想用
interface{}
踩坑实录
坑1:sync.Map 的 Range 不能并发修改
现象: 在 Range 遍历过程中,对 Map 执行 Store/Delete,有时候新写入的 key 在本次遍历里看不到,有时候看得到。
原因: sync.Map 的 Range 遍历是一个快照语义——在 Range 开始时已经存在的 key 一定会被遍历到,Range 期间新增的 key 不保证出现。这是设计特性,不是 bug。
解法: 如果需要在 Range 里修改 Map,这是正常的,只需要理解语义:Range 不会因为并发修改而 panic,但遍历结果可能不包含 Range 开始后新增的 key。
坑2:用 map+Mutex 但 Mutex 粒度太粗,成了全局锁
现象: 有一个全局缓存 Map,所有操作都加同一把锁,高并发下锁竞争严重,成了性能瓶颈。
原因: 一把大锁保护整个 Map,实际上变成了串行操作。
解法: 分片(Sharding)——把 Map 拆成多个分片,每个分片一把锁,减少竞争:
const shardCount = 32
type ShardedMap struct {
shards [shardCount]struct {
sync.RWMutex
m map[string]interface{}
}
}
func (sm *ShardedMap) getShard(key string) int {
// 简单的 hash 分片
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32()) % shardCount
}
func (sm *ShardedMap) Get(key string) (interface{}, bool) {
shard := sm.getShard(key)
sm.shards[shard].RLock()
defer sm.shards[shard].RUnlock()
val, ok := sm.shards[shard].m[key]
return val, ok
}坑3:把 sync.Map 用在高频写入场景,性能比 mutex 差很多
现象: 一个计数器场景,每次请求都要更新计数,用了 sync.Map,发现比 map+mutex 慢2倍。
原因: sync.Map 的实现里有一个 dirty map 和一个 read map 的双层结构,写入时需要维护两层,写操作开销更大。在写多读少的场景下,sync.Map 性能不如 map+mutex。
解法: 高频写场景坚持用 map+mutex,或者用 atomic.Int64 等原子类型替代 Map 的计数器功能:
// 计数器用原子操作,比 Map 更高效
var counter atomic.Int64
counter.Add(1)
val := counter.Load()总结:一张选型图
需要并发安全的 Map?
├── key 只写一次/很少写,读多:sync.Map
├── 读写均衡,key 会频繁增删:map + RWMutex
├── 高并发写,竞争严重:ShardedMap
└── 计数/累加场景:atomic.Int64/atomic.Value