Go 的并發模型與其他語言不同,雖說它簡化了并發程序的開發難度,但如果不了解使用方法,常常會遇到 goroutine 泄露的問題。雖然 goroutine 是輕量級的線程,占" />

Go 防止 goroutine 泄露的方法

 更新時間:2020-01-15 16:01:43   作者:佚名   我要評論(0)

概述
Go 的并發模型與其他語言不同,雖說它簡化了并發程序的開發難度,但如果不了解使用方法,常常會遇到 goroutine 泄露的問題。雖然 goroutine 是輕量級的線程,占

概述

Go 的并發模型與其他語言不同,雖說它簡化了并發程序的開發難度,但如果不了解使用方法,常常會遇到 goroutine 泄露的問題。雖然 goroutine 是輕量級的線程,占用資源很少,但如果一直得不到釋放并且還在不斷創建新協程,毫無疑問是有問題的,并且是要在程序運行幾天,甚至更長的時間才能發現的問題。

對于上面描述的問題,我覺得可以從兩方面入手解決,如下:

一是預防,要做到預防,我們就需要了解什么樣的代碼會產生泄露,以及了解如何寫出正確的代碼;

二是監控,雖說預防減少了泄露產生的概率,但沒有人敢說自己不犯錯,因而,通常我們還需要一些監控手段進一步保證程序的健壯性;

接下來,我將會分兩篇文章分別從這兩個角度進行介紹,今天先談第一點。

如何監控泄露

本文主要集中在第一點上,但為了更好的演示效果,可以先介紹一個最簡單的監控方式。通過 runtime.NumGoroutine() 獲取當前運行中的 goroutine 數量,通過它確認是否發生泄漏。它的使用非常簡單,就不為它專門寫個例子了。

一個簡單的例子

語言級別的并發支持是 Go 的一大優勢,但這個優勢也很容易被濫用。通常我們在開始 Go 并發學習時,常常聽別人說,Go 的并發非常簡單,在調用函數前加上 go 關鍵詞便可啟動 goroutine,即一個并發單元,但很多人可能只聽到了這句話,然后就出現了類似下面的代碼:

package main
import (
 "fmt"
 "runtime"
 "time"
)
func sayHello() {
 for {
 fmt.Println("Hello gorotine")
 time.Sleep(time.Second)
 }
}
func main() {
 defer func() {
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 go sayHello()
 fmt.Println("Hello main")
}

對 Go 比較熟悉的話,很容易發現這段代碼的問題,sayHello 是個死循環,沒有如何退出機制,因此也就沒有任何辦法釋放創建的 goroutine。我們通過在 main 函數最前面的 defer 實現在函數退出時打印當前運行中的 goroutine 數量,毫無意外,它的輸出如下:

the number of goroutines: 2

不過,因為上面的程序并非常駐,有泄露問題也不大,程序退出后系統會自動回收運行時資源。但如果這段代碼在常駐服務中執行,比如 http server,每接收到一個請求,便會啟動一次 sayHello,時間流逝,每次啟動的 goroutine 都得不到釋放,你的服務將會離奔潰越來越近。

這個例子比較簡單,我相信,對 Go 的并發稍微有點了解的朋友都不會犯這個錯。

泄露情況分類

前面介紹的例子由于在 goroutine 運行死循環導致的泄露。接下來,我會按照并發的數據同步方式對泄露的各種情況進行分析。簡單可歸于兩類,即:

  • channel 導致的泄露
  • 傳統同步機制導致的泄露

傳統同步機制主要指面向共享內存的同步機制,比如排它鎖、共享鎖等。這兩種情況導致的泄露還是比較常見的。go 由于 defer 的存在,第二類情況,一般情況下還是比較容易避免的。

chanel 引起的泄露

先說 channel,如果之前讀過官方的那篇并發的文章[1],翻譯版[2],你會發現 channel 的使用,一個不小心就泄露了。我們來具體總結下那些情況下可能導致。

發送不接收

我們知道,發送者一般都會配有相應的接收者。理想情況下,我們希望接收者總能接收完所有發送的數據,這樣就不會有任何問題。但現實是,一旦接收者發生異常退出,停止繼續接收上游數據,發送者就會被阻塞。這個情況在 前面說的文章[3] 中有非常細致的介紹。

示例代碼:

package main
import "time"
func gen(nums ...int) <-chan int {
 out := make(chan int)
 go func() {
 for _, n := range nums {
  out <- n
 }
 close(out)
 }()
 return out
}
func main() {
 defer func() {
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 // Set up the pipeline.
 out := gen(2, 3)
 for n := range out {
 fmt.Println(n)  // 2
 time.Sleep(5 * time.Second) // done thing, 可能異常中斷接收
 if true { // if err != nil
  break
 }
 }
}

例子中,發送者通過 out chan 向下游發送數據,main 函數接收數據,接收者通常會依據接收到的數據做一些具體的處理,這里用 Sleep 代替。如果這期間發生異常,導致處理中斷,退出循環。gen 函數中啟動的 goroutine 并不會退出。

如何解決?

此處的主要問題在于,當接收者停止工作,發送者并不知道,還在傻傻地向下游發送數據。故而,我們需要一種機制去通知發送者。我直接說答案吧,就不循漸進了。Go 可以通過 channel 的關閉向所有的接收者發送廣播信息。

修改后的代碼:

package main
import "time"
func gen(done chan struct{}, nums ...int) <-chan int {
 out := make(chan int)
 go func() {
 defer close(out)
 for _, n := range nums {
  select {
  case out <- n:
  case <-done:
  return
  }
 }
 }()
 return out
}
func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 // Set up the pipeline.
 done := make(chan struct{})
 defer close(done)
 out := gen(done, 2, 3)
 for n := range out {
 fmt.Println(n) // 2
 time.Sleep(5 * time.Second) // done thing, 可能異常中斷接收
 if true { // if err != nil
  break
 }
 }
}

函數 gen 中通過 select 實現 2 個 channel 的同時處理。當異常發生時,將進入 <-done 分支,實現 goroutine 退出。這里為了演示效果,保證資源順利釋放,退出時等待了幾秒保證釋放完成。

執行后的輸出如下:

the number of goroutines:  1

現在只有主 goroutine 存在。

接收不發送

發送不接收會導致發送者阻塞,反之,接收不發送也會導致接收者阻塞。直接看示例代碼,如下:

package main

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()

 var ch chan struct{}
 go func() {
 ch <- struct{}{}
 }()
}

