操作系统导论(二)----虚拟化内存

#### 那些在自己的领域超凡脱俗的人,比那些相当优秀的人强的不是一点点 ####
#### 努力工作,你也可能会成为这种“以一当百”的人 ####
#### 做不到的话,就和这样的人一起工作,你会明白什么是“听君一席话,胜读十年书” ####
#### 如果都做不到,那就太难过了 ####

一、 虚拟化内存导论
    · 理解硬件和操作系统交互的过程有助于学习虚拟内存
    
    · 基址/界限 ---> TLB和多级页表 ---> 全功能的现代虚拟内存管理程序的工作原理

    · 理解虚拟内存:
        1. 用户程序生成的每个地址都是虚拟地址;
        2. 操作系统只是为每个进程提供一个假象;
        3. 操作系统拥有自己的大量内存; 在硬件的帮助下,操作系统会将这些假的虚拟内存变成真实的物理地址,从而能够找到想要的信息;
    
    · 虚拟内存的意义:  (为了易于使用;隔离;保护)
        1. 操作系统会让每个程序觉得,他有一个很大的连续地址空间来放入其代码和数据;
        2. 作为程序员,不必担心具体在哪存储变量 (数据);
        3. 程序的虚拟地址空间很大,有很多空间可以存代码和数据;
        4. 不希望一个错误的程序能够读取或者覆盖其他程序的内存
    
    · 对于错误的进程行为,正确的操作反应是要 杀死 违规进程;


二、 补充 --- 常见进程API
    · fork():
        fork()用于创建新进程;
            #include<stdio.h>
            #include<stdlib.h>
            #include<unistd.h>
            int
            main(int argc,char *argv[])
            {
                    printf("hello world (pid:%d)\n",(int) getpid());
                    int rc=fork();
                    if (rc < 0){
                            fprintf(stderr,"fork failed\n");
                            exit(1);
                    }else if (rc==0){
                            printf("i am child (pid:%d)\n",(int) getpid());
                    }else{
                            printf("i am %d 的 father (pid:%d)\n",rc,(int) getpid());
                    }
                    return 0;
            }
        输出为:
            [root@localhost ~]# ./a.out 
            hello world (pid:1902)
            i am 1903 的 father (pid:1902)
            i am child (pid:1903)
            [root@localhost ~]# 
        fork()创建的进程与调用的进程完全一样,
        对操作系统来说,这时看起来有两个完全一样的程序在运行,并且都从fork()系统调用中返回。
        
        新创建的进程被称为子进程,原来的进程被称为父进程;
          子进程不会从main()函数开始执行 (所以没有执行hello world),而是直接从fork()系统调用返回,就像他自己执行了fork()一样;
        子进程不是完全拷贝了父进程。具体来说,它拥有自己的虚拟地址空间、寄存器、程序计数器...
            父进程的fork()返回值是新创建的子进程的pid,子进程的fork()返回值是0。

    · wait()
        wait()用于等待子进程执行完毕;

    · exec()
        exec()可以让子进程执行与父程序不同的程序;
        给定exec()程序名称和需要的参数之后,exec()可以从目标程序加载代码和静态数据,并且用它复写自己的代码段/静态数据;
        堆、栈以及其他内存空间也会被重新初始化。然后执行指定的程序;
        exec()并没有创建新的进程,而是直接将当前的运行的程序替换为目标的程序;
    
    · 为什么这样设置创建新进程的API?
        将fork()和exec()分开可以保证执行的程序是想要执行的,虽然这样不利于简化代码。
            #include<stdio.h>
            #include<stdlib.h>
            #include<unistd.h>
            #include<string.h>
            #include<fcntl.h>
            #include<sys/wait.h>
            int
            main(int argc,char *argv[])
            {
                printf("hello world (pid:%d)\n",(int) getpid());
                int rc=fork();
                if (rc < 0){
                         fprintf(stderr,"fork failed\n");
                           exit(1);
                }else if (rc==0){
                            close(STDOUT_FILENO);
                            open("./out.txt",O_CREAT|O_WRONLY|O_TRUNC,S_IRWXU);

                            char *myargs[3];
                            myargs[0]=strdup("ls");
                            myargs[1]=strdup("./dict");
                            myargs[2]=NULL;
                            execvp(myargs[0],myargs);

                            printf("i am child (pid:%d)\n",(int) getpid());
                    }else{
                            printf("i am %d 的 father (pid:%d)\n",rc,(int) getpid());
                    }
                    return 0;
            }
        以上代码先fork创建了一个子进程,
          在子进程(rc == 0)中用exec执行之前打开"out.txt"文件,
          再调用exec()执行"ls dict"指令;
          在父进程中调用wait()等待子进程结束再执行;
        输出为:
            [root@localhost ~]# ./a.out 
            hello world (pid:1902)
            i am 1903 的 father (pid:1902)
            [root@localhost ~]# cat out.txt 
            1
            2
            3
            4
            [root@localhost ~]# 
        可以发现fork()的子进程没有修改父进程的数据,exec()调用了"ls"程序,fork+exec的组合可以完成一些看起来复杂的问题;
    
    · 其他API
        kill():向进程发送信号,包括要求睡眠、终止或者其他有用的指令;
        ps:查看当前执行的进程;
        top:查看当前系统中进程消耗资源的情况;
        ...


三、地址空间    (抽象)
    · 虚拟化内存的发展
        1. 最开始的操作系统是一组函数在内存中,运行的程序也在内存中使用剩余的内存;
        2. 为了提高效率,进入了多道程序和时分共享时代;
            一开始实现时分共享的方法:先让进程占据内存并运行一段时间,
                                     然后停止它并将所有状态信息保存在磁盘上,
                                     再加载其他进程的状态信息,再运行一段时间;
            问题是将数据保存到磁盘太慢了,因此将进程信息放到内存中可以更有效的实现时分共享;
            但是当多个程序同时放在内存中,保护变成了重要的问题,因为大家不希望一个进程可以访问其他的进程。
        3. 地址空间
    
    · 隔离原则
        隔离是建立可靠系统的关键原则;
        如果两个实体相互隔离,意味着一个实体的失败不会影响另一个实体;
        操作系统为了防止进程之间相互造成伤害,让进程隔离;
        通过内存隔离,操作系统进一步确保运行程序不会影响底层操作系统的操作;
        现代操作系统通过将操作系统的部分与其他部分隔离开,实现进一步隔离的微内核,这样可以比整体的内核提供更大可靠性;
    
    · 地址空间
        地址空间是物理地址的抽象,是了解内存虚拟化的关键;
        
        地址空间包含运行程序的所有内存状态:
            1. 程序的代码   (code,指令)
            2. 利用栈来保存当前函数的调用信息,分配空间给局部变量、传递参数、函数返回值;
            3. 利用堆来管理动态分配、用户管理的内存;就像从C语言中调用 malloc()和new()获得的内存;
            4. 其他东西:静态初始化获得的变量...
        
        地址空间的分配:
            1. 程序的代码是静态的,放在地址空间的顶部;
            2. 地址空间中还有两个可能会增长,那就是堆和栈,所以将他们放在地址空间的两端 (堆在顶部,栈在底部)
        
        内存虚拟化的关键是 操作系统要在硬件的支持下,将地址空间上的地址映射到正确的物理地址上。
    
    · 虚拟化内存的目标
        1. 透明:运行的程序不应该感知到内存被虚拟化 (透明指的是很难被注意到,而不是公之于众)
        2. 效率:操作系统应该在时间 (不会减慢加载速度)和空间 (不需要太多额外的空间来虚拟化内存)上都要追求高效。
        3. 保护:操作系统应该确保进程收到保护,不会受到其他进程的影响;
                 当进程执行加载、运行或指令提取时,不应该以任何方式访问任何其他进程或操作系统本身的内存内容;
                 为进程之间提供隔离,每个进程都有自己独立的运行环境,避免其他出错或恶意进程的影响;
    
    · 所有能看到的地址都是假的
        在用户层面,可以看到的任何地址都是虚拟地址;
        虚拟地址只是提供地址如何在内存中分布的假象,只有操作系统才知道物理地址
    
    · 问题:
        1. 虚拟化内存的机制
        2. 虚拟化内存的策略
    
    ·总结:
        虚拟内存系统负责为程序提供一个巨大的、稀疏的、私有的地址空间的假象;
          其中保存了程序的所有指令和数据;
        操作系统在硬件的支持下,通过每一个虚拟内存的索引,将其转化为物理地址,
          物理内存根据获得的物理地址取获取所需的信息;
        操作系统会同时堆许多进程执行此操作,并确保程序之间互相不会受到影响,也不会影响操作系统


