# 9.9.12 综合:实现一个简单的分配器

构造一个分配器是一件富有挑战性的任务。设计空间很大,有多种块格式、空闲链表格式,以及放置、分割和合并策略可供选择。另一个挑战就是你经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针强制类型转换和指针运算,这些操作都属于典型的低层系统编程。

虽然分配器不需要大量的代码,但是它们也还是细微而不可忽视的。熟悉诸如 C++ 或者 Java 之类高级语言的学生通常在他们第一次遇到这种类型的编程时,会遭遇一个概念上的障碍。为了帮助你清除这个障碍,我们将基于隐式空闲链表,使用立即边界标记合并方式,从头至尾地讲述一个简单分配器的实现。最大的块大小为 232 = 4GB。代码是 64 位干净的,即代码能不加修改地运行在 32 位(gcc -m32)或 64 位(gcc -m64)的进程中。

# 1. 通用分配器设计

我们的分配器使用如图 9-41 所示的 memlib.c 包所提供的一个内存系统模型。模型的目的在于允许我们在不干涉已存在的系统层 malloc 包的情况下,运行分配器。

mem_init 函数将对于堆来说可用的虚拟内存模型化为一个大的、双字对齐的字节数组。在 mem_heapmem_brk 之间的字节表示已分配的虚拟内存。mem_brk 之后的字节表示未分配的虚拟内存。分配器通过调用 mem_sbrk 函数来请求额外的堆内存,这个函数和系统的 sbrk 函数的接口相同,而且语义也相同,除了它会拒绝收缩堆的请求。

分配器包含在一个源文件中(mm.c),用户可以编译和链接这个源文件到他们的应用之中。分配器输出三个函数到应用程序:

extern int mm_init(void);
extern void *mm_malloc(size_t size);
extern void mm_free(void *ptr);

mm_init 函数初始化分配器,如果成功就返回 0,否则就返回 -1。mm_mallocmm_free 函数与它们对应的系统函数有相同的接口和语义。分配器使用如图 9-39 所示的块格式。最小块的大小为 16 字节。空闲链表组织成为一个隐式空闲链表,具有如图 9-42 所示的恒定形式。

第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block),这是一个 8 字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由 malloc 或者 free 调用创建的普通块。堆总是以一个特殊的结尾块(epilogue block)来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个单独的私有(static)全局变量(heap_listp),它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块。)

code/vm/malloc/memlib.c

/* Private global variables */
static char *mem_heap;      /* Points to first byte of heap */
static char *mem_brk;       /* Points to last byte of heap plus 1 */
static char *mem_max_addr;  /* Max legal heap addr plus 1 */

/*
 * mem_init - Initialize the memory system model
 */
void mem_init(void)
{
    mem_heap = (char *)Malloc(MAX_HEAP);
    mem_brk = (char *)mem_heap;
    mem_max_addr = (char *)(mem_heap + MAX_HEAP);
}

/*
 * mem_sbrk - Simple model of the sbrk function. Extends the heap
 * by incr bytes and returns the start address of the new area. In
 * this model, the heap cannot be shrunk.
 */
void *mem_sbrk(int incr)
{
    char *old_brk = mem_brk;

    if ((incr < 0) || ((mem_brk + incr) > mem_max_addr)) {
        errno = ENOMEM;
        fprintf(stderr, "ERROR: mem_sbrk failed. Ran out of memory...\n");
        return (void *)-1;
    }
    mem_brk += incr;
    return (void *)old_brk;
}

图 9-41 memlib.c:内存系统模型

隐式空闲链表的恒定形式

图 9-42 隐式空闲链表的恒定形式

# 2. 操作空闲链表的基本常数和宏

图 9-43 展示了一些我们在分配器编码中将要使用的基本常数和宏。第 2~4 行定义了一些基本的大小常数:字的大小(WSIZE)和双字的大小(DSIZE),初始空闲块的大小和扩展堆时的默认大小(CHUNKSIZE)。

在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和指针运算。因此,我们发现定义一小组宏来访问和遍历空闲链表是很有帮助的(第 9~25 行)。PACK 宏(第 9 行)将大小和已分配位结合起来并返回一个值,可以把它存放在头部或者脚部中。

code/vm/malloc/mm.c

/* 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))

/* 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)

/* 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)))

图 9-43 操作空闲链表的基本常数和宏

GET 宏(第 12 行)读取和返回参数 p 引用的字。这里强制类型转换是至关重要的。参数 p 典型地是一个(void *)指针,不可以直接进行间接引用。类似地,PUT 宏(第 13 行)将 val 存放在参数 p 指向的字中。

GET_SIZEGET_ALLOC 宏(第 16~17 行)从地址 p 处的头部或者脚部分别返回大小和已分配位。剩下的宏是对块指针(block pointer,用 bp 表示)的操作,块指针指向第一个有效载荷字节。给定一个块指针 bpHDRPFTRP 宏(第 20~21 行)分别返回指向这个块的头部和脚部的指针。NEXT_BLKPPREV_BLKP 宏(第 24~25 行)分别返回指向后面的块和前面的块的块指针。

可以用多种方式来编辑宏,以操作空闲链表。比如,给定一个指向当前块的指针 bp,我们可以使用下面的代码行来确定内存中后面的块的大小:

size_t size = GET_SIZE(HDRP(NEXT_BLKP(bp)));

# 3. 创建初始空闲链表

在调用 mm_malloc 或者 mm_free 之前,应用必须通过调用 mm_init 函数来初始化堆(见图 9-44)。

mm_init 函数从内存系统得到 4 个字,并将它们初始化,创建一个空的空闲链表(第 4~10 行)。然后它调用 extend_heap 函数(图 9-45),这个函数将堆扩展 CHUNKSIZE 字节,并且创建初始的空闲块。此刻,分配器已初始化了,并且准备好接受来自应用的分配和释放请求。

code/vm/malloc/mm.c

int mm_init(void)
{
    /* Create the initial empty heap */
    if ((heap_listp = mem_sbrk(4*WSIZE)) == (void *)-1)
        return -1;
    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);

    /* Extend the empty heap with a free block of CHUNKSIZE bytes */
    if (extend_heap(CHUNKSIZE/WSIZE) == NULL)
        return -1;
    return 0;
}

