細節拉滿,80 張圖帶你一步一步推演 slab 記憶體池的設計與實現

bin的技術小屋發表於2023-04-05

1. 前文回顧

在之前的幾篇記憶體管理系列文章中,筆者帶大家從宏觀角度完整地梳理了一遍 Linux 記憶體分配的整個鏈路,本文的主題依然是記憶體分配,這一次我們會從微觀的角度來探秘一下 Linux 核心中用於零散小記憶體塊分配的記憶體池 —— slab 分配器。

在本小節中,筆者還是按照以往的風格先帶大家簡單回顧下之前宏觀視角下 Linux 記憶體分配最為核心的內容,目的是讓大家從宏觀視角平滑地過度到微觀視角,內容上有個銜接,不至於讓大家感到突兀。

下面的內容我們只做簡單回顧,大家不必糾纏細節,把握整體宏觀流程

《深入理解 Linux 實體記憶體分配與釋放全鏈路實現》一文中,筆者以核心實體記憶體分配與釋放的 API 為起點,詳細為大家介紹了實體記憶體分配與釋放的整個完整流程,以及相關核心原始碼的實現。

image

image

其中實體記憶體分配在核心中的全鏈路流程如下圖所示:

image

在 Linux 核心中,真正負責實體記憶體分配的核心是夥伴系統,在我們從總體上熟悉了實體記憶體分配的全鏈路流程之後,隨後我們繼續來到了夥伴系統的入口 get_page_from_freelist 函式,它的完整流程如下:

image

核心透過 get_page_from_freelist 函式,挨個遍歷檢查各個 NUMA 節點中的實體記憶體區域是否有足夠的空閒記憶體可以滿足本次的記憶體分配要求,當找到符合記憶體分配標準的實體記憶體區域 zone 之後,接下來就會透過 rmqueue 函式進入到該實體記憶體區域 zone 對應的夥伴系統中分配實體記憶體。

image

那麼核心既然已經有了夥伴系統,那麼為什麼還需要一個 slab 記憶體池呢 ?下面就讓我們從這個疑問開始,正式拉開本文的帷幕~~~

image

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

image

夥伴系統所分配的實體記憶體頁全部都是物理上連續的,並且只能分配 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 尺寸的記憶體塊分配給程式使用。

image

我們假設當前夥伴系統中只有 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 物件池中,除了可以避免核心物件頻繁反覆初始化和相關記憶體分配,頻繁反覆銷燬物件和相關記憶體釋放的效能開銷之外,其實還有很多好處,比如:

  1. 利用 CPU 快取記憶體提高訪問速度。當一個物件被直接釋放回 slab 物件池中的時候,這個核心物件還是“熱的”,仍然會駐留在 CPU 快取記憶體中。如果這時,核心繼續向 slab 物件池申請物件,slab 物件池會優先把這個剛剛釋放 “熱的” 物件分配給核心使用,因為物件很大機率仍然駐留在 CPU 快取記憶體中,所以核心訪問起來速度會更快。

  2. 夥伴系統只能分配 2 的次冪個完整的實體記憶體頁,這會引起佔用快取記憶體以及 TLB 的空間較大,導致一些不重要的資料駐留在 CPU 快取記憶體中佔用寶貴的快取空間,而重要的資料卻被置換到記憶體中。 slab 物件池針對小記憶體分配場景,可以有效的避免這一點。

  3. 呼叫夥伴系統的操作會對 CPU 快取記憶體 L1Cache 中的 Instruction Cache(指令快取記憶體)和 Data Cache (資料快取記憶體)有汙染,因為對夥伴系統的長鏈路呼叫,相關的一些指令和資料必然會填充到 Instruction Cache 和 Data Cache 中,從而將頻繁使用的一些指令和資料擠壓出去,造成快取汙染。而在核心空間中越浪費這些快取資源,那麼在使用者空間中的程式就會越少的得到這些快取資源,造成效能的下降。 slab 物件池極大的減少了對夥伴系統的呼叫,防止了不必要的 L1Cache 汙染。