四、地址转换    (机制)
    · 回顾虚拟化CPU
        采用受限直接访问:
            让程序运行的大部分指令直接访问硬件,只在一些关键点  (如 进程发起系统调用和时钟中断)由操作系统介入;
            来确保 在正确的时间、正确的地点,做正确的事;
        为了实现虚拟化,操作系统应该尽量让程序自己运行,同时通过在关键点的及时介入,
        来保持对硬件的控制;    高效和控制是现代操作系统的两个主要目标。
    
    · 实现虚拟化内存:
        和虚拟化CPU一样,在实现高效和控制的同时,提供期望的虚拟化;
            1. 高效决定了我们要利用硬件的支持,从最开始的使用寄存器,到复杂的 TLB、页表...
            2. 控制意味着操作系统要确保应用程序只能访问他自己的空间;
            3. 最后我们还尽量追求灵活性;即 希望程序能以任何方式访问它自己的地址空间,从而让系统更容易编程
        所以关键问题是:
            1. 如何实现高效的虚拟内存?
            2. 如何提供应用程序所需的灵活性?
            3. 如何控制应用程序可访问的内存位置,从而确保程序的内存访问受到合理的限制?
            4. 如何高效的实现这一切?
    
    · 地址转换
        利用地址转换,硬件对每次内存访问进行处理    (即 获取指令、读取数据、写入)
          将指令中的虚拟地址转换为数据实际存储的物理地址;
          每次内存引用的时候,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置;
        仅仅依靠硬件不足以实现虚拟内存;操作系统必须在关键的位置介入,设置好硬件,以便于完成正确的地址转换;
        因此它必须管理内存,记录被占用和空闲的内存位置,并明智而谨慎的介入,保持对内存使用的控制。
    
    · 几个假设:
        用户的地址空间必须连续的放在物理内存中;
        假设地址空间小于物理空间;
        假设每个地址空间大小一样;
    
    · 动态 (基于硬件)重定位
        1. 每个CPU需要两个寄存器:基址 (base)寄存器 和 界限 (bound)寄存器又称限制 (limit)寄存器;
        2. 在程序执行时,将起始地址记录在基址寄存器上,后续该进程的所有内存引用都会加上那个起始地址;
        3. 例如 :
            位于物理地址32KB的程序开始指令 128: movl 0x0 (%ebx), %eax ;
              首先会在基址寄存器上记录32KB,程序计数器(PC)被设置为128,硬件开始执行;
              128被加上基址寄存器中的32KB (32768)得到实际的物理内存地址32896,然后硬件从这个物理地址获取指令;
              接下来处理器处理这个指令,如果需要调用位于栈上16KB处的局部变量,
              处理器同样会将16KB+32KB得到真实的物理地址48KB,从而获取到数据;
        
        将虚拟地址转化为物理地址,这就是所谓的地址转换;
        也就是说,硬件取得进程认为它要访问的地址,将它转换成数据实际位于的物理地址;
        由于这种重定位时在运行时发生的,而且甚至可以在进程开始运行之后改变其地址,这种技术一般称为动态重定位。

        界限寄存器:
            界限寄存器用于提供访问保护,如果界限寄存器被置为16KB,
            当进程需要访问的虚拟地址超过了这个界限或者为负值,CPU就会发出异常;
            界限寄存器的作用就是为了确保 进程产生的所有地址都在进程的地址的"界限"中。
        
        基址寄存器+界限寄存器的组合也通常被称为内存管理单元 (MMU)

    · 硬件支持:
        1. 特权模式:
              (以防用户模式的进程执行特权操作)
            正如CPU虚拟化需要两种CPU模式:内核模式 (可以访问整个机器资源)、用户模式 (只能做有限的操作);
            只要一个位,就能记录CPU的运行模式,在一些特殊时刻 (如 系统调用、异常或中断),CPU会切换状态;
        2. 基址+界限寄存器 (MMU):
              (每个CPU都需要一对寄存器来支持地址转换和界限检查)
            程序运行时,硬件会转换每个地址,将用户程序产生的虚拟地址加上基址寄存器;
            同时也会检查地址是否可用;
        3. 特权指令:
              (让程序运行前,操作系统能够设置这些值,
              以及在程序运行时,遇到异常时,应该执行那些异常处理指令)
    
    · 有了硬件支持,操作系统应该做什么?
        操作系统需要将硬件支持和系统管理结合在一起,实现一个简单的虚拟内存;
        即 在一些关键时刻操作系统需要介入,以实现基址和界限方式的虚拟内存;
        
        1. 在进程创建时:
            操作系统必须位进程的地址空间找到内存空间;
            假设没饿过地址空间小于物理空间,且它们大小一致;
            它可以把整个物理内存看作一组槽块,标记为 空闲和已用;
            当新进程创建时,检索这个数据结构 (空闲列表 free list)找到一块新地址空间的位置;
        2. 在进程终止时 (正常退出,不是异常):
            操作系统必须将结束进程的内存空间回收,给其他进程使用;
            同时,OS也会将这些内存返回到空闲列表中,并处理相关的数据结构;
        3. 在上下文切换时:
            因为每个CPU只有一对基址/界限寄存器,但对于每个运行的程序,因为内存中的物理地址不同,所以它们的值也是不同的;
            因此,在进程切换时,操作系统必须要保存和恢复基址寄存器和界限寄存器;
            也就是说,当OS决定中止进程时,它必须将基址/界限寄存器中的值保存在内存中,放在某种进程都有的结构中;
            如 进程结构 (process structure)或进程控制块 (PCB);
            当OS恢复某个进程时,也必须给基址和界限寄存器设置正确的值。
        4. 在触发异常时:
            OS在面对CPU发出异常时,必须要准备对应的行动;   (异常可能是恶意的操作导致,也有可能是申请特权指令使用)
            通常情况下,操作系统会直接终止错误的进程;但是OS会尽力保护机器,它会对那些企图非法地址或执行非法指令做出充满敌意的反应。
        补充:
            当进程停止时 (即没有运行时),操作系统可以改变地址空间的物理地址;
            要移动进程的地址空间,首先要让进程停下来,然后将地址空间copy到新位置,最后更新基址寄存器 (在进程结构中);
            当进程恢复时,新的基址寄存器被恢复,指令和数据也就都在新的内存位置了
    
    · 受限直接执行协议 (动态重定向,CPU+内存) 流程:
        1. 内核模式  初始化陷阱表;
           硬件      记住系统调用处理程序、时钟处理程序、非法内存处理程序、非常指令处理程序;
           内核模式  开始中断时钟;
           硬件      开始时钟,在x ms后中断;
           内核模式  初始化进程表、初始化空闲列表
        
        2. 内核模式  在进程表中分配条目、为进程分配内存、设置基址/界限寄存器;
           硬件      恢复A进程的寄存器、转向用户模式、跳到A的程序计数器;
           用户模式  进程A运行、获取指令;
           硬件      转换虚拟地址并执行获取;
           用户模式  执行指令;
           硬件      确保地址不越界、转换虚拟地址并执行、加载/保存;
           ...
           硬件      时钟中断、转向内核模式、跳到中断程序处理程序;
        
        3. 内核模式  处理陷阱、调用switch()程序、将A的寄存器保存到A进程结构中,从B进程结构中恢复B寄存器、从陷阱中返回进到B进程。
           硬件      恢复B寄存器、转向用户模式、跳到B的程序计数器
           用户模式  进程B运行、执行错误的加载
           硬件      加载越界、转向内核模式、跳到陷阱处理程序
           内核模式  终止B进程、回收B的内存、移除B在进程表中的条目

    · 总结:
        1. 利用地址转换,OS可以控制进程的所有内存访问,确保在地址空间的界限内;
        2. 地址转换的关键在于硬件支持,硬件快速的将所有内存访问操作中的虚拟地址转化为物理地址;
        3. 所有的操作位于用户程序来说是透明的,进程不知道自己使用的内存是被重定向的;
        4. 基址/界限寄存器只需要使用极少的硬件就能实现地址转换;
        5. 保护是OS最重要的目标之一,没有保护,OS就不能控制机器;
        
        6. 问题:简单的动态重定向存在效率低下的问题,有时候进程的栈区和堆区不需要那么大的空间;
                就会导致内存区域中大量的空间被浪费;
                所以我们需要更复杂的基址,以便于更好的利用物理内存,避免内部碎片。


