G-P-M 模型概述
每一個OS線程都有一個固定大小的內存塊(一般會是2MB)來做棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。因為2MB的棧對於一個小小的goroutine來說是很大的內存浪費,而對於一些復雜的任務(如深度嵌套的遞歸)來說又顯得太小。因此,Go語言做了它自己的『線程』。
在Go語言中,每一個goroutine是一個獨立的執行單元,相較於每個OS線程固定分配2M內存的模式,goroutine的棧采取了動態擴容方式, 初始時僅為2KB,隨着任務執行按需增長,最大可達1GB(64位機器最大是1G,32位機器最大是256M),且完全由golang自己的調度器 Go Scheduler 來調度。此外,GC還會周期性地將不再使用的內存回收,收縮棧空間。 因此,Go程序可以同時並發成千上萬個goroutine是得益於它強勁的調度器和高效的內存模型。Go的創造者大概對goroutine的定位就是屠龍刀,因為他們不僅讓goroutine作為golang並發編程的最核心組件(開發者的程序都是基於goroutine運行的)而且golang中的許多標准庫的實現也到處能見到goroutine的身影,比如net/http這個包,甚至語言本身的組件runtime運行時和GC垃圾回收器都是運行在goroutine上的,作者對goroutine的厚望可見一斑。
任何用戶線程最終肯定都是要交由OS線程來執行的,goroutine(稱為G)也不例外,但是G並不直接綁定OS線程運行,而是由Goroutine Scheduler中的 P - Logical Processor (邏輯處理器)來作為兩者的『中介』,P可以看作是一個抽象的資源或者一個上下文,一個P綁定一個OS線程,在golang的實現里把OS線程抽象成一個數據結構:M,G實際上是由M通過P來進行調度運行的,但是在G的層面來看,P提供了G運行所需的一切資源和環境,因此在G看來P就是運行它的 “CPU”,由 G、P、M 這三種由Go抽象出來的實現,最終形成了Go調度器的基本結構:
- G: 表示Goroutine,每個Goroutine對應一個G結構體,G存儲Goroutine的運行堆棧、狀態以及任務函數,可重用。G並非執行體,每個G需要綁定到P才能被調度執行。
- P: Processor,表示邏輯處理器, 對G來說,P相當於CPU核,G只有綁定到P(在P的local runq中)才能被調度。對M來說,P提供了相關的執行環境(Context),如內存分配狀態(mcache),任務隊列(G)等,P的數量決定了系統內最大可並行的G的數量(前提:物理CPU核數 >= P的數量),P的數量由用戶設置的GOMAXPROCS決定,但是不論GOMAXPROCS設置為多大,P的數量最大為256。
- M: Machine,OS線程抽象,代表着真正執行計算的資源,在綁定有效的P后,進入schedule循環;而schedule循環的機制大致是從Global隊列、P的Local隊列以及wait隊列中獲取G,切換到G的執行棧上並執行G的函數,調用goexit做清理工作並回到M,如此反復。M並不保留G狀態,這是G可以跨M調度的基礎,M的數量是不定的,由Go Runtime調整,為了防止創建過多OS線程導致系統調度不過來,目前默認最大限制為10000個。
關於P,我們需要再絮叨幾句,在Go 1.0發布的時候,它的調度器其實G-M模型,也就是沒有P的,調度過程全由G和M完成,這個模型暴露出一些問題:
- 單一全局互斥鎖(Sched.Lock)和集中狀態存儲的存在導致所有goroutine相關操作,比如:創建、重新調度等都要上鎖;
- goroutine傳遞問題:M經常在M之間傳遞『可運行』的goroutine,這導致調度延遲增大以及額外的性能損耗;
- 每個M做內存緩存,導致內存占用過高,數據局部性較差;
- 由於syscall調用而形成的劇烈的worker thread阻塞和解除阻塞,導致額外的性能損耗。
這些問題實在太扎眼了,導致Go1.0雖然號稱原生支持並發,卻在並發性能上一直飽受詬病,然后,Go語言委員會中一個核心開發大佬看不下了,親自下場重新設計和實現了Go調度器(在原有的G-M模型中引入了P)並且實現了一個叫做 work-stealing 的調度算法:
- 每個P維護一個G的本地隊列;
- 當一個G被創建出來,或者變為可執行狀態時,就把他放到P的可執行隊列中;
- 當一個G在M里執行結束后,P會從隊列中把該G取出;如果此時P的隊列為空,即沒有其他G可以執行, M就隨機選擇另外一個P,從其可執行的G隊列中取走一半。
該算法避免了在goroutine調度時使用全局鎖。
至此,Go調度器的基本模型確立:
G-P-M 模型調度
Go調度器工作時會維護兩種用來保存G的任務隊列:一種是一個Global任務隊列,一種是每個P維護的Local任務隊列。
當通過go
關鍵字創建一個新的goroutine的時候,它會優先被放入P的本地隊列。為了運行goroutine,M需要持有(綁定)一個P,接着M會啟動一個OS線程,循環從P的本地隊列里取出一個goroutine並執行。當然還有上文提及的 work-stealing
調度算法:當M執行完了當前P的Local隊列里的所有G后,P也不會就這么在那躺屍啥都不干,它會先嘗試從Global隊列尋找G來執行,如果Global隊列為空,它會隨機挑選另外一個P,從它的隊列里中拿走一半的G到自己的隊列中執行。
如果一切正常,調度器會以上述的那種方式順暢地運行,但這個世界沒這么美好,總有意外發生,以下分析goroutine在兩種例外情況下的行為。
Go runtime會在下面的goroutine被阻塞的情況下運行另外一個goroutine:
- blocking syscall (for example opening a file)
- network input
- channel operations
- primitives in the sync package
這四種場景又可歸類為兩種類型:
用戶態阻塞/喚醒
當goroutine因為channel操作或者network I/O而阻塞時(實際上golang已經用netpoller實現了goroutine網絡I/O阻塞不會導致M被阻塞,僅阻塞G,這里僅僅是舉個栗子),對應的G會被放置到某個wait隊列(如channel的waitq),該G的狀態由_Gruning
變為_Gwaitting
,而M會跳過該G嘗試獲取並執行下一個G,如果此時沒有runnable的G供M運行,那么M將解綁P,並進入sleep狀態;當阻塞的G被另一端的G2喚醒時(比如channel的可讀/寫通知),G被標記為runnable,嘗試加入G2所在P的runnext,然后再是P的Local隊列和Global隊列。
系統調用阻塞
當G被阻塞在某個系統調用上時,此時G會阻塞在_Gsyscall
狀態,M也處於 block on syscall 狀態,此時的M可被搶占調度:執行該G的M會與P解綁,而P則嘗試與其它idle的M綁定,繼續執行其它G。如果沒有其它idle的M,但P的Local隊列中仍然有G需要執行,則創建一個新的M;當系統調用完成后,G會重新嘗試獲取一個idle的P進入它的Local隊列恢復執行,如果沒有idle的P,G會被標記為runnable加入到Global隊列。
以上就是從宏觀的角度對Goroutine和它的調度器進行的一些概要性的介紹,當然,Go的調度中更復雜的搶占式調度、阻塞調度的更多細節,大家可以自行去找相關資料深入理解,本文只講到Go調度器的基本調度過程,為后面自己實現一個Goroutine Pool提供理論基礎,這里便不再繼續深入上述說的那幾個調度了,事實上如果要完全講清楚Go調度器,一篇文章的篇幅也實在是捉襟見肘,所以想了解更多細節的同學可以去看看Go調度器 G-P-M 模型的設計者 Dmitry Vyukov 寫的該模型的設計文檔《 Go Preemptive Scheduler Design》以及直接去看源碼,G-P-M模型的定義放在src/runtime/runtime2.go
里面,而調度過程則放在了src/runtime/proc.go
里。
REFERENCE: