709 字
4 分钟
sync.Map 不是并发 map 的银弹
看到“并发安全的 map”,很多人的第一反应是 sync.Map。但它的文档写得很清楚:它是为读多写少、键集合基本稳定的场景优化的,不是 map 加锁的通用替代品。
内部是两张表
sync.Map 里有一个只读的 read 和一个可写的 dirty。读操作走 read,大部分时候只需要一次原子加载;写入发现键不在 read 里,才会加锁去 dirty 里找或插入。
当 dirty 的写入积累到一定程度,它会被提升为新的 read,旧的 read 里被删除的键这时才真正消失。这解释了它的几个行为:
- 读路径几乎没有锁,所以读多写少时很快;
- 写入要维护两张表,写多时不如普通 map 加锁;
- 已删除的键在被提升之前仍然占着位置,键空间反复变化时内存不会立刻下降。
它没有的东西
var m sync.Mapm.Store("a", 1)v, ok := m.Load("a")m.Range(func(k, v any) bool { // 遍历不保证是某一时刻的一致快照 return true})- 没有
len:想知道大小只能Range数一遍,而Range期间可能有并发写入。 - 没有类型安全:键值都是
any,每次存取都有装箱和类型断言。 - 没有原子批量操作:需要“读-改-写”时,仍然要自己用
LoadOrStore或CompareAndSwap拼逻辑。 - Range 不是快照:它不阻塞写入,遍历结果可能包含已经被删除的键,也可能漏掉遍历开始后才插入的键。
什么时候值得用
典型场景是“进程级缓存,键在启动或首次访问时写入,之后几乎只读”:
var routes sync.Map // path -> handler,启动期写入,运行期只读
func lookup(path string) (Handler, bool) { v, ok := routes.Load(path) if !ok { return nil, false } return v.(Handler), true}反过来,如果键会频繁增删,或者需要遍历、计数、批量更新,sync.RWMutex + map 通常更简单也更快。另一个常见选择是分片锁:把键哈希到 N 个桶,每个桶一把锁,冲突概率随 N 下降。
type sharded[K comparable, V any] struct { shards [16]struct { mu sync.RWMutex m map[K]V }}一个容易忽略的坑
sync.Map 的零值可用,但不要复制它。它内部包含互斥锁和状态字段,复制之后两份状态各自独立,行为会变得不可预测。作为结构体字段时,用指针或者确保结构体本身不被复制。
结论
sync.Map 是一个针对特定访问模式的优化结构,不是“并发 map”的同义词。选它之前先确认:键集合稳定吗?读远多于写吗?需要长度和一致性遍历吗?三个问题里有一个答不上来,普通 map 加锁往往更合适。