五、分段    (解决内部碎片的分配方法)
    · 面临的问题:
        简单的基址/界限寄存器会导致栈和堆之间有一大块空间,这个空间并没有被内存使用,但是依然占据了实际的物理内存。

    · 分段 (泛化的 基址/界限)
        简单的使用一对MMU来保存进程的地址空间造成空间浪费的原因在于,栈和堆中间的空间没有使用,也没有办法分给其他进程使用;
        分段这个概念给地址空间的每一逻辑段都分配了一对MMU,一个段只是地址空间的一个连续的定长区域,
        一个典型的地址空间有三个逻辑不同的段:
            代码段、栈、堆;
        分段的机制能使OS将不同的段映射到不同的物理内存区域,从而避免了虚拟内存地址空间中未使用的部分占用内存;

        例:
            地址空间中,代码段起始地址为0KB大小为2KB,堆起始地址为4KB大小为2KB向下扩展,栈起始地址为16KB大小为2KB向上扩展;
            段寄存器中,代码段基址为32KB,堆基址为34KB,栈基址为28KB;
            假设现在要引用地址空间中的100 (代码段),MMU将基址加上偏移量,物理地址=100+32KB == 32868;
            现在要引用堆中地址,虚拟地址为4200,此时不能直接用基址加上4200,
            因为堆是从4KB开始的,应该要计算堆空间的偏移量,即真实的偏移量为4200-4096==104,物理内存地址为104+34KB==34920;
        
        所以实际计算段对应的物理内存地址公式应该是: 虚拟地址-段起始地址+基址
    
    · 应该使用哪个段?   
        1. 显示方式:使用虚拟地址中的前几位用于标识
        2. 隐示方式:硬件通过地址产生的方式来确定段 (例 地址由程序计数器PC产生,则地址在代码段)

    · 栈应该如何计算?
        栈和堆不同,堆从低地址向高地址增长,栈从高地址向低地址增长;
        所以除了基址和界限外,硬件还需要知道段的增长方向    (可以用一位来标识,例如1标识高地址增长,0反之)

        例:
            现在需要访问虚拟地址15KB,首先通过虚拟地址的前两位得知了应该使用栈寄存器;
            15kB-16KB+28KB,所以物理地址为27KB;

    · 支持共享
        为了节省内存,有时候在地址空间之间共享某些内存段是有用的;
        为了支持共享,需要一些额外的硬件支持,这就是保护位;
        于是在虚拟地址中又加上了几位用于表示能否读写这段,或者执行其中的代码;
        通过将代码段标记为只读,同样的代码就可以被多个进程共享;
        虽然每个进程都认为自己独占这块内存,但是OS秘密的共享了内存;

    · 细粒度和粗粒度的分段:
        粗粒度分段将地址空间分成较大的、粗粒度的块;
        细粒度分段将地址空间分成更灵活、较小的细粒度的块;
        细粒度分段可以让OS更好的了解哪些段在使用,哪些没有,从而可以更高效的使用内存;

    · 操作系统的支持
        1. 分段解决了栈和堆之间没有使用的区域一直空着的问题,现在这块区域不需要分配物理空间;
        2. 在上下文切换的时候,各个段的寄存器必须保存和恢复,进程运行前,OS确保这些寄存器被正确的赋值;
        3. 现在每个进程都有一些段,每个段的大小也可能不同;
           物理内存很快充满许多空闲的小洞,因而很难分配给新的段,或者扩大已有的段,这个问题被称为外部碎片;
           解决它的办法就是紧凑物理内存,重新安排原有的段;
           例如停止进程,将数据copy到连续的内存地址中,改变段寄存器中的值,从而获取较大的连续的内存空间,但是一般会占用大量的CPU时间;
                ...

    · 总结:   
        1. 分段可以更高效的虚拟内存,不只是动态重定位;
            通过引入逻辑段的概念,分段能更好地支持稀疏地址空间;
            而且代码段、栈、堆之间解耦,可以让一个段被多个进程复用
        2. 由于段的大小不同,空闲内存被割裂成各种奇怪的大小,因此满足内存分配请求可能会很难;
            尽管可以采用办法紧凑内存,但是问题的根本并没有被解决
        
        3. 问题:分段还是不足以支持更一般化的稀疏地址空间,采用什么办法能解决内存资源浪费的问题吗?


