一步一圖帶你深入理解 Linux 實體記憶體管理

2022-11-22 15:01:12

1. 前文回顧

在上篇文章 《深入理解 Linux 虛擬記憶體管理》 中,筆者分別從程序使用者態和核心態的角度詳細深入地為大家介紹了 Linux 核心如何對程序虛擬記憶體空間進行佈局以及管理的相關實現。在我們深入理解了虛擬記憶體之後,那麼何不順帶著也探祕一下實體記憶體的管理呢?

所以本文的目的是在深入理解虛擬記憶體管理的基礎之上繼續帶大家向前奮進,一舉擊破實體記憶體管理的知識盲區,使大家能夠俯瞰整個 Linux 記憶體管理子系統的整體全貌。

而在正式開始實體記憶體管理的主題之前,筆者覺得有必須在帶大家回顧下上篇文章中介紹的虛擬記憶體管理的相關知識,方便大家來回對比虛擬記憶體和實體記憶體,從而可以全面整體地掌握 Linux 記憶體管理子系統。

在上篇文章的一開始,筆者首先為大家展現了我們應用程式頻繁接觸到的虛擬記憶體地址,清晰地為大家介紹了到底什麼是虛擬記憶體地址,以及虛擬記憶體地址分別在 32 位系統和 64 位系統中的具體表現形式:

在我們清楚了虛擬記憶體地址這個基本概念之後,隨後筆者又丟擲了一個問題:為什麼我們要通過虛擬記憶體地址存取記憶體而不是直接通過實體地址存取?

原來是在多程序系統中直接操作實體記憶體地址的話,我們需要精確地知道每一個變數的位置都被安排在了哪裡,而且還要注意當前程序在和多個程序同時執行的時候,不能共用同一個地址,否則就會造成地址衝突。

而虛擬記憶體空間的引入正是為了解決多程序地址衝突的問題,使得程序與程序之間的虛擬記憶體地址空間相互隔離,互不干擾。每個程序都認為自己獨佔所有記憶體空間,將多程序之間的協同相關細節統統交給核心中的記憶體管理模組來處理,極大地解放了程式設計師的心智負擔。這一切都是因為虛擬記憶體能夠為程序提供記憶體地址空間隔離的功勞。

在我們清楚了虛擬記憶體空間引入的意義之後,筆者緊接著為大家介紹了程序使用者態虛擬記憶體空間分別在 32 位機器和 64 位機器上的佈局情況:

在瞭解了使用者態虛擬記憶體空間的佈局之後,緊接著我們又介紹了 Linux 核心如何對使用者態虛擬記憶體空間進行管理以及相應的管理資料結構:

在介紹完使用者態虛擬記憶體空間的佈局以及管理之後,我們隨後又介紹了核心態虛擬記憶體空間的佈局情況,並結合之前介紹的使用者態虛擬記憶體空間,得到了 Linux 虛擬記憶體空間分別在 32 位和 64 位系統中的整體佈局情況:

在虛擬記憶體全部介紹完畢之後,為了能夠承上啟下,於是筆者繼續在上篇文章的最後一個小節從計算機組成原理的角度介紹了實體記憶體的物理組織結構,方便讓大家理解到底什麼是真正的實體記憶體 ?實體記憶體地址到底是什麼 ?由此為本文的主題 —— 實體記憶體的管理 ,埋下伏筆~~~

最後筆者介紹了 CPU 如何通過實體記憶體地址向實體記憶體讀寫資料的完整過程:

在我們回顧完上篇文章介紹的使用者態和核心態虛擬記憶體空間的管理,以及實體記憶體在計算機中的真實組成結構之後,下面筆者就來正式地為大家介紹本文的主題 —— Linux 核心如何對實體記憶體進行管理

2. 從 CPU 角度看實體記憶體模型

在前邊的文章中,筆者曾多次提到核心是以頁為基本單位對實體記憶體進行管理的,通過將實體記憶體劃分為一頁一頁的記憶體塊,每頁大小為 4K。一頁大小的記憶體塊在核心中用 struct page 結構體來進行管理,struct page 中封裝了每頁記憶體塊的狀態資訊,比如:組織結構,使用資訊,統計資訊,以及與其他結構的關聯對映資訊等。

而為了快速索引到具體的實體記憶體頁,核心為每個物理頁 struct page 結構體定義了一個索引編號:PFN(Page Frame Number)。PFN 與 struct page 是一一對應的關係。

核心提供了兩個宏來完成 PFN 與 物理頁結構體 struct page 之間的相互轉換。它們分別是 page_to_pfn 與 pfn_to_page。

核心中如何組織管理這些實體記憶體頁 struct page 的方式我們稱之為做實體記憶體模型,不同的實體記憶體模型,應對的場景以及 page_to_pfn 與 pfn_to_page 的計算邏輯都是不一樣的。

2.1 FLATMEM 平坦記憶體模型

我們先把實體記憶體想象成一片地址連續的儲存空間,在這一大片地址連續的記憶體空間中,核心將這塊記憶體空間分為一頁一頁的記憶體塊 struct page 。

由於這塊實體記憶體是連續的,實體地址也是連續的,劃分出來的這一頁一頁的物理頁必然也是連續的,並且每頁的大小都是固定的,所以我們很容易想到用一個陣列來組織這些連續的實體記憶體頁 struct page 結構,其在陣列中對應的下標即為 PFN 。這種記憶體模型就叫做平坦記憶體模型 FLATMEM 。

核心中使用了一個 mem_map 的全域性陣列用來組織所有劃分出來的實體記憶體頁。mem_map 全域性陣列的下標就是相應物理頁對應的 PFN 。

在平坦記憶體模型下 ,page_to_pfn 與 pfn_to_page 的計算邏輯就非常簡單,本質就是基於 mem_map 陣列進行偏移操作。

#if defined(CONFIG_FLATMEM)
#define __pfn_to_page(pfn) (mem_map + ((pfn)-ARCH_PFN_OFFSET))
#define __page_to_pfn(page) ((unsigned long)((page)-mem_map) + ARCH_PFN_OFFSET)
#endif

ARCH_PFN_OFFSET 是 PFN 的起始偏移量。

Linux 早期使用的就是這種記憶體模型,因為在 Linux 發展的早期所需要管理的實體記憶體通常不大(比如幾十 MB),那時的 Linux 使用平坦記憶體模型 FLATMEM 來管理實體記憶體就足夠高效了。

核心中的預設設定是使用 FLATMEM 平坦記憶體模型。

2.2 DISCONTIGMEM 非連續記憶體模型

FLATMEM 平坦記憶體模型只適合管理一整塊連續的實體記憶體,而對於多塊非連續的實體記憶體來說使用 FLATMEM 平坦記憶體模型進行管理則會造成很大的記憶體空間浪費。

因為 FLATMEM 平坦記憶體模型是利用 mem_map 這樣一個全域性陣列來組織這些被劃分出來的物理頁 page 的,而對於實體記憶體存在大量不連續的記憶體地址區間這種情況時,這些不連續的記憶體地址區間就形成了記憶體空洞。

由於用於組織物理頁的底層資料結構是 mem_map 陣列,陣列的特性又要求這些物理頁是連續的,所以只能為這些記憶體地址空洞也分配 struct page 結構用來填充陣列使其連續。

而每個 struct page 結構大部分情況下需要佔用 40 位元組(struct page 結構在不同場景下記憶體佔用會有所不同,這一點我們後面再說),如果實體記憶體中存在的大塊的地址空洞,那麼為這些空洞而分配的 struct page 將會佔用大量的記憶體空間,導致巨大的浪費。

為了組織和管理這些不連續的實體記憶體,核心於是引入了 DISCONTIGMEM 非連續記憶體模型,用來消除這些不連續的記憶體地址空洞對 mem_map 的空間浪費。

在 DISCONTIGMEM 非連續記憶體模型中,核心將實體記憶體從宏觀上劃分成了一個一個的節點 node (微觀上還是一頁一頁的物理頁),每個 node 節點管理一塊連續的實體記憶體。這樣一來這些連續的實體記憶體頁均被劃歸到了對應的 node 節點中管理,就避免了記憶體空洞造成的空間浪費。

核心中使用 struct pglist_data 表示用於管理連續實體記憶體的 node 節點(核心假設 node 中的實體記憶體是連續的),既然每個 node 節點中的實體記憶體是連續的,於是在每個 node 節點中還是採用 FLATMEM 平坦記憶體模型的方式來組織管理實體記憶體頁。每個 node 節點中包含一個 struct page *node_mem_map 陣列,用來組織管理 node 中的連續實體記憶體頁。

typedef struct pglist_data {
   #ifdef CONFIG_FLATMEM
	  struct page *node_mem_map;
   #endif
}

我們可以看出 DISCONTIGMEM 非連續記憶體模型其實就是 FLATMEM 平坦記憶體模型的一種擴充套件,在面對大塊不連續的實體記憶體管理時,通過將每段連續的實體記憶體區間劃歸到 node 節點中進行管理,避免了為記憶體地址空洞分配 struct page 結構,從而節省了記憶體資源的開銷。

由於引入了 node 節點這個概念,所以在 DISCONTIGMEM 非連續記憶體模型下 page_to_pfn 與 pfn_to_page 的計算邏輯就比 FLATMEM 記憶體模型下的計算邏輯多了一步定位 page 所在 node 的操作。

  • 通過 arch_pfn_to_nid 可以根據物理頁的 PFN 定位到物理頁所在 node。

  • 通過 page_to_nid 可以根據物理頁結構 struct page 定義到 page 所在 node。

當定位到物理頁 struct page 所在 node 之後,剩下的邏輯就和 FLATMEM 記憶體模型一模一樣了。

#if defined(CONFIG_DISCONTIGMEM)

#define __pfn_to_page(pfn)			\
({	unsigned long __pfn = (pfn);		\
	unsigned long __nid = arch_pfn_to_nid(__pfn);  \
	NODE_DATA(__nid)->node_mem_map + arch_local_page_offset(__pfn, __nid);\
})

#define __page_to_pfn(pg)						\
({	const struct page *__pg = (pg);					\
	struct pglist_data *__pgdat = NODE_DATA(page_to_nid(__pg));	\
	(unsigned long)(__pg - __pgdat->node_mem_map) +			\
	 __pgdat->node_start_pfn;					\
})

2.3 SPARSEMEM 稀疏記憶體模型

隨著記憶體技術的發展,核心可以支援實體記憶體的熱插拔了(後面筆者會介紹),這樣一來實體記憶體的不連續就變為常態了,在上小節介紹的 DISCONTIGMEM 記憶體模型中,其實每個 node 中的實體記憶體也不一定都是連續的。

而且每個 node 中都有一套完整的記憶體管理系統,如果 node 數目多的話,那這個開銷就大了,於是就有了對連續實體記憶體更細粒度的管理需求,為了能夠更靈活地管理粒度更小的連續實體記憶體,SPARSEMEM 稀疏記憶體模型就此登場了。

SPARSEMEM 稀疏記憶體模型的核心思想就是對粒度更小的連續記憶體塊進行精細的管理,用於管理連續記憶體塊的單元被稱作 section 。物理頁大小為 4k 的情況下, section 的大小為 128M ,物理頁大小為 16k 的情況下, section 的大小為 512M。

在核心中用 struct mem_section 結構體表示 SPARSEMEM 模型中的 section。

struct mem_section {
	unsigned long section_mem_map;
        ...
}

由於 section 被用作管理小粒度的連續記憶體塊,這些小的連續實體記憶體在 section 中也是通過陣列的方式被組織管理,每個 struct mem_section 結構體中有一個 section_mem_map 指標用於指向 section 中管理連續記憶體的 page 陣列。

SPARSEMEM 記憶體模型中的這些所有的 mem_section 會被存放在一個全域性的陣列中,並且每個 mem_section 都可以在系統執行時改變 offline / online (下線 / 上線)狀態,以便支援記憶體的熱插拔(hotplug)功能。

#ifdef CONFIG_SPARSEMEM_EXTREME
extern struct mem_section *mem_section[NR_SECTION_ROOTS];

在 SPARSEMEM 稀疏記憶體模型下 page_to_pfn 與 pfn_to_page 的計算邏輯又發生了變化。

  • 在 page_to_pfn 的轉換中,首先需要通過 page_to_section 根據 struct page 結構定位到 mem_section 陣列中具體的 section 結構。然後在通過 section_mem_map 定位到具體的 PFN。

在 struct page 結構中有一個 unsigned long flags 屬性,在 flag 的高位 bit 中儲存著 page 所在 mem_section 陣列中的索引,從而可以定位到所屬 section。

  • 在 pfn_to_page 的轉換中,首先需要通過 __pfn_to_section 根據 PFN 定位到 mem_section 陣列中具體的 section 結構。然後在通過 PFN 在 section_mem_map 陣列中定位到具體的物理頁 Page 。

PFN 的高位 bit 儲存的是全域性陣列 mem_section 中的 section 索引,PFN 的低位 bit 儲存的是 section_mem_map 陣列中具體物理頁 page 的索引。

#if defined(CONFIG_SPARSEMEM)
/*
 * Note: section's mem_map is encoded to reflect its start_pfn.
 * section[i].section_mem_map == mem_map's address - start_pfn;
 */
#define __page_to_pfn(pg)					\
({	const struct page *__pg = (pg);				\
	int __sec = page_to_section(__pg);			\
	(unsigned long)(__pg - __section_mem_map_addr(__nr_to_section(__sec)));	\
})

#define __pfn_to_page(pfn)				\
({	unsigned long __pfn = (pfn);			\
	struct mem_section *__sec = __pfn_to_section(__pfn);	\
	__section_mem_map_addr(__sec) + __pfn;		\
})
#endif

從以上的內容介紹中,我們可以看出 SPARSEMEM 稀疏記憶體模型已經完全覆蓋了前兩個記憶體模型的所有功能,因此稀疏記憶體模型可被用於所有記憶體佈局的情況。

2.3.1 實體記憶體熱插拔

前面提到隨著記憶體技術的發展,實體記憶體的熱插拔 hotplug 在核心中得到了支援,由於實體記憶體可以動態的從主機板中插入以及拔出,所以導致了實體記憶體的不連續已經成為常態,因此核心引入了 SPARSEMEM 稀疏記憶體模型以便應對這種情況,提供對更小粒度的連續實體記憶體的靈活管理能力。

本小節筆者就為大家介紹一下實體記憶體熱插拔 hotplug 功能在核心中的實現原理,作為 SPARSEMEM 稀疏記憶體模型的擴充套件內容補充。

在大規模的叢集中,尤其是現在我們處於雲原生的時代,為了實現叢集資源的動態均衡,可以通過實體記憶體熱插拔的功能實現叢集機器實體記憶體容量的動態增減。

叢集的規模一大,那麼實體記憶體出故障的機率也會大大增加,實體記憶體的熱插拔對提供叢集高可用性也是至關重要的。

從總體上來講,記憶體的熱插拔分為兩個階段:

  • 物理熱插拔階段:這個階段主要是從物理上將記憶體硬體插入(hot-add),拔出(hot-remove)主機板的過程,其中涉及到硬體和核心的支援。

  • 邏輯熱插拔階段:這一階段主要是由核心中的記憶體管理子系統來負責,涉及到的主要工作為:如何動態的上線啟用(online)剛剛 hot-add 的記憶體,如何動態下線(offline)剛剛 hot-remove 的記憶體。

實體記憶體拔出的過程需要關注的事情比插入的過程要多的多,實現起來也更加的困難, 這就好比在《Java 技術棧中介軟體優雅停機方案設計與實現全景圖》 一文中我們討論服務優雅啟動,停機時提到的:優雅停機永遠比優雅啟動要考慮的場景要複雜的多,因為停機的時候,線上的服務正在承載著生產的流量需要確保做到業務無失真。

同樣的道理,實體記憶體插入比較好說,困難的是實體記憶體的動態拔出,因為此時即將要被拔出的實體記憶體中可能已經為程序分配了物理頁,如何妥善安置這些已經被分配的物理頁是一個棘手的問題。

前邊我們介紹 SPARSEMEM 記憶體模型的時候提到,每個 mem_section 都可以在系統執行時改變 offline ,online 狀態,以便支援記憶體的熱插拔(hotplug)功能。 當 mem_section offline 時, 核心會把這部分記憶體隔離開, 使得該部分記憶體不可再被使用, 然後再把 mem_section 中已經分配的記憶體頁遷移到其他 mem_section 的記憶體上. 。

但是這裡會有一個問題,就是並非所有的物理頁都可以遷移,因為遷移意味著實體記憶體地址的變化,而記憶體的熱插拔應該對程序來說是透明的,所以這些遷移後的物理頁對映的虛擬記憶體地址是不能變化的。

這一點在程序的使用者空間是沒有問題的,因為程序在使用者空間存取記憶體都是根據虛擬記憶體地址通過頁表找到對應的實體記憶體地址,這些遷移之後的物理頁,雖然實體記憶體地址發生變化,但是核心通過修改相應頁表中虛擬記憶體地址與實體記憶體地址之間的對映關係,可以保證虛擬記憶體地址不會改變。

但是在核心態的虛擬地址空間中,有一段直接對映區,在這段虛擬記憶體區域中虛擬地址與實體地址是直接對映的關係,虛擬記憶體地址直接減去一個固定的偏移量(0xC000 0000 ) 就得到了實體記憶體地址。

