自由空间管理

在本章中,我们将从讨论虚拟化内存的过程中进行一个小的讨论,以讨论任何内存管理系统的一个基本方面,无论是malloc库(管理一个进程堆的页面)还是操作系统本身(处理进程的地址空间的部分)。具体来说,我们将讨论自由空间管理的问题。

让我们把问题讲得更具体些。管理空闲空间当然很容易,因为我们将在讨论分页的概念时看到。当你管理的空间被划分为固定大小的单元时,这是很容易的;在这种情况下,您只需保留这些固定大小的单元列表;当客户端请求其中一个时,返回第一个条目。当自由空间管理变得更加困难(和有趣)时,你所管理的自由空间由可变大小的单元组成;这在用户级内存分配库中出现(如malloc()和free()),在使用分割来实现虚拟内存时管理物理内存的操作系统中。在任何一种情况下,存在的问题都被称为外部碎片:自由空间被分割成不同大小的小块,因此被分割;后续请求可能会失败,因为没有单个连续的空间可以满足请求,即使自由空间的总量超过了请求的大小。


图中显示了这个问题的一个例子。在这种情况下,可用的总空闲空间是20个字节;不幸的是,它被分割成两个大小为10的块。因此,即使有20个字节的空闲,对15个字节的请求也会失败。因此,我们到达了本章所讨论的问题。

难题:如何管理自由空间。

17.1假设

大多数讨论将集中在用户级内存分配库中找到的配置器的伟大历史。我们利用Wilson的优秀调查[W+95],但鼓励有兴趣的读者到源文档本身获取更多细节。我们假设一个基本的接口,如malloc()和free()提供的接口。具体地说,void *malloc(size t size)只接受一个参数,大小,这是应用程序请求的字节数;它将指针(没有特定类型的指针,或C语言中的空指针)返回到该大小的区域(或更大)。互补的互补的例程,void free (void *ptr)获取一个指针并释放相应的块。注意接口的含义:用户在释放空间时,不通知库的大小;因此,库必须能够计算出内存块的大小,而只需要将指针指向它。我们将在后面的章节中讨论如何做这件事。

这个库管理的空间历史上被称为堆,而用于管理堆中的空闲空间的通用数据结构是某种自由列表。该结构包含对托管区域内存中所有空闲块的引用。当然,这个数据结构不必是一个列表本身,而是一种跟踪空闲空间的数据结构。我们进一步假设,我们主要关心的是外部的碎片,如上所述。分配器当然也有内部碎片的问题;如果分配器分发的内存块大于所请求的内存块,那么在这样的块中任何未被请求的(以及因此未使用的)空间被认为是内部碎片(因为浪费发生在分配的单元内),并且是空间浪费的另一个例子。然而,为了简单起见,由于这两种类型的碎片比较有趣,我们将主要关注外部碎片。我们还假设,一旦将内存分发给客户机,它就不能被转移到内存中的另一个位置。例如,如果一个程序调用malloc(),并且给定一个指向堆中的某个空间的指针,那么该内存区域实际上是程序拥有的(并且不能被库移动),直到程序通过一个对应的free()调用返回它为止。因此,不可能有自由空间的压缩,这对抗碎片化会有用。压实可以,但是,在操作系统中,当实现分割时,要使用它来处理碎片问题(正如在章节中所讨论的)。最后,我们假设分配器管理一个连续的字节区域。在某些情况下,分配器可以要求该区域增长;例如,一个用户级内存分配库可能会调用内核来增加堆(通过一个系统调用,比如sbrk),当它耗尽空间时。然而,为了简单起见,我们假设该区域在其整个生命周期内是一个单一的固定大小。

底层机制,

在深入讨论一些策略细节之前,我们将首先介绍在大多数配置器中使用的一些常用机制。首先,我们将讨论在大多数分配器中分离和合并的基本技术。其次,我们将展示如何快速、相对轻松地跟踪分配区域的大小。最后,我们将讨论如何在空闲空间中构建一个简单的列表,以跟踪什么是空闲的。