image

  1. 使用 slab 物件池可以充分利用 CPU 快取記憶體,避免多個物件對同一 cache line 的爭用。如果物件直接儲存排列在夥伴系統提供的記憶體頁中的話(不受 slab 管理),那麼位於不同記憶體頁中具有相同偏移的物件很可能會被放入同一個 cache line 中,即使其他 cache line 還是空的。具體為什麼會造成具有相同記憶體偏移地址的物件會對同一 cache line 進行爭搶,筆者會在文章後面相關章節中為大家解答,這裡我們只是簡單列出 slab 針對小記憶體分配的一些優勢,目的是讓大家先從總體上把握。

3. slab 物件池在核心中的應用場景

現在我們最起碼從概念上清楚了 slab 物件池的產生背景,以及它要解決的問題場景。下面筆者列舉了幾個 slab 物件池在核心中的使用場景,方便大家進一步從總體上理解。

本小節我們依然還是從總體上把握 slab 物件池,大家不必過度地陷入到細節當中。

  1. 當我們使用 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);
          ...........   
}
  1. 為程式建立虛擬記憶體空間的時候,核心需要使用 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();
          ..........
}
  1. 當我們向頁快取記憶體 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;
}
  1. 當我們使用 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());
                 ..........
}
  1. 當服務端網路應用程式使用 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 相關的物件:

image

《從 Linux 核心角度探秘 JDK NIO 檔案讀寫本質》 一文中介紹的頁快取記憶體 page cache 相關的物件:

image

《深入理解 Linux 虛擬記憶體管理》 一文中介紹的虛擬記憶體地址空間相關的物件:

image

現在我們只是對 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 物件池,以便提升核心物件的分配,訪問,釋放相關操作的效能。

image

如上圖所示,slab 物件池在記憶體管理系統中的架構層次是基於夥伴系統之上構建的,slab 物件池會一次性向夥伴系統申請一個或者多個完整的實體記憶體頁,在這些完整的記憶體頁內在逐步劃分出一小塊一小塊的記憶體塊出來,而這些小記憶體塊的尺寸就是 slab 物件池所管理的核心核心物件佔用的記憶體大小。

下面筆者就帶大家從一個最簡單的實體記憶體頁 page 開始,我們一步一步的推演 slab 的整個架構設計與實現。

如果讓我們自己設計一個物件池,首先最直觀最簡單的辦法就是先向夥伴系統申請一個記憶體頁,然後按照需要被池化物件的尺寸 object size,把記憶體頁劃分為一個一個的記憶體塊,每個記憶體塊尺寸就是 object size。

事實上,slab 物件池可以根據情況向夥伴系統一次性申請多個記憶體頁,這裡只是為了方便大家理解,我們先以一個記憶體頁為例,為大家說明 slab 中物件的記憶體佈局。

image

但是在一個工業級的物件池設計中,我們不能這麼簡單粗暴的搞,因為物件的 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 個位元組。

image

從上面過程我們可以看出,CPU 訪問一段未進行 word size 對齊的記憶體,需要兩次訪存操作。

記憶體對齊的好處還有很多,比如,CPU 訪問對齊的記憶體都是原子性的,對齊記憶體中的資料會獨佔 cache line ,不會與其他資料共享 cache line,避免 false sharing。

這裡大家只需要簡單瞭解為什麼要進行記憶體對齊即可,關於記憶體對齊的詳細內容,感興趣的讀者可以回看下 《記憶體對齊的原理及其應用》 一文中的 “ 5. 記憶體對齊 ” 小節。

基於以上原因,我們不能簡單的按照物件尺寸 object size 來劃分記憶體塊,而是需要考慮到物件記憶體地址要按照 word size 進行對齊。於是上面的 slab 物件池的記憶體佈局又有了新的變化。

image

如果被池化物件的尺寸 object size 本來就是和 word size 對齊的,那麼我們不需要做任何事情,但是如果 object size 沒有和 word size 對齊,我們就需要填充一些位元組,目的是要讓物件的 object size 按照 word size 進行對齊,提高 CPU 訪問物件的速度。

