很多 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很适合消费到结束的场景- 多生产者时不要让任意生产者抢着关闭,最好交给协调者统一收口