分裂和合并

一个空闲列表包含一组元素,它们描述了堆中仍然存在的空闲空间。因此,假设有以下30个字节的堆。


此堆的空闲列表将包含两个元素。一个条目描述了第一个10字节的空闲段(字节0-9),一个条目描述了另一个空闲段(字节20-29)。


如上所述,任何大于10字节的请求都将失败(返回NULL);没有一个连续的内存块可用。对于大小(10字节)的请求,可以通过任意一个空闲块轻松满足。但是如果请求小于10字节会发生什么。

假设我们只需要一个字节的内存。在这种情况下,分配器将执行一个被称为拆分的操作:它将找到一个可以满足请求并将其分割成两个的空闲内存块。第一部分将返回给调用者;第二部分将留在名单上。因此,在上面的例子中,如果请求1字节,和分配器决定使用第二个名单上的两个元素来满足请求,调用malloc()将返回20(字节的地址分配的区域)和列表会看起来像这样。


在图片中,你可以看到列表基本上保持不变;唯一的变化是自由区域现在从21开始,而不是20,而自由区域的长度现在只有9。因此,当请求小于任何特定空闲块的大小时,分配程序通常使用分配器。

在许多分配器中发现的一个推论机制被称为自由空间的聚结。再次使用上面的示例(免费10个字节,使用10个字节,另一个免费10个字节)。

考虑到这个(小)堆,当应用程序调用free(10)时,会发生什么,从而返回堆中间的空间?如果我们只是简单地将这个空闲空间添加到列表中,而不需要过多考虑,我们可能会得到一个这样的列表。


注意这个问题:虽然整个堆现在是免费的,但是它看起来被分成了三个块,每个字节10个字节。因此,如果用户请求20个字节,那么一个简单的列表遍历将不会找到这样一个空闲块,并且返回失败。

当内存块被释放时,为了避免这个问题,分配器所做的工作就是合并空闲空间。这个想法很简单:当在内存中返回一个空闲块时,仔细查看您返回的块的地址以及附近的空闲空间块;如果新释放的空间位于一个(或两个)现有的空闲块的旁边,将它们合并成一个更大的自由块。因此,通过合并,我们的最终列表应该是这样的。


实际上,在进行分配之前,这就是堆列表的样子。通过合并,分配器可以更好地确保应用程序可以使用大量的空闲区段。

跟踪已分配区域的大小

您可能已经注意到这个接口,不取大小参数,因此,假定给定一个指针,malloc库可以快速确定释放内存区域的大小,从而将空间重新合并到空闲列表中。为了完成这个任务,大多数的分配器在内存中保存了一些额外的信息,这些信息通常保存在内存中。让我们再看一个示例(图17.1)。在这个例子中,我们正在检查一个被分配的大小为20字节的块,由ptr指向。想象的用户名为将结果存储在ptr中,如ptr = malloc(20)。header最小包含已分配区域的大小(在本例中为20)。它还可能包含额外的指针,以加速存储单元一个神奇的数字,以提供额外的完整性检查和其他信息。让我们假设一个简单的标头,它包含该区域的大小和一个神奇的数字,像这样。


上面的示例看起来如图17.2所示。用户调用free()时候,然后,库使用简单的指针算法来计算头的起始位置。


在获取了一个指向标头的指针之后,库可以很容易地确定这个神奇的数字是否与预期值匹配为一个完整性检查(assert(hptr->magic == 1234567)),并通过简单的数学计算(即,计算新释放的区域的总大小)。,将头部的大小添加到该区域的大小。请注意最后一句话中的小而关键的细节:自由区域的大小是头的大小加上分配给用户的空间的大小。因此,当用户请求N个字节时。 在内存中,库不会搜索大小为N的空闲块;相反, 它搜索一个大小为N的空闲块,加上头指针的大小。

嵌入一个空闲列表

