實驗2介紹了操作系統的基本內存管理,或者說是系統內存管理的軟件接口實現。這對於我們實際上碰到的內存管理操作有一些差距,所以我們這一節補充一些內容,來詳細介紹一些內存管理技術。
這裏要對實驗2進行一些補充說明,實驗2的代碼實現主要參考源碼中給的提示信息,而代碼實現是否正確,這裏沒有老師檢查也沒有任何可參考的代碼,而原課程設計有一個很巧妙的方法,使用assert機制來檢測各個接口實現,通過代碼的方式來檢測每個接口是否正確。這是多麼有意義的做法呀,想想我們在學習代碼的過程中,都是拷貝別人的代碼修改之,或者由有經驗的人來指導,這樣當我們碰到問題時,就很容易喪失自主思考的能力,而且很容易去對比已經有的代碼,從而忽略了程序運行本身。
我不想去重複贅述教科書上的一些概念與總結的言辭,而用實際行動與代碼的剖析去替代。這樣可以讓我們的代碼更有活力,能夠被更好的理解與使用到其他的項目中。有時雖然有些低劣,但是有一種解決方案爲我們指明方向,然後沿着它不斷摸索,不斷改進,產生更好的解決方案。內存管理的終極目標是以最少的內存消耗實現最快的內存分配。本將以如下4個方面去詳細介紹內存管理方案——malloc/free兩種實現方式,夥伴算法分配方案,slab分配算法。這些管理方案在linux的內核中都有合理的使用,這樣就是很複雜的過程,但是效率很高。對於malloc/free的實現方法有兩種,一種是從unix V6中拿出來的,另一種是我修改的變種,這兩種方式其實爲實現內存管理的接口提供了原始而基本的解決方案。另外兩種方法主要是爲了解決內存分配時的碎片問題而產生的算法,它們都是從unix的分支sun-os中產生的算法,夥伴算法解決外部碎片,slab算法解決內部碎片,當然它們還有其他妙用,見各個部分詳細介紹。
一)unix v6版本的malloc/free實現分析
首先,我介紹的是比較原始的malloc/free的代碼實現,它是源自於《萊昂氏UNIX源代碼分析》分析中的malloc.c中詳細介紹。我將從兩個方面來詳細介紹它——其一源碼分析,其二爲代碼運行仿真。
A)源碼分析——對於內存管理的基本操作就是分配(malloc)與釋放(free)。我還是從面向對象的角度來描述,另外在描述內存管理的方式一種形象的方法爲用邏輯示意框圖來描繪,對我們理解是很有意義的。所以我們先以框圖來描述代碼實現的邏輯如下:
如上圖所示:我們以10K大小的內存空間爲分配與釋放對象,每64Byte爲分配單元,所以10K大小有160個分配單元。從左上角開始,我們用一個數組記錄可以分配的空間對——分配的開始地址與大小,所以當程序開始時爲(160,0)——開始地址爲0,有160個分配空間,然後從左向右是不斷分配內存塊,而從右往左是釋放內存塊。在分配過程中以“分配大小首次適配”爲分配對象,分配時當分配大小與當前可分配大小一致時,需要將該分配對從該數組中清除;在釋放過程中需要檢測當前釋放的內存是否能與可分配對的關係,如果能夠連接在一起,則將相關的分配對進行大小調整。
以面向對象的方式來描述分配算法:
屬性:
struct map{//分配對
char *m_size;//可分配的內存大小
char *m_addr;//可分配的內存開始地址
};
方法:
輸入:mp爲分配對數組
Size爲分配的大小
輸出:分配得到的開始地址
int malloc(struct map *mp,int size);//從分配對數組中,分配size大小的內存空間。
輸入:mp爲分配對數組
size爲釋放的空間大小
aa爲釋放的空間開始地址
void mfree(struct map *mp,int size,int aa);//將已經分配的內存(以分配對錶示(aa,size))釋放到分配對數組中。
實例化:
int coremap[CMAPSIZ];//靜態申請分配對數組。
B)代碼仿真
針對如上的描述只是對概念的介紹,而更詳細的分析,需要查看附件的代碼以及註釋,如果純粹的貼代碼,這對博客是一種傷害。當然根據如上我的介紹也可以自己實現相關代碼。對於這部分代碼是比較獨立的代碼,我們可以直接將代碼從萊昂氏的代碼中拷貝出來,自己寫一個main函數來調用之,然後再用打印將如上的分析結果給打印出來;當然也可以用gdb去單步調試malloc與mfree的詳細流程。
代碼參考附件中的unixMalloc_org中的代碼。
爲了實現c語言標準庫中的malloc與free方法,我們需要對代碼進行接口修改,然後增加一些代碼來實現,使用多申請一個int的空間來保存申請的大小,或者通過使用動態鏈表來實現。
代碼參考附件中的unixMalloc_macro。當然它也增加了一系列宏來修改代碼增強代碼可讀性。
二)另一版的malloc/free實現分析
針對unix版本給出的代碼,我們將整體分析分配空間的處理,其實可以用下圖來表示:
如上圖所示,我們將整個內存使用空間分配爲兩部分一部分爲分配索引(代碼中coremap)——用於記錄與管理分配空間的使用情況,而另一部分爲分配空間(代碼中space)實際提供給其他系統使用的空間。這種結構跟現在的文件系統是一致的。這樣的壞處是索引空間會比較浪費。
針對如上情況我們有另外一種實現方式將內存使用空間以幀的方式來進行分配管理,如下如圖所示:
如上所示,我們將整個內存使用空間分配爲不同的分配幀——分配幀用於記錄已經使用的內存塊,而每個分配幀的都有下一幀地址——用於將所有幀連接成鏈表(表頭用frame_head指向)與當前幀大小。如上的算法流程可以看作將unix算法的分配索引整合到分配空間。爲了與之前的講解一致,我們也從類似的方法來講解。
A)源碼分析——先以分配邏輯示意圖的方法來演示,而且也以相同的測試代碼來實現。如下圖所示:
如上圖所示,左邊標記了內存分配,右邊標記了內存釋放,陰影部分爲未使用的內存空間,而frame_head修改只在其值發生變化時發生。
以面向對象的方式來描述分配算法:
屬性:
struct frame_map{//記錄已經分配的幀
struct frame_map* next_frame_addr;//下一幀的地址
int cur_alloced_size;//當前幀的大小
};
方法:
輸入:申請的內存大小
輸出:申請得到的內存地址
int frame_malloc(int size);//通過幀算法得到分配的內存
輸入:釋放的內存地址
輸出:是否釋放成功
int frame_free(int addr);//釋放已經分配的內存
B)代碼仿真——我們將測試unix的代碼放入其中進行測試即可,通過添加打印與單步調試來分析代碼實現,在附件frame_alloc目錄下。
爲了方便調試方便,我們用頂層makefile來管理代碼仿真的部分。
make org 爲編譯unix源碼的malloc與mfree代碼
make macro 爲編譯修改過的unix源碼的代碼
make frame 爲我們實現的幀分配算法
三)夥伴系統算法——buddy
夥伴系統作爲一種有效的內存分配方法,在很多地方都有使用,對於我們需要理解它的實現流程,並能夠編寫實現它的代碼。對於它的描述,常見的分析方法有兩種——其一是從分配效率,其二是從內存使用碎片方式。
我們摘錄了很多參考手冊上的分析方法在附件中,詳細列表如下:
1.《操作系統精髓與設計原理(原書第6版)》第7.2.3節
2.《計算機程序設計藝術(第一卷)》第2.5節,C算法。
爲了直觀的理解夥伴系統算法我們還是用圖的方式來描述內存分配與釋放,來至參考文獻1:
如上圖所示,我們有1MB的空間用於分配使用,首先A請求100KB,我們分配了128KB(因爲所有的分配單元必須爲2^n),同時在分配128KB時,我們將剩餘的內存分割爲512KB,256KB,128KB,方便內存管理。然後B請求240KB,需要我們分配256KB給它,因爲已經有了256KB,所以直接給了。C請求64KB,我們再將128KB分割,然後分配。D請求256KB,我們將512KB分割,然後分配之。釋放B時,我們直接釋放掉,再直接釋放A。再請求75KB,又分配了128KB,在A釋放的區域。釋放C時,我們將剩餘的64KB(夥伴)進行合併。釋放E時,將剩餘的128KB,256KB進行依次合併,釋放D時將所有的又合併成1MB。
對於如上的描述,我們給出了直觀的描述,但是我們需要明白一些概念:
1.分配單元(這是所有分配算法都會用到的),夥伴算法的分配單元都爲2^n的空間。
2.夥伴:從同一塊內存分配成兩塊的子內存。它們的特點是大小相等,地址連續。從參考資料2,我們還得到一個很有用的公式:
如上公式很抽象,我們以實例說明:大小爲32(2^5)的地址爲xxxx.....00000(x爲0/1),它分割的夥伴地址分別爲xxxx.....00000與xxxx.....10000。這對夥伴的地址可以看作將原地址的第4位進行0/1劃分。換句話說如果我們知道了夥伴之一的地址,對其第4位進行異或運算就可以得到另一夥伴的地址,而且將第4位清0就可以得到分配的地址,爲此,我們可以得到快速算法。而不用去反覆遞歸。
3.分配與釋放策略:當在分配內存時,如果能夠直接找到適合的內存大小,直接給予就好,否則需要對剩餘內存進行夥伴拆分,直到滿足分配的單元。當釋放內存時,需要對已經存在的內存進行歸併——合併所有夥伴,直到沒有夥伴合併。
算法實現——我們將以兩種方式實現(遞歸與二進制地址分配方式):
實現的接口如下——目前我們實現的都是在模擬算法,而沒有在實際中使用,因爲在實際使用時,可能沒有malloc與free或者這些接口是要我們實現的(其實,可以預先靜態分配爲數組):
屬性:
typedef struct addr_node{//用於保存未分配的夥伴地址,並且連接成鏈表
unsigned long start_addr;//保存未分配的夥伴地址
struct list_head next;//雙向鏈表
}addr_node_t;
方法:
void buddy_init();//初始化夥伴算法的數據結構與規範要分配的內存空間。
輸入:分配大小
輸出:分配的地址
void* buddy_alloc(int sz_by_order);//根據給的大小分配地址
輸入:addr爲已經分配的地址
sz_by_order爲已經分配的大小
void buddy_free(void * addr,int sz_by_order);//釋放已經分配的地址與大小。
void buddy_destroy();//釋放夥伴算法的數據
初始化:
struct list_head free_ls[MAX_ORDOR];//記錄夥伴算法每個分配單元的鏈表頭
說明:
在算法實現過程中,我們使用了雙向鏈表(struct list_head),而它的實現,是從linux內核進行拷貝出來的。它的實現很巧妙,在內核中得到廣泛的使用,它的實現方式將鏈表的關係與實際保存的數據進行了分離,而且使用宏的方式來時實現簡單方便,快捷。如下爲一個使用demo,當然也可以在附件中找到。
#include "include_linux_list.h"
#include <stdio.h>
#include <stdlib.h>
typedef struct List{//定義使用linux內核雙向鏈表的結構
char* data;//保存的數據
struct list_head link;//雙向鏈表的關係
}List;
int main(int argc,char ** argv)
{
struct list_head head;//定義表頭
struct list_head* p_one;
int i;
List* one_str;
INIT_LIST_HEAD(&head);//初始化表頭
for(i=0;i<argc;i++){
one_str = malloc(sizeof(List));
one_str->data = argv[i];
list_add(&one_str->link,&head);//將元素添加到鏈表中
}
list_for_each(p_one,&head){
one_str = list_entry(p_one,List,link);//得到鏈表中的元素
printf("%s\n",one_str->data);
free(one_str);
}
return 1;
}
在代碼實現過程中,使用二進制地址分析的方法只是加速了代碼的實現,而基本流程是一致的。
四)Slab分配策略
對於這種內存管理方法,純粹是從實踐觀察中得到的,而詳細的介紹,我們會從《The.Slab.Allocator》的論文中得到,如下爲我們從論文中截取的,讓人感興趣的部分。因爲它的分配效率的高效性,在很多操作系統中都得到很好的實現。當然linux也不例外,對它的理解也是理解linux內存管理很重要的部分;
思想來源:
對於前面幾種內存分配算法都是將內存釋放到整個內存中,而slab分配策略則是基於實際使用中對象的內存管理,將以運行過程中申請的內存爲對象,進行分配,緩存,釋放。對象緩存是一項處理經常被分配與釋放的對象。它的思想來源於保持對象初始化的狀態的不變性——構造態,這樣之後,就可以避免在使用對象時需要每次都銷燬與初始化。從而保證了算法的有效性。
算法的優勢:
1.對內核經常使用的對象進行緩存,這樣保證了多次使用時的分配速度會很快
2.對類似大小的對象進行緩存,這樣避免了常見的內存碎片的問題——主要是內部碎片,同樣對外部碎片也有很好處理。
3.常用了硬件緩存對齊與着色,這允許不同緩存中的對象佔用相同的緩存行,從而提高緩存的利用率並獲得更好的性能。
算法實現——基於經典的分層模型:
這種模型之前我們也描述過程,這樣結構對我們理解代碼實現,實際運用以及模擬仿真都有很好的意義。比如:在linux平臺上,基本功能層是使用的是夥伴算法實現實際的內存分配,而對於我們代碼模擬過程,也是從linux內核2.4中截取的代碼,然後用malloc與free來替代了內核中的夥伴算法,從而對這部分代碼進行調試,分析。
接口層設計:
接口的設計需要考慮如下兩點:
(A)對象的屬性描述(大小,對齊,名字,構造與析構函數),從屬於實際使用的地方,而不是由分配器所決定。因爲分配器不知道所有使用的內存大小與構造/析構時所做的操作。
(B)內存的管理策略應該時分配器所要考慮的,而不是由客戶端所決定的。客戶端只需要使用分配與釋放的接口即可,而不用去考慮內在的分配效率與算法。
針對A的設計點,就必需設計客戶端驅動(由客戶端調用)的且包含所有屬性的接口:
(1)struct kmem_cache *kmem_cache_create(
char *name,
size_t size,
int align,
void (*constructor)(void *, size_t),
void (*destructor)(void *, size_t));
該接口用於創建一個對象的緩存,它需要對象的大小與對齊情況。對齊(align)指的是對象需要基本的內存分配的限定,它可以爲0,服從系統默認的對齊機制。名字(name)用於統計與調試。構造函數(constructor),對象被創建時的構造函數,只被調用一次。析構函數(destructor)用於對象被回收時,也只被調用一次。構造與析構函數使用大小作爲參數,是用於支持相似的緩存實現類似的操作。該接口返回使用該緩存的唯一標識。
針對B的設計點,客戶端需要簡單的分配與釋放的接口既可:
(2)void *kmem_cache_alloc(
struct kmem_cache *cp,
int flags);
分配對象的接口——從緩存中獲取一個對象。對象必需處於構造狀態。flags爲KM_SLEEP or KM_NOSLEEP,表明是否能夠理解獲得內存資源。
(3) void kmem_cache_free(
struct kmem_cache *cp,
void *buf);
返回一個對象到緩衝中。對象依舊處於構造狀態。
(4)void kmem_cache_destroy(
struct kmem_cache *cp);
銷燬一個緩存,釋放所有資源。所有分配的對象都必需返回給緩衝。
這些接口允許我們創建靈活的分配器適應客戶端的理想的需求,當然也可以定製相關的接口。對象緩存的接口使客戶端可以使用它們所需要的分配器的服務。
邏輯層設計——我們分析的linux內核2.4中的代碼實現,而參考了論文中的實現描述:
從代碼實現的角度來說,slab分配器的實現主要是使用形如 struct slab_s的結構體對對象緩存進行邏輯管理。
爲了理解設計思路與邏輯實現我們需要理解如下幾個概念:
1.對象——程序中數據結構對內存的實際需求,可以分爲大,中,小
2.Slab——管理對象的基本結構,可以說是維繫緩存與對象的紐帶,分爲空,部分滿,全滿。
3.緩存——cache,用於組織與管理slab與對象,一個cache對應一種對象
它們的關係如下圖所示:
如上圖所示,slab分配器系統是由一系列的對象緩衝塊鏈接在一起,每個緩衝(cache)包含了緩衝對象的屬性以及slab的列表,slab列表是以滿的slab開始,其中第一個非滿的slab也被cache所指向;而slab又管理了所有的對象(obj)列表,而沒有使用的對象鏈接到slab的free指針上。
對於染色的理解爲將對象的開始地址(s_mem)以硬件緩衝對齊的方式偏移,這樣就可以充分利用硬件緩衝的特性來加速對內存的訪問。
算法仿真——對於linux內核的代碼有很多考慮,比如:對多處理器兼容,多進程訪問加鎖,proc文件系統的添加等,而實際我們仿真時不需要,所以需要將它們註釋掉;linux在功能層實現實際物理分配使用了夥伴分配算法,而我們只需要對接分配與釋放內存的代碼即可——在附件中page.c中實現。
一葉說:內存管理在系統軟件或者應用軟件運行無疑起了核心的作用,所以值得我們去分析具體實現。而真正好而有效內存管理就是根據實際使用環境進行組合使用。當然,這些在linux內核中的使用就是一個很好的示例。如本博客描述的算法都在附件中有實現,希望對感興趣的人員提供參考。對於內存管理的理解,更多的是能以邏輯圖的方式將分配與釋放的過程形象的表達出來。