我在生产环境里追过的大多数 Go bug,都不在算法里。它们在 goroutine 之间的接缝上:一个被两个 handler 同时自增的计数器、一条永远不返回的请求路径、一个在等永远不会到来的信号的 worker。Go race detector 能一眼看穿其中第一类——go test -race 把”我机器上是通过的”变成一份报告,指明确切的行,并同时归责于两个 goroutine——而另外两类它根本看不见。接下来是我反复遇到的每一种 data race、deadlock 和 goroutine 泄漏,全部故意复现,附上 race detector 的真实输出,以及那些在生产里扛得住的修复。
Go 并发的形状很小,值得说一次。goroutine 就是一个独立运行的函数:用 go f() 启动,栈只要几千字节而不是线程的一兆字节,运行时把成千上万个 goroutine 多路复用到少数几个操作系统线程上。channel 是一条有类型的管道——ch := make(chan int)——每次 channel 操作都是一个同步点:无缓冲 channel 上的发送会阻塞,直到另一个 goroutine 接收,而这个 happens-before 关系,正是让那个值之后可以被安全触碰的东西。其余一切都是风格。共享内存的 goroutine 必须就”谁什么时候可以碰它”达成一致,而达成一致的方式只有两种:加锁(多个 goroutine 都能碰这个值,但一次只有一个),或者交接(同一时刻只有一个 goroutine 拥有这个值,把它顺 channel 传下去就转移了所有权)。下面三种 bug,就是这种一致失败的三种方式。
Go 的三种经典并发 bug
三类 bug 几乎覆盖了我见过的一切。下面每一份报告都是从 Go 1.27、linux/amd64 上的真实运行原样复制来的。
Bug 1:共享内存上的 data race
这是 Go 里最容易写出来的 bug:八个 worker,一个共享计数器,没有锁。
type counter struct {
n int
}
func (c *counter) inc() {
c.n++ // unsynchronized read-modify-write of shared memory
}
func main() {
c := &counter{}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
c.inc()
}
}()
}
wg.Wait()
fmt.Println("final count:", c.count())
}
用 race detector 跑它,它会把这一行点名两次——一次是读,一次是写——再加上两个 goroutine 分别是在哪里出生的:
$ GORACE="halt_on_error=1" go run -race ./racebug
==================
WARNING: DATA RACE
Read at 0x00c000112028 by goroutine 11:
main.(*counter).inc()
/tmp/opencode/work/go-race/racebug/main.go:15 +0x9c
main.main.func1()
/tmp/opencode/work/go-race/racebug/main.go:31 +0x97
Previous write at 0x00c000112028 by goroutine 14:
main.(*counter).inc()
/tmp/opencode/work/go-race/racebug/main.go:15 +0xae
main.main.func1()
/tmp/opencode/work/go-race/racebug/main.go:31 +0x97
Goroutine 11 (running) created at:
main.main()
/tmp/opencode/work/go-race/racebug/main.go:28 +0x7b
Goroutine 14 (running) created at:
main.main()
/tmp/opencode/work/go-race/racebug/main.go:28 +0x7b
==================
Exited with code 66
Read at ... by goroutine 11 是检测器刚刚观察到的访问;Previous write ... by goroutine 14 是这对访问的另一半;两者之间没有 happens-before 边,所以顺序是未定义的。两个 created at 栈就是催生这一对的 go 语句。退出码 66 的含义是”发现 data race,这次运行不可信”。c.n++ 本身就是 bug:它读、加、写回,而在读和写之间另一个 goroutine 可以做同样的事——所以最终计数会悄悄丢失更新。
map 有它自己的内置检测器,不加任何 flag 你也能看到:
$ go run ./mapbug
fatal error: concurrent map writes
goroutine 11 [running]:
internal/runtime/maps.fatal({0x49ffe1?, 0x5717c0?})
/usr/lib/go/src/runtime/panic.go:1195 +0x18
main.(*hitCounter).record(...)
/tmp/opencode/work/go-race/mapbug/main.go:13
main.main.func1()
/tmp/opencode/work/go-race/mapbug/main.go:25 +0xba
created by main.main in goroutine 1
/tmp/opencode/work/go-race/mapbug/main.go:22 +0x85
map 会大声崩溃;而一个 plain int 只会永远丢失计数,那更糟。
Bug 2:Go 里的 deadlock
data race 是两个 goroutine 碰同一个东西;deadlock 是相反的失败:谁也动不了。经典形态是接收端的 off-by-one——两个 worker,三次接收:
func main() {
results := make(chan int) // unbuffered: every send needs a matching receive
for i := 1; i <= 2; i++ {
go func(id int) {
fmt.Printf("worker %d: sending result\n", id)
results <- id * 10
}(i)
}
total := 0
// BUG: waits for a third result that no worker will ever send.
for i := 0; i < 3; i++ {
total += <-results
}
fmt.Println("total:", total)
}
两个 worker 都发送,两个值都被收走,然后第三次接收永远阻塞:
$ go run ./deadlockbug
worker 2: sending result
worker 1: sending result
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/tmp/opencode/work/go-race/deadlockbug/main.go:18 +0x10f
Exited with code 2
fatal error: all goroutines are asleep - deadlock! 不是可以 recover 的 panic;它是运行时发现每一个 goroutine 都阻塞了,而这在定义上就是致命的。注意那行小字:只有当所有 goroutine 都睡着时它才触发。危险的 deadlock 是那种留着一个 goroutine 还在运行的——一个永远不挑中那个能解开别人的 case 的 select 循环——那种挂死悄无声息,而 -race 一言不发。
Bug 3:goroutine 泄漏
安静的那个。一个阻塞在永远不会发生的接收上的 goroutine,不会崩溃、不打日志、也不会被回收:调度器引用着 goroutine,goroutine 引用着 channel,没有任何东西回收它。GC 也帮不上忙。唯一的症状是 runtime.NumGoroutine() 再也不往下走:
// job starts a worker that exits when done is closed.
// BUG: every call passes a fresh channel nobody can close, so the
// goroutine blocks on <-done forever.
func job(done chan struct{}) {
go func() {
<-done // waits for a shutdown signal that never arrives
}()
}
func main() {
fmt.Printf("%-6s %s\n", "round", "runtime.NumGoroutine()")
for round := 1; round <= 4; round++ {
for i := 0; i < 1000; i++ {
job(make(chan struct{})) // nobody holds (or can close) this channel
}
time.Sleep(50 * time.Millisecond)
runtime.GC()
fmt.Printf("%-6d %d\n", round, runtime.NumGoroutine())
}
}
$ go run ./leakbug
round runtime.NumGoroutine()
1 1001
2 2001
3 3001
4 4001
四轮,四千个停泊的 goroutine,每个都要占几千字节的栈外加它的 channel。一个每个请求泄漏一个 goroutine 的服务会慢慢死——慢到没人把故障和肇事的代码联系起来。
Go race detector 能抓到什么——以及抓不到什么
把它放进 CI,并且留在那里:
$ go test -race ./...
? godemo/deadlockbug [no test files]
? godemo/deadlockfixed [no test files]
? godemo/leakbug [no test files]
? godemo/leakfixed [no test files]
? godemo/mapbug [no test files]
? godemo/racebug [no test files]
ok godemo/racefixed 1.014s
但要睁着眼睛买它。检测器给每一次内存访问插桩,监视中间没有同步的读/写、写/写配对;它跟踪的是真实的 happens-before 图,所以报告里带着两个 goroutine 的完整栈。这些它给你——以及它不给的:
- 它只能看到你的测试真正执行到的 race。 没有测试覆盖到的竞争路径是隐形的。race 覆盖率和测试覆盖率是两根轴。
- 它找不到 goroutine 泄漏。 阻塞的 goroutine 不是 race。上面我的泄漏演示跑
-race是干净的。 - 它找不到逻辑死锁。
all goroutines are asleep检查只在所有 goroutine 都阻塞时触发。饿死select里的一个 case,或者在提前 return 时忘了给 mutex 解锁,你就会挂死,而旁边还有一个可运行的 goroutine 在看着。 - 协议违规不是 race。 往已关闭的 channel 发送、或者少收一个值,会 panic 或挂起,但其中没有任何未同步的内存访问。
- 它有真实成本。 同一个 benchmark、同一份代码,只差一个 flag:
$ go test -run XXX -bench . -benchmem ./racefixed
BenchmarkInc-8 15870174 73.55 ns/op 0 B/op 0 allocs/op
PASS
ok godemo/racefixed 1.250s
$ go test -race -run XXX -bench . -benchmem ./racefixed
BenchmarkInc-8 810561 1260 ns/op 0 B/op 0 allocs/op
PASS
ok godemo/racefixed 2.059s
慢大约 17 倍,内存 footprint 也是数倍。它是测试期工具,不是生产模式;而且第一次冷启的 -race 构建会用插桩后的运行时和标准库重新编译一遍。
最深的限制:无 race 不等于正确。 在错误的东西上加锁,只会得到一个同步得很漂亮的错误答案。
真正扛得住的修复
sync.Mutex,以及什么时候 RWMutex 或 atomic 更好
mutex 版的同一个计数器——多一个字段,改三行:
type counter struct {
mu sync.Mutex
n int
}
func (c *counter) inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *counter) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
$ go run -race ./racefixed
final count: 8000
$ go test -race -v ./racefixed
=== RUN TestConcurrentInc
--- PASS: TestConcurrentInc (0.01s)
PASS
ok godemo/racefixed 1.031s
我给自己定的规矩:mutex 保护的是数据,不是代码路径。对共享字段的每一次访问都要拿锁,包括 count() 里的那个读——加锁的写配未加锁的读仍然是 race,而且这是最常见的一种。当读远多于写、写很少时(缓存、配置重载),sync.RWMutex 有它的位置,但读者仍然要碰共享状态,而等待中的写者会挡住新读者——在高负载下这本身就是一场故障。对于孤零零一个计数器,atomic.Int64 比两者都更简单更快:
var n atomic.Int64
n.Add(1) // no lock, no lost updates — for one value
但 atomic 的组合性很差:两个 atomic 一起更新,并不等于原子地一起更新。我的默认选择是:只要超过一个值就用 sync.Mutex,因为等到”这两个字段必须作为一个整体移动”成为不变式那天,atomic 版本无论如何都得重写。
用 channel 做所有权交接
加锁的替代方案是根本不共享。把值顺 channel 传下去,你就完成了所有权转移:只有接收方碰它,不需要锁,而发送/接收这一对本身就是 race detector 想要的那条 happens-before 边。
channel 也修好了我的 deadlock。worker pool 的模式是:一个由发送方在所有 worker 完成后关闭的 results channel——close 的意思是”再也不会有值了”,于是 range 恰好在应该结束的时候结束,不需要数数,没有 off-by-one。
func main() {
results := make(chan int)
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results <- id * 10
}(i)
}
// One closer goroutine owns the close. Senders never close.
go func() {
wg.Wait()
close(results)
}()
total := 0
for n := range results { // terminates exactly when results is closed
total += n
}
fmt.Println("total:", total)
}
$ go run ./deadlockfixed
total: 30
有两条不变式让它安全:每个 channel 恰好由一个 goroutine 关闭,且它在发送方;接收方永远不需要知道 worker 的数量,因为关闭本身携带了这个信息。
用 context 取消管理 goroutine 生命周期
泄漏的修复是把同样的想法用到时间上。goroutine 的退出条件必须是一个创建它的代码能够点燃的值:一个由创建该作用域的函数取消的 context.Context,并且 defer cancel(),这样它在每条路径上都会触发——包括那些藏着”忘记清理”的提前返回错误路径。
// job starts a worker that exits when ctx is done.
// FIX: the function that creates the cancellation scope is the
// one that calls cancel(), so every worker's <-ctx.Done() is
// guaranteed to fire exactly once.
func job(ctx context.Context) {
go func() {
<-ctx.Done()
// ... cleanup work that runs once, on cancellation
}()
}
func main() {
for round := 1; round <= 4; round++ {
ctx, cancel := context.WithCancel(context.Background())
for i := 0; i < 1000; i++ {
job(ctx)
}
cancel() // wake all 1000 workers at once
time.Sleep(50 * time.Millisecond)
runtime.GC()
fmt.Printf("round %d: %d goroutines\n", round, runtime.NumGoroutine())
}
}
$ go run ./leakfixed
round 1: 1 goroutines
round 2: 1 goroutines
round 3: 1 goroutines
round 4: 1 goroutines
泄漏消失了:同样 4000 个短命 goroutine,计数打印时它们全都已退出。context 比手搓的 done channel 强在三处——它取消一次,且幂等;它沿着调用树传播,不用改任何函数签名;而且每个 net、http、数据库调用本来就接受它,所以顶层一个 context.WithTimeout 就能一次取消整棵子树。
errgroup,简单说
当 N 个 goroutine 做同一类活、你想让第一个错误传播出去并且只有一个汇合点时,golang.org/x/sync/errgroup 就是那个你否则会手写坏的东西:
g := new(errgroup.Group)
g.SetLimit(8) // bound fan-out; unbounded is how leaks start
for _, t := range targets {
t := t
g.Go(func() error {
return probe(ctx, t) // ctx cancellation ends stragglers
})
}
if err := g.Wait(); err != nil { // the first error, after all goroutines join
return err
}
SetLimit 封顶并发度,errgroup.WithContext 在第一个 goroutine 失败时取消组的 context,Wait 是你重新汇合的唯一地点。它替代的是 WaitGroup 加错误 channel 的那套舞蹈,不是思考。
并发安全 Go 的检查清单
我发布并发代码前会跑的一套:
- 每个共享的值恰好有一个所有者——一个 goroutine 或一把 mutex——并且写在类型旁边的注释里。
- 每次推送都跑
go test -race ./...。 race 报告是构建失败,不是可以重跑的 flake。 - 同时断言结果,就像修复版测试里
want 8000那个检查:检测器抓 race,不负责抓你没注意到的丢失更新。 - 锁保护数据,不保护代码路径。 读者也要拿锁。
- 先发布,再共享。 把值构造完整,再交出去——通过 channel,或者在
go语句之前——之后永远不要改它。 - 每个 goroutine 都有写下来的退出条件: 一个关闭的 channel、一个取消的 context,或者一个创建者会等的 WaitGroup。
- cancel 函数一拿到手就
defer cancel();go vet的lostcancel检查能抓住忘记的那些。 - 长时间运行的服务盯住
runtime.NumGoroutine(),或者加一个泄漏测试(go.uber.org/goleak),让泄漏在 CI 挂而不是在生产挂。 - 每个阻塞操作都有超时。 裸 channel 接收是一个你还没遇上的 deadlock。
- 给 fan-out 设上限——扫描、抓取都一样,和我在用 Go 写网络工具里说的坑是同一个。
诚实的总结:go test -race 是检测器,不是证明,而且它只覆盖三类 bug 里的第一类。当一个 Go 服务在负载下行为诡异时,并发 bug 很少在日志指着的地方——这又是我那篇读一个你没写过的代码库里由外向内的那个论据。先 race detector,再数 goroutine,最后读锁。这个顺序,每次如此。