【Go語言踩坑系列(八)】Goroutine(下)

聲明

本系列文章並不會停留在Go語言的語法層面,更關注語言特性、學習和使用中出現的問題以及引發的一些思考。html

引入

還記得咱們在上一篇文章中提到的例子嗎:算法

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

如今咱們分析一下這段代碼,循環十次,每次使用go語句建立一個協程,並在每一個協程中打印i值,注意這個i值是這條打印語句真正獲得執行的時候,從外部for語句代碼塊中取的的當前的i值。那麼爲何在上一篇文章中,咱們說每次打印的i值是不肯定的呢?答案就在於Go協程的調度機制的不肯定性。下面咱們從Go協程演化的角度,來逐步揭開協程調度機制的面紗。編程

起源

單進程

咱們在上一篇文章中已經瞭解到,在單進程的計算機時代,計算機只能一個任務一個任務處理,並且若是有I/O阻塞,CPU就會一直等待這個進程直到阻塞返回,後面的任務徹底得不到機會執行。這裏根本不須要調度器。segmentfault

多進程/多線程

爲了解決這個問題,咱們有了多進程/多線程,一旦某個進程或線程阻塞了,CPU能夠在多個進程或線程之間使用時間片輪轉調度算法來回切換執行的進程/線程,讓CPU再也不去等待阻塞返回,這樣極大的提升了CPU的利用率。這個時候,就須要調度器來作這個工做了,何時、哪一個進程任務容許CPU去執行。剛纔時間片輪轉調度算法就是一個例子。這樣咱們就實現了在一個CPU上面"同時"運行多個任務。這個同時只是咱們看起來是同時,CPU在同一時間只能運行一個任務,只是多個任務之間切換的速度較快,咱們看起來好像是同時在運行的,這個就叫作併發。而並行則是完徹底全在同一時刻,可以執行多個任務。在多核CPU的時代,咱們就能夠作到並行。
可是,多進程/多線程仍然是操做系統內核級別的東西,內核仍然須要全權負責他們整個生命週期。其每次建立、銷燬、切換的開銷都是很是大的,並且內核的調度算法可能並不符合咱們的需求,靈活性較差,那麼怎麼解決內核線程的問題呢?數據結構

用戶態須要作更多的事情

而用戶態線程則解決了這個問題,它與內核態線程有一個對應關係,能夠是1:1 、N:1或者 M:N。用戶態線程全部的建立、切換等操做都在用戶態完成,開銷更小也更靈活。內核再也不須要作那麼多的切換或者調度工做。Goroutine(協程)就是一種用戶態線程的實現。多線程

Go協程的演化

咱們想了一下,設計一個協程無非須要考慮這三個因素:資源、任務、調度器。
資源就是操做系統的內核態線程,而任務就是咱們用go語句啓動的一堆Goroutine,而調度器就是如何將資源分配給這些任務,在有限的操做系統資源中,最大化利用CPU與多線程的能力,且讓每個任務公平且快速的獲得執行。那麼,Go語言中這三要素是如何演化的呢?架構

先本身實現一個

咱們先想一個最簡單的方案,先說如何存聽任務。說到公平,那麼咱們首先想到的數據結構就是隊列,先來的任務先執行就好,那麼咱們用隊列去存這一大堆的任務。那麼資源呢就直接讓內核中多個線程去消費這個隊列,拿到一個任務執行就好。咱們把任務簡單叫作G(Goroutine):

咱們來分析一下這裏面的問題。首先,多個內核態線程共享一個任務隊列,會存在併發問題。若是多個線程同一時刻拿到同一個任務G,那麼會致使兩個內核態線程全都在處理同一個任務G,會致使重複的任務處理。這顯然須要加鎖,才能解決這個問題。並且,這個時候仍然是操做系統內核直接調度整個任務隊列,咱們在用戶態並無幫助內核作太多調度的事情。併發

G-M模型

因此,咱們讓多個任務隊列對應多個內核線程,這樣就能夠不用加鎖了,提升了內核線程的處理效率:

可是這個版本仍然是有問題的。咱們僅僅是在用戶態實現了一個任務隊列而已。而內核態仍然須要負責從任務隊列裏拿出任務、判斷任務當前的狀態是否能夠運行、而後才真正運行這個任務,內核線程的負擔太重。
計算機科學中有一個經典的理論:計算機上的全部問題均可以經過增長一個抽象層來解決。因此,咱們給他加一個幫手,把任務直接喂到線程的嘴裏,內核線程只管運行就行了,至於怎麼調度的,何時會運行哪一個任務,內核態線程不用再關心了。這樣,內核的任務逐漸減小,一個真正的完整用戶態線程的調度機制浮出水面,咱們把這個幫手叫作M。M是Machine的縮寫,每個M就表明一個內核態線程,就是以前咱們說的可用的線程資源(Machine):