六、空闲空间管理 (解决外部碎片的分配方法)
    · 如果要管理的空间被划分为固定大小的单元,管理空闲空间就很容易,只需要维护一个标识空闲空间的列表就行;
      但是如果要管理的空闲空间由大小不同的单元构成,管理就变得困难;
      在分段实现虚拟内存和malloc()/free()的过程中会产生外部碎片:空闲空间被分为了不同大小的小块,导致后续的请求可能会因为足够大的连续的空闲空间失败;


    · 假设:
        1. 拥有malloc()和free()的接口,
            只需要告知需要的size,就会返回一个指针指向等大或者稍大的空间;
            只需要提交一个指向空间的指针,就会释放掉这块空间,即使用户没有提供空间的大小;
        2. 调用了malloc()接口就不会再将这块内存分配给其他程序,直到调用free()将它返回;
            所以不可能使用紧凑空闲空间的操作;
        3. 分配程序所管理的是连续一块字节区域;在一些情况下,允许程序要求这块空间扩张;

    · 底层机制
        1. 分割与合并:
            分配程序会在释放一块空间时检查相邻空间是否可以相连,将空闲列表中连续的空间合并成一个大的空间;
            在分配空间时,会找到一块大小足够的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中;
        2. 追踪已分配空间的大小:
            free()只会告知需要释放的地址指针,所以分配程序还需要知道这块空间大小;
            大多数分配程序会在header中保存固定位数的额外的信息,记录  header分配空间的大小和用户确认完整性的幻数 (magic),通过偏移量计算就能找到真正的空间;
            free()获取指针后检查幻数,并计算要释放的空间大小 (头部大小+空间大小,因为头部也没有用了);
        3. 嵌入空闲列表
            如何在空闲内存中建立一个空闲列表呢?    (4KB)
            首先要初始化这个列表:开始这块内存都是空闲的,列表只有一个条目,记录大小为4094的空间    (还要减去size和next字段的header信息,实际szie处保存的应该要比4096小)
            假设这块的起始地址位于虚拟地址的16KB;
            现在有一个100字节的内存请求,首先会先遍历空闲列表,找到一块足以放下100字节的空闲内存块;
            从空闲块中分配108字节,返回指向它的指针,并在它之前连续8字节中记录头信息 (用于free()),同时将空闲列表的size减去108字节;
            假设又继续请求了两个100字节的空间,这时候空间分布为:
                108(已用) -- 108(已用) -- 108(已用) -- 3764(空闲)       [4096-空闲列表条目header-3*分配地址header-3*100==3764]
            这时候释放中间的内存,free()计算header+分配空间的实际空间,空闲空间被分割成了两段,这时候空间分布为:
                108(已用) -- 100(空闲) -- 108(已用) -- 3764(空闲)       [108-空闲列表条目header=100]
            当继续释放其他的空间,就需要合并内存合并了,merge相邻块,将空闲空间合并为一个整体;
        4. 让堆增长
            如果堆中的空闲空间被分配完了,会向操作系统申请更大的空间,操作系统执行sbrk系统调用,找到空闲的物理内存页;
            再将它们映射到请求进程的地址空间去,并返回新的堆的首尾地址;
        
    · 基本策略
        1. 最优匹配
            首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块;
              也被称为最小匹配,只需要遍历一遍空闲列表,就足以找到正确的块并返回;
            最优匹配的想法很简单:选择最接近用户请求大小的块,从而尽量避免空间浪费;
                                 代价是遍历查找空闲块时要付出较高的性能代价;
        2. 最差匹配
            最差匹配和最优匹配相反,尝试找出最大的空闲块,用分割满足用户需求后,将剩余的块放回空闲列表;
            最差匹配尝试在空闲列表中保留较大的块,而不是像最优匹配那样可能会剩下很多难以利用的小块;
            最差匹配同样需要遍历整个空闲列表 (尽管空闲列表会小一点);但是实际情况会导致过量的碎片,同时还有很高的开销。
        3. 首次匹配
            首次匹配就是找到一个足够大的块就停止,用分割满足用户需求后,将剩余的块放回空闲列表;
            首次匹配的优势是速度快,因为不需要遍历所有的空闲块,但是有时会让空闲列表的开头部分有很多小块;
            因此分配程序如何管理空闲列表的顺序变得很重要;
            其中一种方式是基于地址排序;通过保持空闲块按内存地址有序,合并操作会变得容易,从而减少内存碎片;
        4. 下次匹配
            和首次匹配不同,下次匹配不是从列表的头开始查找,下次算法会维护一个指向上次查找结束位置的指针;
            可以将对列表头的频繁分割分摊到整个列表上;
            下次匹配的性能和首次匹配的性能接近;
        
        例: 现有一个空闲列表为 10 --> 30 --> 20,又有一个15的内存请求;
                最优匹配:遍历整个列表,找到最接近的20开始分割,分配完之后空闲列表为:
                    10 --> 30 --> 5
                最差匹配: 遍历整个列表,找到最大的30开始分割,分配完之后空闲列表为:
                    10 --> 15 -->20
                首次匹配: 从头遍历列表,找到第一个可以使用的30开始分割,分配完之后空闲列表为:
                    10 --> 15 -->20
                下次匹配: 这时候又有一个5的内存请求,下次匹配从15开始,分配完之后空闲列表为:
                    10 --> 5 -->20

    · 其他方式
        除了上述的基本策略之外,还有很多技术和算法改进内存分配;
        
        1. 分离空闲列表
            将常见大小的内存空间单独用一种空闲列表管理,其他大小的请求都交给更通用的内存分配程序;
            好处:
                a. 通过一部分内存专门用于存储特定大小的请求,碎片就不是问题;
                b. 没有复杂的列表查找过程,分配和释放的速度都很快;   (基本策略中的大部分时间都用于寻找一个可用大小的块)
            问题: 
                应该拿出多少内存用于专门为某种大小的请求服务?   (厚块分配程序)
                    在内核启动的时候,为可能频繁操作的内核对象创建一些对象缓存 (如 锁和文件系统的inode)
                    这些对象缓存每个分离了特定大小的空闲列表,因此能够很快响应内存请求和释放;
                    当缓存的空闲列表快要耗尽的时候,就会向通用内存分配程序申请一些内存厚块;
                    如果虚拟内存系统需要更多的空间,且给定厚块中的缓存引用为0,,通用内存分配程序也可以回收这些空间。
            厚块分配程序的优点:
                厚块分配程序将列表中的空闲对象保持在预初始化的状态;
                避免数据结构的初始化和销毁影响性能,将空闲对象保持初始化状态可用避免频繁的初始化和销毁,减少开销;
     
        2. 伙伴系统 (二分伙伴分配程序)
            只允许分配2的幂大小的空闲块 (会有内部碎片,但是会控制的尽量小)
            在块释放的时候,如果一个8KB的块归还给空闲列表,分配程序会检查“伙伴”的8KB是否空闲,然后往上递归合并,直到某个块的伙伴没有释放;
            优点:  合并变得简单。

        3. 其他想法
            上面的几个方法都有一个问题:缺乏可扩展性;
            也就是说,查找列表可能很慢,因此先进的分配程序采用更复杂的数据结构来优化这个开销;
            牺牲简单性来换取性能    (平衡二叉树、伸展树、偏序树等)


七、分页 (解决空间碎片化)(一):介绍
    · 空间管理方法有两种:
        1. 分段:将空间分为分割成不同长度的分片; (会导致空间碎片化)
        2. 分页:将空间分割成固定长度的分片;
                 分页不是将进程的地址空间分割成几个不同长度的逻辑段 (代码、栈、堆),
                 而是分割成固定大小的单元,每个单元称为一页;
                 相应的,我们把物理内存看作是定长槽块的阵列,叫做页帧;
                 每个页帧包含一个虚拟内存页;
    
    · 数据结构--页表:
        页表是现代操作系统的内存管理子系统中最重要的数据结构之一;
        页表存储 虚拟--物理地址转换信息,从而让系统知道地址空间的每个页在物理内存中的位置;
        由于每个地址空间都需要转换,所以一般每个进程都有一个页表;
        页表的确切结构有硬件确定或者由OS更灵活的管理。

    · 问题:    
        1. 然后通过页来实现虚拟内存,从而避免分段的问题?
        2. 分页的基本技术是什么?
        3. 如何让这些技术运行良好,并尽可能的减少时空开销?
    
    · 例:
        假设一个64字节的小地址空间有4个16字节的页 (虚拟页0、1、2、3),现在要将四个页放到8个页帧中;
        只需要找到4个空闲页放大物理页帧中就行;
        为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表;
        页表用于保存地址空间的每个虚拟页面对应的物理地址;
        假设现在这个进程正在访问内存:  movl 21, %eax
        为了转换该过程生成的虚拟地址,它们必须首先将它分成两个组件 虚拟页面号和页内偏移量 (就像网络中的网络号和主机号一样)
        因为进程的虚拟内存空间为64字节,所以只需要6位二进制虚拟地址就能表示所有虚拟地址;
        因为我们使用了4个页来表示地址空间,所以将前两位作为虚拟页号,后四位作为偏移量
        21 --> 010101,前两位01表示在虚拟页1上,后面的0101表示在第5个字节处;
        在通过页表来找到虚拟页1和物理页帧的对应关系,就能找到正式的物理地址了。

    · 页表存在哪里?
        页表可以变得非常大,比 基址/界限寄存器要大得多;
        一个32位的地址空间,带有4KB的页,这个虚拟地址被分为 20位的虚拟页号和12位的偏移量    (2^12 == 4096 == 4KB)
        20位的虚拟页号意味着有2^20个地址转换,假设每个页表条目需要4个字节,这个页表就需要4MB,100个进程仅仅用于地址转换就需要400MB的内存;
        由于页表如此之大,我们没有在MMU (在CPU中)中存储正在运行的进程的页表,而是将每个进程的页表存储在内存中;

    · 页表中究竟有什么?
        页表就是一种数据结构,由于将虚拟地址 (实际上只有虚拟页号)映射到物理地址 (物理页帧号),所以多种数据结构都可以表示;
        最简单的形式称为线性页表,就是一个数组;OS通过虚拟页号 (VPN)检索该数组,并在索引处查找页表项 (PTE),以便找到期望的物理帧号;
        每个页表项 (PTE)的内容中,有很多位:
            1. 有效位:由于指示特定的地址转换是否有效;
                    当一个程序开始运行时,代码和堆在地址空间的一段,栈在另一端;
                    中间未使用的空间标记为无效;
                    通过简单的标记,就不再需要为这些页面分配帧,从而节省大量空间;
            2. 保护位:表明页是否可以读取、写入或执行;
            3. 存在位:表示该页再物理存储器还是磁盘上;
            4. 参考位:追踪页是否被访问,或者确定哪些页很受欢迎,用于清理内存;
            ...
    
    · 分页,也很慢
        还是回到 movl 21, %eax这条指令;
        要获取虚拟地址21的数据,首先要将虚拟地址21转换为实际地址117,因此系统必须要从进程的页表中获取对应的页表项;
          所以硬件必须知道当前进程的页表的位置,假设有一个页表基址寄存器,为了找到页表项,还需要一系列对基址的运算;
        
        对于每一个内存引用,无论是取指令还是显式加载或存储,分页都需要执行一个额外的内存引用,用于从页表中获取地址转换;
        额外的内存引用开销很大,会严重拖慢进程运行的速度    (两倍甚至更多)

        问题:如果不仔细设计硬件和软件,页表会导致系统运行速度过慢,并占用大量内存。
    
    · 总结:
        1. 分页有许多优点,他不会导致外部碎片,以为分页将内存划分为固定大小的单元;
                           并且十分灵活,支持稀疏虚拟内存地址空间。
        2. 简单的分页会把大量的开销用在访问页表执行地址转换上,拖慢机器速度;
                           以及内存浪费,内存被页表塞满,而不是需要使用的应用数据。


