很多 Go 初学者第一次踩到并发 bug,不是因为 goroutine 开少了,而是因为对 channel 的关闭语义理解得太模糊。

关闭 channel 到底表示什么

关闭一个 channel,不是“清空数据”,也不是“通知对方立刻退出”,它表达的是:后续不会再有新值发送进来了。

这也是为什么关闭之后,接收方仍然可能先读到缓冲区里剩余的值,直到缓冲区被消费完,才会读到零值和 ok=false

1ch := make(chan int, 2)
2ch <- 10
3ch <- 20
4close(ch)
5
6v1, ok1 := <-ch // 10, true
7v2, ok2 := <-ch // 20, true
8v3, ok3 := <-ch // 0, false

为什么发送已关闭 channel 会 panic

因为运行时认为这属于明确的程序错误。既然关闭已经表达“不会再发送”,那之后再写入就是逻辑自相矛盾,所以直接 panic,帮助你尽早暴露问题。

1ch := make(chan int)
2close(ch)
3ch <- 1 // panic: send on closed channel

相比之下,从已关闭 channel 读取不会 panic,因为这是一种很常见的“收尾读取”行为。

for range 为什么经常和 close 一起出现

因为 for range ch 会在 channel 被关闭且内部数据读尽之后自动退出,非常适合做消费循环。

1for job := range jobs {
2    fmt.Println("processing", job)
3}

但这里有个前提:必须有人在合适的时机关闭 jobs。如果没人关,消费者会一直阻塞在那里。

多生产者时该怎么关

多生产者是最容易写错的场景。因为只要多个 goroutine 都持有发送权,就很难判断“谁是最后一个发送者”。

这时通常有两个办法:

1. 额外引入协调者

让生产者只负责发送,关闭动作交给单独的协调 goroutine,在它确认所有生产者退出后再关闭。

 1var wg sync.WaitGroup
 2jobs := make(chan int)
 3
 4for i := 0; i < 3; i++ {
 5    wg.Add(1)
 6    go func(id int) {
 7        defer wg.Done()
 8        jobs <- id
 9    }(i)
10}
11
12go func() {
13    wg.Wait()
14    close(jobs)
15}()

2. 不关闭业务 channel,改用 done 信号

有些系统里数据通道会长期存在,这时不一定非要关闭数据通道,可以改成单独传递退出信号。

常见误区

接收方顺手 close

接收方通常不知道后面还有没有发送者,所以它最不适合决定关闭时机。

用 close 当广播,但没人约定职责

close(done) 确实可以当广播,但必须清楚谁拥有这个 done 的关闭权,否则一样会 panic。

先判断再关闭

if !closed(ch) { close(ch) } 这种写法在 Go 里并没有可靠的通用实现,因为状态检查和关闭动作之间不是原子操作。

我的总结

  • close(channel) 表达的是“不会再有新值发送”
  • 关闭后可继续读,但不能再写
  • for range 很适合消费到结束的场景
  • 多生产者时不要让任意生产者抢着关闭,最好交给协调者统一收口