做者 | Dravenessnode
導讀:本文做者寫這篇文章前先後後大概 2 個月的時間,全文大概 2w 字,建議收藏後閱讀或者經過電腦閱讀。linux
調度是一個很是普遍的概念,不少領域都會使用調度這個術語,在計算機科學中,調度就是一種將任務(Work)分配給資源的方法。任務多是虛擬的計算任務,例如線程、進程或者數據流,這些任務會被調度到硬件資源上執行,例如:處理器 CPU 等設備。算法
圖 1 - 調度系統設計精要編程
本文會介紹調度系統的常見場景以及設計過程當中的一些關鍵問題,調度器的設計最終都會歸結到一個問題上 — 如何對資源高效的分配和調度以達到咱們的目的,可能包括對資源的合理利用、最小化成本、快速匹配供給和需求。api
圖 2 - 文章脈絡和內容數組
除了介紹調度系統設計時會遇到的常見問題以外,本文還會深刻分析幾種常見的調度器的設計、演進與實現原理,包括操做系統的進程調度器,Go 語言的運行時調度器以及 Kubernetes 的工做負載調度器,幫助咱們理解調度器設計的核心原理。緩存
調度系統其實就是調度器(Scheduler),咱們在不少系統中都能見到調度器的身影,就像咱們在上面說的,不止操做系統中存在調度器,編程語言、容器編排以及不少業務系統中都會存在調度系統或者調度模塊。安全
這些調度模塊的核心做用就是對有限的資源進行分配,以實現最大化資源的利用率或者下降系統的尾延遲,調度系統面對的就是資源的需求和供給不平衡的問題。網絡
圖 3 - 調度器的任務和資源數據結構
咱們在這一節中將從多個方面介紹調度系統設計時須要重點考慮的問題,其中包括調度系統的需求調研、調度原理以及架構設計。
在着手構建調度系統以前,首要的工做就是進行詳細的需求調研和分析,在這個過程當中須要完成如下兩件事:
調度系統應用的場景是咱們首先須要考慮的問題,對應用場景的分析相當重要,咱們須要深刻了解當前場景下待執行任務和能用來執行任務的資源的特色。咱們須要分析待執行任務的如下特徵:
而用於執行任務的資源也可能存在資源不平衡,不一樣資源處理任務的速度不一致的問題。
資源和任務特色的多樣性決定了調度系統的設計,咱們在這裏舉幾個簡單的例子幫助各位讀者理解調度系統需求分析的過程。
圖 4 - Linux 操做系統
在操做系統的進程調度器中,待調度的任務就是線程,這些任務通常只會處於正在執行或者未執行(等待或者終止)的狀態;而用於處理這些任務的 CPU 每每都是不可再分的,同一個 CPU 在同一時間只能執行一個任務,這是物理上的限制。簡單總結一下,操做系統調度器的任務和資源有如下特性:
在上述場景中,待執行的任務是操做系統調度的基本單位 —— 線程,而可分配的資源是 CPU 的時間。Go 語言的調度器與操做系統的調度器面對的是幾乎相同的場景,其中的任務是 Goroutine,能夠分配的資源是在 CPU 上運行的線程。
圖 5 - 容器編排系統 Kubernetes
除了操做系統和編程語言這種較爲底層的調度器以外,容器和計算任務調度在今天也很常見,Kubernetes 做爲容器編排系統會負責調取集羣中的容器,對它稍有了解的人都知道,Kubernetes 中調度的基本單元是 Pod,這些 Pod 會被調度到節點 Node 上執行:
調度系統在生活和工做中都很常見,除了上述的兩個場景以外,其餘須要調度系統的場景包括 CDN 的資源調度、訂單調度以及離線任務調度系統等。在不一樣場景中,咱們都須要深刻思考任務和資源的特性,它們對系統的設計起者指導做用。
在深刻分析調度場景後,咱們須要理解調度的目的。咱們能夠將調度目的理解成機器學習中的成本函數(Cost function),肯定調度目的就是肯定成本函數的定義,調度理論一書中曾經介紹過常見的調度目的,包含如下內容:
這些都是偏理論的調度的目的,多數業務調度系統的調度目的都是優化與業務聯繫緊密的指標 — 成本和質量。如何在成本和質量之間達到平衡是須要仔細思考和設計的,因爲篇幅所限以及業務場景的複雜,本文不會分析如何權衡成本和質量,這每每都是須要結合業務考慮的事情,不具備足夠的類似性。
性能優異的調度器是實現特定調度目的前提,咱們在討論調度場景和目的時每每都會忽略調度的額外開銷,然而調度器執行時的延時和吞吐量等指標在調度負載較重時是不可忽視的。本節會分析與調度器實現相關的一些重要概念,這些概念可以幫助咱們實現高性能的調度器:
協做式(Cooperative)與搶佔式(Preemptive)調度是操做系統中常見的多任務運行策略。這兩種調度方法的定義徹底不一樣:
圖 6 - 協做式調度與搶佔式調度
任務的執行時間和任務上下文切換的額外開銷決定了哪一種調度方式會帶來更好的性能。以下圖所示,圖 7 展現了一個協做式調度器調度任務的過程,調度器一旦爲某個任務分配了資源,它就會等待該任務主動釋放資源,圖中 4 個任務儘管執行時間不一樣,可是它們都會在任務執行完成後釋放資源,整個過程也只須要 4 次上下文的切換。
圖 7 - 協做式調度
圖 8 展現了搶佔式調度的過程,因爲調度器不知道全部任務的執行時間,因此它爲每個任務分配了一段時間切片。任務 1 和任務 4 因爲執行時間較短,因此在第一次被調度時就完成了任務;可是任務 2 和任務 3 由於執行時間較長,超過了調度器分配的上限,因此爲了保證公平性會觸發搶佔,等待隊列中的其餘任務會得到資源。在整個調度過程當中,一共發生了 6 次上下文切換。
圖 8 - 搶佔式調度
若是部分任務的執行時間很長,協做式的任務調度會使部分執行時間長的任務餓死其餘任務;不過若是待執行的任務執行時間較短而且幾乎相同,那麼使用協做式的任務調度能減小任務中斷帶來的額外開銷,從而帶來更好的調度性能。
由於多數狀況下任務執行的時間都不肯定,在協做式調度中一旦任務沒有主動讓出資源,那麼就會致使其它任務等待和阻塞,因此調度系統通常都會以搶佔式的任務調度爲主,同時支持任務的協做式調度。
使用單個調度器仍是多個調度器也是設計調度系統時須要仔細考慮的,多個調度器並不必定意味着多個進程,也有多是一個進程中的多個調度線程,它們既能夠選擇在多核上並行調度、在單核上併發調度,也能夠同時利用並行和併發提升性能。
圖 9 - 單調度器調度任務和資源
不過對於調度系統來講,由於它作出的決策會改變資源的狀態和系統的上下文進而影響後續的調度決策,因此單調度器的串行調度是可以精準調度資源的惟一方法。單個調度器利用不一樣渠道收集調度須要的上下文,並在收到調度請求後會根據任務和資源狀況作出當下最優的決策。
隨着調度器的不斷演變,單調度器的性能和吞吐量可能會受到限制,咱們仍是須要引入並行或者併發調度來解決性能上的瓶頸,這時咱們須要將待調度的資源分區,讓多個調度器分別負責調度不一樣區域中的資源。
圖 10 - 多調度器與資源分區
多調度器的併發調度可以極大提高調度器的總體性能,例如 Go 語言的調度器。Go 語言運行時會將多個 CPU 交給不一樣的處理器分別調度,這樣經過並行調度可以提高調度器的性能。
上面介紹的兩種調度方法都創建在須要精準調度的前提下,多調度器中的每個調度器都會面對無關的資源,因此對於同一個分區的資源,調度仍是串行的。
圖 11 - 多調度器粗粒度調度
使用多個調度器同時調度多個資源也是可行的,只是可能須要犧牲調度的精確性 — 不一樣的調度器可能會在不一樣時間接收到狀態的更新,這就會致使不一樣調度器作出不一樣的決策。負載均衡就能夠看作是多線程和多進程的調度器,由於對任務和資源掌控的信息有限,這種粗粒度調度的結果極可能就是不一樣機器的負載會有較大差別,因此不管是小規模集羣仍是大規模集羣都頗有可能致使某些實例的負載太高。
這一小節將繼續介紹在多個調度器間從新分配任務的兩個調度範式 — 工做分享(Work Sharing)和工做竊取(Work Stealing)。獨立的調度器能夠同時處理全部的任務和資源,因此它不會遇到多調度器的任務和資源的不平衡問題。在多數的調度場景中,任務的執行時間都是不肯定的,假設多個調度器分別調度相同的資源,因爲任務的執行時間不肯定,多個調度器中等待調度的任務隊列最終會發生差別 — 部分隊列中包含大量任務,而另一些隊列不包含任務,這時就須要引入任務再分配策略。
工做分享和工做竊取是徹底不一樣的兩種再分配策略。在工做分享中,當調度器建立了新任務時,它會將一部分任務分給其餘調度器;而在工做竊取中,當調度器的資源沒有被充分利用時,它會從其餘調度器中竊取一些待分配的任務,以下圖所示:
圖 12 - 工做竊取調度器
這兩種任務再分配的策略都爲系統增長了額外的開銷,與工做分享相比,工做竊取只會在當前調度器的資源沒有被充分利用時纔會觸發,因此工做竊取引入的額外開銷更小。工做竊取在生產環境中更加經常使用,Linux 操做系統和 Go 語言都選擇了工做竊取策略。
本節將從調度器內部和外部兩個角度分析調度器的架構設計,前者分析調度器內部多個組件的關係和作出調度決策的過程;後者分析多個調度器應該如何協做,是否有其餘的外部服務能夠輔助調度器作出更合理的調度決策。
當調度器收到待調度任務時,會根據採集到的狀態和待調度任務的規格(Spec)作出合理的調度決策,咱們能夠從下圖中瞭解常見調度系統的內部邏輯。
圖 13 - 調度器作出調度決策
常見的調度器通常由兩部分組成 — 用於收集狀態的狀態模塊和負責作決策的決策模塊。
狀態模塊會從不一樣途徑收集儘量多的信息爲調度提供豐富的上下文,其中可能包括資源的屬性、利用率和可用性等信息。根據場景的不一樣,上下文可能須要存儲在 MySQL 等持久存儲中,通常也會在內存中緩存一份以減小調度器訪問上下文的開銷。
決策模塊會根據狀態模塊收集的上下文和任務的規格作出調度決策,須要注意的是作出的調度決策只是在當下有效,在將來某個時間點,狀態的改變可能會致使以前作的決策不符合任務的需求,例如:當咱們使用 Kubernetes 調度器將工做負載調度到某些節點上,這些節點可能因爲網絡問題忽然不可用,該節點上的工做負載也就不能正常工做,即調度決策失效。
調度器在調度時都會經過如下的三個步驟爲任務調度合適的資源:
圖 14 - 調度框架
上圖展現了常見調度器決策模塊執行的幾個步驟,肯定優先級、對閒置資源進行打分、肯定搶佔資源的犧牲者,上述三個步驟中的最後一個每每都是可選的,部分調度系統不須要支持搶佔式調度的功能。
若是咱們將調度器當作一個總體,從調度器外部看架構設計就會獲得徹底不一樣的角度 — 如何利用外部系統加強調度器的功能。在這裏咱們將介紹兩種調度器外部的設計,分別是多調度器和反調度器(Descheduler)。
串行調度與並行調度一節已經分析了多調度器的設計,咱們能夠將待調度的資源進行分區,讓多個調度器線程或者進程分別負責各個區域中資源的調度,充分利用多和 CPU 的並行能力。
反調度器是一個比較有趣的概念,它可以移除決策再也不正確的調度,下降系統中的熵,讓調度器根據當前的狀態從新決策。
圖 15 - 調度器與反調度器
反調度器的引入使得整個調度系統變得更加健壯。調度器負責根據當前的狀態作出正確的調度決策,反調度器根據當前的狀態移除錯誤的調度決策,它們的做用看起來相反,可是目的都是爲任務調度更合適的資源。
反調度器的使用沒有那麼普遍,實際的應用場景也比較有限。做者第一次發現這個概念是在 Kubernetes 孵化的descheduler 項目中,不過由於反調度器移除調度關係可能會影響正在運行的線上服務,因此 Kubernetes 也只會在特定場景下使用。
調度器是操做系統中的重要組件,操做系統中有進程調度器、網絡調度器和 I/O 調度器等組件,本節介紹的是操做系統中的進程調度器。
有一些讀者可能會感到困惑,操做系統調度的最小單位不是線程麼,爲何這裏使用的是進程調度。在 Linux 操做系統中,調度器調度的不是進程也不是線程,它調度的是 task_struct 結構體,該結構體既能夠表示線程,也能夠表示進程,而調度器會將進程和線程都當作任務,咱們在這裏先說明這一問題,避免讀者感到困惑。咱們會使用進程調度器這個術語,可是必定要注意 Linux 調度器中並不區分線程和進程。
Linux incorporates process and thread scheduling by treating them as one in the same. A process can be viewed as a single thread, but a process can contain multiple threads that share some number of resources (code and/or data).
接下來,本節會研究操做系統中調度系統的類型以及 Linux 進程調度器的演進過程。
操做系統會將進程調度器分紅三種不一樣的類型,即長期調度器、中期調度器和短時間調度器。這三種不一樣類型的調度器分別提供了不一樣的功能,咱們將在這一節中依次介紹它們。
長期調度器(Long-Term Scheduler)也被稱做任務調度器(Job Scheduler),它可以決定哪些任務會進入調度器的準備隊列。當咱們嘗試執行新的程序時,長期調度器會負責受權或者延遲該程序的執行。長期調度器的做用是平衡同時正在運行的 I/O 密集型或者 CPU 密集型進程的任務數量:
長期調度器能平衡同時正在運行的 I/O 密集型和 CPU 密集型任務,最大化的利用操做系統的 I/O 和 CPU 資源。
中期調度器會將不活躍的、低優先級的、發生大量頁錯誤的或者佔用大量內存的進程從內存中移除,爲其餘的進程釋放資源。
圖 16 - 中期調度器
當正在運行的進程陷入 I/O 操做時,該進程只會佔用計算資源,在這種狀況下,中期調度器就會將它從內存中移除等待 I/O 操做完成後,該進程會從新加入就緒隊列並等待短時間調度器的調度。
短時間調度器應該是咱們最熟悉的調度器,它會從就緒隊列中選出一個進程執行。進程的選擇會使用特定的調度算法,它會同時考慮進程的優先級、入隊時間等特徵。由於每一個進程可以獲得的執行時間有限,因此短時間調度器的執行十分頻繁。
本節將重點介紹 Linux 的 CPU 調度器,也就是短時間調度器。Linux 的 CPU 調度器並非從設計之初就是像今天這樣複雜的,在很長的一段時間裏(v0.01 ~ v2.4),Linux 的進程調度都由幾十行的簡單函數負責,咱們先了解一下不一樣版本調度器的歷史:
這裏會詳細介紹從最初的調度器到今天覆雜的徹底公平調度器(Completely Fair Scheduler,CFS)的演變過程。
Linux 最初的進程調度器僅由 sched.h 和 sched.c 兩個文件構成。你可能很難想象 Linux 早期版本使用只有幾十行的 schedule 函數負責了操做系統進程的調度:
void schedule(void) { int i,next,c; struct task_struct ** p; for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) { ... } while (1) { c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS]; while (--i) { if (!*--p) continue; if ((*p)->state == TASK_RUNNING && (*p)->counter > c) c = (*p)->counter, next = i; } if (c) break; for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) (*p)->counter = ((*p)->counter >> 1) + (*p)->priority; } switch_to(next); }
不管是進程仍是線程,在 Linux 中都被看作是 task_struct 結構體,全部的調度進程都存儲在上限僅爲 64 的數組中,調度器可以處理的進程上限也只有 64 個。
圖 17 - 最初的進程調度器
上述函數會先喚醒得到信號的可中斷進程,而後從隊列倒序查找計數器 counter 最大的可執行進程,counter 是進程可以佔用的時間切片數量,該函數會根據時間切片的值執行不一樣的邏輯:
Linux 操做系統的計時器會每隔 10ms 觸發一次 do_timer 將當前正在運行進程的 counter 減一,當前進程的計數器歸零時就會從新觸發調度。
調度器是 Linux 在 v2.4 ~ v2.6 版本使用的調度器,因爲該調取器在最壞的狀況下會遍歷全部的任務,因此它調度任務的時間複雜度就是 。Linux 調度算法將 CPU 時間分割成了不一樣的時期(Epoch),也就是每一個任務可以使用的時間切片。
咱們能夠在 sched.h 和 sched.c 兩個文件中找到調度器的源代碼。與上一個版本的調度器相比, 調度器的實現複雜了不少,該調度器會在 schedule 函數中遍歷運行隊列中的全部任務並調用 goodness 函數分別計算它們的權重得到下一個運行的進程:
asmlinkage void schedule(void){ ... still_running_back: list_for_each(tmp, &runqueue_head) { p = list_entry(tmp, struct task_struct, run_list); if (can_schedule(p, this_cpu)) { int weight = goodness(p, this_cpu, prev->active_mm); if (weight > c) c = weight, next = p; } } ... }
在每一個時期開始時,上述代碼都會爲全部的任務計算時間切片,由於須要執行 n 次,因此調度器被稱做 調度器。在默認狀況下,每一個任務在一個週期都會分配到 200ms 左右的時間切片,然而這種調度和分配方式是 調度器的最大問題:
正是由於調度器存在了上述的問題,因此 Linux 內核在兩個版本後使用新的 調度器替換該實現。
調度器在 v2.6.0 到 v2.6.22 的 Linux 內核中使用了四年的時間,它可以在常數時間內完成進程調度,你能夠在sched.h 和 sched.c 中查看 調度器的源代碼。由於實現和功能複雜性的增長,調度器的代碼行數從 的 2100 行增長到 5000 行,它在調度器的基礎上進行了以下的改進:
調度器經過運行隊列 runqueue 和優先數組 prio_array 兩個重要的數據結構實現了 的時間複雜度。每個運行隊列都持有兩個優先數組,分別存儲活躍的和過時的進程數組:
struct runqueue { ... prio_array_t *active, *expired, arrays[2]; ... } struct prio_array { unsignedint nr_active; unsignedlong bitmap[BITMAP_SIZE]; struct list_head queue[MAX_PRIO]; };
優先數組中的 nr_active 表示活躍的進程數,而 bitmap 和 list_head 共同組成了以下圖所示的數據結構:
圖 18 - 優先數組
優先數組的 bitmap 總共包含 140 位,每一位都表示對應優先級的進程是否存在。圖 17 中的優先數組包含 3 個優先級爲 2 的進程和 1 個優先級爲 5 的進程。每個優先級的標誌位都對應一個 list_head 數組中的鏈表。 調度器使用上述的數據結構進行以下所示的調度:
上述的這些規則是 調度器運行遵照的主要規則,除了上述規則以外,調度器還須要支持搶佔、CPU 親和等功能,不過在這裏就不展開介紹了。
全局的運行隊列是 調度器難以在對稱多處理器架構上擴展的主要緣由。爲了保證運行隊列的一致性,調度器在調度時須要獲取運行隊列的全局鎖,隨着處理器數量的增長,多個處理器在調度時會致使更多的鎖競爭,嚴重影響調度性能。 調度器經過引入本地運行隊列解決這個問題,不一樣的 CPU 能夠經過 this_rq 獲取綁定在當前 CPU 上的運行隊列,下降了鎖的粒度和衝突的可能性。
#define this_rq() (&__get_cpu_var(runqueues))
圖 19 - 全局運行隊列和本地運行隊列
多個處理器因爲再也不須要共享全局的運行隊列,因此加強了在對稱對處理器架構上的擴展性,當咱們增長新的處理器時,只須要增長新的運行隊列,這種方式不會引入更多的鎖衝突。
調度器中包含兩種不一樣的優先級計算方式,一種是靜態任務優先級,另外一種是動態任務優先級。在默認狀況下,任務的靜態任務優先級都是 0,不過咱們能夠經過系統調用 nice 改變任務的優先級; 調度器會獎勵 I/O 密集型任務並懲罰 CPU 密集型任務,它會經過改變任務的靜態優先級來完成優先級的動態調整,由於與用戶交互的進程時 I/O 密集型的進程,這些進程因爲調度器的動態策略會提升自身的優先級,從而提高用戶體驗。
徹底公平調度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入內核的調度器,也是內核的默認進程調度器,它的目的是最大化 CPU 利用率和交互的性能。Linux 內核版本 v2.6.23 中的 CFS 由如下的多個文件組成:
經過 CFS 的名字咱們就能發現,該調度器的能爲不一樣的進程提供徹底公平性。一旦某些進程受到了不公平的待遇,調度器就會運行這些進程,從而維持全部進程運行時間的公平性。這種保證公平性的方式與『水多了加面,面多了加水』有一些類似:
調度器算法不斷計算各個進程的運行時間並依次調度隊列中的受到最不公平對待的進程,保證各個進程的運行時間差不會大於最小運行的時間單位。
雖然咱們仍是會延用運行隊列這一術語,可是 CFS 的內部已經再也不使用隊列來存儲進程了,cfs_rq 是用來管理待運行進程的新結構體,該結構體會使用紅黑樹(Red-black tree)替代鏈表:
struct cfs_rq { struct load_weight load; unsignedlong nr_running; s64 fair_clock; u64 exec_clock; s64 wait_runtime; u64 sleeper_bonus; unsignedlong wait_runtime_overruns, wait_runtime_underruns; struct rb_root tasks_timeline; struct rb_node *rb_leftmost; struct rb_node *rb_load_balance_curr; struct sched_entity *curr; struct rq *rq; struct list_head leaf_cfs_rq_list; };
紅黑樹(Red-black tree)是平衡的二叉搜索樹,紅黑樹的增刪改查操做的最壞時間複雜度爲 ,也就是樹的高度,樹中最左側的節點 rb_leftmost 運行的時間最短,也是下一個待運行的進程。
注:在最新版本的 CFS 實現中,內核使用虛擬運行時間 vruntime 替代了等待時間,可是基本的調度原理和排序方式沒有太多變化。
CFS 的調度過程仍是由 schedule 函數完成的,該函數的執行過程能夠分紅如下幾個步驟:
CFS 的調度過程與 調度器十分相似,當前調度器與前者的區別只是增長了可選的工做竊取機制並改變了底層的數據結構。
CFS 中的調度類是比較有趣的概念,調度類能夠決定進程的調度策略。每一個調度類都包含一組負責調度的函數,調度類由以下所示的 sched_class 結構體表示:
struct sched_class { struct sched_class *next; void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup); void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep); void (*yield_task) (struct rq *rq, struct task_struct *p); void (*check_preempt_curr) (struct rq *rq, struct task_struct *p); struct task_struct * (*pick_next_task) (struct rq *rq); void (*put_prev_task) (struct rq *rq, struct task_struct *p); unsigned long (*load_balance) (struct rq *this_rq, int this_cpu, struct rq *busiest, unsigned long max_nr_move, unsigned long max_load_move, struct sched_domain *sd, enum cpu_idle_type idle, int *all_pinned, int *this_best_prio); void (*set_curr_task) (struct rq *rq); void (*task_tick) (struct rq *rq, struct task_struct *p); void (*task_new) (struct rq *rq, struct task_struct *p); };
調度類中包含任務的初始化、入隊和出隊等函數,這裏的設計與面向對象中的設計稍微有些類似。內核中包含 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE、SCHED_FIFO 和 SCHED_RR 調度類,這些不一樣的調度類分別實現了 sched_class 中的函數以提供不一樣的調度行爲。
本節介紹了操做系統調度器的設計原理以及演進的歷史,從 2007 年合入 CFS 到如今已通過去了很長時間,目前的調度器也變得更加複雜,社區也在不斷改進進程調度器。
咱們能夠從 Linux 調度器的演進的過程看到主流系統架構的變化,最初幾十行代碼的調度器就能完成基本的調度功能,而如今要使用幾萬行代碼來完成複雜的調度,保證系統的低延時和高吞吐量。
因爲篇幅有限,咱們很難對操做系統的調度器進行面面俱到的分析,你能夠在 這裏 找到做者使用的 Linux 源代碼,親自動手分析不一樣版本的進程調度器。
Go 語言是誕生自 2009 年的編程語言,相信不少人對 Go 語言的印象都是語法簡單,可以支撐高併發的服務。語法簡單是編程語言的頂層設計哲學,而語言的高併發支持依靠的是運行時的調度器,這也是本節將要研究的內容。
對 Go 語言稍微有了解的人都知道,通訊順序進程(Communicating sequential processes,CSP)影響着 Go 語言的併發模型,其中的 Goroutine 和 Channel 分別表示實體和用於通訊的媒介。
圖 20 - Go 和 Erlang 的併發模型
『不要經過共享內存來通訊,咱們應該使用通訊來共享內存』不僅是 Go 語言鼓勵的設計哲學,更爲古老的 Erlang 語言其實也遵循了一樣的設計,可是 Erlang 選擇使用了Actor 模型,咱們在這裏就不介紹 CSP 和 Actor 的區別和聯繫的,感興趣的讀者能夠在推薦閱讀和應引用中找到相關資源。
今天的 Go 語言調度器有着很是優異的性能,可是若是咱們回過頭從新看 Go 語言的 v0.x 版本的調度器就會發現最初的調度器很是簡陋,也沒法支撐高併發的服務。整個調度器通過幾個大版本的迭代纔有了今天的優異性能。
除了多線程、任務竊取和搶佔式調度器以外,Go 語言社區目前還有一個非均勻存儲訪問(Non-uniform memory access,NUMA)調度器的提案,未來有一天可能 Go 語言會實現這個調度器。在這一節中,咱們將依次介紹不一樣版本調度器的實現以及將來可能會實現的調度器提案。
Go 語言在 0.x 版本調度器中只包含表示 Goroutine 的 G 和表示線程的 M 兩種結構體,全局也只有一個線程。咱們能夠在 clean up scheduler 提交中找到單線程調度器的源代碼,在這時 Go 語言的 調度器 仍是由 C 語言實現的,調度函數 schedule 中也只包含 40 多行代碼 :
static void scheduler(void) { G* gp; lock(&sched); if(gosave(&m->sched)){ lock(&sched); gp = m->curg; switch(gp->status){ case Grunnable: case Grunning: gp->status = Grunnable; gput(gp); break; ... } notewakeup(&gp->stopped); } gp = nextgandunlock(); noteclear(&gp->stopped); gp->status = Grunning; m->curg = gp; g = gp; gogo(&gp->sched); }
該函數會遵循以下所示的過程執行:
這個單線程調度器的惟一優勢就是能跑,不過從此次提交中咱們能看到 G 和 M 兩個重要的數據結構,它創建了 Go 語言調度器的框架。
Go 語言 1.0 版本在正式發佈時就支持了多線程的調度器,與上一個版本徹底不可用的調度器相比,Go 語言團隊在這一階段完成了從不可用到可用。咱們能夠在 proc.c 中找到 1.0.1 版本的調度器,多線程版本的調度函數 schedule 包含 70 多行代碼,咱們在這裏保留了其中的核心邏輯:
static void schedule(G *gp) { schedlock(); if(gp != nil) { gp->m = nil; uint32 v = runtime·xadd(&runtime·sched.atomic, -1<<mcpuShift); if(atomic_mcpu(v) > maxgomaxprocs) runtime·throw("negative mcpu in scheduler"); switch(gp->status){ case Grunning: gp->status = Grunnable; gput(gp); break; case ...: } } else { ... } gp = nextgandunlock(); gp->status = Grunning; m->curg = gp; gp->m = m; runtime·gogo(&gp->sched, 0); }
總體的邏輯與單線程調度器沒有太多區別,多線程調度器引入了 GOMAXPROCS 變量幫助咱們控制程序中的最大線程數,這樣咱們的程序中就可能同時存在多個活躍線程。
多線程調度器的主要問題是調度時的鎖競爭,Scalable Go Scheduler Design Doc 中對調度器作的性能測試發現 14% 的時間都花費在 runtime.futex 函數上,目前的調度器實現有如下問題須要解決:
這裏的全局鎖問題和 Linux 操做系統調度器在早期遇到的問題比較類似,解決方案也都大同小異。
2012 年 Google 的工程師 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了現有多線程調度器的問題並在多線程調度器上提出了兩個改進的手段:
基於任務竊取的 Go 語言調度器使用了沿用至今的 G-M-P 模型,咱們能在 runtime: improved scheduler 提交中找到任務竊取調度器剛被實現時的源代碼,調度器的 schedule 函數到如今反而更簡單了:
static void schedule(void) { G *gp; top: if(runtime·gcwaiting) { gcstopm(); goto top; } gp = runqget(m->p); if(gp == nil) gp = findrunnable(); ... execute(gp); }
當前處理器本地的運行隊列中不包含 Goroutine 時,調用 findrunnable 函數會觸發工做竊取,從其餘的處理器的隊列中隨機獲取一些 Goroutine。
運行時 G-M-P 模型中引入的處理器 P 是線程 M 和 Goroutine 之間的中間層,咱們從它的結構體中就能看到 P 與 M 和 G 的關係:
struct P { Lock; uint32 status; // one of Pidle/Prunning/... P* link; uint32 tick; // incremented on every scheduler or system call M* m; // back-link to associated M (nil if idle) MCache* mcache; G** runq; int32 runqhead; int32 runqtail; int32 runqsize; G* gfree; int32 gfreecnt; };
處理器 P 持有一個運行隊列 runq,這是由可運行的 Goroutine 組成的數組,它還反向持有一個線程 M 的指針。調度器在調度時會從處理器的隊列中選擇隊列頭的 Goroutine 放到線程 M 上執行。以下所示的圖片展現了 Go 語言中的線程 M、處理器 P 和 Goroutine 的關係。
圖 21 - G-M-P 模型
基於工做竊取的多線程調度器將每個線程綁定到了獨立的 CPU 上並經過不一樣處理器分別管理,不一樣處理器中經過工做竊取對任務進行再分配,提高了調度器和 Go 語言程序的總體性能,今天全部的 Go 語言服務的高性能都受益於這一改動。
對 Go 語言併發模型的修改提高了調度器的性能,可是在 1.1 版本中的調度器仍然不支持搶佔式調度,程序只能依靠 Goroutine 主動讓出 CPU 資源。Go 語言的調度器在1.2 版本中引入了基於協做的搶佔式調度解決下面的問題:
然而 1.2 版本中實現的搶佔式調度是基於協做的,在很長的一段時間裏 Go 語言的調度器都包含一些沒法被搶佔的邊緣狀況,直到 1.14 才實現了基於信號的真搶佔式調度解決部分問題。
咱們能夠在 proc.c 文件中找到引入搶佔式調度後的調度器實現。Go 語言會在當前的分段棧機制上實現搶佔式的調度,全部的 Goroutine 在函數調用時都有機會進入運行時檢查是否須要執行搶佔。基於協做的搶佔是經過如下的多個提交實現的:
從上述一系列的提交中,咱們會發現 Go 語言運行時會在垃圾回收暫停程序、系統監控發現 Goroutine 運行超過 10ms 時提出搶佔請求 StackPreempt;由於編譯器會在函數調用中插入 runtime.newstack,因此函數調用時會經過 runtime.newstack 檢查 Goroutine 的 stackguard0 是否爲 StackPreempt 進而觸發搶佔讓出當前線程。
這種作法沒有帶來運行時的過多額外開銷,實現也相對比較簡單,不過增長了運行時的複雜度,整體來看仍是一種比較成功的實現。由於上述的搶佔是經過編譯器在特定時機插入函數實現的,仍是須要函數調用做爲入口才能觸發搶佔,因此這是一種協做式的搶佔式調度。
協做的搶佔式調度實現雖然巧妙,可是留下了不少的邊緣狀況,咱們能在 runtime: non-cooperative goroutine preemption 中找到一些遺留問題:
Go 語言在 1.14 版本中實現了非協做的搶佔式調度,在實現的過程當中咱們對已有的邏輯進行重構併爲 Goroutine 增長新的狀態和字段來支持搶佔。Go 團隊經過下面提交的實現了這一功能,咱們能夠順着提交的順序理解其實現原理:
目前的搶佔式調度也只會在垃圾回收掃描任務時觸發,咱們能夠梳理一下觸發搶佔式調度的過程:
上述 9 個步驟展現了基於信號的搶佔式調度的執行過程。咱們還須要討論一下該過程當中信號的選擇,提案根據如下的四個緣由選擇 SIGURG 做爲觸發異步搶佔的信號:
目前的搶佔式調度也沒有解決全部潛在的問題,由於 STW 和棧掃描時更可能出現問題,也是一個能夠搶佔的安全點(Safe-points),因此咱們會在這裏先加入搶佔功能,在將來可能會加入更多搶佔時間點。
非均勻內存訪問(Non-uniform memory access,NUMA)調度器目前只是 Go 語言的提案,由於該提案過於複雜,而目前的調度器的性能已經足夠優異,因此暫時沒有實現該提案。該提案的原理就是經過拆分全局資源,讓各個處理器可以就近獲取本地資源,減小鎖競爭並增長數據局部性。
在目前的運行時中,線程、處理器、網絡輪訓器、運行隊列、全局內存分配器狀態、內存分配緩存和垃圾收集器都是全局的資源。運行時沒有保證本地化,也不清楚系統的拓撲結構,部分結構能夠提供必定的局部性,可是從全局來看沒有這種保證。
圖 22 - Go 語言 NUMA 調度器
如上圖所示,堆棧、全局運行隊列和線程池會按照 NUMA 節點進行分區,網絡輪訓器和計時器會由單獨的處理器持有。這種方式雖然可以利用局部性提升調度器的性能,可是自己的實現過於複雜,因此 Go 語言團隊尚未着手實現這一提案。
Go 語言的調度器在最初的幾個版本中迅速迭代,可是從 1.2 版本以後調度器就沒有太多的變化,直到 1.14 版本引入了真正的搶佔式調度解決了自 1.2 以來一直存在的問題。在可預見的將來,Go 語言的調度器還會進一步演進,增長搶佔式調度的時間點減小存在的邊緣狀況。
本節內容選擇《Go 語言設計與實現》一書中的 Go 語言調度器實現原理,你能夠點擊連接瞭解更多與 Go 語言設計與實現原理相關的內容。
Kubernetes 是生產級別的容器調度和管理系統,在過去的一段時間中,Kubernetes 迅速佔領市場,成爲容器編排領域的實施標準。
圖 23 - 容器編排系統演進
Kubernetes 是希臘語『舵手』的意思,它最開始由 Google 的幾位軟件工程師創立,深受公司內部Borg 和 Omega 項目的影響,不少設計都是從 Borg 中借鑑的,同時也對 Borg 的缺陷進行了改進,Kubernetes 目前是 Cloud Native Computing Foundation (CNCF) 的項目,也是不少公司管理分佈式系統的解決方案。
調度器是 Kubernetes 的核心組件,它的主要功能是爲待運行的工做負載 Pod 綁定運行的節點 Node。與其餘調度場景不一樣,雖然資源利用率在 Kubernetes 中也很是重要,可是這只是 Kubernetes 關注的一個因素,它須要在容器編排這個場景中支持很是多而且複雜的業務需求,除了考慮 CPU 和內存是否充足,還須要考慮其餘的領域特定場景,例如:兩個服務不能佔用同一臺機器的相同端口、幾個服務要運行在同一臺機器上,根據節點的類型調度資源等。
這些複雜的業務場景和調度需求使 Kubernetes 調度器的內部設計與其餘調度器徹底不一樣,可是做爲用戶應用層的調度器,咱們卻能從中學到不少有用的模式和設計。接下來,本節將介紹 Kubernetes 中調度器的設計以及演變。
Kubernetes 調度器的演變過程比較簡單,咱們能夠將它的演進過程分紅如下的兩個階段:
Kubernetes 從 v1.0.0 版本發佈到 v1.14.0,總共 15 個版本一直都在使用謂詞和優先級來管理不一樣的調度算法,知道 v1.15.0 開始引入調度框架(Alpha 功能)來重構現有的調度器。咱們在這裏將以 v1.14.0 版本的謂詞和優先級和 v1.17.0 版本的調度框架分析調度器的演進過程。
謂詞(Predicates)和優先級(Priorities)調度器是從 Kubernetes v1.0.0 發佈時就存在的模式,v1.14.0 的最後實現與最開始的設計也沒有太多區別。然而從 v1.0.0 到 v1.14.0 期間也引入了不少改進:
謂詞和優先級都是 Kubernetes 在調度系統中提供的兩個抽象,謂詞算法使用 FitPredicate 類型,而優先級算法使用 PriorityMapFunction 和 PriorityReduceFunction 兩個類型:
type FitPredicate func(pod *v1.Pod, meta PredicateMetadata, nodeInfo *schedulernodeinfo.NodeInfo) (bool, []PredicateFailureReason, error) type PriorityMapFunction func(pod *v1.Pod, meta interface{}, nodeInfo *schedulernodeinfo.NodeInfo) (schedulerapi.HostPriority, error) type PriorityReduceFunction func(pod *v1.Pod, meta interface{}, nodeNameToInfo map[string]*schedulernodeinfo.NodeInfo, result schedulerapi.HostPriorityList) error
由於 v1.14.0 也是做者剛開始參與 Kubernetes 開發的第一個版本,因此對當時的設計印象也很是深入,v1.14.0 的 Kubernetes 調度器會使用 PriorityMapFunction 和 PriorityReduceFunction 這種 Map-Reduce 的方式計算全部節點的分數並從其中選擇分數最高的節點。下圖展現了,v1.14.0 版本中調度器的執行過程:
圖 24 - 謂詞和優先級算法
如上圖所示,咱們假設調度器中存在一個謂詞算法和一個 Map-Reduce 優先級算法,當咱們爲一個 Pod 在 6 個節點中選擇最合適的一個時,6 個節點會先通過謂詞的篩選,圖中的謂詞算法會過濾掉一半的節點,剩餘的 3 個節點通過 Map 和 Reduce 兩個過程分別獲得了 五、10 和 5 分,最終調度器就會選擇分數最高的 4 號節點。
genericScheduler.Schedule 是 Kubernetes 爲 Pod 選擇節點的方法,咱們省略了該方法中用於檢查邊界條件以及打點的代碼:
func (g *genericScheduler) Schedule(pod *v1.Pod, nodeLister algorithm.NodeLister) (result ScheduleResult, err error) { nodes, err := nodeLister.List() if err != nil { return result, err } iflen(nodes) == 0 { return result, ErrNoNodesAvailable } filteredNodes, failedPredicateMap, err := g.findNodesThatFit(pod, nodes) if err != nil { return result, err } ... priorityList, err := PrioritizeNodes(pod, g.nodeInfoSnapshot.NodeInfoMap, ..., g.prioritizers, filteredNodes, g.extenders) if err != nil { return result, err } host, err := g.selectHost(priorityList) return ScheduleResult{ SuggestedHost: host, EvaluatedNodes: len(filteredNodes) + len(failedPredicateMap), FeasibleNodes: len(filteredNodes), }, err }
這就是使用謂詞和優先級算法時的調度過程,咱們在這裏省略了調度器的優先隊列中的排序,出現調度錯誤時的搶佔以及 Pod 持久存儲卷綁定到 Node 上的過程,只保留了核心的調度邏輯。
Kubernetes 調度框架是 Babak Salamat 和 Jonathan Basseri 2018 年提出的最新調度器設計,這個提案明確了 Kubernetes 中的各個調度階段,提供了設計良好的基於插件的接口。調度框架認爲 Kubernetes 中目前存在調度(Scheduling)和綁定(Binding)兩個循環:
除了兩個大循環以外,調度框架中還包含 QueueSort、PreFilter、Filter、PostFilter、Score、Reserve、Permit、PreBind、Bind、PostBind 和 Unreserve 11 個擴展點(Extension Point),這些擴展點會在調度的過程當中觸發,它們的運行順序以下:
圖 25 - Kubernetes 調度框架
咱們能夠將調度器中的 Scheduler.scheduleOne 方法做爲入口分析基於調度框架的調度器實現,每次調用該方法都會完成一遍爲 Pod 調度節點的所有流程,咱們將該函數的執行過程分紅調度和綁定兩個階段,首先是調度器的調度階段:
func (sched *Scheduler) scheduleOne(ctx context.Context) { fwk := sched.Framework podInfo := sched.NextPod() pod := podInfo.Pod state := framework.NewCycleState() scheduleResult, _ := sched.Algorithm.Schedule(schedulingCycleCtx, state, pod) assumedPod := podInfo.DeepCopy().Pod allBound, _ := sched.VolumeBinder.Binder.AssumePodVolumes(assumedPod, scheduleResult.SuggestedHost) if err != nil { return } if sts := fwk.RunReservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost); !sts.IsSuccess() { return } if err := sched.assume(assumedPod, scheduleResult.SuggestedHost); err != nil { fwk.RunUnreservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) return } ... }
由於每一次調度決策都會改變上下文,因此該階段 Kubernetes 須要串行執行。而綁定階段就是實現調度的過程了,咱們會建立一個新的 Goroutine 並行執行綁定循環:
func (sched *Scheduler) scheduleOne(ctx context.Context) { ... gofunc() { bindingCycleCtx, cancel := context.WithCancel(ctx) defer cancel() fwk.RunPermitPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) if !allBound { sched.bindVolumes(assumedPod) } fwk.RunPreBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) if err := sched.bind(bindingCycleCtx, assumedPod, scheduleResult.SuggestedHost, state); err != nil { fwk.RunUnreservePlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) } else { fwk.RunPostBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) } }() }
目前的調度框架在 Kubernetes v1.17.0 版本中仍是 Alpha 階段,不少功能還不明確,爲了支持更多、更豐富的場景,在接下來的幾個版本還可能會作出不少改進,不過調度框架在很長的一段時間中都會是調度器的核心。
本節介紹了 Kubernetes 調度器從 v1.0.0 到最新版本中的不一樣設計,Kubernetes 調度器中總共存在兩種不一樣的設計,一種是基於謂詞和優先級算法的調度器,另外一種是基於調度框架的調度器。
不少的業務調度器也須要從多個選項中選出最優的選擇,不管是成本最低仍是質量最優,咱們能夠考慮將調度的過程分紅過濾和打分兩個階段爲調度器創建合適的抽象,過濾階段會按照需求過濾掉不知足需求的選項,打分階段可能會按照質量、成本和權重對多個選項進行排序,遵循這種設計思路能夠解決不少相似問題。
目前的 Kubernetes 已經經過調度框架詳細地支持了多個階段的擴展方法,幾乎是調度器內部實現的最終形態了。不過隨着調度器功能的逐漸複雜,將來可能還會遇到更復雜的調度場景,例如:多租戶的調度資源隔離、多調度器等功能,而 Kubernetes 社區也一直都在爲構建高性能的調度器而努力。
從操做系統、編程語言到應用程序,咱們在這篇文章中分析了 Linux、Go 語言和 Kubernetes 調度器的設計與實現原理,這三個不一樣的調度器其實有相互依賴的關係:
圖 26 - 三層調度器
如上圖所示,Kubernetes 的調度器依賴於 Go 語言的運行時調度器,而 Go 語言的運行時調度器也依賴於 Linux 的進程調度器,從上到下離用戶愈來愈遠,從下到上愈來愈關注具體業務。咱們在最後經過兩個比較分析一下這幾個調度器的異同:
這是兩種不一樣層面的比較,相信經過不一樣角度的比較可以讓咱們對調度器的設計有更深刻的認識。
首先是 Linux 和 Go 語言調度器,這兩個調度器的場景都很是類似,它們最終都是要充分利用機器上的 CPU 資源,因此在實現和演進上有不少類似之處:
由於場景很是類似,因此它們的目的也很是類似,只是它們調度的任務粒度會有不一樣,Linux 進程調度器的最小調度單位是線程,而 Go 語言是 Goroutine,與 Linux 進程調度器相比,Go 語言在用戶層創建新的模型,實現了另外一個調度器,爲使用者提供輕量級的調度單位來加強程序的性能,可是它也引入了不少組件來處理系統調用、網絡輪訓等線程相關的操做,同時組合多個不一樣粒度的任務致使實現相對複雜。
Linux 調度器的最終設計引入了調度類的概念,讓不一樣任務的類型分別享受不一樣的調度策略以此來調和低延時和實時性這個在調度上兩難的問題。
Go 語言的調度器目前剛剛引入了基於信號的搶佔式調度,還有不少功能都不完善。除了搶佔式調度以外,複雜的 NUMA 調度器提案也多是將來 Go 語言的發展方向。
若是咱們將系統調度器和業務調度器進行對比的話,你會發現二者在設計差異很是大,畢竟它們處於系統的不一樣層級。系統調度器考慮的是極致的性能,因此它經過分區的方式將運行隊列等資源分離,經過下降鎖的粒度來下降系統的延遲;而業務調度器關注的是完善的調度功能,調度的性能雖然十分重要,可是必定要創建在知足特定調度需求之上,而由於業務上的調度需求每每都是比較複雜,因此只能作出權衡和取捨。
正是由於需求的不一樣,咱們會發現不一樣調度器的演進過程也徹底不一樣。系統調度器都會先充分利用資源,下降系統延時,隨後在性能沒法優化時才考慮加入調度類等功能知足不一樣場景下的調度,而 Kubernetes 調度器更關注內部不一樣調度算法的組織,如何同時維護多個複雜的調度算法,當設計了良好的抽象以後,它纔會考慮更加複雜的多調度器、多租戶等場景。
這種研究歷史變化帶來的快樂是很不一樣的,當咱們發現代碼發生變化的緣由時也會感到欣喜,這讓咱們站在今天從新見證了歷史上的決策,本文中的相應章節已經包含了對應源代碼的連接,各位讀者能夠自行閱讀相應內容,也衷心但願各位讀者可以有所收穫。
「 阿里巴巴雲原生關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,作最懂雲原生開發者的技術圈。」