图 9-44 mm_init:创建带一个初始空闲块的堆

code/vm/malloc/mm.c

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);
}

图 9-45 extend_heap:用一个新的空闲块扩展堆

extend_heap 函数会在两种不同的环境中被调用:1)当堆被初始化时;2)当 mm_malloc 不能找到一个合适的匹配块时。为了保持对齐,extend_heap 将请求大小向上舍入为最接近的 2 字(8 字节)的倍数,然后向内存系统请求额外的堆空间(第 7~9 行)。

extend_heap 函数的剩余部分(第 12~17 行)有点儿微妙。堆开始于一个双字对齐的边界,并且每次对 extend_heap 的调用都返回一个块,该块的大小是双字的整数倍。因此,对 mem_sbrk 的每次调用都返回一个双字对齐的内存片,紧跟在结尾块的头部后面。这个头部变成了新的空闲块的头部(第 12 行),并且这个片的最后一个字变成了新的结尾块的头部(第 14 行)。最后,在很可能出现的前一个堆以一个空闲块结束的情况中,我们调用 coalesce 函数来合并两个空闲块,并返回指向合并后的块的块指针(第 17 行)。

# 4. 释放和合并块

应用通过调用 mm_free 函数(图 9-46),来释放一个以前分配的块,这个函数释放所请求的块(bp),然后使用 9.9.11 节中描述的边界标记合并技术将之与邻接的空闲块合并起来。

code/vm/malloc/mm.c

void mm_free(void *bp)
{
    size_t size = GET_SIZE(HDRP(bp));

    PUT(HDRP(bp), PACK(size, 0));
    PUT(FTRP(bp), PACK(size, 0));
    coalesce(bp);
}

static void *coalesce(void *bp)
{
    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) {            /* Case 1 */
        return bp;
    }

    else if (prev_alloc && !next_alloc) {      /* Case 2 */
        size += GET_SIZE(HDRP(NEXT_BLKP(bp)));
        PUT(HDRP(bp), PACK(size, 0));
        PUT(FTRP(bp), PACK(size, 0));
    }

    else if (!prev_alloc && next_alloc) {      /* Case 3 */
        size += GET_SIZE(HDRP(PREV_BLKP(bp)));
        PUT(FTRP(bp), PACK(size, 0));
        PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
        bp = PREV_BLKP(bp);
    }

    else {                                     /* Case 4 */
        size += GET_SIZE(HDRP(PREV_BLKP(bp))) +
            GET_SIZE(FTRP(NEXT_BLKP(bp)));
        PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
        PUT(FTRP(NEXT_BLKP(bp)), PACK(size, 0));
        bp = PREV_BLKP(bp);
    }
    return bp;
}

图 9-46 mm_free:释放一个块,并使用边界标记合并将之与所有的邻接空闲块在常数时间内合并

coalesce 函数中的代码是图 9-40 中勾画的四种情况的一种简单直接的实现。这里也有一个微妙的方面。我们选择的空闲链表格式(它的序言块和结尾块总是标记为已分配)允许我们忽略潜在的麻烦边界情况,也就是,请求块 bp 在堆的起始处或者是在堆的结尾处。如果没有这些特殊块,代码将混乱得多,更加容易出错,并且更慢,因为我们将不得不在每次释放请求时,都去检查这些并不常见的边界情况。

# 5. 分配块

一个应用通过调用 mm_malloc 函数(见图 9-47)来向内存请求大小为 size 字节的块。在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。第 12~13 行强制了最小块大小是 16 字节:8 字节用来满足对齐要求,而另外 8 个用来放头部和脚部。对于超过 8 字节的请求(第 15 行),一般的规则是加上开销字节,然后向上舍入到最接近的 8 的整数倍。

code/vm/malloc/mm.c

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;
    else
        asize = DSIZE * ((size + (DSIZE) + (DSIZE-1)) / DSIZE);

    /* 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;
}

图 9-47 mm_malloc:从空闲链表分配一个块

一旦分配器调整了请求的大小,它就会搜索空闲链表,寻找一个合适的空闲块(第 18 行)。如果有合适的,那么分配器就放置这个请求块,并可选地分割出多余的部分(第 19 行),然后返回新分配块的地址。

如果分配器不能够发现一个匹配的块,那么就用一个新的空闲块来扩展堆(第 24~26 行),把请求块放置在这个新的空闲块里,可选地分割这个块(第 27 行),然后返回一个指针,指向这个新分配的块。

练习题 9.8 为 9.9.12 节中描述的简单分配器实现一个 find_fit 函数。

static void *find_fit(size_t asize)

你的解答应该对隐式空闲链表执行首次适配搜索。

练习题 9.9 为示例的分配器编写一个 place 函数。

static void place(void *bp, size_t asize)

你的解答应该将请求块放置在空闲块的起始位置,只有当剩余部分的大小等于或者超出最小块的大小时,才进行分割。