配套教材:
Operating Systems: Three Easy Pieces Remzi H. Arpaci-Dusseau Andrea C. Arpaci-Dusseau Peter Reiher
参考书目:
1、计算机操作系统(第4版) 汤小丹 梁红兵 哲凤屏 汤子瀛 编著 西安电子科技大学出版社
在线阅读:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 认为课本应该是免费的。
————————————————————————————————————————
这是专业必修课《操作系统原理》的复习指引。
需要掌握的概念在文档中以蓝色标识,并用可读性更好的字体显示 Linux 命令和代码。代码部分语法高亮。
文档下载地址:
链接:https://pan.baidu.com/s/1hYIjEO1Iv_7UFMIPWmHZMw
提取码:0000
四 虚拟地址、段和空闲列表
“虚拟内存”(virtual memory)指的是计算机内存管理的一种机制,不要与Windows中的“虚拟内存”混淆。虚拟内存机制可以增加编程效率与安全性。
早年,设计操作系统很容易。因为用户们要求不高,不像后来总是要求“易用”“高性能”“可靠”之类的。那时的操作系统非常小,只驻留于内存中最低的那部分地址,剩余空间则存放单个用户程序。
早期的计算机非常昂贵。为了更好地共享每一台机器,在后来,多道程序设计(multiprogramming)出现了:计算机允许多个相互独立的程序同时进入内存,在内核的管理控制之下,相互之间穿插运行。当一个程序因为请求IO或其它原因暂停时,OS就切换另一个程序执行。不久后,算力的需求增长越来越强烈,于是分时系统诞生了。特别地,以程序员为主的很多人意识到了批处理的弊端,且疲于冗长的调试过程,于是交互越来越被强调。因为彼时已经存在大量用户同时使用一台机的情况,每个用户都希望及时获得程序的响应。
早期实现任务切换时,将暂停的程序的进度保存在磁盘里,然后从磁盘把另一份程序载入内存。但是磁盘的速度相比内存是非常慢的。之后内存容量越来越大,暂停的程序就可以直接保留在内存中了。这时需要保存的主要就是寄存器的值。再后来,对分时的需求越发强劲,多任务OS出现了。内存保护也开始发展,一个程序一般不允许读取其它程序的内存。
每个进程被分配了单独的内存空间,这就是地址空间(address space)。一个进程的地址空间是它可以自由访问的内存空间,保存了该程序的全部数据。一个进程的地址空间含有栈,用于刻画程序在函数调用链中执行到的位置(想一想递归的过程),并用于存储局部变量、参数和返回值。地址空间还含有堆,用于动态分配内存。这部分内存是由用户通过改动程序的代码自行申请的。地址空间还有用于其它用途的部分,比如保存static变量的静态存储区和常量存储区。这里只讨论三部分:代码、栈和堆。
本指导规定:“向下生长”指的是高地址空间向低地址空间(从非0x0到0x0)扩展,“向上生长”则相反。
一般而言,栈向下生长。Windows下,每个进程分配的栈空间默认为1 MB,Linux下通常为8 MB。栈空间超过限制,会发生栈溢出(Stack overflow)。栈和堆的一个主要区别就是:堆空间的使用不是连续的,使得栈比堆快。
OS会给每一个进程分配一个虚拟地址(virtual address),又称线性地址(linear address)。程序给出的偏移称为逻辑地址(logical address),加上基地址(所在的一段空间的开头)以后就得到线性地址。
每个进程的逻辑地址都从0开始,并与物理内存的地址一一对应。这称为物理内存对进程透明(transparent),是虚拟地址机制的第一个目标。也就是说,程序不能得知它在内存中的实际地址。操作系统与硬件(MMU)配合,确保进程不会进行非法访问。现代计算机系统中,MMU已经十分复杂,虚拟地址机制的实现细节这里不予讨论。总之,操作系统层面以上皆为虚拟地址,它们与物理地址的关系不一定是线性关系。
虚拟地址机制的第二个目标是效率。为了确保虚拟地址机制的时空复杂度不过高,操作系统依赖硬件支持,比如TLB(翻译后备缓冲区)。具体实现将在相应的课程中学习。
虚拟地址机制的第三个目标是安全。操作系统一般需要阻止进程在读写时不能访问其它进程或操作系统本身的内存空间。
隔离(isolation)是可靠的操作系统必须实现的机制。所有的OS都会把不同进程的地址空间隔离,也会把用户进程和操作系统的内存空间隔离。如果两个实体相互隔离,那么一个实体如果发生故障,则不会影响到另一个实体。有的现代操作系统采用微内核(microkernel)设计,把最核心的部分与系统的其它部分也隔离。
微内核结构具有如下优点:
【1】可扩展性。由于微内核OS的许多功能是由相对独立的服务器或软件来实现的,当开发了新的硬件和软件时,微内核OS只需在相应的服务器中增加新的功能,或再增加一台专门的服务器,而不必对内核作大量改动。这也必然改善系统的灵活性:不仅可在操作系统中增加新功能,还可修改原有功能、删除己过时的老功能,形成一个更为精干有效的操作系统。
【2】可靠性。微内核是通过精心设计和严格测试的,较小的代码量也使得容易保证其正确性;另一方面,它提供了规范而精简的API,为内核外的程序编制高质量的代码创造了条件。此外,由于所有服务器都是运行在用户态,服务器与服务器之间采用的是消息传递通信机制,因此,当某个服务器出现错误时,不会影响内核及其它服务器。换言之,微内核的错误容忍度更高。
【3】可移植性。随着硬件的快速发展,出现了各种各样的硬件平台。一个好的操作系统必须具备可移植性,使其能较容易地运行在不同的计算机硬件平台上。在微内核结构的操作系统中,所有与特定CPU和IO设备硬件有关的代码,均放在内核和内核下面的硬件隐藏层中,而操作系统其它绝大部分一一包括各种软件,乃至服务器,均与硬件平台无关。因而,把操作系统移植到另一个计算机硬件平台上所需作的修改是比较小的。
【4】天生对分布式系统(distributed system)的支持更好、更容易。在微内核OS中,客户和服务器、服务器和服务器之间的通信采用消息传递通信机制,使微内核OS能很好地支持分布式系统和网络系统。事实上,只要在分布式系统中赋予所有进程和服务器唯一的ID,在微内核中再配置一张系统映射表(即进程和服务器的ID与它们所驻留的机器之间的对应表),在进行客户与服务器通信时,只需在所发送的消息中标上发送进程和接收进程的ID,微内核便可利用系统映射表将消息发往目标,而无论目标是驻留在哪台机器上。
但微内核的坏处也很明显:主要是性能损耗。操作系统的工作过程中,需要用到大量的特权指令。这就会导致在用户态和内核态之间频繁切换,带来较大的额外开销。
Windows、Mac OS X采用微内核设计。为了追求性能,它们将需要具备特权的服务组件放进核心空间。可见,许多微内核系统其实并不是真正意义上的微内核,因为它们的部分实现违反了微内核的基本设计原则,更为接近宏内核(单内核)的设计方式。这也被称为混合核心。而Linux采用与之相对的宏内核设计。宏内核在实现上也相对简单些。即便如此,Linux内核的设计也吸取了微内核的不少优点。
在C / C++程序中,用格式化输出函数printf通过格式符%p输出指针。你会看到一个十六进制的值,它的长度为32位或64位。这是一个虚拟地址。无论输出变量还是输出函数(包括main())的地址,它们都不对应物理地址。所有用户进程都无法直接获取变量或函数的物理地址,实际地址只有操作系统可见。当需要对指定地址进行操作时,OS和硬件会翻译成实际地址再做相应的操作。
一般地,微内核必须包含:
① 与硬件处理紧密相关的部分;
② 一些较基本的功能(例如:进程与线程管理、内存管理的基本机制、中断处理与用户态和内核态的切换);
③ 客户和服务器之间的通信。
这些最基本的部分只是为构建通用操作系统提供一个重要基础,这样就可以确保把操作系统内核做得很小。
在C / C++中,如果在函数内新建局部变量,编译器在编译时会补充申请栈内存的指令。当函数返回后,编译器也会补充相应指令,使得执行时局部变量在函数结束后被清除。这个申请内存的过程是隐式的。而通过malloc和free,或new和delete来申请或释放新的内存时,过程是显式的。自由度高是C / C++极为显著的特点。用户申请新的内存空间没有太多限制,而且需要手动释放新申请的空间,所以带来了很多bug。
在终端输入man malloc,可以查看该函数的使用说明。要分配的字节数可以用运算符sizeof给出。sizeof()的值会在编译时期确定,所以一般不视其为函数调用,而将其看作运算符。
调用free来释放新分配的内存。待释放内存的大小没有在语句中给出,需要在内存分配库中查找。较新的语言中,多引入内存自动管理机制。垃圾收集器会自动释放不再被引用的内存,无需程序员手动释放。
但要注意,有时候sizeof并不会给出想要的结果。例如,代码段:
#include
#include
int main() {
int x[10];
printf("%llu\n", sizeof(x));
int* y = reinterpret_cast<int*>(malloc(10 * sizeof(int)));
printf("%llu\n", sizeof(y));
return 0;
}
的输出结果为:
40
8
勘误:malloc位于<cstdlib>中,必须引用此头文件才能(至少在MSVC和GCC上)正确通过编译。书上说无需引用<stdlib.h>(<cstdlib>)是错的。
在C / C++中,手动管理内存常见的错误有:
·段错误(segmentation fault)。原因是访问了非法的指针。例如,将复制的数据写入未分配给程序的内存地址,就会引发此错误。例如,使用strcpy函数之前需要手动申请新的内存空间,再将目标地址作为参数传入。而如果使用strdup替代,就可以省去申请内存的步骤。
·缓冲区溢出(buffer overflow)。原因是写入越界。有时候运行过程中越界时不会报错,但是实际上可能已经覆写了其它变量。当分配的新内存不够时,可能出现此错误。对于C风格字符串(C-style string),需要额外留出1个字节作为结束符(\x00)。否则,在向字符数组写入字符串时,多出来的结束符可能会覆盖其它变量。有的时候这并没有什么影响,但是有的时候就会引发严重错误。实际上,很多安全漏洞就是这样的溢出导致的。其实malloc常常会分配一点点额外的空间,使得少量的溢出不会影响到其它变量。
·未初始化。注意,malloc申请的新内存空间不会初始化。
·未释放已经不使用的内存。这会造成内存泄漏(memory leak)。例如在一个函数中用局部变量保存了新申请内存的首地址,当这个变量因为函数结束后被清除,或者不小心把这个变量指向了其它内存空间,那么原来新申请的空间还处于被占用的状态而无法释放,这就造成了内存泄漏。
在长期运行的应用程序或操作系统中,这是一个非常严重的问题。缓慢的内存泄漏经过长时间的积累,最终会耗尽全部的内存空间,从而不得不重启。垃圾回收机制是无法解决内存泄漏问题的:当垃圾收集器侦测到一段内存仍在被引用(如果不手动释放或由某个函数结束后自动释放相应空间,引用计数不会减少),就不会释放这段内存。
有些时候似乎不手动调用free并无问题。例如:程序运行时间非常短。在程序退出时,操作系统会自动清除其全部占用空间。虽然这并不会出错,但不是一个好的习惯。如果把这种习惯带到开发长期运行的操作系统或应用程序中,就会无意间造成内存泄漏,而内存泄漏通常又难以在代码中被查出来,进而引来大麻烦。所以,一旦新申请的内存不再使用,就应该尽快释放。
当然,随着计算机科学与技术的发展,现在有的IDE(集成开发环境)和软件可以检查是否存在内存泄漏,例如purify和valgrind。杀毒软件可能会将大量产生内存泄漏的进程结束,有的操作系统也会结束造成大量内存泄漏的进程(例如恶意在短时间内通过死循环申请内存的进程)。
·在使用完毕前释放内存。当内存释放后,指向它的指针还没有被修改,此时这些指针称为野指针或悬挂指针(dangling pointer)。未初始化的指针也是野指针。通过野指针操作可能导致程序崩溃或覆写其它区域的内存。
·重复释放内存。释放已经释放的内存,是未定义行为(见下文)。
·不正确地释放内存。free必须释放由malloc分配的内存。如果free的参数不是原来新申请的内存的首地址,那么会出现严重错误。这种错误是坚决要避免的。
未定义行为(undefined behavior,UB)是编程语言的标准中没有规定的行为。下标越界就是一种常见的未定义行为。编译器在优化时,假定不符合标准的行为永远不会发生,以充分进行优化、降低编译和运行需要的运算量。错误检查是要耗费算力的,而语言和编译器的设计者无法穷举所有的错误行为。
设想下面的语句:
int a[10];
编译器容易检测诸如a[11]这样的越界,但如果是这样的语句:
int* p = a[5]; p[6] = 1;
就不容易检测了。能导致未定义的行为多种多样,设计语言标准和编译器时,难以覆盖全部的未定义行为。
另外,如果你使用C / C++编程,你应当了解:C / C++的设计理念中具有非常重要的两条——
(1)信任程序员。
(2)不需要为不使用的特性付出代价。
C / C++的发明者和C / C++编译器的作者默认C / C++程序员应当具有足够的能力使每一条语句都严格符合标准,并希望尽可能提升性能,因此许多常见的错误均被视为未定义。编译器不负责检查并报告未定义行为;程序运行期间,通常也没有相应的错误检查及报告机制。同一个未定义行为在不同环境下一般会引发不同的后果。
malloc和free都是库调用,不过它们在库中也是通过系统调用实现的。系统调用brk通过改变堆的末端的地址来调整堆的大小,sbrk则增加堆大小。但应该调用malloc和free而不要直接调用它们,否则会出现严重错误。
与malloc类似的函数有很多,比如calloc能在申请新内存的基础上将新空间初始化(清零)。realloc则申请更大的内存空间,并将指定空间的数据搬运到新的内存空间中。
通过mmap()也可以从操作系统获得内存。通过传递特定的参数,mmap()分配一段匿名空间。这段空间不与任何文件相关联,而是与交换区(见第六章)。这种内存也可以按照堆来对待。
回忆之前讲过的有限直接执行(LDE),它通过切换用户模式和内核模式来确保程序不会进行非法操作。LDE和基于硬件的地址翻译(hardware-based address translation),或者简写为地址翻译,将内存的物理地址映射到虚拟地址,来进一步限制程序的非法行为。这个机制要通过硬件、软件结合才能实现。将虚拟地址转为物理地址的过程是全部由硬件完成的。
考虑下面的函数:
void func() {
int x = 3000;
x = x + 3;
}
将其转换为汇编语言是这样的:
30: int x = 3000;
00007FF61BC617EA C7 45 04 B8 0B 00 00 mov dword ptr [x],0BB8h
31: x = x + 3;
00007FF61BC617F1 8B 45 04 mov eax,dword ptr [x]
00007FF61BC617F4 83 C0 03 add eax,3
00007FF61BC617F7 89 45 04 mov dword ptr [x],eax
(环境:Windows 10 Professional,Visual Studio 2019,MSVC Debug模式(Release模式下该函数会被跳过))
在IDE中选择“反汇编(disassembly)”,能够看到每条汇编指令及其虚拟地址和机器码。基址寄存器(base register)用于实现偏移。一种偏移方案是:
物理地址 = 虚拟地址 + 基址
这样,应用程序访问0地址的时候,实际上访问的就是其在内存中的地址空间的开头。这个地址互相转换的过程叫做地址翻译,也叫动态重定位(dynamic relocation),因为它发生在运行时;并且,在进程开始运行后,我们甚至还可以将整个地址空间移动。不过,移动地址空间之前需要先暂停进程。
除了基址寄存器,还有界限寄存器(bound register),又称限制寄存器(limit register)。每个CPU都有这样的寄存器,位于MMU中。当进程尝试访问内存时,CPU检查其最终要访问的地址是否在界限寄存器中指定的限制范围内。如果不是,则CPU产生异常,进程可能会被终止。界限寄存器既可以存储地址空间的大小,又可以直接存储地址界限。这两种方法只有实现过程的区别(先检查大小后加基址,或者先加基址后检查实际地址),最终效果是一样的。
但是一定要注意:MMU进行地址转换时,并不仅仅使用这一种方法。在用户层面上看到的一段连续存储的数据,在物理内存中也许不是连续的,甚至两个进程还会有一部分虚拟地址对应的实际地址重叠。不过MMU会保证同一时刻一个内存单元只能由一个进程来访问。详细的机制这里不细讲。
早期的地址翻译由于没有硬件支持,是纯软件方法实现的,称为静态重定位(static relocation)。被称为加载器(loader)的软件来将可执行文件的代码中的地址统一增加一个偏移量,重写为实际地址。但是软件方法实现的地址翻译不提供保护,因为程序员可以设法写入特殊的地址来访问非法区域。另外,如果要将整个进程的地址空间在内存中移动,也比较困难。
硬件必须提供修改基址寄存器和界限寄存器的特权指令。
为了实现动态重定位,操作系统需要做这几件事:
(1)维护一个空闲列表(free list)来存放可用的内存块的标号。在进程创建和结束时,这个表都要及时修改。
(2)如果切换了其它进程,那么OS需要修改基址寄存器与界限寄存器的内容,使其与新进程匹配。当原进程恢复后,OS需要从PCB(进程控制块)中读取并还原这两个寄存器的原有内容。
(3)对于非法访问,CPU必须生成异常,操作系统侦测到后必须执行异常处理程序(exception handler)。这些处理程序要在系统启动时装入内存。
Linux系统中,许多情况下栈占用的地址比堆要高,栈和堆之间还剩余比较大的空间。不过堆的使用不一定是连续的。堆扩大时,新数据写入的位置在进程的地址空间范围内可以是无规律的。这与Linux内核版本有关。Windows的进程地址空间的分配方式与Linux的颇有不同。而且较新版本的Linux和Windows系统为了防止缓冲区溢出攻击,引入了地址空间布局随机化(address space layout randomization,ASLR),此时更不应对栈和堆的相对位置作任何假设。无论如何,内存中总是分散着许多未被利用的空间。这些空间都可以存放新的进程。
地址空间被分成若干个段(segment)。代码段、栈段、堆段等的基址寄存器和界限寄存器都保存在MMU中。
以直接使用“基址 + 虚拟地址”进行偏移为例,早年的VAX / VMS系统中,取虚拟地址的高2位来判断虚拟地址指向哪个段,然后进行相应的偏移,将虚拟地址转为物理地址。但如果代码只有3个段(代码段、栈段、堆段),那么最高位有一种组合不表示任何段,也就是说这两位并没有被充分利用起来,反而令剩下的位数能定位的范围(段的大小)少了足足一半。所以有些系统会把代码段放到堆中,这样就只可以用1位表示在哪个段。
也有其它的方法来判断一个特定的虚拟地址属于哪个段。有一种方法是考察地址是如何生成的。如果是从程序计数器(PC)得到的,那么就判断该地址属于代码段;如果是基于栈或基址指针生成的,就判断其属于栈段;剩下的地址就判断属于堆。
栈一般向下生长,所以MMU中一般留一位来刻画向上还是向下生长。定位栈的地址时,就用相应的基址减去偏移。
为了节省内存,有时会让不同地址空间的段共享同一片内存区域,比如父进程和子进程的代码段。为了支持这种功能,硬件中引入了保护位(protection bits)。保护位设定了一个程序是否可以读写或执行段中的内容。如果把段标记为只读(包括同时允许读和执行),就可以为多进程所共享(当然进程们都无法得知实际上自己是与别的进程共享一段内存空间)。于是,判断内存访问是否非法的标准要添加一条。即使没有越界,如果权限不符,也视作非法。
早年的系统对段的分配更灵活,支持不同大小的段。这就需要在内存中建立一个段表(segment table)来保存每个段的信息。细粒度的(fine-grained)段分配允许更充分的利用内存空间。现在,地址空间常被分为大小相等的段,方便管理。这种分法是相对粗粒度的(coarse-grained)。这些大小相等的段对用户程序不可见。
为了按段给每个进程正确分配内存空间,操作系统应该做这几件事:
(1)上下文切换时,必须保存或恢复相应的段寄存器中的数据。
(2)如果进程申请新内存,应当按照堆的空间是否足够来分情况处理(直接返回堆中的空闲部分,或者扩充堆的容量再返回空闲空间)。同时,段寄存器也要修改。如果物理内存已满或进程申请过于频繁,可以拒绝申请。
(3)如果不能找到满足要求的连续的空闲内存空间,那么需要对内存进行碎片整理(defragmentation),把内存中分散的段排到一起(移动段时,需暂停该段所属的进程,并修改相应的段寄存器),尝试凑出足够大的连续的内存空间。不过碎片整理不算是一个好的方案,因为开销比较大。而且被排到一起的进程如果想扩充段的容量,那么又要对它本身甚至多个进程移动地址空间。更简单的办法是,维护一张空闲内存列表,在分配新的空间时尽量留下大段的连续空间。相关的算法有许多,将在后续章节讨论。在不按照段进行内存分配的操作系统中,也可以具有类似的碎片整理机制。
我们把内存碎片分成两种:一种是因为随机分配空闲空间造成剩余的未被分配的小块的空闲分散在各个段之间,称为外部碎片(external fragmentation);一种是因为分配器申请的空闲空间大小大于需要的大小,剩下的这部分虽然没被应用程序用到,但也被标记已分配,这些是内部碎片(internal fragmentation)。我们更多地讨论外部碎片。
空闲列表记录了堆中未使用的空间。这里用链表实现空闲列表。链表的每个节点记录一段连续的空闲内存的两个值:首地址和长度。如果用户要申请空间,在链表上取一个空闲的空间,然后修改该节点的值或删除该节点,并将新空间的首地址返回给用户。如果用户释放了申请的空间,那么要在链表上重新插入相应节点或修改相应节点的值。
有一点要注意:由于不同空间的释放顺序不同,释放后可能会导致链表上的一片连续空间被多个节点表示为相邻的几片小的连续空间。如果链表中存在两个节点代表的空闲空间是连续的,那么要合并这两个节点,否则可能会导致具有足够的连续内存空间的情况被误判为空间不足。合并要一直进行到任何一段连续的空间在链表上都仅由一个节点刻画为止。
通过free释放已分配空间时,并不需要传入已分配空间的大小作为参数。那么正确释放已分配空间是怎样做到的呢?多数分配器在每次分配的空间和一段空间分配一部分后剩下的空间前面都添加一个头(header),上面至少保存了新申请的这段空间的字节数。有时候这个头会包含额外的指针,以便加速释放。此外,头还包含了一个魔数(magic number),用于检验完整性及提供其它信息。
魔数,一般是指硬写到代码里的整数常量,数值是编程者自己指定的,其他人不知道数值有什么具体意义。
魔数常常用于检测文件类型。一般而言,一个特定类型的文件(如:医学影像)都包含相同的魔数。通过魔数进行识别可以防止后缀名被修改造成的识别错误。此外,游戏《雷神之锤3:竞技场》(Quake III Arena)的源代码中,实现平方根倒数速算法时使用了一个魔数0x5f3759df,使得计算一个数的平方根的倒数远快于当时的主流算法,且精度表现几乎同样优秀。
释放空间时,最终释放的总字节数包括头文件占用的字节数。
当分配器要求扩充堆空间时,通常会产生sbrk之类的系统调用,如果剩余空间允许,那么OS要查找空闲的一段物理内存,将其映射到地址空间中,并返回扩充后的末地址。
理想的分配器不但能迅速分配和释放内存,同时也尽可能少地产生碎片。不幸的是,由于我们无法控制分配和释放的顺序,常见的算法总有最坏的情况。下面简要介绍几种常见的空闲内存空间管理算法。
最佳适应算法(best fit,BF):又称最小适应算法(smallest fit),在空闲列表中寻找满足要求的最小的块。虽然尽可能首先利用小空间,但是全表搜索很耗时。
最差适应算法(worst fit,WF):在空闲列表中寻找最大的块。该方法尝试避免best fit可能产生的大量小碎片,不过由于也需要全表搜索,开销同样比较大,而且研究表明该算法的实际表现并不佳,也会产生大量碎片。
首次适应算法(first fit,FF):找到第一个足够大的块。该方法大大减少了全表扫描的次数。如果列表将空闲空间按地址升序或降序排列,将表上的多块相邻空闲空间合成为一块会更容易,可以令产生的碎片更少,为以后到达的大作业分配大的内存空间创造了条件。
下次适应算法(next fit,NF):又称循环首次适应算法。取得第一个足够大的空闲内存块后,下次分配内存时从上次的搜索进度继续搜索。
伙伴系统(buddy system)每次分配的空间大小都为2的整数次幂。满足以下三个条件的两个空闲块称为伙伴:
1)两个块大小相同;
2)两个块地址连续;
3)两个块必须是同一个大块中分离出来的。
当需要为进程分配一个长度为n的存储空间时,首先计算一个i值,使2^(i-1)<n≤2^i。
然后,在空闲分区大小为2^i的空闲分区链表中查找。若找到,即把该空闲分区分配给进程。否则,表明长度为2^i的空闲分区己经耗尽,则在分区大小为2^(i+1)的空闲分区链表中寻找。
若存在大小为2^(i+1)的一个空闲分区,则要对该空闲分区进行一次分割,分为相等的两个分区,即一对伙伴。其中的一个分区用于分配,而把另一个加入分区大小为2^i的空闲分区链表中。若大小为2^(i+1)的空闲分区也不存在,则需要查找大小为2^(i+2)的空闲分区。
若找到大小为2^(i+2)的空闲分区,则对其进行两次分割:第一次,将其分割为大小为2^(i+1)到的两个分区,一个用于分配,一个加入到大小为2^(i+1)的空闲分区链表中;第二次,将第一次用于分配的空闲区分割为2^i的两个分区,一个用于分配,一个加入到大小为2^i的空闲分区链表中。若仍然找不到,则继续查找大小为2^(i+3)的空闲分区,以此类推。
由此可见,在最坏的情况下,可能需要对2^k的空闲分区进行k次分割才能得到所需分区。
与一次分配可能要进行多次分割一样,一次回收也可能要进行多次合并。
回收大小为2i的空闲分区时,若事先己存在2i的空闲分区,则应将其与伙伴分区合并为大小为2(i+1)的空闲分区,若事先已存在2(i+1)的空闲分区,又应继续与其伙伴分区合并为大小为2^(i+2)的空闲分区,依此类推。
由上述内容我们推出:假设一个大小为2^k, k∈N,地址为A的内存块,其伙伴块的地址是:
B_k (A)={█(A+2^k, A % 2(k+1)=0@A-2k, A % 2(k+1)=2k )┤
初始时,空闲分区为全部内存;而后,无论怎样分配,大小为2k的块的起始地址总是2k的整数倍。起始地址不是2k的整数倍的空间,不会记录在专门记录长度为2k的空闲列表里,而记录在专门记录长度更小的空闲列表里。
伙伴系统的内存分配和回收都很快,但是由于分配的空间大小总是2的整数次幂,容易造成内部碎片。
SLAB分配:
每个slab由一个或多个物理连续的页帧(见第5章)组成,每个cache由若干个slab组成,每个内核数据结构都有一个cache。例如,用于表示进程描述符、文件对象、信号量等的数据结构都有各自单独的cache。每个cache含有内核数据结构的对象实例:信号量(见第8章)cache有信号量对象,进程描述符cache有进程描述符对象,等等。也就是说,不同的数据结构已经预先在内核的内存空间中“划好”了。到底slab中的哪些对象在使用,是通过专门的标记来刻画的。
slab分配器首先尝试在部分为空的slab中用空闲对象来满足请求。如果不存在,则从空的slab中分配空闲对象。如果没有空的slab可用,则分配新的slab,并将其分配给cache;从这个slab上再分配与对象大小匹配的内存。
slab分配器提供两个主要优点:
(1)因碎片而引起内存浪费尽可能少。用一般的方法申请内存时,至少需要申请1页(见第五章),而内核中的许多数据结构是非常小的,这就造成了极大的浪费。
(2)可以快速满足内存请求。当对象频繁地被分配和释放时,如来自内核请求的情况,slab分配方案在管理内存时特别有效。分配和释放内存的过程是耗时的。然而,由于对象已预先创建,因此可以从cache中快速分配(标记从空闲改为使用)。当内核用完对象并释放它时,对象被标记为空闲但不会被清除,也立即可用于后续的内核请求。分配空间时,由于尽量使用最近释放的对象的内存块,因此其驻留在CPU高速缓存中的概率会大大提高。
slab分配器首先出现在Solaris 2.4内核中。由于通用性质,Solaris现在也将这种分配器用于某些用户模式的内存请求。最初,Linux使用的是伙伴系统;然而,从版本2.2开始,Linux内核采用slab分配器。
较新的Linux也包括另外两个内核内存分配器:SLOB和SLUB分配器。
简单块列表(SLOB)分配器用于有限内存的系统,例如嵌入式系统。因为对微型的嵌入式系统来说,SLAB比较复杂,而SLOB就显得小巧很多。SLOB工作采用3个对象列表:小(用于小于256字节的对象)、中(用于小于1024字节的对象)和大(用于小于页面大小的对象)。内存请求采用first fit策略,从适当大小的列表上分配对象。
从版本2.6.22开始,SLUB分配器取代SLAB,成为Linux内核的默认分配器。SLUB通过减少SLAB分配器所需的大量开销(例如,对于具有大量内存的大型系统,仅仅建立slab分配器的数据结构就需要非常多的内存),来解决SLAB分配的性能问题。
哈希(Hash)算法就是利用哈希快速查找的优点,以及空闲分区在可利用空闲区表中的分布规律,建立哈希函数,构造一张以空闲分区大小为关键字的哈希表,该表的每一个表项记录了一个对应的空闲分区链表表头指针。
当进行空闲分区分配时,根据所需空闲分区大小,通过哈希函数计算,即得到在哈希表中的位置,从中得到相应的空闲分区链表,实现最佳分配策略。
在链表中搜索内容是很慢的。先进的分配器会采用更优秀的数据结构来改善性能。例如平衡树、Splay树和偏序树。针对多核CPU的内存分配器也发展很快。