天天看點

golang技術随筆(二)了解goroutine程序、線程和協程淺析goroutinego運作時排程參考資料

程序、線程和協程

要了解什麼是goroutine,我們先來看看程序、線程以及協程它們之間的差別,這能幫助我們更好的了解goroutine。

程序:配置設定完整獨立的位址空間,擁有自己獨立的堆和棧,既不共享堆,亦不共享棧,程序的切換隻發生在核心态,由作業系統排程。

線程:和其它本程序的線程共享位址空間,擁有自己獨立的棧和共享的堆,共享堆,不共享棧,線程的切換一般也由作業系統排程(标準線程是的)。

協程:和線程類似,共享堆,不共享棧,協程的切換一般由程式員在代碼中顯式控制。

程序和線程的切換主要依賴于時間片的控制(關于程序和線程的排程方式,具體可參看這篇文章:http://blog.chinaunix.net/uid-20476365-id-1942505.html),而協程的切換則主要依賴于自身,這樣的好處是避免了無意義的排程,由此可以提高性能,但也是以,程式員必須自己承擔排程的責任。

goroutine可以看作是協程的go語言實作,從百度百科上看協程的定義:與子例程一樣,協程(coroutine)也是一種程式元件。相對子例程而言,協程更為一般和靈活,但在實踐中使用沒有子例程那樣廣泛。實際上,我們可以把子例程當作是協程的一種特例。一般來說,如果沒有顯式的讓出CPU,就會一直執行目前協程。

淺析goroutine

我們知道goroutine是協程的go語言實作,它是語言原生支援的,相對于一般由庫實作協程的方式,goroutine更加強大,它的排程一定程度上是由go運作時(runtime)管理。其好處之一是,當某goroutine發生阻塞時(例如同步IO操作等),會自動出讓CPU給其它goroutine。

goroutine的使用非常簡單,例如foo是一個函數:

go foo()
           

就一個關鍵字go搞定了,這裡會啟動一個goroutine執行foo函數,然後CPU繼續執行後面的代碼。這裡雖然啟動了goroutine,但并不意味着它會得到馬上排程,關于goroutine的排程我們稍後再探讨。

goroutine是非常輕量級的,它就是一段代碼,一個函數入口,以及在堆上為其配置設定的一個堆棧(初始大小為4K,會随着程式的執行自動增長删除)。是以它非常廉價,我們可以很輕松的建立上萬個goroutine。

go運作時排程

預設的, 所有goroutine會在一個原生線程裡跑,也就是隻使用了一個CPU核。在同一個原生線程裡,如果目前goroutine不發生阻塞,它是不會讓出CPU時間給其他同線程的goroutines的。除了被系統調用阻塞的線程外,Go運作庫最多會啟動$GOMAXPROCS個線程來運作goroutine。

那麼goroutine究竟是如何被排程的呢?我們從go程式啟動開始說起。在go程式啟動時會首先建立一個特殊的核心線程sysmon,從名字就可以看出來它的職責是負責監控的,goroutine背後的排程可以說就是靠它來搞定。

接下來,我們再看看它的排程模型,go語言目前的實作是N:M。即一定數量的使用者線程映射到一定數量的OS線程上,這裡的使用者線程在go中指的就是goroutine。go語言的排程模型需要弄清楚三個概念:M、P和G,如下圖表示:

golang技術随筆(二)了解goroutine程式、線程和協程淺析goroutinego運作時排程參考資料

M代表OS線程,G代表goroutine,P的概念比較重要,它表示執行的上下文,其數量由$GOMAXPROCS決定,一般來說正好等于處理器的數量。M必須和P綁定才能執行G,排程器需要保證所有的P都有G執行,以保證并行度。如下圖:

golang技術随筆(二)了解goroutine程式、線程和協程淺析goroutinego運作時排程參考資料

從圖中我們可以看見,目前有兩個P,各自綁定了一個M,并分别執行了一個goroutine,我們還可以看見每個P上還挂了一個G的隊列,這個隊列是代表私有的任務隊列,它們實際上都是runnable狀态的goroutine。當使用go關鍵字聲明時,一個goroutine便被加入到運作隊列的尾部。一旦一個goroutine運作到一個排程點,上下文便從運作隊列中取出一個goroutine, 設定好棧和指令指針,便開始運作新的goroutine。

那麼go中切換goroutine的排程點有哪些呢?具體有以下三種情況

  • 調用runtime·gosched函數。goroutine主動放棄CPU,該goroutine會被設定為runnable狀态,然後放入一個全局等待隊列中,而P将繼續執行下一個goroutine。使用runtime·gosched函數是一個主動的行為,一般是在執行長任務時又想其它goroutine得到執行的機會時調用。
  • 調用runtime·park函數。goroutine進入waitting狀态,除非對其調用runtime·ready函數,否則該goroutine将永遠不會得到執行。而P将繼續執行下一個goroutine。使用runtime·park函數一般是在某個條件如果得不到滿足就不能繼續運作下去時調用,當條件滿足後需要使用runtime·ready以喚醒它(這裡喚醒之後是否會加入全局等待隊列還有待研究)。像channel操作,定時器中,網絡poll等都有可能park goroutine。
  • 慢系統調用。這樣的系統調用會阻塞等待,為了使該P上挂着的其它G也能得到執行的機會,需要将這些goroutine轉到另一個OS線程上去。具體的做法是:首先将該P設定為syscall狀态,然後該線程進入系統調用阻塞等待。之前提到過的sysmom線程會定期掃描所有的P,發現一個P處于了syscall的狀态,就将M和P分離(實際上隻有當 Syscall 執行時間超出某個門檻值時,才會将 M 與 P 分離)。RUNTIME會再配置設定一個M和這個P綁定,進而繼續執行隊列中的其它G。而當之前阻塞的M從系統調用中傳回後,會将該goroutine放入全局等待隊列中,自己則sleep去。
    golang技術随筆(二)了解goroutine程式、線程和協程淺析goroutinego運作時排程參考資料
    該圖描述了M和P的分離過程。

排程點的情況說清楚了,但整個模型還并不完整。我們知道當使用go去調用一個函數,會生成一個新的goroutine放入目前P的隊列中,那麼什麼時候生成别的OS線程,各個OS線程又是如何做負載均衡的呢?

當M從隊列中拿到一個可執行的G後,首先會去檢查一下,自己的隊列中是否還有等待的G,如果還有等待的G,并且也還有空閑的P,此時就會通知runtime配置設定一個新的M(如果有在睡覺的OS線程,則直接喚醒它,沒有的話則生成一個新的OS線程)來分擔任務。

如果某個M發現隊列為空之後,會首先從全局隊列中取一個G來處理。如果全局隊列也空了,則會随機從别的P那裡直接截取一半的隊列過來(偷竊任務),如果發現所有的P都沒有可供偷竊的G了,該M就會陷入沉睡。

整個排程模型大緻就是這樣子了,和所有協程的排程一樣,在響應時間上,這種協作式排程是硬傷。很容易導緻某個協程長時間無法得到執行。但總體來說,它帶來的好處更加讓人驚歎。想要了解的更多可以看看我下面列出的一些參考資料,或是直接看它的源碼:http://golang.org/src/runtime/proc.c

總綱傳送門:golang技術随筆總綱

參考資料

  1. 協程
  2. goroutine背後的系統知識
  3. The Go scheduler
  4. goroutine與排程器