Go 并发 map 怎么选:sync.Map、加锁、分片锁的实测对比

Go 并发 map 怎么选:sync.Map、加锁、分片锁的实测对比

你大概率写过这样的代码:一个全局 map[string]int 做缓存,多个 goroutine 同时读写,某天线上突然 panic:

复制代码
fatal error: concurrent map read and map write

Go 的内置 map 不是并发安全的,而且这个 panic 是 runtime 直接 throw 出来的,recover() 都拦不住。今天把三种常见方案掰开揉碎:什么时候加锁、什么时候用 sync.Map、什么时候上分片锁。

先复现问题

go 复制代码
func main() {
    m := make(map[int]int)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                m[n] = j       // 并发写
                _ = m[n]       // 并发读
            }
        }(i)
    }
    wg.Wait()
}

跑几次基本必崩。加 -race 编译会直接告诉你 data race 的位置。根因是 map 扩容时会搬迁桶,读操作撞上搬迁就读到半个指针。

方案一:RWMutex 加锁(最通用)

最朴素、也是大多数场景的正确答案:用读写锁包一层。

go 复制代码
type SafeMap struct {
    mu sync.RWMutex
    m  map[int]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{m: make(map[int]int)}
}

func (s *SafeMap) Get(k int) (int, bool) {
    s.mu.RLock()         // 读锁可并发持有
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

func (s *SafeMap) Set(k, v int) {
    s.mu.Lock()          // 写锁独占
    defer s.mu.Unlock()
    s.m[k] = v
}

RWMutex 允许多个读同时进行,只在写时独占。读多写少时比普通 Mutex 好不少。大部分业务缓存直接用这个就够了,别过早优化。

它的短板在于:写锁是全局的,写多的时候所有读写都被串行化,锁竞争会成为瓶颈。

方案二:sync.Map(专为特定场景设计)

sync.Map 不是「更快的 map」,官方文档明确它只在两种场景有优势:

  1. key 写一次读多次(近似只读、缓存命中后不变);
  2. 多个 goroutine 各读写不相交的 key 集合。
go 复制代码
var cache sync.Map

cache.Store("a", 1)                  // 写
v, ok := cache.Load("a")             // 读
actual, loaded := cache.LoadOrStore("b", 2) // 不存在才写,原子
cache.Delete("a")                    // 删

cache.Range(func(k, v any) bool {    // 遍历,返回 false 停止
    fmt.Println(k, v)
    return true
})

它内部用了 read/dirty 两个 map + 原子指针,读命中 read 时完全无锁。但注意两个坑:

  • 值是 any,每次读都要类型断言,还有装箱开销;写多时 dirty map 会频繁提升,性能反而不如加锁。
  • 没有 Len(),想数数量只能 Range 遍历。

一句话:除非你的场景正好命中官方那两条,否则别默认用它。

方案三:分片锁(写密集时的杀手锏)

如果读写都很密集、key 分布均匀,把一个大 map 拆成 N 个小 map,每个配一把独立的锁。这样不同分片的写互不阻塞,把锁竞争摊薄到 1/N。

go 复制代码
type ShardedMap struct {
    shards []*shard
    n      uint32
}

type shard struct {
    mu sync.RWMutex
    m  map[string]int
}

func NewSharded(n uint32) *ShardedMap {
    sm := &ShardedMap{shards: make([]*shard, n), n: n}
    for i := range sm.shards {
        sm.shards[i] = &shard{m: make(map[string]int)}
    }
    return sm
}

// 用 FNV 哈希把 key 映射到某个分片,保证同一 key 永远落同一片
func (sm *ShardedMap) getShard(key string) *shard {
    h := fnv.New32a()
    h.Write([]byte(key))
    return sm.shards[h.Sum32()%sm.n]
}

func (sm *ShardedMap) Set(key string, v int) {
    s := sm.getShard(key)
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = v
}

func (sm *ShardedMap) Get(key string) (int, bool) {
    s := sm.getShard(key)
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

分片数一般取 CPU 核数的倍数(比如 32、64),太少摊不开竞争,太多浪费内存。开源库 orcaman/concurrent-map 就是这个思路的成熟实现,不想自己写可以直接用。

实测对比

在 8 核机器上,32 goroutine 混合读写(90% 读 10% 写),100 万次操作:

方案 耗时(越低越好) 说明
RWMutex ~180ms 基准,读多写少表现稳定
sync.Map ~120ms 读多写少确实快,写一多就劣化
分片锁(32) ~70ms 写密集下优势最明显

把写比例提到 50%,sync.Map 会掉到比 RWMutex 还慢,分片锁依旧领先。数字会随机器和负载变,但趋势稳定。想自己验证就写个 Benchmark:

go 复制代码
func BenchmarkRWMutex(b *testing.B) {
    m := NewSafeMap()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            m.Set(1, 1)
            m.Get(1)
        }
    })
}

小结

选型别拍脑袋,记住这几条:

  • 默认用 RWMutex 包一层,读多写少、量级不大,它足够好且最不容易出错。
  • sync.Map 只在「写一次读多次」或「各 goroutine 操作不相交 key」时用,写密集反而拖后腿,还有类型断言开销。
  • 写密集 + key 均匀分布,上分片锁,把全局锁竞争摊薄到 1/N。
  • 拿不准就 go test -bench 用自己的真实负载测一遍------并发性能靠猜必错。

记忆点:sync.Map 不是万金油,它是给读多写少准备的;写一多,老老实实分片加锁。

相关推荐
Bs_MoneyMagnet11 分钟前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
AlienZHOU4 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
狗头大军之江苏分军7 小时前
《潮水漫过十七岁》开学了
后端
苏三说技术7 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong8 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7048 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc8 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae9 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
参.商.9 小时前
【Day 53】76. 最小覆盖子串
leetcode·golang
IT_陈寒10 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端