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 四次。
此错误的另一种不太常见但仍然频繁的形式是在三语句 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。
这种意外共享错误会影响所有 Go 程序员,无论他们刚刚开始学习 Go 还是已经使用它十年了。关于此问题的讨论是 Go 常见问题解答中最早的条目之一。
这是一个 由这种错误引起的生产问题的公开示例,来自 Let’s Encrypt。相关代码如下所示
// 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
中几个字段的指针,在阅读此循环时无法知道这一点。
此错误的最初影响是 Let’s Encrypt 需要 撤销超过 300 万个错误颁发的证书。由于这将对互联网安全产生负面影响,他们最终没有这样做,而是 争论一个例外,但这让你了解了这种影响。
编写时对相关代码进行了仔细审查,作者显然意识到了潜在的问题,因为他们编写了 kCopy := k
,但它仍然存在重大错误,除非您也确切地知道 modelToAuthzPB
的作用,否则该错误是不可见的。
解决方案是什么?
解决方案是使使用 :=
在 for 循环中声明的循环变量在每次迭代中成为变量的不同实例。这样,如果该值被闭包或 goroutine 捕获或以其他方式超出迭代,则对它的后续引用将看到它在该迭代期间具有的值,而不是被后续迭代覆盖的值。
对于 range 循环,效果就像每个循环体以 k := k
和 v := v
开头,用于每个 range 变量。在上面的 Let’s Encrypt 示例中,kCopy := k
将不是必需的,并且由于没有 v := v
而导致的错误将被避免。
对于三语句 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
之间的不良交互而导致错误通过的错误测试,如上面的 TestAllEvenBuggy
。我们重写了这些测试以更正它们。
我们的经验表明,新的语义修复错误代码的频率远高于破坏正确代码的频率。新的语义仅在约每 8,000 个测试包中导致一个测试失败(所有这些都是错误通过的测试),但在我们的整个代码库上运行更新的 Go 1.20 loopclosure
vet 检查会以更高的频率标记测试:1/400(8,000 中的 20)。loopclosure
检查器没有误报:所有报告都是我们源代码树中 t.Parallel
的错误用法。也就是说,大约 5% 的标记测试类似于 TestAllEvenBuggy
;另外 95% 类似于 TestAllEven
:尚未测试其预期内容,但即使修复了循环变量错误,也是对正确代码的正确测试。
自 2023 年 5 月初以来,Google 一直在标准生产工具链中应用于所有 for 循环的新循环语义,并且没有一个报告的问题(以及许多欢呼声)。
有关我们在 Google 的经验的更多详细信息,请参阅 此文章。
我们还 在 Kubernetes 中尝试了新的循环语义。它识别了两个由于底层代码中潜在的循环变量作用域相关错误而导致的新失败测试。相比之下,将 Kubernetes 从 Go 1.20 更新到 Go 1.21 识别了三个由于依赖 Go 本身未公开的行为而导致的新失败测试。与普通的版本更新相比,由于循环变量更改而导致的两个测试失败并不是一项重大新负担。
更改会导致更多分配而使程序变慢吗?
绝大多数循环都不会受到影响。仅当循环变量取其地址 (&i
) 或被闭包捕获时,循环才会以不同的方式编译。
即使对于受影响的循环,编译器的逃逸分析也可能确定循环变量仍然可以分配到堆栈中,这意味着没有新的分配。
但是,在某些情况下,会添加额外的分配。有时,额外的分配是修复潜在错误所固有的。例如,Print123 现在分配了三个单独的 int(事实证明在闭包内),而不是一个,这对于在循环结束后打印三个不同的值是必要的。在其他一些罕见的情况下,循环在使用共享变量时可能是正确的,并且在使用单独的变量时仍然正确,但现在分配了 N 个不同的变量而不是一个。在非常热的循环中,这可能会导致速度下降。此类问题应在内存分配配置文件中很明显(使用 pprof --alloc_objects
)。
对公共“bent”基准套件进行的基准测试显示,总体上没有统计上显着的性能差异,而且我们在 Google 的内部生产使用中也没有观察到任何性能问题。我们预计大多数程序都不会受到影响。
更改是如何部署的?
与 Go 对兼容性的总体 方法 一致,新的 for 循环语义仅在编译的包来自包含声明 Go 1.22 或更高版本的 go
行的模块时才适用,例如 go 1.22
或 go 1.23
。这种保守的方法确保了没有任何程序会因为简单地采用新的 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 的一部分。