Go 的 map 很好用,但普通 map 不是并发安全的。多个 goroutine 同时读写同一个 map,可能触发 fatal error: concurrent map writes,也可能产生数据竞争。初学者常常在 HTTP handler、缓存、在线用户列表里踩到这个问题。
本文讲三种常见方案:mutex 保护普通 map、sync.Map、单 goroutine 通过 channel 管理状态。没有一种永远最好,关键是根据访问模式选择。
问题示例
var counts = map[string]int{}
func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
counts[path]++
fmt.Fprintln(w, counts[path])
}
HTTP server 会并发处理请求。多个请求同时执行 counts[path]++,就会并发读写 map。这个写法不安全。
可以用 race detector 发现:
go test -race ./...
但 race detector 只能发现测试执行到的路径。设计上仍然要明确共享状态如何保护。
mutex 保护 map
最常见:
type Counter struct {
mu sync.Mutex
counts map[string]int
}
func NewCounter() *Counter {
return &Counter{counts: make(map[string]int)}
}
func (c *Counter) Add(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[key]++
return c.counts[key]
}
Handler:
counter := NewCounter()
func handler(w http.ResponseWriter, r *http.Request) {
n := counter.Add(r.URL.Path)
fmt.Fprintln(w, n)
}
这适合大多数场景。代码直观,map 类型清楚,多个操作可以放在同一把锁里保证原子性。
RWMutex 是否更快
如果读多写少,可以用 sync.RWMutex:
type Store struct {
mu sync.RWMutex
items map[string]Item
}
func (s *Store) Get(id string) (Item, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.items[id]
return item, ok
}
func (s *Store) Set(id string, item Item) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[id] = item
}
不要默认认为 RWMutex 一定更好。它比 Mutex 语义更复杂,写多时未必有优势。入门阶段先用 Mutex,确实读多写少且有性能证据,再考虑 RWMutex。
sync.Map
sync.Map 是标准库提供的并发 map:
var sessions sync.Map // key string, value Session
func SaveSession(id string, session Session) {
sessions.Store(id, session)
}
func LoadSession(id string) (Session, bool) {
v, ok := sessions.Load(id)
if !ok {
return Session{}, false
}
session, ok := v.(Session)
return session, ok
}
它的缺点是类型不如普通 map 清楚,需要类型断言。它适合某些特定模式,比如 key 写入后读很多、不同 goroutine 访问不同 key、缓存类场景。普通业务状态不一定需要它。
如果你发现每次 Load 后都要做复杂组合操作,sync.Map 可能不是最佳选择。mutex 保护普通 map 更容易表达事务性逻辑。
单 goroutine 管理状态
还有一种方式是让一个 goroutine 独占 map,其他 goroutine 通过 channel 发请求:
type getReq struct {
key string
resp chan int
}
type addReq struct {
key string
resp chan int
}
func runCounter(adds <-chan addReq, gets <-chan getReq) {
counts := map[string]int{}
for {
select {
case req := <-adds:
counts[req.key]++
req.resp <- counts[req.key]
case req := <-gets:
req.resp <- counts[req.key]
}
}
}
这种方式避免显式锁,但代码更重。它适合状态机、游戏房间、连接管理这类“所有状态变化都应该串行”的场景。普通计数器用 mutex 更简单。
不要锁太久
加锁后不要做慢操作:
c.mu.Lock()
defer c.mu.Unlock()
resp, err := http.Get(url) // 不推荐在锁里做网络请求
锁里应该只做内存状态读写。网络、磁盘、数据库调用可能很慢,会阻塞其他 goroutine。可以先复制需要的数据,解锁后再做慢操作。
测试并发安全
func TestCounterConcurrent(t *testing.T) {
counter := NewCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Add("home")
}()
}
wg.Wait()
if got := counter.Add("home"); got != 101 {
t.Fatalf("count = %d", got)
}
}
配合 go test -race 更有意义。并发测试不一定每次都能暴露 bug,但 race detector 能帮助你发现不受保护的访问。
快照读取
有时你需要把整个 map 返回给调用方。不要直接返回内部 map:
func (s *Store) All() map[string]Item {
return s.items // 不推荐
}
调用方拿到后可以绕过锁修改它。更稳的是复制一份:
func (s *Store) Snapshot() map[string]Item {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]Item, len(s.items))
for k, v := range s.items {
out[k] = v
}
return out
}
如果 value 本身是指针、slice 或 map,还要考虑深拷贝。并发安全不只是给 map 加锁,也包括不要把内部可变状态泄漏出去。
原子组合操作
有些操作看似两步,实际必须在一把锁里完成。比如“如果不存在就创建”:
func (s *Store) GetOrCreate(id string) Item {
s.mu.Lock()
defer s.mu.Unlock()
if item, ok := s.items[id]; ok {
return item
}
item := Item{ID: id}
s.items[id] = item
return item
}
如果先 Get 再 Set,两个 goroutine 可能同时创建。锁保护的不只是单次读写,也保护业务语义。
小结
Go 普通 map 不能并发读写。最常见的解决方式是 mutex 保护普通 map;读多写少可以考虑 RWMutex;特定缓存模式可以考虑 sync.Map;复杂状态机可以由单 goroutine 独占 map。
选择方案时先看访问模式,不要为了“无锁”或“channel 更 Go”而把简单问题复杂化。并发安全的第一步是明确共享状态在哪里,以及谁有权读写它。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。