ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go并发编程:sync.Map底层原理深度解析,读多写少场景的性能利器

Go并发编程:sync.Map底层原理深度解析,读多写少场景的性能利器 经常有人问我一个问题Go 里并发访问 map 到底该怎么写很多人第一反应是 map 加一把 RWMutex也有人直接上手第三方并发 map 库。可 Go 官方在 1.9 版本里就悄悄塞了一个 sync.Map这玩意儿从出现那天起就争议不断——有人说它性能神乎其神有人说它一写就崩。你如果只把它当成一个“并发安全的 map”用那大概率会踩坑。它不是一个通用的、更快的地图容器而是一套针对“读多写少、key 集合稳定”的场景做了极致优化的特殊数据结构。这篇文章我就带你从头拆一遍 sync.Map 的底层原理包括 read/dirty 双 map 是怎么协作的、amended 标记到底在标记什么、misses 计数为什么能触发底表切换、expunged 这个诡异的状态又是怎么出现的。无论你是在准备 Golang 面试还是想搞清楚自己项目里到底该不该用 sync.Map这篇应该都能给你一个比较完整的答案。1. 为什么需要 sync.Map普通 map 在并发下的痛点1.1 原生 map 并发读写会直接 panic先说最底层的问题Go 的普通 map 在并发场景下是不安全的。不是“可能会出错”是只要并发读写同一个 map运行时会直接抛异常程序直接崩溃。m : make(map[int]int) go func() { for { m[1] 1 } }() go func() { for { _ m[1] } }()跑几秒钟就会出现类似这样的 panicfatal error: concurrent map read and map write这个 fatal error 本质上是一种“fail fast”设计。Go 的 map 底层是哈希表加上溢出桶这类结构在并发读写时两个 goroutine 可能同时在修改链表节点、桶结构完全无法保证数据完整性。与其让数据静默损坏不如直接在检测到无锁读写时抛致命错误逼你好好处理并发问题。1.2 map RWMutex 的常规方案到底差在哪最常见的工程写法是给 map 包一层读写锁type KV struct { mu sync.RWMutex m map[string]int } func (k *KV) Get(key string) (int, bool) { k.mu.RLock() defer k.mu.RUnlock() v, ok : k.m[key] return v, ok } func (k *KV) Set(key string, val int) { k.mu.Lock() defer k.mu.Unlock() k.m[key] val }这个方案当然能工作而且逻辑上非常容易理解。但在高并发的读多写少场景下它的问题也很明显每次读操作不管有没有竞争都要走一次RLock/RUnlock这是一次原子操作虽然开销很小但累积在每秒百万级的读操作上也是很可观的。更隐蔽的问题是缓存行竞争。所有 goroutine 都在操作同一个sync.RWMutex变量即使只是读锁也要频繁读写这个锁变量的内存导致 CPU 缓存行在多个核之间来回失效。这在多核机器上是一个非常大的隐藏损耗。一旦出现一次写操作所有读者全部阻塞写锁是排他的。在“读多写少”场景里这几次写操作会打断大量读操作的节奏造成明显的延迟尖刺。sync.Map 的核心目标就是让绝大多数的读操作彻底不碰锁。它的思路是把数据分成“热数据”和“冷数据”两层热数据用一个无锁的只读 map 扛读流量冷数据用一个加锁的 dirty map 兜底。1.3 官方文档给出的适用场景很多人不看官方文档凭名字和感觉用 sync.Map这是最容易出问题的地方。官方注释里其实写得很清楚两个典型场景键值对固定的情况下多个 goroutine 并发读只是偶发写。多个 goroutine 并发读写但是每个 key 基本只会写入一次之后就是纯读。换句话说sync.Map 适合的是“key 集合相对稳定、value 更新频率很低”的业务。比如配置表、路由表、标记位集合这类数据。如果你做的事是“大量 key 高频更新”那 sync.Map 大概率跑不过 map RWMutex。2. sync.Map 整体架构双 map 一锁一计数2.1 核心数据结构拆解sync.Map 的核心结构体由四个字段组成这一点在源码里非常清晰type Map struct { mu Mutex read atomic.Pointer[readOnly] dirty map[any]*entry misses int }mu一把互斥锁只在慢路径上使用。read一个原子指针指向readOnly结构体。这里用 atomic.Pointer 做原子加载保证读操作不用加锁。dirty一个普通的 map保存全量未删除的数据读写它都要先拿锁。misses计数器记录从 read 中未命中、被迫去 dirty 里查找的次数。readOnly结构体也很关键type readOnly struct { m map[any]*entry amended bool }m只读地图保存着 key 对应的 entry 指针。amended标记 read 中的 map 是否落后于 dirty。如果为 true说明 dirty 里有 read 里没有的新 key。注意这里的 read 并不是“只读不可写”而是“读的时候不加锁”。写的时候如果命中已有 key也会通过 CAS 直接更新 read 里的 entry。再看entry结构体type entry struct { p atomic.Pointer[any] }每个 key 对应的值存在一个 entry 的指针p里。这个p有三种状态指向一个正常的 value键值对存活。为 nil键已被逻辑删除但还残留在 read 里。指向一个特殊的 expunged 标记值键已从 dirty 中清除read 中的这个 key 已经“名存实亡”。2.2 为什么设计成 read/dirty 双 map你可以把 read 和 dirty 的关系理解成“CPU 缓存”和“内存”的关系或者“本地缓存”和“数据库”的关系。read 是热路径访问它走原子操作完全无锁。绝大多数业务请求在这里命中直接返回。dirty 是冷路径只有在 read 里找不到 key或者说需要新增 key 时才在锁的保护下去操作。dirty 里的数据是“完整”的它包含所有未删除的 keyread 则可能包含一些已经被标记删除的残留 key。为什么要搞这么复杂因为同步成本太高。如果每个 key 的每一次变更都要同时同步到所有 CPU 缓存那么并发度再高也会被锁和缓存一致性协议拖死。双 map 的本质是空间换时间用一份冗余数据换来读路径上“完全不加锁”的能力。amended字段负责回答一个问题read 里的 map 是不是已经过时了如果amended false说明 read 包含了所有数据dirty 为空或与 read 一致。如果amended true说明 dirty 里有 read 没有的新 key读取时万一在 read 没命中就必须去 dirty 里找。2.3 entry 的三种状态与 expunged 的由来很多初学者最懵的就是 expunged。我先直接给结论expunged 表示“这个 key 已经被删除了而且在最新的 dirty 里也不存在”。出现它的关键触发点是 dirty 的构建过程。举个例子。某个时刻 map 处于干净状态dirty 为空所有数据都在 read 里。现在你删掉了一个 key这个 key 在 read 里的 entry 指针被 CAS 成 nil表示逻辑删除。接着你又往里写一个新 key。此时发现 dirty 为空需要从 read 复制数据构建 dirty。复制过程中遍历到刚才那个被删除 entry发现它的 p 是 nil于是把它 CAS 成 expunged并且不把它复制进 dirty。为什么要这样做因为 dirty 必须保证“它包含所有未删除的 key”。一旦一个 key 被标记为 expunged就说明它已经彻底从 dirty 中剔除了。如果再对这个 key 执行写操作不能直接在 read 里 CAS必须先把 key 补到 dirty 里否则将来 dirty 提升成 read 时这个 key 就丢了。所以 expunged 是 nil 状态的一个“升级版”nil 只是逻辑删除而 expunged 是“我已经和 dirty 划清界限了想复活我必须先把它接回来”。3. 核心操作实现原理3.1 Load读操作的两条路径Load 的代码逻辑并不长但里面有一个非常经典的“快慢路径”设计。我先说整体流程先通过原子加载 read在 read.m 里查 key。如果命中了 entry直接调用 entry 的 load 方法读取值整个过程不加锁。如果 read 里没有并且amended false说明整个 map 里都没有这个 key直接返回空。如果 read 里没有但amended true说明 dirty 里可能有。这时候走慢路径加锁然后重新读一次 readdouble-check。加锁后发现 read 里还是没有就去 dirty 里找。同时把misses加一。如果misses大于等于len(dirty)触发 dirty 提升把 dirty 整个赋给 readdirty 清空amended 置为 falsemisses 归零。第二步里那个amended false的快速返回非常关键。如果 read map 是完整的没有新增 key那查不到就是真的没有没必要再去加锁翻 dirty。这一下就把绝大多数“查不到”的情况也变成了无锁操作。misses这个计数器的本质是“统计 read 的失误率”。如果连续多次都查不到需要进 dirty说明 read 的内容已经明显落后于 dirty 了继续留着这个 read 意义不大干脆在某个阈值把 dirty 提升上来。这里有个源码细节值得注意慢路径里的 double-check 是必须的。因为从第一次无锁读 read到真正加锁之间可能有其他 goroutine 触发了 dirty 提升。如果我们加锁后不重新读 read可能拿到旧的过时数据。类似的问题在 Store 和 Delete 里同样存在。func (m *Map) Load(key any) (value any, ok bool) { read : m.read.Load() e, ok : read.m[key] if ok { return e.load() } if !read.amended { return nil, false } m.mu.Lock() read m.read.Load() if e, ok read.m[key]; ok { m.mu.Unlock() return e.load() } e, ok m.dirty[key] m.missLocked() m.mu.Unlock() if ok { return e.load() } return nil, false }3.2 Store写操作的三种情况写操作是 sync.Map 里最复杂、也最容易理解偏的地方。我一直跟别人说看 Store 之前先建立一个认知sync.Map 的写操作有好几种代价完全不同的路径如果业务里写入路径不对性能会很差。第一种情况read 里已经有这个 key而且 entry 不是 expunged。这种情况最简单直接在 entry 上做 CAS 更新值整个流程无锁。这也是“更新已有 key”时 sync.Map 能保持高性能的基础。你看它的源码第一步就是尝试这个快路径。第二种情况read 里没有这个 key或者 entry 是 expunged。这时必须加锁。加锁后还会再检查一遍 read确认状态没变。如果 entry 是 expunged说明 dirty 里没有这个 key在写 dirty 之前必须先补一个“空 entry”进去然后再把值写进 dirty 和 read。第三种情况read 里完全没有这个 key。加锁如果 dirty 为空需要从 read 完整复制一份非 expunged 的 map 来构建 dirty。这一步是最昂贵的因为要对 read 里的所有 entry 做一次遍历检测。构建完之后把新 key 写进 dirty同时设置 amended true。源码里有一段逻辑可以形象地说明整个流程func (m *Map) Store(key, value any) { read : m.read.Load() if e, ok : read.m[key]; ok e.tryStore(value) { return } m.mu.Lock() read m.read.Load() 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 m.dirty nil { m.dirtyLocked() } m.dirty[key] newEntry(value) } m.mu.Unlock() }这个tryStore就是第一种情况的入口它内部做了一次 CAS如果 entry 是正常的CAS 替换 value 即可。tryStore在两种情况下会失败entry 是 nil或者 entry 是 expunged。前者简单后者麻烦。dirty 为空时需要调dirtyLocked从 read 构建 dirty。dirtyLocked内部有一个tryExpungeLocked会把所有 p 为 nil 的 entry 标记成 expunged同时不复制进 dirty。这也是前面说的 expunged 的来源。3.3 Delete 与 LoadAndDeleteGo 1.15 之后Delete 的底层实现改成了调用 LoadAndDelete。LoadAndDelete 做的事很像 Load只不过在找到之后不是返回而是把 entry 的 p CAS 成 nil。快路径逻辑在 read 里找到这个 key直接调用tryDelete把 entry 的值替换成 nil无锁完成逻辑删除。这一步只改内存指针不会动 dirty。慢路径逻辑如果 read 里没找到并且 amended 为 true加锁。double-check 之后如果 read 里仍然没有就到 dirty 里去删除这个 key。这里要特别注意Delete 在快路径中并不会把 key 从 read.m 里真正删掉只是把 entry.p 置为 nil。这样做的好处是后续对这个 key 的读操作依然能在 read 里命中 entry只是 load 到 nil 后返回“不存在”。这个“逻辑删除”的设计避免了在热读路径上频繁操作 map 结构。3.4 Range遍历时的一致性处理Range 的官方签名是func (m *Map) Range(f func(key, value any) bool)如果调用Range时amended true说明 read 不是全量数据dirty 里还有新 key。Range 会先加锁把 dirty 提升为 read清空 dirty重置 misses然后再遍历 read。这样遍历看到的就是发布时刻的完整快照。之后遍历的是 read.m完全无锁。遍历过程中其他 goroutine 新写入的 key 不会出现在这次 Range 结果里。也就是说Range 不保证严格实时但保证某个时刻的“一致性快照”。在实际项目里这种“快照式遍历”通常够用了。如果你每次遍历都要求精确到纳秒级的最新数据任何无锁结构都做不到只能加全局锁。4. 性能与适用场景分析4.1 什么时候该用 sync.Map根据我自己的工程经验sync.Map 在下面这些场景里会明显优于 map RWMutex配置型数据配置项集合相对固定启动加载后基本不更新运行时大量协程并发读取。这种场景下 read 几乎恒等于全量数据所有读都无锁命中不存在 misses也基本不触底。连接池或资源管理器中的映射表key 是固定资源标识value 是对应的连接对象、句柄等。key 只创建一次之后全是读。需要频繁遍历的并发数据Range 在遍历时是无锁的这一点在很多场景里非常香。用 RWMutex 方案时遍历会被写操作阻塞而 sync.Map 的遍历走 read 快照不会被读流量影响。写多读少、key 集合频繁变动的场景就别硬用 sync.Map 了。比如一个高频计数器、一个不断插入新 key 的缓存sync.Map 的性能通常不如加锁的普通 map。4.2 什么时候不该用 sync.Map我踩过的最大的坑就是把这个 map 用在了一个“高频写新 key”的缓存场景里。当时数据量大概几十万每秒新增几千个 key同时有不少读。结果 benchmark 一跑sync.Map 反而比 RWMutex map 慢了一截。原因很清楚每次写一个新 key如果 dirty 已经存在需要加锁写 dirty。所有读新 key 的操作都要先过 read 未命中、再加锁、再查 dirtymisses 不断增长。misses 涨到 threshold 时触发一次整体提升一次提升复制全量 dirty 到 read几十万 key 的复制开销非常大还会 STW 一样阻塞后续操作一小段时间。提升后 read 变成了全量数据但如果业务还在持续插入新 key很快 dirty 又有大量新 keymisses 又开始累积再次提升……循环往复性能被提升过程反复拖累。所以我把这类场景归纳成三个“不适合”写多读少的场景。写操作无论快慢路径都要比读重得多。一个 key 被高频更新。虽然 CAS 更新无锁但在高并发下多个 goroutine 同时 CAS 同一个 key大量线程会自旋重试反而比一把大锁更糟。数据量非常大、且持续新增 key 的场景。dirty 的反复构建和提升是巨大的性能黑洞。4.3 与 RWMutex map、第三方并发 map 的对比我整理了一个表格方便你直观对比几种方案的定位方案读并发写并发适用场景主要瓶颈原生 map不支持并发不支持单 goroutine 或外部全局串行并发直接 fatalmap RWMutex高共享读锁低独占写锁通用、写读均匀、数据量可控每次读都有锁竞争和缓存行开销sync.Map极高无锁快路径中CAS 或短锁读多写少、key 稳定、反复遍历dirty 构建与提升、写新 key 慢分片锁 map如 orcaman/concurrent-map高分片锁并行高分散到不同分片大型缓存、key 分布均匀分片数固定极端热点有瓶颈4.4 sync.Map 与标准 map 的性能差距直观感受我做过一个简单的 Benchmark一万个 goroutine 并发读同一个配置表key 数量一千基本不写。正常情况下sync.Map的读耗时只有RWMutex map的几分之一尤其在多核机器上差距会被放大。但如果反过来8000 goroutine 写、2000 goroutine 读key 数量十万且持续新增RWMutex map往往会反超。因为 sync.Map 的写路径需要处理 dirty 构建、amended 维护、misses 提升而 RWMutex 方案一次 Lock / Unlock 的代价非常固定。所以选择方案前最好先量化自己的“读:写”比例和“key 新增频次”。如果自己心里没数就写一个小的 benchmark 跑一把别靠感觉。5. 常见问题与面试题实战5.1 面试高频问题sync.Map 快在哪、慢在哪、为什么准备 Golang 面试的人几乎都会被问到 sync.Map。我把几个高频问题统一整理一下问为什么 sync.Map 在读多写少时性能好答因为它把读路径拆分成了“无锁快路径”和“加锁慢路径”。绝大多数读操作直接在原子加载的 read map 里命中全程没有 Mutex 参与也没有锁变量的缓存行竞争。只有当 read 未命中且需要查 dirty 时才会加锁。问misses 计数的作用是什么答misses 统计的是“read 未命中、需要查 dirty”的次数。当这个值达到 dirty 的长度时说明 read 的命中率已经太低继续保留旧 read 没有意义于是触发 dirty 提升让 read 和 dirty 重新同步。它可以防止 read 长时间滞后导致每次读都走慢路径。问expunged 状态是怎么产生的答当 dirty 为空、需要从 read 构建新 dirty 时遍历 read 发现某些 entry 已经被标记为 nil逻辑删除于是把这些 nil 的 entry 标记成 expunged 并跳过不复制到 dirty。这样 dirty 就只包含真正存活的 key。以后如果想复活一个 expunged 的 key必须先把它补回 dirty。问Range 遍历能保证看到最新的数据吗答Range 在开始时会检查 amended 标记。如果 dirty 里还有 read 没有的新 key会先加锁提升 dirty再遍历 read。所以 Range 看到的是启动时的一个一致性快照遍历期间其他 goroutine 的写入不会出现在本次 Range 结果中。问sync.Map 的锁粒度小吗答不能说小应该说“省”。大多数读不碰锁写已有 key 用 CAS只有写新 key 或 expunged key 或触发提升时才需要一把全局锁。所以它并不是把锁拆细了而是让锁尽量不出现在关键路径上。5.2 我自己踩过的坑聊几个真实踩坑经验有的坑光看文档真发现不了。第一个坑是复制 Map。sync.Map 是严禁复制使用的因为里面有 Mutex 和内部状态。如果把整个 Map 作为值传递Go 编译器并不会报错但在运行时可能出现各种诡异行为。后来我在代码里加了go vet它会对复制锁的场景给出提示这个必须养成习惯。第二个坑是在 Store 的时候传 nil value。sync.Map 的 entry 内部用 nil 和 expunged 表示已删除状态所以官方明确要求 value 不能为 nil。我当时从接口里传入一个 nil 的指针结果运行时报 panic。这个问题排查起来不是特别直观因为它们会被当成“已删除”处理而不是普通值。第三个坑是误把 sync.Map 背在路由表这种“key 高频变化”的数据上。当时是从一个旧项目迁移表里几十万条规则每次规则变更都会全量更新几十个 key导致 miss 提升频繁发生。上线后 QPS 高峰期偶尔有几十毫秒延迟抖动。最终换成分片锁才解决。这提醒我选型的第一步永远是先分析自己的读写模型。第四个坑和 Range 的使用有关。我曾在遍历时尝试对其他键做 Store以为会即时反映到本轮遍历结果里。实际上 Range 是基于快照的这样的更新不会被看到。如果业务上要求“边遍历边修改且过程可见”那 sync.Map 根本不合适应该用带锁的 map 自己控制临界区。5.3 快速判断一个项目该不该用 sync.Map我现在判断一个项目是否使用 sync.Map一般走三个问题你的并发读压力大不大如果读不到一定量级比如 QPS 只有几万那 sync.Map 的收益不明显直接用 RWMutex 更简单。你的 key 集合是稳定的吗如果 key 会高频新增或者数据量在持续快速增长dirty 构建和提升会成为新的瓶颈。你的写入频率高不高如果一个 key 会被高频更新使用 CAS 会让大量 goroutine 自旋性能未必比锁好。这三个问题答完基本上选型就清楚了。如果还是不确定那就写个 benchmark用真实数据结构、真实读写比例跑一把用数据说话。6. 从源码视角看一些容易被忽略的优化细节6.1 为什么 read 要设计成 atomic.Pointer 而不是直接 map很多人会问既然 read 在快路径上只是读 map直接用字段不就行了为什么要套一层 atomic 指针核心原因是 read 这个 map 本身需要被整个替换。在 dirty 提升时我们要把一整张 dirty map 直接“换”成新的 read。如果 read 是普通字段那么提升操作必须加锁保护否则读线程会看到不一致的状态。而把 read 设计成 atomic.Pointer提升时只需要 CAS 一次指针读线程无锁也能拿到最新的一整张快照。这个设计本质上是用“指针的原子替换”替代了“复杂结构的读锁”。而且 atomic.Pointer 在 Go 1.19 之后有了更明确的泛型 API类型安全加载和存储成本也足够低比直接用atomic.Value存 map 更优雅。6.2 为什么 Store 里要先 unexpungeLocked 再写 dirty源码里出现了一个叫unexpungeLocked的方法。它做一件事把 entry.p 从 expunged CAS 回 nil。这一步的意义在于一个 expunged entry 已经从 dirty 中剔除了如果我们要覆盖这个 key 的新值必须让这个 entry 重新回到 dirty 的可管理范围。具体流程是先unexpungeLocked把状态从 expunged 改回 nil然后m.dirty[key] e把 entry 重新放回 dirty map最后storeLocked写入真正的 value。在这里dirty 里此时可能还没有这个 key所以必须显式补上一个引用。如果忘了这一步将来 dirty 提升为 read 时这个 key 就不会出现在新 read 中数据就丢了。这是一个非常典型的“状态机”设计每个状态对应了不同的处理策略理解了这个状态转移整个 sync.Map 的写路径就通了。6.3 misses 计数里的一个小陷阱misses 累加的逻辑在missLocked里只有加锁后走到慢路径才累加。快路径命中是不会累计的。这一点看似自然但在理解性能问题时非常关键如果业务中大量读取的 key 都恰好是新增到 dirty 里的新 key那么 miss 数会很快增长频繁触发提升。反之如果业务里 99% 的读都命中 readmiss 数增长极慢可能跑很久都不会提升一次。所以 sync.Map 的实际性能和你业务中的 key 热分布关系极大。不是“用了 sync.Map 就一定快”而是“读的 key 恰好大多在 read 层才快”。6.4 Range 之前的提升真的必要吗我见过有人问Range 为什么不直接用 dirty 遍历非要先提升到 read因为 dirty 在遍历过程中可能被写入、删除直接遍历 dirty 需要持续锁保护还要处理高并发下的一致性非常复杂。而提升成 read 后用一次原子指针替换就获得了一个不可变快照。后续遍历完全无锁也不会受到写入干扰。这个设计的代价是如果 dirty 很大提升操作要复制整个 map会带来一次不小的暂停。所以在“数据量很大且 frequent 触发 Range”的场景里即使读多写少也可能因为 Range 的复制产生性能问题。常见做法是限制 Range 频率或者直接把超大 map 改成多个分片 Map。7. 最后的实操建议硬啃源码是一回事真正能在工程里用好是另一回事。根据我的经验用 sync.Map 时心里要始终记着几条底线第一不要迷信“并发就上 sync.Map”。先用最直接的 map Mutex 或 RWMutex 顶住等你真的用 pprof 跑出了锁竞争热点再考虑换成 sync.Map而不是一上来就用。第二做好 Benchmark而且要用贴近生产的读写比例来测。很多人只测纯读结论自然漂亮可一旦混入写新 key情况完全不同。第三在代码评审里明确注释你用 sync.Map 的原因避免后来的同事看不懂把 sync.Map 当成普通并发容器乱换。还有一个我自己的小习惯当读到别人的代码里出现 sync.Map 时我会首先去看它的写路径频繁不频繁以及 key 集合到底是不是固定的。如果这两个问题答不上来多半就是拍脑袋选型后续是要还债的。sync.Map 这套双 map 加状态机的设计本身就非常值得学习。即使你最终项目里不用它把 read/dirty 的分级思想、misses 的反馈式换底、entry 状态机的流转逻辑吃透对写其他并发组件也大有帮助。它确实是“读多写少”场景下的一把好手但也只是众多并发武器里的其中一个选对场景它才能发挥出真正的价值。
返回列表