goroutine 泄漏与检测

Posted by kzdgt on Wednesday, March 27, 2024

goroutine 泄漏与检测 - 蛮荆 (dbwu.tech)

概述

Go 语言内置 GC,因此一般不会内存泄漏,但是 goroutine 可能会发生泄漏,泄漏的 goroutine 引用的内存同样无法被 GC 正常回收。

常见 goroutine 泄漏场景

下面总结一下开发中经常遇到的 goroutine 泄漏场景,本文示例代码只是为了演示,没有任何现实意义。

通道为 nil

nil 通道 上发送和接收操作将永久阻塞,会造成 goroutine 泄漏

最佳实践:

  1. 永远不要对 nil 通道 进行任何操作
  2. 直接使用 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 泄漏

最佳实践:

  1. 确保 通道 可以正常关闭
  2. 确保 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

扩展阅读

「真诚赞赏,手留余香」

kzdgt Blog

真诚赞赏,手留余香

使用微信扫描二维码完成支付