编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • Go 专栏博客
      • 内存模型与栈堆布局
      • 指针与逃逸分析
      • 结构体内存布局对齐
      • 字符串与切片底层
      • 接口与类型系统
      • map哈希表底层实现
      • 零值初始化设计哲学
      • GMP协程调度器机制
      • 通道channel源码剖析
      • sync同步原语剖析
      • map并发安全与哈希
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 并发 map 的三种方案全景
          • 2.2 为什么 Go 不为 map 内置锁
        • 3. 内置 map 的并发检测机制
          • 3.1 flags 字段的 hashWriting 位
          • 3.2 检测窗口与漏检场景
          • 3.3 fatal error 不可 recover 的设计意图
        • 4. sync.Map 源码精解
          • 4.1 read / dirty 双 map 结构
          • 4.2 Store:从无锁 CAS 到加锁写入
          • 4.3 Load:无锁读 + misses 计数
          • 4.4 dirty 提升为 read 的阈值与流程
          • 4.5 Delete:惰性删除 + dirty 延迟膨胀
        • 5. sync.Map 的完整状态机
        • 6. 分段锁自建并发 map
          • 6.1 分片数与性能的权衡
          • 6.2 扩容与缩容策略
        • 7. 三种方案的性能基准
          • 7.1 读多写少 vs 写多读少 vs 混合负载
          • 7.2 sync.Map 的适用边界
        • 8. sync.Map 典型陷阱
        • 9. 并发 map 的选型决策树
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 sync.Map 操作的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • Go内存模型一致性
      • 加权信号量与限流
      • errgroup并行控制
      • 协程泄漏排查与修复
      • 并发设计模式详解
      • GC三色标记与屏障
      • 内存分配器深挖
      • defer延迟执行机制
      • 定时器四叉堆实现
      • 抢占式调度器原理
      • 协程栈扩容与缩容
      • 上下文取消与传播
      • 泛型与类型约束
      • 反射机制与unsafe
      • 迭代器与rangefunc
      • 错误处理与panic
      • 网络轮询器netpoller
      • HTTP服务端源码分析
      • JSON序列化与编解码
      • 数据库SQL连接池
      • 文件IO与零拷贝
      • 结构化日志与配置
      • 单元测试与基准
      • cgo与系统调用切换
      • 编译链接与PGO优化
      • 写作模板
    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 专栏博客
杨充
2026-05-23
目录

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. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 并发 map 的三种方案全景
    • 2.2 为什么 Go 不为 map 内置锁
  • 3. 内置 map 的并发检测机制
    • 3.1 flags 字段的 hashWriting 位
    • 3.2 检测窗口与漏检场景
    • 3.3 fatal error 不可 recover 的设计意图
  • 4. sync.Map 源码精解
    • 4.1 read / dirty 双 map 结构
    • 4.2 Store:从无锁 CAS 到加锁写入
    • 4.3 Load:无锁读 + misses 计数
    • 4.4 dirty 提升为 read 的阈值与流程
    • 4.5 Delete:惰性删除 + dirty 延迟膨胀
  • 5. sync.Map 的完整状态机
  • 6. 分段锁自建并发 map
    • 6.1 分片数与性能的权衡
    • 6.2 扩容与缩容策略
  • 7. 三种方案的性能基准
    • 7.1 读多写少 vs 写多读少 vs 混合负载
    • 7.2 sync.Map 的适用边界
  • 8. sync.Map 典型陷阱
  • 9. 并发 map 的选型决策树
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 sync.Map 操作的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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")
}
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
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
1
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?不确定——取决于内存序
1
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 章
1
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 章) ── 完整修复 + 选型决策树 + 设计哲学
1
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 均匀分布                             │
└─────────────────────────────────────────────────────────────────┘
1
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 内置锁?

论证:

  1. 锁是"有成本的选择"——给 map 内置锁意味着每次 m["key"] 都要获取和释放锁。在单 goroutine 场景(占 map 使用的 90%+)中这是纯粹的浪费。Go 选择"不为不需要的东西付费"。

  2. 锁的粒度不可知——你的临界区可能是一行 m[key]=value,也可能是一个需要原子性的"读-改-写"序列(v := m[key]; v.Count++; m[key] = v)。内置锁无法覆盖后者——你仍然需要外部锁。

  3. 并发检测比内置锁更好——Go 1.6 引入的 hashWriting 标志位检测让"误用"在第一次并发访问时就 fatal(虽然是尽力检测),而不是随机数据损坏。这比"默默变慢"(内置锁)更能暴露问题。

  4. 多种方案并存——sync.Map 针对读多写少优化,分段锁针对极高并发优化,Mutex+map 针对简单场景。内置锁会剥夺程序员的选择权。

  5. 反向验证——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: 等量扩容标志
)
1
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
}
1
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  // 设置迭代标志
}
1
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 → 同时也写入!← 未被检测到
1
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 的内部结构可能已经损坏
    // 继续执行会导致更严重的数据损坏
}
1
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?
}
1
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()
}
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

Store 的三个路径:

  1. key 在 read 中 → tryStore(CAS 原子更新 value 指针,无锁)
  2. key 在 dirty 中 → storeLocked(加锁更新)
  3. 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()
}
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

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
}
1
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 }
  }
1
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 不触发提升
}
1
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
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

稳定态(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)]
}
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()
    }
}
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.2 sync.Map 的适用边界

sync.Map 最优的条件(两个必须同时满足):

  1. 读远多于写——读比例 > 90%。因为 Store 新 key 需要加锁 + 可能触发 dirty 复制
  2. 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)
1
2
3
4

陷阱 2:Load + Store 不是原子操作

// ❌ 读-改-写不是原子的
v, _ := m.Load("counter")
n := v.(int) + 1
m.Store("counter", n)  // ← 两个 goroutine 可能 Load 到相同值

// ✅ 用 atomic.Value 或者加外部锁
1
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
})
1
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(写串行——简单可靠)
1
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]
}
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

# 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 中真正消失
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
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
1
2
3
4
5
6
7
8
9
10
11
12

下一篇:我们已经看清了 map 并发安全的三种方案和 sync.Map 的读写分离设计,下一步进入 12.Go内存模型一致性——把 Go 的 happens-before 规则、数据竞争检测、atomic 操作的内存序剖开。

上次更新: 2026/06/11, 20:52:18
sync同步原语剖析
Go内存模型一致性

← sync同步原语剖析 Go内存模型一致性→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式