調度系統設計精要

作者 | Draveness

導讀:本文作者寫這篇文章前前後後大概 2 個月的時間,全文大概 2w 字,建議收藏後閱讀或者通過電腦閱讀。

調度是一個非常廣泛的概念,很多領域都會使用調度這個術語,在計算機科學中,調度就是一種將任務(Work)分配給資源的方法。任務可能是虛擬的計算任務,例如線程、進程或者數據流,這些任務會被調度到硬件資源上執行,例如:處理器 CPU 等設備。

·1.png

圖 1 - 調度系統設計精要

本文會介紹調度系統的常見場景以及設計過程中的一些關鍵問題,調度器的設計最終都會歸結到一個問題上 — 如何對資源高效的分配和調度以達到我們的目的,可能包括對資源的合理利用、最小化成本、快速匹配供給和需求。

2.png

圖 2 - 文章脈絡和內容

除了介紹調度系統設計時會遇到的常見問題之外,本文還會深入分析幾種常見的調度器的設計、演進與實現原理,包括操作系統的進程調度器,Go 語言的運行時調度器以及 Kubernetes 的工作負載調度器,幫助我們理解調度器設計的核心原理。

設計原理

調度系統其實就是調度器(Scheduler),我們在很多系統中都能見到調度器的身影,就像我們在上面說的,不止操作系統中存在調度器,編程語言、容器編排以及很多業務系統中都會存在調度系統或者調度模塊。

這些調度模塊的核心作用就是對有限的資源進行分配,以實現最大化資源的利用率或者降低系統的尾延遲,調度系統面對的就是資源的需求和供給不平衡的問題。

3.png

圖 3 - 調度器的任務和資源

我們在這一節中將從多個方面介紹調度系統設計時需要重點考慮的問題,其中包括調度系統的需求調研、調度原理以及架構設計。

1. 需求調研

在着手構建調度系統之前,首要的工作就是進行詳細的需求調研和分析,在這個過程中需要完成以下兩件事:

  • 調研調度系統的應用場景,深入研究場景中待執行的任務(Work)和能用來執行任務的資源(Resource)的特性;
  • 分析調度系統的目的,可能是成本優先、質量優先、最大化資源的利用率等,調度目的一般都是動態的,會隨着需求的變化而轉變;

應用場景

調度系統應用的場景是我們首先需要考慮的問題,對應用場景的分析至關重要,我們需要深入瞭解當前場景下待執行任務和能用來執行任務的資源的特點。我們需要分析待執行任務的以下特徵:

  • 任務是否有截止日期,必須在某個時間點之前完成;
  • 任務是否支持搶佔,搶佔的具體規則是什麼;
  • 任務是否包含前置的依賴條件;
  • 任務是否只能在指定的資源上運行;
  • ...

而用於執行任務的資源也可能存在資源不平衡,不同資源處理任務的速度不一致的問題。

資源和任務特點的多樣性決定了調度系統的設計,我們在這裏舉幾個簡單的例子幫助各位讀者理解調度系統需求分析的過程。

4.jpeg

圖 4 - Linux 操作系統

在操作系統的進程調度器中,待調度的任務就是線程,這些任務一般只會處於正在執行或者未執行(等待或者終止)的狀態;而用於處理這些任務的 CPU 往往都是不可再分的,同一個 CPU 在同一時間只能執行一個任務,這是物理上的限制。簡單總結一下,操作系統調度器的任務和資源有以下特性:

  • 任務 —— Thread。狀態簡單:只會處於正在執行或者未被執行兩種狀態;優先級不同:待執行的任務可能有不同的優先級,在考慮優先級的情況下,需要保證不同任務的公平性;
  • 資源 —— CPU 時間。資源不可再分:同一時間只能運行一個任務;

在上述場景中,待執行的任務是操作系統調度的基本單位 —— 線程,而可分配的資源是 CPU 的時間。Go 語言的調度器與操作系統的調度器面對的是幾乎相同的場景,其中的任務是 Goroutine,可以分配的資源是在 CPU 上運行的線程。

5.png

圖 5 - 容器編排系統 Kubernetes

除了操作系統和編程語言這種較爲底層的調度器之外,容器和計算任務調度在今天也很常見,Kubernetes 作爲容器編排系統會負責調取集羣中的容器,對它稍有了解的人都知道,Kubernetes 中調度的基本單元是 Pod,這些 Pod 會被調度到節點 Node 上執行:

  • 任務 —— Pod。優先級不同:Pod 的優先級可能不同,高優先級的系統 Pod 可以搶佔低優先級 Pod 的資源;有狀態:Pod 可以分爲無狀態和有狀態,有狀態的 Pod 需要依賴持久存儲卷;

  • 資源 —— Node。類型不同:不同節點上的資源類型不同,包括 CPU、GPU 和內存等,這些資源可以被拆分但是都屬於當前節點;不穩定:節點可能由於突發原因不可用,例如:無網絡連接、磁盤損壞等;

調度系統在生活和工作中都很常見,除了上述的兩個場景之外,其他需要調度系統的場景包括 CDN 的資源調度、訂單調度以及離線任務調度系統等。在不同場景中,我們都需要深入思考任務和資源的特性,它們對系統的設計起者指導作用。

調度目的

在深入分析調度場景後,我們需要理解調度的目的。我們可以將調度目的理解成機器學習中的成本函數(Cost function),確定調度目的就是確定成本函數的定義,調度理論一書中曾經介紹過常見的調度目的,包含以下內容:

  • 完成跨度(Makesapan) — 第一個到最後一個任務完成調度的時間跨度;
  • 最大延遲(Maximum Lateness) — 超過截止時間最長的任務;
  • 加權完成時間的和(Total weighted completion time)— 權重乘完成時間的總和;
  • ...

這些都是偏理論的調度的目的,多數業務調度系統的調度目的都是優化與業務聯繫緊密的指標 — 成本和質量。如何在成本和質量之間達到平衡是需要仔細思考和設計的,由於篇幅所限以及業務場景的複雜,本文不會分析如何權衡成本和質量,這往往都是需要結合業務考慮的事情,不具有足夠的相似性。

