2026 年 7 月 28 日,
golang-nuts上出现了一个很典型的问题:map 已经提前填好,之后不再增删 key,多条 goroutine 分别更新不同 key 对应的 value,是否安全?线程共有 10 封邮件,Ian Lance Taylor 参与回复。原始讨论:Concurrent Access to Map Values?。
map 不再增删 key,能并发修改不同 value 吗?
这个问题非常符合程序员直觉。
m:=map[string]int{"a.txt":0,"b.txt":0,}map 已经初始化完毕。现在启动两条 goroutine:
gofunc(){m["a.txt"]=100}()gofunc(){m["b.txt"]=200}()它们写的是不同 key;没有插入新 key,也没有删除;看起来只是在两个独立的int上赋值。
可惜,这段代码仍然不安全。
“不改变 map 结构”只是调用者的想象
提问者的推理是:创建 key 时,map 应该已经为 value 留好了位置。后续给现有 key 赋值,只是修改那个位置里的整数,不需要改变哈希表结构。
问题在于,Go 语言没有承诺 map 元素拥有一个稳定、可独立寻址的存储位置。
map 是运行时管理的复合结构。一次赋值可能涉及:
- 查找 bucket;
- 处理哈希冲突;
- 修改 bucket 内部状态;
- 参与渐进式扩容或迁移;
- 更新控制信息;
- 写入并非单机器字的 value。
调用者不能根据“key 已存在”推断底层只会修改一块互不相关的内存。
官方文档长期以来给出的规则很直接:普通 map 不支持并发读写,也不支持并发写入。只要存在写操作,就需要同步。
Ian Lance Taylor 给出的边界
Ian 在回复中把问题分成了两层。
第一层是 map 本身:
map[string]int对m[k]赋值就是修改 map。即使 key 不同,也不能并发进行。
第二层是 map 中保存的指针指向的对象:
map[string]*int如果 map 初始化后只读,而每条 goroutine 修改不同指针指向的独立变量,问题就不同了。
可以这样写:
packagemainimport("fmt""sync")funcmain(){a,b:=0,0m:=map[string]*int{"a.txt":&a,"b.txt":&b,}varwg sync.WaitGroupfor_,k:=range[]string{"a.txt","b.txt"}{k:=k wg.Add(1)gofunc(){deferwg.Done()(*m[k])++}()}wg.Wait()fmt.Println(*m["a.txt"],*m["b.txt"])}这里并发访问 map 的操作只有读取:
m[k]map 初始化结束后不再被修改。真正被写入的是两个不同的int变量。
如果每个变量只由一条 goroutine 写,并且读取发生在wg.Wait()之后,就不存在对同一内存位置的并发冲突。
map 安全,不代表 value 指向的对象安全
把 value 改成指针,只是把同步边界从 map 移到了对象上。
下面的代码仍然有 data race:
counter:=0m:=map[string]*int{"a":&counter,"b":&counter,}gofunc(){(*m["a"])++}()gofunc(){(*m["b"])++}()虽然 key 不同,但两个指针指向同一个counter。
另一个常见错误是一个 goroutine 写,另一个 goroutine同时读:
gofunc(){(*m["a"])++}()fmt.Println(*m["a"])“只有一个写者”并不自动等于安全。只要读和写并发发生、又没有 happens-before 关系,仍然是 data race。
WaitGroup在前一个正确示例中不只是负责“等任务结束”。Done与Wait返回之间建立了同步关系,因此主 goroutine 在Wait返回后读取,可以观察到子 goroutine 已完成的写入。
为什么单写者也可能看不到更新
邮件中有人提醒:即使某个变量只有一条 goroutine 写,如果另一条 goroutine在没有同步的情况下循环读取,也不能指望它最终看到新值。
vardoneboolgofunc(){done=true}()for!done{}这不是一个合法的同步方案。
编译器和 CPU 可以在不影响单线程语义的前提下重排、缓存或合并普通内存访问。读取方没有通过 channel、mutex、atomic 或其他同步操作建立 happens-before,就没有可见性保证。
正确写法可以使用 channel:
done:=make(chanstruct{})gofunc(){// 修改数据close(done)}()<-done// 此处再读取数据也可以使用sync.WaitGroup、sync.Mutex或sync/atomic,具体取决于数据结构和访问模式。
四种常见设计
1. 一把锁保护整个 map
最简单,也最容易证明正确:
typeStorestruct{mu sync.RWMutex mmap[string]int}func(s*Store)Get(kstring)(int,bool){s.mu.RLock()defers.mu.RUnlock()v,ok:=s.m[k]returnv,ok}func(s*Store)Set(kstring,vint){s.mu.Lock()defers.mu.Unlock()s.m[k]=v}不要因为“不同 key 理论上互不影响”就过早设计复杂锁。很多场景下,一把RWMutex已经足够快。
2. 只读 map 加独立对象
适用于 key 集合固定、对象之间真正独立的情况:
typeItemstruct{mu sync.Mutex sizeint64}items:=map[string]*Item{"a.txt":{},"b.txt":{},}map 初始化后只读;每个Item自己管理并发。
这种设计的关键是对象所有权清楚,而不是简单地“把 value 改成指针”。
3. 分片 map
高并发写入时,可以按 key 哈希到多个 shard:
typeshardstruct{mu sync.RWMutex mmap[string]int}不同 shard 可以并行写,但实现复杂度和测试成本也更高。需要先通过 profile 证明单锁确实是瓶颈。
4.sync.Map
sync.Map针对两类场景做了专门优化:
- entry 通常只写一次、读很多次;
- 多条 goroutine 操作互不相交的 key 集合。
它不是普通 map 的无脑替代品。类型安全、复合操作和业务不变量往往仍然需要额外封装。
用-race验证,而不是靠直觉
最小测试可以直接运行:
gotest-race./...或者:
go run-racemain.gorace detector 能发现很多实际冲突,但“没报告”不等于数学意义上的绝对安全:测试必须真正覆盖并发路径。它是验证工具,不是同步机制。
此外,普通 map 还可能在某些并发读写场景直接触发:
fatal error: concurrent map read and map write这个运行时检查有助于尽早暴露问题,但不能把它理解成完整的安全保证。没有触发 fatal,也可能仍然存在 data race。
一个容易忽略的 API 设计问题
假设业务确实是“预先注册一批任务,之后并发填写结果”。比起暴露一个可写 map,更清楚的设计可能是预先分配结果槽位:
typeResultstruct{NamestringSizeint64}results:=make([]Result,len(files))varwg sync.WaitGroupfori,file:=rangefiles{i,file:=i,file wg.Add(1)gofunc(){deferwg.Done()results[i]=Result{Name:file,Size:stat(file),}}()}wg.Wait()每条 goroutine 写不同的 slice 元素,在没有并发读取且元素不重叠的前提下,可以安全工作。它也避免了把 map 当作并发结果数组使用。
这提醒我们:当 key 集合固定、索引关系已知时,map 未必是最合适的数据结构。
结论
问题可以浓缩成三句话:
- 普通 map 的不同 key 不能被当成天然独立的并发写入单元;
- map 只读、value 为指针时,可以并发修改彼此独立的目标对象,但对象本身仍需遵守内存模型;
- 单写者不等于自动可见,读取方必须通过同步建立 happens-before。
并发代码最危险的地方,往往不是完全不懂规则,而是根据底层实现作出“这次应该没事”的推断。
Go 内存模型首页有一句近乎劝退式的建议:需要同时访问和修改的数据,应当用 channel、sync或sync/atomic序列化。再往后读之前,先不要写得太聪明。
在 map 这个问题上,这条建议依然是最可靠的答案。
参考链接
- golang-nuts:Concurrent Access to Map Values?
- Go Memory Model
- Go Blog:Go maps in action
- Go Data Race Detector
- sync 包文档
- sync/atomic 包文档