Go 竞态检测入门:用 -race 找出并发读写问题

本文讲解 Go race detector 的基本用法,通过计数器、map 并发写入和 HTTP 指标示例说明如何发现并修复数据竞争。

并发 bug 最怕偶尔出现

Go 写并发很方便,go func() 一启动就是一个 goroutine。但多个 goroutine 同时读写同一份数据时,如果没有同步,就会产生数据竞争。数据竞争的麻烦在于它不一定每次都出错。你本地跑十次正常,线上高并发时偶尔错一次,排查成本很高。

Go 提供了 race detector,可以在运行测试或程序时检测数据竞争。它不是静态分析,而是在运行时观察内存访问,所以需要相关代码路径被执行到。入门阶段只要掌握两个命令:

go test -race ./...
go run -race .

这篇文章通过几个常见例子讲如何发现和修复竞态。

错误计数器

type Counter struct {
	value int64
}

func (c *Counter) Inc() {
	c.value++
}

func (c *Counter) Value() int64 {
	return c.value
}

测试:

func TestCounterRace(t *testing.T) {
	var counter Counter
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Inc()
		}()
	}

	wg.Wait()
	t.Log(counter.Value())
}

运行:

go test -race ./...

race detector 会报告多个 goroutine 读写 value。修复方式之一是加锁:

type Counter struct {
	mu    sync.Mutex
	value int64
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}

func (c *Counter) Value() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

另一个方式是 atomic:

type Counter struct {
	value atomic.Int64
}

func (c *Counter) Inc() {
	c.value.Add(1)
}

func (c *Counter) Value() int64 {
	return c.value.Load()
}

单个计数器用 atomic 很合适,多个字段组合状态则更适合 mutex。

map 并发写入

普通 map 不能并发读写:

scores := map[string]int{}

go func() {
	scores["go"] = 1
}()

go func() {
	scores["php"] = 2
}()

这可能直接 panic,也可能被 race detector 报告。修复:

type SafeScores struct {
	mu     sync.RWMutex
	scores map[string]int
}

func NewSafeScores() *SafeScores {
	return &SafeScores{scores: make(map[string]int)}
}

func (s *SafeScores) Set(name string, score int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.scores[name] = score
}

func (s *SafeScores) Get(name string) (int, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	score, ok := s.scores[name]
	return score, ok
}

RWMutex 允许多个读同时进行,但写需要独占。不要因为有 sync.Map 就所有场景都用它。普通 map 加锁在大多数业务代码里更清楚。

HTTP 指标里的竞态

错误写法:

type Metrics struct {
	requests int64
}

func (m *Metrics) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		m.requests++
		next.ServeHTTP(w, r)
	})
}

HTTP server 会并发处理请求,所以 requests++ 有竞态。修复:

type Metrics struct {
	requests atomic.Int64
}

func (m *Metrics) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		m.requests.Add(1)
		next.ServeHTTP(w, r)
	})
}

func (m *Metrics) Count() int64 {
	return m.requests.Load()
}

只要 handler 里访问共享变量,就要问一句:多个请求会不会同时访问?如果会,就需要同步。

race detector 的边界

-race 会让程序变慢,占用更多内存,所以不适合生产常态运行。它适合测试、CI、压测环境和本地排查。

它只能发现实际执行到的竞态。如果测试没有覆盖那段并发代码,race detector 也不会凭空发现。因此并发代码要写测试,让相关路径跑起来。

CI 可以定期跑:

go test -race ./...

如果项目很大,race 测试耗时较长,也可以只对核心包或夜间任务运行。

不要用 sleep 掩盖竞态

排查并发问题时,初学者很容易加一句 time.Sleep,发现本地不报错了,就以为问题解决了。实际上 sleep 只是在改变调度时机,没有建立任何同步关系。两个 goroutine 之间如果要传递完成信号,应该用 channel、sync.WaitGroup、mutex 或 context,而不是靠“等一会儿应该好了”。

比如等待后台任务结束,可以这样写:

var wg sync.WaitGroup
wg.Add(1)
go func() {
	defer wg.Done()
	doWork()
}()
wg.Wait()

如果需要拿到结果,可以用 channel:

resultCh := make(chan Result, 1)
go func() {
	resultCh <- buildResult()
}()

result := <-resultCh

这些同步原语不仅让代码语义更清楚,也让 race detector 更容易判断访问顺序。并发程序的稳定性来自明确的 happens-before 关系,而不是机器刚好调度得比较温柔。

另外,修复竞态后最好保留一个能触发并发路径的测试。否则几个月后别人重构代码,又把锁拿掉了,-race 也没有机会提醒你。并发 bug 最怕“只在事故现场出现”,测试就是给它一个稳定出现的舞台。

小结

Go race detector 是排查并发读写问题的重要工具。go test -race ./... 能发现很多普通测试看不出的数据竞争。常见修复方式包括 mutex、RWMutex、atomic、channel 汇总。

并发代码首先要正确。不要因为某段代码“本地看起来没问题”就忽略同步。能被多个 goroutine 同时访问的共享状态,都应该明确保护。

继续阅读

探索更多技术文章

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

全部文章 返回首页