一步一圖帶你深入理解 Linux 物理內存管理

  1. 前文回顧

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

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

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

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

在我們清楚了虛擬內存地址這個基本概念之後,隨後筆者又拋出了一個問題:爲什麼我們要通過虛擬內存地址訪問內存而不是直接通過物理地址訪問?

原來是在多進程系統中直接操作物理內存地址的話,我們需要精確地知道每一個變量的位置都被安排在了哪裏,而且還要注意當前進程在和多個進程同時運行的時候,不能共用同一個地址,否則就會造成地址衝突。

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

在我們清楚了虛擬內存空間引入的意義之後,筆者緊接着爲大家介紹了進程用戶態虛擬內存空間分別在 32 位機器和 64 位機器上的佈局情況:

32 位機器. png64 位機器. png

在瞭解了用戶態虛擬內存空間的佈局之後,緊接着我們又介紹了 Linux 內核如何對用戶態虛擬內存空間進行管理以及相應的管理數據結構:

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

32 位系統中虛擬內存空間整體佈局. png64 位系統中虛擬內存空間整體佈局. png

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

內存 IO 單位. png

最後筆者介紹了 CPU 如何通過物理內存地址向物理內存讀寫數據的完整過程:

CPU 讀取內存. png

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

  1. 從 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 的操作。

當定位到物理頁 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 的計算邏輯又發生了變化。

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

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 稀疏內存模型的擴展內容補充。

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

集羣的規模一大,那麼物理內存出故障的幾率也會大大增加,物理內存的熱插拔對提供集羣高可用性也是至關重要的。

從總體上來講,內存的熱插拔分爲兩個階段:

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

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

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

但是這裏會有一個問題,就是並非所有的物理頁都可以遷移,因爲遷移意味着物理內存地址的變化,而內存的熱插拔應該對進程來說是透明的,所以這些遷移後的物理頁映射的虛擬內存地址是不能變化的。

這一點在進程的用戶空間是沒有問題的,因爲進程在用戶空間訪問內存都是根據虛擬內存地址通過頁表找到對應的物理內存地址,這些遷移之後的物理頁,雖然物理內存地址發生變化,但是內核通過修改相應頁表中虛擬內存地址與物理內存地址之間的映射關係,可以保證虛擬內存地址不會改變。

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

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

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

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

然後在這些可能會被拔出的內存中只分配那些可遷移的內存頁,這些信息會在內存初始化的時候被設置,這樣一來那些不可遷移的頁就不會包含在可能會拔出的內存中,當我們需要將這塊內存熱拔出時, 因爲裏邊的內存頁全部是可遷移的, 從而使內存可以被拔除。

  1. 從 CPU 角度看物理內存架構

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

在本小節中筆者爲大家提供一個新的視角,這一次我們把物理內存看成一個整體,從 CPU 訪問物理內存的角度來看一下物理內存的架構,並從 CPU 與物理內存的相對位置變化來看一下不同物理內存架構下對性能的影響。

3.1 一致性內存訪問 UMA 架構

我們在上篇文章 《深入理解 Linux 虛擬內存管理》的 “8.2 CPU 如何讀寫主存” 小節中提到 CPU 與內存之間的交互是通過總線完成的。

CPU 與內存之間的總線結構. png

上圖展示的是單核 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);

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

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

  1. 內核如何管理 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)
  1. 內核如何管理 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 |

各個內存區域爲防止被高位內存區域過度擠壓佔用,而爲自己預留的內存大小,我們可以通過前邊 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 預留內存大小。

在這種情況下,進程的內存分配會觸發內存回收,但請求進程本身不會被阻塞,由內核的 kswapd 進程異步回收內存。

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

我們可以通過 cat /proc/zoneinfo 命令來查看不同 NUMA 節點中不同內存區域中的水位線:

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

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 緩存結構. png

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 */
};

以上則是內核版本 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 結構中,每一種遷移類型都會對應一個冷熱頁鏈表。

  1. 內核如何描述物理內存頁

經過前邊幾個小節的介紹,我想大家現在應該對 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 鏈表中物理內存頁的個數:

爲什麼會有 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, 

        ................
};

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

另外在很多情況下,內核通常需要等待物理頁 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;
                };
            };
        };

}

總結

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

在本文的開始,筆者首先從 CPU 角度爲大家介紹了三種物理內存模型:FLATMEM 平坦內存模型,DISCONTIGMEM 非連續內存模型,SPARSEMEM 稀疏內存模型。

隨後筆者又接着介紹了兩種物理內存架構:一致性內存訪問 UMA 架構,非一致性內存訪問 NUMA 架構。

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

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Cn-oX0W5DrI2PivaWLDpPw