Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)你大概率写过这样的代码:开一批 goroutine 并发处理切片里的元素,结果打印出来的值全是最后一个,或者干脆乱序重复。这不是并发调度的锅,而是for-range循环变量的作用域问题。这个坑在 Go 1.22 之前坑了无数人,Go 1.22 改了语义之后又出现了「同一份代码在两个版本跑出不同结果」的新问题。这篇把来龙去脉和正确写法一次讲清。先复现这个经典 bugpackagemainimport(fmtsync)funcmain(){nums:[]int{1,2,3}varwg sync.WaitGroupfor_,n:rangenums{wg.Add(1)gofunc(){deferwg.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,把地址存进切片也一样中招:varptrs[]*intfor_,n:rangenums{ptrsappend(ptrs,n)// 三个指针指向同一个 n}for_,p:rangeptrs{fmt.Println(*p)// Go 1.21: 全是 3}Go 1.21 及之前的正确写法写法一:循环内部重新声明(最常用)for_,n:rangenums{n:n// 关键:在循环体内新建一个同名局部变量,遮蔽外层的 nwg.Add(1)gofunc(){deferwg.Done()fmt.Println(n)// 每个 goroutine 捕获的是各自的副本}()}n : n看着别扭,但它的作用是:每次迭代都创建一个新的局部n,把当前值拷进去。闭包捕获的是这个新变量,每个 goroutine 各拿各的,互不干扰。写法二:用参数传值for_,n:rangenums{wg.Add(1)gofunc(nint){// n 作为参数,调用时就完成了值拷贝deferwg.Done()fmt.Println(n)}(n)// 立刻把当前值传进去}调用go func(n int){...}(n)时,实参n的当前值被拷贝给形参,这个拷贝发生在循环迭代的当下,而不是 goroutine 执行的当下。所以每个 goroutine 拿到的是正确的快照值。这两种写法本质一样:在迭代的当下把值固定下来,不让闭包去追那个会变的循环变量。Go 1.22 改了什么从Go 1.22开始,循环变量的作用域改成了每次迭代都是一个新变量。也就是说,上面那段最初的「有 bug」代码,在 Go 1.22 里直接就是对的:// go.mod 里 go 1.22,这段现在打印 1 2 3(乱序)for_,n:rangenums{wg.Add(1)gofunc(){deferwg.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 的项目引用,留着它两个版本都安全:for_,n:rangenums{n:n// 1.22 下冗余但无害;1.21 下必需。跨版本兼容就留着gowork(n)}第二,依赖「循环结束后变量停在最后一个值」的代码会被悄悄改坏。这种写法本来就少见、也不推荐,但确实存在:varlastintfor_,n:rangenums{lastn_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 里挂上它。# 让 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。