Go Wiki: LoopvarExperiment

在 Go 1.22 中,Go 改变了 for 循环变量的语义,以防止在每次迭代的闭包和 goroutine 中意外共享。

新语义在 Go 1.21 中也可用,作为该变更的初步实现,通过在构建程序时设置 GOEXPERIMENT=loopvar 来启用。

此页面回答有关该变更的常见问题。

我如何尝试此变更?

在 Go 1.22 及更高版本中,该变更由模块 go.mod 文件中的语言版本控制。如果语言版本是 go1.22 或更高,则模块将使用新的循环变量语义。

使用 Go 1.21,通过 GOEXPERIMENT=loopvar 构建程序,例如:

GOEXPERIMENT=loopvar go install my/program
GOEXPERIMENT=loopvar go build my/program
GOEXPERIMENT=loopvar go test my/program
GOEXPERIMENT=loopvar go test my/program -bench=.
...

这解决了什么问题?

考虑一个像这样的循环:

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

此测试旨在检查所有测试用例是否都是偶数(它们不是!),但它在旧语义下通过。问题在于 t.Parallel 停止闭包并让循环继续,然后在 TestAllEvenBuggy 返回时并行运行所有闭包。等到闭包中的 if 语句执行时,循环已经完成,v 具有其最终迭代值 6。所有四个子测试现在并行继续执行,并且它们都检查 6 是否是偶数,而不是检查每个测试用例。

此问题的另一个变体是:

    func TestAllEven(t *testing.T) {
        testCases := []int{0, 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)
                }
            })
        }
    }

这个测试没有错误地通过,因为 0、2、4 和 6 都是偶数,但它也没有测试是否正确处理了 0、2 和 4。与 TestAllEvenBuggy 一样,它测试了 6 四次。

这种 bug 的另一种不那么常见但仍然频繁的形式是在三段式 for 循环中捕获循环变量:

    func Print123() {
        var prints []func()
        for i := 1; i <= 3; i++ {
            prints = append(prints, func() { fmt.Println(i) })
        }
        for _, print := range prints {
            print()
        }
    }

这个程序看起来会打印 1、2、3,但实际上打印 4、4、4。

这种意外共享 bug 困扰着所有 Go 程序员,无论他们是刚开始学习 Go 还是已经使用了十年。关于这个问题的讨论是 Go FAQ 中最早的条目之一

以下是来自 Let's Encrypt 的 一个由这种 bug 导致的生产问题的公开示例。相关代码如下:

// 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
}

请注意 kCopy := k 语句,它旨在防止在循环体末尾使用的 &kCopy。不幸的是,事实证明 modelToAuthzPB 保留了指向 v 中几个字段的指针,这在阅读此循环时无法得知。

此 bug 的最初影响是 Let's Encrypt 需要 撤销超过 300 万张错误签发的证书。他们最终没有这样做,因为这会对互联网安全产生负面影响,而是 争取获得豁免,但这让你对这种影响有所了解。

相关代码在编写时经过了仔细审查,作者显然意识到了潜在问题,因为他们编写了 kCopy := k,但它仍然存在一个重大 bug,除非你确切知道 modelToAuthzPB 的作用,否则这个 bug 是不可见的。

解决方案是什么?

解决方案是使在 for 循环中使用 := 声明的循环变量在每次迭代中都是变量的不同实例。这样,如果值被闭包或 goroutine 捕获或以其他方式超出了迭代生命周期,则以后对它的引用将看到它在该迭代期间的值,而不是被后续迭代覆盖的值。

对于 range 循环,效果就像每个循环体都以 k := kv := v 开始,用于每个 range 变量。在上面的 Let's Encrypt 示例中,kCopy := k 将是不必要的,并且可以避免由于缺少 v := v 引起的 bug。

对于三段式 for 循环,效果就像每个循环体都以 i := i 开始,然后在循环体结束时发生反向赋值,将每次迭代的 i 复制回用于准备下一次迭代的 i。这听起来很复杂,但实际上所有常见的 for 循环惯用法都像以前一样工作。循环行为唯一改变的情况是当 i 被捕获并与其它东西共享时。例如,这段代码像以前一样运行:

    for i := 0;; i++ {
        if i >= len(s) || s[i] == '"' {
            return s[:i]
        }
        if s[i] == '\\' { // skip escaped char, potentially a quote
            i++
        }
    }

有关完整详情,请参阅 设计文档

此更改会破坏程序吗?

是的,有可能编写会被此更改破坏的程序。例如,这里有一个使用单元素映射将列表中的值相加的令人惊讶的方法:

func sum(list []int) int {
    m := make(map[*int]int)
    for _, x := range list {
        m[&x] += x
    }
    for _, sum := range m {
        return sum
    }
    return 0
}

它依赖于循环中只有一个 x 的事实,因此 &x 在每次迭代中都是相同的。使用新语义,x 会在迭代中逸出,因此 &x 在每次迭代中都不同,并且映射现在有多个条目而不是单个条目。

这里有一个令人惊讶的方法来打印 0 到 9 的值:

    var f func()
    for i := 0; i < 10; i++ {
        if i == 0 {
            f = func() { print(i) }
        }
        f()
    }

