参考

Randal E. Bryant, David R. O’Hallaron. Computer Systems A Programmer’s Perspective. ISBN: 978-1-488-67207-1. 第十章 虚拟存储器

背景知识 虚拟主存

前言

  时钟信号在不停地产生,CPU在不停地执行指令,总线在不停的传递数据,内核也只是从一开始就执行的程序,中断也只是改了rip让CPU执行中断处理程序

  磁盘->进程的虚拟主存->物理主存。主存映射mmap将磁盘文件和虚拟页关联起来,地址翻译硬件和页表将虚拟页放入到物理主存中,交换文件将页在物理主存和磁盘之间换来换去

  使用主存时遇到的问题:系统中的所有进程都共享主存(memory)资源,如果太多的进程需要太多的主存,就会有一些倒霉蛋进程因为主存空间不够而无法继续运行。此外,如果某个进程不小心写了另一个进程正在使用的主存空间,另一个进程可能以非常迷惑的方式失败。

  虚拟主存的意义:为了有效地管理主存,并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟主存(virtual memory)。虚拟主存为每个进程提供了一个大的、一致的、私有的地址空间。虚拟主存提供了三个重要的能力:①它将主存看成是一个高速缓存,缓存磁盘上的空间。②在主存中保存进程的活动区域。③并根据需要在磁盘和主存之间来回传送数据。通过这种方式,它高效使用了主存;为每个进程提供了一致的地址空间,从而简化了存储器管理;它保护了每个进程的地址空间不被其他进程破坏。虚拟主存是硬件异常,硬件地址翻译,主存,磁盘文件和内核的完美交互。

  虚拟主存是计算机系统中最重要的概念之一。为什么程序员要理解它?

  • 虚拟主存是中心的。虚拟主存涉及计算机的所有层面,在硬件机场、汇编器、链接器、加载器、共享对象、文件和进程的设计中扮演着重要角色。理解虚拟存储器将帮助你更好地理解系统通常是如何工作的。
  • 虚拟主存是强大的。虚拟主存给予应用程序强大的能力,可以创建和删除主存块,将主存块映射到磁盘文件的某个部分,以及与其他进程共享主存。比如,你知道你可以通过读写主存位置读或者修改一个磁盘文件的内容吗?或者是你可以加在一个文件的内容到主存中,而不需要进行任何显示地拷贝吗?
  • 虚拟主存是危险的。每次应用程序引用一个变量、间接引用一个指针,或者调用一个诸如malloc这样的动态分配包程序时,它就会和虚拟主存发生交互。

  这一章从两个角度来讨论虚拟主存。前一部分描述虚拟主存是如何工作的,后一部分描述应用程序如何使用和管理虚拟主存。

10.1 物理和虚拟寻址

  主存的物理结构和物理寻址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(physical address, PA)。对于这种主存结构,CPU访问主存最自然的方式就是使用物理地址,我们称这种方式为物理寻址(physical addressing)。下图展示了一个物理寻址的例子,此时CPU正在执行一条加载指令,读取从物理地址4处开始的字。

image-20211109183726847

  当CPU执行这条加载指令时,它会生成一个有效的物理地址,通过存储器总线,把它传递给主存。主存取出从物理地址4处开始的4字节的字,返回给CPU, CPU会将它存放在一个就差你器里。

  虚拟寻址:早期的PC使用物理寻址,而且诸如数字信号处理器、嵌入式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式。然而,为通用计算设计的现代处理器使用的是虚拟寻址(virtual addressing)。

image-20211109184247542

  根据虚拟寻址,CPU通过生成一个虚拟地址(virtual address, VA)访问主存,这个虚拟地址在被送到主存之前被转换成物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译(address translation)。CPU芯片上叫做MMU(memory management unit,主存管理单元)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

10.2 地址空间

   地址空间(address space)是一个非负整数地址的有序集合:{0, 1, 2, … }

  如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)。

  在一个带虚拟主存的系统中,CPU从一个有N=2^n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space): {0, 1, 2, …, N-1}

  一个地址空间,可以由表示最大地址所需要的位数来描述。例如,一个包含N=2^n个地址的虚拟地址空间就叫做一个n位地址空间。现代系统一般都支持32位或64位虚拟地址空间。

  一个系统中海油一个物理地址空间(physical address space),它与系统中物理主存的M个字节相对应: {0, 1, 2, …, M-1}

  如此,我们能够清楚地区分数据对象(字节)和它们的属性(地址),在使用虚拟主存的系统中,每个数据对象既有一个选自虚拟地址空间的虚拟地址,还有一个选自物理地址空间的物理地址。

10.3 主存作为虚拟主存的缓存

  虚拟主存被组织为存放在磁盘上的N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。虚拟主存系统将虚拟主存分割为称为虚拟页(virtual page,VP)的大小固定的块,物理主存被分割为物理页(physical page,PP)。页的大小一般为4KB。

  任意时间点,虚拟页由3部分不相交的子集构成:

  • 未分配的虚拟页:虚拟主存系统还未分配的页。未分配的页不与任何数据关联,不占用任何磁盘空间。
  • 已分配未缓存的虚拟页:没有缓存在物理主存中的已分配页。
  • 已缓存的虚拟页:当前缓存在物理主存中的已分配页。

  下图展示了一个有8个虚拟页的虚拟主存。虚拟页0、3还没有分配,磁盘上还不存在。虚拟页1、4、6被缓存在物理主存中,页2、5、7已经被分配了,但是当前并为缓存在主存中。

image-20211110142235210

10.3.1 高速缓存的组织结构

  使用术语SRAM缓存来表示位于CPU和物理主存之间的L1和L2高速缓存,使用术语DRAM缓存来表示虚拟主存系统的缓存:物理主存。DRAM比SRAM慢10倍,磁盘比DRAM慢100000多倍。因此DRAM缓存的不命中(miss)比起SRAM的不命中要昂贵的多,因为DRAM缓存不命中要有磁盘来服务,而SRAM缓存不命中由DRAM来服务。所以,DRAM缓存这种结构完全是用巨大的不命中开销换空间。

10.3.2 页表

  虚拟主存系统需要判定一个虚拟页是否在物理主存中,如果在,存放在物理主存的哪个物理页中。当不命中时,虚拟页放到哪个物理页。这些功能是由许多软硬件联合提供的,包括:操作系统软件,MMU(memory management unit)中的地址翻译硬件,和一个存放在物理主存中叫做页表的数据结构。页表存放在物理主存中,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换成物理地址时,都会读取页表。操作系统负责维护页表内容,以及在磁盘与物理主存之间来回传送页。

  下图展示了一个页表的结构。页表是一个PTE(page table entry,页表条目)的数组。虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。我们假设每个PTE是由一个有效位(valid bit)和一个n位地址字段组成的。有效位表明改虚拟页当前是否被缓存在物理主存中。如果设置了有效位,那么地址字段就表示物理主存中相应物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。分则,这个地址就指向磁盘上虚拟页的起始位置。

  下图展示了一个有8个虚拟页和4个物理页的系统的页表。四个虚拟页1、2、7已被缓存在物理主存中,两个页0、5还没分配。剩下的页3、6已被分配还未缓存。

image-20211110145708890

10.3.3 页命中

  考虑一下当CPU读虚拟主存中的一个字时,它的地址被VP2包含,且被缓存在DRAM中,会发生什么?地址翻译硬件将虚拟地址作为一个索引,来定位PTE2,由于设置了有效位,地址翻译硬件就知道VP2已经缓存在物理主存DRAM中了,所以它使用PTE中的物理主存地址构造出这个字的物理地址。

image-20211110150622783

10.3.4 缺页

  通常将DRAM缓存不命中称为缺页(page fault)。下图展示了在缺页之前我们的示例页表的状态。

image-20211110151117891

  CPU使用了VP3中的一个字,地址翻译硬件从存储器中读取PTE3,从有效位推断出VP3未被缓存,触发一个缺页异常。

  缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,此例中就是PP3,如果PP3中的VP4已经被修改了,内核就将VP4拷贝回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。

  接下来,内核从磁盘拷贝VP3到物理主存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新执行导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件。但是现在VP3已经缓存在物理主存中了,那么地址翻译硬件就能正常地将虚拟地址变成物理地址了。

image-20211110151807094

  虚拟主存是在20世纪60年代早期发明的,远在SRAM之前。在虚拟主存的习惯说法中,块被称为页。在磁盘和主存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入DRAM,和从DRAM换出磁盘。仅当不命中发生时,才换入页的策略被称为按需页面调度(demand paging)。其他的方法也是可能的,比如尝试着预测不命中,在页实际被引用之前就换入页。然而,所有现代系统都使用的是按需页面调度的方式。

10.3.5 分配页面

  下图展示了当操作系统分配一个新的虚拟页时,对示例页表的影响,例如,调用malloc。在这个示例中,通过在磁盘上创建空间,并更新PTE5,使它指向磁盘上这个新创建的页面,从而分配VP5。image-20211110154046679

10.3.6 局部性再次搭救

  在了解到虚拟主存是把物理主存当做磁盘的缓存,通过页表和缺页异常将磁盘上的页放入物理主存时,我们的第一印象:不命中的惩罚辣么大,虚拟主存系统的效率一定很低。实际上虚拟主存工作的相当好,这归功于局部性(locality)。

  尽管在整个运行过程中程序引用的所有页面大小可能超出物理主存的大小,但是程序往往趋向于在一个较小的活动页面集(active page set)上工作,这个集合叫做工作集(working set)或者常驻集合(resident set)。在初始开销,也就是将工作集中的页调入主存中后,在接下来的指令执行中,将大量命中工作集的页,而不会产生多少额外的缺页开销。这种现象称为局部性。

  如果我们的程序有良好的局部性,虚拟主存系统就能工作得相当好。当然,不是所有的程序都有良好的局部性。如果工作集的大小超出了物理存储器的大小,那么程序将处于一种不幸的状态,叫做颠簸(thrashing),这时页面将不断地换进换出。如果一个程序执行慢得像爬一样,那么聪明的程序员会考虑看是不是发生了颠簸。

旁注:统计缺页次数:你可以用unix的getusage函数监测缺页的数量(以及许多其他的信息)。

10.4 虚拟主存作为主存管理的工具

  上述内容,使我们看到虚拟主存是如何用DRAM来缓存来自更大虚拟地址空间的页面。到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上,操作系统为每个进程提供了一个独立的页表,因而每个进程都有一个独立的虚拟地址空间。下图示例中,进程i的页表将VP1映射到PP2,VP2映射到PP7。相似地,进程j的页表将VP1映射到PP67, VP2映射到PP10。注意,多个虚拟页面可以映射到同一个共享物理页上。

image-20211110162016209

  按需页面调度和独立的虚拟地址空间的结合对系统中主存的使用和管理造成了深远的影响。特别的,虚拟主存简化了程序的链接和加载,共享代码和数据,以及对程序分配主存。

10.4.1 简化链接

  独立的地址空间允许每个进程使用相同的结构存放如虚拟主存,而不管代码和数据实际存放在物理主存的何处。例如,每个Linux进程都使用下图所示的格式。

  文本区总是从虚拟地址0x08048000处开始,栈总是从0xbfffffff向下伸展,共享库代码总是从地址0x40000000处开始,而操作系统代码和数据总是从0xc0000000开始。这样的一致性极大地简化了链接器的设计和实现,允许链接器生成全链接的可执行文件,这些可执行文件是独立于物理存储器中代码和数据的最终位置的。

image-20211110162453991

10.4.2 简化共享

  独立地址空间为操作系统提供了管理进程共享的机制。一般而言,每个进程都有自己私有的代码、数据、堆以及栈区域,是不和其他进程共享的。在这种情况中,操作系统创建页表,将相应的虚拟页映射到不同的物理页面。

  然而,有时需要进程共享代码和数据。例如,每个进程必须调用相同的操作系统内核代码,而每个C程序都会调用标准库中的程序,比如printf。操作系统通过将不同进程中适当的虚拟页面映射到的物理页面,从而使多个进程共享代码,而不是在每个进程中都包括单独的内核和C标准库的拷贝。

10.4.3 简化主存分配

  当一个程序要求额外的对空间时(例如调用malloc的结果),操作系统分配k个连续的虚拟主存页,并且将它们映射到物理主存中任意k个页。由于页表工作的方式,操作系统没有必要分配k个连续的物理主存页面。页面可以随机地分散在物理存储器中。

10.4.4 简化加载

  虚拟主存也使加载可执行文件和已共享目标文件到主存中变得容易。ELF(Executable and Linkable Format)可执行文件中的.txt和.data节是相邻的。为了加载这些节到一个新创建的进程中,Linux加载程序分配了一个从地址0x08048000处开始的连续的虚拟页区域,将它们标识为无效(即未缓存),并将它们的页表条目指向目标文件中适当的位置。

  有趣的是,加载器从不真正地从磁盘中拷贝任何数据到主存中。当每个页面第一次被引用时,虚拟主存系统将通过缺页异常把数据从磁盘上调入到主存。

  映射一个连续虚拟页的集合到任意一个文件中的任意一个位置,叫做主存映射(memory mapping)。Unix提供了一个叫做mmap的系统调用,允许应用程序进行主存映射。我们将在10.8节中更详细地描述应用层主存映射。