2. 調度原理

性能優異的調度器是實現特定調度目的前提,我們在討論調度場景和目的時往往都會忽略調度的額外開銷,然而調度器執行時的延時和吞吐量等指標在調度負載較重時是不可忽視的。本節會分析與調度器實現相關的一些重要概念,這些概念能夠幫助我們實現高性能的調度器:

  • 協作式調度與搶佔式調度;
  • 單調度器與多調度器;
  • 任務分享與任務竊取;

協作式與搶佔式

協作式(Cooperative)與搶佔式(Preemptive)調度是操作系統中常見的多任務運行策略。這兩種調度方法的定義完全不同:

  • 協作式調度允許任務執行任意長的時間,直到任務主動通知調度器讓出資源;
  • 搶佔式調度允許任務在執行過程中被調度器掛起,調度器會重新決定下一個運行的任務;

6.png

圖 6 - 協作式調度與搶佔式調度

任務的執行時間和任務上下文切換的額外開銷決定了哪種調度方式會帶來更好的性能。如下圖所示,圖 7 展示了一個協作式調度器調度任務的過程,調度器一旦爲某個任務分配了資源,它就會等待該任務主動釋放資源,圖中 4 個任務儘管執行時間不同,但是它們都會在任務執行完成後釋放資源,整個過程也只需要 4 次上下文的切換。

7.png

圖 7 - 協作式調度

圖 8 展示了搶佔式調度的過程,由於調度器不知道所有任務的執行時間,所以它爲每一個任務分配了一段時間切片。任務 1 和任務 4 由於執行時間較短,所以在第一次被調度時就完成了任務;但是任務 2 和任務 3 因爲執行時間較長,超過了調度器分配的上限,所以爲了保證公平性會觸發搶佔,等待隊列中的其他任務會獲得資源。在整個調度過程中,一共發生了 6 次上下文切換。

8.png

圖 8 - 搶佔式調度

如果部分任務的執行時間很長,協作式的任務調度會使部分執行時間長的任務餓死其他任務;不過如果待執行的任務執行時間較短並且幾乎相同,那麼使用協作式的任務調度能減少任務中斷帶來的額外開銷,從而帶來更好的調度性能。

因爲多數情況下任務執行的時間都不確定,在協作式調度中一旦任務沒有主動讓出資源,那麼就會導致其它任務等待和阻塞,所以調度系統一般都會以搶佔式的任務調度爲主,同時支持任務的協作式調度。

單調度器與多調度器

使用單個調度器還是多個調度器也是設計調度系統時需要仔細考慮的,多個調度器並不一定意味着多個進程,也有可能是一個進程中的多個調度線程,它們既可以選擇在多核上並行調度、在單核上併發調度,也可以同時利用並行和併發提高性能。

9.png

圖 9 - 單調度器調度任務和資源

不過對於調度系統來說,因爲它做出的決策會改變資源的狀態和系統的上下文進而影響後續的調度決策,所以單調度器的串行調度是能夠精準調度資源的唯一方法。單個調度器利用不同渠道收集調度需要的上下文,並在收到調度請求後會根據任務和資源情況做出當下最優的決策。

隨着調度器的不斷演變,單調度器的性能和吞吐量可能會受到限制,我們還是需要引入並行或者併發調度來解決性能上的瓶頸,這時我們需要將待調度的資源分區,讓多個調度器分別負責調度不同區域中的資源。

10.png

圖 10 - 多調度器與資源分區

多調度器的併發調度能夠極大提升調度器的整體性能,例如 Go 語言的調度器。Go 語言運行時會將多個 CPU 交給不同的處理器分別調度,這樣通過並行調度能夠提升調度器的性能。

上面介紹的兩種調度方法都建立在需要精準調度的前提下,多調度器中的每一個調度器都會面對無關的資源,所以對於同一個分區的資源,調度還是串行的。

11.png

圖 11 - 多調度器粗粒度調度

使用多個調度器同時調度多個資源也是可行的,只是可能需要犧牲調度的精確性 — 不同的調度器可能會在不同時間接收到狀態的更新,這就會導致不同調度器做出不同的決策。負載均衡就可以看做是多線程和多進程的調度器,因爲對任務和資源掌控的信息有限,這種粗粒度調度的結果很可能就是不同機器的負載會有較大差異,所以無論是小規模集羣還是大規模集羣都很有可能導致某些實例的負載過高。

工作分享與工作竊取

這一小節將繼續介紹在多個調度器間重新分配任務的兩個調度範式 — 工作分享(Work Sharing)和工作竊取(Work Stealing)。獨立的調度器可以同時處理所有的任務和資源,所以它不會遇到多調度器的任務和資源的不平衡問題。在多數的調度場景中,任務的執行時間都是不確定的,假設多個調度器分別調度相同的資源,由於任務的執行時間不確定,多個調度器中等待調度的任務隊列最終會發生差異 — 部分隊列中包含大量任務,而另外一些隊列不包含任務,這時就需要引入任務再分配策略。

工作分享和工作竊取是完全不同的兩種再分配策略。在工作分享中,當調度器創建了新任務時,它會將一部分任務分給其他調度器;而在工作竊取中,當調度器的資源沒有被充分利用時,它會從其他調度器中竊取一些待分配的任務,如下圖所示:

12.png

圖 12 - 工作竊取調度器

這兩種任務再分配的策略都爲系統增加了額外的開銷,與工作分享相比,工作竊取只會在當前調度器的資源沒有被充分利用時纔會觸發,所以工作竊取引入的額外開銷更小。工作竊取在生產環境中更加常用,Linux 操作系統和 Go 語言都選擇了工作竊取策略。

3. 架構設計

本節將從調度器內部和外部兩個角度分析調度器的架構設計,前者分析調度器內部多個組件的關係和做出調度決策的過程;後者分析多個調度器應該如何協作,是否有其他的外部服務可以輔助調度器做出更合理的調度決策。

調度器內部

