程序印象

Goroutine 泄露 (译)

2018/07/31 Share

作者:Michał Łowicki

原文地址: https://medium.com/golangspec/goroutine-leak-400063aef468

Go 中的并发以 goroutines(独立活动)和 channels(用于通信)的方式实现。然而处理 goroutines 程序员仍然需要小心避免泄漏。 如果最终在 I/O上像 channel 通信那样永久阻塞或者陷入无限循环,则会产生泄漏。 即使是阻塞的 goroutine 也会消耗资源,因此程序可能会使用比实际需要更多的内存,可能最终耗尽内存并导致崩溃。 让我们看看它可能发生的几个例子。 然后我们将专注于如何检测程序是否受到这种泄露的影响。

发送到没有接收方的 channel

假设为了冗余,程序向许多后端发送请求,只使用接受到第一个响应,丢弃后面的响应。 下面的代码将模拟通过等待一个随机的毫秒数向下游服务器发送请求:

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
package main
import (
"fmt"
"math/rand"
"runtime"
"time"
)
func query() int {
n := rand.Intn(100)
time.Sleep(time.Duration(n) * time.Millisecond)
return n
}
func queryAll() int {
ch := make(chan int)
go func() { ch <- query() }()
go func() { ch <- query() }()
go func() { ch <- query() }()
return <-ch
}
func main() {
for i := 0; i < 4; i++ {
queryAll()
fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine())
}
}
#goroutines: 3
#goroutines: 5
#goroutines: 7
#goroutines: 9

每次调用 queryAll ,都会导致 goroutines 数量的增长。 问题是,在收到第一个响应后,”较慢” 的 goroutines 将发送到另一侧没有接收的 channel 中。

如果预先知道后端服务器的数量,则可能的解决方法是使用缓冲通道。 或者我们可以使用一个 goroutine 从 channel 接收数据,这样仍然需要至少有一个 goroutine 进行相关工作。 其他选项可能是使用 context 取消其他请求的一些机制(示例)。

从没有发送方的 channel 接收

此方案类似于在没有任何接收方的情况下发送到 channel 。 泄漏的 goroutine 帖子包含一个例子。

nil channel

写入到 nil channel 会导致永久阻塞:

1
2
3
4
5
package main
func main() {
var ch chan struct{}
ch <- struct{}{}
}

所以它导致死锁:

1
2
3
4
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send (nil chan)]:
main.main()
...

从 nil channel 读取时也是如此:

1
2
var ch chan struct{}
<-ch

这可能在传递尚未初始化的 channel 时发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
var ch chan int
if false {
ch = make(chan int, 1)
ch <- 1
}
go func(ch chan int) {
<-ch
}(ch)

c := time.Tick(1 * time.Second)
for range c {
fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine())
}
}

在这个例子中有一个明显的错误 - if false { 在更复杂的程序中它更容易忽略,将 channel 设置为了 nil。

无限循环

Goroutine 泄漏不仅仅是由于错误使用 channel 造成的。 原因可能是阻塞的 I/O 操作,例如在没有超时的情况下向 API 服务器发送请求。 另一个选择是程序可以简单地陷入无限循环。

分析

runtime.NumGoroutine

简单的方法是使用 runtime.NumGoroutine 查看 goroutine 数量。

net/http/pprof

1
2
3
4
5
6
7
import (
"log"
"net/http"
_ "net/http/pprof"
)
...
log.Println(http.ListenAndServe("localhost:6060", nil))

我们可以在 http://localhost:6060/debug/pprof/goroutine?debug=1 上查看到 goroutine 列表及其堆栈跟踪。

runtime/pprof

要将现有 goroutine 的堆栈跟踪打印到 stdout:

1
2
3
4
5
6
import (
"os"
"runtime/pprof"
)
...
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)

gops

1
> go get -u github.com/google/gops

与程序集成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import "github.com/google/gops/agent"
...
if err := agent.Start(); err != nil {
log.Fatal(err)
}
time.Sleep(time.Hour)
> ./bin/gops
12365 gops (/Users/mlowicki/projects/golang/spec/bin/gops)
12336* lab (/Users/mlowicki/projects/golang/spec/bin/lab)
> ./bin/gops vitals -p=12336
goroutines: 14
OS threads: 9
GOMAXPROCS: 4
num CPU: 4

leaktest

这是自动检测 goroutine 泄漏的方法之一。 它基本上在测试的开始和结束时通过 runtime.Stack 获取活动goroutine 的堆栈跟踪。 如果在测试完成后仍然存在新的 goroutine,那么它被归类为泄漏。

对于 goroutines 管理非常重要,即使是已经在运行中的程序,因为 goroutines 的泄露最终可能导致内存不足。

问题通常会在程序在生产环境中运行数天后才被发现,因此可能会造成真正的损害。

参考

  1. https://golang.org/pkg/
  2. https://github.com/google/gops
  3. https://github.com/golang/go/issues/5308
  4. https://github.com/fortytw2/leaktest
CATALOG
  1. 1. 发送到没有接收方的 channel
  2. 2. 从没有发送方的 channel 接收
    1. 2.1. nil channel
    2. 2.2. 无限循环
  3. 3. 分析
  4. 4. 参考