到目前为止,我们把我们的简单自由列表当作一个概念性的实体;它只是一个描述堆中空闲内存块的列表。但是我们如何在自由空间内建立这样一个列表。在一个更典型的列表中,当分配一个新节点时,您只需调用malloc(),当您需要该节点的空间时。不幸的是,在内存分配库中,您不能这样做。相反,您需要在空闲空间本身构建列表。不要担心这听起来有点奇怪;它是,但不是很奇怪,你不能做它。假设我们有一个4096字节的内存块来管理(即:,堆是4KB)。为了将其作为一个自由列表来管理,我们首先要初始化表示列表,最初,列表应该有一个条目,大小为4096(减去标题的大小)。下面是列表节点的描述。最)。下面是列表节点的描述,


现在让我们来看一些初始化堆的代码,并将空闲列表的第一个元素放在该空间中。我们假设堆是在通过对系统调用mmap()的调用获得的一些空闲空间内构建的;这不是构建此类堆的唯一方法,但在本例中为我们提供了良好的服务。这是代码:


运行此代码后,列表的状态是它只有一个条目,大小为4088。是的,这是一个小堆,但它为我们提供了一个很好的例子。




现在,让我们假设有一大块内存被请求,比如大小为100字节。为了服务这个请求,库将首先找到足够大的块来容纳请求;因为只有一个空闲块(大小:4088),所以将选择这个块。然后,将块分成两部分:一组大到足以服务请求(如上所述的头)和剩余的空闲块。假设一个8字节的头(一个整数和一个整数的数字),堆中的空间现在看起来像图17.4所示。因此,在请求100字节时,库从现有的一个空闲块中分配了108个字节,返回一个指针(上图中标记为ptr)。立即将消息头信息加起来,之前的分配的空间供以后使用(),并将列表中的一个空闲节点压缩到3980字节(4088 - 108)。


现在让我们看看在有三个分配区域时的堆,每个区域都有100个字节(或108个包含header)。这个堆的可视化显示在图17.5中。

正如您所看到的,现在已经分配了堆的前324个字节,因此我们在这个空间中看到了三个头,以及调用程序使用的三个100字节区域。空闲列表仍然没有意义:只有一个节点(由head指向),但是现在只有3764个字节。但是,当调用程序通过free返回一些内存时会发生什么。


在这个示例中,应用程序通过调用free(16500)来返回分配内存的中间块(16500是通过添加内存区域的开始,16384,到前一个块的108和这个块的头8个字节来实现的)。这个值在前面的图中由指针sptr显示。该库立即计算出自由区域的大小,然后将空闲块添加到空闲列表中。假设我们在空闲列表的头部插入,现在的空间看起来是这样的(图17.6)


从图中可以看出,我们现在有一团乱麻!为什么?很简单,我们忘记合并列表了。虽然所有的记忆都是自由的,但它被分割成碎片,因此,尽管不是一个记忆体,但它仍然是一个片段的记忆。解决方法很简单:遍历列表并合并相邻的块;完成后,堆将再次完整。

越来越多的堆

我们应该讨论在许多分配库中发现的最后一个机制。具体地说,如果堆耗尽了空间,您应该怎么做?最简单的方法就是失败。在某些情况下,这是惟一的选项,因此返回NULL是一种值得尊敬的方法。不要感觉不好!你试过了,虽然失败了,但你还是打了一场好仗。

   大多数传统的分配器从一个小的堆开始,然后从操作系统中请求更多的内存。通常,这意味着他们会进行某种类型的系统调用(例如,在大多数UNIX系统中使用sbrk)来增加堆,然后从那里分配新的块。为了服务sbrk请求,操作系统找到了免费的物理页面,将它们映射到请求过程的地址空间,然后返回新堆的结束值;在这一点上,可以使用更大的堆,请求可以被成功地服务。

17.3基本策略

现在我们有了一些机器,让我们来复习一些管理自由空间的基本策略。这些方法主要是基于一些非常简单的策略,你可以自己思考;在阅读之前尝试一下,看看你是否能找到所有的替代方案(或者一些新的方案!)。

