談談Go語言的協程

筆者最近使用Go語言重構了項目的部分代碼,也算是對這門年輕的語言有了一定的認識,來談談在近期的開發中對Go語言的併發性能的認識。

談談Go語言的協程

併發編程中,線程,是我們再熟悉不過的了。高併發往往離不開多線程的支持。Go語言是一種高併發場景下表現良好的語言,然而在Go語言中,我們並不使用線程來作為高併發最小執行單元。Go語言提供了一種特殊的特性 Goroutine,亦叫做協程。

那麼Go語言提供的Goroutine和我們通常所說的線程,又有什麼區別呢?

我們先來說說我們平時常見的線程。

線程,是系統中最小的執行單元。一個進程中至少會有一個線程。線程由操作系統進行管理,當應用程序需要創建一個線程時,它需要向操作系統申請資源,當一個線程完成了它的任務後,需要將資源釋放掉。線程之間的通信,往往是通過共享變量來實現的,在一個進程中,多個線程往往會同時對同一個變量進行修改。這個時候,我們就需要合理的使用鎖來控制這個變量。如果不能合理的使用鎖,容易出現死鎖,這對於一個應用程序來說是致命的。一個線程,從創建到銷燬,往往會有一定的開銷。創建一個線程通常會佔用1M的內存。在一個Java服務中,如果我們創建一千個線程,那麼將會有一筆較大的內存開銷。

那麼,Goroutine比起線程,又有著什麼樣的優勢呢?

通常,創建一個線程的時候,是應用程序直接向操作系統申請資源,線程的調度,也會由操作系統來執行。然而在Go語言中,創建一個Goroutine是通過調用Go Runtime來實現的。Go Runtime是Go語言的運行環境,就像是Java的JVM。創建一個Goroutine比起創建一個線程,開銷要小得多。在時間方面,Goroutine的創建直接由Go Runtime完成,而創建一個線程,需要先向操作系統申請資源,系統分配了硬件資源之後才能夠啟動一個線程。因此,Goroutine能比線程啟動得更快。在空間方面,一個Goroutine所佔用的內存大小往往只有2KB,比起一個線程佔用1MB的開銷,Goroutine能夠節省更多的空間。筆者近期使用Go語言重構時,最直觀的感受就是,相同的硬件配置之下,Go語言的內存佔用少了數十倍。因此,Goroutine可以說是much more cheaper than thread,我們可以在一個應用程序中,創建成千上萬個Goroutine而不會造成大量的系統開銷,因為它們很“便宜”。上文提到,線程間通信使用共享內存來實現,而在Goroutine中,除了共享內存之外,還提供了一種更加便捷高效的方式,那就是使用Channel(通道),我會在下文中闡述Go語言中的Channel。

上文中我提到Goroutine是由Go Runtime來進行調度的,那麼,Goroutine是如何被調度的呢?

Go語言中,Goroutine的調度使用的GPM調度模型。G=Goroutine,P=Processor,M=Machine。Processor(處理器),

不是CPU那個處理器!!!是Go語言用於處理函數邏輯的處理器。Machine(機器),理解為物理線程。

談談Go語言的協程

Figure1

Figure1是Goroutine的調度模型。每一個物理線程對應著一個Processor,每個Processor維持著一個隊列,隊列中存放著的便是等待調度Goroutine。圖中,G0是正在被執行的Goroutine,每當一個Goroutine被執行完成之後,便會執行下一個Goroutine。使用的內核線程數量,默認是當前機器的CPU核數量,當然,也可以通過GOMAXPROCS()函數來自定義將要使用多少物理線程。多少個物理線程代表最多有多少個Goroutine可以並行執行。

然而,當Go程序使用了系統調用,這個調度模型會如何處理呢?

眾所周知,系統調用是應用程序通過操作系統提供的接口調用操作系統底層的功能,比如說,創建進程,文件讀寫等等。當Goroutine執行邏輯中調用了阻塞的系統接口時,線程執行邏輯便會阻塞住等待系統接口返回執行結果。此時假設Figure1中的G0執行系統調用阻塞了,那麼,隊列中的Goroutine豈不是需要等待著?Go Runtime當然不會這麼做,因為浪費了不少時間。

談談Go語言的協程

Figure2

Figure2中闡述了Go的調度器會如何安排。當出現阻塞式系統調用導致某段執行邏輯被阻塞時,Goroutine會專門安排一個物理線程來等待這個阻塞住的Goroutine所調用的系統接口返回系統調用的結果。如圖Figure2,G0原本是由M0線程執行,當發生阻塞式系統調用時,調度器會將隊列中的協程連同上下文,也就是P,轉移到一個專門為此安排的空閒的物理線程M1,M1便會繼續執行隊列中排隊等待執行的Goroutine,而M0則是繼續等待G0調用的系統接口返回結果。

那麼假設,一個Processor動作很快,執行完了隊列中所有的任務,它會啥都不幹躺屍嗎?

當然不會。Go語言維護了一個全局隊列(Global Queue),當一個Processor執行完了隊列中的所有任務之後,它就會去全局隊列獲取排隊中待執行的Goroutine,可不能讓自己閒著工作不飽和。或者還有另一種case,其它某個Processor維持的隊列中有大量的Goroutine待執行,那麼它會去向那個Processor獲取任務,幫人家分擔點。調度器會定期檢查全局隊列中的Goroutine數量,以保證沒事幹的Processor能從全局隊列中獲取Goroutine。

如何使用Goroutine呢?

很簡單,一個go關鍵字解決問題。

談談Go語言的協程

Figure3

談談Go語言的協程

Figure4

Figure3和Figure4是兩種常見的寫法。go關鍵字後面可以直接跟一個函數或者匿名函數,函數內部便是要處理的邏輯。go關鍵字創建了一個Goroutine之後,會繼續執行後面的代碼。如果後續代碼的邏輯強依賴於Goroutine中的邏輯,需要使用WaitGroup。如Figure5所示。

談談Go語言的協程

Figure5

WaitGroup有點類似於信號量。如Figure5,開啟5個Goroutine來一起處理一個邏輯。每次開啟一個Goroutine之前,會先將WaitGroup加1,隨後開啟一個協程。當協程執行完成之後,需要將WaitGroup減一,也就是wg.Done()。圖中的defer關鍵字就是在程序即將退出的時候,執行其中的函數的邏輯,不管程序發生了什麼引發退出,只要在退出之前聲明瞭defer函數,它就會被執行。除了WaitGroup減一之外,還需要注意捕捉異常,也就是圖中的recover()函數。通過go關鍵字開啟的協程中,如果發生了異常且沒有被捕捉到,那麼,將會引發整個

進程退出。Wait()函數是用來等待被創建的Goroutine執行完成的,因為後續的代碼會強依賴於Goroutine的執行結果。

Go語言中的Channel是個什麼樣的存在呢?

Channel,通道,是兩個Goroutine之間傳遞消息的媒介。它有點類似於進程間通信的那個管道。Channel分為有緩衝的和無緩衝的。先上圖講講他是怎麼用的。

談談Go語言的協程

Figure6

如圖Figure6所示,這段代碼中,先創建了一個channel。此時,開啟一個子協程,向裡面寫入一條消息,在主協程中,使用select關鍵字對這個channel進行監聽,一旦監聽到消息,便會對消息進行處理。這是channel的一個簡單使用,兩個Goroutine之間使用channel進行通信,比起普通的線程使用共享內存進行通信,要來的更加便捷高效。


分享到:


相關文章: