跳转到主要内容
返回文章列表

Go 闭包与循环变量陷阱

Go3 分钟阅读 · 975
#Go#闭包#goroutine#循环变量#陷阱

问题

为什么在 for 循环里启动 goroutine 时,直接使用循环变量 i 容易出问题?为什么常见写法是 go func(n int) {}(i)

回答

核心结论

问题的本质不是“goroutine 很奇怪”,而是:

  • 闭包捕获的是 外层变量本身
  • 循环里的变量会在后续迭代中继续变化
  • goroutine 何时真正执行并不受你精确控制

所以当 goroutine 运行起来时,它看到的常常已经不是“创建那一刻的值”,而是“后来被更新过的变量”。

先看现象

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)
    }()
}

很多初学者预期会打印:

0 1 2 3 4

但实际可能打印出重复值,甚至大量接近循环结束时的值。

为什么会这样

1. 闭包捕获的是变量,不是当时的值副本

i := 10

f := func() {
    fmt.Println(i)
}

i = 20
f()

这里打印的是 20,因为闭包读的是外层变量 i 当前的内容,而不是“定义闭包那一刻把 10 拷贝进去了”。

2. 循环里的 i 会持续变化

for i := 0; i < 5; i++ 中,i 会不断递增。

3. goroutine 是异步执行的

启动 goroutine 只表示“安排它去执行”,并不代表它会立刻执行。

所以很常见的时间线是:

循环很快跑完

i 已经变化到后面的值

goroutine 才开始真正读取 i

正确写法:把值作为参数传进去

for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)
    }(i)
}

这时关键变化在于:

  • (i) 会在当前迭代就把值传给参数 n
  • 每个 goroutine 拿到的是自己的参数副本
  • 后面循环变量再怎么变化,都不会影响已经传进去的 n

两种写法的本质区别

写法 读到的是什么 风险
go func() { fmt.Println(i) }() 外层变量 i 当前值 容易读到后续变化后的值
go func(n int) { fmt.Println(n) }(i) 当前迭代时传入的副本 更符合预期

用类比理解

  • 闭包直接读 i:像所有人都盯着同一块黑板,黑板上的数字会继续改
  • 参数传值:像每个人都拿到一张写好数字的小纸条,之后黑板再改也不影响纸条

for range 里也要有同样意识

例如:

for _, v := range nums {
    go func() {
        fmt.Println(v)
    }()
}

也要小心同类问题。更稳妥的写法仍然是:

for _, v := range nums {
    go func(n int) {
        fmt.Println(n)
    }(v)
}

Go 1.22 的变化怎么理解

Go 1.22 对循环变量语义做了改进,减少了这类经典坑在部分场景中的出现概率。

但实际工程里仍然建议保留一个稳妥习惯:

只要 goroutine、闭包、回调和循环变量一起出现,就优先显式传参。

原因很简单:

  • 可读性更强
  • 不依赖读者记住具体语言版本差异
  • 对旧代码和老项目也更安全

一句话总结

在循环里启动 goroutine 时,问题的根源是“闭包读取了会继续变化的外层变量”;最稳妥的做法是显式把当前值作为参数传进去。

相关问题

  • 为什么把参数名也写成 i 不推荐? → 语法上可以,但容易和外层变量混淆,换成 nvalue 更清晰。
  • 这个问题只会出现在 goroutine 里吗? → 不是,只要闭包延后执行,而外层变量又会变化,就可能出现类似问题。
  • 如果闭包就是要修改外层变量怎么办? → 可以,但要明确这是共享状态,并考虑同步问题。

技术拓展

一个简单判断口诀

看到下面三个元素同时出现时,就要提高警惕:

  • for
  • 闭包 func() {}
  • 延后执行(goroutine、回调、异步任务)

这通常意味着应该优先考虑“显式传值”而不是“直接捕获外层变量”。

Learning Note

本文为个人学习记录,主要来自与 AI 对话后的知识整理与实践总结,仅供个人学习参考。