我用一个 channel 收集多个 worker 的结果,某个 worker 干完顺手把 channel 关了,其他还在干活的 worker 一发送就 panic:send on closed channel,一次 Go channel 关闭所有权的深度复盘
那个 panic 是并发任务偶发崩溃才暴露的:我用多个 worker goroutine 并发处理任务,每个 worker 把结果发到一个共享的 results channel,主 goroutine 从这个 channel 收集结果。为了"发完就关、让接收方知道结束",我让每个 worker 在自己干完后 close(results)。功能偶尔能跑,可线上常常崩:panic: send on closed channel(向已关闭的 channel 发送)。我对着这个偶发的 panic 推演了多个 worker 的时序,才看明白,后背发凉:问题出在 channel 的"关闭所有权"混乱了。我让每个 worker 都去 close(results),可这个 channel 是多个 worker 共享、共同发送的;于是:worker A 干完了,把 results 关了;可 worker B、C 还在干活,它们接着往这个已经被 A 关闭的 channel 里发结果——向已关闭的 channel 发送,直接 panic;(而且如果两个 worker 都去 close,第二个 close 会 panic: close of closed channel);问题的本质是:我没搞清"这个 channel 该由谁、在什么时候关闭"——多个发送者共享一个 channel,却让其中任意一个去关,关闭的时机和所有权完全失控。根本原因是:channel 的关闭是有"所有权和契约"的——关闭应由发送方负责,且当有多个发送方时,绝不能让某个发送方擅自关闭(因为它不知道别人发完没);关闭所有权混乱就会导致向已关闭 channel 发送的 panic。问题的根,是多个 worker 共享发送的 channel,却让每个 worker 各自 close 它,关闭所有权混乱,导致其他还在发送的 worker panic: send on closed channel。这篇就把这次"channel 关闭所有权"的坑,从头到尾复盘一遍。
故障现场:多个 worker 共享 channel、各自去 close
问题在于多个发送方共享一个 channel,却让每个发送方各自关闭它:
// ✗ 出问题的代码: 多个worker共享channel发送, 却让每个worker各自close
func process(tasks []Task) {
results := make(chan Result)
for _, task := range tasks {
go func(t Task) { // 多个worker goroutine
r := doWork(t)
results <- r // 往共享的results发送
close(results) // ✗ 每个worker干完都close! 多个发送者都来关!
}(task)
}
for r := range results { // 主goroutine收集
collect(r)
}
}
// 偶发崩溃的时序(多个worker A、B、C):
// - worker A 干完, results <- 结果A, 然后 close(results);
// - worker B 还在干, 它 results <- 结果B → ✗ 向已被A关闭的channel发送 → panic: send on closed channel!
// - (或 worker B 也走到 close(results) → ✗ panic: close of closed channel, 重复关闭)
// 为什么? channel 关闭的几条铁律, 我全违反了:
// 1. 向已关闭的channel发送 → panic(send on closed channel);
// 2. 重复关闭一个channel → panic(close of closed channel);
// 3. 关闭channel是"通知接收方:没有更多数据了"——这个"宣告结束"的权力, 不能乱给。
// 而我让【每个发送者】都去关, 等于让任何一个发送者都能"替所有人宣告结束"——
// 可它根本不知道别的发送者发完没 → 别人还在发, 它一关, 别人就panic。
// channel关闭的所有权原则(Go社区共识):
// - channel应由【发送方】关闭(接收方不该关, 它不知道发送方还发不发);
// - 当有【多个发送方】时, 【任何一个发送方都不该擅自关闭】(它不知道别人发完没);
// → 多发送者场景: 要么不关(靠GC回收), 要么用额外机制协调(等所有发送者都完成, 再由协调者关一次)。
// 关键: channel关闭有所有权和契约——由发送方关、且多发送方时谁都不能擅自关; 向已关闭channel发送/重复关闭
// 都会panic; 多worker共享channel却各自close, 关闭所有权混乱, 必然有人向已关channel发送而panic。
第一次想明白"是 A 抢先关了 channel、B 还往里发就炸了"时,我又懊恼又警醒:"我让每个 worker 干完就 close,本意是'发完了就通知结束';完全没想到多个 worker 共享一个 channel 时,谁都没资格替所有人宣布'发完了'——一个先关了,其他还在发的就崩了。"这个坑最隐蔽的地方在于:它依赖时序、偶发——只有当"一个 worker 关了 channel、而另一个 worker 恰好还在发送"时才 panic;worker 少、跑得快时可能碰巧不崩(都发完了才关),worker 一多、并发一高就常崩;而且是直接 panic 崩溃(不是静默错误)。下面就来拆解,channel 该由谁、怎么关。
第一件事:搞懂 channel 关闭的规则与所有权
我顺着这次事故,把 Go channel 的关闭规则彻底理清了。
channel 关闭有哪些规则? 该由谁关?
【核心: 向已关闭channel发送/重复关闭都panic; 关闭应由发送方负责; 多个发送方时谁都不能擅自关(不知别人发完没); 要协调或不关】
1. channel关闭的几条硬规则(违反就panic或异常):
- 向【已关闭】的channel发送 → panic: send on closed channel;
- 【重复】关闭channel → panic: close of closed channel;
- 关闭【nil】channel → panic;
- 从已关闭channel接收 → 不panic: 收完剩余数据后, 返回零值, 且 v, ok := <-ch 的 ok 为 false;
- → 关闭主要的危险在"发送侧": 关了还发、或关两次。
2. 关闭的语义: "宣告:不会再有数据发来了"
- close(ch) 是发送方告诉接收方"我发完了, 没有更多了";
- 接收方靠它知道何时停止接收(for range自动结束, 或 ok==false);
- → 这是"发送方"的事(它才知道发没发完), 不是接收方的事。
3. 关闭的所有权原则(Go社区共识):
- ① channel由【发送方】关闭, 接收方不要关(接收方不知道发送方还发不发);
- ② 只有【一个发送方】时: 由这唯一的发送方在发完后关;
- ③ 有【多个发送方】时: 【任何单个发送方都不能擅自关】(它不知道别的发送方发完没);
→ 解决: 用sync.WaitGroup等所有发送方都完成, 再由一个【协调者(非发送方)】关一次;
或者干脆不关(channel不再被引用时会被GC回收, 不关不会泄漏内存, 只是接收方要用别的方式知道结束)。
4. 本文的错: 多发送方 + 每个都关
- results被多个worker共享发送(多发送方);
- 却让每个worker各自close → 违反原则③ → 一个先关, 其他还在发就panic。
5. 接收方怎么知道"结束了":
- for v := range ch: channel关闭后循环自动结束(优雅);
- v, ok := <-ch: ok为false表示已关闭且无数据;
- 多发送方场景的正解(下节): WaitGroup等所有发送完→协调者close→接收方range自然结束。
一句话: 向已关闭channel发送/重复关闭都panic; 关闭应由发送方负责, 多个发送方时谁都不能擅自关(不知别人发完没);
多发送者要用WaitGroup等全部发完再由协调者关一次(或不关); 关闭channel是有所有权和契约的, 别乱关。
这套认知,是整个坑的根。硬规则:向已关闭 channel 发送→panic、重复关闭→panic、关闭 nil channel→panic;从已关闭 channel 接收不 panic(收完剩余返回零值、ok=false);危险主要在发送侧。关闭的语义:close 是发送方"宣告不会再有数据了",接收方靠它知道何时停止;这是发送方的事、不是接收方的事。关闭所有权原则:①由发送方关(接收方不要关)②只有一个发送方时由它发完关 ③有多个发送方时任何单个发送方都不能擅自关(不知别人发完没)→ 用 WaitGroup 等所有发送方完成再由协调者关一次,或不关(靠 GC)。本文的错:多发送方+每个都关,违反原则③。接收方怎么知道结束:for range(关闭后自动结束)、v, ok := <-ch(ok=false)。一句话:向已关闭 channel 发送/重复关闭都 panic;关闭应由发送方负责,多个发送方时谁都不能擅自关(不知别人发完没);多发送者要用 WaitGroup 等全部发完再由协调者关一次(或不关);关闭 channel 是有所有权和契约的,别乱关。
第二件事:正解——WaitGroup 等所有发送方完成,再由协调者关一次
搞懂了原理,正解就清晰了:多个发送方时,用 sync.WaitGroup 等所有 worker 都发送完成,再由一个协调者(非发送方)关闭 channel 一次;接收方用 for range 自然结束。
// ====== 正解: WaitGroup 等所有worker完成 → 协调者关一次 → 接收方range结束 ======
func process(tasks []Task) {
results := make(chan Result)
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
r := doWork(t)
results <- r // ✓ worker只负责发送, 【不关闭】channel
}(task)
}
// ★ 用一个独立的goroutine: 等所有worker都Done(都发完了), 再【关闭一次】
go func() {
wg.Wait() // 等所有发送方完成
close(results) // ✓ 由这个"协调者"关闭, 且只关一次, 此时没人再发了
}()
// 主goroutine接收: channel被关闭后, range自然结束
for r := range results { // ✓ 所有结果收完、channel关闭后, 循环优雅退出
collect(r)
}
}
// → worker只发不关; 等全部发完, 由协调者唯一地关一次; 不会有"还在发却被关"的panic; range优雅结束。
# ====== channel关闭的要点 ======
# 1. 谁关: 由【发送方】关闭(接收方不要关); 单发送方→它发完关; 多发送方→谁都不擅自关;
# 2. 多发送方的标准模式: sync.WaitGroup 等所有发送方Done → 独立goroutine里 wg.Wait()后 close 一次;
# 3. 防重复关闭: 确保close只执行一次(协调者关、或用sync.Once包裹close);
# 4. 接收方判结束: for v := range ch(关闭后自动结束)、或 v, ok := <-ch(ok==false);
# 5. 不关也行: channel不再被引用会被GC回收(不关不漏内存); 但接收方要用别的方式知道结束(如已知数量、context取消);
# 6. 用context/done channel做取消: 需要"提前停止"时, 用单独的done/context通知, 别乱关数据channel;
# 7. 别向nil channel收发(永久阻塞, 同556零值篇)。
# ====== 一个心智 ======
# - channel关闭是"宣告结束"的信号, 这个权力要【唯一、明确地】归属给"知道何时真正结束的那一方";
# - 多个发送方里, 没有任何单个发送方"知道整体何时结束"——所以要汇总(WaitGroup), 由汇总者来宣告。
# 核心: channel由发送方关、多发送方时用WaitGroup等全部发完再由协调者唯一地关一次; 接收方用range判结束;
# 关闭权要明确归属给"知道何时真正结束的一方"; 别让多个发送者各自乱关, 别向已关channel发送。
修复的核心,是"worker 只发不关,WaitGroup 等全部发完由协调者关一次"。正解:WaitGroup + 协调者关闭——每个 worker 只负责发送、不关闭 channel;用一个独立 goroutine wg.Wait() 等所有 worker 发完,再 close(results) 唯一地关一次;主 goroutine for range 收完后自然结束。要点:发送方关、多发送方用 WaitGroup 等全部 Done 再由协调者关一次、防重复关(sync.Once)、接收方用 range/ok 判结束、不关也行(GC 回收)、提前停止用 context/done channel、别向 nil channel 收发。心智:channel 关闭是"宣告结束"的信号,这个权力要唯一明确地归属给"知道何时真正结束的那一方";多发送方里没有单个发送方知道整体何时结束,所以要汇总(WaitGroup)、由汇总者宣告。归根结底:channel 由发送方关、多发送方时用 WaitGroup 等全部发完再由协调者唯一地关一次;接收方用 range 判结束;关闭权要明确归属给知道何时真正结束的一方;别让多个发送者各自乱关、别向已关 channel 发送。
第三件事:Go channel 与并发的其他常见坑
排查后我把 Go channel、并发协调相关的其他坑也系统梳理了一遍。
Go channel 与并发的其他常见坑
# 1. 多发送方各自关channel(本文): 向已关发送/重复关panic。→ WaitGroup+协调者关一次。
# 2. 向已关闭channel发送: panic。→ 明确关闭所有权和时机。
# 3. nil channel收发(同556零值篇): 永久阻塞(不报错)。→ make后用。
# 4. goroutine泄漏: 发送方阻塞在无人接收的channel上、或接收方等永不来的数据。→ 用context/超时控制退出。
# 5. 无缓冲channel的死锁: 发送阻塞等接收, 双方互等。→ 理解有/无缓冲的阻塞语义。
# 6. range一个永不关闭的channel: 接收方永久阻塞/泄漏。→ 确保会被关闭或有退出机制。
# 7. select没有default导致阻塞 或 有default变忙轮询: → 按需用default。
# 8. WaitGroup误用(同544篇): Add时机/Done漏了。→ Add在go之前、确保Done。
# 共同根源: channel是Go并发通信的核心, 但它的收发、关闭、阻塞都有明确的规则和"契约"(谁发、谁收、谁关、
# 何时阻塞); 而并发下"多个goroutine对同一channel的操作"必须协调好这些契约——谁都不能想当然地
# "我这边完事了就关/就发", 因为你不知道别的goroutine的状态。
# 核心: 用channel要守好它的契约——明确发送/接收/关闭的所有权和时机, 多方协作时用WaitGroup/context协调;
# 别向已关channel发送、别重复关、别让goroutine阻塞泄漏; channel的优雅, 建立在严格遵守其规则之上。
排查让我把 channel 与并发的其他坑也梳理清了。一、多发送方各自关 channel(本文)。二、向已关闭 channel 发送。三、nil channel 收发永久阻塞。四、goroutine 泄漏。五、无缓冲 channel 死锁。六、range 永不关闭的 channel。七、select 没 default 阻塞/有 default 忙轮询。八、WaitGroup 误用。它们的共同根源是:channel 是 Go 并发通信的核心,但它的收发、关闭、阻塞都有明确的规则和"契约"(谁发、谁收、谁关、何时阻塞);而并发下"多个 goroutine 对同一 channel 的操作"必须协调好这些契约——谁都不能想当然地"我这边完事了就关/就发",因为你不知道别的 goroutine 的状态。核心是:用 channel 要守好它的契约——明确发送/接收/关闭的所有权和时机,多方协作时用 WaitGroup/context 协调;别向已关 channel 发送、别重复关、别让 goroutine 阻塞泄漏;channel 的优雅,建立在严格遵守其规则之上。下面这张图,是这次 channel 关闭坑的成因与解法:
第四件事:channel 关闭场景对比表
这次踩坑后,我把不同发送方场景下 channel 该怎么关对比成一张表。
| 场景 | 谁来关 | 怎么关 |
|---|---|---|
| 单个发送方 | 那个发送方 | 发完后 close 一次 |
| 多个发送方 | 都不能擅自关 | WaitGroup 等全部发完, 协调者关一次 |
| 接收方 | 不该关 | 它不知道发送方还发不发 |
| 需要提前停止 | 用额外的 done/context | 别乱关数据 channel |
| 不确定/不需要 | 可以不关 | 靠 GC 回收, 接收方另判结束 |
这张表把关闭场景钉清了。核心是:"谁能关闭 channel"的答案,取决于"谁'知道'数据真的发完了"——单发送方它自己知道(发完就关);多发送方里没有任何一个单独知道(所以谁都不能关),只有"汇总了所有发送方状态的协调者"才知道(由它关);接收方更不知道(所以它绝不该关);关闭权, 必须归属给"掌握了'是否真的结束'这个全局信息的角色"。它给我的最大启发是:"宣告一件事结束/完成"的权力(关闭 channel、标记任务完成、发布最终结果),应当归属给"真正掌握'是否全部完成'这个完整信息的角色"——而不是任何一个"只知道自己那部分完成了"的局部参与者;让一个"只见局部"的角色去"宣告全局结束",必然出错(它会在别人没完成时就宣告)——这正是本文 worker 各自 close 的错。这给了我一种分配"决断权"的清醒:分配"做出全局性决断"的权力时(宣告结束、整体决策、最终拍板),要把它归属给"拥有全局视野/完整信息"的角色,而非"只有局部信息"的个体——个体只负责报告自己的局部状态, 由汇总者基于全局做决断;"让全局性的决断, 由掌握全局信息的角色来做",是协调多方、避免"局部越权酿成全局错误"的关键设计原则。认清关闭权归属于知道全局是否结束的角色、全局决断要由有全局信息者做——是这个 channel 坑带给我的认知。
第五件事:这次事故暴露的"职责不清"的危险
这次让我反思更深一层:根上是我没定义清楚"谁负责关闭 channel"这个职责。我把"职责清晰"和"职责不清"对比成表。
| 维度 | 职责不清(本文) | 职责清晰 |
|---|---|---|
| 谁关 channel | 每个 worker 都关(都以为是自己的事) | 明确由协调者关一次 |
| 结果 | 抢着关、重复关、关早了 panic | 恰好一次、时机正确 |
| 根源 | 没定义"这是谁的职责" | 明确职责归属 |
| 多方协作 | 要么都做(冲突)要么都不做(遗漏) | 各司其职 |
| 本质 | 责任模糊 | 责任明确 |
这张表道出了问题的组织根源。核心是:我的错,本质是"没把'关闭 channel'这个职责明确地、唯一地分配给某一个角色"——于是它成了一件"没人专门负责、又好像每个人都能做"的模糊职责;结果就是每个 worker 都"顺手"做了(都以为该自己关)→ 抢着做、重复做、做错时机;"一件事没明确归谁负责",常常导致"要么没人做、要么大家乱做"。它给我的深刻启发是:在多方协作的系统(或团队)里,"每一件需要被做的事, 都应该有清晰、唯一的责任归属"——"这件事归谁负责"必须明确;职责模糊("大家都可以做/应该做")会导致两种坏结果:"三个和尚没水喝(都以为别人会做→没人做)"或"七手八脚乱成一团(都来做→冲突/重复)";本文是后者(都来关→冲突 panic)。这给了我一种设计协作的清醒:设计任何"多个角色协作"的系统(代码模块、并发任务、团队分工)时,要为每一项关键职责明确"由谁、唯一地负责"——尤其是那些"只能做一次、或必须协调好时机"的事(关闭资源、提交事务、宣告完成、发布);"让每件事都有清晰唯一的责任归属、消除职责的模糊地带",是让多方协作不冲突、不遗漏、有序运转的根本。认清职责模糊导致抢做或漏做、关键职责要明确唯一的责任归属——是这个 channel 坑带给我的工程态度。
第六件事:用 channel 收集并发结果时,我现在的自检习惯
现在每当我要用 channel 在多个 goroutine 间通信,我都会先按这张图问自己:
这张图的精髓,是"单发送方它关、多发送方 WaitGroup+协调者关一次、接收方不关"。单发送方它发完关、多发送方WaitGroup 等全部发完协调者关一次、接收方不关用 range/ok 判结束、提前停止用 context。这套习惯,让我从"每个 worker 顺手 close"变成了"明确关闭所有权、协调者唯一关一次"——核心始终是:channel 由发送方关、多发送方时用 WaitGroup 等全部发完再由协调者唯一地关一次,接收方用 range 判结束,别让多个发送者各自乱关、别向已关 channel 发送。
我立下的几条规矩
这场"多 worker 各自关 channel、send on closed channel"的事故,换来了我写 Go 并发时,刻进骨子里的几条铁律:
- 向已关闭的 channel 发送会 panic;重复关闭、关闭 nil channel 也 panic。
- 关闭 channel 是发送方"宣告发完了"的事,接收方不要关。
- 只有一个发送方时,由它发完后关一次。
- 有多个发送方时,任何单个发送方都不能擅自关(它不知道别人发完没)。
- 多发送方:用 WaitGroup 等所有发送方完成,再由独立的协调者 close 一次。
- 接收方用 for range(关闭后自动结束)或 v, ok := <-ch 判断结束;不关也行(GC)。
- 关闭权归属给"知道何时真正结束"的一方;关键职责要明确唯一的责任归属。
写在最后
回头看,这场由"多个 worker 都去关 channel"引发的、send on closed channel 的事故,真正教给我的,远不止"用 WaitGroup + 协调者关一次"这一个技巧。它让我对"'宣告一件事结束'这个动作, 必须由'真正知道它是否结束'的角色来做; 一个只看到自己那一摊、就替所有人宣告'结束了'的局部参与者, 会在别人还没完成时, 错误地、甚至破坏性地关上那扇门",有了一次刻骨的体会。我栽跟头,是因为我让每个 worker 都以为"关闭 channel 是我的事"——每个 worker 干完自己那份,就"顺理成章"地觉得"该收尾了、把 channel 关了吧";可每个 worker 只看得到"我自己干完了",看不到"别的 worker 还在干";它站在自己局部的视角, 误以为"我完事了 = 整件事完事了",于是擅自关上了那扇本该等所有人都出来后才关的门——把还在里面干活的同伴, 重重地关在了 panic 里。这让我领悟到一个关于"局部视角与全局收尾"的深刻认知:"收尾/结束/关门"这类全局性的、不可逆的动作,绝不能由"只掌握局部信息的个体"来擅自决定——因为它只知道"自己这部分好了",并不知道"整体是否真的都好了";"我这边完成了" 远不等于 "整件事可以收尾了";误把"局部的完成"当成"全局的结束"而擅自收尾, 是多方协作中破坏性极强的一类错误。这给了我一种处理"协作收尾"的根本清醒:凡是"影响全局、且不可逆"的收尾动作(关闭共享资源、提交、终结、宣告完成),都要确保它由"汇总了全局信息、确实知道整体已结束"的角色来做,而禁止任何局部个体凭"我这边好了"就擅自执行——局部个体只上报自己的完成, 全局的收尾交给汇总者;"全局性的收尾, 必须等全局都就绪、由掌握全局的角色来做",是多方协作不出"有人擅自关门"这类破坏性错误的关键纪律。认清全局收尾不能由局部个体擅自决定、要由掌握全局已就绪的角色来做——这,是我用一次 channel 误关的事故,换来的、关于 Go 并发、也关于多方协作如何正确收尾的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次用 channel 收集多个 goroutine 的结果时,让 worker 只发不关、用 WaitGroup 等全部发完再由协调者关一次,那我对着那 send on closed channel 的 panic 排查的这段时间,就值了。
—— 别看了 · 2026