【譯】Golang中的調度(1):OS調度器 - OS Scheduler

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

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

本部分內容主要討論操作系統層次的調度器工作機制。

引言

Go調度器能使你的多線程Go應用程序運行得更加有效率,這歸功於Go調度器與OS(操作系統)調度器能與硬件上有效地協同工作(Mechanical Sympathy)。然而,如果你的多線程Go應用程序,其設計與運行不能和調度器在硬件上有效地協同,毫無疑問,這樣的多線程是程序是失敗的。對OS與Go調度器工作機制有一個大致的理解,這有助於你正確地設計多線程程序。

我們將以較高視角(higher-level)來解釋調度器的內在機制。我會提供一些例子使你看清調度器的工作流程,這有助你設計更好的程序。儘管當你在設計多線程程序時,你需要考慮很多方面的東西,但調度器是一個非常重要的基礎知識,這是你需要掌握的。

OS調度器

操作系統的調度器是非常複雜的。它必須考慮其配套硬件的配置與性能,包括但不限於多處理器、多核、CPU高速緩存(cache memory)、NUMA(Non-Uniform Memory Access),沒有這些,調度器無法儘可能的高效工作。值得高興的是,你無需深入探討這些主題,仍然可以就調度器的工作方式建立良好的思維模型。 

你的程序最終只是一系列的機器指令,它們會被一條接一條的有序執行。爲了做到這一點,操作系統使用了線程的概念。線程的工作是按序執行分配給它的指令集,直到沒有指令可供這條線程執行,這就是爲何我稱一個線程爲:“一條執行的路徑(a path of execution)”

每個單獨運行的應用程序都會創建一個進程,而每個進程都會有一個初始線程,線程可以創建更多的線程。所有的線程都是相互之間獨立運行的,並且線程的調度是在線程級上,而非進程級。線程能夠併發運行(每個線程都在一個內核上打開),或者並行運行(每個線程同時運行在不同的內核上)。線程同時維護着它的狀態(用於安全與局部性)以及它獨佔的執行指令集。

OS調度器必須確保當有能被執行的線程時,內核不是空閒狀態。它還會創造一種假象,即所有的線程能在同一時間內被執行。在創造這種假象的過程中,調度器會運行優先級更高的線程。但是,優先級相對較低的線程也不會被減少其執行時間。同時,調度器還需要儘可能快速地作出明智的決策來最大程度地減少調度延時(latency)。

要實現這一調度算法,需要考慮的因素很多,幸運地是,隨着數十年行業經驗的積累,我們所用的算法已很成熟。爲了更好的理解調度算法,這裏需要描述一些重要的概念。

執行指令

程序計數器(Progarm counter-PC),有時又被稱爲指令指針(Instruction Pointer-IP),是使線程跟蹤下一條要執行的指令。在大多數處理器中,程序計數器指的是下一條指令而非當前指令。

圖 1 指令指針   https://www.slideshare.net/JohnCutajar/assembly-language-8086-intermediate

如果你曾經看過Go程序的堆棧跟蹤,你可能已經注意到在每行末尾有十六進制數字,正如表1輸出中的+0x39和+0x72。

表1:

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值偏移量。+0x39PC偏移量指的是若程序不發生panic,當前線程會執行位於example函數內的下一條指令位置。+0x72PC偏移量代表若程序運行返回main函數中,線程將要執行的位於main函數內的下一條指令位置。

現在查看產生上述堆棧輸出的代碼,如表2所示。

表2:

https://github.com/ardanlabs/gotraining/blob/master/topics/go/profiling/stack_trace/example1/example1.go

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

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

十六進制數值+0x39代表的是在example函數內,相較函數起始位置相隔57(基於10進制)字節的指令位置。在表3中,你可以從二進制文件中看到example函數中的objdump。觀察位於表3最下面的第12條指令,這條指令之上的代碼是調用panic。

表3:

$ 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)

注意:程序計數器PC是指的下一條指令,而非當前指令。表三是運行在amd64之上的一個很好的例子,它表明了負責go程序運行的線程是按順序執行的。

線程狀態

另外一個重要的概念是線程狀態,它決定了調度器在線程中扮演的角色。一個線程可以是三種狀態之一:等待、就緒和運行。

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

任務類型

線程所處理的任務可以分爲兩類。第一類爲CPU密集型,第二類爲IO密集型。

  • CPU密集型:這種類型的任務從不會造成使當前線程處於等待態的情況。這種任務是不斷進行計算的工作,例如計算Pi的值到N位數的任務即是CPU密集型任務。
  • IO密集型:這種類型的任務會使得線程進入等待態,例如請求某個網絡資源或某系統調用。如果某個線程需要獲取數據庫資源,那麼它是IO密集型。我也會將同步操作(原子,互斥)列入此類,因爲它的某些策略也會使得線程進入等待態。

