Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)
你大概率写过这样的代码:开一批 goroutine 并发处理切片里的元素,结果打印出来的值全是最后一个,或者干脆乱序重复。这不是并发调度的锅,而是 for-range 循环变量的作用域问题。这个坑在 Go 1.22 之前坑了无数人,Go 1.22 改了语义之后又出现了「同一份代码在两个版本跑出不同结果」的新问题。这篇把来龙去脉和正确写法一次讲清。
先复现这个经典 bug
go
package main
import (
"fmt"
"sync"
)
func main() {
nums := []int{1, 2, 3}
var wg sync.WaitGroup
for _, n := range nums {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(n) // 期望 1 2 3(乱序),实际可能全是 3
}()
}
wg.Wait()
}
在 Go 1.21 及更早 版本里,这段代码大概率打印三个 3(顺序还不定)。很多人第一反应是「goroutine 调度有问题」,其实跟调度没关系。
问题根源:循环变量是「复用」的
在 Go 1.22 之前,for _, n := range nums 里的 n 在整个循环中只有一个实例 。每次迭代不是新建一个 n,而是把新值赋给同一个 n。
关键在于:goroutine 里的闭包捕获的是 n 这个变量的地址 ,不是它某一刻的值。等 goroutine 真正被调度执行时,循环往往早就跑完了,n 的最终值停在最后一个元素 3,于是三个 goroutine 读到的都是 3。
用一句话记:闭包捕获的是变量,不是值。 循环共用一个变量,闭包自然共用同一份数据。
不止 goroutine,把地址存进切片也一样中招:
go
var ptrs []*int
for _, n := range nums {
ptrs = append(ptrs, &n) // 三个指针指向同一个 n
}
for _, p := range ptrs {
fmt.Println(*p) // Go 1.21: 全是 3
}
Go 1.21 及之前的正确写法
写法一:循环内部重新声明(最常用)
go
for _, n := range nums {
n := n // 关键:在循环体内新建一个同名局部变量,遮蔽外层的 n
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(n) // 每个 goroutine 捕获的是各自的副本
}()
}
n := n 看着别扭,但它的作用是:每次迭代都创建一个新的 局部 n,把当前值拷进去。闭包捕获的是这个新变量,每个 goroutine 各拿各的,互不干扰。
写法二:用参数传值
go
for _, n := range nums {
wg.Add(1)
go func(n int) { // n 作为参数,调用时就完成了值拷贝
defer wg.Done()
fmt.Println(n)
}(n) // 立刻把当前值传进去
}
调用 go func(n int){...}(n) 时,实参 n 的当前值被拷贝给形参,这个拷贝发生在循环迭代的当下,而不是 goroutine 执行的当下。所以每个 goroutine 拿到的是正确的快照值。
这两种写法本质一样:在迭代的当下把值固定下来,不让闭包去追那个会变的循环变量。
Go 1.22 改了什么
从 Go 1.22 开始,循环变量的作用域改成了每次迭代都是一个新变量。也就是说,上面那段最初的「有 bug」代码,在 Go 1.22 里直接就是对的:
go
// go.mod 里 go 1.22+,这段现在打印 1 2 3(乱序)
for _, n := range nums {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(n) // Go 1.22+ 正确,每次迭代 n 都是新的
}()
}
这是 Go 团队少见的破坏性语义变更 ,靠 go.mod 里声明的版本号来控制:
go.mod写go 1.22或更高 → 新语义,每轮迭代新变量。go.mod写go 1.21或更低 → 老语义,循环共用变量。
编译器按模块声明的版本决定用哪套语义,所以同一份源码,改一下 go.mod 的版本号,行为就变了。
新的坑:版本混淆
语义变更解决了老 bug,却带来两个新的现实问题。
第一,老代码里的 n := n 现在是冗余的,但它无害,别急着删。因为你的库可能同时被 1.21 和 1.22 的项目引用,留着它两个版本都安全:
go
for _, n := range nums {
n := n // 1.22 下冗余但无害;1.21 下必需。跨版本兼容就留着
go work(&n)
}
第二,依赖「循环结束后变量停在最后一个值」的代码会被悄悄改坏。 这种写法本来就少见、也不推荐,但确实存在:
go
var last int
for _, n := range nums {
last = n
_ = n
}
// 用 last,没问题,last 是普通变量,不受影响
真正会变的是这种「循环外读循环变量」------但 Go 本来就不允许在循环外访问 n(作用域限制),所以实际影响面比想象小。真正要留意的是跨版本行为差异:CI 用 1.22、老服务器编译用 1.20,同一份代码可能跑出不同结果。
排查清单
遇到「goroutine/闭包读到的循环变量值不对」,按这个顺序查:
- 看
go.mod的版本 :go 1.21及以下 → 老语义,必须手动拷贝;go 1.22+ → 新语义,可以不拷。 - 确认是不是闭包捕获 :goroutine、
append(&n)、defer func(){...n...}()都是捕获变量地址的典型场景。 - 加
n := n一律安全:不确定版本时,加上它两个版本都对,代价只是一行。 - 用
go vet:go vet的loopclosure检查能在编译期揪出「在 1.21 语义下会出错」的循环闭包,CI 里挂上它。
bash
# 让 vet 帮你抓循环闭包问题
go vet ./...
小结
- Go 1.22 之前 :
for-range的循环变量整个循环共用一个,闭包捕获的是变量本身,goroutine 延迟执行时读到的是最终值 → 全打印最后一个。 - 修法:循环体内
n := n,或用go func(n int){}(n)传值------本质都是在迭代当下把值固定住。 - Go 1.22 之后 :每轮迭代都是新变量 ,老 bug 自动消失,但要警惕同一代码在不同 Go 版本行为不同。
- 跨版本兼容就保留
n := n(1.22 下冗余但无害),CI 挂go vet兜底。
一句话记忆:闭包捕获的是变量不是值;循环变量是不是「每轮一个」,取决于你的 go.mod 写的是不是 1.22。