golang(Go語言)調度(一): 系統調度

調度相關的一系列文章主要參考 Scheduling In Go : Part I - OS Scheduler 翻譯來的。
因爲在學習的過程中偶然發現,感覺總結得蠻好的,就不造輪子了,乾脆直接翻譯過來作爲自己的學習筆記了,英文好的建議直接閱讀原文。

介紹

Go 調度器使你編寫的 Go 程序併發性更好,性能更高。這主要是因爲 Go 調度器很好的運用了系統調度器的機制原理。但是,如果你不瞭解調度器基本的工作原理,那你寫的 Go 服務很可能對調度器很不友好,使得 Go 調度器發揮不出它的優勢。想要正確的設計一個優秀的高併發服務,對操作系統和 Go 的調度機制的一定的理解是很重要的。
這一系列的文章主要專注在調度器的一些宏觀機制上。我會形象化的詳細解釋它是如何工作的,使你可以在編碼時做出更好的工程判斷。儘管在併發編程中你還有很多其他知識點要了解,但在調度器的機制是其中比較基礎的一部分。。

操作系統調度

操作系統調度器是軟件開發中很複雜的一塊。他們必須考慮硬件設施的佈局和設計。這其中就包括了多處理器和多核的存在,CPU 緩存和 NUMA。沒有這些知識,調度器就無法達到高效。慶幸的是,你仍然可以通過構造一個宏觀的心理模型來理解操作系統調度程序的工作原理,而無需深入研究這一主題。

你的程序其實就是一堆需要按照順序一個接一個執行的機器指令。爲此,操作系統使用了一個線程的概念。線程的工作就是按順序執行分配給它的指令集,直到沒有指令可以執行了爲止。

你運行的每一個程序都會創建一個進程,並且每一個進程都會有一個初始線程。線程擁有創建更多線程的能力。這些不同的線程都是獨立運行的,調度策略都是在線程這一級別上的,而不是進程級別(或者說調度的最小單元是線程而不是進程)。線程是可以併發執行的(輪流使用同一個核),或並行(每個線程互不干擾的同時在不同的核上跑)。線程還維護這他們自己狀態,好保證安全、隔離、獨立的執行自己的指令。

系統調度器負責保證當有線程可以執行時,CPU 是不能處於空閒狀態的。它還必須創建一個所有線程同時都在運行的假象。在創造這個假象的過程中,調度器需要優先運行優先級更高的線程。但是低優先級的線程又不能被餓死(就是一直不被運行)。調度器還需要通過快速、明智的決策儘可能的最小化調度延遲。

這方面有很多種算法,不過幸運的是,這方面有行業裏數十年的工作經驗可以參考。

執行指令

Program counter(PC),有時候也被叫做指令指針(instruction pointer, 簡稱IP),線程用它來跟蹤下一個要執行的指令。在大多數處理器中,PC 指向的是下一個指令,而不是當前指令。

如果你曾經看過 Go 程序的 stack trace,你可能注意到了每行的最後都有一個 16 進制數字。比如 +0x390x72

goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE

這些數字表示的是 PC 值距離所在函數的頂部的偏移量。0x39 這個 PC 偏移量代表了線程要執行的在 example 函數中的下一個指令(如果程序沒有崩潰)。0x72 代表的是程序返回到 main 後,要執行的下一個指令。更重要的是,在這個指針之前的那個指令,表示的是正在執行的指令。

來看一下上述 stack trace 的源代碼。

func main() {
    example(make([]string, 2, 4), "hello", 10)
}

func example(slice []string, str string, i int) {
   panic("Want stack trace")
}

16 進制數字 +0x39 代表了距離 example 函數第一條指令後面 57(0x39的10進制值) 字節的那個指令。下面我們通過對二進制文件執行 objdump,來看看這個 example 函數。從下面的彙編代碼中找到第 12 條指令。注意上面代碼中調用 panic 的那條指令。