理想的分配器既快速又最小化了碎片化。不幸的是,由于分配和免费请求流可能是任意的(毕竟,它们是由程序员决定的),任何特定的策略都可能在输入错误的情况下做得很糟糕。因此,我们不会描述一个最好的方法,而是讨论一些基础知识并讨论它们的优缺点。

  • 最佳配合

最适合的策略非常简单:首先,搜索空闲列表,然后找到比请求的大小更大或更大的空闲内存块。然后,返回那组候选人中最小的那个;这就是所谓的最佳拟合块(它也可以称为最小匹配)。一个通过空闲列表就足够找到正确的块返回。

最佳匹配背后的直觉很简单:通过返回一个接近用户要求的块,最佳匹配尝试减少浪费的空间。然而,这是有代价的;在对正确的空闲块进行彻底搜索时,幼稚的实现会付出沉重的性能代价。

  • 最差适配

最糟糕的健身方式与最佳健身方式相反;找到最大的块并返回所请求的数量;将剩余(大)块保留在空闲列表上。最坏的情况是尽量把大块的东西放出来,而不是大量的可以从最佳匹配方法中产生的小块。但是,再一次,需要对空闲空间进行全面的搜索,因此这种方法可能代价高昂。更糟糕的是,大多数研究表明,它表现糟糕,导致过度的碎片化,同时仍然有高的开销。


  • 首次适应

首次适应方法只找到足够大的第一个块,并将请求的数量返回给用户。如前所述,剩余的空闲空间被保留,以供后续请求使用。第一个匹配的优点是速度不彻底搜索所有的空闲空间是必要的,但有时会用小对象污染自由列表的开头。因此,分配器如何管理空闲列表的顺序成为一个问题。一种方法是使用基于地址的排序;通过保持由空闲空间的地址排序的列表,合并变得更容易,而碎片化趋向于减少。

下次适配

与其总是在列表的开始处开始第一次匹配搜索,下面的算法将一个额外的指针保存到列表中的位置。这样做的目的是为了在整个列表中更均匀地展开搜索,从而避免了列表的开始。这种方法的性能非常类似于第一次匹配,因为一次彻底的搜索再次被避免了。

  • 实例

下面是上述策略的一些例子。设想一个包含三个元素的免费列表,大小为10、30和20(我们将忽略这里的头和其他细节,而只关注策略如何操作)。


假设分配请求的大小为15。一个最佳方法 搜索整个列表,发现20是最合适的,因为它是最小的。 可以容纳请求的自由空间。


就像在这个例子中所发生的那样,并且经常发生一个最适合的方法,一个小的空闲块现在被剩下。最坏的方法是相似的,但是在这个示例中找到了最大的块。


在这个示例中,第一个fit策略的执行情况与最坏的情况相同,也就是找到能够满足请求的第一个自由块。不同之处在于搜索成本;在整个列表中,最适合和最不适合的都要看;首先,只检查空闲块,直到找到合适的块,从而降低搜索成本。这些例子仅仅触及了分配策略的表面。对实际工作负载和更复杂的分配器行为(例如合并)进行更详细的分析需要更深入的理解。你说也许是做作业的部分。

17.4其他方法

除了上面描述的基本方法之外,还有许多建议的技术和算法可以以某种方式改善内存分配。我们在这里列出了其中的一些供你考虑。,让你思考的不仅仅是最适合的分配。

隔离的列表

一种有趣的方法是使用隔离列表。基本的想法很简单:如果一个特定的应用程序有一个(或几个)受欢迎的请求,那么就保留一个单独的列表来管理这个大小的对象;所有其他请求都被转发给一个更通用的内存分配器。

这种方法的好处是显而易见的。通过将一大块内存专门用于一个特定大小的请求,碎片化就不那么令人担心了;此外,在适当大小的情况下,分配和回收请求可以非常迅速地提供,因为不需要对列表进行复杂的搜索。