10.5 虚拟主存作为主存保护的工具

  任何现代计算机系统必须为操作系统提供手段来控制进程对主存的访问。不允许一个用户进程修改它的只读文本段,也不应该允许它读或修改任何内核中的代码和数据结构,不应该允许它读或写其他进程的私有主存,不允许它修改任何与其他进程共享的虚拟页面,除非所有共享者显示地允许它这么做。

  提供独立的地址空间使得分离不同进程的私有主存变得容易。每次CPU生成一个地址时,地址翻译硬件都会读一个PTE,可以通过在PTE上添加一些额外的许可位来控制对一个虚拟页面内容的访问。

image-20211110183033531

  在这个示例中,我们已经添加了三个许可位到每个PTE。SUP位表示进程是否必须运行在内核模式下才能访问该页。运行在内核模式中的进程可以访问任何页,但是运行在用户模式中的进程只允许访问那些SUP为0的页面。READ位和WRITE位控制对页面的读和写访问。例如,如果进程i运行在用户模式下,那么它有读VP0和读写VP1的权限。然而不允许它访问VP2。

  如果一条指令违反了这些许可条件,那么CPU就触发一个一般保护屏障,执行一个内核中的异常处理程序。Unix shell将这种异常报告为”段错误(segmentation fault)”。

10.6 地址翻译

  这一节讲的是地址翻译的基础知识,省略了大量细节,尤其是和时钟相关的细节,虽然这些细节对硬件设计者来说是非常重要的,但是超出了我们讨论的范围。下图概括了本节使用的所有符号:

image-20211110184631885

  地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的映射。

  MAP(A) = A’ 如果虚拟地址A出的数据在PAS的物理地址A‘处

     =∅ 如果虚拟地址A出的数据不在物理主存中

  下图展示了MMU是如何利用页表来实现虚拟地址到物理地址的转换。CPU中的一个控制寄存器,页表基址寄存器(page table base register, PTBR)指向当前进程的页表。n位的虚拟地址包含两个部分:一个p位的VPO(virtual page offset,虚拟页面偏移)和一个(n-p)位的VPN(virtual page number,虚拟页号)。MMU利用VPN来选择适当的PTE。例如,VPN0选择PTE0, VPN1选择PTE1, 以此类推。将页表条目中的PPN(physical page number,物理页号)和虚拟地址中的VPO拼接,就得到相应的物理地址。

image-20211110184736396

下图展示了页命中时,CPU硬件执行的步骤。

  • 第一步:处理器生成一个虚拟地址,并把它传给MMU。
  • 第二步:MMU生成PTE地址,并从高速缓存/主存请求得到它。
  • 第三步:高速缓存/主存向MMU返回PTE。
  • 第四步:MMU构造物理地址,并把它传送给高速缓存/主存。
  • 第五步:高速缓存/主存返回所请求的数据字给处理器。

image-20211110190300518

缺页时,要求硬件和操作系统内核协作完成地址映射

  • 第一步:处理器生成一个虚拟地址,并把它传给MMU。
  • 第二步:MMU生成PTE地址,并从高速缓存/主存请求得到它。
  • 第三步:高速缓存/主存向MMU返回PTE。
  • 第四步:PTE中的有效位是0,MMU触发一次异常,执行内核中的缺页异常处理程序。
  • 第五步:缺页处理程序找到一个物理主存中的牺牲页,如果这个页已经被修改了,就把它换出到磁盘。
  • 第六步:缺页处理程序调入新的页面到主存,并更新主存中的PTE。
  • 第七步:缺页处理程序返回到原来的进程,重新执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。这次虚拟页面在主存中。MMU向高速缓存/主存请求PTE, 高速缓存/主存返回PTE, MMU构造物理地址发送给高速缓存/主存,高速缓存/主存返回所请求的数据字给CPU。

image-20211110191121387

10.6.1 结合高速缓存和虚拟主存

  在任何既使用虚拟主存又使用SRAM缓存的系统中,都有应该使用虚拟地址还是使用物理地址来访问高速缓存的问题。大多数系统选择物理寻址。使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事。而且,高速缓存无需处理保护问题,因为访问权限的检查是地址翻译的一部分。

image-20211110191843187

10.6.2 使用TLB加速地址翻译

  每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE, 以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求一次对存储器的额外的取数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为TLB(translation lookaside buffer,翻译后备缓冲器)。

  TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的(索引找到组,标记找到行)。如果TLB有T=2^t个组,那么TLB索引是由VPN的t个最低位组成的,而TLB标记是由VPN中剩余的位组成的。

image-20211110193146823

TLB命中时,地址翻译的步骤:

  • 第一步:CPU产生一个虚拟地址。
  • 第二步和第三步:MMU从TLB中取出相应的PTE。
  • 第四步:MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
  • 第五步:高速缓存/主存将所请求的数据字返回给CPU。

image-20211110193443627

当TLB不命中时,MMU必须从L1缓存中取出相应的PTE放入TLB中,可能会覆盖一个已有的条目

  如果该组有空行,则缓存入空行;如果该组中没有空行,就选择一个非空行进行替换。

image-20211110193508099

10.6.3 多级页表

  每个进程都有一个页表,在地址空间为32位的系统中,如果每个页大小4KB,每个PTE大小4字节,则每个进程需要4M(4*2^32/2^12)B大小的页表驻留在主存中。

  用来压缩页表的常用方法是使用层次结构。比如在一个两级页表结构中,让每个页表的大小都为4KB,每个页表就有1K个PTE。一级页表中的每个PTE指向一个二级页表,一个二级页表存放了1k个正常的PTE。所以一个一级页表的PTE相当于映射了1K个页,即1K*4KB=4MB的虚拟地址空间。一个一级页表有1K个PTE,就相当于映射了1K*4MB=4GB的虚拟地址空间到物理主存。

image-20211110195246419

  这种层级页表结构节省了很多空间,如果一级页表中的一个PTE是空的,那么就没有二级页表,对于一个普通程序而言,4GB的虚拟地址空间大部分都是未分配的。只有一级页表和最经常使用的二级页表才需要缓存在主存中。

  使用k级页表结构的地址翻译。虚拟地址被划分成了k个VPN和1个VPO。每个VPNi都是一个找到i级页表的索引,1≤i≤k。第j级页表中的每个PTE,1≤j≤k-1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE都包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,MMU必须访问k个PTE。

image-20211110201809977

  访问k个PTE, 乍一看是昂贵的。然而,这里TLB能够起作用,通过将页表中不同层次上的PTE缓存起来,实际中,多级页表的地址翻译并不比单级页表慢很多。

10.6.4 综合:端到端的地址翻译

  这一节,我们通过一个具体的端到端的地址翻译示例,来综合一下我们刚学过的内容。这个示例运行在有一个TLB和L1缓存的小系统上。我们假设:

  • 主存是按字节寻址的。
  • 主存访问的字长是1字节。
  • 虚拟地址是14位长(n=14)。
  • 物理地址是12位长(m=12)。
  • 页面大小是64字节(P=64)。
  • TLB有四组,每组4行,共16个条目。
  • L1缓存是物理寻址,16组,每组一行,每行大小4字节(也叫直接映射)。

  因为页面大小是64=2^6字节,所以虚拟地址和物理地址的低6位分别是VPO和PPO。虚拟地址的高8位作为VPN。物理地址的高6位作为PPN。

image-20211110202649191

TLB、页表、L1cache某时间点的状态:

  • TLB。TLB是利用VPN的为进行虚拟寻址的。因为TLB有四个组,所以VPN的低两位就作为组索引(TLBI)。VPN中剩下的高6位作为标记(TLBT),用来区别可能映射到同一个TLB组的不同的VPN。

image-20211110203159830

  • 页表。这个页表是一级结构,一共有2^14/2^6=2^8个页,也就是有256个页表条目。然而我们只对开头的16个感兴趣。为了方便,我们用索引它的VPN标识每个PTE; 但要记住这些VPN并不是页表的一部分,也不在主存中。

image-20211110203251665

  • 缓存。直接映射的缓存,是通过物理地址中的字段来寻址的。因为处理器只要4字节,而cache一行有16字节,分为4块,所以物理地址的低2位作为块偏移。因为有16组,所以接下来的4位就用来表示组索引(CI),剩下6位作为标记(CT)。

image-20211110203631143

  给定了初始化设定。让我们来看看当CPU执行一条读地址0x03d4处字节的加载指令时,会发生什么?

image-20211110204314000

  开始时,MMU从虚拟地址中抽出VPN(0x0f),并且检查TLB是否缓存了PTE 0x0f的一个拷贝。TLB从VPN中抽取出TLB索引(0x3)和TLB标记(0x3),组0x3的第二个条目中有效位匹配,命中,然后将缓存的PPN(0X0D)返回给MMU。如果TLB不命中,那么MMU就需要从主存中取出相应的PTE。

  现在,MMU将PTE的PPN(0x0D)和来自虚拟地址的VPO(0x14)拼接起来,形成物理地址(0x354)。

image-20211110205440198

  接下来,MMU将物理地址发送给缓存,缓存从物理地址中抽取出缓存偏移CO(0X0)、缓存组索引CI(0x5)以及缓存标记CT(0X0D)。组0x5中的标记与CT匹配,命中,读出在偏移量CO处的字节(0x36),并将它返回给MMU,随后MMU将它传递会CPU。

10.7 案例研究 :Linux主存系统

  Linux为每个进程维持了一个单独的虚拟地址空间。内核虚拟主存位于0xc0000000之上,包含内核中的代码和数据。内核虚拟主存的某些区域被映射到所有进程的共享物理页面。例如,每个进程共享内核的代码和全局数据结构。Linux也将一组连续的虚拟页面(大小为主存大小)映射到相应的一组连续的物理页面。这就为内核提供了一种便利的方法,来访问物理存储器中任何特定的位置。内核虚拟主存的其他区域包含每个进程都不相同的数据。示例包括页表、内核在进程的上下文中执行代码时用的栈,以及记录虚拟地址空间当前组织的各种数据结构。

image-20211110215115625

Linux虚拟主存区域

  Linux将虚拟主存组织成一些区域(也叫作段)的集合。一个区域(area)是已经分配的连续虚拟页。例如代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个已分配的虚拟页都保存在某个区域中。区域的概念允许虚拟地址空间有间隙。

  图10.29展示了记录进程虚拟主存区域的内核数据结构。内核为每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行文件的名字,以及程序计数器)。

  task_struct中的一个条目mm指向mm_struct, mm_struct描述了虚拟主存当前的状态。我们感兴趣的两个字段是pgd和mmap,其中pgd指向一级页表的基址,mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域(area)。当内核运行这个进程时,它就将pgd存放在PDBR控制寄存器中。

  一个区域结构(vm_area_struct)包含下面的字段:

  • vm_start:指向这个区域的起始处。
  • vm_end:指向这个区域的结束处。
  • vm_port:描述这个区域内包含的所有页面的读写许可权限。
  • vm_flags:描述这个区域内的页面是否是与其他进程共享的,还是这个进程私有的(还描述了一些其他信息)。
  • vm_next:指向链表中下一个区域结构。

image-20211111103449044