$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
  0x104dfa0     65488b0c2530000000  MOVQ GS:0x30, CX
  0x104dfa9     483b6110        CMPQ 0x10(CX), SP
  0x104dfad     762c            JBE 0x104dfdb
  0x104dfaf     4883ec18        SUBQ $0x18, SP
  0x104dfb3     48896c2410      MOVQ BP, 0x10(SP)
  0x104dfb8     488d6c2410      LEAQ 0x10(SP), BP
    panic("Want stack trace")
  0x104dfbd     488d059ca20000  LEAQ runtime.types+41504(SB), AX
  0x104dfc4     48890424        MOVQ AX, 0(SP)
  0x104dfc8     488d05a1870200  LEAQ main.statictmp_0(SB), AX
  0x104dfcf     4889442408      MOVQ AX, 0x8(SP)
  0x104dfd4     e8c735fdff      CALL runtime.gopanic(SB)
  0x104dfd9     0f0b            UD2              <--- LOOK HERE PC(+0x39)

線程狀態

另一個重要的概念就是線程狀態,它決定了線程在調度器中的角色。一個線程有 3 中狀態:阻塞態就緒態運行態

譯者注:實際情況中不止這 3 個,阻塞也分可中斷和不可中斷 2 種,此外還有殭屍態、初始化狀態等。
但如作者所說,我們只是在宏觀上建立一個簡單心理模型來理解調度原理,不深入細節。

阻塞態:表示線程已經停止,需要等待一些事情發生後纔可繼續。這有很多種原因,比如需要等待硬件(磁盤或網絡),系統調用,或者互斥鎖(atomic, mutexes)。這類情況導致的延遲,往往是性能不佳的根本原因。

譯者注:如果你發現,機器的 CPU 利用率很低,同時程序的 QPS 還很低,待處理的請求還有很多堆在後面,速度就是上不去。那說明你的線程都處於阻塞態等待被喚醒,沒有幹活。

就緒態:這代表線程想要一個 CPU 核來執行被分配的機器指令。如果你有很多個線程需要 CPU,那麼線程就不得不等待更長時間。此時,因爲許多的線程都在爭用 CPU,每個線程得到的運行時間也就縮短了。

運行態:這表示線程已經被分配了一個 CPU 核,正在執行它的指令。與應用相關的工作正在被完成。這是每個人都想要的狀態。

負荷類型

線程的工作有 2 種類型的負荷。第一種叫做 CPU密集型,第二種叫 IO密集型

CPU密集:處理這種工作的線程從來不會主動進入阻塞態。它一直都需要使用 CPU。這種工作通常都是數學計算。比如計算圓周率的第 n 位的工作就屬於 CPU密集型的工作。

IO密集:這種工作會使得線程進入阻塞態。常見於通過網絡請求資源,或者進行了系統調用。一個需要訪問數據庫的線程屬於 IO密集的。互斥鎖的使用也屬於這種。

上下文切換

Linux,Mac 或者 Windows 系統上都擁有搶佔式調度器。這表明了很重要的幾點。

第一,線程何時被調度器選中,被分配 CPU 時間片是不可預測的。線程的優先級和事件同時都會對調度結果有影響,這導致你不可能確定調度去什麼時候能調度你的線程。

譯者注:你動一下鼠標,敲一下鍵盤,這些動作都會觸發 CPU 的中斷響應,也就是事件。這都會對調度器的結果產生影響的,所以說它是不可預測的。

第二,這表明了,你絕不能憑感覺來寫代碼,因爲你的經驗不能保證總是應驗。人是很容易平感覺下定論的,同樣的事情反覆出現了上千次,就認爲它是百分百的。如果你想要有百分百的確定性,必須在線程中使用互斥鎖。

在同一個 CPU 核上交換線程的行爲過程,稱爲上下文切換。上下文切換髮生時,調度器把一個線程從核上拿下來,把另一個就緒態的線程放到核上。從就緒隊列中選中的這個線程,就這樣被置成了運行態。而被從核上拿來下的那個線程,被置爲就緒態(如果它仍然可以被執行),或者進入阻塞態(如果它是因爲執行了 IO 操作才被替換的)。

