前面几篇我们聊了各种同步原语,这一篇聊一个更贴近日常开发的话题——map 的并发安全。Go 内建的 map 类型不是线程安全的,并发读写会直接 panic。那怎么办?这一篇我们从内建 map 的基本用法和陷阱开始,逐步讲到加锁方案和标准库的 sync.Map

一、内建 map 基本用法

Go 的 map 是内建的哈希表类型:

m := make(map[string]int)
m["age"] = 28         // 写入
v := m["age"]         // 读取
v, ok := m["name"]    // 读取 + 检查是否存在
delete(m, "age")      // 删除

key 的类型必须是 可比较的(comparable)——能用 ==!= 比较:

可以做 key不能做 key
bool、整数、浮点数、复数、字符串slice
指针、channel、接口map
所有字段都可比较的 struct函数
元素都可比较的数组

struct 做 key 的坑

用 struct 做 key 时要特别小心——修改了 struct 字段后,就再也找不到原来的值了:

type Key struct {
    ID int
}

func main() {
    m := make(map[Key]string)
    k := Key{ID: 10}
    m[k] = "hello"
    fmt.Println(m[k]) // "hello"

    k.ID = 100
    fmt.Println(m[k]) // ""  ← 找不到了!原来的 key 还在 map 里
}

map 内部的记录 Key{10} → "hello" 还在,但用 Key{100} 去查询时 hash 完全不同,自然找不到。

建议:用 struct 做 key 时,优先使用不可变的字段(如 ID),或者直接用基本类型做 key。

二、并发读写 map 会怎样?

Go 的内建 map 不是线程安全的。并发读写会触发运行时检测,直接 panic:

func main() {
    m := make(map[int]int)
    go func() {
        for i := 0; i < 10000; i++ {
            m[i] = i // 写
        }
    }()
    go func() {
        for i := 0; i < 10000; i++ {
            _ = m[i] // 读
        }
    }()
    time.Sleep(time.Second)
}

运行结果:

fatal error: concurrent map read and map write

Go 1.6 之后,运行时内置了并发 map 检测。这不是数据竞争检测器(-race)的功能,而是 运行时的硬检测——不需要加 -race 也会 panic。

为什么 Go 不把内建 map 做成线程安全的? 官方的理由是:大多数场景下 map 不需要并发访问,内置锁会给所有使用者带来性能开销,不应该让多数人为少数场景买单。需要并发访问时,由使用者自己选择合适的同步方案。

三、方案一:Mutex/RWMutex 保护 map

最直接的方案——用锁把 map 包起来:

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

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

func (s *SafeMap) Get(key string) (int, bool) {
    s.mu.RLock()         // ✅ 读锁,允许并发读
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

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

func (s *SafeMap) Delete(key string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    delete(s.m, key)
}

func (s *SafeMap) Len() int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return len(s.m)
}

优点:简单直接,适用于任何场景。

缺点:写操作频繁时,RWMutex 退化为 Mutex,所有操作串行化。

分片锁优化

当 key 数量很大、并发度很高时,单把锁会成为瓶颈。可以用分片锁(Sharded Map)来降低锁竞争:

const shardCount = 32

type ShardedMap struct {
    shards [shardCount]struct {
        mu sync.RWMutex
        m  map[string]int
    }
}

func (s *ShardedMap) getShard(key string) int {
    h := fnv.New32a()
    h.Write([]byte(key))
    return int(h.Sum32()) % shardCount
}

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

func (s *ShardedMap) Set(key string, value int) {
    shard := &s.shards[s.getShard(key)]
    shard.mu.Lock()
    defer shard.mu.Unlock()
    shard.m[key] = value
}
graph TB subgraph 单锁SafeMap["单锁 SafeMap"] L1[🔒 全局锁] K1[key1] --> L1 K2[key2] --> L1 K3[key3] --> L1 L1 --> M1[map] end subgraph 分片锁["分片锁 ShardedMap"] KA[k1] --> S0[🔒 shard 0] KB[k2] --> S1[🔒 shard 1] KC[k3] --> S2[🔒 shard 2] S0 --> MA[map 0] S1 --> MB[map 1] S2 --> MC[map 2] end

单锁的并发度 = 1;分片锁的并发度 ≈ shard 数量(上例中是 32)。

提示:开源库 orcaman/concurrent-map 就是基于分片锁实现的,开箱即用。

四、方案二:sync.Map

Go 1.9 引入了 sync.Map,它是标准库提供的线程安全 map,为特定场景做了深度优化

基本用法

var m sync.Map

// 写入
m.Store("name", "Go")

// 读取
v, ok := m.Load("name")

// 删除
m.Delete("name")

// 读取或写入(key 不存在时才写入)
actual, loaded := m.LoadOrStore("name", "Go")

// 读取并删除
v, loaded := m.LoadAndDelete("name")

