深入理解 Linux 實體記憶體分配全鏈路實現

bin的技術小屋發表於2023-01-02

前文回顧

在上篇文章 《深入理解 Linux 實體記憶體管理》中,筆者詳細的為大家介紹了 Linux 核心如何對實體記憶體進行管理以及相關的一些核心資料結構。

在介紹實體記憶體管理之前,筆者先從 CPU 的角度開始,介紹了三種 Linux 實體記憶體模型:FLATMEM 平坦記憶體模型,DISCONTIGMEM 非連續記憶體模型,SPARSEMEM 稀疏記憶體模型。

image

image

image

隨後筆者又帶大家站在一個新的視角上,把實體記憶體看做成一個整體,從 CPU 訪問實體記憶體以及 CPU 與實體記憶體的相對位置變化的角度介紹了兩種實體記憶體架構:一致性記憶體訪問 UMA 架構,非一致性記憶體訪問 NUMA 架構。

image

image

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

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

image

這樣一來這兩種架構模式就在核心中被統一管理起來,我們基於這個事實,深入剖析了核心針對 NUMA 架構下用於實體記憶體管理的相關資料結構:struct pglist_data (NUMA 節點),struct zone(實體記憶體區域),struct page(物理頁)。

image

上圖展示的是在 NUMA 架構下,NUMA 節點與實體記憶體區域 zone 以及實體記憶體頁 page 之間的層次關係。

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

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

在上篇文章的最後,筆者又花了大量的篇幅來為大家介紹了 struct page 結構,我們瞭解了核心如何透過 struct page 結構來描述實體記憶體頁,這個結構是核心中最為複雜的一個結構體,因為它是實體記憶體管理的最小單位,被頻繁應用在核心中的各種複雜機制下。

透過以上內容的介紹,筆者覺得大家已經在架構層面上對 Linux 實體記憶體管理有了一個較為深刻的認識,現在實體記憶體管理的架構我們已經建立起來了,那麼核心如何根據這個架構層次來分配實體記憶體呢?

為了給大家梳理清楚核心分配實體記憶體的過程及其涉及到的各個重要模組,於是就有了本文的內容~~

image

1. 核心實體記憶體分配介面

image

在為大家介紹實體記憶體分配之前,筆者先來介紹下核心中用於實體記憶體分配的幾個核心介面,這幾個實體記憶體分配介面全部是基於夥伴系統的,夥伴系統有一個特點就是它所分配的實體記憶體頁全部都是物理上連續的,並且只能分配 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 對映到核心虛擬地址空間中。

image

忘記這塊內容的同學,可以在回看下筆者之前的文章 《深入理解虛擬記憶體管理》中的 “ 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 區域是核心從邏輯上的劃分,該區域中的實體記憶體頁面來自於上述幾個記憶體區域,目的是避免記憶體碎片和支援記憶體熱插拔

image

當我們呼叫上小節中介紹的那幾個實體記憶體分配介面時,比如: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。

image

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 掩碼 記憶體區域降級策略
什麼都沒有設定 ZONE_NORMAL -> ZONE_DMA
__GFP_DMA ZONE_DMA
__GFP_DMA & __GFP_HIGHMEM ZONE_DMA
__GFP_HIGHMEM ZONE_HIGHMEM -> ZONE_NORMAL -> ZONE_DMA

除了上述介紹 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] 之上。

image

  • ___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 核心關於記憶體分配的原始碼實現之前,我們需要先找到記憶體分配的入口函式在哪裡,在上小節中為大家介紹的眾多記憶體分配介面的依賴層級關係如下圖所示:

image

我們看到記憶體分配的任務最終會落在 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。

image

核心使用了一個大小為 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》小節。

image

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];
}

實體記憶體區域中不同水位線的含義以及記憶體分配在不同水位線下的行為如下圖所示:

image

  • 當該實體記憶體區域的剩餘記憶體容量高於 _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 語句中可以看得出來。

image

  • 校驗本次記憶體分配指定夥伴系統的分配階 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 函式記憶體分配邏輯以及與對應的記憶體水位線之間的關係如下圖所示:

image

總體流程介紹完之後,我們接著來看一下以上記憶體分配過程涉及到的三個重要記憶體分配輔助函式: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 到底是幹什麼的呢?它在慢速記憶體分配過程起了什麼作用?

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

image

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

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

為了解決記憶體碎片化的問題,核心將記憶體頁面分為了:可移動的,可回收的,不可移動的三種型別。

可移動的頁面聚集在一起,可回收的的頁面聚集在一起,不可移動的的頁面聚集也在一起。從而作為去碎片化的基礎, 然後進行成塊回收。

在回收時把可回收的一起回收,把可移動的一起移動,從而能空出大量連續物理頁面。direct_compact 會掃描記憶體區域 zone 裡的頁面,把已分配的頁記錄下來,然後把所有已分配的頁移動到 zone 的一端,這樣就會把一個已經充滿碎片的 zone 整理成一段完全未分配的區間和一段已經分配的區間,從而騰出大塊連續的物理頁面供核心分配。

image

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 之間搜尋可供分配的空閒記憶體。

image

快速分配路徑下的失敗意味著此時系統中的空閒記憶體已經不足了,所以在慢速分配路徑下核心需要改變記憶體分配策略,採用更加激進的方式來進行記憶體分配,首先會把記憶體分配水位線降低到 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 分支進行處理。

image

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 判斷是否應該進行重試,重試標準:

  1. 如果核心已經重試了 MAX_RECLAIM_RETRIES (16) 次仍然失敗,則放棄重試執行後續 OOM。

  2. 如果核心將所有可選記憶體區域中的所有可回收頁面全部回收之後,仍然無法滿足記憶體的分配,那麼放棄重試執行後續 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 產生了作用,至少釋放了一些記憶體那麼就再次進行重試。

image

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 記憶體分配流程總覽

到這裡為止,筆者就為大家完整地介紹完核心分配記憶體的整個流程,現在筆者再把記憶體分配的完整流程圖放出來,我們在結合完整的記憶體分配相關原始碼,整體在體會一下:

image

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 函式執行的是具體的記憶體分配動作,所以它和核心中的夥伴系統有著千絲萬縷的聯絡,而本文的主題更加側重描述整個實體記憶體分配的鏈路流程,考慮到文章篇幅的關係,筆者把夥伴系統這部分的內容放在下篇文章為大家講解。

image

總結

本文首先從 Linux 核心中常見的幾個實體記憶體分配介面開始,介紹了這些記憶體分配介面的各自的使用場景,以及介面函式中引數的含義。

image

並以此為起點,結合 Linux 核心 5.19 版本原始碼詳細討論了實體記憶體分配在核心中的整個鏈路實現。在整個鏈路中,記憶體的分配整體分為了兩個路徑:

  1. 快速路徑 fast path:該路徑的下,記憶體分配的邏輯比較簡單,主要是在 WMARK_LOW 水位線之上快速的掃描一下各個記憶體區域中是否有足夠的空閒記憶體能夠滿足本次記憶體分配,如果有則立馬從夥伴系統中申請,如果沒有立即返回。

  2. 慢速路徑 slow path:慢速路徑下的記憶體分配邏輯就變的非常複雜了,其中包含了記憶體分配的各種異常情況的處理,並且會根據文中介紹的 GFP_,ALLOC_ 等各種記憶體分配策略掩碼進行不同分支的處理,整個鏈路非常龐大且繁雜。

本文鋪墊了大量的記憶體分配細節,但是整個記憶體分配鏈路流程的精髓,筆者繪製在了下面這副流程圖中,方便大家忘記的時候回顧。

image

相關文章