虚拟内存
原书《操作系统精髓与设计原理——富兰克林》第八章。
虚拟内存
我们的程序只能运行在内存中,这是即使有了虚拟内存也依然存在的一个限制。
传统的进程内存分配设计让一个进程必须完全加载进内存中,要么就完全被换出到磁盘中,这让操作系统能够在内存中同时容纳的进程数量受限,而且如果你开发一个很大的程序,大到内存根本无法容纳,那你必须给你的程序限定一个最大的内存用量,然后开发者自行将程序分块,然后ka
虚拟内存存在的目的就是将上面一系列问题交给硬件和操作系统来处理,进程的一部分存在于内存中,通常是目前常用的部分(保证当前程序的正常运行),其它部分存在于外存中,当需要那些在外存的部分时再动态加载这些部分到内存,如果此时系统给该程序分配的内存空间不足以容纳新的部分,旧的部分就会被换出到磁盘中。
虚拟内存的关键在于将一个进程“分块”,而上一章中的分页和分段机制正是将进程分块。所以虚拟内存多数采用分页机制和分块机制来协同完成。
局部性原理
这几个字都要看腻歪了,局部性原理就是说一个进程在一段时间内对程序指令和数据的引用通常倾向于在一个紧凑的范围内,举个例子,如果使用分页机制,那么在一段时间内它就总是访问那几个页,这也是虚拟内存机制比较实用的原因之一,因为我们我们最近加载进内存的那些页是程序在最近经常访问的页的概率很高。
分页和分段
无论是基于分页还是分段,还是段页混用的虚拟内存实现,其段表和页表都不能再像以前那样简单。
使用虚拟内存后,程序只有一部分被加载到了内存,其它的都在外存中,并且当前在内存中的页一段时间后可能会被写回到外存。所以最起码需要一个控制位P,它代表该页表项对应的页是否在内存中。上图中还有个控制位M,它代表该页加载到内存中后是否被修改了,如果一个页没有被修改,那么当需要它为其它磁盘中的页让出内存空间时,就没必要再将它写回磁盘了,因为当前内存中的版本和磁盘中完全一致。
简单分页机制
上图给出了一种简单的页表实现。进程访问的地址被称为“虚拟地址”,也就是逻辑地址,它由页号和偏移量组成。页号用于索引页表中的页表项(Page Table Entry, PTE),页表项中存储的就是实际的物理内存中的页框号,偏移量指明了一个地址单元在实际的物理页中的位置,假设一个页大小为4KB,那么偏移量就需要占用12位,因为\(2^{12}=4096\)。下面是使用这种简单页表实现的虚拟地址映射过程:
- 进程使用页表寄存器找到页表对应的物理地址
- 使用虚拟地址前n位的页号到页表中查找对应页表项,得到页框号
- 使用页框号去内存中索引对应页框
- 使用偏移量确定具体该读取该页中的哪个字节
页框号+偏移量其实就是实际的物理地址。需要注意的是上图中有两个一致的地方:
- 偏移量长度和页大小的一致。因为偏移量映射了一个页中的某个字节,所以它们必须一致
- 页号和页表大小的一致。这里不是说它们完全一致,这取决于页表项的大小。举个例子,若页号占用10位,也就是能够索引\(2^{10}=1024\)个页表项,若页表项占用4字节,那么页表的大小就是\(4\times 1024 = 4096 Bytes\)
多级分页机制
现在脑海里要有的画面是,每个进程都有一个页表,页表存储在内存中,同时页表和程序中的其他数据没有不同,也是通过页来加载管理。只不过有一个寄存器专门记录当前进程页表的起始地址。
当前计算机的地址长度通常是32位或64位,若用比较常用的4KB页大小,那么偏移量需要占用12位,若使用32位地址的话那剩余的地址空间就由\(2^{20}\)个页组成,若每个页表项4字节,整张页表需要\(2^{20+2}\)(4MB)的内存空间来存储这张页表,也就是这张表就占了\(2^{10}\)个页。看起来也没啥大不了的,但是如果将地址长度扩展到64位,这个数就变成了天文数字。
无论怎么说,将一个进程的页表完全存储在内存中是不太可行的方案。
下图是分级页表机制,内存中只存储一个4KB的根页表,比如每个页表项占用4字节,那么需要用地址空间的前10位来在根页表中进行索引,根页表索引的结果是得到存储在磁盘中的同样为4KB的用户页表,然后再加载这个用户页表到内存中(页表也被看作一个普通的页进行加载),使用地址空间中的后10位来在这个用户页表中进行索引。用户页表中索引出来的才是实际的物理页框号,再加上最初虚拟地址中的偏移量,得到最后的物理地址。
我的另一篇MIT操作系统公开课的笔记也记录了这种页表机制,同时还介绍了任意多级的页表结构:页表 —— MIT 6S081 FALL 2020
也可以收看MIT的公开课:6.S081 / Fall 2020 Lecture 4 Page Table
倒排页表
上面介绍的页表管理方式都是将注意力放在映射整个地址空间上,倒排页表将注意力放在映射实际的物理页框上。当然现在的机器内存越来越大,这种页表貌似也不是很好的选择,不过也介绍下。
倒排页表中有很多页表项,每个页表项代表物理内存中一个实际的页框,第i个页表项代表物理内存的第i个页框。现在把这具有\(2^m\)个页表项的倒排页表看作一个具有\(2^m\)个槽的散列桶,使用一个散列函数将虚拟地址均匀的映射到这个页表上,这就是倒排页表。当然有散列的地方就要应付冲突溢出的发生,所以必须有一个链来保存被映射到相同页表项上的页。
TLB
转换检测缓冲区(Translation Lookaside Buffer,TLB)。
上面所有的虚存方案都加倍了对一个物理地址的访问时间,因为你先要访问页表,再访问实际的物理空间,在多级页表结构中可能效率递减的更严重。好在有局部性原理,这个局部性原理真是支撑了整个计算机行业的发展,任何有缓存的地方都有它,任何需要高效率的地方都有缓存。
TLB是一个高速硬件缓存,它用于保存最近使用过的页表项,CPU在访问页表之前,先检查TLB中是否已经有它要找的页表项了,如果有则直接使用,如果没有再查询页表,并把该项保存到TLB中。
TLB的结构和置换本书没有研究。
页尺寸
选择合适的页尺寸在分页虚存系统中对效率起决定性作用,下图是页尺寸和分配的页框数目对页错误率的影响。所谓页错误就是要查找的页不在内存中,需要发生中断并从外存中读取的情况。
页尺寸非常小的时候,由于系统中能装入的页很多,所以不会经常缺页,当页尺寸慢慢变大,会经常发生缺页,当页尺寸接近进程大小时,缺页率又会慢慢降低,当页尺寸和进程一样大时不会发生缺页。
分段
分段式允许程序员把内存看作是由多个不固定大小且动态的用户可见的段组成的。
- 简化对不断增长的数据结构的处理,不需要事先知道一个特定的数据结构大小。
- 允许程序独立进行修改编译。
- 有助于进程间的共享,可以在单独的段中放置共享数据和工具。
- 有助于保护。
看起来和分页没啥差别,最主要就是分段大小不固定且动态。
段页式
将段的优点和页的优点结合。先将内存划分为段,然后再在段中划分页。
由于页对用户不透明,所以用户并感知不到页的存在。
操作系统软件
操作系统如何实现内存管理取决于三个方面的基本选择:
- 是否使用虚拟内存技术
- 使用分页还是分段,或是混合
- 为各种存储管理特征采用的算法
对于前两个方面,主要看系统所运行的硬件是否提供了支持。不过现今大部分系统都是支持虚拟内存技术并且使用段页式或页式的内存组织方式的。
本书和其他操作系统教材一样,主要聚焦在第三点的置换算法上,并且聚焦在页而不是段上。因为操作系统面临的大多数内存管理问题都发生在页上。
下图是采用虚拟内存的操作系统所需要在各种时机使用的各种策略。具体使用什么策略取决于多方面因素,比如内存大小、内存外存相对速度、竞争资源的进程大小和数目以及单个程序的执行情况。操作系统只能根据这些情况来选择一种算法,对于哪个算法才是最好的,没有最终答案。
读取策略
请求分页是指当进程访问到某一个页中的某一个单元时才将它取入内存
预先分页是指一次性读取一些连续的页到内存。
对于请求分页,刚开始可能会发生一堆缺页中断,但随着程序慢慢运行,局部性原理生效,缺页中断发生频率就会降低。预先分页利用了磁盘的机械特性,因为大量的随机存储带来很多的磁盘搜索时间,如果连续读取一批页面,并且这批页面还会被用到,那么这些磁盘搜索时间就可以节省掉。当然也有可能用不到。
放置策略
对于分页和分段设计,放置策略并不重要。
对于多处理器共享内存的情况,可能需要考虑放置到距离当前处理器最近的位置。
置换策略
我们给一个进程分配的页框数量是有限的,置换策略是指需要加载新的页到页框中但已经没有给该进程的页框时,按照什么策略幻出一个页并加载新的页。
页框锁定
有些页框不可以被置换出去,比如操作系统的内核。
这可以通过在页框表或页表中加入一个锁定位来完成。
基本算法
置换策略的基本算法包括:OPT(最优)、LRU(最近最少使用)、FIFO(先进先出队列)和时钟。
OPT是理论上存在的一种算法,它会置换掉当前在页框中的,在该程序以后的运行中,最晚被重新访问的一个页。这个算法确实是最优的,但却不可能实现,操作系统不可能预见进程日后的执行。
假设一个进程被分配了3个页框,下面是它要访问的页的顺序
2 3 2 1 5 2 4 5 3 2 5 2
根据OPT算法,需要发生3次页面置换,这图的最后两个画错了。
LRU,老生常谈的算法,它的性能接近OPT。它是置换最近最少使用的页面。一种实现方法是给每个页添加一个最近使用时间的标签。
LRU需要维护复杂的数据结构和高昂的开销,在操作系统这种底层的软件上不太合适。
FIFO就是先进先出队列,它把在当前系统中驻留时间最长的那个页换出。当然程序中经常有一些页贯穿整个程序的生命周期,对于这种页,FIFO算法会导致它们被频繁的换入换出。
下面给出三种算法的比较:
- OPT:性能最优,却不可实现
- LRU:性能次优,实现复杂
- FIFO:性能差,实现简单
被操作系统设计者广泛采用的是一种被称作时钟策略的算法,它的性能接近LRU,使用的数据结构简单,开销较小。
时钟算法将系统中的每个页框组成一个环,然后提供一个指针来顺时针按环旋转,像一个时钟一样。
时钟算法为每个页框添加了一个使用位,当它被加载到内存中或被使用时都会将这个位置为1。当需要置换页面时,从指针停留位置旋转指针,如果遇到使用位为0的,就将它置换出去,如果遇到使用位位1的,将该位置0。
如上图a,新的页面需要置换出一个旧的页面,当前指针在页框2,它的使用位为1,将它置0,继续旋转,页框3的使用位也为1,置0,继续旋转,页框4的使用位为0,将它置换出去,将新页加载到这个页框,并且将使用位置1,旋转指针到下一个位置(页框5)。
如果算法需要其它方面的权衡,还可以添加其它位用作置换的参考。比如常见的是添加该页自进入页框中以来是否被修改过。位u
代表该页是否使用过,位m
代表该位是否被修改过。
- 置换算法在首轮旋转时期待找到一个
u
和m
都为0的页框,这代表它目前的使用频率可能稍低并且不用将它写回外存。在这轮修改中不对位u做任何修改。 - 如果第一步失败,重新扫描,查找
u=0
、m=1
的页框,找到后置换,对于跳过的每个框,将使用位置为0。 - 如果第二步失败,指针将回到它最初的位置,并所有页的
u
都为0,重复前两步。
还有种页面置换算法叫页缓冲,它用FIFO加一个空闲页和修改页来提高性能,这个我没太看懂,书里也没过多介绍,就不研究了。(后面读到清除算法时突然明白了它的意义,后面介绍)
驻留集管理
驻留集大小
驻留集,我看书里的意思就是分配给一个进程的页框数?它对操作系统有些副作用:
- 如果分配给一个进程的空间越小,系统中就能同时容纳更多进程,对于支持挂起进程的系统,系统中能容纳更多的进程是好事
- 尽管第一点看起来是好的,但是分配给一个进程的页越少,它发生缺页的可能性越高,性能可能会下降
- 当给一个进程分配的页数到达一定大小后,由于局部性原理,它的缺页率不再有明显变化
所以对于驻留集大小的分配,操作系统通常采用固定分配策略或可变分配策略。
固定分配策略通过考量程序的类型或程序员、管理员的要求来给进程分配一个固定的页框数目。当这个页框数被塞满,再想加载页就必须置换出一个已经在内存中的页。
可变分配算法允许动态的改变程序所得到的页框,操作系统中可能有一个评估程序,它评估一个进程的缺页率,当缺页率过高,它可能就会多给该进程分配几个页框,当缺页率低,有可能回收几个页框。
置换范围
局部置换策略只置换当前进程中的页,全局置换策略没有这个限制,当缺页中断发生,它可能从全局任意一个进程中置换页面。
可变分配,局部范围 和 工作集策略
- 当进程被装入内存时,先给它分配一定数目的页框作为驻留集。
- 当发生缺页中断时,从产生中断的进程的驻留集中选择一页置换
- 不时重新评估进程的页框分配情况,为提高性能动态增加或减少页框
这个策略可以带来很好的性能,但它的评估算法需要一定的开销。书上说用的比较多的是可变分配+全局范围的组合。解决可变分配,局部范围的评估算法开销比较常见的办法是使用工作集策略。
书上依然没给工作集的定义,我理解是在某个时间段内进程中被访问到的页的集合。这个时间段由两个参数确定,\(t\)好像大概是这个时间段的结束时间,\(\Delta\)是从结束时间开始往前多少个时间单位。函数\(W(t, \Delta)\)就代表进程在\(t\)过去的\(\Delta\)个虚拟时间单位中被访问到的页的集合。\(\Delta\)也被称作窗口大小,在t固定的情况下,窗口大小越大工作集就越大。
因为局部性原理的存在,在时间窗口固定的情况下,工作集大小在某一刻开始瞬变(局部性原理发挥作用之前或转换到下一个局部时),然后进入一段时间的稳定状态,然后再次瞬变。由于上一个局部的页还可能存留在内存中,所以工作集可能会变大,当时间窗口划过它们之后,工作集就会收缩回来。
有了工作集的概念,就可以给可变分配、局部范围策略提供关于确定驻留集大小和变化时间的参考。
清除策略
请求清除策略和预清除策略,它们的原理和区别同请求读取策略和预读取策略。
预清除策略可以与页缓冲算法结合!!我明白页缓冲算法的意义了。之前的印象里,置换操作是一个磁盘中的页换走一个内存中的页,是成对出现,所以我不知道为什么页缓冲算法中可以将从内存中换走的页保存到空闲列表或修改列表中,按理说是应该直接被写回磁盘或丢弃了(当没有修改过此页时丢弃)。而当使用预清除策略时,可以提前将要用于置换的页面假清除,将它们放到空闲列表或修改列表中,当需要置换,首选这里面的页面,而且修改列表中的页面也可以被批量写出,减少磁盘搜索。
加载控制
略
习题
虚拟内存分页允许不将进程的全部部分一次性加载到内存中。
在一段时间内,一个页被不断的换出后加载。
虚拟内存的很多算法建立在局部性原理上,很多算法的设计是在假设程序一段时间内会频繁访问的一部分数据和指令下设计的,事实也确实是这样。
- 页框号:用于确定物理存储中的页框
- 存在位:确定该页是否在内存中
- 修改位:确定该页是否被修改
记录经常被访问的页表项,用于提供高速的缓存
- 请求读取:在请求一个页时读取
- 预读取:预先读取一批页面
它们有什么关系吗??
页面置换策略决定在给定的页集下如何选择被置换的页面,驻留集管理决定给进程分配的内存大小和置换策略作用的范围(给定什么页集)。
没啥联系啊,,CLOCK的数据结构很像环形队列吗??
选定最简单,开销最小的FIFO置换算法,添加空闲队列和修改队列来提高FIFO的性能问题。如果被移除的页还在空闲列表中未被刷出内存,那么它可以不用磁盘访问立即被召回,而且修改队列里面的页可以以簇的方式写回。
全局置换策略允许当一个进程由于要加载一个新页而要置换出一个旧页时,可以选择置换出其它进程的页。在一个置换了其他进程的页的进程看来,它莫名其妙的多了一个页框可以用,而被置换的进程少了一个页框。固定分配策略是限制所有进程的页框数固定,这两个目的相冲突。
驻留集是指进程占据的页框数,工作集是指进程在一个时间段内被访问的页的集合。
请求式清除当请求换走一个页时才会清除该页,预约式清除可以提前清除一个页。