靠譜:保證實時的動態內存分配

五一在家沒有出門,研究了一下 TLSF 動態內存分配算法。

緣起

要說清楚TLSF,得從動態內存分配開始說起。動態內存分配是所有系統中都要考慮的問題,我們從學C語言開始就知道用的malloc函數就是用來申請動態內存的。其基本原理就是malloc時系統從堆中拿出一塊內存分配給你用,用完之後通過free再還回去。

這個過程有點像借錢,比如最近疫情手頭緊,沒錢吃飯了,於是找朋友malloc點錢去吃飯,比如說10塊吧,等我打工掙了錢再free給朋友,這就是動態分配,而朋友就是那個擁有很多錢(內存)的壕。

那麼問題就來了,既然朋友是個壕,必然有很多錢,我去malloc 10塊錢的時候,他可能沒有零錢,都是100的,於是他直接扔給了我100,我拿着這100也不敢都花了,畢竟現在疫情緊張能有飯吃就不錯了,標準都是10塊,所以其實還有90塊給我是浪費掉了,明明這90塊還可以再去幫助9個像我這樣的,於是資源利用效率就降低了。朋友雖然壕,但架不住我這樣的人多啊,資源慢慢就被耗盡,還利用率低,系統就開始卡了。

這就是現在嵌入式系統經常遇到的問題:資源有限。物聯網的發展帶火了嵌入式系統,因爲價格便宜,但同時也導致資源有限,不好好用有可能就跑不動了,換高端的又要多花錢,所以需要研究如何更有效的利用資源。動態內存分配就是用來管理分配內存資源的。

如何來管理分配內存呢?其實這就是我的朋友壕怎麼管錢的問題。壕的錢太多,所以借錢的人也多,爲了記住誰借了多少,壕就要記賬,於是他就會有一個賬本。比如我之前找他借10塊,他給了我100,於是他就會在賬本上記上這100已經借出了。借錢的人一多,可能每個人要借的錢有多有少,有的借10000,有的借200,有的借42,各種各樣的需求都有。朋友開始一筆一筆按順序記,後來慢慢發現有不少人每次都借10000,這個人借了10000還回來,那個人正好來借10000,於是不用把這10000放回去了,直接借給那個人更方便,方便了自己管理。於是朋友壕想到了一個辦法,規定了借錢的規格,比如借錢只能借10、100、1000、10000,其他的數量都不借,他自己只需要把錢按這個規格提前分好,然後用幾個鏈表來管理就可以了。這就是一種動態分配內存的方法。

碎片

前面說的朋友的這個方法好不好呢?比一筆一筆的順序記肯定要好多了。但是會有個問題:比如我要借20,按朋友的規格,沒有20的,我只能借100,其實浪費了80,這80我也不用,朋友想用卻用不上,這就是內部碎片。可以想象,隨着長時間的使用,因爲每個人的需求各種各樣,內部碎片可能會越來越多,造成了整體利用率下降。

前面說了那80是內部碎片,既然有內部碎片,就肯定有外部碎片。外部碎片不太好用錢來舉例,因爲它涉及到了內存連續的問題。我們知道系統中沒有碎片的時候內存都是連續的,假設有三個進程分別申請了相連的1,2,3三塊內存,而進程2因爲執行完畢,把內存2釋放了,此時1和3之間就空出了一塊內存,如果這塊空出的區域無法滿足其他進程申請的需要,那它就只能一直在這空着,造成了浪費,這就是外部碎片

長時間的使用會導致越來越碎片化,那這些碎片怎麼解決呢?

對於內部碎片,一個很容易想到的辦法就是把借錢的規格分得更細,比如規格可以有1,2,3,4,…,10000,這樣借錢的數量只要在這個範圍內(我們只考慮借整數元的情況),就不會有內部碎片產生。但是這個代價就是原本只要幾個鏈表就可以管理的,現在需要10000個鏈表,朋友壕覺得太累了。於是考慮折中的方法,不要分那麼多規格,就分一些常用的規格,比如總有人借42塊,就定一個42的規格,於是鏈表數量也不會那麼多。但是總有不符合規格的,所以也還是會有碎片,只是碎片不會那麼多。這就是Linux內核中slab機制所採取的辦法。