當調度器收到待調度任務時,會根據採集到的狀態和待調度任務的規格(Spec)做出合理的調度決策,我們可以從下圖中瞭解常見調度系統的內部邏輯。

13.png

圖 13 - 調度器做出調度決策

常見的調度器一般由兩部分組成 — 用於收集狀態的狀態模塊和負責做決策的決策模塊。

  • 狀態模塊

狀態模塊會從不同途徑收集儘可能多的信息爲調度提供豐富的上下文,其中可能包括資源的屬性、利用率和可用性等信息。根據場景的不同,上下文可能需要存儲在 MySQL 等持久存儲中,一般也會在內存中緩存一份以減少調度器訪問上下文的開銷。

  • 決策模塊

決策模塊會根據狀態模塊收集的上下文和任務的規格做出調度決策,需要注意的是做出的調度決策只是在當下有效,在未來某個時間點,狀態的改變可能會導致之前做的決策不符合任務的需求,例如:當我們使用 Kubernetes 調度器將工作負載調度到某些節點上,這些節點可能由於網絡問題突然不可用,該節點上的工作負載也就不能正常工作,即調度決策失效。

調度器在調度時都會通過以下的三個步驟爲任務調度合適的資源:

  1. 通過優先級、任務創建時間等信息確定不同任務的調度順序;
  2. 通過過濾和打分兩個階段爲任務選擇合適的資源;
  3. 不存在滿足條件的資源時,選擇犧牲的搶佔對象。

14.png

圖 14 - 調度框架

上圖展示了常見調度器決策模塊執行的幾個步驟,確定優先級、對閒置資源進行打分、確定搶佔資源的犧牲者,上述三個步驟中的最後一個往往都是可選的,部分調度系統不需要支持搶佔式調度的功能。

調度器外部

如果我們將調度器看成一個整體,從調度器外部看架構設計就會得到完全不同的角度 — 如何利用外部系統增強調度器的功能。在這裏我們將介紹兩種調度器外部的設計,分別是多調度器和反調度器(Descheduler)。

  • 多調度器

串行調度與並行調度一節已經分析了多調度器的設計,我們可以將待調度的資源進行分區,讓多個調度器線程或者進程分別負責各個區域中資源的調度,充分利用多和 CPU 的並行能力。

  • 反調度器

反調度器是一個比較有趣的概念,它能夠移除決策不再正確的調度,降低系統中的熵,讓調度器根據當前的狀態重新決策。

15.png

圖 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 進程調度器的演進過程。

1. 調度系統類型

操作系統會將進程調度器分成三種不同的類型,即長期調度器、中期調度器和短期調度器。這三種不同類型的調度器分別提供了不同的功能,我們將在這一節中依次介紹它們。

長期調度器

長期調度器(Long-Term Scheduler)也被稱作任務調度器(Job Scheduler),它能夠決定哪些任務會進入調度器的準備隊列。當我們嘗試執行新的程序時,長期調度器會負責授權或者延遲該程序的執行。長期調度器的作用是平衡同時正在運行的 I/O 密集型或者 CPU 密集型進程的任務數量:

  • 如果 I/O 密集型任務過多,就緒隊列中就不存在待調度的任務,短期調度器不需要執行調度,CPU 資源就會面臨閒置;
  • 如果 CPU 密集型任務過多,I/O 等待隊列中就不存在待調度的任務,I/O 設備就會面臨閒置;

長期調度器能平衡同時正在運行的 I/O 密集型和 CPU 密集型任務,最大化的利用操作系統的 I/O 和 CPU 資源。

中期調度器

中期調度器會將不活躍的、低優先級的、發生大量頁錯誤的或者佔用大量內存的進程從內存中移除,爲其他的進程釋放資源。

16.png

圖 16 - 中期調度器

當正在運行的進程陷入 I/O 操作時,該進程只會佔用計算資源,在這種情況下,中期調度器就會將它從內存中移除等待 I/O 操作完成後,該進程會重新加入就緒隊列並等待短期調度器的調度。

短期調度器

短期調度器應該是我們最熟悉的調度器,它會從就緒隊列中選出一個進程執行。進程的選擇會使用特定的調度算法,它會同時考慮進程的優先級、入隊時間等特徵。因爲每個進程能夠得到的執行時間有限,所以短期調度器的執行十分頻繁。

2. 設計與演進

本節將重點介紹 Linux 的 CPU 調度器,也就是短期調度器。Linux 的 CPU 調度器並不是從設計之初就是像今天這樣複雜的,在很長的一段時間裏(v0.01 ~ v2.4),Linux 的進程調度都由幾十行的簡單函數負責,我們先了解一下不同版本調度器的歷史:

  • 初始調度器 · v0.01 ~ v2.4。由幾十行代碼實現,功能非常簡陋;同時最多處理 64 個任務;

  • 調度器 · v2.4 ~ v2.6。調度時需要遍歷全部任務當待執行的任務較多時,同一個任務兩次執行的間隔很長,會有比較嚴重的飢餓問題;

  • 調度器 · v2.6.0 ~ v2.6.22。通過引入運行隊列和優先數組實現  的時間複雜度;使用本地運行隊列替代全局運行隊列增強在對稱多處理器的擴展性;引入工作竊取保證多個運行隊列中任務的平衡;

  • 完全公平調度器 · v2.6.23 ~ 至今。引入紅黑樹和運行時間保證調度的公平性;引入調度類實現不同任務類型的不同調度策略;

