【譯】Golang中的調度(2):Go調度器 - Go Scheduler

爲了更好理解Go調度器的內在機制,我會以三個部分的內容分別進行闡述,鏈接如下:

  1. Golang中的調度(1):OS調度器 - OS Scheduler
  2. Golang中的調度(2):Go調度器 - Go Scheduler
  3. Golang中的調度(3):併發- Concurrency

本部分內容主要討論Go調度器。

引言

在本系列文章的第一部分,我闡述了在OS調度器中各個方面的知識,這些內容對於理解與欣賞Go調度器的機制是非常重要的。在本部分內容,我將在語義級別上闡述Go調度器的工作模式,並專注於較高視角下的行爲。Go調度器是一個複雜的系統,一些細節並不重要,重要的是要有一個良好的模型來表明調度器的工作方式和行爲表象,這將有助你作出更好的工程決策。

開始你的程序

當你的Go程序啓動時,主機會爲每個標識了的虛擬內核分配一個邏輯處理器(P)。如果你的處理器每個物理內核具有多個硬件線程(Hyper-Threading超線程),而每個硬件線程將會作爲一個虛擬內核提供給你的Go程序使用。爲了更好的理解這一點,提供一下我的MacBook Pro的系統報告。

圖 1

你可以看到我的Mac有一個四內核的處理器。但該報告中並未公開本Mac上每個物理內核上的硬件線程數。英特爾酷睿i7處理器具有超線程技術,這意味着每個物理內核具有兩個硬件線程。這意味着當並行執行操作系統線程時,將有8個虛擬內核提供給Go程序使用。爲了驗證這一點,請看以下程序:

表 1

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

當在我本機電腦運行以上程序時,NumCPU()函數的執行結果是8。在我電腦上運行的任何Go程序都會是8個處理器(8 P’s)。

每個P都分配有一個操作系統線程(M),該線程由操作系統管理,並且操作系統仍負責將線程置於內核上以執行,正如上部分內容所訴。這意味着當在我的機器上運行一個Go程序,我將能有8個線程去執行任務,每一個都分別對應到一個P上。

每個Go程序啓動時都會有一個初始Goroutine(G),它是Go程序的執行路徑。一個Goroutine從本質上講,它是一個協程(Coroutine)。但這是Go,因此我們用G字母代替了字母C,並且使用了新的單詞Goroutine。你可以將Goroutine視爲應用程序級的線程,同時它與操作系統線程在很多方面也很類似。就像操作系統線程的上下文切換在內核上,Goroutine的上下文切換是在M上。

最後一塊讓人困惑的是運行隊列。在Go調度器中有兩個不同的運行隊列:全局運行隊列Global Run Queue(GRQ)和本地運行隊列Local Run Queue(LRQ)。每個P都有一個LRQ,該LRQ管理分配給P的上下文中執行的Goroutines。這些Goroutine被分配給P的M輪流進行上下文切換。GRQ指的是尚未分配給某個P的Goroutines。從GRQ轉移到LRQ的過程,我們將在稍後進行討論。

圖2提供了所有上述這些模型組件的圖形表示。

圖 2

協作式調度器

正如我們在第一部分討論的一樣,OS調度策略是搶佔式的。從本質上,這意味着你無法在任何給定時間上預測調度器將要執行的操作。內核在做決策,但這一切都是不確定的。運行在操作系統之上的程序無法通過調度來控制內核的決策,除非在程序中加入諸如原子指令和互斥調用之類的同步原語。

Go調度器是Go runtime的一部分,且Go runtime已經內置於Go程序中。這意味着Go調度器運行在用戶態,而不是內核態。當前Go調度器的實現不是搶佔式,而是協作式的。協作式調度意味着調度器在做調度決策時,需要明確定義的用戶態事件,這些事件發生在代碼中的安全點。

Go協作式調度器的出色之處就在於它看起來是搶佔式的:你無法預測Go調度器將要執行的操作。這是因爲協作調度器的決策權並不在開發者手中,而是在Go runtime上。把Go調度器看作是搶佔式的,因爲它的調度是不確定的,這是很重要的,並且這沒什麼大不了的。

Goroutine狀態

