Go 并发读写 map 报错,怎么改才安全
那天小程序后台刚上线,日志里突然多出一行 fatal 级别的报错,进程直接退了。原因是我在一个统计 map 上,一边有 goroutine 在读,一边有 goroutine 在写。Go 并发安全的 map 怎么写,只有两条路:要么给 map 配一把锁,要么换成官方那个并发版 map,没有第三条。
一、先说清楚为什么会直接崩
Go 里内置的 map 不是并发安全的。这事跟很多语言不一样——有的语言是「可能读到脏数据」,Go 是检测到并发写就直接让进程退出。
这个设计其实挺狠,但也是好意:与其让你带着一个已经错乱的数据结构继续跑,不如当场死给你看。
麻烦在于它不是必然触发。本地单机测试偶尔并发一下,多半没事;上线一压测就崩。所以这个 bug 有个特点:测试环境永远看不到,上线才见。
二、Go 并发安全的 map 怎么写:加一把读写锁
最直接的做法。给这个 map 配一个 sync.RWMutex,读的时候上读锁,写的时候上写锁。
读多写少的场景,读写锁比普通互斥锁划算,因为读和读之间不互斥。
写法上有两个要点。
第一,锁和 map 要放在一起。我习惯包一个小结构体,把 map 和锁作为它的字段,所有访问都走这个结构体的方法。这样从类型上看就知道它是共享的,不会有人绕过锁直接碰。
第二,返回值要拷贝。读方法如果直接把 map 里的值(尤其是字符串、切片这类引用类型)传出去,调用方拿到的是内部数据的引用,你在锁外改它,等于在锁外改共享状态。这个问题比忘加锁更隐蔽。
三、方案二:换成官方的并发 map
标准库里有个 sync.Map,专门给并发场景用。它的适用场景比较窄,官方文档里说得很清楚。
它适合的两种情况:一是键基本只写一次、之后反复读(典型是各种缓存);二是多个 goroutine 各管各的一部分键,互不重叠。
它不适合的情况:如果你的代码是「每次请求都要更新同一个计数器」,用它会比加锁还慢,因为它内部为了适应不同类型的使用模式做了不少额外工作。
我自己那个统计 map 属于后者——每笔订单都要更新当天的合计。所以我最后用的是读写锁,没用 sync.Map。
四、第三个选择:干脆别共享
还有一个我之前没意识到的方向:如果这份数据只在某一个 goroutine 里被改,那它根本就不需要锁。
我后来把统计拆成了一个独立的汇总 goroutine,别人把订单通过 channel 交给它,map 只有它一个人碰。这样锁也省了,改动范围也更集中。
代价是读结果也要走 channel 或者加一个快照导出。如果你的读频率很低,这是最干净的做法。
五、改完怎么验证
改完必须跑一次带 -race 的检测。这个参数会开启数据竞争检测器,能在测试阶段把问题抓出来。
注意 -race 只能报它这次跑到的竞争。它检测不到你没执行到的分支。所以跑的时候最好让并发量稍微上来一点,别只跑一个 goroutine。
所以 Go 并发安全的 map 怎么写,说白了就是让每一次访问都从锁里过一遍。
另外,-race 有性能开销,别用在生产构建上,只在测试和本地跑。
六、我的默认选择
Go 并发安全的 map 怎么写,我新代码默认用读写锁包结构体,只有明确的「写一次读很多次」缓存场景才用 sync.Map,能改成不共享的就改成不共享。
判断顺序是这样:先问能不能不共享;能共享但读写都频繁,用读写锁;纯缓存,用 sync.Map。
顺带提醒一句:切片、结构体字段的并发访问也是同一个问题,不只是 map。只要能被多个 goroutine 同时碰到,就都要想一遍——这就是 Go 并发安全的 map 怎么写这个问题往外扩一圈的答案。
常见问题
Q:Go 并发安全的 map 怎么写最省事?
A:把 map 和一把 sync.RWMutex 包进同一个结构体,所有读写都走方法。省事的不是少写几行,是别人不会绕过锁直接访问。
Q:为什么本地测试没事,上线就崩?A:因为并发写是概率触发的,单机低并发时可能一次都不撞上。所以要用带 -race 的测试主动去撞,别等线上。
Q:sync.Map 是不是比加锁快?
A:不一定。键写一次之后反复读的场景它快,频繁更新同一批键的场景它可能更慢。按使用模式选,别按名气选。
相关阅读
- Go 里 channel 和 mutex 该用哪个
- Go context 超时取消怎么一层层传下去
- Go 并发测试老是挂,我最后这么改稳的
- Go 并发编程的 5 个常见坑,新手先看
- WaitGroup 最容易写错的三种写法
本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。
完整代码、踩坑清单与最新进展,都在公众号。
📱 关注公众号「西米分享」