首先,Golang 調度器的設計和實現讓咱們的 Go 程序在多線程執行時效率更高,性能更好。這要歸功於 Go 調度器與操做系統(OS)調度器的協同合做。不過在本篇文章中,多線程 Go 程序在設計和實現上是否與調度器的工做原理徹底契合不是重點。重要的是對系統調度器和 Go 調度器,它們是如何正確地設計多線程程序,有一個全面且深刻的理解。git
本章多數內容將側重於討論調度器的高級機制和語義。我將展現一些細節,讓你能夠經過圖像來理解它們是如何工做的,可讓你在寫代碼時作出更好的決策。由於原理和語義是必備的基礎知識中的關鍵。github
操做系統調度器是一個複雜的程序。它們要考慮到運行時的硬件設計和設置,其中包括但不限於多處理器核心、CPU 緩存和 NUMA,只有考慮全面,調度器才能作到儘量地高效。值得高興的是,你不須要深刻研究這些問題,就能夠大體上了解操做系統調度器是如何工做的。算法
你的代碼會被翻譯成一系列機器指令,而後依次執行。爲了實現這一點,操做系統使用線程(Thread)的概念。線程負責順序執行分配給它的指令。一直執行到沒有指令爲止。這就是我將線程稱爲「執行流」的緣由。緩存
你運行的每一個程序都會建立一個進程,每一個進程都有一個初始線程。然後線程能夠建立更多的線程。每一個線程互相獨立地運行着,調度是在線程級別而不是在進程級別作出的。線程能夠併發運行(每一個線程在單個內核上輪流運行),也能夠並行運行(每一個線程在不一樣的內核上同時運行)。線程還維護本身的狀態,以便安全、本地和獨立地執行它們的指令。安全
若是有線程能夠執行,操做系統調度器就會調度它到空閒的 CPU 核心上去執行,保證 CPU 不閒着。它還必須模擬一個假象,即全部能夠執行的線程都在同時地執行着。在這個過程當中,調度器還會根據優先級不一樣選擇線程執行的前後順序,高優先級的先執行,低優先級的後執行。固然,低優先級的線程也不會被餓着。調度器還須要經過快速而明智的決策儘量減小調度延遲。網絡
爲了實現這一目標,算法在其中作了不少工做,且幸運的是,這個領域已經積累了幾十年經驗。爲了咱們能更好地理解這一切,接下來咱們來看幾個重要的概念。多線程
程序計數器(PC),有時稱爲指令指針(IP),線程利用它來跟蹤下一個要執行的指令。在大多數處理器中,PC指向的是下一條指令,而不是當前指令。
若是你以前看過 Go 程序的堆棧跟蹤,那麼你可能已經注意到了每行末尾的這些十六進制數字。以下:併發
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 偏移量表示在程序沒中斷的狀況下,線程即將執行的下一條指令。若是控制權回到主函數中,則主函數中的下一條指令是0+x72
PC 偏移量。更重要的是,指針前面的指令是當前正在執行的指令。函數
下面是對應的代碼 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
表示示例函數內的一條指令的 PC 偏移量,該指令位於函數的起始指令後面第57條(10進制)。接下來,咱們用 objdump 來看一下彙編指令。找到第57條指令,注意,runtime.gopanic
那一行。性能
$ 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 <--- 這裏是 PC(+0x39)
記住: PC 是下一個指令,而不是當前指令。上面是基於 amd64 的彙編指令的一個很好的例子,該 Go 程序的線程負責順序執行。
另外一個重要的概念是線程狀態,它描述了調度器在線程中的角色。
線程能夠處於三種狀態之一: 等待中(Waiting)
、待執行(Runnable)
或執行中(Executing)
。
等待中(Waiting)
:這意味着線程中止並等待某件事情以繼續。這多是由於等待硬件(磁盤、網絡)、操做系統(系統調用)或同步調用(原子、互斥)等緣由。這些類型的延遲是性能降低的根本緣由。
待執行(Runnable)
:這意味着線程須要內核上的時間,以便執行它指定的機器指令。若是有不少線程都須要時間,那麼線程須要等待更長的時間才能得到執行。此外,因爲更多的線程在競爭,每一個線程得到的單個執行時間都會縮短。這種類型的調度延遲也可能致使性能降低。
執行中(Executing)
:這意味着線程已經被放置在一個核心上,而且正在執行它的機器指令。與應用程序相關的工做正在完成。這是每一個人都想要的。
線程能夠作兩種類型的工做。第一個稱爲 CPU-Bound,第二個稱爲 IO-Bound。
CPU-Bound:這種工做類型永遠也不會讓線程處在等待狀態,由於這是一項不斷進行計算的工做。好比計算 π 的第 n 位,就是一個 CPU-Bound 線程。
IO-Bound:這是致使線程進入等待狀態的工做類型。好比經過網絡請求對資源的訪問或對操做系統進行系統調用。
諸如 Linux、Mac、 Windows 是一個具備搶佔式調度器的操做系統。這意味着一些重要的事情。首先,這意味着調度程序在何時選擇運行哪些線程是不可預測的。線程優先級和事件混在一塊兒(好比在網絡上接收數據)使得沒法肯定調度程序將選擇作什麼以及何時作。
其次,這意味着你永遠不能基於一些你曾經歷過但不能保證每次都發生的行爲來編寫代碼。若是應用程序中須要肯定性,則必須控制線程的同步和協調管理。
在覈心上交換線程的物理行爲稱爲上下文切換。當調度器將一個正在執行的線程從內核中取出並將其更改狀態爲一個可運行的線程時,就會發生上下文切換。
上下文切換的代價是高昂的,由於在覈心上交換線程會花費不少時間。上下文切換的延遲取決於不一樣的因素,大概在在 50 到 100 納秒之間。考慮到硬件應該可以合理地(平均)在每一個核心上每納秒執行 12 條指令,那麼一次上下文切換可能會花費 600 到 1200 條指令的延遲時間。實際上,上下文切換佔用了大量程序執行指令的時間。
若是你在執行一個 IO-Bound 程序,那麼上下文切換將是一個優點。一旦一個線程更改到等待狀態,另外一個處於可運行狀態的線程就會取而代之。這使得 CPU 老是在工做。這是調度器最重要的之一,最好不要讓 CPU 閒下來。
而若是你在執行一個 CPU-Bound 程序,那麼上下文切換將成爲性能瓶頸的噩夢。因爲線程老是有工做要作,因此上下文切換阻礙了工做的進展。這種狀況與 IO-Bound 類型的工做造成了鮮明對比。
在早期處理器只有一個核心的時代,調度相對簡單。由於只有一個核心,因此物理上在任什麼時候候都只有一個線程能夠執行。其思想是定義一個調度程序週期,並嘗試在這段時間內執行全部可運行線程。算法很簡單:用調度週期除以須要執行的線程數。
例如,若是你將調度器週期定義爲 10ms(毫秒),而且你有 2 個線程,那麼每一個線程將分別得到 5ms。若是你有 5 個線程,每一個線程獲得 2ms。可是,若是有 1000 個線程,會發生什麼狀況呢?給每一個線程一個時間片 10μs (微秒)?錯了,這麼幹是愚蠢的,由於你會花費大量的時間在上下文切換上,而真正的工做卻作不成。
你須要限制時間片的長度。在最後一個場景中,若是最小時間片是 2ms,而且有 1000 個線程,那麼調度器週期須要增長到 2s(秒)。若是有 10000 個線程,那麼調度器週期就是 20s。在這個簡單的例子中,若是每一個線程使用它的全時間片,那麼全部線程運行一次須要花費 20s。
要知道,這是一個很是簡單的場景。在真正進行調度決策時,調度程序須要考慮和處理比這更多的事情。你能夠控制應用程序中使用的線程數量。當有更多的線程要考慮,而且發生 IO-Bound 工做時,就會出現一些混亂和不肯定的行爲。任務須要更長的時間來調度和執行。
這就是爲何遊戲規則是「少便是多」。處於可運行狀態的線程越少,意味着調度開銷越少,每一個線程執行的時間越長。完成的工做會越多。如此,效率就越高。
你須要在 CPU 核心數和爲應用程序得到最佳吞吐量所需的線程數之間找到平衡。當涉及到管理這種平衡時,線程池是一個很好的解決方案。將在第二部分中爲你解析,Go 並非這樣作的。
從主存訪問數據有很高的延遲成本(大約 100 到 300 個時鐘週期),所以處理器核心使用本地高速緩存來將數據保存在須要的硬件線程附近。從緩存訪問數據的成本要低得多(大約 3 到 40 個時鐘週期),這取決於所訪問的緩存。現在,提升性能的一個方面是關於如何有效地將數據放入處理器以減小這些數據訪問延遲。編寫多線程應用程序也須要考慮 CPU 緩存的機制。
數據經過cache lines
在處理器和主存儲器之間交換。cache line
是在主存和高速緩存系統之間交換的 64 字節內存塊。每一個內核都有本身所需的cache line
的副本,這意味着硬件使用值語義。這就是爲何多線程應用程序中內存的變化會形成性能噩夢。
當並行運行的多個線程正在訪問相同的數據值,甚至是相鄰的數據值時,它們將訪問同一cache line
上的數據。在任何核心上運行的任何線程都將得到同一cache line
的副本。
若是某個核心上的一個線程對其cache line
的副本進行了更改,那麼同一cache line
的全部其餘副本都必須標記爲dirty
的。當線程嘗試對dirty cache line
進行讀寫訪問時,須要向主存訪問(大約 100 到 300 個時鐘週期)來得到cache line
的新副本。
也許在一個 2 核處理器上這不是什麼大問題,可是若是一個 32 核處理器在同一cache line
上同時運行 32 個線程來訪問和改變數據,那會發生什麼?若是一個系統有兩個物理處理器,每一個處理器有16個核心,那又該怎麼辦呢?這將變得更糟,由於處理器處處理器的通訊延遲更大。應用程序將會在主存中週轉,性能將會大幅降低。
這被稱爲緩存一致性問題,還引入了錯誤共享等問題。在編寫可能會改變共享狀態的多線程應用程序時,必須考慮緩存系統。
假設我要求你基於我給你的信息編寫操做系統調度器。考慮一下這個你必須考慮的狀況。記住,這是調度程序在作出調度決策時必須考慮的許多有趣的事情之一。
啓動應用程序,建立主線程並在核心1
上執行。當線程開始執行其指令時,因爲須要數據,正在檢索cache line
。如今,線程決定爲一些併發處理建立一個新線程。下面是問題:
核心1
的主線程,切入新線程?這樣作有助於提升性能,由於這個新線程須要的相同部分的數據極可能已經被緩存。但主線程沒有獲得它的所有時間片。核心1
在主線程完成以前變爲可用?線程沒有運行,但一旦啓動,獲取數據的延遲將被消除。cache line
將被刷新、檢索和複製,從而致使延遲。然而,線程將啓動得更快,主線程能夠完成它的時間片。有意思嗎?這些是系統調度器在作出調度決策時須要考慮的有趣問題。幸運的是,不是我作的。我能告訴你的就是,若是有一個空閒核心,它將被使用。你但願線程在能夠運行時運行。
本文的第一部分深刻介紹了在編寫多線程應用程序時須要考慮的關於線程和系統調度器的問題。這些是 Go 調度器也要考慮的事情。在下一篇文章中,我將解析 Go 調度器的語義以及它們如何與這些信息相關聯,並經過一些示例程序來展現。