defer 解析

作用域

defer 关键字传入的函数会在函数返回之前运行。假设我们在 for 循环中多次调用 defer 关键字:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
	for i := 0; i < 5; i++ {
		defer fmt.Println(i)
	}
}

$ go run main.go
4
3
2
1
0

运行上述代码会倒序执行传入 defer 关键字的所有表达式,因为最后一次调用 defer 时传入了 fmt.Println(4),所以这段代码会优先打印 4。我们可以通过下面这个简单例子强化对 defer 执行时机的理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
    {
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    }

    fmt.Println("main ends")
}

$ go run main.go
block ends
main ends
defer runs

从上述代码的输出我们会发现,defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。

预计算参数

Go 语言中所有的函数调用都是传值的,虽然 defer 是关键字,但是也继承了这个特性。假设我们想要计算 main 函数运行的时间,可能会写出以下的代码:

1
2
3
4
5
6
7
8
9
func main() {
	startedAt := time.Now()
	defer fmt.Println(time.Since(startedAt))

	time.Sleep(time.Second)
}

$ go run main.go
0s

然而上述代码的运行结果并不符合我们的预期,这个现象背后的原因是什么呢?

  • 经过分析,我们会发现调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s。

想要解决这个问题的方法非常简单,我们只需要向 defer 关键字传入匿名函数:

1
2
3
4
5
6
7
8
9
func main() {
	startedAt := time.Now()
	defer func() { fmt.Println(time.Since(startedAt)) }()

	time.Sleep(time.Second)
}

$ go run main.go
1s

虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt) 会在 main 函数返回前调用并打印出符合预期的结果。

使用对循环迭代器变量的引用

在 Go 中,循环迭代器变量是一个在每次循环迭代中采用不同值的单个变量。这是非常有效的,但如果使用不当可能会导致意外行为。例如,请参阅以下程序:

1
2
3
4
5
6
7
8
func main() {
	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])
}

它会输出意想不到的结果:

1
2
Values: 3 3 3
Addresses: 0x40e020 0x40e020 0x40e020

解释:在每次迭代中,我们将 i 的地址附加iout切片,但由于它是相同的变量,因此我们附加了相同的地址,该地址最终包含分配给 的最后一个值i。解决方案之一是将循环变量复制到新变量中:

1
2
3
4
 for i := 0; i < 3; i++ {
	i := i // Copy i into a new variable.
 	out = append(out, &i)
 }

该程序的新输出是预期的:

1
2
Values: 0 1 2
Addresses: 0x40e020 0x40e024 0x40e028

说明:该行将i := i循环变量复制i到作用域为 for 循环体块的新变量中,也称为i. 新变量的地址是附加到数组的地址,这使得它比 for 循环体块更有效。在每次循环迭代中,都会创建一个新变量。

虽然这个例子可能看起来有点明显,但在其他一些情况下,同样的意外行为可能更隐蔽。例如,循环变量可以是一个数组,而引用可以是一个切片:

1
2
3
4
5
6
7
func main() {
	var out [][]int
	for _, i := range [][1]int{{1}, {2}, {3}} {
		out = append(out, i[:])
	}
	fmt.Println("Values:", out)
}

输出:

1
Values: [[3] [3] [3]]

当在 Goroutine 中使用循环变量时,也可以证明同样的问题(见下一节)。

在循环迭代器变量上使用 goroutines

在 Go 中进行迭代时,可能会尝试使用 goroutines 并行处理数据。例如,您可以使用闭包编写如下内容:

1
2
3
4
5
for _, val := range values {
	go func() {
		fmt.Println(val)
	}()
}

上面的 for 循环可能不会像你期望的那样做,因为它们的val变量实际上是一个单一的变量,它接受每个切片元素的值。因为闭包都只绑定到那个变量,所以当你运行这段代码时,很有可能你会看到每次迭代打印的最后一个元素而不是按顺序打印的每个值,因为 goroutines 可能不会开始执行,直到循环后。

编写该闭环的正确方法是:

1
2
3
4
5
for _, val := range values {
	go func(val interface{}) {
		fmt.Println(val)
	}(val)
}

通过将 val 作为参数添加到闭包中,val在每次迭代时评估并放置在 goroutine 的堆栈中,因此每个切片元素在最终执行时对 goroutine 可用。

同样重要的是要注意,在循环体中声明的变量在迭代之间不共享,因此可以在闭包中单独使用。下面的代码使用一个公共索引变量i来创建单独的vals,这会导致预期的行为:

1
2
3
4
5
6
for i := range valslice {
	val := valslice[i]
	go func() {
		fmt.Println(val)
	}()
}

请注意,如果没有将这个闭包作为 goroutine 执行,代码会按预期运行。以下示例打印出 1 到 10 之间的整数。

1
2
3
4
5
for i := 1; i <= 10; i++ {
	func() {
		fmt.Println(i)
	}()
}

即使闭包仍然关闭同一个变量(在这种情况下,i),它们在变量更改之前执行,从而导致所需的行为。 http://golang.org/doc/go_faq.html#closures_and_goroutines

您可能会发现另一种类似的情况,如下所示:

1
2
3
4
5
6
7
for _, val := range values {
	go val.MyMethod()
}

func (v *val) MyMethod() {
	fmt.Println(v)
}

上面的例子也将打印值的最后一个元素,原因与闭包相同。要解决此问题,请在循环内声明另一个变量。

1
2
3
4
5
6
7
8
for _, val := range values {
	newVal := val
	go newVal.MyMethod()
}

func (v *val) MyMethod() {
	fmt.Println(v)
}