八、分页(二):快速地址转换(TLB)
    · 简单分页面临的问题: 每次地址转换都需要增加一次从内存中查找页表获取页映射的过程;
        如何才能加速虚拟内存转换?  尽量避免额外的内存访问?    

    · 额外的硬件支持:  地址转换旁路缓冲存储器  (TLB,地址转换缓存)
        就是一个频繁发生的虚拟到物理地址转换的硬件缓存;
        每次内存访问,硬件先检查TLB,看看其中是否有期望的转换映射,如果有就直接使用,不需要访问页表 (里面有全部的转换映射)

    ·  TLB基本流程:
        1. 先从虚拟地址中提取 虚拟页号;
        2. 检查TLB中是否有该虚拟页号的转换映射;
        
        3.1. 如果有,从相关的TLB项中获取真实的页帧号,与虚拟地址中的偏移量组合获得物理地址,并访问内存;
        3.2. 如果没有,按照之前页表的方法,从内存中获取映射并更新到TLB中,这样后续访问该虚拟页的地址都会减少这个步骤;

        TLB和其他缓存相似,加速的前提就是转换映射在缓存中;
        如果缓存未命中的情况过多。仍然会导致速度慢的情况,所以要尽量避免缓存未命中的情况。
    
    · 例子:
        假设一个10个元素的int4数组,起始的虚拟地址为100;
        页的大小为16B,虚拟地址为8位;虚拟地址的虚拟页号位4位,偏移量为4位;
        所以    100 == 01100100,初始地址位于 06虚拟页的第4个字节;
        整个数组为  06虚拟页的第4个字节 到 08虚拟页的第12个字节;

        先向访问数组的第一个元素 01100100,先检查 0110在TLB的缓存,缓存未命中,取内存中获取页表,得到映射关系,加载到TLB中;
        访问 01101000 ,检查0110在TLB中的缓存,缓存命中,直接转换;
        ...
        访问 01110000 ,检查0111在TLB中的缓存,缓存未命中,取内存中获取页表,得到映射关系,加载到TLB中;
        ...
    
    · 缓存
        缓存是计算机系统中最基本的性能改进技术之一,用于让“常见的情况更快”;
        硬件缓存背后的思想是利用指令和数据引用的局部性,即 时间局部性和空间局部性;
        时间局部性:最近访问过的指令或数据项可能很快会再次访问;
        空间局部性:当程序中访问x地址时,很有可能会很快访问邻近的内存;
        硬件缓存,无论是指令、数据还是地址转换,都利用了局部性,在小而快的芯片内存储器中保存一个副本;
        处理器就可以先检查缓存中是否存在就近的副本,而不是访问缓慢的内存来满足请求;

        为什么不做更大的缓存装下所有的数据?
            如果想要快速的缓存,它就必须小,因为光速和其他物理定律限制会起到作用;
            大的缓存注定会慢,因此无法实现目的,所以只能利用好优先的缓存来提升性能。
        
    · 如何处理缓存未命中?
        1. 硬件 (复杂指令集计算机):硬件必须要通过页表基址寄存器,知道页表在内存中的确切位置,以及页表的确切格式;
                                   发生未命中的时候,硬件会“遍历”页表,找到正确的页表项,去除想要的转换映射,更新TLB,并重试该指令;

        2. 软件 (脚尖指令集计算机):发生TLB未命中时,硬件抛出异常,暂停当前的指令流,将特权级提升至内核模式;
                                   查找页表中的转换映射,用特别的指令更新TLB,返回用户模式,硬件重试该指令;

        与之前的特权级转换不同,之前因为系统调用的转换会从下一条指令继续执行,这里的转换会重新执行这次的指令;
        在运行特别指令更新TLB的时候,为了避免TLB未命中无限递归,可以将这段指令直接放在内存中,或者在TLB中永久保存这些指令的映射关系;

        软件管理的优势:
            1. 灵活性:操作系统可以用任意数据结构来实现页表,不需要改变硬件;
            2. 简单性:硬件只需要抛出异常,操作系统会处理具体的操作;
    
    · TLB中的内容
        典型的TLB有32项、64项、128项,并且它们是全相联的    (一条地址映射可能出现在任意位置,所以硬件会并行的查找这些项);
        TLB除了 虚拟页号和物理页号,还有一个有效位用于标识该项是否有效的转换映射,
        还有保护位用于标识该页是否有访问权限  (例如 代码页被标识位可读和可执行,堆页被标识为可读和可写..)
        还有一些其他位,包括地址空间标识符、脏位...

        TLB有效位 不等于 页表的有效位:
            页表有效位用于标识 该页是否被进程使用,正常运行的程序不应该访问该页;
            TLB有效位用于标识 TLB项是否位有效的地址转换,例如机器刚启动TLB中所有的项都标识未无效,当正确的转换映射被加载到TLB中,该项就被标识为有效;
        通过将TLB项设置为无效,可以确保要运行的进程不会错误地使用前一个进程的转换映射;

    · 上下文切换时对TLB的处理   (进程切换)
        在上下文切换 (地址空间切换)时,现在有了一些新问题,TLB中包含的地址映射信息只对当前的进程有效;因此要确保运行的进程不要误读了之前进程的地址映射;

        进程切换时如何管理TLB的内容?
            1. 在上下文切换时,简单的清空TLB,这样在新进程运行前TLB就变成了空的;
               在软件管理TLB系统中,可以在上下文切换时  (上下文切换也是通过操作系统来控制的),通过一条特权指令来完成;
               在硬件管理TLB系统中,可以在页表基址寄存器内容发生变换时  (硬件无法直接感知上下文切换,只能通过寄存器的变化来判断),直接清空TLB;
            
            2. 清空TLB的方法面临的问题是,如果操作系统频繁的切换进程,就会频繁面临TLB未命中带来的开销;
                所以需要增加硬件的支持,例如    增加一个 地址空间标识符 用于标识 属于哪一个进程;
                同时硬件也需要知道当前运行的进程是哪一个,所以要将某个寄存器设置为当前的 地址空间标识符
    
    · TLB替换策略
        和其他缓存一样,TLB也需要考虑缓存替换的问题。
        常见的几个替换策略为LRU (最近最少使用)和random (随机)策略;
        LRU:尝试利用内存引用流中的局限性,假定最近没有使用的项是好的换出候选项;
        random:随机换出一项;  (可以避免极端情况,例如循环遍历N+1个页,TLB中只能存放N个项,LRU在遍历第N+1个页会换掉第一个项,在下一轮遍历中就每次都触发TLB未命中)
    
    · 实际中的TLB表项   (简化的 MIPS TLB项,软件管理系统)
        MIPS R4000支持32位地址空间,页大小为4KB,所以在经典的虚拟地址中,预期会看到20位的虚拟页号和12位的偏移量 (1KB ==2^10)
        但是在实际情况中,用户地址只占地址空间的一半,剩下的一半留给内核,所以虚拟页号只有19位;虚拟页号会转化位24位的页帧号;
        因此可以支持64GB的物理内存  (2^24个4KB的页)

        MIPS TLB有一些特殊的标识位:
            1. 全局位 (G):用于指示这个项是不是被所有进程全局共享,如果为1则会忽略地址空间标识符;
            2. 地址空间符:用于标识属于哪一个进程;
            3. 一致位 (C):用于决定硬件如何缓存该页
            4. 脏位:表示该页是否被写入新数据
            5. 有效位:标识该项地址映射是否有效
            6. 页掩码:用来支持不同的页大小
        
        MIPS更新TLB的指令:
            1. TLBP:用于查找指定的转换映射是否在TLB中;
            2. TLBR:用于将TLB中的内容读取到指定寄存器中;
            3. TLBWI:用于替换指定的TLB项;
            4. TLBWR:用来随机替换一个TLB项;
    
    · 总结:
        1. 可以通过一个小的、芯片内的TLB作为地址转换的缓存,可以使大多数内存引用不用访问内存中的页表;
        2. 如果一个程序短时间访问大量的页,就会产生大量的TLB未命中,也被称为超出TLB覆盖范围,解决的一种方法是扩大页,让数据放有限的页里;


