今天來簡單談談,Go 如何防止 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,如果之前讀過官方的那篇併發的文章,翻譯版,你會發現 channel 的使用,一個不小心就洩露了。我們來具體總結下那些情況下可能導致。
傳送不接收
我們知道,傳送者一般都會配有相應的接收者。理想情況下,我們希望接收者總能接收完所有傳送的資料,這樣就不會有任何問題。但現實是,一旦接收者發生異常退出,停止繼續接收上游資料,傳送者就會被阻塞。這個情況在 前面說的文章 中有非常細緻的介紹。
示例程式碼:
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。
本篇主要從如何寫出正確程式碼的角度來介紹如何防止 goroutine 的洩露。下篇,將會介紹如何實現更好的監控檢測,以幫助我們發現當前程式碼中已經存在的洩露。
參考資料
Concurrency In Go
Goroutine leak
Leaking-Goroutines
Go Concurrency Patterns: Context
Go Concurrency Patterns: Pipelines and cancellation
make goroutine stay running after returning from function
Never start a goroutine without knowing how it will stop