goroutine 泄漏与检测
2023-06-11 Golang 并发编程 Go 源码分析 读代码
概述
Go 语言内置 GC,因此一般不会内存泄漏,但是 goroutine
可能会发生泄漏,泄漏的 goroutine
引用的内存同样无法被 GC 正常回收。
常见 goroutine 泄漏场景
下面总结一下开发中经常遇到的 goroutine
泄漏场景,本文示例代码只是为了演示,没有任何现实意义。
通道为 nil
在 nil 通道
上发送和接收操作将永久阻塞,会造成 goroutine 泄漏
。
最佳实践:
- 永远不要对
nil 通道
进行任何操作- 直接使用
make()
初始化通道
接收通道为 nil
func main() {
var ch chan bool
go func() {
defer func() { // defer 不会执行
fmt.Println("goroutine ending") // 不会输出
}()
for v := range ch {
fmt.Println(v)
}
fmt.Println("range broken") // 执行不到这里
}()
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 没有任何输出,goroutine 泄漏
发送通道为 nil
func main() {
var ch chan bool
go func() {
defer func() { // defer 不会执行
fmt.Println("goroutine ending") // 不会输出
}()
ch <- true
fmt.Println("range broken") // 执行不到这里
}()
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 没有任何输出,goroutine 泄漏
遍历未关闭通道
遍历 无缓冲 (阻塞) 并且未关闭
的通道时,如果通道一直未关闭, 将会永久阻塞,造成 goroutine 泄漏
。
遍历 缓冲 (非阻塞) 并且未关闭
的通道时,将通道内的所有缓存数据接收完毕后,如果通道一直未关闭,将会永久阻塞,造成 goroutine 泄漏
。
最佳实践:
- 确保
通道
可以正常关闭- 确保
goroutine
可以正常退出
遍历无缓冲并且未关闭的通道
错误的做法
func main() {
ch := make(chan bool)
go func() {
defer func() { // defer 不会执行
fmt.Println("goroutine ending") // 不会输出
}()
for v := range ch {
fmt.Println(v)
break
}
fmt.Println("range broken") // 执行不到这里
}()
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 没有任何输出,goroutine 泄漏
正确的做法
参照最佳实践,对代码进行以下调整: 在 goroutine
外部关闭通道,防止 goroutine
内部遍历陷入无限阻塞。
func main() {
ch := make(chan bool)
go func() {
defer func() { // defer 正常执行
fmt.Println("goroutine ending") // 正常输出
}()
for v := range ch { // 外部关闭通道后,for 循环结束
fmt.Println(v) // 不会输出
}
fmt.Println("range broken") // 可以执行到这里
}()
close(ch) // 关闭通道,内存遍历循环立即结束
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 输出如下
/**
range broken
goroutine ending
*/
遍历缓冲并且未关闭的通道
错误的做法
func main() {
ch := make(chan bool, 3)
go func() {
defer func() { // defer 不会执行
fmt.Println("goroutine ending") // 不会输出
}()
for v := range ch {
fmt.Println(v)
}
fmt.Println("range broken") // 执行不到这里
}()
ch <- true
ch <- false
ch <- true
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 输出如下
/**
true
false
true
// 接收完缓冲区的 3 个值后, 后面不再有任何输出,goroutine 泄漏
*/
正确的做法
参照最佳实践,对代码进行以下调整: 在 goroutine
外部关闭通道,防止 goroutine
内部遍历陷入无限阻塞。
func main() {
ch := make(chan bool)
go func() {
defer func() { // defer 正常执行
fmt.Println("goroutine ending") // 正常输出
}()
for v := range ch { // 外部关闭通道后,for 循环结束
fmt.Println(v) // 不会输出
}
fmt.Println("range broken") // 可以执行到这里
}()
close(ch) // 关闭通道,内存遍历循环立即结束
time.Sleep(time.Second) // 假设主程序 1 秒后退出
}
// $ go run main.go
// 输出如下
/**
true
false
true
range broken
goroutine ending
*/
发送/接收 不同步
只有发送者,没有接收者
func main() {
ch := make(chan bool)
go func() {
ch <- true
}()
}
只有接收者,没有发送者
func main() {
ch := make(chan bool)
go func() {
<-ch
}()
}
资源无法释放
如果 goroutine
内的引用的资源长时间无法被释放,也会导致 goroutine 泄漏
,典型的场景如 加锁/解锁 未同步、网络访问超时、写入大文件、数据库读写产生死锁
等。
互斥锁
func main() {
var mu sync.Mutex
go func() {
mu.Lock()
}()
time.Sleep(time.Second)
go func() {
mu.Lock()
}()
}
上述代码中,第一个 goroutine
加锁后并没有对应的解锁操作,导致第二个 goroutine
阻塞在加锁操作,发生泄漏。
通用的的工程实践是: 加锁操作完成后使用 defer
注册对应的解锁操作。
func main() {
var mu sync.Mutex
go func() {
mu.Lock()
defer mu.Unlock()
}()
time.Sleep(time.Second)
go func() {
mu.Lock()
defer mu.Unlock()
}()
}
标准库 http.Client
标准库中的 http.Client
对象默认没有超时时间限制,如果我们直接调用的情况下,很可能发生死锁:
http.Get("https://go.dev")
正确的调用方法是: 创建对象时就设置超时时间:
client := http.Client{
Timeout: 3 * time.Second,
}
client.Get("https://go.dev")
main 函数
func main() {
go func() {
time.Sleep(100 * time.Millisecond) // 模拟耗时操作
}()
}
main
函数结束时不会考虑当前是否还有 goroutine
正在执行,上面的代码中, main
函数退出后,goroutine
发生泄漏。
通用的的工程实践是: 使用同步原语保证 main
程序结束前所有 goroutine
正常退出。
os.Exit 方法
func main() {
go func() {
time.Sleep(1 * time.Second) // 模拟耗时操作
}()
go func() {
os.Exit(1)
}()
time.Sleep(100 * time.Millisecond)
}
os.Exit
方法会直接结束程序,不会考虑当前是否还有 goroutine
正在执行,所以调用前要考虑后台运行的 goroutine
情况。
最佳实践
通过上面的这些例子,我们可以看到 goroutine 泄漏
的大部分场景是因为对 channel
的错误使用而导致的。
针对上面的问题,我们来总结一下 goroutine
的应用最佳实践。
异步调用方法的选择权交给调用方
- 调用方可能并不知道方法内部使用了
goroutine
, 所以是否需要异步由调用方来决定 - 对于异步调用的方法,要设置自动退出机制,比如
信号
,超时控制
等
启动一个 goroutine 时
- 永远不要启动无法控制退出的
goroutine
- 永远不要启动无法确定何时退出的
goroutine
- 启动
goroutine
时实现panic recovery
机制,避免服务内部错误导致的不可用 - 尽量避免在请求中直接启动
goroutine
, 应该通过类似生产者/消费者
模式处理,可以避免流量突增时创建大量goroutine
导致的OOM
- 将
goroutine
设计为只能通过channel
通信退出
为什么 goroutine 不能被 kill ?
kill
一个 goroutine
在底层设计上存在很多挑战,例如:
- 当前 goroutine 持有的资源如何处理?
- 堆栈如何处理?
- defer 语句还需要执行么?
- 如果允许 defer 语句执行,但是 defer 语句可能阻塞 goroutine 退出 (形成死循环),这种场景如何处理?
goroutine 泄漏检测
针对上面提到的各种问题,是否可以实现一个 goroutine 泄漏检测
功能,如果可以的话,如何实现这个功能呢?
如果手动从零开始实现一个 goroutine 泄漏检测
功能,最简单直观的办法是抓取多次 stacktrace
,解析出所有的 goroutine ID
对比差异,最终多出来的部分就是泄漏的 goroutine
。
开源的组件会如何实现这个功能呢?我们找一个成熟的开源组件一起来学习下,毕竟站在巨人的肩膀上可以看的更远。
goleak 组件
笔者选择由 Uber
开源的 goleak 作为研究 goroutine 泄漏检测
代码实现,版本为 v1.2.1
。
示例代码
package main
import (
"testing"
"go.uber.org/goleak"
)
func TestGoroutineLeak(t *testing.T) {
defer goleak.VerifyNone(t)
ch := make(chan int)
go func() {
_ = <-ch // goroutine 阻塞造成的泄漏
t.Error("It's not going to be executed here") // 代码不会执行到这里
}()
}
测试失败,输出泄漏的 goroutine
信息:
$ go test -v -count=1 -run='TestGoroutineLeak' .
# 输出如下
=== RUN TestGoroutineLeak
main_test.go:18: found unexpected goroutines:
[Goroutine 21 in state chan receive, with test.TestGoroutineLeak.func1 on top of the stack:
goroutine 21 [chan receive]:
...
...
--- FAIL: TestGoroutineLeak (0.47s)
FAIL
goleak 源代码
配置对象
// 默认检测次数为 20 次
const _defaultRetries = 20
type opts struct {
filters []func(stack.Stack) bool // 过滤函数 (用来自定义过滤 goroutine)
maxRetries int // 最大检测次数
maxSleep time.Duration // 最长休眠时间 (默认 100 ms)
cleanup func(int) // 清理函数 (检测结束时调用)
}
创建检测配置对象
buildOpts
函数通过经典的 FUNCTIONAL OPTIONS
模式创建一个 检测对象
。
func buildOpts(options ...Option) *opts {
opts := &opts{
maxRetries: _defaultRetries, // 默认最大检测次数 20 次
maxSleep: 100 * time.Millisecond, // 默认最长休眠时间 100 ms
}
// 过滤掉 4 种调用栈信息
opts.filters = append(opts.filters,
isTestStack,
isSyscallStack,
isStdLibStack,
isTraceStack,
)
for _, option := range options {
option.apply(opts)
}
return opts
}
检测单个测试用例
VerifyNone
函数检测单个测试用例是否发生 goroutine
泄漏,常规用法是在测试用例函数中注册 defer
并调用检测函数,如 defer VerifyNone(t)
。
func VerifyNone(t TestingT, options ...Option) {
// 创建检测配置对象
opts := buildOpts(options...)
var cleanup func(int)
// 重置清理函数
cleanup, opts.cleanup = opts.cleanup, nil
...
if err := Find(opts); err != nil {
// 如果检测到 goroutine 泄漏, 直接报错
t.Error(err)
}
if cleanup != nil {
// 如果没有检测到 goroutine 泄漏, 执行清理函数
cleanup(0)
}
}
检测 goroutine 泄漏
Find
函数根据配置信息,查找泄漏的 goroutine
并返回对应的错误信息。
func Find(options ...Option) error {
// 当前执行检测的 goroutine ID
cur := stack.Current().ID()
// 创建检测配置对象
opts := buildOpts(options...)
...
var stacks []stack.Stack
retry := true
for i := 0; retry; i++ {
// 获取所有 goroutine
// 然后过滤掉当前执行检测的 goroutine 和符合过滤条件的 goroutine
stacks = filterStacks(stack.All(), cur, opts)
if len(stacks) == 0 {
// 如果没有 goroutine 了
// 说明所有的 goroutine 均已正常退出,直接返回即可
return nil
}
// 如果还有运行中的 goroutine,则休眠一会,继续检测
retry = opts.retry(i)
}
// 代码执行到这里
// 说明还有 goroutine 未退出,返回对应的 goroutine 信息
return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}
goroutine 过滤
filterStacks
函数过滤掉符合条件的 goroutine
。
func filterStacks(stacks []stack.Stack, skipID int, opts *opts) []stack.Stack {
// 高性能 Tips: 切片数据复用
filtered := stacks[:0]
for _, stack := range stacks {
// 过滤掉当前执行检测的 goroutine
if stack.ID() == skipID {
continue
}
// 过滤掉符合配置中过滤函数的 goroutine
if opts.filter(stack) {
continue
}
filtered = append(filtered, stack)
}
return filtered
}
小结
通过对源代码的分析,我们可以得出 goleak
组件的实现原理: 定时获取所有 goroutine
并且进行过滤,达到最大检测次数后,最终过滤剩下的 goroutine
就被判定为泄漏。
Reference
- uber-go/goleak
- Goroutine Leaks - The Forgotten Sender
- is it possible to a goroutine immediately stop another goroutine?