直接對映區中的物理頁的虛擬地址會隨著實體記憶體地址變動而變動, 因此這部分物理頁是無法輕易遷移的,然而不可遷移的頁會導致記憶體無法被拔除,因為無法妥善安置被拔出記憶體中已經為程序分配的物理頁。那麼核心是如何解決這個頭疼的問題呢?

既然是這些不可遷移的物理頁導致記憶體無法拔出,那麼我們可以把記憶體分一下類,將記憶體按照物理頁是否可遷移,劃分為不可遷移頁,可回收頁,可遷移頁。

大家這裡需要記住一點,核心會將實體記憶體按照頁面是否可遷移的特性進行分類,筆者後面在介紹核心如何避免記憶體碎片的時候還會在提到

然後在這些可能會被拔出的記憶體中只分配那些可遷移的記憶體頁,這些資訊會在記憶體初始化的時候被設定,這樣一來那些不可遷移的頁就不會包含在可能會拔出的記憶體中,當我們需要將這塊記憶體熱拔出時, 因為裡邊的記憶體頁全部是可遷移的, 從而使記憶體可以被拔除。

3. 從 CPU 角度看實體記憶體架構

在上小節中筆者為大家介紹了三種實體記憶體模型,這三種實體記憶體模型是從 CPU 的視角來看待實體記憶體內部是如何佈局,組織以及管理的,主角是實體記憶體。

在本小節中筆者為大家提供一個新的視角,這一次我們把實體記憶體看成一個整體,從 CPU 存取實體記憶體的角度來看一下實體記憶體的架構,並從 CPU 與實體記憶體的相對位置變化來看一下不同實體記憶體架構下對效能的影響。

3.1 一致性記憶體存取 UMA 架構

我們在上篇文章 《深入理解 Linux 虛擬記憶體管理》的 「 8.2 CPU 如何讀寫主記憶體」 小節中提到 CPU 與記憶體之間的互動是通過匯流排完成的。

  • 首先 CPU 將實體記憶體地址作為地址訊號放到系統匯流排上傳輸。隨後 IO bridge 將系統匯流排上的地址訊號轉換為儲存匯流排上的電子訊號。

  • 主記憶體感受到儲存匯流排上的地址訊號並通過儲存控制器將儲存匯流排上的實體記憶體地址 A 讀取出來。

  • 儲存控制器通過實體記憶體地址定位到具體的記憶體模組,從 DRAM 晶片中取出實體記憶體地址對應的資料。

  • 儲存控制器將讀取到的資料放到儲存匯流排上,隨後 IO bridge 將儲存匯流排上的資料訊號轉換為系統匯流排上的資料訊號,然後繼續沿著系統匯流排傳遞。

  • CPU 晶片感受到系統匯流排上的資料訊號,將資料從系統匯流排上讀取出來並拷貝到暫存器中。

上圖展示的是單核 CPU 存取記憶體的架構圖,那麼在多核伺服器中多個 CPU 與記憶體之間的架構關係又是什麼樣子的呢?

在 UMA 架構下,多核伺服器中的多個 CPU 位於匯流排的一側,所有的記憶體條組成一大片記憶體位於匯流排的另一側,所有的 CPU 存取記憶體都要過匯流排,而且距離都是一樣的,由於所有 CPU 對記憶體的存取距離都是一樣的,所以在 UMA 架構下所有 CPU 存取記憶體的速度都是一樣的。這種存取模式稱為 SMP(Symmetric multiprocessing),即對稱多處理器。

這裡的一致性是指同一個 CPU 對所有記憶體的存取的速度是一樣的。即一致性記憶體存取 UMA(Uniform Memory Access)。

但是隨著多核技術的發展,伺服器上的 CPU 個數會越來越多,而 UMA 架構下所有 CPU 都是需要通過匯流排來存取記憶體的,這樣匯流排很快就會成為效能瓶頸,主要體現在以下兩個方面:

  1. 匯流排的頻寬壓力會越來越大,隨著 CPU 個數的增多導致每個 CPU 可用頻寬會減少

  2. 匯流排的長度也會因此而增加,進而增加存取延遲

UMA 架構的優點很明顯就是結構簡單,所有的 CPU 存取記憶體速度都是一致的,都必須經過匯流排。然而它的缺點筆者剛剛也提到了,就是隨著處理器核數的增多,匯流排的頻寬壓力會越來越大。解決辦法就只能擴寬匯流排,然而成本十分高昂,未來可能仍然面臨頻寬壓力。

為了解決以上問題,提高 CPU 存取記憶體的效能和擴充套件性,於是引入了一種新的架構:非一致性記憶體存取 NUMA(Non-uniform memory access)。

3.2 非一致性記憶體存取 NUMA 架構

在 NUMA 架構下,記憶體就不是一整片的了,而是被劃分成了一個一個的記憶體節點 (NUMA 節點),每個 CPU 都有屬於自己的本地記憶體節點,CPU 存取自己的本地記憶體不需要經過匯流排,因此存取速度是最快的。當 CPU 自己的本地記憶體不足時,CPU 就需要跨節點去存取其他記憶體節點,這種情況下 CPU 存取記憶體就會慢很多。

在 NUMA 架構下,任意一個 CPU 都可以存取全部的記憶體節點,存取自己的本地記憶體節點是最快的,但存取其他記憶體節點就會慢很多,這就導致了 CPU 存取記憶體的速度不一致,所以叫做非一致性記憶體存取架構。

如上圖所示,CPU 和它的本地記憶體組成了 NUMA 節點,CPU 與 CPU 之間通過 QPI(Intel QuickPath Interconnect)對等完成互聯,在 CPU 的本地記憶體不足的情況下,CPU 需要通過 QPI 存取遠端 NUMA 節點上的記憶體控制器從而在遠端記憶體節點上分配記憶體,這就導致了遠端存取比本地存取多了額外的延遲開銷(需要通過 QPI 遍歷遠端 NUMA 節點)。

在 NUMA 架構下,只有 DISCONTIGMEM 非連續記憶體模型和 SPARSEMEM 稀疏記憶體模型是可用的。而 UMA 架構下,前面介紹的三種記憶體模型都可以設定使用。

3.2.1 NUMA 的記憶體分配策略

NUMA 的記憶體分配策略是指在 NUMA 架構下 CPU 如何請求記憶體分配的相關策略,比如:是優先請求本地記憶體節點分配記憶體呢 ?還是優先請求指定的 NUMA 節點分配記憶體 ?是隻能在本地記憶體節點分配呢 ?還是允許當本地記憶體不足的情況下可以請求遠端 NUMA 節點分配記憶體 ?

記憶體分配策略 策略描述
MPOL_BIND 必須在繫結的節點進行記憶體分配,如果記憶體不足,則進行 swap
MPOL_INTERLEAVE 本地節點和遠端節點均可允許分配記憶體
MPOL_PREFERRED 優先在指定節點分配記憶體,當指定節點記憶體不足時,選擇離指定節點最近的節點分配記憶體
MPOL_LOCAL (預設) 優先在本地節點分配,當本地節點記憶體不足時,可以在遠端節點分配記憶體

我們可以在應用程式中通過 libnuma 共用庫中的 API 呼叫 set_mempolicy 介面設定程序的記憶體分配策略。

#include <numaif.h>

long set_mempolicy(int mode, const unsigned long *nodemask,
                          unsigned long maxnode);
  • mode : 指定 NUMA 記憶體分配策略。

  • nodemask:指定 NUMA 節點 Id。

  • maxnode:指定最大 NUMA 節點 Id,用於遍歷遠端節點,實現跨 NUMA 節點分配記憶體。

libnuma 共用庫 API 檔案:https://man7.org/linux/man-pages/man3/numa.3.html

set_mempolicy 介面檔案:https://man7.org/linux/man-pages/man2/set_mempolicy.2.html

3.2.2 NUMA 的使用簡介

在我們理解了實體記憶體的 NUMA 架構,以及在 NUMA 架構下的記憶體分配策略之後,本小節筆者來為大家介紹下如何正確的利用 NUMA 提升我們應用程式的效能。

前邊我們介紹了這麼多的理論知識,但是理論的東西總是很虛,正所謂眼見為實,大家一定想親眼看一下 NUMA 架構在計算機中的具體表現形式,比如:在支援 NUMA 架構的機器上到底有多少個 NUMA 節點?每個 NUMA 節點包含哪些 CPU 核,具體是怎樣的一個分佈情況?

前面也提到 CPU 在存取本地 NUMA 節點中的記憶體時,速度是最快的。但是當存取遠端 NUMA 節點,速度就會相對很慢,那麼到底有多慢?本地節點與遠端節點之間的存取速度差異具體是多少 ?

3.2.2.1 檢視 NUMA 相關資訊

numactl 檔案:https://man7.org/linux/man-pages/man8/numactl.8.html

針對以上具體問題,numactl -H 命令可以給出我們想要的答案:

available: 4 nodes (0-3)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 
node 0 size: 64794 MB
node 0 free: 55404 MB

node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 1 size: 65404 MB
node 1 free: 58642 MB

node 2 cpus: 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
node 2 size: 65404 MB
node 2 free: 61181 MB

node 3 cpus:  48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
node 3 size: 65402 MB
node 3 free: 55592 MB

node distances:
node   0   1   2   3
  0:  10  16  32  33
  1:  16  10  25  32
  2:  32  25  10  16
  3:  33  32  16  10

numactl -H 命令可以檢視伺服器的 NUMA 設定,上圖中的伺服器設定共包含 4 個 NUMA 節點(0 - 3),每個 NUMA 節點中包含 16個 CPU 核心,本地記憶體大小約為 64G。

大家可以關注下最後 node distances: 這一欄,node distances 給出了不同 NUMA 節點之間的存取距離,對角線上的值均為本地節點的存取距離 10 。比如 [0,0] 表示 NUMA 節點 0 的本地記憶體存取距離。

我們可以很明顯的看到當出現跨 NUMA 節點存取的時候,存取距離就會明顯增加,比如節點 0 存取節點 1 的距離 [0,1] 是16,節點 0 存取節點 3 的距離 [0,3] 是 33。距離越遠,跨 NUMA 節點記憶體存取的延時越大。應用程式執行時應減少跨 NUMA 節點存取記憶體。

此外我們還可以通過 numactl -s 來檢視 NUMA 的記憶體分配策略設定:

policy: default
preferred node: current

通過 numastat 還可以檢視各個 NUMA 節點的記憶體存取命中率:

                           node0           node1            node2           node3
numa_hit              1296554257       918018444         1296574252       828018454
numa_miss                8541758        40297198           7544751        41267108
numa_foreign            40288595         8550361          41488585         8450375
interleave_hit             45651           45918            46654           49718
local_node            1231897031       835344122         1141898045       915354158
other_node              64657226        82674322           594657725       82675425 

  • numa_hit :記憶體分配在該節點中成功的次數。

  • numa_miss : 記憶體分配在該節點中失敗的次數。

  • numa_foreign:表示其他 NUMA 節點本地記憶體分配失敗,跨節點(numa_miss)來到本節點分配記憶體的次數。

  • interleave_hit : 在 MPOL_INTERLEAVE 策略下,在本地節點分配記憶體的次數。

  • local_node:程序在本地節點分配記憶體成功的次數。

  • other_node:執行在本節點的程序跨節點在其他節點上分配記憶體的次數。

numastat 檔案:https://man7.org/linux/man-pages/man8/numastat.8.html

3.2.2.2 繫結 NUMA 節點

numactl 工具可以讓我們應用程式指定執行在哪些 CPU 核心上,同時也可以指定我們的應用程式可以在哪些 NUMA 節點上分配記憶體。通過將應用程式與具體的 CPU 核心和 NUMA 節點繫結,從而可以提升程式的效能。

numactl --membind=nodes  --cpunodebind=nodes  command
  • 通過 --membind 可以指定我們的應用程式只能在哪些具體的 NUMA 節點上分配記憶體,如果這些節點記憶體不足,則分配失敗。

  • 通過 --cpunodebind 可以指定我們的應用程式只能執行在哪些 NUMA 節點上。

numactl --physcpubind=cpus  command

另外我們還可以通過 --physcpubind 將我們的應用程式繫結到具體的物理 CPU 上。這個選項後邊指定的引數我們可以通過 cat /proc/cpuinfo 輸出資訊中的 processor 這一欄檢視。例如:通過 numactl --physcpubind= 0-15 ./numatest.out 命令將程序 numatest 繫結到 0~15 CPU 上執行。

我們可以通過 numactl 命令將 numatest 程序分別繫結在相同的 NUMA 節點上和不同的 NUMA 節點上,執行觀察。

numactl --membind=0 --cpunodebind=0 ./numatest.out
numactl --membind=0 --cpunodebind=1 ./numatest.out

大家肯定一眼就能看出繫結在相同 NUMA 節點的程序執行會更快,因為通過前邊對 NUMA 架構的介紹,我們知道 CPU 存取本地 NUMA 節點的記憶體是最快的。

除了 numactl 這個工具外,我們還可以通過共用庫 libnuma 在程式中進行 NUMA 相關的操作。這裡筆者就不演示了,感興趣可以檢視下 libnuma 的 API 檔案:https://man7.org/linux/man-pages/man3/numa.3.html

4. 核心如何管理 NUMA 節點

在前邊我們介紹實體記憶體模型和實體記憶體架構的時候提到過:在 NUMA 架構下,只有 DISCONTIGMEM 非連續記憶體模型和 SPARSEMEM 稀疏記憶體模型是可用的。而 UMA 架構下,前面介紹的三種記憶體模型均可以設定使用。

無論是 NUMA 架構還是 UMA 架構在核心中都是使用相同的資料結構來組織管理的,在核心的記憶體管理模組中會把 UMA 架構當做只有一個 NUMA 節點的偽 NUMA 架構。這樣一來這兩種架構模式就在核心中被統一管理起來。

下面筆者先從最頂層的設計開始為大家介紹一下核心是如何管理這些 NUMA 節點的~~

NUMA 節點中可能會包含多個 CPU,這些 CPU 均是物理 CPU,這點大家需要注意一下。

4.1 核心如何統一組織 NUMA 節點

首先我們來看第一個問題,在核心中是如何將這些 NUMA 節點統一管理起來的?

核心中使用了 struct pglist_data 這樣的一個資料結構來描述 NUMA 節點,在核心 2.4 版本之前,核心是使用一個 pgdat_list 單連結串列將這些 NUMA 節點串聯起來的,單連結串列定義在 /include/linux/mmzone.h 檔案中:

extern pg_data_t *pgdat_list;

每個 NUMA 節點的資料結構 struct pglist_data 中有一個 next 指標,用於將這些 NUMA 節點串聯起來形成 pgdat_list 單連結串列,連結串列的末尾節點 next 指標指向 NULL。

typedef struct pglist_data {
    struct pglist_data *pgdat_next;
}

在核心 2.4 之後的版本中,核心移除了 struct pglist_data 結構中的 pgdat_next 之指標, 同時也刪除了 pgdat_list 單連結串列。取而代之的是,核心使用了一個大小為 MAX_NUMNODES ,型別為 struct pglist_data 的全域性陣列 node_data[] 來管理所有的 NUMA 節點。

全域性陣列 node_data[] 定義在檔案 /arch/arm64/include/asm/mmzone.h中:

#ifdef CONFIG_NUMA
extern struct pglist_data *node_data[];
#define NODE_DATA(nid)		(node_data[(nid)])

NODE_DATA(nid) 宏可以通過 NUMA 節點的 nodeId,找到對應的 struct pglist_data 結構。

node_data[] 陣列大小 MAX_NUMNODES 定義在 /include/linux/numa.h檔案中:

#ifdef CONFIG_NODES_SHIFT
#define NODES_SHIFT     CONFIG_NODES_SHIFT
#else
#define NODES_SHIFT     0
#endif
#define MAX_NUMNODES    (1 << NODES_SHIFT)

UMA 架構下 NODES_SHIFT 為 0 ,所以核心中只用一個 NUMA 節點來管理所有實體記憶體。

4.2 NUMA 節點描述符 pglist_data 結構

typedef struct pglist_data {
    // NUMA 節點id
    int node_id;
    // 指向 NUMA 節點內管理所有物理頁 page 的陣列
    struct page *node_mem_map;
    // NUMA 節點內第一個物理頁的 pfn
    unsigned long node_start_pfn;
    // NUMA 節點內所有可用的物理頁個數(不包含記憶體空洞)
    unsigned long node_present_pages;
    // NUMA 節點內所有的物理頁個數(包含記憶體空洞)
    unsigned long node_spanned_pages; 
    // 保證多程序可以並行安全的存取 NUMA 節點
    spinlock_t node_size_lock;
        .............
}

node_id 表示 NUMA 節點的 id,我們可以通過 numactl -H 命令的輸出結果檢視節點 id。從 0 開始依次對 NUMA 節點進行編號。

struct page 型別的陣列 node_mem_map 中包含了 NUMA節點內的所有的實體記憶體頁。

node_start_pfn 指向 NUMA 節點內第一個物理頁的 PFN,系統中所有 NUMA 節點中的物理頁都是依次編號的,每個物理頁的 PFN 都是全域性唯一的(不只是其所在 NUMA 節點內唯一)

node_present_pages 用於統計 NUMA 節點內所有真正可用的物理頁面數量(不包含記憶體空洞)。

由於 NUMA 節點內包含的實體記憶體並不總是連續的,可能會包含一些記憶體空洞,node_spanned_pages 則是用於統計 NUMA 節點內所有的記憶體頁,包含不連續的實體記憶體地址(記憶體空洞)的頁面數。

