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 中的条目《当闭包作为 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

已经有工具被开发出来用于识别这些错误,但很难分析变量的引用是否会在其迭代结束后仍然存在。这些工具必须在假阴性(false negatives)和假阳性(false positives)之间做出选择。go vetgopls 使用的 loopclosure 分析器倾向于假阴性,只在确定存在问题时报告,但会遗漏其他问题。其他检查器倾向于假阳性,指责正确的代码有误。我们分析了在开源 Go 代码中添加 x := x 行的提交,原以为会发现错误修复。结果却发现添加了许多不必要的行,这表明流行的检查器存在显著的假阳性率,但开发者还是会添加这些行以让检查器满意。

我们发现的一对例子特别具有启发性:

这个 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 循环,使这些变量具有每次迭代的作用域,而不是整个循环的作用域。这一改变将修复上述示例,使它们不再是有 bug 的 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 做准备。

如果您遇到其他问题,常见问题解答(FAQ)中提供了示例链接以及关于如何使用我们编写的一个工具来识别在新语义应用时导致测试失败的具体循环的详细信息。

更多信息

有关此更改的更多信息,请参阅设计文档常见问题解答(FAQ)

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