這裏會詳細介紹從最初的調度器到今天覆雜的完全公平調度器(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.png

圖 17 - 最初的進程調度器

上述函數會先喚醒獲得信號的可中斷進程,然後從隊列倒序查找計數器 counter 最大的可執行進程,counter 是進程能夠佔用的時間切片數量,該函數會根據時間切片的值執行不同的邏輯:

  • 如果最大的 counter 時間切片大於 0,調用彙編語言的實現的 switch_to 切換進程;
  • 如果最大的 counter 時間切片等於 0,意味着所有進程的可執行時間都爲 0,那麼所有進程都會獲得新的時間切片;

Linux 操作系統的計時器會每隔 10ms 觸發一次 do_timer 將當前正在運行進程的 counter 減一,當前進程的計數器歸零時就會重新觸發調度。

O(n)調度器

 調度器是 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 左右的時間切片,然而這種調度和分配方式是  調度器的最大問題:

  • 每輪調度完成之後就會陷入沒有任務需要調度的情況,需要提升交互性能的場景會受到嚴重影響,例如:在桌面拖動鼠標會感覺到明顯的卡頓;
  • 每次查找權重最高的任務都需要遍歷數組中的全部任務;
  • 調度器分配的平均時間片大小爲 210ms,當程序中包含 100 個進程時,同一個進程被運行兩次的間隔是 21s,這嚴重影響了操作系統的可用性.

正是因爲調度器存在了上述的問題,所以 Linux 內核在兩個版本後使用新的  調度器替換該實現。

O(1)調度器

調度器在 v2.6.0 到 v2.6.22 的 Linux 內核中使用了四年的時間,它能夠在常數時間內完成進程調度,你可以在sched.h 和 sched.c 中查看  調度器的源代碼。因爲實現和功能複雜性的增加,調度器的代碼行數從  的 2100 行增加到 5000 行,它在調度器的基礎上進行了如下的改進

  • 調度器支持了  時間複雜度的調度;
  • 調度器支持了對稱多處理(Symmetric multiprocessing,SMP)的擴展性;
  • 調度器優化了對稱多處理的親和性。

數據結構

調度器通過運行隊列 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.png

圖 18 - 優先數組

優先數組的 bitmap 總共包含 140 位,每一位都表示對應優先級的進程是否存在。圖 17 中的優先數組包含 3 個優先級爲 2 的進程和 1 個優先級爲 5 的進程。每一個優先級的標誌位都對應一個 list_head 數組中的鏈表。 調度器使用上述的數據結構進行如下所示的調度:

  • 調用 sched_find_first_bit 按照優先級分配 CPU 資源;
  • 調用 schedule 從鏈表頭選擇進程執行;
  • 通過 schedule 輪訓調度同一優先級的進程,該函數在每次選中待執行的進程後,將進程添加到隊列的末尾,這樣可以保證同一優先級的進程會依次執行(Round-Robin);
  • 計時器每隔 1ms 會觸發一次 scheduler_tick 函數,如果當前進程的執行時間已經耗盡,就會將其移入過期數組;
  • 當活躍隊列中不存在待運行的進程時,schedule 會交換活躍優先數組和過期優先數組;

上述的這些規則是  調度器運行遵守的主要規則,除了上述規則之外,調度器還需要支持搶佔、CPU 親和等功能,不過在這裏就不展開介紹了。

本地運行隊列

全局的運行隊列是  調度器難以在對稱多處理器架構上擴展的主要原因。爲了保證運行隊列的一致性,調度器在調度時需要獲取運行隊列的全局鎖,隨着處理器數量的增加,多個處理器在調度時會導致更多的鎖競爭,嚴重影響調度性能。 調度器通過引入本地運行隊列解決這個問題,不同的 CPU 可以通過 this_rq 獲取綁定在當前 CPU 上的運行隊列,降低了鎖的粒度和衝突的可能性。

#define this_rq()        (&__get_cpu_var(runqueues))

19.png

圖 19 - 全局運行隊列和本地運行隊列

多個處理器由於不再需要共享全局的運行隊列,所以增強了在對稱對處理器架構上的擴展性,當我們增加新的處理器時,只需要增加新的運行隊列,這種方式不會引入更多的鎖衝突。

優先級和時間切片

調度器中包含兩種不同的優先級計算方式,一種是靜態任務優先級,另一種是動態任務優先級。在默認情況下,任務的靜態任務優先級都是 0,不過我們可以通過系統調用 nice 改變任務的優先級; 調度器會獎勵 I/O 密集型任務並懲罰 CPU 密集型任務,它會通過改變任務的靜態優先級來完成優先級的動態調整,因爲與用戶交互的進程時 I/O 密集型的進程,這些進程由於調度器的動態策略會提高自身的優先級,從而提升用戶體驗。

完全公平調度器

完全公平調度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入內核的調度器,也是內核的默認進程調度器,它的目的是最大化 CPU 利用率和交互的性能。Linux 內核版本 v2.6.23 中的 CFS 由以下的多個文件組成:

  • include/linux/sched.h
  • kernel/sched_stats.h
  • kernel/sched.c
  • kernel/sched_fair.c
  • kernel/sched_idletask.c
  • kernel/sched_rt.c

通過 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 函數完成的,該函數的執行過程可以分成以下幾個步驟:

  • 關閉當前 CPU 的搶佔功能;
  • 如果當前 CPU 的運行隊列中不存在任務,調用 idle_balance 從其他 CPU 的運行隊列中取一部分執行;
  • 調用 pick_next_task 選擇紅黑樹中優先級最高的任務;
  • 調用 context_switch 切換運行的上下文,包括寄存器的狀態和堆棧;
  • 重新開啓當前 CPU 的搶佔功能。

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 中的函數以提供不同的調度行爲。

3. 小結

本節介紹了操作系統調度器的設計原理以及演進的歷史,從 2007 年合入 CFS 到現在已經過去了很長時間,目前的調度器也變得更加複雜,社區也在不斷改進進程調度器。

我們可以從 Linux 調度器的演進的過程看到主流系統架構的變化,最初幾十行代碼的調度器就能完成基本的調度功能,而現在要使用幾萬行代碼來完成複雜的調度,保證系統的低延時和高吞吐量。

由於篇幅有限,我們很難對操作系統的調度器進行面面俱到的分析,你可以在 這裏 找到作者使用的 Linux 源代碼,親自動手分析不同版本的進程調度器。

4. 延伸閱讀

Go 語言

Go 語言是誕生自 2009 年的編程語言,相信很多人對 Go 語言的印象都是語法簡單,能夠支撐高併發的服務。語法簡單是編程語言的頂層設計哲學,而語言的高併發支持依靠的是運行時的調度器,這也是本節將要研究的內容。

對 Go 語言稍微有了解的人都知道,通信順序進程(Communicating sequential processes,CSP)影響着 Go 語言的併發模型,其中的 Goroutine 和 Channel 分別表示實體和用於通信的媒介。

20.png

圖 20 - Go 和 Erlang 的併發模型

『不要通過共享內存來通信,我們應該使用通信來共享內存』不只是 Go 語言鼓勵的設計哲學,更爲古老的 Erlang 語言其實也遵循了同樣的設計,但是 Erlang 選擇使用了Actor 模型,我們在這裏就不介紹 CSP 和 Actor 的區別和聯繫的,感興趣的讀者可以在推薦閱讀和應引用中找到相關資源。

1. 設計與演進

今天的 Go 語言調度器有着非常優異的性能,但是如果我們回過頭重新看 Go 語言的 v0.x 版本的調度器就會發現最初的調度器非常簡陋,也無法支撐高併發的服務。整個調度器經過幾個大版本的迭代纔有了今天的優異性能。

  • 單線程調度器 · 0.x - 源代碼。只包含 40 多行代碼;只能單線程調度,由 G-M 模型組成;
  • 多線程調度器 · 1.0 - 源代碼。引入了多線程調度;全局鎖導致競爭嚴重;
  • 任務竊取調度器 · 1.1 - 源代碼。引入了處理器 P,構成了目前的 G-M-P 模型;在處理器 P 的基礎上實現了基於工作竊取的調度器;在某些情況下,Goroutine 不會讓出線程造成飢餓問題;時間過長的程序暫停(Stop-the-world,STW)會導致程序無法工作;
  • 搶佔式調度器 · 1.2 ~ 至今 - 源代碼。實現基於信號的真搶佔式調度;垃圾回收對棧進行掃描時會觸發搶佔調度;搶佔的時間點不夠多,還不能覆蓋全部的邊緣情況;通過編譯器在函數調用時插入檢查指令,實現基於協作的搶佔式調度;GC 和循環可能會導致 Goroutine 長時間佔用資源導致程序暫停;協作的搶佔式調度器 - 1.2 ~ 1.13;搶佔式調度器 - 1.14 ~ 至今;
  • 非均勻存儲訪問調度器 · 提案。對運行時中的各種資源進行分區;實現非常複雜,到今天還沒有提上日程;

除了多線程、任務竊取和搶佔式調度器之外,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);
}

