
本文揭示了一个典型的 go 并发陷阱:使用无缓冲 channel 配合 select 实现超时控制时,若超时先于 goroutine 完成,会导致 goroutine 永久阻塞,引发不可回收的内存泄漏。
在 Go 中,chan T(无缓冲通道)是同步通道:发送操作 ch 阻塞,直到有另一个 goroutine 正在执行对应的接收操作
以原始代码为例:
func Read(url string, timeout time.Duration) (res *Response) {
ch := make(chan *Response) // ❌ 无缓冲通道
go func() {
time.Sleep(time.Millisecond * 300)
ch <- Get(url) // ⚠️ 若此时无人接收,此行永久阻塞
}()
select {
case res = <-ch: // 成功接收
case <-time.After(timeout): // 超时触发 → 主 goroutine 退出
}
return
}问题核心在于:当 time.After(timeout) 分支被选中(例如超时为 100ms,而 Get(url) 需 300ms),主 goroutine 立即返回,ch 从此再无任何接收者。而子 goroutine 在执行 ch 栈空间、局部变量(包括 *Response)、以及 goroutine 本身的运行时元数据,均无法被垃圾回收器(GC)释放。这不是 CPU 占用问题,而是goroutine 泄漏(goroutine leak),属于典型的内存泄漏。
✅ 解决方案之一:使用带缓冲的通道(make(chan *Response, 1))
func Read(url string, timeout time.Duration) (res *Response) {
ch := make(chan *Response, 1) // ✅ 缓冲大小为 1
go func() {
time.Sleep(time.Millisecond * 300)
ch <- Get(url) // ✅ 发送立即返回(缓冲区有空位),goroutine 正常退出
}()
select {
case res = <-ch:
case <-time.After(timeout):
res = &Response{"Gateway timeout\n", 504}
}
return
}原理很简单:缓冲通道允许发送方在缓冲区未满时非阻塞发送。此处缓冲容量为 1,子 goroutine 总能成功将 *Response 写入通道并立即结束。即使主 goroutine 已超时返回,该 *Response 会暂存于通道缓冲中;而由于主 goroutine 退出后,ch 变量不再被引用,整个通道及其缓冲内容最终会被 GC 回收。
⚠️ 注意事项:
- 缓冲仅解决“发送端阻塞”问题,不改变业务逻辑语义。若需取消后台任务(如中断 Get(url) 的网络请求),应配合 context.Context;
- 缓冲大小需严格匹配预期并发写入次数(本例中最多 1 次写入,故 1 足够);过大缓冲可能掩盖设计缺陷或造成意外内存积压;
- 更健壮的实践是:显式关闭通道 + 使用 select 的 default 或 ok 检查,或采用 context.WithTimeout 封装可取消操作。
总结:Go 的 channel 是强大而精巧的并发原语,但其同步语义要求开发者对 goroutine 生命周期有清晰把控。无缓冲 channel 在超时场景下极易引发静默泄漏;合理使用缓冲、结合上下文取消机制,是编写高可靠性 Go 服务的关键习惯。