上下文切換是昂貴的,因爲在一個核上交換線程需要時間片。上下文切換造成的計算損失受很多因素影響,一般是 50 到 100 納秒左右。
假設一個 CPU 核平均每納秒可執行 12 個機器指令,一次上下文切換要執行 600 到 1200 條指令,那麼本質上,你的程序在上下文切換期間丟失了可以執行大量指令的機會。

如果你有一個 IO 密集的工作,那麼上下文切換會有一定的優勢。一旦一個線程進入了阻塞態,另一個就緒態的線程就可以立馬執行。這使得 CPU 一直都在工作。這是調度中最重要的目標,就是如果有線程可執行(處於就緒態),就不能讓 CPU 閒着。

如果你的程序是 CPU 密集的,那麼上下文切換將會是性能的噩夢。因爲線程總是有指令要執行,而上下文切換中斷了這個過程。這與 IO 密集型的工作形成了鮮明的對比。

少即是多

在早期,CPU 只有單核的。調度也就沒那麼複雜。因爲你只有一個單核 CPU。在任意一個時間點,只有一個線程可以運行。有一種輪詢調度的方法,它嘗試對每個就緒態的線程都執行一段時間。使用調度週期,除以線程總數,就是每個線程應該執行的時間。

比如,如果你定義你的調度週期是 10 毫秒,現在有 2 個線程,那麼在一個調度週期內,每個線程可以執行 5 毫秒。如果你有 5 個線程,那麼每個線程可以執行 2 毫秒。但是,如果你有 1000 個線程呢?每個線程執行 10 微妙是沒有意義的,因爲你大部分時間都花在了上下文切換上。

這時你就需要限制最短的執行時間應該是多少。在上面的那個場景中,如果最短的執行時間是 2 毫秒,同時你有 100 個線程,那麼調度週期就需要增加到 2000 毫秒(2秒)。如果你有 1000 個線程,調度週期就要變成 20 秒。這個簡單的例子中,一個線程要等 20 秒才能執行一次。

要知道這我們只是舉了最簡單調度場景。實際上調度器在做調度策略時需要考慮很多事情。這是你應該會想到一個常見併發手段,就是線程池的使用。讓線程的數量在控制之內。

所以遊戲規則就是『少即是多』,越少的就緒態線程意味着越少的調度工作,每個線程就會得到更多的時間。越多的就緒態線程意味着每個線程會得到越少的時間,也就意味着同一時間你能完成的工作越少(其他的 CPU 時間都被操作系統拿去做調度用了)。

尋找平衡

你需要在 CPU 核數和線程數量上尋找一個平衡,來使你的應用能夠擁有最高的吞吐。當需要維持這個平衡時,線程池是一個最好的解決方案。在下一篇文章中我會想你展示,在 Go 語言中根本不需要線程池。我認爲 Go 語言最優秀的一點就是,它使得併發編程更簡單了。

在寫 Go 之前,我使用 C++ 和 C# 在 NT 上開發。在那個操作系統上,主要是使用 IOCP 線程池來完成併發編程的。作爲一個工程師,你需要指定需要多少線程池,每個線程池的最大線程數是多少,來保證在固定核上達到最高的性能。

當寫 web 服務的時候,需要和數據庫打交道,每核 3 個線程的配置,似乎總能在 NT 平臺上德奧最高的吞吐量。換句話說,就是每核 3 個線程可以使上下文切換的代價最小,從而最大化線程的執行時間。

如果配置每核只用 2 個線程,它會花費更多時間把工作完成,因爲 CPU 會經常處在空閒狀態。如果我一個核創建 4 個線程,它也會花費更長時間,因爲上下文切換的代價會升高。每核 3 個線程的平衡,總是能得到最好的結果,不知道什麼原因,它就是個魔法數字。

