
本文深入剖析 Go 语言中非缓冲通道导致死锁的根本原因,解释 select 的就绪优先级规则,并通过对比缓冲容量为 0、1、2 的通道行为,揭示 goroutine 协作中发送/接收同步的本质。
本文深入剖析 go 语言中非缓冲通道导致死锁的根本原因,解释 `select` 的就绪优先级规则,并通过对比缓冲容量为 0、1、2 的通道行为,揭示 goroutine 协作中发送/接收同步的本质。
在 Go 并发编程中,通道(channel)是 goroutine 间通信的核心原语,而缓冲区容量直接决定了其同步语义:非缓冲通道(make(chan T))是同步的,要求发送与接收操作严格配对阻塞;缓冲通道(make(chan T, N))则引入了有限的异步缓冲能力。理解这一区别,是避免死锁和设计可靠并发流程的关键。
? 非缓冲通道为何必然死锁?
原始代码中,ch1 和 ch2 均为非缓冲通道(容量为 0):
ch1 := make(chan string) // 同步通道:发送阻塞,直到有 goroutine 准备接收 ch2 := make(chan string)
执行流程如下:
- 主 goroutine 启动
testgoroutine 后,立即执行ch1 —— 此时test中的select尚未进入第一次循环(因 goroutine 调度存在微小延迟),ch1无接收者,主 goroutine 阻塞在此处; -
testgoroutine 终于开始运行,进入for循环并执行select; - 此时
ch1已有"hi"待接收(主 goroutine 仍卡在发送上),而ch2为空 →case 就绪,case 不就绪; -
select必然选择就绪的ch1分支(注意:“随机选择”仅适用于多个 case 同时就绪 时;若仅一个就绪,则唯一确定),打印"hi 1",随后执行ch2 ; - 关键来了:
ch2是非缓冲通道,此时testgoroutine 尝试向ch2发送"ch2",但主 goroutine 早已在ch1 处阻塞,无人接收ch2→testgoroutine 在ch2 处永久阻塞; - 主 goroutine 仍卡在
ch1 ,test卡在ch2 ,两者互相等待 → deadlock。
✅ 澄清误区:
select并非“总是随机”,而是“在多个就绪 case 中伪随机选择”;当仅一个 case 就绪时,行为完全确定。因此ch1先发 →ch1分支必先执行;若交换ch1 和ch2 ,则ch2先就绪,必然输出"bye 2"。
至于超时时间(time.After(2s))——它只是 select 等待的上限。只要 ch1 和 ch2 始终无法完成配对发送/接收,goroutine 就永远卡在 channel 操作上,直到 timeout 触发并退出 test,但此时主 goroutine 仍在 ch1 阻塞,最终仍触发全局死锁(all goroutines are asleep)。
? 缓冲容量为 1:看似“工作”,实则逻辑中断
将通道改为 make(chan string, 1) 后:
- 主 goroutine 可无阻塞地执行
ch1 和ch2 (缓冲区容纳 1 个值); -
testgoroutine 进入select时,ch1和ch2均有数据 → 两个case同时就绪 →select真正随机选择其一,因此每次运行可能输出"hi 1"或"bye 2"; - 但问题在于:无论选哪个分支,执行完发送操作(如
ch2 )后,缓冲区已满(容量为 1),而主 goroutine 并未尝试从ch2接收 → 下一次select循环中,ch2仍有旧数据(未被消费),但ch1已空("hi"已被取走),且test无法再向ch2发送新值(缓冲满),ch1也无新数据 → 仅剩timeout可触发,2 秒后退出。
此时程序不再死锁(因主 goroutine 未阻塞),但 test 逻辑只执行一次便等待超时,无法形成预期的双向接力循环。
✅ 缓冲容量 ≥ 2:实现稳定双向通信
当 ch1 和 ch2 均设为 make(chan string, 2):
- 主 goroutine 发送
"hi"和"bye"后,两通道各存 1 个值,缓冲剩余空间为 1; -
test执行ch1分支:取"hi"→ 打印 → 向ch2发"ch2"(ch2原有"bye",现共 2 个,缓冲满); - 下次
select:ch2有 2 个值(就绪),ch1为空(不就绪)→ 必选ch2分支,取"bye"→ 打印 → 向ch1发"ch1"(ch1由空变为 1 个); - 此时
ch1有"ch1",ch2剩 1 个("ch2")→ 下次可继续循环……
缓冲区容量 ≥ 2 确保了:在任意时刻,至少有一个通道能接收下一次发送,从而维持 select 循环持续就绪,形成稳定的生产者-消费者闭环。
? 关键总结
| 缓冲容量 | 主 goroutine 行为 |
test goroutine 行为 |
是否死锁 | 是否可持续循环 |
|---|---|---|---|---|
| 0(非缓冲) | ch1 / |
因主 goroutine 阻塞,无法完成配对收发 | ✅ 是 | ❌ 否 |
| 1 | 无阻塞发送,但不消费通道数据 | 仅执行 1 次 select,后续因缓冲满/空而停滞 |
❌ 否 | ❌ 否 |
| ≥2 | 无阻塞发送,且 test 能持续收发 |
形成闭环:收 A → 发 B → 收 B → 发 A → … | ❌ 否 | ✅ 是 |
⚠️ 最佳实践:避免依赖缓冲通道掩盖同步缺陷。真正的解耦应明确责任——若需异步解耦,显式启动独立 receiver goroutine;若需严格同步协作,使用非缓冲通道并确保收发逻辑配对。缓冲通道适合做“流量削峰”,而非修复逻辑死锁。
通过本例可见,Go 的 channel 设计精妙而严格:它不隐藏并发复杂性,而是将同步契约清晰暴露给开发者。掌握其底层行为,是写出健壮并发程序的第一步。