運行結果顯示:

the number of goroutines:  2

當然,我們正常不會遇到這么傻的情況發生,現實工作中的案例更多可能是發送已完成,但是發送者并沒有關閉 channel,接收者自然也無法知道發送完畢,阻塞因此就發生了。

解決方案是什么?那當然就是,發送完成后一定要記得關閉 channel。

nil channel

向 nil channel 發送和接收數據都將會導致阻塞。這種情況可能在我們定義 channel 時忘記初始化的時候發生。

示例代碼:

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 var ch chan int
 go func() {
 <-ch
 // ch<-
 }()
}

兩種寫法:<-ch 和 ch<- 1,分別表示接收與發送,都將會導致阻塞。如果想實現阻塞,通過 nil channel 和 done channel 結合實現阻止 main 函數的退出,這或許是可以一試的方法。

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 done := make(chan struct{})
 var ch chan int
 go func() {
 defer close(done)
 }()
 select {
 case <-ch:
 case <-done:
 return
 }
}

在 goroutine 執行完成,檢測到 done 關閉,main 函數退出。

真實的場景

真實的場景肯定不會像案例中的簡單,可能涉及多階段 goroutine 之間的協作,某個 goroutine 可能即使接收者又是發送者。但歸根到底,無論什么使用模式。都是把基礎知識組織在一起的合理運用。

傳統同步機制

雖然,一般推薦 Go 并發數據的傳遞,但有些場景下,顯然還是使用傳統同步機制更合適。Go 中提供傳統同步機制主要在 sync 和 atomic 兩個包。接下來,我主要介紹的是鎖和 WaitGroup 可能導致 goroutine 的泄露。

Mutex

和其他語言類似,Go 中存在兩種鎖,排它鎖和共享鎖,關于它們的使用就不作介紹了。我們以排它鎖為例進行分析。

示例如下:

func main() {
 total := 0
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("total: ", total)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 var mutex sync.Mutex
 for i := 0; i < 2; i++ {
 go func() {
  mutex.Lock()
  total += 1
 }()
 }
}

執行結果如下:

total: 1
the number of goroutines: 2

這段代碼通過啟動兩個 goroutine 對 total 進行加法操作,為防止出現數據競爭,對計算部分做了加鎖保護,但并沒有及時的解鎖,導致 i = 1 的 goroutine 一直阻塞等待 i = 0 的 goroutine 釋放鎖。可以看到,退出時有 2 個 goroutine 存在,出現了泄露,total 的值為 1。

怎么解決?因為 Go 有 defer 的存在,這個問題還是非常容易解決的,只要記得在 Lock 的時候,記住 defer Unlock 即可。

示例如下:

mutex.Lock()
defer mutext.Unlock()

其他的鎖與這里其實都是類似的。

WaitGroup

WaitGroup 和鎖有所差別,它類似 Linux 中的信號量,可以實現一組 goroutine 操作的等待。使用的時候,如果設置了錯誤的任務數,也可能會導致阻塞,導致泄露發生。

一個例子,我們在開發一個后端接口時需要訪問多個數據表,由于數據間沒有依賴關系,我們可以并發訪問,示例如下:

package main
import (
 "fmt"
 "runtime"
 "sync"
 "time"
)
func handle() {
 var wg sync.WaitGroup
 wg.Add(4)
 go func() {
 fmt.Println("訪問表1")
 wg.Done()
 }()
 go func() {
 fmt.Println("訪問表2")
 wg.Done()
 }()
 go func() {
 fmt.Println("訪問表3")
 wg.Done()
 }()
 wg.Wait()
}
func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 go handle()
 time.Sleep(time.Second)
}

執行結果如下:

the number of goroutines: 2

