1. 程式人生 > 實用技巧 >28. Python記憶體管理與垃圾回收(第一部分):深度剖析Python記憶體管理架構、記憶體池的實現原理

28. Python記憶體管理與垃圾回收(第一部分):深度剖析Python記憶體管理架構、記憶體池的實現原理

楔子

記憶體管理,對於Python這樣的動態語言來說是非常重要的一部分,它在很大程度上決定了Python的執行效率,因為Python在執行中會建立和銷燬大量的物件,這些都涉及記憶體的管理,因此精湛的記憶體管理技術是確保記憶體使用效率的關鍵。

此外,我們知道Python還是一門提供了垃圾回收機制(GC, garbage collection)的語言,可以將開發者從繁瑣的手動維護記憶體的工作中解放出來。

那麼下面我們就來分析一下Python中的記憶體管理和垃圾回收。

記憶體管理架構

首先Python的記憶體管理機制是分層次的,我們可以看成是有6層:-2、-1、0、1、2、3。

  • 最底層,也就是-2和-1層是由作業系統提供的記憶體管理介面,因為計算機硬體資源由作業系統負責管理,記憶體資源也不例外,應用程式通過系統呼叫向作業系統申請記憶體。注意:這一層Python是無權干預的。
  • 第0層,C的庫函式會將系統呼叫封裝成通用的記憶體分配器,也就是我們所熟悉的malloc系列函式。注意:這一層Python同樣無法干預。
  • 第1、2、3層,由於Python直譯器實現並負責維護。

所以我們看到Python的記憶體管理實際上封裝了C的malloc,C的malloc則是封裝了系統呼叫。

我們自下而上來簡單說一下,首先作業系統內部是一個基於頁表的虛擬記憶體管理器(第-1層),以"頁(page)"為單位管理記憶體,而CPU記憶體管理單元(MMU)在這個過程中發揮重要作用。虛擬記憶體管理器下方則是底層儲存裝置(第-2層),直接管理實體記憶體以及磁碟等二級儲存裝置。

所以最後的兩層是作業系統的領域,過於底層,不在我們的涉及範圍內,簡單瞭解就好。有興趣的話,可以網上查閱相關資料,看看作業系統是如何管理記憶體的。

C庫函式實現的"通用目的記憶體分配器"是一個重要的分水嶺,即記憶體管理層次中的第0層。此層之上是應用程式自己的記憶體管理,之下則是隱藏在冰山中的作業系統的記憶體管理。

第1、2、3層則是Python自己的記憶體管理,總共分為3層,作用如下:

第1層:基於第0層的"通用目的記憶體分配器"包裝而成。

這一層並沒有在第0層上加入太多的動作,其目的僅僅是為Python提供一層統一的raw memory的管理介面。這麼做的原因就是雖然不同的作業系統都提供了ANSI C標準 所定義的記憶體管理介面,但是對於某些特殊情況不同作業系統有不同的行為。比如呼叫malloc(0)

,有的作業系統會返回NULL,表示申請失敗,但是有的作業系統則會返回一個貌似正常的指標, 但是這個指標指向的記憶體並不是有效的。為了最廣泛的可移植性,Python必須保證相同的語義一定代表著相同的執行時行為,為了處理這些與平臺相關的記憶體分配行為,Python必須要在C的記憶體分配介面之上再提供一層包裝。

在Python中,第一層的實現就是一組以PyMem_為字首的函式簇,下面來看一下。

//Include/pymem.h
PyAPI_FUNC(void *) PyMem_Malloc(size_t size);
PyAPI_FUNC(void *) PyMem_Realloc(void *ptr, size_t new_size);
PyAPI_FUNC(void) PyMem_Free(void *ptr);


//Objects/obmalloc.c
void *
PyMem_Malloc(size_t size)
{
    /* see PyMem_RawMalloc() */
    if (size > (size_t)PY_SSIZE_T_MAX)
        return NULL;
    return _PyMem.malloc(_PyMem.ctx, size);
}

void *
PyMem_Realloc(void *ptr, size_t new_size)
{
    /* see PyMem_RawMalloc() */
    if (new_size > (size_t)PY_SSIZE_T_MAX)
        return NULL;
    return _PyMem.realloc(_PyMem.ctx, ptr, new_size);
}

void
PyMem_Free(void *ptr)
{
    _PyMem.free(_PyMem.ctx, ptr);
}

我們看到在第一層,Python提供了類似於類似於C中malloc、realloc、free的語義。並且我們發現,比如 PyMem_Malloc ,如果申請的記憶體大小超過了 PY_SSIZE_T_MAX 直接返回NULL,並且還呼叫了 _PyMem.malloc ,這和C中的malloc幾乎沒啥區別,但是會對特殊值進行一些處理。到目前為止,僅僅是分配了raw memory而已。當然在第一層,Python還提供了面向物件中型別的記憶體分配器。

//Include/pymem.h
#define PyMem_New(type, n) \
  ( ((size_t)(n) > PY_SSIZE_T_MAX / sizeof(type)) ? NULL :      \
        ( (type *) PyMem_Malloc((n) * sizeof(type)) ) )
#define PyMem_NEW(type, n) \
  ( ((size_t)(n) > PY_SSIZE_T_MAX / sizeof(type)) ? NULL :      \
        ( (type *) PyMem_MALLOC((n) * sizeof(type)) ) )
#define PyMem_Resize(p, type, n) \
  ( (p) = ((size_t)(n) > PY_SSIZE_T_MAX / sizeof(type)) ? NULL :        \
        (type *) PyMem_Realloc((p), (n) * sizeof(type)) )
#define PyMem_RESIZE(p, type, n) \
  ( (p) = ((size_t)(n) > PY_SSIZE_T_MAX / sizeof(type)) ? NULL :        \
        (type *) PyMem_REALLOC((p), (n) * sizeof(type)) )