該函數會遵循如下所示的過程執行:

  • 獲取調度器的全局鎖;
  • 調用 gosave 保存棧寄存器和程序計數器;
  • 調用 nextgandunlock 獲取下一個線程 M 需要運行的 Goroutine 並解鎖調度器;
  • 修改全局線程 m 上要執行的 Goroutine;
  • 調用 gogo 函數運行最新的 Goroutine。

這個單線程調度器的唯一優點就是能跑,不過從這次提交中我們能看到 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 函數上,目前的調度器實現有以下問題需要解決:

  • 全局唯一的調度器和全局鎖,所有的調度狀態都是中心化存儲的,帶來了鎖競爭;
  • 線程需要經常互相傳遞可運行的 Goroutine,引入了大量的延遲和額外開銷;
  • 每個線程都需要處理內存緩存,導致大量的內存佔用並影響數據局部性(Data locality);
  • 系統調用頻繁阻塞和解除阻塞正在運行的線程,增加了額外開銷。

這裏的全局鎖問題和 Linux 操作系統調度器在早期遇到的問題比較相似,解決方案也都大同小異。

任務竊取調度器

2012 年 Google 的工程師 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了現有多線程調度器的問題並在多線程調度器上提出了兩個改進的手段:

  • 在當前的 G-M 模型中引入了處理器 P;
  • 在處理器 P 的基礎上實現基於工作竊取的調度器。

基於任務竊取的 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);
}
  • 如果當前運行時在等待垃圾回收,調用 gcstopm 函數;
  • 調用 runqget 和 findrunnable 從本地的或者全局的運行隊列中獲取待執行的 Goroutine;
  • 調用 execute 函數在當前線程 M 上運行 Goroutine。

當前處理器本地的運行隊列中不包含 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.png

圖 21 - G-M-P 模型

基於工作竊取的多線程調度器將每一個線程綁定到了獨立的 CPU 上並通過不同處理器分別管理,不同處理器中通過工作竊取對任務進行再分配,提升了調度器和 Go 語言程序的整體性能,今天所有的 Go 語言服務的高性能都受益於這一改動。

搶佔式調度器

對 Go 語言併發模型的修改提升了調度器的性能,但是在 1.1 版本中的調度器仍然不支持搶佔式調度,程序只能依靠 Goroutine 主動讓出 CPU 資源。Go 語言的調度器在1.2 版本中引入了基於協作的搶佔式調度解決下面的問題

  • 單獨的 Goroutine 可以一直佔用線程運行,不會切換到其他的 Goroutine,造成飢餓問題;
  • 垃圾回收需要暫停整個程序(Stop-the-world,STW),如果沒有搶佔可能需要等待幾分鐘的時間,導致整個程序無法工作。

然而 1.2 版本中實現的搶佔式調度是基於協作的,在很長的一段時間裏 Go 語言的調度器都包含一些無法被搶佔的邊緣情況,直到 1.14 才實現了基於信號的真搶佔式調度解決部分問題。

基於協作的搶佔式調度

我們可以在 proc.c 文件中找到引入搶佔式調度後的調度器實現。Go 語言會在當前的分段棧機制上實現搶佔式的調度,所有的 Goroutine 在函數調用時都有機會進入運行時檢查是否需要執行搶佔。基於協作的搶佔是通過以下的多個提交實現的:

  • runtime: mark runtime.goexit as nosplit
  • runtime: add stackguard0 to G。爲 Goroutine 引入 stackguard0 字段,當該字段被設置成 StackPreempt 時,Goroutine 會被搶佔;
  • runtime: introduce preemption function (not used for now)。引入搶佔函數 preemptone 和 preemptall,這兩個函數會設置 Goroutine 的 StackPreempt;引入搶佔請求 StackPreempt;
  • runtime: preempt goroutines for GC。在垃圾回收調用的 runtime·stoptheworld 中調用 preemptall 函數設置所有處理器上 Goroutine 的 StackPreempt;在 runtime·newstack 函數中增加搶佔的代碼,當 stackguard0 等於 StackPreempt 時觸發調度器的搶佔;
  • runtime: preempt long-running goroutines。在系統監控中,如果一個 Goroutine 的運行時間超過 10ms,就會調用 retake 和 preemptone;
  • runtime: more reliable preemption。修復 Goroutine 因爲週期性執行非阻塞的 CGO 或者系統調用不會被搶佔的問題。