以上內容是筆者從整體上為大家介紹的 NUMA 節點如何管理節點內部的本地記憶體。事實上核心還會將 NUMA 節點中的本地記憶體做近一步的劃分。那麼為什麼要近一步劃分呢?

4.3 NUMA 節點實體記憶體區域的劃分

我們都知道核心對實體記憶體的管理都是以頁為最小單位來管理的,每頁預設 4K 大小,理想狀況下任何種類的資料都可以存放在任何頁框中,沒有什麼限制。比如:存放核心資料,使用者資料,磁碟緩衝資料等。

但是實際的電腦架構受到硬體方面的制約,間接導致限制了頁框的使用方式。

比如在 X86 體系結構下,ISA 匯流排的 DMA (直接記憶體存取)控制器,只能對記憶體的前16M 進行定址,這就導致了 ISA 裝置不能在整個 32 位地址空間中執行 DMA,只能使用實體記憶體的前 16M 進行 DMA 操作。

因此直接對映區的前 16M 專門讓核心用來為 DMA 分配記憶體,這塊 16M 大小的記憶體區域我們稱之為 ZONE_DMA。

用於 DMA 的記憶體必須從 ZONE_DMA 區域中分配。

而直接對映區中剩下的部分也就是從 16M 到 896M(不包含 896M)這段區域,我們稱之為 ZONE_NORMAL。從字面意義上我們可以瞭解到,這塊區域包含的就是正常的頁框(沒有任何使用限制)。

ZONE_NORMAL 由於也是屬於直接對映區的一部分,對應的實體記憶體 16M 到 896M 這段區域也是被直接對映至核心態虛擬記憶體空間中的 3G + 16M 到 3G + 896M 這段虛擬記憶體上。

而實體記憶體 896M 以上的區域被核心劃分為 ZONE_HIGHMEM 區域,我們稱之為高階記憶體。

由於核心虛擬記憶體空間中的前 896M 虛擬記憶體已經被直接對映區所佔用,而在 32 體系結構下核心虛擬記憶體空間總共也就 1G 的大小,這樣一來核心剩餘可用的虛擬記憶體空間就變為了 1G - 896M = 128M。

顯然實體記憶體中剩下的這 3200M 大小的 ZONE_HIGHMEM 區域無法繼續通過直接對映的方式對映到這 128M 大小的虛擬記憶體空間中。

這樣一來實體記憶體中的 ZONE_HIGHMEM 區域就只能採用動態對映的方式對映到 128M 大小的核心虛擬記憶體空間中,也就是說只能動態的一部分一部分的分批對映,先對映正在使用的這部分,使用完畢解除對映,接著對映其他部分。

所以核心會根據各個實體記憶體區域的功能不同,將 NUMA 節點內的實體記憶體主要劃分為以下四個實體記憶體區域:

  1. ZONE_DMA:用於那些無法對全部實體記憶體進行定址的硬體裝置,進行 DMA 時的記憶體分配。例如前邊介紹的 ISA 裝置只能對實體記憶體的前 16M 進行定址。該區域的長度依賴於具體的處理器型別。

  2. ZONE_DMA32:與 ZONE_DMA 區域類似,該區域內的物理頁面可用於執行 DMA 操作,不同之處在於該區域是提供給 32 位裝置(只能定址 4G 實體記憶體)執行 DMA 操作時使用的。該區域只在 64 位系統中起作用,因為只有在 64 位系統中才會專門為 32 位裝置提供專門的 DMA 區域。

  3. ZONE_NORMAL:這個區域的物理頁都可以直接對映到核心中的虛擬記憶體,由於是線性對映,核心可以直接進行存取。

  4. ZONE_HIGHMEM:這個區域包含的物理頁就是我們說的高階記憶體,核心不能直接存取這些物理頁,這些物理頁需要動態對映進核心虛擬記憶體空間中(非線性對映)。該區域只在 32 位系統中才會存在,因為 64 位系統中的核心虛擬記憶體空間太大了(128T),都可以進行直接對映。

以上這些實體記憶體區域的劃分定義在 /include/linux/mmzone.h 檔案中:

enum zone_type {
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
	ZONE_DEVICE,
#endif
    // 充當結束標記, 在核心中想要迭代系統中所有記憶體域時, 會用到該常數
	__MAX_NR_ZONES

};

大家可能注意到核心中定義的 zone_type 除了上邊為大家介紹的四個實體記憶體區域,又多出了兩個區域:ZONE_MOVABLE 和 ZONE_DEVICE。

ZONE_DEVICE 是為支援熱插拔裝置而分配的非易失性記憶體( Non Volatile Memory ),也可用於核心崩潰時儲存相關的偵錯資訊。

ZONE_MOVABLE 是核心定義的一個虛擬記憶體區域,該區域中的物理頁可以來自於上邊介紹的幾種真實的物理區域。該區域中的頁全部都是可以遷移的,主要是為了防止記憶體碎片和支援記憶體的熱插拔。

既然有了這些實際的實體記憶體區域,那麼核心為什麼又要劃分出一個 ZONE_MOVABLE 這樣的虛擬記憶體區域呢

因為隨著系統的執行會伴隨著不同大小的實體記憶體頁的分配和釋放,這種記憶體不規則的分配釋放隨著系統的長時間執行就會導致記憶體碎片,記憶體碎片會使得系統在明明有足夠記憶體的情況下,依然無法為程序分配合適的記憶體。

如上圖所示,假如現在系統一共有 16 個實體記憶體頁,當前系統只是分配了 3 個物理頁,那麼在當前系統中還剩餘 13 個實體記憶體頁的情況下,如果核心想要分配 8 個連續的物理頁的話,就會由於記憶體碎片的存在導致分配失敗。(只能分配最多 4 個連續的物理頁)

核心中請求分配的物理頁面數只能是 2 的次冪!!

如果這些物理頁處於 ZONE_MOVABLE 區域,它們就可以被遷移,核心可以通過遷移頁面來避免記憶體碎片的問題:

核心通過遷移頁面來規整記憶體,這樣就可以避免記憶體碎片,從而得到一大片連續的實體記憶體,以滿足核心對大塊連續記憶體分配的請求。所以這就是核心需要根據物理頁面是否能夠遷移的特性,而劃分出 ZONE_MOVABLE 區域的目的

到這裡,我們已經清楚了 NUMA 節點中實體記憶體區域的劃分,下面我們繼續回到 struct pglist_data 結構中看下核心如何在 NUMA 節點中組織這些劃分出來的記憶體區域:

typedef struct pglist_data {
  // NUMA 節點中的實體記憶體區域個數
	int nr_zones; 
  // NUMA 節點中的實體記憶體區域
	struct zone node_zones[MAX_NR_ZONES];
  // NUMA 節點的備用列表
	struct zonelist node_zonelists[MAX_ZONELISTS];
} pg_data_t;

nr_zones 用於統計 NUMA 節點內包含的實體記憶體區域個數,不是每個 NUMA 節點都會包含以上介紹的所有實體記憶體區域,NUMA 節點之間所包含的實體記憶體區域個數是不一樣的

事實上只有第一個 NUMA 節點可以包含所有的實體記憶體區域,其它的節點並不能包含所有的區域型別,因為有些記憶體區域比如:ZONE_DMA,ZONE_DMA32 必須從實體記憶體的起點開始。這些在實體記憶體開始的區域可能已經被劃分到第一個 NUMA 節點了,後面的實體記憶體才會被依次劃分給接下來的 NUMA 節點。因此後面的 NUMA 節點並不會包含 ZONE_DMA,ZONE_DMA32 區域。

ZONE_NORMAL、ZONE_HIGHMEM 和 ZONE_MOVABLE 是可以出現在所有 NUMA 節點上的。

node_zones[MAX_NR_ZONES] 陣列包含了 NUMA 節點中的所有實體記憶體區域,實體記憶體區域在核心中的資料結構是 struct zone 。

node_zonelists[MAX_ZONELISTS] 是 struct zonelist 型別的陣列,它包含了備用 NUMA 節點和這些備用節點中的實體記憶體區域。備用節點是按照存取距離的遠近,依次排列在 node_zonelists 陣列中,陣列第一個備用節點是存取距離最近的,這樣當本節點記憶體不足時,可以從備用 NUMA 節點中分配記憶體。

各個 NUMA 節點之間的記憶體分配情況我們可以通過前邊介紹的 numastat 命令檢視。

4.4 NUMA 節點中的記憶體規整與回收

記憶體可以說是計算機系統中最為寶貴的資源了,再怎麼多也不夠用,當系統執行時間長了之後,難免會遇到記憶體緊張的時候,這時候就需要核心將那些不經常使用的記憶體頁面回收起來,或者將那些可以遷移的頁面進行記憶體規整,從而可以騰出連續的實體記憶體頁面供核心分配。

核心會為每個 NUMA 節點分配一個 kswapd 程序用於回收不經常使用的頁面,還會為每個 NUMA 節點分配一個 kcompactd 程序用於記憶體的規整避免記憶體碎片。

typedef struct pglist_data {
        .........
    // 頁面回收程序
    struct task_struct *kswapd;
    wait_queue_head_t kswapd_wait;
    // 記憶體規整程序
    struct task_struct *kcompactd;
    wait_queue_head_t kcompactd_wait;

        ..........
} pg_data_t;

NUMA 節點描述符 struct pglist_data 結構中的 struct task_struct *kswapd 屬性用於指向核心為 NUMA 節點分配的 kswapd 程序。

kswapd_wait 用於 kswapd 程序週期性回收頁面時使用到的等待佇列。

同理 struct task_struct *kcompactd 用於指向核心為 NUMA 節點分配的 kcompactd 程序。

kcompactd_wait 用於 kcompactd 程序週期性規整記憶體時使用到的等待佇列。

本小節筆者主要為大家介紹 NUMA 節點的資料結構 struct pglist_data。詳細的記憶體回收會在本文後面的章節單獨介紹。

4.5 NUMA 節點的狀態 node_states

如果系統中的 NUMA 節點多於一個,核心會維護一個點陣圖 node_states,用於維護各個 NUMA 節點的狀態資訊。

如果系統中只有一個 NUMA 節點,則沒有節點點陣圖。

節點點陣圖以及節點的狀態掩碼值定義在 /include/linux/nodemask.h 檔案中:

typedef struct { DECLARE_BITMAP(bits, MAX_NUMNODES); } nodemask_t;
extern nodemask_t node_states[NR_NODE_STATES];

節點的狀態可通過以下掩碼錶示:

enum node_states {
	N_POSSIBLE,		/* The node could become online at some point */
	N_ONLINE,		/* The node is online */
	N_NORMAL_MEMORY,	/* The node has regular memory */
#ifdef CONFIG_HIGHMEM
	N_HIGH_MEMORY,		/* The node has regular or high memory */
#else
	N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
#ifdef CONFIG_MOVABLE_NODE
	N_MEMORY,		/* The node has memory(regular, high, movable) */
#else
	N_MEMORY = N_HIGH_MEMORY,
#endif
	N_CPU,		/* The node has one or more cpus */
	NR_NODE_STATES
};

N_POSSIBLE 表示 NUMA 節點在某個時刻可以變為 online 狀態,N_ONLINE 表示 NUMA 節點當前的狀態為 online 狀態。

我們在本文《2.3.1 實體記憶體熱插拔》小節中提到,在稀疏記憶體模型中,NUMA 節點的狀態可以在系統執行的過程中隨時切換 online ,offline 的狀態,用來支援記憶體的熱插拔。

N_NORMAL_MEMORY 表示節點沒有高階記憶體,只有 ZONE_NORMAL 記憶體區域。

N_HIGH_MEMORY 表示節點有 ZONE_NORMAL 記憶體區域或者有 ZONE_HIGHMEM 記憶體區域。

N_MEMORY 表示節點有 ZONE_NORMAL,ZONE_HIGHMEM,ZONE_MOVABLE 記憶體區域。

N_CPU 表示節點包含一個或多個 CPU。

此外核心還提供了兩個輔助函數用於設定或者清除指定節點的特定狀態:

static inline void node_set_state(int node, enum node_states state)
static inline void node_clear_state(int node, enum node_states state)

核心提供了 for_each_node_state 宏用於迭代處於特定狀態的所有 NUMA 節點。

#define for_each_node_state(__node, __state) \
	for_each_node_mask((__node), node_states[__state])

比如:for_each_online_node 用於迭代所有 online 的 NUMA 節點:

#define for_each_online_node(node) for_each_node_state(node, N_ONLINE)

5. 核心如何管理 NUMA 節點中的實體記憶體區域

在前邊《4.3 NUMA 節點實體記憶體區域的劃分》小節的介紹中,由於實際的電腦架構受到硬體方面的制約,間接限制了頁框的使用方式。於是核心會根據各個實體記憶體區域的功能不同,將 NUMA 節點內的實體記憶體劃分為:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 這幾個實體記憶體區域。

ZONE_MOVABLE 區域是核心從邏輯上的劃分,區域中的物理頁面來自於上述幾個記憶體區域,目的是避免記憶體碎片和支援記憶體熱插拔(前邊筆者已經介紹過了)。

我們可以通過 cat /proc/zoneinfo | grep Node 命令來檢視 NUMA 節點中記憶體區域的分佈情況:

筆者使用的伺服器是 64 位,所以不包含 ZONE_HIGHMEM 區域。

通過 cat /proc/zoneinfo 命令來檢視系統中各個 NUMA 節點中的各個記憶體區域的記憶體使用情況:

下圖中我們以 NUMA Node 0 中的 ZONE_NORMAL 區域為例說明,大家只需要瀏覽一個大概,圖中每個欄位的含義筆者會在本小節的後面一一為大家介紹~~~

核心中用於描述和管理 NUMA 節點中的實體記憶體區域的結構體是 struct zone,上圖中顯示的 ZONE_NORMAL 區域中,實體記憶體使用統計的相關資料均來自於 struct zone 結構體,我們先來看一下核心對 struct zone 結構體的整體佈局情況:

struct zone {

    .............省略..............

    ZONE_PADDING(_pad1_)

    .............省略..............

    ZONE_PADDING(_pad2_)

    .............省略..............

    ZONE_PADDING(_pad3_)

    .............省略..............

} ____cacheline_internodealigned_in_smp;

由於 struct zone 結構體在核心中是一個存取非常頻繁的結構體,在多處理器系統中,會有不同的 CPU 同時大量頻繁的存取 struct zone 結構體中的不同欄位。

因此核心對 struct zone 結構體的設計是相當考究的,將這些頻繁存取的欄位資訊歸類為 4 個部分,並通過 ZONE_PADDING 來分割。

目的是通過 ZONE_PADDING 來填充位元組,將這四個部分,分別填充到不同的 CPU 快取記憶體行(cache line)中,使得它們各自獨佔 cache line,提高存取效能。

根據前邊實體記憶體區域劃分的相關內容介紹,我們知道核心會把 NUMA 節點中的實體記憶體區域頂多劃分為 ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 這幾個實體記憶體區域。因此 struct zone 的範例在核心中會相對比較少,通過 ZONE_PADDING 填充位元組,帶來的 struct zone 結構體範例記憶體佔用增加是可以忽略不計的。

在結構體的最後核心還是用了 ____cacheline_internodealigned_in_smp 編譯器關鍵字來實現最優的快取記憶體行對齊方式。

關於 CPU 快取記憶體行對齊的詳細內容,感興趣的同學可以回看下筆者之前的文章 《一文聊透物件在JVM中的記憶體佈局,以及記憶體對齊和壓縮指標的原理及應用》

筆者為了使大家能夠更好地理解核心如何使用 struct zone 結構體來描述記憶體區域,從而把結構體中的欄位按照一定的層次結構重新排列介紹,這並不是原生的欄位對齊方式,這一點需要大家注意!!!

