跳到主要内容
约 13 分钟5 张卡

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 原文 · 免费阅读,引用请保留出处。内容使用说明