问题
为什么在 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不推荐? → 语法上可以,但容易和外层变量混淆,换成n、value更清晰。 - 这个问题只会出现在 goroutine 里吗? → 不是,只要闭包延后执行,而外层变量又会变化,就可能出现类似问题。
- 如果闭包就是要修改外层变量怎么办? → 可以,但要明确这是共享状态,并考虑同步问题。
技术拓展
一个简单判断口诀
看到下面三个元素同时出现时,就要提高警惕:
for- 闭包
func() {} - 延后执行(goroutine、回调、异步任务)
这通常意味着应该优先考虑“显式传值”而不是“直接捕获外层变量”。