那如果你的服務的即有 CPU 密集的工作也有 IO 密集的工作呢?這可能會產生不同類型的延遲。這種情況就不太可能找到一個魔法數字來適用於所有情況。當使用線程池來調整服務的性能時,找到一個正確的一致配置是很複雜的。

Cache Line

訪問主內存中的數據是有很高延遲的。大約 100 ~ 300 個時鐘週期。所以 CPU 往往都會有一個本地 cache,使得數據距離需要它的線程所在的核更近。訪問 cache 中的數據是很快的,和訪問寄存器差不多。今天,性能優化中很重要的一部分就是怎麼才能讓 CPU 更快的得到數據,來減少數據訪問的延遲。寫多線程應用時面對狀態異變問題時,需要考慮 cache 系統的機制。

cache line 是 cache 與主內存交換數據的最小單位。一個 cache line 是一塊 64 字節的內存,用以在主內存和 cache 系統之間交換數據。每個核都有一份它自己所需要數據的拷貝。這就是爲什麼在多線程應用中,內存異變是導致性能問題的噩夢。因爲 CPU Core 上運行的線程變了,不同的線程需要訪問的數據不同,cache 裏的數據也就失效了。

當多線程並行時,如果他們訪問同樣的數據,或者相鄰很近的數據。他們將會訪問同一個 cache line 中的數據。運行這些線程的任何一個核,都會在自己的 cache 上對數據做一份拷貝。也就是說,每個 CPU 核的 cache 中都有同樣的一份數據拷貝,這些拷貝對應於內存中的同一塊地址。

如果一個核上的線程,對拷貝數據進行了修改,那麼硬件會將其他所有核上的 cache line 拷貝都標記爲『髒』。當其他核上的線程試圖訪問或修改這個數據時,需要重新從主內存上拷貝最新的數據到自己的 cache 中。

也許 2 核的 CPU,不會出大問題,但如果是 32 核的 CPU 並行的運行着 32 個線程呢?如果系統有 2 個 CPU ,每個 CPU 有 16 核呢?這更糟糕,因爲增加了 CPU 之間的的通信延遲。這個應用將會出現內存顛簸現象,性能會急劇下降,然而你可能都不知道爲什麼。

調度決策場景

假如現在要求你在以上給出信息的基礎上,設計一個系統調度器了。想象一下你需要解決的這種場景。記住,上面所描述的,只是做調度決策時,需要考慮的衆多情況之一。

現在假設機器只有一個單核CPU。你啓動了應用,線程被創建了並且在一個 CPU 核上運行。隨着線程開始執行它的指令,cache line 也開始檢索數據,因爲指令需要數據。現在線程要創建一個新的線程做一些併發處理。現在的問題是。

線程一旦創建並進入了就緒態,調度器有以下幾種選擇:

  1. 直接進行上下文切換,把主線程從 CPU 上拿掉?
    這是對性能有益的,因爲新線程需要同樣的數據,而這些數據之前已經存在與 cache 上了。但主線程就不得不把時間片分給子線程了。
  2. 讓新線程等待主線程執行完它的所有時間片?
    線程沒執行,執行時不必從主內存中同步數據到 local cache 了。
  3. 讓線程等待其他可用核?
    這就意味着被選中的核,要拷貝一份 cache line 中的數據,這會導致一定的延遲。但是新線程會立刻開始執行,主線程也能繼續完成自己的工作。

用哪種方式呢?這就是系統調度在做調度決策時需要考慮的一個有趣的問題。答案是,如果有空閒的核,那就直接用。我們的目標是,如果有工作要做,就決不讓 CPU 閒着。

結論

文章帶你瞭解了,當編寫多線程應用時,關於線程和系統調度器需要考慮的一些事情。這些也是 Go 語言調度器需要考慮的事情。下一篇文章中,我會描述Go語言調度程序的實現,以及它與本篇所述內容的關係。

 

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