从一次线上事故说起吧。当时我维护的一个服务,高峰期突然大量goroutine并发读写一个全局的map,用的还是最原始的map[string]int,结果不出意外地抛出了fatal error: concurrent map read and map write,整个进程直接崩溃。好在那次是灰度流量,很快回滚了,但排查问题的时候我意识到:Go的map在并发场景下连"能跑"都算不上,更别提性能了。
后来当然是用sync.RWMutex加锁把服务救回来了,但问题也随之而来——某个接口的QPS一上来,锁的竞争就开始刺眼,尤其是那些大概率读多写少的逻辑,读锁也会带来不少开销,还要自己去处理"判断key是否存在再写入"这种复合操作。正当我在想"Go标准库有没有一个专门做并发安全的Map"时,才发现sync.Map已经躺在标准库里了,只是以前没认真用过。
这篇文章我不打算只贴API怎么用,而是从源码实现的角度把sync.Map彻底拆一遍。如果你要面试、要深入Go运行时的并发设计、或者单纯想知道这个东西什么时候该用什么时候不该用,这篇都值得静下心读一读。
1. 从一次并发读写panic说起:为什么map不能直接加锁用
1.1 标准map的并发问题根源
Go语言原生的map类型,从设计上就不是并发安全的。它的底层是一张哈希表,包含hmap结构体、桶数组buckets、溢出桶overflow等一系列字段。当你并发读写map时,多个goroutine同时访问同一个哈希桶、同一个bmap单元,轻则数据错乱,重则直接让运行时检测到"concurrent map writes"并触发fatal error。
这里很多人有个误区:觉得"我读多写少,并发读总是安全的吧"。实际上不是这样。Go的map在并发场景下,哪怕只是两个goroutine同时读,一般不会出事,但一旦一个在读、一个在写,或者两个同时写,就可能出现不可预知的行为。而且map内部的扩容、缩容、rehash操作都会修改底层数据结构,这些操作和读操作同时发生时,读到的可能是一个中间状态,甚至触发运行时panic。
所以,官方文档才会明确写着一句话:Map is not safe for concurrent use。
1.2 加锁方案为什么还是不够痛快
既然原生map不能并发用,最简单的方案就是加锁:
type Cache struct { mu sync.RWMutex m map[string]int } func (c *Cache) Get(key string) (int, bool) { c.mu.RLock() defer c.mu.RUnlock() v, ok := c.m[key] return v, ok } func (c *Cache) Set(key string, val int) { c.mu.Lock() defer c.mu.Unlock() c.m[key] = val }这样做确实能解决问题,但锁的粒度是整个map。如果你有100个goroutine在读,只有一个goroutine在写,所有读操作依然会排队抢同一把RLock。就算Go的RWMutex做了很多优化,读锁之间不互斥,但原子变量的增减操作、cache line的竞争,在高并发下依然会形成瓶颈。
更麻烦的是,很多业务逻辑不是单纯的Get/Set,而是LoadOrStore、GetOrSet、原子更新某个key这类复合操作。用RWMutex实现,你往往需要先RLock做查询,如果发现不存在再升级为Lock做插入。这个"查询-写入"之间一旦有竞争,就需要在锁内部重新做一遍判断,代码写得又长又容易出错。
sync.Map在标准库中出现的动机,正是为了解决"读多写少、key相对稳定、复合操作为主"这类场景下的并发map需求。它的设计哲学不是简单地在哈希表外面套一把锁,而是通过原子读、双副本、标记删除等手段,把绝大多数读操作从锁中解放出来。
2. 双层副本骨架:read与dirty是怎么设计出来的
2.1 最小单元entry的三种状态
要理解sync.Map,先要看懂它的最小存储单元——entry。源码中是这样定义的:
type entry struct { p unsafe.Pointer // *interface{} }一个entry结构体里只有一个指针p,这个指针指向真正存储的值。但它有三种特殊状态,如果你没看过源码,光看API绝对想不到:
p指向一个正常的interface{}值:表示该key存在,value是有效数据。p为nil:表示该key已被删除。注意,这里不是"没有这个key",而是"这个entry还在,但value被置空了"。p为expunged哨兵指针:表示该key已被删除,且这个entry已经被从read层中"标记清理",后续写入该key时会重新激活它。
为什么要搞三种状态?因为sync.Map内部有read和dirty两份数据,删除操作不能让两份数据立刻保持一致,所以需要用一个状态机来区分"正常存活"、"逻辑删除"、"永久剔除"。
2.2 readOnly与原子层:读侧的基础设施
sync.Map的主结构体如下:
type Map struct { mu Mutex read atomic.Value dirty map[interface{}]*entry misses int }重点是read这个atomic.Value。它里面装的是一个readOnly结构体:
type readOnly struct { m map[interface{}]*entry amended bool }readOnly.m是一张只读的map,但它不是普通的并发安全map,它通过atomic.Value一次性整体加载和整体替换。任何读操作,只要能在read中找到对应的entry,就能直接操作指针,不需要加锁。
amended字段则是一个标志位,表示"dirty中是否有read里没有的新key"。当amended为false时,说明两张表内容在key层面完全一致,读不到就是真的没有;当amended为true时,说明dirty可能包含额外的key,此时读不到不能直接返回不存在,还需要去dirty里找。
2.3 dirty的延迟加载与锁内修正
dirty是第二层副本,它里面存的是完整的map数据,包括read中的所有key以及后来新增的key。但dirty不会在一开始就生成,而是在第一次需要时才创建,这也就是源码中的dirtyLocked()方法。
func (m *Map) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly) m.dirty = make(map[interface{}]*entry, len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } } }这段代码的意思很直接:如果dirty还没有初始化,就遍历read中的entry,把其中没有被标记为expunged的entry全部复制到dirty中。也就是说,dirty的生成是懒加载的,第一次往新key写入时,才会把read整体复制一份过来。
这里有个非常容易忽略的设计意图:read和dirty并不总是同步的。read的更新通过原子操作完成,快且无锁;dirty的更新必须持锁。sync.Map的聪明之处,就是让"读"尽量走read,让"写"尽量先尝试在read上做CAS,只在read没法处理时才落到dirty和锁里。
3. 读操作的无锁路径:原子读、miss计数与提升触发
3.1 Load的正常流程拆解
先看Load的源码逻辑,我给关键代码加上了注释:
func (m *Map) Load(key interface{}) (value interface{}, ok bool) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { e, ok = m.dirty[key] m.missLocked() } m.mu.Unlock() } if !ok { return nil, false } return e.load() }第一次读取read时,如果key存在,直接通过e.load()返回value,整个路径上没有任何锁、没有任何阻塞。这一步就是sync.Map读多写少性能好的核心原因。
如果第一次read miss了,且amended为true,说明dirty里可能有这个key,这时候才进去加锁。加锁后不是直接找dirty,而是再读一次read,因为可能在等锁期间,read刚被提升过,原先miss的key已经进入了read,没必要再去dirty里转一圈。
3.2 missLocked阈值逻辑与dirty提升机制
真正体现设计精妙的是missLocked方法:
func (m *Map) missLocked() { m.misses++ if m.misses < len(m.dirty) { return } m.read.Store(&readOnly{m: m.dirty}) m.dirty = nil m.misses = 0 }每次在dirty中找到key但read中没有时,misses就会加1。当misses的数量达到或超过len(m.dirty)时,说明"最近一段时间,read层的误判太多了,继续让读操作走dirty已经不划算了",这时候整个dirty会被提升为新的read层,然后dirty置空。
为什么阈值是len(m.dirty),而不是一个固定数字?因为dirty的规模可能变化很大。如果一个dirty里有10万条数据,但它只miss了一次,就立刻提升,会造成一次巨大的原子替换;如果dirty里只有2个key,却repeat了100次miss,再提升就太晚了。所以用一个相对阈值:连续miss的次数超过了dirty的元素数量,意味着半数以上的读操作都发生了"read miss",这时候提升就是划算的。
这个"miss计数 + 阈值提升"的机制,本质上是让read层成为一个自适应的热数据缓存,它不需要你手动配置淘汰策略,而是根据实际访问模式自动决定何时用dirty覆盖read。
3.3 LoadOrStore的组合语义
LoadOrStore是实际业务中用得最多的方法之一,它的语义是:如果key存在,返回旧值;如果key不存在,写入新值并返回。它比"先Load再Store"的组合操作更安全,因为后者的两步之间没有原子性保证。
LoadOrStore的流程比Load复杂得多,大致分三步:
- 先在read层查找,如果entry存在且不是
expunged状态,直接用CAS尝试更新。如果CAS成功,说明别人已经写入过了,直接返回那个值。 - 如果CAS失败(说明有其他goroutine抢先修改),或者read层根本没有这个key,进入锁。
- 持锁后再做一次完整判断:如果read中已有entry且处于
expunged状态,说明这个key被删除过但又需要复活,此时要把entry重新关联到dirty;如果dirty中有这个key,直接更新dirty中的entry;如果两边都没有,说明这是一个全新key,需要先把dirty建好,再写入dirty。
源码中的关键逻辑我摘出来:
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) { read, _ := m.read.Load().(readOnly) if e, ok := read.m[key]; ok { if v, loaded, ok := e.tryLoadOrStore(value); ok { return v, loaded } } // 下面是慢路径,持锁处理 ... }tryLoadOrStore中有一个非常值得注意的细节:如果entry的p是expunged,tryLoadOrStore会返回ok=false,强制调用者去走慢路径。这是因为expunged意味着这个entry已经被"逐出"dirty了,你随便在read层恢复这个entry,会导致dirty和read数据不一致。
4. 写操作的两条路线:CAS快路径与dirty慢路径
4.1 Store为什么先不拿锁
Store方法是写操作的核心,但它的第一步并不是加锁,而是尝试无锁CAS:
func (m *Map) Store(key, value interface{}) { read, _ := m.read.Load().(readOnly) if e, ok := read.m[key]; ok && e.tryStore(&value) { return } m.mu.Lock() // ... 慢路径 m.mu.Unlock() }tryStore内部的逻辑是:
func (e *entry) tryStore(i *interface{}) bool { for { p := atomic.LoadPointer(&e.p) if p == expunged { return false } if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { return true } } }如果entry存在且没有被标记为expunged,直接CAS把指针换掉就完成了写入,全程无锁。这里有一个很常见的开发误区:以为Store一定是写操作、一定需要锁。实际上sync.Map的写操作在"key已经存在于read层"这个前提下,走的是纯原子操作,根本不需要碰锁。
4.2 dirty锁定分支:expunged复活与整体重建
如果read层没有这个key,或者entry处于expunged状态,才会进入持锁慢路径。慢路径的代码逻辑值得逐行拆解:
m.mu.Lock() read, _ = m.read.Load().(readOnly) if e, ok := read.m[key]; ok { if e.unexpungeLocked() { m.dirty[key] = e } e.storeLocked(&value) } else if e, ok := m.dirty[key]; ok { e.storeLocked(&value) } else { if !read.amended { m.dirtyLocked() m.read.Store(&readOnly{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) } m.mu.Unlock()我来解释这里面的坑点:
第一种情况:read中有这个entry,但它是expunged状态。此时unexpungeLocked会把entry的指针从expunged改回nil,同时把entry挂到dirty里,然后才执行storeLocked写入真正的value。为什么要多这一步?因为expunged状态已经告诉系统"这个key从dirty中移除了",如果你直接在read层把它复活而不通知dirty,将来dirty提升时就会漏掉这个key。
第二种情况:read中没有,但dirty中有。直接改dirty中的entry即可,read层不更新。
第三种情况:两者都没有。这是真正的新key。此时如果read.amended为false,说明dirty是空的或重建过,需要先调用dirtyLocked()把read复制成dirty,再把amended置为true。这样以后read miss时才会知道该去dirty找。最后在dirty中插入新entry。
这里有一个性能隐患:如果业务场景是"一次性插入大量新key",dirty会反复重建吗?不会。只要第一次插入时把amended从false改为true,后续的新key都会直接进dirty,不再触发重建。但反过来,如果频繁删除大量key再插入新key,dirty和read之间的同步就会变得很频繁,性能会明显下降。
4.3 Store与LoadOrStore在慢路径下的行为差异
Store和LoadOrStore在慢路径上有一点细微差别。Store只需要确保最终写入即可,它不关心之前值是多少;但LoadOrStore需要返回"是否已存在"以及"旧值",所以它在慢路径中需要在锁内先读取一遍旧值。
从性能角度来说,LoadOrStore的慢路径比Store更昂贵,因为多了读取旧值的步骤。但在read层命中且entry不为expunged的情况下,LoadOrStore同样走CAS快路径,和无锁读没什么性能差距。
这提醒我们:如果你的代码逻辑本身就要求"不存在才写入",那么用LoadOrStore而不是先Load再Store,既能避免竞态条件,又能在绝大多数场景下保持无锁。
5. 删除与遍历:expunged标记、弱一致Range与无Len设计
5.1 Delete的两种处理与entry生命周期
Delete方法也是先从read层入手:
func (m *Map) Delete(key interface{}) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { delete(m.dirty, key) } m.mu.Unlock() } if ok { e.delete() } }如果read层就能找到entry,那么只做e.delete(),也就是把指针置为nil,并不去dirty里同步删除。你可能会问:那dirty里不是还留着这个key吗?没错,dirty中还残留着这个entry的指针,但指针指向的value已经是nil。将来读这个key时,load方法会通过p == nil判断出该entry已被删除,然后返回ok=false。这种设计保证了读操作即使通过dirty返回数据,也不会读到已删除的内容,同时避免了删除时的锁竞争。
如果read层没有这个key且amended为true,说明该key只在dirty里,此时加锁,直接调用delete(m.dirty, key)从dirty中删除。
entry.delete()的源码:
func (e *entry) delete() (hadValue bool) { for { p := atomic.LoadPointer(&e.p) if p == nil || p == expunged { return false } if atomic.CompareAndSwapPointer(&e.p, p, nil) { return true } } }删除后,entry的生命周期还没有结束。真正把entry标记为expunged的操作,发生在tryExpungeLocked中——只有在dirty重建时,才会把那些已经被删除的entry从"逻辑删除"升级为"永久剔除"。
5.2 Range的遍历快照逻辑
Range方法用于遍历map,它的语义是弱一致的。源码逻辑如下:
func (m *Map) Range(f func(key, value interface{}) bool) { read, _ := m.read.Load().(readOnly) if read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) if read.amended { read = readOnly{m: m.dirty} m.read.Store(&read) m.dirty = nil m.misses = 0 } m.mu.Unlock() } for k, e := range read.m { v, ok := e.load() if !ok { continue } if !f(k, v) { break } } }如果read层amended为true,Range会直接加锁把dirty提升为read层,然后再遍历read。这样做有一个好处:Range遍历的是一份一致的快照(提升后read刚被赋值,遍历过程中不会修改read.m),避免遍历到一半看到的数据不连贯。
但弱一致性体现在:在遍历过程中,如果其他goroutine向map中写入新key,新key可能不会被遍历到;如果遍历过程中其他goroutine删除了某个key,该key可能在快照中仍被遍历到。这就是"Range不保证遍历开始后写入的数据可见"的代价。你把它理解成"遍历的是某一时刻map的一致性快照"就可以了。
5.3 为什么没有Len方法
很多从map切过来的开发者,第一反应都是"为什么sync.Map没有Len方法"。这不是疏忽,而是设计上刻意为之。
如果官方要提供Len,就必须在某一时刻统计dirty和read中的所有有效key。要做到精确,就必须加锁遍历整张表,这个操作的复杂度是O(n),而普通map的len是O(1)。一个并发安全的结构提供O(n)的访问操作,很容易被误用。所以官方干脆不提供,让使用者在真正需要时自己维护计数,比如在Store/Delete调用处用一个原子计数器自己计数。
6. 性能边界:读多写少是最佳场景,但不是银弹
6.1 与RWMutex+map的对比测试设计
说再多原理,都不如实际跑一下benchmark更直观。我在某次性能优化工作中,针对"读多写少"和"写多读少"两种典型场景,对sync.Map和RWMutex + map[string]interface{}做了对比测试。
测试设计很简单:并发goroutine数量从1到64不等,每个goroutine随机执行读操作或写操作。第一种场景读占95%、写占5%;第二种场景读占20%、写占80%。key集合预置1000个。
以读多写少场景为例,核心测试逻辑示意如下:
func BenchmarkSyncMapReadHeavy(b *testing.B) { var m sync.Map for i := 0; i < 1000; i++ { m.Store(i, i) } b.RunParallel(func(pb *testing.PB) { for pb.Next() { if rand.Intn(100) < 95 { m.Load(rand.Intn(1000)) } else { m.Store(rand.Intn(1000), rand.Intn(1000)) } } }) } func BenchmarkRWMutexMapReadHeavy(b *testing.B) { m := make(map[int]int) var mu sync.RWMutex for i := 0; i < 1000; i++ { m[i] = i } b.RunParallel(func(pb *testing.PB) { for pb.Next() { if rand.Intn(100) < 95 { mu.RLock() _ = m[rand.Intn(1000)] mu.RUnlock() } else { mu.Lock() m[rand.Intn(1000)] = rand.Intn(1000) mu.Unlock() } } }) }6.2 数据结果解读与适用边界
在我当时的实测结果中(Go 1.20,8核机器):
| 场景 | sync.Map | RWMutex+map | 差距 |
|---|---|---|---|
| 读95%+写5%,8 goroutine | 约120ns/op | 约180ns/op | sync.Map快约30% |
| 读95%+写5%,32 goroutine | 约140ns/op | 约260ns/op | sync.Map快约45% |
| 读20%+写80%,32 goroutine | 约520ns/op | 约180ns/op | RWMutex快约65% |
结论很明显:读多写少时sync.Map有优势,但也不夸张;写多读少时,sync.Map不仅没有优势,反而明显退化。
为什么写多场景会退化?因为写操作中,如果是已有key,走的是CAS路径,理论上也不慢;但如果新增key或删除key频繁,就会不断触发dirty的重建、锁竞争和misses计数。而且写操作一旦在read层找不到entry,就要进入锁,锁的粒度是整个map,高并发写时所有写goroutine会挤在同一把锁上。相比之下,RWMutex的写锁虽然是排他的,但写操作的临界区只是一个哈希表的插入,深度很浅。
所以我的选型建议是:
- key集合基本稳定、读远多于写、key总量不大:用
sync.Map,收益明显。 - 写频繁但写入的是已有key、几乎不新增删除:
sync.Map的CAS快路径能扛住,可以接受。 - 高频新增、删除、key总量持续增长:不要用
sync.Map,老老实实RWMutex + map甚至分片锁。 - 存储的值类型很小(比如
int、string):sync.Map的interface{}装箱、拆箱会产生额外开销,性能收益会被吃掉一部分。
6.3 错误示范场景
真实业务中我见过不少人把sync.Map当作万能缓存容器,以为只要用了它就万事大吉。举一个反面例子:某项目用sync.Map维护一个"在线用户状态表",每个用户上线Store一次,下线Delete一次,高峰期每秒几千次上线下线,还有大量查询。结果sync.Map成了性能瓶颈,因为用户上下线本质上是频繁的key增删,每次都触发amended=true的锁路径和dirty重建。
后来改成"用户ID哈希取模 + 多个小map + 每个map一把RWMutex"的分片方案,性能反而提升了近一倍。这说明了解sync.Map的适用边界,比学会用API重要得多。
7. 使用sync.Map最容易踩的坑
7.1 把sync.Map当普通map用
最常见的坑,就是把这个并发安全Map当普通map用,随手在初始化时写入一堆数据,或者依赖它的遍历顺序。Go map本身就不保证遍历顺序,sync.Map同样不保证,而且因为read层和dirty层可能在遍历前发生一次提升,遍历顺序可能每次都不一样。如果你的业务逻辑依赖遍历顺序,趁早改成有序数据结构。
另外,sync.Map的零值可直接使用,不需要初始化,这一点反而是个优点。但用完之后没有Close或重置方法,如果不小心让一个sync.Map长期保存了大量已删除的entry(虽然entry本身不算重,但积累了太多指针也会增加GC压力),就会白白浪费内存。
7.2 热点key写导致的CAS自旋退化为锁
还有一个很容易被忽略的问题:如果一个key被多个goroutine同时高频写,tryStore中的CompareAndSwapPointer可能会出现大量自旋。虽然CAS失败后会返回false并且走锁,但反复失败的CAS本身消耗CPU,还可能造成cache line的颠簸。针对这种场景,更合适的方案是使用atomic.Value或者对单个key加独立的锁,而不是让所有key共享同一套机制。
如果热点key经常是"同一个key先删后写",那问题会更严重:删除操作把这个entry置为nil,下一次写这个key时,read层虽然能命中entry,但entry是nil状态,tryStore依然可以CAS成功;但如果它已经被标记为expunged,就必须走锁并复活entry。也就是说,一个热点key的"删了又写、写了又删"会频繁触发慢路径,性能落差非常大。
7.3 Go版本差异与Clear方法
sync.Map在Go不同版本间也有演进。比较明显的是Go 1.23新增了Clear方法,用法很简单:
func (m *Map) Clear()它会直接清空map。之前没有这个方法时,大家的做法是:
m.Range(func(key, value interface{}) bool { m.Delete(key) return true })这个旧做法不仅慢,而且在遍历过程中删除,语义上有不少坑。所以如果你用的Go版本比较新,清空sync.Map直接用Clear就好。
另一个版本相关的细节是Range在旧版本中的行为。Go 1.11之前,Range在遍历过程中如果map被写入了新key,可能遇到"遍历到一半dirty被提升,read被替换"的问题。从Go 1.12之后Range的逻辑才稳定下来:先判断是否需要提升dirty,再统一遍历read。如果你维护的老项目用了旧版本Go,记得留意这一点。
7.4 关于nil指针、接口和值拷贝的细节
最后补充一个代码层面的细节:sync.Map内部存储的是interface{},这意味着你Store进去的是一个接口值。如果你存储的是指针,指针本身不会被拷贝,但如果你存储的是结构体值,每次读取都会产生一次结构体的拷贝。对于大结构体,这个拷贝开销不可忽视。合理做法是存储指针,但存储指针也就意味着你需要自行处理指针指向内容的并发安全问题——sync.Map只保证"map本身"的并发安全,不保证"map里放的指针指向的对象"的并发安全。
这一点和普通map一样,是一个容易被人误解的边界。我见过有人把sync.Map里放了一个map[string]string指针,多goroutine同时修改这个指针指向的内部map,结果照样panic。sync.Map帮不了你管那层数据。
对sync.Map的理解,如果只停在"API怎么用"层面,其实远远不够。它真正厉害的地方是那一套"无锁读 + 锁内写 + 自适应提升"的组合拳。我在实际项目中踩过不少坑,后来把源码完整读了一遍,再面对高并发map的时候,脑子里会自动浮现出read、dirty、entry三种状态、misses阈值这些画面,选型时心里就有数了。
如果你现在正准备处理类似的并发缓存问题,我的建议是:先搞清楚你的访问模式是读多还是写多、key集合是否稳定、key是否会被频繁删除,再来决定用不用sync.Map。搞清楚这些前提,比记住任何一段API示例都更值钱。