但是上面的這些工作對於一個工業級的物件池來說還遠遠不夠,工業級的物件池需要應對很多複雜的詭異場景,比如,我們偶爾在複雜生產環境中會遇到的記憶體讀寫訪問越界的情況,這會導致很多莫名其妙的異常。

核心為了應對記憶體讀寫越界的場景,於是在物件記憶體的周圍插入了一段不可訪問的記憶體區域,這些記憶體區域用特定的位元組 0xbb 填充,當程式訪問的到記憶體是 0xbb 時,表示已經越界訪問了。這段記憶體區域在 slab 中的術語為 red zone,大家可以理解為紅色警戒區域。

插入 red zone 之後,slab 物件池的記憶體佈局近一步演進為下圖所示的佈局:

image

  • 如果物件尺寸 object size 本身就是 word size 對齊的,那麼就需要在物件左右兩側填充兩段 red zone 區域,red zone 區域的長度一般就是 word size 大小。

  • 如果物件尺寸 object size 是透過填充 padding 之後,才與 word size 對齊。核心會巧妙的利用物件右邊的這段 padding 填充區域作為 red zone。只需要額外的在物件記憶體區域的左側填充一段 red zone 即可。

image

在有了新的記憶體佈局之後,我們接下來就要考慮一個問題,當我們向 slab 物件池獲取到一個空閒物件之後,我們需要知道它的下一個空閒物件在哪裡,這樣方便我們下次獲取物件。那麼我們該如何將記憶體頁 page 中的這些空閒物件串聯起來呢?

有讀者朋友可能會說了,這很簡單啊,用一個連結串列把這些空閒物件串聯起來不就行了嘛,其實核心也是這樣想的,哈哈。不過核心巧妙的地方在於不需要為串聯物件所用到的 next 指標額外的分配記憶體空間。

因為物件在 slab 中沒有被分配出去使用的時候,其實物件所佔的記憶體中存放什麼,使用者根本不會關心的。既然這樣,核心乾脆就把指向下一個空閒物件的 freepointer 指標直接存放在物件所佔記憶體(object size)中,這樣避免了為 freepointer 指標單獨再分配記憶體空間。巧妙的利用了物件所在的記憶體空間(object size)。

image

我們接著對 slab 記憶體佈局進行演化,有時候我們期望知道 slab 物件池中各個物件的狀態,比如是否處於空閒狀態。那麼物件的狀態我們在哪裡儲存呢?

答案還是和 freepointer 的處理方式一樣,巧妙的利用物件所在的記憶體空間(object size)。核心會在物件所佔的記憶體空間中填充一些特殊的字元用來表示物件的不同狀態。因為反正物件沒有被分配出去使用,記憶體裡存的是什麼都無所謂。

當 slab 剛剛從夥伴系統中申請出來,並初始化劃分實體記憶體頁中的物件記憶體空間時,核心會將物件的 object size 記憶體區域用特殊位元組 0x6b 填充,並用 0xa5 填充物件 object size 記憶體區域的最後一個位元組表示填充完畢。

或者當物件被釋放回 slab 物件池中的時候,也會用這些位元組填充物件的記憶體區域。

image

這種透過在物件記憶體區域填充特定位元組表示物件的特殊狀態的行為,在 slab 中有一個專門的術語叫做 SLAB_POISON (SLAB 中毒)。POISON 這個術語起的真的是隻可意會不可言傳,其實就是表示 slab 物件的一種狀態。

是否毒化 slab 物件是可以設定的,當 slab 物件被 POISON 之後,那麼會有一個問題,就是我們前邊介紹的存放在物件記憶體區域 object size 裡的 freepointer 就被會特殊位元組 0x6b 覆蓋掉。這種情況下,核心就只能為 freepointer 在額外分配一個 word size 大小的記憶體空間了。

image

slab 物件的記憶體佈局資訊除了以上內容之外,有時候我們還需要去跟蹤一下物件的分配和釋放相關資訊,而這些資訊也需要在 slab 物件中儲存,核心中使用一個 struct track 結構體來儲存跟蹤資訊。

這樣一來,slab 物件的記憶體區域中就需要在開闢出兩個 sizeof(struct track) 大小的區域出來,用來分別儲存 slab 物件的分配和釋放資訊。