// 遍历
m.Range(func(key, value any) bool {
    fmt.Printf("%s: %s\n", key, value)
    return true // 返回 false 停止遍历
})

API 一览

方法说明
Store(key, value any)写入/覆盖
Load(key any) (value any, ok bool)读取
Delete(key any)删除
LoadOrStore(key, value any) (any, bool)不存在才写入,返回实际值
LoadAndDelete(key any) (any, bool)读取并删除
Range(f func(key, value any) bool)遍历(f 返回 false 停止)
CompareAndSwap(key, old, new any) boolCAS 操作(Go 1.20+)
CompareAndDelete(key, value any) boolCAS 删除(Go 1.20+)
Swap(key, value any) (any, bool)写入并返回旧值(Go 1.20+)

实现原理

sync.Map 内部使用了 读写分离 的策略,由三部分组成:

字段作用
read (atomic.Pointer[readOnly])只读 map,原子访问、无锁
dirty (map[any]*entry)包含所有有效的 key-value,写新 key 先到这里,需要加锁
misses (int)未命中计数;当 misses >= len(dirty) 时,dirty 提升为 read

读写流程

graph TD A[Load key] --> B{在 read 中?} B -->|找到| C[返回
无锁 fast path] B -->|未找到 且 amended=true| D[Lock] D --> E[在 dirty 中查找] E --> F[misses++] F --> G{misses >= len dirty?} G -->|是| H[dirty 提升为 read] G -->|否| I[Unlock 返回] H --> I

Store(key, value) 的路径:如果 keyread 中已存在,用 CAS 更新 entry(无锁);否则 Lock 后写入 dirty

sync.Map 适合什么场景?

优势场景

读多写少:key 一旦写入就很少变化,大量并发读(配置缓存、路由表、DNS 缓存)。

不同 goroutine 操作不同 key:每个 goroutine 只读写自己的 key(每个连接维护自己的 session)。

劣势场景

  • 大量写入新 key:频繁触发 dirty 提升,性能不如加锁 map
  • 需要原子的复合操作:如 check-then-act
  • 需要类型安全:sync.Map 的 key 和 value 都是 any

五、三种方案对比

特性Mutex + map分片锁sync.Map
实现复杂度直接用
类型安全✅ 泛型支持✅ 泛型支持❌ any
读多写少性能一般✅ 最好
写多性能一般✅ 好❌ 可能更差
key 不交叉性能一般✅ 最好
自定义复合操作✅ 灵活✅ 灵活❌ 有限
遍历✅ range需遍历所有分片✅ Range 方法

选择依据

场景推荐
通用场景 / 写入频繁RWMutex + map
高并发 + 大量 key分片锁
读多写少 / key 不交叉sync.Map
不确定先用 RWMutex + map,benchmark 再决定

六、使用 map 最常踩的 4 个坑

坑 1:未初始化就写入

var m map[string]int
m["key"] = 1 // panic: assignment to entry in nil map

正确做法:用 make 初始化,或者用字面量:

m := make(map[string]int)
// 或
m := map[string]int{"key": 1}

坑 2:遍历时修改 map

遍历 map 时删除或新增 key,行为是未定义的——可能漏掉、重复、甚至 panic:

// ❌ 不要在 range 中修改 map
for k, v := range m {
    if v < 0 {
        delete(m, k) // 删除当前 key 是安全的(Go spec 允许)
    }
    m[k+"_new"] = v  // ❌ 新增 key,行为不确定
}

注意:Go spec 明确允许在 range 中 delete 当前迭代的 key,但新增 key 的行为未定义。

坑 3:依赖 map 的遍历顺序

Go 的 map 遍历顺序是 故意随机化 的,不要依赖它:

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k := range m {
    fmt.Print(k) // 每次运行结果可能不同:abc、bca、cab...
}

需要有序遍历?先取出 key 排序:

keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, m[k])
}

坑 4:sync.Map 不支持 len

sync.Map 没有 Len() 方法。如果需要统计元素数量,只能用 Range 遍历计数:

var count int
m.Range(func(_, _ any) bool {
    count++
    return true
})

这是 O(n) 的操作。如果频繁需要 len,说明 sync.Map 可能不适合你的场景。

七、实战建议

  1. 内建 map 不是线程安全的——并发读写会 panic,不是数据错乱,是直接崩溃
  2. 通用方案用 RWMutex + map——简单、可控、类型安全
  3. 高并发大量 key 用分片锁——把锁竞争分散到多个分片
  4. 读多写少用 sync.Map——无锁读路径性能最好,但 API 是 any 类型,牺牲了类型安全
  5. -race 检测并发问题——map 的并发 bug 有时不会立即 panic,race detector 能提前发现
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...

map 是 Go 中使用频率最高的数据结构之一,而并发访问 map 是最常见的并发 bug 来源之一。理解内建 map 的限制,根据场景选择合适的并发安全方案,才能写出既正确又高效的并发代码。