#define PyMem_Del               PyMem_Free
#define PyMem_DEL               PyMem_FREE

很明顯,在 PyMem_Malloc 中需要程式設計師自行提供所申請的空間大小。然而在 PyMem_New 中,只需要提供型別和數量,Python會自動偵測其所需的記憶體空間大小。

第2層:在第1層提供的通用 PyMem_ 介面基礎上,實現統一的物件記憶體分配(object.tp_alloc)

第1層所提供的記憶體管理介面的功能是非常有限的,如果建立一個PyLongObject物件,還需要做很多額外的工作,比如設定物件的型別引數、初始化物件的引用計數值等等。因此為了簡化Python自身的開發,Python在比第1層更高的抽象層次上提供了第2層記憶體管理介面。在這一層,是一組以PyObject_為字首的函式簇,主要提供了建立Python物件的介面。這一套函式簇又被稱為Pymalloc機制,因此在第2層的記憶體管理機制上,Python對於一些內建物件構建了更高抽象層次的記憶體管理策略。

第3層:為特定物件服務

這一層主要是用於物件的快取機制,比如:小整數物件池,浮點數快取池等等。

所以Python中GC是隱藏在哪一層呢?不用想,肯定是第二層,也是在Python的記憶體管理中發揮巨大作用的一層,我們後面也會基於第二層進行剖析。

小塊空間的記憶體池

為什麼要引入記憶體池

在Python中,很多時候申請的記憶體都是小塊的記憶體,這些小塊的記憶體在申請後很快又被釋放,並且這些記憶體的申請並不是為了建立物件,所以並沒有物件一級的記憶體池機制。這就意味著Python在執行期間需要大量地執行底層的malloc和free操作,導致作業系統在使用者態和核心態之間進行切換,這將嚴重影響Python的效率。所以為了提高執行效率,Python引入了一個記憶體池機制,用於管理對小塊記憶體的申請和釋放,這就是之前說的Pymalloc機制,並且提供了pymalloc_allocpymalloc_reallocpymalloc_free三個介面。

而整個小塊記憶體的記憶體池可以視為一個層次結構,從下至上分別是:block、pool、arena。當然記憶體池只是一個概念上的東西,表示Python對整個小塊記憶體分配和釋放行為的記憶體管理機制。

block

在最底層,block是一個確定大小的記憶體塊。而Python中,有很多種block,不同種類的block都有不同的記憶體大小,這個記憶體大小的值被稱之為size class。為了在當前主流的32位平臺和64位平臺都能獲得最佳效能,所有的block的長度都是8位元組對齊的。

//Objects/obmalloc.c
#define ALIGNMENT               8               /* must be 2^N */
#define ALIGNMENT_SHIFT         3

但是問題來了,Python為什麼要有這麼多種類的block呢?為了更好理解這一點,我們需要了解"記憶體碎片化"這個概念。

"記憶體碎片化"是困擾經典記憶體分配器的一大難題,碎片化導致的結果也是慘重的。看一個典型的記憶體碎片化例子:

雖然還有1350K的可用記憶體,但由於分散在一系列不連續的碎片上,因此連675K、總可用記憶體的一半都分配不出來。

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

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

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

找到了問題的原因,那麼解決方案也就自然而然浮出水面了,那就是將記憶體空間劃分成不同區域,獨立管理,比如:

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

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

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

那麼,尺寸多大的記憶體才會動用記憶體池呢?Python512 位元組為上限,小於等於 512 的記憶體分配才會被記憶體池接管。所以當申請的記憶體大小不超過這個上限時, Python 可以使用不同種類的block滿足對記憶體的需求;當申請的記憶體大小超過了上限, Python 就會將對記憶體的請求轉交給第一層的記憶體管理機制,即PyMem函式簇來處理。所以這個上限值在 Python 中被設定為 512 ,如果超過了這個值還是要經過作業系統臨時申請的。

//Objects/obmalloc.c
#define SMALL_REQUEST_THRESHOLD 512
#define NB_SMALL_SIZE_CLASSES   (SMALL_REQUEST_THRESHOLD / ALIGNMENT)
  • 0: 直接呼叫 malloc 函式
  • 1 ~ 512: 由專門的記憶體池負責分配,記憶體池以記憶體尺寸進行劃分
  • 512以上: 直接調動 malloc 函式

那麼,Python 是否為每個尺寸的記憶體都準備一個獨立記憶體池呢?答案是否定的,原因有幾個:

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

相反,Python8 位元組為梯度,將記憶體塊分為:8 位元組、16 位元組、24 位元組,以此類推。總共 64 種block:

 * Request in bytes     Size of allocated block      Size class idx
 * ----------------------------------------------------------------
 *        1-8                     8                       0
 *        9-16                   16                       1
 *       17-24                   24                       2
 *       25-32                   32                       3
 *       33-40                   40                       4
 *       41-48                   48                       5
 *       49-56                   56                       6
 *       57-64                   64                       7
 *       65-72                   72                       8
 *        ...                   ...                     ...
 *      497-504                 504                      62
 *      505-512                 512                      63

當然Python也提供了一個巨集,來描述"Size of allocated block"和"Size class idx"之間的關係:

#define INDEX2SIZE(I) (((uint)(I) + 1) << ALIGNMENT_SHIFT)
//索引為0的話, 就是1 << 3, 顯然結果為8
//索引為1的話, 就是2 << 3, 顯然結果為16
//以此類推

因此當我們申請一個 44 位元組的記憶體時, PyObject_Malloc 會從記憶體池中劃分一個 48 位元組的block給我們。

但是這樣也暴露了一個問題,首先記憶體池是由多個記憶體頁組成,每個記憶體頁劃分為多個記憶體塊(block),這些後面會說。假設我們申請 7 位元組的記憶體,那麼毫無疑問會給我們一個 8 位元組的塊;但是當我們申請 1 位元組的時候,分配給我們的還是 8 位元組的塊,因為最小的塊就是 8 位元組。

