Xiaojun 的技术笔记

Go channel 死锁的四个检查点

Go约 3 分钟读完
目录

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 代码时的习惯,大部分死锁和泄漏在落笔阶段就能避免。