系統線程固定2M,且維護一堆上下文,對需求多變的併發應用並不友好,有可能造成內存浪費或內存不夠用。Go將併發的單位下降到線程以下,由其設計的goroutine初始空間非常小,僅2kb,但支持動態擴容到最大1G,這就是go自己的併發單元——goroutine協程。
實際上系統最小的執行單元仍然是線程,go運行時執行的協程也是掛載到某一系統線程之上的,這種協程與系統線程的調度分配由Go的併發調度器承擔,Go的併發調度器是屬於混合的二級調度併發模型,其內部設計有G、P、M三種抽象結構,我們來看一下它們分別是什麼:
- G: 表示Goroutine,每個Goroutine對應一個G結構體,G存儲Goroutine的運行堆棧、狀態以及任務函數,可重用。G運行隊列是一個棧結構,分全局隊列和P綁定的局部隊列,每個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,系統物理線程,代表着真正執行計算的資源,在綁定有效的P後,進入schedule循環;而schedule循環的機制大致是從Global隊列、P的Local隊列以及wait隊列中獲取G,切換到G的執行棧上並執行G的函數,調用goexit做清理工作並回到M,如此反覆。M並不保留G狀態,這是G可以跨M調度的基礎,M的數量是不定的,由Go Runtime調整,爲了防止創建過多OS線程導致系統調度不過來,目前默認最大限制爲10000個。
關於P這個設計,是在Go1.0之後才實現的,起初的Go併發性能並不十分亮眼,協程和系統線程的調度比較粗暴,導致很多性能問題,如全局資源鎖、M的內存過高等造成許多性能損耗,加入P的設計後實現了一個叫做 work-stealing 的調度算法:由P來維護Goroutine隊列並選擇一個適當的M綁定
- go關鍵字創建goroutine(G),優先加入某個P維護的局部隊列(當局部隊列已滿時才加入全局隊列);
- P需要持有或者綁定一個M,而M會啓動一個系統線程,不斷的從P的本地隊列取出G並執行;
- M執行完P維護的局部隊列後,它會嘗試從全局隊列尋找G,如果全局隊列爲空,則從其他的P維護的隊列裏竊取一般的G到自己的隊列;
- 重複以上知道所有的G執行完畢。
如系統GC,M會解綁P,出讓控制權給其他M,讓該P維護的G運行隊列不至於阻塞。
當goroutine因爲管道操作或者系統IO、網絡IO而阻塞時,對應的G會被放置到某個等待隊列,該G的狀態由運行時變爲等待狀態,而M會跳過該G嘗試獲取並執行下一個G,如果此時沒有可運行的G供M運行,那麼M將解綁P,並進入休眠狀態;當阻塞的G被另一端的G2喚醒時,如管道通知,G又被標記爲可運行狀態,嘗試加入G2所在P局部隊列的隊頭,然後再是G全局隊列。
當P維護的局部隊列全部運行完畢,它會嘗試在全局隊列獲取G,直到全局隊列爲空,再向其他局部隊列竊取一般的G。
至此Go的調度器模型解析完畢。基於Go調度器的優越設計,它號稱能實現百萬級併發,即使日常很難達到這種併發量,我們也應該對併發的使用要心存敬畏,真正的併發依賴於物理核心,啓動併發是需要系統開銷的,雖然在Go的運行時它看起來很小,但量變引起質變,當業務啓動的併發到十萬級、百萬級甚至千萬級時,其性能開銷還是非常巨大的。可以通過一定的手段控制併發數量以防止系統奔潰,如實現一個協程池,通過worker機制控制併發數。