從上述一系列的提交中,我們會發現 Go 語言運行時會在垃圾回收暫停程序、系統監控發現 Goroutine 運行超過 10ms 時提出搶佔請求 StackPreempt;因爲編譯器會在函數調用中插入 runtime.newstack,所以函數調用時會通過 runtime.newstack 檢查 Goroutine 的 stackguard0 是否爲 StackPreempt 進而觸發搶佔讓出當前線程。

這種做法沒有帶來運行時的過多額外開銷,實現也相對比較簡單,不過增加了運行時的複雜度,總體來看還是一種比較成功的實現。因爲上述的搶佔是通過編譯器在特定時機插入函數實現的,還是需要函數調用作爲入口才能觸發搶佔,所以這是一種協作式的搶佔式調度。

基於信號的搶佔式調度

協作的搶佔式調度實現雖然巧妙,但是留下了很多的邊緣情況,我們能在 runtime: non-cooperative goroutine preemption 中找到一些遺留問題:

  • runtime: tight loops should be preemptible #10958
  • An empty for{} will block large slice allocation in another goroutine, even with GOMAXPROCS > 1 ? #17174
  • runtime: tight loop hangs process completely after some time #15442
  • ...

Go 語言在 1.14 版本中實現了非協作的搶佔式調度,在實現的過程中我們對已有的邏輯進行重構併爲 Goroutine 增加新的狀態和字段來支持搶佔。Go 團隊通過下面提交的實現了這一功能,我們可以順着提交的順序理解其實現原理:

  • runtime: add general suspendG/resumeG。掛起 Goroutine 的過程是在棧掃描時完成的,我們通過 runtime.suspendG 和 runtime.resumeG 兩個函數重構棧掃描這一過程;調用 runtime.suspendG 函數時會將運行狀態的 Goroutine 的 preemptStop 標記成 true;調用 runtime.preemptPark 函數可以掛起當前 Goroutine、將其狀態更新成 _Gpreempted 並觸發調度器的重新調度,該函數能夠交出線程控制權;
  • runtime: asynchronous preemption function for x86。在 x86 架構上增加異步搶佔的函數 runtime.asyncPreempt 和 runtime.asyncPreempt2;
  • runtime: use signals to preempt Gs for suspendG。支持通過向線程發送信號的方式暫停運行的 Goroutine;在 runtime.sighandler 函數中註冊了 SIGURG 信號的處理函數 runtime.doSigPreempt;runtime.preemptM 函數可以向線程發送搶佔請求;
  • runtime: implement async scheduler preemption。修改 runtime.preemptone 函數的實現,加入異步搶佔的邏輯。

目前的搶佔式調度也只會在垃圾回收掃描任務時觸發,我們可以梳理一下觸發搶佔式調度的過程:

  • 程序啓動時,在 runtime.sighandler 函數中註冊了 SIGURG 信號的處理函數 runtime.doSigPreempt;
  • 在觸發垃圾回收的棧掃描時會調用 runtime.suspendG 函數掛起 Goroutine。將 _Grunning 狀態的 Goroutine 標記成可以被搶佔,即 preemptStop 設置成 true;調用 runtime.preemptM 函數觸發搶佔;
  • runtime.preemptM 函數會調用 runtime.signalM 向線程發送信號 SIGURG;
  • 操作系統會中斷正在運行的線程並執行預先註冊的信號處理函數 runtime.doSigPreempt;
  • runtime.doSigPreempt 函數會處理搶佔信號,獲取當前的 SP 和 PC 寄存器並調用 runtime.sigctxt.pushCall;
  • runtime.sigctxt.pushCall 會修改寄存器並在程序回到用戶態時從 runtime.asyncPreempt 開始執行;
  • 彙編指令 runtime.asyncPreempt 會調用運行時函數 runtime.asyncPreempt2;
  • runtime.asyncPreempt2 會調用 runtime.preemptPark 函數;
  • runtime.preemptPark 會修改當前 Goroutine 的狀態到 _Gpreempted 並調用 runtime.schedule 讓當前函數陷入休眠並讓出線程,調度器會選擇其他的 Goroutine 繼續執行;

上述 9 個步驟展示了基於信號的搶佔式調度的執行過程。我們還需要討論一下該過程中信號的選擇,提案根據以下的四個原因選擇 SIGURG 作爲觸發異步搶佔的信號

  • 該信號需要被調試器透傳;
  • 該信號不會被內部的 libc 庫使用並攔截;
  • 該信號可以隨意出現並且不觸發任何後果;
  • 我們需要處理多個平臺上的不同信號。

目前的搶佔式調度也沒有解決所有潛在的問題,因爲 STW 和棧掃描時更可能出現問題,也是一個可以搶佔的安全點(Safe-points),所以我們會在這裏先加入搶佔功能,在未來可能會加入更多搶佔時間點。

非均勻內存訪問調度器

非均勻內存訪問(Non-uniform memory access,NUMA)調度器目前只是 Go 語言的提案,因爲該提案過於複雜,而目前的調度器的性能已經足夠優異,所以暫時沒有實現該提案。該提案的原理就是通過拆分全局資源,讓各個處理器能夠就近獲取本地資源,減少鎖競爭並增加數據局部性。

在目前的運行時中,線程、處理器、網絡輪訓器、運行隊列、全局內存分配器狀態、內存分配緩存和垃圾收集器都是全局的資源。運行時沒有保證本地化,也不清楚系統的拓撲結構,部分結構可以提供一定的局部性,但是從全局來看沒有這種保證。