上下文切換

如果你的程序運行在Linux、Mac或者Windows,那麼其OS調度器是搶佔式的。這表明了一些重要的特性。首先,這意味着在任何時候調度器會選擇哪些線程來運行這是不可預測的。線程優先級和事件(例如從網絡上獲取數據)使得無法確定調度器將選擇執行的線程以及何時執行。

其次,這意味着你不能僅憑一些感覺來進行多線程編程,儘管當前可能幸運的通過了測試,但是這並不能保證每次運行都如你預期的一樣。因爲這很容易造成一種假象,讓自己相信這是一個可靠的程序:我已經以相同的方式運行了上千次。記住,在多線程編程時,你必須確保能有效地控制線程之間的同步與編排。

在一個內核上進行線程交換的行爲被稱做上下文切換。上下文切換髮生在當調度器從內核中取下一個運行態的線程並將一個處於就緒態的線程置上的時候。這個從運行隊列中選中並被置上的線程進入了執行狀態。被取下的線程可能重新進入了就緒態(如果它還需要執行),或者進入了等待態(如果他是因爲一個IO密集型任務而被置換)。

上下文切換的開銷是昂貴的,因爲它從一個內核上進行線程交換是耗時的。上下文切換期間的等待時間量取決於多種因素,但是它佔了1000到1500納秒,這是不合理的。從物理硬件上考慮,每個內核在每納秒能夠執行12條指令(平均地),一次上下文切換就佔據了12k到18k指令集的時延。在本質上,你的程序在上下文切換期間失去了執行大量指令集的能力。

如果你的程序是集中在IO密集型任務,那麼上下文切換將是一個優勢。一旦某線程進入了等待態,另一個處於就緒態的線程就可接替它繼續執行。這可以保證內核總是處於工作中的。這是調度最重要的方面之一:如果有任務需要做(線程處於就緒態),永遠不要讓內核空閒下來。

如果你的程序是集中在CPU密集型任務,那麼上下文切換將會是性能的夢魘。因爲線程總是有工作去做,而上下文切換正在阻止工作的進行。這種情況與IO密集型任務形成鮮明對比。

少即是多

在早期的時候,處理器只有一個內核,那時的調度還並不是過於複雜。因爲你只有單內核的單處理器,在任何時刻只能有一條線程能被執行。那時的做法是定義一個調度週期(scheduler period),並嘗試在一個調度週期內執行所有的就緒態線程。方法:將調度週期除以需要執行的線程數。

舉個例子,如果你定義你的調度週期爲10ms(milliseconds)並且你有2個線程需要執行,那麼每個線程可獲取5ms,如果你有5個線程需要執行,那麼每個線程獲取2ms。但是,如果有100個線程呢,那怎麼辦?如果分配給每個線程10μs(microseconds)的時間片,那將不能工作,因爲你會耗費大量的時間在上下文切換上面。

你需要做的事是限制時間片的長度。在上例中,如果最小時間片是2ms而有100個線程需要執行,那麼調度週期就需要增加到2000ms,即2s(seconds)。倘使是1000個線程呢,則你需要將調度週期調到20s。在這個簡單的例子中,如果每個線程都執行一次,並且每個線程的這次執行都用完了分配給它的時間片,那麼讓所有線程都執行一次就花費了20s。

請注意,在調度問題上,這只是非常簡單的一個情況。調度器在做調度決策時,存在更多的因素需要去考慮與處理。你需要控制的是你程序中的線程數。當需要考慮更多的線程數,或者正在做IO密集型任務,會發生更混亂與不確定的情況。線程需要更長的時間來被調度與執行。

這就是爲什麼這個遊戲的規則叫“少即是多(Less is More)”。少的就緒態線程意味着少的調度開銷,每個線程能夠獲得多的執行時間。多的就緒態線程意味着每個線程獲得少的執行時間,這意味着同樣的時間開銷下,你能完成的工作變得更少。

尋找平衡

你需要在擁有的內核數量和爲程序獲得最佳通量所需的線程數量之間找到一個平衡點。處理這種平衡問題,線程池是一個不錯的方案。我會在第二部分的內容中提到線程池,但這並不是Go中所需要的。Go使得開發多線程應用變得更簡單,我認爲這是Go所做的最酷的事情之一。

在使用Go之前,我在WindowsNT上用C++和C#編程。在這個操作系統上,使用IOCP(IO Completion Ports)線程池來進行多線程程序的編寫是至關重要的。作爲一個工程師,你需要評估所需線程池的數量,以及所給線程池的最大線程數,根據當前內核數量,使其通量最大化。

