协程引用循环变量的问题
引
如果我们需要使用循环从0
打印到9
,每行一个数,我们可以用下面这样的Go代码完成
for i := 0; i < 10; i++ {
fmt.Println(i)
}
得到期望的结果,如下:
0
1
2
3
4
5
6
7
8
9
但是现实中我们往往需要使用异步并发处理来提高性能,比如循环中可能是一个很耗时的逻辑。而这个时候就很容易出现问题了。
协程引用循环变量的坑
循环体中启动协程异步执行,这个时候就容易出现问题了,比如下面这样一段代码就会出现我们不期望的结果。
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
} ()
}
我们期望他能乱序输出0
到9
这几个数,但是他的执行结果并非如此。实际的执行结果如下:
7
10
10
10
10
10
10
10
10
7
可以看到他的执行结果大家基本都输出10
。其实原因也很容易解释:
主协程的循环很快就跑完了,而各个协程才开始跑,此时
i
的值已经是10
了,所以各协程都输出了10
。(输出7
的两个协程,在开始输出的时候主协程的i
值刚好是7
,这个结果每次运行输出都不一样)
这是一个初学者很容易出现的问题,还比较隐晦难以发现。
原因与解决办法
出现这个问题最主要的原因是Golang中允许启动的协程中引用外部的变量。Java对这类问题的解决方式比较合理,它也允许异步任务引用外部变量,但是要求外部变量必须是final
或者是effective final
的[1]。
for (int i = 0; i < 10; i++) {
final int finalI = i;
new Thread(new Runnable() {
public void run() {
// 这儿要求使用变量finalI,
// 如果使用i,就会报编译错误,
// 而且一般IDE也会提示错误,我们很容易发现。
System.out.println(finalI);
}
})
}
所以Java中只能写一个临时变量finalI
来供异步任务使用,这样每个异步任务都会拿到当时i
的一个snapshot。
Go代码也能改成类似的代码使运行出正确的结果
for i := 0; i < 10; i++ {
i0 := i
go func() {
fmt.Println(i0)
} ()
}
运行结果为
1
7
2
9
0
3
4
8
6
5
其实Golang推荐其他更简洁的写法
for i := 0; i < 10; i++ {
go func(i0 int) {
fmt.Println(i0)
} (i) //
}
// 或者
for i := 0; i < 10; i++ {
// 这一段代码相当与下面这样的一段伪码
// routine = makeroutine(fmt.Println, i)
// start(routine)
// 于是routine中的i值是一个副本
go fmt.Println(i)
}
这两个写法其实与前面java代码中用临时变量的原理是一样的,即变量i
已经有了一个副本,协程中针对副本处理。
工具
这个问题Golang虽然没有在语言层面上像Java一样要求使用final
变量,但是他也提供了一个代码检查工具go vet
能发现这个问题:
$ go vet main.go
main.go:24:16: loop variable i captured by func literal
我们可以将这个工具集成到IDE中,让我们在写代码的时候能自动对代码进行检查,用于快速发现这类的问题。
Goland设置
Goland中可以在 Preferences / Tools / File Watchers
中添加一个golangci-lint
的工具
有了这样的设置之后,后续编辑代码的时候,他就能自动检查出这类问题,提示我们可能存在的问题。
golangci-lint run --disable=typecheck demo
main.go:12:16: loopclosure: loop variable i captured by func literal (govet)
fmt.Println(i)
^
参考信息
https://github.com/golang/go/wiki/CommonMistakes
-
effective final出现与java8,见accessing-members-of-enclosing-class) ↩