九、分页(三):较小的表
    · 分页的第二个问题:页表太大,因此消耗的内存太多;
        假设一个32位的地址空间,页的大小是4KB (12位),和一个页表项4B,一个进程大概有100万 (2^20)个虚拟页;
        所以一个地址空间的页表大小大约为4MB,系统中大概有100个活动进程,那么单为页表就需要几百兆的内存。
      如何让页表变小? 关键思路是什么? 新的数据结构会出现什么效率影响?
    
    · 简单的解决方法    (更大的页)
        同样以32位的地址空间为例,假设页的大小扩大到了16KB (14位),
        那么页表中的页表项就缩减到了2^18个,每个页表大小缩减到了1MB;
        但是这种方法的问题在于,大的内存页会导致每一页的内部浪费,也就是会有内部碎片问题,内存中会充满较大的页,但是每一只使用了较小的一部分;
    
    · 混合方法  (分页和分段)
        简单的分配会给整个进程的地址空间可能要使用的页表项分配空间,尽管它们没有被使用;
            (例 16KB的地址空间和1KB的页,在分配页表的时候,会一次性分配16*4==64B的内存,即使实际只是用了一点点空间)
        杂合方法不会给整个进程的地址空间提供单个页表,而是为每个逻辑段 (堆、栈、代码段)提供一个;
        
        在分段中,每个逻辑段都有一对 基址/界限寄存器来控制每个段在物理内存的位置;
        在杂合方法中,仍然保存着这些结构,基址寄存器用于保存该段的页表的物理地址,界限寄存器用于指示页表的结尾 (即有多少页);

        例子:
            32位的地址空间,页的大小位4KB (12位),在之前会有20位用于标识虚拟页号,
            现在会拿出两位用于标识使用的是那个段    (00表示未使用,01表示代码段,10表示堆段,11表示栈段);
            硬件中有3对基址/界限寄存器,每个进程运行时,每个段的基址寄存器都包含该段的线性页表的物理地址,因此内存中有3个页表;
            在上下文切换的时候,必须更改这些寄存器,以反映新进程的页表的位置;
            在TLB未命中的时候,先使用分段位确定要使用那一对基址/界限寄存器,然后找到页表项的地址,这样就能通过界限寄存器来控制分配页表的大小;

        问题:
            1. 仍然需要分段,它假定地址空间有一定的使用模式;例如有一个大而稀疏的堆,仍然可能会导致页表浪费;
            2. 外部碎片再次出现,尽管大部分内存是以分页来管理的,但是页表可以是任意大小的,因此内存中会为它们寻找自由空间;

        基于这些原因,需要找到更好的方法来实现更小的表

    · 多级页表
        · 如果不使用分段来控制页表大小,那么如何去掉页表中所有无效的区域?
          多级页表将线性页表变成了类似树的东西;
        
        · 多级页表将页表分成页大小的单元,然后如果整页的页表项无效,就完全不分配该页的页表,
          为了追踪页表的页书否有效,使用了名为页目录的新结构,页目录因此可以告诉你页表的页在哪,或者页表的整个页不包含有效页;

          即 将页表分成固定大小的的块,用一个页目录记录下页表块的位置,如果这块页表中所有页表项都无效,则在页目录中也记录为无效,否则标志好这块的位置

        · 多级页表最明显的优势是,多级页表分配的页表空间与正在使用的地址空间内存量成正比;
          和线性页表相比,整个页表必须连续的存储在物理内存中,但是多级页表,由于页目录记录了使用了的页表块的位置,所以可以稀疏分布;
        
        · 多级页表的劣势:
            1. 当TLB未命中时,需要从内存中加载两次 (从页目录中获取页表块位置,从页表块中获取页帧映射);
               因此多级页表是一个时空折中的方案 (如果你希望更快的访问特定的数据结构,就必须为该结构付出空间上的代价)
            2. 复杂性,无论是硬件控制还是软件控制,多级页表都要比线性页表更复杂;
        
        · 例子:
            假设有一个16KB的地址空间,页的大小为64B,虚拟地址大小为14位,虚拟页号8位,偏移量6位;
            其中0、1被用于了代码段,4、5被用于了堆、255、254被用于了栈;在线性页表中,虽然只有6个页被使用,但是仍然有256条页表项;
            假设一个页表项为4B,那么一个页表块中有 64/4 == 16条页表项,一共有 256/16 == 16个页表块;
            因为页表块有16个,所以可以将虚拟地址的前4位作为页表块标识,前四位相同的放到一个页表块中;
            从页目录中获取到了页表块的地址后,再将剩余的虚拟页号作为页表索引,查找具体的映射关系

            比如例子中的地址空间在页目录中会记录成16个页目录项,其中中间14个项都标记为无效;
            假设现在需要查询254 (1111 1110)页的映射关系,
                1. 首先根据前四位 1111 得知在最后一个页目录项;
                2. 查看页目录项,记录为有效,查看页目录项记录的页表块位置
                3. 根据剩余位 1110 得知是倒数第二条页表项
                4. 根据页表项中记录的映射关系得到页帧位置
        
        · 超过两级的多级链表
            假设现在虚拟地址是30位,页大小为512B (9位),那么将有21位虚拟页号,9位的偏移量;
            一个页表块中包含 512/4 == 128个页表项,如果只有一级页目录,页目录项就有 2^21/128 == 2^14项;
            引用多级页表的目的是解决页表过大的问题,但是现在单机页目录并不能解决这个问题,就需要上级的页目录来记录二级页目录;

            可以将2^14项的页目录页按照页大小来封装,512/4 == 128,一个一级页目录项中记录着128个二级页目录项组成的页目录块的位置;
            这样我们将21位虚拟页号的 
                前七位用于记录在一级页目录中的哪一项,查找页目录在哪
                中间七位用于记录在二级页目录的哪一项,查找页表块在哪
                剩余的七位用于记录在页表块中的哪一项,查找地址映射
            
        · TLB
            在多级页表中同样可以通过TLB来加速地址转换

        · 反向页表
            反向页表是最节省空间的方法,和一半的页表记录每个虚拟页映射到哪个页帧不同,反向页表记录的是所有页帧的使用进程以及使用的虚拟页号;
            查找正确的项,一般不会使用线性扫描,而是会在此基础上建立散列表,以加速查询;

        · 将页表交换到磁盘中
            有时候即使用了多种策略仍然不能减小页表大小,
            因此系统会将这样的页表放入内核虚拟内存中,
            从而在内存压力大的时候将这些页表交换到磁盘中
        
        · 总结:
            1. 真实的页表不是简单的线性页表,而是更复杂的数据结构;
            2. 表格越大,TLB未命中处理更快,但是内存越浪费;


