Go Wiki: LoopvarExperiment
在 Go 1.22 中,Go 改变了 for 循环变量的语义,以防止在每次迭代的闭包和 goroutine 中意外共享。
新语义在 Go 1.21 中也可用,作为该变更的初步实现,通过在构建程序时设置 GOEXPERIMENT=loopvar
来启用。
此页面回答有关该变更的常见问题。
我如何尝试此变更?
在 Go 1.22 及更高版本中,该变更由模块 go.mod 文件中的语言版本控制。如果语言版本是 go1.22 或更高,则模块将使用新的循环变量语义。
使用 Go 1.21,通过 GOEXPERIMENT=loopvar
构建程序,例如:
GOEXPERIMENT=loopvar go install my/program
GOEXPERIMENT=loopvar go build my/program
GOEXPERIMENT=loopvar go test my/program
GOEXPERIMENT=loopvar go test my/program -bench=.
...
这解决了什么问题?
考虑一个像这样的循环:
func TestAllEvenBuggy(t *testing.T) {
testCases := []int{1, 2, 4, 6}
for _, v := range testCases {
t.Run("sub", func(t *testing.T) {
t.Parallel()
if v&1 != 0 {
t.Fatal("odd v", v)
}
})
}
}
此测试旨在检查所有测试用例是否都是偶数(它们不是!),但它在旧语义下通过。问题在于 t.Parallel
停止闭包并让循环继续,然后在 TestAllEvenBuggy
返回时并行运行所有闭包。等到闭包中的 if 语句执行时,循环已经完成,v 具有其最终迭代值 6。所有四个子测试现在并行继续执行,并且它们都检查 6 是否是偶数,而不是检查每个测试用例。
此问题的另一个变体是:
func TestAllEven(t *testing.T) {
testCases := []int{0, 2, 4, 6}
for _, v := range testCases {
t.Run("sub", func(t *testing.T) {
t.Parallel()
if v&1 != 0 {
t.Fatal("odd v", v)
}
})
}
}
这个测试没有错误地通过,因为 0、2、4 和 6 都是偶数,但它也没有测试是否正确处理了 0、2 和 4。与 TestAllEvenBuggy
一样,它测试了 6 四次。
这种 bug 的另一种不那么常见但仍然频繁的形式是在三段式 for 循环中捕获循环变量:
func Print123() {
var prints []func()
for i := 1; i <= 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, print := range prints {
print()
}
}
这个程序看起来会打印 1、2、3,但实际上打印 4、4、4。
这种意外共享 bug 困扰着所有 Go 程序员,无论他们是刚开始学习 Go 还是已经使用了十年。关于这个问题的讨论是 Go FAQ 中最早的条目之一。
以下是来自 Let's Encrypt 的 一个由这种 bug 导致的生产问题的公开示例。相关代码如下:
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a
// protobuf authorizations map
func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {
resp := &sapb.Authorizations{}
for k, v := range m {
// Make a copy of k because it will be reassigned with each loop.
kCopy := k
authzPB, err := modelToAuthzPB(&v)
if err != nil {
return nil, err
}
resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
}
return resp, nil
}
请注意 kCopy := k
语句,它旨在防止在循环体末尾使用的 &kCopy
。不幸的是,事实证明 modelToAuthzPB
保留了指向 v
中几个字段的指针,这在阅读此循环时无法得知。
此 bug 的最初影响是 Let's Encrypt 需要 撤销超过 300 万张错误签发的证书。他们最终没有这样做,因为这会对互联网安全产生负面影响,而是 争取获得豁免,但这让你对这种影响有所了解。
相关代码在编写时经过了仔细审查,作者显然意识到了潜在问题,因为他们编写了 kCopy := k
,但它仍然存在一个重大 bug,除非你确切知道 modelToAuthzPB
的作用,否则这个 bug 是不可见的。
解决方案是什么?
解决方案是使在 for 循环中使用 :=
声明的循环变量在每次迭代中都是变量的不同实例。这样,如果值被闭包或 goroutine 捕获或以其他方式超出了迭代生命周期,则以后对它的引用将看到它在该迭代期间的值,而不是被后续迭代覆盖的值。
对于 range 循环,效果就像每个循环体都以 k := k
和 v := v
开始,用于每个 range 变量。在上面的 Let's Encrypt 示例中,kCopy := k
将是不必要的,并且可以避免由于缺少 v := v
引起的 bug。
对于三段式 for 循环,效果就像每个循环体都以 i := i
开始,然后在循环体结束时发生反向赋值,将每次迭代的 i
复制回用于准备下一次迭代的 i
。这听起来很复杂,但实际上所有常见的 for 循环惯用法都像以前一样工作。循环行为唯一改变的情况是当 i
被捕获并与其它东西共享时。例如,这段代码像以前一样运行:
for i := 0;; i++ {
if i >= len(s) || s[i] == '"' {
return s[:i]
}
if s[i] == '\\' { // skip escaped char, potentially a quote
i++
}
}
有关完整详情,请参阅 设计文档。
此更改会破坏程序吗?
是的,有可能编写会被此更改破坏的程序。例如,这里有一个使用单元素映射将列表中的值相加的令人惊讶的方法:
func sum(list []int) int {
m := make(map[*int]int)
for _, x := range list {
m[&x] += x
}
for _, sum := range m {
return sum
}
return 0
}
它依赖于循环中只有一个 x
的事实,因此 &x
在每次迭代中都是相同的。使用新语义,x
会在迭代中逸出,因此 &x
在每次迭代中都不同,并且映射现在有多个条目而不是单个条目。
这里有一个令人惊讶的方法来打印 0 到 9 的值:
var f func()
for i := 0; i < 10; i++ {
if i == 0 {
f = func() { print(i) }
}
f()
}
它依赖于这样一个事实:在第一次迭代中初始化的 f
每次被调用时都会“看到” i
的新值。使用新语义,它会打印 0 十次。
尽管可以构造出使用新语义会破坏的人工程序,但我们尚未看到任何实际程序会错误执行。
C# 在 C# 5.0 中也进行了类似的更改,他们也报告说 这种更改导致的问题很少。
此更改破坏实际程序的频率如何?
根据经验,几乎从不。对 Google 代码库的测试发现许多测试得到了修复。它还识别出一些由于循环变量和 t.Parallel
之间的错误交互而错误通过的 bug 测试,就像上面 TestAllEvenBuggy
中那样。我们重写了这些测试以纠正它们。
我们的经验表明,新语义修复 bug 代码的频率远高于破坏正确代码的频率。新语义仅在约 8000 个测试包中导致了 1 个测试失败(所有这些都是错误通过的测试),但在我们整个代码库中运行更新的 Go 1.20 loopclosure
vet 检查以更高的速率标记了测试:1/400 (8000 中的 20)。loopclosure
检查器没有误报:所有报告都是我们源代码树中 t.Parallel
的 bug 用法。也就是说,约 5% 的标记测试类似于 TestAllEvenBuggy
;其他 95% 类似于 TestAllEven
:尚未测试其预期功能,但即使修复了循环变量 bug,也是对正确代码的正确测试。
自 2023 年 5 月初以来,Google 一直在标准生产工具链中对所有 for 循环应用新的循环语义,没有报告任何问题(并获得了许多赞扬)。
有关我们在 Google 经验的更多详情,请参阅 这篇综述。
我们还在 Kubernetes 中尝试了新的循环语义。它识别出两个新失败的测试,原因是底层代码中潜在的循环变量作用域相关 bug。相比之下,将 Kubernetes 从 Go 1.20 更新到 Go 1.21 识别出三个新失败的测试,原因是依赖于 Go 本身未文档化的行为。由于循环变量更改而导致的两个测试失败与普通的版本更新相比,并不是一个显著的新负担。
此更改会通过导致更多分配而使程序变慢吗?
绝大多数循环不受影响。只有当循环变量取地址 (&i
) 或被闭包捕获时,循环才会以不同的方式编译。
即使对于受影响的循环,编译器的逃逸分析也可能确定循环变量仍然可以在栈上分配,这意味着没有新的分配。
但是,在某些情况下,会增加额外的分配。有时,额外的分配是修复潜在 bug 所固有的。例如,Print123 现在分配了三个独立的 int(结果是在闭包内部)而不是一个,这对于在循环结束后打印三个不同的值是必要的。在极少数其他情况下,循环可能在共享变量下是正确的,并且在独立变量下仍然是正确的,但现在分配了 N 个不同的变量而不是一个。在非常热的循环中,这可能会导致速度变慢。这些问题应该在内存分配配置文件中显而易见(使用 pprof --alloc_objects
)。
对公共“bent”基准测试套件的基准测试显示,总体上没有统计学上显著的性能差异,我们也没有在 Google 的内部生产使用中观察到任何性能问题。我们预计大多数程序不会受到影响。
此更改如何部署?
与 Go 一般的 兼容性方法 一致,新的 for 循环语义仅适用于正在编译的包来自包含声明 Go 1.22 或更高版本(如 go 1.22
或 go 1.23
)的 go
行的模块。这种保守的方法确保没有任何程序会因为简单地采用新的 Go 工具链而改变行为。相反,每个模块作者控制其模块何时更改为新语义。
GOEXPERIMENT=loopvar
试用机制没有使用声明的 Go 语言版本:它无条件地将新语义应用于程序中的每个 for 循环。这提供了最坏情况行为,以帮助识别更改的最大可能影响。
我可以看到代码中受此更改影响的位置列表吗?
是的。您可以在命令行上使用 -gcflags=all=-d=loopvar=2
进行构建。这将为每个编译方式不同的循环打印一条警告样式的输出行,例如:
$ go build -gcflags=all=-d=loopvar=2 cmd/go
...
modload/import.go:676:7: loop variable d now per-iteration, stack-allocated
modload/query.go:742:10: loop variable r now per-iteration, heap-allocated
all=
会打印关于构建中所有包的更改信息。如果您省略 all=
,例如 -gcflags=-d=loopvar=2
,则只有您在命令行上指定的包(或当前目录中的包)才会发出诊断信息。
我的测试在更改后失败。如何调试它?
一个名为 bisect
的新工具允许在程序的不同子集上启用该更改,以识别在编译时哪些特定循环会触发测试失败。如果您有失败的测试,bisect
将识别导致问题的特定循环。使用:
go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test
有关实际示例,请参阅 此评论的 bisect 副本部分,有关更多详细信息,请参阅 bisect 文档。
这是否意味着我的循环中不再需要编写 x := x
了?
在您将模块更新为使用 go1.22 或更高版本之后,是的。
此内容是 Go Wiki 的一部分。