出現了泄露。再看代碼,它的開始部分定義了類型為 sync.WaitGroup 的變量 wg,設置并發任務數為 4,但是從例子中可以看出只有 3 個并發任務。故最后的 wg.Wait() 等待退出條件將永遠無法滿足,handle 將會一直阻塞。

怎么防止這類情況發生?

我個人的建議是,盡量不要一次設置全部任務數,即使數量非常明確的情況。因為在開始多個并發任務之間或許也可能出現被阻斷的情況發生。最好是盡量在任務啟動時通過 wg.Add(1) 的方式增加。

示例如下:

 ...
 wg.Add(1)
 go func() {
 fmt.Println("訪問表1")
 wg.Done()
 }()
 wg.Add(1)
 go func() {
 fmt.Println("訪問表2")
 wg.Done()
 }()
 wg.Add(1)
 go func() {
 fmt.Println("訪問表3")
 wg.Done()
 }()
 ...

總結

大概介紹完了我認為的所有可能導致 goroutine 泄露的情況。總結下來,其實無論是死循環、channel 阻塞、鎖等待,只要是會造成阻塞的寫法都可能產生泄露。因而,如何防止 goroutine 泄露就變成了如何防止發生阻塞。為進一步防止泄露,有些實現中會加入超時處理,主動釋放處理時間太長的 goroutine。

以上所述是小編給大家介紹的Go 防止 goroutine 泄露的方法,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對腳本之家網站的支持!
如果你覺得本文對你有幫助,歡迎轉載,煩請注明出處,謝謝!

您可能感興趣的文章:

  • GOLANG使用Context管理關聯goroutine的方法
  • Golang 探索對Goroutine的控制方法(詳解)
  • 關于Golang中for-loop與goroutine的問題詳解
  • go語言執行等待直到后臺goroutine執行完成實例分析
  • Go語言輕量級線程Goroutine用法實例

相關文章

  • Go 防止 goroutine 泄露的方法

    Go 防止 goroutine 泄露的方法

    概述 Go 的并發模型與其他語言不同,雖說它簡化了并發程序的開發難度,但如果不了解使用方法,常常會遇到 goroutine 泄露的問題。雖然 goroutine 是輕量級的線程,占
    2020-01-15
  • Go實現雙向鏈表的示例代碼

    Go實現雙向鏈表的示例代碼

    本文介紹什么是鏈表,常見的鏈表有哪些,然后介紹鏈表這種數據結構會在哪些地方可以用到,以及 Redis 隊列是底層的實現,通過一個小實例來演示 Redis 隊列有哪些功能
    2020-01-15
  • golang中之strconv包的具體使用方法

    golang中之strconv包的具體使用方法

    在編程過程中,我們常常需要用到字符串與其它類型的轉換,strconv包可以幫我們實現此功能。 1.string -> int 使用方法:func Atoi(s string) (i int, err error)
    2020-01-15
  • golang實現對docker容器心跳監控功能

    golang實現對docker容器心跳監控功能

    自己寫的go程序放到線上本來編譯成二進制扔上去就行啦,但是懷著一顆docker的心,最終還是將它放到docker容器中運行起來了,運行起來也ok,一個最小容器64M,統一管
    2020-01-15
  • golang之數據校驗的實現代碼示例

    golang之數據校驗的實現代碼示例

    目前大都是使用 validator 安裝 go get gopkg.in/go-playground/validator.v9 原理 當然只能通過反射來實現了,之前寫過一篇反射的文章 golang之反射和斷言
    2020-01-15
  • golang協程池設計詳解

    golang協程池設計詳解

    Why Pool go自從出生就身帶“高并發”的標簽,其并發編程就是由groutine實現的,因其消耗資源低,性能高效,開發成本低的特性而被廣泛應用到各種場景,例如服務端開
    2020-01-15
  • golang之反射和斷言的具體使用

    golang之反射和斷言的具體使用

    1. 反射 反射這個概念絕大多數語言都有,比如Java,PHP之類,golang自然也不例外,反射其實程序能夠自描述和自控制的一類機制。 比如,通過PHP的反射,你可以
    2020-01-15
  • golang中使用proto3協議導致的空值字段不顯示的問題處理方案

    golang中使用proto3協議導致的空值字段不顯示的問題處理方案

    最近在使用grpc協議的時候,由于采用的是Proto3協議,在查找記錄信息的時候,由于某些字段會有默認空值,導致在通過協議調用后,返回的json結構中并沒有這些字段,雖
    2020-01-15
  • 使用Go添加HTTPS的實現代碼示例

    使用Go添加HTTPS的實現代碼示例

    簡介 現在的網站沒有 HTTPS 都不好意思見人了. 超文本傳輸安全協議(英語:HyperText Transfer Protocol Secure,縮寫:HTTPS;常稱為 HTTP over TLS、HTTP over
    2020-01-15
  • Golang實現請求限流的幾種辦法(小結)

    Golang實現請求限流的幾種辦法(小結)

    在開發高并發系統時,有三把利器用來保護系統:緩存、降級和限流。那么何為限流呢?顧名思義,限流就是限制流量,就像你寬帶包了1個G的流量,用完了就沒了。 簡單的
    2020-01-15

最新評論

买宝宝用品赚钱吗