前面几篇我们聊了各种同步原语,这一篇聊一个更贴近日常开发的话题——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 的类型                                    │
├──────────────────────────────────────────────────────────┤
│  bool、整数、浮点数、复数、字符串                        │
│  指针、Channel、接口                                     │
│  所有字段都可比较的 struct                                │
│  元素都可比较的数组                                      │
├──────────────────────────────────────────────────────────┤
│  ❌ 不能做 key 的类型                                    │
├──────────────────────────────────────────────────────────┤
│  slice、map、函数                                        │
└──────────────────────────────────────────────────────────┘

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=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 做成线程安全的?              │
├──────────────────────────────────────────────────────┤
│  Go 官方的理由:                                     │
│                                                      │
│  大多数场景下 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
}
单锁 SafeMap:                    分片锁 ShardedMap:

  所有 key 竞争同一把锁              key 按 hash 分散到 32 个分片
  ┌──────────────────┐              ┌────┐ ┌────┐ ┌────┐
  │  🔒 全局锁       │              │🔒 0│ │🔒 1│ │🔒 2│ ...
  │  key1, key2, ... │              │k1  │ │k2  │ │k3  │
  └──────────────────┘              └────┘ └────┘ └────┘
  并发度 = 1                         并发度 ≈ 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 一览

┌──────────────────────────────────────────────────────────────────────┐
│                          sync.Map                                    │
├──────────────────────────────────────────────────────────────────────┤
│  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) bool   CAS 操作(Go 1.20+)     │
│  CompareAndDelete(key, value any) bool    CAS 删除(Go 1.20+)     │
│  Swap(key, value any) (any, bool)         写入并返回旧值(Go 1.20+)│
└──────────────────────────────────────────────────────────────────────┘

实现原理

sync.Map 内部使用了 读写分离 的策略:

┌────────────────────────────────────────────────────────────┐
│                    sync.Map 内部结构                        │
├────────────────────────────────────────────────────────────┤
│                                                            │
│   read  atomic.Pointer[readOnly]    ← 无锁读              │
│   ┌──────────────────────────────┐                         │
│   │ map[any]*entry               │  只读 map,原子访问     │
│   │ amended bool                 │  dirty 是否有新 key     │
│   └──────────────────────────────┘                         │
│                                                            │
│   dirty  map[any]*entry             ← 加锁读写            │
│   ┌──────────────────────────────┐                         │
│   │ 包含所有有效的 key-value     │  写入新 key 先到这里    │
│   └──────────────────────────────┘                         │
│                                                            │
│   misses int                        ← 未命中计数           │
│   当 misses >= len(dirty) 时,dirty 提升为 read            │
│                                                            │
└────────────────────────────────────────────────────────────┘

读写流程:

Load(key):
  ┌─ 在 read 中查找 ──→ 找到 ──→ 返回(无锁,fast path)
  └─ 没找到,且 amended=true
      │── Lock()
      │── 在 dirty 中查找
      │── misses++
      │── 如果 misses >= len(dirty):dirty 提升为 read
      │── Unlock()

Store(key, value):
  ┌─ key 在 read 中已存在 ──→ CAS 更新 entry(无锁)
  └─ key 不在 read 中
      │── Lock()
      │── 写入 dirty
      │── Unlock()

sync.Map 适合什么场景?

┌──────────────────────────────────────────────────────────────┐
│  ✅ sync.Map 优势场景(官方推荐的两种)                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 读多写少                                                 │
│     key 一旦写入就很少变化,大量并发读                       │
│     例如:配置缓存、路由表、DNS 缓存                         │
│                                                              │
│  2. 不同 goroutine 操作不同的 key(无交叉)                  │
│     每个 goroutine 只读写自己的 key                          │
│     例如:每个连接维护自己的 session                          │
│                                                              │
├──────────────────────────────────────────────────────────────┤
│  ❌ sync.Map 劣势场景                                        │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  • 大量写入新 key(频繁触发 dirty 提升,性能不如加锁 map)   │
│  • 需要对 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 的限制,根据场景选择合适的并发安全方案,才能写出既正确又高效的并发代码。