Go 中 5 个常见错误

1. 循环中

使用循环时下边几个容易尝试混乱的编码方式我们要尽量避免。

1.1 对循环的变量进行引用

考虑到效率,在进行循环遍历过程中,迭代出的变量会赋值到同一个地址。这可能会导致无意识的错误。

1
2
3
4
5
6
7
8
9
in := []int{1, 2, 3}

var out []*int
for _, v := range in {
out = append(out, &v)
}

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: 0xc000014188 0xc000014188 0xc000014188

原因很容易解释:每次迭代时我们将 v 的地址追加到 out 切片中,前边提到,v 在每次遍历时为同一个变量,在输出的第二行可以看到打印出了相同的地址。

简单的修复方法是,将每一次的迭代出的变量复制给一个新的变量:

1
2
3
4
5
6
7
8
9
10
in := []int{1, 2, 3}

var out []*int
for _, v := range in {
v := v
out = append(out, &v)
}

fmt.Println("Values:", *out[0], *out[1], *out[2])
fmt.Println("Addresses:", out[0], out[1], out[2])

输出:

1
2
Values: 1 2 3
Addresses: 0xc0000b6010 0xc0000b6018 0xc0000b6020

同样的问题会出现在将迭代出的变量用在 Goroutine 中:

1
2
3
4
5
6
7
list := []int{1, 2, 3}

for _, v := range list {
go func() {
fmt.Printf("%d ", v)
}()
}

输出:

1
3 3 3

这个 bug 也可以使用上边提到的方法解决。(注:如果不在 Goroutine 中执行,上边的代码是没有问题的)

1.2 在循环中调用 WaitGroup.Wait

下边代码循环中的 group.Wait() 会被阻塞,导致无法执行后边的循环。

1
2
3
4
5
6
7
8
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, t := range tasks {
go func(t *task) {
defer group.Done()
}(t)
group.Wait()
}

正确的写法是把 Wait() 放在循环外:

1
2
3
4
5
6
7
8
9
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, t := range tasks {
go func(t *task) {
defer group.Done()
}(t)
}

group.Wait()

1.3 在循环中使用 defer

只有当函数返回时,defer 才会被执行。除非你知道你在做什么,否则不应该将 defer 用在循环中。

1
2
3
4
5
6
7
8
9
10
var mutex sync.Mutex
type Person struct {
Age int
}
persons := make([]Person, 10)
for _, p := range persons {
mutex.Lock()
defer mutex.Unlock()
p.Age = 13
}

在上边的例子中,在完成第一次循环后,之后的循环无法获得互斥锁从而被阻塞。应该改成下边的显性释放锁的方式:

1
2
3
4
5
6
7
8
9
10
11
var mutex sync.Mutex
type Person struct {
Age int
}
persons := make([]Person, 10)
for _, p := range persons {
mutex.Lock()

p.Age = 13
mutex.Unlock()
}

如果你确实需要在循环中使用 defer,可以考虑将工作委托给另一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
var mutex sync.Mutex
type Person struct {
Age int
}
persons := make([]Person, 10)
for _, p := range persons {
func() {
mutex.Lock()
defer mutex.Unlock()
p.Age = 13
}()
}

2. 往 unbuffered channel 中发送数据

1
2
3
4
5
6
7
8
9
10
11
12
13
func doReq(timeout time.Duration) obj {
ch :=make(chan obj)
go func() {
obj := do()
ch <- obj
} ()
select {
case result = <- ch :
return result
case<- time.After(timeout):
return nil
}
}

上边的代码模拟这样一个行为:超时前获得到结果将结果返回,若超时则返回 nil。

我们通过一个 Goroutine 异步获取结果,并通过一个 channel 配合 select 来阻塞代码往后执行。

上边代码使用了 unbuffered channel,这会导致的问题是,如果代码因超时提前返回了,Goroutine 在获取到结果后,会阻塞在 ch <- obj 这一行(因为没有其他的 Goroutine 来读取这个 channle),从而这个 Goroutine 无法退出,进而会发生 Goroutine 泄露。

解决方法是使用一个长度为 1 的 buffered channel

1
2
3
4
5
6
7
8
9
10
11
12
13
func doReq(timeout time.Duration) obj {
ch := make(chan obj, 1)
go func() {
obj := do()
ch <- result
} ()
select {
case result = <- ch :
return result
case<- time.After(timeout):
return nil
}
}

还有一种修复方式是在 Goroutine 中使用一个 select 配合一个空的 default

1
2
3
4
5
6
...
select {
case ch <- result:
default:
}
...