Linux缺页异常处理

  假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常将导致CPU执行内核的缺页处理程序,处理程序执行如下步骤:

  1.   虚拟地址A是合法的吗?换句话说,A在某个区域结构(vm_area_struct)定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构链表,将A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图10.30中标识为”1”。
      因为一个进程可以创建任意数量的虚拟主存区域(使用下一节中描述的mmap函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux在链表中添加了一棵树,并在这棵树上查找。
  2.   试图进行的对存储器的访问是否合法?换句话说,进程是否有读或这写这个区域内页面的权限?例如,这个缺页是不是由一条试图对代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式下的进程试图从内核虚拟主存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,终止这个进程。这种情况在图10.30中标识为“2”。
  3.   此时,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面,并更新页表。当缺页处理程序返回时,CPU重新执行引起缺页的指令,这条指令将再次发送A到MMU,这一次,MMU就能正常地翻译A。

image-20211111110309063

10.8 主存映射

  Linux通过将一个虚拟主存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟主存区域的内容,这个过程称为主存映射(memory mapping)。虚拟主存区域可以映射到两种类型的对象:

  1. Unix文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件(例如一个可执行目标文件)的连续部分。文件被分成页面大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理主存,直到CPU第一次引用到页面。如果区域比文件的这部分要大一些,就用0填充区域的剩下部分。
  2. 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理主存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制0覆盖牺牲页面,并更新页表,将这个页面标记为是驻留在存储器中的。注意在磁盘和主存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面,有时也叫作二进制零的页(demand-zero page)。

  无论在那种情况,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫作交换空间或者交换区域。需要意识到很重要的一点是,任何时间点,交换空间都限制着当前运行着的程序能够分配的虚拟页面的总数。

10.8.1 再看共享对象

  一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟主存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。

  另一方面,对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个共享对象映射到的虚拟主存区域叫做共享区域,一个私有对象映射到主存区域叫做私有区域。

  假设进程1将一个共享对象映射到它的虚拟主存的一个区域中,进程2将同一个共享对象也映射到它的虚拟主存中(虚拟地址不一定相同)。

image-20211110213424453

  因为每个对象都有一个唯一的文件名,内核可以迅速判定进程1已经映射了这个对象,而且可以使进程2中的页表条目指向相应的物理页面。

  私有对象使用叫做写时拷贝(copy-on-write)的巧妙技术被映射到虚拟主存中。一开始,多个进程将同一个私有对象映射到自己的虚拟主存时,在物理主存中只保存私有对象的一份拷贝。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理主存中对象的一个单独拷贝。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护屏障。

  当故障处理程序注意到保护异常是由于进程试图写私有的写时拷贝区域中的一个页面而引起的,他就会在物理主存中创建这个页面的一个新拷贝,更新页表条目指向这个新的拷贝,然后恢复这个页面的可写权限。当故障处理程序返回时,CPU重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了。

  写时拷贝节省了稀有的物理主存。

image-20211110214508298

10.8.2 再看fork函数

  现在,我们理解了虚拟主存和主存映射,那么我们可以清晰地知道fork函数是如何创建一个新进程的。

  当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟主存,它创建了当前进程的 mm_struct、区域结构(vm_area_struct)和页表的原样拷贝。它标记两个进程中的每个页面为只读,并标记两个进程中每个区域结构为写时私有拷贝的。

  当fork函数返回时,新进程的虚拟主存和调用fork时的虚拟主存相同。当这两个进程中的任一个后来进行写操作时,写时拷贝机制就会创建新页面并更新页表。

10.8.3 再看execve函数

  虚拟主存和主存映射在将程序加载到主存的过程中扮演着关键的角色。假设运行在当前进程的程序执行了如下的execve调用:

  Execve(“a.out”, argv, environ);

  execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序替代当前程序。加载并运行a.out需要以下步骤:

  • 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
  • 映射私有区域:为新程序的文本、数据、bss和栈区域创建新的区域结构(task->mm->mmap->vm_area_structs)。所有这些新的区域都是私有的写时拷贝的。文本和数据区域被映射为a.out文件中的文本和数据区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为0。图10.33概括了私有区域的不同映射。
  • 映射共享区域:如果a.out程序与共享对象链接,比如标准C库的lib.so,那么这些对象都是动态链接到这个程序的,并且映射到用户虚拟地址空间中的共享区域内。
  • 设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文中(进程记住自己被切换时的寄存器值,如rip中的指令地址)的程序计数器,使之指向文本区域的入口点。

image-20211111113721701

10.8.4 使用mmap函数的用户级主存映射

  Unix进程可以使用mmap函数来创建新的虚拟主存区域,并将对象映射到这些区域中。

1
2
3
4
5
#include <unistd.h>
#include <sys/mman.h>

void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t, offset);
//返回:若成功时则为指向映射区域的指针,若出错则为1

  mmap函数让内核创建一个虚拟主存区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续组块(多个连续页)映射到这个新的区域。连续的组块大小为length字节,从距文件开始出偏移量为offset字节的地方开始。start地址通常被定义为NULL。

image-20211111122753433

  参数port是新映射虚拟主存区域的访问权限字段(也就是,在相应区域结构中的vm_port字段)。

  • PORT_EXEC:这个区域内的页面由可以被CPU执行的指令组成。
  • PORT_READ:这个区域内的页面可读。
  • PORT_WRITE:这个区域内的页面可写。
  • PORT_NONE:这个区域内的页面不能被访问。

  参数flags由描述被映射对象类型的位组成。如果MAP_ANON标记位被设置,并且fd为NULL,那么被映射的对象就是一个匿名对象,而相应的虚拟页面是请求二进制零的。MAP_PRIVATE表示被映射的对象是一个私有的写时拷贝对象,而MAP_SHARED表示是一个共享对象。例如:

  bufp = Mmap(NULL, size, PORT_READ, MAP_PRIVATE|MAP_ANON, 0, 0);

让内核创建一个新的包含size字节的、只读、私有、请求二进制零的虚拟存储器区域。如果调用成功,bufp指代新区域的地址。

  munmap函数删除虚拟存储器的区域:

1
2
3
4
5
#include <unistd.h>
#include <sys/mman.h>

int munmap(void* start, size_t length);
//返回:若成功则为0,若出错则为-1

  munmap函数删除从虚拟地址start开始的,由接下来length字节组成的区域。接下来对已删除区域的引用会导致段错误。

10.9 动态主存分配

  虽然可以使用低级的mmap和munmap函数来创建和删除虚拟主存的区域,但是大多数C程序还是会在运行时需要额外虚拟主存时,使用一种动态主存分配器(dynamic memory allocator)。

  一个动态主存分配器维护着一个称为(heap)(图10.35)的进程虚拟主存区域。在大多数的Unix系统中,堆是一个请求二进制零的区域,它紧接在未初始化的bss区域后开始,并向上扩大(向更高的地址)。对于每个进程,内核维护着一个变量brk(读作“break”),它指向堆的顶部。

image-20211111124445279

  分配器将堆视为一组不同大小的(block)的集合来维护。每个块就是一个连续的虚拟主存组块(连续多个虚拟页),要么是已分配的,要么是空闲的。

  分配器有两种基本风格。两种风格都要求程序显示地分配块,不同之处在于由负责释放已分配的块。

  • 显示分配器(explicit allocator):要求程序显示地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。

  • 隐式分配器(implicit allocator):要求分配器检测何时一个已分配块不再被程序使用,然后就释放这个块。隐式分配器也叫作垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。诸如Lisp、ML以及Java之类的高级语言就用垃圾收集来释放已分配的块。

  本节剩下的部分讨论的是显示分配器的设计和实现。我们将在10.10小结中讨论隐式分配器。为了更具体,我们的讨论集中于管理堆区域的分配器。然而,学生们应该明白,主存分配是一个普遍的概念,例如,图形处理密集的程序就经常使用标准分配器来要求获得一大块虚拟主存,然后使用与应用相关的分配器来管理块中的主存,以支持图形节点的创建和销毁。

10.9.1 malloc和free函数

  C标准库提供了一个称为malloc程序包的显示分配器。程序通过调用malloc函数来从堆中分配块。

1
2
3
4
#include <stdlib.h>

void* malloc(size_t size);
//返回:若成功则为指针,若出错则为NULL

  malloc函数返回一个指针,指向大小为至少size字节的主存块,这个块会为可能包含在这个块内的所有数据对象类型做对齐。在我们熟悉的Unix系统上,malloc返回一个8字节边界对齐的块。size_t类型被定义为unsigned int(无符号整数)。

  如果malloc遇到问题(例如,程序要求的主存块比可用的虚拟主存还要大),那么它就返回NULL,并设置errno。malloc不初始化它返回的主存。那些想要已初始化的动态主存的应用程序可以使用calloc,calloc是一个基于malloc的瘦包装函数,它将分配的主存初始化为0。想要改变一个以前已分配块的大小,可以使用realloc函数。

  动态主存分配器(例如malloc)可以通过使用mmap和munmap函数,显示地分配和释放堆主存,还可以使用sbrk函数。

1
2
3
4
#include <unistd.h>

void* sbrk(int incr);
//返回:若成功则为老brk指针,若出错则为-1

  sbrk通过将内核的brk指针增加incr来扩大和减小堆。若果成功,它就返回brk的旧值,否则,它就返回-1,并将errno设置为ENOMEM。如果incr为零,那么sbrk就返回brk的当前值。用一个为负的incr来调用sbrk是合法的。而且很巧妙,因为返回值(brk的旧值)指向在新堆顶上面的第abs(incr)字节。

  程序通过调用free函数来释放已分配的堆块。

1
2
3
4
#include <stdlib.h>

void free(void *ptr);
//返回:无

  ptr参数必须指向一个从malloc获得的已分配块的起始位置。如果不是,那么free的行为就是错的0。更糟的是,free什么都不返回,就不会告诉应用程序出了错误。

  图10.36展示了使用malloc和free是如何管理一个C程序的16字的(非常)小的堆的。每个方框代表一个4字节的字。内部有阴影的方框表示已分配,内部无阴影的方框表示空闲块。初始时,堆是由一个大小为16字的、双字对齐的空闲块组成的。

  • 图10.36(a):程序请求一个4字的块。malloc的响应是:从空闲块的前部切出一个4字的块,并返回一个指向这个块的第一字的指针。
  • 图10.36(b):程序请求一个5字的块。malloc的响应时:从空闲块的前部分配一个6字的块。在本例中,malloc在块里填充了一个额外的字,是为了保持空闲块是双字边界对齐的。
  • 图10.36(c):程序请求一个6字的块,而malloc就从空闲块的前部切出一个6字的块。
  • 图10.36(d):程序释放在图10.36(b)中分配的那个6字的块。注意,在调用free返回之后,指针p2仍然指向被释放了的块。应用有责任在它调用一个新的malloc重新分配之前,不再使用p2。
  • 图10.36(e):程序请求一个2字的块。在这种情况中,malloc分配在前一步中被释放了的块的一部分,并返回一个指向这个新块的指针。

image-20211111164034631

image-20211111164049976

10.9.2 为什么要使用动态主存分配

  程序使用动态主存分配的最重要原因是,它们经常直到程序实际运行时,才知道某些数据结构的大小。例如,假设要求我们编写一个C程序,它读一个n个ASCII码整数的链表,每一行一个整数,从stdin到一个C数组。输入是由整数n,和接下来要读和存储到数组中的n个整数组成的。最简单的方法就是用某种硬编码的最大数组大小静态地定义这个数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define MAXN 15213

int array[MAXN];

int main()
{
int i, n;

scanf("%d", &n);
if (n > MAXN);
printf("Input file too big");
for (i=0; i<n; i++)
scanf("%d", &array[i]);
return 0;
}

  用这样的硬编码的大小来分配数组通常不是好想法。MAXN的值是任意的,和机器上可用的虚拟主存的实际数量没有关系。而且,如果这个程序的使用者想读取一个比MAXN更大的文件,唯一的办法就是用一个更大的MAXN值来重新编译这个程序。虽然对于这个简单的示例来说这不成问题,但是硬编码数组界限的出现对于拥有百万行代码和大量使用者的大型软件产品而言是一场维护的噩梦。

  一种更好的办法是在运行时,在已知了n的值之后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由可用的虚拟主存数量来限制了。

1
2
3
4
5
6
7
8
9
10
int main()
{
int *array, i, n;

scanf("%d", &n);
array = (int*)Malloc(n * sizeof(int));
for (i=0; i<n; i++)
scanf("%d", &array[i]);
return 0;
}

  动态主存分配是一种有用而重要的编程技术。

10.9.3 分配器的要求和目标

  显示分配器必须在一些相当严格的约束条件下工作:

  • 处理任意请求序列。一个应用可以有任意序列的分配请求和释放请求,只要满足约束条件:每个释放请求必须对应于一个当前已分配的块,这个块产生于以前的分配请求。
  • 立即响应请求。分配器必须立即相应分配请求。因此,不允许分配器为了提高性能缓冲请求重新排列。
  • 只使用堆。分配器使用的任何非标量数据结构都必须保存在堆里。
  • 对齐块。分配器必须对齐块,使得它可以保存任何类型的数据对象。在大多数系统中,这意味着分配器返回的块是8字节对齐的。
  • 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。

  在这些条件下,分配器的编写者试图实现吞吐率最大化和存储器使用率最大化,而这两个性能目标经常是相互冲突的。

  • 目标1:最大化吞吐率。假定n个分配和释放请求的某种序列:R0,R1,…,Rk,…,Rn-1。
    我们希望一个分配器的吞吐率最大化,吞吐率就是在每个单位时间里完成的请求数。例如,如果一个分配器在1秒中内完成500个分配请求和500个释放请求,那么它的吞吐率就是每秒1000次操作。一般而言,我们可以通过使满足分配和释放请求的平均时间最小化,来使吞吐率最大化。正如我们会看到的,开发一个具有合理性能的分配器并不困难,所谓合理性能是指一个分配请求的最糟运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间是一个常数。
  • 目标2:最大化主存利用率。天真的程序员经常不正确地假设虚拟主存是一个无限的资源。实际上,一个系统中被所有进程分配的虚拟主存的全部数量是受磁盘上交换空间的数量限制的。好的程序员知道虚拟主存是一个有限的空间,必须高效地使用,对于可能被要求分配和释放大块主存的动态主存分配器来说,尤其如此。
    有很多方式来描述一个分配器使用堆的效率如何。在我们的经验中,最有用的标准是峰值利用率(peak utilization)。假定n个分配和释放请求的某种序列:R0,R1,…,Rk,…,Rn-1。
    如果一个应用程序请求一个p字节的块,那么得到的已分配块的有效载荷(payload)是p字节。在 请求Rk完成之后,聚集有效载荷(aggregate payload),表示为Pk,为当前已分配的块的有效载荷之和,而Hk表示堆的当前的大小。
      那么,前k个请求的峰值利用率,表示为 Uk = max(Pi) / Hk,i≤k。
    那么,分配器的目标就是在真个序列中使峰值利用率Un-1最大化。正如我们将要看到的,在最大化吞吐率和最大化利用率之间是有平衡关系的。特别是,以堆利用率为代价,很容易编写出吞吐率最大化的分配器。分配器设计中一个有趣的挑战就是在两个目标之间找到一个适当的平衡。