對於外部碎片,可以用著名的夥伴算法。其思想是把分配的規格定爲2的冪次,即1,2,4,8,16,32,64,128,256,512,1024,2048,…,這樣鏈表的數量不多。用的時候,若要分配內存,就給能滿足要求的最小的,比如申請42,就給64,若沒有64的,就找128的,拆成2個64的,給一個,留一個;若128的也沒有,就繼續往上找,以此類推。若找到頭若還找不到,則分配失敗。釋放內存的時候,則看緊鄰的內存塊是否空閒,若爲空閒,則進行合併。因爲小塊都是大塊2分出來的,所以緊鄰的一定有一塊跟它一樣大,就等着合併那一塊,合併成一塊大的之後還可以再看緊鄰的是否空閒,若是可以再合併,以此類推。這就是爲什麼規格要爲2的冪次。這樣就不會產生外部碎片了。Linux內核中就採取了這個辦法。很明顯,這種方式雖然能夠完全避免外部碎片的產生,但卻產生了內部碎片。所以Linux內核才用了slab機制來優化。

實時

動態內存分配的另一個問題是實時性。比如前面講的夥伴算法,若是最壞的情況,申請1可能要一直找到2048去,然後要不斷拆出來。當然這種情況很少見,所以平均來說效率還不錯。對於Linux這種實時性要求不高的系統來說,也就一直用着了。

但對於一起實時嵌入式系統來說,實時性的要求就高得多,舉個沒那麼恰當的例子,比如剎車系統平均響應時間是0.1s,但最壞情況下可能要2s,這種剎車你敢用嗎?實時系統需要有可預期的時間保證,必須要保證在最壞的情況下多少時間內操作要完成。我們要說的TLSF就可以保證其最壞執行(分配、釋放)時間是 O(1)O(1) 的。

TLSF (Two-Level Segregate Fit)

終於說到TLSF了。TLSF號稱實現了三大目標:實時性(可預期的時間保證)、執行速度快、利用率高(碎片少)。怎麼實現的呢?結合下圖來說。

tlsf

在劃分的規格上,TLSF改進了夥伴算法,它分了兩層,第一層(圖中First Level)跟夥伴算法一樣,也是採用2的冪次,但這樣很容易產生很多內部碎片,所以TLSF進行了第二層(圖中Second Level)劃分,比如64這一級規格,再細分爲8個區間,64-71,72-79,80-87,88-95,96-103,104-111,112-119,120-127,這樣雖然不能完全沒有碎片,但碎片可以儘量小,同時也儘量不會浪費內存,保證了內存的利用率。

爲了方便管理,對每一層都用位圖來表示相應的規格是否有空閒塊,每種規格的空閒塊都用一個鏈表來管理。相關結構體代碼如下,其中fl_bitmap和sl_bitmap分別是第一層和第二層位圖,matrix是所有規格的鏈表。

typedef struct TLSF_struct {
    /* the TLSF's structure signature */
    u32_t tlsf_signature;

#if TLSF_USE_LOCKS
    TLSF_MLOCK_T lock;
#endif

#if TLSF_STATISTIC
    /* These can not be calculated outside tlsf because we
     * do not know the sizes when freeing/reallocing memory. */
    size_t used_size;
    size_t max_size;
#endif

    /* A linked list holding all the existing areas */
    area_info_t *area_head;

    /* the first-level bitmap */
    /* This array should have a size of REAL_FLI bits */
    u32_t fl_bitmap;

    /* the second-level bitmap */
    u32_t sl_bitmap[REAL_FLI];

    bhdr_t *matrix[REAL_FLI][MAX_SLI];
} tlsf_t;

申請內存

申請內存的時候用如下malloc_ex函數。先調整要申請的內存大小size,然後通過MAPPING_SEARCH找到對應這個size的位圖的位置索引,再調用FIND_SUITABLE_BLOCK去找到可用的合適的內存塊。這裏FIND_SUITABLE_BLOCK中會先在size對應的那個鏈表去找看是否有空閒的,若找不到就到比size大的那些鏈表中去找,一般都會找到有空的,找不到則返回空指針。malloc_ex中找到空閒塊之後就獲取它的信息,看它是否太大以至於需要分割,若需要分割就分割一下,把多的做成一個空閒塊放到該放的鏈表中;若不需分割就標記一下並返回。

