深入理解 Linux 物理內存分配全鏈路實現
前文回顧
在上篇文章 《深入理解 Linux 物理內存管理》中,筆者詳細的爲大家介紹了 Linux 內核如何對物理內存進行管理以及相關的一些內核數據結構。
在介紹物理內存管理之前,筆者先從 CPU 的角度開始,介紹了三種 Linux 物理內存模型:FLATMEM 平坦內存模型,DISCONTIGMEM 非連續內存模型,SPARSEMEM 稀疏內存模型。
隨後筆者又帶大家站在一個新的視角上,把物理內存看做成一個整體,從 CPU 訪問物理內存以及 CPU 與物理內存的相對位置變化的角度介紹了兩種物理內存架構:一致性內存訪問 UMA 架構,非一致性內存訪問 NUMA 架構。
在 NUMA 架構下,只有 DISCONTIGMEM 非連續內存模型和 SPARSEMEM 稀疏內存模型是可用的。而 UMA 架構下,前面介紹的三種內存模型可以配置使用。
無論是 NUMA 架構還是 UMA 架構在內核中都是使用相同的數據結構來組織管理的,在內核的內存管理模塊中會把 UMA 架構當做只有一個 NUMA 節點的僞 NUMA 架構。
這樣一來這兩種架構模式就在內核中被統一管理起來,我們基於這個事實,深入剖析了內核針對 NUMA 架構下用於物理內存管理的相關數據結構:struct pglist_data (NUMA 節點),struct zone(物理內存區域),struct page(物理頁)。
上圖展示的是在 NUMA 架構下,NUMA 節點與物理內存區域 zone 以及物理內存頁 page 之間的層次關係。
物理內存被劃分成了一個一個的內存節點(NUMA 節點),在每個 NUMA 節點內部又將其所管理的物理內存按照功能不同劃分成了不同的內存區域 zone ,每個內存區域 zone 管理一片用於具體功能的物理內存頁 page,而內核會爲每一個內存區域分配一個夥伴系統用於管理該內存區域下物理內存頁 page 的分配和釋放。
物理內存在內核中管理的層級關係爲:
None -> Zone -> page
在上篇文章的最後,筆者又花了大量的篇幅來爲大家介紹了 struct page 結構,我們瞭解了內核如何通過 struct page 結構來描述物理內存頁,這個結構是內核中最爲複雜的一個結構體,因爲它是物理內存管理的最小單位,被頻繁應用在內核中的各種複雜機制下。
通過以上內容的介紹,筆者覺得大家已經在架構層面上對 Linux 物理內存管理有了一個較爲深刻的認識,現在物理內存管理的架構我們已經建立起來了,那麼內核如何根據這個架構層次來分配物理內存呢?
爲了給大家梳理清楚內核分配物理內存的過程及其涉及到的各個重要模塊,於是就有了本文的內容~~
1. 內核物理內存分配接口
在爲大家介紹物理內存分配之前,筆者先來介紹下內核中用於物理內存分配的幾個核心接口,這幾個物理內存分配接口全部是基於夥伴系統的,夥伴系統有一個特點就是它所分配的物理內存頁全部都是物理上連續的,並且只能分配 2 的整數冪個頁,這裏的整數冪在內核中稱之爲分配階。
下面要介紹的這些物理內存分配接口均需要指定這個分配階,意思就是從夥伴系統申請多少個物理內存頁,假設我們指定分配階爲 order,那麼就會從夥伴系統中申請 2 的 order 次冪個物理內存頁。
內核中提供了一個 alloc_pages 函數用於分配 2 的 order 次冪個物理內存頁,參數中的 unsigned int order 表示向底層夥伴系統指定的分配階,參數 gfp_t gfp 是內核中定義的一個用於規範物理內存分配行爲的修飾符,這裏我們先不展開,後面的小節中筆者會詳細爲大家介紹。
struct page *alloc_pages(gfp_t gfp, unsigned int order);
alloc_pages 函數用於向底層夥伴系統申請 2 的 order 次冪個物理內存頁組成的內存塊,該函數返回值是一個 struct page 類型的指針用於指向申請的內存塊中第一個物理內存頁。
alloc_pages 函數用於分配多個連續的物理內存頁,在內核的某些內存分配場景中有時候並不需要分配這麼多的連續內存頁,而是隻需要分配一個物理內存頁即可,於是內核又提供了 alloc_page 宏,用於這種單內存頁分配的場景,我們可以看到其底層還是依賴了 alloc_pages 函數,只不過 order 指定爲 0。
#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)
當系統中空閒的物理內存無法滿足內存分配時,就會導致內存分配失敗,alloc_pages,alloc_page 就會返回空指針 NULL 。
vmalloc 分配機制底層就是用的 alloc_page
在物理內存分配成功的情況下, alloc_pages,alloc_page 函數返回的都是指向其申請的物理內存塊第一個物理內存頁 struct page 指針。
大家可以直接理解成返回的是一塊物理內存,而 CPU 可以直接訪問的卻是虛擬內存,所以內核又提供了一個函數 __get_free_pages ,該函數直接返回物理內存頁的虛擬內存地址。用戶可以直接使用。
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
__get_free_pages 函數在使用方式上和 alloc_pages 是一樣的,函數參數的含義也是一樣,只不過一個是返回物理內存頁的虛擬內存地址,一個是直接返回物理內存頁。
事實上 __get_free_pages 函數的底層也是基於 alloc_pages 實現的,只不過多了一層虛擬地址轉換的工作。
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
struct page *page;
// 不能在高端內存中分配物理頁,因爲無法直接映射獲取虛擬內存地址
page = alloc_pages(gfp_mask & ~__GFP_HIGHMEM, order);
if (!page)
return 0;
// 將直接映射區中的物理內存頁轉換爲虛擬內存地址
return (unsigned long) page_address(page);
}
page_address 函數用於將給定的物理內存頁 page 轉換爲它的虛擬內存地址,不過這裏只適用於內核虛擬內存空間中的直接映射區,因爲在直接映射區中虛擬內存地址到物理內存地址是直接映射的,虛擬內存地址減去一個固定的偏移就可以直接得到物理內存地址。
如果物理內存頁處於高端內存中,則不能這樣直接進行轉換,在通過 alloc_pages 函數獲取物理內存頁 page 之後,需要調用 kmap 映射將 page 映射到內核虛擬地址空間中。
忘記這塊內容的同學,可以在回看下筆者之前的文章 《深入理解虛擬內存管理》中的 “7.1.4 永久映射區” 小節。
同 alloc_page 函數一樣,內核也提供了 __get_free_page 用於只分配單個物理內存頁的場景,底層還是依賴於 __get_free_pages 函數,參數 order 指定爲 0 。
#define __get_free_page(gfp_mask) \
__get_free_pages((gfp_mask), 0)
無論是 alloc_pages 也好還是 __get_free_pages 也好,它們申請到的內存頁中包含的數據在一開始都不是空白的,而是內核隨機產生的一些垃圾信息,但其實這些信息可能並不都是完全隨機的,很有可能隨機的包含一些敏感的信息。
這些敏感的信息可能會被一些黑客所利用,並對計算機系統產生一些危害行爲,所以從使用安全的角度考慮,內核又提供了一個函數 get_zeroed_page,顧名思義,這個函數會將從夥伴系統中申請到內存頁全部初始化填充爲 0 ,這在分配物理內存頁給用戶空間使用的時候非常有用。
unsigned long get_zeroed_page(gfp_t gfp_mask)
{
return __get_free_pages(gfp_mask | __GFP_ZERO, 0);
}
get_zeroed_page 函數底層也依賴於 __get_free_pages,指定的分配階 order 也是 0,表示從夥伴系統中只申請一個物理內存頁並初始化填充 0 。
除此之外,內核還提供了一個 __get_dma_pages 函數,專門用於從 DMA 內存區域分配適用於 DMA 的物理內存頁。其底層也是依賴於 __get_free_pages 函數。
unsigned long __get_dma_pages(gfp_t gfp_mask, unsigned int order);
這些底層依賴於 __get_free_pages 的物理內存分配函數,在遇到內存分配失敗的情況下都會返回 0 。
以上介紹的物理內存分配函數,分配的均是在物理上連續的內存頁。
當然了,有內存的分配就會有內存的釋放,所以內核還提供了兩個用於釋放物理內存頁的函數:
void __free_pages(struct page *page, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
-
__free_pages : 同 alloc_pages 函數對應,用於釋放一個或者 2 的 order 次冪個內存頁,釋放的物理內存區域起始地址由該區域中的第一個 page 實例指針表示,也就是參數裏的 struct page *page 指針。
-
free_pages:同 __get_free_pages 函數對應,與 __free_pages 函數的區別是在釋放物理內存時,使用了虛擬內存地址而不是 page 指針。
在釋放內存時需要非常謹慎小心,我們只能釋放屬於你自己的內存頁,傳遞了錯誤的 struct page 指針或者錯誤的虛擬內存地址,或者傳遞錯了 order 值,都可能會導致系統的崩潰。在內核空間中,內核是完全信賴自己的,這點和用戶空間不同。
另外內核也提供了 __free_page 和 free_page 兩個宏,專門用於釋放單個物理內存頁。
#define __free_page(page) __free_pages((page), 0)
#define free_page(addr) free_pages((addr), 0)
到這裏,關於內核中對於物理內存分配和釋放的接口,筆者就爲大家交代完了,但是大家可能會有一個疑問,就是我們在介紹 alloc_pages 和 __get_free_pages 函數的時候,它們的參數中都有 gfp_t gfp_mask,之前筆者簡單的提過這個 gfp_mask 掩碼:它是內核中定義的一個用於規範物理內存分配行爲的掩碼。
那麼這個掩碼究竟規範了哪些物理內存的分配行爲 ?並對物理內存的分配有哪些影響呢 ?大家跟着筆者的節奏繼續往下看~~~
2. 規範物理內存分配行爲的掩碼 gfp_mask
筆者在 《深入理解 Linux 物理內存管理》一文中的 “4.3 NUMA 節點物理內存區域的劃分” 小節中曾經爲大家詳細的介紹了 NUMA 節點中物理內存區域 zone 的劃分。
筆者在文章中提到,由於實際的計算機體系結構受到硬件方面的制約,間接限制了頁框的使用方式。於是內核會根據不同的物理內存區域的功能不同,將 NUMA 節點內的物理內存劃分爲:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 這幾個物理內存區域。
ZONE_MOVABLE 區域是內核從邏輯上的劃分,該區域中的物理內存頁面來自於上述幾個內存區域,目的是避免內存碎片和支持內存熱插拔
當我們調用上小節中介紹的那幾個物理內存分配接口時,比如:alloc_pages 和 __get_free_pages。就會遇到一個問題,就是我們申請的這些物理內存到底來自於哪個物理內存區域 zone,假如我們想要從指定的物理內存區域中申請內存,我們該如何告訴內核呢 ?
struct page *alloc_pages(gfp_t gfp, unsigned int order);
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
這時,這些物理內存分配接口中的 gfp_t 參數就派上用場了,前綴 gfp 是 get free page 的縮寫,意思是在獲取空閒物理內存頁的時候需要指定的分配掩碼 gfp_mask。
gfp_mask 中的低 4 位用來表示應該從哪個物理內存區域 zone 中獲取內存頁 page。
gfp_mask 掩碼中這些區域修飾符 zone modifiers 定義在內核 /include/linux/gfp.h
文件中:
#define ___GFP_DMA 0x01u
#define ___GFP_HIGHMEM 0x02u
#define ___GFP_DMA32 0x04u
#define ___GFP_MOVABLE 0x08u
大家這裏可能會感到好奇,爲什麼沒有定義 ___GFP_NORMAL 的掩碼呢?
這是因爲內核對物理內存的分配主要是落在 ZONE_NORMAL 區域中,如果我們不指定物理內存的分配區域,那麼內核會默認從 ZONE_NORMAL 區域中分配內存,如果 ZONE_NORMAL 區域中的空閒內存不夠,內核則會降級到 ZONE_DMA 區域中分配。
關於物理內存分配的區域降級策略,筆者在前面的文章《深入理解 Linux 物理內存管理》的 “5.1 物理內存區域中的預留內存” 小節中已經詳細地爲大家介紹過了,但是之前的介紹只是停留在理論層面,那麼這個物理內存區域降級策略是在哪裏實現的呢?接下來的內容筆者就爲大家揭曉~~~
內核在 /include/linux/gfp.h
文件中定義了一個叫做 gfp_zone 的函數,這個函數用於將我們在物理內存分配接口中指定的 gfp_mask 掩碼轉換爲物理內存區域,返回的這個物理內存區域是內存分配的最高級內存區域,如果這個最高級內存區域不足以滿足內存分配的需求,則按照 ZONE_HIGHMEM -> ZONE_NORMAL -> ZONE_DMA
的順序依次降級。
static inline enum zone_type gfp_zone(gfp_t flags)
{
enum zone_type z;
int bit = (__force int) (flags & GFP_ZONEMASK);
z = (GFP_ZONE_TABLE >> (bit * GFP_ZONES_SHIFT)) &
((1 << GFP_ZONES_SHIFT) - 1);
VM_BUG_ON((GFP_ZONE_BAD >> bit) & 1);
return z;
}
上面的這個 gfp_zone 函數是在內核 5.19 版本中的實現,在高版本的實現中用大量的移位操作替換了低版本中的實現,目的是爲了提高程序的性能,但是帶來的卻是可讀性的大幅下降。
筆者寫到這裏覺得給大家分析清楚每一步移位操作的實現對大家理解這個函數的主幹邏輯並沒有什麼實質意義上的幫助,並且和本文主題偏離太遠,所以我們退回到低版本 2.6.24 中的實現,在這一版中直擊 gfp_zone 函數原本的面貌。
static inline enum zone_type gfp_zone(gfp_t flags)
{
int base = 0;
#ifdef CONFIG_NUMA
if (flags & __GFP_THISNODE)
base = MAX_NR_ZONES;
#endif
#ifdef CONFIG_ZONE_DMA
if (flags & __GFP_DMA)
return base + ZONE_DMA;
#endif
#ifdef CONFIG_ZONE_DMA32
if (flags & __GFP_DMA32)
return base + ZONE_DMA32;
#endif
if ((flags & (__GFP_HIGHMEM | __GFP_MOVABLE)) ==
(__GFP_HIGHMEM | __GFP_MOVABLE))
return base + ZONE_MOVABLE;
#ifdef CONFIG_HIGHMEM
if (flags & __GFP_HIGHMEM)
return base + ZONE_HIGHMEM;
#endif
// 默認從 normal 區域中分配內存
return base + ZONE_NORMAL;
}
我們看到在內核 2.6.24 版本中的 gfp_zone 函數實現邏輯就非常的清晰了,核心邏輯主要如下:
-
只要掩碼 flags 中設置了 __GFP_DMA,則不管 __GFP_HIGHMEM 有沒有設置,內存分配都只會在 ZONE_DMA 區域中分配。
-
如果掩碼只設置了 ZONE_HIGHMEM,則在物理內存分配時,優先在 ZONE_HIGHMEM 區域中進行分配,如果容量不夠則降級到 ZONE_NORMAL 中,如果還是不夠則進一步降級至 ZONE_DMA 中分配。
-
如果掩碼既沒有設置 ZONE_HIGHMEM 也沒有設置 __GFP_DMA,則走到最後的分支,默認優先從 ZONE_NORMAL 區域中進行內存分配,如果容量不夠則降級至 ZONE_DMA 區域中分配。
-
單獨設置 __GFP_MOVABLE 其實並不會影響內核的分配策略,我們如果想要讓內核在 ZONE_MOVABLE 區域中分配內存需要同時指定 __GFP_MOVABLE 和 __GFP_HIGHMEM 。
ZONE_MOVABLE 只是內核定義的一個虛擬內存區域,目的是避免內存碎片和支持內存熱插拔。上述介紹的 ZONE_HIGHMEM,ZONE_NORMAL,ZONE_DMA 纔是真正的物理內存區域,ZONE_MOVABLE 虛擬內存區域中的物理內存來自於上述三個物理內存區域。
在 32 位系統中 ZONE_MOVABLE 虛擬內存區域中的物理內存頁來自於 ZONE_HIGHMEM。
在 64 位系統中 ZONE_MOVABLE 虛擬內存區域中的物理內存頁來自於 ZONE_NORMAL 或者 ZONE_DMA 區域。
下面是不同的 gfp_t 掩碼設置方式與其對應的內存區域降級策略彙總列表:
除了上述介紹 gfp_t 掩碼中的這四個物理內存區域修飾符之外,內核還定義了一些規範內存分配行爲的修飾符,這些行爲修飾符並不會限制內核從哪個物理內存區域中分配內存,而是會限制物理內存分配的行爲,那麼具體會限制哪些內存分配的行爲呢?讓我們接着往下看~~~
這些內存分配行爲修飾符同樣也是定義在 /include/linux/gfp.h
文件中:
#define ___GFP_RECLAIMABLE 0x10u
#define ___GFP_HIGH 0x20u
#define ___GFP_IO 0x40u
#define ___GFP_FS 0x80u
#define ___GFP_ZERO 0x100u
#define ___GFP_ATOMIC 0x200u
#define ___GFP_DIRECT_RECLAIM 0x400u
#define ___GFP_KSWAPD_RECLAIM 0x800u
#define ___GFP_NOWARN 0x2000u
#define ___GFP_RETRY_MAYFAIL 0x4000u
#define ___GFP_NOFAIL 0x8000u
#define ___GFP_NORETRY 0x10000u
#define ___GFP_HARDWALL 0x100000u
#define ___GFP_THISNODE 0x200000u
#define ___GFP_MEMALLOC 0x20000u
#define ___GFP_NOMEMALLOC 0x80000u
-
___GFP_RECLAIMABLE 用於指定分配的頁面是可以回收的,___GFP_MOVABLE 則是用於指定分配的頁面是可以移動的,這兩個標誌會影響底層的夥伴系統從哪個區域中去獲取空閒內存頁,這塊內容我們會在後面講解夥伴系統的時候詳細介紹。
-
___GFP_HIGH 表示該內存分配請求是高優先級的,內核急切的需要內存,如果內存分配失敗則會給系統帶來非常嚴重的後果,設置該標誌通常內存是不允許分配失敗的,如果空閒內存不足,則會從緊急預留內存中分配。
關於物理內存區域中的緊急預留內存相關內容,筆者在之前文章 《深入理解 Linux 物理內存管理》一文中的 “5.1 物理內存區域中的預留內存” 小節中已經詳細介紹過了。
-
___GFP_IO 表示內核在分配物理內存的時候可以發起磁盤 IO 操作。什麼意思呢?比如當內核在進行內存分配的時候,發現物理內存不足,這時需要將不經常使用的內存頁置換到 SWAP 分區或者 SWAP 文件中,這時就涉及到了 IO 操作,如果設置了該標誌,表示允許內核將不常用的內存頁置換出去。
-
___GFP_FS 允許內核執行底層文件系統操作,在與 VFS 虛擬文件系統層相關聯的內核子系統中必須禁用該標誌,否則可能會引起文件系統操作的循環遞歸調用,因爲在設置 ___GFP_FS 標誌分配內存的情況下,可能會引起更多的文件系統操作,而這些文件系統的操作可能又會進一步產生內存分配行爲,這樣一直遞歸持續下去。
-
___GFP_ZERO 在內核分配內存成功之後,將內存頁初始化填充字節 0 。
-
___GFP_ATOMIC 該標誌的設置表示內存在分配物理內存的時候不允許睡眠必須是原子性地進行內存分配。比如在中斷處理程序中,就不能睡眠,因爲中斷程序不能被重新調度。同時也不能在持有自旋鎖的進程上下文中睡眠,因爲可能導致死鎖。綜上所述這個標誌只能用在不能被重新安全調度的進程上下文中。
-
___GFP_DIRECT_RECLAIM 表示內核在進行內存分配的時候,可以進行直接內存回收。當剩餘內存容量低於水位線 _watermark[WMARK_MIN] 時,說明此時的內存容量已經非常危險了,如果進程在這時請求內存分配,內核就會進行直接內存回收,直到內存水位線恢復到 _watermark[WMARK_HIGH] 之上。
-
___GFP_KSWAPD_RECLAIM 表示內核在分配內存的時候,如果剩餘內存容量在 _watermark[WMARK_MIN] 與 _watermark[WMARK_LOW] 之間時,內核就會喚醒 kswapd 進程開始異步內存回收,直到剩餘內存高於 _watermark[WMARK_HIGH] 爲止。
-
___GFP_NOWARN 表示當內核分配內存失敗時,抑制內核的分配失敗錯誤報告。
-
___GFP_RETRY_MAYFAIL 在內核分配內存失敗的時候,允許重試,但重試仍然可能失敗,重試若干次後停止。與其對應的是 ___GFP_NORETRY 標誌表示分配內存失敗時不允許重試。
-
___GFP_NOFAIL 在內核分配失敗時一直重試直到成功爲止。
-
___GFP_HARDWALL 該標誌限制了內核分配內存的行爲只能在當前進程分配到的 CPU 所關聯的 NUMA 節點上進行分配,當進程可以運行的 CPU 受限時,該標誌纔會有意義,如果進程允許在所有 CPU 上運行則該標誌沒有意義。
-
___GFP_THISNODE 該標誌限制了內核分配內存的行爲只能在當前 NUMA 節點或者在指定 NUMA 節點中分配內存,如果內存分配失敗不允許從其他備用 NUMA 節點中分配內存。
-
___GFP_MEMALLOC 允許內核在分配內存時可以從所有內存區域中獲取內存,包括從緊急預留內存中獲取。但使用該標示時需要保證進程在獲得內存之後會很快的釋放掉內存不會過長時間的佔用,尤其要警惕避免過多的消耗緊急預留內存區域中的內存。
-
___GFP_NOMEMALLOC 標誌用於明確禁止內核從緊急預留內存中獲取內存。___GFP_NOMEMALLOC 標識的優先級要高於 ___GFP_MEMALLOC
好了到現在爲止,我們已經知道了 gfp_t 掩碼中包含的內存區域修飾符以及內存分配行爲修飾符,是不是感覺頭有點大了,事實上確實很讓人頭大,因爲內核在不同場景下會使用不同的組合,這麼多的修飾符總是以組合的形式出現,如果我們每次使用的時候都需要單獨指定,那就會非常繁雜也很容易出錯。
於是內核將各種標準情形下用到的 gfp_t 掩碼組合,提前爲大家定義了一些標準的分組,方便大家直接使用。
#define GFP_ATOMIC (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM)
#define GFP_NOIO (__GFP_RECLAIM)
#define GFP_NOFS (__GFP_RECLAIM | __GFP_IO)
#define GFP_USER (__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
#define GFP_DMA __GFP_DMA
#define GFP_DMA32 __GFP_DMA32
#define GFP_HIGHUSER (GFP_USER | __GFP_HIGHMEM)
-
GFP_ATOMIC 是掩碼 __GFP_HIGH,__GFP_ATOMIC,__GFP_KSWAPD_RECLAIM 的組合,表示內存分配行爲必須是原子的,是高優先級的。在任何情況下都不允許睡眠,如果空閒內存不夠,則會從緊急預留內存中分配。該標誌適用於中斷程序,以及持有自旋鎖的進程上下文中。
-
GFP_KERNEL 是內核中最常用的標誌,該標誌設置之後內核的分配內存行爲可能會阻塞睡眠,可以允許內核置換出一些不活躍的內存頁到磁盤中。適用於可以重新安全調度的進程上下文中。
-
GFP_NOIO 和 GFP_NOFS 分別禁止內核在分配內存時進行磁盤 IO 和 文件系統 IO 操作。
-
GFP_USER 用於映射到用戶空間的內存分配,通常這些內存可以被內核或者硬件直接訪問,比如硬件設備會將 Buffer 直接映射到用戶空間中
-
GFP_DMA 和 GFP_DMA32 表示需要從 ZONE_DMA 和 ZONE_DMA32 內存區域中獲取適用於 DMA 的內存頁。
-
GFP_HIGHUSER 用於給用戶空間分配高端內存,因爲在用戶虛擬內存空間中,都是通過頁表來訪問非直接映射的高端內存區域,所以用戶空間一般使用的是高端內存區域 ZONE_HIGHMEM。
現在我們算是真正理解了,在本小節開始時,介紹的那幾個內存分配接口函數中關於內存分配掩碼 gfp_mask 的所有內容,其中包括用於限制內核從哪個內存區域中分配內存,內核在分配內存過程中的行爲,以及內核在各種標準分配場景下預先定義的掩碼組合。
這時我們在回過頭來看內核中關於物理內存分配的這些接口函數是不是感覺瞭如指掌了:
struct page *alloc_pages(gfp_t gfp, unsigned int order)
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
unsigned long get_zeroed_page(gfp_t gfp_mask)
unsigned long __get_dma_pages(gfp_t gfp_mask, unsigned int order)
好了,現在我們已經清楚了這些內存分配接口的使用,那麼這些接口又是如何實現的呢 ?讓我們再一次深入到內核源碼中去探索內核到底是如何分配物理內存的~~
3. 物理內存分配內核源碼實現
本文基於內核 5.19 版本討論
在介紹 Linux 內核關於內存分配的源碼實現之前,我們需要先找到內存分配的入口函數在哪裏,在上小節中爲大家介紹的衆多內存分配接口的依賴層級關係如下圖所示:
我們看到內存分配的任務最終會落在 alloc_pages 這個接口函數中,在 alloc_pages 中會調用 alloc_pages_node 進而調用 __alloc_pages_node 函數,最終通過 __alloc_pages 函數正式進入內核內存分配的世界~~
__alloc_pages 函數爲 Linux 內核內存分配的核心入口函數
static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
return alloc_pages_node(numa_node_id(), gfp_mask, order);
}
static inline struct page *
__alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order)
{
// 校驗指定的 NUMA 節點 ID 是否合法,不要越界
VM_BUG_ON(nid < 0 || nid >= MAX_NUMNODES);
// 指定節點必須是有效在線的
VM_WARN_ON((gfp_mask & __GFP_THISNODE) && !node_online(nid));
return __alloc_pages(gfp_mask, order, nid, NULL);
}
__alloc_pages_node 函數參數中的 nid 就是我們在上篇文章 《深入理解 Linux 物理內存管理》的 “4.1 內核如何統一組織 NUMA 節點” 小節介紹的 NUMA 節點 id。
內核使用了一個大小爲 MAX_NUMNODES 的全局數組 node_data[] 來管理所有的 NUMA 節點,數組的下標即爲 NUMA 節點 Id 。
#ifdef CONFIG_NUMA
extern struct pglist_data *node_data[];
#define NODE_DATA(nid) (node_data[(nid)])
這裏指定 nid 是爲了告訴內核應該在哪個 NUMA 節點上分配內存,我們看到在 alloc_pages 函數中通過 numa_node_id() 獲取運行當前進程的 CPU 所在的 NUMA 節點。並通過 !node_online(nid)
確保指定的 NUMA 節點是有效在線的。
關於 NUMA 節點的狀態信息,大家可回看上篇文章的 《4.5 NUMA 節點的狀態 node_states》小節。
3.1 內存分配行爲標識掩碼 ALLOC_*
在我們進入 __alloc_pages 函數之前,筆者先來爲大家介紹幾個影響內核分配內存行爲的標識,這些重要標識定義在內核文件 /mm/internal.h
中:
#define ALLOC_WMARK_MIN WMARK_MIN
#define ALLOC_WMARK_LOW WMARK_LOW
#define ALLOC_WMARK_HIGH WMARK_HIGH
#define ALLOC_NO_WATERMARKS 0x04 /* don't check watermarks at all */
#define ALLOC_HARDER 0x10 /* try to alloc harder */
#define ALLOC_HIGH 0x20 /* __GFP_HIGH set */
#define ALLOC_CPUSET 0x40 /* check for correct cpuset */
#define ALLOC_KSWAPD 0x800 /* allow waking of kswapd, __GFP_KSWAPD_RECLAIM set */
我們先來看前四個標識內存水位線的常量含義,這四個內存水位線標識表示內核在分配內存時必須考慮內存的水位線,在不同的水位線下內存的分配行爲也會有所不同。
筆者在上篇文章 《深入理解 Linux 物理內存管理》的 “5.2 物理內存區域中的水位線” 小節中曾詳細地介紹了各個水位線的含義以及在不同水位線下內存分配的不同表現。
上篇文章中我們提到,內核會爲 NUMA 節點中的每個物理內存區域 zone 定製三條用於指示內存容量的水位線,它們分別是:WMARK_MIN(頁最小閾值), WMARK_LOW (頁低閾值),WMARK_HIGH(頁高閾值)。
這三個水位線定義在 /include/linux/mmzone.h
文件中:
enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
NR_WMARK
};
三條水位線對應的 watermark 具體數值存儲在每個物理內存區域 struct zone 結構中的 _watermark[NR_WMARK] 數組中。
struct zone {
// 物理內存區域中的水位線
unsigned long _watermark[NR_WMARK];
}
物理內存區域中不同水位線的含義以及內存分配在不同水位線下的行爲如下圖所示:
-
當該物理內存區域的剩餘內存容量高於 _watermark[WMARK_HIGH] 時,說明此時該物理內存區域中的內存容量非常充足,內存分配完全沒有壓力。
-
當剩餘內存容量在 _watermark[WMARK_LOW] 與_watermark[WMARK_HIGH] 之間時,說明此時內存有一定的消耗但是還可以接受,能夠繼續滿足進程的內存分配需求。
-
當剩餘內存容量在 _watermark[WMARK_MIN] 與 _watermark[WMARK_LOW] 之間時,說明此時內存容量已經有點危險了,內存分配面臨一定的壓力,但是還可以滿足進程此時的內存分配要求,當給進程分配完內存之後,就會喚醒 kswapd 進程開始內存回收,直到剩餘內存高於 _watermark[WMARK_HIGH] 爲止。
在這種情況下,進程的內存分配會觸發內存回收,但請求進程本身不會被阻塞,由內核的 kswapd 進程異步回收內存。
- 當剩餘內存容量低於 _watermark[WMARK_MIN] 時,說明此時的內存容量已經非常危險了,如果進程在這時請求內存分配,內核就會進行直接內存回收,這時內存回收的任務將會由請求進程同步完成。
注意:上面提到的物理內存區域 zone 的剩餘內存是需要刨去 lowmem_reserve 預留內存大小(用於緊急內存分配)。也就是說 zone 裏被夥伴系統所管理的內存並不包含 lowmem_reserve 預留內存。
好了,在我們重新回顧了內存分配行爲在這三條水位線:_watermark[WMARK_HIGH],_watermark[WMARK_LOW],watermark[WMARK_MIN] 下的不同表現之後,我們在回過來看本小節開始處提到的那幾個 ALLOC* 內存分配標識。
ALLOC_NO_WATERMARKS 表示在內存分配過程中完全不會考慮上述三個水位線的影響。
ALLOC_WMARK_HIGH 表示在內存分配的時候,當前物理內存區域 zone 中剩餘內存頁的數量至少要達到 _watermark[WMARK_HIGH] 水位線,才能進行內存的分配。
ALLOC_WMARK_LOW 和 ALLOC_WMARK_MIN 要表達的內存分配語義也是一樣,當前物理內存區域 zone 中剩餘內存頁的數量至少要達到水位線 _watermark[WMARK_LOW] 或者 _watermark[WMARK_MIN],才能進行內存的分配。
ALLOC_HARDER 表示在內存分配的時候,會放寬內存分配規則的限制,所謂的放寬規則就是降低 _watermark[WMARK_MIN] 水位線,努力使內存分配最大可能成功。
當我們在 gfp_t 掩碼中設置了 ___GFP_HIGH 時,ALLOC_HIGH 標識才起作用,該標識表示當前內存分配請求是高優先級的,內核急切的需要內存,如果內存分配失敗則會給系統帶來非常嚴重的後果,設置該標誌通常內存是不允許分配失敗的,如果空閒內存不足,則會從緊急預留內存中分配。
ALLOC_CPUSET 表示內存只能在當前進程所允許運行的 CPU 所關聯的 NUMA 節點中進行分配。比如使用 cgroup 限制進程只能在某些特定的 CPU 上運行,那麼進程所發起的內存分配請求,只能在這些特定 CPU 所在的 NUMA 節點中進行。
ALLOC_KSWAPD 表示允許喚醒 NUMA 節點中的 KSWAPD 進程,異步進行內存回收。
內核會爲每個 NUMA 節點分配一個 kswapd 進程用於回收不經常使用的頁面。
typedef struct pglist_data {
.........
// 頁面回收進程
struct task_struct *kswapd;
..........
} pg_data_t;
3.2 內存分配的心臟 __alloc_pages
好了,在爲大家介紹完這些影響內存分配行爲的相關標識掩碼:GFP_*
,ALLOC_*
之後,下面就該來介紹本文的主題——物理內存分配的核心函數 __alloc_pages ,從下面內核源碼的註釋中我們可以看出,這個函數正是夥伴系統的核心心臟,它是內核內存分配的核心入口函數,整個內存分配的完整過程全部封裝在這裏。
該函數的邏輯比較複雜,因爲在內存分配過程中需要涉及處理各種
GFP_*
,ALLOC_*
標識,然後根據上述各種標識的含義來決定內存分配該如何進行。所以大家需要多點耐心,一步一步跟着筆者的思路往下走~~~
/*
* This is the 'heart' of the zoned buddy allocator.
*/
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
// 用於指向分配成功的內存
struct page *page;
// 內存區域中的剩餘內存需要在 WMARK_LOW 水位線之上才能進行內存分配,否則失敗(初次嘗試快速內存分配)
unsigned int alloc_flags = ALLOC_WMARK_LOW;
// 之前小節中介紹的內存分配掩碼集合
gfp_t alloc_gfp;
// 用於在不同內存分配輔助函數中傳遞參數
struct alloc_context ac = { };
// 檢查用於向夥伴系統申請內存容量的分配階 order 的合法性
// 內核定義最大分配階 MAX_ORDER -1 = 10,也就是說一次最多隻能從夥伴系統中申請 1024 個內存頁。
if (WARN_ON_ONCE_GFP(order >= MAX_ORDER, gfp))
return NULL;
// 表示在內存分配期間進程可以休眠阻塞
gfp &= gfp_allowed_mask;
alloc_gfp = gfp;
// 初始化 alloc_context,併爲接下來的快速內存分配設置相關 gfp
if (!prepare_alloc_pages(gfp, order, preferred_nid, nodemask, &ac,
&alloc_gfp, &alloc_flags))
// 提前判斷本次內存分配是否能夠成功,如果不能則儘早失敗
return NULL;
// 避免內存碎片化的相關分配標識設置,可暫時忽略
alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp);
// 內存分配快速路徑:第一次嘗試從底層夥伴系統分配內存,注意此時是在 WMARK_LOW 水位線之上分配內存
page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
if (likely(page))
// 如果內存分配成功則直接返回
goto out;
// 流程走到這裏表示內存分配在快速路徑下失敗
// 這裏需要恢復最初的內存分配標識設置,後續會嘗試更加激進的內存分配策略
alloc_gfp = gfp;
// 恢復最初的 node mask 因爲它可能在第一次內存分配的過程中被改變
// 本函數中 nodemask 起初被設置爲 null
ac.nodemask = nodemask;
// 在第一次快速內存分配失敗之後,說明內存已經不足了,內核需要做更多的工作
// 比如通過 kswap 回收內存,或者直接內存回收等方式獲取更多的空閒內存以滿足內存分配的需求
// 所以下面的過程稱之爲慢速分配路徑
page = __alloc_pages_slowpath(alloc_gfp, order, &ac);
out:
// 內存分配成功,直接返回 page。否則返回 NULL
return page;
}
__alloc_pages 函數中的內存分配整體邏輯如下:
- 首先內核會嘗試在內存水位線 WMARK_LOW 之上快速的進行一次內存分配。這一點我們從開始的
unsigned int alloc_flags = ALLOC_WMARK_LOW
語句中可以看得出來。
- 校驗本次內存分配指定夥伴系統的分配階 order 的有效性,夥伴系統在內核中的最大分配階定義在
/include/linux/mmzone.h
文件中,最大分配階 MAX_ORDER -1 = 10,也就是說一次最多隻能從夥伴系統中申請 1024 個內存頁,對應 4M 大小的連續物理內存。
/* Free memory management - zoned buddy allocator. */
#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
- 調用 prepare_alloc_pages 初始化 alloc_context ,用於在不同內存分配輔助函數中傳遞內存分配參數。爲接下來即將進行的快速內存分配做準備。
struct alloc_context {
// 運行進程 CPU 所在 NUMA 節點以及其所有備用 NUMA 節點中允許內存分配的內存區域
struct zonelist *zonelist;
// NUMA 節點狀態掩碼
nodemask_t *nodemask;
// 內存分配優先級最高的內存區域 zone
struct zoneref *preferred_zoneref;
// 物理內存頁的遷移類型分爲:不可遷移,可回收,可遷移類型,防止內存碎片
int migratetype;
// 內存分配最高優先級的內存區域 zone
enum zone_type highest_zoneidx;
// 是否允許當前 NUMA 節點中的髒頁均衡擴散遷移至其他 NUMA 節點
bool spread_dirty_pages;
};
- 調用 get_page_from_freelist 方法首次嘗試在夥伴系統中進行內存分配,這次內存分配比較快速,只是快速的掃描一下各個內存區域中是否有足夠的空閒內存能夠滿足本次內存分配,如果有則立馬從夥伴系統中申請,如果沒有立即返回, page 設置爲 null,進行後續慢速內存分配處理。
這裏需要注意的是:首次嘗試的快速內存分配是在 WMARK_LOW 水位線之上進行的。
- 當快速內存分配失敗之後,情況就會變得非常複雜,內核將不得不做更多的工作,比如開啓 kswapd 進程異步內存回收,更極端的情況則需要進行直接內存回收,或者直接內存整理以獲取更多的空閒連續內存。這一切的複雜邏輯全部封裝在 __alloc_pages_slowpath 函數中。
alloc_pages_slowpath 函數複雜在於需要結合前邊小節中介紹的 GFP*,ALLOC* 這些內存分配標識,根據不同的標識進入不同的內存分配邏輯分支,涉及到的情況比較繁雜。這裏大家只需要簡單瞭解,後面筆者會詳細介紹~~~
以上介紹的 __alloc_pages 函數內存分配邏輯以及與對應的內存水位線之間的關係如下圖所示:
總體流程介紹完之後,我們接着來看一下以上內存分配過程涉及到的三個重要內存分配輔助函數:prepare_alloc_pages,__alloc_pages_slowpath,get_page_from_freelist 。
3.3 prepare_alloc_pages
prepare_alloc_pages 初始化 alloc_context ,用於在不同內存分配輔助函數中傳遞內存分配參數,爲接下來即將進行的快速內存分配做準備。
static inline bool prepare_alloc_pages(gfp_t gfp_mask, unsigned int order,
int preferred_nid, nodemask_t *nodemask,
struct alloc_context *ac, gfp_t *alloc_gfp,
unsigned int *alloc_flags)
{
// 根據 gfp_mask 掩碼中的內存區域修飾符獲取內存分配最高優先級的內存區域 zone
ac->highest_zoneidx = gfp_zone(gfp_mask);
// 從 NUMA 節點的備用節點鏈表中一次性獲取允許進行內存分配的所有內存區域
ac->zonelist = node_zonelist(preferred_nid, gfp_mask);
ac->nodemask = nodemask;
// 從 gfp_mask 掩碼中獲取頁面遷移屬性,遷移屬性分爲:不可遷移,可回收,可遷移。這裏只需要簡單知道,後面在相關章節會細講
ac->migratetype = gfp_migratetype(gfp_mask);
// 如果使用 cgroup 將進程綁定限制在了某些 CPU 上,那麼內存分配只能在
// 這些綁定的 CPU 相關聯的 NUMA 節點中進行
if (cpusets_enabled()) {
*alloc_gfp |= __GFP_HARDWALL;
if (in_task() && !ac->nodemask)
ac->nodemask = &cpuset_current_mems_allowed;
else
*alloc_flags |= ALLOC_CPUSET;
}
// 如果設置了允許直接內存回收,那麼內存分配進程則可能會導致休眠被重新調度
might_sleep_if(gfp_mask & __GFP_DIRECT_RECLAIM);
// 提前判斷本次內存分配是否能夠成功,如果不能則儘早失敗
if (should_fail_alloc_page(gfp_mask, order))
return false;
// 獲取最高優先級的內存區域 zone
// 後續內存分配則首先會在該內存區域中進行分配
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
return true;
}
prepare_alloc_pages 主要的任務就是在快速內存分配開始之前,做一些準備初始化的工作,其中最核心的就是從指定 NUMA 節點中,根據 gfp_mask 掩碼中的內存區域修飾符獲取可以進行內存分配的所有內存區域 zone (包括其他備用 NUMA 節點中包含的內存區域)。
之前筆者已經在 《深入理解 Linux 物理內存管理》一文中的 “4.3 NUMA 節點物理內存區域的劃分” 小節爲大家已經詳細介紹了 NUMA 節點的數據結構 struct pglist_data。
struct pglist_data 結構中不僅包含了本 NUMA 節點中的所有內存區域,還包括了其他備用 NUMA 節點中的物理內存區域,當本節點中內存不足的情況下,內核會從備用 NUMA 節點中的內存區域進行跨節點內存分配。
typedef struct pglist_data {
// NUMA 節點中的物理內存區域個數
int nr_zones;
// NUMA 節點中的物理內存區域
struct zone node_zones[MAX_NR_ZONES];
// NUMA 節點的備用列表,其中包含了所有 NUMA 節點中的所有物理內存區域 zone,按照訪問距離由近到遠順序依次排列
struct zonelist node_zonelists[MAX_ZONELISTS];
} pg_data_t;
我們可以根據 nid 和 gfp_mask 掩碼中的物理內存區域描述符利用 node_zonelist 函數一次性獲取允許進行內存分配的所有內存區域(所有 NUMA 節點)。
static inline struct zonelist *node_zonelist(int nid, gfp_t flags)
{
return NODE_DATA(nid)->node_zonelists + gfp_zonelist(flags);
}
4. 內存慢速分配入口 alloc_pages_slowpath
正如前邊小節我們提到的那樣,alloc_pages_slowpath 函數非常的複雜,其中包含了內存分配的各種異常情況的處理,並且會根據前邊介紹的 GFP__,ALLOC__ 等各種內存分配策略掩碼進行不同分支的處理,這樣就變得非常的龐大而繁雜。
alloc_pages_slowpath 函數包含了整個內存分配的核心流程,本身非常的繁雜龐大,爲了能夠給大家清晰的梳理清楚這些複雜的內存分配流程,所以筆者決定還是以 總 - 分 - 總
的結構來給大家呈現。
下面這段僞代碼是筆者提取出來的 alloc_pages_slowpath 函數的主幹框架,其中包含的一些核心分支以及核心步驟筆者都通過註釋的形式爲大家標註出來了,這裏我先從總體上大概瀏覽下 alloc_pages_slowpath 主要分爲哪幾個邏輯處理模塊,它們分別處理了哪些事情。
還是那句話,這裏大家只需要總體把握,不需要掌握每個細節,關於細節的部分,筆者後面會帶大家逐個擊破!!!
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速內存分配路徑下的相關參數 .......
retry_cpuset:
......... 調整內存分配策略 alloc_flags 採用更加激進方式獲取內存 ......
......... 此時內存分配主要是在進程所允許運行的 CPU 相關聯的 NUMA 節點上 ......
......... 內存水位線下調至 WMARK_MIN ...........
......... 喚醒所有 kswapd 進程進行異步內存回收 ...........
......... 觸發直接內存整理 direct_compact 來獲取更多的連續空閒內存 ......
retry:
......... 進一步調整內存分配策略 alloc_flags 使用更加激進的非常手段進行內存分配 ...........
......... 在內存分配時忽略內存水位線 ...........
......... 觸發直接內存回收 direct_reclaim ...........
......... 再次觸發直接內存整理 direct_compact ...........
......... 最後的殺手鐧觸發 OOM 機制 ...........
nopage:
......... 經過以上激進的內存分配手段仍然無法滿足內存分配就會來到這裏 ......
......... 如果設置了 __GFP_NOFAIL 不允許內存分配失敗,則不停重試上述內存分配過程 ......
fail:
......... 內存分配失敗,輸出告警信息 ........
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
......... 內存分配成功,返回新申請的內存塊 ........
return page;
}
4.1 初始化內存分配慢速路徑下的相關參數
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
// 在慢速內存分配路徑中可能會導致內核進行直接內存回收
// 這裏設置 __GFP_DIRECT_RECLAIM 表示允許內核進行直接內存回收
bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
// 本次內存分配是否是針對大量內存頁的分配,內核定義 PAGE_ALLOC_COSTLY_ORDER = 3
// 也就是說內存請求內存頁的數量大於 2 ^ 3 = 8 個內存頁時,costly_order = true,後續會影響是否進行 OOM
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
// 用於指向成功申請的內存
struct page *page = NULL;
// 內存分配標識,後續會根據不同標識進入到不同的內存分配邏輯處理分支
unsigned int alloc_flags;
// 後續用於記錄直接內存回收了多少內存頁
unsigned long did_some_progress;
// 關於內存整理相關參數
enum compact_priority compact_priority;
enum compact_result compact_result;
int compaction_retries;
// 記錄重試的次數,超過一定的次數(16次)則內存分配失敗
int no_progress_loops;
// 臨時保存調整後的內存分配策略
int reserve_flags;
// 流程現在來到了慢速內存分配這裏,說明快速分配路徑已經失敗了
// 內核需要對 gfp_mask 分配行爲掩碼做一些修改,修改爲一些更可能導致內存分配成功的標識
// 因爲接下來的直接內存回收非常耗時可能會導致進程阻塞睡眠,不適用原子 __GFP_ATOMIC 內存分配的上下文。
if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
(__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
gfp_mask &= ~__GFP_ATOMIC;
retry_cpuset:
retry:
nopage:
fail:
got_pg:
}
在內核進入慢速內存分配路徑之前,首先會在這裏初始化後續內存分配需要的參數,由於筆者已經在各個字段上標註了豐富的註釋,所以這裏筆者只對那些難以理解的核心參數爲大家進行相關細節的鋪墊,這裏大家對這些參數有個大概印象即可,後續在使用到的時候,筆者還會再次提起~~~
首先我們看 costly_order 參數,order 表示底層夥伴系統的分配階,內核只能向夥伴系統申請 2 的 order 次冪個內存頁,costly 從字面意思上來說表示有一定代價和消耗的,costly_order 連起來就表示在內核中 order 分配階達到多少,在內核看來就是代價比較大的內存分配行爲。
這個臨界值就是 PAGE_ALLOC_COSTLY_ORDER 定義在 /include/linux/mmzone.h
文件中:
#define PAGE_ALLOC_COSTLY_ORDER 3
也就是說在內核看來,當請求內存頁的數量大於 2 ^ 3 = 8 個內存頁時,costly_order = true,內核就認爲本次內存分配是一次成本比較大的行爲。後續會根據這個參數 costly_order 來決定是否觸發 OOM 。
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
當內存嚴重不足的時候,內核會開啓直接內存回收 direct_reclaim ,參數 did_some_progress 表示經過一次直接內存回收之後,內核回收了多少個內存頁。這個參數後續會影響是否需要進行內存分配重試。
no_progress_loops 用於記錄內存分配重試的次數,如果內存分配重試的次數超過最大限制 MAX_RECLAIM_RETRIES,則停止重試,開啓 OOM。
MAX_RECLAIM_RETRIES 定義在 /mm/internal.h
文件中:
#define MAX_RECLAIM_RETRIES 16
compact_* 相關的參數用於直接內存整理 direct_compact,內核通常會在直接內存回收 direct_reclaim 之前進行一次 direct_compact,如果經過 direct_compact 整理之後有了足夠多的空間內存就不需要進行 direct_reclaim 了。
那麼這個 direct_compact 到底是幹什麼的呢?它在慢速內存分配過程起了什麼作用?
隨着系統的長時間運行通常會伴隨着不同大小的物理內存頁的分配和釋放,這種不規則的分配釋放,隨着系統的長時間運行就會導致內存碎片,內存碎片會使得系統在明明有足夠內存的情況下,依然無法爲進程分配合適的內存。
如上圖所示,假如現在系統一共有 16 個物理內存頁,當前系統只是分配了 3 個物理頁,那麼在當前系統中還剩餘 13 個物理內存頁的情況下,如果內核想要分配 8 個連續的物理頁由於內存碎片的存在則會分配失敗。(只能分配最多 4 個連續的物理頁)
內核中請求分配的物理頁面數只能是 2 的次冪!!
爲了解決內存碎片化的問題,內核將內存頁面分爲了:可移動的,可回收的,不可移動的三種類型。
可移動的頁面聚集在一起,可回收的的頁面聚集在一起,不可移動的的頁面聚集也在一起。從而作爲去碎片化的基礎, 然後進行成塊回收。
在回收時把可回收的一起回收,把可移動的一起移動,從而能空出大量連續物理頁面。direct_compact 會掃描內存區域 zone 裏的頁面,把已分配的頁記錄下來,然後把所有已分配的頁移動到 zone 的一端,這樣就會把一個已經充滿碎片的 zone 整理成一段完全未分配的區間和一段已經分配的區間,從而騰出大塊連續的物理頁面供內核分配。
4.2 retry_cpuset
在介紹完了內存分配在慢速路徑下所需要的相關參數之後,下面就正式來到了 alloc_pages_slowpath 的內存分配邏輯:
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速內存分配路徑下的相關參數 .......
retry_cpuset:
// 在之前的快速內存分配路徑下設置的相關分配策略比較保守,不是很激進,用於在 WMARK_LOW 水位線之上進行快速內存分配
// 走到這裏表示快速內存分配失敗,此時空閒內存嚴重不足了
// 所以在慢速內存分配路徑下需要重新設置更加激進的內存分配策略,採用更大的代價來分配內存
alloc_flags = gfp_to_alloc_flags(gfp_mask);
// 重新按照新的設置按照內存區域優先級計算 zonelist 的迭代起點(最高優先級的 zone)
// fast path 和 slow path 的設置不同所以這裏需要重新計算
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
// 如果沒有合適的內存分配區域,則跳轉到 nopage , 內存分配失敗
if (!ac->preferred_zoneref->zone)
goto nopage;
// 喚醒所有的 kswapd 進程異步回收內存
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 此時所有的 kswapd 進程已經被喚醒,正在異步進行內存回收
// 之前我們已經在 gfp_to_alloc_flags 方法中重新調整了 alloc_flags
// 換成了一套更加激進的內存分配策略,注意此時是在 WMARK_MIN 水位線之上進行內存分配
// 調整後的 alloc_flags 很可能會立即成功,因此這裏先嚐試一下
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
// 內存分配成功,跳轉到 got_pg 直接返回 page
goto got_pg;
// 對於分配大內存來說 costly_order = true (超過 8 個內存頁),需要首先進行內存整理,這樣內核可以避免直接內存回收從而獲取更多的連續空閒內存頁
// 對於需要分配不可移動的高階內存的情況,也需要先進行內存整理,防止永久內存碎片
if (can_direct_reclaim &&
(costly_order ||
(order > 0 && ac->migratetype != MIGRATE_MOVABLE))
&& !gfp_pfmemalloc_allowed(gfp_mask)) {
// 進行直接內存整理,獲取更多的連續空閒內存防止內存碎片
page = __alloc_pages_direct_compact(gfp_mask, order,
alloc_flags, ac,
INIT_COMPACT_PRIORITY,
&compact_result);
if (page)
goto got_pg;
if (costly_order && (gfp_mask & __GFP_NORETRY)) {
// 流程走到這裏表示經過內存整理之後依然沒有足夠的內存供分配
// 但是設置了 NORETRY 標識不允許重試,那麼就直接失敗,跳轉到 nopage
if (compact_result == COMPACT_SKIPPED ||
compact_result == COMPACT_DEFERRED)
goto nopage;
// 同步內存整理開銷太大,後續開啓異步內存整理
compact_priority = INIT_COMPACT_PRIORITY;
}
}
retry:
nopage:
fail:
got_pg:
return page;
}
流程走到這裏,說明內核在 《3.2 內存分配的心臟 __alloc_pages》小節中介紹的快速路徑下嘗試的內存分配已經失敗了,所以纔會走到慢速分配路徑這裏來。
之前我們介紹到快速分配路徑是在 WMARK_LOW 水位線之上進行內存分配,與其相配套的內存分配策略比較保守,目的是快速的在各個內存區域 zone 之間搜索可供分配的空閒內存。
快速分配路徑下的失敗意味着此時系統中的空閒內存已經不足了,所以在慢速分配路徑下內核需要改變內存分配策略,採用更加激進的方式來進行內存分配,首先會把內存分配水位線降低到 WMARK_MIN 之上,然後將內存分配策略調整爲更加容易促使內存分配成功的策略。
而內存分配策略相關的調整邏輯,內核定義在 gfp_to_alloc_flags 函數中:
static inline unsigned int gfp_to_alloc_flags(gfp_t gfp_mask)
{
// 在慢速內存分配路徑中,會進一步放寬對內存分配的限制,將內存分配水位線調低到 WMARK_MIN
// 也就是說內存區域中的剩餘內存需要在 WMARK_MIN 水位線之上纔可以進行內存分配
unsigned int alloc_flags = ALLOC_WMARK_MIN | ALLOC_CPUSET;
// 如果內存分配請求無法運行直接內存回收,或者分配請求設置了 __GFP_HIGH
// 那麼意味着內存分配會更多的使用緊急預留內存
alloc_flags |= (__force int)
(gfp_mask & (__GFP_HIGH | __GFP_KSWAPD_RECLAIM));
if (gfp_mask & __GFP_ATOMIC) {
// ___GFP_NOMEMALLOC 標誌用於明確禁止內核從緊急預留內存中獲取內存。
// ___GFP_NOMEMALLOC 標識的優先級要高於 ___GFP_MEMALLOC
if (!(gfp_mask & __GFP_NOMEMALLOC))
// 如果允許從緊急預留內存中分配,則需要進一步放寬內存分配限制
// 後續根據 ALLOC_HARDER 標識會降低 WMARK_LOW 水位線
alloc_flags |= ALLOC_HARDER;
// 在這個分支中表示內存分配請求已經設置了 __GFP_ATOMIC (非常重要,不允許失敗)
// 這種情況下爲了內存分配的成功,會去除掉 CPUSET 的限制,可以在所有 NUMA 節點上分配內存
alloc_flags &= ~ALLOC_CPUSET;
} else if (unlikely(rt_task(current)) && in_task())
// 如果當前進程不是 real time task 或者不在 task 上下文中
// 設置 HARDER 標識
alloc_flags |= ALLOC_HARDER;
return alloc_flags;
}
在調整好的新的內存分配策略 alloc_flags 之後,就需要根據新的策略來重新獲取可供分配的內存區域 zone。
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
從上圖中我們可以看出,當剩餘內存處於 WMARK_MIN 與 WMARK_LOW 之間時,內核會喚醒所有 kswapd 進程來異步回收內存,直到剩餘內存重新回到水位線 WMARK_HIGH 之上。
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
到目前爲止,內核已經在慢速分配路徑下通過 gfp_to_alloc_flags 調整爲更加激進的內存分配策略,並將水位線降低到 WMARK_MIN,同時也喚醒了 kswapd 進程來異步回收內存。
此時在新的內存分配策略下進行內存分配很可能會一次性成功,所以內核會首先嚐試進行一次內存分配。
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
如果首次嘗試分配內存失敗之後,內核就需要進行直接內存整理 direct_compact 來獲取更多的可供分配的連續內存頁。
如果經過 direct_compact 之後依然沒有足夠的內存可供分配,那麼就會進入 retry 分支採用更加激進的方式來分配內存。如果內存分配策略設置了 __GFP_NORETRY 表示不允許重試,那麼就會直接失敗,流程跳轉到 nopage 分支進行處理。
4.3 retry
內存分配流程來到 retry 分支這裏說明情況已經變得非常危急了,在經過 retry_cpuset 分支的處理,內核將內存水位線下調至 WMARK_MIN,並開啓了 kswapd 進程進行異步內存回收,觸發直接內存整理 direct_compact,在採取了這些措施之後,依然無法滿足內存分配的需求。
所以在接下來的分配邏輯中,內核會近一步採取更加激進的非常手段來獲取連續的空閒內存,下面我們來一起看下這部分激進的內容:
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速內存分配路徑下的相關參數 .......
retry_cpuset:
......... 調整內存分配策略 alloc_flags 採用更加激進方式獲取內存 ......
......... 此時內存分配主要是在進程所允許運行的 CPU 相關聯的 NUMA 節點上 ......
......... 內存水位線下調至 WMARK_MIN ...........
......... 喚醒所有 kswapd 進程進行異步內存回收 ...........
......... 觸發直接內存整理 direct_compact 來獲取更多的連續空閒內存 ......
retry:
// 確保所有 kswapd 進程不要意外進入睡眠狀態
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 流程走到這裏,說明在 WMARK_MIN 水位線之上也分配內存失敗了
// 並且經過內存整理之後,內存分配仍然失敗,說明當前內存容量已經嚴重不足
// 接下來就需要使用更加激進的非常手段來嘗試內存分配(忽略掉內存水位線),繼續修改 alloc_flags 保存在 reserve_flags 中
reserve_flags = __gfp_pfmemalloc_flags(gfp_mask);
if (reserve_flags)
alloc_flags = gfp_to_alloc_flags_cma(gfp_mask, reserve_flags);
// 如果內存分配可以任意跨節點分配(忽略內存分配策略),這裏需要重置 nodemask 以及 zonelist。
if (!(alloc_flags & ALLOC_CPUSET) || reserve_flags) {
// 這裏的內存分配是高優先級系統級別的內存分配,不是面向用戶的
ac->nodemask = NULL;
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
}
// 這裏使用重新調整的 zonelist 和 alloc_flags 在嘗試進行一次內存分配
// 注意此次的內存分配是忽略內存水位線的 ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
goto got_pg;
// 在忽略內存水位線的情況下仍然分配失敗,現在內核就需要進行直接內存回收了
if (!can_direct_reclaim)
// 如果進程不允許進行直接內存回收,則只能分配失敗
goto nopage;
// 開始直接內存回收
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
if (page)
goto got_pg;
// 直接內存回收之後仍然無法滿足分配需求,則再次進行直接內存整理
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
if (page)
goto got_pg;
// 在內存直接回收和整理全部失敗之後,如果不允許重試,則只能失敗
if (gfp_mask & __GFP_NORETRY)
goto nopage;
// 後續會觸發 OOM 來釋放更多的內存,這裏需要判斷本次內存分配是否需要分配大量的內存頁(大於 8 ) costly_order = true
// 如果是的話則內核認爲即使執行 OOM 也未必會滿足這麼多的內存頁分配需求.
// 所以還是直接失敗比較好,不再執行 OOM,除非設置 __GFP_RETRY_MAYFAIL
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
// 流程走到這裏說明我們已經嘗試了所有措施內存依然分配失敗了,此時內存已經非常危急了。
// 走到這裏說明進程允許內核進行重試流程,但在開始重試之前,內核需要判斷是否應該進行重試,重試標準:
// 1 如果內核已經重試了 MAX_RECLAIM_RETRIES (16) 次仍然失敗,則放棄重試執行後續 OOM。
// 2 如果內核將所有可選內存區域中的所有可回收頁面全部回收之後,仍然無法滿足內存的分配,那麼放棄重試執行後續 OOM
if (should_reclaim_retry(gfp_mask, order, ac, alloc_flags,
did_some_progress > 0, &no_progress_loops))
goto retry;
// 如果內核判斷不應進行直接內存回收的重試,這裏還需要判斷下是否應該進行內存整理的重試。
// did_some_progress 表示上次直接內存回收,具體回收了多少內存頁
// 如果 did_some_progress = 0 則沒有必要在進行內存整理重試了,因爲內存整理的實現依賴於足夠的空閒內存量
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
// 根據 nodemask 中的內存分配策略判斷是否應該在進程所允許運行的所有 CPU 關聯的 NUMA 節點上重試
if (check_retry_cpuset(cpuset_mems_cookie, ac))
goto retry_cpuset;
// 最後的殺手鐧,進行 OOM,選擇一個得分最高的進程,釋放其佔用的內存
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
// 只要 oom 產生了作用並釋放了內存 did_some_progress > 0 就不斷的進行重試
if (did_some_progress) {
no_progress_loops = 0;
goto retry;
}
nopage:
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}
retry 分支包含的是更加激進的內存分配邏輯,所以在一開始需要調用 __gfp_pfmemalloc_flags 函數來重新調整內存分配策略,調整後的策略爲:後續內存分配會忽略水位線的影響,並且允許內核從緊急預留內存中獲取內存。
static inline int __gfp_pfmemalloc_flags(gfp_t gfp_mask)
{
// 如果不允許從緊急預留內存中分配,則不改變 alloc_flags
if (unlikely(gfp_mask & __GFP_NOMEMALLOC))
return 0;
// 如果允許從緊急預留內存中分配,則後面的內存分配會忽略內存水位線的限制
if (gfp_mask & __GFP_MEMALLOC)
return ALLOC_NO_WATERMARKS;
// 當前進程處於軟中斷上下文並且進程設置了 PF_MEMALLOC 標識
// 則忽略內存水位線
if (in_serving_softirq() && (current->flags & PF_MEMALLOC))
return ALLOC_NO_WATERMARKS;
// 當前進程不在任何中斷上下文中
if (!in_interrupt()) {
if (current->flags & PF_MEMALLOC)
// 忽略內存水位線
return ALLOC_NO_WATERMARKS;
else if (oom_reserves_allowed(current))
// 當前進程允許進行 OOM
return ALLOC_OOM;
}
// alloc_flags 不做任何修改
return 0;
}
在調整好更加激進的內存分配策略 alloc_flags 之後,內核會首先嚐試從夥伴系統中進行一次內存分配,這時會有很大概率促使內存分配成功。
注意:此次嘗試進行的內存分配會忽略內存水位線:ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
如果在忽略內存水位線的情況下,內存依然分配失敗,則進行直接內存回收 direct_reclaim 。
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
經過 direct_reclaim 之後,仍然沒有足夠的內存可供分配的話,那麼內核會再次進行直接內存整理 direct_compact 。
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
如果 direct_compact 之後還是沒有足夠的內存,那麼現在內核已經處於絕境了,是時候使用殺手鐧:觸發 OOM 機制殺死得分最高的進程以獲取更多的空閒內存。
但是在進行 OOM 之前,內核還是需要經過一系列的判斷,這時就用到了我們在 《4.1 初始化內存分配慢速路徑下的相關參數》小節中介紹的 costly_order 參數了,它會影響內核是否觸發 OOM 。
如果 costly_order = true,表示此次內存分配的內存頁大於 8 個頁,內核會認爲這是一次代價比較大的分配行爲,況且此時內存已經非常危急,嚴重不足。在這種情況下內核認爲即使觸發了 OOM,也無法獲取這麼多的內存,依然無法滿足內存分配。
所以當 costly_order = true 時,內核不會觸發 OOM,直接跳轉到 nopage 分支,除非設置了 __GFP_RETRY_MAYFAIL 內存分配策略:
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
下面內核也不會直接開始 OOM,而是進入到重試流程,在重試流程開始之前內核需要調用 should_reclaim_retry 判斷是否應該進行重試,重試標準:
-
如果內核已經重試了 MAX_RECLAIM_RETRIES (16) 次仍然失敗,則放棄重試執行後續 OOM。
-
如果內核將所有可選內存區域中的所有可回收頁面全部回收之後,仍然無法滿足內存的分配,那麼放棄重試執行後續 OOM。
如果 should_reclaim_retry = false,後面會進一步判斷是否應該進行 direct_compact 的重試。
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
did_some_progress 表示上次直接內存回收具體回收了多少內存頁, 如果 did_some_progress = 0 則沒有必要在進行內存整理重試了,因爲內存整理的實現依賴於足夠的空閒內存量。
當這些所有的重試請求都被拒絕時,殺手鐧 OOM 就開始登場了:
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
如果 OOM 之後並沒有釋放內存,那麼就來到 nopage 分支處理。
但是如果 did_some_progress > 0 表示 OOM 產生了作用,至少釋放了一些內存那麼就再次進行重試。
4.4 nopage
到現在爲止,內核已經嘗試了包括 OOM 在內的所有回收內存的措施,但是仍然沒有足夠的內存來滿足分配要求,看上去此次內存分配就要宣告失敗了。
但是這裏還有一定的迴旋餘地,如果內存分配策略中配置了 __GFP_NOFAIL,則表示此次內存分配非常的重要,不允許失敗。內核會在這裏不停的重試直到分配成功爲止。
我們在 《深入理解 Linux 物理內存管理》一文中的 “3.2 非一致性內存訪問 NUMA 架構” 小節,介紹 NUMA 內存架構的時候曾經提到:當 CPU 自己所在的本地 NUMA 節點內存不足時,CPU 就需要跨 NUMA 節點去訪問其他內存節點,這種跨 NUMA 節點分配內存的行爲就發生在這裏,這種情況下 CPU 訪問內存就會慢很多。
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速內存分配路徑下的相關參數 .......
retry_cpuset:
......... 調整內存分配策略 alloc_flags 採用更加激進方式獲取內存 ......
......... 此時內存分配主要是在進程所允許運行的 CPU 相關聯的 NUMA 節點上 ......
......... 內存水位線下調至 WMARK_MIN ...........
......... 喚醒所有 kswapd 進程進行異步內存回收 ...........
......... 觸發直接內存整理 direct_compact 來獲取更多的連續空閒內存 ......
retry:
......... 進一步調整內存分配策略 alloc_flags 使用更加激進的非常手段盡心內存分配 ...........
......... 在內存分配時忽略內存水位線 ...........
......... 觸發直接內存回收 direct_reclaim ...........
......... 再次觸發直接內存整理 direct_compact ...........
......... 最後的殺手鐧觸發 OOM 機制 ...........
nopage:
// 流程走到這裏表明內核已經嘗試了包括 OOM 在內的所有回收內存的動作。
// 但是這些措施依然無法滿足內存分配的需求,看上去內存分配到這裏就應該失敗了。
// 但是如果設置了 __GFP_NOFAIL 表示不允許內存分配失敗,那麼接下來就會進入 if 分支進行處理
if (gfp_mask & __GFP_NOFAIL) {
// 如果不允許進行直接內存回收,則跳轉至 fail 分支宣告失敗
if (WARN_ON_ONCE_GFP(!can_direct_reclaim, gfp_mask))
goto fail;
// 此時內核已經無法通過回收內存來獲取可供分配的空閒內存了
// 對於 PF_MEMALLOC 類型的內存分配請求,內核現在無能爲力,只能不停的進行 retry 重試。
WARN_ON_ONCE_GFP(current->flags & PF_MEMALLOC, gfp_mask);
// 對於需要分配 8 個內存頁以上的大內存分配,並且設置了不可失敗標識 __GFP_NOFAIL
// 內核現在也無能爲力,畢竟現實是已經沒有空閒內存了,只是給出一些告警信息
WARN_ON_ONCE_GFP(order > PAGE_ALLOC_COSTLY_ORDER, gfp_mask);
// 在 __GFP_NOFAIL 情況下,嘗試進行跨 NUMA 節點內存分配
page = __alloc_pages_cpuset_fallback(gfp_mask, order, ALLOC_HARDER, ac);
if (page)
goto got_pg;
// 在進行內存分配重試流程之前,需要讓 CPU 重新調度到其他進程上
// 運行一會其他進程,因爲畢竟此時內存已經嚴重不足
// 立馬重試的話只能浪費過多時間在搜索空閒內存上,導致其他進程處於飢餓狀態。
cond_resched();
// 跳轉到 retry 分支,重試內存分配流程
goto retry;
}
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}
這裏筆者需要着重強調的一點就是,在 nopage 分支中決定開始重試之前,內核不能立即進行重試流程,因爲之前已經經歷過那麼多嚴格激進的內存回收策略仍然沒有足夠的內存,內存現狀非常緊急。
所以我們有理由相信,如果內核立即開始重試的話,依然沒有什麼效果,反而會浪費過多時間在搜索空閒內存上,導致其他進程處於飢餓狀態。
所以在開始重試之前,內核會調用 cond_resched()
讓 CPU 重新調度到其他進程上,讓其他進程也運行一會,與此同時 kswapd 進程一直在後臺異步回收着內存。
當 CPU 重新調度回當前進程時,說不定 kswapd 進程已經回收了足夠多的內存,重試成功的概率會大大增加同時又避免了資源的無謂消耗。
5. __alloc_pages 內存分配流程總覽
到這裏爲止,筆者就爲大家完整地介紹完內核分配內存的整個流程,現在筆者再把內存分配的完整流程圖放出來,我們在結合完整的內存分配相關源碼,整體在體會一下:
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
// 在慢速內存分配路徑中可能會導致內核進行直接內存回收
// 這裏設置 __GFP_DIRECT_RECLAIM 表示允許內核進行直接內存回收
bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
// 本次內存分配是否是針對大量內存頁的分配,內核定義 PAGE_ALLOC_COSTLY_ORDER = 3
// 也就是說內存請求內存頁的數量大於 2 ^ 3 = 8 個內存頁時,costly_order = true,後續會影響是否進行 OOM
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
// 用於指向成功申請的內存
struct page *page = NULL;
// 內存分配標識,後續會根據不同標識進入到不同的內存分配邏輯處理分支
unsigned int alloc_flags;
// 後續用於記錄直接內存回收了多少內存頁
unsigned long did_some_progress;
// 關於內存整理相關參數
enum compact_priority compact_priority;
enum compact_result compact_result;
int compaction_retries;
int no_progress_loops;
unsigned int cpuset_mems_cookie;
int reserve_flags;
// 流程現在來到了慢速內存分配這裏,說明快速分配路徑已經失敗了
// 內核需要對 gfp_mask 分配行爲掩碼做一些修改,修改爲一些更可能導致內存分配成功的標識
// 因爲接下來的直接內存回收非常耗時可能會導致進程阻塞睡眠,不適用原子 __GFP_ATOMIC 內存分配的上下文。
if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
(__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
gfp_mask &= ~__GFP_ATOMIC;
retry_cpuset:
// 在之前的快速內存分配路徑下設置的相關分配策略比較保守,不是很激進,用於在 WMARK_LOW 水位線之上進行快速內存分配
// 走到這裏表示快速內存分配失敗,此時空閒內存嚴重不足了
// 所以在慢速內存分配路徑下需要重新設置更加激進的內存分配策略,採用更大的代價來分配內存
alloc_flags = gfp_to_alloc_flags(gfp_mask);
// 重新按照新的設置按照內存區域優先級計算 zonelist 的迭代起點(最高優先級的 zone)
// fast path 和 slow path 的設置不同所以這裏需要重新計算
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
// 如果沒有合適的內存分配區域,則跳轉到 nopage , 內存分配失敗
if (!ac->preferred_zoneref->zone)
goto nopage;
// 喚醒所有的 kswapd 進程異步回收內存
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 此時所有的 kswapd 進程已經被喚醒,正在異步進行內存回收
// 之前我們已經在 gfp_to_alloc_flags 方法中重新調整了 alloc_flags
// 換成了一套更加激進的內存分配策略,注意此時是在 WMARK_MIN 水位線之上進行內存分配
// 調整後的 alloc_flags 很可能會立即成功,因此這裏先嚐試一下
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
// 內存分配成功,跳轉到 got_pg 直接返回 page
goto got_pg;
// 對於分配大內存來說 costly_order = true (超過 8 個內存頁),需要首先進行內存整理,這樣內核可以避免直接內存回收從而獲取更多的連續空閒內存頁
// 對於需要分配不可移動的高階內存的情況,也需要先進行內存整理,防止永久內存碎片
if (can_direct_reclaim &&
(costly_order ||
(order > 0 && ac->migratetype != MIGRATE_MOVABLE))
&& !gfp_pfmemalloc_allowed(gfp_mask)) {
// 進行直接內存整理,獲取更多的連續空閒內存防止內存碎片
page = __alloc_pages_direct_compact(gfp_mask, order,
alloc_flags, ac,
INIT_COMPACT_PRIORITY,
&compact_result);
if (page)
goto got_pg;
if (costly_order && (gfp_mask & __GFP_NORETRY)) {
// 流程走到這裏表示經過內存整理之後依然沒有足夠的內存供分配
// 但是設置了 NORETRY 標識不允許重試,那麼就直接失敗,跳轉到 nopage
if (compact_result == COMPACT_SKIPPED ||
compact_result == COMPACT_DEFERRED)
goto nopage;
// 同步內存整理開銷太大,後續開啓異步內存整理
compact_priority = INIT_COMPACT_PRIORITY;
}
}
retry:
// 確保所有 kswapd 進程不要意外進入睡眠狀態
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 流程走到這裏,說明在 WMARK_MIN 水位線之上也分配內存失敗了
// 並且經過內存整理之後,內存分配仍然失敗,說明當前內存容量已經嚴重不足
// 接下來就需要使用更加激進的非常手段來嘗試內存分配(忽略掉內存水位線),繼續修改 alloc_flags 保存在 reserve_flags 中
reserve_flags = __gfp_pfmemalloc_flags(gfp_mask);
if (reserve_flags)
alloc_flags = gfp_to_alloc_flags_cma(gfp_mask, reserve_flags);
// 如果內存分配可以任意跨節點分配(忽略內存分配策略),這裏需要重置 nodemask 以及 zonelist。
if (!(alloc_flags & ALLOC_CPUSET) || reserve_flags) {
// 這裏的內存分配是高優先級系統級別的內存分配,不是面向用戶的
ac->nodemask = NULL;
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
}
// 這裏使用重新調整的 zonelist 和 alloc_flags 在嘗試進行一次內存分配
// 注意此次的內存分配是忽略內存水位線的 ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
goto got_pg;
// 在忽略內存水位線的情況下仍然分配失敗,現在內核就需要進行直接內存回收了
if (!can_direct_reclaim)
// 如果進程不允許進行直接內存回收,則只能分配失敗
goto nopage;
// 開始直接內存回收
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
if (page)
goto got_pg;
// 直接內存回收之後仍然無法滿足分配需求,則再次進行直接內存整理
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
if (page)
goto got_pg;
// 在內存直接回收和整理全部失敗之後,如果不允許重試,則只能失敗
if (gfp_mask & __GFP_NORETRY)
goto nopage;
// 後續會觸發 OOM 來釋放更多的內存,這裏需要判斷本次內存分配是否需要分配大量的內存頁(大於 8 ) costly_order = true
// 如果是的話則內核認爲即使執行 OOM 也未必會滿足這麼多的內存頁分配需求.
// 所以還是直接失敗比較好,不再執行 OOM,除非設置 __GFP_RETRY_MAYFAIL
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
// 流程走到這裏說明我們已經嘗試了所有措施內存依然分配失敗了,此時內存已經非常危急了。
// 走到這裏說明進程允許內核進行重試流程,但在開始重試之前,內核需要判斷是否應該進行重試,重試標準:
// 1 如果內核已經重試了 MAX_RECLAIM_RETRIES (16) 次仍然失敗,則放棄重試執行後續 OOM。
// 2 如果內核將所有可選內存區域中的所有可回收頁面全部回收之後,仍然無法滿足內存的分配,那麼放棄重試執行後續 OOM
if (should_reclaim_retry(gfp_mask, order, ac, alloc_flags,
did_some_progress > 0, &no_progress_loops))
goto retry;
// 如果內核判斷不應進行直接內存回收的重試,這裏還需要判斷下是否應該進行內存整理的重試。
// did_some_progress 表示上次直接內存回收具體回收了多少內存頁
// 如果 did_some_progress = 0 則沒有必要在進行內存整理重試了,因爲內存整理的實現依賴於足夠的空閒內存量
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
// 根據 nodemask 中的內存分配策略判斷是否應該在進程所允許運行的所有 CPU 關聯的 NUMA 節點上重試
if (check_retry_cpuset(cpuset_mems_cookie, ac))
goto retry_cpuset;
// 最後的殺手鐧,進行 OOM,選擇一個得分最高的進程,釋放其佔用的內存
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
// 只要 oom 產生了作用並釋放了內存 did_some_progress > 0 就不斷的進行重試
if (did_some_progress) {
no_progress_loops = 0;
goto retry;
}
nopage:
// 流程走到這裏表明內核已經嘗試了包括 OOM 在內的所有回收內存的動作。
// 但是這些措施依然無法滿足內存分配的需求,看上去內存分配到這裏就應該失敗了。
// 但是如果設置了 __GFP_NOFAIL 表示不允許內存分配失敗,那麼接下來就會進入 if 分支進行處理
if (gfp_mask & __GFP_NOFAIL) {
// 如果不允許進行直接內存回收,則跳轉至 fail 分支宣告失敗
if (WARN_ON_ONCE_GFP(!can_direct_reclaim, gfp_mask))
goto fail;
// 此時內核已經無法通過回收內存來獲取可供分配的空閒內存了
// 對於 PF_MEMALLOC 類型的內存分配請求,內核現在無能爲力,只能不停的進行 retry 重試。
WARN_ON_ONCE_GFP(current->flags & PF_MEMALLOC, gfp_mask);
// 對於需要分配 8 個內存頁以上的大內存分配,並且設置了不可失敗標識 __GFP_NOFAIL
// 內核現在也無能爲力,畢竟現實是已經沒有空閒內存了,只是給出一些告警信息
WARN_ON_ONCE_GFP(order > PAGE_ALLOC_COSTLY_ORDER, gfp_mask);
// 在 __GFP_NOFAIL 情況下,嘗試進行跨 NUMA 節點內存分配
page = __alloc_pages_cpuset_fallback(gfp_mask, order, ALLOC_HARDER, ac);
if (page)
goto got_pg;
// 在進行內存分配重試流程之前,需要讓 CPU 重新調度到其他進程上
// 運行一會其他進程,因爲畢竟此時內存已經嚴重不足
// 立馬重試的話只能浪費過多時間在搜索空閒內存上,導致其他進程處於飢餓狀態。
cond_resched();
// 跳轉到 retry 分支,重試內存分配流程
goto retry;
}
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}
現在內存分配流程中涉及到的三個重要輔助函數:prepare_alloc_pages,__alloc_pages_slowpath,get_page_from_freelist 。筆者已經爲大家介紹了兩個了。prepare_alloc_pages,__alloc_pages_slowpath 函數主要是根據不同的空閒內存剩餘容量調整內存的分配策略,儘量使內存分配行爲盡最大可能成功。
理解了以上兩個輔助函數的邏輯,我們就相當於梳理清楚了整個內存分配的鏈路流程。但目前我們還沒有涉及到具體內存分配的真正邏輯,而內核中執行具體內存分配動作是在 get_page_from_freelist 函數中,這也是掌握內存分配的最後一道關卡。
由於 get_page_from_freelist 函數執行的是具體的內存分配動作,所以它和內核中的夥伴系統有着千絲萬縷的聯繫,而本文的主題更加側重描述整個物理內存分配的鏈路流程,考慮到文章篇幅的關係,筆者把夥伴系統這部分的內容放在下篇文章爲大家講解。
總結
本文首先從 Linux 內核中常見的幾個物理內存分配接口開始,介紹了這些內存分配接口的各自的使用場景,以及接口函數中參數的含義。
並以此爲起點,結合 Linux 內核 5.19 版本源碼詳細討論了物理內存分配在內核中的整個鏈路實現。在整個鏈路中,內存的分配整體分爲了兩個路徑:
-
快速路徑 fast path:該路徑的下,內存分配的邏輯比較簡單,主要是在 WMARK_LOW 水位線之上快速的掃描一下各個內存區域中是否有足夠的空閒內存能夠滿足本次內存分配,如果有則立馬從夥伴系統中申請,如果沒有立即返回。
-
慢速路徑 slow path:慢速路徑下的內存分配邏輯就變的非常複雜了,其中包含了內存分配的各種異常情況的處理,並且會根據文中介紹的 GFP_,ALLOC_ 等各種內存分配策略掩碼進行不同分支的處理,整個鏈路非常龐大且繁雜。
本文鋪墊了大量的內存分配細節,但是整個內存分配鏈路流程的精髓,筆者繪製在了下面這副流程圖中,方便大家忘記的時候回顧。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/llZXDRG99NUXoMyIAf00ig