這種做法好處顯而易見,前面提到的問題均得到解決。此外這種方式是字對齊的,記憶體以字對齊的方式可以提高讀寫速度。字大小從早期硬體的 2 位元組、4 位元組,慢慢發展到現在的 8 位元組,甚至 16 位元組。

當然了,有得必有失,記憶體利用率成了被犧牲的因素,以8位元組記憶體塊為例,平均利用率為 (1+8)/2/8*100% ,大約只有 56.25% 。當然對於現在的機器而言,完全是可以容忍的。

另外在 Python 中,block其實也只是一個概念,在 Python 原始碼中沒有與之對應的實體存在。之前我們說物件,物件在原始碼中有對應的 PyObject ,列表在原始碼中則有對應的 PyListObject ,但是這裡的block僅僅是概念上的東西,我們知道它是具有一定大小的記憶體,但是它並不與 Python 原始碼裡面的某個東西對應。但是, Python 提供了一個管理block的東西,也就是我們下面要分析的pool。

pool

一組block的集合稱為一個pool,換句話說,一個pool管理著一堆具有固定大小的記憶體塊(block)。事實上,pool管理著一大塊記憶體,它有一定的策略,將這塊大的記憶體劃分為多個小的記憶體塊。在Python中,一個pool的大小通常是為一個系統記憶體頁,也就是4kb。

//Objects/obmalloc.c
#define SYSTEM_PAGE_SIZE        (4 * 1024)
#define SYSTEM_PAGE_SIZE_MASK   (SYSTEM_PAGE_SIZE - 1)
#define POOL_SIZE               SYSTEM_PAGE_SIZE        /* must be 2^N */
#define POOL_SIZE_MASK          SYSTEM_PAGE_SIZE_MASK

雖然Python沒有為block提供對應的結構,但是提供了和pool相關的結構,我們說Python是將記憶體頁看成由一個個記憶體塊(block)組成的池子(pool),我們來看看pool的結構:

//Objects/obmalloc.c
/* Pool for small blocks. */
struct pool_header {
    union { block *_padding;
            uint count; } ref;          /* 當前pool裡面已分配出去的block數量 */
    block *freeblock;                   /* 指向空閒block連結串列的第一塊 */
    
    /* 底層會有多個pool, 多個pool之間也會形成一個連結串列 */
    struct pool_header *nextpool;       /* 所以nextpool指向下一個pool */
    struct pool_header *prevpool;       /* prevpool指向上一個pool */
    uint arenaindex;                    /* 在area裡面的索引(area後面會說) */
    uint szidx;                         /* 尺寸類別編號, 如果是2, 那麼管理的block的大小就是24 */
    uint nextoffset;                    /* 下一個可用block的記憶體偏移量 */
    uint maxnextoffset;                 /* 最後一個block距離開始位置的偏移量 */
};

typedef struct pool_header *poolp;

我們剛才說了一個pool的大小在Python中是4KB,但是從當前的這個pool的結構體來看,用鼻子想也知道吃不完4KB(4048位元組)的記憶體,事實上這個結構體只佔48位元組。所以呀,這個結構體叫做pool_header,它僅僅一個pool的頭部,除去這個pool_header,剩下的記憶體才是維護的所有block的集合所佔的記憶體。

我們注意到,pool_header裡面有一個szidx,這就意味著pool裡面管理的記憶體塊大小都是一樣的。也就是說,一個pool管理的block可以是32位元組、也可以是64位元組,但是不會出現既有32位元組的block、又有64位元組的block。每一個pool都和一個size聯絡在一起,更確切的說都和一個size class index聯絡在一起,表示pool裡面儲存的block都是多少位元組的。這就是裡面的域szidx存在的意義。

我們以16位元組(szidx=1)的block為例,看看Python是如何將一塊4KB的記憶體改造成管理16位元組block的pool:

//Objects/obmalloc.c
#define POOL_OVERHEAD   _Py_SIZE_ROUND_UP(sizeof(struct pool_header), ALIGNMENT)

static void*
pymalloc_alloc(void *ctx, size_t nbytes)
{
    block *bp;
    poolp pool;
    poolp next;
    uint size;
    //......
    //......
    init_pool: //pool指向了一塊4KB的記憶體
        next = usedpools[size + size]; /* == prev */
        pool->nextpool = next;
        pool->prevpool = next;
        next->nextpool = pool;
        next->prevpool = pool;
        pool->ref.count = 1;
        //......
        //設定pool的size class index
        pool->szidx = size;
        //一個巨集, 將szidx轉成記憶體塊的大小, 比如: 0->8, 1->16, 63->512
        size = INDEX2SIZE(size);
        //跳過用於pool_header的記憶體,並進行對齊
        bp = (block *)pool + POOL_OVERHEAD;
        //等價於pool->nextoffset = POOL_OVERHEAD+size+size
        pool->nextoffset = POOL_OVERHEAD + (size << 1);
        pool->maxnextoffset = POOL_SIZE - size;
        pool->freeblock = bp + size;
        *(block **)(pool->freeblock) = NULL;
        goto success;
    }
   //.....
success:
    assert(bp != NULL);
    return (void *)bp;

failed:
    return NULL;
}

注意最後的(void *)bp;,它指的就是pool的freeblock域。我們說它指向的是pool中的第一塊空閒block、或者說可用block,但是新記憶體頁總是由記憶體請求觸發,所以第一個block一定會被分配出去,因此這裡的bp最後指向的只能是第二個、或者第二個之後的記憶體塊。而且從ref.count中我們也可以看出端倪,我們說ref.count記錄了當前已經被分配的block的數量,但初始化的時候不是0,而是1。最終改造成pool之後的4kb記憶體如圖所示:

實線箭頭是指標,但是虛線箭頭則是偏移位置的形象表示。在nextoffset,maxnextoffset中儲存的是相對於pool頭部的偏移位置。

在瞭解初始化之後的pool的樣子之後,可以來看看Python在申請block時,pool_header中的各個域是怎麼變動的。假設我們再申請1塊16位元組的記憶體塊:

//Objects/obmalloc.c
static void*
pymalloc_alloc(void *ctx, size_t nbytes)
{
    //......
    if (pool != pool->nextpool) {
        //首先pool中已分配的block數自增1
        ++pool->ref.count;
        //這裡的freeblock指向的是下一個可用的block的起始地址
        bp = pool->freeblock;
        assert(bp != NULL);
        if ((pool->freeblock = *(block **)bp) != NULL) {
            goto success;
        }

        //因此當再次申請16位元組block時,只需要返回freeblock指向的地址就可以了。
        //那麼很顯然,freeblock需要前進,指向下一個可用的block,這個時候nextoffset就現身了
        if (pool->nextoffset <= pool->maxnextoffset) {
            //當nextoffset小於等於maxoffset時候
            //freeblock等於當前block的地址 + nextoffset(下一個可用block的記憶體偏移量)
            //所以freeblock正好指向了下一個可用block的地址
            pool->freeblock = (block*)pool +
                              pool->nextoffset;
            //同理,nextoffset也要向前移動一個block的距離
            pool->nextoffset += INDEX2SIZE(size);
            //依次反覆,即可對所有的block進行遍歷。而maxnextoffset指明瞭該pool中最後一個可用的block距離pool開始位置的偏移
            //當pool->nextoffset > pool->maxnextoffset就意味著遍歷完pool中的所有block了
            //再次獲取顯然就是NULL了
            *(block **)(pool->freeblock) = NULL;
            goto success;
        }

        /* Pool is full, unlink from used pools. */
        next = pool->nextpool;
        pool = pool->prevpool;
        next->prevpool = pool;
        pool->nextpool = next;
        goto success;
    }
    //......
}

所以當我們再申請1塊16位元組的記憶體塊時,pool的結構圖就變成了這樣:

首先freeblock指向了第三塊block,仍然是第一塊可用block;注意:nextoffset,它表示下一塊可用block的偏移量,顯然下一塊的可用block是第三塊,因此48 + 16 * 3 = 96,前進了16位元組的偏移量;至於maxnextoffset仍然是4080,它是不變的。

隨著記憶體分配的請求不斷髮起,空閒的block(記憶體塊)也將不斷地分配出去,freeblock不斷前進、指向下一個可用記憶體塊,nextoffset也在不斷前進、偏移量每次增加記憶體塊的大小,直到所有的空閒記憶體塊被消耗完。

所以,申請、前進、申請、前進,一直重複著相同的動作,整個過程非常自然,也很容易理解。但是我們知道一個pool裡面的block都是相同大小的,這就使得一個pool只能滿足POOL_SIZE / size次對block的申請,但是這樣存在一個問題,舉個栗子:

我們知道記憶體塊不可能一直被使用,肯定有釋放的那一天。假設我們分配了兩個記憶體塊,理論上下一次應該申請第三個記憶體塊,但是某一時刻第一個記憶體塊被釋放了,那麼下一次申請的時候,Python是申請第一個記憶體塊、還是第三個記憶體塊呢?

顯然為了pool的使用效率,最好分配第一個block。因此可以想象,一旦Python運轉起來,記憶體的釋放動作將導致pool中出現大量的離散的自由block,Python為了知道哪些block是被使用之後再次被釋放的,必須建立一種機制,將這些離散自由的block組合起來,再次使用。這個機制就是所有的自由block連結串列(freeblock list),這個連結串列的關鍵就在pool_header中的那個freeblock身上。

再來回顧一下pool_header的定義:

//Objects/obmalloc.c
/* Pool for small blocks. */
struct pool_header {
    union { block *_padding;
            uint count; } ref;          
    block *freeblock;                   /* 指向空閒block連結串列的第一塊 */
    struct pool_header *nextpool;       
    struct pool_header *prevpool;       
    uint arenaindex;                    
    uint szidx;                         
    uint nextoffset;                    
    uint maxnextoffset;                 
};

typedef struct pool_header *poolp;

當pool初始化完後之後,freeblock指向了一個有效的地址,也就是下一個可以分配出去的block的地址。然而奇特的是,當Python設定了freeblock時,還設定了 *freeblock。這個動作看似詭異,然而我們馬上就能看到設定 *freeblock的動作正是建立離散自由block連結串列的關鍵所在。目前我們看到的freeblock只是在機械地前進前進,因為它在等待一個特殊的時刻,在這個特殊的時刻,你會發現freeblock開始成為一個甦醒的精靈,在這4kb的記憶體上開始靈活地舞動,這個特殊的時刻就是一個block被釋放的時刻。

//Objects/obmalloc.c

//基於地址P獲得離P最近的pool的邊界地址
#define POOL_ADDR(P) ((poolp)_Py_ALIGN_DOWN((P), POOL_SIZE))

static int
pymalloc_free(void *ctx, void *p)
{
    poolp pool;
    block *lastfree;
    poolp next, prev;
    uint size;

    assert(p != NULL);

    pool = POOL_ADDR(p);
    //如果p不再pool裡面,直接返回0
    if (!address_in_range(p, pool)) {
        return 0;
    }
    //釋放,那麼ref.count就勢必大於0
    assert(pool->ref.count > 0);            /* else it was empty */
    *(block **)p = lastfree = pool->freeblock;
    pool->freeblock = (block *)p;
    //......
}

