Go 语言陷阱(一)
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
初学者最容易犯的错误之一。更多资源竞态的问题,比如slice
,map
(它们不是并发安全的),参看我的另外一篇文章。
上述程序的正确写法有两种。
一种是将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}]
并没有改变原来的值。
解答
原因在于程序中的student
是students
的值拷贝,并不是指针或引用。就像 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
语言的类型系统有关。一个interface
与nil
相等,必须是type
和value
都相等,程序中[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]
。