如何在測試中發現 goroutine 泄漏
前言
哈嘍,大家好,我是
asong
;衆所周知,
gorourtine
的設計是Go
語言併發實現的核心組成部分,易上手,但是也會遭遇各種疑難雜症,其中goroutine
泄漏就是重症之一,其出現往往需要排查很久,有人說可以使用pprof
來排查,雖然其可以達到目的,但是這些性能分析工具往往是在出現問題後藉助其輔助排查使用的,有沒有一款可以防患於未然的工具嗎?當然有,goleak
他來了,其由Uber
團隊開源,可以用來檢測goroutine
泄漏,並且可以結合單元測試,可以達到防範於未然的目的,本文我們就一起來看一看goleak
。
goroutine 泄漏
不知道你們在日常開發中是否有遇到過goroutine
泄漏,goroutine
泄漏其實就是goroutine
阻塞,這些阻塞的goroutine
會一直存活直到進程終結,他們佔用的棧內存一直無法釋放,從而導致系統的可用內存會越來越少,直至崩潰!簡單總結了幾種常見的泄漏原因:
-
Goroutine
內的邏輯進入死循壞,一直佔用資源 -
Goroutine
配合channel
/mutex
使用時,由於使用不當導致一直被阻塞 -
Goroutine
內的邏輯長時間等待,導致Goroutine
數量暴增
接下來我們使用Goroutine
+channel
的經典組合來展示goroutine
泄漏;
func GetData() {
var ch chan struct{}
go func() {
<- ch
}()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
GetData()
time.Sleep(2 * time.Second)
}
這個例子是channel
忘記初始化,無論是讀寫操作都會造成阻塞,這個方法如果是寫單測是檢查不出來問題的:
func TestGetData(t *testing.T) {
GetData()
}
運行結果:
=== RUN TestGetData
--- PASS: TestGetData (0.00s)
PASS
內置測試無法滿足,接下來我們引入goleak
來測試一下。
goleak
github 地址:https://github.com/uber-go/goleak
使用goleak
主要關注兩個方法即可:VerifyNone
、VerifyTestMain
,VerifyNone
用於單一測試用例中測試,VerifyTestMain
可以在TestMain
中添加,可以減少對測試代碼的入侵,舉例如下:
使用VerifyNone
:
func TestGetDataWithGoleak(t *testing.T) {
defer goleak.VerifyNone(t)
GetData()
}
運行結果:
=== RUN TestGetDataWithGoleak
leaks.go:78: found unexpected goroutines:
[Goroutine 35 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
goroutine 35 [chan receive (nil chan)]:
asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
]
--- FAIL: TestGetDataWithGoleak (0.45s)
FAIL
Process finished with the exit code 1
通過運行結果看到具體發生goroutine
泄漏的具體代碼段;使用VerifyNone
會對我們的測試代碼有入侵,可以採用VerifyTestMain
方法可以更快的集成到測試中:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
運行結果:
=== RUN TestGetData
--- PASS: TestGetData (0.00s)
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 5 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
goroutine 5 [chan receive (nil chan)]:
asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
]
Process finished with the exit code 1
VerifyTestMain
的運行結果與VerifyNone
有一點不同,VerifyTestMain
會先報告測試用例執行結果,然後報告泄漏分析,如果測試的用例中有多個goroutine
泄漏,無法精確定位到發生泄漏的具體 test,需要使用如下腳本進一步分析:
# Create a test binary which will be used to run each test individually
$ go test -c -o tests
# Run each test individually, printing "." for successful tests, or the test name
# for failing tests.
$ for test in $(go test -list . | grep -E "^(Test|Example)"); do ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo -e "\n$test failed"; done
這樣會打印出具體哪個測試用例失敗。
goleak 實現原理
從VerifyNone
入口,我們查看源代碼,其調用了Find
方法:
// Find looks for extra goroutines, and returns a descriptive error if
// any are found.
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
stacks = filterStacks(stack.All(), cur, opts)
if len(stacks) == 0 {
return nil
}
retry = opts.retry(i)
}
return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}
我們在看一下filterStacks
方法:
// filterStacks will filter any stacks excluded by the given opts.
// filterStacks modifies the passed in stacks slice.
func filterStacks(stacks []stack.Stack, skipID int, opts *opts) []stack.Stack {
filtered := stacks[:0]
for _, stack := range stacks {
// Always skip the running goroutine.
if stack.ID() == skipID {
continue
}
// Run any default or user-specified filters.
if opts.filter(stack) {
continue
}
filtered = append(filtered, stack)
}
return filtered
}
這裏主要是過濾掉一些不參與檢測的goroutine stack
,如果沒有自定義filters
,則使用默認的filters
:
func buildOpts(options ...Option) *opts {
opts := &opts{
maxRetries: _defaultRetries,
maxSleep: 100 * time.Millisecond,
}
opts.filters = append(opts.filters,
isTestStack,
isSyscallStack,
isStdLibStack,
isTraceStack,
)
for _, option := range options {
option.apply(opts)
}
return opts
}
從這裏可以看出,默認檢測20
次,每次默認間隔100ms
;添加默認filters
;
總結一下goleak
的實現原理:
使用runtime.Stack()
方法獲取當前運行的所有goroutine
的棧信息,默認定義不需要檢測的過濾項,默認定義檢測次數 + 檢測間隔,不斷週期進行檢測,最終在多次檢查後仍沒有找到剩下的goroutine
則判斷沒有發生goroutine
泄漏。
總結
本文我們分享了一個可以在測試中發現goroutine
泄漏的工具,但是其還是需要完備的測試用例支持,這就暴露出測試用例的重要性,朋友們好的工具可以助我們更快的發現問題,但是代碼質量還是掌握在我們自己的手中,加油吧,少年們~。
好啦,本文到這裏就結束了,我是 asong,我們下期見。
Golang 夢工廠 asong 是一名後端程序員,目前就職於一家電商公司,專注於 Golang 技術,定期分享 Go 語言、MySQL、Redis、Elasticsearch、計算機基礎、微服務架構設計、面試等知識。這裏不僅有技術,還有故事!
參考資料
-
https://github.com/uber-go/goleak
-
https://segmentfault.com/a/1190000040161853
-
https://blog.schwarzeni.com/2021/04/09/goleak-%E7%A0%94%E7%A9%B6/
-
https://zhuanlan.zhihu.com/p/361737398
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/GQFuVTurmY0m8WByB1nKeQ