struct zone {
    // 防止並行存取該記憶體區域
    spinlock_t      lock;
    // 記憶體區域名稱:Normal ,DMA,HighMem
    const char      *name;
    // 指向該記憶體區域所屬的 NUMA 節點
    struct pglist_data  *zone_pgdat;
    // 屬於該記憶體區域中的第一個物理頁 PFN
    unsigned long       zone_start_pfn;
    // 該記憶體區域中所有的物理頁個數(包含記憶體空洞)
    unsigned long       spanned_pages;
    // 該記憶體區域所有可用的物理頁個數(不包含記憶體空洞)
    unsigned long       present_pages;
    // 被夥伴系統所管理的物理頁數
    atomic_long_t       managed_pages;
    // 夥伴系統的核心資料結構
    struct free_area    free_area[MAX_ORDER];
    // 該記憶體區域記憶體使用的統計資訊
    atomic_long_t       vm_stat[NR_VM_ZONE_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;

struct zone 是會被核心頻繁存取的一個結構體,在多核處理器中,多個 CPU 會並行存取 struct zone,為了防止並行存取,核心使用了一把 spinlock_t lock 自旋鎖來防止並行錯誤以及不一致。

name 屬性會根據該記憶體區域的型別不同儲存記憶體區域的名稱,比如:Normal ,DMA,HighMem 等。

前邊我們介紹 NUMA 節點的描述符 struct pglist_data 的時候提到,pglist_data 通過 struct zone 型別的陣列 node_zones 將 NUMA 節點中劃分的實體記憶體區域連線起來。

typedef struct pglist_data {
    // NUMA 節點中的實體記憶體區域個數
    int nr_zones; 
    // NUMA 節點中的實體記憶體區域
    struct zone node_zones[MAX_NR_ZONES];
}

這些實體記憶體區域也會通過 struct zone 中的 zone_pgdat 指向自己所屬的 NUMA 節點。

NUMA 節點 struct pglist_data 結構中的 node_start_pfn 指向 NUMA 節點內第一個物理頁的 PFN。同理實體記憶體區域 struct zone 結構中的 zone_start_pfn 指向的是該記憶體區域內所管理的第一個物理頁面 PFN 。

後面的屬性也和 NUMA 節點對應的欄位含義一樣,比如:spanned_pages 表示該記憶體區域內所有的物理頁總數(包含記憶體空洞),通過 spanned_pages = zone_end_pfn - zone_start_pfn 計算得到。

present_pages 則表示該記憶體區域內所有實際可用的物理頁面總數(不包含記憶體空洞),通過 present_pages = spanned_pages - absent_pages(pages in holes) 計算得到。

在 NUMA 架構下,實體記憶體被劃分成了一個一個的記憶體節點(NUMA 節點),在每個 NUMA 節點內部又將其所管理的實體記憶體按照功能不同劃分成了不同的記憶體區域,每個記憶體區域管理一片用於具體功能的實體記憶體,而核心會為每一個記憶體區域分配一個夥伴系統用於管理該記憶體區域下實體記憶體的分配和釋放。

實體記憶體在核心中管理的層級關係為:None -> Zone -> page

struct zone 結構中的 managed_pages 用於表示該記憶體區域內被夥伴系統所管理的物理頁數量。

陣列 free_area[MAX_ORDER] 是夥伴系統的核心資料結構,筆者會在後面的系列文章中詳細為大家介紹夥伴系統的實現。

vm_stat 維護了該記憶體區域實體記憶體的使用統計資訊,前邊介紹的 cat /proc/zoneinfo命令的輸出資料就來源於這個 vm_stat。

5.1 實體記憶體區域中的預留記憶體

除了前邊介紹的關於實體記憶體區域的這些基本資訊之外,每個實體記憶體區域 struct zone 還為作業系統預留了一部分記憶體,這部分預留的實體記憶體用於核心的一些核心操作,這些操作無論如何是不允許記憶體分配失敗的。

什麼意思呢?核心中關於記憶體分配的場景無外乎有兩種方式:

  1. 當程序請求核心分配記憶體時,如果此時記憶體比較充裕,那麼程序的請求會被立刻滿足,如果此時記憶體已經比較緊張,核心就需要將一部分不經常使用的記憶體進行回收,從而騰出一部分記憶體滿足程序的記憶體分配的請求,在這個回收記憶體的過程中,程序會一直阻塞等待。

  2. 另一種記憶體分配場景,程序是不允許阻塞的,記憶體分配的請求必須馬上得到滿足,比如執行中斷處理程式或者執行持有自旋鎖等臨界區內的程式碼時,程序就不允許睡眠,因為中斷程式無法被重新排程。這時就需要核心提前為這些核心操作預留一部分記憶體,當記憶體緊張時,可以使用這部分預留的記憶體給這些操作分配。

struct zone {
             ...........

    unsigned long nr_reserved_highatomic;
    long lowmem_reserve[MAX_NR_ZONES];
            
             ...........
}

nr_reserved_highatomic 表示的是該記憶體區域內預留記憶體的大小,範圍為 128 到 65536 KB 之間。

lowmem_reserve 陣列則是用於規定每個記憶體區域必須為自己保留的物理頁數量,防止更高位的記憶體區域對自己的記憶體空間進行過多的侵佔擠壓。

那麼什麼是高位記憶體區域 ?什麼是低位記憶體區域 ? 高位記憶體區域為什麼會對低位記憶體區域進行侵佔擠壓呢 ?

因為實體記憶體區域比如前邊介紹的 ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 這些都是針對實體記憶體進行的劃分,所謂的低位記憶體區域和高位記憶體區域其實還是按照實體記憶體地址從低到高進行排列布局:

根據實體記憶體地址的高低,低位記憶體區域到高位記憶體區域的順序依次是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。

高位記憶體區域為什麼會對低位記憶體區域進行擠壓呢

一些用於特定功能的實體記憶體必須從特定的記憶體區域中進行分配,比如外設的 DMA 控制器就必須從 ZONE_DMA 或者 ZONE_DMA32 中分配記憶體。

但是一些用於常規用途的實體記憶體則可以從多個實體記憶體區域中進行分配,當 ZONE_HIGHMEM 區域中的記憶體不足時,核心可以從 ZONE_NORMAL 進行記憶體分配,ZONE_NORMAL 區域記憶體不足時可以進一步降級到 ZONE_DMA 區域進行分配。

而低位記憶體區域中的記憶體總是寶貴的,核心肯定希望這些用於常規用途的實體記憶體從常規記憶體區域中進行分配,這樣能夠節省 ZONE_DMA 區域中的實體記憶體保證 DMA 操作的記憶體使用需求,但是如果記憶體很緊張了,高位記憶體區域中的實體記憶體不夠用了,那麼核心就會去佔用擠壓其他記憶體區域中的實體記憶體從而滿足記憶體分配的需求。

但是核心又不會允許高位記憶體區域對低位記憶體區域的無限制擠壓佔用,因為畢竟低位記憶體區域有它特定的用途,所以每個記憶體區域會給自己預留一定的記憶體,防止被高位記憶體區域擠壓佔用。而每個記憶體區域為自己預留的這部分記憶體就儲存在 lowmem_reserve 陣列中。

每個記憶體區域是按照一定的比例來計算自己的預留記憶體的,這個比例我們可以通過 cat /proc/sys/vm/lowmem_reserve_ratio 命令檢視:

從左到右分別代表了 ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_MOVABLE,ZONE_DEVICE 實體記憶體區域的預留記憶體比例。

筆者使用的伺服器是 64 位,所以沒有 ZONE_HIGHMEM 區域。

那麼每個記憶體區域如何根據各自的 lowmem_reserve_ratio 來計算各自區域中的預留記憶體大小呢

為了讓大家更好的理解,下面我們以 ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM 這三個實體記憶體區域舉例,它們的 lowmem_reserve_ratio 分別為 256,32,0。它們的大小分別是:8M,64M,256M,按照每頁大小 4K 計算它們區域裡包含的物理頁個數分別為:2048, 16384, 65536。

lowmem_reserve_ratio 記憶體區域大小 實體記憶體頁個數
ZONE_DMA 256 8M 2048
ZONE_NORMAL 32 64M 16384
ZONE_HIGHMEM 0 256M 65536
  • ZONE_DMA 為防止被 ZONE_NORMAL 擠壓侵佔,而為自己預留的實體記憶體頁為:16384 / 256 = 64

  • ZONE_DMA 為防止被 ZONE_HIGHMEM 擠壓侵佔而為自己預留的實體記憶體頁為:(65536 + 16384) / 256 = 320

  • ZONE_NORMAL 為防止被 ZONE_HIGHMEM 擠壓侵佔而為自己預留的實體記憶體頁為:65536 / 32 = 2048

各個記憶體區域為防止被高位記憶體區域過度擠壓佔用,而為自己預留的記憶體大小,我們可以通過前邊 cat /proc/zoneinfo 命令來檢視,輸出資訊的 protection:則表示各個記憶體區域預留記憶體大小。

此外我們還可以通過 sysctl 對核心引數 lowmem_reserve_ratio 進行動態調整,這樣核心會根據新的 lowmem_reserve_ratio 動態重新計算各個記憶體區域的預留記憶體大小。

前面介紹的實體記憶體區域內被夥伴系統所管理的物理頁數量 managed_pages 的計算方式就通過 present_pages 減去這些預留的實體記憶體頁 reserved_pages 得到的。

調整核心引數的多種方法,筆者在《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》 一文中的 "13.6 髒頁回寫引數的相關設定方式" 小節中已經詳細介紹過了,感興趣的同學可以在回看下。

5.2 實體記憶體區域中的水位線

記憶體資源是系統中最寶貴的系統資源,是有限的。當記憶體資源緊張的時候,系統的應對方法無非就是三種:

  1. 產生 OOM,核心直接將系統中佔用大量記憶體的程序,將 OOM 優先順序最高的程序幹掉,釋放出這個程序佔用的記憶體供其他更需要的程序分配使用。

  2. 記憶體回收,將不經常使用到的記憶體回收,騰挪出來的記憶體供更需要的程序分配使用。

  3. 記憶體規整,將可遷移的物理頁面進行遷移規整,消除記憶體碎片。從而獲得更大的一片連續實體記憶體空間供程序分配。

我們都知道,核心將實體記憶體劃分成一頁一頁的單位進行管理(每頁 4K 大小)。記憶體回收的單位也是按頁來的。在核心中,實體記憶體頁有兩種型別,針對這兩種型別的實體記憶體頁,核心會有不同的回收機制。

第一種就是檔案頁,所謂檔案頁就是其實體記憶體頁中的資料來自於磁碟中的檔案,當我們進行檔案讀取的時候,核心會根據區域性性原理將讀取的磁碟資料快取在 page cache 中,page cache 裡存放的就是檔案頁。當程序再次讀取讀檔案頁中的資料時,核心直接會從 page cache 中獲取並拷貝給程序,省去了讀取磁碟的開銷。

對於檔案頁的回收通常會比較簡單,因為檔案頁中的資料來自於磁碟,所以當回收檔案頁的時候直接回收就可以了,當程序再次讀取檔案頁時,大不了再從磁碟中重新讀取就是了。

但是當程序已經對檔案頁進行修改過但還沒來得及同步回磁碟,此時檔案頁就是髒頁,不能直接進行回收,需要先將髒頁回寫到磁碟中才能進行回收。

我們可以在程序中通過 fsync() 系統呼叫將指定檔案的所有髒頁同步回寫到磁碟,同時核心也會根據一定的條件喚醒專門用於回寫髒頁的 pflush 核心執行緒。

關於檔案頁相關的詳細內容,感興趣的同學可以回看下筆者的這篇文章 《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》

而另外一種物理頁型別是匿名頁,所謂匿名頁就是它背後並沒有一個磁碟中的檔案作為資料來源,匿名頁中的資料都是通過程序執行過程中產生的,比如我們應用程式中動態分配的堆記憶體。

當記憶體資源緊張需要對不經常使用的那些匿名頁進行回收時,因為匿名頁的背後沒有一個磁碟中的檔案做依託,所以匿名頁不能像檔案頁那樣直接回收,無論匿名頁是不是髒頁,都需要先將匿名頁中的資料先儲存在磁碟空間中,然後在對匿名頁進行回收。

並把釋放出來的這部分記憶體分配給更需要的程序使用,當程序再次存取這塊記憶體時,在重新把之前匿名頁中的資料從磁碟空間中讀取到記憶體就可以了,而這塊磁碟空間可以是單獨的一片磁碟分割區(Swap 分割區)或者是一個特殊的檔案(Swap 檔案)。匿名頁的回收機制就是我們經常看到的 Swap 機制。

所謂的頁面換出就是在 Swap 機制下,當記憶體資源緊張時,核心就會把不經常使用的這些匿名頁中的資料寫入到 Swap 分割區或者 Swap 檔案中。從而釋放這些資料所佔用的記憶體空間。

所謂的頁面換入就是當程序再次存取那些被換出的資料時,核心會重新將這些資料從 Swap 分割區或者 Swap 檔案中讀取到記憶體中來。

綜上所述,實體記憶體區域中的記憶體回收分為檔案頁回收(通過 pflush 核心執行緒)和匿名頁回收(通過 kswapd 核心程序)。Swap 機制主要針對的是匿名頁回收。

那麼當記憶體緊張的時候,核心到底是該回收檔案頁呢?還是該回收匿名頁呢

事實上 Linux 提供了一個 swappiness 的核心選項,我們可以通過 cat /proc/sys/vm/swappiness 命令檢視,swappiness 選項的取值範圍為 0 到 100,預設為 60。

swappiness 用於表示 Swap 機制的積極程度,數值越大,Swap 的積極程度越高,核心越傾向於回收匿名頁。數值越小,Swap 的積極程度越低。核心就越傾向於回收檔案頁。

注意: swappiness 只是表示 Swap 積極的程度,當記憶體非常緊張的時候,即使將 swappiness 設定為 0 ,也還是會發生 Swap 的。

那麼到底什麼時候記憶體才算是緊張的?緊張到什麼程度才開始 Swap 呢?這一切都需要一個量化的標準,於是就有了本小節的主題 —— 實體記憶體區域中的水位線。

核心會為每個 NUMA 節點中的每個實體記憶體區域客製化三條用於指示記憶體容量的水位線,分別是:WMARK_MIN(頁最小閾值), WMARK_LOW (頁低閾值),WMARK_HIGH(頁高閾值)。

這三條水位線定義在 /include/linux/mmzone.h 檔案中:

enum zone_watermarks {
	WMARK_MIN,
	WMARK_LOW,
	WMARK_HIGH,
	NR_WMARK
};

#define min_wmark_pages(z) (z->_watermark[WMARK_MIN] + z->watermark_boost)
#define low_wmark_pages(z) (z->_watermark[WMARK_LOW] + z->watermark_boost)
#define high_wmark_pages(z) (z->_watermark[WMARK_HIGH] + z->watermark_boost)

這三條水位線對應的 watermark 數值儲存在每個實體記憶體區域 struct zone 結構中的 _watermark[NR_WMARK] 陣列中。

struct zone {
    // 實體記憶體區域中的水位線
    unsigned long _watermark[NR_WMARK];
    // 優化記憶體碎片對記憶體分配的影響,可以動態改變記憶體區域的基準水位線。
    unsigned long watermark_boost;

} ____cacheline_internodealigned_in_smp;

注意:下面提到的實體記憶體區域的剩餘記憶體是需要刨去上小節介紹的 lowmem_reserve 預留記憶體大小。

  • 當該實體記憶體區域的剩餘記憶體容量高於 _watermark[WMARK_HIGH] 時,說明此時該實體記憶體區域中的記憶體容量非常充足,記憶體分配完全沒有壓力。

  • 當剩餘記憶體容量在 _watermark[WMARK_LOW] 與_watermark[WMARK_HIGH] 之間時,說明此時記憶體有一定的消耗但是還可以接受,能夠繼續滿足程序的記憶體分配需求。

  • 當剩餘內容容量在 _watermark[WMARK_MIN] 與 _watermark[WMARK_LOW] 之間時,說明此時記憶體容量已經有點危險了,記憶體分配面臨一定的壓力,但是還可以滿足程序的記憶體分配要求,當給程序分配完記憶體之後,就會喚醒 kswapd 程序開始記憶體回收,直到剩餘記憶體高於 _watermark[WMARK_HIGH] 為止。

在這種情況下,程序的記憶體分配會觸發記憶體回收,但請求程序本身不會被阻塞,由核心的 kswapd 程序非同步回收記憶體。

  • 當剩餘內容容量低於 _watermark[WMARK_MIN] 時,說明此時的內容容量已經非常危險了,如果程序在這時請求記憶體分配,核心就會進行直接記憶體回收,這時請求程序會同步阻塞等待,直到記憶體回收完畢。

位於 _watermark[WMARK_MIN] 以下的記憶體容量是預留給核心在緊急情況下使用的,這部分記憶體就是我們在 《5.1 實體記憶體區域中的預留記憶體》小節中介紹的預留記憶體 nr_reserved_highatomic。

我們可以通過 cat /proc/zoneinfo 命令來檢視不同 NUMA 節點中不同記憶體區域中的水位線:

其中大部分欄位的含義筆者已經在前面的章節中為大家介紹過了,下面我們只介紹和本小節內容相關的欄位含義:

  • free 就是該實體記憶體區域內剩餘的記憶體頁數,它的值和後面的 nr_free_pages 相同。

  • min、low、high 就是上面提到的三條記憶體水位線:_watermark[WMARK_MIN],_watermark[WMARK_LOW] ,_watermark[WMARK_HIGH]。

  • nr_zone_active_anon 和 nr_zone_inactive_anon 分別是該記憶體區域內活躍和非活躍的匿名頁數量。

  • nr_zone_active_file 和 nr_zone_inactive_file 分別是該記憶體區域內活躍和非活躍的檔案頁數量。

5.3 水位線的計算

在上小節中我們介紹了核心通過對實體記憶體區域設定記憶體水位線來決定記憶體回收的時機,那麼這三條記憶體水位線的值具體是多少,核心中是根據什麼計算出來的呢?

事實上 WMARK_MIN,WMARK_LOW ,WMARK_HIGH 這三個水位線的數值是通過核心引數 /proc/sys/vm/min_free_kbytes 為基準分別計算出來的,使用者也可以通過 sysctl 來動態設定這個核心引數。

核心引數 min_free_kbytes 的單位為 KB 。

通常情況下 WMARK_LOW 的值是 WMARK_MIN 的 1.25 倍,WMARK_HIGH 的值是 WMARK_LOW 的 1.5 倍。而 WMARK_MIN 的數值就是由這個核心引數 min_free_kbytes 來決定的。

下面我們就來看下核心中關於 min_free_kbytes 的計算方式:

5.4 min_free_kbytes 的計算邏輯

以下計算邏輯是針對 64 位系統中記憶體區域水位線的計算,在 64 位系統中沒有高階記憶體 ZONE_HIGHMEM 區域。

min_free_kbytes 的計算邏輯定義在核心檔案 /mm/page_alloc.cinit_per_zone_wmark_min 方法中,用於計算最小水位線 WMARK_MIN 的數值也就是這裡的 min_free_kbytes (單位為 KB)。 水位線的單位是實體記憶體頁的數量。

int __meminit init_per_zone_wmark_min(void)
{
  // 低位記憶體區域(除高階記憶體之外)的總和
	unsigned long lowmem_kbytes;
  // 待計算的 min_free_kbytes
	int new_min_free_kbytes;

  // 將低位記憶體區域記憶體容量總的頁數轉換為 KB
	lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);
  // min_free_kbytes 計算邏輯:對 lowmem_kbytes * 16 進行開平方
	new_min_free_kbytes = int_sqrt(lowmem_kbytes * 16);
  // min_free_kbytes 的範圍為 128 到 65536 KB 之間
	if (new_min_free_kbytes > user_min_free_kbytes) {
		min_free_kbytes = new_min_free_kbytes;
		if (min_free_kbytes < 128)
			min_free_kbytes = 128;
		if (min_free_kbytes > 65536)
			min_free_kbytes = 65536;
	} else {
		pr_warn("min_free_kbytes is not updated to %d because user defined value %d is preferred\n",
				new_min_free_kbytes, user_min_free_kbytes);
	}
  // 計算記憶體區域內的三條水位線
	setup_per_zone_wmarks();
  // 計算記憶體區域的預留記憶體大小,防止被高位記憶體區域過度擠壓佔用
	setup_per_zone_lowmem_reserve();
        .............省略................
	return 0;
}
core_initcall(init_per_zone_wmark_min)

首先我們需要先計算出當前 NUMA 節點中所有低位記憶體區域(除高階記憶體之外)中記憶體總容量之和。也即是說 lowmem_kbytes 的值為: ZONE_DMA 區域中 managed_pages + ZONE_DMA32 區域中 managed_pages + ZONE_NORMAL 區域中 managed_pages 。

lowmem_kbytes 的計算邏輯在 nr_free_zone_pages 方法中:

/**
 * nr_free_zone_pages - count number of pages beyond high watermark
 * @offset: The zone index of the highest zone
 *
 * nr_free_zone_pages() counts the number of counts pages which are beyond the
 * high watermark within all zones at or below a given zone index.  For each
 * zone, the number of pages is calculated as:
 *     managed_pages - high_pages
 */
static unsigned long nr_free_zone_pages(int offset)
{
	struct zoneref *z;
	struct zone *zone;

	unsigned long sum = 0;
    // 獲取當前 NUMA 節點中的所有實體記憶體區域 zone
	struct zonelist *zonelist = node_zonelist(numa_node_id(), GFP_KERNEL);
    // 計算所有實體記憶體區域內 managed_pages - high_pages 的總和
	for_each_zone_zonelist(zone, z, zonelist, offset) {
		unsigned long size = zone->managed_pages;
		unsigned long high = high_wmark_pages(zone);
		if (size > high)
			sum += size - high;
	}
    // lowmem_kbytes 的值
	return sum;
}

nr_free_zone_pages 方法上面的註釋大家可能看的有點蒙,這裡需要為大家解釋一下,nr_free_zone_pages 方法的計算邏輯本意是給定一個 zone index (方法引數 offset),計算範圍為:這個給定 zone 下面的所有低位記憶體區域。

nr_free_zone_pages 方法會計算這些低位記憶體區域內在 high watermark 水位線之上的記憶體容量( managed_pages - high_pages )之和。作為該方法的返回值。

但此時我們正準備計算這些水位線,水位線還沒有值,所以此時這個方法的語意就是計算低位記憶體區域內被夥伴系統所管理的記憶體容量( managed_pages )之和。也就是我們想要的 lowmem_kbytes。

接下來在 init_per_zone_wmark_min 方法中會對 lowmem_kbytes * 16 進行開平方得到 new_min_free_kbytes。

如果計算出的 new_min_free_kbytes 大於使用者設定的核心引數值 /proc/sys/vm/min_free_kbytes ,那麼最終 min_free_kbytes 就是 new_min_free_kbytes。如果小於使用者設定的值,那麼就採用使用者指定的 min_free_kbytes 。

min_free_kbytes 的取值範圍限定在 128 到 65536 KB 之間。

隨後核心會根據這個 min_free_kbytes 在 setup_per_zone_wmarks() 方法中計算出該實體記憶體區域的三條水位線。

最後在 setup_per_zone_lowmem_reserve() 方法中計算記憶體區域的預留記憶體大小,防止被高位記憶體區域過度擠壓佔用。該方法的邏輯就是我們在《5.1 實體記憶體區域中的預留記憶體》小節中提到的內容。

5.5 setup_per_zone_wmarks 計算水位線

這裡我們依然不會考慮高階記憶體區域 ZONE_HIGHMEM。

實體記憶體區域內的三條水位線:WMARK_MIN,WMARK_LOW,WMARK_HIGH 的最終計算邏輯是在 __setup_per_zone_wmarks 方法中完成的:

static void __setup_per_zone_wmarks(void)
{
  // 將 min_free_kbytes 轉換為頁
	unsigned long pages_min = min_free_kbytes >> (PAGE_SHIFT - 10);
  // 所有低位記憶體區域 managed_pages 之和
	unsigned long lowmem_pages = 0;
	struct zone *zone;
	unsigned long flags;

	/* Calculate total number of !ZONE_HIGHMEM pages */
	for_each_zone(zone) {
		if (!is_highmem(zone))
			lowmem_pages += zone->managed_pages;
	}

  // 迴圈計算各個記憶體區域中的水位線
	for_each_zone(zone) {
		u64 tmp;
		tmp = (u64)pages_min * zone->managed_pages;
  // 計算 WMARK_MIN 水位線的核心方法
		do_div(tmp, lowmem_pages);
		if (is_highmem(zone)) {
            ...........省略高階記憶體區域............
		} else {
    // WMARK_MIN水位線
			zone->watermark[WMARK_MIN] = tmp;
		}
  // 這裡可暫時忽略
		tmp = max_t(u64, tmp >> 2,
			    mult_frac(zone->managed_pages,
				      watermark_scale_factor, 10000));

		zone->watermark[WMARK_LOW]  = min_wmark_pages(zone) + tmp;
		zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;
	}
}

在 for_each_zone 迴圈內依次遍歷 NUMA 節點中的所有記憶體區域 zone,計算每個記憶體區域 zone 裡的記憶體水位線。其中計算 WMARK_MIN 水位線的核心邏輯封裝在 do_div 方法中,在 do_div 方法中會先計算每個 zone 記憶體容量之間的比例,然後根據這個比例去從 min_free_kbytes 中劃分出對應 zone 的 WMARK_MIN 水位線來。

比如:當前 NUMA 節點中有兩個 zone :ZONE_DMA 和 ZONE_NORMAL,記憶體容量大小分別是:100 M 和 800 M。那麼 ZONE_DMA 與 ZONE_NORMAL 之間的比例就是 1 :8。

根據這個比例,ZONE_DMA 區域裡的 WMARK_MIN 水位線就是:min_free_kbytes * 1 / 8 。ZONE_NORMAL 區域裡的 WMARK_MIN 水位線就是:min_free_kbytes * 7 / 8

計算出了 WMARK_MIN 的值,那麼接下來 WMARK_LOW, WMARK_HIGH 的值也就好辦了,它們都是基於 WMARK_MIN 計算出來的。

WMARK_LOW 的值是 WMARK_MIN 的 1.25 倍,WMARK_HIGH 的值是 WMARK_LOW 的 1.5 倍。

此外,大家可能對下面這段程式碼比較有疑問?

