Python內存管理機制(Real Python版)
是否曾想過Python怎樣在幕后管理數據?變量是怎樣存儲在內存中的?什么時候會被刪除?
在這篇文章中,我們將深入python內部來探究內存管理。
讀完這篇文章,你將:
- 了解更多關于底層計算邏輯,尤其是內存相關方面
- 理解Python怎樣對底層操作進行抽象
- 明白Python內存管理的的算法
探究Python內部原理能讓你有個更好的視角觀察Python。希望你對Python有個新的認識。在你的程序正常運行的背后有大量Python的功勞。
內存是一本空白的書
首先,你可以把計算機的內存想象成一本寫短篇故事的空白書。當前的每一頁都是空的。不同的作者會參與進來。每個作者都會得到一些頁面來寫入他們的故事。
他們得寫的很小心因為不能把東西寫到其他人的頁面上,在他們寫之前,他們會和經理商量一下。經理來決定他們允許寫到哪些頁上。
因為這書寫了很久了,很多故事已經沒什么意義了。當一個故事沒人看或沒人提及時,就會被刪掉,留下頁面給新的故事。
本質上,計算機內存就像那本空白的書。實際上,通常將固定長度的連續內存稱為內存頁,因此這個比喻很相似。
書的作者就像需要存數據到內存的應用或進程。經理決定作者可以在書中何處寫入內容,他扮演著類似內存管理器的角色。刪掉舊的故事給新故事騰出空白頁的人就是垃圾回收器。
內存管理:從硬件到軟件
內存管理是指應用程序讀寫數據的過程。內存管理器決定把應用數據放在哪。因為內存是有限的,就跟前面書的比喻一樣,內存管理器得找一些空位給程序。提供內存空間的過程通常叫內存分配。
另一方面,當數據不再需要時,可以被刪除或釋放。但釋放到哪里呢?這些內存又是從哪里來的?
在計算機內部,有個物理設備存儲著正在運行的Python程序數據。在Python代碼和硬件之間隔著很多抽象層。
其中在硬件(比如內存,硬盤)上面的最主要一層是操作系統。
操作系統之上就是程序了,其中就有Python的默認實現版(內置在操作系統或從python.org下載的)。Python代碼的內存管理由Python程序負責的。Python程序用于內存管理的算法和數據結構就是本文的主旨。
Python的默認實現版
Python的默認實現版叫CPython,是C語言寫的。
***次知道的時候讓我很驚訝。一門語言由另一門語言編寫?!好吧,并不全是,但也差不多。
Python這門語言的定義是由英語寫在參考手冊上的。(https://docs.python.org/3/reference/index.html)
然而手冊本身并沒有什么很大作用。你仍需要按參考手冊中的規則寫出一些解析代碼。
注意:虛擬機就像硬件機,但是由軟件實現的。
典型的基于指令的處理過程和匯編指令很相似。
Python是解釋執行的語言。你的Python代碼實際上會被編譯成計算機更能識別的叫字節碼的指令。當你運行代碼的時候這些指令由虛擬機解析出來。
記得你見到的.pyc文件或__pycache__文件夾嗎?就是那些字節碼來被虛擬機解析。
同時你也需要能在計算機中實際執行這些字節碼的東西。默認的Python實現包含了這以上兩樣。需要了解的是除了CPython外還有很多其他實現。IronPython被編譯成在微軟的公共語言運行時上運行。Jython將編譯為Java字節碼在Java虛擬機上運行。還有PyPy,關于它還得另起一篇文章,還是一筆帶過先。
這篇文章主要集中在Python默認實現CPython是如何管理內存上。
聲明:Python每個版本的發布都會有很多改變。
當前篇幅主要討論的是Python3.7版。
說回來,CPython是用C寫的并可以解析Python字節碼。這些和內存管理又有什么關聯呢?因為內存管理的算法和數據結構就在C寫的CPython代碼里。要理解Python的內存管理機制,就得對CPython本身有個基本的了解。
也許你聽說過在Python里面一切皆對象,包括像int或str這樣的類型本身。
注意:在C語言里面一個struct就是一組不同類型數據的集合。
可以類比為面向對象語言里面一個只有屬性沒有方法的類。
CPython是用沒有原生面向對象支持的C語言寫的。所以,在CPython的代碼里面有很多有趣的設計。
PyObject在Python里面是所有對象的鼻祖,它只包含兩樣東西:
- ob_refcnt: 引用計數
- ob_type: 類型指針
引用計數是用于垃圾回收的。類型指針則是指向另一實體類型的指針。那個類型的指針只是另一個描述Python實體的struct(比如dict或int)。
每個實體包含特定的內存分配器,用于申請內存和存儲自身。每個實體也有特定的內存釋放器用于當自身不被引用時的內存釋放。
與此同時,在申請和釋放內存時還有個很重要的因素。內存在計算機內是共享資源,如果兩個不同的進程同時使用一塊相同的內存則會出錯。
全局解釋器鎖(GIL)
GIL是解決計算機中如內存之類的共享資源的通用解決方案。當兩個線程試圖同時修改相同的資源時,它們可能會影響到對方。最終結果可能是都得不到自己想要的結果。
再拿書本來打個比方。想象一下兩個固執的作者堅持本次該輪到自己來書寫。而且,他們寫的還是同一頁紙。
他們都忽略對方然后各自在這一頁上寫故事。
結果是兩個故事互相交織在一起,整頁都沒人看得懂。
有個解決方案是當線程影響到共享資源(書本中的空白頁)的時候有唯一一個全局的的鎖來鎖住解釋器。換句話說,同時只有一個作者可寫。
Python的GIL通過加鎖整個解釋器來獲得資格,就是說另一個線程不會影響到當前這個。當CPython對內存進行處理的時候,使用GIL來確保這些操作是安全的。
這種方式有它的優點和缺點,Python社區對GIL的爭論很激烈。想要了解更多GIL的知識,我建議你們可以看看《什么是全局解釋器鎖》(https://realpython.com/python-gil/?from=ethan)這篇文章。
垃圾回收
我們再看一下書的類比,假設其中一些故事已經過時了。沒人看也沒人引用這些故事。這種情況下就該處理掉這些故事以便騰出新的頁面。
這些沒人看和引用的故事就像Python里面引用計數為0的對象。提醒一下每個實體對象在Python中都有一個引用計數和類型指針。
有幾個不同的因素可讓引用計數增長。比如,當前對象被賦予其它變量時引用計數會增長。
- numbers = [1, 2, 3]
- # 引用計數 = 1
- more_numbers = numbers
- # 引用計數 = 2
當把對象傳參使用的時候也會增加引用計數:
- total = sum(numbers)
***舉個例子,當一個list包含此對象的時候也會增加引用計數:
- matrix = [numbers, numbers, numbers]
你可以通過sys模塊來檢查Python對象的引用計數。你可以這樣用sys.getrefcount(numbers), 但要記得當你用getrefcount()的時候numbers的引用計數也會加1。
任何情況下,如果一個對象仍在你代碼某處被使用,那它的引用計數就會大于0.一旦降為0的時候,這個對象特定的釋放函數就會被調用來釋放內存給其他對象復用。
所謂的“釋放”到底是什么意思呢?其他對象又是如何復用這塊內存的?讓我們深入CPython的內存管理機制。
CPython的內存管理機制
準備好,我們即將深入研究CPython的內存結構和算法。
如上所述,在硬件和CPython之間還有很多抽象層。操作系統對實體內存做了抽象并建立了一個虛擬內存層給程序(包括Python)來訪問。
Python留了一塊內存來給對象之外的內部使用。其他部分取決于對象如何存儲(int,dict等等)如果你想要個全面的了解,可以看下CPython的源碼,所有內存管理相關的都在里面。
CPython有一個內存分配器來負責在對象內存區分配內存。這個對象分配器就是所有魔法發生的源頭。每當一個新的對象需要分配或釋放時都會被調用。
像典型的int或list等Python對象在每次分配和釋放時不會包含太多的數據。所以分配器被設計成在分配少批量數據時如何更好的工作。同時也要避免不要當真的需要內存的時候才去申請物理內存。
源碼里面關于分配器的描述是:一種快速且為小塊內存專用的分配器,用于通用malloc之上。此處講的malloc是C里面用于分配內存的庫函數。
現在我們來看看CPython的內存分配策略。首先,我們先講一下3個互相影響的區。
arenas區是內存中***的區,在內存中是按頁對齊的。頁是指被操作系統使用的一小塊連續且固定大小的內存塊。Python假設操作系統使用的頁大小是256K。
arenas區內部是內存池,每個內存池是個虛擬內存頁(4K)。就像我們類比書里面的空白頁面。這些內存池被切分成更小的內存塊。
同個內存池內的所有塊大小均相同。給定一組請求數據,規格類定義了指定塊大小。以下圖表是從源碼注釋轉換而來:
例如,如果需要42個字節,那么數據會存放在一個48字節的塊中。
內存池
內存池是由相同規格類定義的塊組成。每個內存池都管理著一個雙向鏈表,鏈接著其他相同規格的內存池。由此算法可以很容易的通過給定的塊大小找到可用空間,甚至是在不同內存池之間也行。
可通過已使用的內存池列表追蹤所有相同規格類的可用空間。給定一個塊大小,算法可以從已使用內存池列表中檢測出來。
內存池必須是以下3種狀態之一:使用中,滿,空。使用中的內存池有特定大小塊可供數據存儲。滿的內存池內被已分配的數據占滿。空內存池沒有數據,當需要的時候可以被初始化為任意大小規格的內存池。
空內存池列表記錄著所有空狀態的內存池。那空內存池什么時候會被用到呢?
假設你的代碼需要8個字節的內存池塊。如果在已使用的內存池列表中沒有關于8個字節規格的,那么一個空的內存池會被初始化為專門存儲8個字節。同時這個新的內存池會被添加到已使用內存池中供接下來的請求使用。
當滿的內存池當中有些塊被回收了,那么這個內存池又會被添加到當前大小的使用中內存池列表中。
現在你知道這些內存池是怎樣從不同狀態之間自由切換的算法了。
內存塊
由上圖可知,內存池包含一個指向空內存塊的指針。這里有一點細微的差別。源代碼的注釋指出,分配器力求在各級別(arena, pool, block)內存真正被需要的時候才去使用它。
內存池中的塊有3種狀態。這些狀態的定義如下:
- untouched: 還未被分配使用的內存塊
- free: 被分配然后又被"釋放"的內存塊且里面沒有保存相關數據了
- allocated: 已分配且含有數據的內存塊
free狀態的塊指針列表保存著一系列的free態內存。換句話說,一個可用來放數據的列表。如果需要比可用的所有free態內存還要多,那么分配器會去使用那些untouched態的塊。
當內存管理器把內存塊狀態置為"釋放"時會把它添加到free態鏈表的頭部。這個鏈表可能不像上面那圖一樣為連續的內存塊。它可能是如下圖那樣:
Arenas區
arenas區包含著內存池。這些內存池可以是使用中,滿,或空的。arenas區不像內存池那樣有明顯的狀態區分。
arenas區由稱為usable_arenas的雙向鏈表組織而成。此鏈表按可用內存池的數量排序。越少可用內存池的排在越前面。
這意味著arena區會選擇更接近用滿的地方來存放數據。為什么不反過來做呢?為什么數據不放到最空的地方去?
這就要說到真正的內存釋放。你也許注意到我給釋放加了引號, 它并不是真正的釋放到操作系統。Python繼續保留著以供新的數據使用。真正的內存釋放是返回給操作系統使用。
arenas區是唯一可以真正被釋放的地方。所以那些接近為空的區域也理所當然應當為空。通過這種方式,可以真正釋放內存,減少Python程序的總體內存占用。
總結
內存管理是計算機工作中不可或缺的一部分。不管好壞,Python幾乎在幕后處理所有這些問題。
在本篇中,你學到了:
- 什么是內存管理和為什么它很重要
- 默認的Python實現CPython是用C寫的
- CPython的內存管理是怎樣通過數據結構和算法來管理你的數據的
Python抽象了很多繁雜的細節來與計算機打交道,讓你有能力從更高的層次來開發代碼而不用為字節存放到哪而頭疼。