8.7 样例 - 内存分配器
在第五章中,我们给出了一个非常受限的面向栈的内存分配器。这次我们要写一个不受限制的版本。使用者能够以任意顺序调用 malloc 和 free;malloc 按需向操作系统申请更多的内存。这些例程说明了以相对不依赖于机器的方式来写依赖于机器的代码时的一些注意事项,并展示了一个使用结构体、联合体和 typedef 的现实的应用程序。
malloc 会在需要时向操作系统申请内存,而不是从编译好的固定大小的数组中申请。由于程序中的其他活动(代码)也可能不通过这个内存分配器来申请内存,因此 malloc 管理的内存可能不是连续的。因此,空闲的内存保存为一个由空闲内存块构成的链表。每个块包含的信息有:块的大小、指向下一个块的指针,以及空间本身。这些块按照内存地址的递增顺序排列,且最后一个块(最高的地址)指向第一个块。
当收到请求时会扫描空闲列表,直到找到一个足够大的块。这个算法被称为“首次适配”(first fit),区别于“最佳适配”(best fit),后者会找到满足要求的最小的块。如果这个块的大小正好是请求的大小,则它从链表脱离,并被返回给用户。如果块太大了,则会被拆分,其中合适的大小返回给用户,而剩余部分则保留在空闲列表中。如果找不到足够大的块,则会从操作系统获取一个更大的块,并将其加入到空闲列表中。
释放操作也需要在空闲列表中搜索,以便找到合适的位置来插入被释放的块。如果被释放的块正好与某个空闲块的任一端相邻,则这两个块合并成一个更大的块,这样内存就不会太碎片化。判断是否相邻很简单,因为空闲列表是以地址递增的顺序来维护的。
我们在第五章提到过的一个问题,是保证 malloc 返回的内存空间要正确地对齐,这样存入其中的对象才能正确地被机器访问。尽管机器之间有差异,但每个机器都有一个最严格的类型:如果最严格的类型能保存到某个特定的地址,则所有其他类型也能。在某些机器上,最严格的类型是 double;而在另一些机器上, int 和 long 就足够了。
空闲块包含三部分:指向链表中下一个块的指针,块大小的记录,以及空闲的内存空间本身;开头的控制信息称为“头部”(header)。为了简化对齐,我们使所有的块都为头部大小的倍数,而且让头部自身达到正确对齐。这可以通过一个联合体来实现,它包含了所需的头部结构体,以及一个有着最严格对齐类型的实例,这里我们随意选取了 long:
typedef long Align; /* 对齐到long的边界 */
union header { /* 块的头部 */
struct {
union header *ptr; /* 若在空闲列表上,则指向下一个空闲块 */
unsigned size; /* 这个块的大小 */
} s;
Align x; /* 强制使块对齐 */
}
typedef union header Header;
联合体中的 Align 域从不被使用;它只是使每个头部对齐在最差情况的边界上。
在 malloc 中,把请求的字节数向上舍入(round up),计算得出头部大小的内存单元的正确数量;而要分配的内存块中多包含了一个单元,用来保存头部。这些单元的数量,也就是头部 size 字段记录的值。malloc 返回的指针指向空闲的内存,而不是头部自己。用户可以在请求的内存空间内做任何事情,但如果在超出所分配的空间外写内存,则链表有可能被破坏。
size 字段是需要的,因为 malloc 控制的块不一定是连续的——不可能通过指针运算来计算内存大小。
变量 base 用来启动。如果 freep 为 NULL(当首次调用 malloc 时即如此),则会创建一个退化的空闲链表;该链表包含一个大小为零的块,并指向自身。不管是否首次调用,接下来都是搜索这个空闲链表。从上一个所找到的块(freep)开始,搜索足够大的空闲块;这个策略有助于保持链表同构。如果找到一个太大的块,则把末端返回给用户;按这种方法,只需要调整原始块的大小。在所有情况下,返回给用户的指针都指向块中的空闲空间,该空间的起始位置比头部多出一个单元。
static Header base; /* 用于启动的空闲链表 */
static Header *freep = NULL; /* 空闲链表的开头 */
/* malloc:通用的内存分配器 */
void *malloc(unsigned nbytes)
{
Header *p, *prevp;
Header *morecore(unsigned);
unsigned nunits;
nunits = (nbytes+sizeof(Header)-1)/sizeof(Header) + 1;
if ((prevp = freep) == NULL) { /* 还没有空闲链表 */
base.s.ptr = freep = prevp = &base;
base.s.size = 0;
}
for (p=prevp->s.ptr; ; prevp = p, p = p->s.ptr) {
if (p->size >= nunits) { /* 足够大 */
if (p->size == nunits) /* 正好 */
prevp->s.str = p->s.str;
else { /* 分配末端 */
p->s.size -= nunits;
p += p->s.size;
p->s.size = nunits;
}
freep = prevp;
return (void *)(p+1);
}
if (p == freep) /* 空闲列表找了一圈回到起点 */
if ((p = morecore(nunits)) == NULL)
return NULL; /* 没空间了 */
}
}
morecore 函数向操作系统请求内存。如何做到这一点,其细节随系统的不同而不同。由于向操作系统请求内存是代价相对高昂的操作,我们不想每次调用 malloc 时都去做一次,因此让 morecore 最少请求 NALLOC 个单元;得到的大块单元在需要时被拆分。在设置完大小字段之后, morecore 调用 free 把新增加的内存插入到空闲区域(arena)中。
UNIX 系统调用 sbrk(n) 返回一个指针,指向(额外申请的) n 个字节的内存空间。如果没有空间,则 sbrk 返回 -1,其实设计成返回 NULL 会更好。 -1 必须被强制转换为 char *,这样才能与返回值进行比较。而且,强制类型转换使函数相对地不那么受到不同机器里指针表示细节的影响。然而这里还有一个假设,即 sbrk 返回的不同块的指针之间能够进行有意义的比较。标准并没有保证这一点,它只允许数组内的指针比较。因此,这个版本的 malloc 只在通用指针比较是有意义的机器上是可移植的。
#define NALLOC 1024 /* 请求的最小内存单元 */
/* morecore:向系统申请更多内存 */
static Header *morecore(unsigned nu)
{
char *cp, *sbrk(int);
Header *up;
if (nu < NALLOC)
nu = NALLOC;
cp = sbrk(nu * sizeof(Header));
if (cp == (char *) -1) /* 完全没空间了 */
return NULL;
up = (Header *) cp;
up->size = nu;
free((void *)(up+1));
return freep;
}
最后是 free 。它从 freep 开始扫描空闲链表,寻找空闲块的插入位置。这位置可以是在两个现有的块之间,或是在链表的某一端。不管是哪种情况,如果释放的块地址与链表中前一块或后一块的地址相邻,则相邻的块会被合并。仅有的难点在于使指针保持指向正确的内容,并且大小正确。
/* free:把ap块放入空闲链表 */
void free(void *ap)
{
Header *bp, *p;
bp = (Header *)ap - 1; /* 指向块的头部 */
for (p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr)
if (p >= p->s.ptr && (bp > p || bp < p->s.ptr))
break; /* 空闲块在空闲内存的开头或结尾 */
if (bp + bp->s.size == p->s.ptr) { /* 加入上面的nbr */
bp->s.size += p->s.ptr->s.size;
bp->s.ptr = p->s.ptr->s.ptr;
} else
bp->s.ptr = p->s.ptr;
if (p + p->s.size == bp) { /* 加入下面的nbr */
p->s.size += bp->s.size;
p->s.ptr = bp->s.ptr;
} else
p->s.ptr = bp;
freep = p;
}
尽管内存分配本质上是依赖于机器的,但上述代码说明了如何控制这些依赖于机器的代码,并将其限制在程序的一个很小的部分中。使用 typedef 和 union 来处理对齐(前提是 sbrk 提供了合适的指针)。强制类型转换使指针转换变成显式,甚至能与设计差的系统接口配合。尽管这里的代码细节与存储分配相关,但这种通用的做法也适用于其他场合。
练习8-6、标准库函数 calloc(n, size) 指向大小为 size 的 n 个对象的指针,且内存初始化为 0。通过调用或者修改 malloc,来实现 calloc。
练习8-7、malloc 未检查所申请的内存大小的合理性。free 相信要释放的块包含了合法的size 字段。改进这两个例程,使之进行更多错误检查。
练习8-8、写一个例程 bfree(p,n),释放包含 n 个字符的任意空闲块 p 到 malloc 和 free 管理的链表中。通过使用 bfree, 用户可以在任何时候将静态或外部数组加到空闲链表中。
(全书完)
附录略