Fixing For Loops in Go 1.22

Summary

Go 1.22 将 for 循环变量的作用域从整个循环共享改为每次迭代独立,彻底解决了在循环中使用闭包或 Goroutine 时常见的变量捕获陷阱(即所有协程都读取到最后一次迭代的值),并通过 go.mod 版本控制确保了对旧代码的向后兼容性。

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()
    }
}

这种错误在许多公司都引发了生产问题,其中包括 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
}

这段代码的作者显然理解了这个普遍问题,因为他们复制了 k,但事实证明,modelToAuthzPB 在构建结果时使用了指向 v 中字段的指针,所以循环也需要复制 v。

已经有工具被编写出来用于识别这些错误,但很难分析对变量的引用是否会超过其迭代周期。这些工具必须在漏报和误报之间做出选择。loopclosure 分析器被 go vet 和 gopls 所使用,它倾向于漏报,只在确定存在问题时才报告,却会遗漏其他问题。其他检查器则倾向于误报,将正确的代码指责为错误代码。我们对开源 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 月初对谷歌内部的 Go 工具链进行了补丁更新,强制在所有构建过程中启用这种模式,在过去的四个月里,我们没有收到任何关于生产代码出现问题的报告。

你也可以尝试测试程序,以便在 Go 游乐场中更好地理解语义,方法是在程序顶部添加一个 // GOEXPERIMENT=loopvar 注释,就像在这个程序中一样。(此注释仅在 Go 游乐场中适用。)

修复有问题的测试

虽然我们没有遇到生产问题,但为了为这种转换做准备,我们确实需要修正许多有问题的测试,这些测试并没有测试到他们认为应该测试的内容,比如这样:

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 游乐场的这个程序中看到该报告。如果 go vet 在你自己的测试中报告了这类问题,修复它们将让你更好地为 Go 1.22 做好准备。

如果您遇到其他问题,常见问题解答包含一些链接,这些链接指向相关示例以及有关我们编写的一款工具的详细信息,该工具可用于在应用新语义时识别导致测试失败的具体循环。