goroutine 泄漏與檢測

概述

Go 語言內置 GC,因此一般不會內存泄漏,但是 goroutine 可能會發生泄漏,泄漏的 goroutine 引用的內存同樣無法被 GC 正常回收。

常見泄漏場景

下面總結一下開發中經常遇到的 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 不能被 kill ?

kill 一個 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

鏈接

[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