在釋放block時,神祕的freeblock驚鴻一瞥,顯然覆蓋在freeblock身上的那層面紗就要被揭開了。我們知道,這是freeblock雖然指向了一個有效的pool裡面的地址,但是 *freeblock是為NULL的。假設這時候Python釋放的是block 1,那麼block 1中的第一個位元組的值被設定成了當前freeblock的值,然後freeblock的值被更新了,指向了block 1的首地址。就是這兩個步驟,一個block被插入到了離散自由的block連結串列中。

簡單點,說人話就是:原來freeblock指向block 3,現在變成了block 1指向block 3,而freeblock則指向了block 1。

所以pool的結構圖變化如下:

到了這裡,這條實現方式非常奇特的block連結串列被我們挖掘出來了,從freeblock開始,我們可以很容易的以freeblock = *freeblock的方式遍歷這條連結串列,而當發現了*freeblock為NULL時,則表明到達了該連結串列(可用自由連結串列)的尾部了,那麼下次就需要申請新的block了。

//Objects/obmalloc.c

static void*
pymalloc_alloc(void *ctx, size_t nbytes)
{
    if (pool != pool->nextpool) {
        ++pool->ref.count;
        bp = pool->freeblock;
        assert(bp != NULL);
        //如果這裡的條件不為真,表明離散自由連結串列中已經不存在可用的block了
        //如果為真那麼代表存在,則會繼續分配pool的nextoffset指定的下一塊block
        if ((pool->freeblock = *(block **)bp) != NULL) {
            goto success;
        }
        
        //離散自由block連結串列中不存在,則從pool裡面申請新的block
        if (pool->nextoffset <= pool->maxnextoffset) {
            pool->freeblock = (block*)pool +
                              pool->nextoffset;
            pool->nextoffset += INDEX2SIZE(size);
            *(block **)(pool->freeblock) = NULL;
            goto success;
        }

        //......
    }    
}

因此我們可以得出,一個pool在其宣告週期內,可以處於以下三種狀態:

為什麼要討論pool的狀態呢?我們在上面的程式碼中說自由連結串列中不存在可用的block時,會從pool中申請,但是顯然是有條件的。我們看到必須滿足:pool->nextoffset <= pool->maxnextoffset才行,但如果連這個條件都不成立了呢?而這個條件不成立顯然意味著pool中已經沒有可用的block了,因為pool是有大小限制的。所以這個時候想在申請一個block要怎麼做?答案很簡單,再來一個pool不就好了,然後從新的pool裡面申請。

所以block組合起來可以成為一個pool,那麼同理多個pool也是可以組合起來的。而多個pool組合起來會得到什麼呢,我們說記憶體池是分層次的,從下至上分別是:block、pool、arena,顯然多個pool組合起來,可以得到我們下面要介紹的arena。

arena

在Python中,多個pool聚合的結果就是一個arena。上一節提到,pool的大小預設是4kb,同樣每個arena的大小也有一個預設值。#define ARENA_SIZE (256 << 10),顯然這個值預設是256KB,也就是ARENA_SIZE / POOL_SIZE = 64個pool的大小。我們來看看arena的底層結構體定義,同樣藏身於 Objects/obmalloc.c 中。

struct arena_object {
    //arena的地址
    uintptr_t address;

    //池對齊指標,指向下一個被劃分的pool
    block* pool_address;

    //該arena中可用pool的數量
    uint nfreepools;

    // 該arena中所有pool的數量
    uint ntotalpools;

    //我們在介紹pool的時候說過,pool之間也會形成一個連結串列,而這裡freepools指的是第一個可用pool
    struct pool_header* freepools;

    //從名字上也能看出:nextarena指向下一個arena、prevarena指向上一個arena
    //是不是說明arena之間也會組成連結串列呢?答案不是的,其實多個arena之間組成的是一個數組,至於為什麼我們下面說
    struct arena_object* nextarena;
    struct arena_object* prevarena;
};

一個概念上的arena在Python原始碼中就對應一個arena_object結構體例項,確切的說,arena_object僅僅是arena的一部分。就像pool_header僅僅是pool的一部分一樣,一個完整的pool包括一個pool_header和透過這個pool_header管理的block集合;一個完整的arena也包括一個arena_object和透過這個arena_object管理的pool集合。

"未使用的"的arena和"可用"的arena

在arena_object結構體的定義中,我們看到了nextarena和prevarena這兩個東西,這似乎意味著在Python中會有一個或多個arena構成的連結串列。呃,這種猜測實際上只對了一半,實際上,在Python中確實會存在多個arena_object構成的集合,但是這個集合不夠成連結串列,而是一個數組。陣列的首地址由arenas來維護,這個陣列就是Python中的通用小塊記憶體的記憶體池。另一方面,nextarea和prevarena也確實是用來連線arena_object組成連結串列的,咦,不是已經構成或陣列了嗎?為啥又要來一個連結串列。

我們曾說arena是用來管理一組pool的集合的,arena_object的作用看上去和pool_header的作用是一樣的。但是實際上,pool_header管理的記憶體(block所使用)和arena_object管理的記憶體(pool所使用)有一點細微的差別,pool_header管理的記憶體pool_header自身是一塊連續的記憶體,但是arena_object與其管理的記憶體則是分離的:

咋一看,貌似沒啥區別,不過一個是連著的,一個是分開的。但是這後面隱藏了這樣一個事實:當pool_header被申請時,它所管理的記憶體也一定被申請了;但是當arena_object被申請時,它所管理的pool集合的記憶體則沒有被申請。換句話說,arena_object和pool集合在某一時刻需要建立聯絡。

當一個arena的arena_object沒有與pool集合建立聯絡的時候,這時的arena就處於"未使用"狀態;一旦建立了聯絡,這時arena就轉換到了"可用"狀態。對於每一種狀態,都有一個arena連結串列。"未使用"的arena連結串列表頭是unused_arena_objects,多個arena之間通過nextarena連線,並且是一個單向的連結串列;而"可用的"arena連結串列表頭是usable_arenas,多個arena之間通過nextarena、prevarena連線,是一個雙向連結串列。

