內存管理學習筆記
作者簡介:尹忠凱,Linux 內核愛好者,閱碼場用戶。現就職於北京地平線信息技術有限公司,任系統軟件工程師。
物理內存分配設計有兩個重要的評價維度。一方面,物理內存分配器要追求更高的內存資源利用率,即儘可能減少資源浪費。另一方面,物理內存分配器要追求更好的性能,主要是儘可能降低分配延遲和節約 CPU 資源。
內存碎片
內存碎片指的是無法被利用的內存,進一步又可以分爲外部碎片和內部碎片。
外部碎片
圖 1 外部碎片
如圖 1 所示,假設系統中共有 25MB 內存,系統經過長期的運行後,使用了 19MB 內存 (帶顏色的部分),假如此時想申請 3MB 內存,總的空閒內存是滿足要求的,但每一塊單獨的空閒內存都不滿足要求。此時這些無法被使用的內存稱爲外部碎片。
內部碎片
爲了解決外部碎片問題,一種比較簡的方式就是將物理內存以固定大小劃分成若干塊,每次用一個塊或者多個塊來滿足一次內存請求。
圖 2 內部碎片
如圖 2 所示,還是這 25MB 內存,這次以 3MB 大小固定塊來分配內存,申請 6MB 內存就分配兩個固定塊,申請 3MB 內存分配一個固定塊,申請 1MB 內存,也分配一個固定塊。可以看到此時外部碎片的問題是解決了,但是又引入了新的問題即只申請 1MB 內存,卻分配了 3MB 內存,多出的 2MB 內存即爲內部碎片。
下面會介紹 linux 系統中的頁分配器和塊分配器,頁分配器用來解決外部碎片問題,而塊分配器用來解決內部碎片問題,通過這兩個分配器,可以有效減少內存碎片的產生,從而提高內存資源的利用率。
物理內存組織
在介紹頁分配器和塊分配器之前,先來了解下現代計算的物理內存是如何組織的,因爲有些概念在後面會用到。
SMP 架構
圖 3 SMP 架構
SMP 架構就是對稱多處理器,所有的 CPU 必須通過相同的內存總線訪問**相同的內存資源。**所以所有 CPU 訪問內存等資源的速度是一樣的,即對稱。這種架構的缺點是,CPU 數量增加,內存訪問衝突將迅速增加,最終會造成 CPU 資源的浪費,使 CPU 性能的有效性大大降低。
NUMA 架構
圖 4 NUMA 架構
NUMA 架構將 CPU 劃分到多個 Node 中,每個 node 有自己獨立的內存空間。各個 node 之間通過高速互聯通訊。CPU 訪問不同類型節點內存的速度是不相同的,訪問本地節點的速度最快,訪問遠端節點的速度最慢,即訪問速度與節點的距離有關,距離越遠訪問速度越慢,即非一致。 這種架構的缺點是,本 node 的內存不足時,需要垮節點訪問內存,節點間的訪問速度慢。
三級結構
內存管理子系統使用節點 (node)、區域(zone)、和頁(page) 三級結構描述物理內存。
圖 5 物理內存三級結構
如圖 5 所示,在 linux 中使用三級結構來描述物理內存。
-
節點 (node):每個節點用一個
struct pglist_data
結構體來表示,每個節點內的內存會劃分成不同的區域 -
區域 (zone):每個區域用一個
struct zone
結構體來表示,每個區域又會劃分成不同的頁 -
頁 (page):每個頁用一個
struct page
結構體來表示,頁是夥伴算法分配的最小單位 (比如:4K,16K,64K)。
頁分配器 (buddy)
夥伴算法
夥伴算法的基本思想是將物理內存劃分成連續的塊,以塊作爲基本單位進行分配。不同的塊大小可以不同,但每個塊都由一個或多個連續的物理頁組成,物理頁的數量必須是 2 的 n 次冪 (0 <= n < 預設的最大值)。
內存分配過程
圖 6 夥伴系統的空閒鏈表數組
如圖 6 所示夥伴算法一般使用空閒鏈表數組來實現,假如我們要申請一塊 7K 大小的內存。
-
首先 7K / 4K = 1,也就 arr[1] 這一個空閒鏈表上申請空閒塊,結果發現鏈表爲空。
-
然後發現下一級空閒鏈表 arr[2] 不爲空,從 arr[2] 的鏈表頭取出一個 16K 的空閒塊,並分裂成 2 個 8K 的空閒塊。
-
最後將分裂的 2 個 8K 空閒塊,一個返回給申請者,另一個放到 arr[1] 的空閒鏈表中。
申請完畢,就是這麼簡單。
夥伴算法特點
-
查找空閒鏈表的過程十分簡單,比如頁大小來 4K,如果申請 7K 內存的話,直接 7K / 4K = 1 計算一下就可以找到空閒鏈表位置。
-
確定一個塊的夥伴塊十分簡單,比如 arr[0] 空閒鏈表塊 A(0 ~ 4K) 和塊 B(4K ~ 8K) 互爲夥伴塊,塊 A 物理地址爲 0x0,塊 B 物理地址爲 0x1000,可以看出只有 12 位不同,同時塊大小也是 2^12;再比如 arr[1] 空閒鏈表塊 A(16K ~ 24K) 和塊 B(24K ~ 32K) 互爲夥伴塊,塊 A 物理地址爲 0x4000,塊 B 物理地址 0x6000,可以看出只有 13 位不同,同時塊大小也是 2^13(很神奇)。
-
由於夥伴算法的這種特點,在塊的分裂與合併的計算都很高效,有效的管理了物理內存,很好的緩解了外部碎片的問題。
分區的夥伴算法
前面介紹了 Linux 使用三級結構來描述物理內存,Linux 夥伴算法是基於區域 (zone) 這一級來實現。
/* include/linuxmmzone.h */
#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
#else
#define MAX_ORDER CONFIG_FORCE_MAX_ZONEORDER
#endif
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
struct zone {
...
/* free areas of different sizes */
struct free_area free_area[MAX_ORDER];
....
} ____cacheline_internodealigned_in_smp;
實現方法也很簡單,就是在struct zone
結構體中定義了一個struct free_area
結構體數據,這個數組也就是我們前面提到了空閒鏈表數組。可以看到MAX_ORDER
默認值爲 11,也就是說最大的塊爲爲 2^10 * 4K(4K 頁大小) = 4M,假如申請內存大小超過 4M,會申請成功嗎?。
/ # cat /proc/buddyinfo
Node 0, zone DMA 1 4 2 3 1 4 2 5 3 3 207
/ #
如上爲使用 qemu 模擬的環境,可以看出 2^10 的大小塊有 207 個,2^9 的大小塊爲 3 個……
可移動性分組
到前一小節爲止,夥伴系統的主要內容已經介紹完了,但是 Linux 針對內存碎片問題還有很多優化,本小節會介紹下可移動性分組的實現原理。
圖 7 內存碎片整理
如圖 7 所示,假如系統運行一段時間後,被使用的內存爲紫色部分,空閒的爲淡藍色部分,此時可使用的連續的物理內存只有 8K;如果經過內存碎片整理,把使用的內存都放到一起,那麼可使的連續的物理內存就有 16K 了。夥伴算法只會保證在內存申請的時候儘量避免內存碎片的產生,但是內存碎片不可避免的還是會產生,這時就需要在運行時對內存碎片進行整理,從而獲取更大的連續內存。
如圖 7 所示是對編號 5 的內存塊進行了遷移,假如編號 5 的內存塊不能移動,中間的內存碎片就得不到整理,這種情況咱辦呢?
針對前面提到的問題,Linux 將物理內存進行了劃分,分爲不可移動頁,可移動頁,可回收頁。
-
不可移動頁:位置必須固定,不能移動,直接映射到內核虛擬地址空間的頁屬於這一類。
-
可移動頁:使用頁表映射的頁屬於這一類,可以移動到其他位置,然後修改頁表映射。
-
可回收頁:不能移動,但可以回收,需要數據的時候,可以重新從數據源獲取。後備存儲設備支持的頁屬於這一類。
/* include/linuxmmzone.h */
enum migratetype {
MIGRATE_UNMOVABLE,
MIGRATE_MOVABLE,
MIGRATE_RECLAIMABLE,
MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,
#ifdef CONFIG_CMA
MIGRATE_CMA,
#endif
#ifdef CONFIG_MEMORY_ISOLATION
MIGRATE_ISOLATE, /* can't allocate from here */
#endif
MIGRATE_TYPES
};
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
struct zone {
...
/* free areas of different sizes */
struct free_area free_area[MAX_ORDER];
....
} ____cacheline_internodealigned_in_smp;
繼續看struct free_area
結構體,裏面定義了一個數組鏈表,數組索引爲當前塊的遷移類型。所以在申請內存的時候,可以通過 flag 來告訴系統,要申請什麼類型的內存,系統會將相同類型的內存放在一起,從而解決上面的提到的問題。這裏的思路也是在內存申請時,儘量減少內存碎片的產生。
假如申請內存大小超過 4M,會申請成功嗎?
/* mm/page_alloc.c */
struct page *__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid, nodemask_t *nodemask)
{
...
/*
* There are several places where we assume that the order value is sane
* so bail out early if the request is out of bound.
*/
if (unlikely(order >= MAX_ORDER)) {
WARN_ON_ONCE(!(gfp_mask & __GFP_NOWARN));
return NULL;
}
...
return page;
}
EXPORT_SYMBOL(__alloc_pages_nodemask);
如上代碼 10 ~ 13 行,當申請內存大小超過系統最大塊時,內存申請會失敗的。但如果就想申請大塊內存,比如像圖像數據這種就需要大塊內存,怎麼辦呢?可以使用保留內存的方式。
# 這裏申請16M大小物理內存,可能看到內存申請不成功,系統報了一個warning
/ # insmod /app/cdev-test.ko
[ 11.895824] cdev_test: loading out-of-tree module taints kernel.
[ 11.921420] [cdev_test_init:151]devno = 0x0e600000
[ 11.921561] <__alloc_pages_nodemask: 4984>order = 12
[ 11.922725] ------------[ cut here ]------------
[ 11.923045] WARNING: CPU: 2 PID: 47 at mm/page_alloc.c:4985 __alloc_pages_nodemask+0x8c/0x268
[ 11.923509] Modules linked in: cdev_test(O+)
[ 11.924604] CPU: 2 PID: 47 Comm: insmod Tainted: G O 5.12.0-00008-g48ca89975eb3-dirty #14
[ 11.925044] Hardware name: linux,dummy-virt (DT)
[ 11.925521] pstate: 40000005 (nZcv daif -PAN -UAO -TCO BTYPE=--)
[ 11.925885] pc : __alloc_pages_nodemask+0x8c/0x268
[ 11.926191] lr : __alloc_pages_nodemask+0x68/0x268
[ 11.926467] sp : ffff800010223a50
[ 11.926677] x29: ffff800010223a50 x28: ffff6a86485ff8d0
[ 11.927121] x27: ffff6a86485ff880 x26: 0000000000000001
[ 11.927477] x25: ffff6a86485ff8d0 x24: ffff6a8648521bc0
[ 11.927835] x23: 0000000000000000 x22: ffffdafc7ac98000
[ 11.928186] x21: 000000000000000c x20: 000000000000000c
[ 11.928540] x19: 0000000000040cc0 x18: 000000000000000a
[ 11.928892] x17: 0000000000000000 x16: ffffdafc7a8e840c
[ 11.929246] x15: 00000000000e0fd9 x14: 000000000000008c
[ 11.929606] x13: ffff800010223720 x12: 00000000ffffffea
[ 11.929962] x11: ffffdafc7af65410 x10: ffffdafc7aca5468
[ 11.930312] x9 : ffff800010223718 x8 : ffff800010223720
[ 11.930732] x7 : ffffdafc7b025450 x6 : 00000000ffff7fff
[ 11.931082] x5 : 00000000002bffa8 x4 : 0000000000000000
[ 11.931440] x3 : 0000000000000000 x2 : 7aafd30192e33000
[ 11.931791] x1 : 0000000000000000 x0 : 0000000000000028
[ 11.932244] Call trace:
[ 11.932464] __alloc_pages_nodemask+0x8c/0x268
[ 11.932727] alloc_pages_current+0xc4/0xc8
[ 11.932964] kmalloc_order+0x34/0xa4
[ 11.933190] cdev_test_init+0x48/0x1000 [cdev_test]
[ 11.934498] do_one_initcall+0x74/0x184
[ 11.934726] do_init_module+0x54/0x1f4
[ 11.934956] load_module+0x1870/0x1f24
[ 11.935184] __do_sys_init_module+0x154/0x184
[ 11.935421] __arm64_sys_init_module+0x14/0x1c
[ 11.935666] do_el0_svc+0x100/0x144
[ 11.935885] el0_svc+0x10/0x18
[ 11.936090] el0_sync_handler+0x64/0x12c
[ 11.936314] el0_sync+0x13c/0x140
[ 11.936632] ---[ end trace 206fd1429c29dfb5 ]---
[ 11.937737] kmalloc failed
insmod: can't insert '/app/cdev-test.ko': Operation not permitted
塊分配器 (slab)
夥伴系統最小的分配單位是一個物理頁,但是大多數情況下,內核需要分配的內存大小通常是幾十個字節或者幾百個字節,遠遠小於一個物理頁的大小。如果僅僅使用夥伴系統進行內存分配,會出現嚴重的內部碎片問題,從而導致資源利用率降低。
塊分配器 (slab) 就是用來解決這個問題的,slab 分配器做的事情是把夥伴系統分配的大塊內存進一步細分成小塊內存進行管理。一方面由於操作系統頻繁分配的對象大小相對固定,另一方面爲了避免外部碎片問題。其實現也比較簡單,一般 slab 分配器只分配固定大小的內存塊,大小通常是 2^n 個字節(一般來說,3 <= n < 12,4K 大小頁)。
SLAB 發展
20 世紀 90 年代,Jeff Bonwick 最先在 Solaris 2.4 操作系統內核中設計並實現了 SLAB 分配器。之後,該分配器廣泛地被 Linux、FreeBSD 等操作系統使用並得以發展。21 世紀,操作系統開發人員逐漸發現 SLAB 分配器存在一些問題,比如維護了太多了隊列、實現日趨複雜、存儲開銷也由於複雜的設計而增大等。於是,開發人員在 SLAB 的分配器的基礎上設計了 SLUB 分配器。
SLUB 分配器極大簡化了 SLAB 分配器的設計和數據結構,在降低複雜度的同時依然能夠提供與原來相當甚至更好的性能,同時也繼承了 SLAB 分配器的接口。
此外,SLAB 分配器家族中還有一種最簡單的分配器稱爲 SLOB 分配器,它的出現主要爲了滿足內存資源稀缺的場景(比如嵌入式設備)的需求,它具有最小的存儲開銷,但在碎片問題的處理方面比不上其他兩種分配器。
SLAB、SLUB、SLOB 三種分配器往往被統稱爲 SLAB 分配器。
基本原理
圖 8 slab 空閒鏈表
slab 分配器實現也很簡單,如圖 8 所示,Linux 通過鏈表來維護不同大小的 slab 結構 (struct kmem_cache
結構體), 每個 slab 結構中都會記錄內存塊的信息,並將大的內存塊劃分內多個小的內存塊備用。當有內存申請時,首先會找到合適大小的 slab 結構,然後從小的內存塊中返回一個給申請者就可以了 (看就是這麼簡單)。用鏈表來維護 slab 結構,難道申請內存的時候需要遍歷一次鏈表?當然不會!
struct kmem_cache
結構體一般有兩種初始化方式。
- 系統初始化
在系統初始化時,會初始化一批常用大小的 slab 結構體,其對應全局變量kmalloc_caches
,使用類似kmalloc()
這種接口時,會根據申請內存大小直接找到對應的 slab 結構。
/* mm/slab_common.c */
struct kmem_cache *kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1] =
{ /* initialization for https://bugs.llvm.org/show_bug.cgi?id=42570 */ };
EXPORT_SYMBOL(kmalloc_caches);
- 自己初始化
除了系統自帶的 slab 結構,當然也可以使用自己創建的 slab 結構,常用接口如下,相信大家基於都使用過,就不多介紹了
/* linux/slab.h */
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size, unsigned int align,
slab_flags_t flags, void (*ctor)(void *));
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags);
void kmem_cache_free(struct kmem_cache *s, void *x);
void kmem_cache_destroy(struct kmem_cache *s);
調試 slab 分配器
Linux 對 slab 分配器還做了很多優化,比如 per cpu 支持,numa 構架的支持,這裏面有很多細節,可以研究研究源碼。本小節從實際需求出發,比如我們有如下需求:
kmalloc/kfree時間戳
kmalloc/kfree堆棧信息
內存重複釋放
申請內存模塊id
訪問釋放後的內存
圖 9 object 內存結構
前一小節介紹了,slab 分配器會將大塊內存劃分成多個小塊內存,每一個小塊內存對應一個 object 結構 (如圖 9),對於上面的需求,基本思路就是給每個 object 多分配一些內存,用來存儲與該內存塊相關的額外信息。下面分析下 object 內存結構。
- red_left_pad: 該區域會填充兩個值 0xbb 或 0xcc,分別表示該內存塊是否被使用,這個區域就可以用來做一些內存檢查。
/* linux/poison.h */
#define SLUB_RED_INACTIVE 0xbb
#define SLUB_RED_ACTIVE 0xcc
- object_ptr: 該區域是真正給申請都使用的內存區域,如果開啓毒化功能的話,該區域會填充 0x5a、0x6b、0xa5,從夥伴系統申請大塊內存後,會填充成 0x5a,劃分成小塊後會填充 0x6b,而該區域最後一個字節填充 0xa5,這個區域也可以用來做一些內存檢查。
/* linux/poison.h */
/* ...and for poisoning */
#define POISON_INUSE 0x5a /* for use-uninitialised poisoning */
#define POISON_FREE 0x6b /* for use-after-free poisoning */
#define POISON_END 0xa5 /* end-byte of poisoning */
- track: 該區域用來記錄內存申請或者釋放相關的信息,比如申請或釋放的堆棧,申請或釋放的 cpu/pid / 時間等信息。
/* mm/slub.c */
/*
* Tracking user of a slab.
*/
#define TRACK_ADDRS_COUNT 16
struct track {
unsigned long addr; /* Called from address */
#ifdef CONFIG_STACKTRACE
unsigned long addrs[TRACK_ADDRS_COUNT]; /* Called from address */
#endif
int cpu; /* Was running on cpu */
int pid; /* Pid context */
unsigned long when; /* When did the operation occur */
};
所以當進行內存的申請與釋放時,只需要把這些額外的信息記錄下來就可以了。
總結
-
內存分配設計的兩個維度,提高內存的利用率,減少內存分配時的延時與 CPU 佔用率。
-
頁分配器用來解決外部碎片問題,塊分配器用來解決內部碎片問題,從而提高內存的利用率。
-
頁分配器與塊分配器的算法簡單、高效。
其它
實驗環境
-
kernel version:v5.12
-
qemu version:v6.0.0
參考
-
http://www.wowotech.net/memory_management/426.html
-
http://www.wowotech.net/memory_management/427.html
-
https://blog.csdn.net/weixin_42319496/article/details/125940807
-
https://zhuanlan.zhihu.com/p/149581303
-
《現代操作系統原理與實現》
-
《Linux 內核深度解析》
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/oVLdy6pKsZc-RWRAgSkozw