系統內核之堆管理

相比於棧內存而言,堆這片內存的管理模式更爲複雜,因爲程序可能隨時發出請求,並且申請一段內存,申請內存的大小不固定,釋放的時間也不確定。棧在面向過程的程序設計中遠遠不夠,因爲棧上的數據在函數返回時就會被釋放掉,所以無法將數據傳遞至函數外部,而類似於全局變量或模塊間共享對象,這是要在編程序編譯階段就要存在的,以及一些規模超大的數據對象,這些數據存在棧上顯然是不合理的。

針對程序內部最爲常見的動態分配內存的情況,常用malloc申請一塊內存塊:

char *p = (char*) malloc(1000);

malloc空間分配是怎麼實現的?一種做法是將進程內內存申請直接下移交給kernel,因爲系統內核本來就管理着“虛擬內存—實際內存”映射表和內存佔用調度表,現在交給內核內存申請的任務也合情合理,但是user-thread切換到kernel thread性能開銷是很大的,直接下移到kernel管理內存申請,對程序性能影響較大。另一種比較好的做法是運行庫批發零售內存塊,程序直接向操作系統申請一塊適當大小的堆空間,然後在user-thread層進行管理調度,管理堆空間的一般是程序的runtime運行庫。Runtime運行庫向OS批發一塊較大的堆空間,然後零售給程序用,這便引出了堆的管理和分配算法。

1. Linux堆管理

Linux系統有兩種方式可以創建堆
1. int brk(void * end_data_segment)
brk()是系統調用,實際是設置進程數據段的結束地址,將數據段的結束地址向高地址移動,那麼擴大的那部分空間便可以拿來作爲堆空間使用;
2. mmap()向操作系統申請一段虛擬地址空間(一般是用來映射到某個文件),當不用這段空間來映射到某個文件時,這塊空間則稱爲匿名空間,可以用來作爲堆空間
void *mmap( void* start, size_t length, int prot, int flags, int fd, off_t offset);
參數解釋:前兩個參數用於指定需要申請的空間的起始地址和長度,如果起始地址設置爲0,那麼linux系統會自動挑選合適的起始地址,prot/flags這兩個參數用於設置申請的空間的權限(可讀,可寫,可執行)以及映射類型(文件映射、匿名空間),最後兩個參數用於文件映射時指定文件描述和文件偏移。

Linux下的glibc運行庫中的malloc函數是這樣處理用戶的空間請求的:<128 KB的請求,會在現有的堆空間中按照堆分配算法爲它分配一塊空間並返回;>128 KB的請求,使用mmap()函數爲它分配一塊匿名空間,如下

void *malloc(size_t nbytes)
{
    void* ret = mmap(0, nbytes, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, 0, 0);
    if (ret == MAP_FAILED)
        return 0;
    return ret;
}

上述只是演示,mmap()函數是系統虛擬空間申請函數,它申請的空間起始地址和大小都必須是系統頁的大小的整數倍(物理最小單位,至於映射那是可以再處理的),所以如果對於字節數很小的請求也使用mmap的話,容易造成浪費。

2. Windows堆管理

Linux系統並無進程和線程的區分,統一將其看作任務,故而Linux下的程序虛擬空間VM看起來要整齊不少,而Windows出於支持多線程的小粒度管理視角,一個進程可能有多個線程,每個線程的棧都是獨立的,故而Windows下的程序VM有多處棧,每個線程默認的棧大小都是1MB,在線程啓動時,系統會爲它在進程的虛擬空間中分配對應的空間作爲該線程的專屬棧,棧的大小由創建線程時CreateThread參數指定。這些零碎的棧一旦分配之後,就已經將Windows下的進程空間弄得零碎不堪,再需要申請堆空間,得靠VirtualAlloc系統函數向系統批發後(4KB整數倍起批),然後再由user-thread層,一般是運行庫集中分配給程序,這個算法位於堆管理器Heap Manager。它提供了一系列API用於管理堆

1.HeapCreate()  創建一個堆
2.HeapAlloc()   在堆中分配內存
3.HeapFree()    釋放已經分配的內存
4.HeapDestroy() 摧毀一個堆

