linux slub分配器淺析

FreeeLinux發表於2017-01-29

在《linux記憶體管理淺析》中提到核心管理自己使用的記憶體時,使用了SLAB物件池。SLAB確實是比較複雜,所以一直以來都沒有深入看一看。
不過現在,linux核心中,SLAB已經被它的簡化版--SLUB所代替。最近抽時間看了一下SLUB的程式碼,略記一些自己的理解。
儘管SLUB是在核心裡面實現的,使用者態的物件池其實也可以借鑑這樣的做法。

SLUB的總體思想還是跟SLAB類似,物件池裡面的記憶體都是以“大塊”為單位來進行分配與回收的。然後每個“大塊”又按物件的大小被分割成“小塊”,使用者對於物件的分配與回收都是以“小塊”為單位來進行的。
SLUB的結構如下圖:
3725884266736043485.jpg


另外,kmem_cache還有以下一些引數(後面會解釋到):
int size; /* 每個物件佔用的空間 */
int objsize; /* 物件的大小 */
int offset; /* 物件所佔用的空間中,存放next指標的偏移 */
int refcount; /* 引用計數 */
int inuse; /* 物件除next指標外所佔用的大小 */
int align; /* 對齊位元組數 */
void (*ctor)(void *); /* 建構函式 */
unsigned long min_partial; /* kmem_cache_node中儲存的最小page數 */
struct kmem_cache_order_objects oo; /* 首選的page分配策略(分配多少個連續頁面,能劃分成多少個物件) */
struct kmem_cache_order_objects min; /* 次選的page分配策略 */
(另外還有一些成員,或支援了一些選項、或支援了DEBUG、或是為週期性的記憶體回收服務。這裡就不列舉了。)

大體結構

kmem_cache是物件池管理器,每一個kmem_cache管理一種型別的物件。所有的管理器通過雙向連結串列由表頭slab_caches串連起來。 
這一點跟之前的SLAB是一樣的。 

kmem_cache的記憶體管理工作是通過成員kmem_cache_node來完成的。在NUMA環境下(非均質儲存結構),一個kmem_cache維護了一組kmem_cache_node,分別對應每一個記憶體節點。kmem_cache_node只負責管理對應記憶體節點下的記憶體。(如果不是 NUMA環境,那麼kmem_cache_node只有一個。) 

而實際的記憶體還是靠page結構來管理的,kmem_cache_node通過partial指標串連起一組page(nr_partial代表連結串列長度),它們就代表了物件池裡面的記憶體。page結構不僅代表了記憶體,結構裡面還有一些union變數用來記錄其對應記憶體的物件分配情況(僅當page被加入到SLUB分配器後有效,其他情況下這些變數有另外的解釋)。 
原先的SLAB則要複雜一些,SLAB裡面的page僅僅是管理記憶體,不維護“物件”的概念。而是由額外的SLAB控制結構(slab)來管理物件,並通過slab結構的一些指標陣列來劃定物件的邊界。 

前面說過,物件池裡面的記憶體是以“大塊”為單位來進行分配與回收的,page就是這樣的大塊。page內部被劃分成若干個小塊,每一塊用於容納一個物件。這些物件是以連結串列的形式來儲存的,page->freelist就是連結串列頭,只有未被分配的物件才會放在連結串列中。物件的next指標存放在其偏移量為kmem_cache->offset的位置。(見上面的圖) 
而在SLAB中,“大塊”則是提供控制資訊的slab結構。page結構只表示記憶體,它僅是slab所引用的資源。 

每一個page並不只代表一個頁面,而是2^order個連續的頁面,這裡的order值是由kmem_cache裡面的oo或min來確定的。分配頁面時,首先嚐試使用oo裡面的order值,分配較合適大小的連續頁面(這個值是在kmem_cache建立的時候計算出來的,使用這個值時需要分配一定的連續頁面,以使得記憶體分割成“小塊”後剩餘的邊角廢料較少)。如果分配不成功(執行時間長了,記憶體碎片多了,分配大量連續頁面就不容易了),則使用min裡面的order值,分配滿足物件大小的最少量的連續頁面(這個值也是建立kmem_cache時計算出來的)。 

