前面几篇我们聊了各种同步原语,这一篇聊一个更贴近日常开发的话题——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 writeGo 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 可能不适合你的场景。
七、实战建议
- 内建 map 不是线程安全的——并发读写会 panic,不是数据错乱,是直接崩溃
- 通用方案用 RWMutex + map——简单、可控、类型安全
- 高并发大量 key 用分片锁——把锁竞争分散到多个分片
- 读多写少用 sync.Map——无锁读路径性能最好,但 API 是 any 类型,牺牲了类型安全
- 用
-race检测并发问题——map 的并发 bug 有时不会立即 panic,race detector 能提前发现
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...map 是 Go 中使用频率最高的数据结构之一,而并发访问 map 是最常见的并发 bug 来源之一。理解内建 map 的限制,根据场景选择合适的并发安全方案,才能写出既正确又高效的并发代码。