就和線程一樣,Goroutine具有相同的三種狀態。它決定了Go調度器在Goroutine中扮演的角色。一個Goroutine可以是三種狀態之一:等待、就緒和運行。

  • 等待(waiting):該狀態表明瞭Goroutine停止,並等待繼續運行。這個狀態可由以下情況引起:等待硬件響應(磁盤、網絡),操作系統(系統調用),同步機制(原子操作,互斥操作)。這些類型的延遲是程序性能不佳的主要原因。
  • 就緒(runnable):該狀態表明Goroutine想要從M獲取運行時間來執行分配其上的機器指令集。如果有大量的Goroutine想要獲取運行時間,那麼這些Goroutine需要等待更久。而且,隨着更多Goroutine爭奪時間,任何給定Goroutine獲得的時間都將縮短。這種類型的調度延遲也是性能下降的原因。
  • 運行(executing):該狀態表明Goroutine已置於M上並正在執行其機器指令集。與程序相關的工作正在被執行,這是任何Goroutine都想處於的狀態。

上下文切換

Go 調度器需要明確定義的用戶態事件,該事件發生在代碼中的安全點,這以便於上下文切換。這些事件中的安全點表現在函數調用。函數調用對於Go調度器的運行狀況至關重要。如今(使用Go1.11或更低版本),如果你運行不進行函數調用的任何迭代次數很大的循環(tight loops),將導致調度器和垃圾回收的時延(latency)。在合理的時間內產生函數調用至關重要。

注意:對於1.12的提議已被接受,即可以在Go調度器中採用非協作式搶佔技術,以允許對tight loops進行搶佔。

在Go程序中存在四種事件能使得調度器做出調度決策,但是這並不意味着發生這些事件時總會產生調度行爲,它只是表明調度器有機會調度。

  • 關鍵字go
  • 垃圾回收
  • 系統調用
  • 同步與編排(Orchestration)

關鍵字 go

關鍵字go表明創建新的Goroutine,一旦建立新的Goroutine,調度器就獲得做調度決策的機會。

垃圾回收 GC

因爲垃圾回收運行在它自己的Goroutine集,而這些Goroutine需要獲得M上的時間片去運行,這就導致GC會產生大量的調度噪音。然而,Go調度器十分的聰明,它清楚Goroutine在做什麼,並會利用所收集的信息做出明智的決策。一個明智的例子就是在GC時,將一個想要獲取堆數據的Goroutine的與那些不接觸堆數據的Groutine進行上下文切換。實際上,當GC時,調度器需要做出大量的調度決策。

系統調用

當Goroutine進行系統調用,那麼它將阻塞其M。有時調度器通過上下文切換將Goroutine從M上取下並置一個新的Goroutine在該M之上。然而,有時需要一個新的M來繼續執行P中的Goroutine隊列。這些內容會在下文進行更詳細地說明。

同步與編排

如果原子、互斥或管道(channel)操作阻塞了Goroutine,那麼調度器可通過上下文切換一個新的Goroutine運行。一旦被阻塞了的Goroutine可繼續運行,它會被重新入隊列,並最終通過上下文切換回M上執行。

異步系統調用

操作系統有能力異步地處理系統調用,即能利用網絡輪詢器(network poller)來更高效地處理系統調用。例如MacOS的kqueue,Linux的epoll以及Windows中的iocp。

我們今天所使用的許多操作系統都可以異步處理基於網絡的系統調用。這就是網絡輪詢器的得名由來,因爲它的主要用途就是處理網絡操作。通過利用網絡輪詢器進行網絡系統調用,調度器能夠防止Goroutine在進行這些系統調用時阻塞M。這有助於使M保持可用,以執行P的LRQ中的其他Goroutine,而無需創建新的M,這減少了OS調度器的調度負載。

瞭解工作原理的最佳方式是看一個例子。

圖 3

圖3顯示了我們的基本調度圖。G1正在M上執行,LRQ中還有3個Goroutine等待它們在M上的執行時間片。網絡輪詢器被閒置了。

圖 4

圖4中,G1希望進行網絡系統調用,因此G1移至了網絡輪詢器並處理了異步網絡系統調用。一旦G1被移至網絡輪詢器,這時M就可用於執行LRQ上其他的Groutine,在這個例子中,G2通過上下文切換置於M。

