調度系統設計精要 & 內存管理設計精要

系統設計精要是一系列深入研究系統設計方法的系列文章,文中不僅會分析系統設計的理論,還會分析多個實際場景下的具體實現。這是一個季更或者半年更的系列,如果你有想要了解的問題,可以在文章下面留言。

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

圖 1 - 調度系統設計精要

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

圖片
mind-node

圖 2 - 文章脈絡和內容

 

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

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

設計原理

調度系統其實就是調度器(Scheduler),我們在很多系統中都能見到調度器的身影,就像我們在上面說的,不止操作系統中存在調度器,編程語言、容器編排以及很多業務系統中都會存在調度系統或者調度模塊。這些調度模塊的核心作用就是對有限的資源進行分配以實現最大化資源的利用率或者降低系統的尾延遲,調度系統面對的就是資源的需求和供給不平衡的問題。

圖片
scheduler-works-and-resources

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

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

需求調研

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

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

應用場景

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

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

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

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

圖片
linux-banner

圖 4 - Linux 操作系統

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

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

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

圖片
kubernetes-banner

圖 5 - 容器編排系統 Kubernetes

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

  • 任務 —— Pod
    • 優先級不同:Pod 的優先級可能不同,高優先級的系統 Pod 可以搶佔低優先級 Pod 的資源;
    • 有狀態:Pod 可以分爲無狀態和有狀態,有狀態的 Pod 需要依賴持久存儲卷;
  • 資源 —— Node
    • 類型不同:不同節點上的資源類型不同,包括 CPU、GPU 和內存等,這些資源可以被拆分但是都屬於當前節點;
    • 不穩定:節點可能由於突發原因不可用,例如:無網絡連接、磁盤損壞等;

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

調度目的

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

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

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

調度原理

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

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

協作式與搶佔式

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

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

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

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

圖片
cooperative-scheduling

圖 7 - 協作式調度

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

圖片
preemptive-scheduling

圖 8 - 搶佔式調度

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

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

單調度器與多調度器

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

圖片
single-schedule

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

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

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

圖片
multi-scheduler

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

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

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

圖片
multi-scheduler-with-coarse-grained

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

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

工作分享與工作竊取

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

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

圖片
work-stealing-scheduler

圖 12 - 工作竊取調度器

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

架構設計

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

調度器內部

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

圖片
how-scheduler-works

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

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

狀態模塊

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

決策模塊

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

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

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

圖 14 - 調度框架

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

調度器外部

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

多調度器

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

反調度器

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

圖片
scheduler-and-descheduler

圖 15 - 調度器與反調度器

反調度器的引入使得整個調度系統變得更加健壯。調度器負責根據當前的狀態做出正確的調度決策,反調度器根據當前的狀態移除錯誤的調度決策,它們的作用看起來相反,但是目的都是爲任務調度更合適的資源。

反調度器的使用沒有那麼廣泛,實際的應用場景也比較有限。作者第一次發現這個概念是在 Kubernetes 孵化的 descheduler 項目[^4]中,不過因爲反調度器移除調度關係可能會影響正在運行的線上服務,所以 Kubernetes 也只會在特定場景下使用。

操作系統

調度器是操作系統中的重要組件,操作系統中有進程調度器、網絡調度器和 I/O 調度器等組件,本節介紹的是操作系統中的進程調度器。

有一些讀者可能會感到困惑,操作系統調度的最小單位不是線程麼,爲什麼這裏使用的是進程調度。在 Linux 操作系統中,調度器調度的不是進程也不是線程,它調度的是 task_struct 結構體,該結構體既可以表示線程,也可以表示進程,而調度器會將進程和線程都看成任務,我們在這裏先說明這一問題,避免讀者感到困惑[^5]。我們會使用進程調度器這個術語,但是一定要注意 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 資源就會面臨閒置;
  • 如果 CPU 密集型任務過多,I/O 等待隊列中就不存在待調度的任務,I/O 設備就會面臨閒置;

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

中期調度器

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

圖片
mid-term-scheduler

圖 16 - 中期調度器

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

短期調度器

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

設計與演進

本節將重點介紹 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 函數負責了操作系統進程的調度[^6]:

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 個。

圖片
linux-initial-scheduler

圖 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 函數分別計算它們的權重獲得下一個運行的進程[^7]:

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[^8],當程序中包含 100 個進程時,同一個進程被運行兩次的間隔是 21s,這嚴重影響了操作系統的可用性;

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

O(1) 調度器

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

  • 調度器支持了  時間複雜度的調度;
  • 調度器支持了對稱多處理(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 共同組成了如下圖所示的數據結構:

圖片
runqueue-and-prio-array

圖 18 - 優先數組

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

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

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

本地運行隊列

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

#define this_rq()		(&__get_cpu_var(runqueues))
圖片
global-runqueue-and-local-runqueue

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

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

優先級和時間切片

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

完全公平調度器

完全公平調度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入內核的調度器,也是內核的默認進程調度器,它的目的是最大化 CPU 利用率和交互的性能[^10]。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 的名字我們就能發現,該調度器的能爲不同的進程提供完全公平性。一旦某些進程受到了不公平的待遇,調度器就會運行這些進程,從而維持所有進程運行時間的公平性。這種保證公平性的方式與『水多了加面,面多了加水』有一些相似:

  1. 調度器會查找運行隊列中受到最不公平待遇的進程,併爲進程分配計算資源,分配的計算資源是與其他資源運行時間的差值加上最小能夠運行的時間單位;
  2. 進程運行結束之後發現運行隊列中又有了其他的進程受到了最不公平的待遇,調度器又會運行新的進程;
  3. ...

調度器算法不斷計算各個進程的運行時間並依次調度隊列中的受到最不公平對待的進程,保證各個進程的運行時間差不會大於最小運行的時間單位。

數據結構

雖然我們還是會延用運行隊列這一術語,但是 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)是平衡的二叉搜索樹[^11],紅黑樹的增刪改查操作的最壞時間複雜度爲 ,也就是樹的高度,樹中最左側的節點 rb_leftmost 運行的時間最短,也是下一個待運行的進程。

注:在最新版本的 CFS 實現中,內核使用虛擬運行時間 vruntime 替代了等待時間,但是基本的調度原理和排序方式沒有太多變化。

調度過程

CFS 的調度過程還是由 schedule 函數完成的,該函數的執行過程可以分成以下幾個步驟:

  1. 關閉當前 CPU 的搶佔功能;
  2. 如果當前 CPU 的運行隊列中不存在任務,調用 idle_balance 從其他 CPU 的運行隊列中取一部分執行;
  3. 調用 pick_next_task 選擇紅黑樹中優先級最高的任務;
  4. 調用 context_switch 切換運行的上下文,包括寄存器的狀態和堆棧;
  5. 重新開啓當前 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_NORMALSCHED_BATCHSCHED_IDLESCHED_FIFO 和 SCHED_RR 調度類,這些不同的調度類分別實現了 sched_class 中的函數以提供不同的調度行爲。

小結

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

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

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

延伸閱讀

  • What is long term scheduler, short term scheduler and mid term term scheduler in OS?
  • A brief history of the Linux Kernel's process scheduler: The very first scheduler, v0.01
  • Understanding the Linux 2.6.8.1 CPU Scheduler
  • CFS Scheduler
  • Inside the Linux 2.6 Completely Fair Scheduler
  • The Linux desktop may soon be a lot faster
  • Modular Scheduler Core and Completely Fair Scheduler
  • The Linux Scheduler: A Decade of Wasted Cores

