
本文深入剖析 Go 并发程序中因 channel 阻塞导致 select 永远无法响应已发送值的典型死锁场景,并通过负载均衡器实例说明如何定位、复现及修复此类问题。
本文深入剖析 go 并发程序中因 channel 阻塞导致 `select` 永远无法响应已发送值的典型死锁场景,并通过负载均衡器实例说明如何定位、复现及修复此类问题。
在您提供的负载均衡器实现中,select 语句看似正确监听了 work(请求通道)和 b.done(完成通知通道),但程序却卡在 case w := 隐式双向阻塞引发的系统级死锁。
根本原因在于:
- Balancer.balance() 在 select 中等待 b.done 接收,但此时它正被 dispatch() 中的 w.request
- 而每个 Worker.work() 在处理完请求后,需执行 done
- 但 done 同步发送操作,要求有 goroutine 同时从 done 接收——而唯一监听 done 的 balance() 正被 w.request
- 反过来,w.request Worker.work() 的 for 循环本身也卡在 done 。
这就形成了经典环形死锁:
Balancer.balance() → blocked on w.request <- req ↓ Worker.work() → blocked on done <- w ↓ Balancer.balance() → cannot enter select → cannot receive from done
✅ 正确修复方式是解除 done 。最直接的做法是将其异步化:
func (w *Worker) work(done chan<- *Worker) {
for req := range w.request {
req.c <- req.fn(w)
fmt.Println("sending to done:", done)
go func() { done <- w }() // ✅ 异步发送,避免阻塞 worker
fmt.Println("sended to done")
}
}⚠️ 注意事项:
- 不要使用 go done
- 异步发送虽解除了死锁,但需确保 done channel 容量足够或有持续消费者,否则仍可能堆积(本例中 done 是无缓冲 channel,依赖 balance() 及时接收;异步化后只要 balance() 最终能运行一次,即可逐步消化);
- 更健壮的设计应为 done channel 设置合理缓冲(如 make(chan *Worker, 10)),或使用 select 配合 default 避免阻塞(但本场景需保证完成信号必达,故缓冲更稳妥);
- time.Sleep(200s) 是不可靠的终止方式,应改用 sync.WaitGroup 等待所有请求完成:
var wg sync.WaitGroup
for i := 0; i < NumWorkers*4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
requester(req)
}()
}
wg.Wait() // 替代 time.Sleep总结:Go 的 select 本身不会“忽略”已发送值;当它无法进入某 case,本质是该 channel 的对应操作(发送或接收)因上下游阻塞而无法完成。诊断此类问题,优先使用 kill -ABRT


















