Go 的并发模型简单,但 channel 的死锁和 goroutine 泄漏依然是最常见的线上问题之一。这类问题往往不是语法不熟,而是缺少一套固定的检查流程。本文用一个 worker pool 的错误实现,说明四个检查点。
典型的错误实现
type Result struct {
In, Out int
Err error
}
func Process(ctx context.Context, workers int, in <-chan int,
fn func(context.Context, int) (int, error)) <-chan Result {
perms := make(chan struct{}, workers)
out := make(chan Result, workers)
defer close(out)
for {
select {
case v, ok := <-in:
if !ok {
return out
}
perms <- struct{}{}
go func() {
defer func() { <-perms }()
r, err := fn(ctx, v)
out <- Result{In: v, Out: r, Err: err}
}()
case <-ctx.Done():
return out
}
}
}
意图是并发处理输入、限制并发数、通过 out 返回结果。但这段代码存在两个问题:输入量明显大于 workers(大约超过 2×workers)时会死锁;即使不死锁,也可能向已关闭的 channel 发送而 panic。下面逐条对照检查。
谁来关闭
原则:只有发送方(或代表所有发送方的协调者)才能关闭 channel,接收方不应关闭。
示例中 out 的发送方是多个 worker goroutine,但关闭它的却是 Process 自己的 defer。函数返回时 worker 可能仍在运行,随后的 out <- ... 会触发 panic: send on closed channel。
当发送方有多个时,应由一个协调者在 sync.WaitGroup 等待全部发送方结束后再关闭:
defer func() { wg.Wait(); close(out) }()
谁来读取
每个 channel 都应该能说出它的消费者,以及消费者何时开始读。
示例中 Process 是同步函数,返回 out 之前一直在循环。调用方拿不到 out,也就无法消费。out 的缓冲只有 workers,一旦已完成的结果填满缓冲,worker 就会阻塞在 out <- ...,无法释放 perms;主循环随即阻塞在 perms <- struct{}{},永远到不了 return。生产者在等消费者,消费者在等生产者返回,形成死锁。
解决办法:让返回 channel 的函数立即返回,把生产逻辑放进后台 goroutine。
经验:如果函数既负责生产数据,又负责返回读取数据的句柄,生产就必须是异步的。缓冲只能推迟问题,不能解决问题。
阻塞点能否退出
对每一个 ch <- x 和 <-ch,都问一句:对面永远不来,这里怎么办?
示例中至少有两处无法退出的阻塞:
perms <- struct{}{}:并发已满且 worker 卡住时,主循环无法响应ctx.Done()。out <- Result{...}:消费者提前退出后,worker 永远阻塞。
处理方式是让每个阻塞操作都参与 select,并带上取消信号:
select {
case out <- res:
case <-ctx.Done():
}
这样 context 取消后,整条链路都能收敛退出。
谁来收尾
启动 goroutine 之前,先想清楚它的生命周期归谁管:
- 谁等待它结束?(
WaitGroup、errgroup、关闭 channel) - 它依赖什么条件退出?(输入关闭、
ctx取消) - 异常路径下还能退出吗?
没有答案的 goroutine 就是泄漏。泄漏不会立即报错,只会随时间累积内存和连接,往往在高负载时才暴露。
修正后的实现
func Process(ctx context.Context, workers int, in <-chan int,
fn func(context.Context, int) (int, error)) <-chan Result {
perms := make(chan struct{}, workers)
out := make(chan Result, workers)
go func() {
var wg sync.WaitGroup
defer func() { wg.Wait(); close(out) }() // 1. 协调者在发送方全部结束后关闭
for {
select {
case v, ok := <-in:
if !ok {
return
}
select { // 3. 获取并发许可也可被取消
case perms <- struct{}{}:
case <-ctx.Done():
return
}
wg.Add(1)
go func() {
defer func() { <-perms; wg.Done() }() // 4. 生命周期由 wg 管理
r, err := fn(ctx, v)
select { // 3. 发送结果可被取消
case out <- Result{In: v, Out: r, Err: err}:
case <-ctx.Done():
}
}()
case <-ctx.Done():
return
}
}
}()
return out // 2. 立即返回,调用方可以马上消费
}
四个检查点分别对应注释中的编号。
这个实现还有三点要注意:
- 调用方必须 cancel。 消费者提前退出时,如果不取消
ctx,worker 仍会阻塞在发送结果上。调用方应defer cancel(),这是函数契约的一部分,要写进文档。 - 取消后可能多处理一个输入。
select在多个分支就绪时随机选择,ctx已取消时仍可能读到in中的值。需要严格停止,可以在循环开头先检查ctx.Err()。 - 取消后的结果会被丢弃。 调用方无法得知哪些输入没处理完。需要错误传播或统计时,可以改用
errgroup,它在首个错误出现时统一取消并返回。
用工具兜底
人工检查之外,建议在测试中加上自动化手段:
go test -race:检测数据竞争。go.uber.org/goleak:在测试结束时检查是否有遗留的 goroutine。- 为测试设置超时(
-timeout或context.WithTimeout),让死锁表现为失败,而不是无限挂起。 - 针对性地构造边界用例:输入量远大于缓冲、消费者提前退出、中途取消 context。
小结
| 检查点 | 核心问题 | 常见对策 |
|---|---|---|
| 谁来关 | 关闭方是否是唯一发送方或协调者 | WaitGroup + 单点 close |
| 谁来读 | 消费者何时开始读 | 异步生产,立即返回 channel |
| 能否退出 | 对面不来怎么办 | select + ctx.Done() |
| 谁收尾 | goroutine 生命周期归谁 | WaitGroup / errgroup |
把这四个问题变成写 channel 代码时的习惯,大部分死锁和泄漏在落笔阶段就能避免。