22.png

圖 22 - Go 語言 NUMA 調度器

如上圖所示,堆棧、全局運行隊列和線程池會按照 NUMA 節點進行分區,網絡輪訓器和計時器會由單獨的處理器持有。這種方式雖然能夠利用局部性提高調度器的性能,但是本身的實現過於複雜,所以 Go 語言團隊還沒有着手實現這一提案。

2. 小結

Go 語言的調度器在最初的幾個版本中迅速迭代,但是從 1.2 版本之後調度器就沒有太多的變化,直到 1.14 版本引入了真正的搶佔式調度解決了自 1.2 以來一直存在的問題。在可預見的未來,Go 語言的調度器還會進一步演進,增加搶佔式調度的時間點減少存在的邊緣情況。

本節內容選擇《Go 語言設計與實現》一書中的 Go 語言調度器實現原理,你可以點擊鏈接瞭解更多與 Go 語言設計與實現原理相關的內容。

3. 延伸閱讀

Kubernetes

Kubernetes 是生產級別的容器調度和管理系統,在過去的一段時間中,Kubernetes 迅速佔領市場,成爲容器編排領域的實施標準。

23.png

圖 23 - 容器編排系統演進

Kubernetes 是希臘語『舵手』的意思,它最開始由 Google 的幾位軟件工程師創立,深受公司內部Borg 和 Omega 項目的影響,很多設計都是從 Borg 中借鑑的,同時也對 Borg 的缺陷進行了改進,Kubernetes 目前是 Cloud Native Computing Foundation (CNCF) 的項目,也是很多公司管理分佈式系統的解決方案

調度器是 Kubernetes 的核心組件,它的主要功能是爲待運行的工作負載 Pod 綁定運行的節點 Node。與其他調度場景不同,雖然資源利用率在 Kubernetes 中也非常重要,但是這只是 Kubernetes 關注的一個因素,它需要在容器編排這個場景中支持非常多並且複雜的業務需求,除了考慮 CPU 和內存是否充足,還需要考慮其他的領域特定場景,例如:兩個服務不能佔用同一臺機器的相同端口、幾個服務要運行在同一臺機器上,根據節點的類型調度資源等。

這些複雜的業務場景和調度需求使 Kubernetes 調度器的內部設計與其他調度器完全不同,但是作爲用戶應用層的調度器,我們卻能從中學到很多有用的模式和設計。接下來,本節將介紹 Kubernetes 中調度器的設計以及演變。

1. 設計與演進

Kubernetes 調度器的演變過程比較簡單,我們可以將它的演進過程分成以下的兩個階段:

  • 基於謂詞和優先級的調度器 · v1.0.0 ~ v1.14.0
  • 基於調度框架的調度器 · v1.15.0 ~ 至今

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 期間也引入了很多改進:

  • 調度器擴展 · v1.2.0 - Scheduler extension。通過調用外部調度器擴展的方式改變調度器的決策;

  • Map-Reduce 優先級算法 · v1.5.0 - MapReduce-like scheduler priority functions。爲調度器的優先級算法支持 Map-Reduce 的計算方式,通過引入可並行的 Map 階段優化調度器的計算性能;

  • 調度器遷移 · v1.10.0 - Move scheduler code out of plugin directory。從 plugin/pkg/scheduler 移到 pkg/scheduler;kube-scheduler 成爲對外直接提供的可執行文件;

謂詞和優先級都是 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.png

圖 24 - 謂詞和優先級算法

如上圖所示,我們假設調度器中存在一個謂詞算法和一個 Map-Reduce 優先級算法,當我們爲一個 Pod 在 6 個節點中選擇最合適的一個時,6 個節點會先經過謂詞的篩選,圖中的謂詞算法會過濾掉一半的節點,剩餘的 3 個節點經過 Map 和 Reduce 兩個過程分別得到了 5、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
}
  • 從 NodeLister 中獲取當前系統中存在的全部節點;
  • 調用 genericScheduler.findNodesThatFit 方法並行執行全部的謂詞算法過濾節點。謂詞算法會根據傳入的 Pod 和 Node 對節點進行過濾,這時會過濾掉端口號衝突、資源不足的節點;調用所有調度器擴展的 Filter 方法輔助過濾;
  • 調用 PrioritizeNodes 函數爲所有的節點打分。以 Pod 和 Node 作爲參數併發執行同一優先級的 PriorityMapFunction;Pod 和優先級返回的 Node 到分數的映射爲參數調用 PriorityReduceFunction 函數;調用所有調度器擴展的 Prioritize 方法;將所有分數按照權重相加後返回從 Node 到分數的映射;
  • 調用 genericScheduler.selectHost 方法選擇得分最高的節點。

這就是使用謂詞和優先級算法時的調度過程,我們在這裏省略了調度器的優先隊列中的排序,出現調度錯誤時的搶佔以及 Pod 持久存儲卷綁定到 Node 上的過程,只保留了核心的調度邏輯。

調度框架

Kubernetes 調度框架是 Babak Salamat 和 Jonathan Basseri 2018 年提出的最新調度器設計,這個提案明確了 Kubernetes 中的各個調度階段,提供了設計良好的基於插件的接口。調度框架認爲 Kubernetes 中目前存在調度(Scheduling)和綁定(Binding)兩個循環:

  • 調度循環在多個 Node 中爲 Pod 選擇最合適的 Node;
  • 綁定循環將調度決策應用到集羣中,包括綁定 Pod 和 Node、綁定持久存儲等工作。

除了兩個大循環之外,調度框架中還包含 QueueSort、PreFilter、Filter、PostFilter、Score、Reserve、Permit、PreBind、Bind、PostBind 和 Unreserve 11 個擴展點(Extension Point),這些擴展點會在調度的過程中觸發,它們的運行順序如下:

25.png

