sync.Map
sync.Map 是 Go 标准库提供的并发键值映射,多个 goroutine 可以直接调用它的读取、写入和删除方法。它保护的是映射操作,不会自动保护取出的对象,也不会把连续调用的几个方法合并成事务。
面试中的实现问题,需要从这个边界继续解释:哪些访问可以走原子操作,哪些访问仍要加锁,以及为什么某种访问模式会承担额外成本。本文以 Go 1.22.12 的 API 和 read、dirty 实现为准;普通 map 的类型和哈希规则见 Go map。
1. 面试核心
先记住这几条,再读正文sync.Map的零值可直接使用,键和值通过any传递。键的动态值仍须可比较,取值时仍需明确类型;首次使用后不能复制整个 sync.Map。- 单次映射操作有并发保证,Load 后再 Store 仍是两个操作,不能自动完成原子递增或跨键更新。取出指针后修改对象,也不受容器自动保护。卡 · 读改写与条件更新
LoadOrStore在键不存在时存入给定值。它不保证构造这个值的函数只执行一次;函数实参会先求值,多个调用者可能各自完成构造。卡 · LoadOrStore 不负责只构造一次Swap、LoadAndDelete与条件更新分别表达不同的单键操作。CompareAndSwap 和 CompareAndDelete 比较当前值与 old,old 必须可比较;键不存在时不会凭比较操作创建它。卡 · 按操作目的选择方法Range可以与其他映射操作并发,回调也可以调用映射方法。它不提供某一时刻的一致快照,也不保证顺序;回调返回 false 只是停止本次遍历。卡 · Range- Go 1.22.12 的
read保存结构稳定的键到条目映射,dirty维护需要锁协调的键集合。两者可以指向同一个 entry,值通过原子指针变化;read 并不表示其中的值永远不变。卡 · Go 1.22.12 的实现 - 命中、补查、加入新键、删除和提升走的路径不同。键写入后反复读取、不同 goroutine 主要访问不同键集合,是评估
sync.Map的起点;性能还取决于实际访问和竞争,不能承诺比加锁 map 恒快。
2. 类型与单次操作
2.1 零值、键和值
声明 var m sync.Map 后即可使用,不需要 make。它不像 map[string]int 那样在类型中规定键值类型,接口通常需要由应用统一约定,例如所有键都用字符串、所有值都用同一种记录类型。
键按相等性匹配,切片、map 和函数等不可比较的动态值不能作为合法键。int(1) 与 int64(1) 是不同的接口值,也会成为不同键。类型转换和断言失败不会因为容器支持并发就消失。
值可以是 nil、切片或指针。Load 返回的 ok 表示键是否存在,因此保存了 nil 时可以得到 nil, true,不能只检查返回值是不是 nil。值中的共享引用没有被递归复制;这一点与 Go map 的值复制一致。
2.2 按操作目的选择方法
需要一个不可分割的单键操作时,应选择表达该操作的方法,而不是先查询状态再自行拼接:
| 目的 | 方法 | 返回结果与边界 |
|---|---|---|
| 查询当前值 | Load(key) | 返回值与是否存在 |
| 设置或覆盖值 | Store(key, value) | 不返回旧值 |
| 仅在不存在时存入 | LoadOrStore(key, value) | 返回实际值;loaded 为 true 表示读到已有值,为 false 表示本次存入 |
| 替换并取得旧值 | Swap(key, value) | 返回旧值及旧键是否存在;原来不存在时也会存入新值 |
| 取得并删除 | LoadAndDelete(key) | 返回删除前的值及是否存在 |
| 无条件删除 | Delete(key) | 键不存在时不改变内容 |
| 当前值匹配才替换 | CompareAndSwap(key, old, new) | 返回是否替换成功,不创建缺失键 |
| 当前值匹配才删除 | CompareAndDelete(key, old) | 返回是否删除成功,缺失键返回 false |
loaded 不是统一的“操作成功”字段。LoadOrStore 返回 false 反而表示这次调用完成了存入;Swap 返回 false 表示此前没有值,不代表新值没存进去。
按照 API 的内存同步约定,映射写操作与观察到这次写入效果的读操作建立同步关系。例如先初始化一个对象,再把它 Store 进去,其他 goroutine 经 Load 取得这个对象时,可以使用已经发布的初始化状态。对象被取出之后发生的新修改,仍需要另行同步。
3. 原子操作与业务约束
3.1 LoadOrStore 不负责只构造一次
LoadOrStore 可以避免把“检查不存在”和“存入”拆成两步,但传给它的值已经由调用方构造完成。
package main
import (
"fmt"
"sync"
)
func build() string {
fmt.Println("build")
return "ready"
}
func main() {
var m sync.Map
for i := 0; i < 2; i++ {
actual, loaded := m.LoadOrStore("config", build())
fmt.Println(actual, loaded)
}
}
输出依次是 build、ready false、build、ready true。第二次虽然没有替换映射,build() 仍会先执行。
多个调用者竞争一个初始不存在、期间不被删除的键时,只会有一个完成这次建立,其余取得已有值。但它们仍可能各自创建连接、读取文件或计算结果;未被采用的资源也要由调用方处理。
需要只初始化一次时,可以让映射保存一个具有明确初始化协议的对象,再由取得同一对象的调用者协调初始化。sync.Once 的完成和失败边界见 Go 同步原语。不要在映射里放入半成品后,就让其他调用者直接使用尚未完成的字段。
3.2 读改写与条件更新
两个 goroutine 都先 Load 到计数 0,再各自 Store 1,最终只得到 1。两个方法分别安全,并没有把整个递增变成原子操作。
对一个独立整数计数,可以使用比较并交换循环:先读取旧值,尝试仅在当前值仍等于旧值时更新;失败就重新读取。下面约定这个映射只保存整数计数,示例期间不删除键,也不混入其他类型:
package main
import (
"fmt"
"sync"
)
func increment(m *sync.Map, key string) {
for {
old, ok := m.Load(key)
if !ok {
actual, loaded := m.LoadOrStore(key, 1)
if !loaded {
return
}
old = actual
}
if m.CompareAndSwap(key, old, old.(int)+1) {
return
}
}
}
func main() {
var counts sync.Map
var wg sync.WaitGroup
for worker := 0; worker < 4; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
increment(&counts, "requests")
}
}()
}
wg.Wait()
value, _ := counts.Load("requests")
fmt.Println(value)
}
输出 4000。首次建立计数与已有值上的条件递增分别使用对应方法,失败后重试。这个例子只验证有限次整数递增,不处理整数溢出,也不承诺竞争下某个调用者一定先完成;若只需要一个固定计数器,直接选择合适的原子计数类型或锁通常更容易表达目的。
CompareAndSwap 和 CompareAndDelete 的 old 必须是可比较的值。存进去的切片本身可以通过 Load 取回,却不能直接拿切片内容作为合法的 old;触发这种接口等值比较时会 panic。新值不必可比较,但后续若要继续条件比较,就要考虑值的表示方式。
比较关注的是调用时的当前值,不记录所有历史。值从 A 变成 B 又变回 A 后,期待 A 的操作仍可能成功;如果业务需要识别“期间曾被修改”,可以比较带版本的不可变记录,不能只比较最终内容。
3.3 跨键状态与值内部修改
单个键上的比较更新不能保护多键约束。例如把余额从键 A 转移到键 B,分两次更新时,其他调用者可能在中间读到只完成一半的状态。各自使用 CompareAndSwap 也不会自动把两次成功组合成事务。
需要共同成立的条件,可以集中到一个不可变记录并整体替换,或用所有相关访问共同遵守的锁保护。若本来就有多个字段和复杂不变量,普通类型化 map 配合锁往往更直接,见 Go map 的完整操作保护。
值内部的状态是另一个同步范围。假设映射保存 *User,两个 goroutine 都 Load 出同一个指针,再执行 user.Score++,仍然会竞争同一个字段。容器只保证取指针的映射操作,不会在后续解引用时替你加锁。
可以让 User 自己提供受锁保护的方法,也可以保存不再修改的记录、通过条件替换更新映射。即便某个键已经删除,之前取出的指针仍可能被使用;删除映射不是撤销所有旧引用的权限。
4. Range
Range 顺序调用回调访问映射,回调返回 false 时结束本次遍历。它不会为了完整遍历而阻止其他方法,回调本身也可以调用同一个 sync.Map 的方法。
它不提供一致快照:同一个键在一次遍历中至多访问一次,但不同键的值可能来自遍历期间不同的状态。遍历顺序也没有保证,不能据此生成稳定排序结果或精确的并发时刻总数。
下面利用回调中的更新,在 Go 1.22.12 中观察到一个不曾作为整体存在过的结果组合:
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
m.Store("a", 0)
m.Store("b", 0)
firstKey := ""
firstValue, secondValue := 0, 0
m.Range(func(key, value any) bool {
if firstKey == "" {
firstKey = key.(string)
firstValue = value.(int)
m.Store(firstKey, 1)
other := "a"
if firstKey == "a" {
other = "b"
}
m.Store(other, 1)
} else {
secondValue = value.(int)
}
return true
})
fmt.Println(firstValue, secondValue)
}
输出 0 1,这里按“先访问的键、后访问的键”描述结果,不假定先访问 a 还是 b。映射整体先是 (0, 0),再变成 (1, 0),最后为 (1, 1);回调组合出的 (0, 1) 并不是其中任何一个整体状态。
这个固定版本例子说明一致快照不能依赖 Range,并不是规定并发遍历一定得到哪种新旧组合。需要一致快照时,应设计所有修改都遵守的外部同步,或整体发布不可变快照;只给 Range 自己加一把其他写入者不使用的锁,没有作用。
API 还允许 Range 在回调很早返回 false 时仍承担与条目规模相关的工作。不要把“只处理第一个回调”当作恒定成本,也不要通过反复遍历推断容器在某个瞬间的精确长度。
5. Go 1.22.12 的实现
5.1 read、dirty 与共享条目
该版本使用两个键到条目的映射,以及条目内部的原子值指针。需要区分“映射里有哪些键”和“某个条目的当前值”这两层变化。
| 组成 | 作用 |
|---|---|
read | 原子发布的 readOnly,包含结构稳定的键到 entry 映射 |
read.amended | 表示 dirty 含有尚未进入 read 的键 |
dirty | 在互斥锁下访问的映射,包含新键和 read 中未被排除的旧条目 |
entry.p | 原子指针,表示条目的当前值或删除状态 |
mu | 保护需要变更或补查的映射结构 |
misses | 部分慢路径查询的累计次数,用于决定提升 dirty |
read 和 dirty 中相同的键可以指向同一个 entry,不是两份互不相关的业务值。已发布的 read 不直接增删键,但对应 entry.p 可以原子替换,所以“只读映射”不能理解成“其中的值不可修改”。
dirty 也不只是“最近写入的新数据”。为了能够直接提升为新的 read,它还要包括未被排除的旧条目;建立它时复制的是条目引用,不是深拷贝每个业务对象。
5.2 查询、新键与提升
Load 首先读取 read 并查找键。已经找到条目时,可以读取条目的原子指针;如果没有找到且 amended 为 false,就能直接判断该键不存在,不必再取锁补查。
如果没找到且 amended 为 true,就需要取得 mu,重新检查当前 read,必要时查询 dirty。重新检查是因为等锁期间可能已经发生提升,不应继续使用过时判断。
写入已有且未被排除的 entry,可以通过原子替换走快速路径;因此“所有写入都会加锁并进入 dirty”是不准确的。真正加入新键时,需要锁下维护 dirty。若还没有 dirty,会从 read 浅拷贝仍可用的条目引用,并设置 amended。
部分需要补查的慢路径会增加 misses。Go 1.22.12 在 misses 达到当前 dirty 的条目数时,把 dirty 提升为新的 read,清空 dirty 字段并重置计数;Range 发现 amended 时也可以直接触发提升。
提升主要是切换到已有映射,并不重新深拷贝全部业务值。下一次需要容纳新键时,才可能再次建立 dirty。这里的成本分布解释了为什么只看“读多还是写多”还不够:新键加入、失配查询、已有键覆盖和遍历可能走不同路径。
5.3 删除状态与重新写入
为了让已经发布的 read 保持结构稳定,删除不一定立即移除其中的键。条目指针有三类状态:
entry.p 状态 | 含义 |
|---|---|
| 指向一个接口值 | 当前存在的值;接口值本身也可以是 nil |
| nil 指针 | 条目在逻辑上已删除 |
expunged 标记 | 已删除,而且建立 dirty 时被排除在外 |
逻辑删除的条目还可能暂留在 dirty 中,不能把“当前没有值”直接等同于“已经从两个映射中移除”。
保存 nil 值时,指针仍然指向“内容为 nil 的接口值”;这与用于表示删除的 nil 指针不同,所以 Load 能区分 nil, true 和 nil, false。
后续建立 dirty 时,可以把已经逻辑删除的旧条目标为 expunged,不再复制进去。若又给这个键写值,需要在锁下解除该标记、把条目补入 dirty,再写入新值。否则未来提升 dirty 时就可能漏掉已经重新出现的键。
这些状态协调的是不同映射视图和并发条目操作,不是数据库事务日志,也不是普通 map 的扩容标记。解释到查询、删除、恢复和提升能够连起来即可,不需要把内部字段作为业务代码依赖。
6. 访问模式与选择
官方实现主要针对两类模式优化:键写入后反复读取;不同 goroutine 主要读写不同的键集合。它们有机会减少共享锁竞争,但是否获益仍取决于键集合、更新方式、命中率和业务操作。
| 工作负载特征 | 需要关注的成本或约束 |
|---|---|
| 已有键反复读取 | 是否能持续命中 read,以及值是否保持只读 |
| 不同 goroutine 覆盖不同键 | 条目竞争较少,但仍要考虑新增键和结构维护 |
| 高频增加、删除新键 | dirty 建立、锁竞争、删除状态和提升成本 |
| 多个 goroutine 争同一个热键 | 条目上的原子重试也可能竞争,容器不会消除热点 |
| 需要跨键一致性或强类型接口 | 普通 map 与统一锁协议可能更容易正确维护 |
“写多读少就一定不能用”和“读多就一定更快”都过于简单。覆盖稳定键集合与持续引入新键,并不是同一种写入负载;选型应先确认需要的原子范围,再用实际访问模式测量。已有比较题可在 Go map中自检。
sync.Map 首次使用后不能复制,包含它的结构体也要避免值接收者或按值传参。它不是内置 map 的赋值别名机制;复制后内部锁、原子字段与共享条目之间的关系不再符合使用契约。go vet 可以帮助发现部分复制,但不能把没出现诊断当作许可。
7 · 面试题卡片
本页题目与参考回答
7.1 sync.Map 怎样实现并发安全,read 和 dirty 各自负责什么?
考察重点:映射结构、原子条目、慢路径补查、删除与提升。
相关内容:Go 1.22.12 的实现、访问模式与选择。
参考回答
Go 1.22.12 用结构稳定的 read、锁保护的 dirty 和条目内部的原子指针配合。 命中已有条目时可以走原子访问;read 未命中且 dirty 可能含新键时,需要锁下重新检查和补查。
read 与 dirty 可以共享 entry,已有值的更新并非全部需要全局锁。新增键可能建立 dirty,慢路径累计及 Range 可以触发提升;删除还用 nil 与 expunged 区分逻辑删除和已从 dirty 排除的条目。
它针对特定访问模式减少竞争,不保护跨键事务或值内部的任意修改,也不保证所有负载都快于加锁 map。
7.2 LoadOrStore 能保证一个键对应的构造函数只执行一次吗?
考察重点:原子建立映射、实参求值、初始化和资源责任。
相关内容:LoadOrStore 不负责只构造一次。
参考回答
不能,它原子选择已有值或存入给定值,但给定值在调用前就已经构造。 LoadOrStore(key, build()) 即使读到已有值,也会先执行 build。
多个调用者可能各自完成构造,未采用的资源还要清理。需要共同等待一次初始化时,要为实际共享的对象设计初始化与失败协议,不能只把半成品放进映射就认为完成。
7.3 Swap、LoadAndDelete 和比较更新有什么区别,old 有什么要求?
考察重点:替换、删除、当前值比较、缺失键和动态类型。
参考回答
Swap 无条件存入新值并返回旧值,LoadAndDelete 原子取得并删除;CompareAndSwap 和 CompareAndDelete 只有当前值等于 old 时才修改。 比较操作不创建不存在的键。
old 必须可比较,不能把切片等直接用作合法的比较期待值。比较只核对当前内容,不证明它从未发生过变化;需要识别历史变化时,应把版本纳入记录。
7.4 sync.Map 的方法安全,能否据此保护递增、转账和取出对象后的字段修改?
考察重点:单次操作、读改写、跨键不变量与共享对象。
相关内容:读改写与条件更新、跨键状态与值内部修改。
参考回答
不能直接推出这些业务操作安全。 Load 后 Store 可能丢更新;对两个键分别操作不构成事务;Load 出同一指针后,多个调用者修改字段仍可能发生数据竞争。
独立单键更新可使用适当的原子方法;跨键关系可以整体替换记录,或让所有访问遵守同一锁协议;值内部也要明确不可变、所有权或自身同步规则。
7.5 Range 是一致快照吗,能否在回调中更新同一个 sync.Map?
考察重点:遍历期间变化、单键访问次数、回调和整体一致性。
相关内容:Range。
参考回答
Range 不是一致快照,回调可以调用同一个映射的方法。 一次遍历不会重复访问同一个键,但各键观察到的值可能来自遍历期间不同状态,顺序也没有保证。
回调返回 false 会停止本次遍历,不承诺整个调用因此只有恒定成本。需要一致快照时,必须让相关修改共同遵守外部同步,或整体发布不可变状态。
8. 参考资料
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明