申請arena

在執行期間,Python使用new_arena來建立一個arena,我們來看看它是如何被建立的。

//arenas,多個arena組成的陣列的首地址
static struct arena_object* arenas = NULL;

//當arena陣列中的所有arena的個數
static uint maxarenas = 0;

//未使用的arena的個數
static struct arena_object* unused_arena_objects = NULL;

//可用的arena的個數
static struct arena_object* usable_arenas = NULL;

//初始化需要申請的arena的個數
#define INITIAL_ARENA_OBJECTS 16

static struct arena_object*
new_arena(void)
{	
    //arena,一個arena_object結構體物件
    struct arena_object* arenaobj;
    uint excess;        /* number of bytes above pool alignment */
    
    //[1]:判斷是否需要擴充"未使用"的arena列表
    if (unused_arena_objects == NULL) {
        uint i;
        uint numarenas;
        size_t nbytes;

        //[2]:確定本次需要申請的arena_object的個數,並申請記憶體
        numarenas = maxarenas ? maxarenas << 1 : INITIAL_ARENA_OBJECTS;
        nbytes = numarenas * sizeof(*arenas);
        arenaobj = (struct arena_object *)PyMem_RawRealloc(arenas, nbytes);
        if (arenaobj == NULL)
            return NULL;
        arenas = arenaobj;

        //[3]:初始化新申請的arena_object,並將其放入"未使用"arena連結串列中
        for (i = maxarenas; i < numarenas; ++i) {
            arenas[i].address = 0;              /* mark as unassociated */
            arenas[i].nextarena = i < numarenas - 1 ?
                                   &arenas[i+1] : NULL;
        }

        /* Update globals. */
        unused_arena_objects = &arenas[maxarenas];
        maxarenas = numarenas;
    }

    /* Take the next available arena object off the head of the list. */
    //[4]:從"未使用"arena連結串列中取出一個"未使用"的arena
    assert(unused_arena_objects != NULL);
    arenaobj = unused_arena_objects;
    unused_arena_objects = arenaobj->nextarena;
    assert(arenaobj->address == 0);
    //[5]:申請arena管理的記憶體
    address = _PyObject_Arena.alloc(_PyObject_Arena.ctx, ARENA_SIZE);
    if (address == NULL) {
        arenaobj->nextarena = unused_arena_objects;
        unused_arena_objects = arenaobj;
        return NULL;
    }
    arenaobj->address = (uintptr_t)address;
    //調整個數
    ++narenas_currently_allocated;
    ++ntimes_arena_allocated;
    if (narenas_currently_allocated > narenas_highwater)
        narenas_highwater = narenas_currently_allocated;
    //[6]:設定poo集合的相關資訊,這是設定為NULL
    arenaobj->freepools = NULL;
    arenaobj->pool_address = (block*)arenaobj->address;
    arenaobj->nfreepools = MAX_POOLS_IN_ARENA;
    //將pool的起始地址調整為系統頁的邊界
    excess = (uint)(arenaobj->address & POOL_SIZE_MASK);
    if (excess != 0) {
        --arenaobj->nfreepools;
        arenaobj->pool_address += POOL_SIZE - excess;
    }
    arenaobj->ntotalpools = arenaobj->nfreepools;

    return arenaobj;
}

因此我們可以看到,Python首先會檢查當前"未使用"連結串列中是否還有"未使用"arena,檢查的結果將決定後續的動作。

如果在"未使用"連結串列中還存在未使用的arena,那麼Python會從"未使用"arena連結串列中抽取一個arena,接著調整"未使用"連結串列,讓它和抽取的arena斷絕一切聯絡。然後Python申請了一塊256KB大小的記憶體,將申請的記憶體地址賦給抽取出來的arena的address。我們已經知道,arena中維護的是pool集合,這塊256KB的記憶體就是pool的容身之處,這時候arena就已經和pool集合建立聯絡了。這個arena已經具備了成為"可用"記憶體的條件,該arena和"未使用"arena連結串列脫離了關係,就等著被"可用"arena連結串列接收了,不過什麼時候接收呢?先別急。

隨後,python在程式碼的[6]處設定了一些arena用於維護pool集合的資訊。需要注意的是,Python將申請到的256KB記憶體進行了處理,主要是放棄了一些記憶體,並將可使用的記憶體邊界(pool_address)調整到了與系統頁對齊。然後通過arenaobj->freepools = NULL;將freepools設定為NULL,這不奇怪,基於對freeblock的瞭解,我們知道要等到釋放一個pool時,這個freepools才會有用。最後我們看到,pool集合佔用的256KB記憶體在進行邊界對齊後,實際是交給pool_address來維護了。

回到new_arena中的[1]處,如果unused_arena_objects為NULL,則表明目前系統中已經沒有"未使用"arena了,那麼Python首先會擴大系統的arena集合(小塊記憶體記憶體池)。Python在內部通過一個maxarenas的變數維護了儲存arena的陣列的個數,然後在[2]處將待申請的arena的個數設定為當然arena個數(maxarenas)的2倍。當然首次初始化的時候maxarenas為0,此時為16。

在獲得了新的maxarenas後,Python會檢查這個新得到的值是否溢位了。如果檢查順利通過,Python就會在[3]處通過realloc擴大arenas指向的記憶體,並對新申請的arena_object進行設定,特別是那個不起眼的address,要將新申請的address一律設定為0。實際上,這是一個標識arena是出於"未使用"狀態還是"可用"狀態的重要標記。而一旦arena(arena_object)和pool集合建立了聯絡,這個address就變成了非0,看程式碼的[6]處。當然別忘記我們為什麼會走到[3]這裡,是因為unused_arena_objects == NULL了,而且最後還設定了unused_arena_objects,這樣系統中又有了"未使用"的arena了,接下來Python就在[4]處對一個arena進行初始化了。