10.9.4 碎片

  造成堆利用率很低的主要原因是一种称为碎片(fragmentation)的现象,这种现象是当有未使用的主存但不能满足分配请求。有两种形式的碎片:内部碎片(internal fragmentation)和外部碎片(external fragmentation)。

  内部碎片是描述一个已分配块比有效载荷大的现象。很多原因都可能造成这个问题。例如,一个分配器的实现可能对增加块的大小以满足边界对齐。

  内部碎片的量化是简单的,就是已分配块和它们的有效载荷之差的和。因此,在任意时间点,内部碎片的数量只取决之前的请求和分配器的实现方式。

  外部碎片是描述空闲存储器合计起来能够满足一个分配请求,但是没有一个单独的空闲块大到能处理这个请求的现象。

  外部碎片比内部碎片的量化要困难的多。因为它不仅取决于以前的请求和分配器的实现方式,还取决于未来的请求。假设k个请求之后,所有空闲块的大小都恰好是4个字。这个堆会有外部碎片吗?答案取决于将来的请求,如果将来所有的分配请求都比4个字小,那么就不会有外部碎片。如果有一个或多个请求比4个字大,那么这个堆就会有外部碎片。

  因为外部碎片难以量化,所以分配器试图维持少量的大空闲块,而不是维持大量的小空闲块。

10.9.5 实现问题

  可以想象出的最简单的分配器,会把堆组织成一个大的字节数组,还有一个指针P,初始指向这个数组的第一个字节。为了分配size字节,malloc将P的当前值保存在栈里,将P增加size,并将P的旧值返回到调用函数。free只是简单地返回调用函数,而不做任何其他事情。

  这个简单的分配器是一种极端情况。因为每个malloc和free只执行很少量的指令,吞吐率会极好。然而,因为分配器从不重复使用任何块,存储器利用率将极差。一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题。

  • 空闲块组织:我们如何记录空闲块。
  • 放置:我们如何选择一个合适的空闲块来放置一个新分配的块?
  • 分隔:在我们将一个新分配的块放置到某个空闲块之后,我们如何处理这个空闲块中的剩余部分?
  • 合并:我们如何处理一个刚刚被释放的块?

  本章的剩余部分,将详细讨论这些问题。因为像放置、分隔以及合并这样的基本技术贯穿在许多不同的空闲块组织方式中,所以我们将在一种叫做隐式空闲链表的简单空闲块组织结构中来介绍它们。

10.9.6 隐式空闲链表

  任何实际的分配器都需要一些数据结构,用来划分块边界,并区别已分配块和空闲块。大多数分配器将这些信息嵌在块本身当中。一个简单的方法如图10.37所示。

