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


























