虚拟内存的使用方便了更多的程序同时(宏观意义上的)执行,增加了CPU的使用率。它将物理内存与逻辑内存分开,允许文件和内存通过共享页为多个进程所共享。
1. 虚拟内存的实现方式-请求页面调度
请求页面调度 也就是在分页系统+交换,需要时再将进程载入内存。注意不是换入整个进程,而且是lazy swapper,也就是"Demand paging"和"和写时复制(Copy On Write)的技术"。lazy swapper技术的实现靠页表中的标志位(v/i):有效位v表示该页存在于物理内存中,此时进程可以正常访问该页;无效位i表示该页未载入内存,进程访问时会触发一个缺页异常,然后从内存中寻找一个空闲帧(或者牺牲页)替代它。接下来,重新载入该页,并重新执行因缺页而中断的指令(这是因为在缺页时,系统保存了中断进程的状态,包括寄存器、条件代码、指令计数等)。考虑一种情况:如果跨页移动内容,在移动执行了一半时出现页错误,若源和目的有交叠,而源块已经被修改,那么此时就不能简单的重新执行指令。解决的方法之一是采用临时寄存器保存重写位置的值。
性能:一般内存访问时间为10-200ns左右,而一次页错误时间约为25ms(主要是硬盘寻道和访问时间),页错误会引发的动作为:
- 陷入操作系统
- 保存当前寄存器和进程状态
- 确定中断是否为页错误,并确定页引用是否合法
-确定页在磁盘的位置,从磁盘读入页到空闲帧
- (等待过程中CPU被分配给其他进程)
- 磁盘中断
- (保存其他进程的寄存器状态和进程状态)
- 修正页表和其他表
- (等待CPU再次分配给当前进程)
- 恢复用户寄存器、进程状态和新页表,重新执行中断的指令
简单的来说也就是:处理页错误中断->读入页->重新启动进程。
交换空间比文件系统快,进程开始时先将整个文件映像复制到交换空间。BSD UNIX使用的方法是,二进制文件缺页时直接从文件系统中读入,而与文件无关的页(如堆栈)还是使用交换空间。
还有一种调度策略时预约式页面调度,即在程序开始前将所有页一起掉入到内存中。不过这种方法要权衡这些页是否被程序利用,否则会产生很大的开销。
2. 两种提高进程创建和运行性能的技术
A. 写时拷贝copy-on-write。fork一个子进程时,允许子进程与父进程共享同一页面,这些页面被标记上写时复制(不需要修改的页不需要标记),如果任何一个进程需要对页进行写操作,那么就创建一个共享页的拷贝。windows、linux均使用这种机制。那从哪里为写时复制分为空闲页?OS为这类请求提供了空闲缓冲池,这些空闲页在进程堆栈必须扩展时可用于分配。OS采用按需填零(zero-fill-on-demand)技术,按需填零页在分配前先填零,清除以前页的内容,复制的页被复制到已填零的页上。(详见《深入理解计算机操作系统》p622)vfork不使用写时复制,而是直接用父进程的进程空间,所做的修改在父进程重启时是可见的,主要用于进程创建后立即调用exec的情况(?)。
B.内存映射文件
open、read和write在进行一系列读写操作时,文件每次访问都需要一个系统调用和磁盘访问。可以采用内存映射(memory mapping)的方法,将一磁盘块映射成内存的一页(或多页)。开始文件访问按照普通页面调度进行,会产生页错误,然后一页大小(有时候多个)的部分文件从系统读入物理页,以后文件的读写就按正常的内存访问来处理,而不是系统调用read和write,简化了文件的访问和使用。对映射到内存中的文件进行写可能不会立即写到磁盘上的文件,OS会定期检查。
多个进程还可以允许同一文件映射到各自的虚拟内存中,以允许数据共享,任一进程修改数据其他进程都可以看到。互斥机制。
3. 页面置换
过度分配:本质来说就是多道程序的程度很高,物理帧不够用了。通常使用页置换(page replacement),也就是查找当前不在使用的帧,将其内容写入交换空间,并改变页表表示该页不在内存中。即换入-换出。主要步骤包括:
- 查找所需页在磁盘上的位置
- 查找一个空闲帧,如果有的话,直接使用该帧
- 否则,使用页面置换算法选择一个“牺牲”帧,将牺牲帧内容写到磁盘上(如果磁盘上已经有了且内容相同,则可以不写以降低I/O,通过修改位查看是否有改动),更新页表和帧表(表中的一些标志位,比如“修改位”)
- 将所需页读入到空闲帧,更新页表和帧表
- 重新启动用户进程
页置换是请求页面调度的基础,逻辑地址空间不再受物理内存所限制。为了实现这种功能,必须有帧分配算法(决定为每个进程分配多少内存)和页置换算法(选择要置换的页)。
Linux 中的页面回收(也就是页置换,应该算是物理内存的释放,腾讯笔试题)是基于 LRU(least recently used,即最近最少使用 ) 算法的。LRU 算法基于这样一个事实,过去一段时间内频繁使用的页面,在不久的将来很可能会被再次访问到。反过来说,已经很久没有访问过的页面在未来较短的时间内也不会被频繁访问到。因此,在物理内存不够用的情况下,这样的页面成为被换出的最佳候选者。【参考】
可以借助内存引用串来评估一个置换算法的好坏(也就是看需要多少次页置换),不好的页置换算法会减慢进程执行。一些页置换算法:
- FIFO页置换:通过建立一个FIFO队列,置换最旧的页。并不是最好,比如说旧的都是常量。且会出现Belady异常(页错误有时会随帧数增加)
- OPT最优置换:所有算法中错误率最低的,但难以实现,因为需要引用串的未来知识。(理论上的算法)。
- LRU置换算法:least-recently-used,对每个页记录上次使用的时间,LRU置换最长时间没有使用的页(不一定是最先进来的)。需要大量的硬件支持,可以借助应用计数器(时钟),或者堆栈(双向链表)实现。
- LRU近似页置换:在页表中加入附加引用位,如8bit(极端情况下只有一个引用位,即二次机会算法)。每个时钟都向右移位,引用的话高位置1,否则置0。
- 二次机会算法:1bit引用位+FIFO。
- 增强型二次机会算法:引用位(是否用过)+修改位(是否修改过)。置换顺序为(0,0)->(0,1)->(1,0)->(1,1)。
(- 基于计数的页置换:最不经常使用页置换算法,最常使用页置换算法。这两种算法性能很差都不常用 --!)
- 页缓冲算法:借助空闲帧缓冲池,允许进程尽可能快速的重启,而无需等待牺牲页的写出(相当多了一步缓存,不需要I/O)。
另外帧置换算法还有全局置换(从所有帧集合中选取牺牲帧)和局部置换(只能从分配给自己进程的帧中选取)之分。
4. 帧分配
一方面分配的帧不能超过可用帧的数量;<-最大帧数目由物理内存决定
另一方面因为在指令完成之前出现页错误时,该指令必须重新执行,因此必须要有足够的帧来容纳所有单个指令所引用的地址。如果多级引用太严重的话(A引用B,B引用C,...)则很难处理,因此必须对间接引用加以限制(利用引用计数)。<-最小帧数目由计算机体系结构决定
帧分配算法:平均算法(不好),按比例分配(也不好,都没考虑优先级),好的帧分配算法应该结合进程的优先级和大小。
5. 系统颠簸
频繁的页调度行为称作颠簸。系统监视CPU利用率,如果过低的话就向其调入新的进程。同时这也增加了页错误的几率,进程都在等待调页,反过来降低了CPU利用率。然后CPU又增加多道程序的程度...雪球越滚越大。
解决方法:
1)使用局部置换算法而不是全局置换算法
2)提供进程所需的足够多的帧,基于工作集合模型(给出所需帧数目的下限),即分配大于其工作集合的帧数。如果所有工作集合之和超过了现有可用帧的总数,那么操作系统会选择暂停一个进程。注:工作集合模型定义了进程执行的局部模型,即进程执行时总是从一个局部(由程序结构和数据结构组成,比如一个子进程)转移到另外一个局部。工作集合模型的困难是跟踪工作集合。
3)控制页错误频率。是最直接的方法,超过门限则需要分配更多的帧,低于门限就移走多于的帧。
6. 其他
1)页大小的选择,一般4K-4M。大小的权衡,包括碎片,页表大小,I/O时间等。(linux和windows好像默认都是4K)
2)反向页表。虽然反向页表会降低内存占用量,但由于功能不全<进程ID,页码>通常需要外部页表支持,这样在页错误时调用外部页表的时间花销会比较大。
3)程序结构。选择好的程序结构和数据结构来增加局部性,降低页错误率和工作集合内的页数。例如多维数组嵌套循环的调用顺序。堆栈局部性较好,哈希表局部性不好,太多使用指针也会导致局部性不好(因此Java程序比C或C++有更好的局部性)。
4)I/O互锁。防止在对用户(虚拟)内存进行I/O时,CPU交给了其他进程并将这些正在等待I/O的帧置换出去。解决方法是在每个帧上加一个锁住位,被锁住的帧不能被置换。锁住位还可以解决在普通页置换时高优先级欺负低优先级进程(防止把低优先级正在等待的帧置换出去)。
Ref: 《操作系统概念》