kmem_cache_node通過partial指標串連的一組page,這些page必須是沒被佔滿的(一個page被劃分成page->objects個物件大小的空間,這些空間中有page->inuse個已經被使用。如果page->objects==page->inuse,則page為full)。如果一個page為full,則它會被從連結串列中移除。而如果page是free的(page->inuse==0),一般情況下它也會被釋放,除非這裡的nr_partial(連結串列長度)小於kmem_cache裡面的min_partial。(既然是池,就應該有一定的存量,min_partial就代表最低存量。這個值也是在建立kmem_cache時計算出來的,物件的size較大時,會得到較大的min_partial值。因為較大的size值在分配page時會需要更多連續頁面,而分配連續頁面不如單個的頁面容易,所以應該多快取一些。) 
而原先的SLAB則有三個連結串列,分別維護“full”、“partial”、“free”的slab。“free”和“partial”在SLUB裡面合而為一,成了前面的partial連結串列。而“full”的page就不維護了。其實也不需要維護,因為page已經full了,不能再滿足物件的分配,只能響應物件的回收。而在物件回收時,通過物件的地址就能得到對應的page結構(page結構的地址是與記憶體地址相對應的,見《 linux記憶體管理淺析》)。維護full的page可以便於檢視分配器的狀態,所以在DEBUG模式下,kmem_cache_node裡面還是會提供一個full連結串列。 

分配與釋放

物件的分配與釋放並不是直接在kmem_cache_node上面操作的,而是在kmem_cache_cpu上。一個kmem_cache維護了一組kmem_cache_cpu,分別對應系統中的每一個CPU。kmem_cache_cpu相當於為每一個CPU提供了一個分配快取,以避免CPU總是去kmem_cache_node上面做操作,而產生競爭。並且kmem_cache_cpu能讓被它快取的物件固定在一個CPU上,從而提高CPU的cache命中率。kmem_cache_cpu只提供了一個page的快取。

原先的SLAB是為每個CPU提供了一個array_cache結構來快取物件。物件在array_cache結構中的組織形式跟它在slab中的組織形式是不一樣的,這也就增加了複雜性。而SLUB則都是通過page結構來組織物件的,組織形式都一樣。 

進行物件分配的時候,首先嚐試在kmem_cache_cpu上去分配。如果分配不成功,再去kmem_cache_node上move一個page到kmem_cache_cpu上面來。分配不成功的原因有兩個:kmem_cache_cpu上的page已經full了、或者現在需要分配的node跟kmem_cache_cpu上快取page對應的node不相同。對於page已full的情況,page被從kmem_cache_cpu上移除掉(或者DEBUG模式下,被移動到對應kmem_cache_node的full連結串列上);而如果是node不匹配的情況,則kmem_cache_cpu上快取page會先被move回到其對應kmem_cache_node的partial連結串列上(再進一步,如果page是free的,且partial連結串列的長度已經不小於min_partial了,則page被釋放)。 
反過來,釋放物件的時候,通過物件的地址能找到它所對應的page的地址,將物件放歸該page即可。但是裡面也有一些特殊邏輯,如果page正被kmem_cache_cpu快取,就沒有什麼需要額外處理的了;否則,在將物件放歸page時,需要對page加鎖(因為其他CPU也可能正在該page上分配或釋放物件)。另外,如果物件在回收之前該page是full的,則物件釋放後該page就成partial的了,它還應該被新增到對應的kmem_cache_node的partial連結串列中。而如果物件回收之後該page成了free的,則它應該被釋放掉。 
物件的釋放還有一個細節,既然物件會放回到對應的page上去,那如果這個page正在被其他的CPU cache呢(其他CPU的kmem_cache_cpu正指使用這個page)?其實沒關係,kmem_cache_cpu和page各自有一個freelist指標,當page被一個CPU cache時,page的freelist上的所有物件全部移動到kmem_cache_cpu的freelist上面去(其實就是一個指標賦值),page的freelist變成NULL。而釋放的時候是釋放到page的freelist上去。兩個freelist互不影響。但是這個地方貌似有個問題,如果一個被cache的page的freelist由於物件的釋放而變成非NULL,那麼這個page就可能再被cache到其他CPU的kmem_cache_cpu上面去,於是多個kmem_cache_cpu可能cache同一個page。這將導致一個CPU內部的快取可能cache到其他CPU上的物件(因為CPU快取跟物件並不是對齊的),從而一個CPU上的物件寫操作可能引起另一個CPU的快取失效。 

