一、什麼是Goroutine
在go語言中,每一個併發的執行單元叫作一個goroutine,與併發相對的是串行,即代碼按照順序一行一行執行,goroutine 給 go 語言提供了併發編程的能力。
當一個程序啓動時,其主函數在一個單獨的goroutine中運行,我們叫它main goroutine,新的goroutine會用go語句來創建。在語法上,go語句是一個普通的函數或方法調用前加上關鍵字go,go語句會使其語句中的函數在一個新創建的goroutine 中運行。
f() // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait
在 Go 語言中通過協程實現併發編程非常簡單:我們可以在一個處理進程中通過關鍵字 go 啓用多個協程,然後在不同的協程中完成不同的子任務。
Goroutine 可以看作對 thread 加的一層抽象,它是用户態的,更輕量。因為有了這層抽象,Gopher 不會直接面對 thread。操作系統卻相反,是看不到 goroutine 存在的,安心地執行線程就可以了,線程才是它調度的基本單位。
Goroutine 和 thread 有什麼區別?
1、內存佔用
創建一個 goroutine 的棧內存消耗為 2 KB,實際運行過程中,如果棧空間不夠用,會自動進行擴容。創建一個 thread 則需要消耗 1 MB 棧內存。
對於一個用 Go 構建的 HTTP Server 而言,對到來的每個請求,創建一個 goroutine 用來處理是非常輕鬆的一件事。而如果用一個使用線程作為併發原語的語言構建的服務,例如 Java 來説,每個請求對應一個線程則太浪費資源了,很快就會出 OOM 錯誤(Out Of Mermory Error)。
2、創建和銷毀
Thread 創建和銷毀都會有巨大的消耗,因為要和操作系統打交道,是內核級的,通常解決的辦法就是線程池。而 goroutine 因為是由 Go runtime 負責管理的,創建和銷燬的消耗非常小,是用户級。
3、切換
當 threads 切換時,需要保存各種寄存器,以便將來恢復:包括所有的寄存器,16個通用寄存器、程序計數器、棧指針、段寄存器、16個XMM寄存器、FP協處理器、16個 AVX寄存器、所有的MSR等等。
goroutine的保存和恢復只需要三個寄存器:程序計數器、棧指針和DX寄存器,也不需要陷入操作系統內核層。
一般而言,線程切換會消耗 1000-1500 納秒,Goroutine 的切換約為 200 ns,相比之下goroutines 切換成本比 threads 要小得多。
二、線程的併發模型
線程是操作系統內核的最小調度單元,我們上面所説的goroutine 也是基於線程,根據用户線程與內核線程的對應關係,分為以下幾種併發模型:
1、用户級線程模型
用户線程與內核線程是多對一(N : 1)的映射模型,即一個進程中所有創建的線程都只和同一個內核線程在運行時動態綁定,python實現的協程庫gevent就都屬於這種方式。
這些線程的創建、銷燬以及多線程之間的協調等操作都是由用户自己的線程庫來負責而無須藉助系統調用來實現,這種實現方式相比內核級線程可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的線程數量與上下文切換所花費的代價也會小得多。但該模型有個原罪:並不能做到真正意義上的併發,假設在某個用户進程上的某個用户線程因為一個阻塞調用(比如 I/O 阻塞)而被 CPU 給中斷(搶佔式調度)了,那麼該進程內的所有線程都被阻塞,整個進程被掛起。
2、內核級線程模型
用户線程與內核線程 KSE 是一對一(1 : 1)的映射模型,也就是每一個用户線程綁定一個實際的內核線程,而線程的調度則完全交付給操作系統內核去做,Java 的線程便是基於此實現的。
這種模型的優勢是實現簡單,直接藉助操作系統內核的線程以及調度器,所以 CPU 可以快速切換調度線程,於是多個線程可以同時運行,因此相較於用户級線程模型它真正做到了並行處理;但它的劣勢是,由於直接藉助了操作系統內核來創建、銷燬和以及多個線程之間的上下文切換和調度,因此資源成本大幅上漲,且對性能影響很大。
3、兩級線程模型
用户線程與內核是多對多(N : M)的映射模型,該模型對於前面兩種模型揚長避短:首先,區別於用户級線程模型,兩級線程模型中的一個進程可以與多個內核線程關聯,也就是説一個進程內的多個線程可以分別綁定一個自己的 KSE,這點和內核級線程模型相似;其次,又區別於內核級線程模型,它的進程裏的線程並不與 KSE 唯一綁定,而是可以多個用户線程映射到同一個 KSE,當某個 KSE 因為其綁定的線程的阻塞操作被內核調度出 CPU 時,其關聯的進程中其餘用户線程可以重新與其他 KSE 綁定運行。
這種模型實現比較複雜,Go 語言中的 runtime 調度器就是採用的這種實現方案,實現了 Goroutine 與 KSE 之間的動態關聯,不過 Go 語言的實現更加高級和優雅;該模型為何被稱為兩級?即用户調度器實現用户線程到 KSE 的『調度』,內核調度器實現 KSE 到 CPU 上的『調度』。
三、Go調度器的作用
提到“調度”,我們首先想到的就是操作系統對進程、線程的調度。操作系統調度器會將系統中的多個線程按照一定算法調度到物理CPU上去運行。
這種傳統支持併發的方式有諸多不足:
一個thread的代價已經比進程小了很多了,但我們依然不能大量創建thread,因為除了每個thread佔用的資源不小之外,線程開得越多操作系統調度切換的代價也就更大;
一個Go程序對於操作系統來説只是一個用户層程序,它的眼中只有thread,它甚至不知道Goroutine的存在。將這些goroutines按照一定算法放到“CPU”上執行的程序就稱為goroutine調度器或goroutine scheduler。在操作系統層面,Thread競爭的“CPU”資源是真實的物理CPU,但在Go程序層面,各個Goroutine要競爭的”CPU”資源是操作系統線程。
説到這裏goroutine scheduler的任務就明確了:
goroutine調度器通過使用與CPU數量相等的線程減少線程頻繁切換的內存開銷,同時在每一個線程上執行額外開銷更低的 Goroutine 來降低操作系統和硬件的負載。
Workflow
+-------------------- sysmon ---------------//------+
| |
| |
+---+ +---+-------+ +--------+ +---+---+
go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M |
+---+ +---+-------+ +--------+ +---+---+
| | |
| +---+ | |
+----> | M | <--- findrunnable ---+--- steal <--//--+
+---+
|
mstart
|
+--- execute <----- schedule
| |
| |
+--> G.fn --> goexit --+
1. go func() 語句創建G。
2. 將G放入P的本地隊列(或者平衡到全局隊列)。
3. 喚醒或新建M來執行任務。
4. 進入調度循環
5. 盡力獲取可執行的G,並執行
6. 清理現場並且重新進入調度循環
四、Go調度器模型與演化過程
1、G-M模型
2012年3月28日,Go 1.0正式發佈。在這個版本中,每個goroutine對應於runtime中的一個抽象結構:G,而os thread則被抽象為一個結構:M。
M想要執行、放回G都必須訪問全局G隊列,並且M有多個,即多線程訪問同一資源需要加鎖進行保證互斥/同步,所以全局G隊列是有互斥鎖進行保護的。
這個結構雖然簡單,但是卻存在着許多問題:
1、所有goroutine相關操作,比如:創建、重新調度等都要上鎖;
2、M轉移G會造成延遲和額外的系統負載。比如當G中包含創建新協程的時候,M創建了G1,為了繼續執行G,需要把G1交給M1執行,也造成了很差的局部性,因為G1和G是相關的,最好放在M上執行,而不是其他M1。
3、系統調用(CPU在M之間的切換)導致頻繁的線程阻塞和取消阻塞操作增加了系統開銷。
2、G-P-M模型
在Go 1.1中實現了G-P-M調度模型和work stealing算法,這個模型一直沿用至今:
在新調度器中,除了M(thread)和G(goroutine),又引進了P(Processor)。P是一個“邏輯Proccessor”,每個G要想真正運行起來,首先需要被分配一個P(進入到P的本地隊列中),對於G來説,P就是運行它的“CPU”,可以説:G的眼裏只有P。但從Go scheduler視角來看,真正的“CPU”是M,只有將P和M綁定才能讓P的runq中G得以真實運行起來。
3、搶佔式調度
G-P-M模型的實現算是Go scheduler的一大進步,但Scheduler仍然有一個頭疼的問題,那就是不支持搶佔式調度,導致一旦某個G中出現死循環或永久循環的代碼邏輯,那麼G將永久佔用分配給它的P和M,位於同一個P中的其他G將得不到調度,出現“餓死”的情況。更為嚴重的是,當只有一個P時(GOMAXPROCS=1)時,整個Go程序中的其他G都將“餓死”。
這個搶佔式調度的原理則是在每個函數或方法的入口,加上一段額外的代碼,讓runtime有機會檢查是否需要執行搶佔調度。
五、G-P-M模型的深入理解
1、基本概念
1、全局隊列:存放等待運行的G;
2、P的本地隊列:同全局隊列類似,存放的也是等待運行的G,存儲數量不超過256個;
3、G:Groutine協程,調度系統的最基本單位,擁有運行函數的指針、棧、上下文;
4、P:Processor,調度邏輯處理器,擁有各種G對象隊列、鏈表、一些cache和狀態,P的數量決定了系統內最大可並行的G的數量;
所有的P都在程序啓動時創建,並保存在數組中,默認等於CUP核數,最多有GOMAXPROCS(可配置)個;
5、M:代表實際工作的執行者,對應到操作系統級別的線程,每個M就像一個勤勞的工作者,從各種隊列中找到可運行的G;
這裏有一幅圖可以形象的説明它們的關係:
地鼠用小車運着一堆待加工的磚,M就可以看作圖中的地鼠,P就是小車,G就是小車裏裝的磚。
goroutine 的創建與調度循環是一個生產-消費流程,整個 go 程序的運行就是在不斷地執行 goroutine 的生產與消費流程。
2、調度器的設計策略
2.1、複用線程
避免頻繁的創建、銷燬線程,而是對線程的複用。
1)work stealing機制
當本線程無可運行的G時,嘗試從其他線程綁定的P偷取G,而不是銷燬線程。
2)hand off機制
當本線程因為G進行系統調用阻塞時,線程釋放綁定的P,把P轉移給其他空閒的線程執行。
2.2、利用並行
GOMAXPROCS設置P的數量,最多有GOMAXPROCS個線程分佈在多個CPU上同時運行。
2.3、搶佔
Go 程序啓動時,運行時會去啓動一個名為 sysmon 的 M,會定時向長時間(>=10MS)運行的 G 任務發出搶佔調度,防止其他goroutine被餓死。
3、goroutine的調度流程
1、我們通過 go func()來創建一個goroutine;
2、有兩個存儲G的隊列,一個是局部調度器P的本地隊列、一個是全局G隊列。新創建的G會先保存在P的本地隊列中,如果P的本地隊列已經滿了就會保存在全局的隊列中;
3、G只能運行在M中,一個M必須持有一個P,M與P是1:1的關係;
4、一個M調度G執行的過程是一個循環機制,M會從P的本地隊列彈出一個可執行狀態的G來執行,如果P的本地隊列為空,就會去看全局隊列,全局也沒有就會從其他的MP組合偷取一個可執行的G來執行,如果其他隊列也獲取不到M就會自旋;
5、如果 G 被阻塞在某個 channel 操作或網絡 I/O 操作上時,G 會被放置到某個等待(wait)隊列中,而 M 會嘗試運行 P 的下一個可運行的 G。如果這個時候 P 沒有可運行的 G 供 M運行,那麼 M 將解綁 P,並進入掛起狀態。
當 I/O 操作完成或 channel 操作完成,在等待隊列中的 G 會被喚醒,標記為可運行(runnable),並被放入到某 P 的隊列中,綁定一個 M 後繼續執行。
6、如果 G 被阻塞在某個系統調用(system call)上,那麼不光 G 會阻塞,執行這個 G 的 M也會解綁 P,與 G 一起進入掛起狀態。如果此時有空閒的 M,那麼 P 就會和它綁定,並繼續執行其他 G;如果沒有空閒的 M,但仍然有其他 G 要去執行,那麼 Go 運行時就會創建一個新 M(線程)。
當M系統調用結束時候,這個G會嘗試獲取一個空閒的P執行,並放入到這個P的本地隊列。如果獲取不到P,那麼這個線程M變成休眠狀態,加入到空閒線程中,然後這個G會被放入全局隊列中。
7、當一個新的goroutine創建或者有多個goroutine進入等待運行狀態時,會先判斷是否有自旋的M,有則使用自旋的M,當沒有自旋的M,但m空閒隊列中有空閒的M則會喚醒M,否則會創建一個新的M。
4、調度器的生命週期
特殊的M0和G0
M0
M0是啓動程序後的編號為0的主線程,M0負責執行初始化操作和啓動第一個G,還在整個運行期專門負責做特定的事情——系統監控(sysmon)。
G0
G0是每次啓動一個M都會第一個創建的gourtine,G0僅用於負責調度的G,G0不指向任何可執行的函數, 每個M都會有一個自己的G0。在調度或系統調用時會使用G0的棧空間, 全局變量的G0是M0的G0。
調度器的運行流程如圖所示:
- runtime創建最初的線程m0和goroutine g0,並把2者關聯。
- 調度器初始化:初始化m0、棧、垃圾回收,以及創建和初始化由GOMAXPROCS個P構成的P列表。
- runtime中也有1個main函數——runtime.main,代碼經過編譯後,runtime.main會調用main.main,程序啓動時會為runtime.main創建goroutine,稱它為main goroutine吧,然後把main goroutine加入到P的本地隊列。
- 啓動m0,m0已經綁定了P,會從P的本地隊列獲取G,獲取到main goroutine。
- G擁有棧,M根據G中的棧信息和調度信息設置運行環境
- M運行G
- G退出,再次回到M獲取可運行的G,這樣重複下去,直到main.main退出,runtime.main執行Defer和Panic處理,或調用runtime.exit退出程序。
調度器的生命週期幾乎佔滿了一個Go程序的一生,runtime.main的goroutine執行之前都是為調度器做準備工作,runtime.main的goroutine運行,才是調度器的真正開始,直到runtime.main結束而結束。
六、小結
Go調度器的本質是把大量的goroutine任務分配到少量線程上運行,利用多核並行,實現更強大的併發。
參考資料:
也談goroutine調度器
Golang的協程調度器原理及GMP設計思想
Goroutine調度器
深度解密Go語言之 scheduler
GMP調度器原理深度解析之手擼一個高性能goutine pool