      /*
         * Set the kswapd watermarks distance according to the
         * scale factor in proportion to available memory, but
         * ensure a minimum size on small systems.
         */
        tmp = max_t(u64, tmp >> 2,
                mult_frac(zone->managed_pages,
                      watermark_scale_factor, 10000));

這段程式碼主要是通過核心引數 watermark_scale_factor 來調節水位線:WMARK_MIN,WMARK_LOW,WMARK_HIGH 之間的間距,那麼為什麼要調整水位線之間的間距大小呢?

5.6 watermark_scale_factor 調整水位線的間距

為了避免核心的直接記憶體回收 direct reclaim 阻塞程序影響系統的效能,所以我們需要儘量保持記憶體區域中的剩餘記憶體容量儘量在 WMARK_MIN 水位線之上,但是有一些極端情況,比如突然遇到網路流量增大,需要短時間內申請大量的記憶體來存放網路請求資料,此時 kswapd 回收記憶體的速度可能趕不上記憶體分配的速度,從而造成直接記憶體回收 direct reclaim,影響系統效能。

在記憶體分配過程中,剩餘記憶體容量處於 WMARK_MIN 與 WMARK_LOW 水位線之間會喚醒 kswapd 程序來回收記憶體,直到記憶體容量恢復到 WMARK_HIGH 水位線之上。

剩餘記憶體容量低於 WMARK_MIN 水位線時就會觸發直接記憶體回收 direct reclaim。

而剩餘記憶體容量高於 WMARK_LOW 水位線又不會喚醒 kswapd 程序,因此 kswapd 程序活動的關鍵範圍在 WMARK_MIN 與 WMARK_LOW 之間,而為了應對這種突發的網路流量暴增,我們需要保證 kswapd 程序活動的範圍大一些,這樣核心就能夠時刻進行記憶體回收使得剩餘記憶體容量較長時間的保持在 WMARK_HIGH 水位線之上。

這樣一來就要求 WMARK_MIN 與 WMARK_LOW 水位線之間的間距不能太小,因為 WMARK_LOW 水位線之上就不會喚醒 kswapd 程序了。

因此核心引入了 /proc/sys/vm/watermark_scale_factor 引數來調節水位線之間的間距。該核心引數預設值為 10,最大值為 3000。

那麼如何使用 watermark_scale_factor 引數調整水位線之間的間距呢?

水位線間距計算公式:(watermark_scale_factor / 10000) * managed_pages 。

        zone->watermark[WMARK_MIN] = tmp;
        // 水位線間距的計算邏輯
        tmp = max_t(u64, tmp >> 2,
                mult_frac(zone->managed_pages,
                      watermark_scale_factor, 10000));

        zone->watermark[WMARK_LOW]  = min_wmark_pages(zone) + tmp;
        zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;

在核心中水位線間距計算邏輯是:(WMARK_MIN / 4) 與 (zone_managed_pages * watermark_scale_factor / 10000) 之間較大的那個值。

使用者可以通過 sysctl 來動態調整 watermark_scale_factor 引數,核心會動態重新計算水位線之間的間距,使得 WMARK_MIN 與 WMARK_LOW 之間留有足夠的緩衝餘地,使得 kswapd 能夠有時間回收足夠的記憶體,從而解決直接記憶體回收導致的效能抖動問題

5.7 實體記憶體區域中的冷熱頁

之前筆者在《一文聊透物件在JVM中的記憶體佈局,以及記憶體對齊和壓縮指標的原理及應用》 一文中為大家介紹 CPU 的快取記憶體時曾提到過,根據摩爾定律:晶片中的電晶體數量每隔 18 個月就會翻一番。導致 CPU 的效能和處理速度變得越來越快,而提升 CPU 的執行速度比提升記憶體的執行速度要容易和便宜的多,所以就導致了 CPU 與記憶體之間的速度差距越來越大。

CPU 與 記憶體之間的速度差異到底有多大呢? 我們知道暫存器是離 CPU 最近的,CPU 在存取暫存器的時候速度近乎於 0 個時鐘週期,存取速度最快,基本沒有時延。而存取記憶體則需要 50 - 200 個時鐘週期。

所以為了彌補 CPU 與記憶體之間巨大的速度差異,提高CPU的處理效率和吞吐,於是我們引入了 L1 , L2 , L3 快取記憶體整合到 CPU 中。CPU 存取快取記憶體僅需要用到 1 - 30 個時鐘週期,CPU 中的快取記憶體是對記憶體熱點資料的一個快取。

CPU 存取快取記憶體的速度比存取記憶體的速度快大約10倍,引入快取記憶體的目的在於消除CPU與記憶體之間的速度差距,CPU 用快取記憶體來用來存放記憶體中的熱點資料。

另外我們根據程式的時間區域性性原理可以知道,記憶體的資料一旦被存取,那麼它很有可能在短期內被再次存取,如果我們把經常存取的實體記憶體頁快取在 CPU 的快取記憶體中,那麼當程序再次存取的時候就會直接命中 CPU 的快取記憶體,避免了進一步對記憶體的存取,極大提升了應用程式的效能。

程式區域性性原理表現為:時間區域性性和空間區域性性。時間區域性性是指如果程式中的某條指令一旦執行,則不久之後該指令可能再次被執行;如果某塊資料被存取,則不久之後該資料可能再次被存取。空間區域性性是指一旦程式存取了某個儲存單元,則不久之後,其附近的儲存單元也將被存取。

本文我們的主題是 Linux 實體記憶體的管理,那麼在 NUMA 記憶體架構下,這些 NUMA 節點中的實體記憶體區域 zone 管理的這些實體記憶體頁,哪些是在 CPU 的快取記憶體中?哪些又不在 CPU 的快取記憶體中呢?核心如何來管理這些載入進 CPU 快取記憶體中的實體記憶體頁呢?

本小節標題中所謂的熱頁就是已經載入進 CPU 快取記憶體中的實體記憶體頁,所謂的冷頁就是還未載入進 CPU 快取記憶體中的實體記憶體頁,冷頁是熱頁的後備選項。

筆者先以核心版本 2.6.25 之前的冷熱頁相關的管理邏輯為大家講解,因為這個版本的邏輯比較直觀,大家更容易理解。在這個基礎之上,筆者會在介紹核心 5.0 版本對於冷熱頁管理的邏輯,差別不是很大。

struct zone {
    struct per_cpu_pageset	pageset[NR_CPUS];
}

在 2.6.25 版本之前的核心原始碼中,實體記憶體區域 struct zone 包含了一個 struct per_cpu_pageset 型別的陣列 pageset。其中核心關於冷熱頁的管理全部封裝在 struct per_cpu_pageset 結構中。

因為每個 CPU 都有自己獨立的快取記憶體,所以每個 CPU 對應一個 per_cpu_pageset 結構,pageset 陣列容量 NR_CPUS 是一個可以在編譯期間設定的宏常數,表示核心可以支援的最大 CPU個數,注意該值並不是系統實際存在的 CPU 數量。

在 NUMA 記憶體架構下,每個實體記憶體區域都是屬於一個特定的 NUMA 節點,NUMA 節點中包含了一個或者多個 CPU,NUMA 節點中的每個記憶體區域會關聯到一個特定的 CPU 上,但 struct zone 結構中的 pageset 陣列包含的是系統中所有 CPU 的快取記憶體頁。

因為雖然一個記憶體區域關聯到了 NUMA 節點中的一個特定 CPU 上,但是其他CPU 依然可以存取該記憶體區域中的實體記憶體頁,因此其他 CPU 上的快取記憶體仍然可以包含該記憶體區域中的實體記憶體頁。

每個 CPU 都可以存取系統中的所有實體記憶體頁,儘管存取速度不同(這在前邊我們介紹 NUMA 架構的時候已經介紹過),因此特定的實體記憶體區域 struct zone 不僅要考慮到所屬 NUMA 節點中相關的 CPU,還需要照顧到系統中的其他 CPU。

在表示每個 CPU 快取記憶體結構 struct per_cpu_pageset 中有一個 struct per_cpu_pages 型別的陣列 pcp,容量為 2。 陣列 pcp 索引 0 表示該記憶體區域載入進 CPU 快取記憶體的熱頁集合,索引 1 表示該記憶體區域中還未載入進 CPU 快取記憶體的冷頁集合。

struct per_cpu_pageset {
	struct per_cpu_pages pcp[2];	/* 0: hot.  1: cold */
}

struct per_cpu_pages 結構則是最終用於管理 CPU 快取記憶體中的熱頁,冷頁集合的資料結構:

struct per_cpu_pages {
	int count;		/* number of pages in the list */
	int high;		/* high watermark, emptying needed */
	int batch;		/* chunk size for buddy add/remove */
	struct list_head list;	/* the list of pages */
};
  • int count :表示集合中包含的物理頁數量,如果該結構是熱頁集合,則表示載入進 CPU 快取記憶體中的物理頁面個數。

  • struct list_head list :該 list 是一個雙向連結串列,儲存了當前 CPU 的熱頁或者冷頁。

  • int batch:每次批次向 CPU 快取記憶體填充或者釋放的物理頁面個數。

  • int high:如果集合中頁面的數量 count 值超過了 high 的值,那麼表示 list 中的頁面太多了,核心會從快取記憶體中釋放 batch 個頁面到實體記憶體區域中的夥伴系統中。

  • int low : 在之前更老的版本中,per_cpu_pages 結構還定義了一個 low 下限值,如果 count 低於 low 的值,那麼核心會從夥伴系統中申請 batch 個頁面填充至當前 CPU 的快取記憶體中。之後的版本中取消了 low ,核心對容量過低的頁面集合並沒有顯示的使用水位值 low,當列表中沒有其他成員時,核心會重新填充快取記憶體。

以上則是核心版本 2.6.25 之前管理 CPU 快取記憶體冷熱頁的相關資料結構,我們看到在 2.6.25 之前,核心是使用兩個 per_cpu_pages 結構來分別管理冷頁和熱頁集合的

後來核心開發人員通過測試發現,用兩個列表來管理冷熱頁,並不會比用一個列表集中管理冷熱頁帶來任何的實質性好處,因此在核心版本 2.6.25 之後,將冷頁和熱頁的管理合並在了一個列表中,熱頁放在列表的頭部,冷頁放在列表的尾部。

在核心 5.0 的版本中, struct zone 結構中去掉了原來使用 struct per_cpu_pageset 數,因為 struct per_cpu_pageset 結構中分別管理了冷頁和熱頁。

struct zone {
	struct per_cpu_pages	__percpu *per_cpu_pageset;

