原文在这里。
由 David Chase and Russ Cox 发布于2023年9月19日
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 FAQ 中的条目 "What happens with closures running as goroutines?" 给出了这个例子,并指出 "在使用闭包与并发时可能会引起一些困惑"。
尽管上面的问题通常都涉及并发,但也不全是。这个例子虽然没有使用 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
分析器选择了漏报,只有在确定存在问题时才会报告,但会错过其他情况。其他检查器则选择了误报,将正确的代码误认为是错误的。我们对添加了 x := x
行的开源 Go 代码进行了分析,期望找到 bug 修复。然而,我们发现许多不必要的行被添加进去,这表明尽管流行的检查器存在相当高的误报率,但开发人员仍然添加这些行来满足检查器的要求。
我们发现的一对示例特别有启发性:
在某个程序中,出现了以下差异:
for _, informer := range c.informerMap { + informer := informer go informer.Run(stopCh) }
在另一个程序中:
for _, a := range alarms { + a := a go a.Monitor(b) }
这两个差异中,一个是 bug 修复,另一个是不必要的更改。除非你对涉及的类型和函数有更多了解,否则无法确定哪个是哪个。
修复
在 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
我们在 Google 内部的 Go 工具链中进行了补丁,从 2023 年 5 月初开始,在所有构建过程中强制启用了这种模式,并且在过去的四个月中,我们没有收到任何关于生产代码的问题报告。
您还可以尝试一些测试程序,通过在程序顶部包含一个 // 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 做准备。
如果你遇到其他问题,FAQ中提供了示例和详细信息的链接,可以使用我们编写的工具来识别在应用新语义时导致测试失败的具体循环。
更多详情
要了解更多关于这个改变的信息,请参阅设计文档和常见问题解答(FAQ)。这些资源将提供更详细的解释和指导,帮助您更好地理解这个改变以及如何适应它。