1. 前文回顧
在之前的幾篇記憶體管理系列文章中,筆者帶大家從宏觀角度完整地梳理了一遍 Linux 記憶體分配的整個鏈路,本文的主題依然是記憶體分配,這一次我們會從微觀的角度來探秘一下 Linux 內核中用于零散小記憶體塊分配的記憶體池 —— slab 分配器,
在本小節中,筆者還是按照以往的風格先帶大家簡單回顧下之前宏觀視角下 Linux 記憶體分配最為核心的內容,目的是讓大家從宏觀視角平滑地過度到微觀視角,內容上有個銜接,不至于讓大家感到突兀,
下面的內容我們只做簡單回顧,大家不必糾纏細節,把握整體宏觀流程
在 《深入理解 Linux 物理記憶體分配與釋放全鏈路實作》一文中,筆者以內核物理記憶體分配與釋放的 API 為起點,詳細為大家介紹了物理記憶體分配與釋放的整個完整流程,以及相關內核原始碼的實作,
其中物理記憶體分配在內核中的全鏈路流程如下圖所示:
在 Linux 內核中,真正負責物理記憶體分配的核心是伙伴系統,在我們從總體上熟悉了物理記憶體分配的全鏈路流程之后,隨后我們繼續來到了伙伴系統的入口 get_page_from_freelist 函式,它的完整流程如下:
內核通過 get_page_from_freelist 函式,挨個遍歷檢查各個 NUMA 節點中的物理記憶體區域是否有足夠的空閑記憶體可以滿足本次的記憶體分配要求,當找到符合記憶體分配標準的物理記憶體區域 zone 之后,接下來就會通過 rmqueue 函式進入到該物理記憶體區域 zone 對應的伙伴系統中分配物理記憶體,
那么內核既然已經有了伙伴系統,那么為什么還需要一個 slab 記憶體池呢 ?下面就讓我們從這個疑問開始,正式拉開本文的帷幕~~~
2. 既然有了伙伴系統,為什么還需要 Slab ?
從上篇文章 《深度剖析 Linux 伙伴系統的設計與實作》第一小節 “1. 伙伴系統的核心資料結構” 的介紹中我們知道,內核中的伙伴系統管理記憶體的最小單位是物理記憶體頁 page,
伙伴系統會將它所屬物理記憶體區 zone 里的空閑記憶體劃分成不同尺寸的物理記憶體塊,這里的尺寸必須是 2 的次冪,物理記憶體塊可以是由 1 個 page 組成,也可以是 2 個 page,4 個 page ........ 1024 個 page 組成,
內核將這些相同尺寸的記憶體塊用一個內核資料結構 struct free_area 中的雙向鏈表 free_list 串聯組織起來,
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
而這些由 free_list 串聯起來的相同尺寸的記憶體塊又會近一步根據物理記憶體頁 page 的遷移型別 MIGRATE_TYPES 進行歸類,比如:MIGRATE_UNMOVABLE (不可移動的頁面型別),MIGRATE_MOVABLE (可以移動的記憶體頁型別),MIGRATE_RECLAIMABLE (不能移動,但是可以直接回收的頁面型別)等等,
這樣一來,具有相同遷移型別,相同尺寸的記憶體塊就被組織在了同一個 free_list 中,最侄訓伴系統完整的資料結構如下圖所示:
free_area 中組織的全部是相同尺寸的記憶體塊,不同尺寸的記憶體塊被不同的 free_area 管理,在 free_area 的內部又會近一步按照物理記憶體頁面的遷移型別 MIGRATE_TYPES,將相同遷移型別的物理記憶體頁組織在同一個 free_list 中,
伙伴系統所分配的物理記憶體頁全部都是物理上連續的,并且只能分配 2 的整數冪個頁
隨后在物理記憶體分配的程序中,內核會基于這個完整的伙伴系統資料結構,進行不同尺寸的物理記憶體塊的分配與釋放,而分配與釋放的單位依然是 2 的整數冪個物理記憶體頁 page,
詳細的記憶體分配程序感興趣的讀者朋友可以回看下 《深度剖析 Linux 伙伴系統的設計與實作》一文中的第 3 小節 “ 3. 伙伴系統的記憶體分配原理 ” 以及第 6 小節 “ 6. 伙伴系統的實作 ”,
這里我們只對伙伴系統的記憶體分配原理做一個簡單的整體回顧:
當內核向伙伴系統申請連續的物理記憶體頁時,會根據指定的物理記憶體頁遷移型別 MIGRATE_TYPES,以及申請的物理記憶體塊尺寸,找到對應的 free_list 鏈表,然后依次遍歷該鏈表尋找物理記憶體塊,
比如我們向內核申請 ( 2 ^ (order - 1),2 ^ order ] 之間大小的記憶體,并且這塊記憶體我們指定的遷移型別為 MIGRATE_MOVABLE 時,內核會按照 2 ^ order 個記憶體頁進行申請,
隨后內核會根據 order 找到伙伴系統中的 free_area[order] 對應的 free_area 結構,并進一步根據頁面遷移型別定位到對應的 free_list[MIGRATE_MOVABLE],如果該遷移型別的 free_list 中沒有空閑的記憶體塊時,內核會進一步到上一級鏈表也就是 free_area[order + 1] 中尋找,
如果 free_area[order + 1] 中對應的 free_list[MIGRATE_MOVABLE] 鏈表中還是沒有,則繼續回圈到更高一級 free_area[order + 2] 尋找,直到在 free_area[order + n] 中的 free_list[MIGRATE_MOVABLE] 鏈表中找到空閑的記憶體塊,
但是此時我們在 free_area[order + n] 鏈表中找到的空閑記憶體塊的尺寸是 2 ^ (order + n) 大小,而我們需要的是 2 ^ order 尺寸的記憶體塊,于是內核會將這 2 ^ (order + n) 大小的記憶體塊逐級減半分裂,將每一次分裂后的記憶體塊插入到相應的 free_area 陣列里對應的 free_list[MIGRATE_MOVABLE] 鏈表中,并將最后分裂出的 2 ^ order 尺寸的記憶體塊分配給行程使用,
我們假設當前伙伴系統中只有 order = 3 的空閑鏈表 free_area[3],其余剩下的分配階 order 對應的空閑鏈表中均是空的, free_area[3] 中僅有一個空閑的記憶體塊,其中包含了連續的 8 個 page,我們暫時忽略 MIGRATE_TYPES 相關的組織結構,
現在我們向伙伴系統申請一個 page 大小的記憶體(對應的分配階 order = 0),如上圖所示,內核會在伙伴系統中首先查看 order = 0 對應的空閑鏈表 free_area[0] 中是否有空閑記憶體塊可供分配,如果有,則將該空閑記憶體塊從 free_area[0] 摘下回傳,記憶體分配成功,
如果沒有,隨后內核會根據前邊介紹的記憶體分配邏輯,繼續升級到 free_area[1] , free_area[2] 鏈表中尋找空閑記憶體塊,直到查找到 free_area[3] 發現有一個可供分配的記憶體塊,這個記憶體塊中包含了 8 個 連續的空閑 page,但是我們只要一個 page 就夠了,那該怎么辦呢?
于是內核先將 free_area[3] 中的這個空閑記憶體塊從鏈表中摘下,然后減半分裂成兩個記憶體塊,分裂出來的這兩個記憶體塊分別包含 4 個 page(分配階 order = 2),
隨后內核會將分裂出的后半部分(上圖中綠色部分,order = 2),插入到 free_area[2] 鏈表中,
前半部分(上圖中黃色部分,order = 2)繼續減半分裂,分裂出來的這兩個記憶體塊分別包含 2 個 page(分配階 order = 1),如上圖中第 4 步所示,前半部分為黃色,后半部分為紫色,同理按照前邊的分裂邏輯,內核會將后半部分記憶體塊(紫色部分,分配階 order = 1)插入到 free_area[1] 鏈表中,
前半部分(圖中黃色部分,order = 1)在上圖中的第 6 步繼續減半分裂,分裂出來的這兩個記憶體塊分別包含 1 個 page(分配階 order = 0),前半部分為青色,后半部分為黃色,
黃色后半部分插入到 frea_area[0] 鏈表中,青色前半部分回傳給行程,這時伙伴系統分配記憶體流程結束,
我們從以上介紹的伙伴系統核心資料結構,以及伙伴系統記憶體分配原理的相關內容來看,伙伴系統管理物理記憶體的最小單位是物理記憶體頁 page,也就是說,當我們向伙伴系統申請記憶體時,至少要申請一個物理記憶體頁,
而從內核實際運行程序中來看,無論是從內核態還是從用戶態的角度來說,對于記憶體的需求量往往是以位元組為單位,通常是幾十位元組到幾百位元組不等,遠遠小于一個頁面的大小,如果我們僅僅為了這幾十位元組的記憶體需求,而專門為其分配一整個記憶體頁面,這無疑是對寶貴記憶體資源的一種巨大浪費,
于是在內核中,這種專門針對小記憶體的分配需求就應運而生了,而本文的主題—— slab 記憶體池就是專門應對小記憶體頻繁的分配和釋放的場景的,
slab 首先會向伙伴系統一次性申請一個或者多個物理記憶體頁面,正是這些物理記憶體頁組成了 slab 記憶體池,
隨后 slab 記憶體池會將這些連續的物理記憶體頁面劃分成多個大小相同的小記憶體塊出來,同一種 slab 記憶體池下,劃分出來的小記憶體塊尺寸是一樣的,內核會針對不同尺寸的小記憶體分配需求,預先創建出多個 slab 記憶體池出來,
這種小記憶體在內核中的使用場景非常之多,比如,內核中那些經常使用,需要頻繁申請釋放的一些核心資料結構物件:task_struct 物件,mm_struct 物件,struct page 物件,struct file 物件,socket 物件等,
而創建這些內核核心資料結構物件以及為這些核心物件分配記憶體,銷毀這些內核物件以及釋放相關的記憶體是需要性能開銷的,
這一點我們從 《深入理解 Linux 物理記憶體分配與釋放全鏈路實作》一文中詳細介紹的記憶體分配與釋放全鏈路程序中已經非常清楚的看到了,整個記憶體分配鏈路還是比較長的,如果遇到記憶體不足,還會涉及到記憶體的 swap 和 compact ,從而進一步產生更大的性能開銷,
既然 slab 專門是用于小記憶體塊分配與回收的,那么內核很自然的就會想到,分別為每一個需要被內核頻繁創建和釋放的核心物件創建一個專屬的 slab 物件池,這些內核物件專屬的 slab 物件池會根據其所管理的具體內核物件所占用記憶體的大小 size,將一個或者多個完整的物理記憶體頁按照這個 size 劃分出多個大小相同的小記憶體塊出來,每個小記憶體塊用于存盤預先創建好的內核物件,
這樣一來,當內核需要頻繁分配和釋放內核物件時,就可以直接從相應的 slab 物件池中申請和釋放內核物件,避免了鏈路比較長的記憶體分配與釋放程序,極大地提升了性能,這是一種池化思想的應用,
關于更多池化思想的介紹,以及物件池的應用與實作,筆者之前寫過一篇物件池在用戶態應用程式中的設計與實作的文章 《詳解 Netty Recycler 物件池的精妙設計與實作》,感興趣的讀者朋友可以看一下,
將內核中的核心資料結構物件,池化在 slab 物件池中,除了可以避免內核物件頻繁反復初始化和相關記憶體分配,頻繁反復銷毀物件和相關記憶體釋放的性能開銷之外,其實還有很多好處,比如:
-
利用 CPU 高速快取提高訪問速度,當一個物件被直接釋放回 slab 物件池中的時候,這個內核物件還是“熱的”,仍然會駐留在 CPU 高速快取中,如果這時,內核繼續向 slab 物件池申請物件,slab 物件池會優先把這個剛剛釋放 “熱的” 物件分配給內核使用,因為物件很大概率仍然駐留在 CPU 高速快取中,所以內核訪問起來速度會更快,
-
伙伴系統只能分配 2 的次冪個完整的物理記憶體頁,這會引起占用高速快取以及 TLB 的空間較大,導致一些不重要的資料駐留在 CPU 高速快取中占用寶貴的快取空間,而重要的資料卻被置換到記憶體中, slab 物件池針對小記憶體分配場景,可以有效的避免這一點,
-
呼叫伙伴系統的操作會對 CPU 高速快取 L1Cache 中的 Instruction Cache(指令高速快取)和 Data Cache (資料高速快取)有污染,因為對伙伴系統的長鏈路呼叫,相關的一些指令和資料必然會填充到 Instruction Cache 和 Data Cache 中,從而將頻繁使用的一些指令和資料擠壓出去,造成快取污染,而在內核空間中越浪費這些快取資源,那么在用戶空間中的行程就會越少的得到這些快取資源,造成性能的下降, slab 物件池極大的減少了對伙伴系統的呼叫,防止了不必要的 L1Cache 污染,
- 使用 slab 物件池可以充分利用 CPU 高速快取,避免多個物件對同一 cache line 的爭用,如果物件直接存盤排列在伙伴系統提供的記憶體頁中的話(不受 slab 管理),那么位于不同記憶體頁中具有相同偏移的物件很可能會被放入同一個 cache line 中,即使其他 cache line 還是空的,具體為什么會造成具有相同記憶體偏移地址的物件會對同一 cache line 進行爭搶,筆者會在文章后面相關章節中為大家解答,這里我們只是簡單列出 slab 針對小記憶體分配的一些優勢,目的是讓大家先從總體上把握,
3. slab 物件池在內核中的應用場景
現在我們最起碼從概念上清楚了 slab 物件池的產生背景,以及它要解決的問題場景,下面筆者列舉了幾個 slab 物件池在內核中的使用場景,方便大家進一步從總體上理解,
本小節我們依然還是從總體上把握 slab 物件池,大家不必過度地陷入到細節當中,
- 當我們使用 fork() 系統呼叫創建行程的時候,內核需要使用 task_struct 專屬的 slab 物件池分配 task_struct 物件,
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
...........
struct task_struct *tsk;
// 從 task_struct 物件專屬的 slab 物件池中申請 task_struct 物件
tsk = alloc_task_struct_node(node);
...........
}
- 為行程創建虛擬記憶體空間的時候,內核需要使用 mm_struct 專屬的 slab 物件池分配 mm_struct 物件,
static struct mm_struct *dup_mm(struct task_struct *tsk,
struct mm_struct *oldmm)
{
..........
struct mm_struct *mm;
// 從 mm_struct 物件專屬的 slab 物件池中申請 mm_struct 物件
mm = allocate_mm();
..........
}
- 當我們向頁高速快取 page cache 查找對應的檔案快取頁時,內核需要使用 struct page 專屬的 slab 物件池分配 struct page 物件,
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset,
int fgp_flags, gfp_t gfp_mask)
{
struct page *page;
repeat:
// 在 radix_tree(page cache)中根據快取頁 offset 查找快取頁
page = find_get_entry(mapping, offset);
// 快取頁不存在的話,跳轉到 no_page 處理邏輯
if (!page)
goto no_page;
.......省略.......
no_page:
// 從 page 物件專屬的 slab 物件池中申請 page 物件
page = __page_cache_alloc(gfp_mask);
// 將新分配的記憶體頁加入到頁高速快取 page cache 中
err = add_to_page_cache_lru(page, mapping, offset, gfp_mask);
.......省略.......
}
return page;
}
- 當我們使用 open 系統呼叫打開一個檔案時,內核需要使用 struct file專屬的 slab 物件池分配 struct file 物件,
struct file *do_filp_open(int dfd, struct filename *pathname,
const struct open_flags *op)
{
struct file *filp;
// 分配 struct file 內核物件
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
..........
return filp;
}
static struct file *path_openat(struct nameidata *nd,
const struct open_flags *op, unsigned flags)
{
struct file *file;
// 從 struct file 物件專屬的 slab 物件池中申請 struct file 物件
file = alloc_empty_file(op->open_flag, current_cred());
..........
}
- 當服務端網路應用程式使用 accpet 系統呼叫接收客戶端的連接時,內核需要使用 struct socket 專屬的 slab 物件池為新進來的客戶端連接分配 socket 物件,
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
int __user *, upeer_addrlen, int, flags)
{
struct socket *sock, *newsock;
// 查找正在 listen 狀態的監聽 socket
sock = sockfd_lookup_light(fd, &err, &fput_needed);
// 為新進來的客戶端連接申請 socket 物件以及與其關聯的 inode 物件
// 從 struct socket 物件專屬的 slab 物件池中申請 struct socket 物件
newsock = sock_alloc();
............. 利用監聽 socket 初始化 newsocket ..........
}
當然了被 slab 物件池所管理的內核核心物件不只是筆者上面為大家列舉的這五個,事實上,凡是需要被內核頻繁使用的內核物件都需要被 slab 物件池所管理,
比如:我們在 《從 Linux 內核角度探秘 IO 模型的演變》 一文中為大家介紹的 epoll 相關的物件:
在《從 Linux 內核角度探秘 JDK NIO 檔案讀寫本質》 一文中介紹的頁高速快取 page cache 相關的物件:
在 《深入理解 Linux 虛擬記憶體管理》 一文中介紹的虛擬記憶體地址空間相關的物件:
現在我們只是對 slab 物件池有了一個最表面的認識,那么接下來的內容,筆者會帶大家深入到 slab 物件池的實作細節中一探究竟,
在開始介紹內核原始碼實作之前,筆者想和大家交代一下本文的行文思路,之前的系列文章中筆者都是采用 “總——分——總” 的思路為大家講述原始碼,但是本文要介紹的 slab 物件池實作比較復雜,一上來就把總體架構給大家展示出來,大家看的也是一臉懵,
所以這里我們換一種思路,筆者會帶大家從一個最簡單的物理記憶體頁 page 開始,一步一步地演進,直到一個完整的 slab 物件池架構清晰地展現在大家的面前,
4. slab, slub, slob 傻傻分不清楚
在開始正式介紹 slab 物件池之前,筆者覺得有必要先向大家簡單交代一下 Linux 系統中關于 slab 物件池的三種實作:slab,slub,slob,
其中 slab 的實作,最早是由 Sun 公司的 Jeff Bonwick 大神在 Solaris 2.4 系統中設計并實作的,由于 Jeff Bonwick 大神公開了 slab 的實作方法,因此被 Linux 所借鑒并于 1996 年在 Linux 2.0 版本中引入了 slab,用于 Linux 內核早期的小記憶體分配場景,
由于 slab 的實作非常復雜,slab 中擁有多種存盤物件的佇列,佇列管理開銷比較大,slab 元資料比較臃腫,對 NUMA 架構的支持臃腫繁雜(slab 引入時內核還沒支持 NUMA),這樣導致 slab 內部為了維護這些自身元資料管理結構就得花費大量的記憶體空間,這在配置有超大容量記憶體的服務器上,記憶體的浪費是非常可觀的,
針對以上 slab 的不足,內核大神 Christoph Lameter 在 2.6.22 版本(2007 年發布)中引入了新的 slub 實作,slub 簡化了 slab 一些復雜的設計,同時保留了 slab 的基本思想,摒棄了 slab 眾多管理佇列的概念,并針對多處理器,NUMA 架構進行優化,放棄了效果不太明顯的 slab 著色機制,slub 與 slab 相比,提高了性能,吞吐量,并降低了記憶體的浪費,成為現在內核中常用的 slab 實作,
而 slob 的實作是在內核 2.6.16 版本(2006 年發布)引入的,它是專門為嵌入式小型機器小記憶體的場景設計的,所以實作上很精簡,能在小型機器上提供很不錯的性能,
而內核中關于記憶體池(小記憶體分配器)的相關 API 介面函式均是以 slab 命名的,但是我們可以通過配置的方式來平滑切換以上三種 slab 的實作,本文我們主要討論被大規模運用在服務器 Linux 作業系統中的 slub 物件池的實作,所以本文下面的內容,如無特殊說明,筆者提到的 slab 均是指 slub 實作,
5. 從一個簡單的記憶體頁開始聊 slab
從前邊小節的內容中,我們知道內核會把那些頻繁使用的核心物件統一放在 slab 物件池中管理,每一個核心物件對應一個專屬的 slab 物件池,以便提升核心物件的分配,訪問,釋放相關操作的性能,
如上圖所示,slab 物件池在記憶體管理系統中的架構層次是基于伙伴系統之上構建的,slab 物件池會一次性向伙伴系統申請一個或者多個完整的物理記憶體頁,在這些完整的記憶體頁內在逐步劃分出一小塊一小塊的記憶體塊出來,而這些小記憶體塊的尺寸就是 slab 物件池所管理的內核核心物件占用的記憶體大小,
下面筆者就帶大家從一個最簡單的物理記憶體頁 page 開始,我們一步一步的推演 slab 的整個架構設計與實作,
如果讓我們自己設計一個物件池,首先最直觀最簡單的辦法就是先向伙伴系統申請一個記憶體頁,然后按照需要被池化物件的尺寸 object size,把記憶體頁劃分為一個一個的記憶體塊,每個記憶體塊尺寸就是 object size,
事實上,slab 物件池可以根據情況向伙伴系統一次性申請多個記憶體頁,這里只是為了方便大家理解,我們先以一個記憶體頁為例,為大家說明 slab 中物件的記憶體布局,
但是在一個工業級的物件池設計中,我們不能這么簡單粗暴的搞,因為物件的 object size 可以是任意的,并不是記憶體對齊的,CPU 訪問一塊沒有進行對齊的記憶體比訪問對齊的記憶體速度要慢一倍,
因為 CPU 向記憶體讀取資料的單位是根據 word size 來的,在 64 位處理器中 word size = 8 位元組,所以 CPU 向記憶體讀寫資料的單位為 8 位元組,CPU 只能一次性向記憶體訪問按照 word size ( 8 位元組) 對齊的記憶體地址,如果 CPU 訪問一個未進行 word size 對齊的記憶體地址,就會經歷兩次訪存操作,
比如,我們現在需要訪問 0x0007 - 0x0014 這樣一段沒有對 word size 進行對齊的記憶體,CPU只能先從 0x0000 - 0x0007 讀取 8 個位元組出來先放入結果暫存器中并左移 7 個位元組(目的是只獲取 0x0007 ),然后 CPU 在從 0x0008 - 0x0015 讀取 8 個位元組出來放入臨時暫存器中并右移1個位元組(目的是獲取 0x0008 - 0x0014 )最后與結果暫存器或運算,最終得到 0x0007 - 0x0014 地址段上的 8 個位元組,
從上面程序我們可以看出,CPU 訪問一段未進行 word size 對齊的記憶體,需要兩次訪存操作,
記憶體對齊的好處還有很多,比如,CPU 訪問對齊的記憶體都是原子性的,對齊記憶體中的資料會獨占 cache line ,不會與其他資料共享 cache line,避免 false sharing,
這里大家只需要簡單了解為什么要進行記憶體對齊即可,關于記憶體對齊的詳細內容,感興趣的讀者可以回看下 《記憶體對齊的原理及其應用》 一文中的 “ 5. 記憶體對齊 ” 小節,
基于以上原因,我們不能簡單的按照物件尺寸 object size 來劃分記憶體塊,而是需要考慮到物件記憶體地址要按照 word size 進行對齊,于是上面的 slab 物件池的記憶體布局又有了新的變化,
如果被池化物件的尺寸 object size 本來就是和 word size 對齊的,那么我們不需要做任何事情,但是如果 object size 沒有和 word size 對齊,我們就需要填充一些位元組,目的是要讓物件的 object size 按照 word size 進行對齊,提高 CPU 訪問物件的速度,
但是上面的這些作業對于一個工業級的物件池來說還遠遠不夠,工業級的物件池需要應對很多復雜的詭異場景,比如,我們偶爾在復雜生產環境中會遇到的記憶體讀寫訪問越界的情況,這會導致很多莫名其妙的例外,
內核為了應對記憶體讀寫越界的場景,于是在物件記憶體的周圍插入了一段不可訪問的記憶體區域,這些記憶體區域用特定的位元組 0xbb 填充,當行程訪問的到記憶體是 0xbb 時,表示已經越界訪問了,這段記憶體區域在 slab 中的術語為 red zone,大家可以理解為紅色警戒區域,
插入 red zone 之后,slab 物件池的記憶體布局近一步演進為下圖所示的布局:
-
如果物件尺寸 object size 本身就是 word size 對齊的,那么就需要在物件左右兩側填充兩段 red zone 區域,red zone 區域的長度一般就是 word size 大小,
-
如果物件尺寸 object size 是通過填充 padding 之后,才與 word size 對齊,內核會巧妙的利用物件右邊的這段 padding 填充區域作為 red zone,只需要額外的在物件記憶體區域的左側填充一段 red zone 即可,
在有了新的記憶體布局之后,我們接下來就要考慮一個問題,當我們向 slab 物件池獲取到一個空閑物件之后,我們需要知道它的下一個空閑物件在哪里,這樣方便我們下次獲取物件,那么我們該如何將記憶體頁 page 中的這些空閑物件串聯起來呢?
有讀者朋友可能會說了,這很簡單啊,用一個鏈表把這些空閑物件串聯起來不就行了嘛,其實內核也是這樣想的,哈哈,不過內核巧妙的地方在于不需要為串聯物件所用到的 next 指標額外的分配記憶體空間,
因為物件在 slab 中沒有被分配出去使用的時候,其實物件所占的記憶體中存放什么,用戶根本不會關心的,既然這樣,內核干脆就把指向下一個空閑物件的 freepointer 指標直接存放在物件所占記憶體(object size)中,這樣避免了為 freepointer 指標單獨再分配記憶體空間,巧妙的利用了物件所在的記憶體空間(object size),
我們接著對 slab 記憶體布局進行演化,有時候我們期望知道 slab 物件池中各個物件的狀態,比如是否處于空閑狀態,那么物件的狀態我們在哪里存盤呢?
答案還是和 freepointer 的處理方式一樣,巧妙的利用物件所在的記憶體空間(object size),內核會在物件所占的記憶體空間中填充一些特殊的字符用來表示物件的不同狀態,因為反正物件沒有被分配出去使用,記憶體里存的是什么都無所謂,
當 slab 剛剛從伙伴系統中申請出來,并初始化劃分物理記憶體頁中的物件記憶體空間時,內核會將物件的 object size 記憶體區域用特殊位元組 0x6b 填充,并用 0xa5 填充物件 object size 記憶體區域的最后一個位元組表示填充完畢,
或者當物件被釋放回 slab 物件池中的時候,也會用這些位元組填充物件的記憶體區域,
這種通過在物件記憶體區域填充特定位元組表示物件的特殊狀態的行為,在 slab 中有一個專門的術語叫做 SLAB_POISON (SLAB 中毒),POISON 這個術語起的真的是只可意會不可言傳,其實就是表示 slab 物件的一種狀態,
是否毒化 slab 物件是可以設定的,當 slab 物件被 POISON 之后,那么會有一個問題,就是我們前邊介紹的存放在物件記憶體區域 object size 里的 freepointer 就被會特殊位元組 0x6b 覆寫掉,這種情況下,內核就只能為 freepointer 在額外分配一個 word size 大小的記憶體空間了,
slab 物件的記憶體布局資訊除了以上內容之外,有時候我們還需要去跟蹤一下物件的分配和釋放相關資訊,而這些資訊也需要在 slab 物件中存盤,內核中使用一個 struct track 結構體來存盤跟蹤資訊,
這樣一來,slab 物件的記憶體區域中就需要在開辟出兩個 sizeof(struct track)
大小的區域出來,用來分別存盤 slab 物件的分配和釋放資訊,
上圖展示的就是 slab 物件在記憶體中的完整布局,其中 object size 為物件真正所需要的記憶體區域大小,而物件在 slab 中真實的記憶體占用大小 size 除了 object size 之外,還包括填充的 red zone 區域,以及用于跟蹤物件分配和釋放資訊的 track 結構,另外,如果 slab 設定了 red zone,內核會在物件末尾增加一段 word size 大小的填充 padding 區域,
當 slab 向伙伴系統申請若干記憶體頁之后,內核會按照這個 size 將記憶體頁劃分成一個一個的記憶體塊,記憶體塊大小為 size ,
其實 slab 的本質就是一個或者多個物理記憶體頁 page,內核會根據上圖展示的 slab 物件的記憶體布局,計算出物件的真實記憶體占用 size,最后根據這個 size 在 slab 背后依賴的這一個或者多個物理記憶體頁 page 中劃分出多個大小相同的記憶體塊出來,
所以在內核中,都是用 struct page 結構來表示 slab,如果 slab 背后依賴的是多個物理記憶體頁,那就使用在 《深度剖析 Linux 伙伴系統的設計與實作》 一文中 " 5.3.2 設定復合頁 compound_page " 小節提到的復合頁 compound_page 來表示,
struct page {
// 首頁 page 中的 flags 會被設定為 PG_head 表示復合頁的第一頁
unsigned long flags;
// 其余尾頁會通過該欄位指向首頁
unsigned long compound_head;
// 用于釋放復合頁的解構式,保存在首頁中
unsigned char compound_dtor;
// 該復合頁有多少個 page 組成,order 還是分配階的概念,在首頁中保存
// 本例中的 order = 2 表示由 4 個普通頁組成
unsigned char compound_order;
// 該復合頁被多少個行程使用,記憶體頁反向映射的概念,首頁中保存
atomic_t compound_mapcount;
// 復合頁使用計數,首頁中保存
atomic_t compound_pincount;
}
slab 的具體資訊也是在 struct page 中存盤,下面筆者提取了 struct page 結構中和 slab 相關的欄位:
struct page {
struct { /* slub 相關欄位 */
union {
// slab 所在的管理鏈表
struct list_head slab_list;
struct { /* Partial pages */
// 用 next 指標在相應管理鏈表中串聯起 slab
struct page *next;
#ifdef CONFIG_64BIT
// slab 所在管理鏈表中的包含的 slab 總數
int pages;
// slab 所在管理鏈表中包含的物件總數
int pobjects;
#else
short int pages;
short int pobjects;
#endif
};
};
// 指向 slab cache,slab cache 就是真正的物件池結構,里邊管理了多個 slab
// 這多個 slab 被 slab cache 管理在了不同的鏈表上
struct kmem_cache *slab_cache;
// 指向 slab 中第一個空閑物件
void *freelist; /* first free object */
union {
struct { /* SLUB */
// slab 中已經分配出去的獨享
unsigned inuse:16;
// slab 中包含的物件總數
unsigned objects:15;
// 該 slab 是否在對應 slab cache 的本地 CPU 快取中
// frozen = 1 表示快取再本地 cpu 快取中
unsigned frozen:1;
};
};
};
}
在筆者當前所在的內核版本 5.4 中,內核是使用 struct page 來表示 slab 的,但是考慮到 struct page 結構已經非常龐大且復雜,為了減少 struct page 的記憶體占用以及提高可讀性,內核在 5.17 版本中專門為 slab 引入了一個管理結構 struct slab,將原有 struct page 中 slab 相關的欄位全部洗掉,轉移到了 struct slab 結構中,這一點,大家只做了解即可,
6. slab 的總體架構設計
在上一小節的內容中,筆者帶大家從 slab 的微觀層面詳細的介紹了 slab 物件的記憶體布局,首先 slab 會從伙伴系統中申請一個或多個物理記憶體頁 page,然后根據 slab 物件的記憶體布局計算出物件在記憶體中的真實尺寸 size,并根據這個 size,在物理記憶體頁中劃分出多個記憶體塊出來,供內核申請使用,
有了這個基礎之后,在本小節中,筆者將繼續帶大家從 slab 的宏觀層面上繼續深入 slab 的架構設計,
筆者在前邊的內容中多次提及的 slab 物件池其實就是上圖中的 slab cache,而上小節中介紹的 slab 只是 slab cache 架構體系中的基本單位,物件的分配和釋放最侄訓落在 slab 這個基本單位上,
如果一個 slab 中的物件全部分配出去了,slab cache 就會將其視為一個 full slab,表示這個 slab 此刻已經滿了,無法在分配物件了,slab cache 就會到伙伴系統中重新申請一個 slab 出來,供后續的記憶體分配使用,
當內核將物件釋放回其所屬的 slab 之后,如果 slab 中的物件全部歸位,slab cache 就會將其視為一個 empty slab,表示 slab 此刻變為了一個完全空閑的 slab,如果超過了 slab cache 中規定的 empty slab 的閾值,slab cache 就會將這些空閑的 empty slab 重新釋放回伙伴系統中,
如果一個 slab 中的物件部分被分配出去使用,部分卻未被分配仍然在 slab 中快取,那么內核就會將該 slab 視為一個 partial slab,
這些不同狀態的 slab,會在 slab cache 中被不同的鏈表所管理,同時 slab cache 會控制管理鏈表中 slab 的個數以及鏈表中所快取的空閑物件個數,防止它們無限制的增長,
slab cache 中除了需要管理眾多的 slab 之外,還包括了很多 slab 的基礎資訊,比如:
-
上小節中提到的 slab 物件記憶體布局相關的資訊
-
slab 中的物件需要按照什么方式進行記憶體對齊,比如,按照 CPU 硬體高速快取行 cache line (64 位元組) 進行對齊,slab 物件是否需要進行毒化 POISON,是否需要在 slab 物件記憶體周圍插入 red zone,是否需要追蹤 slab 物件的分配與回收資訊,等等,
-
一個 slab 具體到底需要多少個物理記憶體頁 page,一個 slab 中具體能夠容納多少個 object (記憶體塊),
6.1 slab 的基礎資訊管理
slab cache 在內核中的資料結構為 struct kmem_cache,以上介紹的這些 slab 的基本資訊以及 slab 的管理結構全部定義在該結構體中:
/*
* Slab cache management.
*/
struct kmem_cache {
// slab cache 的管理標志位,用于設定 slab 的一些特性
// 比如:slab 中的物件按照什么方式對齊,物件是否需要 POISON 毒化,是否插入 red zone 在物件記憶體周圍,是否追蹤物件的分配和釋放資訊 等等
slab_flags_t flags;
// slab 物件在記憶體中的真實占用,包括為了記憶體對齊填充的位元組數,red zone 等等
unsigned int size; /* The size of an object including metadata */
// slab 中物件的實際大小,不包含填充的位元組數
unsigned int object_size;/* The size of an object without metadata */
// slab 物件池中的物件在沒有被分配之前,我們是不關心物件里邊存盤的內容的,
// 內核巧妙的利用物件占用的記憶體空間存盤下一個空閑物件的地址,
// offset 表示用于存盤下一個空閑物件指標的位置距離物件首地址的偏移
unsigned int offset; /* Free pointer offset */
// 表示 cache 中的 slab 大小,包括 slab 所需要申請的頁面個數,以及所包含的物件個數
// 其中低 16 位表示一個 slab 中所包含的物件總數,高 16 位表示一個 slab 所占有的記憶體頁個數,
struct kmem_cache_order_objects oo;
// slab 中所能包含物件以及記憶體頁個數的最大值
struct kmem_cache_order_objects max;
// 當按照 oo 的尺寸為 slab 申請記憶體時,如果記憶體緊張,會采用 min 的尺寸為 slab 申請記憶體,可以容納一個物件即可,
struct kmem_cache_order_objects min;
// 向伙伴系統申請記憶體時使用的記憶體分配標識
gfp_t allocflags;
// slab cache 的參考計數,為 0 時就可以銷毀并釋放記憶體回伙伴系統重
int refcount;
// 池化物件的建構式,用于創建 slab 物件池中的物件
void (*ctor)(void *);
// 物件的 object_size 按照 word 字長對齊之后的大小
unsigned int inuse;
// 物件按照指定的 align 進行對齊
unsigned int align;
// slab cache 的名稱, 也就是在 slabinfo 命令中 name 那一列
const char *name;
};
slab_flags_t flags
是 slab cache 的管理標志位,用于設定 slab 的一些特性,比如:
- 當 flags 設定了 SLAB_HWCACHE_ALIGN 時,表示 slab 中的物件需要按照 CPU 硬體高速快取行 cache line (64 位元組) 進行對齊,
- 當 flags 設定了 SLAB_POISON 時,表示需要在 slab 物件記憶體中填充特殊位元組 0x6b 和 0xa5,表示物件的特定狀態,
-
當 flags 設定了 SLAB_RED_ZONE 時,表示需要在 slab 物件記憶體周圍插入 red zone,防止記憶體的讀寫越界,
-
當 flags 設定了 SLAB_CACHE_DMA 或者 SLAB_CACHE_DMA32 時,表示指定 slab 中的記憶體來自于哪個記憶體區域,DMA or DMA32 區域 ?如果沒有特殊指定,slab 中的記憶體一般來自于 NORMAL 直接映射區域,
- 當 flags 設定了 SLAB_STORE_USER 時,表示需要追蹤物件的分配和釋放相關資訊,這樣會在 slab 物件記憶體區域中額外增加兩個
sizeof(struct track)
大小的區域出來,用于存盤 slab 物件的分配和釋放資訊,
相關 slab cache 的標志位 flag,定義在內核檔案 /include/linux/slab.h
中:
/* DEBUG: Red zone objs in a cache */
#define SLAB_RED_ZONE ((slab_flags_t __force)0x00000400U)
/* DEBUG: Poison objects */
#define SLAB_POISON ((slab_flags_t __force)0x00000800U)
/* Align objs on cache lines */
#define SLAB_HWCACHE_ALIGN ((slab_flags_t __force)0x00002000U)
/* Use GFP_DMA memory */
#define SLAB_CACHE_DMA ((slab_flags_t __force)0x00004000U)
/* Use GFP_DMA32 memory */
#define SLAB_CACHE_DMA32 ((slab_flags_t __force)0x00008000U)
/* DEBUG: Store the last owner for bug hunting */
#define SLAB_STORE_USER
struct kmem_cache 結構中的 size 欄位表示 slab 物件在記憶體中的真實占用大小,該大小包括物件所占記憶體中各種填充的記憶體區域大小,比如下圖中的 red zone,track 區域,等等,
unsigned int object_size
表示單純的存盤 slab 物件所需要的實際記憶體大小,如上圖中的 object size 藍色區域所示,
在上小節我們介紹 freepointer 指標的時候提到過,當物件在 slab 中快取并沒有被分配出去之前,其實物件所占記憶體中存盤的是什么,用戶根本不會去關心,內核會巧妙的利用物件的記憶體空間來存盤 freepointer 指標,用于指向 slab 中的下一個空閑物件,
但是當 kmem_cache 結構中的 flags 設定了 SLAB_POISON 標志位之后,slab 中的物件會 POISON 毒化,被特殊位元組 0x6b 和 0xa5 所填充,這樣一來就會覆寫原有的 freepointer,在這種情況下,內核就需要把 freepointer 存盤在物件所在記憶體區域的外面,
所以內核就需要用一個欄位來標識 freepointer 的位置,struct kmem_cache 結構中的 unsigned int offset
欄位干的就是這個事情,它表示物件的 freepointer 指標距離物件的起始記憶體地址的偏移 offset,
上小節中,我們也提到過,slab 的本質其實就是一個或者多個物理記憶體頁,slab 在內核中的結構也是用 struct page 來表示的,那么一個 slab 中到底包含多少個記憶體頁 ? 這些記憶體頁中到底能容納多少個記憶體塊(object)呢?
struct kmem_cache_order_objects oo
欄位就是保存這些資訊的,struct kmem_cache_order_objects 結構體其實就是一個無符號的整形欄位,它的高 16 位用來存盤 slab 所需的物理記憶體頁個數,低 16 位用來存盤 slab 所能容納的物件總數,
struct kmem_cache_order_objects {
// 高 16 為存盤 slab 所需的記憶體頁個數,低 16 為存盤 slab 所能包含的物件總數
unsigned int x;
};
struct kmem_cache_order_objects max
欄位表示 oo 的最大值,內核在初始化 slab 的時候,會將 max 的值設定為 oo,
struct kmem_cache_order_objects min
欄位表示 slab 中至少需要容納的物件個數以及容納最少的物件所需要的記憶體頁個數,內核在初始化 slab 的時候會 將 min 的值設定為至少需要容納一個物件,
內核在創建 slab 的時候,最開始會按照 oo 指定的尺寸來向伙伴系統申請記憶體頁,如果記憶體緊張,申請記憶體失敗,那么內核會降級采用 min 的尺寸再次向伙伴系統申請記憶體,也就是說 slab 中至少會包含一個物件,
gfp_t allocflags
是內核在向伙伴系統為 slab 申請記憶體頁的時候,所用到的記憶體分配標志位,感興趣的朋友可以回看下 《深入理解 Linux 物理記憶體分配全鏈路實作》 一文中的 “ 2.規范物理記憶體分配行為的掩碼 gfp_mask ” 小節中的內容,那里有非常詳細的介紹,
unsigned int inuse
表示物件的 object size 按照 word size 對齊之后的大小,如果我們設定了SLAB_RED_ZONE,inuse 也會包括物件右側 red zone 區域的大小,
unsigned int align
在創建 slab cache 的時候,我們可以向內核指定 slab 中的物件按照 align 的值進行對齊,內核會綜合 word size , cache line ,align 計算出一個合理的對齊尺寸,
const char *name
表示該 slab cache 的名稱,這里指定的 name 將會在 cat /proc/slabinfo
命令中顯示,該命令用于查看系統中所有 slab cache 的資訊,
cat /proc/slabinfo
命令的顯示結構主要由三部分組成:
-
statistics 部分顯示的是 slab cache 的基本統計資訊,這部分是我們最常用的,下面是每一列的含義:
- active_objs 表示 slab cache 中已經被分配出去的物件個數
- num_objs 表示 slab cache 中容納的物件總數
- objsize 表示 slab 中物件的 object size ,單位為位元組
- objperslab 表示 slab 中可以容納的物件個數
- pagesperslab 表示 slab 所需要的物理記憶體頁個數
-
tunables 部分顯示的 slab cache 的動態可調節引數,如果我們采用的 slub 實作,那么 tunables 部分全是 0 ,
/proc/slabinfo
檔案不可寫,無法動態修改相關引數,如果我們使用的 slab 實作的話,可以通過# echo 'name limit batchcount sharedfactor' > /proc/slabinfo
命令動態修改相關引數,命令中指定的 name 就是 kmem_cache 結構中的 name 屬性,tunables 這部分顯示的資訊均是 slab 實作中的相關欄位,大家只做簡單了解即可,與我們本文主題 slub 的實作沒有關系,- limit 表示在 slab 的實作中,slab cache 的 cpu 本地快取 array_cache 最大可以容納的物件個數
- batchcount 表示當 array_cache 中快取的物件不夠時,需要一次性填充的空閑物件個數,
-
slabdata 部分顯示的 slab cache 的總體資訊,其中 active_slabs 一列展示的 slab cache 中活躍的 slab 個數,nums_slabs 一列展示的是 slab cache 中管理的 slab 總數
在 cat /proc/slabinfo
命令顯示的這些系統中所有的 slab cache,內核會將這些 slab cache 用一個雙向鏈表統一串聯起來,鏈表的頭結點指標保存在 struct kmem_cache 結構的 list 中,
struct kmem_cache {
// 用于組織串聯系統中所有型別的 slab cache
struct list_head list; /* List of slab caches */
}
系統中所有的這些 slab cache 占用的記憶體總量,我們可以通過 cat /proc/meminfo
命令查看:
除此之外,我們還可以通過 slabtop
命令來動態查看系統中占用記憶體最高的 slab cache,當記憶體緊張的時候,如果我們通過 cat /proc/meminfo
命令發現 slab 的記憶體占用較高的話,那么可以快速通過 slabtop
迅速定位到究竟是哪一類的 object 分配過多導致記憶體占用飆升,
6.2 slab 的組織架構
在上小節的內容中,筆者主要為大家介紹了 struct kmem_cache 結構中關于 slab 的一些基礎資訊,其中主要包括 slab cache 中所管理的 slabs 相關的容量控制,以及 slab 中物件的記憶體布局資訊,
那么 slab cache 中的這些 slabs 是如何被組織管理的呢 ?在本小節中,筆者將為大家揭開這個謎底,
slab cache 其實就是內核中的一個物件池,而關于物件池的設計,筆者在之前的文章 《詳解 Recycler 物件池的精妙設計與實作》 中詳細的介紹過 Netty 關于物件池這塊的設計,其中用了大量的篇幅重點著墨了多執行緒無鎖化設計,
內核在對 slab cache 的設計也是一樣,也充分考慮了多行程并發訪問 slab cache 所帶來的同步性能開銷,內核在 slab cache 的設計中為每個 cpu 引入了 struct kmem_cache_cpu 結構的 percpu 變數,作為 slab cache 在每個 cpu 中的本地快取,
/*
* Slab cache management.
*/
struct kmem_cache {
// 每個 cpu 擁有一個本地快取,用于無鎖化快速分配釋放物件
struct kmem_cache_cpu __percpu *cpu_slab;
}
這樣一來,當行程需要向 slab cache 申請對應的記憶體塊(object)時,首先會直接來到 kmem_cache_cpu 中查看 cpu 本地快取的 slab,如果本地快取的 slab 中有空閑物件,那么就直接回傳了,整個程序完全沒有加鎖,而且訪問路徑特別短,防止了對 CPU 硬體高速快取 L1Cache 中的 Instruction Cache(指令高速快取)污染,
下面我們來看一下 slab cache 它的 cpu 本地快取 kmem_cache_cpu 結構的詳細設計細節:
struct kmem_cache_cpu {
// 指向被 CPU 本地快取的 slab 中第一個空閑的物件
void **freelist; /* Pointer to next available object */
// 保證行程在 slab cache 中獲取到的 cpu 本地快取 kmem_cache_cpu 與當前執行行程的 cpu 是一致的,
unsigned long tid; /* Globally unique transaction id */
// slab cache 中 CPU 本地所快取的 slab,由于 slab 底層的存盤結構是記憶體頁 page
// 所以這里直接用記憶體頁 page 表示 slab
struct page *page; /* The slab from which we are allocating */
#ifdef CONFIG_SLUB_CPU_PARTIAL
// cpu cache 快取的備用 slab 串列,同樣也是用 page 表示
// 當被本地 cpu 快取的 slab 中沒有空閑物件時,內核會從 partial 串列中的 slab 中查找空閑物件
struct page *partial; /* Partially allocated frozen slabs */
#endif
#ifdef CONFIG_SLUB_STATS
// 記錄 slab 分配物件的一些狀態資訊
unsigned stat[NR_SLUB_STAT_ITEMS];
#endif
};
在本文 《5. 從一個簡單的記憶體頁開始聊 Slab》小節后面的內容介紹中,我們知道,slab 在內核中是用 struct page 結構來描述的,這里 struct kmem_cache_cpu 結構中的 page 指標
指向的就是被 cpu 本地快取的 slab,
freelist
指標指向的是該 slab 中第一個空閑的物件,在本文第五小節介紹 slab 物件記憶體布局的內容中,筆者提到過,為了充分利用 slab 物件所占用的記憶體,內核會在物件占用記憶體區域內開辟一塊區域來存放 freepointer 指標,而 freepointer 可以用來指向下一個空閑物件,
這樣一來,通過這里的 freelist 和 freepointer 就將 slab 中所有的空閑物件串聯了起來,
事實上,在 struct page 結構中也有一個 freelist 指標,用于指向該記憶體頁中第一個空閑物件,當 slab 被快取進 kmem_cache_cpu 中之后,page 結構中的 freelist 會賦值給 kmem_cache_cpu->freelist,然后 page->freelist 會置空,page 的 frozen 狀態設定為1,表示 slab 在本地 cpu 中快取,
struct page {
// 指向記憶體頁中第一個空閑物件
void *freelist; /* first free object */
// 該 slab 是否在對應 slab cache 的本地 CPU 快取中
// frozen = 1 表示快取再本地 cpu 快取中
unsigned frozen:1;
}
kmem_cache_cpu 結構中的 tid 是內核為 slab cache 的 cpu 本地快取結構設定的一個全域唯一的 transaction id ,這個 tid 在 slab cache 分配記憶體塊的時候主要有兩個作用:
-
內核會將 slab cache 每一次分配記憶體塊或者釋放記憶體塊的程序視為一個事物,所以在每次向 slab cache 申請記憶體塊或者將記憶體塊釋放回 slab cache 之后,內核都會改變這里的 tid,
-
tid 也可以簡單看做是 cpu 的一個編號,每個 cpu 的 tid 都不相同,可以用來標識區分不同 cpu 的本地快取 kmem_cache_cpu 結構,
其中 tid 的第二個作用是最主要的,因為行程可能在執行的程序中被更高優先級的行程搶占 cpu (開啟 CONFIG_PREEMPT 允許內核搶占)或者被中斷,隨后行程可能會被內核重新調度到其他 cpu 上執行,這樣一來,行程在被搶占之前獲取到的 kmem_cache_cpu 就與當前執行行程 cpu 的 kmem_cache_cpu 不一致了,
所以在內核中,我們經常會看到如下的代碼片段,目的就是為了保證行程在 slab cache 中獲取到的 cpu 本地快取 kmem_cache_cpu 與當前執行行程的 cpu 是一致的,
do {
// 獲取執行當前行程的 cpu 中的 tid 欄位
tid = this_cpu_read(s->cpu_slab->tid);
// 獲取 cpu 本地快取 cpu_slab
c = raw_cpu_ptr(s->cpu_slab);
// 如果兩者的 tid 欄位不一致,說明行程已經被調度到其他 cpu 上了
// 需要再次獲取正確的 cpu 本地快取
} while (IS_ENABLED(CONFIG_PREEMPT) &&
unlikely(tid != READ_ONCE(c->tid)));
如果開啟了 CONFIG_SLUB_CPU_PARTIAL
配置項,那么在 slab cache 的 cpu 本地快取 kmem_cache_cpu 結構中就會多出一個 partial 串列,partial 串列中存放的都是 partial slub,相當于是 cpu 快取的備用選擇.
當 kmem_cache_cpu->page (被本地 cpu 所快取的 slab)中的物件已經全部分配出去之后,內核會到 partial 串列中查找一個 partial slab 出來,并從這個 partial slab 中分配一個物件出來,最后將 kmem_cache_cpu->page 指向這個 partial slab,作為新的 cpu 本地快取 slab,這樣一來,下次分配物件的時候,就可以直接從 cpu 本地快取中獲取了,
如果開啟了 CONFIG_SLUB_STATS
配置項,內核就會記錄一些關于 slab cache 的相關狀態資訊,這些資訊同樣也會在 cat /proc/slabinfo
命令中顯示,
slab cache 的架構演變到現在,筆者已經為大家介紹了三種內核資料結構了,它們分別是:
- slab cache 在內核中的資料結構 struct kmem_cache
- slab cache 的本地 cpu 快取結構 struct kmem_cache_cpu
- slab 在內核中的資料結構 struct page
現在我們把這種三種資料結構結合起來,得到下面這副 slab cache 的架構圖:
但這還不是 slab cache 的最終架構,到目前為止我們的 slab cache 架構只演進到了一半,下面請大家繼續跟隨筆者的思路我們接著進行 slab cache 架構的演進,
我們先把 slab cache 比作一個大型超市,超市里擺放了一排一排的商品貨架,毫無疑問,顧客進入超市直接從貨架上選取自己想要的商品速度是最快的,
上圖中的 kmem_cache 結構就好比是超市,slab cache 的本地 cpu 快取結構 kmem_cache_cpu 就好比超市的營業廳,營業廳內擺滿了一排一排的貨架,這些貨架就是上圖中的 slab,貨架上的商品就是 slab 中劃分出來的一個一個的記憶體塊,
毫無疑問,顧客來到超市,直接去營業廳的貨架上拿取商品是最快的,那么如果貨架上的商品賣完了,該怎么辦呢?
這時,超市的經理就會到超市的倉庫中重新拿取商品填充貨架,那么 slab cache 的倉庫到底在哪里呢?
答案就在筆者之前文章 《深入理解 Linux 物理記憶體管理》 中的 “ 3.2 非一致性記憶體訪問 NUMA 架構 ” 小節中介紹的記憶體架構,在 NUMA 架構下,記憶體被劃分成了一個一個的 NUMA 節點,每個 NUMA 節點內包含若干個 cpu,
每個 cpu 都可以任意訪問所有 NUMA 節點中的記憶體,但是會有訪問速度上的差異, cpu 在訪問本地 NUMA 節點的速度是最快的,當本地 NUMA 節點中的記憶體不足時,cpu 會跨節點訪問其他 NUMA 節點,
slab cache 的倉庫就在 NUMA 節點中,而且在每一個 NUMA 節點中都有一個倉庫,當 slab cache 本地 cpu 快取 kmem_cache_cpu 中沒有足夠的記憶體塊可供分配時,內核就會來到 NUMA 節點的倉庫中拿出 slab 填充到 kmem_cache_cpu 中,
那么 slab cache 在 NUMA 節點的倉庫中也沒有足夠的貨物了,那該怎么辦呢?這時,內核就會到伙伴系統中重新批量申請一批 slabs,填充到本地 cpu 快取 kmem_cache_cpu 結構中,
伙伴系統就好比上面那個超市例子中的進貨商,當超市經理發現倉庫中也沒有商品之后,就會聯系進貨商,從進貨商那里批發商品,重新填充貨架,
slab cache 的倉庫在內核中采用 struct kmem_cache_node 結構來表示:
struct kmem_cache {
// slab cache 中 numa node 中的快取,每個 node 一個
struct kmem_cache_node *node[MAX_NUMNODES];
}
/*
* The slab lists for all objects.
*/
struct kmem_cache_node {
spinlock_t list_lock;
....... 省略 slab 相關欄位 ........
#ifdef CONFIG_SLUB
// 該 node 節點中快取的 slab 個數
unsigned long nr_partial;
// 該鏈表用于組織串聯 node 節點中快取的 slabs
// partial 鏈表中快取的 slab 為部分空閑的(slab 中的物件部分被分配出去)
struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG // 開啟 slab_debug 之后會用到的欄位
// slab 的個數
atomic_long_t nr_slabs;
// 該 node 節點中快取的所有 slab 中包含的物件總和
atomic_long_t total_objects;
// full 鏈表中包含的 slab 全部是已經被分配完畢的 full slab
struct list_head full;
#endif
#endif
};
這里筆者省略了 slab 實作相關的欄位,我們只關注 slub 實作的部分,nr_partial
表示該 NUMA 節點快取中快取的 slab 總數,這些被快取的 slabs 也是通過一個 partial 串列
被串聯管理起來,
如果我們配置了 CONFIG_SLUB_DEBUG
選項,那么 kmem_cache_node 結構中就會多出一些欄位來存盤更加豐富的資訊,nr_slabs
表示 NUMA 節點快取中 slabs 的總數,這里會包含 partial slub 和 full slab,這時,nr_partial
表示的是 partial slab 的個數,其中 full slab 會被串聯在 full 串列上,total_objects
表示該 NUMA 節點快取中快取的物件的總數,
在介紹完 struct kmem_cache_node 結構之后,我們終于看到了 slab cache 的架構全貌,如下圖所示:
上圖中展示的 slab cache 本地 cpu 快取 kmem_cache_cpu 中的 partial 串列以及 NUMA 節點快取 kmem_cache_node 結構中的 partial 串列并不是無限制增長的,它們的容量收到下面兩個引數的限制:
/*
* Slab cache management.
*/
struct kmem_cache {
// slab cache 在 numa node 中快取的 slab 個數上限,slab 個數超過該值,空閑的 empty slab 則會被回收至伙伴系統
unsigned long min_partial;
#ifdef CONFIG_SLUB_CPU_PARTIAL
// 限定 slab cache 在每個 cpu 本地快取 partial 鏈表中所有 slab 中空閑物件的總數
// cpu 本地快取 partial 鏈表中空閑物件的數量超過該值,則會將 cpu 本地快取 partial 鏈表中的所有 slab 轉移到 numa node 快取中,
unsigned int cpu_partial;
#endif
};
-
min_partial 主要控制 NUMA 節點快取 partial 串列 slab 個數,如果超過該值,那么串列中空閑的 empty slab 就會被釋放回伙伴系統中,
-
cpu_partial 主要控制 slab cache 本地 cpu 快取 kmem_cache_cpu 結構 partial 鏈表中快取的空閑物件總數,如果超過該值,那么 kmem_cache_cpu->partial 串列中快取的 slab 將會被全部轉移至 kmem_cache_node->partial 串列中,
現在 slab cache 的整個架構全貌已經展現在了我們面前,下面我們基于 slab cache 的整個架構,來看一下它是如何分配和釋放記憶體的,
7. slab 記憶體分配原理
同伙伴系統的記憶體分配原理一樣,slab cache 在分配記憶體塊的時候同樣也分為快速路徑 fastpath 和慢速路徑 slowpath,而且 slab cache 的組織架構比較復雜,所以在分配記憶體塊的時候又會分為很多場景,在本小節中,筆者會為大家一一列舉這些場景,并用圖解的方式為大家闡述 slab cache 記憶體分配在不同場景下的邏輯,
7.1 從本地 cpu 快取中直接分配
我們假設現在 slab cache 中的容量情況如上如圖所示,slab cache 的本地 cpu 快取中有一個 slab,slab 中有很多的空閑物件,kmem_cache_cpu->page 指向快取的 slab,kmem_cache_cpu->freelist 指向快取的 slab 中第一個空閑物件,
當內核向該 slab cache 申請物件的時候,首先會進入快速分配路徑 fastpath,通過 kmem_cache_cpu->freelist 直接查看本地 cpu 快取 kmem_cache_cpu->page 中是否有空閑物件可供分配,
如果有,則將 kmem_cache_cpu->freelist 指向的第一個空閑物件拿出來分配,隨后調整 kmem_cache_cpu->freelist 指向下一個空閑物件,
7.2 從本地 cpu 快取 partial 串列中分配
當 slab cache 本地 cpu 快取的 slab (kmem_cache_cpu->page) 中沒有任何空閑的物件時(全部被分配出去了),那么 slab cache 的記憶體分配就會進入慢速路徑 slowpath,
內核會到本地 cpu 快取的 partial 串列中去查看是否有一個 slab 可以分配物件,這里內核會從 partial 串列中的頭結點開始遍歷直到找到一個可以滿足分配的 slab 出來,
隨后內核會將該 slab 從 partial 串列中摘下,直接提升為新的本地 cpu 快取,
這樣一來 slab cache 的本地 cpu 快取就被更新了,內核通過 kmem_cache_cpu->freelist 指標將快取 slab 中的第一個空閑物件分配出去,隨后更新 kmem_cache_cpu->freelist 指向 slab 中的下一個空閑物件,
7.3 從 NUMA 節點快取中分配
隨著時間的推移, slab cache 本地 cpu 快取的 slab 中的物件被一個一個的分配出去,變成了一個 full slab,于此同時本地 cpu 快取 partial 鏈表中的 slab 也被全部摘除完畢,此時是一個空的鏈表,
那么在這種情況下,slab cache 如何分配記憶體呢?根據前邊 《6.2 slab 的組織架構》小節介紹的內容,此時 slab cache 就該從倉庫中拿 slab 了,這個倉庫就是上圖中的 kmem_cache_node 結構中的 partial 鏈表,
內核會從 kmem_cache_node->partial 鏈表的頭結點開始遍歷,將遍歷到的第一個 slab 從鏈表中摘下,直接提升為新的本地 cpu 快取 kmem_cache_cpu->page, kmem_cache_cpu->freelist 指標重新指向該 slab 中第一個空閑獨享,
隨后內核會接著遍歷 kmem_cache_node->partial 鏈表,將鏈表中的 slab 挨個摘下填充到本地 cpu 快取 partial 鏈表中,最多只能填充 cpu_partial / 2
個 slab,這里的 cpu_partial
就是前邊介紹的 struct kmem_cache 結構中的屬性,
struct kmem_cache {
// 限定 slab cache 在每個 cpu 本地快取 partial 鏈表中快取的所有 slab 中空閑物件的總數
// cpu 本地快取 partial 鏈表中空閑物件的數量超過該值,則會將 cpu 本地快取 partial 鏈表中的所有 slab 轉移到 numa node 快取中,
unsigned int cpu_partial;
}
這樣一來,slab cache 就從倉庫 kmem_cache_node->partial 鏈表中重新填充了本地 cpu 快取 kmem_cache_cpu->page 以及 kmme_cache_cpu->partial 鏈表,
隨后內核直接從本地 cpu 快取中,通過 kmem_cache_cpu->freelist 指標將快取 slab 中的第一個空閑物件分配出去,隨后更新 kmem_cache_cpu->freelist 指向 slab 中的下一個空閑物件,
7.4 從伙伴系統中重新申請 slab
當 slab cache 的本地 cpu 快取 kmem_cache_cpu->page 是空的,kmem_cache_cpu->partial 鏈表中也是空,NUMA 節點快取 kmem_cache_node->partial 鏈表中也是空的時候,比如,slab cache 在剛剛被創建出來時,就是上圖中的架構,完全是一個空的 slab cache,
這時,內核就需要到伙伴系統中重新申請一個 slab 出來,具體向伙伴系統申請多少記憶體頁是由 struct kmem_cache 結構中的 oo
來決定的,它的高 16 位表示一個 slab 所需要的記憶體頁個數,低 16 位表示 slab 中所包含的物件總數,
struct kmem_cache {
// 表示 cache 中的 slab 大小,包括 slab 所申請的頁面個數,以及所包含的物件個數
// 其中低 16 位表示一個 slab 中所包含的物件總數,高 16 位表示一個 slab 所占有的記憶體頁個數,
struct kmem_cache_order_objects oo;
// 當按照 oo 的尺寸為 slab 申請記憶體時,如果記憶體緊張,會采用 min 的尺寸為 slab 申請記憶體,可以容納一個物件即可,
struct kmem_cache_order_objects min;
}
當系統中空閑記憶體不足時,無法獲得 oo
指定的記憶體頁個數,那么內核會降級采用 min
指定的記憶體頁個數,重新到伙伴系統中去申請,這些內容筆者已經在本文 《6.1 slab 的基礎資訊管理》小節中詳細介紹過了,忘記的讀者朋友可以在回顧一下,
當內核從伙伴系統中申請出指定的記憶體頁個數之后,就會根據筆者在 《5. 從一個簡單的記憶體頁開始聊 Slab》 小節中介紹的內容,初始化 slab ,最后將初始化好的 slab 直接提升為本地 cpu 快取 kmem_cache_cpu->page ,
現在 slab cache 的本地 cpu 快取被重新填充了,內核直接從本地 cpu 快取中,通過 kmem_cache_cpu->freelist 指標將快取 slab 中的第一個空閑物件分配出去,隨后更新 kmem_cache_cpu->freelist 指向 slab 中的下一個空閑物件,
8. slab 記憶體釋放原理
slab cache 的記憶體釋放正好和記憶體分配的程序相反,但記憶體釋放的程序會比記憶體分配的程序復雜一些,記憶體釋放同樣也包含快速路徑 fastpath 和慢速路徑 slowpath,也會分為很多場景,在本小節中,筆者繼續用圖解的方式為大家闡述 slab cache 在不同場景下的記憶體釋放邏輯,
8.1 釋放物件所屬 slab 在 cpu 本地快取中
如果將要釋放回 slab cache 的物件所在的 slab 剛好是本地 cpu 快取中快取的 slab,那么內核直接會把物件釋放回快取的 slab 中,這個就是 slab cache 的快速記憶體釋放路徑 fastpath,
隨后修正 kmem_cache_cpu->freelist 指標使其指向剛剛被釋放的物件,釋放物件的 freepointer 指標指向原來 kmem_cache_cpu->freelist 指向的物件,
8.2 釋放物件所屬 slab 在 cpu 本地快取 partial 串列中
當釋放的物件所屬的 slab 在 cpu 本地快取 kmem_cache_cpu->partial 鏈表中時,內核也是直接將物件釋放回 slab 中,然后修改 slab (struct page)中的 freelist 指標指向剛剛被釋放的物件,釋放物件的 freepointer 指向其下一個空閑物件,
8.3 釋放物件所屬 slab 從 full slab 變為了 partial slab
本小節中介紹的釋放場景是,當前釋放物件所在的 slab 原來是一個 full slab,由于物件的釋放剛好變成了一個 partial slab,并且該 slab 原來并不在 slab cache 的本地 cpu 快取中,
這種情況下,當物件釋放回 slab 之后,內核為了利用區域性的優勢需要把該 slab 在插入到 slab cache 的本地 cpu 快取 kmem_cache_cpu->partial 鏈表中,
因為 slab 之前之所以是一個 full slab,恰恰證明了該 slab 是一個非常活躍的 slab,常常供不應求導致變成了一個 full slab,當物件釋放之后,剛好變成 partial slab,這時需要將這個被頻繁訪問的 slab 放入 cpu 快取中,加快下次分配物件的速度,
以上內容只是 slab 被釋放回 kmem_cache_cpu->partial 鏈表的正常流程,但是通過本文 《6.2 slab 的組織架構》小節最后的內容介紹我們知道,slab cache 的本地 cpu 快取 kmem_cache_cpu->partial 鏈表中的容量不可能是無限制增長的,它受到 kmem_cache 結構中 cpu_partial
屬性的限制:
struct kmem_cache {
// 限定 slab cache 在每個 cpu 本地快取 partial 鏈表中所有 slab 中空閑物件的總數
// cpu 本地快取 partial 鏈表中空閑物件的數量超過該值,則會將 cpu 本地快取 partial 鏈表中的所有 slab 轉移到 numa node 快取中,
unsigned int cpu_partial;
};
當每次向 kmem_cache_cpu->partial 鏈表中填充 slab 的時候,內核都需要首先檢查當前 kmem_cache_cpu->partial 鏈表中所有 slabs 所包含的空閑物件總數是否超過了 cpu_partial
的限制,
如果沒有超過限制,則將 slab 插入到 kmem_cache_cpu->partial 鏈表的頭部,如果超過了限制,則需要首先將當前 kmem_cache_cpu->partial 鏈表中的所有 slab 轉移至對應的 NUMA 節點快取 kmem_cache_node->partial 鏈表的尾部,然后才能將釋放物件所在的 slab 插入到 kmem_cache_cpu->partial 鏈表中,
大家讀到這里,我想一定會有這樣的一個疑問,就是內核這里為什么要把 kmem_cache_cpu->partial 鏈表中的 slab 一次性全部移動到 kmem_cache_node->partial 鏈表中呢?
這樣一來如果在 slab cache 的本地 cpu 快取不夠的情況下,不是還要在大老遠從 kmem_cache_node->partial 鏈表中再次轉移 slab 填充 kmem_cache_cpu 嗎?這樣一來路徑就拉長了,內核為啥要這樣設計呢?
其實我們做任何設計都是要考慮當前場景的,當 slab cache 演進到如上圖所示的架構時,說明內核當前所處的場景是一個記憶體釋放頻繁的場景,由于記憶體頻繁的釋放,所以導致 kmem_cache_cpu->partial 鏈表中的空閑物件都快被填滿了,已經超過了 cpu_partial
的限制,
所以在記憶體頻繁釋放的場景下,kmem_cache_cpu->partial 鏈表太滿了,而記憶體分配的請求又不是很多,kmem_cache_cpu 中快取的 slab 并不會頻繁的消耗,這樣一來,就需要將鏈表中的所有 slab 一次性轉移到 NUMA 節點快取 partial 鏈表中備用,否則的話,就得頻繁的轉移 slab,這樣性能消耗更大,
但是當前釋放物件所在的 slab 仍然會被添加到 kmem_cache_cpu->partial 表中,用以應對不那么頻繁的記憶體分配需求,
8.4 釋放物件所屬 slab 從 partial slab 變為了 empty slab
如果釋放物件所屬的 slab 原來是一個 partial slab,在物件釋放之后變成了一個 empty slab,在這種情況下,內核將會把該 slab 插入到 slab cache 的備用倉庫 NUMA 節點快取中,
因為 slab 之所以會變成 empty slab,表明該 slab 并不是一個活躍的 slab,內核已經好久沒有從該 slab 中分配物件了,所以只能把它釋放回 kmem_cache_node->partial 鏈表中作為本地 cpu 快取的后備選項,
但是 kmem_cache_node->partial 鏈表中的 slab 不可能是無限增長的,鏈表中快取的 slab 個數受到 kmem_cache 結構中 min_partial
屬性的限制:
struct kmem_cache {
// slab cache 在 numa node 中快取的 slab 個數上限,slab 個數超過該值,空閑的 empty slab 則會被回收至伙伴系統
unsigned long min_partial;
}
所以內核在將 slab 插入到 kmem_cache_node->partial 鏈表之前,需要檢查當前 kmem_cache_node->partial 鏈表中快取的 slab 個數 nr_partial
是否已經超過了 min_partial
的限制,
struct kmem_cache_node {
// 該 node 節點中快取的 slab 個數
unsigned long nr_partial;
}
如果超過了限制,則直接將 slab 釋放回伙伴系統中,如果沒有超過限制,才會將 slab 插入到 kmem_cache_node->partial 鏈表中,
還有一種直接釋放回 kmem_cache_node->partial 鏈表的情形是,釋放物件所屬的 slab 本來就在 kmem_cache_node->partial 鏈表中,這種情況下就是直接釋放物件回 slab 中,無需改變 slab 的位置,
總結
本文在伙伴系統的基礎上又為大家詳細介紹了一款內核專門應對小記憶體塊管理的 slab 記憶體池,并列舉了 slab 記憶體池在內核中的幾種應用場景,
然后我們從一個簡單的記憶體頁開始聊起,首先詳細介紹了在 slab 記憶體池中所管理的記憶體塊在記憶體中的布局:
在此基礎上,筆者帶大家繼續采用一步一圖的方式,一步一步地推演出 slab cache 的整體架構:
在我們得到了 slab cache 的整體架構之后,后續筆者基于此架構圖,又為大家詳細介紹了 slab cache 的運行原理,其中包括內核在多種不同場景下針對記憶體塊的分配和回收邏輯,
在介紹 slab cache 針對小記憶體塊分配原理的章節,我們列舉了如下四種場景:
- 從本地 cpu 快取中直接分配
- 從本地 cpu 快取 partial 串列中分配
- 從 NUMA 節點快取中分配
- 從伙伴系統中重新申請 slab
slab cache 針對小記憶體塊回收,又分為如下四種場景:
- 釋放物件所屬 slab 在 cpu 本地快取中
- 釋放物件所屬 slab 在 cpu 本地快取 partial 串列中
- 釋放物件所屬 slab 從 full slab 變為了 partial slab
- 釋放物件所屬 slab 從 partial slab 變為了 empty slab
好了,本文的內容就到這里了,slab cache 的機制確實比較復雜,涉及到的場景又很多,后續的文章筆者會帶大家到內核原始碼中去一一驗證本文內容的正確性,我們下篇文章見~~~
轉載請註明出處,本文鏈接:https://www.uj5u.com/caozuo/549202.html
標籤:其他
下一篇:你是不是暴露了?