Go 語言

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

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

圖片
go-and-erlang

圖 20 - Go 和 Erlang 的併發模型

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

設計與演進

今天的 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);
}

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

  1. 獲取調度器的全局鎖;
  2. 調用 gosave 保存棧寄存器和程序計數器;
  3. 調用 nextgandunlock 獲取下一個線程 M 需要運行的 Goroutine 並解鎖調度器;
  4. 修改全局線程 m 上要執行的 Goroutine;
  5. 調用 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 函數上[^15],目前的調度器實現有以下問題需要解決:

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

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

任務竊取調度器

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

  1. 在當前的 G-M 模型中引入了處理器 P;
  2. 在處理器 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);
}
  1. 如果當前運行時在等待垃圾回收,調用 gcstopm 函數;
  2. 調用 runqget 和 findrunnable 從本地的或者全局的運行隊列中獲取待執行的 Goroutine;
  3. 調用 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 的關係。

圖片
golang-gmp

圖 21 - G-M-P 模型

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

搶佔式調度器

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

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

然而 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 函數的實現,加入異步搶佔的邏輯;

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

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

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

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

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

非均勻內存訪問調度器

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

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

圖片
go-numa-scheduler-architecture

圖 22 - Go 語言 NUMA 調度器

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

小結

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

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

延伸閱讀

  • How Erlang does scheduling
  • Analysis of the Go runtime scheduler
  • Go's work-stealing scheduler
  • cmd/compile: insert scheduling checks on loop backedges
  • runtime: clean up async preemption loose ends
  • Proposal: Non-cooperative goroutine preemption
  • Proposal: Conservative inner-frame scanning for non-cooperative goroutine preemption
  • NUMA-aware scheduler for Go
  • The Go scheduler
  • Why goroutines are not lightweight threads?
  • Scheduling In Go : Part I - OS Scheduler
  • Scheduling In Go : Part II - Go Scheduler
  • Scheduling In Go : Part III - Concurrency
  • The Go netpoller
  • System Calls Make the World Go Round
  • Linux Syscall Reference

Kubernetes

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

圖片
container-orchestration

圖 23 - 容器編排系統演進

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

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

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

設計與演進

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 版本中調度器的執行過程:

圖片
predicates-and-priorities-scheduling

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

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

調度框架

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

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

除了兩個大循環之外,調度框架中還包含 QueueSortPreFilterFilterPostFilterScoreReservePermitPreBindBindPostBind 和 Unreserve 11 個擴展點(Extension Point),這些擴展點會在調度的過程中觸發,它們的運行順序如下:

圖片
kubernetes-scheduling-queue

圖 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
	}
	...
}
  1. 調用內部優先隊列的 MakeNextPodFunc 返回的函數從隊列中獲取下一個等待調度的 Pod,用於維護等待 Pod 的隊列會執行 QueueSort 插件;
  2. 調用 genericScheduler.Schedule 函數選擇節點,該過程會執行 PreFilterFilterPostFilterScore 四個擴展點的插件;
  3. 調用 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)
		}
	}()
}
  1. 啓動一個 Goroutine 並調用 framework.RunPermitPlugin 異步運行 Permit 插件,這個階段可以用來實現批調度器;
  2. 調用 Scheduler.bindVolumes 將卷先綁定到 Node 上;
  3. 調用 Scheduler.bind 函數將 Pod 綁定到 Node 上完成調度,綁定的過程會執行 PreBindBind 和 PostBind 三個擴展點的插件;

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

小結

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

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

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

延伸閱讀

  • Borg, Omega, and Kubernetes
  • Scheduling Framework
  • Scheduling Framework #624
  • Create a custom Kubernetes scheduler
  • Scheduler extender Document

總結

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

圖片
schedulers

圖 26 - 三層調度器

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

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

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

Linux 和 Go

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

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

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

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

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

系統和業務

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

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

總結的總結

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

系統設計精要是一系列深入研究系統設計方法的系列文章,文中不僅會分析系統設計的理論,還會分析多個實際場景下的具體實現。這是一個季更或者半年更的系列,如果你有想要了解的問題,可以在文章下面留言。

延伸閱讀

  • Cooperative vs. Preemptive: a quest to maximize concurrency power
  • Randomized Work Stealing versus Sharing in Large-scale Systems with Non-exponential Job Sizes
  • Scalable work stealing

內存管理設計精要 (qq.com)

系統設計精要是一系列深入研究系統設計方法的系列文章,文中不僅會分析系統設計的理論,還會分析多個實際場景下的具體實現。這是一個季更或者半年更的系列,如果你有想要了解的問題,可以在文章下面留言。

持久存儲的磁盤在今天已經不是稀缺的資源了,但是 CPU 和內存仍然是相對比較昂貴的資源,作者在 調度系統設計精要 中曾經介紹操作系統和編程語言對 CPU 資源的調度策略和原理,本文將會介紹計算機中常見的另一個稀缺資源 — 內存,是如何管理的。

圖片
圖 1 - 內存系統設計精要

內存管理系統和模塊在操作系統以及編程語言中都佔有着重要的地位,任何資源的使用都離不開申請和釋放兩個動作,內存管理中的兩個重要過程就是內存分配和垃圾回收,內存管理系統如何利用有限的內存資源爲儘可能多的程序或者模塊提供服務是它的核心目標。

圖片
圖 2 - 文章脈絡和內容

雖然多數系統都會將內存管理拆分成多個複雜的模塊並引入一些中間層提供緩存和轉換的功能,但是內存管理系統實際上都可以簡化成兩個模塊,即內存分配器(Allocator)、垃圾收集器(Collector)。當然除了這兩個模塊之外,在研究內存管理時都會引入第三個模塊 — 用戶程序(Mutator),幫助我們理解整個系統的工作流程。

圖片
圖 3 - 內存管理系統模塊
  • 用戶程序(Mutator)- 可以通過分配器創建對象或者更新對象持有的指針;
  • 內存分配器(Allocator)— 處理用戶程序的的內存分配請求;
  • 垃圾收集器(Collector)- 標記內存中的對象並回收不需要的內存;

上述的三個模塊是內存管理系統中的核心,它們在應用程序運行期間可以維護管理內存達到相對平衡的狀態,我們在介紹內存管理時也會圍繞這三個不同的組件,本節將從基本概念、內存分配和垃圾回收三個方面詳細介紹內存管理的相關理論。

基本概念

基本概念這一節將介紹內存管理中的基本問題,我們會簡單介紹應用程序的內存佈局、內存管理中的設計的常見概念以及廣義上的幾種不同內存管理方式,這裏會幫助各位讀者從頂層瞭解內存管理。

內存佈局

操作系統會爲在其上運行的應用程序分配一片巨大的虛擬內存,需要注意的是,與操作系統的主存和物理內存不一樣,虛擬內存並不是在物理上真正存在的概念,它是操作系統構建的邏輯概念。應用程序的內存一般會分成以下幾個不同的區域:

圖片
圖 4 - 內存佈局
  • 棧區(Stack)— 存儲程序執行期間的本地變量和函數的參數,從高地址向低地址生長;
  • 堆區(Heap)— 動態內存分配區域,通過 mallocnewfree 和 delete 等函數管理;
  • 未初始化變量區(BSS)— 存儲未被初始化的全局變量和靜態變量;
  • 數據區(Data)— 存儲在源代碼中有預定義值的全局變量和靜態變量;
  • 代碼區(Text)— 存儲只讀的程序執行代碼,即機器指令;