在kmem_cache被建立的時候,SLUB會根據各種各樣的資訊,計算出物件池的合理佈局(見上面的圖)。objsize是物件本身的大小;這個大小經過對齊處理以後就成了inuse;緊貼inuse的後面可能會存放物件的next指標(由offset來標記),從而將物件實際佔用的大小擴大到size。 
其實,這裡的offset並不總是指向inuse後面的位置(否則offset就可以用inuse來代替了)。offset有兩種可能的取值,一是 inuse、一是0。這裡的offset指定了next指標的位置,而next是為了將物件串連在空閒連結串列中。那麼,需要用到next指標的時候,物件必定是空閒的,物件裡面的空間是未被使用的。於是正好,物件裡的第一個字長的空間就拿來當next指標好了,此時offset就是0。但是在一些特殊情況下,物件裡面的空間不能被複用作next指標,比如物件提供了建構函式ctor,那麼物件的空間是被構造過的。此時,offset就等於inuse,next指標只好存放在物件的空間之後。 

關於kmem_cache_cpu

前面在講物件分配與釋放的時候,著重講的是過程。下面再細細分析一下kmem_cache_cpu的作用。

如果沒有kmem_cache_cpu,那麼分配物件的過程就應該是:
1、從對應的kmem_cache_node的partial連結串列裡面選擇一個page;
2、從選中的page的freelist連結串列裡面選擇一個物件;
這就是最基本的分配過程。

但是這個過程有個不好的地方,kmem_cache_node的partial連結串列是全域性的、page的freelist連結串列也是全域性的:
1、第一步訪問partial連結串列的時候,需要上鎖;
2、第二步訪問page->freelist連結串列的時候,也需要上鎖;
3、物件可能剛在CPU1上被釋放,又馬上被CPU2分配走。不利於CPU cache;

引入kmem_cache_cpu就是對這一問題的優化。每個CPU各自對應一個kmem_cache_cpu例項,用於快取第一步中選中的那個page。這樣一來,第一步就不需要上鎖了;而page中的物件在一段時間內也將趨於在同一個CPU上使用,有利於CPU cache。
而kmem_cache_cpu中的freelist則是為了避免第二步的上鎖。

假設沒有kmem_cache_cpu->freelist,而page->freelist初始時有1、2、3、4,四個物件。考慮如下事件序列:
1、page被CPU1所cache,然後1、2被分配;
2、由於在CPU1上請求的node id與該page不匹配,page被放回kmem_cache_node的partial連結串列,那麼此時page->freelist還剩3和4兩個物件;
3、page又被CPU2所cache(page在上一步已經被放回partial連結串列了)。
此時,page->freelist就有可能被CPU1和CPU2兩個CPU所訪問,當物件1或2被釋放時(這兩個物件已經分配給了CPU1),CPU1會訪問page->freelist;而顯然CPU2分配物件時也要會訪問page->freelist。

所以為了避免上鎖,kmem_cache_cpu要維護自己的freelist,把page->freelist下的物件都接管過來。
這樣一來,CPU1就只跟page->freelist打交道,CPU2跟kmem_cache_cpu的freelist打交道,就不需要上鎖了。

關於page同時被多CPU使用

前面還提到,從屬於同一個page的物件可能cache到不同CPU上,從而可能對CPU的快取造成一定的影響。不過這似乎也是沒辦法的事情。

