Go中的Map和并发写入错误
1. 概述
Map是Go中最常用的数据结构,仅次于slice。但map在多个goroutine并发访问时并不安全。如果你从多个goroutine写入一个map,程序可能会因为fatal error: concurrent map writes而完全崩溃。
本文回顾map的基本操作。然后解释为什么会出现上述错误,如何用race detector检测它,以及三种修复方案:sync.Mutex、sync.RWMutex、sync.Map。示例使用Go 1.22及以上版本。
2. Go中的Map:需要记住的要点
2.1. 基本操作
package main
import "fmt"
func main() {
stock := map[string]int{"apple": 10, "banana": 5}
stock["orange"] = 7
qty, ok := stock["grape"]
fmt.Println(qty, ok)
delete(stock, "banana")
fmt.Println(len(stock))
}
解释:
map[string]int{...}创建一个key为string、value为int的map。stock["grape"]当key不存在时返回零值(0)。变量ok告诉你这个key是否真的在map中。delete删除一个key。对不存在的key调用delete不会导致错误。
0 false
2
2.2. nil map
声明var m map[K]V而不初始化会给你一个nil map。从nil map读取是可以的,但写入会panic:
package main
func main() {
var orders map[string]int
_ = orders["A-001"] // OK,返回0
orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map
修复的方法是总是用make(map[string]int)或literal map[string]int{}来初始化map。当map是struct中的字段且你忘记在构造函数中初始化时,这个错误很常见。
2.3. Map是引用类型,遍历顺序是随机的
当把map赋给另一个变量或传入函数时,它们都指向同一个底层数据结构。因此两个goroutine接收“同一个map”实际上是在共享同一块内存。这是下一小节问题的根源。
另外,for k, v := range m的遍历顺序被runtime故意打乱。不要写依赖于这个顺序的代码或测试。如果需要固定顺序,取出所有key、排序,然后遍历。
3. 并发写入错误
3.1. 重现错误
假设你按用户计数访问次数,每个请求在一个goroutine上运行:
package main
import (
"fmt"
"sync"
)
func main() {
visits := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
visits[fmt.Sprintf("user-%d", j%10)]++
}
}()
}
wg.Wait()
fmt.Println(len(visits))
}
多次运行,你几乎肯定会看到:
fatal error: concurrent map writes
goroutine 23 [running]:
main.main.func1()
/tmp/visits/main.go:17 +0x...
...
exit status 2
visits[...]++包含一次读取和一次写入。当100个goroutine同时做这个操作时,多个goroutine可能同时修改map的内部结构(bucket、增长过程)。Go的runtime有一个简单的检查机制:map保持一个“正在写入”的标志。如果一个goroutine在开始写入时看到这个标志已经开启,runtime会立即停止程序。
类似的变体发生在当一个goroutine正在读取而另一个在写入时:
fatal error: concurrent map read and map write
3.2. 为什么不能用recover
很多人尝试用recover包裹代码:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
visits["user-1"]++
}()
这种方法没有用。concurrent map writes不是panic。这是runtime调用throw的fatal error,它会终止整个进程。recover无法捕获它。这个选择是有意的:如果map已经损坏,继续运行比直接停止更危险。
注意: Runtime的检测机制只是“尽力而为”。程序没有崩溃不意味着代码是安全的。可能你只是还没遇到两个goroutine恰好碰撞的时刻,而数据已经悄悄出错了。
3.3. 用race detector提前检测
更可靠的工具是race detector,用-race标志启用:
go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
runtime.mapaccess1_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
Previous write at 0x00c000... by goroutine 7:
runtime.mapassign_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
==================
Race detector准确指出了冲突的读取和写入代码行。你应该在CI中运行测试时启用-race。用-race运行的程序会更慢、消耗更多内存,所以通常不会在production构建中使用这个标志。
4. 第一种修复方案:sync.Mutex
最常见和最易理解的方法是把map包裹在一个有mutex的struct中,然后只通过method访问map:
package visits
import (
"maps"
"sync"
)
type VisitCounter struct {
mu sync.Mutex
counts map[string]int
}
func NewVisitCounter() *VisitCounter {
return &VisitCounter{counts: make(map[string]int)}
}
func (c *VisitCounter) Inc(user string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[user]++
}
func (c *VisitCounter) Get(user string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[user]
}
func (c *VisitCounter) Snapshot() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.counts)
}
解释:
counts字段是小写的,所以包外的代码无法直接访问map。Inc和Get都锁住mutex,所以在任何时刻只有一个goroutine操作map。Snapshot用maps.Clone返回一个副本(Go 1.21引入)。如果直接返回c.counts,调用者会在没有锁的情况下遍历map,你又回到了原始的错误。- 构造函数
NewVisitCounter初始化map,避免第2.2小节的nil map错误。
4.1. 用-race写测试
Go不使用JUnit或AssertJ。标准的testing包对这个例子来说已经足够了:
package visits
import (
"sync"
"testing"
)
func TestVisitCounter_ConcurrentInc(t *testing.T) {
counter := NewVisitCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Inc("alice")
}
}()
}
wg.Wait()
if got := counter.Get("alice"); got != 100_000 {
t.Fatalf("Get(alice) = %d, want 100000", got)
}
}
go test -race ./...
ok example.com/visits 1.214s
测试检查两个事情。首先,结果是100,000,即没有丢失任何增量。其次,race detector没有报告DATA RACE。尝试从Inc中移除c.mu.Lock(),然后再运行,测试会立即失败。
4.2. mutex的常见错误
复制包含mutex的struct。 如果你声明一个value receiver的method,如func (c VisitCounter) Inc(...),每次调用method时会使用一个mutex的副本。对副本上的锁定无法保护任何东西。总是使用pointer receiver。go vet会通过copylocks检查来警告这种情况:
./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex
持有锁太久。 不要在持有锁时调用HTTP、查询数据库或写文件。取出你需要的数据,释放锁,然后再做慢速操作。
检查然后执行分离。 下面的代码有两个单独的锁定,所以两次之间其他goroutine可能插入:
if counter.Get("bob") == 0 {
counter.Inc("bob") // 可能已经有其他goroutine先增加了
}
如果需要“如果还没有就创建”的逻辑,把它写成一个method,在整个method期间持有锁。
5. 第二种修复方案:sync.RWMutex
对于读取非常频繁但很少写入的数据,比如缓存配置或产品价格表,sync.RWMutex允许多个goroutine并行读取:
package pricing
import "sync"
type PriceTable struct {
mu sync.RWMutex
prices map[string]int64
}
func NewPriceTable() *PriceTable {
return &PriceTable{prices: make(map[string]int64)}
}
func (p *PriceTable) Price(sku string) (int64, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
v, ok := p.prices[sku]
return v, ok
}
func (p *PriceTable) SetPrice(sku string, cents int64) {
p.mu.Lock()
defer p.mu.Unlock()
p.prices[sku] = cents
}
RLock允许多个goroutine同时读取。Lock是排它性的:它等待所有reader完成,并在写完之前阻止新的reader。
注意:
RWMutex并不总是比Mutex更快。它本身有更高的管理开销。当写入比例相当可观,或锁内的代码段非常短时,Mutex通常与之相当或更快。在改变之前用benchmark来测量。
一个常见的错误:调用p.mu.RLock()然后在其中调用另一个也RLock的method。如果两者之间有一个writer在等待,程序可能会deadlock。原因是当已经有writer排队时RWMutex会阻止新的reader。
6. 第三种修复方案:sync.Map
sync包有sync.Map,一个线程安全的map,不需要自己管理锁:
package main
import (
"fmt"
"sync"
)
type Session struct {
UserID string
}
func main() {
var sessions sync.Map
sessions.Store("token-abc", Session{UserID: "alice"})
actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
fmt.Println(actual.(Session).UserID, loaded)
if v, ok := sessions.Load("token-abc"); ok {
fmt.Println(v.(Session).UserID)
}
sessions.Range(func(key, value any) bool {
fmt.Println(key, value.(Session).UserID)
return true
})
}
alice true
alice
token-abc alice
解释:
Store写入一个key/value对。LoadOrStore是一个原子操作。如果key已经存在,method返回旧值并loaded = true,同时不覆盖。这是用sync.Map处理检查然后执行情况的正确方式。Range遍历map。回调函数返回false可以提前停止。
sync.Map有一些你需要了解的限制:
- Key和value类型为
any,所以你失去了编译时类型检查,到处都要做type assertion。 - 没有
Len()。要计数元素,必须用Range遍历。 sync.Map针对两个具体情况进行了优化。一个是每个key写入一次然后读取很多次,比如只增加的缓存。二个是各goroutine读写不相交的key集合。
注意: 除了上述两种情况外,一个map加上
Mutex或RWMutex通常更快、更易读、类型更安全。不要默认把所有map改成sync.Map。
7. 选择哪个方案?
| 情况 | 推荐选择 |
|---|---|
| 读写交替,有条件逻辑 | map + sync.Mutex |
| 读为主,写很少 | map + sync.RWMutex(先测量) |
| 只增加的缓存,或每个goroutine用独立key集 | sync.Map |
| Map只在单个goroutine中使用 | 普通map,无需锁 |
还有一种方法:让单个goroutine拥有map,其他goroutine通过channel发送请求。当map的更新与现有的处理循环绑定时这种方式合适。对于简单的计数器,这比mutex更冗长,没有明显的好处。
关于只读map:如果map在goroutine启动之前初始化完毕,之后没有人再写,那么多个goroutine同时读取是安全的,不需要锁。
8. 总结
Go中的map很快也很方便,但对并发不安全。当多个goroutine同时写入,或既读又写时,runtime可能会因为fatal error: concurrent map writes而停止程序。这个错误无法recover。程序不crash也不能证明代码正确,所以要用go test -race运行测试来尽早发现data race。
在大多数情况下,你只需要把map包裹在一个有sync.Mutex的struct里,通过pointer receiver的method访问,并返回副本而不是原始map。当读取负载真的压倒性地占优时使用sync.RWMutex,只在sync.Map被设计的情况下才用它。