圖解內存池內部結構,看它是如何克服內存碎片化的?

內存是軟件系統必不可少的物理資源,精湛的內存管理技術是確保內存使用效率的關鍵,也是進階高級研發的必備技巧。爲提高內存分配效率,Python 內部做了很多殫心竭慮的優化,從中我們可以獲得一些啓發。

開始研究 Python 內存池之前,我們先大致瞭解下 Python 內存管理層次:

衆所周知,計算機硬件資源由操作系統負責管理,內存資源也不例外。應用程序通過 系統調用 向操作系統申請內存,而 C 庫函數則進一步將系統調用封裝成通用的 內存分配器 ,並提供了 malloc 系列函數。

C 庫函數實現的通用目的內存管理器是一個重要的分水嶺,即內存管理層次中的 第0層 。此層之上是應用程序自己的內存管理,此層之下則是隱藏在冰山下方的操作系統部分。

操作系統內部是一個基於頁表的虛擬內存管理器(第-1層),以 ( page )爲單位管理內存,CPU 內存管理單元( MMU )在這個過程中發揮重要作用。虛擬內存管理器下方則是底層存儲設備(第-2層),直接管理物理內存以及磁盤等二級存儲設備。

綠色部分則是 Python 自己的內存管理,分爲 3 層:

  • 1 層,是一個內存分配器,接管一切內存分配,內部是本文的主角—— 內存池
  • 2 層,在第 1 層提供的統一 PyMem_XXXX 接口基礎上,實現統一的對象內存分配( object.tp_alloc );
  • 3 層,爲特定對象服務,例如前面章節介紹的 float 空閒對象緩存池;

那麼,Python 爲什麼不直接使用 malloc 系列函數,而是自己折騰一遍呢?原因主要是以下幾點:

  • 引入內存池,可化解對象頻繁創建銷燬帶來的內存分配壓力;
  • 最大程度避免內存碎片化,提升內存利用效率;
  • malloc 有很多實現版本,不同實現性能千差萬別;

內存碎片的挑戰

內存碎片化 是困擾經典內存分配器的一大難題,碎片化導致的結果也是慘重的。這是一個典型的內存碎片化例子:

雖然還有 1900K 的空閒內存,但都分散在一系列不連續的碎片上,甚至無法成功分配出 1000K

那麼,如何避免內存碎片化呢?想要解決問題,必先分析導致問題的根源。

我們知道,應用程序請求內存塊尺寸是不確定的,有大有小;釋放內存的時機也是不確定的,有先有後。經典內存分配器將不同尺寸內存塊混合管理,按照先來後到的順序分配:

當大塊內存回收後,可以被分爲更小的塊,然後分配出去:

而先分配的內存塊未必先釋放,慢慢地空洞就出現了:

隨着時間的推移,碎片化會越來越嚴重,最終變得支離破碎:

由此可見,將不同尺寸內存塊混合管理,將大塊內存切分後再次分配的做法是罪魁禍首。

按尺寸分類管理

揪出內存碎片根源後,解決方案也就浮出水面了——根據內存塊尺寸,將內存空間劃分成不同區域,獨立管理。舉個最簡單的例子:

如圖,內存被劃分成小、中、大三個不同尺寸的區域,區域可由若干內存頁組成,每個頁都劃分爲統一規格的內存塊。這樣一來,小塊內存的分配,不會影響大塊內存區域,使其碎片化。

每個區域的碎片仍無法完全避免,但這些碎片都是可以被重新分配出去的,影響不大。此外,通過優化分配策略,碎片還可被進一步合併。以小塊內存爲例,新內存優先從內存頁 1 分配,內存頁 2 將慢慢變空,最終將被整體回收。

Python 虛擬機內部,時刻有對象創建、銷燬,這引發頻繁的內存申請、釋放動作。這類內存尺寸一般不大,但分配、釋放頻率非常高,因此 Python 專門設計 內存池 對此進行優化。

那麼,尺寸多大的內存纔會動用內存池呢?Python512 字節爲限,小於 512 的內存分配纔會被內存池接管:

  • 0 ,直接調用 malloc 函數;
  • 1 ~ 512 ,由專門的內存池負責分配,內存池以內存尺寸進行劃分;
  • 512 以上,直接調動 malloc 函數;

