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 吗?

这个问题非常符合程序员直觉。

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 在前一个正确示例中不只是负责"等任务结束"。DoneWait 返回之间建立了同步关系,因此主 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.WaitGroupsync.Mutexsync/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 未必是最合适的数据结构。

结论

问题可以浓缩成三句话:

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

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

Go 内存模型首页有一句近乎劝退式的建议:需要同时访问和修改的数据,应当用 channel、syncsync/atomic 序列化。再往后读之前,先不要写得太聪明。

在 map 这个问题上,这条建议依然是最可靠的答案。

参考链接

相关推荐
小小小米粒1 小时前
阿姆达尔定律(Amdahl‘s Law)
java·开发语言
知彼解己1 小时前
Java 版本演进
java·开发语言·spring boot
风样滴男人哟1 小时前
PHP特性之反射类ReflectionClass机制
android·开发语言·php
看昭奚恤哭2 小时前
Flutter 布局核心思想
开发语言·javascript·flutter
朱容zr3331332 小时前
为什么推荐使用自增主键?使用UUID作为主键的优缺点是什么?
java·运维·数据库·后端·mysql·面试·性能优化
名字还没想好☜2 小时前
Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试
golang·单元测试·log4j·go·testing
用户608186527902 小时前
一文吃透 Avalonia 布局容器|Grid 到 UniformGrid 核心用法 + 语法糖实战
后端
IsSh9nj6q2 小时前
Python全栈应用搭建神器magic-dash .新版本介绍
开发语言·python·dash
Ai拆代码的曹操2 小时前
Dubbo 线程池爆满排坑:Linux 用户线程数限制,一个容易被忽略的根因
后端·源码阅读