十、超越物理内存(一):机制
    · 在这之前我们一直假设地址空间非常小,能放在物理内存中;
      但是如果需要运行巨大的地址空间应该这么做?
    
    · 为了支持更大的地址空间,OS需要把当前没有使用的地址空间找个地方存储起来;
      这个地方需要拥有比内存更大的容量,因此他也会更慢,这就是磁盘 (硬盘);
      因此,在存储层级结构中,大而慢的硬盘位于底层;
      问题变成了:  OS如何利用大而慢的设备,透明地提供巨大的虚拟地址空间假象?
    
    · 交换空间
        第一件要做的事就是在硬盘上开辟一部分空间用于物理页的移出和移入;
        在OS中,这样的空间被称为交换空间,因为将内存中的页交换到其中,并在需要的时候又交换回去;
        假设操作系统以页大小为单元读取或者写入交换空间;
        OS需要记住给定页的的硬盘地址;

        例: 4页的物理内存和8页的交换空间,多个进程共享有限的物理内存,其他当前当前不在运行的进程和没有被使用的的页被放在了交换空间;
    
    · 存在位
        先回想一下内存引用的流程:硬件从虚拟地址中获取虚拟页号,先检查虚拟页号是否在TLB中 (TLB命中),如果命中直接使用对应的物理页帧;
                                 否则解析虚拟页号,在内存中查找页目录,,根据页目录项中的记录找到对应页表块的位置;
                                 再在对应页表块中根据剩余的虚拟页号查找对应页表项位置,并将查询到的页表项加载到TLB中;
                                 重试刚刚的指令,这次直接在TLB中命中,执行。
        但是如果希望允许页交换到硬盘中,必须添加更多的机制;
        具体地说,当硬件在查找页表块的时候,可能会出现页不在内存中的情况,OS必须要能够判断出是否在内存中,只需要在页表项中添加个存在位就能实现这个功能;
        如果存在位为1,表示访问的页在内存中;否则表示不在内存中;
        访问不在内存中的页被称为页错误
    
    · 页错误
        前面提到了可能会出现也不在内存中的情况,但是现在需要访问这个页,就需要“页错误处理程序”;

        如果一个页不在内存中,那么它就是被交换到了硬盘中,处理页错误也就是将该页交换到内存中;
            (操作系统如何知道所需的页在哪?)
        因此OS可以用页表项的某些位来存储硬盘地址;当OS受到页错误信息时,会在页表中查找硬盘地址,将页交换到内存中;
        当硬盘I/O完成后,OS会更新页表,将该页标记为存在,更新页表项的地址映射为最新的内存地址,并重试指令;

        注意:当硬盘I/O的时候,进程会处于阻塞状态;因此处理页错误的时候,OS会自由地运行其他可执行的进程;

    · 如果内存满了怎么办?
        如果内存中没有足够的空间来存放放入的页,OS会希望先交换出内存中的一个或多个页,以便于操作系统为新放入的页预留空间;
            选择哪些页交换出去 (交换策略)成为了新的问题;
    
    · 内存访问的的流程
        TLB未命中的几个情况:
            1. 页存在 (在内存中)且有效 (这个页被使用);
                正常的从页目录 --> 页表 --> 页表项 --> 更新TLB --> 重试指令
            2. 页不存在 但有效;
                页目录 --> 页表 --> 页表项 --> 硬盘加载到内存 --> 更新页表 --> 重试指令
            3. 页存在 但无效;
                表示是非法访问这个页,发出panic
        
        页错误处理流程
            1. 尝试为要换入内存的页找到一个页帧;
            2. 如果没找到,使用交换算法踢出一些页,释放帧供新页的使用;
            3. 找到合适的页帧,发出I/O请求从交换空间读取页;
            4. 更新页表并重试指令,这次重试会TLB未命中;
            5. 从页目录中获取到页表的位置,更新TLB,重试指令
            6. TLB命中,直接转换地址
    
    · 交换发生的时间
        之前讨论的情况中都是得到内存满了之后,新页无法放进内存才开始交换;
        但是,这样的策略很明显是不切实际的,因为会导致在一段时间之后会持续的面对需要交换的情况,负载会变得非常不平衡;
        
        因此OS会利用 高水位线 (HW,上限)和低水位线 (LW,下限)来决定何时从内存去除页,以保证预留少量的空闲空间;

        当OS发现空闲页少于LW时,会开始释放内存;
        直到释放到有HW空闲页,就会停止释放内存;
        这个后台线程被称为 交换守护线程或者页守护进程;

        通过同时执行多个交换过程,还可以进行一些性能优化;
        例如:把多个要写入的页 聚集或者分组,同时写入交换空间,从而提高硬盘的效率;

        为了配合后台的分页线程,交换算法需要先检查是否有空闲页,而不是直接执行替换;
        如果没有空闲页,会通知后台分页线程按需要释放页;
        当释放一定数量后,重新唤醒之前的线程,这样就能将页写入内存了
    
    · 总结:
        1. 可以在页表结构中添加一些额外信息用于表示目标页是否在内存中;
        2. 如果访问的页不在内存中,OS会处理页错误,将需要的页读取到内存中,同时还可能需要从内存中换出一些页;
        3. 这些行为对应进程来说都是透明的,对应进程来说,他只是访问了自己私有的、连续的虚拟内存;
        4. 在后台,物理页被放置在了任意 (可能非连续)的位置,甚至可能不在内存中
        5. 通过在硬盘的交换空间可以让较大的地址空间放得以运行,但是同样带来的是访问较慢的硬盘造成的时间损失问题