那麼,Python 是否爲每個尺寸的內存都準備一個獨立內存池呢?答案是否定的,願意有幾個:

  • 內存規格有 512 種之多,如果內存池分也分 512 種,徒增複雜性;
  • 內存池種類越多,額外開銷越大;
  • 如果某個尺寸內存只申請一次,將浪費內存頁內其他空閒內存;

相反,Python8 字節爲梯度,將內存塊分爲:8 字節、16 字節、24 字節,以此類推。總共 64 種:

請求大小 分配內存塊大小 類別編號
1 ~ 8 8 0
9 ~ 16 16 1
17 ~ 24 24 2
25 ~ 32 32 3
... ... ...
497 ~ 504 504 62
505 ~ 512 512 63

8 字節內存塊爲例,內存池由多個 內存頁 ( page ,一般是 4K )構成,每個內存頁劃分爲若干 8 字節內存塊:

上圖表示一個內存頁,每個小格表示 1 字節,8 個字節組成一個塊( block )。灰色表示空閒內存塊,藍色表示已分配內存塊,深藍色表示應用內存請求大小。

只要請求的內存大小不超過 8 字節,Python 都在這個內存池爲其分配一塊 8 字節內存,就算只申請 1 字節內存也是如此。

這種做法好處顯而易見,前面提到的問題均得到解決,還帶來另一個好處:內存起始地址均以計算機字爲單位對齊。計算機以 ( word )爲單位訪問內存,因此內存以字對齊可提升內存讀寫速度。字大小從早期硬件的 2 字節、4 字節,慢慢發展到現在的 8 字節,甚至 16 字節。

當然了,有得必有失,內存利用率成了被犧牲的因素,平均利用率爲 (1+8)/2/8*100% ,大約只有 56.25%

乍然一看,內存利用率有些慘不忍睹,但這只是 8 字節內存塊的平均利用率。如果考慮所有內存塊的平均利用率,其實數值並不低——可以達到 98.65% 呢!計算方法如下:

# 請求內存總量
total_requested = 0
# 實際分配內存總量
total_allocated = 0

# 請求內存從1到512字節
for i in range(1, 513):
    total_requested += i
    # 實際分配內存爲請求內存向上對齊爲8的整數倍
    total_allocated += (i+7)//8*8

print('{:.2f}%'.format(total_requested/total_allocated*100))
# 98.65%

內存池實現

pool

鋪墊了這麼多,終於可以開始研究源碼,窺探 Python 內存池實現的祕密了,源碼位於 Objects/obmalloc.c 。在源碼中,我們發現對於 64 位系統,Python 將內存塊大小定義爲 16 字節的整數倍,而不是上述的 8 字節:

#if SIZEOF_VOID_P > 4
#define ALIGNMENT              16               /* must be 2^N */
#define ALIGNMENT_SHIFT         4
#else
#define ALIGNMENT               8               /* must be 2^N */
#define ALIGNMENT_SHIFT         3
#endif

爲畫圖方便,我們仍然假設內存塊爲 8 字節的整數倍,即(實際上,這些宏定義也是可配置的):

#define ALIGNMENT               8
#define ALIGNMENT_SHIFT         3

下面這個宏將類別編號轉化成塊大小,例如將類別 1 轉化爲塊大小 16

#define INDEX2SIZE(I) (((uint)(I) + 1) << ALIGNMENT_SHIFT)

Python 每次申請一個 內存頁 ( page ),然後將其劃分爲統一尺寸的 內存塊 ( block ),一個內存頁大小是 4K

#define SYSTEM_PAGE_SIZE        (4 * 1024)
#define SYSTEM_PAGE_SIZE_MASK   (SYSTEM_PAGE_SIZE - 1)

#define POOL_SIZE               SYSTEM_PAGE_SIZE
#define POOL_SIZE_MASK          SYSTEM_PAGE_SIZE_MASK

Python 將內存頁看做是由一個個內存塊組成的池子( pool ),內存頁開頭是一個 pool_header 結構,用於組織當前頁,並記錄頁中的空閒內存塊:

