標籤:go
Go語言調度器譯序
本文翻譯 Daniel Morsing 的博文 The Go scheduler,個人覺得這篇文章把Go Routine和調度器的知識講的淺顯易懂,作為一篇介紹性的文章,很不錯。
譯文介紹
Go 1.1版本最大的特性之一就是一個新的調度器,由Dmitry Vyukov貢獻。這個新的調度器為並行Go程式帶來了令人激動、無以後繼的效能提升,我覺得我應該為之寫點什麼東西。
這篇部落格的大部分內容都已經在這篇原始設計文檔中描述過了,這是一份相當好理解的文章,但是略顯技術性。
雖然該設計文檔已經包含了關於新調度器你所需要知道的一切,但本篇博文包含圖片,所以很明顯它略勝一籌。
為什麼Go運行時需要一個調度器
在我們研究這個新調度器之前,我們需要搞清楚為什麼需要它,為什麼要製造一個使用者空間的調度器,即使作業系統已經能為你調度線程。
POSIX線程API很大程度上是現有UNIX進程模型的邏輯擴充,線程擁有許多與進程相同的控制。線程有自己的訊號掩碼,可以設定CPU親和力,可以被分組到cgroups,也可以查詢它們使用了哪些資源。所有這些控制因為一些Go語言使用Goroutine時並不需要的特性而增加了開銷,並且當你的程式有10萬個線程時這些開銷就快速疊加起來。
另一個問題是作業系統無法做出通知的調度決策,基於Go的模型。比如,Go的垃圾收集器需要在收集時所有線程都停止,並且記憶體需要處於一致的狀態。這涉及到等待運行中的線程達到我們所知的記憶體一致的點。
當你有很多線程需要隨機調度的時候,很大的可能性你需要等待許多線程已達到一致的狀態。Go的調度器可以做出決策,只在當他知道記憶體已經一致的時候進行調度。這意味著當我們因為垃圾收集而停下來時,我們只需要等待那些正在CPU核心中啟動並執行線程。
我們的角色
線程通常有3種模型:一個是N:1模型,該模型中多個使用者線程運行在一個核心線程中,這種模型的好處在於環境切換非常快,但是無法充分利用多核線程。另一個是1:1模型,其中一個執行線程對應一個系統線程,該模型充分利用機器上的多個核心,但是環境切換很慢,因為需要陷入核心。
Go採用M:N的模型,嘗試取兩者的長處。它在任意數量的系統線程上調度任意數量的使用者線程,這樣你不僅可以獲得很快的環境切換,也可以充分利用你系統的多核。這種方式的主要缺點是給調度器帶來的複雜性。
為了完成調度的任務,Go調度器使用到了3個實體:
三角形表示系統線程,它由作業系統管理的,行為很像POSIX線程。在運行時代碼中,它被稱為M(Machine,機器)。
圓圈表示一個goroutine。它包含了棧,指令指標,以及其他對調度goroutine很重要的資訊,例如其阻塞的channel。在運行時代碼中,稱作G。
矩形表示調度的上下文。你可以把它看成是在單個線程中運行Go代碼的調度器的本地版本。它是讓我們從N:1調度到M:N調度的重要部分。在運行時代碼中,稱作P(Processor,處理器)。
圖中我們看到有2個線程(M),每個線程都持有一個上下文(P),每個上下文都運行著一個goroutine(G)。為了運行goroutines,每個線程都必須持有一個上下文。
內容相關的數量是在啟動時被設定為環境變數GOMAXPROCS
的值,或者通過運行時調用函數GOMAXPROCS()
進行設定。一般來說,這個值在程式運行過程中不會改變。上下文數量固定意味著任意時刻只有GOMAXPROCS
個線程在運行go代碼。我們可以利用這一點根據不同機器調節go進程的調用,例如在4核的CPU上用4個線程運行go代碼。
灰色的goroutine是沒在運行中的,但等待著被調度。它們被安排在一個稱為runqueues
的列表中,當一個goroutine執行go
運算式時,goroutines被添加到列表的末尾。當一個上下文運行goroutine到調度點時,它從它的runqueues中彈出一個goroutine,設定棧和指令指標,然後開始執行這個goroutine。
為了打破互斥,每個上下文有自己的本地runqueues。前一個版本的go調度器只有一個全域的runqueues以及一個互斥鎖來保護它。線程經常被阻塞,等待互斥鎖釋放接觸阻塞。如果你的機器有32個核心,這將變得非常低效。
只要所有上下文都有goroutine可以執行,go的調度器就會按照這種穩定的狀態進行調度。然而,存在幾種例外的情況。
你要(系統)調用誰?
現在你可能會好奇,到底為什麼要上下文?難道我們不能拋棄上下文,直接把runqueues放線上程上嗎?不盡然。我們之所以需要上下文,是因為我們可以在當前運行中的線程需要阻塞時把上下文交給其他線程。
需要阻塞的一個例子是我們進行系統調用。由於線程不能既執行代碼又阻塞於系統調用,我們需要交接上下文,以繼續進行調度。
我們可以看到,一個線程放棄了它的上下文,好讓其他線程可以運行之。調度器保證有足夠的線程來運行所有上下文。插圖中的M1可能剛剛被建立,用於處理這個系統調用,或者它來自於線程緩衝。執行系統調用的線程會繼續持有產生系統調用的goroutine,因為從技術上講它扔在執行,只是阻塞在作業系統中了。
當系統調用返回時,線程必須嘗試獲得上下文,才得以繼續運行返回的goroutine。通常的操作模式從其他線程那偷取一個上下文。如果偷取失敗,它會把goroutine放到全域的runqueue,將自己進入線程緩衝然後睡眠。
當內容相關的本地runqueue為空白時,就會到全域的runqueue去拉取。上下文也會定期檢查全域runqueue,否則全域runqueue中的goroutine可能永遠都不能執行最終餓死。
偷取工作
系統的穩定點改變的另一種情況是當其中一個內容相關的runqueue為空白,沒有goroutine可以調度。這在上下文之間的runqueues不平衡的情況下可能發生。這可能導致上下文耗盡其runqueue而系統仍然有工作要完成。為了繼續執行go代碼,上下文可以從全域runqueue中擷取goroutine,但是如果其中沒有goroutines,上下文就需要從別的地方擷取。
所謂別的地方即是其他的上下文。當一個上下文耗完其goroutines時,它會從另外一個上下文偷取一半的goroutine。這保證了每個上下文總是有活兒可以幹,從而保證了所有線程都以其最大的能力工作著。
[翻譯]Go語言調度器