Go 博客
修复 Go 1.22 中的 for 循环
Go 1.21 包含对 for
循环作用域更改的预览,我们计划在 Go 1.22 中发布此更改,从而消除最常见的 Go 错误之一。
问题
如果您编写过任何数量的 Go 代码,您可能犯过在循环迭代结束后保留对循环变量的引用的错误,此时它会获取您不希望的新值。例如,考虑以下程序
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}
// wait for all goroutines to complete before exiting
for _ = range values {
<-done
}
}
这三个创建的 goroutine 都打印相同的变量 v
,因此它们通常打印“c”、“c”、“c”,而不是以某种顺序打印“a”、“b”和“c”。
Go 常见问题解答条目“闭包与 goroutine 协同工作时会发生什么?”提供了此示例并指出“在将闭包与并发一起使用时可能会产生一些混淆”。
尽管并发通常会参与其中,但它并非必须参与。此示例具有相同的问题,但没有 goroutine
func main() {
var prints []func()
for i := 1; i <= 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, print := range prints {
print()
}
}
这种错误已导致许多公司出现生产问题,包括Lets 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
}
此代码的作者显然理解了总体问题,因为他们创建了 k
的副本,但事实证明 modelToAuthzPB
在构建其结果时使用了指向 v
中字段的指针,因此循环也需要创建 v
的副本。
已编写工具来识别这些错误,但很难分析变量的引用是否在其迭代结束后仍然存在。这些工具必须在假阴性和假阳性之间做出选择。go vet
和 gopls
使用的 loopclosure
分析器选择假阴性,仅在确定存在问题时才报告,但会遗漏其他问题。其他检查器选择假阳性,指责正确的代码不正确。我们对开源 Go 代码中添加 x := x
行的提交进行了分析,希望找到错误修复。相反,我们发现添加了许多不必要的行,这表明流行的检查器具有较高的假阳性率,但开发人员无论如何都会添加这些行以使检查器满意。
我们发现的一对示例特别有启发意义
此差异在一个程序中
for _, informer := range c.informerMap {
+ informer := informer
go informer.Run(stopCh)
}
此差异在另一个程序中
for _, a := range alarms {
+ a := a
go a.Monitor(b)
}
这两个差异之一是错误修复;另一个是不必要的更改。除非您了解所涉及的类型和函数,否则您无法分辨哪个是哪个。
修复
对于 Go 1.22,我们计划更改 for
循环,使其变量具有每个迭代的作用域,而不是每个循环的作用域。此更改将修复上述示例,因此它们不再是错误的 Go 程序;它将结束由此类错误引起的生产问题;并且它将消除对导致用户对其代码进行不必要更改的不精确工具的需求。
为了确保与现有代码向后兼容,新语义仅适用于包含在模块中的包,这些模块在其 go.mod
文件中声明了 go 1.22
或更高版本。此模块级决策为开发人员提供对在整个代码库中逐步更新到新语义的控制。也可以使用 //go:build
行在每个文件的基础上控制决策。
旧代码将继续完全按照今天的含义进行解释:此修复仅适用于新代码或更新的代码。这将使开发人员能够控制特定包中语义何时发生变化。由于我们的向前兼容性工作,Go 1.21 不会尝试编译声明 go 1.22
或更高版本的代码。我们在 Go 1.20.8 和 Go 1.19.13 的点版本中包含了具有相同效果的特例,因此当 Go 1.22 发布时,依赖于新语义编写的代码将永远不会使用旧语义进行编译,除非人们使用非常旧的不受支持的 Go 版本。
预览修复
Go 1.21 包含作用域更改的预览。如果您在环境中设置了 GOEXPERIMENT=loopvar
来编译代码,则新语义将应用于所有循环(忽略 go.mod
go
行)。例如,要检查您的测试在将新循环语义应用于您的包和所有依赖项后是否仍然通过
GOEXPERIMENT=loopvar go test
我们在 2023 年 5 月初修补了 Google 的内部 Go 工具链以强制执行此模式,并在过去的四个月中,我们没有收到任何有关生产代码中出现任何问题的报告。
您还可以尝试测试程序以更好地了解 Go playground 上的语义,方法是在程序顶部包含 // GOEXPERIMENT=loopvar
注释,如此程序所示。(此注释仅适用于 Go playground。)
修复错误的测试
虽然我们没有生产问题,但为了准备切换,我们确实不得不更正许多错误的测试,这些测试没有测试它们认为自己在测试的内容,例如
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)
}
})
}
}
在 Go 1.21 中,此测试通过,因为 t.Parallel
会阻塞每个子测试,直到整个循环完成,然后并行运行所有子测试。当循环完成后,v
始终为 6,因此所有子测试都检查 6 是否为偶数,因此测试通过。当然,此测试确实应该失败,因为 1 不是偶数。修复 for 循环会暴露这种错误的测试。
为了帮助准备这种发现,我们改进了 Go 1.21 中 loopclosure
分析器的精度,以便它可以识别和报告此问题。您可以在 Go playground 上的此程序中看到报告。如果 go vet
在您自己的测试中报告了此类问题,则修复它们将使您更好地为 Go 1.22 做好准备。
如果您遇到其他问题,常见问题解答提供了指向示例和有关使用我们编写的工具的详细信息的链接,以识别在应用新语义时导致测试失败的特定循环。
更多信息
下一篇文章:解构类型参数
上一篇文章:Go 中的 WASI 支持
博客索引