內存是軟件系統必不可少的物理資源,精湛的內存管理技術是確保內存使用效率的關鍵,也是進階高級研發的必備技巧。為提高內存分配效率,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 專門設計 內存池 對此進行優化。
那么,尺寸多大的內存才會動用內存池呢?Python 以 512 字節為限,小於 512 的內存分配才會被內存池接管:
- 0 ,直接調用 malloc 函數;
- 1 ~ 512 ,由專門的內存池負責分配,內存池以內存尺寸進行划分;
- 512 以上,直接調動 malloc 函數;
那么,Python 是否為每個尺寸的內存都准備一個獨立內存池呢?答案是否定的,願意有幾個:
- 內存規格有 512 種之多,如果內存池分也分 512 種,徒增復雜性;
- 內存池種類越多,額外開銷越大;
- 如果某個尺寸內存只申請一次,將浪費內存頁內其他空閑內存;
相反,Python 以 8 字節為梯度,將內存塊分為: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 ,完全空閑 狀態,內部所有內存塊都是空閑的,沒有任何塊已被分配,因此 count 為 0 ;
- used ,部分使用 狀態,內部內存塊部分已被分配,但還有另一部分是空閑的;
- full ,完全用滿 狀態,內部所有內存塊都已被分配,沒有任何空閑塊,因此 freeblock 為 NULL ;
為什么要討論 pool 狀態呢?——因為 pool 的狀態決定 Python 對它的處理策略:
- 如果 pool 完全空閑,Python 可以將它占用的內存頁歸還給操作系統,或者緩存起來,后續需要分配新 pool 時直接拿來用;
- 如果 pool 完全用滿,Python 就無須關注它了,將它丟到一邊;
- 如果 pool 只是部分使用,說明它還有內存塊未分配,Python 則將它們以 雙向循環鏈表 的形式組織起來;
可用 pool 鏈表
由於 used 狀態的 pool 只是部分使用,內部還有內存塊未分配,將它們組織起來可供后續分配。Python 通過 pool_header 結構體中的 nextpool 和 prevpool 指針,將他們連成一個雙向循環鏈表:
注意到,同個可用 pool 鏈表中的內存塊大小規格都是一樣的,上圖以 16 字節類別為例。另外,為了簡化鏈表處理邏輯,Python 引入了一個虛擬節點,這是一個常見的 C 語言鏈表實現技巧。一個空的 pool 鏈表是這樣的,判斷條件是 pool->nextpool == pool
:
虛擬節點只參與鏈表維護,並不實際管理內存塊。因此,無須為虛擬節點分配一個完整的 4K 內存頁,64 字節的 pool_header 結構體足矣。實際上,Python 作者們更摳,只分配剛好足夠 nextpool 和 prevpool 指針用的內存,手法巧妙得令人瞠目結舌,我們稍后再表。
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 結構體中參與鏈表維護的 nextpool 和 prevpool 這兩個指針字段:
為避免淺藍色部分內存浪費,Python 作者們將虛擬節點想象成一個個卡片,將深藍色部分首尾相接,最終轉換成一個純指針數組。數組在 Objects/obmalloc.c 中定義,即 usedpools 。每個虛擬節點對應數組里面的兩個指針:
接下來的一切交給想象力——將兩個指針前后的內存空間想象成自己的,這樣就得到一個虛無縹緲的卻非常完整的 pool_header 結構體(如下圖左邊虛線部分),我們甚至可以使用這個 pool_header 結構體的地址!由於我們不會訪問除了 nextpool 和 prevpool 指針以外的字段,因此雖有內存越界,卻也無傷大雅。
下圖以一個代表空鏈表的虛擬節點為例,nextpool 和 prevpool 指針均指向 pool_header 自己。雖然實際上 nextpool 和 prevpool 都指向了數組中的其他虛擬節點,但邏輯上可以想象成指向當前的 pool_header 結構體:
卧槽,這移花接木大法也太牛逼了吧!非常享受研究源碼的過程,當年研究 Linux 內核數據結構中的鏈表實現時,也是大開眼界!
經過這般優化,數組只需 16*64 = 1024 字節的內存空間即可,折合 1K ,節省了三分之二。為了節約這 2K 內存,代碼變得難以理解。我第一次閱讀源碼時,在紙上花了半天才完全弄懂這個思路。
效率與代碼可讀性經常是一對矛盾,如何選擇見仁見智。不過,如果是日常項目,我多半不會為了 2K 內存而引入復雜性。Python 作為基礎工具,能省則省。當然這個思路也有可能是在內存短缺的年代引入的,然后就這么一直用着。
不管怎樣,我還是決定將它寫出來。如果你有興趣研究 Objects/obmalloc.c 中的源碼,就不用像我一樣費勁,瞎耽誤功夫。
因篇幅關系,源碼無法一一列舉。對源碼感興趣的同學,請自己動手,豐衣足食。結合圖示閱讀,應該可以做到事半功倍。什么,不知道從何入手?——那就緊緊抓住這兩個函數吧,一個負責分配,一個負責釋放:
- pymalloc_alloc
- pymalloc_free
雖然本節研究了很多東西,但還無法涵蓋 Python 內存池的全部秘密,pool 的管理同樣因篇幅關系無法展開。后續有機會我會接着寫,感興趣的童鞋請關注我。等不及?——源碼歡迎您!
如果覺得我寫得還行,記得點贊關注喲~