事實上,在Go1.1版本以前,Go語言就是採用的G-M模型來進行協程調度。可是這種調度模型仍然有一個問題。試想一下,若是咱們M與這個隊列一對一綁定死,那麼若是M中的全部G都運行完了,咱們就須要從另外一個M結構中拿出一些未執行的任務G,而後放到本身的結構中,繼續執行。這樣作實際上是很是麻煩且不靈活的。學習

G-M-P模型

若是有一個結構,能讓咱們動態的去綁定M與任務隊列就行了,M只關心和他綁定的這個結構,能讓我執行任務便可,並不關心這個任務我要如何存儲,更不用關心要不要從另外一個M的隊列裏拿一些任務放到本身這邊。因此,一個M與任務隊列的中介出現了,那就是P:

P是Go1.1版本新加入的一個數據結構。這個中間層讓咱們能夠更加靈活的、隨時切換任務隊列運行所須要的線程資源M,真正實現了M與任務的動態1:N的綁定方式。
回到咱們最開始的問題,打印字符串是一個耗時的I/O操做,須要使用系統調用,將字符寫到標準輸出中。那麼假設執行這個任務的G執行系統調用的時間較長,一直未能等到系統調用完成返回,那麼當前的M就會一直阻塞在這個任務G上,不能執行其餘的任務。爲了解決這個問題,P解除和原有M的綁定,帶着剩餘的任務G小弟們去尋找另外一個下家M,否則G要一直等待阻塞結束,那就要餓死了。
因此,經過P,咱們能夠靈活的將任務隊列遷移到任意一個可用的線程資源M上,讓剩下的任務可以繼續獲得執行,再也不讓線程資源傻傻的等待。注意每一個任務G須要保存當前執行的上下文,以便阻塞的任務完成的時候,可以讓M繼續任務執行後續的邏輯。因此,有了P這個中間層,一個M就能夠動態綁定多個任務隊列了,而再也不將任務隊列寫死放到M的數據結構內部,解除了M與任務G的直接耦合。
正由於Go協程有這種調度機制,因此咱們開篇那個例子,循環並不會等待打印操做執行完再建立下一個協程,而是直接進行下一個循環,馬上建立新協程,一共建立了10個協程。而這10個協程的調度時機又是不肯定的,因此打印的因此咱們也沒有辦法確認最終的打印順序。

相比前文的G-M調度模型。若是上文的M管轄的隊列已經沒有任務了,M還須要本身去找其餘隊列,並把任務加到本身的數據結構中。而有了P以後,那M直接從其餘有G的P那裏偷取一半G過來,放到本身的P本地隊列便可。看到區別了嗎,經過加入P這個中間層,真正實現了任務與M的動態綁定,與G-M模型相比更加靈活。這個機制叫作work stealing。

假設咱們又想添加一個任務G,可是全部P的隊列都滿了,怎麼辦呢?在這個模型中還有一個全局共享的任務隊列,由於其仍有咱們初版實現中須要加鎖的缺點,因此任務實在放不下的時候纔會使用全局隊列。因此全局隊列在調度器中的地位也是很是低的。只有本地隊列沒法找到任務來運行的時候,纔會到全局隊列中拿到任務來運行。spa

總結

咱們用一張圖來總結一下總體的數據結構:

接下來咱們總結一下咱們從中學到的幾個思想:

  • 複用思想:當M綁定的P無可運行的G時,嘗試從其餘線程綁定的P偷取G,而不是銷燬線程。這個機制被叫作work stealing。
  • 非阻塞思想:當本線程由於G進行系統調用阻塞時,M釋放綁定的P,P會轉移給其餘空閒的線程執行,最大化壓榨CPU,提升了CPU的利用率。這個機制被叫作hand off。
  • 中間層思想:當一個實體承載的任務過多,能夠加一箇中間層以減輕負擔,同時可以解除雙方的耦合,更加靈活。
  • 架構的邊界劃分:M的加入讓內核只須要執行任務便可,P讓M中再也不與任務G耦合,讓M更專一線程資源自己的管理,而非任務隊列的管理。

參考資料

Golang調度器GMP原理與調度全分析
Go併發編程-Goroutine如何調度的?

關注咱們

歡迎對本系列文章感興趣的讀者訂閱咱們的公衆號,關注博主下次不迷路~

Nosay

相關文章
相關標籤/搜索