Go闭包捕获循环变量问题

博客中搜索 循环变量

loopvar

Go 1.22新特性介绍

https://mp.weixin.qq.com/s/OnvniPEvDz6k5LLKGH1MFA 不再共享循环变量

RSC的交棒

go vet中的那些检测项

Go视频转文字汇总

Go中的匿名函数与闭包

Implicit memory aliasing in for loop.


目前go vet有一个检测项,会检测出这种情况并 loop variable up captured by func literal.

但是因为go 1.22已经修改,不会再有这种情况出现,所以其实可以加一个判断,对于Go 1.22及以上的版本,不进行这个检测项目


关于闭包捕获循环变量 问题


在 Go 中,闭包捕获循环变量是一个常见的陷阱。这个问题通常出现在你在循环中启动 goroutine 或者定义匿名函数时,它们捕获了共享的循环变量,而不是每次迭代时的具体值。

问题举例

下面是一个会导致 loop variable captured by func literal 问题的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

// 假设我们要在 3 个 goroutine 中打印 3 个数
numbers := []int{1, 2, 3}

for _, num := range numbers {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(num) // 捕获了循环变量 num
}()
}

wg.Wait()
}

问题原因

在上面的代码中,num 是循环变量,每次循环时它的值都会改变。然而,go func() 中的匿名函数实际上捕获了 num 的引用,而不是它的值。由于 goroutine 并不会立即执行,所有的 goroutine 捕获的 num 都是同一个引用(即循环结束后的 num 值)。

因此,所有的 goroutine 打印的值将是循环结束时的最后一个值,也就是 3

输出结果

1
2
3
3
3
3

所有的 goroutine 都打印 3,而不是 1, 2, 3

正确的做法

我们需要确保每个 goroutine 捕获的是当前迭代时的 num 值,而不是共享的循环变量。可以通过将 num 作为参数传递给匿名函数,确保每次迭代时的值是独立的。

修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

numbers := []int{1, 2, 3}

for _, num := range numbers {
numCopy := num // 创建 num 的副本
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n) // 使用副本 n
}(numCopy) // 将 numCopy 作为参数传递给函数
}

wg.Wait()
}

解释

  • numCopy := num:在每次迭代中,创建当前 num 的副本 numCopy。这样每个 goroutine 都会捕获到当前迭代时的 numCopy,而不是共享的 num
  • go func(n int):将 numCopy 传递给匿名函数,确保 goroutine 捕获的是 numCopy 的值。

修改后的输出结果

1
2
3
1
2
3

每个 goroutine 都打印了正确的数值。

总结

  • 问题:在循环中启动 goroutine 时,匿名函数会捕获共享的循环变量引用,而不是每次迭代时的具体值。这会导致所有 goroutine 打印相同的值。
  • 解决方法:通过将循环变量的副本传递给匿名函数,确保每个 goroutine 捕获到的是当前迭代时的值,而不是共享的引用。

对于go 1.22及以后: https://go.dev/play/p/B2KxciPgtPu?v=goprev

当前go.mod中go的版本是1.22.6


移除go.mod中对go版本的指定(不然一直downloading go 1.22.6)

输出和go 1.22.6完全不一样,并且go vet能够检测到~

看来go vet是做了什么文章,不需要我搞了…



https://github.com/cuishuang/loopvar



loopclosure.Analyzer这个分析器是2018年11月份加的

是2024年2月份加的…和go 1.22基本同期更新

1
2
3
4
5
6
7
8
9
10
// Before reports whether the file version v is strictly before a Go release.
//
// Use this predicate to disable a behavior once a certain Go release
// has happened (and stays enabled in the future).
func Before(v, release string) bool {
if v == Future {
return false // an unknown future version happens after y.
}
return Compare(Lang(v), Lang(release)) < 0
}
1
2
3
4
5
6
7
8
9
10
// Compare returns -1, 0, or +1 depending on whether
// x < y, x == y, or x > y, interpreted as Go versions.
// The versions x and y must begin with a "go" prefix: "go1.21" not "1.21".
// Invalid versions, including the empty string, compare less than
// valid versions and equal to each other.
// The language version "go1.21" compares less than the
// release candidate and eventual releases "go1.21rc1" and "go1.21.0".
// Custom toolchain suffixes are ignored during comparison:
// "go1.21.0" and "go1.21.0-bigcorp" are equal.
func Compare(x, y string) int { return compare(stripGo(x), stripGo(y)) }

这段代码定义了一个名为 Compare 的函数,用于比较两个 Go 语言的版本号,并返回一个整数来表示它们的关系。具体来说,Compare 函数会根据 Go 版本号的大小返回 -10+1,分别表示:

  • -1:表示 x 小于 y
  • 0:表示 x 等于 y
  • +1:表示 x 大于 y

详细说明

  1. 函数签名:

    1
    func Compare(x, y string) int
    • xy 是两个字符串,表示两个 Go 语言的版本号。
    • 返回值是一个 int,用于表示两个版本号之间的比较结果:
      • -1x 小于 y
      • 0x 等于 y
      • +1x 大于 y
  2. 版本号格式要求:

    • 版本号必须以 "go" 作为前缀,例如:"go1.21",而不是单纯的 "1.21"
    • 不符合版本格式的字符串(如空字符串)会被视为无效版本,且无效版本会被认为比任何有效版本都小。
  3. 版本比较的规则

    • 普通版本号:例如 "go1.21"
    • 候选版本:例如 "go1.21rc1",这是发布候选版本,通常比最终版本要小。
    • 补丁版本:例如 "go1.21.0",代表正式发布的版本。
    • 自定义后缀:如果版本号中包含自定义后缀(如 "go1.21.0-bigcorp"),在比较时将忽略这些后缀。也就是说,"go1.21.0""go1.21.0-bigcorp" 被认为是相等的。
  4. 实现细节

    • Compare 函数内部调用了 compare 函数(未展示),而 compare 函数实际上是进行版本比较的核心。
    • 版本号在比较之前,首先会通过 stripGo 函数(未展示)去掉前缀 "go",然后再进行比较。stripGo 可能会将 "go1.21" 变成 "1.21",以便在 compare 函数中更容易处理。

补充说明

为了更好地理解,可以模拟 Compare 函数的行为:

  • 相同版本

    1
    Compare("go1.21.0", "go1.21.0") // 返回 0
  • 不同版本

    1
    2
    Compare("go1.21", "go1.22")  // 返回 -1,因为 "go1.21" 小于 "go1.22"
    Compare("go1.22", "go1.21") // 返回 1,因为 "go1.22" 大于 "go1.21"
  • 带有候选版本的比较

    1
    2
    Compare("go1.21", "go1.21rc1") // 返回 -1,因为 "go1.21" < "go1.21rc1"
    Compare("go1.21rc1", "go1.21.0") // 返回 -1,因为 "go1.21rc1" < "go1.21.0"
  • 带有自定义后缀的版本比较

    1
    Compare("go1.21.0", "go1.21.0-bigcorp") // 返回 0,因为自定义后缀被忽略

总结

这段代码的功能是比较两个 Go 语言的版本号,并且遵循一些特定的规则:

  • 必须以 "go" 开头。
  • 忽略版本号中的自定义后缀。
  • 无效的版本号被视为小于任何有效版本号。

Compare 函数通过调用 stripGo 去掉 "go" 前缀后,使用 compare 来完成具体的版本比较逻辑。



所以但凡 go vet出现loop variable up captured by func literal的,可以认为go.mod里指定的go版本都低于go 1.22

但凡是go版本大于等于go 1.22, 都不会进行该项的检测