記憶體池

通過#define SMALL_REQUEST_THRESHOLD 512我們知道Python內部預設的小塊記憶體與大塊記憶體的分界點為512個位元組。也就是說,當申請的記憶體小於512個位元組,pymalloc_alloc會在記憶體池中申請記憶體,而當申請的記憶體超過了512位元組,那麼pymalloc_alloc將退化為malloc,通過作業系統來申請記憶體。當然,通過修改Python原始碼我們可以改變這個值,從而改變Python的預設記憶體管理行為。

當申請的記憶體小於512位元組時,Python會使用area所維護的記憶體空間。那麼Python內部對於area的個數是否有限制呢?換句話說,Python對於這個小塊空間記憶體池的大小是否有限制?其實這個決策取決於使用者,Python提供了一個編譯符號,用於控制是否限制記憶體池的大小,不過這裡不是重點,只需要知道就行。

儘管我們在前面花了不少篇幅介紹arena,同時也看到arena是Python小塊記憶體池的最上層結構,其實所有arena的集合就是小塊記憶體池。然而在實際的使用中,Python並不直接與arenas和arena陣列打交道。當Python申請記憶體時,最基本的操作單元並不是arena,而是pool。估計到這裡懵了,別急,慢慢來。

舉個例子,當我們申請一個28位元組的記憶體時,Python內部會在記憶體池尋找一塊能夠滿足需求的pool,從中取出一個block返回,而不會去尋找arena。這實際上是由pool和arena的屬性決定的,在Python中,pool是一個有size概念的記憶體管理抽象體,一個pool中的block總是有確定的大小,這個pool總是和某個size class index對應,還記得pool_header中的那個szidx麼?而arena是沒有size概念的記憶體管理抽象體。這就意味著,同一個arena在某個時刻,其內部的pool集合管理的可能都是相同位元組的block,比如:32位元組;而到了另一個時刻,由於系統需要,這個arena可能被重新劃分,其中的pool集合管理的block可能變成是64位元組了,甚至pool集合中一半的pool管理的是32位元組block,另一半管理64位元組block。這就決定了在進行記憶體分配和銷燬時,所有的動作都是在pool上完成的。

所以一個arena,並不要求pool集合中所有pool管理的block必須一樣;可以有管理16位元組block的pool,也可以有管理32位元組block的pool,等等。

當然記憶體池中的pool不僅僅是一個有size概念的記憶體管理抽象體,更進一步的,它還是一個有狀態的記憶體管理抽象體。正如我們之前說的,一個pool在Python執行的任何一個時刻,總是處於以下三種狀態中的一種:

  • empty狀態:pool中所有的block都未被使用
  • used狀態:pool中至少有一個block已經被使用,並且至少有一個block未被使用
  • full狀態:pool中所有的block都已經被使用,這種狀態的pool在arena中,但是不在arena的freepools連結串列中。

而且pool處於不同的狀態,也會得到Python不同的對待:

  • 如果pool完全空閒,那麼Python會將它佔用的記憶體頁歸還給作業系統、或者快取起來,後續需要重新分配時直接拿來用。
  • 如果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的可用記憶體塊用完了,就將其從可用pool連結串列中剔除。

當一個記憶體塊(block)被回收,Python根據塊地址計算得到距離該塊最近的pool邊界地址,計算方式就是我們上面說的那個巨集:POOL_ADDR,將塊(block)地址對齊為記憶體頁(pool)尺寸的整數倍,便得到pool地址。

得到pool地址後,Python將空閒記憶體塊插入到空閒記憶體塊連結串列的頭部,如果pool狀態是由full變成used,那麼Python還會將它插回到可用pool連結串列的頭部。

插入到可用pool連結串列頭部是為了保證較滿的pool在連結串列的前面,以便優先使用。位於尾部的pool被使用的概率很低,隨著時間的推移,更多的記憶體塊被釋放出來,慢慢變空。因此可用pool連結串列很明顯頭重腳輕,靠前的pool比較慢,靠後的pool比較空。

當一個pool中所有的記憶體塊(block)都被釋放,狀態就變成了empty,那麼Python就會將它移除可用pool連結串列,記憶體頁可能直接歸還給作業系統,或者快取起來備用:

實際上,pool連結串列任一節點均有機會完全空閒下來,這由概率決定,尾部節點概率最高。

可用pool連結串列陣列

Python記憶體池管理記憶體塊,按照尺寸分門別類進行。因此每種規格都需要維護一個獨立可執行的可用pool連結串列,以8直接為梯度,那麼會有64中pool連結串列。

那麼如何組織這麼多pool連結串列呢?最直接的辦法就是分配一個長度為64的虛擬節點陣列,這個虛擬節點陣列就是我們上面提到過的usedpools。Python內部維護的usedpools陣列是一個非常巧妙的實現,該陣列維護著所有的處於used狀態的pool。當申請記憶體時,Python就會通過usedpools尋找到一個可用的pool(處於used狀態),從中分配一個block。因此我們想,一定有一個usedpools相關聯的機制,完成從申請的記憶體的大小到size class index之間的轉換,否則Python就無法找到最合適的pool了。這種機制和usedpools的結構有著密切的關係,而usedpools也藏身於 Objects/obmalloc.c 中。但是我們暫時先不看它的結構,因為還缺少一個東西,我們後面會說。

然後如果程式請求 5 位元組,Python 將分配 8 位元組記憶體塊,通過陣列第 0 個虛擬節點即可找到 8 位元組 pool 連結串列;如果程式請求 56 位元組,Python 將分配 64 位元組記憶體塊,則需要從陣列第 7 個虛擬節點出發;其他以此類推。