上述五種不同段雖然存儲着不同的數據,但是我們可以將它們分成三種不同的內存分配類型,也就是靜態內存、棧內存和堆內存。

靜態內存

靜態內存可以最早追溯到 1960 年的 ALGOL 語言[^1],靜態變量的生命週期可以貫穿整個程序。所有靜態內存的佈局都是在編譯期間確認的,運行期間也不會分配新的靜態內存,因爲所有的靜態內存都是在編譯期間確認的,所以會爲這些變量申請固定大小的內存空間,這些固定的內存空間也會導致靜態內存無法支持函數的遞歸調用:

圖片
圖 5 - 靜態內存的特性

因爲編譯器可以確定靜態變量的地址,所以它們是程序中唯一可以使用絕對地址尋址的變量。當程序被加載到內存中時,靜態變量會直接存儲在程序的 BSS 區或者數據區,這些變量也會在程序退出時被銷燬,正是因爲靜態內存的這些特性,我們並不需要在程序運行時引入靜態內存的管理機制。

棧內存

棧是應用程序中常見的內存空間,它遵循後進先出的規則管理存儲的數據[^2]。當應用程序調用函數時,它會將函數的參數加入棧頂,當函數返回時,它會將當前函數使用的棧全部銷燬。棧內存管理的指令也都是由編譯器生成的,我們會使用 BP 和 SP 這兩個寄存器存儲當前棧的相關信息,完全不需要工程師的參與,不過我們也只能在棧上分配大塊固定的數據結構。

圖片
圖 6 - 棧內存的特性

因爲棧內存的釋放是動態的並且是線性的,所以它可以支持函數的遞歸調用,不過運行時動態棧分配策略的引入也會導致程序棧內存的溢出,如果我們在編程語言中使用的遞歸函數超出了程序內存的上限,會造成棧溢出錯誤。

堆內存

堆內存也是應用程序中的常見內存,與超過函數作用域會自動回收的棧內存相比,它能夠讓函數的被調用方向調用方返回內存並在內存的分配提供更大的靈活性,不過它提供的靈活性也帶來了內存泄漏和懸掛指針等內存安全問題。

圖片
圖 7 - 堆內存的特性

因爲堆上的內存是工程師手動申請的,所以需要在使用結束時釋放,一旦用過的內存沒有釋放,就會造成內存泄漏,佔用更多的系統內存;如果在使用結束前釋放,會導致危險的懸掛指針,其他對象指向的內存已經被系統回收或者重新使用。雖然進程的內存可以劃分成很多區域,但是當我們在談內存管理時,一般指的都是堆內存的管理,也就是如何解決內存泄漏和懸掛指針的問題。

管理方式

我們可以將內存管理簡單地分成手動管理和自動管理兩種方式,手動管理內存一般是指由工程師在需要時通過 malloc 等函數手動申請內存並在不需要時調用 free 等函數釋放內存;自動管理內存由編程語言的內存管理系統自動管理,在大多數情況下不需要工程師的參與,能夠自動釋放不再使用的內存。

圖片
圖 8 - 手動管理和自動管理

手動管理和自動管理只是內存管理的兩種不同方式,本節將分別介紹兩種內存管理的方式以及不同編程語言做出的不同選擇。

手動管理

手動管理內存是一種比較傳統的內存管理方式,C/C++ 這類系統級的編程語言不包含狹義上的自動內存管理機制,工程師需要主動申請或者釋放內存。如果存在理想的工程師能夠精準地確定內存的分配和釋放時機,人肉的內存管理策略只要做到足夠精準,使用手動管理內存的方式可以提高程序的運行性能,也不會造成內存安全問題,

但是這種理想的工程師往往不存在於現實中,人類因素(Human Factor)總會帶來一些錯誤,內存泄漏和懸掛指針基本是 C/C++ 這類語言中最常出現的錯誤,手動的內存管理也會佔用工程師的大量精力,很多時候都需要思考對象應該分配到棧上還是堆上以及堆上的內存應該何時釋放,維護成本相對來說還是比較高的,這也是必然要做的權衡。

自動管理

自動管理內存基本是現代編程語言的標配,因爲內存管理模塊的功能非常確定,所以我們可以在編程語言的編譯期或者運行時中引入自動的內存管理方式,最常見的自動內存管理機制就是垃圾回收,不過除了垃圾回收之外,一些編程語言也會使用自動引用計數輔助內存的管理。

自動的內存管理機制可以幫助工程師節省大量的與內存打交道的時間,讓工程師將全部的精力都放在覈心的業務邏輯上,提高開發的效率;在一般情況下,這種自動的內存管理機制都可以很好地解決內存泄漏和懸掛指針的問題,但是這也會帶來額外開銷並影響語言的運行時性能。

對象頭

對象頭是實現自動內存管理的關鍵元信息,內存分配器和垃圾收集器都會訪問對象頭以獲取相關的信息。當我們通過 malloc 等函數申請內存時,往往都需要將內存按照指針的大小對齊(32 位架構上爲 4 字節,64 位架構上爲 8 字節),除了用於對齊的內存之外,每一個堆上的對象也都需要對應的對象頭:

圖片
圖 9 - 對象頭與對象

不同的自動內存管理機制會在對象頭中存儲不同的信息,使用垃圾回收的編程語言會存儲標記位 MarkBit/MarkWord,例如:Java 和 Go 語言;使用自動引用計數的會在對象頭中存儲引用計數 RefCount,例如:Objective-C。

編程語言會選擇將對象頭與對象存儲在一起,不過因爲對象頭的存儲可能影響數據訪問的局部性,所以有些編程語言可能會單獨開闢一片內存空間來存儲對象頭並通過內存地址建立兩者之間的隱式聯繫。

內存分配

內存分配器是內存管理系統中的重要組件,它的主要職責是處理用戶程序的內存申請。雖然內存分配器的職責非常重要,但是內存的分配和使用其是一個增加系統中熵的過程,所以內存分配器的設計與工作原理相對比較簡單,我們在這裏介紹內存分配器的兩種類型。

內存分配器只包含線性內存分配器(Sequential Allocator)和空閒鏈表內存分配器(Free-list Allocator)兩種,內存管理機制中的所有內存分配器其實都是上述兩種不同分配器的變種,它們的設計思路完全不同,同時也有着截然不同的應用場景和特性,我們在這裏依次介紹這兩種內存分配器的原理。

線性分配器

線性分配(Bump Allocator)是一種高效的內存分配方法,但是有較大的侷限性。當我們在編程語言中使用線性分配器,我們只需要在內存中維護一個指向內存特定位置的指針,當用戶程序申請內存時,分配器只需要檢查剩餘的空閒內存、返回分配的內存區域並修改指針在內存中的位置,即移動下圖中的指針:

圖片
圖 10 - 線性分配器

根據線性分配器的原理,我們可以推測它有較快的執行速度,以及較低的實現複雜度;但是線性分配器無法在內存被釋放時重用內存。如下圖所示,如果已經分配的內存被回收,線性分配器是無法重新利用紅色的這部分內存的:

圖片
圖 11 - 線性分配器回收內存

