第6篇CPython內(nèi)存模型架構(gòu)-Layer 2 - 內(nèi)存池緩存陣列

在Python3.x中,Python內(nèi)部默認的小塊內(nèi)存與大塊內(nèi)存的分界點是512字節(jié),我們知道當小于512字節(jié)的內(nèi)存請求,PyObject_Malloc會在內(nèi)存池中申請內(nèi)存,當申請的內(nèi)存大于512字節(jié),PyObject_Malloc的行為會蛻化為malloc的行為。例如當我們申請一個28字節(jié)的內(nèi)存時,Python內(nèi)部會在內(nèi)存池尋找一塊能滿足需求的pool,并從中取出一個block,而不會去需找arena,這實際上事由pool和arena自身的屬性確定的,pool有一個size概念的內(nèi)存管理抽象體,一個pool中的block總是有確定的類型尺寸.pool_header結(jié)構(gòu)體定義中有一個szidx就是指定了對應(yīng)的pool分配出去的塊的最小的塊單位-類型尺寸(size class),然而arena沒有size idx的概念,這意味著同一個arena,在某個時刻,其托管的內(nèi)存池集合可能是size class為32字節(jié)的內(nèi)存池,而另一個時刻該內(nèi)存池可能會被重新劃分,變?yōu)?4字節(jié)的block。

我們在討論單個內(nèi)存池時,有涉及池狀態(tài)的概念。這里復(fù)習(xí)一下

  • used:池中至少由一個block已經(jīng)正在使用,并且至少由一個block還未被使用,這種狀態(tài)的內(nèi)存池由CPython的usedpool統(tǒng)一管理
  • full狀態(tài):pool中所有block都已正在使用,這種狀態(tài)的pool在arena托管的池集合內(nèi),但不再arena的freepools鏈表中。
  • empty狀態(tài):pool中的所有狀態(tài)都未被使用,處于這個狀態(tài)的pool的集合通過其pool_header結(jié)構(gòu)體的nextpool構(gòu)成一個鏈表,這個鏈表的表頭就是arena_object結(jié)構(gòu)體的freepools指針。

解讀usedpools數(shù)組

Python內(nèi)部通過使用usedpools數(shù)組,維護者所有處于used狀態(tài)的pool。當申請內(nèi)存size class為N時,Python會通過usedpools查找到與N對應(yīng)的size idx可用的內(nèi)存池,從中分配一個類型尺寸為N的塊,我們看看Objects/obmalloc.c源代碼的第1101行到1130行定義,其中的NB_SMALL_SIZE_CLASSES標識當前的CPython實現(xiàn)有多少個size class,對于CPython3.6之前表示有64種size class,CPython3.7之后有32種size class.

#define SMALL_REQUEST_THRESHOLD 512
#define NB_SMALL_SIZE_CLASSES   (SMALL_REQUEST_THRESHOLD / ALIGNMENT)

參考如下源代碼的第1101行到1130行。

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

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_CLASSE

由于任意一個usedpools元素項的表達式為PT(x)等價于“PTA(x),PTA(x)”,那么usedpools的中間形式均等價如下

對于CPython 3.6之前的版本,按8字節(jié)對齊,上面的usedpools數(shù)組形式,等價于以下代碼

static poolp usedpools[142] = {
    PTA(0), PTA(0), PTA(1), PTA(1), PTA(2), PTA(2), PTA(3), PTA(3),
    PTA(4), PTA(4), PTA(5), PTA(5), 
    ...PTA(70),PTA(70)
}

對于CPython3.7之后的版本,按16字節(jié)對齊,上面的usedpools數(shù)組形式,等價于以下代碼

static poolp usedpools[78] = {
    PTA(0), PTA(0), PTA(1), PTA(1), PTA(2), PTA(2), PTA(3), PTA(3),
    PTA(4), PTA(4), PTA(5), PTA(5), 
    ...PTA(38),PTA(38)
}

好了,從任意一個PTA(x)的元素項,等價于

((poolp )((uint8_t *)&(usedpools[2*(x)]) - 2*sizeof(block *)))

其實整個usedpools數(shù)組的核心難點就是該PTA(x)的宏等價表達式。這個宏表達式意思是,指向usedpools[2x]的地址再向前偏移sizeof(pool_header.ref)+sizeof(block)后的地址,顯然我們知道在x86_64的C編譯器中剛好是16個字節(jié),從pool_header結(jié)構(gòu)體的定義發(fā)現(xiàn)從pool_header首個字節(jié)到nextpool字段首個字節(jié)的邊界,剛好相差16個字節(jié)的偏移量。

我們?nèi)绾卫斫庹麄€usedpools的內(nèi)部運行機制呢?相信看過《Python源碼剖析:深度探索動態(tài)語言核心技術(shù)》這本的大伙們應(yīng)該知道那本書會給出直觀地給出下面的圖例,包括國內(nèi)任何有關(guān)CPython內(nèi)存管理的源碼分析的技術(shù)文章,基本上會引用到該圖。但細心的讀者有否提出個疑問這個圖憑什么依據(jù)繪制出來的呢?對于剛接觸C代碼的程序員可能不太好理解。


來源:《Python源碼剖析:深度探索動態(tài)語言核心技術(shù)》第445頁

