Go 迭代变量的陷阱

在并发的goroutine中遇到这个问题,还以为是goroutine的问题,或者Go闭包的问题, 一层一层分析下来,才知道是Go本身迭代变量的问题,想起了PHP的foreach陷阱【PHP引用理解之神奇的foreach面试题】,不仅感慨深入掌握一门语言何其不易呀 ​​​

先说结论:Go在循环体中,每次遍历的变量是同一个,指向同样的内存地址,这是语言本身的一个陷阱

问题先行,我们先来看看问题

// 匿名函数测试
func TestAnonymous2(t *testing.T) {
	defer func() {
		if r := recover(); r != nil {
			log.Fatal(r)
		}
	}()

	fmt.Println(runtime.NumCPU())
	codeNameMap := map[string]string{
		"1000": "滴滴出行",
		"2000": "Uber",
		"3000": "高德打车",
	}

	wg := &sync.WaitGroup{}
	for code, name := range codeNameMap {
		wg.Add(1)
		go func() {
			defer wg.Done()
			res := fmt.Sprintf("goroutine two. code: %s; name: %s", code, name)
			fmt.Println(res)
		}()
	}

	wg.Wait()

	fmt.Println("over")
}

// 执行结果
8
goroutine two. code: 3000; name: 高德打车
goroutine two. code: 3000; name: 高德打车
goroutine two. code: 3000; name: 高德打车
over

如果你也遇到类似问题,困惑很久,那么这篇笔记很适合你,要有耐心读到最后,除了解惑,还会领悟很多

一、陷阱现象
1、描述

func TestIterator1(t *testing.T) {
	for i := 0; i < 3; i++ {
		fmt.Println(&i)
	}
}

// 执行结果
0x140000981c0
0x140000981c0
0x140000981c0

2、原因分析
循环迭代的变量共享共一块内存地址

3、地址是同一个,为啥值不相同

func TestIterator2(t *testing.T) {
	for i := 0; i < 3; i++ {
		fmt.Println(i)
	}
}

// 执行结果
0
1
2

打印循环变量某一刻的值【某一次遍历的值】

func TestIterator3(t *testing.T) {
	out := []int{}
	for i := 0; i < 3; i++ {
		out = append(out, i)
	}

	fmt.Println(out)
}

// 执行结果
[0 1 2]

记录循环变量某一刻的值,最后统一打印【某一次遍历的值】

二、陷阱场景之循环变量中使用引用
1、现象

func TestIterator4(t *testing.T) {
	var out []*int
	for i := 0; i < 3; i++ {
		out = append(out, &i)
	}

	fmt.Println("values: ", *out[0], *out[1], *out[2])
	fmt.Println("addresses: ", out[0], out[1], out[2])
}

// 执行结果
values:  3 3 3
addresses:  0x140000981c0 0x140000981c0 0x140000981c0

2、解法
每次遍历的变量赋值给一个新的变量【一般用同名变量】,注意line4

func TestIterator5(t *testing.T) {
	var out []*int
	for i := 0; i < 3; i++ {
		i := i
		out = append(out, &i)
	}

	fmt.Println("values: ", *out[0], *out[1], *out[2])
	fmt.Println("addresses: ", out[0], out[1], out[2])
}

三、陷阱场景之循环变量中使用goroutines
1、现象

func TestIterator6(t *testing.T) {
	for i := 0; i < 3; i++ {
		go func() {
			fmt.Println(i)
		}()
	}

	time.Sleep(10 * time.Millisecond)
}

// 执行结果
3
3
3

2、原理
在goroutine执行之前,匿名函数中变量已经被修改了

3、解法a

func TestIterator7(t *testing.T) {
	for i := 0; i < 3; i++ {
		i := i
		go func() {
			fmt.Println(i)
		}()
	}

	time.Sleep(10 * time.Millisecond)
}

// 执行结果
2
1
0

4、解法b

func TestIterator8(t *testing.T) {
	for i := 0; i < 3; i++ {
		go func(i int) {
			fmt.Println(i)
		}(i)
	}

	time.Sleep(10 * time.Millisecond)
}

// 执行结果
2
1
0

5、不使用goroutine,结果是符合预期的
因为也是打印了某一次遍历的值

func TestIterator9(t *testing.T) {
	for i := 0; i < 3; i++ {
		func() {
			fmt.Println(i)
		}()
	}
}

// 执行结果
0
1
2

四、陷阱场景之循环变量中使用defer
1、现象

func TestIterator10(t *testing.T) {
	for i := 0; i < 3; i++ {
		defer func() {
			fmt.Println(i)
		}()
	}
}

// 执行结果
3
3
3

2、原理
加defer,遍历完了才执行,匿名函数中变量已经被修改了

3、解法a

func TestIterator11(t *testing.T) {
	for i := 0; i < 3; i++ {
		i := i
		defer func() {
			fmt.Println(i)
		}()
	}
}

// 执行结果
2
1
0

4、解法b

func TestIterator12(t *testing.T) {
	for i := 0; i < 3; i++ {
		defer func(i int) {
			fmt.Println(i)
		}(i)
	}
}

// 执行结果
2
1
0

五、陷阱场景之循环变量中使用匿名函数
1、现象

func TestIterator13(t *testing.T) {
	var squares []func() int
	for i := 0; i < 3; i++ {
		squares = append(squares, func() int {
			return i * i
		})
	}

	for _, square := range squares {
		res := square()
		fmt.Println(res)
	}
}

// 执行结果
9
9
9

2、原理
因为循环变量是共享的,打印的不是某一次的值,最后统一输出,值就会被改写,【笼统的说法是匿名函数是引用类型,很容易让人迷惑】
比如:《Go圣经》5.6.1. 警告:捕获迭代变量

3、解法a

func TestIterator14(t *testing.T) {
	var squares []func() int
	for i := 0; i < 3; i++ {
		i := i
		squares = append(squares, func() int {
			return i * i
		})
	}

	for _, square := range squares {
		res := square()
		fmt.Println(res)
	}
}

六、小结
1、对于for range上面的问题同理
2、问题的本质,还是看记录打印的是某一次的值,还是最后统一记录
3、每种语言都有易错的陷阱,想起了PHP的foreach,一定要把陷阱了熟于胸,才行之不乱
PHP引用理解之神奇的foreach面试题【本站】

七、参考
1、https://github.com/golang/go/wiki/CommonMistakes【Github, Go官方common mistakes】
2、https://go.dev/doc/faq#closures_and_goroutines
3、How golang’s “defer” capture closure’s parameter?【stackoverflow经典问题,关于defer】
4、Go internals: capturing loop variables in closures
5、Go中被闭包捕获的变量何时会被回收

发表评论

电子邮件地址不会被公开。 必填项已用*标注