正是因爲線性分配器的這種特性,我們需要合適的垃圾回收算法配合使用。標記壓縮(Mark-Compact)、複製回收(Copying GC)和分代回收(Generational GC)等算法可以通過拷貝的方式整理存活對象的碎片,將空閒內存定期合併,這樣就能利用線性分配器的效率提升內存分配器的性能了。

因爲線性分配器的使用需要配合具有拷貝特性的垃圾回收算法,所以 C 和 C++ 等需要直接對外暴露指針的語言就無法使用該策略,我們會在下一節詳細介紹常見垃圾回收算法的設計原理。

空閒鏈表分配器

空閒鏈表分配器(Free-List Allocator)可以重用已經被釋放的內存,它在內部會維護一個類似鏈表的數據結構。當用戶程序申請內存時,空閒鏈表分配器會依次遍歷空閒的內存塊,找到足夠大的內存,然後申請新的資源並修改鏈表:

圖片
圖 12 - 空閒鏈表分配器

因爲不同的內存塊以鏈表的方式連接,所以使用這種方式分配內存的分配器可以重新利用回收的資源,但是因爲分配內存時需要遍歷鏈表,所以它的時間複雜度就是 O(n)。空閒鏈表分配器可以選擇不同的策略在鏈表中的內存塊中進行選擇,最常見的就是以下四種方式:

  • 首次適應(First-Fit)— 從鏈表頭開始遍歷,選擇第一個大小大於申請內存的內存塊;
  • 循環首次適應(Next-Fit)— 從上次遍歷的結束位置開始遍歷,選擇第一個大小大於申請內存的內存塊;
  • 最優適應(Best-Fit)— 從鏈表頭遍歷整個鏈表,選擇最合適的內存塊;
  • 隔離適應(Segregated-Fit)— 將內存分割成多個鏈表,每個鏈表中的內存塊大小相同,申請內存時先找到滿足條件的鏈表,再從鏈表中選擇合適的內存塊;

上述四種策略的前三種就不過多介紹了,Go 語言使用的內存分配策略與第四種策略有些相似,我們通過下圖瞭解一下該策略的原理:

圖片
圖 13 - 隔離適應策略

如上圖所示,該策略會將內存分割成由 4、8、16、32 字節的內存塊組成的鏈表,當我們向內存分配器申請 8 字節的內存時,我們會在上圖中的第二個鏈表找到空閒的內存塊並返回。隔離適應的分配策略減少了需要遍歷的內存塊數量,提高了內存分配的效率。

垃圾回收

垃圾回收是一種自動的內存管理形式[^3],垃圾收集器是內存管理系統的重要組件,內存分配器會負責在堆上申請內存,而垃圾收集器會釋放不再被用戶程序使用的對象。談到垃圾回收,很多人的第一反應可能都是暫停程序(stop-the-world、STW)和垃圾回收暫停(GC Pause),垃圾回收確實會帶來 STW,但是這不是垃圾回收的全部,本節將詳細介紹垃圾回收以及垃圾收集器的相關概念和理論。

什麼是垃圾

在深入分析垃圾回收之前,我們需要先明確垃圾回收中垃圾的定義,明確定義能夠幫助我們更精確地理解垃圾回收解決的問題以及它的職責。計算機科學中的垃圾包括對象、數據和計算機系統中的其他的內存區域,這些數據不會在未來的計算中使用,因爲內存資源是有限的,所以我們需要將這些垃圾佔用的內存交還回堆並在未來複用[^4]。

圖片
圖 14 - 語義和語法垃圾

垃圾可以分成語義垃圾和語法垃圾兩種,*語義垃圾(Semantic Garbage)*是計算機程序中永遠不會被程序訪問到的對象或者數據;*語法垃圾(Syntactic Garbage)*是計算機程序內存空間中從根對象無法達到(Unreachable)的對象或者數據。

語義垃圾是不會被使用的的對象,可能包括廢棄的內存、不使用的變量,垃圾收集器無法解決程序中語義垃圾的問題,我們需要通過編譯器來一部分語義垃圾。語法垃圾是在對象圖中不能從根節點達到的對象,所以語法垃圾在一般情況下都是語義垃圾:

圖片
圖 15 - 無法達到的語法垃圾

垃圾收集器能夠發現並回收的就是對象圖中無法達到的語法垃圾,通過分析對象之間的引用關係,我們可以得到圖中根節點不可達的對象,這些不可達的對象會在垃圾收集器的清理階段被回收。

收集器性能

吞吐量(Throughput)和最大暫停時間(Pause time)是兩個衡量垃圾收集器的主要指標,除了這兩個指標之外,堆內存的使用效率和訪問的局部性也是垃圾收集的常用指標,我們簡單介紹以下這些指標對垃圾收集器的影響。

吞吐量

垃圾收集器的吞吐量其實有兩種解釋,一種解釋是垃圾收集器在執行階段的速度,也就是單位時間的標記和清理內存的能力,我們可以用堆內存除以 GC 使用的總時間來計算。

HEAP_SIZE / TOTAL_GC_TIME

另一種吞吐量計算方法是使用程序運行的總時間除以所有 GC 循環運行的總時間,GC 的時間對於整個應用程序來說是額外開銷,這個指標能看出額外開銷佔用資源的百分比,從這一點,我們也能看出 GC 的執行效率。

最大暫停時間

由於在垃圾回收的某些階段會觸發 STW,所以用戶程序是不能執行的,最長的 STW 時間會嚴重影響程序處理請求或者提供服務的尾延遲,所以這一點也是我們在測量垃圾收集器性能時需要考慮的指標。

圖片
圖 16 - 最大暫停時間

使用 STW 垃圾收集器的編程語言,用戶程序在垃圾回收的全部階段都不能執行。併發標記清除的垃圾收集器將可以與用戶程序併發執行的工作全部併發執行,能夠減少最大程序暫停時間,

堆使用效率

堆的使用效率也是衡量垃圾收集器的重要指標。爲了能夠標識垃圾,我們需要在內存空間中引入包含特定信息的對象頭,這些對象頭都是垃圾收集器帶來的額外開銷,正如網絡帶寬可能不是最終的下載速度,協議頭和校驗碼的傳輸會佔用網絡帶寬,對象頭的大小最終也會影響堆內存的使用效率;除了對象頭之外,堆在使用過程中出現的碎片也會影響內存的使用效率,爲了保證內存的對齊,我們會在內存中留下很多縫隙,這些縫隙也是內存管理帶來的開銷。

訪問局部性

訪問的局部性是我們在討論內存管理時不得不談的話題,空間的局部性是指處理器在短時間內總會重複地訪問同一片或者相鄰的內存區域,操作系統會以內存頁爲單位管理內存空間,在理想情況下,合理的內存佈局可以使得垃圾收集器和應用程序都能充分地利用空間局部性提高程序的執行效率。

收集器類型

垃圾收集器的類型在總體上可以分成直接(Direct)垃圾收集器和跟蹤(Tracing)垃圾收集器。直接垃圾收集器包括引用計數(Refernce-Counting),跟蹤垃圾收集器包含標記清理、標記壓縮、複製垃圾回收等策略,而引用計數收集器卻不是特別常見,少數編程語言會使用這種方式管理內存。

圖片
圖 17 - 垃圾收集器類型

除了直接和跟蹤垃圾收集器這些相對常見的垃圾回收方法之外,也有使用所有權或者手動的方式管理內存,我們在本節中會介紹引用計數、標記清除、標記壓縮和複製垃圾回收四種不同類型垃圾收集器的設計原理以及它們的優缺點。

