Go 并发 map 入门:mutex、sync.Map 和单 goroutine 管理

讲 Go map 在并发读写下为什么会出问题,并比较 mutex、sync.Map 和 channel 管理三种常见做法。

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
}

如果先 GetSet,两个 goroutine 可能同时创建。锁保护的不只是单次读写,也保护业务语义。

小结

Go 普通 map 不能并发读写。最常见的解决方式是 mutex 保护普通 map;读多写少可以考虑 RWMutex;特定缓存模式可以考虑 sync.Map;复杂状态机可以由单 goroutine 独占 map。

选择方案时先看访问模式,不要为了“无锁”或“channel 更 Go”而把简单问题复杂化。并发安全的第一步是明确共享状态在哪里,以及谁有权读写它。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页