Golang: Language: Internal: Capturing Loop Variables

 13th September 2022 at 2:24pm

Basic

func PrintInt(n int) {
	  fmt.Println(n)
}

func main() {
	  for i := 0; i < 5; i++ {
        go PrintInt(i)
	  }

	  time.Sleep(100 * time.Millisecond)
}

上面代码会乱序输出 0 ~ 4。没有意外。

func PrintInt(n *int) {
    fmt.Println(*n)
}

func main() {
    for i := 0; i < 5; i++ {
        go PrintInt(&i)
  	}

  	time.Sleep(100 * time.Millisecond)
}

上面代码大概率会全部输出 5。如果用 -race 来运行,会报 data race。

原因

From Go spec:

Variables declared by the init statement are re-used in each iteration.

即是说在 for 循环的 init statement 部分声明的函数

这意味着 Go 不会 为循环的每一次 iteration 都重新分配一个 i 的内存空间。PrintInt(&i) 的 5 次调用,获得的参数(&i)都是一样的地址。

为什么会有 data race?因为 main 函数的 goroutine 在修改 i,新起的 5 个协程在读取 i

Methods with value vs. pointer receivers

下面的两段代码,区别在于 Show 函数的 receiver 是 value 还是 pointer。这会造成它们输出也不同。想想为什么?

type MyInt int

func (mi MyInt) Show() {
    fmt.Println(mi)
}

func main() {
    ms := []MyInt{50, 60, 70, 80, 90}
    for _, m := range ms {
        go m.Show()
    }

    time.Sleep(100 * time.Millisecond)
}
type MyInt int

func (mi *MyInt) Show() {
    fmt.Println(*mi)
}

func main() {
    ms := []MyInt{50, 60, 70, 80, 90}
    for _, m := range ms {
        go m.Show()
    }

    time.Sleep(100 * time.Millisecond)
}

原因

Method 可以认为是第一个参数是 receiver 的函数。

Closures

闭包在使用 loop variable 时候要特别注意。

func main() {
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i)
        }()
    }

    time.Sleep(100 * time.Millisecond)
}

这段代码大概率会打印出 5 个 5。原因是:

i is a free variable inside the closure, and such variables are captured by reference in Go.

即是说,closure 中使用了外部的变量时,变量是 pass by reference 的。上面的代码可以翻译成:

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(*(&i))
    }()
}

由于 for 的 init statement,也就是 i := 0,其中定义的变量是复用同个变量的,意味着 i 在循环的 5 次迭代中,其地址都是不变的。所以 5 个新起的协程在运行时,fmt.Println 所使用的 &i 的地址,与 for 循环中的 i 是同个地址。而这个地址的值在 main 协程中(大概率)已经被修改。因此输出的结果不如预期。

简单的解决办法是:

for i := 0; i < 5; i++ {
    go func(i int) {
        fmt.Println(i)
    }(i)
}

或者在循环体的第一行重新定义一个同名变量:

for i := 0; i < 5; i++ {
    i := i
    go func() {
        fmt.Println(i)
    }()
}

它们的代价是接近的,都有一次额外的变量生成和拷贝。

参考