Go设计取舍之四: map不变时能否并发修改不同value

Go设计取舍之四: map不变时能否并发修改不同value

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在前一个正确示例中不只是负责“等任务结束”。DoneWait返回之间建立了同步关系,因此主 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.WaitGroupsync.Mutexsync/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.go

race 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 未必是最合适的数据结构。

结论

问题可以浓缩成三句话:

  1. 普通 map 的不同 key 不能被当成天然独立的并发写入单元;
  2. map 只读、value 为指针时,可以并发修改彼此独立的目标对象,但对象本身仍需遵守内存模型;
  3. 单写者不等于自动可见,读取方必须通过同步建立 happens-before。

并发代码最危险的地方,往往不是完全不懂规则,而是根据底层实现作出“这次应该没事”的推断。

Go 内存模型首页有一句近乎劝退式的建议:需要同时访问和修改的数据,应当用 channel、syncsync/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 包文档