圖 5

圖5中,網絡輪詢器完成了異步網絡系統調用,並將G1移回至P的LRQ中。一旦G1被上下文切換回M上,它對應的Go相關代碼就可以再次被執行。這裏最大的好處是:執行網絡系統調用不需要額外的M。網絡輪詢器擁有OS線程,並且它正在處理有效的事件循環。

同步系統調用

當Goroutine想要進行的系統調用無法異步地處理,會發生什麼?在這種情況下,網絡輪詢器將無法使用,而進行系統調用地Goroutine將阻塞M。這種情況很不幸,但是卻無法阻止其發生。不能異步處理典型的示例是基於文件的系統調用。如果你使用CGO,在某些情況下,調用C函數也會阻塞M。

注意:Windows操作系統能夠異步地進行基於文件的系統調用。因爲從技術上講,在Windows上運行時,可以使用網絡輪詢器。

讓我們看一下同步系統調用(例如文件I/O)導致M阻塞的情況。

圖 6

圖6再次顯示了我們的基本調度圖,但是這次G1將進行阻塞M1的同步系統調用。

圖 7

圖7中,調度器能識別G1將阻塞M。此時,調度器將M1與P分離,而G1仍然置於M1之上。然後,調度器引入一個新的M2來服務於P。這時,P可以從LRQ中選擇G2並在M2上進行上下文切換。如果,在之前的交換中已經存在了M,則在本次過渡M的速度會比創建一個新的M快。

圖 8

圖8中,G1中進行阻塞的系統調用已經完成。此時,G1可以移回LRQ並再次由P服務。M1則被放置一邊,以備這種情況再次發生時被啓用。

工作偷竊(Work Stealing)

Go調度器的另一個特點是,它是一種採用工作偷竊的調度器,這有助於在某些方面保持調度效率。首先,你最不想看到的事情是M進入等待狀態,因爲一旦發生這種情況,操作系統調度器會通過上下文切換將M從內核上取下。而這意味着,即使有Goroutine處於就緒狀態,直到M通過上下文切換回內核之前,P無法完成任何工作。工作偷竊還有助於在所有P上平衡Goroutine數量,從而更好地分配工作並更高效地完成工作。

讓我們看一個例子。

圖 9

圖9中,我們有一個多線程Go程序,其中兩個P分別爲各自的4個Goroutine和GRQ中的一個Goroutine服務。如果其中的一個P很快地就執行完了它的所有Goroutine,會發生什麼情況?

圖 10

圖10中,P1已經沒有可執行的Goroutine了。但是在P2的LRQ和GRQ中都有就緒的Goroutine,這是P1需要工作偷竊的時刻。工作偷竊的規則如下所示。

表 2

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

因此,基於表2中所述的規則,P1需要檢查P2的LRQ的Goroutine,並拿走檢查結果一半數量的Goroutine。

圖 11

圖11中,p2一半的Gotoutine被取至P1,G3正被放置於P1中的M上執行。

如果P2完成了其上所有Goroutine的服務而P1的LRQ中沒有剩餘的Goroutine,會發生什麼?

圖 12

圖12中,P2完成了所有的工作,現在需要竊取一些。首先,它尋找P1的LRQ,但是沒檢查到剩餘Goroutine,接着它就尋找GRQ,因此,它找到了G9。

圖 13

圖13中,P2從GRQ中偷竊了G9並開始執行。工作偷竊的最大好處是,它可以使得所有的M保持忙碌而不至於閒着。工作偷竊從內部機理被認爲是旋轉M,JBD在其work-stealing博客中很好的解釋了旋轉M的好處。

實際例子

有了上述調度器機制的認識之後,我想向你展示當所有這些結合在一起,隨着時間推移,Go調度器是如何執行更多的工作。想象一下用C語言編寫的多線程程序,該程序包含兩個OS線程,它們之間相互傳遞消息。

圖 14

圖14中,有兩個線程來回傳遞消息。線程1通過上下文切換在內核C1正在執行,線程1將消息發送給線程2。

注意:消息的傳遞方式無關緊要,重要的是在此編排過程中線程的狀態。

