G-P-M 模型

G-P-M 模型概述

每個OS線程都有一個固定大小的內存塊(通常會是2MB)來作棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。由於2MB的棧對於一個小小的goroutine來講是很大的內存浪費,而對於一些複雜的任務(如深度嵌套的遞歸)來講又顯得過小。所以,Go語言作了它本身的『線程』。golang

在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的厚望可見一斑。web

任何用戶線程最終確定都是要交由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模型

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:

 

Goroutine併發調度模型深度解析&手擼一個協程池

相關文章
相關標籤/搜索