引用計數

基於引用計數的垃圾收集器是直接垃圾收集器,當我們改變對象之間的引用關係時會修改對象之間的引用計數,每個對象的引用計數都記錄了當前有多少個對象指向了該對象,當對象的引用計數歸零時,當前對象就會被自動釋放。在使用引用計數的編程語言中,垃圾收集是在用戶程序運行期間實時發生的,所以在理論上也就不存在 STW 或者明顯地垃圾回收暫停。

圖片
圖 18 - 對象的引用計數

如上圖所示,基於引用計數的垃圾收集器需要應用程序在對象頭中存儲引用計數,引用計數就是該類型的收集器在內存中引入的額外開銷。我們在這裏舉一個例子介紹引用計數的工作原理,如果在使用引用計數回收器的編程語言中使用如下所示賦值語句時:

obj.field = new_ref;
  1. 對象 obj 原來引用的對象 old_ref 的引用計數會減一
  2. 對象 obj 引用的新對象 new_ref 的引用計數會加一
  3. 如果 old_ref 對象的引用計數歸零,我們會釋放該對象回收它的內存;

這種類型的垃圾收集器會帶來兩個比較常見的問題,分別是遞歸的對象回收和循環引用:

  • 遞歸回收 — 每當對象的引用關係發生改變時,我們都需要計算對象的新引用計數,一旦對象被釋放,我們就需要遞歸地訪問所有該對象的引用並將被引用對象的計數器減一,一旦涉及到較多的對象就可能會造成 GC 暫停;
  • 循環引用 — 對象的相互引用在對象圖中也非常常見,如果對象之間的引用都是強引用,循環引用會導致多個對象的計數器都不會歸零,最終會造成內存泄漏;

遞歸回收是使用引用計數時不得不面對的問題,我們很難在工程上解決該問題;不過使用引用計數的編程語言卻可以利用弱引用來解決循環引用的問題,弱引用也是對象之間的引用關係,建立和銷燬弱引用關係都不會修改雙方的引用計數,這就能避免對象之間的弱引用關係,不過這也需要工程師對引用關係作出額外的並且正確的判斷。

圖片
圖 19 - 強引用與弱引用

除了弱引用之外,一些編程語言也會在引用計數的基礎上加入標記清除技術,通過遍歷和標記堆中不再被使用的對象解決循環引用的問題。

引用計數垃圾收集器是一種非移動(Non-moving)的垃圾回收策略,它在回收內存的過程中不會移動已有的對象,很多編程語言都會對工程師直接暴露內存的指針,所以 C、C++ 以及 Objective-C 等編程語言其實都可以使用引用計數來解決內存管理的問題。

標記清除

標記清除(Mark-Sweep)是最簡單也最常見的垃圾收集策略,它的執行過程可以分成標記清除兩個階段,標記階段會使用深度優先或者廣度優先算法掃描堆中的存活對象,而清除階段會回收內存中的垃圾。當我們使用該策略回收垃圾時,它會首先從根節點出發沿着對象的引用遍歷堆中的全部對象,能夠被訪問到的對象是存活的對象,不能被訪問到的對象就是內存中的垃圾。

如下圖所示,內存空間中包含多個對象,我們從根對象出發依次遍歷對象的子對象並將從根節點可達的對象都標記成存活狀態,即 A、C 和 D 三個對象,剩餘的 B、E 和 F 三個對象因爲從根節點不可達,所以會被當做垃圾:

圖片
圖 20 - 標記清除的標記階段

標記階段結束後會進入清除階段,在該階段中收集器會依次遍歷堆中的所有對象,釋放其中沒有被標記的 B、E 和 F 三個對象並將新的空閒內存空間以鏈表的結構串聯起來,方便內存分配器的使用。

圖片
圖 21 - 標記清除的收集階段

使用標記清除算法的編程語言需要在對象頭中加入表示對象存活的標記位(Mark Bit),標記位與操作系統的寫時複製不兼容,因爲即使內存頁中的對象沒有被修改,垃圾收集器也會修改內存頁中對象相鄰的標記位導致內存頁的複製。我們可以使用位圖(Bitmap)標記避免這種情況,表示對象存活的標記與對象分別存儲,清理對象時也只需要遍歷位圖,能夠降低清理過程的額外開銷。

如上圖所示,使用標記清除算法的垃圾收集器一般會使用基於空閒鏈表的分配器,因爲對象在不被使用時會被就地回收,所以長時間運行的程序會出現很多內存碎片,這會降低內存分配器的分配效率,在實現上我們可以將空閒鏈表按照對象大小分成不同的區以減少內存中的碎片。

標記清除策略是一種實現簡單的垃圾收集策略,但是它的內存碎片化問題也比較嚴重,簡單的內存回收策略也增加了內存分配的開銷和複雜度,當用戶程序申請內存時,我們也需要在內存中找到足夠大的塊分配內存。

標記壓縮

標記壓縮(Mark-Compact)也是比較常見的垃圾收集算法,與標記清除算法類似,標記壓縮的執行過程可以分成標記壓縮兩個階段。該算法在標記階段也會從根節點遍歷對象,查找並標記所有存活的對象;在壓縮階段,我們會將所有存活的對象緊密排列,『擠出』存活對象之間的縫隙:

圖片
圖 22 - 標記壓縮算法

因爲在壓縮階段我們需要移動存活的對象,所以這一種 moving 收集器,如果編程語言支持使用指針訪問對象,那麼我們就無法使用該算法。標記的過程相對比較簡單,我們在這裏以 Lisp 2 壓縮算法爲例重點介紹該算法的壓縮階段:

  1. 計算當前對象遷移後的最終位置並將位置存儲在轉發地址(Forwarding Address)中;
  2. 根據當前對象子對象的轉發地址,將引用指向新的位置;
  3. 將所有存活的對象移動到對象頭中轉發地址的位置;

從上述過程我們可以看出,使用標記壓縮算法的編程語言不僅要在對象頭中存儲標記位,還需要存儲當前對象的轉發地址,這增加了對象在內存中的額外開銷。

標記壓縮算法的實現比較複雜,在執行的過程中需要遍歷三次堆中的對象,作爲 moving 的垃圾收集器,它不適用於 C、C++ 等編程語言;壓縮算法的引入可以減少程序中的內存碎片,我們可以直接使用最簡單的線性分配器爲用戶程序快速分配內存。

複製垃圾回收

複製垃圾回收(Copying GC)也是跟蹤垃圾收集器的一種,它會將應用程序的堆分成兩個大小相等的區域,如下圖所示,其中左側區域負責爲用戶程序分配內存空間,而右側區域用於垃圾回收。

圖片
圖 23 - 複製垃圾回收

當用戶程序使用的內存超過上圖中的左側區域就會出現內存不足(Out-of memory、OOM),垃圾收集器在這時會開啓新的垃圾收集循環,複製垃圾回收的執行過程可以非常以下的四個階段:

  1. 複製階段 — 從 GC 根節點出發遍歷內存中的對象,將發現的存活對象遷移到右側的內存中;
  2. 轉發階段 — 在原始對象的對象頭或者在原位置設置新對象的轉發地址(Forwarding Address),如果其他對象引用了該對象可以從轉發地址轉到新的地址;
  3. 修復指針 — 遍歷當前對象持有的引用,如果引用指向了左側堆中的對象,回到第一步遷移發現的新對象;
  4. 交換階段 — 當內存中不存在需要遷移的對象之後,交換左右兩側的內存區域;
