跟讀者聊 Goroutine 泄露的 N 種方法

大家好,我是煎魚。

前幾天分享 Go 羣友提問的文章時,有讀者在朋友圈下提到,希望我能夠針對 Goroutine 泄露這塊進行講解,他在面試的時候經常被問到。

另外我也相信很多小夥伴,在做救火隊長時排查過 Goroutine 泄露的問題,因爲 Goroutine 作爲一個載體,基本跑不了干係。

因此今天的男主角,就是 Go 語言的著名品牌標識 Goroutine,一個隨隨便便就能開幾十萬個快車進車道的大殺器。

for {
    go func() {}()
}

本文會聚焦於 Goroutine 泄露的 N 種方法,進行詳解和說明。

爲什麼要問

面試官爲啥會問 Goroutine(協程)泄露這種奇特的問題呢?

可以猜測是:

很多 Go 工程在線上出事故時,基本 Goroutine 的關聯,大家都會作爲救火隊長,風風火火的跑去看指標、看日誌,通過 PProf 採集 Goroutine 運行情況等。

自然他也就是最受矚目的那顆 “星” 了,所以在日常面試中,被問幾率也就極高了。

Goroutine 泄露

瞭解清楚大家愛問的原因後,我們開始對 Goroutine 泄露的 N 種方法進行研究,希望通過前人留下的 “坑”,瞭解其原理和避開這些問題。

泄露的原因大多集中在:

接下來我會引用在網上衝浪收集到的一些 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