其實很簡單,你在pymalloc_alloc函數(shù)或pymalloc_free函數(shù),因為它們的上下文在函數(shù)都調(diào)用usedpools數(shù)組,只要在函數(shù)結(jié)束的return語句之前,嘗試用for循環(huán)配合printf函數(shù)打印usedpools數(shù)組的元素項,如下代碼

static inline void*
pymalloc_alloc(void *ctx, size_t nbytes)
{
    ...

    //測試代碼
    for(int j=0;j<30;j++){
        printf("usedpools[%d] addr is %ld; usedpools[%d]->nextpool is %ld,
        usedpools[%d]->prevpool is %ld\n"
        ,j,&usedpools[j],j,usedpools[j]->nextpool,j,usedpools[j]->prevpool);
    }
    return (void *)bp;
}

在編譯后,嘗試在運行一次python解釋器,在運行時隨機打印前12項的usedpools元素,請留意觀察如下代碼的一些規(guī)律

Ok,還不明白的話!我們將上面的打印輸出繪制成一個usedpools數(shù)組的前12項,這回一目了然吧。


我們看看如上圖中有什么規(guī)律可循吧?。?/p>

  • 下標為偶數(shù)的元素項usedpool[2x]->nextpool始終指向usedpools[2x-2]的內(nèi)存地址。
  • 下標為偶數(shù)的元素項usedpool[2x]->prevpool始終指向usedpools[2x-2]的內(nèi)存地址。
  • 下標為奇數(shù)的元素項usedpool[2x+1]->prevpool始終指向usedpools[2x+1-3] ,即usedpools[2x-2]的內(nèi)存地。

備注:以上x是一個大于1的正整數(shù),很奇妙的是不論什么索引,當前usedpool元素項的prevpool或nextpool指針的向前偏移單位均為2倍的block尺寸。

我們用一個源代碼示例簡述一下CPython如何調(diào)用usedpools數(shù)組中的可用內(nèi)存池。例如當CPython嘗試為一個內(nèi)存量為28字節(jié)的Python對象,那么CPython會在底層執(zhí)行如下源代碼Objects/obmalloc.c的第1604行到1634行所示


static inline void*
pymalloc_alloc(void *ctx, size_t nbytes)
{
    ...
    //第一步:
    uint size = (uint)(nbytes - 1) >> ALIGNMENT_SHIFT;
    poolp pool = usedpools[size + size];
    block *bp;

    //第二步:比較pool->nextpool和當前pool的地址不同表明存在可用的內(nèi)存池
    if (LIKELY(pool != pool->nextpool)) {
        ++pool->ref.count;
        bp = pool->freeblock;
        assert(bp != NULL);
    //第三步
    if (UNLIKELY((pool->freeblock = *(block **)bp) == NULL)) {
            // Reached the end of the free list, try to extend it.
            pymalloc_pool_extend(pool, size);
    }
    else {
        /* There isn't a pool of the right size class immediately
         * available:  use a free pool.
         */
        bp = allocate_from_new_pool(size);
    }

    return (void *)bp;
}

第一步:這是一個小型對象,28字節(jié)換算成size class就是32,那么size class換算成對應(yīng)的szidx在不同版本的CPython版本中,會有不同的差別,見如下圖,Cpython3.6之前的szidx是3,CPython3.7之后的szidx是1


第二步:CPython根據(jù)對應(yīng)的szidx去訪問usedpools,對于CPython3.6之前的是usedpool[3+3],那么usedpools[6]->nextpool指向usedpools[4]的內(nèi)存地址,并從usedpools[4]所指向的內(nèi)存池(pool->freeblock)分配可用的32字節(jié)的塊

對于CPython3.7之后來說就是usedpools[1+1],那么usedpools[2]->nextpool自然就指向usedpools[0],并從usedpools[0]所指向的內(nèi)存池(pool->freeblock)分配可用的32字節(jié)的塊。


第三步:若第二步usedpools若返回的內(nèi)存池pool,并且pool中freeblock鏈表為NULL時,就會觸發(fā)pymalloc_pool_extend(pool, size)函數(shù),朝高地址方向偏移pool內(nèi)部nextoffset指針劃分新的塊,并分配該塊。

static void
pymalloc_pool_extend(poolp pool, uint size)
{
    if (UNLIKELY(pool->nextoffset <= pool->maxnextoffset)) {
        /* There is room for another block. */
        pool->freeblock = (block*)pool + pool->nextoffset;
        pool->nextoffset += INDEX2SIZE(size);
        *(block **)(pool->freeblock) = NULL;
        return;
    }

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

內(nèi)存池緩存隊列的初始化

OK,我們理解usedpools的內(nèi)存模型后,還有個問題,就是CPython如何初始化我們的usedpools的呢?當CPython啟動后,usedpool這個連續(xù)的內(nèi)存空間并不存在任何指向可用內(nèi)存池的指針。事實上CPython采取了延遲分配的策略,當確實開始申請小型對象的內(nèi)存時,CPython才科室構(gòu)建這個內(nèi)存池,正如前面所提到的,當申請28個字節(jié)的內(nèi)存時,實際上是申請32個字節(jié)的內(nèi)存,假設(shè)CPython3.6之前的版本,也就找到usedpool[4]中的pool_header指針所指向的內(nèi)存池,若在對應(yīng)的位置沒有找到任何pool_header指針,怎么辦呢?請回憶一下前一篇所說過arenas對象,CPython會從usable_arenas鏈表中的第一個"可用"的arena對象中取出一個pool

更新中....

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容