并发 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 同时访问的共享状态,都应该明确保护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。