圖 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
    }
    ...
}
  • 調用內部優先隊列的 MakeNextPodFunc 返回的函數從隊列中獲取下一個等待調度的 Pod,用於維護等待 Pod 的隊列會執行 QueueSort 插件;
  • 調用 genericScheduler.Schedule 函數選擇節點,該過程會執行 PreFilter、Filter、PostFilter、Score 四個擴展點的插件;
  • 調用 framework.RunReservePlugins 函數運行 Reserve 插件用於保留資源並進入綁定階段(綁定階段運行時間較長,避免資源被搶佔)。如果運行失敗執行,調用 framework.RunUnreservePlugins 函數運行 Unreserve 插件。

因爲每一次調度決策都會改變上下文,所以該階段 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)
        }
    }()
}
  • 啓動一個 Goroutine 並調用 framework.RunPermitPlugin 異步運行 Permit 插件,這個階段可以用來實現批調度器;
  • 調用 Scheduler.bindVolumes 將卷先綁定到 Node 上;
  • 調用 Scheduler.bind 函數將 Pod 綁定到 Node 上完成調度,綁定的過程會執行 PreBind、Bind 和 PostBind 三個擴展點的插件。

目前的調度框架在 Kubernetes v1.17.0 版本中還是 Alpha 階段,很多功能還不明確,爲了支持更多、更豐富的場景,在接下來的幾個版本還可能會做出很多改進,不過調度框架在很長的一段時間中都會是調度器的核心。

2. 小結

本節介紹了 Kubernetes 調度器從 v1.0.0 到最新版本中的不同設計,Kubernetes 調度器中總共存在兩種不同的設計,一種是基於謂詞和優先級算法的調度器,另一種是基於調度框架的調度器。

很多的業務調度器也需要從多個選項中選出最優的選擇,無論是成本最低還是質量最優,我們可以考慮將調度的過程分成過濾和打分兩個階段爲調度器建立合適的抽象,過濾階段會按照需求過濾掉不滿足需求的選項,打分階段可能會按照質量、成本和權重對多個選項進行排序,遵循這種設計思路可以解決很多類似問題。

目前的 Kubernetes 已經通過調度框架詳細地支持了多個階段的擴展方法,幾乎是調度器內部實現的最終形態了。不過隨着調度器功能的逐漸複雜,未來可能還會遇到更復雜的調度場景,例如:多租戶的調度資源隔離、多調度器等功能,而 Kubernetes 社區也一直都在爲構建高性能的調度器而努力。

3. 延伸閱讀

總結

從操作系統、編程語言到應用程序,我們在這篇文章中分析了 Linux、Go 語言和 Kubernetes 調度器的設計與實現原理,這三個不同的調度器其實有相互依賴的關係:

26.png

圖 26 - 三層調度器

如上圖所示,Kubernetes 的調度器依賴於 Go 語言的運行時調度器,而 Go 語言的運行時調度器也依賴於 Linux 的進程調度器,從上到下離用戶越來越遠,從下到上越來越關注具體業務。我們在最後通過兩個比較分析一下這幾個調度器的異同:

  • Linux 進程調度器與 Go 語言調度器;
  • 系統級調度器(Linux 和 Go)與業務調度器(Kubernetes)。

這是兩種不同層面的比較,相信通過不同角度的比較能夠讓我們對調度器的設計有更深入的認識。

1. Linux 和 Go

首先是 Linux 和 Go 語言調度器,這兩個調度器的場景都非常相似,它們最終都是要充分利用機器上的 CPU 資源,所以在實現和演進上有很多相似之處:

  • 調度器的初始版本都非常簡單,甚至很簡陋,只能支持協作式的調度;
  • 按照運行隊列進行分區,通過工作竊取的方式平衡不同 CPU 或者線程上的運行隊列;
  • 最終都通過某些方式實現了基於信號的搶佔式調度,不過 Go 語言的實現並不完善。

因爲場景非常相似,所以它們的目的也非常相似,只是它們調度的任務粒度會有不同,Linux 進程調度器的最小調度單位是線程,而 Go 語言是 Goroutine,與 Linux 進程調度器相比,Go 語言在用戶層建立新的模型,實現了另一個調度器,爲使用者提供輕量級的調度單位來增強程序的性能,但是它也引入了很多組件來處理系統調用、網絡輪訓等線程相關的操作,同時組合多個不同粒度的任務導致實現相對複雜。

Linux 調度器的最終設計引入了調度類的概念,讓不同任務的類型分別享受不同的調度策略以此來調和低延時和實時性這個在調度上兩難的問題。

Go 語言的調度器目前剛剛引入了基於信號的搶佔式調度,還有很多功能都不完善。除了搶佔式調度之外,複雜的 NUMA 調度器提案也可能是未來 Go 語言的發展方向。

2. 系統和業務

如果我們將系統調度器和業務調度器進行對比的話,你會發現兩者在設計差別非常大,畢竟它們處於系統的不同層級。系統調度器考慮的是極致的性能,所以它通過分區的方式將運行隊列等資源分離,通過降低鎖的粒度來降低系統的延遲;而業務調度器關注的是完善的調度功能,調度的性能雖然十分重要,但是一定要建立在滿足特定調度需求之上,而因爲業務上的調度需求往往都是比較複雜,所以只能做出權衡和取捨。

正是因爲需求的不同,我們會發現不同調度器的演進過程也完全不同。系統調度器都會先充分利用資源,降低系統延時,隨後在性能無法優化時才考慮加入調度類等功能滿足不同場景下的調度,而 Kubernetes 調度器更關注內部不同調度算法的組織,如何同時維護多個複雜的調度算法,當設計了良好的抽象之後,它纔會考慮更加複雜的多調度器、多租戶等場景。

3. 最後

這種研究歷史變化帶來的快樂是很不同的,當我們發現代碼發生變化的原因時也會感到欣喜,這讓我們站在今天重新見證了歷史上的決策,本文中的相應章節已經包含了對應源代碼的鏈接,各位讀者可以自行閱讀相應內容,也衷心希望各位讀者能夠有所收穫。<br />

延伸閱讀

雲原生網絡研討會邀您參加

27.png

阿里巴巴雲原生關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的技術圈。”

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