goroutine 泄漏與檢測
概述
Go 語言內置 GC,因此一般不會內存泄漏,但是 goroutine
可能會發生泄漏,泄漏的 goroutine
引用的內存同樣無法被 GC 正常回收。
常見泄漏場景
下面總結一下開發中經常遇到的 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 泄漏檢測
功能,最簡單直觀的辦法是抓取多次 stacktrace
,解析出所有的 goroutine ID
對比差異,最終多出來的部分就是泄漏的 goroutine
。
開源的組件會如何實現這個功能呢?我們找一個成熟的開源組件一起來學習下,畢竟站在巨人的肩膀上可以看的更遠。
goleak 組件
筆者選擇由 Uber
開源的 goleak[1] 作爲研究 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[2]
-
Goroutine Leaks - The Forgotten Sender[3]
-
is it possible to a goroutine immediately stop another goroutine? [4]
鏈接
[1]
goleak: https://github.com/uber-go/goleak
[2]
uber-go/goleak: https://github.com/uber-go/goleak
[3]
Goroutine Leaks - The Forgotten Sender: https://www.ardanlabs.com/blog/2018/11/goroutine-leaks-the-forgotten-sender.html
[4]
is it possible to a goroutine immediately stop another goroutine? : https://github.com/golang/go/issues/32610
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-hflcZfkYSIPers4PYJ7zQ