圖 15

圖15中,一旦線程1發送完消息後,它就需要等待響應。這將導致線程1被上下文切換從內核1取下,並進入等待狀態。一旦線程2收到有關該消息的通知,它將進入就緒狀態。現在操作系統可以執行上下文切換,使線程2在內核2上執行,接着,線程2處理消息併發送新消息給線程1。

圖 16

圖16中,線程1收到線程2的消息後,再次進行線程上下文切換。線程2從運行態切換到等待態,線程1從等待態切換到就緒態並最終進入運行態,這使得其可以處理併發送新消息。

所有的這些上下文切換和狀態更改都需要執行時間,這限制了工作可以完成的速度。每個上下文切換的潛在時延時間約爲1000納秒,硬件每納秒有希望執行12條指令,然而,或多或少的,12k條指令在上下文切換期間未能執行。由於線程也在不同的內核之間相互反饋,因此由於高速緩存未命中(cache-line miss)而導致的額外延遲可能性也很高。

讓我們以相同的例子爲例,但是使用Gotoutine和Go調度器。

圖 17

圖17中,存在兩個goroutine,他們彼此之間來回發送消息。M1在C1上,G1在M1上進行上下文切換,正在執行其工作。G1的工作是將消息發送給G2。

圖 18

圖18中,一旦G1完成了發送信息的工作,它需要等待響應,這會導致G1通過上下文切換從M1取下,並進入等待態。一旦G2收到有關該消息的通知後,它會進入就緒態。現在Go調度器可以通過上下文切換使得G2在M1上得到執行,這時M1仍然是在C1上運行的。接着,G2處理該消息,併發送一個新消息返回給G1。

圖 19

圖19中,G1收到G2發送的消息時,又發生了上下文切換。現在G2通過上下文切換從運行態轉變成等待態,G1從等待態進入就緒態並最終回到運行態,以此來處理和發送新的消息回去。

從表面看,事情似乎沒有什麼不同。無論是使用線程還是Goroutine,都會發生相同的上下文切換和狀態改變。咋一看可能並不明顯,然而,事實上在使用線程和Goroutine卻有很大的不同。

在使用Goroutine的情況下,所有處理都使用相同的OS線程和內核。這意味着,從操作系統的角度來看,操作系統線程永遠不會進入等待狀態。因此,在使用線程時我們因爲上下文切換而丟失的可執行指令時間在使用Goroutine時不會丟失。

從本質上將,Go在操作系統級將IO密集型任務轉變成了CPU密集型的工作。因爲所有的上下文切換都發生在應用程序級,在使用線程時每次上下文切換,我們會丟失平均12k條指令的運行機會,而使用Goroutine不會。在Go中,在這些相同的每次上下文切換中,我們會丟失200納秒或2.4k條指令。Go調度器同樣有助於提高高速緩存效率和內存訪問不一致(Non-uniform memory access UNMA)。這就是爲什麼我們不需要線程多於虛擬內核的原因。在Go中,隨着時間累計,有可能完成更多的工作,這是因爲Go調度器嘗試使用更少的線程並在每個線程上執行更多的操作,這有助於減少操作系統和硬件的負載。

結論

Go調度器在設計中考慮到了操作系統和硬件的複雜性,這是非常了不起的。在操作系統級別將IO密集型任務轉變成CPU密集型任務的能力是我們在如何更好地利用CPU的一大勝利,這也是爲什麼你不需要比虛擬內核更多的線程的原因。你可以合理地期望通過每個虛擬內核只有一個OS線程來完成所有工作(CPU或IO密集型)。這樣做對於網絡應用程序和其他不阻塞OS線程的系統調用的應用程序是可行的。

作爲開發者,你仍需要根據你的應用程序是在做什麼來確定你正在處理的任務類型。你不能通過創建無數數量的Goroutine來期望獲得驚人的性能。少即是多,但是有了對Go調度器的理解,你就可以做出更好的工程決策。在下一部分內容中,我將探討併發編程來獲得更好的性能,同時對可能需要添加至代碼中的複雜性進行平衡。

英文原文鏈接:

Scheduling In Go : Part II - Go Scheduler

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章