/* Pool for small blocks. */
struct pool_header {
    union { block *_padding;
            uint count; } ref;          /* number of allocated blocks    */
    block *freeblock;                   /* pool's free list head         */
    struct pool_header *nextpool;       /* next pool of this size class  */
    struct pool_header *prevpool;       /* previous pool       ""        */
    uint arenaindex;                    /* index into arenas of base adr */
    uint szidx;                         /* block size class index        */
    uint nextoffset;                    /* bytes to virgin block         */
    uint maxnextoffset;                 /* largest valid nextoffset      */
};

  • count ,已分配出去的內存塊個數;
  • freeblock ,指向空閒塊鏈表的第一塊;
  • nextpool ,用於將 pool 組織成鏈表的指針,指向下一個 pool
  • prevpool ,用於將 pool 組織成鏈表的指針,指向上一個 pool
  • szidx ,尺寸類別編號;
  • nextoffset ,下一個未初始化內存塊的偏移量;
  • maxnextoffset ,合法內存塊最大偏移量;

Python 通過內存池申請內存時,如果沒有可用 pool ,內存池將新申請一個 4K 頁,並進行初始化。注意到,由於新內存頁總是由內存請求觸發,因此初始化時第一個內存塊便已經被分配出去了:

隨着內存分配請求的發起,空閒塊將被分配出去。Python 將從灰色區域取出下一個作爲空閒塊,直到灰色塊用光:

當有內存塊被釋放時,比如第一塊,Python 將其鏈入空閒塊鏈表頭。請注意空閒塊鏈表的組織方式——每個塊頭部保存一個 next 指針,指向下一個空閒塊:

這樣一來,一個 pool 在其生命週期內,可能處於以下 3 種狀態(空閒內存塊鏈表結構被省略,請自行腦補):

  • empty完全空閒 狀態,內部所有內存塊都是空閒的,沒有任何塊已被分配,因此 count0
  • used部分使用 狀態,內部內存塊部分已被分配,但還有另一部分是空閒的;
  • full完全用滿 狀態,內部所有內存塊都已被分配,沒有任何空閒塊,因此 freeblockNULL

爲什麼要討論 pool 狀態呢?——因爲 pool 的狀態決定 Python 對它的處理策略:

  • 如果 pool 完全空閒,Python 可以將它佔用的內存頁歸還給操作系統,或者緩存起來,後續需要分配新 pool 時直接拿來用;
  • 如果 pool 完全用滿,Python 就無須關注它了,將它丟到一邊;
  • 如果 pool 只是部分使用,說明它還有內存塊未分配,Python 則將它們以 雙向循環鏈表 的形式組織起來;

可用 pool 鏈表

由於 used 狀態的 pool 只是部分使用,內部還有內存塊未分配,將它們組織起來可供後續分配。Python 通過 pool_header 結構體中的 nextpoolprevpool 指針,將他們連成一個雙向循環鏈表:

注意到,同個可用 pool 鏈表中的內存塊大小規格都是一樣的,上圖以 16 字節類別爲例。另外,爲了簡化鏈表處理邏輯,Python 引入了一個虛擬節點,這是一個常見的 C 語言鏈表實現技巧。一個空的 pool 鏈表是這樣的,判斷條件是 pool->nextpool == pool :

虛擬節點只參與鏈表維護,並不實際管理內存塊。因此,無須爲虛擬節點分配一個完整的 4K 內存頁,64 字節的 pool_header 結構體足矣。實際上,Python 作者們更摳,只分配剛好足夠 nextpoolprevpool 指針用的內存,手法巧妙得令人瞠目結舌,我們稍後再表。

Python 優先從鏈表第一個 pool 分配內存塊,如果 pool 用滿則將其從鏈表中剔除:

當一個內存塊( block )被回收,Python 根據塊地址計算得到 pool 地址。計算方法是大概是這樣的:將 block 地址對齊爲內存頁( pool )尺寸的整數倍,便得到 pool 地址,具體請參看源碼中的宏定義 POOL_ADDR

得到 pool 地址後,Python 將空閒內存塊插到空閒內存塊鏈表頭部。如果 pool 狀態是由 完全用滿 ( full )變爲 可用 ( used ),Python 還會將它插回可用 pool 鏈表頭部:

