Go 博客

修复 Go 1.22 中的 for 循环

David Chase 和 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 运行时会发生什么?)给出了这个例子,并指出“在使用闭包和并发时可能会产生一些困惑。”

尽管并发经常涉及其中,但并非必须。以下示例存在相同的问题,但没有 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 的公开记录问题。在该实例中,对循环变量的意外捕获 spread 到了多个函数中,并且更难以注意到。

// 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` 行的 commit 进行了分析,希望能找到 bug 修复。结果我们发现添加了许多不必要的行,这反而表明流行的检查器具有显著的假阳性率,但开发者仍然添加这些行以让检查器满意。

我们发现的一对示例尤其具有启发性:

此 diff 存在于一个程序中:

     for _, informer := range c.informerMap {
+        informer := informer
         go informer.Run(stopCh)
     }

此 diff 存在于另一个程序中:

     for _, a := range alarms {
+        a := a
         go a.Monitor(b)
     }

这两个 diff 中有一个是 bug 修复;另一个是不必要的更改。除非您了解所涉及的类型和函数,否则您无法分辨哪个是哪个。

解决方案

对于 Go 1.22,我们计划将 `for` 循环更改为使这些变量具有每次迭代的作用域,而不是每个循环的作用域。此更改将修复上述示例,使其不再是 buggy 的 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 工具链进行了补丁,强制在所有构建中启用此模式,并且在过去的四个月里,我们收到了零个关于生产代码中出现任何问题的报告。

您也可以通过在程序顶部包含 `// GOEXPERIMENT=loopvar` 注释,在 Go playground 上通过测试程序来更好地理解语义,例如 此程序。(此注释仅在 Go playground 中有效。)

修复 buggy 的测试

尽管我们没有生产问题,但在为切换做准备时,我们确实不得不纠正许多 buggy 的测试,这些测试并没有测试它们所认为的,比如这个:

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 循环会暴露这种 buggy 的测试。

为了帮助为这种发现做准备,我们在 Go 1.21 中提高了 `loopclosure` 分析器的精度,使其能够识别并报告此问题。您可以在 Go playground 上的 此程序 中看到报告。如果 `go vet` 在您的测试中报告了此类问题,修复它们将使您为 Go 1.22 做好更好的准备。

如果您遇到其他问题,FAQ 提供了指向示例的链接以及有关使用我们编写的工具来识别当新语义应用于特定循环时导致测试失败的详细信息。

更多信息

有关此更改的更多信息,请参阅 设计文档FAQ

下一篇文章: 解构类型参数
上一篇文章: WASI 在 Go 中的支持
博客索引