前面几篇我们聊了各种同步原语,这一篇聊一个更贴近日常开发的话题——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 writeGo 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
}单锁的并发度 = 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) 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 内部使用了 读写分离 的策略,由三部分组成:
| 字段 | 作用 |
|---|---|
read (atomic.Pointer[readOnly]) | 只读 map,原子访问、无锁 |
dirty (map[any]*entry) | 包含所有有效的 key-value,写新 key 先到这里,需要加锁 |
misses (int) | 未命中计数;当 misses >= len(dirty) 时,dirty 提升为 read |
读写流程
无锁 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) 的路径:如果 key 在 read 中已存在,用 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 可能不适合你的场景。
七、实战建议
- 内建 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 的限制,根据场景选择合适的并发安全方案,才能写出既正确又高效的并发代码。