image

上圖展示的就是 slab 物件在記憶體中的完整佈局,其中 object size 為物件真正所需要的記憶體區域大小,而物件在 slab 中真實的記憶體佔用大小 size 除了 object size 之外,還包括填充的 red zone 區域,以及用於跟蹤物件分配和釋放資訊的 track 結構,另外,如果 slab 設定了 red zone,核心會在物件末尾增加一段 word size 大小的填充 padding 區域。

當 slab 向夥伴系統申請若干記憶體頁之後,核心會按照這個 size 將記憶體頁劃分成一個一個的記憶體塊,記憶體塊大小為 size 。

image

其實 slab 的本質就是一個或者多個實體記憶體頁 page,核心會根據上圖展示的 slab 物件的記憶體佈局,計算出物件的真實記憶體佔用 size。最後根據這個 size 在 slab 背後依賴的這一個或者多個實體記憶體頁 page 中劃分出多個大小相同的記憶體塊出來。

所以在核心中,都是用 struct page 結構來表示 slab,如果 slab 背後依賴的是多個實體記憶體頁,那就使用在 《深度剖析 Linux 夥伴系統的設計與實現》 一文中 " 5.3.2 設定複合頁 compound_page " 小節提到的複合頁 compound_page 來表示。

image

      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 的總體架構設計

image

在上一小節的內容中,筆者帶大家從 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 出來,供後續的記憶體分配使用。

image

當核心將物件釋放回其所屬的 slab 之後,如果 slab 中的物件全部歸位,slab cache 就會將其視為一個 empty slab,表示 slab 此刻變為了一個完全空閒的 slab。如果超過了 slab cache 中規定的 empty slab 的閾值,slab cache 就會將這些空閒的 empty slab 重新釋放回夥伴系統中。

image

如果一個 slab 中的物件部分被分配出去使用,部分卻未被分配仍然在 slab 中快取,那麼核心就會將該 slab 視為一個 partial slab。

image

這些不同狀態的 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 位元組) 進行對齊。

image

  • 當 flags 設定了 SLAB_POISON 時,表示需要在 slab 物件記憶體中填充特殊位元組 0x6b 和 0xa5,表示物件的特定狀態。

image

  • 當 flags 設定了 SLAB_RED_ZONE 時,表示需要在 slab 物件記憶體周圍插入 red zone,防止記憶體的讀寫越界。

  • 當 flags 設定了 SLAB_CACHE_DMA 或者 SLAB_CACHE_DMA32 時,表示指定 slab 中的記憶體來自於哪個記憶體區域,DMA or DMA32 區域 ?如果沒有特殊指定,slab 中的記憶體一般來自於 NORMAL 直接對映區域。

image

  • 當 flags 設定了 SLAB_STORE_USER 時,表示需要追蹤物件的分配和釋放相關資訊,這樣會在 slab 物件記憶體區域中額外增加兩個 sizeof(struct track) 大小的區域出來,用於儲存 slab 物件的分配和釋放資訊。

image

相關 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 區域,等等。

image

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。

image

上小節中,我們也提到過,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 區域的大小。

image

unsigned int align 在建立 slab cache 的時候,我們可以向核心指定 slab 中的物件按照 align 的值進行對齊,核心會綜合 word size , cache line ,align 計算出一個合理的對齊尺寸。

const char *name 表示該 slab cache 的名稱,這裡指定的 name 將會在 cat /proc/slabinfo 命令中顯示,該命令用於檢視系統中所有 slab cache 的資訊。

image

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

image

系統中所有的這些 slab cache 佔用的記憶體總量,我們可以透過 cat /proc/meminfo 命令檢視:

image

除此之外,我們還可以透過 slabtop 命令來動態檢視系統中佔用記憶體最高的 slab cache,當記憶體緊張的時候,如果我們透過 cat /proc/meminfo 命令發現 slab 的記憶體佔用較高的話,那麼可以快速透過 slabtop 迅速定位到究竟是哪一類的 object 分配過多導致記憶體佔用飆升。

image

6.2 slab 的組織架構

