2026 年 7 月 28 日,
golang-nuts上出现了一个很典型的问题:map 已经提前填好,之后不再增删 key,多条 goroutine 分别更新不同 key 对应的 value,是否安全?线程共有 10 封邮件,Ian Lance Taylor 参与回复。原始讨论:Concurrent Access to Map Values?。
map 不再增删 key,能并发修改不同 value 吗?
这个问题非常符合程序员直觉。
go
m := map[string]int{
"a.txt": 0,
"b.txt": 0,
}
map 已经初始化完毕。现在启动两条 goroutine:
go
go func() {
m["a.txt"] = 100
}()
go func() {
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 本身:
go
map[string]int
对 m[k] 赋值就是修改 map。即使 key 不同,也不能并发进行。
第二层是 map 中保存的指针指向的对象:
go
map[string]*int
如果 map 初始化后只读,而每条 goroutine 修改不同指针指向的独立变量,问题就不同了。
可以这样写:
go
package main
import (
"fmt"
"sync"
)
func main() {
a, b := 0, 0
m := map[string]*int{
"a.txt": &a,
"b.txt": &b,
}
var wg sync.WaitGroup
for _, k := range []string{"a.txt", "b.txt"} {
k := k
wg.Add(1)
go func() {
defer wg.Done()
(*m[k])++
}()
}
wg.Wait()
fmt.Println(*m["a.txt"], *m["b.txt"])
}
这里并发访问 map 的操作只有读取:
go
m[k]
map 初始化结束后不再被修改。真正被写入的是两个不同的 int 变量。
如果每个变量只由一条 goroutine 写,并且读取发生在 wg.Wait() 之后,就不存在对同一内存位置的并发冲突。
map 安全,不代表 value 指向的对象安全
把 value 改成指针,只是把同步边界从 map 移到了对象上。
下面的代码仍然有 data race:
go
counter := 0
m := map[string]*int{
"a": &counter,
"b": &counter,
}
go func() {
(*m["a"])++
}()
go func() {
(*m["b"])++
}()
虽然 key 不同,但两个指针指向同一个 counter。
另一个常见错误是一个 goroutine 写,另一个 goroutine同时读:
go
go func() {
(*m["a"])++
}()
fmt.Println(*m["a"])
"只有一个写者"并不自动等于安全。只要读和写并发发生、又没有 happens-before 关系,仍然是 data race。
WaitGroup 在前一个正确示例中不只是负责"等任务结束"。Done 与 Wait 返回之间建立了同步关系,因此主 goroutine 在 Wait 返回后读取,可以观察到子 goroutine 已完成的写入。
为什么单写者也可能看不到更新
邮件中有人提醒:即使某个变量只有一条 goroutine 写,如果另一条 goroutine在没有同步的情况下循环读取,也不能指望它最终看到新值。
go
var done bool
go func() {
done = true
}()
for !done {
}
这不是一个合法的同步方案。
编译器和 CPU 可以在不影响单线程语义的前提下重排、缓存或合并普通内存访问。读取方没有通过 channel、mutex、atomic 或其他同步操作建立 happens-before,就没有可见性保证。
正确写法可以使用 channel:
go
done := make(chan struct{})
go func() {
// 修改数据
close(done)
}()
<-done
// 此处再读取数据
也可以使用 sync.WaitGroup、sync.Mutex 或 sync/atomic,具体取决于数据结构和访问模式。
四种常见设计
1. 一把锁保护整个 map
最简单,也最容易证明正确:
go
type Store struct {
mu sync.RWMutex
m map[string]int
}
func (s *Store) Get(k string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *Store) Set(k string, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
不要因为"不同 key 理论上互不影响"就过早设计复杂锁。很多场景下,一把 RWMutex 已经足够快。
2. 只读 map 加独立对象
适用于 key 集合固定、对象之间真正独立的情况:
go
type Item struct {
mu sync.Mutex
size int64
}
items := map[string]*Item{
"a.txt": {},
"b.txt": {},
}
map 初始化后只读;每个 Item 自己管理并发。
这种设计的关键是对象所有权清楚,而不是简单地"把 value 改成指针"。
3. 分片 map
高并发写入时,可以按 key 哈希到多个 shard:
go
type shard struct {
mu sync.RWMutex
m map[string]int
}
不同 shard 可以并行写,但实现复杂度和测试成本也更高。需要先通过 profile 证明单锁确实是瓶颈。
4. sync.Map
sync.Map 针对两类场景做了专门优化:
- entry 通常只写一次、读很多次;
- 多条 goroutine 操作互不相交的 key 集合。
它不是普通 map 的无脑替代品。类型安全、复合操作和业务不变量往往仍然需要额外封装。
用 -race 验证,而不是靠直觉
最小测试可以直接运行:
bash
go test -race ./...
或者:
bash
go run -race main.go
race detector 能发现很多实际冲突,但"没报告"不等于数学意义上的绝对安全:测试必须真正覆盖并发路径。它是验证工具,不是同步机制。
此外,普通 map 还可能在某些并发读写场景直接触发:
text
fatal error: concurrent map read and map write
这个运行时检查有助于尽早暴露问题,但不能把它理解成完整的安全保证。没有触发 fatal,也可能仍然存在 data race。
一个容易忽略的 API 设计问题
假设业务确实是"预先注册一批任务,之后并发填写结果"。比起暴露一个可写 map,更清楚的设计可能是预先分配结果槽位:
go
type Result struct {
Name string
Size int64
}
results := make([]Result, len(files))
var wg sync.WaitGroup
for i, file := range files {
i, file := i, file
wg.Add(1)
go func() {
defer wg.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 这个问题上,这条建议依然是最可靠的答案。