事實上,運行庫提供的malloc函數便是對於Heapxxx系列函數的封裝。當然也正是因爲Windows系統的折中多線程棧插入導致的Windows下的一次性最大堆空間爲1.5G左右,小於Linux下的一次性最大可分配堆空間 3G多的空間。


Fig.1 Windows下進程虛擬空間分佈(摘自《程序員的自我修養》)

Windows堆管理存放在兩個位置,一個NTDLL.DLL,負責Windows子系統DLL與Windows內核之間的接口,所有用戶程序、運行時庫和子系統的堆分配都是使用這部分代碼,在Windows內核Ntoskrnl.exe還存在一份類似的堆管理器,內核堆空間分配和用戶堆不是同一個,Windows內核、內核組件和驅動程序使用的堆都是使用內核堆,內核堆接口都是RtlHeap開頭。

3. 堆管理算法

1. 空閒鏈表法
前面說過進程malloc通過系統調用brk(),mmap()向操作系統批發了一大堆內存,現在如何管理這一大堆內存空間,零售給程序需求的算法便是堆分配算法。(操作系統內存的頁映射機制已經做了一層從物理離散到虛擬連續的封裝)

而在虛擬空間中連續的堆空間如何零售給不同的程序需求,顯然需要二次封裝。首先我們需要一個數據結構來登記堆空間裏所有的空閒空間,這樣才能知道程序請求空間的時候分配給它哪一塊內存,這樣的結構有很多種,這裏介紹最簡單的—-空閒鏈表

//堆的實現
/*在遵循Mini CRT的原則下,我們將Mini CRT堆的實現歸納爲以下幾條
1.實現一個以空閒鏈表算法爲基礎的堆空間分配算法;
2.爲了簡單起見,堆空間大小固定爲32MB,初始化後空間不再擴展或縮小;
3.在Windows平臺下不適用HeapAlloc等堆分配算法,採用VirtualAlloc 向系統直接申請32MB空間,由我們自己的堆分配算法實現malloc
4.在Linux平臺下,使用brk將數據段結束地址向後調整32MB,將這塊空間作爲堆空間
*/
/*
 brk系統調用可以設置進程的數據段.data邊界,而sbrk可以移動進程的數據段邊界,顯然如果將數據段邊界後移,就相當於分配了一定量的內存。但是這段內存初始只是分配了虛擬空間,這些空間的申請一開始是不會提交的(即不會分配物理頁面),當進程師徒訪問一個地址的時候,操作系統會檢測到頁缺少異常,從而會爲被訪問的地址所在的頁分配物理頁面。
故而這種被動的物理分配,又被稱爲按踐踏分配,即不打不動。
*/
#include "minicrt.h"

typedef struct _heap_header
{
    enum{
        HEAP_BLOCK_FREE = 0xABABABAB, //空閒塊的魔數
        HEAP_BLOCK_USED = 0xCDCDCDCD, //佔用快的魔數
    }type;

    unsigned size;  //塊的尺寸包括塊的信息頭
    struct _heap_header* next;
    struct _heap_header* prev;
}heap_header;

#define ADDR_ADD(a,o) ( ((char*)a ) + o)
#define HEADER_SIZE (sizeof(heap_header))

static heap_header* list_head = NULL;

void free(void* ptr)
{
    heap_header* header = (heap_header*) ADDR_ADD(ptr, -HEADER_SIZE);
    if(header->type != HEAP_BLOCK_USED)
        return;

    header->type = HEAP_BLOCK_FREE;
    if(header->prev != NULL && header->prev->type == HEAP_BLOCK_FREE) {
        //釋放塊的前一個塊也正好爲空
        header->prev->next = header->next;
        if(header->next != NULL)
            header->next->prev = header->prev;
        header->prev->size += header->size;

        header = header->prev;
    }

    if(header->next != NULL && header->next->type == HEAP_BLOCK_FREE) {
        //釋放塊的後一個塊也是空塊
        header->size += header->next->size;
        header->next = header->next->next;
    }
}