它依赖于这样一个事实:在第一次迭代中初始化的 f 每次被调用时都会“看到” i 的新值。使用新语义,它会打印 0 十次。

尽管可以构造出使用新语义会破坏的人工程序,但我们尚未看到任何实际程序会错误执行。

C# 在 C# 5.0 中也进行了类似的更改,他们也报告说 这种更改导致的问题很少。

更多破坏性或令人惊讶的案例在 这里这里 展示。

此更改破坏实际程序的频率如何?

根据经验,几乎从不。对 Google 代码库的测试发现许多测试得到了修复。它还识别出一些由于循环变量和 t.Parallel 之间的错误交互而错误通过的 bug 测试,就像上面 TestAllEvenBuggy 中那样。我们重写了这些测试以纠正它们。

我们的经验表明,新语义修复 bug 代码的频率远高于破坏正确代码的频率。新语义仅在约 8000 个测试包中导致了 1 个测试失败(所有这些都是错误通过的测试),但在我们整个代码库中运行更新的 Go 1.20 loopclosure vet 检查以更高的速率标记了测试:1/400 (8000 中的 20)。loopclosure 检查器没有误报:所有报告都是我们源代码树中 t.Parallel 的 bug 用法。也就是说,约 5% 的标记测试类似于 TestAllEvenBuggy;其他 95% 类似于 TestAllEven:尚未测试其预期功能,但即使修复了循环变量 bug,也是对正确代码的正确测试。

自 2023 年 5 月初以来,Google 一直在标准生产工具链中对所有 for 循环应用新的循环语义,没有报告任何问题(并获得了许多赞扬)。

有关我们在 Google 经验的更多详情,请参阅 这篇综述

我们还在 Kubernetes 中尝试了新的循环语义。它识别出两个新失败的测试,原因是底层代码中潜在的循环变量作用域相关 bug。相比之下,将 Kubernetes 从 Go 1.20 更新到 Go 1.21 识别出三个新失败的测试,原因是依赖于 Go 本身未文档化的行为。由于循环变量更改而导致的两个测试失败与普通的版本更新相比,并不是一个显著的新负担。

此更改会通过导致更多分配而使程序变慢吗?

绝大多数循环不受影响。只有当循环变量取地址 (&i) 或被闭包捕获时,循环才会以不同的方式编译。

即使对于受影响的循环,编译器的逃逸分析也可能确定循环变量仍然可以在栈上分配,这意味着没有新的分配。

但是,在某些情况下,会增加额外的分配。有时,额外的分配是修复潜在 bug 所固有的。例如,Print123 现在分配了三个独立的 int(结果是在闭包内部)而不是一个,这对于在循环结束后打印三个不同的值是必要的。在极少数其他情况下,循环可能在共享变量下是正确的,并且在独立变量下仍然是正确的,但现在分配了 N 个不同的变量而不是一个。在非常热的循环中,这可能会导致速度变慢。这些问题应该在内存分配配置文件中显而易见(使用 pprof --alloc_objects)。

对公共“bent”基准测试套件的基准测试显示,总体上没有统计学上显著的性能差异,我们也没有在 Google 的内部生产使用中观察到任何性能问题。我们预计大多数程序不会受到影响。

此更改如何部署?

与 Go 一般的 兼容性方法 一致,新的 for 循环语义仅适用于正在编译的包来自包含声明 Go 1.22 或更高版本(如 go 1.22go 1.23)的 go 行的模块。这种保守的方法确保没有任何程序会因为简单地采用新的 Go 工具链而改变行为。相反,每个模块作者控制其模块何时更改为新语义。

GOEXPERIMENT=loopvar 试用机制没有使用声明的 Go 语言版本:它无条件地将新语义应用于程序中的每个 for 循环。这提供了最坏情况行为,以帮助识别更改的最大可能影响。

我可以看到代码中受此更改影响的位置列表吗?

是的。您可以在命令行上使用 -gcflags=all=-d=loopvar=2 进行构建。这将为每个编译方式不同的循环打印一条警告样式的输出行,例如:

$ go build -gcflags=all=-d=loopvar=2 cmd/go
...
modload/import.go:676:7: loop variable d now per-iteration, stack-allocated
modload/query.go:742:10: loop variable r now per-iteration, heap-allocated

all= 会打印关于构建中所有包的更改信息。如果您省略 all=,例如 -gcflags=-d=loopvar=2,则只有您在命令行上指定的包(或当前目录中的包)才会发出诊断信息。

我的测试在更改后失败。如何调试它?

一个名为 bisect 的新工具允许在程序的不同子集上启用该更改,以识别在编译时哪些特定循环会触发测试失败。如果您有失败的测试,bisect 将识别导致问题的特定循环。使用:

go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test

有关实际示例,请参阅 此评论的 bisect 副本部分,有关更多详细信息,请参阅 bisect 文档

这是否意味着我的循环中不再需要编写 x := x 了?

在您将模块更新为使用 go1.22 或更高版本之后,是的。


此内容是 Go Wiki 的一部分。