跟面試官聊 Goroutine 泄露的 6 種方法,真刺激!

微信搜索【 腦子進煎魚了】關注這一隻爆肝煎魚。本文 GitHub github.com/eddycjy/blog 已收錄,有個人系列文章、資料和開源 Go 圖書。

你們好,我是煎魚git

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

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

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 種常見的方式方法進行了一一分析,雖然說看起來都是比較基礎的場景。

但結合在實際業務代碼中,就是一大坨中的某個細節致使全盤皆輸了,但願上面幾個案例可以給你們帶來警戒。

而面試官愛問,怕不是本身踩過許多坑,也但願進來的同僚,也是身經百戰了。

靠譜的工程師,而非只是八股工程師。

如有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創做的最大動力,感謝支持。

文章持續更新,能夠微信搜【腦子進煎魚了】閱讀,回覆【 000】有我準備的一線大廠面試算法題解和資料;本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。
相關文章
相關標籤/搜索