文章目录
操作系统
一些常见名词解释:
名词 | 概念 |
---|---|
线程 (threads) | 保留了并发的优点,避免了进程切换代价,因为不需要切换内存映射表 |
进程 (process) | 运行中的程序,与静态程序不同 |
pcb程序块 (process control block) | 用于保存process中的一些现场信息 |
tcb程序块 (threads control block) | 用于保存threads中的相关信息 |
内核态 (kernel mode) | 在操作系统中,给不同的程序分配了不同的权限,内核态可以理解为当前进程可以访问计算机的绝大部分资源 |
用户态 (user mode) | 相比于内核态,用户态才是我们经常接触的,除非从用户态切换成内核态,否则访问一些资源是受限的,比如调用系统接口. |
调度 (schedule) | 为了提高cpu的吞吐量和系统利用率,可能将会使用到的一些进程/线程频繁切换那么如何分配资源的优先级,就成为了我们的问题,如何实现调度算法 |
操作系统概述
什么是操作系统
操作系统是介于计算机软件与计算机硬件之间的桥梁.因为我们通过软件直接控制硬件难度很大,所以出现了操作系统用来管理分配计算机硬件和软件资源.
操作系统封装了一些软硬件的复杂性,我们只需要了解一些对外接口就可以完成对计算机的操作,不需要自己去了解具体的细节
操作系统分为两部分,分为用户态和内核态.如果一个用户态进程可以随意的访问系统管理员的账号信息是十分危险的,所以我们要将系统分成用户态和核心态,这样分层后一个用户态要想访问系统资源,就需要切换为内核态,在liunx中一般通过中断系统调用进入,此时可以访问系统资源.
什么是系统调用
当一个用户态程序想要调用系统级别的子功能就需要通过系统调用,按照功能可分为五类
系统调用 | 具体功能 |
---|---|
进程管理 | 完成进程的创建,撤销,阻塞,唤醒等基本功能 |
进程通信 | 完成进程之间合作,消息传递和信号传递功能 |
内存管理 | 完成内存的分配,回收以及获取作业占用内存大小等功能 |
文件管理 | 完成文件的读、写、删除等功能 |
设备管理 | 管理I/O设备的输入和输出等。 |
进程与线程
我们计算机专业的同学相信写过很多的代码,当一个程序没有在cpu内执行时,它与一个正在运行中的程序是不同的概念,此时我们将运行中的程序称为进程.
.每一个进程会有自己的资源及要保存的信息 即process control block
多个进程依次在计算机上运行,假如当前运行的程序阻塞了,比如在访问I/O操作,那么其他的进程只能等待当前进程执行完毕,这样的结构远不是我们所期望的,那么我们就要解决如何让CPU的系统效率达到最大,提高系统的吞吐量.就引入了多进程图像,多进程的组织:PCB+状态+队列.
运行态->阻塞态:等待IO事件完成. 运行态->就绪态:当前分配的时间片用完
就绪态->运行:获得了CPU的时间片 阻塞态->就绪态:当前IO事件完成
进程的切换
当一个进程进入了阻塞态,我们就要想办法去切换当前进程,保持对CPU的利用率.同时将这些不同状态的PCB有效的管理起来,创建就绪队列,阻塞队列,执行指针等等.此时设计几个不同的指针直接指向,或者可以创建不同的索引表,比如阻塞队列指针->阻塞表索引表->阻塞队列都是有效的管理方式.
我们现在已经知道了为什么要切换,但是如何交替就成为我们要解决的.
我们可以利用队列数据结构存储信息+调度函数+切换函数来实现.
//启动磁盘读写:pCur.state="W";//pCur放到阻塞队列EntrtQueue(DiskWaitQueue,pCur);//调用调度函数:schedule();//一个schedule函数的框架,后面会展开描述,操作系统的核心就是不断地切换程序来达到CPU的高利用率,切换的重点就是如何切换,做哪些工作同时,如何保持一个我们满意的调度.schedule(){ //从就绪队列中获取一个进程pNew = getNext(ReadyQueue); //进行进程的切换,注意pCur和pNew两个都是进程的PCBSwitch_to(pCur,pNew);}//切换进程:switch_to(pCur,pNew){ //保存现场信息 pCur.ax=CPU.ax; pCur.bx=CPU.bx; pCur.cx=CPU.cx; ...... pCur.cs=CPU.cs; pCur.retip=CPU.ip; //CPU的切换 CPU.ax=pNew.ax; CPU.bx=pNew.bx; CPU.cx=pNew.cx; ...... CPU.ax=pNew.cs; CPU.ax=pNew.ip; CPU.retip=pNew.ip;}
内存管理的引入
如果进程B要访问内存地址100的数据,但是在切换到B进程之前,A进程刚刚新建了一个变量在100地址,就造成了脏数据.我们后面会引入内存映射的概念,用来保证每个进程之间资源的独立.
一个程序的运行
一个程序是由ip偏移指针配合cs段寄存器来确定的具体的物理地址,那么两个进程的切换不仅需要切换不同的ip指针,同时也要切换不同的TCB,来确保资源与当前进程一致.进程之间的切换需要花费较多的时间和资源,假如两个进程之间访问的资源地址是相同的,我们就可以减少进程切换之间的资源浪费(需要切换地址映射表),我们将访问相同资源地址的两个进程称为线程.线程之间会共享数据,创建线程的开销也比进程要小很多.
用户态线程切换
我们来举一个常见的例子,一个浏览器进程需要多个线程,一个线程要从服务器接受数据,一个线程要显示文本,当要访问的资源较大,等到资源下载完毕在给客户端显示,显然带来的体验是不好的,需要及时给用户反馈一部分下载的内容时,如何将当前线程切换到其他子线程,保持系统的高利用率.
首先我们要知道如何切换两个线程,只改变Pc指针的内容就可以解决问题了吗.答案是不够的.因为我们要处理的问题可能会多层嵌套,先来看下面的Demo,有两个线程,后面简称左边的线程为线程A,同理右边的线程为线程B.
//左Yield(){ 找到要切换的300地址; jmp 300;}//右Yield(){ 找到要切换的204地址; jmp 204;}
Yield函数为切换用户态线程的核心函数,当函数A执行到Yield函数前,首先会将之前的函数返回地址压入sp指向的堆栈段寄存器中,即104地址入栈,执行B函数之后在执行Yield函数切换线程至C函数,将B函数的地址入栈,进入C函数在调用函数D,同时将304地址压入栈中,在D函数执行中在次切换线程回到B函数中,执行B函数的内容.此时当B函数运行结束,我们期待的结果应该是返回函数A,实际上并没有得到预计的结果.问题在于当D()第二个调用Yield()时,右侧Yield找到204,回到第一次切换的位置,继续执行,然后遇到B的},}在汇编中就是弹栈的意思,此时404被弹出,PC指向404因此出现错误.
核心原因是两个线程之间的栈共同使用,导致找不到我们预期返回的地址,所以引入了TCB的概念,和PCB一样,将每个线程的基本信息保存起来.这样就不会乱套了.但是此时Yield还只是不断地在用户态程序之间切换.
//改写后右边的yield举例,此时用了两套TCBvoid Yield(){ TCB2.esp=esp; esp=TCB1.esp; jmp 204;}
现在依然存在一个问题,每次都直接jmp跳转到了新的地址,yield函数后面的}一直没有弹栈,所以可以去掉jmp,因为其他函数调用时也会自动入栈保存.
//再次改进后的yieldvoid Yield(){ TCB2.esp=esp; esp=TCB1.esp;}
创建一个线程
- 创建线程ThreadCreate就是做出切换的样子,三样东西,分别是TCB,栈和切换的PC在栈中;
- 创建线程ThreadCreate流程:
void CreateThread(){//首先申请一个TCB TCB *tcb=malloc();//再申请一个栈stack *stack=malloc();//再在栈中填入PC内容(100) *stack=A;//tcb.esp=stack将TCB与栈关联 tcb.esp=stack;}
内核级线程切换
一个程序中有多个线程,当其中一个线程A进入了阻塞态,那么其余的线程B线程C依然不能访问资源,此时我们就要解决要面临的问题.之前的用户级线程切换缺点在于如果要访问硬件IO,就必须通过操作系统,因为操作系统就是管理硬件的,操作系统在切换进程时,就看不到之前的线程.
进程的切换分为两个部分,线程的切换和切换资源(映射表),进程必须在内核中,没有用户级进程这一种说法,所以是内核级线程.
内核级线程,首先就意味着要进入内核,在内核中也有内核栈,用户栈也有用户栈,每个核心级线程要拥有两套栈(用户栈+内核栈),用户级线程是通过切换PCB来切换用户栈,核心级线程是通过切换TCB来切换一套栈(用户栈+内核栈).用户态程序进入内核的唯一方式就是中断,执行中断后,会自动到内核栈,由硬件自动完成.
内核级线程是如何实现的
我们来看一个例子:
假设有两个内核级线程S和T。线程S的任务是启动磁盘读取数据,线程T随便。他俩的代码如下所示
//线程S 100:A(){ ... int ox80; ... 2000:sys_read(){ } } //线程T 500:c(){ ... interrupt: call sys_xxx; 4000: sys_xxx(){ } }
通过中断进入内核。首先执行线程S,通过int 0x80
中断进入内核,内核栈的SS
,SP
指针建立起了内核栈和用户栈之间的联系;同时可以看到,PC
指针指向的是中断int 0x80
的下一个语句,CS
代码段寄存器指向线程S代码的起始位置。
通过int0x80
这个中断号进入内核态后,执行系统调用sys_read
。由于读取数据需要等待,不能一直占用着CPU,这时候系统会将线程S设置为阻塞态,切换到下一个线程T去继续执行
sys_read() { 启动磁盘读; 将自己变成阻塞状态,让出CPU,让CPU执行其他线程; 找到next(寻找到下一个可执行的线程); 调用switch_to(cur,next); }
switch_to()函数负责切换线程,形参cur
表示当前线程S的TCB(Thread Control Block)
,next
表示下一个执行线程T的TCB
switch_to()这个函数首先将目前esp(Extended Stack Pointer)
寄存器的值存入cur.TCB.esp
,将next.TCB.esp
放入esp
寄存器里面。其实就是从当前线程的内核栈切换到next线程的内核栈。
cur.TCB.esp = esp;esp = next.TCB.esp;
这里要明白一件事,内核级线程的代码还是在用户态的,只是进入内核态完成系统调用,也就是逛一圈之后还是要回去执行的.所以整体可以分成五个步骤:
- 中断进入内核;
- 在内核态中,由于启动磁盘或者时钟中断,引发线程切换;
- 通过
TCB
对内核栈进行切换; - 使用
IRET
退出中断,对用户栈进行切换。至此,内核栈+用户栈都完成了切换; - 如果两个相互切换的线程不是同一个进程,还需要对内存映射表进行切换
什么是调度
我们为了高效利用CPU,当一个进程阻塞住了,我们如果等待这个进程运行完毕后,依次执行,显然是对我们CPU资源的一种浪费,为了避免这种情况,我们引入了多进程图像,以及每一个进程应该有在运行时有不同的状态,当一个进程要进行I/O操作,进入阻塞状态时,CPU应当从就绪队列中取出一个进程来执行,来达到CPU的高利用率,当CPU被充分利用了,就会带动内存,外设一起工作,使整个操作系统有条不紊的工作,形成一个高效的系统.
那么如何从就绪队列中取出一个进程,现在就变成了我们的问题:
- 先进先出,比如我们去食堂吃饭,每个人要去窗口依次买饭都是队首的成员优先得到资源
- 当我们只需要去前台签个字,前面排队的成员每个都需要很长的处理时间,是不是短作业的优先级可以高一些?
- 假如一个进程是需要及时给用户反馈的,而其他的进程又不着急,就又出现了优先级的想法…
常用调度算法
FIFO算法
先到先服务(FCFS)调度算法 : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
SJF短时间作业优先算法
短作业优先(SJF)的调度算法 : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度
RR轮询算法
时间片轮转调度算法: 时间片轮转调度是一种公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
priority算法
优先级调度: 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
比如前台任务更关心响应时间,采用RR轮转调度,减少响应时间;后台任务更关心周转时间,采用SJF短作业优先,前台后台采用不同的调度算法;通过设置优先级,前台任务优先于后台任务.
但是后台任务可能一直在等待,因为前台任务优先于后台任务同时前台任务不断地处理,所以我们应该动态的分配优先级
Linux如何分配的
void Schedule(void){ while(1){ c=-1; next=0; i=NR_Tasks; p=&task[NR_Tasks]; while(--i){ if((*p)->state==Task_Running && (*p)->counter>c){ c=(*p)->counter,next=i; } } if(c) break; for(p=&Last_Task;p > &First_Task;--p){ (*p)->counter=((*p)->counter>>1)+(*p)->priority; } switch_to(next); }}
-
TCB的实现是数组,这里是从最后开始依次向前检索,判断TADK_RUNNING表示就绪态,如果是就绪,并且counter>c,将c赋值为counter;其实这个过程就是寻找最大优先级的过程!!
-
每次调度给最大counter的那个进程,优先级算法;同时,counter是一个时间片,就综合了时间片和优先级算法;
-
如果就绪态的counter(时间片)都用完了counter==0,就将所有进程的counter右移一位(除以2)再加上counter的初值;对于就绪态就是counter初值,对于刚刚阻塞态(执行IO)进程,就是自己的counter/2+counter初值一定会大于刚刚的就绪态,会优先执行刚刚是阻塞态的进程.同时可以通过计算得到这是一个收敛数列,哪怕进程一直阻塞,优先级最大也就是2p,降低了溢出的可能性
-
保证了每个进程都会执行,不会一直等待,同时阻塞的进程也会动态的提升优先级,配合RR算法,保证了每个进程之间也是公平的
进程同步与信号量
信号与信号量
进程之间合作要合理有序的推进,而合理有序的推进就需要进程同步,进程同步就需要依靠信号量来实现.
进程之间关于同步的问题,我们先来了解一下生产者,消费者模型
什么是生产者/消费者?
生产者在进程之间合作过程中,为共享缓冲区不断生产数据的进程,而消费者则完全相反,消费者要不断地从共享缓存区中取数据去执行操作,多个进程之间是不能随意运行的,需要及时发送信号来唤醒其他的进程,核心是进程之间为了实现同步不断地走走停停.
//生产者进程 while(true){ //当缓存区资源个数满了就睡眠 while(counter==BUFFER_SIZE); buffer[in]=item; in=(in+1)%BUFFER_SIZE; counter++; }//消费者进程 while(true){ //当缓存区没有资源就睡眠 while(counter==0); item=buffer[out]; out=(out+1)%BUFFER_SIZE; counter--; }
目前还是通过缓存区中的数据个数来绝对生产者是不是要生产数据,消费者要不要消费数据.
但是我们目前信号所携带的数据信息太少了,我们知道了当一定条件下为了进程合作,一个要等另外一个进程执行完毕我们需要的数据内容,而且可以在一定条件下唤醒需要的其他的合作进程.
//生产者进程 while(true){ //当缓存区资源个数满了就睡眠 while(counter==BUFFER_SIZE) sleep(); ... counter=counter+1; //有新的资源就要唤醒消费者 if(counter==1){ wakeup(消费者); }//消费者进程 while(true){ //当缓存区没有资源就睡眠 while(counter==0) sleep(); ... counter=counter-1; //当一个资源被消费就要唤醒生产者 if(counter==BUFFER_SIZE-1){ wakeup(生产者); } }
我们假设这样一种情况:
-
当缓存区满了之后,一个生产者进程P1会生产一个item放入,会sleep
-
因为系统是多线程,无法确定其他线程不能进入继续生产,所以可以又出现一个生产者进程P2生产一个item后,也在睡眠.
-
消费者执行一次循环,counter==buffersize-1;
-
唤醒了一个生产者,消费者继续进入循环,但是此时buffersize-2是不能进入唤醒其他的生产者进程,所以P2会一直进入睡眠.造成了资源的浪费
-
所以我们还需要知道进程中休眠的个数,引入了信号量的概念.
信号量:1956年,由荷兰学者Dijkstra提出的一种特殊整形变量,量用来记录,信号用来sleep和wakeup操作
当我们用信号量来携带更多的信息,当一个信号量小于0时,说明此时就有一个生产者进程在休眠.如果大于0说明有n个空闲缓存区需要生产.
假如资源量总数是8,一个信号量携带的是2,说明了什么问题呢?
说明有两个闲置资源需要等待生产者生产,同时消费者可以消费的资源还有6个
同样的情况,我们经过处理变成了这样:
-
缓存区满,P1执行,P1sleep 此时sem=-1
-
P2执行,发现已经有一个生产者睡眠了,P2也Sleep.此时sem=-2
-
消费者C1执行一次循环,wakeup一个P1.此时sem=-1
-
消费者C1继续在循环内执行,wakeup另外一个P2.此时sem=0
-
消费者C1在执行一次,此时sem=1,说明没有进程在休眠的同时,还有一个空闲资源需要生产
-
生产者P3开始执行…
信号量的实现
struct semaphore{ int value;//记录资源个数 PCB *queue;//记录等待在该信号量上的进程}//消费资源,P来源于荷兰语的proberen,即testvoid P(semaphore s){ s.value--; //先减一操作,操作后小于0说明原来是小于等于0,所以休眠 if(s.value<0){ sleep(s.queue); }} //生产资源,V来源于荷兰语的verhogevoid V(semaphore s){ s.value++; //先加一操作,操作后小于等于0,说明原来有进程在休眠,唤醒一个休眠的进程 if(s.value<=0){ wakeup(s.queue); }}
信号量来解决生产者-消费者问题
//定义一段缓存区int fd=open("buffer.txt");write(fd,0,sizeof(int)); //写in操作write(fd,0,sizeof(int)); //写out操作//定义几个关键信号量semaphore full=0;semaphore empty=BUFFER_SIZE;semaphore mutex=1;//生产者模型Producer(item){ P(empty); P(mutex); 读入in,将item写入到in的位置; V(mutex); V(full);}//消费者模型Consumer(item){ P(full); P(mutex); 读入out,将item读入到item的位置; V(mutex); V(empty);}
empty是空闲缓存区个数,full是有多少资源
生产者里面先看是不是缓存区满了,也就是信号量为0,停.
mutex要互斥,一次只能进入一个
消费者也是生产待生产者的个数的生产者
生产者也是消费待生产者的个数的消费者
信号量临界区保护
empty要保持正确,比如刚开始给初始值-1,生产者就不工作了
或者发生这样的情况
//假设一个生产者模型,empty仍然为上文所代表的信号量Producer(){ register=empty; register=register-1; empty=register;}
因为程序的调度是我们无法确定的,可能运行过程中因为时间片过期切换到其他进程,对于我们运行到一半的程序来说是不安全的,也不能做到原子性.当共享数据不收保护就可能发生问题
//可能出现的场景 P1.register=empty; P1.register=P1.register-1; P2.register=empty; P2.register=P2.register-1; empty=P1.register; empty=P2.register;
互斥锁
因为出现的问题是在两个进程共同操作同一个信号量的时候出现的问题,我们直观想法就是能不能在操作信号量的时候加一个锁,有人在使用时,其他的进程就不能访问.
有了思路后,我们将这段空间定义为临界区,临界区是一次只允许一个进程进入的该进程的那一段代码.
所以读写信号量代码一定是临界区.
临界区的基本原则就是要互斥进入:如果一个进程在临界区执行,则其他进程不允许进入.
引入了临界区这个概念后,我们要思考什么是一个好的临界区
- 当若干进程要求进入空闲临界区应当尽快使一个进程进入
- 有限等待,每个进程从发出进入请求到允许进入不能无限等待.
轮换法
为了保证两个进程互斥,我们可以尝试两个进程中设计一个变量,在退出区将变量置零或者置一,进入区中可以判断该进程是否应该执行,但是只能适合两个线程,满足了互斥,满足了空闲进入,也保证了不会一直等待
标记法
有了轮转法的思路,我们可以尝试做一些标记来表示一些状态
出现了标记法的思路,但是似乎依然存在着问题,比如当第一个进程把状态标记,然后被调度切换到进程B,进程B也标记了,此时CPU就可以开开心心的摸鱼了,因为两个进程都在自旋,谁也无法进入临界区,于是我们可以将轮转法和标记法整合起来…
Peterson算法
Peterson算法结合了轮转和标记的思路
面包店算法
前面我们只解决了只有少数进程之间的互斥锁,在一个操作系统中,我们一般拥有很多个不同的进程需要执行,而之前的想法只能实现少数进程之间的互斥,面包店算法就很好的解决了这个关键问题:
面包店算法:每个想要进入临界区的进程都获得一个序号,序号最小的先得到服务,离开时将序号置成0,不为0的序号即标记
整个算法流程为:
- 申请选号时加锁信号(给其他进程的信号)
- 选号中从序号数组获取最大的值并+1
- 关闭锁信号
- 服务前while判断有没有人正在选号,当有进程在选号时就sheep,如果都没有开始服务
- 从任务序列中找到最小的的序号服务
- 执行服务
- 退出时序号置位0
面包店算法满足了互斥进入,有空让进,有限等待等性质,是一个优秀的临界区思想
面包店算法的伪代码实现:
void bread(){ choosing[i]=true; num[i]=max(num[0],...,num[n-1])+1; choosing[i]=false; for(j=0;j<n;j++){ while(choose[j]); while(num[j]!=0 && (num[j],j)<(num[i],i)); } 临界区代码... num[i]=0;}
软硬件相结合的临界区实现
我们利用程序来实现的临界区有些麻烦,但是我们要思考一个问题,进程切换回切换调度,调度切换要改变时会引发中断,说到这里可能一些同学就已经想到了,就是STI,CLI,开中断关断指令.当执行关中断时,其他的应用程序请求引发中断,但是我们已经把中断给关掉了,所以只能等当前程序结束后开中断在继续响应其他的中断服务.
但是依然有缺点,比如多核心cpu就不好使了,因为每次关中断时,只能把当前的CPU中断禁用,但是其他的CPU也有可能调度到相关的程序代码,如果为了一个进程牺牲掉所有的CPU,代价是很昂贵的,所以引出最后的一个实现,即原子指令
硬件原子指令,即一次性执行完毕,没有其他的状态,执行或者没执行
//原子指令Boolean TestAndSet(boolean &x){ boolean rv=x; x=true; return rv;}//原子指令实现伪代码:while(TestAndSet (&lock));临界区...lock=false;
临界区方面我们已经简单的叙述过一些思路了,我们最后要思考一个问题,为什么信号量不能当做临界区保护代码?
答案是如果将信号量作为临界区的进入代码,我们不能保证它是不会被其他的进程干扰,为了保护这个信号量又需要去设置一个信号量…进行了死循环,所以不是一个可以实现的代码.
死锁
死锁是什么?
死锁描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
死锁的四个条件
- 互斥使用,一个资源只能一个进程使用
- 不可抢占,资源只能自愿放弃
- 进程必须占有资源再去申请
- 循环等待:在资源分配图中存在一个环路
- 只有当以上四个条件同时存在,才会出现死锁
处理死锁的四种方式
死锁预防
破坏死锁产生的必要条件,在进程执行前一次性申请所有需要的资源,不会占有资源再去申请其他资源
缺点是需要知道所有要请求的资源,编程困难,而且许多资源分配后长时间后才使用,资源利用率低.
死锁避免
判断这次请求是否引起死锁,可以用到安全序列的银行家算法,但是每次都需要进行判断,时间复杂度很高,复杂度为O(m*n^2)
死锁检测+死锁恢复
或者可以当CPU工作效率低的时候进行检测,但是选择哪些进程回滚,已经修改的文件怎么办,都是比较麻烦的
忽略死锁
我们前面都是一些可能会出现死锁的情况,可以看到无论哪个算法,其支付的代价都是比较大的,所以liunx和Windows一些低版本就采用了忽略死锁的办法,因为假如一旦遇到自锁,我们就可以关机或者重启.
内存管理
内存映射
程序运行需要pc指针,那一个程序编译后,如何确定它的具体地址.假如一个程序中有一条指令是调用其他子程序如"call 40",因为是call 40,因此将程序从磁盘放在内存中,就必须将call 40 调用的子程序放在物理内存的40地址处,但是地址0不一定空闲,或者其他程序也需要放在0地址呢?
如果把程序的位置固定分配空间,这样就需要每次将程序地址写入在固定的位置,在一些内嵌式开发中可以这样操作,但是也带来了很多的缺点,比如程序变量的不灵活,很难预测具体的地址等等…
这样就引入了偏移地址的概念.每次只需要记录偏移地址的具体地址,每次在非配内存空间的时候将分配空间的首地址给pcb,然后有了基地址后,基地址+偏移地址就可以算出具体的物理地址.
在进程阻塞切换地址时,都是根据PCB中的基址信息,在执行时,每一条指令都会去PCB取出基址进行相加,在运行时才开始重定位在进程切换时,会根据当前运行进程对应的PCB的基地址来修改基地址
内存分区
既然分配了地址就解决了变量之间不存在读取其他程序的数据的问题,现在要思考另外一个问题.
我们给一段程序配置地址时,是将所有的程序一次性装入内存吗?
对于用户来说,我们希望是分段,为了方便拓展,同时也避免了一次赋值很多的资源,造成资源的浪费,我们也只需要关注切换目标段的具体内容就可以.将其分段是为了分开管理,例如主程序段是“只读”;而变量集为“可写”;函数库有可能是动态载入;通过分段分治可以增加效率;
一个进程段表的样子:
对于操作系统OS,将其看做一个整个程序分段,的段表就是GDT;而对于用户进程的段表就叫LDT;
对于每个进程在切换的时候,相应的LDT也需要切换,每次进行地址翻译,需要根据LDT表从逻辑地址到物理地址;
GDT 可以理解为"操作系统的段映射",LDT理解为"进程的段映射",执行每一条指令都需要进行LDT基址查询,地址翻译
固定分区
既然我们已经有了分段的思路,下一步我们应该思考的就是如何分段,是应该将内存分割成若干个大的段区,再将程序依次填入吗
可是这样似乎不够灵活,因为一个程序不一定会将全部的空间都利用到,很多的空间都被浪费掉了.
可变分区
相信一些对链表熟悉的同学已经联想到了,固定分区好像和顺序表很像,那我们是不是可以采用链表的形式将内存一段一段的分割起来,配合段表,形成一个不错的可变分区.也避免了空间资源的浪费.
可变分区的数据结构:
可变分区的请求分配资源
可变分区释放资源
现在一个进程要请求一段空间,reqSize=40k,有很多的空闲分区,我们应该选哪一个.引出以下几种算法
- 最佳适配:结果空闲分区会越来越小;
- 最差适配:结果空闲分区会越来越均匀;
- 首先适配:分配第一个寻找到的空间,速度最快,其他算法都要遍历一遍复杂度为O(n)
内存分页
可变分区的剩余问题:
假如当剩余空间大于160k,但是都不是连续的地址,形成了内存碎片,如果我们将空闲区紧缩,在合并的过程中,其他的程序不可以运行,因为指令的地址在改变(基址),所以会对外显示为死机.因此,内存紧缩的处理方式并不可行
既然内存分段是将内存分成不同的区域,那是不是可以分割成更小的颗粒,比如分割成一页一页,每一页最多也就浪费一页的空间4k,相比于分段,内存碎片少了很多,看起来是一个很好的想法.对于物理内存,分页的思路也是可行的
我们可以将16位逻辑地址中前4位标记为第几页,后面12位用作页面尺寸(4k).
假如执行这样一条指令: MOV [0x2240],%eax
页表寄存器是cr3,一般为16位,.一页是4K,2的12次方,将地址右移12位,也就是去掉后三位,(16进制,2的4次方);剩下0就是02页,这个计算过程是硬件MMU完成的;,可以计算得到页号是2,再通过查找页表指针,查到页框号,得到实际的物理地址.
多级页表
虽然分页是一个可以实现的方案,但是当一个操作系统内存变大时,一个32位程序地址为2^32次即4G
4G/4K=1M,对应的页表就有1M个开销.而且一个程序就要对应一个页表,加上实际运行过程中只有一小部分会使用到.所以还需要其他的解决思路,注意现在页表需要是连续的,那我们页表中不连续存放页表可以吗?
为什么页表要连续的呢?
因为假如我们每次在页表中,只添加自己的使用的页号,虽然降低了内存的开销,但是我们要注意我们最开始的目标是想要在分页的情况下迅速找到对应的物理地址,如果页表不是连续的,那么我们虽然拿到了页表,我们也无法确定对应的物理地址是多少,如果一次遍历,代价又变得非常昂贵.假如一条指令就要重定位,就要查几百次表,表也在内存中,因此每执行访问内存一次需要额外访问内存几百次,是不理想的.
在翻书时,我们想找到某一块的章节我们是如何操作的,是从第一页开始一次往后遍历吗,那样就太费时间了,所以一般会先去找指定的哪一章后在开始按页查找引出了多级页表,即目录表+页表
假设有32位逻辑地址,我们可以前十位存放目录号,后十位存放页号,最后在存放偏移地址.形成了一个链表的结构.
关于链表的特性,我们每一次增加一级页表就会增加一次访问内存,因此多级页表提高了空间效率,但增加了额外的访问次数;页表级数增多可以节省内存,但是会增加访问内存的次数
对于64位系统,可能就有5.6级的页表,会增加访问内存的次数
多级页表在保证空间连续和时间,节省了空间,但是时间访问内存也有消耗
快表
所以我们可以设计这样一个电路,将一些经常访问的地址存放到TLB中,TLB是一组相联快速存储,是寄存器.
因此访问速度很快,我们需要查找一个页号对应的物理地址要先去从TLB中查找,如果没有找到,再通过多级页表去寻找.这个过程分为命中与未命中,既然要快,关键在于命中率,另一方面要选择存储哪些地址映射
我们又如何保证当前存储的地址映射是经常使用的,是因为我们写的程序多呈现出“局部性”和“循环性”,也就是我们在程序中多为循环,顺序结构,所以在某些地址空间中会经常高频率的引用,保证了快表的高命中率.
通过快表+多级页表的方式,我们也对分页是如何分割的有了一些更加清晰的认识.
段页结合的实际内存管理
在实际的内存管理中,用户希望用段,物理内存希望用页,所以我们需要结合两种方式,尝试让段页同时存在,段面向用户/页面向硬件.引入了虚拟内存的概念,虚拟内存就是负责结合段和页的重定位问题.
在用户请求一段空间时,虚拟内存对于用户来说是透明的,看不见的,通过虚拟内存中的映射,在去分配实际的物理内存.
整体分为五步:
- 在虚拟内存中分区
- 建立段表;(建立用户与虚拟内存之间的地址映射)
- 在物理内存中寻找空闲页;
- 建立页表(虚拟内存与物理内存的地址映射)
- 通过重定位具体的使用内存;
内存换入
为了实现虚拟内存,就应该换入换出,基于虚拟内存实现段页结合,通过换入换出机制支持虚拟内存.那么一个内存换入的过程是怎样的
用户眼里的内存其实虚拟内存,假如一个内存只有1G,虚拟内存可以抽象出4G的内存空间,假如一个进程要访问的地址不在物理内存中,需要在请求时将这一部分写入到内存中,请求并建立页表映射
请求调页:
一个进程要访问一个不在物理内存的地址,会经过硬件MMU查页表,发现缺页,就会引发一个中断.因为在CPU中,没执行完一次指令,CPU就要去检测是不是发生了中断,如果发生了中断就去执行中断服务程序,从磁盘上调用一个新页,并建立新页的地址映射,中断重新返回到当前指令
内存换出
既然了解了内存是如何换入的,程序在运行是不会一直从磁盘读入内存,那么当内存满的时候,我们也需要绝对到底是哪一个元素需要被替换出去,下面是一些常见的换出算法
内存换出常见算法:
FIFO页面置换算法
既然要换出,我们可能会联想到之前schedule调度切换算法,算法也是从FIFO开始的,FIFO算法非常简单,只需要找到最先进入内存的部分替换掉就可以了.总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
MIN页面置换算法
LRU页面置换算法
用过去的历史预测将来。LRU算法: 选最近最长一段时间没有使用的 页淘汰(最近最少使用),LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最小的,即最近最久未使用的页面予以淘汰。
LRU的时间戳实现
实际上并不可行,A是逻辑页,每执行一条指令,要取值,要重定位(地址翻译)MMU查页面,每个时间戳都要维护这样一个数组或修改;时间戳很容易溢出;
LRU的页码栈实现
Clock页面置换算法
最近是否被使用,在转一圈的时间内没有被访问过,这个引用位放在页表中,MMU硬件实现就很快了,每次访问只需要置一位0/1就可以;不需要LRU维护一个复杂的数据结构;使用这个引用位来近似最近是否被访问;SCR,clock算法
clock算法的分析与改造
-
缺页是很少的,局部性+循环性;缺页很少意味着从1变0很少;是在缺页转动指针时从1到0的改动;访问这一页就会从1变成0;这些页一直被访问,就都变成1,最终效果是R都是1;一旦发生缺页,会将所有1变成0,很长时间内都不缺页都变成0,然后再发生缺页就会换成依次换出;算法就退化为FIFO;
-
来一个扫描指针(转的快),定时清除R位,将R=1都置为R=0;要表达最近没有被使用过的思想;之前是因为时间太长了,基本上所有都被访问过了,变得一样,而加快扫描,就突出最近没有被使用过
文件管理
要了解文件读写是如何实现的,就需要先了解一个程序操作外设是如何运行的.
- CPU向外设发出out指令
- 外设向CPU发起中断…
- 执行中断处理程序…
在实际的liunx0.11版本中为了使用外设更加简单,形成了一个统一的外设接口,即文件视图
系统调用接口->I/O系统驱动>外设
生磁盘的使用
CPU读取磁盘也是类似的工作流程,CPU发出一个读/写命令,将数据送往内存,读/写完成后向CPU发出中断…
一个磁盘访问的单位是扇区,一个扇区一般大小为512字节,扇区的大小是传输时间和碎片浪费的折中.
磁盘的I/O过程:首先将磁头移动到相应磁道上,旋转磁道,找到512字节的扇区,磁生电;磁信号变为电信号,将电信号内容写入内存缓存中,如果在内存缓存中写一个字节,就将电信号转为磁信号写到对应磁道扇区内
所以我们之间使用磁盘,只需要知道柱面、磁头、扇区、缓存位置,就可以实现对磁盘的基本使用,利用out指令传递对应信息
但是不用这好几个信息,每次只能访问512个字节是满足不了用户的需求的,于是我们通过改进变成了告诉第几个磁盘块来读写磁盘,相应转化由操作系统完成
程序发起请求(带有block信息)->磁盘驱动->磁盘控制器->磁盘的读写
通过磁盘驱动器来把柱面、磁头、扇区等关键信息分离出来.
这样我们隐藏了信息,从一维信息到三维信息的编址过程.同时block相邻的盘块可以快速读出,因为用户经常会访问相邻的磁盘盘块.在效率上,磁臂运动到相应柱面的时间(机械运动)往往是比较大的开销.因此将相邻盘块尽量放在同一个磁道上;可以放在相邻或者下一个盘面,因此最好放在相邻位置;一个磁臂上有多个磁头.
现在我们非常简单的了解了磁盘的简单读写,在我们使用计算机的过程中,一般会有多个请求队列在等待,每一个都需要去读写磁盘上的资源,于是出现了一些访问磁盘的经典算法.
FCFS磁盘调度算法
依然是从FCFS算法开始,因为它是最直观,最公平的算法.但是在磁盘读写中,显然它的效率是非常低下的.因为多个进程交替进行,是混乱的,没有规律的,引出了下一个算法SSTF,在移动过程中把经过的请求处理了
SSTF算法
SSTF算法是从最近的开始,先将距离当前磁盘最近的请求依次访问,再往远处访问.但是在计算机处理程序中,因为位于中间的磁道较多,总是在中间来回移动就会造成饥饿.
SCAN算法
SSTF+中途不回头,就变成了SCAN算法,也就是我们常见的电梯算法.
从一端来回移动至另外一端,加上磁臂复位是很快的,很快就可以进行下一轮的读写.
熟磁盘的使用
用户使用磁盘,往往是通过文件,而不是盘块号,对于用户来说,磁盘就是一些字符流,一些盘块的集合.为了用户可以更加方便的使用磁盘,我们需要在操作系统中建立一个新的映射,FCB,用来记录文件名,起始快,块数等信息.操作系统负责维护这个映射.
每个文件都用FCB数据结构来确定映射关系,只需要存放文件的起始块和块数,也就是按照连续结构来实现文件,但是相对的就变得不再灵活.
链表结构的文件存放
链表结构的文件存放的实现也就是在FCB结构中将每个文件通过链表来连接起来,这样空间利用率好但是每一次的访问效率就需要依次遍历,复杂度为O(N).
索引结构的文件存放
索引类似于目录;根据inode找到索引块,其实也是有一些链表的思路在里面,不过与链表不同的是,链表是指向下一个结点元素,而索引是相当于新建了一个"目录结构",也就是实现了内存的高利用率,同时查找效率也得到很大的优化.
实际系统是多级索引,也就是在iNode节点中,可能是一阶索引,也可能是二阶…三阶…
满足了大文件,中等文件,小文件的高效访问.
来源地址:https://blog.csdn.net/qq_52357869/article/details/126927832