
Go channel 关闭的正确姿势:谁来关、向已关 channel 发送 panic 与 range 优雅收尾写并发时,channel 用得挺顺,一到「怎么关」就出事:程序偶发panic: send on closed channel,或者range ch的循环永远不退出把 goroutine 泄漏了,又或者关了两次直接panic: close of closed channel。这些都不是随机 bug,而是没搞清 channel 关闭的几条硬规则。这篇把规则讲透,再给几个能直接抄的收尾套路。三条硬规则,先背下来关于关闭 channel,Go 有三条不讲情面的规则:向已关闭的 channel 发送,panic(send on closed channel)。关闭已关闭的 channel,panic(close of closed channel)。关闭 nil channel,panic;向 nil channel 发送或接收,永久阻塞。而从已关闭的 channel 接收,不 panic——这是唯一安全的操作:能把缓冲区里剩下的值全读完,读完后立刻返回该类型的零值。配合v, ok : -ch的ok,就能判断 channel 是不是关了:ch:make(chanint,2)ch-1ch-2close(ch)v,ok:-ch// 1, truev,ok-ch// 2, truev,ok-ch// 0, false —— 缓冲读完了,okfalse 表示已关闭ok false且拿到零值,就是「channel 已关且没数据了」的信号。range ch正是靠这个信号自动退出的。铁律:只让「发送方」关,且只有一个发送方时才关新手最容易犯的错是「在接收方关 channel」或「多个发送方各自关」。正确原则只有一句:channel 应该由发送方关闭,而且当有多个发送方时,谁都不该关。原因就是规则 1:一旦关了,任何还在发送的 goroutine 都会 panic。接收方关了它,发送方毫不知情,下一次ch - x就炸。多个发送方时,A 关了,B 还在发,同样炸。看一个错误示范:// ❌ 接收方关 channel,发送方会 panicfuncbad(){ch:make(chanint)gofunc(){fori:0;i5;i{ch-i// 某一刻这里 panic: send on closed channel}}()forv:rangech{ifv2{close(ch)// 接收方擅自关闭,埋雷}_v}}正确写法:发送方发完了自己关,接收方只管 range:// ✅ 发送方发完就关,接收方用 range 自动收尾funcgood(){ch:make(chanint)gofunc(){deferclose(ch)// 发送方负责关,defer 保证一定执行fori:0;i5;i{ch-i}}()// 循环结束,defer close(ch),range 随之退出forv:rangech{fmt.Println(v)}}defer close(ch)放在发送 goroutine 里,发完(或中途 return)都会关,接收方的range读到零值okfalse 后自然退出。记住:关 channel 是「发送方通知接收方:没有更多数据了」的手段,不是「释放资源」的手段——channel 本身不需要手动释放,GC 会回收。很多场景根本不需要 close,只有当接收方要用 range 或 ok 判断结束时才需要。多个发送方怎么优雅退出:用一个 done 信号如果有多个 goroutine 同时往一个 channel 发,谁都不能关它。此时的标准套路是:不关数据 channel,另开一个donechannel 广播「停」。关闭done会让所有监听它的 goroutine 同时收到零值信号(这正是「关闭已关 channel 的接收全返回零值」这条规则的经典用法):funcmultiSender(){data:make(chanint)done:make(chanstruct{})// 只用来广播停止,不传数据varwg sync.WaitGroup// 3 个发送方forid:0;id3;id{wg.Add(1)gofunc(idint){deferwg.Done()fori:0;;i{select{casedata-id*100i:case-done:// done 被 close 后,这个 case 立刻可读return// 所有发送方一起退出}}}(id)}// 接收方收够 5 个就广播停止gofunc(){fori:0;i5;i{fmt.Println(-data)}close(done)// 关一次 done,3 个发送方全收到 —— 一对多广播}()wg.Wait()// 等发送方都退出,避免 goroutine 泄漏}要点:数据 channeldata全程不关,规避「多发送方关闭冲突」。done用chan struct{},不传值只传信号,struct{}零内存开销,是 Go 里的惯用写法。close(done)只调一次,由「唯一的决策方」调(这里是接收方),因为它不是发送数据、而是发广播,不违反「发送方关」的原则。关闭比done - struct{}{}好,因为关闭能一次通知所有监听者,发值只能通知一个。select里同时监听「发数据」和「收停止」,done一关,-done立刻就绪,发送方 return。想安全关一次:sync.Once有时你确实需要一个「关闭函数」被多处调用但只真正关一次,靠sync.Once:typeSafeCloserstruct{chchanintonce sync.Once}func(s*SafeCloser)Close(){s.once.Do(func(){close(s.ch)// 无论 Close 被调几次,这里只执行一次})}once.Do保证里面的close全程只跑一次,后续调用直接跳过,彻底杜绝「close of closed channel」。比起用sync.Mutex 一个 bool 标志位判断「关没关」,sync.Once更简洁也更难写错。select default:非阻塞发送,别被关闭卡住还有个实用套路:向可能满了或将被关闭的 channel 发送时,用select加default做非阻塞发送,避免卡死:functrySend(chchanint,vint)bool{select{casech-v:returntrue// 发成功default:returnfalse// channel 满了(或没接收方),不阻塞,直接放弃}}注意这防不住「向已关 channel 发送 panic」——default只处理「发不进去(满/无接收方)」,已关闭的 channel 发送依然 panic。所以根子上还是要靠前面「发送方关、done 广播」的结构保证不会往关了的 channel 发。小结三条硬规则:向已关 channel 发送 panic、重复 close panic、操作 nil channel panic/永久阻塞;唯一安全的是从已关 channel 接收(读完剩余值后返回零值,okfalse)。铁律:channel 由发送方关闭;有多个发送方时谁都不关,改用关闭一个done(chan struct{})来一对多广播停止。defer close(ch)放发送方,接收方用for v : range ch自动收尾;需要防重复关就套sync.Once。close 的语义是「发送方告诉接收方:没有更多数据了」,不是释放资源;不需要 range/ok 判断结束时,根本不用 close。一句话记忆点:关 channel 是「发送方广播结束」,所以永远让发送方关、只关一次;接收方只负责读,读到 okfalse 就收工。