bigywater.tech
Go · 27

闭包捕获变量:Go 1.22 的 for 循环变量语义改了什么

Go 1.22 把 for 循环的变量从“整个循环一份”改成了“每次迭代一份”,修掉了一个著名的陷阱。这篇不靠文档,用同一份代码在不同的 go.mod 版本下构建运行,对比结果;再看单文件覆盖、编译器的诊断,以及性能代价到底有多大。

3 3 3 → 0 1 2
同一份代码,go.mod 从 go 1.21 改成 go 1.22 之后三个闭包的返回值
go 指令
循环变量语义由 go.mod(与文件级 //go:build)里的 Go 版本决定,而不是工具链版本
102 vs 101
闭包被保存的 100 次迭代,两种语义的分配次数

旧语义与新语义

实测 · 本机执行输出
  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(旧版本里的惯用法,本文没有运行)。

分步演示

闭包与循环变量
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。每个版本的对比都是同一份源码,只改 go.mod 的 go 指令(1.21 / 1.22 / 1.27)后构建运行;分配次数来自 testing.AllocsPerRun(100 次);编译器诊断来自 -gcflags=-d=loopvar=2 与 -m 的原样输出
    • 没有验证:GOEXPERIMENT 或更老的工具链上的行为、go.mod 里 toolchain 行的影响、闭包按值捕获优化的条件、for 循环里 defer 引用循环变量的情形、v := v 惯用法在新旧版本里的行为
    京ICP备2026067502号-1京公网安备11010502063487号