首先,初始狀態下,page加入到slub之後,從屬於該page的物件都是空閒的,都存在於page->freelist中;
然後,這個page可能被某個CPU的kmem_cache_cpu所cache,假設是CPU-0,那麼這個kmem_cache_cpu將得到屬於該page的所有物件。 page->freelist將為空;
接下來,這個page下的一部分物件可能在CPU-0上被分配出去;
再接著,可能由於NUMA的node不匹配,這個page從CPU-0的kmem_cache_cpu上面脫離下來。這時page->freelist將儲存著那些未被分配出去的物件(而其他的物件已經在CPU-0上被分配出去了);

這時,從屬於該page的一部分物件正在CPU-0上被使用著,另一部分物件存在於page->freelist中。
那麼,現在就有兩個選擇:
1、不將這個page放回partial list,阻止其他CPU使用這個page;
2、將這個page放回partial list,允許其他CPU使用這個page;

對於第一種做法,可以避免屬於同一個page的物件被cache到不同CPU。但是這個page必須等到CPU-0再次cache它以後才能被繼續使用;或者等待CPU-0所使用的從屬於這個page的物件全都被釋放,然後這個page才能被放回partial list或者直接被釋放掉。
這樣一來,一個page儘管擁有空閒的物件,卻可能在一定時間內處於不可用狀態(極端情況是永遠不可用)。這樣實現的系統似乎不太可控……

而現在的slub選擇了第二種做法,將page放回partial list,於是page馬上就能被其他CPU使用起來。那麼,由此引發的從屬於同一個page的物件被cache到不同CPU的問題,也就是沒辦法的事情……

vs slab

相比SLAB,SLUB還有一個比較有意思的特性。當建立新的物件池時,如果發現原先已經建立的某個kmem_cache的size剛好等於或略大於新的size,則新的kmem_cache不會被建立,而是複用這個大小差不多kmem_cache。所以kmem_cache裡面還維護了一個refcount(引用計數),表示它被複用的次數。 

另外,SLUB也去掉了SLAB中很有意思的一個特性,Coloring(著色)。 
什麼是著色呢?一個記憶體“大塊”,在按物件大小劃分成“小塊”的時候,可能並不是那麼剛好,還會空餘一些邊邊角角。著色就是利用這些邊邊角角來做文章,使得“小塊”的起始地址並不總是等於“大塊”內的0地址,而是在0地址與空餘大小之間浮動。這樣就使得同一種型別的各個物件,其地址的低幾位存在更多的變化。 
為什麼要這樣做呢?這是考慮到了CPU的cache。在學習作業系統原理的時候我們都聽說過,為提高CPU對記憶體的訪存效率,CPU提供了cache。於是就有了從記憶體到cache之間的對映。當CPU指令要求訪問一個記憶體地址的時候,CPU會先看看這個地址是否已經被快取了。 
記憶體到cache的對映是怎麼實現的呢?或者說CPU怎麼知道某個記憶體地址有沒有被快取呢? 
一種極端的設計是“全相連對映”,任何記憶體地址都可以對映到任何的cache位置上。那麼CPU拿到一個地址時,它可能被快取的cache位置就太多了,需要維護一個龐大的對映關係表,並且花費大量的查詢時間,才能確定一個地址是否被快取。這是不太可取的。 
於是,cache的對映總是會有這樣的限制,一個記憶體地址只可以被對映到某些個cache位置上。而一般情況下,記憶體地址的低幾位又決定了記憶體被cache的位置(如:cache_location = address % cache_size)。 
好了,回到SLAB的著色,著色可以使同一型別的物件其低幾位地址相同的概率減小,從而使得這些物件在cache中對映衝突的概率降低。 
這有什麼用呢?其實同一種型別的很多物件被放在一起使用的情況是很多的,比如陣列、連結串列、vector、等等情況。當我們在遍歷這些物件集合的時候,如果每一個物件都能被CPU快取住,那麼這段遍歷程式碼的處理效率勢必會得到提升。這就是著色的意義所在。 

SLUB把著色給去掉了,是因為對記憶體使用更加摳門了,儘可能的把邊邊角角減少到最小,也就乾脆不要著色了。還有就是,既然kmem_cache可以被size差不多的多種物件所複用,複用得越多,著色也就越沒意義了。

相關文章