书本配套实验地址
构造一个分配器是一件富有挑战的任务。设计空间很大,有多种块格式、空闲链表格式,以及放置、分割和合并策略可供选择。另一个挑战就是我们经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针强制类型转换和指针运算,这些操作都属于典型的低层系统编程。
一、理解动态内存分配器是个什么东西?
其实动态内存分配器就是我们平时在C语言上用的malloc和free,realloc,通过分配堆上的内存给程序,我们通过向堆申请一块连续的内存,然后将堆中连续的内存按malloc所需要的块来分配,不够了,就继续向堆申请新的内存,也就是扩展堆。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。不同系统之间存在一些细节上的不同,但不失通用性。分配器将堆视为一组大小不同的块block的集合来维护;每个块就是一个连续的虚拟存储器片,即页面大小;要么是已分配的,要么是空闲的。
分配器分为两种:显示分配器和隐式分配器。
二、为什么要使用动态内存分配?
程序使用动态存储器分配的最重要原因:经常直到程序实际运行时,它们才知道某些数据结构的大小。
三、分配器的要求和目标
要求:处理任意请求序列、立即响应请求、对齐块、不修改已分配的块。
目标:吞吐率最大化和存储器使用率最大化。但这两个性能要求通常是相互冲突的,分配器设计的一个有趣的挑战就是在两者之间找到一个平衡。
通常会考虑以下几个问题:
- 空闲块组织: 如何记录空闲块?
- 放置: 如何选择一个合适的空闲快来放置一个新分配的块?
- 分割: 将一个新分配的块放入某个空闲块后,如何处理这个空闲快中的剩余部分?
- 合并: 我们如何处理一个刚刚被释放的块?
四、实现分配器
弄了一圈发现分数并没有提高,所以就主要以书本为基础。
1、操作空闲链表哦的基本常数和宏
/* single word (4) or double word (8) alignment */
#define ALIGNMENT 8
/* rounds up to the nearest multiple of ALIGNMENT */
#define ALIGN(size) (((size) + (ALIGNMENT-1)) & ~0x7)
#define SIZE_T_SIZE (ALIGN(sizeof(size_t)))
/* Basic constants and macros */
#define WSIZE 4 /* Word and header/footer size (bytes) */
#define DSIZE 8 /* Double word size (bytes) */
#define CHUNKSIZE (1<<12) /* Extend heap by this amount (bytes) */
#define MAX(x,y) ((x) > (y)? (x):(y))
/* Pack a size and allocated bit into a word */
#define PACK(size,alloc) ((size) | (alloc))
/* Read and write a word at address p */
#define GET(p) (*(unsigned int *)(p))
#define PUT(p,val) (*(unsigned int *)(p)=(val))
#define GET_ADDRESS(p) (*(void **)(p))
//总size,包括头尾
/* Read the size and allocated fields from address p */
#define GET_SIZE(p) (GET(p) & ~0x7)
#define GET_ALLOC(p) (GET(p) & 0x1)
/* Given block ptr bp, compute address of its header and footer */
#define HDRP(bp) ((char *)(bp)-WSIZE) //头部的指针
#define FTRP(bp) ((char *)(bp)+ GET_SIZE(HDRP(bp))-DSIZE) //脚部的指针
#define PRED(bp) ((char *)(bp)) //祖先节点
#define SUCC(bp) ((char *)(bp)) //后继节点,只留后继结点
/* 获取有效字节,即获取总的size数-头尾指针(因为busyblock没有前继和后继指针) */
#define GET_PAYLOAD(bp) (GET_SIZE(HDRP(bp))-DSIZE)
/* Given block ptr bp, compute address of next and previous blocks */
#define NEXT_BLKP(bp) ((char *)(bp)+GET_SIZE(((char *)(bp)-WSIZE)))
#define PREV_BLKP(bp) ((char *)(bp)-GET_SIZE(((char *)(bp)-DSIZE)))
字的大小(WSIZE)和双字的大小(DSIZE),初始空闲块的大小和扩展堆时的默认大小(CHUNKSIZE)。
PACK宏将大小和已分配位结合起来并返回一个值,可以把它存放在头部或者脚部。
GET宏读取和返回参数p引用的字。类型转换很重要,参数p是一个(void* )指针,不可以直接进行间接引用。类似地,PUT宏将val存放在参数p指向的字中。
GET_SIZE和GET_ALLOC宏从地址p处的头部或者脚部分别返回大小和已分配位。剩下的宏是对块指针(block pointer,用bp表示)的操作,块指针指向第一个有效载荷字节。给定一个块指针bp,HDRP和FTRP宏分别返回指向这个块的头部和脚部的指针。NEXT_BLKP和PREV_BLKP宏分别返回指向后面的块和前面的块的块指针。
可以用多种方式来编辑宏,以操作空闲链表。比如,给定一个指向当前块的指针bp,我们可以使用下面的代码行来确定内存中后面的块的大小:
size_t size = GET_SIZE(HDRP(NEXT_BLKP(bp)));
2、创建初始空闲链表
在调用mm_malloc或者mm_free之前,应用必须通过调用mm_init函数来初始化堆。mm_init函数从内存系统得到4个字,并将它们初始化,创建一个空的空闲链表。
/*
* mm_init - initialize the malloc package.
*/int mm_init(void)
{
/* Create the initial empty heap */
if((heap_listp=mem_sbrk(4*WSIZE))==(void *)-1)
return -1;
//链表初始化
for(int i=0;i<MAX_SIZE;++i)
linkhead[i]=NULL;
PUT(heap_listp,0); /* Alignment padding */
PUT(heap_listp+(1*WSIZE),PACK(DSIZE,1)); /* Prologue header */
PUT(heap_listp+(2*WSIZE),PACK(DSIZE,1)); /* Prologue footer */
PUT(heap_listp+(3*WSIZE),PACK(0,1)); /* Epilogue header */
heap_listp+=(2*WSIZE);
if (extend_heap(CHUNKSIZE/WSIZE)==NULL)
return -1;
return 0;
}
然后调用extend_heap函数,这个函数将堆扩展CHUNKSIZE字节,并且创建初始的空闲块。此刻,分配器已经初始化了,并且准备好接受来自应用的分配和释放请求。
extend_heap函数会在两种不同的环境中被调用:1)当堆被初始化时;2)当mm_malloc不能找到一个合适的匹配块时。为了保持对齐,extend_heap将请求大小向上舍入为最接近的2字(8字节)的倍数,然后向内存系统请求额外的堆空间。
堆开始于一个双字对齐的边界,并且每次对extend_heap的调用都返回一个块,该块的大小时双字的整数倍。因此,对mem_sbrk的每次调用都返回一个双字对齐的内存片,紧跟在结尾块的头部后面。这个头部变成了新的空闲块的头部,并且这个片的最后一个字变成了新的结尾块的头部。最后,在很可能出现的前一个堆以一个空闲块结束的情况中,我们调用coalesce函数来合并两个空闲块,并返回指向合并后的块的块指针。
//扩展堆的大小
static void *extend_heap(size_t words)
{
char *bp;
size_t size;
/* Allocate an even number of words to maintain alignment */
size=(words %2)? (words+1)*WSIZE: words*WSIZE;
if((long)(bp=mem_sbrk(size))==-1)
return NULL;
/* Initialize free block header/footer and the epilogue header */
PUT(HDRP(bp),PACK(size,0)); /* Free block header */
PUT(FTRP(bp),PACK(size,0)); /* Free block footer */
PUT(HDRP(NEXT_BLKP(bp)),PACK(0,1)); /* New epilogue header */
/* Coalesce if the previous block was free */
return coalesce(bp);
}
3、释放和合并块
应用通过调用mm_free函数,来释放一个以前分配的块,这个函数释放所请求的块(bp),然后使用边界标记合并技术将之于邻接的空闲块合并起来。
Knuth提出过一个技术,叫做边界标记(boundary tag),允许在常数时间内进行对前面块的合并。这思想是在每个块的结尾处添加一个脚部(footer),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
它也存在一个潜在的缺陷。它要求每个块都保持一个头部和脚部,在应用程序操作许多个小块时,会产生显著的内存开销。但也有一种优化方法,因为只有在前面的块是空闲时才需要用到它的脚部,所以我们可以把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不需要脚部了,这样我们就可以将多出来的空间用作有效载荷了。不过,空闲块任然需要脚部。
coalesce函数中的代码是上面四种情况的一种简单直接地实现。我们选择空闲链表格式(它的序言块和结尾块总是标记为已分配)允许我们忽略潜在的麻烦边界情况,也就是,请求块bp在堆的起始处或者是在堆的结尾处。如果我们没有这些特殊块,代码将混乱很多,更容易出错,并且更慢,因为我们将不得不在每次释放请求时,都去检查这些并不常见的边界情况。
/*
* mm_free - Freeing a block does nothing.
*/
void mm_free(void *ptr)
{
size_t size=GET_SIZE(HDRP(ptr));
//头尾归为free的block
PUT(HDRP(ptr),PACK(size,0));
PUT(FTRP(ptr),PACK(size,0));
coalesce(ptr);
}
static void *coalesce(void *bp)
{
//关于这一块的改free操作已经在free函数的过程中执行了
size_t prev_alloc=GET_ALLOC(FTRP(PREV_BLKP(bp)));
size_t next_alloc=GET_ALLOC(HDRP(NEXT_BLKP(bp)));
size_t size=GET_SIZE(HDRP(bp));
//情况一,前一块和后一块都被申请了
if(prev_alloc && next_alloc)
{
return bp;
}
//情况二,后一块是空闲的
else if(prev_alloc && !next_alloc)
{
size+=GET_SIZE(HDRP(NEXT_BLKP(bp)));
PUT(HDRP(bp),PACK(size,0));
//改完头部大小就变了,只能直接访问尾部,对尾部进行改大小的操作
PUT(FTRP(bp),PACK(size,0));
return bp;
}
//情况三,前一块是空闲的
else if(!prev_alloc && next_alloc)
{
size+=GET_SIZE(FTRP(PREV_BLKP(bp)));
PUT(FTRP(bp),PACK(size,0));
PUT(HDRP(PREV_BLKP(bp)),PACK(size,0));
return PREV_BLKP(bp);
}
//情况四,前后都是空的
else
{
size+=(GET_SIZE(HDRP(NEXT_BLKP(bp)))+GET_SIZE(FTRP(PREV_BLKP(bp))));
PUT(FTRP(NEXT_BLKP(bp)),PACK(size,0));
PUT(HDRP(PREV_BLKP(bp)),PACK(size,0));
return PREV_BLKP(bp);
}
}
4、分配块
一个应用通过调用mm_malloc函数来向内存请求大小为size字节的块。在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。
/*
* mm_malloc - Allocate a block by incrementing the brk pointer.
* Always allocate a block whose size is a multiple of the alignment.
*/
void *mm_malloc(size_t size)
{
size_t asize; /* Adjusted block size */
size_t extendsize; /* Amount to extend heap if no fit */
char *bp;
/* Ignore spurious requests */
if(size==0)
{
return NULL;
}
/* Adjust block size to include overhead and alignment reqs. */
//要加上头尾两个指针
if(size<=DSIZE)
asize=2*DSIZE; //强制了最小块大小是16字节,8字节用来满足对齐要求,另外8字节用来存放头部和脚部。
else
asize=DSIZE*((size+(DSIZE)+(DSIZE-1))/DSIZE); //对于超过8字节的请求,一般规则是加上开销字节,然后向上舍入到最接近8的整数倍
/* Search the free list for a fit */
if((bp=find_fit(asize))!=NULL) { //一旦分配器调整了请求的大小,它就会搜索空闲链表,寻找一个合适的空闲块
place(bp,asize); //如果有合适的,分配器就放置这个请求块,并可选地分割出多余的部分
return bp; //然后返回新分配块的地址
}
/* No fit found. Get more memory and place the block */
//如果分配器不能够发现一个匹配的块,那么就用一个新的空闲块来扩展堆
extendsize=MAX(asize,CHUNKSIZE);
if((bp=extend_heap(extendsize/WSIZE))==NULL)
return NULL;
//把请求块放置在这个新的空闲块里,可选地分割这个块,然后返回一个指针,指向新分配地块
place(bp,asize);
return bp;
}
5、放置策略
- 首次适配:从头开始搜索空闲链表,选择第一个合适地空闲块。优点是趋向于将大的空闲块保留在链表的后面;缺点是它去向于在靠近链表起始处留下小空闲块的“碎片”,增加了堆较大快的搜索时间。
- 下一次适配:和首次适配相似,只不过是从上一次查询结束的地方开始。它比首次适配运行起来更快,但内存利用率低得多。
- 最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。它比首次适配和最佳适配的内存利用率都高,但它要求堆堆进行彻底的搜索。
- 分离适配:用分离式空闲链表组织,它接近于最佳适配策略,不需要进行彻底的对搜索。它的搜索时间减少了,因为搜索被限制在堆的某个地方,而不是整个堆;内存利用率得到改善,因为对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个对的最佳适配搜索。
1)首次适配
//寻找合适的块
static void *find_fit(size_t asize)
{
/* First fit search */
void *bp;
for (bp=heap_listp; GET_SIZE(HDRP(bp))>0; bp=NEXT_BLKP(bp)) {
if (!GET_ALLOC(HDRP(bp))&&(asize<=GET_SIZE(HDRP(bp)))) {
return bp;
}
}
return NULL; // No fit
}
2)分离适配
分离适配的基本思想就是将所有空闲块分成大小类,分别分成0-8,9-16,17-32,33-64,65-128,…… ,2049-4096,4097-正无穷,这么几个大小类的空闲链表,然后我们想要进行malloc的时候,就将空闲块进行筛选,将其分到对应的大小块中进行搜索,这样就可以将malloc搜索块的时间从所有空的空闲块降低到局部链表的空闲块中,提高了效率。并且事实证明,当分到对应的大小类链表的时候,它的空间也会在大小类链表的范围里面,这样使得即使是首次适配也可以是空间利用率接近最佳适配。那么,他的free也是相同,在合并的时候,将前后空闲块从链表中删除,然后合并,合并后再加入对应的空闲链表。分割的时候,也是分割后将分割块插入适当的空闲链表中。
//寻找合适的块
static void *find_fit(size_t size)
{
for(int index=findlink(size);index<MAX_SIZE;++index)
{
void* bp=linkhead[index];
while(bp!=NULL)
{
if(GET_SIZE(HDRP(bp))>=size) return bp;
bp=GET_ADDRESS(SUCC(bp));
}
}
return NULL;
}
这里测试的时候两种策略的得分居然是一样的,我也很懵逼~
6、place函数
对于这个分配器,最小块是16字节。如果分割后剩下的块大于或者等于最小块的大小,那么我们就分割这个块;这里有一个技巧:要意识到在移动到下一块之前(bp=NEXT_BLKP(bp)😉,我们必须放置新的已分配块。
static void place(void* bp,size_t asize)
{
size_t csize=GET_SIZE(HDRP(bp));
//分割这个块
if ((csize-asize)>=(2*DSIZE)) {
PUT(HDRP(bp),PACK(asize,1));
PUT(FTRP(bp),PACK(asize,1));
bp=NEXT_BLKP(bp);
PUT(HDRP(bp),PACK(csize-asize,0));
PUT(FTRP(bp),PACK(csize-asize,0));
}
else {
PUT(HDRP(bp),PACK(asize,1));
PUT(FTRP(bp),PACK(asize,1));
}
}
7、mm_realloc函数
/*
* mm_realloc - Implemented simply in terms of mm_malloc and mm_free
*/
void *mm_realloc(void *ptr, size_t size)
{
void *oldptr = ptr;
void *newptr;
size_t copySize;
newptr = mm_malloc(size);
if (newptr == NULL)
return NULL;
copySize = *(size_t *)((char *)oldptr - SIZE_T_SIZE);
if (size < copySize)
copySize = size;
memcpy(newptr, oldptr, copySize);
mm_free(oldptr);
return newptr;
}
五、分配器的最终得分
六、注释
1、config.h文件中更改一些地方,把压缩包里提供的rep文件加进去。
2、
gcc -c mm.c -o mm.o
gcc -o mdriver mdriver.o mm.o memlib.o fsecs.o fcyc.o clock.o ftimer.o -Wall -O2
./mdriver
gcc -g mdriver.c mm.c memlib.c fsecs.c fcyc.c clock.c ftimer.c -o mdriver
./mdriver
ls
gdb mdriver core.90596