void *malloc_ex(size_t size, void *mem_pool)
{
    tlsf_t *tlsf = (tlsf_t *) mem_pool;
    bhdr_t *b, *b2, *next_b;
    int fl, sl;
    size_t tmp_size;

    size = (size < MIN_BLOCK_SIZE) ? MIN_BLOCK_SIZE : ROUNDUP_SIZE(size);

    /* Rounding up the requested size and calculating fl and sl */
    MAPPING_SEARCH(&size, &fl, &sl);

    /* Searching a free block, recall that this function changes the values of fl and sl,
       so they are not longer valid when the function fails */
    b = FIND_SUITABLE_BLOCK(tlsf, &fl, &sl);
    if (!b)
        return NULL;            /* Not found */

    EXTRACT_BLOCK_HDR(b, tlsf, fl, sl);

    /*-- found: */
    next_b = GET_NEXT_BLOCK(b->ptr.buffer, b->size & BLOCK_SIZE);
    /* Should the block be split? */
    tmp_size = (b->size & BLOCK_SIZE) - size;
    if (tmp_size >= sizeof(bhdr_t)) {
        tmp_size -= BHDR_OVERHEAD;
        b2 = GET_NEXT_BLOCK(b->ptr.buffer, size);
        b2->size = tmp_size | FREE_BLOCK | PREV_USED;
        next_b->prev_hdr = b2;
        MAPPING_INSERT(tmp_size, &fl, &sl);
        INSERT_BLOCK(b2, tlsf, fl, sl);

        b->size = size | (b->size & PREV_STATE);
    } else {
        next_b->size &= (~PREV_FREE);
        b->size &= (~FREE_BLOCK);       /* Now it's used */
    }

    TLSF_ADD_SIZE(tlsf, b);

    return (void *) b->ptr.buffer;
}
static __inline__ bhdr_t *FIND_SUITABLE_BLOCK(tlsf_t * _tlsf, int *_fl, int *_sl)
{
    u32_t _tmp = _tlsf->sl_bitmap[*_fl] & (~0 << *_sl);
    bhdr_t *_b = NULL;

    if (_tmp) { //找到空閒的了
        *_sl = ls_bit(_tmp);
        _b = _tlsf->matrix[*_fl][*_sl];
    } else {  //沒找到空閒的
        *_fl = ls_bit(_tlsf->fl_bitmap & (~0 << (*_fl + 1))); //找比其大的有空閒的最小的
        if (*_fl > 0) {         /* likely 一般都能找到*/
            *_sl = ls_bit(_tlsf->sl_bitmap[*_fl]);
            _b = _tlsf->matrix[*_fl][*_sl];
        }
    }
    return _b;
}

釋放內存

釋放內存的時候用如下free_ex函數。首先更新待釋放的內存塊的狀態,然後查找其下一個塊是否也是空閒,若是則將兩塊合併成一個大塊;再查找其前一塊是否是空閒的,若是則與前一塊也合併;根據情況合併前後內存塊之後,將自身插入到對應的空閒鏈表中,並更新下一塊的相應標誌。

void free_ex(void *ptr, void *mem_pool)
{
    tlsf_t *tlsf = (tlsf_t *) mem_pool;
    bhdr_t *b, *tmp_b;
    int fl = 0, sl = 0;

    if (!ptr) {
        return;
    }
    b = (bhdr_t *) ((char *) ptr - BHDR_OVERHEAD);
    b->size |= FREE_BLOCK;

    TLSF_REMOVE_SIZE(tlsf, b);

    b->ptr.free_ptr.prev = NULL;
    b->ptr.free_ptr.next = NULL;
    /* 查找其下一個塊是否是空閒,若是則合併 */
    tmp_b = GET_NEXT_BLOCK(b->ptr.buffer, b->size & BLOCK_SIZE);
    if (tmp_b->size & FREE_BLOCK) {
        MAPPING_INSERT(tmp_b->size & BLOCK_SIZE, &fl, &sl);
        EXTRACT_BLOCK(tmp_b, tlsf, fl, sl);
        b->size += (tmp_b->size & BLOCK_SIZE) + BHDR_OVERHEAD;
    }
    /* 查找其前一個塊是否是空閒,若是則合併 */
    if (b->size & PREV_FREE) {
        tmp_b = b->prev_hdr;
        MAPPING_INSERT(tmp_b->size & BLOCK_SIZE, &fl, &sl);
        EXTRACT_BLOCK(tmp_b, tlsf, fl, sl);
        tmp_b->size += (b->size & BLOCK_SIZE) + BHDR_OVERHEAD;
        b = tmp_b;
    }
    MAPPING_INSERT(b->size & BLOCK_SIZE, &fl, &sl);
    INSERT_BLOCK(b, tlsf, fl, sl);

    /* 更新下一塊的相應標誌 */
    tmp_b = GET_NEXT_BLOCK(b->ptr.buffer, b->size & BLOCK_SIZE);
    tmp_b->size |= PREV_FREE;
    tmp_b->prev_hdr = b;
}

分析

從代碼可以看出,申請和釋放內存的過程中沒有任何循環,所以其時間複雜度爲 O(1)O(1),有實時性的保證。而執行過程,因爲沒有循環,正常都挺快的。至於內存利用率,主要取決於第二層又細分了多少級,比如若分了8級,最壞情況下利用率也高於7/8。

需要源碼的同學可以去 http://www.gii.upv.es/tlsf/ 自行下載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章