Go 防止 goroutine 泄露的方法
概述
Go 的并發(fā)模型與其他語言不同,雖說它簡化了并發(fā)程序的開發(fā)難度,但如果不了解使用方法,常常會遇到 goroutine 泄露的問題。雖然 goroutine 是輕量級的線程,占用資源很少,但如果一直得不到釋放并且還在不斷創(chuàng)建新協(xié)程,毫無疑問是有問題的,并且是要在程序運(yùn)行幾天,甚至更長的時(shí)間才能發(fā)現(xiàn)的問題。
對于上面描述的問題,我覺得可以從兩方面入手解決,如下:
一是預(yù)防,要做到預(yù)防,我們就需要了解什么樣的代碼會產(chǎn)生泄露,以及了解如何寫出正確的代碼;
二是監(jiān)控,雖說預(yù)防減少了泄露產(chǎn)生的概率,但沒有人敢說自己不犯錯(cuò),因而,通常我們還需要一些監(jiān)控手段進(jìn)一步保證程序的健壯性;
接下來,我將會分兩篇文章分別從這兩個(gè)角度進(jìn)行介紹,今天先談第一點(diǎn)。
如何監(jiān)控泄露
本文主要集中在第一點(diǎn)上,但為了更好的演示效果,可以先介紹一個(gè)最簡單的監(jiān)控方式。通過 runtime.NumGoroutine() 獲取當(dāng)前運(yùn)行中的 goroutine 數(shù)量,通過它確認(rèn)是否發(fā)生泄漏。它的使用非常簡單,就不為它專門寫個(gè)例子了。
一個(gè)簡單的例子
語言級別的并發(fā)支持是 Go 的一大優(yōu)勢,但這個(gè)優(yōu)勢也很容易被濫用。通常我們在開始 Go 并發(fā)學(xué)習(xí)時(shí),常常聽別人說,Go 的并發(fā)非常簡單,在調(diào)用函數(shù)前加上 go 關(guān)鍵詞便可啟動(dòng) goroutine,即一個(gè)并發(fā)單元,但很多人可能只聽到了這句話,然后就出現(xiàn)了類似下面的代碼:
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 比較熟悉的話,很容易發(fā)現(xiàn)這段代碼的問題,sayHello 是個(gè)死循環(huán),沒有如何退出機(jī)制,因此也就沒有任何辦法釋放創(chuàng)建的 goroutine。我們通過在 main 函數(shù)最前面的 defer 實(shí)現(xiàn)在函數(shù)退出時(shí)打印當(dāng)前運(yùn)行中的 goroutine 數(shù)量,毫無意外,它的輸出如下:
the number of goroutines: 2
不過,因?yàn)樯厦娴某绦虿⒎浅qv,有泄露問題也不大,程序退出后系統(tǒng)會自動(dòng)回收運(yùn)行時(shí)資源。但如果這段代碼在常駐服務(wù)中執(zhí)行,比如 http server,每接收到一個(gè)請求,便會啟動(dòng)一次 sayHello,時(shí)間流逝,每次啟動(dòng)的 goroutine 都得不到釋放,你的服務(wù)將會離奔潰越來越近。
這個(gè)例子比較簡單,我相信,對 Go 的并發(fā)稍微有點(diǎn)了解的朋友都不會犯這個(gè)錯(cuò)。
泄露情況分類
前面介紹的例子由于在 goroutine 運(yùn)行死循環(huán)導(dǎo)致的泄露。接下來,我會按照并發(fā)的數(shù)據(jù)同步方式對泄露的各種情況進(jìn)行分析。簡單可歸于兩類,即:
- channel 導(dǎo)致的泄露
- 傳統(tǒng)同步機(jī)制導(dǎo)致的泄露
傳統(tǒng)同步機(jī)制主要指面向共享內(nèi)存的同步機(jī)制,比如排它鎖、共享鎖等。這兩種情況導(dǎo)致的泄露還是比較常見的。go 由于 defer 的存在,第二類情況,一般情況下還是比較容易避免的。
chanel 引起的泄露
先說 channel,如果之前讀過官方的那篇并發(fā)的文章[1],翻譯版[2],你會發(fā)現(xiàn) channel 的使用,一個(gè)不小心就泄露了。我們來具體總結(jié)下那些情況下可能導(dǎo)致。
發(fā)送不接收
我們知道,發(fā)送者一般都會配有相應(yīng)的接收者。理想情況下,我們希望接收者總能接收完所有發(fā)送的數(shù)據(jù),這樣就不會有任何問題。但現(xiàn)實(shí)是,一旦接收者發(fā)生異常退出,停止繼續(xù)接收上游數(shù)據(jù),發(fā)送者就會被阻塞。這個(gè)情況在 前面說的文章[3] 中有非常細(xì)致的介紹。
示例代碼:
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 } } }
例子中,發(fā)送者通過 out chan 向下游發(fā)送數(shù)據(jù),main 函數(shù)接收數(shù)據(jù),接收者通常會依據(jù)接收到的數(shù)據(jù)做一些具體的處理,這里用 Sleep 代替。如果這期間發(fā)生異常,導(dǎo)致處理中斷,退出循環(huán)。gen 函數(shù)中啟動(dòng)的 goroutine 并不會退出。
如何解決?
此處的主要問題在于,當(dāng)接收者停止工作,發(fā)送者并不知道,還在傻傻地向下游發(fā)送數(shù)據(jù)。故而,我們需要一種機(jī)制去通知發(fā)送者。我直接說答案吧,就不循漸進(jìn)了。Go 可以通過 channel 的關(guān)閉向所有的接收者發(fā)送廣播信息。
修改后的代碼:
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 } } }
函數(shù) gen 中通過 select 實(shí)現(xiàn) 2 個(gè) channel 的同時(shí)處理。當(dāng)異常發(fā)生時(shí),將進(jìn)入 <-done 分支,實(shí)現(xiàn) goroutine 退出。這里為了演示效果,保證資源順利釋放,退出時(shí)等待了幾秒保證釋放完成。
執(zhí)行后的輸出如下:
the number of goroutines: 1
現(xiàn)在只有主 goroutine 存在。
接收不發(fā)送
發(fā)送不接收會導(dǎo)致發(fā)送者阻塞,反之,接收不發(fā)送也會導(dǎo)致接收者阻塞。直接看示例代碼,如下:
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{}{} }() }
運(yùn)行結(jié)果顯示:
the number of goroutines: 2
當(dāng)然,我們正常不會遇到這么傻的情況發(fā)生,現(xiàn)實(shí)工作中的案例更多可能是發(fā)送已完成,但是發(fā)送者并沒有關(guān)閉 channel,接收者自然也無法知道發(fā)送完畢,阻塞因此就發(fā)生了。
解決方案是什么?那當(dāng)然就是,發(fā)送完成后一定要記得關(guān)閉 channel。
nil channel
向 nil channel 發(fā)送和接收數(shù)據(jù)都將會導(dǎo)致阻塞。這種情況可能在我們定義 channel 時(shí)忘記初始化的時(shí)候發(fā)生。
示例代碼:
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,分別表示接收與發(fā)送,都將會導(dǎo)致阻塞。如果想實(shí)現(xiàn)阻塞,通過 nil channel 和 done channel 結(jié)合實(shí)現(xiàn)阻止 main 函數(shù)的退出,這或許是可以一試的方法。
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 執(zhí)行完成,檢測到 done 關(guān)閉,main 函數(shù)退出。
真實(shí)的場景
真實(shí)的場景肯定不會像案例中的簡單,可能涉及多階段 goroutine 之間的協(xié)作,某個(gè) goroutine 可能即使接收者又是發(fā)送者。但歸根到底,無論什么使用模式。都是把基礎(chǔ)知識組織在一起的合理運(yùn)用。
傳統(tǒng)同步機(jī)制
雖然,一般推薦 Go 并發(fā)數(shù)據(jù)的傳遞,但有些場景下,顯然還是使用傳統(tǒng)同步機(jī)制更合適。Go 中提供傳統(tǒng)同步機(jī)制主要在 sync 和 atomic 兩個(gè)包。接下來,我主要介紹的是鎖和 WaitGroup 可能導(dǎo)致 goroutine 的泄露。
Mutex
和其他語言類似,Go 中存在兩種鎖,排它鎖和共享鎖,關(guān)于它們的使用就不作介紹了。我們以排它鎖為例進(jìn)行分析。
示例如下:
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 }() } }
執(zhí)行結(jié)果如下:
total: 1
the number of goroutines: 2
這段代碼通過啟動(dòng)兩個(gè) goroutine 對 total 進(jìn)行加法操作,為防止出現(xiàn)數(shù)據(jù)競爭,對計(jì)算部分做了加鎖保護(hù),但并沒有及時(shí)的解鎖,導(dǎo)致 i = 1 的 goroutine 一直阻塞等待 i = 0 的 goroutine 釋放鎖??梢钥吹?,退出時(shí)有 2 個(gè) goroutine 存在,出現(xiàn)了泄露,total 的值為 1。
怎么解決?因?yàn)?Go 有 defer 的存在,這個(gè)問題還是非常容易解決的,只要記得在 Lock 的時(shí)候,記住 defer Unlock 即可。
示例如下:
mutex.Lock() defer mutext.Unlock()
其他的鎖與這里其實(shí)都是類似的。
WaitGroup
WaitGroup 和鎖有所差別,它類似 Linux 中的信號量,可以實(shí)現(xiàn)一組 goroutine 操作的等待。使用的時(shí)候,如果設(shè)置了錯(cuò)誤的任務(wù)數(shù),也可能會導(dǎo)致阻塞,導(dǎo)致泄露發(fā)生。
一個(gè)例子,我們在開發(fā)一個(gè)后端接口時(shí)需要訪問多個(gè)數(shù)據(jù)表,由于數(shù)據(jù)間沒有依賴關(guān)系,我們可以并發(fā)訪問,示例如下:
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) }
執(zhí)行結(jié)果如下:
the number of goroutines: 2
出現(xiàn)了泄露。再看代碼,它的開始部分定義了類型為 sync.WaitGroup
的變量 wg,設(shè)置并發(fā)任務(wù)數(shù)為 4,但是從例子中可以看出只有 3 個(gè)并發(fā)任務(wù)。故最后的 wg.Wait()
等待退出條件將永遠(yuǎn)無法滿足,handle 將會一直阻塞。
怎么防止這類情況發(fā)生?
我個(gè)人的建議是,盡量不要一次設(shè)置全部任務(wù)數(shù),即使數(shù)量非常明確的情況。因?yàn)樵陂_始多個(gè)并發(fā)任務(wù)之間或許也可能出現(xiàn)被阻斷的情況發(fā)生。最好是盡量在任務(wù)啟動(dòng)時(shí)通過 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() }() ...
總結(jié)
大概介紹完了我認(rèn)為的所有可能導(dǎo)致 goroutine 泄露的情況??偨Y(jié)下來,其實(shí)無論是死循環(huán)、channel 阻塞、鎖等待,只要是會造成阻塞的寫法都可能產(chǎn)生泄露。因而,如何防止 goroutine 泄露就變成了如何防止發(fā)生阻塞。為進(jìn)一步防止泄露,有些實(shí)現(xiàn)中會加入超時(shí)處理,主動(dòng)釋放處理時(shí)間太長的 goroutine。
以上所述是小編給大家介紹的Go 防止 goroutine 泄露的方法,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時(shí)回復(fù)大家的。在此也非常感謝大家對腳本之家網(wǎng)站的支持!
如果你覺得本文對你有幫助,歡迎轉(zhuǎn)載,煩請注明出處,謝謝!
相關(guān)文章
Go并發(fā)編程之sync.Once使用實(shí)例詳解
sync.Once使用起來很簡單, 下面是一個(gè)簡單的使用案例,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2021-11-11一文詳解golang延時(shí)任務(wù)的實(shí)現(xiàn)
這篇文章主要為大家介紹了golang延時(shí)任務(wù)的實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-03-03Go語言定時(shí)任務(wù)的實(shí)現(xiàn)示例
本文主要介紹了Go語言定時(shí)任務(wù)的實(shí)現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-05-05golang 實(shí)現(xiàn)一個(gè)負(fù)載均衡案例(隨機(jī),輪訓(xùn))
這篇文章主要介紹了golang 實(shí)現(xiàn)一個(gè)負(fù)載均衡案例(隨機(jī)、輪訓(xùn)),具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-04-04