圖片
圖 24 - 複製垃圾回收的複製階段

如上圖所示,當我們把 A 對象複製到右側的區域後,會將原始的 A 對象指向新的 A 對象,這樣其他引用 A 的對象可以快速找到它的新地址;因爲 A 對象的複製是『像素級複製』,所以 A 對象仍然會指向左側內存的 C 對象,這時需要將 C 對象複製到新的內存區域並修改 A 對象的指針。在最後,當不存在需要拷貝的對象時,我們可以直接交換兩個內存區域的指針。

複製垃圾回收與標記壓縮算法一樣都會拷貝對象,能夠減少程序中的內存碎片,我們可以使用線性的分配器快速爲用戶程序分配內存。因爲只需要掃描一半的堆,遍歷堆的次數也會減少,所以可以減少垃圾回收的時間,但是這也會降低內存的利用率。

高級垃圾回收

內存管理是一個相對比較大的話題,我們在上一小節介紹了垃圾回收的一些基本概念,其中包括常見的垃圾回收算法:引用計數、標記清除、標記壓縮和複製垃圾回收,這些算法都是比較基本的垃圾回收算法,我們在這一節中將詳細介紹一些高級的垃圾回收算法,它們會利用基本的垃圾回收算法和新的數據結構構建更復雜的收集器。

分代垃圾收集器

分代垃圾回收(Generational garbage collection)是在生產環境中比較常見的垃圾收集算法,該算法主要建立在弱分代假設(Weak Generational Hypothesis)上 —— 大多數的對象會在生成後馬上變成垃圾,只有極少數的對象可以存活很久[^5]。根據該經驗,分代垃圾回收會把堆中的對象分成多個代,不同代垃圾回收的觸發條件和算法都完全不同。

圖片
圖 25 - 青年代和老年代

常見的分代垃圾回收會將堆分成青年代(Young、Eden)和老年代(Old、Tenured),所有的對象在剛剛初始化時都會進入青年代,而青年代觸發 GC 的頻率也更高;而老年代的對象 GC 頻率相對比較低,只有青年代的對象經過多輪 GC 沒有被釋放纔可能被晉升(Promotion)到老年代,晉升的過程與複製垃圾回收算法的執行過程相差無幾。

青年代的垃圾回收被稱作是 Minor GC 循環,而老年代的垃圾回收被稱作 Major GC 循環,Full GC 循環一般是指整個堆的垃圾回收,需要注意的是很多時候我們都會混淆 Major GC 循環和 Full GC 循環,在討論時一定要先搞清楚雙方對這些名詞的理解是否一致。

青年代的垃圾回收只會掃描整個堆的一部分,這能夠減少一次垃圾回收需要的掃描的堆大小和程序的暫停時間,提高垃圾回收的吞吐量。然而分代也爲垃圾回收引入了複雜度,其中最常見的問題是跨代引用(Intergenerational Pointer),即老年代引用了青年代的對象,如果堆中存在跨代引用,那麼在 Minor GC 循環中我們不僅應該遍歷垃圾回收的根對象,還需要從包含跨代引用的對象出發標記青年代中的對象。

圖片
圖 26 - 跨代引用

爲了處理分代垃圾回收的跨代引用,我們需要解決兩個問題,分別是如何識別堆中的跨代引用以及如何存儲識別的跨代引用,在通常情況下我們會使用*寫屏障(Write Barrier)識別跨代引用並使用卡表(Card Table)*存儲相關的數據。

注意:卡表只是標記或者存儲跨代引用的一種方式,除了卡表我們也可以使用記錄集(Record Set)存儲跨代引用的老年代對象或者使用頁面標記按照操作系統內存頁的維度標記老年代的對象。

寫屏障是當對象之間的指針發生改變時調用的代碼片段,這段代碼會判斷該指針是不是從老年代對象指向青年代對象的跨代引用。如果該指針是跨代引用,我們會在如下所示的卡表中標記老年代對象所在的區域:

圖片
圖 27 - 卡表

卡表與位圖比較相似,它也由一系列的比特位組成,其中每一個比特位都對應着老年區中的一塊內存,如果該內存中的對象存在指向青年代對象的指針,那麼這塊內存在卡表中就會被標記,當觸發 Minor GC 循環時,除了從根對象遍歷青年代堆之外,我們還會從卡表標記區域內的全部老年代對象開始遍歷青年代。

分代垃圾回收基於弱分代假說,結合了複製垃圾回收、寫屏障以及卡表等技術,將內存中的堆區分割成了青年代和老年代等區域,爲不同的代使用不同的內存分配和垃圾回收算法,可以有效地減少 GC 循環遍歷的堆大小和處理時間,但是寫屏障技術也會帶了額外開銷,移動收集器的特性也使它無法在 C、C++ 等編程語言中使用,在部分場景下弱分代假說不一定會成立,如果大多數的對象都會活得很久,那麼使用分代垃圾回收可能會起到反效果。

標記區域收集器

標記區域收集器(Mark-Region Garbage Collector)是 2008 年提出的垃圾收集算法[^6],這個算法也被稱作混合垃圾回收(Immix GC),它結合了標記清除和複製垃圾回收算法,我們使用前者來追蹤堆中的存活對象,使用後者減少內存中存在的碎片。

圖片
圖 28 - 標記區域收集器

Immix 垃圾回收算法包含兩個組件,分別是用於標記區域的收集器和去碎片化機制[^7]。標記區域收集器與標記清除收集器比較類似,它將堆內存拆分成特定大小的內存塊,再將所有的內存塊拆分成特定大小的線。當用戶程序申請內存時,它會在上述內存塊中查找空閒的線並使用線性分配器快速分配內存;通過引入粗粒度的內存塊和細粒度的線,可以更好地控制內存的分配和釋放。

圖片
圖 29 - 線性分配器的光標

標記區域收集器與標記清除收集器比較類似,因爲它們不會移動對象,所以都會面臨內存碎片化的問題。如下圖所示,標記區域收集器在回收內存時都是以塊和線爲單位進行回收的,所以只要當前內存線中包含存活對象,收集器就會保留該片內存區域,這會帶來我們在上面提到的內存碎片。

Immix 引入的機會轉移(Opportunistic Evacuation)機制能夠有效地減少程序中的碎片化,當收集器在內存塊中遇到可以被轉移的對象,它就會使用複製垃圾回收算法將當前塊中的存活對象移動到新的塊中並釋放原塊中的內存。

標記區域收集器將堆內存分成了粗粒度的內存塊和細粒度的內存線,結合了標記清除算法和複製垃圾回收幾種基本垃圾收集器的特性,既能夠提升垃圾收集器的吞吐量,還能夠利用線性分配器提高內存的分配速度,但是該收集器的實現相對比較複雜。

增量併發收集器

相信很多人對垃圾收集器的印象都是暫停程序(Stop the world,STW),隨着用戶程序申請越來越多的內存,系統中的垃圾也逐漸增多;當程序的內存佔用達到一定閾值時,整個應用程序就會全部暫停,垃圾收集器會掃描已經分配的所有對象並回收不再使用的內存空間,當這個過程結束後,用戶程序纔可以繼續執行。

傳統的垃圾收集算法會在垃圾收集的執行期間暫停應用程序,一旦觸發垃圾收集,垃圾收集器就會搶佔 CPU 的使用權佔據大量的計算資源以完成標記和清除工作,然而很多追求實時的應用程序無法接受長時間的 STW。

