排查了几天几夜,Go 通道迭代的坑太坑了

举报
golang学习记 发表于 2026/06/25 15:33:17 2026/06/25
【摘要】 在 Go 语言中,for range 遍历通道是一个极其常见的模式。然而,正是这个看似无害的语法糖,在生产环境中埋下了一种隐晦的 goroutine 泄漏陷阱。最近,我在调试一个自研的工具时,就再次掉进了这个“经典的坑”。for range 遍历通道看起来非常优雅,但它隐藏着一个很容易踩的坑:如果通道没有关闭,for range 会永远阻塞,导致 goroutine 泄漏。下面我用一个你能立...

在 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 读取通道时,立刻反问自己:

“这个通道在哪里、何时、被谁关闭?”

如果答案不清晰,代码里就可能埋着定时炸弹。掌握这种模式,比熟悉一百个库函数更能体现对语言本质的理解。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。