跟讀者聊 Goroutine 泄露的 N 種方法
大家好,我是煎魚。
前幾天分享 Go 羣友提問的文章時,有讀者在朋友圈下提到,希望我能夠針對 Goroutine 泄露這塊進行講解,他在面試的時候經常被問到。
另外我也相信很多小夥伴,在做救火隊長時排查過 Goroutine 泄露的問題,因爲 Goroutine 作爲一個載體,基本跑不了干係。
因此今天的男主角,就是 Go 語言的著名品牌標識 Goroutine,一個隨隨便便就能開幾十萬個快車進車道的大殺器。
for {
go func() {}()
}
本文會聚焦於 Goroutine 泄露的 N 種方法,進行詳解和說明。
爲什麼要問
面試官爲啥會問 Goroutine(協程)泄露這種奇特的問題呢?
可以猜測是:
-
Goroutine 實在是使用門檻實在是太低了,隨手就一個就能起,出現了不少濫用的情況。例如:併發 map。
-
Goroutine 本身在 Go 語言的標準庫、複合類型、底層源碼中應用廣泛。例如:HTTP Server 對每一個請求的處理就是一個協程去運行。
很多 Go 工程在線上出事故時,基本 Goroutine 的關聯,大家都會作爲救火隊長,風風火火的跑去看指標、看日誌,通過 PProf 採集 Goroutine 運行情況等。
自然他也就是最受矚目的那顆 “星” 了,所以在日常面試中,被問幾率也就極高了。
Goroutine 泄露
瞭解清楚大家愛問的原因後,我們開始對 Goroutine 泄露的 N 種方法進行研究,希望通過前人留下的 “坑”,瞭解其原理和避開這些問題。
泄露的原因大多集中在:
-
Goroutine 內正在進行 channel/mutex 等讀寫操作,但由於邏輯問題,某些情況下會被一直阻塞。
-
Goroutine 內的業務邏輯進入死循環,資源一直無法釋放。
-
Goroutine 內的業務邏輯進入長時間等待,有不斷新增的 Goroutine 進入等待。
接下來我會引用在網上衝浪收集到的一些 Goroutine 泄露例子(會在文末參考註明出處)。
channel 使用不當
Goroutine+Channel 是最經典的組合,因此不少泄露都出現於此。
最經典的就是上面提到的 channel 進行讀寫操作時的邏輯問題。
發送不接收
第一個例子:
func main() {
for i := 0; i < 4; i++ {
queryAll()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
func queryAll() int {
ch := make(chan int)
for i := 0; i < 3; i++ {
go func() { ch <- query() }()
}
return <-ch
}
func query() int {
n := rand.Intn(100)
time.Sleep(time.Duration(n) * time.Millisecond)
return n
}
輸出結果:
goroutines: 3
goroutines: 5
goroutines: 7
goroutines: 9
在這個例子中,我們調用了多次 queryAll
方法,並在 for
循環中利用 Goroutine 調用了 query
方法。其重點在於調用 query
方法後的結果會寫入 ch
變量中,接收成功後再返回 ch
變量。
最後可看到輸出的 goroutines 數量是在不斷增加的,每次多 2 個。也就是每調用一次,都會泄露 Goroutine。
原因在於 channel 均已經發送了(每次發送 3 個),但是在接收端並沒有接收完全(只返回 1 個 ch),所誘發的 Goroutine 泄露。
接收不發送
第二個例子:
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan struct{}
go func() {
ch <- struct{}{}
}()
time.Sleep(time.Second)
}
輸出結果:
goroutines: 2
在這個例子中,與 “發送不接收” 兩者是相對的,channel 接收了值,但是不發送的話,同樣會造成阻塞。
但在實際業務場景中,一般更復雜。基本是一大堆業務邏輯裏,有一個 channel 的讀寫操作出現了問題,自然就阻塞了。
nil channel
第三個例子:
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan int
go func() {
<-ch
}()
time.Sleep(time.Second)
}
輸出結果:
goroutines: 2
在這個例子中,可以得知 channel 如果忘記初始化,那麼無論你是讀,還是寫操作,都會造成阻塞。
正常的初始化姿勢是:
ch := make(chan int)
go func() {
<-ch
}()
ch <- 0
time.Sleep(time.Second)
調用 make
函數進行初始化。
奇怪的慢等待
第四個例子:
func main() {
for {
go func() {
_, err := http.Get("https://www.xxx.com/")
if err != nil {
fmt.Printf("http.Get err: %v\n", err)
}
// do something...
}()
time.Sleep(time.Second * 1)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}
}
輸出結果:
goroutines: 5
goroutines: 9
goroutines: 13
goroutines: 17
goroutines: 21
goroutines: 25
...
在這個例子中,展示了一個 Go 語言中經典的事故場景。也就是一般我們會在應用程序中去調用第三方服務的接口。
但是第三方接口,有時候會很慢,久久不返回響應結果。恰好,Go 語言中默認的 http.Client
是沒有設置超時時間的。
因此就會導致一直阻塞,一直阻塞就一直爽,Goroutine 自然也就持續暴漲,不斷泄露,最終佔滿資源,導致事故。
在 Go 工程中,我們一般建議至少對 http.Client
設置超時時間:
httpClient := http.Client{
Timeout: time.Second * 15,
}
並且要做限流、熔斷等措施,以防突發流量造成依賴崩塌,依然喫 P0。
互斥鎖忘記解鎖
第五個例子:
func main() {
total := 0
defer func() {
time.Sleep(time.Second)
fmt.Println("total: ", total)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
total += 1
}()
}
}
輸出結果:
total: 1
goroutines: 10
在這個例子中,第一個互斥鎖 sync.Mutex
加鎖了,但是他可能在處理業務邏輯,又或是忘記 Unlock
了。
因此導致後面的所有 sync.Mutex
想加鎖,卻因未釋放又都阻塞住了。一般在 Go 工程中,我們建議如下寫法:
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
defer mutex.Unlock()
total += 1
}()
}
同步鎖使用不當
第六個例子:
func handle(v int) {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < v; i++ {
fmt.Println("腦子進煎魚了")
wg.Done()
}
wg.Wait()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
go handle(3)
time.Sleep(time.Second)
}
在這個例子中,我們調用了同步編排 sync.WaitGroup
,模擬了一遍我們會從外部傳入循環遍歷的控制變量。
但由於 wg.Add
的數量與 wg.Done
數量並不匹配,因此在調用 wg.Wait
方法後一直阻塞等待。
在 Go 工程中使用,我們會建議如下寫法:
var wg sync.WaitGroup
for i := 0; i < v; i++ {
wg.Add(1)
defer wg.Done()
fmt.Println("腦子進煎魚了")
}
wg.Wait()
排查方法
我們可以調用 runtime.NumGoroutine
方法來獲取 Goroutine 的運行數量,進行前後一比較,就能知道有沒有泄露了。
但在業務服務的運行場景中,Goroutine 內導致的泄露,大多數處於生產、測試環境,因此更多的是使用 PProf:
import (
"net/http"
_ "net/http/pprof"
)
http.ListenAndServe("localhost:6060", nil))
只要我們調用 http://localhost:6060/debug/pprof/goroutine?debug=1
,PProf 會返回所有帶有堆棧跟蹤的 Goroutine 列表。
也可以利用 PProf 的其他特性進行綜合查看和分析,這塊參考我之前寫的《Go 大殺器之性能剖析 PProf》,基本是全村最全的教程了。
總結
在今天這篇文章中,我們針對 Goroutine 泄露的 N 種常見的方式方法進行了一一分析,雖說看起來都是比較基礎的場景。
但結合在實際業務代碼中,就是一大坨中的某個細節導致全盤皆輸了,希望上面幾個案例能夠給大家帶來警惕。
而面試官愛問,怕不是自己踩過許多坑,也希望進來的同僚,也是身經百戰了。
靠譜的工程師,而非只是八股工程師。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ql01K1nOnEZpdbp--6EDYw