那麼,虛擬節點陣列需要佔用多少記憶體呢?很好計算:48 * 64 = 3072位元組,也就是3KB的記憶體,0.75個記憶體頁。

話說3KB的記憶體,你們覺得多嗎?對於現在的機器來說,3KB可以忽略不計吧。但是高階程式猿對記憶體的精打細算,完全堪比、甚至凌駕於菜市場買菜的大媽,所以Python的作者從中還扣掉了三分之二。相當於只給虛擬機器節點原來的三分之一、也就是1KB的記憶體,那麼這是如何做到的呢?

事實上我們在前面已經埋下伏筆了,虛擬節點只參與維護連結串列結構,並不管了記憶體頁。因此虛擬節點其實只使用pool_header結構體中參與連結串列維護的nextpool和prevpool兩個指標欄位。

為避免淺藍色部分記憶體浪費,Python 作者們將虛擬節點想象成一個個卡片,將深藍色部分首尾相接,最終轉換成一個純指標陣列。

而這個純指標陣列就是在 Objects/obmalloc.c 中定義的 usedpools ,每個虛擬節點對應數組裡面的兩個指標。所以之前我們說先不看 usedpools 的結構體定義,就是因為直接看的話絕對會一臉懵,因為不知道數組裡面存的是啥,但是現在我們知道了數組裡面存的就是一堆指標:

typedef uint8_t block;
#define PTA(x)  ((poolp )((uint8_t *)&(usedpools[2*(x)]) - 2*sizeof(block *)))
#define PT(x)   PTA(x), PTA(x)

//NB_SMALL_SIZE_CLASSES之前好像出現過,但是不用說也知道這表示當前配置下有多少個不同size的塊
//在我當前的機器就是512/8=64個,對應的size class index就是從0到63
#define NB_SMALL_SIZE_CLASSES   (SMALL_REQUEST_THRESHOLD / ALIGNMENT)

static poolp usedpools[2 * ((NB_SMALL_SIZE_CLASSES + 7) / 8) * 8] = {
    PT(0), PT(1), PT(2), PT(3), PT(4), PT(5), PT(6), PT(7)
#if NB_SMALL_SIZE_CLASSES > 8
    , PT(8), PT(9), PT(10), PT(11), PT(12), PT(13), PT(14), PT(15)
#if NB_SMALL_SIZE_CLASSES > 16
    , PT(16), PT(17), PT(18), PT(19), PT(20), PT(21), PT(22), PT(23)
#if NB_SMALL_SIZE_CLASSES > 24
    , PT(24), PT(25), PT(26), PT(27), PT(28), PT(29), PT(30), PT(31)
#if NB_SMALL_SIZE_CLASSES > 32
    , PT(32), PT(33), PT(34), PT(35), PT(36), PT(37), PT(38), PT(39)
#if NB_SMALL_SIZE_CLASSES > 40
    , PT(40), PT(41), PT(42), PT(43), PT(44), PT(45), PT(46), PT(47)
#if NB_SMALL_SIZE_CLASSES > 48
    , PT(48), PT(49), PT(50), PT(51), PT(52), PT(53), PT(54), PT(55)
#if NB_SMALL_SIZE_CLASSES > 56
    , PT(56), PT(57), PT(58), PT(59), PT(60), PT(61), PT(62), PT(63)
#if NB_SMALL_SIZE_CLASSES > 64
#error "NB_SMALL_SIZE_CLASSES should be less than 64"
#endif /* NB_SMALL_SIZE_CLASSES > 64 */
#endif /* NB_SMALL_SIZE_CLASSES > 56 */
#endif /* NB_SMALL_SIZE_CLASSES > 48 */
#endif /* NB_SMALL_SIZE_CLASSES > 40 */
#endif /* NB_SMALL_SIZE_CLASSES > 32 */
#endif /* NB_SMALL_SIZE_CLASSES > 24 */
#endif /* NB_SMALL_SIZE_CLASSES > 16 */
#endif /* NB_SMALL_SIZE_CLASSES >  8 */
};

然後將對應的兩個指標的前後空間都想象成是自己的,這樣就能夠得到一個虛無縹緲、但又非常完整的pool_header結構體。儘管它們前後的空間不是自己的,但是不妨礙精神層面上YY一下,不過由於我們不會訪問除了nextpool和prevpool指標之外的其它欄位,所以雖然有記憶體越界,但也無傷大雅。

以一個代表空連結串列的虛擬節點為例,nextpoolprevpool 指標均指向 pool_header 自己。雖然實際上 nextpoolprevpool 都指向了陣列中的其他虛擬節點,但邏輯上可以想象成指向當前的 pool_header 結構體:

經過這番優化,陣列只需要 16 * 64 = 1024 位元組的記憶體空間即可,也就是1KB,所以節省了三分之二。然而為了節省這三分之二的記憶體,程式碼變得難以理解。當然Python誕生的那個年代,記憶體還是比較精貴的,所以秉承著能省則省的策略,然後這個優良傳統一直保持到了現在。

小結

對於一個用C開發的龐大的軟體(python是一門高階語言,但是執行對應程式碼的直譯器則可以看成是c的一個軟體),其中的記憶體管理可謂是最複雜、最繁瑣的地方了。不同尺度的記憶體會有不同的抽象,這些抽象在各種情況下會組成各式各樣的連結串列,非常複雜。但是我們還是有可能從一個整體的尺度上把握整個記憶體池,儘管不同的連結串列變幻無常,但我們只需記住,所有的記憶體都在arenas(或者說那個存放多個arena的陣列)的掌握之中 。

更詳細的內容可以自己進入 Objects/obmalloc.c 中檢視對應原始碼,主要看兩個函式:

  • pymalloc_alloc: 負責記憶體分配
  • pymalloc_free: 負責記憶體釋放

關於記憶體管理和記憶體池我們就說到這裡,下一篇介紹Python中的垃圾回收。