圖片
圖 30 - 垃圾收集與暫停程序

遠古時代的計算資源還沒有今天這麼豐富,今天的計算機往往都是多核的處理器,垃圾收集器一旦開始執行就會浪費大量的計算資源,爲了減少應用程序暫停的最長時間和垃圾收集的總暫停時間,我們會使用下面的策略優化現代的垃圾收集器:

  • 增量垃圾收集 — 增量地標記和清除垃圾,降低應用程序暫停的最長時間;
  • 併發垃圾收集 — 利用多核的計算資源,在用戶程序執行時併發標記和清除垃圾;

因爲增量和併發兩種方式都可以與用戶程序交替運行,所以我們需要使用屏障技術保證垃圾收集的正確性;與此同時,應用程序也不能等到內存溢出時觸發垃圾收集,因爲當內存不足時,應用程序已經無法分配內存,這與直接暫停程序沒有什麼區別,增量和併發的垃圾收集需要提前觸發並在內存不足前完成整個循環,避免程序的長時間暫停。

增量式(Incremental)的垃圾收集是減少程序最長暫停時間的一種方案,它可以將原本時間較長的暫停時間切分成多個更小的 GC 時間片,雖然從垃圾收集開始到結束的時間更長了,但是這也減少了應用程序暫停的最大時間:

圖片
圖 31 - 增量垃圾收集器

需要注意的是,增量式的垃圾收集需要與三色標記法一起使用,爲了保證垃圾收集的正確性,我們需要在垃圾收集開始前打開寫屏障,這樣用戶程序對內存的修改都會先經過寫屏障的處理,保證了堆內存中對象關係的強三色不變性或者弱三色不變性。雖然增量式的垃圾收集能夠減少最大的程序暫停時間,但是增量式收集也會增加一次 GC 循環的總時間,在垃圾收集期間,因爲寫屏障的影響用戶程序也需要承擔額外的計算開銷,所以增量式的垃圾收集也不是隻有優點的。

併發(Concurrent)的垃圾收集不僅能夠減少程序的最長暫停時間,還能減少整個垃圾收集階段的時間,通過開啓讀寫屏障、利用多核優勢與用戶程序並行執行,併發垃圾收集器確實能夠減少垃圾收集對應用程序的影響:

圖片
圖 32 - 併發垃圾收集器

雖然併發收集器能夠與用戶程序一起運行,但是並不是所有階段都可以與用戶程序一起運行,部分階段還是需要暫停用戶程序的,不過與傳統的算法相比,併發的垃圾收集可以將能夠併發執行的工作儘量併發執行;當然,因爲讀寫屏障的引入,併發的垃圾收集器也一定會帶來額外開銷,不僅會增加垃圾收集的總時間,還會影響用戶程序,這是我們在設計垃圾收集策略時必須要注意的。

但是因爲增量併發收集器的併發標記階段會與用戶程序一同或者交替運行,所以可能出現標記爲垃圾的對象被用戶程序中的其他對象重新引用,當垃圾回收的標記階段結束後,被錯誤標記爲垃圾的對象會被直接回收,這就會帶來非常嚴重的問題,想要解決增量併發收集器的這個問題,我們需要了解三色抽象和屏障技術。

三色抽象

爲了解決原始標記清除算法帶來的長時間 STW,多數現代的追蹤式垃圾收集器都會實現三色標記算法的變種以縮短 STW 的時間。三色標記算法將程序中的對象分成白色、黑色和灰色三類[^8]:

  • 白色對象 — 潛在的垃圾,其內存可能會被垃圾收集器回收;
  • 黑色對象 — 活躍的對象,包括不存在任何引用外部指針的對象以及從根對象可達的對象;
  • 灰色對象 — 活躍的對象,因爲存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象;
圖片
圖 33 - 三色的對象

在垃圾收集器開始工作時,程序中不存在任何的黑色對象,垃圾收集的根對象會被標記成灰色,垃圾收集器只會從灰色對象集合中取出對象開始掃描,當灰色集合中不存在任何對象時,標記階段就會結束。

圖片
圖 34 - 三色標記垃圾收集器的執行過程

三色標記垃圾收集器的工作原理很簡單,我們可以將其歸納成以下幾個步驟:

  1. 從灰色對象的集合中選擇一個灰色對象並將其標記成黑色;
  2. 將黑色對象指向的所有對象都標記成灰色,保證該對象和被該對象引用的對象都不會被回收;
  3. 重複上述兩個步驟直到對象圖中不存在灰色對象;

當三色的標記清除的標記階段結束之後,應用程序的堆中就不存在任何的灰色對象,我們只能看到黑色的存活對象以及白色的垃圾對象,垃圾收集器可以回收這些白色的垃圾,下面是使用三色標記垃圾收集器執行標記後的堆內存,堆中只有對象 D 爲待回收的垃圾:

圖片
圖 35 - 三色標記後的堆

因爲用戶程序可能在標記執行的過程中修改對象的指針,所以三色標記清除算法本身是不可以併發或者增量執行的,它仍然需要 STW,在如下所示的三色標記過程中,用戶程序建立了從 A 對象到 D 對象的引用,但是因爲程序中已經不存在灰色對象了,所以 D 對象會被垃圾收集器錯誤地回收。

圖片
圖 36 - 三色標記與用戶程序

本來不應該被回收的對象卻被回收了,這在內存管理中是非常嚴重的錯誤,我們將這種錯誤成爲懸掛指針,即指針沒有指向特定類型的合法對象,影響了內存的安全性[^9],想要併發或者增量地標記對象還是需要使用屏障技術。

垃圾回收屏障

內存屏障技術是一種屏障指令,它可以讓 CPU 或者編譯器在執行內存相關操作時遵循特定的約束,目前的多數的現代處理器都會亂序執行指令以最大化性能,但是該技術能夠保證代碼對內存操作的順序性,在內存屏障前執行的操作一定會先於內存屏障後執行的操作[^10]。

想要在併發或者增量的標記算法中保證正確性,我們需要達成以下兩種三色不變性(Tri-color invariant)中的任意一種:

  • 強三色不變性 — 黑色對象不會指向白色對象,只會指向灰色對象或者黑色對象;
  • 弱三色不變性 — 黑色對象指向的白色對象必須包含一條從灰色對象經由多個白色對象的可達路徑[^11];
圖片
圖 37 - 三色不變性

上圖分別展示了遵循強三色不變性和弱三色不變性的堆內存,遵循上述兩個不變性中的任意一個,我們都能保證垃圾收集算法的正確性,而屏障技術就是在併發或者增量標記過程中保證三色不變性的重要技術。

垃圾收集中的屏障技術更像是一個鉤子方法,它是在用戶程序讀取對象、創建新對象以及更新對象指針時執行的一段代碼,根據操作類型的不同,我們可以將它們分成讀屏障(Read barrier)和寫屏障(Write barrier)兩種,因爲讀屏障需要在讀操作中加入代碼片段,對用戶程序的性能影響很大,所以編程語言往往都會採用寫屏障保證三色不變性。

我們在這裏想要介紹的是以下幾種寫屏障技術,分別是 Dijkstra 提出的插入寫屏障[^12]和 Yuasa 提出的刪除寫屏障[^13],這裏會分析它們如何保證三色不變性和垃圾收集器的正確性。

插入寫屏障

