排查了几天几夜,Go 通道迭代的坑太坑了
在 Go 语言中,for range 遍历通道是一个极其常见的模式。然而,正是这个看似无害的语法糖,在生产环境中埋下了一种隐晦的 goroutine 泄漏陷阱。最近,我在调试一个自研的工具时,就再次掉进了这个“经典的坑”。
for range 遍历通道看起来非常优雅,但它隐藏着一个很容易踩的坑:如果通道没有关闭,for range 会永远阻塞,导致 goroutine 泄漏。
下面我用一个你能立刻自己动手运行的最小示例来说明这个问题。
泄漏版本:运行这段代码,你会看到一个永远不会退出的 goroutine
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 每 2 秒打印一次当前 goroutine 数量
go func() {
for {
time.Sleep(2 * time.Second)
fmt.Printf("当前 goroutine 数量: %d\n", runtime.NumGoroutine())
}
}()
// 启动我们的"问题函数"
leakyFunction()
// 主程序不退出,方便观察
select {}
}
// leakyFunction 启动一个 goroutine 后立即返回
// 但启动的那个 goroutine 永远不会退出
func leakyFunction() {
ch := make(chan int)
// 启动一个 goroutine 去读通道
go func() {
fmt.Println("收集器启动,开始等待数据...")
for v := range ch { // ⚠️ 问题在这里:ch 永远不会被关闭
fmt.Println("收到:", v)
}
fmt.Println("收集器退出") // 这行永远不会被执行
}()
// 发送 3 个数据
for i := 1; i <= 3; i++ {
ch <- i
fmt.Printf("发送: %d\n", i)
}
// ❌ 漏掉了 close(ch)
// 收集器的 goroutine 在读完 3 个数据后,
// 会继续等待第 4 个数据,永远阻塞
fmt.Println("leakyFunction 返回")
}
运行结果:
发送: 1
收集器启动,开始等待数据...
收到: 1
发送: 2
收到: 2
发送: 3
收到: 3
leakyFunction 返回
当前 goroutine 数量: 2
当前 goroutine 数量: 2
当前 goroutine 数量: 2
... (每 2 秒打印一次,数量永远是 2)
看到没?收集器退出 这行永远不会打印,那个 goroutine 永远卡在了 for range 上。
为什么 for range 会阻塞?
关键要理解:for range 只在通道被关闭时才会结束。
对比一下这两种写法:
// 写法 A:显式接收(不会阻塞)
ch := make(chan int)
go func() {
<-ch // 收一个,结束
<-ch // 收一个,结束
<-ch // 收一个,结束
// 三次显式接收后,goroutine 自然结束
}()
ch <- 1
ch <- 2
ch <- 3
// goroutine 正常退出 ✅
// 写法 B:for range(会阻塞)
ch := make(chan int)
go func() {
for v := range ch { // 会一直等,直到 close(ch)
_ = v
}
}()
ch <- 1
ch <- 2
ch <- 3
// 没有 close(ch),goroutine 永远等待 ❌
显式接收的 goroutine 知道自己要接收几个值。而 for range 不知道,它会一直读到通道关闭为止。
修复版本:只需加一行 close(ch)
func fixedFunction() {
ch := make(chan int)
go func() {
fmt.Println("收集器启动,开始等待数据...")
for v := range ch {
fmt.Println("收到:", v)
}
fmt.Println("收集器退出") // ✅ 现在这行会被执行
}()
for i := 1; i <= 3; i++ {
ch <- i
fmt.Printf("发送: %d\n", i)
}
close(ch) // ✅ 关键修复:关闭通道
fmt.Println("fixedFunction 返回")
}
运行结果:
收集器启动,开始等待数据...
发送: 1
收到: 1
发送: 2
收到: 2
发送: 3
收到: 3
收集器退出
fixedFunction 返回
重要提醒:缓冲通道也救不了你
很多人会想:“用缓冲通道是不是就不会阻塞了?” 答案是不会。
ch := make(chan int, 100) // 缓冲100个
go func() {
for v := range ch { // 读完100个后,依然会阻塞等第101个
fmt.Println(v)
}
}()
// 即使发了100个,没有 close(ch),
// 这个 goroutine 仍然会在读完100个后永久阻塞
只有关闭通道,才能终止 for range。
总结
这个例子揭示了一个容易被忽视但重要的原则:
使用
for range读取通道时,你必须确保通道会在某个时刻被关闭。
这是一个“契约”:
- 发送方(或控制生命周期的代码)负责在发送完数据后调用
close() - 接收方使用
for range读取,依赖这个close()信号来退出
如果忘记关闭通道,for range 就会变成一个永远不会结束的循环,那个 goroutine 就泄漏了。
对于 Go 开发者,要培养“通道生命周期管理”的思维模式。每当写 for range 读取通道时,立刻反问自己:
“这个通道在哪里、何时、被谁关闭?”
如果答案不清晰,代码里就可能埋着定时炸弹。掌握这种模式,比熟悉一百个库函数更能体现对语言本质的理解。
- 点赞
- 收藏
- 关注作者
评论(0)