	int pageset_high;
	int pageset_batch;

} ____cacheline_internodealigned_in_smp;

直接使用 struct per_cpu_pages 結構的連結串列來集中管理系統中所有 CPU 快取記憶體冷熱頁。

struct per_cpu_pages {
	int count;		/* number of pages in the list */
	int high;		/* high watermark, emptying needed */
	int batch;		/* chunk size for buddy add/remove */
        
        .............省略............

	/* Lists of pages, one per migrate type stored on the pcp-lists */
	struct list_head lists[NR_PCP_LISTS];
};

前面我們提到,核心為了最大程度的防止記憶體碎片,將實體記憶體頁面按照是否可遷移的特性分為了多種遷移型別:可遷移,可回收,不可遷移。在 struct per_cpu_pages 結構中,每一種遷移型別都會對應一個冷熱頁連結串列。

6. 核心如何描述實體記憶體頁

經過前邊幾個小節的介紹,我想大家現在應該對 Linux 核心整個記憶體管理框架有了一個總體上的認識。

如上圖所示,在 NUMA 架構下記憶體被劃分成了一個一個的記憶體節點(NUMA Node),在每個 NUMA 節點中,核心又根據節點內實體記憶體的功能用途不同,將 NUMA 節點內的實體記憶體劃分為四個實體記憶體區域分別是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。其中 ZONE_MOVABLE 區域是邏輯上的劃分,主要是為了防止記憶體碎片和支援記憶體的熱插拔。

實體記憶體區域中管理的就是實體記憶體頁( Linux 記憶體管理的最小單位),前面我們介紹的核心對實體記憶體的換入,換出,回收,記憶體對映等操作的單位就是頁。核心為每一個實體記憶體區域分配了一個夥伴系統,用於管理該實體記憶體區域下所有實體記憶體頁面的分配和釋放。

Linux 預設支援的實體記憶體頁大小為 4KB,在 64 位體系結構中還可以支援 8KB,有的處理器還可以支援 4MB,支援實體地址擴充套件 PAE 機制的處理器上還可以支援 2MB。

那麼 Linux 為什麼會預設採用 4KB 作為標準實體記憶體頁的大小呢

首先關於物理頁面的大小,Linux 規定必須是 2 的整數次冪,因為 2 的整數次冪可以將一些數學運算轉換為移位元運算,比如乘除運算可以通過移位元運算來實現,這樣效率更高。

那麼系統支援 4KB,8KB,2MB,4MB 等大小的物理頁面,它們都是 2 的整數次冪,為啥偏偏要選 4KB 呢?

因為前面提到,在記憶體緊張的時候,核心會將不經常使用到的物理頁面進行換入換出等操作,還有在記憶體與檔案對映的場景下,都會涉及到與磁碟的互動,資料在磁碟中組織形式也是根據一個磁碟塊一個磁碟塊來管理的,4kB 和 4MB 都是磁碟塊大小的整數倍,但在大多數情況下,記憶體與磁碟之間傳輸小塊資料時會更加的高效,所以綜上所述核心會採用 4KB 作為預設實體記憶體頁大小。


假設我們有 4G 大小的實體記憶體,每個實體記憶體頁大小為 4K,那麼這 4G 的實體記憶體會被核心劃分為 1M 個實體記憶體頁,核心使用一個 struct page 的結構體來描述實體記憶體頁,而每個 struct page 結構體佔用記憶體大小為 40 位元組,那麼核心就需要用額外的 40 * 1M = 40M 的記憶體大小來描述實體記憶體頁。

對於 4G 實體記憶體而言,這額外的 40M 記憶體佔比相對較小,這個代價勉強可以接受,但是對記憶體錙銖必較的核心來說,還是會盡最大努力想盡一切辦法來控制 struct page 結構體的大小。

因為對於 4G 的實體記憶體來說,核心就需要使用 1M 個物理頁面來管理,1M 個物理頁的數量已經是非常龐大的了,因此在後續的核心迭代中,對於 struct page 結構的任何微小改動,都可能導致用於管理實體記憶體頁的 struct page 範例所需要的記憶體暴漲。

回想一下我們經歷過的很多複雜業務系統,由於業務邏輯已經非常複雜,在加上業務版本日積月累的迭代,整個業務系統已經變得異常複雜,在這種型別的業務系統中,我們經常會使用一個非常龐大的類來包裝全量的業務響應資訊用以應對各種複雜的場景,但是這個類已經包含了太多太多的業務欄位了,而且這些業務欄位在有的場景中會用到,在有的場景中又不會用到,後面還可能繼續臨時增加很多欄位。系統的維護就這樣變得越來越困難。

相比上面業務系統開發中隨意地增加改動類中的欄位,在核心中肯定是不會允許這樣的行為發生的。struct page 結構是核心中存取最為頻繁的一個結構體,就好比是 Linux 世界裡最繁華的地段,在這個最繁華的地段租間房子,那租金可謂是相當的高,同樣的道理,核心在 struct page 結構體中增加一個欄位的代價也是非常之大,該結構體中每個欄位中的每個位元,核心用的都是淋漓盡致。

但是 struct page 結構同樣會面臨很多複雜的場景,結構體中的某些欄位在某些場景下有用,而在另外的場景下卻沒有用,而核心又不可能像業務系統開發那樣隨意地為 struct page 結構增加欄位,那麼核心該如何應對這種情況呢?

下面我們即將會看到 struct page 結構體裡包含了大量的 union 結構,而 union 結構在 C 語言中被用於同一塊記憶體根據不同場景儲存不同型別資料的一種方式。核心之所以在 struct page 結構中使用 union,是因為一個實體記憶體頁面在核心中的使用場景和使用方式是多種多樣的。在這多種場景下,利用 union 盡最大可能使 struct page 的記憶體佔用保持在一個較低的水平。

struct page 結構可謂是核心中最為繁雜的一個結構體,應用在核心中的各種功能場景下,在本小節中一一解釋清楚各個欄位的含義是不現實的,下面筆者只會列舉 struct page 中最為常用的幾個欄位,剩下的欄位筆者會在後續相關文章中專門介紹。


struct page {
    // 儲存 page 的定位資訊以及相關標誌位
    unsigned long flags;        

    union {
        struct {    /* Page cache and anonymous pages */
            // 用來指向物理頁 page 被放置在了哪個 lru 連結串列上
            struct list_head lru;
            // 如果 page 為檔案頁的話,低位為0,指向 page 所在的 page cache
            // 如果 page 為匿名頁的話,低位為1,指向其對應虛擬地址空間的匿名對映區 anon_vma
            struct address_space *mapping;
            // 如果 page 為檔案頁的話,index 為 page 在 page cache 中的索引
            // 如果 page 為匿名頁的話,表示匿名頁在對應程序虛擬記憶體區域 VMA 中的偏移
            pgoff_t index;
            // 在不同場景下,private 指向的場景資訊不同
            unsigned long private;
        };
        
        struct {    /* slab, slob and slub */
            union {
                // 用於指定當前 page 位於 slab 中的哪個具體管理連結串列上。
                struct list_head slab_list;
                struct {
                    // 當 page 位於 slab 結構中的某個管理連結串列上時,next 指標用於指向連結串列中的下一個 page
                    struct page *next;
#ifdef CONFIG_64BIT
                    // 表示 slab 中總共擁有的 page 個數
                    int pages;  
                    // 表示 slab 中擁有的特定型別的物件個數
                    int pobjects;   
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            // 用於指向當前 page 所屬的 slab 管理結構
            struct kmem_cache *slab_cache; 
        
            // 指向 page 中的第一個未分配出去的空閒物件
            void *freelist;     
            union {
                // 指向 page 中的第一個物件
                void *s_mem;    
                struct {            /* SLUB */
                    // 表示 slab 中已經被分配出去的物件個數
                    unsigned inuse:16;
                    // slab 中所有的物件個數
                    unsigned objects:15;
                    // 當前記憶體頁 page 被 slab 放置在 CPU 本地快取列表中,frozen = 1,否則 frozen = 0
                    unsigned frozen:1;
                };
            };
        };
        struct {    /* 複合頁 compound page 相關*/
            // 複合頁的尾頁指向首頁
            unsigned long compound_head;    
            // 用於釋放複合頁的解構函式,儲存在首頁中
            unsigned char compound_dtor;
            // 該複合頁有多少個 page 組成
            unsigned char compound_order;
            // 該複合頁被多少個程序使用,記憶體頁反向對映的概念,首頁中儲存
            atomic_t compound_mapcount;
        };

        // 表示 slab 中需要釋放回收的物件連結串列
        struct rcu_head rcu_head;
    };

    union {     /* This union is 4 bytes in size. */
        // 表示該 page 對映了多少個程序的虛擬記憶體空間,一個 page 可以被多個程序對映
        atomic_t _mapcount;

    };

    // 核心中參照該物理頁的次數,表示該物理頁的活躍程度。
    atomic_t _refcount;

#if defined(WANT_PAGE_VIRTUAL)
    void *virtual;  // 記憶體頁對應的虛擬記憶體地址
#endif /* WANT_PAGE_VIRTUAL */

} _struct_page_alignment;

下面筆者就來為大家介紹下 struct page 結構在不同場景下的使用方式:

第一種使用方式是核心直接分配使用一整頁的實體記憶體,在《5.2 實體記憶體區域中的水位線》小節中我們提到,核心中的實體記憶體頁有兩種型別,分別用於不同的場景:

  1. 一種是匿名頁,匿名頁背後並沒有一個磁碟中的檔案作為資料來源,匿名頁中的資料都是通過程序執行過程中產生的,匿名頁直接和程序虛擬地址空間建立對映供程序使用。

  2. 另外一種是檔案頁,檔案頁中的資料來自於磁碟中的檔案,檔案頁需要先關聯一個磁碟中的檔案,然後再和程序虛擬地址空間建立對映供程序使用,使得程序可以通過操作虛擬記憶體實現對檔案的操作,這就是我們常說的記憶體檔案對映。

struct page {
    // 如果 page 為檔案頁的話,低位為0,指向 page 所在的 page cache
    // 如果 page 為匿名頁的話,低位為1,指向其對應虛擬地址空間的匿名對映區 anon_vma
    struct address_space *mapping;
    // 如果 page 為檔案頁的話,index 為 page 在 page cache 中的索引
    // 如果 page 為匿名頁的話,表示匿名頁在對應程序虛擬記憶體區域 VMA 中的偏移
    pgoff_t index; 
}

我們首先來介紹下 struct page 結構中的 struct address_space *mapping 欄位。提到 struct address_space 結構,如果大家之前看過筆者 《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》 這篇文章的話,一定不會對 struct address_space 感到陌生。

在核心中每個檔案都會有一個屬於自己的 page cache(頁快取記憶體),頁快取記憶體在核心中的結構體就是這個 struct address_space。它被檔案的 inode 所持有。

如果當前實體記憶體頁 struct page 是一個檔案頁的話,那麼 mapping 指標的最低位會被設定為 0 ,指向該記憶體頁關聯檔案的 struct address_space(頁快取記憶體),pgoff_t index 欄位表示該記憶體頁 page 在頁快取記憶體 page cache 中的 index 索引。核心會利用這個 index 欄位從 page cache 中查詢該實體記憶體頁,

同時該 pgoff_t index 欄位也表示該記憶體頁中的檔案資料在檔案內部的偏移 offset。偏移單位為 page size。

對相關查詢細節感興趣的同學可以在回看下筆者 《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》 文章中的《8. page cache 中查詢快取頁》小節。

如果當前實體記憶體頁 struct page 是一個匿名頁的話,那麼 mapping 指標的最低位會被設定為 1 , 指向該匿名頁在程序虛擬記憶體空間中的匿名對映區域 struct anon_vma 結構(每個匿名頁對應唯一的 anon_vma 結構),用於實體記憶體到虛擬記憶體的反向對映。

6.1 匿名頁的反向對映

我們通常所說的記憶體對映是正向對映,即從虛擬記憶體到實體記憶體的對映。而反向對映則是從實體記憶體到虛擬記憶體的對映,用於當某個實體記憶體頁需要進行回收或遷移時,此時需要去找到這個物理頁被對映到了哪些程序的虛擬地址空間中,並斷開它們之間的對映。

在沒有反向對映的機制前,需要去遍歷所有程序的虛擬地址空間中的對映頁表,這個效率顯然是很低下的。有了反向對映機制之後核心就可以直接找到該實體記憶體頁到所有程序對映的虛擬地址空間 VMA ,並從 VMA 使用的程序頁表中取消對映,

談到 VMA 大家一定不會感到陌生,VMA 相關的內容筆者在 《深入理解 Linux 虛擬記憶體管理》 這篇文章中詳細的介紹過。

如下圖所示,程序的虛擬記憶體空間在核心中使用 struct mm_struct 結構表示,程序的虛擬記憶體空間包含了一段一段的虛擬記憶體區域 VMA,比如我們經常接觸到的堆,棧。核心中使用 struct vm_area_struct 結構來描述這些虛擬記憶體區域。

這裡筆者只列舉出 struct vm_area_struct 結構中與匿名頁反向對映相關的欄位屬性:

struct vm_area_struct {  

    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;   
}

這裡大家可能會感到好奇,既然核心中有了 struct vm_area_struct 結構來描述虛擬記憶體區域,那不管是檔案頁也好,還是匿名頁也好,都可以使用 struct vm_area_struct 結構體來進行描述,這裡為什麼有會出現 struct anon_vma 結構和 struct anon_vma_chain 結構?這兩個結構到底是幹嘛的?如何利用它倆來完成匿名記憶體頁的反向對映呢?

根據前幾篇文章的內容我們知道,程序利用 fork 系統呼叫建立子程序的時候,核心會將父程序的虛擬記憶體空間相關的內容拷貝到子程序的虛擬記憶體空間中,此時子程序的虛擬記憶體空間和父程序的虛擬記憶體空間是一模一樣的,其中虛擬記憶體空間中對映的實體記憶體頁也是一樣的,在核心中都是同一份,在父程序和子程序之間共用(包括 anon_vma 和 anon_vma_chain)。

當程序在向核心申請記憶體的時候,核心首先會為程序申請的這塊記憶體建立初始化一段虛擬記憶體區域 struct vm_area_struct 結構,但是並不會為其分配真正的實體記憶體。

當程序開始存取這段虛擬記憶體時,核心會產生缺頁中斷,在缺頁中斷處理常式中才會去真正的分配實體記憶體(這時才會為子程序建立自己的 anon_vma 和 anon_vma_chain),並建立虛擬記憶體與實體記憶體之間的對映關係(正向對映)。

static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
        .............

	if (!vmf->pte) {
		if (vma_is_anonymous(vmf->vma))
            // 處理匿名頁缺頁
			return do_anonymous_page(vmf);
		else
            // 處理檔案頁缺頁
			return do_fault(vmf);
	}

        .............

	if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
		if (!pte_write(entry))
            // 子程序缺頁處理
			return do_wp_page(vmf);
	}

這裡我們主要關注 do_anonymous_page 函數,正是在這裡核心完成了 struct anon_vma 結構和 struct anon_vma_chain 結構的建立以及相關匿名頁反向對映資料結構的相互關聯。

static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct page *page;	

        ........省略虛擬記憶體到實體記憶體正向對映相關邏輯.........

	if (unlikely(anon_vma_prepare(vma)))
		goto oom;

	page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

	if (!page)
		goto oom;
  // 建立反向對映關係
	page_add_new_anon_rmap(page, vma, vmf->address);

        ........省略虛擬記憶體到實體記憶體正向對映相關邏輯.........
}

在 do_anonymous_page 匿名頁缺頁處理常式中會為 struct vm_area_struct 結構建立匿名頁相關的 struct anon_vma 結構和 struct anon_vma_chain 結構。

並在 anon_vma_prepare 函數中實現 anon_vma 和 anon_vma_chain 之間的關聯 ,隨後呼叫 alloc_zeroed_user_highpage_movable 從夥伴系統中獲取實體記憶體頁 struct page,並在 page_add_new_anon_rmap 函數中完成 struct page 與 anon_vma 的關聯(這裡正是反向對映關係建立的關鍵)

在介紹匿名頁反向對映原始碼實現之前,筆者先來為大家介紹一下相關的兩個重要資料結構 struct anon_vma 和 struct anon_vma_chain,方便大家理解為何 struct page 與 anon_vma 關聯起來就能實現反向對映?

前面我們提到,匿名頁的反向對映關鍵就是建立實體記憶體頁 struct page 與程序虛擬記憶體空間 VMA 之間的對映關係。

匿名頁的 struct page 中的 mapping 指標指向的是 struct anon_vma 結構。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
}

只要我們實現了 anon_vma 與 vm_area_struct 之間的關聯,那麼 page 到 vm_area_struct 之間的對映就建立起來了,struct anon_vma_chain 結構做的事情就是建立 anon_vma 與 vm_area_struct 之間的關聯關係。