当没有其他 Goroutine 来读取这个 channel 时,会走到 default 行为,这个 Goroutine 也就可以正常退出了。

3. 不使用接口

接口可以使代码更具灵活性,是在代码中引入多态的一种方法。接口允许我们关注一组行为而非特定类型。不使用接口不会有错误产生,但会让我们的代码看起来不那么优雅、不具有可扩展性。

在众多接口中,io.Readerio.Writer 可能是最受欢迎的一对。

1
2
3
4
5
6
7
type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

这些接口非常强大, 假设我们需要将一个对象写入一个文件,可以这样定义一个 Save 方法:

1
func (o *obj) Save(file os.File) error

如果明天我们有需要将这个文件写入 http.ResponseWriter 呢?我们可不想重新定义一个新的方法,这时 io.Writer 就派上用场了:

1
func (o *obj) Save(w io.Writer) error

还需明白的一点是:我们应该只关心我们要使用的行为。在上边的例子中,使用 io.ReadWriteCloser 虽然也行得通,但如果我们只用到了 Write 方法,就不是特别好的实践了。接口面积越大,抽象能力越弱。

因此,在大部分情况下,我们应关注行为而不是具体类型。

4. struct 中未考虑字段声明顺序

下边的代码不会出现错误,但会有使用更多的内存:

1
2
3
4
5
type BadOrderedPerson struct {
Veteran bool // 1 byte
Name string // 16 byte
Age int32 // 4 byte
}

上边的 struct 看起来会分配 21 bytes 的内存,但实际上分配的是 32 bytes。出现这个情况原因是数据结构对齐。在 64 位架构中,内存以 8 bytes 为一个连续单元,改成下边的声明顺序可以优化到分配 24 bytes:

1
2
3
4
5
type OrderedPerson struct {
Name string
Age int32
Veteran bool
}

在频繁使用不合理字段顺序的类型时,会导致额外的内存开销。

不过,我们也不必手动计算和优化结构体内存,可以使用 go tool 提供的 fieldalignment 工具来检测并修复不合理的声明顺序。

fieldalignment 安装:

1
2
3
4
cd $GOPATH
git clone git@github.com:golang/tools.git src/golang.org/x/tools
src/golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment
go install

fieldalignment 使用

1
2
3
4
5
6
➜ fieldalignment .
/Users/jiapan/Projects/tantan-live-distribution/app/domain/recommend_service.go:179:30: struct of size 88 could be 80
/Users/jiapan/Projects/tantan-live-distribution/app/domain/voice_recommend_service.go:63:35: struct with 40 pointer bytes could be 24

// 修复字段顺序
➜ fieldalignment -fix .

5. test 时未使用 race 检测器

数据竞争会导致一些很迷的问题,而且通常是在部署一段时间后才会发生。所以此类问题在并发系统中是最常见而且最难排查的 bug。为了更方便找出此类 bug,Go 1.1 中引入了一个内置的数据竞争检测器,只需加上 -race 标识就可以了。

1
2
3
4
$ go test -race pkg    // to test the package
$ go run -race pkg.go // to run the source file
$ go build -race // to build the package
$ go install -race pkg // to install the package

当开启竞争检测器时,编译器会记录代码对内存进行了何时、何种方式的访问,同时 runtime 监控共享变量的非同步访问。

发现数据竞争时,竞争检测器会打印包含访问冲突的调用栈记录,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
WARNING: DATA RACE
Read by goroutine 185:
net.(*pollServer).AddFD()
src/net/fd_unix.go:89 +0x398
net.(*pollServer).WaitWrite()
src/net/fd_unix.go:247 +0x45
net.(*netFD).Write()
src/net/fd_unix.go:540 +0x4d4
net.(*conn).Write()
src/net/net.go:129 +0x101
net.func·060()
src/net/timeout_test.go:603 +0xaf
Previous write by goroutine 184:
net.setWriteDeadline()
src/net/sockopt_posix.go:135 +0xdf
net.setDeadline()
src/net/sockopt_posix.go:144 +0x9c
net.(*conn).SetDeadline()
src/net/net.go:161 +0xe3
net.func·061()
src/net/timeout_test.go:616 +0x3ed
Goroutine 185 (running) created at:
net.func·061()
src/net/timeout_test.go:609 +0x288
Goroutine 184 (running) created at:
net.TestProlongTimeout()
src/net/timeout_test.go:618 +0x298
testing.tRunner()
src/testing/testing.go:301 +0xe8

写在最后:人类从历史中学到的唯一教训,就是人类无法从历史中学到任何教训。