ELI5 · 说人话 · GO 并发
ch <- v 不是「把 v 放进去」,是站在那儿等人来拿。
没人拿,这一行就是终点站。
先建心智模型
goroutine 不是线程,是运行时对象,起一个只要 2KB 栈。但它不会被 GC 回收——只要还挂在某个 channel 上,它引用的一整条链就都活着。所以卡住一个 goroutine,漏的从来不止那 2KB。
写 go 之前
它什么时候退出?
谁负责让它退出?
答不上来,这一行 go 就是一个已经写好的泄漏——不是「可能会漏」,是现在就漏。
ch <- v、<-ch、wg.Wait()、mu.Lock()、select{}——这几个都可能永远停住,但它们长得跟普通赋值一模一样。看到它们就问一句:谁保证对面会来?
六种常用搭配
生成器
goroutine 产数据,返回 <-chan T。阻塞点在发送:调用方读了三个就 break,第四次发送就卡死。发送也要套 ctx.Done()。
扇入
多个 goroutine 写同一条 channel。谁都不能 close——加一个守墓者:go func(){ wg.Wait(); close(out) }()。
Worker 池
for _, j := range 百万任务 { go do(j) } 是标准事故写法。并发度必须是配置里的一个数字,不能由任务量决定。
信号量
sem := make(chan struct{}, 10),拿到令牌才准跑。归还一定写 defer,panic 也不会漏掉一张票。
一次性结果
make(chan T, 1)——这个 1 是防泄漏的关键。调用方超时不要了,发送方也能写进去然后收工。
广播
发送是一对一,只有 close 是一对多。想通知所有人就 close(done),别循环发 N 次。
这六种拼起来就是 90% 的并发代码。真要选,先想 errgroup——它把「等待 + 收错误 + 传播取消 + 并发上限」四件事一次做对,自己手搓的每一处都是一个潜在阻塞点。
现场
ch := make(chan int) go func() { ch <- slowCall() // 超时后没人收 }() // 永远停在这一行 select { case v := <-ch: use(v) case <-ctx.Done(): return ctx.Err() // 我走了,它走不了 }
ch := make(chan int, 1) go func() { ch <- slowCall() // 往缓冲里一放 }() // 立刻收工 select { case v := <-ch: use(v) case <-ctx.Done(): return ctx.Err() // 两个都能走掉 }
for i := 0; i < 3; i++ {
go func() {
out <- work()
close(out) // 第二个就 panic
}()
}
// panic: close of closed channel
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
defer wg.Done()
out <- work()
}()
}
go func(){ wg.Wait(); close(out) }()
第一例是线上最经典的一个:接口 QPS 1000、超时率 2%,一分钟漏 1200 个 goroutine,一天一百多万。而修复只是让发送方不必等人接。
症状 → 修法
ch <- v 再也没返回无缓冲,而接收方已经 return 或 panic 走了。发送侧也要套 select + ctx.Done(),或者给 channel 一格缓冲。
for range ch 永远不结束没人 close。close 只由发送方做,而且只能有一个发送方做;多发送方就上 wg.Wait() + 守墓者。
所有 case 都不就绪,又没有兜底分支。每个 select 都要留一个逃生口:<-ctx.Done() 或 default。
wg.Wait() 不返回某条路径提前 return,漏了 Done()。defer wg.Done() 写在 goroutine 第一行,让 panic 路径也算数。
var ch chan int 忘了 make。反过来这也是个特性——把 select 里某个 case 的 channel 置 nil,就能动态关掉那条分支。
这个不是卡住,是当场崩。close 的归属必须单一,别让「谁先干完谁关门」这种写法上线。
一个反直觉的点:向已关闭的 channel 发送会 panic,接收不会——接收立刻返回零值。所以 v, ok := <-ch 里的 ok,是判断「已关闭且已取空」的唯一办法。
Code review 清单
每个 go 都要有归宿写下 go 的同时就写下它的退出条件。做不到就先别开这个 goroutine。
收发都套 ctx.Done()发送侧也要套——这是最常被漏掉的那一半,大家只记得给接收加。
close 归发送方,且只一次多发送方就用 WaitGroup + 守墓 goroutine。要靠 sync.Once 兜底,通常说明所有权设计本身有问题。
并发度必须有界for 循环里裸 go 是禁止的。worker pool、信号量、errgroup.SetLimit,挑一个。
一次性结果给缓冲容量 = 发送方数量。让发送方发完就走,不依赖接收方还活着。
超时统一走 contexttime.After 只管一行,context 能跨函数、跨 RPC 传。循环里非要用 timer,就 time.NewTimer + defer t.Stop()。
旁路非阻塞投递监控、日志、事件通知写不进就丢并计数——旁路把主路径卡死,是最冤的线上故障。
select {
case metrics <- m:
default: // 投不进就丢,绝不阻塞
dropped.Inc()
}怎么发现已经卡了
看斜率
把 runtime.NumGoroutine() 打成监控指标。斜率不回落 = 泄漏,比盯绝对值靠谱得多。
看栈计数
debug=1 按调用栈聚合,N @ 0x… 的 N 就是卡在同一处的数量。哪条栈单调上升,哪行就是泄漏点。
让 CI 拦
goleak 挂在测试入口,跑完检查有没有多出来的 goroutine。这是把规范变成机器可执行的唯一手段。
import _ "net/http/pprof"
go func() { http.ListenAndServe("localhost:6060", nil) }()
$ curl 'localhost:6060/debug/pprof/goroutine?debug=1' # 按栈聚合
$ curl 'localhost:6060/debug/pprof/goroutine?debug=2' # 全栈 + 等了多久
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
别指望运行时那句 all goroutines are asleep - deadlock!——它只在全体都睡着时才触发。线上总有 HTTP server 在跑,这句话你永远不会看到。
拿不准就按这个来
并行 + 收错误
errgroup
先想它,再想手搓。任一出错自动 cancel 其余。
广播停止
close(chan struct{})
或者直接用 context。零内存,一次唤醒所有人。
限并发
SetLimit / 信号量
数字写在配置里,不由任务量决定。
一次性结果
make(chan T, 1)
那个 1 就是发送方的退路。
旁路投递
select + default
投不进就丢,主路径一秒都不能等。
上线前
goleak · 监控 · pprof
CI 拦一道、面板看一道、端口留一道。
还有一条跟版本有关:for _, v := range items { go func(){ use(v) }() } 在 Go 1.22 之前所有 goroutine 共享同一个 v,结果会错乱。跨版本安全写法是 v := v 或显式传参——注意语义由 go.mod 声明的版本决定,不是编译器版本。
goroutine 很便宜,
没人管的 goroutine 很贵。