struct anon_vma_chain {
    // 匿名頁關聯的程序虛擬記憶體空間(vma屬於一個特定的程序,多個程序多個vma)
    struct vm_area_struct *vma;
    // 匿名頁 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    struct list_head same_vma;   
    struct rb_node rb;         
    unsigned long rb_subtree_last;
#ifdef CONFIG_DEBUG_VM_RB
    unsigned long cached_vma_start, cached_vma_last;
#endif
};

struct anon_vma_chain 結構通過其中的 vma 指標和 anon_vma 指標將相關的匿名頁與其對映的程序虛擬記憶體空間關聯了起來。

從目前來看匿名頁 struct page 算是與 anon_vma 建立了關係,又通過 anon_vma_chain 將 anon_vma 與 vm_area_struct 建立了關係。那麼就剩下最後一道關係需要打通了,就是如何通過 anon_vma 找到 anon_vma_chain 進而找到 vm_area_struct 呢?這就需要我們將 anon_vma 與 anon_vma_chain 之間的關係也打通。

我們知道每個匿名頁對應唯一的 anon_vma 結構,但是一個匿名物理頁可以對映到不同程序的虛擬記憶體空間中,每個程序的虛擬記憶體空間都是獨立的,也就是說不同的程序就會有不同的 VMA。

不同的 VMA 意味著同一個匿名頁 anon_vma 就會對應多個 anon_vma_chain。那麼如何通過一個 anon_vma 找到和他關聯的所有 anon_vma_chain 呢?找到了這些 anon_vma_chain 也就意味著 struct page 找到了與它關聯的所有程序虛擬記憶體空間 VMA。

我們看看能不能從 struct anon_vma 結構中尋找一下線索:

struct anon_vma {
    struct anon_vma *root;      /* Root of this anon_vma tree */
    struct rw_semaphore rwsem; 
    atomic_t refcount;
    unsigned degree;
    struct anon_vma *parent;    /* Parent of this anon_vma */
    struct rb_root rb_root; /* Interval tree of private "related" vmas */
};

我們重點來看 struct anon_vma 結構中的 rb_root 欄位,struct anon_vma 結構中管理了一顆紅黑樹,這顆紅黑樹上管理的全部都是與該 anon_vma 關聯的 anon_vma_chain。我們可以通過 struct page 中的 mapping 指標找到 anon_vma,然後遍歷 anon_vma 中的這顆紅黑樹 rb_root ,從而找到與其關聯的所有 anon_vma_chain。

struct anon_vma_chain {
    // 匿名頁關聯的程序虛擬記憶體空間(vma屬於一個特定的程序,多個程序多個vma)
    struct vm_area_struct *vma;
    // 匿名頁 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    // 指向 vm_area_struct 中的 anon_vma_chain 列表
    struct list_head same_vma;   
    // anon_vma 管理的紅黑樹中該 anon_vma_chain 對應的紅黑樹節點
    struct rb_node rb;         
};

struct anon_vma_chain 結構中的 rb 欄位表示其在對應 anon_vma 管理的紅黑樹中的節點。

到目前為止,實體記憶體頁 page 到與其對映的程序虛擬記憶體空間 VMA,這樣一種一對多的對映關係現在就算建立起來了。

而 vm_area_struct 表示的只是程序虛擬記憶體空間中的一段虛擬記憶體區域,這塊虛擬記憶體區域中可能會包含多個匿名頁,所以 VMA 與實體記憶體頁 page 也是有一對多的對映關係存在。而這個對映關係在哪裡儲存呢?

大家注意 struct anon_vma_chain 結構中還有一個列表結構 same_vma,從這個名字上我們很容易就能猜到這個列表 same_vma 中儲存的 anon_vma_chain 對應的 VMA 全都是一樣的,而列表元素 anon_vma_chain 中的 anon_vma 卻是不一樣的。核心用這樣一個連結串列結構 same_vma 儲存了程序相應虛擬記憶體區域 VMA 中所包含的所有匿名頁。

struct vm_area_struct 結構中的 struct list_head anon_vma_chain 指向的也是這個列表 same_vma。

struct vm_area_struct {  
    // 儲存該 VMA 中所包含的所有匿名頁 anon_vma
    struct list_head anon_vma_chain;
    // 用於快速判斷 VMA 有沒有對應的匿名 page
    // 一個 VMA 可以包含多個 page,但是該區域內的所有 page 只需要一個 anon_vma 來反向對映即可。
    struct anon_vma *anon_vma;   
}

現在整個匿名頁到程序虛擬記憶體空間的反向對映鏈路關係,筆者就為大家梳理清楚了,下面我們接著回到 do_anonymous_page 函數中,來一一驗證上述對映邏輯:

static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct page *page;	

        ........省略虛擬記憶體到實體記憶體正向對映相關邏輯.........

	if (unlikely(anon_vma_prepare(vma)))
		goto oom;

	page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

	if (!page)
		goto oom;

	page_add_new_anon_rmap(page, vma, vmf->address);

        ........省略虛擬記憶體到實體記憶體正向對映相關邏輯.........
}

在 do_anonymous_page 中首先會呼叫 anon_vma_prepare 方法來為匿名頁建立 anon_vma 範例和 anon_vma_chain 範例,並建立它們之間的關聯關係。

int __anon_vma_prepare(struct vm_area_struct *vma)
{
    // 獲取程序虛擬記憶體空間
	struct mm_struct *mm = vma->vm_mm;
    // 準備為匿名頁分配 anon_vma 以及 anon_vma_chain
	struct anon_vma *anon_vma, *allocated;
	struct anon_vma_chain *avc;
    // 分配 anon_vma_chain 範例
	avc = anon_vma_chain_alloc(GFP_KERNEL);
	if (!avc)
		goto out_enomem;
    // 在相鄰的虛擬記憶體區域 VMA 中查詢可複用的 anon_vma
	anon_vma = find_mergeable_anon_vma(vma);
	allocated = NULL;
	if (!anon_vma) {
        // 沒有可複用的 anon_vma 則建立一個新的範例
		anon_vma = anon_vma_alloc();
		if (unlikely(!anon_vma))
			goto out_enomem_free_avc;
		allocated = anon_vma;
	}

	anon_vma_lock_write(anon_vma);
	/* page_table_lock to protect against threads */
	spin_lock(&mm->page_table_lock);
	if (likely(!vma->anon_vma)) {
        // VMA 中的 anon_vma 屬性就是在這裡賦值的
		vma->anon_vma = anon_vma;
        // 建立反向對映關聯
		anon_vma_chain_link(vma, avc, anon_vma);
		/* vma reference or self-parent link for new root */
		anon_vma->degree++;
		allocated = NULL;
		avc = NULL;
	}
        .................
}

anon_vma_prepare 方法中呼叫 anon_vma_chain_link 方法來建立 anon_vma,anon_vma_chain,vm_area_struct 三者之間的關聯關係:

static void anon_vma_chain_link(struct vm_area_struct *vma,
				struct anon_vma_chain *avc,
				struct anon_vma *anon_vma)
{
    // 通過 anon_vma_chain 關聯 anon_vma 和對應的 vm_area_struct
	avc->vma = vma;
	avc->anon_vma = anon_vma;
    // 將 vm_area_struct 中的 anon_vma_chain 連結串列加入到 anon_vma_chain 中的 same_vma 連結串列中
	list_add(&avc->same_vma, &vma->anon_vma_chain);
    // 將初始化好的 anon_vma_chain 加入到 anon_vma 管理的紅黑樹 rb_root 中
	anon_vma_interval_tree_insert(avc, &anon_vma->rb_root);
}

到現在為止還缺關鍵的最後一步,就是打通匿名記憶體頁 page 到 vm_area_struct 之間的關係,首先我們就需要呼叫 alloc_zeroed_user_highpage_movable 方法從夥伴系統中申請一個匿名頁。當獲取到 page 範例之後,通過 page_add_new_anon_rmap 最終建立起 page 到 vm_area_struct 的整條反向對映鏈路。

static void __page_set_anon_rmap(struct page *page,
    struct vm_area_struct *vma, unsigned long address, int exclusive)
{
    struct anon_vma *anon_vma = vma->anon_vma;
           .........省略..............
    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;
    // 轉換為 address_space 指標賦值給 page 結構中的 mapping 欄位
    page->mapping = (struct address_space *) anon_vma;
    // page 結構中的 index 表示該匿名頁在虛擬記憶體區域 vma 中的偏移
    page->index = linear_page_index(vma, address);
}

現在讓我們再次回到本小節 《6.1 匿名頁的反向對映》的開始,再來看這段話,是不是感到非常清晰了呢~~

如果當前實體記憶體頁 struct page 是一個匿名頁的話,那麼 mapping 指標的最低位會被設定為 1 , 指向該匿名頁在程序虛擬記憶體空間中的匿名對映區域 struct anon_vma 結構(每個匿名頁對應唯一的 anon_vma 結構),用於實體記憶體到虛擬記憶體的反向對映。

如果當前實體記憶體頁 struct page 是一個檔案頁的話,那麼 mapping 指標的最低位會被設定為 0 ,指向該記憶體頁關聯檔案的 struct address_space(頁快取記憶體)。pgoff_t index 欄位表示該記憶體頁 page 在頁快取記憶體中的 index 索引,也表示該記憶體頁中的檔案資料在檔案內部的偏移 offset。偏移單位為 page size。

struct page 結構中的 struct address_space *mapping 指標的最低位如何置 1 ,又如何置 0 呢?關鍵在下面這條語句:

    struct anon_vma *anon_vma = vma->anon_vma;
    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;

anon_vma 指標加上 PAGE_MAPPING_ANON ,並轉換為 address_space 指標,這樣可確保 address_space 指標的低位為 1 表示匿名頁。

address_space 指標在轉換為 anon_vma 指標的時候可通過如下語句實現:

anon_vma = (struct anon_vma *) (mapping - PAGE_MAPPING_ANON)

PAGE_MAPPING_ANON 常數定義在核心 /include/linux/page-flags.h 檔案中:

#define PAGE_MAPPING_ANON	0x1

而對於檔案頁來說,page 結構的 mapping 指標最低位本來就是 0 ,因為 address_space 型別的指標實現總是對齊至 sizeof(long),因此在 Linux 支援的所有計算機上,指向 address_space 範例的指標最低位總是為 0 。

核心可以通過這個技巧直接檢查 page 結構中的 mapping 指標的最低位來判斷該實體記憶體頁到底是匿名頁還是檔案頁

前面說了檔案頁的 page 結構的 index 屬性表示該記憶體頁 page 在磁碟檔案中的偏移 offset ,偏移單位為 page size 。

那匿名頁的 page 結構中的 index 屬性表示什麼呢?我們接著來看 linear_page_index 函數:

static inline pgoff_t linear_page_index(struct vm_area_struct *vma,
                    unsigned long address)
{
    pgoff_t pgoff;
    if (unlikely(is_vm_hugetlb_page(vma)))
        return linear_hugepage_index(vma, address);
    pgoff = (address - vma->vm_start) >> PAGE_SHIFT;
    pgoff += vma->vm_pgoff;
    return pgoff;
}

邏輯很簡單,就是表示匿名頁在對應程序虛擬記憶體區域 VMA 中的偏移。

在本小節最後,還有一個與反向對映相關的重要屬性就是 page 結構中的 _mapcount。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
    // 表示該 page 對映了多少個程序的虛擬記憶體空間,一個 page 可以被多個程序對映
    atomic_t _mapcount
}

經過本小節詳細的介紹,我想大家現在已經猜到 _mapcount 欄位的含義了,我們知道一個實體記憶體頁可以對映到多個程序的虛擬記憶體空間中,比如:共用記憶體對映,父子程序的建立等。page 與 VMA 是一對多的關係,這裡的 _mapcount 就表示該物理頁對映到了多少個程序的虛擬記憶體空間中。

6.2 記憶體頁回收相關屬性

我們接著來看 struct page 中剩下的其他屬性,我們知道實體記憶體頁在核心中分為匿名頁和檔案頁,在《5.2 實體記憶體區域中的水位線》小節中,筆者還提到過兩個重要的連結串列分別為:active 連結串列和 inactive 連結串列。

其中 active 連結串列用來存放存取非常頻繁的記憶體頁(熱頁), inactive 連結串列用來存放存取不怎麼頻繁的記憶體頁(冷頁),當記憶體緊張的時候,核心就會優先將 inactive 連結串列中的記憶體頁置換出去。

核心在回收記憶體的時候,這兩個列表中的回收優先順序為:inactive 連結串列尾部 > inactive 連結串列頭部 > active 連結串列尾部 > active 連結串列頭部。

我們可以通過 cat /proc/zoneinfo 命令來檢視不同 NUMA 節點中不同記憶體區域中的 active 連結串列和 inactive 連結串列中實體記憶體頁的個數:

  • nr_zone_active_anon 和 nr_zone_inactive_anon 分別是該記憶體區域內活躍和非活躍的匿名頁數量。

  • nr_zone_active_file 和 nr_zone_inactive_file 分別是該記憶體區域內活躍和非活躍的檔案頁數量。

為什麼會有 active 連結串列和 inactive 連結串列

記憶體回收的關鍵是如何實現一個高效的頁面替換演演算法 PFRA (Page Frame Replacement Algorithm) ,提到頁面替換演演算法大家可能立馬會想到 LRU (Least-Recently-Used) 演演算法。LRU 演演算法的核心思想就是那些最近最少使用的頁面,在未來的一段時間內可能也不會再次被使用,所以在記憶體緊張的時候,會優先將這些最近最少使用的頁面置換出去。在這種情況下其實一個 active 連結串列就可以滿足我們的需求。

但是這裡會有一個嚴重的問題,LRU 演演算法更多的是在時間維度上的考量,突出最近最少使用,但是它並沒有考量到使用頻率的影響,假設有這樣一種狀況,就是一個頁面被瘋狂頻繁的使用,毫無疑問它肯定是一個熱頁,但是這個頁面最近的一次存取時間離現在稍微久了一點點,此時進來大量的頁面,這些頁面的特點是隻會使用一兩次,以後將再也不會用到。

在這種情況下,根據 LRU 的語意這個之前頻繁地被瘋狂存取的頁面就會被置換出去了(本來應該將這些大量一次性存取的頁面置換出去的),當這個頁面在不久之後要被存取時,此時已經不在記憶體中了,還需要在重新置換進來,造成效能的損耗。這種現象也叫 Page Thrashing(頁面顛簸)。

因此,核心為了將頁面使用頻率這個重要的考量因素加入進來,於是就引入了 active 連結串列和 inactive 連結串列。工作原理如下:

  1. 首先 inactive 連結串列的尾部存放的是存取頻率最低並且最少存取的頁面,在記憶體緊張的時候,這些頁面被置換出去的優先順序是最大的。

  2. 對於檔案頁來說,當它被第一次讀取的時候,核心會將它放置在 inactive 連結串列的頭部,如果它繼續被存取,則會提升至 active 連結串列的尾部。如果它沒有繼續被存取,則會隨著新檔案頁的進入,核心會將它慢慢的推到 inactive 連結串列的尾部,如果此時再次被存取則會直接被提升到 active 連結串列的頭部。大家可以看出此時頁面的使用頻率這個因素已經被考量了進來。

  3. 對於匿名頁來說,當它被第一次讀取的時候,核心會直接將它放置在 active 連結串列的尾部,注意不是 inactive 連結串列的頭部,這裡和檔案頁不同。因為匿名頁的換出 Swap Out 成本會更大,核心會對匿名頁更加優待。當匿名頁再次被存取的時候就會被被提升到 active 連結串列的頭部。

  4. 當遇到記憶體緊張的情況需要換頁時,核心會從 active 連結串列的尾部開始掃描,將一定量的頁面降級到 inactive 連結串列頭部,這樣一來原來位於 inactive 連結串列尾部的頁面就會被置換出去。

核心在回收記憶體的時候,這兩個列表中的回收優先順序為:inactive 連結串列尾部 > inactive 連結串列頭部 > active 連結串列尾部 > active 連結串列頭部。

為什麼會把 active 連結串列和 inactive 連結串列分成兩類,一類是匿名頁,一類是檔案頁

在本文 《5.2 實體記憶體區域中的水位線》小節中,筆者為大家介紹了一個叫做 swappiness 的核心引數, 我們可以通過 cat /proc/sys/vm/swappiness 命令檢視,swappiness 選項的取值範圍為 0 到 100,預設為 60。

swappiness 用於表示 Swap 機制的積極程度,數值越大,Swap 的積極程度,越高越傾向於回收匿名頁。數值越小,Swap 的積極程度越低,越傾向於回收檔案頁

因為回收匿名頁和回收檔案頁的代價是不一樣的,回收匿名頁代價會更高一點,所以引入 swappiness 來控制核心回收的傾向。

注意: swappiness 只是表示 Swap 積極的程度,當記憶體非常緊張的時候,即使將 swappiness 設定為 0 ,也還是會發生 Swap 的。

假設我們現在只有 active 連結串列和 inactive 連結串列,不對這兩個連結串列進行匿名頁和檔案頁的歸類,在需要頁面置換的時候,核心會先從 active 連結串列尾部開始掃描,當 swappiness 被設定為 0 時,核心只會置換檔案頁,不會置換匿名頁。

由於 active 連結串列和 inactive 連結串列沒有進行物理頁面型別的歸類,所以連結串列中既會有匿名頁也會有檔案頁,如果連結串列中有大量的匿名頁的話,核心就會不斷的跳過這些匿名頁去尋找檔案頁,並將檔案頁替換出去,這樣從效能上來說肯定是低效的。