在上小節的內容中,筆者主要為大家介紹了 struct kmem_cache 結構中關於 slab 的一些基礎資訊,其中主要包括 slab cache 中所管理的 slabs 相關的容量控制,以及 slab 中物件的記憶體佈局資訊。

image

那麼 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(指令快取記憶體)汙染。

image

下面我們來看一下 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 中所有的空閒物件串聯了起來。

image

事實上,在 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 分配記憶體塊的時候主要有兩個作用:

  1. 核心會將 slab cache 每一次分配記憶體塊或者釋放記憶體塊的過程視為一個事物,所以在每次向 slab cache 申請記憶體塊或者將記憶體塊釋放回 slab cache 之後,核心都會改變這裡的 tid。

  2. 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 本地快取中獲取了。

image

如果開啟了 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 的架構圖:

image

但這還不是 slab cache 的最終架構,到目前為止我們的 slab cache 架構只演進到了一半,下面請大家繼續跟隨筆者的思路我們接著進行 slab cache 架構的演進。

我們先把 slab cache 比作一個大型超市,超市裡擺放了一排一排的商品貨架,毫無疑問,顧客進入超市直接從貨架上選取自己想要的商品速度是最快的。

上圖中的 kmem_cache 結構就好比是超市,slab cache 的本地 cpu 快取結構 kmem_cache_cpu 就好比超市的營業廳,營業廳內擺滿了一排一排的貨架,這些貨架就是上圖中的 slab,貨架上的商品就是 slab 中劃分出來的一個一個的記憶體塊。

image

毫無疑問,顧客來到超市,直接去營業廳的貨架上拿取商品是最快的,那麼如果貨架上的商品賣完了,該怎麼辦呢?

這時,超市的經理就會到超市的倉庫中重新拿取商品填充貨架,那麼 slab cache 的倉庫到底在哪裡呢?

答案就在筆者之前文章 《深入理解 Linux 實體記憶體管理》 中的 “ 3.2 非一致性記憶體訪問 NUMA 架構 ” 小節中介紹的記憶體架構,在 NUMA 架構下,記憶體被劃分成了一個一個的 NUMA 節點,每個 NUMA 節點內包含若干個 cpu。

image

每個 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 的架構全貌,如下圖所示:

image

上圖中展示的 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 快取中直接分配

image

我們假設現在 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 指向下一個空閒物件。

image

7.2 從本地 cpu 快取 partial 列表中分配

image

當 slab cache 本地 cpu 快取的 slab (kmem_cache_cpu->page) 中沒有任何空閒的物件時(全部被分配出去了),那麼 slab cache 的記憶體分配就會進入慢速路徑 slowpath。

核心會到本地 cpu 快取的 partial 列表中去檢視是否有一個 slab 可以分配物件。這裡核心會從 partial 列表中的頭結點開始遍歷直到找到一個可以滿足分配的 slab 出來。

隨後核心會將該 slab 從 partial 列表中摘下,直接提升為新的本地 cpu 快取。

image

這樣一來 slab cache 的本地 cpu 快取就被更新了,核心透過 kmem_cache_cpu->freelist 指標將快取 slab 中的第一個空閒物件分配出去,隨後更新 kmem_cache_cpu->freelist 指向 slab 中的下一個空閒物件。

image

7.3 從 NUMA 節點快取中分配

image

隨著時間的推移, 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 中第一個空閒獨享。

image

隨後核心會接著遍歷 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;
}

image

這樣一來,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 中的下一個空閒物件。

image

7.4 從夥伴系統中重新申請 slab

image

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

image

現在 slab cache 的本地 cpu 快取被重新填充了,核心直接從本地 cpu 快取中,透過 kmem_cache_cpu->freelist 指標將快取 slab 中的第一個空閒物件分配出去,隨後更新 kmem_cache_cpu->freelist 指向 slab 中的下一個空閒物件。

image

8. slab 記憶體釋放原理

slab cache 的記憶體釋放正好和記憶體分配的過程相反,但記憶體釋放的過程會比記憶體分配的過程複雜一些,記憶體釋放同樣也包含快速路徑 fastpath 和慢速路徑 slowpath,也會分為很多場景,在本小節中,筆者繼續用圖解的方式為大家闡述 slab cache 在不同場景下的記憶體釋放邏輯。