Dijkstra 在 1978 年提出了插入寫屏障,通過如下所示的寫屏障,用戶程序和垃圾收集器可以在交替工作的情況下保證程序執行的正確性:

writePointer(slot, ptr):
    shade(ptr)
    *field = ptr

上述插入寫屏障的僞代碼非常好理解,每當我們執行類似 *slot = ptr 的表達式時,我們會執行上述寫屏障通過 shade 函數嘗試改變指針的顏色。如果 ptr 指針是白色的,那麼該函數會將該對象設置成灰色,其他情況則保持不變。

圖片
圖 38 - Dijkstra 插入寫屏障

假設我們在應用程序中使用 Dijkstra 提出的插入寫屏障,在一個垃圾收集器和用戶程序交替運行的場景中會出現如上圖所示的標記過程:

  1. 垃圾收集器將根對象指向 A 對象標記成黑色並將 A 對象指向的對象 B 標記成灰色;
  2. 用戶程序修改 A 對象的指針,將原本指向 B 對象的指針指向 C 對象,這時觸發寫屏障將 C 對象標記成灰色;
  3. 垃圾收集器依次遍歷程序中的其他灰色對象,將它們分別標記成黑色;

Dijkstra 的插入寫屏障是一種相對保守的屏障技術,它會將有存活可能的對象都標記成灰色以滿足強三色不變性。在如上所示的垃圾收集過程中,實際上不再存活的 B 對象最後沒有被回收;而如果我們在第二和第三步之間將指向 C 對象的指針改回指向 B,垃圾收集器仍然認爲 C 對象是存活的,這些被錯誤標記的垃圾對象只有在下一個循環纔會被回收。

插入式的 Dijkstra 寫屏障雖然實現非常簡單並且也能保證強三色不變性,但是它也有很明顯的缺點。因爲棧上的對象在垃圾收集中也會被認爲是根對象,所以爲了保證內存的安全,Dijkstra 必須爲棧上的對象增加寫屏障或者在標記階段完成重新對棧上的對象對象進行掃描,這兩種方法各有各的缺點,前者會大幅度增加寫入指針的額外開銷,後者重新掃描棧對象時需要暫停程序,垃圾收集算法的設計者需要在這兩者之前做出權衡。

刪除寫屏障

Yuasa 在 1990 年的論文 Real-time garbage collection on general-purpose machines 中提出了刪除寫屏障,因爲一旦該寫屏障開始工作,它就會保證開啓寫屏障時堆上所有對象的可達,所以也被稱作快照垃圾收集(Snapshot GC)[^14]:

This guarantees that no objects will become unreachable to the garbage collector traversal all objects which are live at the beginning of garbage collection will be reached even if the pointers to them are overwritten.

該算法會使用如下所示的寫屏障保證增量或者併發執行垃圾收集時程序的正確性:

writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

上述代碼會在老對象的引用被刪除時,將白色的老對象塗成灰色,這樣刪除寫屏障就可以保證弱三色不變性,老對象引用的下游對象一定可以被灰色對象引用。

圖片
圖 39 - Yuasa 刪除寫屏障

假設我們在應用程序中使用 Yuasa 提出的刪除寫屏障,在一個垃圾收集器和用戶程序交替運行的場景中會出現如上圖所示的標記過程:

  1. 垃圾收集器將根對象指向 A 對象標記成黑色並將 A 對象指向的對象 B 標記成灰色;
  2. 用戶程序將 A 對象原本指向 B 的指針指向 C,觸發刪除寫屏障,但是因爲 B 對象已經是灰色的,所以不做改變;
  3. 用戶程序將 B 對象原本指向 C 的指針刪除,觸發刪除寫屏障,白色的 C 對象被塗成灰色
  4. 垃圾收集器依次遍歷程序中的其他灰色對象,將它們分別標記成黑色;

上述過程中的第三步觸發了 Yuasa 刪除寫屏障的着色,因爲用戶程序刪除了 B 指向 C 對象的指針,所以 C 和 D 兩個對象會分別違反強三色不變性和弱三色不變性:

  • 強三色不變性 — 黑色的 A 對象直接指向白色的 C 對象;
  • 弱三色不變性 — 垃圾收集器無法從某個灰色對象出發,經過幾個連續的白色對象訪問白色的 C 和 D 兩個對象;

Yuasa 刪除寫屏障通過對 C 對象的着色,保證了 C 對象和下游的 D 對象能夠在這一次垃圾收集的循環中存活,避免發生懸掛指針以保證用戶程序的正確性。

總結

內存管理在今天仍然是十分重要的話題,當我們在討論編程語言的性能和便利程度時,內存管理機制都是繞不開的。編程語言在設計內存管理機制時,往往需要在手動管理和自動管理之間進行抉擇,現代的大多數編程語言爲了減少工程師的負擔,多數都會選擇使用垃圾回收的方式自動管理內存,但是也有少數編程語言通過手動管理追求極致的性能。

想要在一篇文章中詳盡展示內存管理的方方面面是不可能的,我們可能需要一本書或者幾本書的厚度才能詳細地展示內存管理的相關技術,這裏更多側重的還是垃圾回收,Rust 的所有權、生命週期以及 C++ 的智能指針等機制在文章中都沒有提及,感興趣的讀者可以自行了解。

[^1]: Wikipedia: Static variable https://en.wikipedia.org/wiki/Static_variable

[^2]: Wikipedia: Stack-based memory allocation https://en.wikipedia.org/wiki/Stack-based_memory_allocation

[^3]: Wikipedia: Garbage collection (computer science) https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)

[^4]: Wikipedia: Garbage (computer science) https://en.wikipedia.org/wiki/Garbage_(computer_science)

[^5]: Garbage Collection in Java (1) - Heap Overview http://insightfullogic.com/2013/Feb/20/garbage-collection-java-1/

[^6]: Immix: A Mark-Region Garbage Collector with Space Efficiency, Fast Collection, and Mutator Performance. Stephen M. Blackburn. Kathryn S. McKinley. 2008. http://www.cs.utexas.edu/users/speedway/DaCapo/papers/immix-pldi-2008.pdf

[^7]: The CS 6120 Course Blog. Siqiu Yao. 2019. https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/immix/

[^8]: "Tri-color marking" https://en.wikipedia.org/wiki/Tracing_garbage_collection#Tri-color_marking

[^9]: "Dangling pointer" https://en.wikipedia.org/wiki/Dangling_pointer

[^10]: "Wikpedia: Memory barrier" https://en.wikipedia.org/wiki/Memory_barrier

[^11]: P. P. Pirinen. Barrier techniques for incremental tracing. In ACM SIGPLAN Notices, 34(3), 20–25, October 1998. https://dl.acm.org/doi/10.1145/301589.286863

[^12]: E. W. Dijkstra, L. Lamport, A. J. Martin, C. S. Scholten, and E. F. Steffens. On-the-fly garbage collection: An exercise in cooperation. Communications of the ACM, 21(11), 966–975, 1978. https://www.cs.utexas.edu/users/EWD/transcriptions/EWD05xx/EWD520.html

[^13]: T. Yuasa. Real-time garbage collection on general-purpose machines. Journal of Systems and Software, 11(3):181–198, 1990. https://www.sciencedirect.com/science/article/pii/016412129090084Y

[^14]: Paul R Wilson. "Uniprocessor Garbage Collection Techniques" https://www.cs.cmu.edu/~fp/courses/15411-f14/misc/wilson94-gc.pdf

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