這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
近期工作有些調整,所以這篇東西用了差不多兩個星期才翻譯完。想起 @Fenng 幾年前跟我說的關於行業和工作的話,雖然出發點不太一樣,但是結論還真是正確啊!
工作上的變動,就不多扯了。原文在此《The Go scheduler》。
————翻譯分割線————
Go 的調度器
Daniel Morsing
概述
Go 1.1 重要特性之一就是由 Dmitry Vyukov 貢獻的新調度器。無需對程式進行任何調整,新的調度器就可以為 Go 程式帶來令人興奮的效能提升。因此我覺得有必要就此寫點什麼。
在本博文所述的大多數內容都已經在原始的設計文檔中有所介紹。那是一篇相當全面的文檔,同時也相當專業。
你想要瞭解的關於新的調度器的一切都能在那篇文檔裡找到,而這篇博文描繪了整體情況,所以優略得所。
為什麼 Go 運行時需要一個調度器
在瞭解新調度器之前,先要瞭解為什麼需要它。為什麼在作業系統已經能夠對線程進行調度的情況下還需要建立一個使用者空間調度器。
POSIX 線程 API 絕對是對已有的 Unix 進程模型的邏輯擴充,這樣線程就獲得了跟進程類似的控制方式。線程擁有自己的訊號掩碼,可以與 CPU 關聯起來,可以放入 cgroups 或查詢哪些資源被其使用。所有這些控制方式所帶來的特性對於使用 goroutine 的 Go 程式來說都不需要,並且當程式有 100000 個線程的時候,所需的控制會急速膨脹。
另一個問題是 OS 不能基於 Go 模型根據實際情況進行調度。例如,Go 垃圾收集器在執行回收時,需要所有的線程都先停止,而記憶體也必須在一致的狀態。這包含了等待正在啟動並執行線程執行到某個已知記憶體會達到一致狀態的地方。
當有許多線程進行隨機的調度,挑戰是你必須不停的等待他們達到一致狀態。Go 調度器可以決定在已知記憶體會一致的地方進行調度。這意味著當停下進行垃圾收集時,只需要等待在 CPU 核心上實際啟動並執行線程。
我們的陣容
通常有三個執行緒模式。一個是 N:1,也就是若干個使用者空間線程運行在一個 OS 線程上。它的好處是環境切換非常迅速,而壞處是無法發揮多核系統。另一個是 1:1,也就是一個執行線程對應一個 OS 線程。好處是可以利用機器上的所有核心,不過由於它是通過 OS 來進行的,所以環境切換非常慢。
Go 試圖利用 M:N 調度器在兩個世界中找到平衡點。若干 goroutine 調度在若干 OS 線程上。得到了快速的環境切換,並且可以利用系統裡的所有核心。而主要的問題是這個方法會增加調度器的複雜度。
為了完成任務的調度,Go 調度器使用了 3 個主要的實體:
三角形代表 OS 線程。它是由系統管理執行的線程,並且工作方式與標準的 POSIX 線程相當類似。在運行時的代碼裡,叫做 M 代表裝置。
圓形代表 goroutine。它包括了棧、指令指標和其他調度 goroutine 所需的重要訊息,如可能阻塞它的任何一個 channel。在運行時代碼裡,它被叫做 G。
矩形代表調度的上下文。可以將其看作是一個在一個線程上運行 Go 代碼的局部版本的調度器。這是從 N:1 調度器演化到 M:N 調度器的重要的一環。在運行時代碼中,它被叫做 P 代表處理器。關於這部分還得再多說幾句。
這裡有 2 個線程(M),每個都擁有一個上下文(P),每個都執行一個 goroutine(G)。線程必須擁有一個上下文才能執行 goroutine。
內容相關的數量是由環境變數 GOMAXPROCS 在啟動的時候設定的,也可以通過運行時函數 GOMAXPROCS() 設定。事實上內容相關的數量是固定的,這也就是說任何時候都只有 GOMAXPROCS 個 Go 代碼在執行。可以使用這個來在不同的電腦上進行調整,比如 4 核 PC 會運行 4 條 Go 代碼的線程。
灰色的 goroutine 沒有在運行,但是已經準備好被調度了。它們排列在一個叫做 runqueues 的列表裡。當 goroutine 執行 go 語句時就會被添加到 runquque 的尾部。一個正在啟動並執行 goroutine 到達調度點時,上下文就會從 runqueue 中彈出這個 goroutine,並且設定棧和指令指標,然後開始執行下一個 goroutine。
為了減少互斥爭用,每個上下文都有它自己本地的 runqueue。上一個版本的 Go 調度器只有一個使用互斥量保護的全域 runqueue。線程經常為了等待互斥量解鎖而被阻塞。當在一個 32 核的機器上想要儘可能的壓榨效能時這會變得非常糟糕。
只要上下文有 goroutine 需要運行,調度器就會在這個穩定的狀態下持續的進行調度。然而,有一些情況可能會改變這個局面。
你要(系統)調用誰?
你現在可能在想,為什麼需要上下文?為什麼不能拋開上下文直接將 runqueue 放線上程上?其實不是這樣的。有內容相關的原因是當由於某些原因正在執行的線程會阻塞時可以切換到其他線程。
一個關於阻塞的例子就是系統調用。由於線程無法在運行代碼的同時又阻塞在系統調用上,所以需要上下文進行切換來保證調度。
這裡可以看到一個線程放棄了它的上下文,因此其他線程可以運行。調度器保證了有足夠的線程運行所有的上下文。為了正確的處理系統調用,會建立或者是從線程緩衝中擷取中的 M1。技術上說被系統調用線程持有的進行了系統調用的 goroutine 仍然是在啟動並執行,儘管在 OS 層它被阻塞了。
當系統調用返回,線程必須嘗試擷取上下文以便讓 goroutine 繼續運行。通常的模式是從其他線程竊取一個上下文。如果沒辦法偷得到,就會將 goroutine 放入全域 runqueue 中,然後自己返回線程緩衝繼續休眠。
當上下文執行完本地的 runqueue 後,會從全域 runqueue 擷取 goroutine。上下文也會週期性檢查全域 runqueue。否則的話在全域 runqueue 上的 goroutine 可能由於缺乏資源而永遠都不會運行。
這個處理系統調用的方法說明了為什麼即使 GOMAXPROCS 為 1 的時候,Go 程式也會運行多個線程。運行時用 goroutine 調用系統調用,而讓線程藏在背後。
竊取工作
當一個上下文調度執行完所有的 goroutine 時系統的穩定點也會發生變化。這發生在內容相關的 runqueue 分配的工作不平衡的時候。這會導致當上下文清空其 runqueue 後,在系統中仍然有工作需要完成。為了讓 Go 代碼繼續執行,上下文可以從全域 runqueue 擷取 goroutine,但是如果沒有 goroutine 在其中的話,總得從其他什麼地方擷取到它們。
這個其他地方其實就是其他上下文。當一個上下文執行完,它會試圖偷取其他內容相關的一半 runqueue。這保證了每個上下文都總是有工作可做,也保證了所有的線程都進其最大的能力在工作。
何去何從?
還有許多調度器的細節,如 cgo 線程,LockOSThread() 函數和帶有網路池的指令。它們不在本文討論的範圍內,但是仍然值得學習。以後我可能會寫一些關於這些的內容。在 Go 執行階段程式庫中還有許許多多有趣的創造等待著被探索。