void* malloc(unsigned size )
{
    heap_header* header;

    //printf("the needed sie %d\n", size);
    //printf("the heap_block_header size %d\n", HEADER_SIZE);


    if(size == 0)
    {
        return NULL;
    }

    //printf("enter ----malloc-------\n");

    header = list_head;
    while(header != 0) 
    {
        if (header->type == HEAP_BLOCK_USED) {
            header = header->next;
            continue;
        }

        //剛好碰到一個空閒快,且其塊的大小大於所需size加上一個信息頭尺寸,但是小於所需size加上兩個信息頭尺寸,即剩餘的內部碎片就算分離出來,也沒有利用價值了,直接整個塊都分配給used,等待整體釋放
        if (header->size > size + HEADER_SIZE && header->size <= size + HEADER_SIZE*2) 
        {
            header->type = HEAP_BLOCK_USED;
            return ADDR_ADD(header, HEADER_SIZE);
        }
        //空閒塊空間足夠,且剩餘的內部碎片分離出來還可以再使用
        if (header->size > size + HEADER_SIZE * 2) {
            //split
            heap_header* next = (heap_header*) ADDR_ADD(header, size+HEADER_SIZE);
            next->prev = header;
            next->next = header->next;
            next->type = HEAP_BLOCK_FREE;
            next->size = header->size - (size + HEADER_SIZE); //此處有誤吧

            if(header->next) //如果當前塊存在下一塊
            {
                header->next->prev = next;
            }

            header->next = next;
            header->size = size + HEADER_SIZE;
            header->type = HEAP_BLOCK_USED;
            return ADDR_ADD(header, HEADER_SIZE);
        };
        header = header->next;
    }
    return NULL;
}

#ifndef WIN32
//Linux brk system call
static int brk(void* end_data_segment) {
    int ret = 0;
    //brk system call number:45
    //in /usr/include/asm-i386/unistd.h:
    //#define __NR_brk 45
    asm("movl $45, %%eax \n\t"
        "movl %1, %%ebx  \n\t"
        "int $0x80       \n\t"
        "movl %%eax, %0  \n\t"
        :"=r"(ret):"m"(end_data_segment) );
}
#endif

#ifdef WIN32
#include <Windows.h>
#endif

int mini_crt_heap_init()
{
    void* base = NULL;
    heap_header* header = NULL;
    //32MB heap size
    unsigned heap_size = 1024*1024*32;

//以base爲起點分配32MB的內存空間   
#ifdef WIN32
    base = VirtualAlloc(0, heap_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (base == NULL)
        return 0;
#else
    base = (void*)brk(0);
    void* end = ADDR_ADD(base, heap_size);
    end = (void*)brk(end);
    if(!end)
        return 0;
#endif

    header = (heap_header*) base;
    header->size = heap_size;
    header->type = HEAP_BLOCK_FREE;
    header->next = NULL;
    header->prev = NULL;

    list_head = header;
//  printf("heap init compeleted\n");
    return 1;
}

2. 位圖
將堆劃分成大量塊進行整數倍零售,利用Bitmap來完成狀態指示。只有三種狀態頭/主體/空閒,故而只需要2位便可以表示塊狀態。關於位圖的優點顯然很明顯:
速度快且穩定性好:爲了避免用戶越界讀寫破壞數據,只需要簡單備份一下位圖即可,而且即使部分數據被破壞,也不會導致整個堆無法工作。塊不需要額外信息,易於管理。

缺點也是很明顯的:容易產生內部碎片。塊的大小設置是個學問,太大會導致內部碎片,太小會導致位圖規模很大。

這裏寫圖片描述

3. 對象池
對象池的思路很簡單,如果每一次分配的空間大小都一樣,那麼可以按照這個每次請求分配的大小作爲單位,把整個堆空間劃分大量的小塊,每次請求時只需要給出一塊就可以,無需搜索整個鏈表或者位圖數組來尋找合適的連續區間,故而速度很快。對象池的具體實現可以採用鏈表也可以採用位圖,之所以和上面兩種技術作區分,最大的不同是對象池假設每次申請的空間大小是一致的,故而常用於遊戲地圖場景渲染加載(渲染速度和規模是預先可以確定的)。而關於對象池的實現,可以直接參考boost::pool的代碼實現。

實際應用時,堆的分配算法是採取多種算法混合而成的。比如Linux下的glibc運行庫: <64 B,採用類似於對象池的方法;> 512 B,則採用空閒鏈表法依次遍歷分配空間最適合的空閒塊; 64 B< n <512 B,則採用最佳折中策略;而對於> 128 KB的請求,則會使用mmap系統調用直接向操作系統申請一塊同等規模的空閒塊。

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