插到可用 pool 鏈表頭部是爲了保證比較滿的 pool 在鏈表前面,以便優先使用。位於尾部的 pool 被使用的概率很低,隨着時間的推移,更多的內存塊被釋放出來,慢慢變空。因此,pool 鏈表明顯頭重腳輕,靠前的 pool 比較滿,而靠後的 pool 比較空,正如上圖所示。

當一個 pool 所有內存塊( block )都被釋放,狀態就變爲 完全空閒( empty )。Python 會將它移出鏈表,內存頁可能直接歸還給操作系統,或者緩存起來以備後用:

實際上,pool 鏈表任一節點均有機會完全空閒下來。這由概率決定,尾部節點概率最高,因此上圖就這麼畫了。

pool 鏈表數組

Python 內存池管理內存塊,按照尺寸分門別類進行。因此,每種規格都需要維護一個獨立的可用 pool 鏈表。如果以 8 字節爲梯度,內存塊規格可分 64 種之多(見上表)。

那麼,如何組織這麼多 pool 鏈表呢?最直接的方法是分配一個長度爲 64 的虛擬節點數組:

如果程序請求 5 字節,Python 將分配 8 字節內存塊,通過數組第 0 個虛擬節點即可找到 8 字節 pool 鏈表;如果程序請求 56 字節,Python 將分配 64 字節內存塊,則需要從數組第 7 個虛擬節點出發;其他以此類推。

那麼,虛擬節點數組需要佔用多少內存呢?這不難計算:

$$48 \times 64 = 3072 = 3K$$

喲,看上去還不少!Python 作者們可沒這麼大方,他們還從中摳出三分之二,具體是如何做到的呢?

您可能已經注意到了,虛擬節點只參與維護鏈表結構,並不管理內存頁。因此,虛擬節點其實只使用 pool_header 結構體中參與鏈表維護的 nextpoolprevpool 這兩個指針字段:

爲避免淺藍色部分內存浪費,Python 作者們將虛擬節點想象成一個個卡片,將深藍色部分首尾相接,最終轉換成一個純指針數組。數組在 Objects/obmalloc.c 中定義,即 usedpools 。每個虛擬節點對應數組裏面的兩個指針:

接下來的一切交給想象力——將兩個指針前後的內存空間想象成自己的,這樣就得到一個虛無縹緲的卻非常完整的 pool_header 結構體(如下圖左邊虛線部分),我們甚至可以使用這個 pool_header 結構體的地址!由於我們不會訪問除了 nextpoolprevpool 指針以外的字段,因此雖有內存越界,卻也無傷大雅。

下圖以一個代表空鏈表的虛擬節點爲例,nextpoolprevpool 指針均指向 pool_header 自己。雖然實際上 nextpoolprevpool 都指向了數組中的其他虛擬節點,但邏輯上可以想象成指向當前的 pool_header 結構體:

臥槽,這移花接木大法也太牛逼了吧!非常享受研究源碼的過程,當年研究 Linux 內核數據結構中的鏈表實現時,也是大開眼界!

經過這般優化,數組只需 16*64 = 1024 字節的內存空間即可,摺合 1K ,節省了三分之二。爲了節約這 2K 內存,代碼變得難以理解。我第一次閱讀源碼時,在紙上花了半天才完全弄懂這個思路。

效率與代碼可讀性經常是一對矛盾,如何選擇見仁見智。不過,如果是日常項目,我多半不會爲了 2K 內存而引入複雜性。Python 作爲基礎工具,能省則省。當然這個思路也有可能是在內存短缺的年代引入的,然後就這麼一直用着。

不管怎樣,我還是決定將它寫出來。如果你有興趣研究 Objects/obmalloc.c 中的源碼,就不用像我一樣費勁,瞎耽誤功夫。

因篇幅關係,源碼無法一一列舉。對源碼感興趣的同學,請自己動手,豐衣足食。結合圖示閱讀,應該可以做到事半功倍。什麼,不知道從何入手?——那就緊緊抓住這兩個函數吧,一個負責分配,一個負責釋放:

  • pymalloc_alloc
  • pymalloc_free

雖然本節研究了很多東西,但還無法涵蓋 Python 內存池的全部祕密,pool 的管理同樣因篇幅關係無法展開。後續有機會我會接着寫,感興趣的童鞋請關注我。等不及?——源碼歡迎您!

如果覺得我寫得還行,記得點贊關注喲~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章