就像任何好的想法一样,这种方法也将新的复杂性引入到一个系统中。例如,对于一个给定大小的特殊请求的内存池,应该使用多少内存,而不是一般的池?一个特别的分配器,由超级工程师Jeff Bonwick(设计用于Solaris内核的设计)的slab分配器,以一种相当好的方式处理这个问题[B94]。

具体地说,当内核启动时,它会为可能经常被请求的内核对象分配一些对象缓存(例如锁、文件系统索引等);因此,对象缓存是给定大小的每个隔离的免费列表,并快速提供内存分配和回收请求。当一个给定的缓存在空闲空间上运行时,它会从一个更一般的内存分配程序(请求的总数量是页面大小的倍数和问题的对象)请求一些内存块。相反地,当给定的平板a中的对象的引用计数。

分配器也超越了大多数隔离的列表方法,它使列表中的自由对象处于预初始化状态。Bonwick显示数据结构的初始化和破坏代价很高[B94];通过将释放的对象保存在初始化状态的特定列表中,slab分配器因此避免了每个对象频繁的初始化和销毁周期,从而显著降低了开销。

巴迪分配

因为合并对于分配器来说至关重要,所以一些方法已经被设计成简单的结合。在二进制伙伴分配器[K65]中找到了一个很好的例子。在这样的系统中,自由内存是第一个概念上被认为是大小为的大空间。当请求内存时,对空闲空间的搜索会递归地将空闲空间分成两部分,直到找到一个足够大的块来容纳请求(然后再将其分割成两个会导致空间太小)。此时,请求的块将返回给用户。这里是一个64KB空闲空间的例子,它在搜索7KB块时被分割。

在这个例子中,最左边的8KB块被分配(如灰色阴影所示)并返回给用户;请注意,这个方案可能会受到内部碎片的影响,因为您只允许分发两个大小的块。

当这个块被释放时,就会发现好友分配的好处。当将8KB块返回到空闲列表时,分配器检查buddy 8KB是否空闲;如果是这样,它将两个块合并成一个16KB的块。然后分配器检查16KB块的伙伴是否仍然自由;如果是这样,那两个街区就合并了。这个递归合并进程继续向上爬树,要么恢复整个空闲空间,要么在发现好友正在使用时停止。

buddy分配如此有效的原因是它很容易确定一个特定区块的好友。怎么,你问?想想上面自由空间中块的地址。如果你仔细想想,你会发现每个伙伴对的地址只会有一点不同;这一点由buddy树的级别决定。因此,你有一个基本的想法,二进制伙伴分配方案是如何工作的。如往常一样,请参阅Wilson调查[W+95]。

  • 其它

上面描述的许多方法的一个主要问题是它们缺乏伸缩性。具体来说,搜索列表可能非常慢。因此,高级分配器使用更复杂的数据结构来处理这些成本,为性能进行简单的交易。例如,平衡的二叉树,splay树,或部分有序的树[W+95]。

考虑到现代系统通常有多个处理器,并运行多线程工作负载(您将在本书关于并发性的章节中详细地了解这些内容),因此花费大量精力使分配器在基于多处理器的系统上运行良好也就不足为奇了。在Berger等[B+00]和Evans [E06]中发现了两个很好的例子;检查他们的细节。

这些只是人们对记忆分配器的几千种想法中的两种;如果你好奇,就自己去读。如果失败了,请阅读glibc分配程序的工作原理(S15),让您了解真实的世界是什么样子的。

17.5总结

在本章中,我们讨论了最基本的内存分配程序。这样的分配器存在于任何地方,链接到您所编写的每个C程序中,以及在底层操作系统中,该操作系统是为自己的数据结构管理内存。与许多系统一样,在构建这样的系统时需要进行许多权衡,而且您对分配给分配器的确切工作负载了解得越多,您就越可以对其进行调优,以便更好地为该工作负载工作。快速、高效、可伸缩的分配器,适用于广泛的工作负载,这在现代计算机系统中仍然是一个不断挑战的挑战。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值