尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Go sync.Map源码深度解析:read-copy-update模型与性能边界

Go sync.Map源码深度解析:read-copy-update模型与性能边界 有一段时间我对sync.Map的理解停留在“并发安全的map”这个层面。说起来有点惭愧真正逼我去啃源码的是一次线上性能排查pprof显示大量goroutine阻塞在sync.(*Map)内部当时我手里的方案是拿sync.Map替换一个mapRWMutex本意是优化读多写少的缓存热点结果压测数据反而退步了。后来我把sync.Map源码完整过了一遍才真正搞懂read-copy-update这套模型在解决什么问题、代价又在哪里。这篇就把我梳理过的实现原理、源码细节和实战教训完整写一遍给同样在纠结“到底要不要用sync.Map”的人一个参考。1. 先从一次失败的性能优化说起1.1 无脑替换mapmutex的教训当时项目里有个黑名单缓存key集合基本稳定业务流量是典型的读多写少读请求QPS很高。我先用的方案是sync.RWMutex保护一个原生map读走RLock写走Lock。RWMutex在低并发下其实很便宜读锁本质上只是对一个计数器做原子递增没有系统调用也没有goroutine切换。后来我听说sync.Map是为“读多写少”设计的就顺手把它换了进去。结果压测数据让人意外高并发读场景下确实快了但一旦写请求稍微上来一点比如每秒几百次更新延迟反而变差了。更离谱的是在8核以下、map元素只有几百个的时候sync.Map连读性能都打不过原来的RWMutex方案。这个反直觉的结果让我意识到sync.Map不是万能药它用一套复杂的读写分离机制换来了特定场景下的无锁读但这份“无锁”是有前置条件的。不理解它的实现原理很容易在错误场景里做“反向优化”。1.2 原生map并发写panic与标准解法先说说为什么需要并发安全的map。Go原生map在并发读写时会直接抛fatal error: concurrent map read and map write。这不是普通的error而是不可恢复的运行时致命错误程序会直接崩溃连recover都接不住。因为map底层是hmapbmap结构并发写可能破坏桶里的链表、溢出桶的指针等内部状态。所以只要存在并发写就必须在map外加同步机制。最常见的做法就是mapMutex或mapRWMutex读多写少用RWMutex读锁是共享的读写比例接近或写更多直接用Mutex还省心如果要按key分片可以拆成多个小map每个map一把锁降低锁粒度。这套方案最大的问题在于高并发读场景下RWMutex的读锁虽然共享但所有读者都会去原子操作同一个计数器。多核环境下这个计数器所在的缓存行会被反复争抢产生严重的cache line ping-pong导致读锁退化成一种隐性的“伪共享”。sync.Map正是为了绕过这个问题才引入read-copy-update模式的。1.3 官方文档里那三条“适合用”的场景Go官方文档给sync.Map划了三个适用场景我结合实践重新翻译一下一个key只写一次但被读很多次。典型的就是只增缓存key初始化后不再变化读请求反复命中。多个goroutine读写不相交的key集合。意思是每个goroutine主要操作自己的那批key没有激烈竞争。只增不减的缓存类场景。key只增加、不删除或者删除频率极低。这三条其实都在指向同一个核心读操作要尽可能命中read快照不触发慢路径。如果你的业务不符合这个模式用sync.Map大概率是给自己找麻烦。下面就从源码层面拆解它到底怎么实现这种“读优先”的。2. read-copy-update模型一张公告栏和一本工作簿2.1 什么是read-copy-update为什么sync.Map需要它read-copy-updateRCU本来是一种无锁同步技术核心思想很简单读者不需要任何锁直接读一个共享快照写者不原地修改共享数据而是先复制一份在副本上改改完通过原子指针替换让新副本生效。我习惯用一个“公告栏”类比来理解它公司挂了一张“员工通讯录”打印版在墙上所有人都能随时看。这就是read快照。有人入职或离职行政不会直接在墙上的打印版上涂改那样看的人会看到一半新一半旧的数据而是拿着原版去复印一份在复印件上改好再拿新的打印版替换旧的。这一步就是copy和update。sync.Map就是这样设计的绝大多数读操作只访问一个叫read的只读快照完全无锁。只有在快照里找不到key或者需要写入新key时才会去碰一个叫dirty的待定数据集合并涉及锁操作。整个流程可以拆成三个阶段Read从read快照直接原子读取不加锁。Copy当需要向dirty里添加新key时把read复制一份作为dirty的底稿。Update当慢路径访问次数足够多时把dirty整体提升为新的read通过原子指针更新完成“换版”。这不是教科书RCU的精确复刻但借鉴了它最核心的思想读不做原地修改写先复制后原子替换。2.2 核心数据结构拆解先看sync.Map最核心的定义以Go 1.21.x源码为准type Map struct { mu Mutex // read 是允许无锁读的只读快照 read atomic.Pointer[readOnly] // dirty 是需要在 mu 保护下访问的数据 dirty map[any]*entry // misses 记录 read 未命中、需要加锁查 dirty 的次数 misses int } type readOnly struct { m map[any]*entry amended bool // 如果 dirty 中存在 read 里没有的 keyamended 为 true } type entry struct { p atomic.Pointer[any] }逐字段解释一下read是一个atomic.Pointer[readOnly]指向一个只读快照。读操作直接原子加载这个指针再查里面的map全程不拿mu。dirty是一个普通map里面放着比read更新的数据。对这个map的任何读写都必须持有mu。misses是一个计数器专门记录“在read里没找到key被迫加锁去dirty找”的次数。这个计数器是整篇代码里最精妙的设计之一后面专门讲。readOnly.amended是一个布尔标记表示dirty里是否有read里不存在的key。它为true时Load不能因为read里没找到就直接返回不存在还得去dirty里翻一翻。entry结构体里只有一个原子指针p这个指针有三种状态是整个状态机的关键指向一个真实的any值key有效Load可以读取。为nilkey被软删除了。逻辑上已经不存在Load读不到但entry还挂在map里没有真正清理。指向expunged这个哨兵指针这个key不仅被删除还被从dirty里清除了下次复制read到dirty时不会再带上它。expunged是一个全局变量var expunged new(any)用它作为特殊的指针标记。看到这里你可能会问为什么删除不直接删掉map里的key而是搞出一个expunged状态下一节讲不可变快照时你就理解了。2.3 不可变快照read.m为何从不原地修改readOnly.m这个map有一个非常重要的约束它一旦被放进read指针引用就再也不被原地修改。这意味着什么当你要往map里加一个新key时不会直接read.m[key] newEntry(value)因为那样做的话正在无锁读这个map的goroutine会看到map内部结构的变更可能读到半初始化状态甚至引发并发读写map的崩溃。所以真正的写入流程是加锁拿到mu。把read.m复制成一个新的dirtymap。在dirty上做修改。通过m.read.Store(readOnly{m: read.m, amended: true})发布一个新的readOnly对象浅拷贝m还是原来那个map或者干脆把dirty整体提升成新的read。这种“快照发布”方式保证了无锁读的goroutine拿到的是某个时刻的稳定快照要么看到旧版要么看到新版绝不会看到改了一半的中间状态。这是整个read-copy-update模型能成立的基石。3. 四条主链路的源码推演3.1 Load无锁读优先慢路径的double checkLoad是使用最频繁的入口源码逻辑如下func (m *Map) Load(key any) (value any, ok bool) { read : m.loadReadOnly() e, ok : read.m[key] if !ok read.amended { m.mu.Lock() // 加锁后再看一次 read避免等锁期间 dirty 已经被提升 read m.loadReadOnly() e, ok read.m[key] if !ok read.amended { e, ok m.dirty[key] // 无论是否找到都记一次 miss为后续提升做准备 m.missLocked() } m.mu.Unlock() } if !ok { return nil, false } return e.load() }流程拆开看先无锁加载read快照查key。命中就直接e.load()取value全程不碰锁。如果read里没有且amended为true说明dirty里可能有这个key才进入慢路径加锁。加锁后第一件事是再读一次read这就是double check。等锁的过程中其他goroutine可能已经把dirty提升成了新read如果还用旧read判断就会产生一次多余的“伪miss”甚至可能因为这次伪miss触发不必要的提升。源码注释里特意写了这一点“Avoid reporting a spurious miss if m.dirty got promoted while we were blocked on m.mu”。确认新read里还是没有且amended仍为true才去dirty里找并调用missLocked()把misses加一。e.load()里面还有一个细节func (e *entry) load() (value any, ok bool) { p : e.p.Load() if p nil || p expunged { return nil, false } return *p, true }也就是说即使read.m里有这个key只要entry.p已经被置为nil软删除或expungedLoad同样返回(nil, false)。所以sync.Map是支持存nil值的Store(key, nil)之后entry.p指向一个值为nil的any指针p本身不是nilload()会返回(nil, true)。网上有些文章说“sync.Map不能存nil”以Go 1.21源码来看这个说法并不准确。3.2 Store快速CAS与锁内分支Store在Go 1.20之后是直接调Swap实现的。完整的Swap分支非常清晰func (m *Map) Swap(key, value any) (previous any, loaded bool) { read : m.loadReadOnly() if e, ok : read.m[key]; ok { if v, ok : e.trySwap(value); ok { if v nil { return nil, false } return v, true } } m.mu.Lock() read m.loadReadOnly() if e, ok : read.m[key]; ok { if e.unexpungeLocked() { // entry 之前是 expunged说明 dirty 里没有它需要加回来 m.dirty[key] e } if v : e.swapLocked(value); v ! nil { loaded true previous v } } else if e, ok : m.dirty[key]; ok { if v : e.swapLocked(value); v ! nil { loaded true previous v } } else { // 这是一个全新的 key if !read.amended { // dirty 为空或已清空先基于 read 复制一份 m.dirtyLocked() m.read.Store(readOnly{m: read.m, amended: true}) } m.dirty[key] newEntry(value) } m.mu.Unlock() return previous, loaded }这里有几个关键路径路径一快速CAS更新。如果key已经在read.m里且entry.p不是expunged直接走trySwap做无锁CAS把entry.p从当前值替换成新值。多个goroutine同时写同一个key时会通过CAS循环保证最终只有一个成功这个过程不需要持有mu。所以“更新已存在的key”在sync.Map里其实很轻量代价只是一个原子比较交换。路径二expunged恢复。如果entry.p是expunged说明这个key在之前某次dirtyLocked复制时被清理掉了。现在要重新赋值必须先通过unexpungeLocked把expunged替换成nil以此作为“未删除”的中间标记再把entry放回dirty。这个过程必须在锁内完成因为涉及dirtymap的修改。路径三真·新key。当read和dirty里都没有这个key且read.amended为false时需要调用dirtyLocked()把read全量复制成一份dirty。这一步是O(N)的N是read.m的长度。复制完成后把read替换成一个amendedtrue的新快照然后在dirty里加入新key。路径三才是sync.Map“写代价高”的根源新增一个从来没见过的key可能要先为整个快照做一次拷贝。如果业务是key持续增长的缓存每次新增key都会在amended从false变true的那一下触发一次全量复制。3.3 Delete软删除和expunged延迟清理Delete在Go 1.15之后变成LoadAndDelete的别名func (m *Map) LoadAndDelete(key any) (value any, loaded bool) { read : m.loadReadOnly() e, ok : read.m[key] if !ok read.amended { m.mu.Lock() read m.loadReadOnly() e, ok read.m[key] if !ok read.amended { e, ok m.dirty[key] delete(m.dirty, key) m.missLocked() } m.mu.Unlock() } if ok { return e.delete() } return nil, false }关键在e.delete()func (e *entry) delete() (value any, ok bool) { for { p : e.p.Load() if p nil || p expunged { return nil, false } if e.p.CompareAndSwap(p, nil) { return *p, true } } }这里有一个非常“反直觉”的设计当key命中read.m时Delete并不会把key从map里移除只是把entry.p用CAS置为nil。这个key仍然占据着read.m里的槽位read快照还是那么大。为什么这么设计因为read.m是只读快照不能原地删除key。如果要从map结构上移除key必须更新read指针、让新快照里不再包含这个key。但更新read指针本身就是一次“发布新快照”成本不低。所以删除操作选择了一个廉价的中间方案先软删除置nil等之后某个时机dirty提升或重新复制再真正把key从快照里剔除。如果key不在read里、而在dirty里才会直接delete(m.dirty, key)因为dirty是在锁保护下的可变map可以随便删。那么expunged状态是哪里来的发生在dirtyLocked复制read时func (e *entry) tryExpungeLocked() (isExpunged bool) { p : e.p.Load() for p nil { if e.p.CompareAndSwap(nil, expunged) { return true } p e.p.Load() } return p expunged }复制时如果发现某个entry.p是nil已被软删除就把这个nil原子替换成expunged并且不把该entry复制进dirty。这样一来expunged就成了“已从dirty中彻底清除”的标记。下次再用这个keyStore里的路径二就会被触发。这套设计的精妙之处在于expunged只存在于read里dirty里的entry永远不可能是expunged。因为dirty是可变mapkey真删了就delete(m.dirty, key)直接移除没必要搞软删除。3.4 Range强制提升后的遍历Range的源码值得仔细看func (m *Map) Range(f func(key, value any) bool) { read : m.loadReadOnly() if read.amended { m.mu.Lock() read m.loadReadOnly() 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 } } }Range的思路是如果dirty里还有比read更新的数据就先把dirty提升成新的read相当于执行了一次update然后遍历新read。这个设计带来两个重要语义Range能看到调用开始时已经存在、且未被软删除的key因为提升过程中dirty被完整保留下来。遍历期间新增的key不会被看到。因为Range用的是某一个固定的read快照新增key只会进dirty不会改这个快照。遍历期间对已有key的更新和删除则可能被观察到因为entry.p是原子的可能遍历到一半发现值变了或者被删了。所以Range并不是一个“一致性快照”遍历它是“尽力而为”的当时视图。如果你需要严格一致的遍历得自己额外加锁或拷贝数据。4. 交接班机制misses、dirtyLocked与提升成本4.1 missLocked什么时候把dirty提升成readmissLocked是sync.Map整个状态机的“调度器”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 }逻辑很直接每次慢路径Load或Deletemisses加一。当misses达到len(dirty)时把dirty整体提升为新的read然后清空dirty和计数器。为什么阈值是len(dirty)而不是固定的100或1000因为misses代表“read里找不到、需要去dirty里找”的次数。dirty里有多少个key理论上就有多少个key会触发这种miss。当一个key被频繁访问但一直没提升时其他key的miss也会累积。当累积次数达到dirty长度说明“这些新增key已经被访问得足够频繁”值得把它提升为read快照让后续读走无锁路径。这个设计保证了只有当慢路径真正频繁发生时才付出“提升”的代价。如果一个新key写进去后再也没人读它永远不会推动dirty提升也就不会扰动read快照。4.2 dirtyLocked新增新key时的全量复制dirtyLocked是写路径中最贵的操作func (m *Map) dirtyLocked() { if m.dirty ! nil { return } read : m.loadReadOnly() m.dirty make(map[any]*entry, len(read.m)) for k, e : range read.m { if !e.tryExpungeLocked() { m.dirty[k] e } } }它做三件事基于当前read.m创建一个和它一样大的dirtymap。遍历read.m所有entry把有效entry放进dirty。对pnil的软删除entry尝试CAS成expunged并跳过不让它进dirty。这个O(N)复制只有在一个条件下触发read.amended为false且要写入一个全新的key。一旦amended变为true后续新key只需要加锁往dirty里塞即可不再重复复制。理解了这一点你就能明白为什么“key集持续增长”对sync.Map不友好每次从amendedfalse变成true都要付出一次全量复制成本而提升后amended回到false如果业务继续增长又会触发下一次全量复制。周期性的全量复制和提升会让延迟出现明显的尖刺。4.3 一个entry的生命周期从正常、软删到expunged把三态变化完整串一遍帮助理解状态机正常态entry.p指向真实value。Load、Store都能快速处理。软删态Delete将entry.pCAS成nil。此时Load返回(nil, false)但key还占着read.m的槽位dirty里可能还有同一个entry。expunged态在dirtyLocked复制时某个pnil的entry被CAS成expunged且没有复制进新的dirty。这个entry彻底从“用于同步的dirty视图”中消失但read快照里那个map槽位还在直到整个read快照被替换。恢复态Store一个已expunged的key时通过unexpungeLocked把expunged变回nil再重新加入dirty。一个关键结论软删除的key在read里占用的内存只有在dirty被提升替换整个read时才可能被释放。如果业务只删不增一直不触发复制或提升那些被软删除的key会一直挂在read.m里。这就是“只增不减适合sync.Map频繁增删不适合”的底层原因。5. 性能边界我实测下来的结论5.1 读多写少key稳定优势明显在key集合基本固定、读请求远多于写请求的压测场景下sync.Map的Load几乎完全命中read快照走无锁路径。和高并发下的RWMutex相比读请求不再反复争夺同一个共享计数器线程调度和缓存一致性开销都明显下降。我在20核机器、64并发goroutine、读写比例99:1的场景下压测sync.Map的吞吐明显高于mapRWMutex最高差距能达到数倍。这个场景也是官方文档第一条描述的场景key写一次、读多次。比如配置中心本地缓存、规则引擎的特征开关表都很适合。5.2 写多和频繁增删时会输给mutex写操作一旦变多局面立刻反转。每次Store要么加锁要么在已存在key上做CAS要么触发dirtyLocked全量复制每次Delete也可能加锁并更新misses。当写占比超过10%左右具体数值取决于map规模和并发度sync.Map的优势就开始消失写占比更高时它的延迟和吞吐很可能不如一个朴素的mapMutex。原因在于sync.Map的写路径本质上没有比普通mutex方案省多少同步开销还多了CAS、复制、提升这些额外成本。普通mapMutex的写操作就是一次加锁一次map赋值循环利用反而更简单高效。5.3 低并发小map场景的隐藏开销低并发和map元素很少时sync.Map的“隐藏开销”会被放大Load需要先loadReadOnly原子加载指针再查一次read.m还要再对entry.p做一次原子加载。中间多跳了两层原子操作。而RWMutex读锁在无竞争时只是一个原子递增通常比两层原子加载两次map查找还要便宜。更不用说sync.Map没有len()统计长度必须Range遍历成本远高于原生map的len()。所以如果你的场景是“小map、低并发”直接用原生map加锁通常更划算。我现在的选型标准很简单并发度不高或map很小不用sync.Map并发度高且读占比极高才考虑它。5.4 排查sync.Map瓶颈的pprof姿势如果已经用了sync.Map但不确定性能瓶颈在哪可以按下面的思路用pprof排查go test -bench . -benchmem -cpuprofile cpu.prof go tool pprof -top cpu.prof重点关注几个信号sync.(*Map).Load的占比高但都在快速路径说明场景正常瓶颈可能在别处。sync.(*Map).mu.Lock出现大量阻塞等待说明写操作或慢路径太多。sync.(*Map).missLocked或其调用的dirtyLocked占比高说明频繁触发提升或全量复制业务和sync.Map不匹配。在goroutine火焰图里看到大量goroutine堆积在sync.(*Map).Store基本可以断定写并发是瓶颈。拿到pprof数据后如果mu.Lock等待时间占比大优先考虑换回mapMutex或分片锁而不是在sync.Map上调参——它本来就没有可调的参数只有“匹配”或“不匹配”两种结局。6. 实战避坑与个人建议6.1 没有len和Clear统计长度有代价sync.Map从设计上就没提供Len()和Clear()这类“一把梭”的操作。想统计元素个数只能Range遍历count : 0 m.Range(func(key, value any) bool { count return true })这个操作是O(N)的而且Range内部可能触发一次dirty提升代价不小。如果在高QPS接口里频繁统计长度性能会很糟糕。清空整个map也一样没有内置方法只能遍历删除或直接重建一个新的sync.Map替换引用。业务里如果确实需要len和clear通常说明你的数据规模和维护模式更适合普通map加锁。6.2 Range不是一致性快照我在生产环境踩过这个坑用Range统计缓存数量做监控结果监控数据偶尔会少几条或多几条。原因是Range遍历期间其他goroutine可以并发删除或更新entry.p导致遍历到某个key时它已经被软删除load()返回okfalse于是跳过也可能遍历到某个key时值已被更新成新版本。Range确实能看到“调用开始时已经存在”的大部分key但不保证遍历期间能看到一致的全局状态。它更像是“从某个时间点开始尽力遍历一遍当时的快照”。需要精确一致性的场景在Range前加业务层面的锁或者复制一份数据出来遍历。6.3 软删除导致的内存驻留问题前面说过Delete只是把entry.p置为nilkey依然留在read.m里。如果业务模式是“不断写入新key、偶尔删除旧key、删除后又有新key进来”长期运行后read.m里会积累大量软删除的key内存占用只增不减。更棘手的是如果业务很少触发dirty提升比如大多数读都能命中read这些软删除key可能永远得不到清理。我在一个长生命周期服务里观察过某个sync.Map在数月运行后占用的内存远超实际有效数据量就是因为大量软删除key堆积在read快照里。要避免这种情况得确认业务真的符合“只增不减”或“删除极低频”。如果删除是常态还是用普通map手动delete更可控。6.4 CompareAndSwap的快照歧义和不可比较类型panicGo 1.20给sync.Map增加了CompareAndSwap、CompareAndDelete、Swap等原子方法。这些方法很方便但有一个隐藏坑CompareAndSwap要求old值和map里的当前值可以做相等性比较。如果value是slice、map、func这类不可比较类型调用CompareAndSwap会在比较*p ! old时直接panic。另外CompareAndSwap只能判断“当前值和old相等”但它无法区分“key存在且值为old”和“key不存在”。因为key不存在时它返回falsekey存在但值等于old时才返回true。如果你需要区分这两种情况只靠返回值做不到得结合Load一起判断。用这些原子方法时还必须考虑比较的不是并发安全对象本身如果value是指针CompareAndSwap比较的是指针值是否相等而不是指针指向的内容是否相等。很多人在这上面栽过跟头以为能对*SomeStruct做深比较结果语义完全不是那么回事。6.5 到底怎么决定用不用sync.Map根据这几年的使用经验我把自己的选型判断标准总结成下面几条并发度低比如8核心以下或map规模小几百个key以内直接用普通map写多就配Mutex读多写少配RWMutex简单直观。并发度高、key集合稳定、读占比极高接近99%甚至更高可以考虑sync.Map无锁读优势明显。key只增长不删除且每个key写一次后长期读这是sync.Map最舒服的赛道闭眼用。写占比超过10%或存在频繁增删谨慎。先压测别盲从“读多写少用sync.Map”这种简化结论。有精确len、清空、一致性遍历需求尽量别用sync.Map它会让你在业务代码里做很多额外工作。最后分享一个实用技巧如果你决定用sync.Map建议把“是否该用”的判断做成一次可量化的压测而不是拍脑袋。压测里至少覆盖三档读写比例99:1、90:10、50:50再对比mapMutex和mapRWMutex。我自己每次做技术选型都会留一组这样的基准数据它比任何博客里的经验总结都更贴近你的真实业务。理解了read-copy-update的机制再看那些压测曲线你就能非常清楚地知道快快在哪里慢又是慢在哪个机制上。
返回列表