Go開發中,如何有效控制Goroutine的併發數量
相信大家在學習Go的過程中,都會看到類似這樣一句話:”與傳統的系統級線程和進程相比,協程的最大優勢在於其‘輕量級’,可以輕鬆創建上百萬個而不會導致系統資源衰竭”。那是不是意味着我們在開發過程中,可以隨心所欲的調用協程,而不關心它的數量呢?
答案當然是否定的。我們在開發過程中,如果不對Goroutine加以控制而進行濫用的話,可能會導致服務程序整體崩潰。
這裏我先模擬一下協程數量太多的危害:
func main() { number := math.MaxInt64 for i := 0; i < number; i++ { go func(i int) { // 做一些業務邏輯處理 fmt.Printf("go func: %d\n", i) time.Sleep(time.Second) }(i) } }
如果number是用戶輸入的一個參數,沒有做限制。有些開發人員會全部丟進去進行循環,認爲全部都併發使用Goroutine去做一件事情,效率比較高。但這樣的話,噩夢般的事情就開始了,服務器系統資源利用率不斷上漲,到最後程序自動killed。
通過執行top命令查看到該程序佔用的CPU、內存較高。
爲了避免上圖這種情況,下面會簡單的介紹一下Goroutine以及在我們日常開發中如何控制Goroutine的數量。
一、基本介紹
工欲善其事必先利其器。先簡單的介紹一下Goroutine,Goroutine是Go中最基本的執行單元。事實上每一個Go程序至少有一個Goroutine:主Goroutine。當程序啓動時,它會自動創建。
爲了更好理解Goroutine,先講一下進程、線程和協程的概念。
進程(process):用戶下達運行程序的命令後,就會產生進程。同一程序可產生多個進程(一對多關係),以允許同時有多位用戶運行同一程序,卻不會相沖突。進程需要一些資源才能完成工作,如CPU使用時間、存儲器、文件以及I/O設備,且爲依序逐一進行,也就是每個CPU核心任何時間內僅能運行一項進程。進程的侷限是創建、撤銷和切換的開銷比較大。
線程(Thread):有時被稱爲輕量級進程(Lightweight Process,LWP),是程序執行流的最小單元。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成。另外,線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程自己不擁有系統資源,只擁有一點兒在運行中必不可少的資源,但它可與同屬一個進程的其它線程共享進程所擁有的全部資源。線程擁有自己獨立的棧和共享的堆,共享堆,不共享棧,線程的切換一般也由操作系統調度。
協程(coroutine):又稱微線程與子例程(或者稱爲函數)一樣,協程(coroutine)也是一種程序組件。相對子例程而言,協程更爲一般和靈活,但在實踐中使用沒有子例程那樣廣泛。和線程類似,共享堆,不共享棧,協程的切換一般由程序員在代碼中顯式控制。它避免了上下文切換的額外耗費,兼顧了多線程的優點,簡化了高併發程序的複雜。
Goroutine和其他語言的協程(coroutine)在使用方式上類似,但從字面意義上來看不同(一個是Goroutine,一個是coroutine),再就是協程是一種協作任務控制機制,在最簡單的意義上,協程不是併發的,而Goroutine支持併發的。因此Goroutine可以理解爲一種Go語言的協程,同時它可以運行在一個或多個線程上。
在Go中生成一個Goroutine的方式非常的簡單:只要在函數前面加上go就生成了。
func number() { for i := 0; i < ; i++ { fmt.Printf("%d ", i) } } func main() { go number() // 啓動一個goroutine number() }
二、協程池解決?
回到開頭的問題,如何控制Goroutine的數量?相信有過開發經驗的人,第一想法是生成協程池,通過協程池控制連接的數量,這樣每次連接都從協程池裏去拿。在Golang開發中需要協程池嗎?這裏分享下知乎有個相關點贊最高的回答:
顯然不需要,goroutine的初衷就是輕量級的線程,爲的就是讓你隨用隨起,結果你又搞個池子來,這不是脫褲子放屁麼?你需要的是限制併發,而協程池是一種違背了初衷的方法。池化要解決的問題一個是頻繁創建的開銷,另一個是在等待時佔用的資源。goroutine 和普通線程相比,創建和調度都不需要進入內核,也就是創建的開銷已經解決了。同時相比系統線程,內存佔用也是輕量的。所以池化技術要解決的問題goroutine 都不存在,爲什麼要創建 goroutine pool 呢?如果因爲 goroutine 持有資源而要去創建goroutine pool,那隻能說明代碼的耦合度較高,應該爲這類資源創建一個goroutine-safe的對象池,而不是把goroutine本身池化。
在我們日常大部分場景下,不需要使用協程池。因爲Goroutine非常輕量,默認2kb,使用go func()很難成爲性能瓶頸。當然一些極端情況下需要追求性能,可以使用協程池實現資源的複用,例如FastHttp使用協程池性能提高許多。
當然現在我們如果需要使用Goroutine池也不需要重複造輪子了,目前github上已經有開源的項目ants來實現 Goroutine 池。ants已經實現了對大規模 Goroutine 的調度管理、Goroutine 複用,允許使用者在開發併發程序的時候限制 Goroutine 數量,複用資源,達到更高效執行任務的效果。
項目地址: https://github.com/panjf2000/ants
三、 通過channel和sync方式限制協程數量
3.1 Channel
Goroutine運行在相同的地址空間,因此訪問共享內存必須做好同步。那麼Goroutine之間如何進行數據的通信呢?Go提供了一個很好的通信機制channel,channel可以與 Unix shell 中的雙向管道做類比:可以通過它發送或者接收值。這些值只能是特定的類型:channel類型。定義一個channel時,也需要定義發送到channel的值的類型。注意,必須使用make創建channel。
3.2 Sync
Go語言中有一個sync.WaitGroup,WaitGroup 對象內部有一個計數器,最初從0開始,它有三個方法:Add(), Done(), Wait() 用來控制計數器的數量。下面示例代碼中wg.Wati會阻塞代碼的運行,直到計數器值爲0。
通過Golang自帶的channel和sync,可以實現需求,下面代碼中通過channel控制Goroutine數量。
package main import ( "fmt" "sync" "time" ) type Glimit struct { n int c chan struct{} } // initialization Glimit struct func New(n int) *Glimit { return &Glimit{ n: n, c: make(chan struct{}, n), } } // Run f in a new goroutine but with limit. func (g *Glimit) Run(f func()) { g.c <- struct{}{} go func() { f() <-g.c }() } var wg = sync.WaitGroup{} func main() { number := 10 g := New(2) for i := 0; i < number; i++ { wg.Add(1) value :=i goFunc := func() { // 做一些業務邏輯處理 fmt.Printf("go func: %d\n", value) time.Sleep(time.Second) wg.Done() } g.Run(goFunc) } wg.Wait() }
四、總結
在文章的開頭通過在服務器模擬Goroutine數量太多導致系統資源上升,提醒大家避免這類問題。當然每個人可根據自己所在的場景選擇最合適的方案,有時候成熟的第三方庫也是個很好的選擇,可以避免重複造輪子。
下面有兩個思考問題,大家可以嘗試着去思考一下。
思考1:爲什麼我們要使用sync.WaitGroup?
這裏如果我們不使用sync.WaitGroup控制的話,原因出在當主程序結束時,子協程也是會被終止掉的。因此剩餘的 goroutine 沒來及把值輸出,程序就已經中斷了
思考2:代碼中channel數據結構爲什麼定義struct,而不定義成bool這種類型呢?
因爲空結構體變量的內存佔用大小爲0,而bool類型內存佔用大小爲1,這樣可以更加最大化利用我們服務器的內存空間。
func main(){ a :=struct{}{} b := true fmt.Println(unsafe.Sizeof(a)) # println 0 fmt.Println(unsafe.Sizeof(b)) # println 1 }
【作者簡介】
洋洋,攜程高級安全研發工程師,擅長Python、Golang開發,負責安全工具研發。