先记住这个答案
Go map 并非线程安全,并发读写时会检查标志并抛出致命错误,程序退出。实践中,在多个 goroutine 共享 map 并含写操作时,要用 sync.RWMutex 保护,读多写少用 RWMutex,写多或用 sync.Map 的专用场景才用它。锁必须覆盖每次读写。
- 并发写 map 触发 fatal error,不可 recover。
- 用
sync.RWMutex或sync.Map保证安全。 - 纯只读并发无需加锁;有写者时读也要加锁。
map 的并发检测与致命错误
Go map 内部实现是一个 hmap 结构,包含桶数组、溢出指针等。写操作会增减键值、调整桶结构;读操作在扩容时也可能迁移数据。当多个 goroutine 同时操作且至少有一个写发生时,运行时会检测到并触发致命错误。这个错误由运行时直接抛出致命错误,而非普通 panic,因此不可通过 recover 恢复,直接导致进程退出。
准确来说,map 没有内置锁,但运行时在读写函数开头会检查一个叫做 hashWriting 的标志位。如果发现有其他 goroutine 在写,则立即触发 'concurrent map writes' 或 'concurrent map read and map write' 致命错误。由于这是运行时抛出的致命错误,进程会直接终止,即使你 defer recover 也无济于事。因此源码中必须从设计上避免这种竞争。
服务缓存场景下的并发写入
假设 Web 服务用 map[int]time.Time 存储用户最近登录时间。假设某个 A/B 测试中,模拟 1000 个并发请求,每个都要更新自己的键。起初直接用 map,预期运行不到 1 秒就会崩溃,日志只有一行 'fatal error: concurrent map writes'。预期服务全部请求失败。
改造方案:用 sync.RWMutex 包裹 map。写操作加 Lock,读操作加 RLock。在假设的类似压力测试中可稳定运行,无崩溃。典型读多写少场景下,RWMutex 允许读操作并行,能有效降低锁竞争,因此是常见的同步选择。
锁方案与 sync.Map 的适用边界
加锁方案并非万能。若 map 被封装在结构体中,但某个方法漏加锁,或复制结构体导致锁被拷贝,都会让保护失效。Go vet 能检测锁拷贝,但漏加锁只能靠代码审查或运行压力测试。另外,锁必须覆盖所有可能的路径,包括 Range 遍历也要持锁。
sync.Map 并非为所有并发场景优化。它适合两类情况:键值对只在初始化时写入后几乎只读,以及多个 goroutine 操作不相交的键集合。若写入频繁且键冲突多,sync.Map 内部有锁和原子操作,性能可能不如传统 map + Mutex。判断标准:先 profile,再决定。
容易答错的地方
- map 并发写 panic 可以被 recover
- 这是错误认识。该错误由 runtime.throw 触发,recover 只能捕捉普通 panic,无法拦截 fatal。进程直接退出,必须从源头避免竞争。
- 只在两个写 goroutine 同时运行才会出现
- 事实上,并发读和写也可能触发,因为读也会检查写标志。只要某个时刻 map 正在被写,任何同时的读或写都可能导致检测失败。
面试官还会怎么问?
map 并发读安全吗?
纯读并发安全,因为读操作不会修改内部状态。但运行时仍会检查写标志,因此只要没有写者,多个读者不会触发 panic。
为什么 Go 不默认让 map 线程安全?
为了性能和灵活性。为 map 加锁会让所有读写付出额外开销,而 Go 鼓励用显式同步工具。开发者能按需选择 RWMutex、sync.Map 或 channel。
如何选择锁还是 sync.Map?
如果键集合基本稳定且读远多于写,用 RWMutex;如果多个 goroutine 操作不相交的键集合,sync.Map 可减少锁竞争。否则使用普通 map + Mutex 通常更简单可控。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。