8.1 釋放物件所屬 slab 在 cpu 本地快取中

image

如果將要釋放回 slab cache 的物件所在的 slab 剛好是本地 cpu 快取中快取的 slab,那麼核心直接會把物件釋放回快取的 slab 中,這個就是 slab cache 的快速記憶體釋放路徑 fastpath。

隨後修正 kmem_cache_cpu->freelist 指標使其指向剛剛被釋放的物件,釋放物件的 freepointer 指標指向原來 kmem_cache_cpu->freelist 指向的物件。

image

8.2 釋放物件所屬 slab 在 cpu 本地快取 partial 列表中

image

當釋放的物件所屬的 slab 在 cpu 本地快取 kmem_cache_cpu->partial 連結串列中時,核心也是直接將物件釋放回 slab 中,然後修改 slab (struct page)中的 freelist 指標指向剛剛被釋放的物件。釋放物件的 freepointer 指向其下一個空閒物件。

image

8.3 釋放物件所屬 slab 從 full slab 變為了 partial slab

本小節中介紹的釋放場景是,當前釋放物件所在的 slab 原來是一個 full slab,由於物件的釋放剛好變成了一個 partial slab,並且該 slab 原來並不在 slab cache 的本地 cpu 快取中。

image

這種情況下,當物件釋放回 slab 之後,核心為了利用區域性性的優勢需要把該 slab 在插入到 slab cache 的本地 cpu 快取 kmem_cache_cpu->partial 連結串列中。

因為 slab 之前之所以是一個 full slab,恰恰證明了該 slab 是一個非常活躍的 slab,常常供不應求導致變成了一個 full slab,當物件釋放之後,剛好變成 partial slab,這時需要將這個被頻繁訪問的 slab 放入 cpu 快取中,加快下次分配物件的速度。

image

以上內容只是 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;
};

image

當每次向 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 連結串列中。

image

大家讀到這裡,我想一定會有這樣的一個疑問,就是核心這裡為什麼要把 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 快取的後備選項。

image

但是 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 連結串列中。

image

還有一種直接釋放回 kmem_cache_node->partial 連結串列的情形是,釋放物件所屬的 slab 本來就在 kmem_cache_node->partial 連結串列中,這種情況下就是直接釋放物件回 slab 中,無需改變 slab 的位置。

image

總結

image

本文在夥伴系統的基礎上又為大家詳細介紹了一款核心專門應對小記憶體塊管理的 slab 記憶體池,並列舉了 slab 記憶體池在核心中的幾種應用場景。

然後我們從一個簡單的記憶體頁開始聊起,首先詳細介紹了在 slab 記憶體池中所管理的記憶體塊在記憶體中的佈局:

image

在此基礎上,筆者帶大家繼續採用一步一圖的方式,一步一步地推演出 slab cache 的整體架構:

image

在我們得到了 slab cache 的整體架構之後,後續筆者基於此架構圖,又為大家詳細介紹了 slab cache 的執行原理,其中包括核心在多種不同場景下針對記憶體塊的分配和回收邏輯。

在介紹 slab cache 針對小記憶體塊分配原理的章節,我們列舉了如下四種場景:

  1. 從本地 cpu 快取中直接分配

image

  1. 從本地 cpu 快取 partial 列表中分配

image

  1. 從 NUMA 節點快取中分配

image

  1. 從夥伴系統中重新申請 slab

image

slab cache 針對小記憶體塊回收,又分為如下四種場景:

  1. 釋放物件所屬 slab 在 cpu 本地快取中

image

  1. 釋放物件所屬 slab 在 cpu 本地快取 partial 列表中

image

  1. 釋放物件所屬 slab 從 full slab 變為了 partial slab

image

  1. 釋放物件所屬 slab 從 partial slab 變為了 empty slab

image

好了,本文的內容就到這裡了,slab cache 的機制確實比較複雜,涉及到的場景又很多,後續的文章筆者會帶大家到核心原始碼中去一一驗證本文內容的正確性。我們下篇文章見~~~

相關文章