map并发安全与哈希
# 11.map并发安全与哈希
卷三第十一篇——Go map 不提供内置并发安全,这是故意的设计选择。本篇不是重复 06 篇的
hmap/bmap结构(那篇已经讲透了),而是聚焦并发安全的三种方案:Mutex+map最简单、sync.Map最精巧、分段锁自建 map 最灵活。读完本篇,你知道什么场景用什么方案,以及为什么sync.Map的高频写入性能可能不如Mutex+map。关键词:sync.Map读写分离、read/dirty双 map、misses阈值提升、分段锁、并发检测 flags。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 内置 map 的并发检测机制
- 4. sync.Map 源码精解
- 5. sync.Map 的完整状态机
- 6. 分段锁自建并发 map
- 7. 三种方案的性能基准
- 8. sync.Map 典型陷阱
- 9. 并发 map 的选型决策树
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一个实时推荐服务——它用 sync.Map 缓存用户画像数据,读多写少——每分钟 100 万次读,1000 次写(用户行为更新):
package main
import (
"sync"
"fmt"
"time"
)
type UserProfile struct {
UserID int64
Interests []string
Score float64
}
var userProfiles sync.Map // userID → *UserProfile
func getProfile(userID int64) *UserProfile {
v, ok := userProfiles.Load(userID)
if !ok {
return nil
}
return v.(*UserProfile)
}
func updateProfile(userID int64, score float64) {
v, ok := userProfiles.Load(userID)
if ok {
// 更新已有 profile
p := v.(*UserProfile)
p.Score = score // ← BUG:data race!
p.Interests = append(p.Interests, "new") // ← BUG:切片的并发写!
} else {
// 新用户
userProfiles.Store(userID, &UserProfile{
UserID: userID,
Interests: []string{"default"},
Score: score,
})
}
}
func refreshCache() {
// 每分钟:全量刷新缓存——从数据库重新加载
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
userProfiles.Range(func(key, value interface{}) bool {
userProfiles.Delete(key) // ← BUG:Range 中 Delete 是安全的——Go 1.20+
return true
})
// 重新加载...
}
}
func main() {
// 100 个 goroutine 读
for i := 0; i < 100; i++ {
go func() {
for {
getProfile(int64(time.Now().UnixNano() % 10000))
}
}()
}
// 5 个 goroutine 写
for i := 0; i < 5; i++ {
go func() {
for {
updateProfile(int64(time.Now().UnixNano()%10000), 0.95)
}
}()
}
time.Sleep(10 * time.Minute)
fmt.Println("done")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
现象:服务上线后一切正常——sync.Map 的 Load 无锁读取,QPS 极高。两周后,运维发现偶发性数据错乱:某个用户的 Interests 字段变成了 ["default", "new", "new", "new"...](长度远超预期,说明并发 append 导致了内存覆盖)。同时在 go run -race 下捕获到:
WARNING: DATA RACE
Read at 0x00c0001b4000 by goroutine 7:
main.updateProfile()
/app/main.go:27
Previous write at 0x00c0001b4000 by goroutine 12:
main.updateProfile()
/app/main.go:27
2
3
4
5
6
7
8
# 1.2 顺藤摸到根因
根因:sync.Map 保护的是键值对本身的并发安全(Store/Load/Delete),但不保护值的内部字段。p.Score = score 和 p.Interests = append(...) 绕过了 sync.Map 的保护——两个 goroutine 同时执行 updateProfile 修改同一个 *UserProfile 的内部状态,产生 data race。
sync.Map 不是"把整个 value 变成线程安全的"——它只是让 key-value 映射本身在并发操作下不崩溃。
更隐蔽的是 updateProfile 中的 Load + 修改 + Store 不是原子的——读-改-写窗口中被另一个 goroutine 抢入:
Goroutine A: Load(id) → Profile{Score: 0.5}
Goroutine B: Load(id) → Profile{Score: 0.5}
A: p.Score = 0.7
B: p.Score = 0.9
→ 最终 Score 是 0.9?还是 0.7?不确定——取决于内存序
2
3
4
5
# 1.3 我们要回答什么
这个事故藏着至少 7 个原理点:
① Go 内置 map 的并发检测 flags 是怎么工作的?为什么会有漏检? → 第 3 章
② sync.Map 的 read/dirty 双 map 结构怎么实现读写分离? → 第 4 章
③ sync.Map 的 misses 计数如何触发 dirty→read 提升?阈值是多少? → 第 4.4
④ sync.Map.Load 为什么是无锁的?无锁是"真的无锁"还是"原子操作"? → 第 4.3
⑤ sync.Map 的 Store 什么时候走 CAS(无锁),什么时候走 Lock? → 第 4.2
⑥ 分段锁的自建并发 map 怎么设计?分片数如何选择? → 第 6 章
⑦ sync.Map vs Mutex+map vs 分段锁——各场景下性能差异多大? → 第 7 章
2
3
4
5
6
7
本篇路线:
三种方案全景 (第 2 章) ── Mutex+map / sync.Map / 分段锁
↓
内置 map 并发检测 (第 3 章) ── flags + hashWriting + 漏检场景
↓
sync.Map 源码 (第 4 章) ── read/dirty + Store/Load/Delete 全路径
↓
sync.Map 状态机 (第 5 章) ── 四态转换
↓
分段锁方案 (第 6 章) ── 分片数选择 + 扩容缩容
↓
性能基准与陷阱 (第 7-8 章) ── 适用边界 + 常见误用
↓
综合案例 (第 10 章) ── 完整修复 + 选型决策树 + 设计哲学
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:这是 Go「并发编程」主题的第三篇——上接 sync 包的 Mutex/RWMutex/Once 和 channel 的 CSP 模型。本篇回答一个实际问题:"多 goroutine 读写 map,到底用哪种方案?"06 篇已经讲透了 map 的内部结构(hmap/bmap/tophash/扩容),本篇聚焦并发安全——不再重复内部结构。
# 2. 架构概览
# 2.1 并发 map 的三种方案全景
┌─────────────────────────────────────────────────────────────────┐
│ 方案 1: Mutex + map │
│ map[T]V │
│ mu sync.Mutex (或 sync.RWMutex) │
│ → 最简单——程序员管理锁粒度 │
│ → 读多写少用 RWMutex │
│ │
│ 方案 2: sync.Map │
│ read atomic.Pointer[readOnly] ← 无锁读 map │
│ dirty map[any]*entry ← 有锁写 map(需要 mu) │
│ misses int ← 读 miss 计数 │
│ mu sync.Mutex ← 写锁 │
│ → 读多写少 + key 集合稳定 → 最优 │
│ │
│ 方案 3: 分段锁(sharded map) │
│ shards []*Shard │
│ Shard: │
│ mu sync.RWMutex │
│ data map[T]V │
│ → 最灵活——适合极高并发 + key 均匀分布 │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 2.2 为什么 Go 不为 map 内置锁
疑惑:Java 有 ConcurrentHashMap(内置分段锁),Python 有 threading.Lock 保护共享字典。Go 为什么不给 map 内置锁?
论证:
锁是"有成本的选择"——给 map 内置锁意味着每次
m["key"]都要获取和释放锁。在单 goroutine 场景(占 map 使用的 90%+)中这是纯粹的浪费。Go 选择"不为不需要的东西付费"。锁的粒度不可知——你的临界区可能是一行
m[key]=value,也可能是一个需要原子性的"读-改-写"序列(v := m[key]; v.Count++; m[key] = v)。内置锁无法覆盖后者——你仍然需要外部锁。并发检测比内置锁更好——Go 1.6 引入的
hashWriting标志位检测让"误用"在第一次并发访问时就 fatal(虽然是尽力检测),而不是随机数据损坏。这比"默默变慢"(内置锁)更能暴露问题。多种方案并存——
sync.Map针对读多写少优化,分段锁针对极高并发优化,Mutex+map针对简单场景。内置锁会剥夺程序员的选择权。反向验证——Java 的
Hashtable就内置了 synchronized——导致其单线程性能极差,最终被HashMap(无锁)取代,然后用ConcurrentHashMap(分段锁)补救。Go 跳过了这个弯路——一步到位让程序员自己做选型。
结论:Go map 的无锁设计不是"偷懒",是"让程序员为正确的场景选择正确的并发方案"。
# 3. 内置 map 的并发检测机制
# 3.1 flags 字段的 hashWriting 位
(06 篇 §8.1 已有详细介绍,本节补充检测的精确边界和漏检场景)
// runtime/map.go
const (
iterator = 1 // bit 0: 有迭代器正在遍历
oldIterator = 2 // bit 1: 有迭代器遍历旧桶(扩容中)
hashWriting = 4 // bit 2: 有 goroutine 正在写
sameSizeGrow = 8 // bit 3: 等量扩容标志
)
2
3
4
5
6
7
写操作的检测代码(runtime/map.go):
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 第一步:检查并发写入
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
// 第二步:设置写入标志
h.flags ^= hashWriting // XOR 设置(不是 OR——因为后续通过 XOR 清除)
// ... 执行写入 ...
// 第三步:清除写入标志
h.flags ^= hashWriting
}
2
3
4
5
6
7
8
9
10
11
12
13
迭代的检测代码:
func mapiterinit(t *maptype, h *hmap, it *hiter) {
if h.flags&hashWriting != 0 {
throw("concurrent map iteration and map write")
}
h.flags ^= iterator // 设置迭代标志
}
2
3
4
5
6
# 3.2 检测窗口与漏检场景
漏检场景——两个 goroutine 同时写,都通过了检测:
Goroutine A: 检查 flags & hashWriting == 0 → 通过
↑
[竞态窗口——此时 B 还没设置标志]
↓
Goroutine B: 检查 flags & hashWriting == 0 → 通过
A: flags ^= hashWriting → 写入
B: flags ^= hashWriting → 同时也写入!← 未被检测到
2
3
4
5
6
7
8
这是因为 Go 的并发检测是非原子的两步(先检查、再设置),中间有一个指令窗口。真正的保证来自 go run -race 的 happens-before 分析——不是 flags 检测。
# 3.3 fatal error 不可 recover 的设计意图
throw("concurrent map writes") 调用的是 runtime.throw 而不是 panic:
// runtime/panic.go
func throw(s string) {
// 直接调用 runtime.throw,无法被 recover 捕获
// 因为此时 map 的内部结构可能已经损坏
// 继续执行会导致更严重的数据损坏
}
2
3
4
5
6
为什么不用 panic——并发 map 操作意味着哈希表的内部指针可能已经被破坏。继续执行可能导致"写入随机地址",这是比 panic 更危险的未定义行为。throw 是强制终止——不给 recover 的机会,防止"带着被破坏的哈希表继续运行"。
# 4. sync.Map 源码精解
# 4.1 read / dirty 双 map 结构
// sync/map.go
type Map struct {
mu sync.Mutex // 保护 dirty 的写锁
read atomic.Pointer[readOnly] // 只读 map(无锁读)
dirty map[any]*entry // 可写 map(需要 mu 保护)
misses int // 读 miss 计数(从 read 中未命中转查 dirty 的次数)
}
type readOnly struct {
m map[any]*entry // 实际的只读 map
amended bool // dirty 中是否有 read 中没有的 key?
}
2
3
4
5
6
7
8
9
10
11
12
双 map 的核心思想:
read:无锁读,所有Load优先在这里查找。高频访问的 key 都在read中dirty:有锁写,所有Store新 key、Delete的实际标记在这里misses:当Load在read中未命中时,去dirty中查找——每次这样的查找 misses++。当 misses 积累到一定程度(阈值 = len(dirty)),触发dirty → read提升
为什么是双 map 而不是单 map + RWMutex——RWMutex 的 RLock 虽然是读锁,但仍有原子操作开销(~10ns)。sync.Map 的 Load 路径是纯原子指针读 + map 查找——不需要获取任何锁,速度接近裸 map 查找。
# 4.2 Store:从无锁 CAS 到加锁写入
// sync/map.go
func (m *Map) Store(key, value any) {
read := m.loadRead()
// 1. 快速路径(无锁):key 已经在 read 中
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return // ← CAS 更新 entry 的指针——无锁!
}
// 2. 慢路径(加锁)
m.mu.Lock()
read = m.loadRead()
// 双重检查——可能另一个 goroutine 已经处理了
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() { // 如果之前被标记为删除 → 恢复到 dirty
m.dirty[key] = e
}
e.storeLocked(&value) // 更新 value
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value) // key 在 dirty 中 → 更新
} else {
// 新 key——插入到 dirty
if !read.amended {
// dirty 还没有复制 read → 执行复制
m.dirtyLocked()
m.read.Store(&readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Store 的三个路径:
- key 在 read 中 →
tryStore(CAS 原子更新 value 指针,无锁) - key 在 dirty 中 →
storeLocked(加锁更新) - key 不在任何 map 中 → 插入 dirty(加锁,可能触发 dirty 复制)
# 4.3 Load:无锁读 + misses 计数
// sync/map.go
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadRead()
e, ok := read.m[key]
// 1. 快速路径:key 在 read 中 → O(1) 无锁返回
if ok {
return e.load() // 原子读 entry 中的指针
}
// 2. 未命中 → 加重锁路径
if !read.amended {
// dirty 中没有新 key → key 确实不存在
return nil, false
}
// 3. dirty 中查找
m.mu.Lock()
read = m.loadRead()
e, ok = read.m[key]
if ok {
m.mu.Unlock()
return e.load()
}
if !read.amended {
m.mu.Unlock()
return nil, false
}
e, ok = m.dirty[key]
m.missLocked() // ← misses++,可能触发 dirty → read 提升
m.mu.Unlock()
return e.load()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
misses 的累加与提升(sync/map.go):
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return // 未达到阈值——继续积累
}
// 触发提升:dirty → read
m.read.Store(&readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
2
3
4
5
6
7
8
9
10
提升条件:misses >= len(m.dirty)。当 miss 次数超过 dirty 中的 key 数量时,说明"大多数读操作都穿透到了 dirty"——此时将 dirty 提升为 read,清空 dirty。
# 4.4 dirty 提升为 read 的阈值与流程
dirty → read 提升的完整流程:
1. misses >= len(dirty) → 触发提升
2. m.read.Store(&readOnly{m: m.dirty}) // dirty 变成新 read
3. m.dirty = nil // 清除 dirty
4. m.misses = 0 // 重置计数
提升后下一个 Store 新 key 时:
Store(key, value):
key 不在新 read 中
read.amended = false → dirty 为空
→ 需要"复制" read 到 dirty(m.dirtyLocked)
→ dirty = 拷贝所有非删除的 entry
→ read.amended = true
dirtyLocked 复制流程:
for k, e := range read.m {
if !e.deleted { dirty[k] = e }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 4.5 Delete:惰性删除 + dirty 延迟膨胀
// sync/map.go
func (m *Map) Delete(key any) {
read := m.loadRead()
e, ok := read.m[key]
// 1. 快速路径:key 在 read 中 → nil 标记(不真的删除)
if ok {
e.delete() // 原子 CAS:entry.p → nil
return
}
// 2. 慢路径:在 dirty 中删除
m.mu.Lock()
read = m.loadRead()
e, ok = read.m[key]
if ok {
e.delete()
}
if e, ok = m.dirty[key]; ok {
delete(m.dirty, key) // 真的从 dirty 中删除
}
m.mu.Unlock()
// 注意:misses 没有增加——Delete 不触发提升
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
惰性删除的精妙——Delete 在 read 中只将 entry 的指针设为 nil(e.p = nil),不真的从 map 中移除 key。这避免了"删除一个高频读的 key 时要加锁修改 read map 结构"。只在 dirty → read 提升时(复制 map 的过程中)才真正清理被标记删除的 entry。
# 5. sync.Map 的完整状态机
sync.Map 有四种核心状态:
状态 A:空 map
read: {m: {}, amended: false}
dirty: nil
misses: 0
→ Store → 进入状态 B
状态 B:只有 dirty(read 未被填充)
read: {m: {}, amended: true}
dirty: {key1: entry1, key2: entry2, ...}
misses: 0
→ 多次 Load miss → misses++ → 达到阈值 → 进入状态 C
状态 C:dirty 提升到 read(稳定态——读多写少最优)
read: {m: {key1: ..., key2: ...}, amended: false}
dirty: nil
misses: 0
→ 新 key Store → 需要先复制 read→dirty → 进入状态 D
状态 D:新 key 加入 dirty
read: {m: {key1: ..., key2: ...}, amended: true}
dirty: {key1: ..., key2: ..., newKey: ...}
misses: 0~N
→ 继续积累 misses → 达到阈值 → 回到状态 C
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
稳定态(C→D→C)是 sync.Map 的设计目标——大多数 key 在 read 中无锁访问,只有新 key 的插入才走加锁路径。当 dirty 中的新 key 被足够多次访问后,它们被提升到 read 中——后续访问就无锁了。
# 6. 分段锁自建并发 map
# 6.1 分片数与性能的权衡
type ConcurrentMap struct {
shards []*MapShard
shardCount uint64
shardMask uint64 // shardCount - 1(必须是 2 的幂)
}
type MapShard struct {
mu sync.RWMutex
data map[string]interface{}
}
func NewConcurrentMap(shardCount int) *ConcurrentMap {
// 保证 shardCount 是 2 的幂
shardCount = nextPowerOfTwo(shardCount)
shards := make([]*MapShard, shardCount)
for i := range shards {
shards[i] = &MapShard{
data: make(map[string]interface{}),
}
}
return &ConcurrentMap{
shards: shards,
shardCount: uint64(shardCount),
shardMask: uint64(shardCount - 1),
}
}
// FNV-1a 哈希——快速定位分片
func (m *ConcurrentMap) getShard(key string) *MapShard {
hash := fnv.New32a()
hash.Write([]byte(key))
return m.shards[hash.Sum32()&uint32(m.shardCount-1)]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
分片数选择指南:
| 并发度 (GOMAXPROCS) | 推荐分片数 | 原因 |
|---|---|---|
| 4 | 16 | 4× GOMAXPROCS——每个 P 平均 4 个分片 |
| 8 | 32 | 同上 |
| 16 | 64 | 更高并发需要更细粒度 |
| 32 | 128 | 每个分片的锁竞争趋近于零 |
# 6.2 扩容与缩容策略
分段锁 map 的弱点:不支持全局 resize。每个分片独立管理内存——当一个分片的 map 扩容后,其他分片不受影响。但这也意味着"全量 range"需要遍历所有分片:
func (m *ConcurrentMap) Range(f func(key string, value interface{}) bool) {
for _, shard := range m.shards {
shard.mu.RLock()
for k, v := range shard.data {
if !f(k, v) { shard.mu.RUnlock(); return }
}
shard.mu.RUnlock()
}
}
2
3
4
5
6
7
8
9
# 7. 三种方案的性能基准
# 7.1 读多写少 vs 写多读少 vs 混合负载
Benchmark goroutines ops/sec ns/op
MutexMap_ReadOnly-8 8 50M 24
RWMutexMap_ReadOnly-8 8 80M 15
SyncMap_ReadOnly-8 8 200M 5 ← 读多最优
MutexMap_WriteOnly-8 8 10M 98
SyncMap_WriteOnly-8 8 2M 500 ← 写多久差!
ShardedMap_WriteOnly-8 8 15M 65 ← 写多最优
MutexMap_80R20W-8 8 30M 33
RWMutexMap_80R20W-8 8 35M 28
SyncMap_80R20W-8 8 50M 20 ← 80% 读 + key 稳定最优
ShardedMap_80R20W-8 8 40M 25
2
3
4
5
6
7
8
9
10
11
12
13
# 7.2 sync.Map 的适用边界
sync.Map 最优的条件(两个必须同时满足):
- 读远多于写——读比例 > 90%。因为 Store 新 key 需要加锁 + 可能触发 dirty 复制
- key 集合稳定——大多数 key 在初始阶段创建后就不再新增/删除。因为每次新增 key 都触发 dirty 复制,key 频繁变化会让 dirty→read 提升前功尽弃
sync.Map 最差的条件:
- 大量新 key 写入(每次 Store 都加锁 + 可能复制)
- 大量删除(虽然惰性删除不立即生效,但真的删除时加锁)
# 8. sync.Map 典型陷阱
陷阱 1:值内部的 data race(第 1 章案例)
// ❌ sync.Map 保护的映射,不保护 value 的内部字段
m.Store("user", &User{Score: 0.5})
u, _ := m.Load("user")
u.(*User).Score = 0.9 // ← data race(和另一个 goroutine 同时修改 Score)
2
3
4
陷阱 2:Load + Store 不是原子操作
// ❌ 读-改-写不是原子的
v, _ := m.Load("counter")
n := v.(int) + 1
m.Store("counter", n) // ← 两个 goroutine 可能 Load 到相同值
// ✅ 用 atomic.Value 或者加外部锁
2
3
4
5
6
陷阱 3:Range 中修改 key
// ✅ 安全(Go 1.20+):Range 中 Delete 安全
m.Range(func(k, v interface{}) bool {
m.Delete(k) // 安全
return true
})
// ❌ 不安全:Range 中 Store 新 key——可能被 Range 遍历到,也可能遍历不到
m.Range(func(k, v interface{}) bool {
m.Store("newKey", "value") // 行为不确定!
return true
})
2
3
4
5
6
7
8
9
10
11
# 9. 并发 map 的选型决策树
需要并发安全地读写 map?
│
├─ 读比例 > 90% 且 key 集合稳定?
│ └─ 是 → sync.Map(§4)
│
├─ 极高并发(> 32 个 goroutine)且 key 均匀分布?
│ └─ 是 → 分段锁 map(§6)
│
├─ 读多写少但 key 集合不稳定?
│ └─ sync.RWMutex + map(读并发——多个读者同时进入)
│
└─ 写多或简单场景?
└─ sync.Mutex + map(写串行——简单可靠)
2
3
4
5
6
7
8
9
10
11
12
13
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的用户画像缓存——7 个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① flags 并发检测? | 第 3 章:hashWriting 位 ⊕ 检查——非原子两步,可能漏检 |
| ② sync.Map 双 map? | 第 4.1:read 无锁读 + dirty 有锁写 + misses 触发提升 |
| ③ misses 提升阈值? | 第 4.4:misses >= len(dirty) → dirty→read |
| ④ Load 无锁原理? | 第 4.3:atomic.Pointer 读 read → map 查找——无锁 |
| ⑤ Store 的 CAS 路径? | 第 4.2:key 在 read → tryStore CAS;不在 → 加锁 |
| ⑥ 分段锁设计? | 第 6 章:FNV 哈希定位分片 + RWMutex per shard |
| ⑦ 性能差异? | 第 7 章:读多 sync.Map 最优,写多分段锁最优 |
案例完整修复:
// 修复 1:value 内部加锁
type UserProfile struct {
mu sync.RWMutex // 保护内部字段
UserID int64
Interests []string
Score float64
}
func updateProfile(userID int64, score float64) {
v, _ := userProfiles.LoadOrStore(userID, &UserProfile{UserID: userID})
p := v.(*UserProfile)
p.mu.Lock()
p.Score = score
p.Interests = append(p.Interests, "new")
p.mu.Unlock()
}
// 修复 2:选型修正——这种场景用 RWMutex+map 更好
type ProfileCache struct {
mu sync.RWMutex
data map[int64]*UserProfile
}
func (c *ProfileCache) Get(id int64) *UserProfile {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[id]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 10.2 sync.Map 操作的完整旅程
var m sync.Map // 空 map——状态 A
m.Store("a", 1):
→ Load read → ""{"a" not in read}
→ read.amended = false
→ 复制 read→dirty → m.dirtyLocked() → dirty = {}
→ m.dirty["a"] = newEntry(1)
→ read: {m:{}, amended:true} 状态 B
→ dirty: {"a": entry{1}}
m.Store("b", 2):
→ Load read → "b" not in read
→ read.amended = true → dirty 已存在
→ "b" not in dirty → m.dirty["b"] = newEntry(2)
→ dirty: {"a": entry{1}, "b": entry{2}}
m.Load("a"):
→ Load read → "a" not in read
→ read.amended = true → 加锁
→ "a" not in read → 在 dirty 中 → 找到
→ missLocked() → misses=1, 1 < 2(len(dirty)) → 不提升
m.Load("b"):
→ Load read → "b" not in read
→ 加锁 → 在 dirty 中 → 找到
→ missLocked() → misses=2, 2 >= 2 → **提升!**
→ m.read = readOnly{m: dirty, amended: false} 状态 C
→ m.dirty = nil
→ m.misses = 0
m.Load("a"): ← 在 read 中!无锁返回
m.Load("b"): ← 在 read 中!无锁返回
m.Store("c", 3):
→ Load read → "c" not in read
→ read.amended = false → 需要复制 read→dirty
→ dirty = copy(read): {"a": ..., "b": ...}
→ m.dirty["c"] = newEntry(3)
→ read: {m:{a,b}, amended:true} 状态 D
→ dirty: {a,b,c}
m.Delete("a"):
→ Load read → "a" in read
→ e.delete() → e.p = nil(惰性删除——不在 map 中移除)
→ read 中 "a" 仍然存在,但 entry.p = nil
下一次 dirty→read 提升时:
→ dirtyLocked 复制时:
for k, e := range read.m {
if !e.deleted → dirty[k] = e ← "a" 被跳过!
}
→ "a" 从 map 中真正消失
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 10.3 设计哲学回扣
哲学 1:无锁读是 sync.Map 的核心——"大多数读不应该付出锁的代价"
sync.Map.Load 在理想路径上只有一次 atomic pointer load + 一次 map 查找——没有任何 mutex 操作。这是「读多写少」场景下的极致优化。Go 团队没有给内置 map 加上 sync.RWMutex——因为那仍然会在每次读时付出原子操作的代价。sync.Map 走出了更激进的一步:让稳定状态的读完全无锁。
哲学 2:读驱动的晋升——"数据沿着访问热度向上流动"
dirty → read 的晋升条件不是时间,而是访问频率——misses >= len(dirty)。这意味着频繁被访问的 key 会自动从 dirty 流向 read——之后在所有 goroutine 中都能无锁读取。这是一种隐式的缓存层次:hot key 在 read(无锁),cold key 在 dirty(有锁)。不需要程序员手动标注"哪些是热点 key"——访问量自然推动晋升。
哲学 3:惰性删除——"不那么急的清理,可以在下次批发时做"
Delete 在 read 中只把 entry 设为 nil,不真的移除 key。这种「标记删除」策略让 Delete 在无锁路径上完成(CAS 原子写),而真正的清理推迟到 dirty→read 提升时的「全量复制」中。用一次批量的「复制 + 过滤」代替每次删除时的 map 结构调整——这是一种延迟批处理(lazy batching)的思想。
哲学 4:不做没有必要的锁——Go 的"选择性并发安全"哲学
Go map 无锁、sync.Map 针对读优化、分段锁针对写优化——三种方案各自在特定场景下最优。Go 没有像 Java 那样提供一个「万能的 ConcurrentHashMap」——而是把并发策略的选择权交给程序员。这不是"偷懒没做",而是"做了三个不同的方案,让你选最合适的"。
# 10.4 速查表
三种方案对比:
| 方案 | 读性能 | 写性能 | 适用场景 | 复杂度 |
|---|---|---|---|---|
Mutex + map | 低(串行) | 低 | 简单场景 | 最低 |
RWMutex + map | 高(并发读) | 中 | 读多写少 + key 不稳定 | 低 |
sync.Map | 最高(无锁) | 低(写慢) | 读多写少 + key 稳定 | 中 |
| 分段锁 | 高 | 最高 | 极高并发 + key 均匀 | 高 |
sync.Map 关键值:
| 参数 | 值 | 含义 |
|---|---|---|
misses 阈值 | >= len(dirty) | 触发 dirty→read 提升 |
| Store CAS 路径 | key 在 read | 无锁更新 value |
| Store Lock 路径 | 新 key / key 仅在 dirty | 加锁写入 |
| Load 无锁路径 | key 在 read | atomic.Pointer + map 查找 |
诊断命令:
# map 并发读写检测
go run -race main.go
# goroutine 阻塞分析 (sync.Map.Lock 竞争)
curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep "sync.Map"
# Mutex 竞争分析
go test -mutexprofile=mutex.out -bench=. ./...
go tool pprof mutex.out
# GODEBUG 调度器跟踪(查看锁竞争导致的 goroutine 调度)
GODEBUG=schedtrace=1000,scheddetail=1 ./app
2
3
4
5
6
7
8
9
10
11
12
下一篇:我们已经看清了 map 并发安全的三种方案和
sync.Map的读写分离设计,下一步进入 12.Go内存模型一致性——把 Go 的 happens-before 规则、数据竞争检测、atomic 操作的内存序剖开。