Go 语言陷阱(一)

Published: by Creative Commons Licence

  • Tags:

hahh 本文总结自己在使用Go语言时碰到的一些"陷阱"。

遍历时的资源竞态问题

问题

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	nums := []int{1, 2, 3, 4, 5}
	for _, v := range nums {
		go func() {
			fmt.Print(v, "\t")
			wg.Done()
		}()
	}
	wg.Wait()
}

输出并不是所想的1 2 3 4 5各随机出现一次,而是5 5 5 5 5

解答

这样的结果似乎难以理解,我们来改写下程序,这段程序等价于上面的程序。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	nums := []int{1, 2, 3, 4, 5}
	var v int
	for k := range nums {
		v = nums[k]
		go func() {
			fmt.Print(v, "\t")
			wg.Done()
		}()
	}
	wg.Wait()
}

第一段程序range子句中的变量v只被定义一次,却在不同的协程中被循环使用,这就产生了资源竞态(Data Race)问题。协程中打印变量v的时候,可能for循环已经改写了v很多次,因此打印的结果不可预料。实际上大部分情况打印的是最后一个值,因为for循环执行的足够快。

这是最简单的资源竞态问题,也是Go初学者最容易犯的错误之一。更多资源竞态的问题,比如slicemap(它们不是并发安全的),参看我的另外一篇文章。

上述程序的正确写法有两种。

一种是将v作为函数参数传到协程里面,

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	nums := []int{1, 2, 3, 4, 5}
	for _, v := range nums {
		go func(n int) {
			fmt.Print(n, "\t")
			wg.Done()
		}(v)
	}
	wg.Wait()
}

另一种是直接把v的定义写到for循环里面,这样每次取的就是不同的v了,

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	nums := []int{1, 2, 3, 4, 5}
	for k := range nums {
		var v int
		v = nums[k]
		go func() {
			fmt.Print(v, "\t")
			wg.Done()
		}()
	}
	wg.Wait()
}

扩展

Go内置了竞态资源检测器。用法很简单,在对应的go build, test , run命令后加上-race即可。

尤其在编写复杂业务系统时,加上-race编译项目,或许就能检测出隐藏很深的bug。笔者维护着公司的推荐系统,由于业务较为复杂,所以经常会使用go test -race来做单元测试。

Range的值拷贝

问题

先看一个例子:

package main

import "fmt"

type Student struct {
	Name string
	Age  int
}

func main() {
	var students []Student
	students = append(students, Student{Name: "Tom", Age: 1})
	students = append(students, Student{Name: "Edward", Age: 2})
	students = append(students, Student{Name: "Jack", Age: 3})
	for _, student := range students {
		student.Age = 10
	}
	fmt.Println(students)
}

输出的仍是

[{Tom 1} {Edward 2} {Jack 3}]

并没有改变原来的值。

解答

原因在于程序中的studentstudents的值拷贝,并不是指针或引用。就像 studentB = studentA,对

studentB进行一系列操作并不会改变studentA本身。这个错误很容易理解,但是也很容易犯。

解决的办法也很简单,操作指针即可。

for i := 0; i < len(students); i ++ {
	students[i].Age = 10
}

或者这样

for idx := range students {
	// 这里也要注意,当for后面只跟一个变量的时候,表示的key,而不是value
	students[idx].Age = 10
}

扩展

我们已经知道了 用 for range来遍历 slice会比传统的for循环多一次值拷贝操作。所以有人建议在Go语言中用for range遍历 map,而用传统的for循环来遍历slice

一般情况下,这个性能消耗是可以忽略的。但是在性能要求较高的场景下,比如做算法的基准测试,这点就很有用。

Slice 截取导致原数据改变

问题

package main

import "fmt"

func main() {
	data := []int{1, 2, 3, 4}
	part := data[:3]
	// ……
	part[0] = 10
	fmt.Println(data)
}

输出

[10 2 3 4]

虽然程序中只改变part的值,其实原数据data的值也改变了。

解答

这个例子看到输出结果也很容易理解,切片操作是指针操作,尽管重新命名了变量part,实际操作的还是原来的内存地址。

在实际应用中,切片之后往往还有一大段业务逻辑,最后已经忘了原数据已经被修改,从而不经意间就写了Bug

怎样避免呢?假如我们想申请一个跟data无关的part的话,可以这样

part := append([]int{}, data[:3]...)

或者

part := make([]int, 3)
copy(part, data[:3])

这样,无论怎么操作part都不会影响到data了。

nil != nil

问题

package main

import (
	"fmt"
	"os"
)

func Foo() error {
	var err *os.PathError = nil
	// …
	return err
}

func main() {
	err := Foo()
	fmt.Println(err)        
	fmt.Println(err == nil) 
}

输出的是,

<nil>
false

解答

这跟Go语言的类型系统有关。一个interfacenil相等,必须是typevalue都相等,程序中[nil, *os.PathError] != [nil, nil],所以打印出false

扩展

如果这样,

var err *os.PathError = nil
fmt.Println(err)
fmt.Println(err == nil)

打印的结果又是

<nil>
true

因为等号右面的nil并不是interface,已经成了[*os.PathError, nil]