十一、超越物理内存(二):策略
    · 在虚拟内存管理中,如果有大量的空闲内存,操作就会变得很容易,页错误时,直接将空闲页分配给不在内存的页;
      但是内存不够的情况时常见的,这时候就需要换出一些页才能放入需要的页;
        因此面临的问题是,应该换出哪些页? 
        (在TLB缓存中也面临过这个问题,当时简单的说了一下可以采用 LRU最近最少使用算法 和 random方法)
    
    · 缓存管理
        内存中只包含系统中所有页的子集,因此完全可以把内存视为 系统中所有页的缓存 (cache);
        因此为这个缓存选择替换策略的时候,目标就是尽量减少缓存未命中的情况,
        即 尽量让内存中的页都是需要使用的,从磁盘获取页的次数尽量少;

        同时可以得出硬件缓存指标: 平均内存访问时间(AMAT) == P命中*T内存 + P未命中*T磁盘
        假设命中率为90%,T内存 == 100ns,T磁盘 == 10ms,则 AMAT 约为 1ms;
        单纯将命中率提高到99.9%,AMAT 变成了10.1us,大约快了100倍
        因此 提高命中率可以说是最佳提升缓存性能的方法

    · 设计策略的思路
        虽然最优策略非常不切实际,但是在设计新策略的时候还是非常有用的;
        比如说,新的策略命中率为80%,单纯的80%并不能代表什么,但是如果最优策略的命中率为82%,那么新策略可能就非常有意义了;
        因此在设计新策略的时候,直到最优策略可以方便进行对比,直到策略还有多少改进空间,以及减少无谓的优化。
    
    · 替换策略的最优策略
        最优策略:替换内存中在最远将来才会访问到的页,即 踢出最远才会访问的页;
                  因为在访问最远才会访问的页之前,其他页都会被访问
        
        例:假设按照0、1、2、0、1、3、0、3、1、2、1的顺序访问页,内存只能放入3个页;
                程序启动,因为内存中为空,所以前三个都 MISS*3;
                接下来的0、1在内存中 HIT*2;
                接下来的3不在内存中,MISS,同时需要踢出一个页;往后面看,访问的顺序是0、1、2,2是最后访问的,踢出2;
                接下来的0、3、1都在内存中 HIT*3;
                接下来的2不在内存中,MISS,后续的执行顺序是 1、null;踢出0或3
                接下来的1在内存中 HIT;
            命中率为 (2+3+1)/11
        但是很可惜,实际情况中,很难知道未来的访问,因此无法通过OS实现最优策略;
        因此最优策略只能用作比较,用于知道新策略有多接近“完美”
    
    · 简单策略:FIFO
        FIFO的实现方法是 当页使用的时候放入一个队列中,当需要踢出一个页时,踢出最先入队的页;
        按照上面的例子,执行过程为
                程序启动,因为内存中为空,所以前三个都 MISS*3;
                接下来的0、1在内存中 HIT*2;
                接下来的3不在内存中,MISS,同时需要踢出一个页;踢出队首0,队列 == [1,2,3]
                接下来的0不在内存中,MISS,同时需要踢出一个页;踢出队首1,队列 == [2,3,0]
                接下来的3在内存中HIT;
                接下来的1不在内存中,MISS,同时需要踢出一个页;踢出队首2,队列 == [3,0,1]
                接下来的2不在内存中,MISS,同时需要踢出一个页;踢出队首3,队列 == [0,1,2]
                接下来的1在内存中,HIT
            命中率为 (2+1+1)/11
        FIFO的问题在于 无法区分页的重要性 (即使页0被多次访问,它仍然会被踢出,因为他是第一个进入队列的)

    · 另一个简单策略:random随机
        random的实现方法是 在内存满了之后随机挑选一个页进行替换;
        某种情况下,random可能会在每次踢出时都能踢出正确的页,但是它也有可能会导致最糟糕的情况发生。
        随机策略取决于当时的运气
    
    · 利用历史数据:LRU
        之前提到的两种简单策略都有一个共同的问题:它可能会踢出一个重要的页;因此需要设计一个可以判断重要性替换策略;
        就像之前 多级反馈队列利用历史运行情况 (一个进程运行固定时间就降级)来决定调度 (认为I/O少的进程响应优先级低),
        为了提高后续的命中率,同样可以使用历史信息来作为参考;
        
        替换策略可以使用的一个历史信息是频率:如果一个页被访问了很多次,就可以认为它很重要;
        另一个属性是近期性:如果一个页最近被访问,则认为它可能会在不久后再次被访问。
        这一系列的策略是基于局部性原则;
        LRU:最少最近使用   (替换掉最近频率最低的)
        LFU:最不经常使用   (替换掉频率最低的)

    · 局部性原则
        空间局部性:当一个数据被访问,他周边地址空间的数据也很有可能被访问;
        时间局部性:当一个数据被访问,他很有可能会在不久之后再次被访问;
        硬件通过很多指令来维持这个局部性,以便于存在此类局部性时,能帮助程序的运行

        LRU策略对比其他方法的优势正式基于这种局部性的存在,如果这种局部性不存在,LRU的效果和其他方法的区别差不多
    
    · 实现基于历史信息的算法
        可以发现类似于LRU的算法通常情况下优于其他替换策略,所以问题变成了:如何实现?
        以LRU为例,需要在每次页访问的时候,都要更新时间字段;当需要替换页的时候,OS会扫描内存中所有页的时间字段找到最近最少使用的页;

        但是面临的情况是,内存变得越来越大,内存中的页也变多,每次都要扫描导致的时间损耗变得很大;
        所以可以放宽LRU的限制,只找到一个近似最旧的页替换,而不需要找到最旧的页替换     (近似LRU)
    
    · 近似LRU
        从计算机开销来看,近似LRU更为可行;
        在页中用一位来记录最近是否被使用,1表示被使用;
        系统中所有的页都在一个循环列表上,开始停留在一个页上,当页需要替换的时候,遍历检查直到某个页的使用位被记为0;
        如果经过一轮还没找到,就将所有的页记为0;   也就是说,最差的情况也只会遍历一次内存;

        通过这个基本逻辑,还可以采用随机扫描的方法来找到最近未使用的页。
    
    · 考虑脏页
        脏页的意思是,这个页被写入过新数据;
        如果该页为脏页,那么踢出这个页就必须重新写回磁盘,代价会变得比较高;
        因此,应该更倾向于踢出干净页 而不是浪费I/O选择踢出脏页
    
    · 其他虚拟内存策略
        页面替换不是虚拟内存系统采用的唯一策略,例如:OS需要决定何时将页载入内存 (又称为 页选择策略)
        大部分情况下,页被访问时加载到内存中,同时OS会猜测某个页可能会使用,从而提前加载,这种行为被称为预取;

        另一个策略决定了OS如何将页面写入到磁盘:
        除了简单的一次写入一个页,更高效的方法是,将内存中待写入的页收集起来一起写入;
        这种行为被称为 聚集写入或分组写入;因为硬盘驱动器的性质,执行单次大的写操作,比许多次小的写操作更有效;
    
    · 抖动
        当内存就是被超额请求时,OS会不断进行换页,这就是抖动;
        早期的OS有一组复杂的机制以便于在都用发生时检测并应对;  例如:给定一组进程,系统可以决定不运行部分进程  (少做工作有时比尝试一下子做好所有事情更好)
        现代系统采用更严格的方法;  例如:当内存超额请求时,系统会杀死一个内存密集型进程
    

十二、VAX/VMS虚拟内存系统
    · 内存管理硬件
        VAX-11为每个进程提供了一个32位的虚拟地址空间,页的大小为512B (9位),因此虚拟地址由23位虚拟页号和9位偏移量组成;
        VPN的高两位由于区分页所在的段,所以VAX-11使用的是 分段+分页的混合体;

        地址空间的下半部分称为进程部分,对于每个进程都是唯一的;
        进程空间的前半部分有 进程的指令和向下增长的堆;后半部分有 向上增长的栈;
        地址空间的前半部分是系统空间,受保护的操作系统代码和数据驻留在这,操作系统以这种方式保证每个进程都能共享操作系统;

        因为VAX的页大小很小,只有512B,为了防止页表大小过大,设计了两种方法来解决:
            1. 将地址空间分成了两份,为每个进程的每个区域 (前半部分和后半部分)提供了一个页表,用基址/界限寄存器控制页表的大小   (分块)
            2. 将页表放入内核虚拟内存中,允许内核将页交换到硬盘中
        
        将页表放入内核虚拟内存意味着地址转换更加复杂;例如:硬件必须查询系统页表,找到进程页表,进而找到页表项;
        同时,引入了TLB让所有的工作更快
    
    · 页替换
        VAX的页表项包括:有效位、保护字段 (4位)、脏位、为OS保留的字段 (5位)、物理帧号码
        因此VMS的替换算法没有得到硬件引用位的支持,无法确定哪些页是活跃的   (可以利用保护字段达到同样的目的,比如将其设置为不可访问表示不活跃)

        分段的FIFO:
            每个进程都有一个可以保存在内存中的最大页数 (RSS),当一个进程超过其RSS时,先入的页会被踢出;
            同时VMS对FIFO做出了改良,引入了两个二次机会列表;即 如果引入了一个全局干净页列表和全局脏列表;
            踢出的页时,判断该页是否是脏页,并放入对于的二次机会列表,并不是真正的踢出内存;
            如果另一个进程需要空闲页,会从干净列表中获取一个提出内存;
            如果本进程在回收这页之前发生了对这一页的页错误,会从脏列表中回收,以减少磁盘访问
        
        页聚集:
            VMS采用的是小页面来管理内存,但是这样的小页面对于硬盘I/O来说效率非常低;
            为了让交换I/O更有效率,VMS将大批量的页从全局脏列表中分组到一起,并将它们一起写入磁盘;

    · 其他虚拟内存技巧
        1. 按需置零:当在地址空间中添加一个页,OS首先会在物理内存中找到一个页,并将它置零 (清空),然后再将其映射到地址空间;
                     利用按需置零可以当访问该页的时候再执行置零的工作,减少了分配后从不使用所带来的性能开销;
        2. 写时复制:当OS需要将一个页从一个地址空间复制到另一个地址空间,实际上不会直接复制它,而是会在两个地址空间之间添加一个映射关系;
                     并将两个地址空间标记为只读,如果后续只读取页面,则不会进一步操作;
                     一旦有一个地址空间尝试写入页面,操作系统就会分配一个新的页填充数据,并将新页映射该地址空间;
                     写诗复制避免了大量的不必要的复制;
    

· 总结:   
    1. 基址/界限寄存器来控制地址空间在物理内存的位置和大小;
    2. 分段的思想是将地址空间分为不同的逻辑段,每段都拥有自己的基址/界限寄存器,但是分段会导致内存空间被多次分割合并,导致空间碎片化;
    3. 分页就是将内存空间分成等大的区域,将数据以页为单位管理;
    1. TLB是一个非常关键的部分,为系统提供了一个快速地址转换的硬件缓存,是分页实现的根本支持;
    2. 页表可以用任何数据结构来表示,从简单的线性页表到多级页表 (页目录),使用越复杂的数据结构的目的是为了减少页表的大小
    3. 地址转化结构需要足够的灵活,以支持程序员想要处理的地址空间;
       多级表可以很好的解决这个问题,它在用户需要一部分空间的时候才创建表空间 
       (将页表项用页为单位合并起来,再利用页目录指向页表块的位置,没有使用的页表块不会被分配并在页目录中标记为无效)
    4. 利用硬盘的交换空间可以运行超出内存大小的地址空间,保证正在使用的页在内存上,利用交换策略将合适的页换回到硬盘;

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值