ELI5 · 说人话 · GO 并发

没人接,就一直站着

两边都在 → 一手交一手 ch <- v channel <-ch ch <- v channel 没人接 发送方原地站住 channel 是「配对」,不是「投递」

ch <- v 不是「把 v 放进去」,是站在那儿等人来拿
没人拿,这一行就是终点站。

每次收发,都得对面有人

先建心智模型

缓冲只是把等待往后推

make(chan T) 两边同时到,才成交 make(chan T, 2) 满了,第 3 个照样卡住

goroutine 不是线程,是运行时对象,起一个只要 2KB 栈。但它不会被 GC 回收——只要还挂在某个 channel 上,它引用的一整条链就都活着。所以卡住一个 goroutine,漏的从来不止那 2KB。

写 go 之前

先回答这两句

1

它什么时候退出?

2

谁负责让它退出?

答不上来,这一行 go 就是一个已经写好的泄漏——不是「可能会漏」,是现在就漏。

ch <- v<-chwg.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()  // 两个都能走掉
}
崩 · 三个发送方各自 close
for i := 0; i < 3; i++ {
    go func() {
        out <- work()
        close(out)   // 第二个就 panic
    }()
}
// panic: close of closed channel
稳 · 守墓者统一 close
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 永远不结束

没人 closeclose 只由发送方做,而且只能有一个发送方做;多发送方就上 wg.Wait() + 守墓者。

select 停在那儿不动了

所有 case 都不就绪,又没有兜底分支。每个 select 都要留一个逃生口<-ctx.Done()default

wg.Wait() 不返回

某条路径提前 return,漏了 Done()defer wg.Done() 写在 goroutine 第一行,让 panic 路径也算数。

nil channel 读写永久阻塞

var ch chan int 忘了 make反过来这也是个特性——把 select 里某个 case 的 channel 置 nil,就能动态关掉那条分支。

panic: send on closed channel

这个不是卡住,是当场崩。close 的归属必须单一,别让「谁先干完谁关门」这种写法上线。

一个反直觉的点:向已关闭的 channel 发送会 panic,接收不会——接收立刻返回零值。所以 v, ok := <-ch 里的 ok,是判断「已关闭且已取空」的唯一办法。

Code review 清单

七条照做就行

  1. 1

    每个 go 都要有归宿写下 go 的同时就写下它的退出条件。做不到就先别开这个 goroutine

  2. 2

    收发都套 ctx.Done()发送侧也要套——这是最常被漏掉的那一半,大家只记得给接收加。

  3. 3

    close 归发送方,且只一次多发送方就用 WaitGroup + 守墓 goroutine。要靠 sync.Once 兜底,通常说明所有权设计本身有问题

  4. 4

    并发度必须有界for 循环里裸 go 是禁止的。worker pool、信号量、errgroup.SetLimit,挑一个

  5. 5

    一次性结果给缓冲容量 = 发送方数量。让发送方发完就走,不依赖接收方还活着

  6. 6

    超时统一走 contexttime.After 只管一行,context 能跨函数、跨 RPC 传。循环里非要用 timer,就 time.NewTimer + defer t.Stop()

  7. 7

    旁路非阻塞投递监控、日志、事件通知写不进就丢并计数——旁路把主路径卡死,是最冤的线上故障。

    select { case metrics <- m: default: // 投不进就丢,绝不阻塞 dropped.Inc() }

怎么发现已经卡了

三个探头,装上就行

1

看斜率

runtime.NumGoroutine() 打成监控指标。斜率不回落 = 泄漏,比盯绝对值靠谱得多。

2

看栈计数

debug=1 按调用栈聚合,N @ 0x… 的 N 就是卡在同一处的数量。哪条栈单调上升,哪行就是泄漏点

3

让 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 很