Go 1.22 把 for 循环的变量从“整个循环一份”改成了“每次迭代一份”,修掉了一个著名的陷阱。这篇不靠文档,用同一份代码在不同的 go.mod 版本下构建运行,对比结果;再看单文件覆盖、编译器的诊断,以及性能代价到底有多大。
go.mod 写 go 1.21:
三个闭包捕获 for 的 i,循环结束后依次调用: 3 3 3
取 &i 存进切片:三个指针相同?true;解引用得到 3 3 3
for range 里起 goroutine 引用 v(每个 goroutine 排队后才运行),收集到:[30 30 30]
go.mod 写 go 1.22:
三个闭包捕获 for 的 i,循环结束后依次调用: 0 1 2
取 &i 存进切片:三个指针相同?false;解引用得到 0 1 2
for range 里起 goroutine 引用 v(每个 goroutine 排队后才运行),收集到:[10 20 30]
go.mod 写 go 1.27:
三个闭包捕获 for 的 i,循环结束后依次调用: 0 1 2
取 &i 存进切片:三个指针相同?false;解引用得到 0 1 2
for range 里起 goroutine 引用 v(每个 goroutine 排队后才运行),收集到:[10 20 30]旧语义下整个循环只有一个 i,闭包与 &i 拿到的都是它,循环结束时它是 3;新语义下每次迭代有自己的 i,闭包得到各自的值。for range 的 v 同理——这就是 goroutine 引用循环变量时旧版本里常见的 bug。工具链是 Go 1.27.1,但 go.mod 写 go 1.21 时它仍按旧语义编译,所以升级工具链不会悄悄改变旧模块的行为。
新语义文件: [0 1 2] 旧语义文件: [3 3 3]
语言版本也可以用文件头部的 //go:build go1.21 约束到单个文件。这个文件会按旧语义编译,可用来逐个文件地迁移或定位问题。需要注意的是,版本约束带来的这种效果是实测观察,本文没有去读工具链里处理它的代码。
main.go:11:6: loop variable i now per-iteration, stack-allocated main.go:19:6: loop variable i now per-iteration, heap-allocated main.go:25:9: loop variable v now per-iteration, stack-allocated
-gcflags=-d=loopvar=2 打印每个“语义已改变”的循环,并说明那个变量最终放在栈上还是堆上。升级到 1.22 之前后对一个大项目跑一遍,能找到真正会被影响的位置。
go.mod 写 go 1.21:闭包被保存(逃逸)的循环 build(100):102 次分配;只在循环里立刻调用的闭包 immediate(100):0 次分配 go.mod 写 go 1.27:闭包被保存(逃逸)的循环 build(100):101 次分配;只在循环里立刻调用的闭包 immediate(100):0 次分配
人们担心“每次迭代一个新变量”会多出分配。实测的闭包保存例子里没有多出:新语义是 101 次,旧语义反而是 102 次。这是这个例子的结果:闭包捕获的变量如果在被捕获后不再修改,闭包可以直接保存它的值,我没有读编译器里这条优化的实现,所以只能说“实测没有额外分配”。
两个闭包共享同一个 n:inc() 三次后 get() = 3
x := 1;f := func() int { return x };x = 99;f() = 99(闭包创建之后的修改也看得到)
把 y 作为参数传进去(参数是拷贝):y = 99 之后 g() = 1
编译器:main.go:6:2: moved to heap: n不论哪个版本,闭包捕获的是变量本身而不是值:两个闭包共享 n,创建闭包之后修改的 x 也能被看到(n 因此被编译器放到堆上)。想要拷贝就把它作为参数传进去,或者在循环体里写 v := v(旧版本里的惯用法,本文没有运行)。