image-20211111183244428

  在这种结构下,一个块是由一个字的头部、有效载荷,以及可能的填充组成的。头部记录了这个块的大小(包括头部和所有填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要存储块大小的29个高位,用剩余的3位来记录其他信息。我们用头部的最低位指明这个块是已分配的,还是空闲的。例如,假设我们有一个已分配的块,大小为24(0x18)字节。那么它的头部将是:

  0x00000018 | 0x1 = 0x00000019.

  类似地,一个块大小为40(0x28)字节的空闲块有如下的头部:

  0x00000028 | 0x0 = 0x00000028.

  头部后面就是应用调用malloc时申请的有效载荷。有效载荷后面是一块不使用的填充块,其大小可以是任意的。填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

  假设块的格式如图10.37所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如图10.38所示。

image-20211111185230381

  我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐式地连接着的。分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块的结合。注意,我们需要特别标记出结束的块,在这个示例中,就是一个设置了已分配位而大小为0的终止头部(terminating header)。

  隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块需要搜索空闲块,与堆中已分配块和空闲块的总数呈线性关系。

  很重要的一点是,意识到系统对齐要求和分配器对齐要求对最小快的大小有强制要求。例如,我们假设一个双字对齐的要求,那么每个块的大小都必须是双字(8字节)的倍数。因此,图10.37中的块格式就导致最小的块大小为两个字:一个字作头,另一个字维持对齐要求。

10.9.7 放置分配的块

  当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大、可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略(placement policy)确定的。一些常见的策略是首次适配(first fit)、下一次适配(next fit)和最佳适配(best fit)。

  首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择匹配所需请求大小的最小空闲块。

  首次适配的一个优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向于在靠近链表起始处留下小空闲块的”碎片”,这就增加了对较大块的搜索时间。下一次适配是由Donald Knuth作为首次适配的一种替代品最早提出的,源于这样一个想法:如果我们上一次在某个空闲块里已经发现了一个匹配,那么很可能下一次我们也能在这个剩余块中发现匹配。下一次适配比首次适配运行起来明显要快一些。然而,一些研究表明,下一次适配的主存利用率比首次适配低得多。研究还表明最佳适配比首次试配和下一次适配的主存利用率都高一些。然而,在简单空闲链表组织结构中,比如隐式空闲链表中,使用最佳适配的缺点是它要求对堆进行彻底的搜索。在后面,我们将看到更加巧妙的分离式空闲链表结构,它实现了最佳适配策略,而不需要进行彻底的堆搜索。

10.9.8 分割空闲块

  一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空间块。虽然这种方式简单而快捷,但是主要的缺点是会造成内部碎片。如果放置策略趋向于生产好的匹配,那么额外的内部碎片也是可以接收的。

  然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割成两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。图10.39展示了分配器如何分隔图10.38中8个字的空闲块,来满足一个应用对堆主存3个字的请求。

image-20211111192901515

10.9.9 获取额外的堆存储器

  如果分配器不能为请求找到合适的空闲块,将会发生什么呢?一个选择是通过合并那些虚拟主存中相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还是不能生成一个足够大的块,那么分配器就会向内核请求额外的堆主存,要么是调用mmap,要么是通过调用sbrk函数。在任一种情况下,分配器都会将额外的(或增加的)主存转换成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

10.9.10 合并空闲块

  当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些相邻的空闲块可能引起一种现象,叫做假碎片(fault fragmentation),这里有许多可用的空闲块被切割称为小的、无法使用的空闲块。比如,图10.40展示了释放图10.39中分配块后得到的结果。结果是两个相邻的空闲块,每个有效载荷都为3个字。因此,接下来一个请求4字有效载荷的malloc就会失败,即使两个空间块的合计大小足够大,可以满足这个请求。

image-20211111201537688

  为了对付假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并(coalescing)。这就提出了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并(immediate coalescing),也就是在一个块被释放的时候,就合并所有的相邻块。或者它也可以选择推迟合并(deferred coalescing),也就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有空闲块。

  立即合并简单明了,可以在常数时间内完成,但是对于某些请求模式,这种方式会产生一种抖动:块会反复地合并,然后马上分割。例如,在图10.40中,反复地分配和释放一个3个字的块将产生大量不必要的分割和合并。在我们对分配器的讨论中,我们会假设使用立即合并,但是你应该了解,快速的分配器通常会选择某种形式的推迟合并。

10.9.11 带边界标记的合并

  分配器是如何实现合并的?让我们称我们想要释放的块为当前块。那么,合并下一个空闲块很简单且高效。当前块的头部指向下一个块的额头不,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块的头部上,这两个块在常数时间内被合并。

  但是我们该如何合并前面的块呢?给定一个带头部的隐式空闲链表,唯一的选择将是搜索整个链表,记住前面块的位置,直到我们到达当前块。使用隐式链表,这意味着每次调用free的时间都与块的数目成线性关系。即使更巧妙的空闲链表组织,搜索时间也不会是常数。

  Knuth提出了一种聪明而通用的技术,叫做边界标记(boundary tag),允许在常数时间内进行对前面块的合并。这种思想,如图10.41所示,是在每个块的结尾处添加一个脚部(footer边界标记),其中脚部就是头部的一个副本。如果每个块包含这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块结尾位置一个字的距离。

image-20211111222157928

考虑当分配器释放当前块时所有可能存在的情况:

  1. 前面的块和后面的块都是已分配的。
  2. 前面的块是已分配的,后面的块是空闲的。
  3. 前面的块是空闲的,而后面的块是已分配的。
  4. 前面的和后面的块都是空闲的。

图10.42展示了我们如何对这四种情况进行合并。

image-20211111230158312

  边界标记是简单优雅的,它对许多不同类型的分配器和空闲链表结构都是通用的。然而,它也存在一个缺陷。要求每个块都保持一个头部和一个脚部,在应用程序操作许多小块时,会产生显著的主存开销,例如,如果一个图形应用通过反复调用malloc和free,来动态地创建和销毁图形节点,并且每个图形节点只要求两个字,那么头部和脚部将占用每个已分配块的一半的空间。

  幸运的是,有一种非常聪明的边界标记的优化方法。已分配的块实际上不需要脚部,空闲的块才需要脚部。我们可以仅在块被释放,写脚部曾经的覆盖效载荷。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//memlib.c 堆模型
/*private global variables*/
static char *mem_start_brk; /* points to first byte of the heap */
static char *mem_brk; /* points to last byte of the heap */
static char *mem_max_addr; /* max virtual address for the heap */

/*
*mem_init - initializes the memory system model
*/
void mem_init(int size)
{
mem_start_brk = (char *)Malloc(size); /* models available VM */
mem_brk = mem_start_brk; /* heap is initially empty */
mem_max_addr = mem_start_brk + size; /* max VM address for 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;
return (void*)-1;
}
mem_brk += incr;
return old_brk;
}

  分配器包含在一个源文件中(malloc.c),用户可以编译链接这个源文件到它们的应用之中。分配器提供三个函数给应用程序:

  1. int mm_init(void);

  2. void* mm_malloc(size_t size);

  3. void mm_free(void bp);

  分配器使用图10.41所示的块格式,空闲链表结构为隐式空闲链表,具有图10.44所示的恒定形式(invariant form)。

image-20211112135230156

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

  下面是我们在分配器编码中将要使用的基本常数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//操作空闲链表的基本常数和宏
/* Basic constants and macros */
#define WSIZE 4 /* word size(bytes) */
#define DSIZE 8 /* doubleword size(bytes) */
#define CHUNKSIZE (1<<12) /* initial heap size(bytes) */
#define OVERHEAD 8 /* overhead of header and footer(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) (*(size_t *)(p))
#define PUT(p, val) (*(size_t *)(p) = (val))

/* Read the size and allocated fields from address p */
#define GET_SIZE(p) (GET(p) & ~0x7) //~0111 = 前面补1 1000
#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)))

  在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和指针运算。因此,定义一个小的宏集合来访问和遍历空闲链表是很有帮助的。比如我们可以用如下代码确定下一个块的大小:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//初始化空闲链表
int mm_init(void)
{
/* create the initial empty heap */
if ((heap_listp = mem_sbrk(4*WSIZE)) == NULL)
return -1;
PUT(heap_listp, 0); /* alignment padding */
PUT(heap_listp+WSIZE, PACK(OVERHEAD, 1)); /* prologue header */
PUT(heap_listp+DSIZE, PACK(OVERHEAD, 1)); /* prologue footer */
PUT(heap_listp+WSIZE+DSIZE, PACK(0, 1)); /* epilogue header */
heap_listp += DSIZE; /* point to prologue block */

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

  mm_init函数从主存中得到4个字,并将它们初始化,从而创建一个空的空闲链表。然而它调用extend_heap函数(下面的代码块中展示),这个函数将堆扩展CHUNKSIZE字节,并且创建初始空闲块。此刻分配器已初始化了,并且准备好接受来自应用的分配和释放请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 ((int)(bp = mem_sbrk(size)) < 0)
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);
}

  extend_heap函数会在两种情况下被调用:①当堆被初始化时;②mm_malloc不能找到一个合适的匹配块时。为了保持对齐,extend_heap将请求大小向上取整为2字的倍数,然后向虚拟主存系统请求额外的堆空间。在很可能出现最后一个块是空闲块的情况下,我们调用coalesce函数来合并两个空闲块,并返回指向合并后的块的块指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//释放和合并块
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_BKLP(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(HDRP(bp), PACK(size, 0));
return(bp);
}

else if (!prev_alloc && next_alloc){
size += GET_SIZE(HDRP(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(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));
return(PREV_BLKP(bp));
}
}

  我们的空闲链表结构十分巧妙,它的序言块和结尾块总是标记为已分配,使得我们能够忽略麻烦的边界情况,也就是释放的块在堆的起始或结尾处。如果没有这些特殊的块,我们必须在每次释放块时,检查这些并不常见的边界情况,这将导致代码混乱,容易出错,执行更多指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//分配块
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 = DSIZE + OVERHAED;
else
asize = DSIZE * ((size + (OVERHEAD) + (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;
}

  需要你为分配器实现一个find_fit函数,对隐式空闲链表执行首次适配搜索

1
static void* find_fit(size_t asize)

  需要你为分配器实现一个place函数,j将分配块放置在空闲块的起始位置,仅当剩余部分的大小大于等于最小块大小16字节时才分割(加块结构)。

1
static void place(void* bp, size_t asize)

10.9.13 显式空闲链表

  隐式空闲链表结构,因为块分配与堆块的综述呈线性关系,所以对通用的分配器,隐式空闲链表是不合适的(尽管对于堆块预先就知道是很小的特殊分配器来说,它是比较好的)。

  一种更好的办法是将空闲块组织为某种形式的显示数据结构。因为程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,可以使用一个双向空闲链表结构,在每个空闲块中,包含一个pred(前驱)和succ(后继)指针,如图10.50所示。

image-20211112170857426

  使用双向链表,而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间,减少到了空闲块数量的线性时间。不过释放一个块的时间可以是线性的,也可以是常数的,这取决于我们在空闲链表中对块排序的策略。

  一种方法是后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记(当前块可以借助上一块的尾部找到上一块,是个隐式指针),那么合并也可以在常数时间内完成。

  另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它的祖先的地址。在这种情况下,释放一个块需要线性时间的搜索,来定位合适的祖先。按照地址排序的首次适配比LIFO的首次适配有更高的存储器利用率,接近最佳适配的利用率。

  一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

10.9.14 分离的空闲链表

  就像我们已经看到的,一个使用单向空闲链表的分配器需要与空闲块数量成线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储(segregated storage),维护多个空闲链表,其中每个链表中的块有大致相等的大小。

  一般的思路是将所有可能的块大小分成几类(size class)。分配器维护着一个空闲链表数组,每类一个空闲链表数组,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果它不能找到相应大小的块与之匹配,它就搜索下一个链表,依次类推。

  有关动态存储分配的文献描述了很多种分离存储方法,主要的区别在于它们如何划分类,何时进行合并,何时向操作系统请求额外的堆存储器,是否允许分隔,等等。为了使你大致了解有哪些可能性,我们会描述两种基本的方法:简单分离存储(simple segregated storage)和分离适配(segregated fit)。

  简单分离存储

  使用简单分离存储,每个大小类的空闲链表包含大小相等的块。一个{17-32}大小块,其空闲链表全由32字节的块组成。

  为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,我们就简单地分配其中第一块的全部。分空闲块是不会分割的。如果链表为空,分配器就向操作系统请求一个固定大小的额外主存组块(通常是页面大小的整数倍),将这个组块(chunk)分成大小相等的块,并将这些块连接起来形成性的空闲链表。要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。

  这种方法有许多优点。分配和释放块都是很快的常数时间操作。而且,每个组块(chunk)中都是大小相等的块,不分割,不合并,这意味着每个块分配释放的开销很小。既然每个组块只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有合并,所以已分配块的头部就不需要一个已分配/空闲标记。因此已分配块不需要头部,同时因为没有合并,它们也不需要脚部。因为分配和释放操作都是在空闲链表的起始处操作,所以链表只需要是单向的,而不用是双向的了。关键点在于,唯一在任何块中都需要的字段是每个空闲块中的一个字的succ指针,因此最小快大小就是一个字。

  一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块是不会被分隔的,所以可能会造成内部碎片。更糟的是,某些引用模式会引起极多的外部碎片,因为是不会合并空闲块的。

  研究者提出了一种粗糙的合并形式来对付外部碎片问题。分配器记录操作系统返回的每个组块(chunk)中的空闲块的数量。无论何时,如果有一个组块完全由空闲块组成,那么分配器就从它的当前大小类中删除这个组块,使得它对其他大小类可用。

  分离适配

  使用这种方法,分配器维护着一个空闲链表的数组。每个空闲链表适合一个大小类相关联的,每个链表包含大小不同的块,这些块的大小是大小类的元素。有许多不同的分离适配分配器,我们描述一个简单版本。

  为了分配一个块,我们必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果我们找到了一个,那么我们(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果我们找不到合适的块,那么我们就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果没有空闲链表中有合适的块,那么我们就向操作系统请求额外的堆主存,从这个新的堆主存中分配出一块,将剩余的部分放置在最大的大小类中。要释放一个块,我们自行合并,并将结果放置到相应的空闲链表中。

  分离适配方法是一种常见的选择,C标准库中提供GNU malloc包就是采用的这种方法,因为这种方法既快速,对主存的使用也很有效率。搜索时间减少了,因为搜索被限制在堆的某个部分,而不是整个堆。主存利用率得到了改善,因为有一个有趣的事实:对分离空闲链表的简单的首次适配搜索相当于对整个堆的最佳适配搜索。

  伙伴系统

  伙伴系统(buddy system)是分离匹配的一种特例,其中每个大小类都是2的幂。基本的思路是假设一个堆的大小为2^m个字,我们为每个块大小2^k维护一个分离空闲链表,其中0≤k≤m。请求块大小向上取整为最接近的2的幂。最开始时,只有一个大小为2^m个字的空闲块。

  为了分配一个大小为2^k的块,我们找到第一个可用的、大小为2^j的块,其中k≤j≤m。如果j=k,那么我们就找到了。否则,我们递归地二分这个块,直到j=k。当我们进行这样的分割时,每个剩下的半块(也叫作伙伴),被放置在相应的空闲链表中。要释放一个大小为2^k的块,我们继续合并空闲的伙伴。当我们遇到一个已分配的伙伴时,我们就停止合并。

  关于伙伴系统的一个关键事实是,给定地址和块大小,很容易计算出它的伙伴的地址。例如,一个块,大小为32字节,地址为:xxx…x00000,它的伙伴的地址为 xxx…x10000。换句话说,一个块的地址和它的伙伴只有一位不相同。

  伙伴系统分配器的主要优点是它的快速搜索和快速合并。主要缺点是要求块大小为2的幂可能导致显著的内部碎片。因此,伙伴系统分配器不适合通用目的的工作负载。然而,对于某些与应用相关的工作负载,其中块大小预先知道是2的幂,伙伴系统分配器就很有吸引力了。

10.10 垃圾回收 To Do

  在诸如C malloc包这样的显式分配器中,应用通过调用malloc和free来分配和释放堆块。应用要负责释放所有不再需要的已分配块。

  未能释放已分配的块是一种常见的编程错误。例如,考虑下面的C函数:

1
2
3
4
5
6
void garbage()
{
int *p = (int*)Malloc(15213);

return; /* array p is garbage at this point*/
}

  因为程序不再需要p,所以在garbage返回前应该释放p。不幸的是,程序员忘了释放这个块。它在程序的生命周期内都保持为已分配状态,毫无必要地占用着本来可以用来满足后面分配请求的堆空间。

  垃圾收集器(garbage collector)是一种动态存储分配器,它自动释放程序不再需要的已分配块。这些块被称之为垃圾(garbage)。自动回收堆主存的过程叫做垃圾收集(garbage collection)。在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显式地释放它们,取而代之的是,垃圾收集器定期识别垃圾块,并相应地调用free,将这些块放到空闲链表中。

  垃圾收集器可以追溯到John McCarthy在20世纪60年代早期在MIT开发的Lisp系统。它是诸如Java、ML、Perl和Mathematica等现代语言系统的一个重要部分,而且它仍然是一个重要的研究领域。有关文献描述了大量的垃圾收集方法,其数量令人吃惊。我们的讨论局限于McCarthy独创的Mark&Sweep(标记&清除)算法,这个算法很有趣,因为它可以建立在已存在的malloc包的基础上,为C和C++程序提供垃圾收集。

10.10.1 垃圾收集器的基本要素

  垃圾收集器将主存视为一张有向可达图(reachability graph),其形式如图10.51所示。该图的结点被分成一组根节点(root node)和一组堆结点(heap node)。每个堆结点对应于堆中的一个已分配块。有向边p->q意味着块p中的某个位置指向块q中的某个位置。根节点对应于一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器,栈里的变量,或者虚拟主存中读写数据区域内的全局变量。

image-20211116130921856

  当存在一条从任意根节点出发并到达p的有向路径时,我们说一个结点p是可达的(reachable)。在任何时刻,和垃圾相对应的不可达节点是不能被应用再次使用的。

  像ML和Java这样的语言的垃圾收集器,对应用如何创建和使用指针有很严格控制,能够维护可达图的一种精确的表示,因此也就能够回收所有垃圾。然而,诸如C和C++这样的语言的收集器通常不能维持可达图的精确表示。这样的收集器也叫作保守的垃圾收集器(conservative garbage)。每个可达块都被正确地标记为可达了,而一些不可达结点却可能被错误地标记为可达。

  收集器可以按需要提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不断地更新可达图和回收垃圾。例如,考虑我们如何为C程序将一个保守的收集器加入到已存在的malloc包中。

image-20211118103829015

  无论何时应用需要堆空间时,它都会用通常的方式调用malloc。如果malloc找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过调用free函数将它们返回给堆。关键的想法是收集器代替应用去调用free。当对收集器的调用返回时,malloc重试,试图发现一个合适的空闲块。如果还是失败了,那么它就会向操作系统要求额外的存储器。最后,malloc返回一个指向请求块的指针(如果成功)或者返回一个空指针(如果不成功)。

10.10.2 Mark&Sweep垃圾收集器

  Mark&Sweep垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成。标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。典型地,块头部中空闲的低位中的一位用来表示这个块是否被标记了。

  我们对Mark&Sweep的描述将假设使用下列函数,其中ptr定义为typedef char* ptr:

  • ptr isPtr(ptr p):如果p指向一个已分配块中的某个字,那么就返回一个指向这个块的起始位置的指针b。否则返回NULL。
  • int blockMarked(ptr b):如果已经标记了块b,那么就返回true。
  • int blockAllocated(ptr b):如果块b是已分配的,那么就返回true。
  • void markBlock(ptr b):标记块b。
  • int length(b):返回块b的字长(包括头部)。
  • void unmarkBlock(ptr b):将块b的状态由已标记的改为未标记的。
  • ptr nextBlock(ptr b):返回堆中块b的后继。

  标记阶段为每个根节点调用一次图10.53(a)所示的mark函数。如果p不指向一个已分配并且未标记的堆块,mark函数就立即返回。否则,它就标记这个块,并对块中的每个字递归地调用它自己。每次对mark函数的调用都标记某个根结点的所有未标记并且可达的后继结点。在标记阶段的末尾,任何未标记的已分配块都被认定为是不可达的,是垃圾,可以在清除阶段回收。

  清除阶段是对图10.53(b)所示的sweep函数的一次调用。sweep函数在堆中每个块上反复循环,释放它锁遇到的所有未标记的已分配块(也就是垃圾)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//mark 和 sweep函数的伪代码
void mark(ptr p) {
if ((b = isPtr(p)) == NULL)
return;
if (blockMarked(b))
return;
markBlock(b);
for (i=0; i<len; i++)
mark(b[i])
return;
}

void sweep(ptr b, ptr end)
{
while (b<end){
if (blockMarked(b))
unmarkBlock(b);
else if (blockAllocated(b))
free(b);
b = nextBlock(b)
}
return;
}

  图10.54展示了一个小堆的Mark&Sweep的图形化解释。块边界用粗线条表示。每个方块对应于主存中的一个字。每个块有一个字的头部,要么是标记了的,要么是标记了的,要么是未标记的。

image-20211118110445077

  初始情况下,图10.54中的堆由6个已分配块组成,其中每个块都是未分配的。第3块包含一个指向第1块的指针。第4块包含指向第3块和第6块的指针。根指向第4块。在标记阶段之后,第1块、第3块、第4块和第6块被做了标记,因为它们是从根节点可达的。第2块和第5块是未标记的,因为它们是不可达的。在清除阶段之后,这两个不可达块被回收到空闲链表。

C程序的保守Mark&Sweep

  Mark&Sweep对C程序的垃圾收集是一种合适的方法,因为它可以就地工作,而不需要移动任何块。然而,C语言为isPtr函数的实现造成了一些有趣的挑战。

  第一,C不会使用任何类型信息来标记主存位置。因此,对isPtr没有一种明显方式来判断它的输入参数p是不是一个指针。第二,即使我们知道p是一个指针,对isPtr也没有明显的方式来判断p是否指向一个已分配块的有效载荷中的某个位置。

  对后一问题的解决方法是将已分配块集合维护成一棵平衡二叉树,这棵树保持着这样一个属性:左子树中的所有块都放在较小的地址出,而右子树中的所有块都放在较大的地址处。如图10.55所示,这就要求每个已分配块的头部里有两个附加字段(left和right)。每个字段指向某个已分配块的头部。

image-20211118112352762

  isPtr(ptr p)函数用树来执行对已分配块的二分查找。在每一步中,它依赖于块头部中的大小字段来判断p是否落在这个块的范围内。

  从某种意义上来说,平衡树方法是正确的,例如它保证会标记所有从根节点可达的结点。这是一个必要的保证,因为应用程序的用户当然不会喜欢把它们的已分配块过早地返回给空闲链表。然而,这种方法从某种意义上而言又是保守的,因为它可能不正确地标记实际上不可达的块。

  C程序的Mark&Sweep收集器必须是保守的,其根本原因是C语言不会用类型信息来标记主存位置。因此,像int或者float这样的标量可以伪装成指针。例如,假设某个可达的已分配块在它的有效载荷中包含一个int,其值恰巧对应于某个其他已分配块b的有效载荷中的一个地址。对收集器而言,是没有办法推断出这个数据实际上是int而不是指针。因此,分配器必须保守的将块b标记为可达,尽管事实上它可能是不可达的。

10.11 C程序中常见的与主存有关的错误

  对C程序员来说,管理和使用虚拟主存可能是个苦难的、容易出错的任务。

10.11.1 间接引用坏指针

  正如我们在10.7.2节中学到的,在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常终止我们的程序。而且,虚拟主存的某些区域是只读的。试图写这些区域将造成以保护异常终止这个程序。

  间接引用坏指针的一个常见示例是经典的scanf错误。假设我们想要使用scanf从stdin读一个整数到一个变量。做这件事情正确地方式是传递给scanf一个格式串和变量的地址:

1
scanf("%d", &val);

  然而,对于C程序员初学者而言(对于有经验者也是如此!),很容易传递val的内容,而不是地址。

1
scanf("%d", val);

  在这种情况下,scanf将把val的内容解释为一个地址,并试图讲一个字写到这个位置。在最好的情况下,程序立即以异常终止。在最糟糕的情况下,val的内容对应于虚拟主存的某个合法的读写区域,于是我们就覆盖了主存,这通常会在以后造成灾难性的、令人困惑的后果。

10.11.2

  虽然.bss主存位置(诸如未初始化的全局C变量)总是被加载器初始化为0,但是对于堆主存却并不是这样的。一个常见的错误就是假设堆存储器被初始化为零。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* return y = Ax */
int* matvec(int** A, int* x, int n)
{
int i,j;
int* y = (int*)Malloc(n * sizeof(int));
for (i=0; i<n; i++)
{
for (j=0; j<n; j++)
{
y[i] += A[i][j] * x[j];
}
}
}

  在这个示例中,程序员不正确地假设向量y被初始化为零。正确的实现方式是将y[i]设置为0,或者使用calloc。

10.11.3 允许栈缓冲区溢出

  正如我们在3.13节中看到的,如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)。例如,下面的函数就有缓冲区错误,因为gets函数拷贝一个任意长度的串到缓冲区。为了纠正这个错误,我们必须使用fgets函数,这个函数限制了输入串的大小。

1
2
3
4
5
6
7
void bufoverflow()
{
char buf[64];

gets(buf); /* here is the stack buffer overflow bug */
return;
}

10.11.4 假设指针和它们指向的对象是相同大小的

  一种常见的错误是假设指向对象的指针和它们所指向的对象是相同大小的:

1
2
3
4
5
6
7
8
9
10
/* Create an nxm array */
int** makeArray1(int n, int m)
{
int i;
int** A = (int**)Malloc(n * sizeof(int));

for(i=0;i<n;i++)
A[i] = (int*)Malloc(m * sizeof(int));
return A;
}

  这里的目的是创建一个由n个指针组成的数组,每个字镇都指向一个包含m个int的数组。然而,因为程序员将sizeof(int*)写成了sizeof(int),代码实际创建的是一个int的数组。这段代码只有在int和指向int的指针大小相同的环境下运行良好。

10.11.5 造成错位错误

  错位(Off-by-one)错误是另一种很常见的覆盖错误发生的原因。

1
2
3
4
5
6
7
8
9
/* Create an nxm array */
int** makeArray2(int n, int m)
{
int i;
int** A = (int**)Malloc(n * sizeof(int));
for (i=0; i<=n; i++)
A[i] = (int*)Malloc(m*sizeof(int));
return A;
}

10.11.6 引用指针,而不是它所指的对象

  如果我们不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是期望的操作指针所指向的对象。比如,考虑下面的函数,其目的是删除一个有*size项的二叉堆里的第一项,然后对剩下的size-1重建堆。

1
2
3
4
5
6
7
8
9
int* binheapDelete(int** binheap, int* size)
{
int* packet = binheap[0];

binheap[0] = binheap[*size - 1];
*size--; /* this should be (*size)-- */
heapify(binheap, *size, 0);
reuturn(packet);
}

—和*运算符优先级相同,从右向左结合。这里原则是如果你对优先级和结合性有疑问,就使用括号。

10.11.7 误解指针运算

  另一种常见的错误是忘记了指针的算术操作是以它们指向的对象的大小为单位来进行的,而这种大小单位并不一定是字节。例如,下面函数的目的是扫描一个int的数组,并返回一个指针,指向val的首次出现。

1
2
3
4
5
6
int* search(int* p, int val)
{
while (*p && *p != val)
p += sizeof(int); /*should be p++*/
return p;
}

10.11.8 引用不存在的变量

  没有太多经验的C程序员不理解栈的规则,有时会引用不再合法的本地变量,如下列所示:

1
2
3
4
5
6
int* stackref()
{
int val;

return &val;
}

  这个函数返回一个指针如p,指向栈里的一个局部变量,然后弹出它的栈帧。尽管p仍然指向一个合法的存储器地址,但是它已经不再指向一个合法的变量了。当以后在程序中调用其他函数时,存储器将重用它们的栈帧。后来,如果程序分配某个值给*p,那么它可能实际正在修改另一个函数中的栈帧中的一个条目,从而带来潜在地灾难性的、令人困惑的后果。

10.11.9 应用空闲堆块中的数据

  一个相似的错误是应用已经被释放了的堆块中的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int* heapref(int n, int m)
{
int i;
int *x, *y;

x = (int*)Malloc(n * sizeof(int));
free(x);

y = (int*)Malloc(m * sizeof(int));
for(i=0; i<m; i++)
y[i] = x[i]++; /* oops!x[i] is a word in a free block */

return y;
}

10.11.10 引起存储器泄漏

  存储器泄漏是缓慢、隐形的杀手,表示程序员忘记释放已分配块。

1
2
3
4
5
6
void leak(int n)
{
int* x = (int*)Malloc(n * sizeof(int));

return; /* x is garbage at this point */
}

  如果leak经常被调用,那么渐渐地,堆里就会充满了垃圾,最糟糕的情况下,会占有整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,存储器泄漏是特别严重的,这些程序是不会终止的。

10.12 扼要重述一些有关虚拟主存的关键概念 (咕咕咕!)

  在这一章里,我们已经看到了虚拟存储器是如何工作的,系统如何用它来实现某些功能,例如加载程序、映射共享库以及为进程提供私有受保护的地址空间。我们还看到了许多应用程序正确或者不正确地使用虚拟存储器的方式。

  一个关键的经验教训是,即使虚拟存储器是由系统自动提供的,它也是一种有限的存储器资源,应用程序必须精明地管理它。征途我们从堆动态存储分配器的研究中学到的那样,管理虚拟存储器资源可能包括些微妙的时间和空间的平衡。另一个关键经验教训是,在C程序中很容易犯与存储器有关的错误。坏的指针值、释放已经空闲了的块、不恰当的强制类型转换和指针运算,以及覆盖堆结构,这些只是可能给我们带来麻烦的许多方式中的一小部分。实际上,与存储器有关的错误很讨厌,这是导致java产生的一个重要原因,java取消了取变量地址的能力,完全控制了动态存储分配器,从而严格控制了对虚拟存储器的访问。

10.13 小结

  虚拟存储器是对主存的一个抽象。支持虚拟存储器的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。

  虚拟存储器提供三个重要的功能。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟存储器缓存中的块叫做页。对磁盘上页的应用会触发缺页,缺页导致处理器执行一个缺页处理程序。缺页处理程序将页面从磁盘拷贝到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟存储器简化了存储器的管理,进而又简化了链接、在进程间共享数据、进程的存储器分配,以及程序加载。最后,虚拟存储器通过在每条页表条目中加入保护位,从而简化了存储器保护。

  地址翻译的过程必须和系统中任意硬件缓存的操作集成在一起。大多数页表条目位于L1高速缓存中,但是一个称为TLB的页表条目在芯片上的高速缓存,,通常会消除访问在L1上的页表条目的开销。

  现代系统通过将虚拟存储器组块(chunk)和磁盘上的文件组块关联起来,来初始化虚拟存储器组块,这个过程称为存储器映射。存储器映射为共享数据、创建新的进程以及加载程序,提供了一种高效的机制。应用可以使用mmap函数来手工地创建和删除虚拟地址空间的区域。然而,大多数的程序依赖于动态存储分配器,例如malloc,它管理虚拟地址空间区域内一个称为堆的区域。动态存储分配器是一个有系统级感觉的应用级程序,它直接操作存储器,而无需类型系统的很多帮助。分配器有两种类型:显式分配器要求应用显示地释放它们的存储器块;隐式分配器(垃圾收集器)自动释放任何无用的和不可达的块。

CS:APP3e, Bryant and O’Hallaron (cmu.edu)上可以下载每个实验。

image-20211118134709857

注意左边的链接需要讲师认证才能下载(含答案),右边单个的链接挨个点击下载。(不含答案)

简单版本

隐式空闲链表结构,mmap分配组块(chunk),首次适配策略放置块,分割,边界标记便于合并。

简单起见,不对地址参数的正确性做检查

画图工具为google浏览器插件 Gliffy Diagrams。

mmap和munmap

就是简单包裹了一下系统调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "syscall.h"
#include "stddef.h"

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
{
void* paddr = (void *)SYSCALL6(9, addr, length, prot, flags, fd, offset);
return paddr;
}

int munmap(void *addr, size_t length)
{
int ret = (int)SYSCALL2(11, addr, length);
return ret;
}

常量和宏

1
2
3
4
5
6
7
8
#define ALIGN_UP_N_BYTES(size, n) ((((size) + (n) - 1) / (n)) * (n))
#define ALIGN_UP_8_BYTES(size) ALIGN_UP_N_BYTES(size, 8)
#define ALIGN_UP_4KB(size) ALIGN_UP_N_BYTES(size, 4096)

#define THRESHOLD_FOR_MMAP (256 * 1024) //max block size in segment
#define SEGMENT_SIZE (2 * 1024 * 1024)

#define MEM_BLOCK_MINIMUM 16

Segment和块结构

Segment结构

Segment是通过mmap函数请求的区域。后面将只放一个大块的区域称为区域,将用来再分配给多个块的区域称为Segment。对每个请求到的Segment,添加16字节的Segment头部(前驱和后继指针),用来放一个大块的区域,直接放一个块头部。

1
2
3
4
5
struct SLMemSegment
{
struct SLMemSegment *pPrevMemSegment;
struct SLMemSegment *pNextMemSegment;
};

经过测试,输出的结构大小为16字节,意味着一个指针变量的大小默认为8字节。

image-20211113121707861

Segment结构示意图:

image-20211113140403399

块结构

关于C的struct的位域,使用如下代码测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct test{
unsigned int a : 1;
unsigned int b : 3;
};

int main()
{
struct test t;
struct test* p = &t;
p->a = 1;
p->b = 0;

return 0;
}

gdb查看内存发现,先申请的在低位,后申请的在高位。且注意使用unsigned无符号数,不然1位的a为1时,会显示成负数。

image-20211112230237761

块头部大小为8个字节即64位,由于块是8字节对齐的,所以块大小的最低3位始终为0,只需要高61位表示块大小。第0位CurBlkInUseBit表示该块是否是空闲块,第1位PrevBlkInUseBit表示该块的上一块是否在使用,第3位FromMmapBit表示该块的虚拟主存空间是否字节通过mmap分配。

1
2
3
4
5
6
7
struct SLMemBlock
{
unsigned long CurBlkInUseBit : 1;
unsigned long PrevBlkInUseBit : 1;
unsigned long FromMmapBit : 1;
unsigned long ulBlockSize : 61;
};

块结构示意图:仅当该块为空闲块时,才写上脚部。由于其头部脚部皆为8字节,所以一个最小块大小为16字节。

image-20211113152023237

分配原理

MyMalloc函数。首先请求的size加上头部,再对size做对齐。超过256KB的请求,通过mmap分配区域,否则,从已分配的Segment中分配空间给块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *MyMalloc(size_t size)
{
size = ALIGN_UP_8_BYTES(size);

if(size < MEM_BLOCK_MINIMUM)
size = MEM_BLOCK_MINIMUM;
else
{
size += sizeof(struct SLMemBlock);

if(size >= THRESHOLD_FOR_MMAP)
return MallocBymmap(size);
}

return MallocBySegmentList(size);
}

MallocBymmap函数。首先对加过头部的size进行4KB的对齐,通过mmap分配区域,让内核创建一个包含size字节的、请求二进制零的虚拟主存区域。如果调用成功,addr指代新区域的地址。将块头部写入区域的开始位置,返回主体的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void* MallocBymmap(size_t size);
{
size = ALIGN_UP_4KB(size);

void *addr = mmap(NULL, size, PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if(addr == MAP_FAILED)
return NULL;

struct SLMemBlock *p = (struct SLMemBlock *)addr;

p->CurBlkInUseBit = 1;
p->PrevBlkInUseBit = 1;
p->FromMmapBit = 1;
p->ulBlockSize = size;

return (char *)addr + sizeof(struct SLMemBlock);
}

MallocBySegmentList函数。遍历所有Segment,寻找合适的空闲块,找到返回块的主体地址。如果还没有Segment,或者找不到合适的空闲块,则获取新的Segment,在新的Segment中找到一个空闲块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void *MallocBySegmentList(size_t size)
{
for(struct SLMemSegment *pSegment = g_pSegmentList; pSegment != NULL; pSegment = pSegment->pNextMemSegment)
{
void* addr = GetFreeMemBlockFromSegment(pSegment, size);
if(addr != NULL)
return addr;
}

if(GetAndInsertSegment() == -1)
return NULL;

return GetFreeMemBlockFromSegment(g_pSegmentList, size);
}

GetAndInsertSegment函数。使用mmap函数请求一块大小为2MB的区域。将其起始位置的16字节设置为Segment头部,通过头部的前驱后继指针,将该Segment放入Segment链表的起始位置。再将8字节的块头部放入剩下部分的起始位置。由于该块目前是空闲的,将主体的最后8字节设置为脚部,包含块的大小。这里使用了强制类型转换使指针加减1对应的字节数不同,以此定位到脚部位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int GetAndInsertSegment(void)
{
void *addr = mmap(NULL, SEGMENT_SIZE, PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if(addr == MAP_FAILED)
return -1;

struct SLMemSegment *pSegment = (struct SLMemSegment *)addr;

pSegment->pNextMemSegment = g_pSegmentList;
pSegment->pPrevMemSegment = NULL;

if(g_pSegmentList != NULL)
g_pSegmentList->pPrevMemSegment = pSegment;

g_pSegmentList = pSegment;

struct SLMemBlock *pFirstBlock = (struct SLMemBlock *)((char *)addr + sizeof(struct SLMemSegment));

pFirstBlock->CurBlkInUseBit = 0;
pFirstBlock->PrevBlkInUseBit = 1;
pFirstBlock->FromMmapBit = 0;
pFirstBlock->ulBlockSize = SEGMENT_SIZE - sizeof(struct SLMemSegment);

*((unsigned long *)((char *)pFirstBlock + pFirstBlock->ulBlockSize) - 1) = pFirstBlock->ulBlockSize;

return 0;
}

GetFreeMemBlockFromSegment函数,从Segment中获取空闲块。输入为一个Segment地址和请求的块大小。遍历所有Segmetn中的所有块,找到当前未使用的(空闲),满足请求大小的块。如果分割空闲块后的剩余部分比最小块大小要小,就不分割,更新下一块(如果有)的PrevBlkInUseBit位。如果可以分割,写新块的头部和脚部。更新空闲块的大小,返回找到空闲块的主体地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void *GetFreeMemBlockFromSegment(struct SLMemSegment *pSegment, size_t size)
{
struct SLMemBlock *pBlock = (struct SLMemBlock *)((char *)pSegment + sizeof(struct SLMemSegment));
struct SLMemBlock *pEndBlock = (struct SLMemBlock *)((char *)pSegment + SEGMENT_SIZE);

for(; pBlock < pEndBlock; pBlock = (struct SLMemBlock *)((char *)pBlock + pBlock->ulBlockSize))
{
if(pBlock->CurBlkInUseBit == 1)
continue;

if(pBlock->ulBlockSize < size)
continue;

if(pBlock->ulBlockSize - size < MEM_BLOCK_MINIMUM)
{
struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + pBlock->ulBlockSize);

if(pNextBlock < pEndBlock)
pNextBlock->PrevBlkInUseBit = 1;
}
else
{
struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + size);

pNextBlock->CurBlkInUseBit = 0;
pNextBlock->PrevBlkInUseBit = 1;
pNextBlock->FromMmapBit = 0;
pNextBlock->ulBlockSize = pBlock->ulBlockSize - size;

*((unsigned long *)((char *)pNextBlock + pNextBlock->ulBlockSize) - 1) = pNextBlock->ulBlockSize;

pBlock->ulBlockSize = size;
}

pBlock->CurBlkInUseBit = 1;
return (char *)pBlock + sizeof(struct SLMemBlock);
}

return NULL;
}

IsTheLastBlockInSegment函数,判断某块是否是某Segment的最后一个块。输入为某块地址。遍历段,如果该块地址大于Segment地址且该块下一字节的地址小于Segment下一字节的地址,说明该块不是最后一块,返回0。如果该块的下一字节地址,和某Segment下一字节的地址相同则为最后一块,返回1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int IsTheLastBlockInSegment(struct SLMemBlock *pBlock)
{
char *pEnd = (char *)pBlock + pBlock->ulBlockSize;

for(struct SLMemSegment *pSegment = g_pSegmentList; pSegment != NULL; pSegment = pSegment->pNextMemSegment)
{
char *pSegmentEnd = (char *)pSegment + SEGMENT_SIZE;

if(((char *)pBlock > (char *)pSegment) && (pEnd < pSegmentEnd))
return 0;

if(pEnd == pSegmentEnd)
return 1;
}

return 0;
}

释放原理

MyFree函数。输入一个块的主体地址。检查块头部,如果是mmap分配的区域,调用unmap释放区域。检查该块是否是最后一块。如果不是最后一块,如果下一块也空闲,记录新的块大小加上下一块大小。如果上一块也空闲,记录新的块大小加上上一块大小,同时更新型的块地址为上一块地址,更新新块的头部和脚部。如果新块不是Segment的最后一块,更新下一块头部的PrevBlkInUseBit=0。如果新块是最后一块,且块大小和去了Segment头部的Segment一样大,说明Segment为空,调用FreeSegment函数释放Segment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void MyFree(void *ptr)
{
if(ptr == NULL)
return;

struct SLMemBlock *pBlock = (struct SLMemBlock *)((char *)ptr - sizeof(struct SLMemBlock));
if(pBlock->FromMmapBit == 1)
{
munmap((void*)pBlock, pBlock->ulBlockSize);
return;
}

size_t len = pBlock->ulBlockSize;

int bLast = IsTheLastBlockInSegment(pBlock);

if(!bLast)
{
struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + len);
if(pNextBlock->CurBlkInUseBit == 0)
len += pNextBlock->ulBlockSize;
}

if(pBlock->PrevBlkInUseBit == 0)
{
unsigned long prev_size = *((unsigned long *)pBlock - 1);
len += prev_size;

pBlock = (struct SLMemBlock *)((char *)pBlock - prev_size);
}

pBlock->ulBlockSize = len;
pBlock->CurBlkInUseBit = 0;
pBlock->PrevBlkInUseBit = 1;
pBlock->FromMmapBit = 0;

bLast = IsTheLastBlockInSegment(pBlock);

struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + pBlock->ulBlockSize);
*((unsigned long *)pNextBlock - 1) = pBlock->ulBlockSize;

if(!bLast)
{
pNextBlock->PrevBlkInUseBit = 0;
}
else
{
if(pBlock->ulBlockSize == SEGMENT_SIZE - sizeof(struct SLMemSegment))
FreeSegment((struct SLMemSegment *)((char *)pBlock - sizeof(struct SLMemSegment)));
}
}

FreeSegment函数,释放Segment。输入参数为Segment地址。如果该Segment在Segment链表初始位置,更新g_pSegmentList指向下一个Segment,如果下一个Segment存在,修改其前驱为NULL。否则修改其前驱的后继为其后继,如果其后继存在,修改其后继的前驱为其前驱。调用munmap释放Segment所占的区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void FreeSegment(struct SLMemSegment *pSegment)
{
if(g_pSegmentList == pSegment)
{
g_pSegmentList = pSegment->pNextMemSegment;

if(g_pSegmentList != NULL)
g_pSegmentList->pPrevMemSegment = NULL;
}
else
{
pSegment->pPrevMemSegment->pNextMemSegment = pSegment->pNextMemSegment;

if(pSegment->pNextMemSegment != NULL)
pSegment->pNextMemSegment->pPrevMemSegment = pSegment->pPrevMemSegment;
}

munmap((void*)pSegment, SEGMENT_SIZE);
}

某一时间点分配器分配的虚拟主存快照

实际上,Segment中的块要更多,因为块大小超过256KB就会直接mmap申请一个4KB对齐的区域,而一个Segment的大小是2M。

image-20211114013556567

效率分析

直接由mmap分配的块,其分配和释放完全独立。

从Segment中分配的块:

吞吐率:搜索空闲块的开销与Segment和所有块的数目线性相关。释放块时需要确认块是否为Segment中最后一块,开销与Segment数目线性相关。

主存利用率:由于是8字节对齐,分配空闲块时进行分隔,内部碎片不多。由于使用首次适配策略放置空闲块,外部碎片要看请求序列是什么样,可能会有比较多的小空闲块不能被分配。

显式空闲链表,加快分配

双向空闲链表

总体设计

新增一个全局指针变量g_pFreeBlockList,指向空闲块链表。

在空闲块的主体里增加先驱和后继的指针。以便空闲块能找到空闲链表中的前驱和后继,在合并时好修改空闲链表。

将新释放的空闲块放到链表最前面。

分配时,首次适配策略放置块,分割,更新空闲链表。

释放时,尝试合并虚拟主存地址相邻的块,合并后更新空闲链表。

效率分析

直接由mmap分配的块,其分配和释放完全独立。

从Segment中分配的块:

吞吐率:搜索空闲块的开销最糟与空闲块数目线性相关,吞吐率有了提高。释放块时需要确认块是否为Segment中最后一块,开销与Segment数目线性相关。修改空闲链表的开销为常数时间。

主存利用率:因为块多了两个指针变量,所以最小块大小变大,由于使用了优化的边界标记,所以仅当请求的块大小很小时,内部碎片才变多。仍然使用首次适配策略放置空闲块,外部碎片要看请求序列是什么样,可能会有比较多的小空闲块不能被分配。

详细设计与实现

Segment结构

不变

块结构

头部并没有变。

1
2
3
4
5
6
7
struct SLMemBlock
{
unsigned long CurBlkInUseBit : 1;
unsigned long PrevBlkInUseBit : 1;
unsigned long FromMmapBit : 1;
unsigned long ulBlockSize : 61;
};

变的是仅当块空闲时,写在主体中的脚部。脚部也作为链表的组成单位。

1
2
3
4
5
6
struct SLFreeBlock
{
struct SLFreeBlock* pPrevFreeBlock;
struct SLFreeBlock* pNextFreeBlock;
unsigned long ulBlockSize;
};

块结构示意图:

仅当块空闲时,写脚部。脚部现在包含空闲块前驱指针,空闲块后继指针,空闲块大小。现在一个块的大小最小为32字节。

image-20211113165715918

常量和宏

最小块的大小从16字节变为32字节。

1
2
3
4
5
6
7
8
#define ALIGN_UP_N_BYTES(size, n) ((((size) + (n) - 1) / (n)) * (n))
#define ALIGN_UP_8_BYTES(size) ALIGN_UP_N_BYTES(size, 8)
#define ALIGN_UP_4KB(size) ALIGN_UP_N_BYTES(size, 4096)

#define THRESHOLD_FOR_MMAP (256 * 1024) //max block size in segment
#define SEGMENT_SIZE (2 * 1024 * 1024)

#define MEM_BLOCK_MINIMUM 32 //it used to be 16, now its 32

分配原理

MyMalloc函数。首先8字节对齐size,再给size加上头部大小。如果size小于最小块大小32字节,size就为32字节。如果size大于等于32字节,且size大于等于256KB,调用MallocBymmap单独分配一个区域放置该块,该块的分配和释放是完全独立的。否则,调用MallocBySegmentList函数从Segment中分配一个块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void *MyMalloc(size_t size)
{
size = ALIGN_UP_8_BYTES(size);
size += sizeof(struct SLMemBlock);

if(size < MEM_BLOCK_MINIMUM)
size = MEM_BLOCK_MINIMUM;
else
{
if(size >= THRESHOLD_FOR_MMAP)
return MallocBymmap(size);
}

return MallocByFreeBlockList(size);
}

MallocBymmap不变。

MallocBySegmentList函数,改为MallocByFreeBlock函数,调用GetFreeBlockFromFreeBlockList搜索空闲链表,寻找第一个合适的空闲块,找到返回块的主体地址,取缔了GetFreeMemBlockFromSegment。如果还没有空闲块,或者找不到合适的空闲块,则获取新的Segment,更新空闲链表,再次搜索空闲链表,第一个就是合适的空闲块。

1
2
3
4
5
6
7
8
9
10
11
void *MallocByFreeBlockList(size_t size)
{
void* addr = GetFreeBlockFromFreeBlockList(size);
if(addr != NULL)
return addr;

if(GetAndInsertSegment() == -1)
return NULL;

return GetFreeBlockFromFreeBlockList(size);
}

GetAndInsertSegment函数。使用mmap函数请求一块大小为2MB的区域。将其起始位置的16字节设置为Segment头部,通过头部的前驱后继指针,将该Segment放入Segment链表的起始位置。再将8字节的块头部放入剩下部分的起始位置。将作为空闲链表组成单元的脚部放入区域的最后24字节。将脚部放入FreeBlock链表的起始位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int GetAndInsertSegment(void)
{
void *addr = mmap(NULL, SEGMENT_SIZE, PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if(addr == MAP_FAILED)
return -1;

struct SLMemSegment *pSegment = (struct SLMemSegment *)addr;

pSegment->pNextMemSegment = g_pSegmentList;
pSegment->pPrevMemSegment = NULL;

if(g_pSegmentList != NULL)
g_pSegmentList->pPrevMemSegment = pSegment;

g_pSegmentList = pSegment;

struct SLMemBlock *pFirstBlock = (struct SLMemBlock *)((char *)addr + sizeof(struct SLMemSegment));
struct SLFreeBlock *pFirstFreeBlock = (struct SLFreeBlock *)((unsigned long*)((char*)addr + SEGMENT_SIZE)-3);

pFirstBlock->CurBlkInUseBit = 0;
pFirstBlock->PrevBlkInUseBit = 1;
pFirstBlock->FromMmapBit = 0;
pFirstBlock->ulBlockSize = SEGMENT_SIZE - sizeof(struct SLMemSegment);


pFirstFreeBlock->ulBlockSize = pFirstBlock->ulBlockSize;
pFirstFreeBlock->pPrevFreeBlock = NULL;
pFirstFreeBlock->pNextFreeBlock = g_pFreeBlockList;

if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = pFirstFreeBlock;

g_pFreeBlockList = pFirstFreeBlock;
return 0;
}

GetFreeMemBlockFromSegment函数,改为GetFreeMemBlockFromFreeBlockList。遍历空闲块链表,找到当前未使用的(空闲),满足请求大小的块。如果分割空闲块后的剩余部分比最小块大小要小,就不分割,更新空闲链表,如果有,更新下一块的PrevBlkInUseBit位。如果可以分割,写新块的头部和脚部,更新空闲链表。更新空闲块的大小,返回找到空闲块的主体地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
void *GetFreeBlockFromFreeBlockList(size_t size)
{
struct SLFreeBlock* pFreeBlock = g_pFreeBlockList;

for(; NULL != pFreeBlock; pFreeBlock = pFreeBlock->pNextFreeBlock)
{
if(pFreeBlock->ulBlockSize < size)
continue;

struct SLMemBlock* pBlock = (struct SLMemBlock*)( (unsigned long*)( (char*)(pFreeBlock) - pFreeBlock->ulBlockSize ) + 3 );

//nocut
if(pFreeBlock->ulBlockSize - size < MEM_BLOCK_MINIMUM)
{
//Delete pFreeBlock from FreeBlockList
if(g_pFreeBlockList == pFreeBlock)
{
g_pFreeBlockList = pFreeBlock->pNextFreeBlock;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = NULL;
}else{
pFreeBlock->pPrevFreeBlock->pNextFreeBlock = pFreeBlock->pNextFreeBlock;
if(pFreeBlock->pNextFreeBlock != NULL)
pFreeBlock->pNextFreeBlock->pPrevFreeBlock = pFreeBlock->pPrevFreeBlock;
}

struct SLMemBlock* pNextBlock = (struct SLMemBlock *)( (char*)pFreeBlock + sizeof(struct SLFreeBlock) );
int bLast = IsTheLastBlockInSegment(pBlock);
if(!bLast)
pNextBlock->PrevBlkInUseBit = 1;
}
//cut
else
{
//add new header
struct SLMemBlock* pNextBlock = (struct SLMemBlock *)( (char*)pBlock + size );
pNextBlock->CurBlkInUseBit = 0;
pNextBlock->PrevBlkInUseBit = 1;
pNextBlock->FromMmapBit = 0;
pNextBlock->ulBlockSize = pBlock->ulBlockSize - size;

//add new footer
struct SLFreeBlock* pNextFreeBlock = (struct SLFreeBlock*)((unsigned long*)((char*)pNextBlock + pNextBlock->ulBlockSize) - 3);
pNextFreeBlock->ulBlockSize = pNextBlock->ulBlockSize;

//Delete pFreeBlock from FreeBlockList
if(g_pFreeBlockList == pFreeBlock)
{
g_pFreeBlockList = pFreeBlock->pNextFreeBlock;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = NULL;
}else{
pFreeBlock->pPrevFreeBlock->pNextFreeBlock = pFreeBlock->pNextFreeBlock;
if(pFreeBlock->pNextFreeBlock != NULL)
pFreeBlock->pNextFreeBlock->pPrevFreeBlock = pFreeBlock->pPrevFreeBlock;
}
//add pNextFreeBlock to FreeBlockList
pNextFreeBlock->pPrevFreeBlock = NULL;
pNextFreeBlock->pNextFreeBlock = g_pFreeBlockList;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = pNextFreeBlock;
g_pFreeBlockList = pNextFreeBlock;

//update old size
pBlock->ulBlockSize = size;
}

//update CurBlkInUseBit
pBlock->CurBlkInUseBit = 1;
return (char *)pBlock + sizeof(struct SLMemBlock);
}

return NULL;
}

IsTheLastBlockInSegment函数,不变。

释放原理

MyFree函数。输入一个块的主体地址。检查块头部,如果是mmap分配的区域,调用unmap释放区域。检查该块是否是最后一块。如果不是最后一块,如果下一块也空闲,记录新的块大小加上下一块大小,删除空闲链表。如果上一块也空闲,记录新的块大小加上上一块大小,删除空闲链表,同时更新型的块地址为上一块地址,更新新块的头部和脚部,更新空闲链表。如果新块不是Segment的最后一块,更新下一块头部的PrevBlkInUseBit=0,增加空闲链表。如果新块是最后一块,且块大小和去了Segment头部的Segment一样大,说明Segment为空,调用FreeSegment函数释放Segment,否则增加空闲链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
void MyFree(void *ptr)
{
if(ptr == NULL)
return;

struct SLMemBlock *pBlock = (struct SLMemBlock *)((char *)ptr - sizeof(struct SLMemBlock));
struct SLFreeBlock *pFreeBlock = (struct SLFreeBlock *)( (unsigned long*)( (char *)pNextBlock + pBlock->ulBlockSize ) - 3 );

//free by ummap
if(pBlock->FromMmapBit == 1)
{
munmap((void*)pBlock, pBlock->ulBlockSize);
return;
}

size_t len = pBlock->ulBlockSize;

int bLast = IsTheLastBlockInSegment(pBlock);

//not last block
if(!bLast)
{
struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + len);
struct SLFreeBlock *pNextBlockFooter = (struct SLFreeBlock *)( (unsigned long*)( (char *)pNextBlock + pBlock->ulBlockSize ) - 3 );
//next block is free
if(pNextBlock->CurBlkInUseBit == 0)
{
//update size of new block
len += pNextBlock->ulBlockSize;
//Delete pNextBlockFooter from FreeBlockList
if(g_pFreeBlockList == pNextBlockFooter)
{
g_pFreeBlockList = pNextBlockFooter->pNextFreeBlock;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = NULL;
}else{
pNextBlockFooter->pPrevFreeBlock->pNextFreeBlock = pNextBlockFooter->pNextFreeBlock;
if(pNextBlockFooter->pNextFreeBlock != NULL)
pNextBlockFooter->pNextFreeBlock->pPrevFreeBlock = pNextBlockFooter->pPrevFreeBlock;
}
}
}

//prev block is free
if(pBlock->PrevBlkInUseBit == 0)
{
struct SLFreeBlock* pPrevBlockFooter = (struct SLFreeBlock*)( (unsigned long*)pBlock - 3 );
unsigned long prev_size = pPrevBlockFooter->ulBlockSize;
//update size of new block
len += prev_size;
//Delete pPrevBlockFooter from FreeBlockList
if(g_pFreeBlockList == pPrevBlockFooter)
{
g_pFreeBlockList = pPrevBlockFooter->pNextFreeBlock;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = NULL;
}else{
pPrevBlockFooter->pPrevFreeBlock->pNextFreeBlock = pPrevBlockFooter->pNextFreeBlock;
if(pPrevBlockFooter->pNextFreeBlock != NULL)
pPrevBlockFooter->pNextFreeBlock->pPrevFreeBlock = pPrevBlockFooter->pPrevFreeBlock;
}
//update pBlock
pBlock = (struct SLMemBlock *)((char *)pBlock - prev_size);
}

//new header
pBlock->ulBlockSize = len;
pBlock->CurBlkInUseBit = 0;
pBlock->PrevBlkInUseBit = 1;
pBlock->FromMmapBit = 0;

//new footer
struct SLMemBlock *pNextBlock = (struct SLMemBlock *)((char *)pBlock + pBlock->ulBlockSize);
struct SLFreeBlock *pBlockFooter = (struct SLFreeBlock *)( (unsigned long*)(pNextBlock) - 3 );
pBlockFooter->ulBlockSize = pBlock->ulBlockSize;

//new block is not last block
bLast = IsTheLastBlockInSegment(pBlock);
if(!bLast)
{
pNextBlock->PrevBlkInUseBit = 0;
//add new footer to FreeBlockList
pBlockFooter->pPrevFreeBlock = NULL;
pBlockFooter->pNextFreeBlock = g_pFreeBlockList;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = pBlockFooter;
g_pFreeBlockList = pBlockFooter;
}
//new block is last
else
{
//segment is free
if(pBlock->ulBlockSize == SEGMENT_SIZE - sizeof(struct SLMemSegment))
{
FreeSegment((struct SLMemSegment *)((char *)pBlock - sizeof(struct SLMemSegment)));
}
else //segment is not free
{
//add new footer to FreeBlockList
pBlockFooter->pPrevFreeBlock = NULL;
pBlockFooter->pNextFreeBlock = g_pFreeBlockList;
if(g_pFreeBlockList != NULL)
g_pFreeBlockList->pPrevFreeBlock = pBlockFooter;
g_pFreeBlockList = pBlockFooter;
}
}
}

FreeSegment函数,不变。

某一时间点分配器分配的虚拟主存快照

image-20211114014635415

测试

测试单个Segment

1
2
3
4
5
6
7
8
9
10
11
12
void *p1 = MyMalloc(8);
void *p2 = MyMalloc(256*1024);
void *p3 = MyMalloc(64);
void *p4 = MyMalloc(71);
MyFree(p1);
MyFree(p2);
void *p5 = MyMalloc(420*1024);
void *p6 = MyMalloc(99);
MyFree(p4);
MyFree(p3);
MyFree(p6);
MyFree(p5);

测试两个Segment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void *p1 = MyMalloc(262120);  //256*1024-24
void *p2 = MyMalloc(262120);
void *p3 = MyMalloc(262120);
void *p4 = MyMalloc(262120);
void *p5 = MyMalloc(262120);
void *p6 = MyMalloc(262120);
void *p7 = MyMalloc(262120);
void *p8 = MyMalloc(262120);
void *p9 = MyMalloc(262120);
MyFree(p9);
MyFree(p8);
MyFree(p4);
MyFree(p3);
MyFree(p7);
MyFree(p1);
MyFree(p2);
MyFree(p5);
MyFree(p6);

分离空闲链表 To Do

将块大小分成数个类,分配器维护一个空闲链表数组,每个空闲链表包含对应大小的空闲块

加锁,并发分配

为什么要加锁

比如这样一个场景:多线程递减一全局变量的问题。

C编译器将增减运算转换成3条机器指令:从内存装载到寄存器,递减寄存器,从寄存器存储到内存。

某个出错情形:线程A运行,把nconn的值3装载到一个寄存器。系统把运行线程从A切换到B,A的寄存器被保存,B的寄存器被恢复。线程B执行与C表达式nconn—相对应的3条指令,把新值2存储到nconn。一段时间后,系统把运行线程从B切换回A。A的寄存器被恢复,A从原来离开的地方继续执行,把那个寄存器值从3减为2,再把值2存储到nconn。由于A没读到B减过的数据,所以最后A写了脏数据到nocnn中。

把问题放大的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include "pthread.h"

#define NLOOP 5000

int counter; /* incremented by threads */

void *doit(void *);

int
main(int argc, char **argv)
{
pthread_t tidA, tidB;

Pthread_create(&tidA, NULL, &doit, NULL);
Pthread_create(&tidB, NULL, &doit, NULL);

/* wait for both threads to terminate */
Pthread_join(tidA, NULL);
Pthread_join(tidB, NULL);

exit(0);
}

void *
doit(void *vptr)
{
int i, val;
/*
Each threads fetches, prints, and increments the counter NLOOP times.The value of the counter should increase monotonically.
*/

for (i = 0; i < NLOOP; i++){
val = counter;
printf("%d: %d\n", pthread_self(), val + 1);
counter = val + 1;
}

return (NULL);
}

某段结果:image-20211113232125079

同步锁

在MyMalloc.c中将一个静态(仅该源文件可用)全局变量初始化为锁。

1
static pthread_mutex_t allocator_mutex = PTHREAD_MUTEX_INITIALIZER;

在MyMalloc和MyFree函数的入口出口分别加锁,解锁。

1
2
3
Pthread_mutex_lock(&counter_mutex);
......
Pthread_mutex_unlock(&counter_mutex);

异步锁 To Do

每个线程一个私有空闲链表 To Do

futex To Do

CAS免锁 Compared and Swap To Do