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 都是需要通過總線來訪問記憶體的,這樣總線很快就會成為性能瓶頸,主要體現在以下兩個方面:
-
總線的帶寬壓力會越來越大,隨著 CPU 個數的增多導致每個 CPU 可用帶寬會減少
-
總線的長度也會因此而增加,進而增加訪問延遲
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#top_of_page
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#top_of_page
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 節點內的物理記憶體主要劃分為以下四個物理記憶體區域:
-
ZONE_DMA:用于那些無法對全部物理記憶體進行尋址的硬體設備,進行 DMA 時的記憶體分配,例如前邊介紹的 ISA 設備只能對物理記憶體的前 16M 進行尋址,該區域的長度依賴于具體的處理器型別,
-
ZONE_DMA32:與 ZONE_DMA 區域類似,該區域內的物理頁面可用于執行 DMA 操作,不同之處在于該區域是提供給 32 位設備(只能尋址 4G 物理記憶體)執行 DMA 操作時使用的,該區域只在 64 位系統中起作用,因為只有在 64 位系統中才會專門為 32 位設備提供專門的 DMA 區域,
-
ZONE_NORMAL:這個區域的物理頁都可以直接映射到內核中的虛擬記憶體,由于是線性映射,內核可以直接進行訪問,
-
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 還為作業系統預留了一部分記憶體,這部分預留的物理記憶體用于內核的一些核心操作,這些操作無論如何是不允許記憶體分配失敗的,
什么意思呢?內核中關于記憶體分配的場景無外乎有兩種方式:
-
當行程請求內核分配記憶體時,如果此時記憶體比較充裕,那么行程的請求會被立刻滿足,如果此時記憶體已經比較緊張,內核就需要將一部分不經常使用的記憶體進行回收,從而騰出一部分記憶體滿足行程的記憶體分配的請求,在這個回收記憶體的程序中,行程會一直阻塞等待,
-
另一種記憶體分配場景,行程是不允許阻塞的,記憶體分配的請求必須馬上得到滿足,比如執行中斷處理程式或者執行持有自旋鎖等臨界區內的代碼時,行程就不允許睡眠,因為中斷程式無法被重新調度,這時就需要內核提前為這些核心操作預留一部分記憶體,當記憶體緊張時,可以使用這部分預留的記憶體給這些操作分配,
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 物理記憶體區域中的水位線
記憶體資源是系統中最寶貴的系統資源,是有限的,當記憶體資源緊張的時候,系統的應對方法無非就是三種:
-
產生 OOM,內核直接將系統中占用大量記憶體的行程,將 OOM 優先級最高的行程干掉,釋放出這個行程占用的記憶體供其他更需要的行程分配使用,
-
記憶體回收,將不經常使用到的記憶體回收,騰挪出來的記憶體供更需要的行程分配使用,
-
記憶體規整,將可遷移的物理頁面進行遷移規整,消除記憶體碎片,從而獲得更大的一片連續物理記憶體空間供行程分配,
我們都知道,內核將物理記憶體劃分成一頁一頁的單位進行管理(每頁 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.c 的 init_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 物理記憶體區域中的水位線》小節中我們提到,內核中的物理記憶體頁有兩種型別,分別用于不同的場景:
-
一種是匿名頁,匿名頁背后并沒有一個磁盤中的檔案作為資料來源,匿名頁中的資料都是通過行程運行程序中產生的,匿名頁直接和行程虛擬地址空間建立映射供行程使用,
-
另外一種是檔案頁,檔案頁中的資料來自于磁盤中的檔案,檔案頁需要先關聯一個磁盤中的檔案,然后再和行程虛擬地址空間建立映射供行程使用,使得行程可以通過操作虛擬記憶體實作對檔案的操作,這就是我們常說的記憶體檔案映射,
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 鏈表,作業原理如下:
-
首先 inactive 鏈表的尾部存放的是訪問頻率最低并且最少訪問的頁面,在記憶體緊張的時候,這些頁面被置換出去的優先級是最大的,
-
對于檔案頁來說,當它被第一次讀取的時候,內核會將它放置在 inactive 鏈表的頭部,如果它繼續被訪問,則會提升至 active 鏈表的尾部,如果它沒有繼續被訪問,則會隨著新檔案頁的進入,內核會將它慢慢的推到 inactive 鏈表的尾部,如果此時再次被訪問則會直接被提升到 active 鏈表的頭部,大家可以看出此時頁面的使用頻率這個因素已經被考量了進來,
-
對于匿名頁來說,當它被第一次讀取的時候,內核會直接將它放置在 active 鏈表的尾部,注意不是 inactive 鏈表的頭部,這里和檔案頁不同,因為匿名頁的換出 Swap Out 成本會更大,內核會對匿名頁更加優待,當匿名頁再次被訪問的時候就會被被提升到 active 鏈表的頭部,
-
當遇到記憶體緊張的情況需要換頁時,內核會從 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 結構來描述物理記憶體頁,其中匿名頁反向映射的內容比較復雜,需要大家多多梳理回顧一下,
好了,本文的內容到這里就全部結束了,感謝大家的耐心觀看,我們下篇文章見~~~
轉載請註明出處,本文鏈接:https://www.uj5u.com/caozuo/537971.html
標籤:其他
下一篇:全志V3S 除錯串口更改或關閉