當寫一個與數據庫打交道的web服務,魔法數字3,是每個內核之上的線程數量,總是能使得在NT上得到最佳通量。換句話說,每個內核上3個線程,能在最大化在內核上執行時間的同時,最小化上下文切換的延時開銷。當建立一個IOCP線程池時,我知道在確定的主機上,我會在每個內核上以最小1個線程最大3個線程來執行。

如果在每個內核上2個線程,要完成所有的工作會花費更長的時間,因爲有些本來可以用來完成任務的時間卻變成了空閒時間。如果我使用4個線程,它同樣會花費更長的時間,因爲存在了更多的上下文切換的延時開銷。每個內核的3個線程變成了一個平衡數據,不管原因到底是啥,它在NT系統上似乎已是一個具有魔法意味的數字。

倘使你的服務正在做大量不同類型的任務呢?這可能會導致差異性與不一致的延時。或許它正在生成大量不同的需要處理的系統級事件,因此在所有不同的任務流中,可能並不能找到一個魔法數字去適應所有的情況。當涉及到使用線程池來調整服務的性能時,找到正確的一致性配置會變得非常複雜。

快取線/緩存線(Cache lines)

從主存中直接獲取數據有高的延時開銷(100-300個時鐘週期),因此處理器和內核具有局部緩存(local caches)​​,以使數據保持在需要它的硬件線程附近。從緩存中獲取數據延時開銷明顯變小(3-40個時鐘週期),具體大小取決於要獲取數據的緩存級別上。在現今,性能優化的一個方面是如何有效地將數據放入處理器以減少數據訪問延遲。寫多線程程序需要考慮緩存系統的內在機制。

圖 2

處理器與主存交換數據是通過快取線完成的。一條快取線是64字節大小的內存塊,它用來在主存和緩存系統進行數據交換。每個內核都得到了它所需快取線的副本,這意味着硬件使用的是值語義(value semantics)。這就是在多線程程序中內存突變(mutations to memory)是性能夢魘的原因。

當多線程以並行的形式運行,並且它們要拿相同的數據或數據值相近的數據,那麼它們會在同一條快取線上獲取數據。運行在任意內核上的任意線程都會從這條快取線上得到它的副本。

圖 3

如果某內核上的某線程要對一條快取線的副本做某個更改,那麼通過硬件操作,所有其他對這同一條快取線上的副本會被標記爲髒副本。當某個線程嘗試通過一個髒副本來進行讀寫操作,主內存訪問(main memory acces)(100-300時鐘週期)需要得到一個針對該快取線新的副本。

或許在雙核處理器上,這並不是一個大問題。但是,如果是32個線程並行地運行在32核處理器上面,且它們都要在同一個快取線上做訪問和更改數據的操作呢?如果系統是兩個物理獨立的處理器且每個處理器有16個內核呢?由於處理器與處理器之間需要通信所至的額外延時,這會使得問題變得更加糟糕。應用程序會內存崩潰,性能變得十分糟糕,更可能地是,你並不知道爲什麼會這樣。

這個問題被稱爲緩存一致性問題(cache-coherency problem),並且會引起像錯誤共享之類的問題。當多線程程序會改變共享狀態時,必須考慮上緩存系統機制。

調度決策場景

假設我讓你寫一個OS調度器,就基於上述以較高視角給你的信息。思考一個你必須考慮的情況,請記住,這是調度器在做出調度決策時必須考慮的許多有趣的事情之一。

你啓動應用程序,創建了主線程且在內核1上執行。主線程開始執行它的指令集,因爲需要數據所以取得了快取線。這時,主線程決定創建一個新線程用於併發處理,那麼問題來了。

一旦新線程被創建且處於就緒態,這個時候調度器應該考慮:

  1. 是否要通過上下文切換將主線程從內核1上取下?這樣做可以提高性能,因爲這個新線程需要的相同的數據已經在緩存中了,這個時機是非常完美的。但是這樣做,主線程就不能得到它全部的時間片了。
  2. 是否讓這個新線等待主線程時間片的完成?這時內核1變得可用,新線程並未運行,但是一旦啓動,獲取數據的時延將會消除。
  3. 是否讓新線程等待下一個可用的內核?這意味着從所選內核的快取線,將進行刷新,獲取和複製,從而產生了時延。但是,線程將啓動得更快,並且主線程可以完成其時間片。

玩的開心嗎?在OS調度器做調度決策時,有很多有趣的問題需要考慮。幸運的是,我們不需要做這些事情。我只能告訴你的是,如果有一個空閒的內核,它將被使用,你希望線程可以在能被運行時運行。

結論

第一部分的內容,提供了在寫多線程程序時必須考慮的有關線程和OS調度器的見解。這些也是Go調度器需要考慮的事項。在下一部分內容中,我將描述Go調度器的機制以及它們如何與該機制相關。最後,運行幾個程序,你將看到所有的祕密。

英文原文連接:

Scheduling In Go : Part I - OS Scheduler

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