因此核心將 active 連結串列和 inactive 連結串列按照匿名頁和檔案頁進行了歸類,當 swappiness 被設定為 0 時,核心只需要去 nr_zone_active_file 和 nr_zone_inactive_file 連結串列中掃描即可,提升了效能。

其實除了以上筆者介紹的四種 LRU 連結串列(匿名頁的 active 連結串列,inactive 連結串列和檔案頁的active 連結串列, inactive 連結串列)之外,核心還有一種連結串列,比如程序可以通過 mlock() 等系統呼叫把記憶體頁鎖定在記憶體裡,保證該記憶體頁無論如何不會被置換出去,比如出於安全或者效能的考慮,頁面中可能會包含一些敏感的資訊不想被 swap 到磁碟上導致洩密,或者一些頻繁存取的記憶體頁必須一直貯存在記憶體中。

當這些被鎖定在記憶體中的頁面很多時,核心在掃描 active 連結串列的時候也不得不跳過這些頁面,所以核心又將這些被鎖定的頁面單獨拎出來放在一個獨立的連結串列中。

現在筆者為大家介紹五種用於存放 page 的連結串列,核心會根據不同的情況將一個物理頁放置在這五種連結串列其中一個上。那麼對於物理頁的 struct page 結構中就需要有一個屬性用來標識該物理頁究竟被核心放置在哪個連結串列上。

struct page {
   struct list_head lru;
   atomic_t _refcount;
}

struct list_head lru 屬性就是用來指向物理頁被放置在了哪個連結串列上。

atomic_t _refcount 屬性用來記錄核心中參照該物理頁的次數,表示該物理頁的活躍程度。

6.3 實體記憶體頁屬性和狀態的標誌位 flag

struct page {
    unsigned long flags;
} 

在本文 《2.3 SPARSEMEM 稀疏記憶體模型》小節中,我們提到,核心為了能夠更靈活地管理粒度更小的連續實體記憶體,於是就此引入了 SPARSEMEM 稀疏記憶體模型。

SPARSEMEM 稀疏記憶體模型的核心思想就是提供對粒度更小的連續記憶體塊進行精細的管理,用於管理連續記憶體塊的單元被稱作 section 。核心中用於描述 section 的資料結構是 struct mem_section。

由於 section 被用作管理小粒度的連續記憶體塊,這些小的連續實體記憶體在 section 中也是通過陣列的方式被組織管理(圖中 struct page 型別的陣列)。

每個 struct mem_section 結構體中有一個 section_mem_map 指標用於指向連續記憶體的 page 陣列。而所有的 mem_section 也會被存放在一個全域性的陣列 mem_section 中。

那麼給定一個具體的 struct page,在稀疏記憶體模型中核心如何定位到這個實體記憶體頁到底屬於哪個 mem_section 呢 ?這是第一個問題~~

筆者在《5. 核心如何管理 NUMA 節點中的實體記憶體區域》小節中講到了記憶體的架構,在 NUMA 架構下,實體記憶體被劃分成了一個一個的記憶體節點(NUMA 節點),在每個 NUMA 節點內部又將其所管理的實體記憶體按照功能不同劃分成了不同的記憶體區域 zone,每個記憶體區域管理一片用於特定具體功能的實體記憶體 page。

實體記憶體在核心中管理的層級關係為:None -> Zone -> page

那麼在 NUMA 架構下,給定一個具體的 struct page,核心又該如何確定該實體記憶體頁究竟屬於哪個 NUMA 節點,屬於哪塊記憶體區域 zone 呢? 這是第二個問題。

關於以上筆者提出的兩個問題所需要的定位資訊全部儲存在 struct page 結構中的 flags 欄位中。前邊我們提到,struct page 是 Linux 世界裡最繁華的地段,這裡的地價非常昂貴,所以 page 結構中這些欄位裡的每一個位元核心都會物盡其用。

struct page {
    unsigned long flags;
} 

因此這個 unsigned long 型別的 flags 欄位中不僅包含上面提到的定位資訊還會包括實體記憶體頁的一些屬性和標誌位。flags 欄位的高 8 位用來表示 struct page 的定位資訊,剩餘低位表示特定的標誌位。

struct page 與其所屬上層結構轉換的相應函數定義在 /include/linux/mm.h 檔案中:

static inline unsigned long page_to_section(const struct page *page)
{
	return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
}

static inline pg_data_t *page_pgdat(const struct page *page)
{
	return NODE_DATA(page_to_nid(page));
}

static inline struct zone *page_zone(const struct page *page)
{
	return &NODE_DATA(page_to_nid(page))->node_zones[page_zonenum(page)];
}

在我們介紹完了 flags 欄位中高位儲存的位置定位資訊之後,接下來就該來介紹下在低位位元中表示的實體記憶體頁的那些標誌位~~

實體記憶體頁的這些標誌位定義在核心 /include/linux/page-flags.h檔案中:

enum pageflags {
	PG_locked,		/* Page is locked. Don't touch. */
	PG_referenced,
	PG_uptodate,
	PG_dirty,
	PG_lru,
	PG_active,
	PG_slab,
	PG_reserved,
    PG_compound,
	PG_private,		
	PG_writeback,		
	PG_reclaim,		
#ifdef CONFIG_MMU
	PG_mlocked,		/* Page is vma mlocked */
	PG_swapcache = PG_owner_priv_1,	

        ................
};
  • PG_locked 表示該物理頁面已經被鎖定,如果該標誌位置位,說明有使用者正在操作該 page , 則核心的其他部分不允許存取該頁, 這可以防止記憶體管理出現競態條件,例如:在從硬碟讀取資料到 page 時。

  • PG_mlocked 表示該實體記憶體頁被程序通過 mlock 系統呼叫鎖定常駐在記憶體中,不會被置換出去。

  • PG_referenced 表示該物理頁面剛剛被存取過。

  • PG_active 表示該物理頁位於 active list 連結串列中。PG_referenced 和 PG_active 共同控制了系統使用該記憶體頁的活躍程度,在記憶體回收的時候這兩個資訊非常重要。

  • PG_uptodate 表示該物理頁的資料已經從塊裝置中讀取到記憶體中,並且期間沒有出錯。

  • PG_readahead 當程序在順序存取檔案的時候,核心會預讀若干相鄰的檔案頁資料到 page 中,物理頁 page 結構設定了該標誌位,表示它是一個正在被核心預讀的頁。相關詳細內容可回看筆者之前的這篇文章 《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》

  • PG_dirty 實體記憶體頁的髒頁標識,表示該實體記憶體頁中的資料已經被程序修改,但還沒有同步會磁碟中。筆者在 《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》 一文中也詳細介紹過。

  • PG_lru 表示該實體記憶體頁現在被放置在哪個 lru 連結串列上,比如:是在 active list 連結串列中 ? 還是在 inactive list 連結串列中 ?

  • PG_highmem 表示該實體記憶體頁是在高階記憶體中。

  • PG_writeback 表示該實體記憶體頁正在被核心的 pdflush 執行緒回寫到磁碟中。詳情可回看文章《從 Linux 核心角度探祕 JDK NIO 檔案讀寫本質》

  • PG_slab 表示該實體記憶體頁屬於 slab 分配器所管理的一部分。

  • PG_swapcache 表示該實體記憶體頁處於 swap cache 中。 struct page 中的 private 指標這時指向 swap_entry_t 。

  • PG_reclaim 表示該實體記憶體頁已經被核心選中即將要進行回收。

  • PG_buddy 表示該實體記憶體頁是空閒的並且被夥伴系統所管理。

  • PG_compound 表示實體記憶體頁屬於複合頁的其中一部分。

  • PG_private 標誌被置位的時候表示該 struct page 結構中的 private 指標指向了具體的物件。不同場景指向的物件不同。

除此之外核心還定義了一些標準宏,用來檢查某個實體記憶體頁 page 是否設定了特定的標誌位,以及對這些標誌位的操作,這些宏在核心中的實現都是原子的,命名格式如下:

  • PageXXX(page):檢查 page 是否設定了 PG_XXX 標誌位

  • SetPageXXX(page):設定 page 的 PG_XXX 標誌位

  • ClearPageXXX(page):清除 page 的 PG_XXX 標誌位

  • TestSetPageXXX(page):設定 page 的 PG_XXX 標誌位,並返回原值

另外在很多情況下,核心通常需要等待物理頁 page 的某個狀態改變,才能繼續恢復工作,核心提供瞭如下兩個輔助函數,來實現在特定狀態的阻塞等待:

static inline void wait_on_page_locked(struct page *page)
static inline void wait_on_page_writeback(struct page *page)

當物理頁面在鎖定的狀態下,程序呼叫了 wait_on_page_locked 函數,那麼程序就會阻塞等待知道頁面解鎖。

當物理頁面正在被核心回寫到磁碟的過程中,程序呼叫了 wait_on_page_writeback 函數就會進入阻塞狀態直到髒頁資料被回寫到磁碟之後被喚醒。

6.4 複合頁 compound_page 相關屬性

我們都知道 Linux 管理記憶體的最小單位是 page,每個 page 描述 4K 大小的實體記憶體,但在一些對於記憶體敏感的使用場景中,使用者往往期望使用一些巨型大頁。

巨型大頁就是通過兩個或者多個物理上連續的記憶體頁 page 組裝成的一個比普通記憶體頁 page 更大的頁,

因為這些巨型頁要比普通的 4K 記憶體頁要大很多,所以遇到缺頁中斷的情況就會相對減少,由於減少了缺頁中斷所以效能會更高。

另外,由於巨型頁比普通頁要大,所以巨型頁需要的頁表項要比普通頁要少,頁表項裡儲存了虛擬記憶體地址與實體記憶體地址的對映關係,當 CPU 存取記憶體的時候需要頻繁通過 MMU 存取頁表項獲取實體記憶體地址,由於要頻繁存取,所以頁表項一般會快取在 TLB 中,因為巨型頁需要的頁表項較少,所以節約了 TLB 的空間同時降低了 TLB 快取 MISS 的概率,從而加速了記憶體存取。

還有一個使用巨型頁受益場景就是,當一個記憶體佔用很大的程序(比如 Redis)通過 fork 系統呼叫建立子程序的時候,會拷貝父程序的相關資源,其中就包括父程序的頁表,由於巨型頁使用的頁表項少,所以拷貝的時候效能會提升不少。

以上就是巨型頁存在的原因以及使用的場景,但是在 Linux 記憶體管理架構中都是統一通過 struct page 來管理記憶體,而巨型大頁卻是通過兩個或者多個物理上連續的記憶體頁 page 組裝成的一個比普通記憶體頁 page 更大的頁,那麼巨型頁的管理與普通頁的管理如何統一呢?

這就引出了本小節的主題-----複合頁 compound_page,下面我們就來看下 Linux 如果通過統一的 struct page 結構來描述這些巨型頁(compound_page):

雖然巨型頁(compound_page)是由多個物理上連續的普通 page 組成的,但是在核心的視角里它還是被當做一個特殊記憶體頁來看待。

下圖所示,是由 4 個連續的普通記憶體頁 page 組成的一個 compound_page:

組成複合頁的第一個 page 我們稱之為首頁(Head Page),其餘的均稱之為尾頁(Tail Page)。

我們來看一下 struct page 中關於描述 compound_page 的相關欄位:

      struct page {      
            // 首頁 page 中的 flags 會被設定為 PG_head 表示複合頁的第一頁
            unsigned long flags;	
            // 其餘尾頁會通過該欄位指向首頁
            unsigned long compound_head;   
            // 用於釋放複合頁的解構函式,儲存在首頁中
            unsigned char compound_dtor;
            // 該複合頁有多少個 page 組成,order 還是分配階的概念,首頁中儲存
            // 本例中的 order = 2 表示由 4 個普通頁組成
            unsigned char compound_order;
            // 該複合頁被多少個程序使用,記憶體頁反向對映的概念,首頁中儲存
            atomic_t compound_mapcount;
            // 複合頁使用計數,首頁中儲存
            atomic_t compound_pincount;
      }

首頁對應的 struct page 結構裡的 flags 會被設定為 PG_head,表示這是複合頁的第一頁。

另外首頁中還儲存關於複合頁的一些額外資訊,比如用於釋放複合頁的解構函式會儲存在首頁 struct page 結構裡的 compound_dtor 欄位中,複合頁的分配階 order 會儲存在首頁中的 compound_order 中,以及用於指示覆合頁的參照計數 compound_pincount,以及複合頁的反向對映個數(該複合頁被多少個程序的頁表所對映)compound_mapcount 均在首頁中儲存。

複合頁中的所有尾頁都會通過其對應的 struct page 結構中的 compound_head 指向首頁,這樣通過首頁和尾頁就組裝成了一個完整的複合頁 compound_page 。

6.5 Slab 物件池相關屬性

本小節只是對 slab 的一個簡單介紹,大家有個大概的印象就可以了,後面筆者會有一篇專門的文章為大家詳細介紹 slab 的相關實現細節,到時候還會在重新詳細介紹 struct page 中的相關屬性。

核心中對記憶體頁的分配使用有兩種方式,一種是一頁一頁的分配使用,這種以頁為單位的分配方式核心會向相應記憶體區域 zone 裡的夥伴系統申請以及釋放。

另一種方式就是隻分配小塊的記憶體,不需要一下分配一頁的記憶體,比如前邊章節中提到的 struct page ,anon_vma_chain ,anon_vma ,vm_area_struct 結構範例的分配,這些結構通常就是幾十個位元組大小,並不需要按頁來分配。

為了滿足類似這種小記憶體分配的需要,Linux 核心使用 slab allocator 分配器來分配,slab 就好比一個物件池,核心中的資料結構物件都對應於一個 slab 物件池,用於分配這些固定型別物件所需要的記憶體。

它的基本原理是從夥伴系統中申請一整頁記憶體,然後劃分成多個大小相等的小塊記憶體被 slab 所管理。這樣一來 slab 就和實體記憶體頁 page 發生了關聯,由於 slab 管理的單元是實體記憶體頁 page 內進一步劃分出來的小塊記憶體,所以當 page 被分配給相應 slab 結構之後,struct page 裡也會存放 slab 相關的一些管理資料。

struct page {

        struct {    /* slab, slob and slub */
            union {
                struct list_head slab_list;
                struct {    /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages;  /* Nr of pages left */
                    int pobjects;   /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache *slab_cache; /* not slob */
            /* Double-word boundary */
            void *freelist;     /* first free object */
            union {
                void *s_mem;    /* slab: first object */
                struct {            /* SLUB */
                    unsigned inuse:16;
                    unsigned objects:15;
                    unsigned frozen:1;
                };
            };
        };

}
  • struct list_head slab_list :slab 的管理結構中有眾多用於管理 page 的連結串列,比如:完全空閒的 page 連結串列,完全分配的 page 連結串列,部分分配的 page 連結串列,slab_list 用於指定當前 page 位於 slab 中的哪個具體連結串列上。

  • struct page *next : 當 page 位於 slab 結構中的某個管理連結串列上時,next 指標用於指向連結串列中的下一個 page。

  • int pages : 表示 slab 中總共擁有的 page 個數。

  • int pobjects : 表示 slab 中擁有的特定型別的物件個數。

  • struct kmem_cache *slab_cache : 用於指向當前 page 所屬的 slab 管理結構,通過 slab_cache 將 page 和 slab 關聯起來。

  • void *freelist : 指向 page 中的第一個未分配出去的空閒物件,前面介紹過,slab 向夥伴系統申請一個或者多個 page,並將一整頁 page 劃分出多個大小相等的記憶體塊,用於儲存特定型別的物件。

  • void *s_mem : 指向 page 中的第一個物件。

  • unsigned inuse : 表示 slab 中已經被分配出去的物件個數,當該值為 0 時,表示 slab 中所管理的物件全都是空閒的,當所有的空閒物件達到一定數目,該 slab 就會被夥伴系統回收掉。

  • unsigned objects : slab 中所有的物件個數。

  • unsigned frozen : 當前記憶體頁 page 被 slab 放置在 CPU 本地快取列表中,frozen = 1,否則 frozen = 0 。

總結

到這裡,關於 Linux 實體記憶體管理的相關內容筆者就為大家介紹完了,本文的內容比較多,尤其是實體記憶體頁反向對映相關的內容比較複雜,涉及到的關聯關係比較多,現在筆者在帶大家總結一下本文的主要內容,方便大家複習回顧:

在本文的開始,筆者首先從 CPU 角度為大家介紹了三種實體記憶體模型:FLATMEM 平坦記憶體模型,DISCONTIGMEM 非連續記憶體模型,SPARSEMEM 稀疏記憶體模型。

隨後筆者又接著介紹了兩種實體記憶體架構:一致性記憶體存取 UMA 架構,非一致性記憶體存取 NUMA 架構。

在這個基礎之上,又按照核心對實體記憶體的組織管理層次,分別介紹了 Node 節點,實體記憶體區域 zone 等相關核心結構。它們的層次如下圖所示:

在把握了實體記憶體的總體架構之後,又引出了眾多細節性的內容,比如:實體記憶體區域的管理與劃分,實體記憶體區域中的預留記憶體,實體記憶體區域中的水位線及其計算方式,實體記憶體區域中的冷熱頁。

最後,筆者詳細介紹了核心如何通過 struct page 結構來描述實體記憶體頁,其中匿名頁反向對映的內容比較複雜,需要大家多多梳理回顧一下。

好了,本文的內容到這裡就全部結束了,感謝大家的耐心觀看,我們下篇文章見~~~