1.虚拟化
在了解具体概念之前,不访先思考两个问题:
1.为什么需要虚拟化?
2.怎么实现虚拟化?
第一个问题现在很难说明白到底是为什么?比如我可能会说虚拟化是为了让系统使用更加方便,但对于没有了解过的同学来说,可能很难理解为什么虚拟化让系统使用更加方便,因此可以带着“虚拟化让系统使用更加方便”这个结论来阅读下面的内容,当你了解了虚拟化的实现后相必也明白了为什么需要虚拟化。
第二个问题你可以自己先思考一下让你来设计你会采用什么方案?然后对比一下现代操作系统采用的主流方案。
在操作系统中可以认为虚拟化就是操作系统将物理资源(处理器、内存、磁盘)转换为更通用、更强大且更易于使用的虚拟形式。 虚拟化能够让许多程序并发运行,并发访问自己的指令和数据,并发访问设备。
本篇文章主要讲虚拟化CPU。
2.CPU虚拟化
进程:至今没有一个确切的定义,一个非正式的定义进程就是运行中的程序。
如何理解这个定义?程序就是自己编写的源文件,它在计算机上就是一些用二进制保存的指令和数据的集合,是静态的东西。但是操作系统让它运行起来来发挥作用,这是一个动态的过程。
思考一下,你经常使用电脑的时候是不是同时打开好几个软件使用。比如你正在浏览这篇文章,但是同时你还正在听歌,或者你Wegame的客户端还处于开启状态。事实上,除了这些你主动开启的服务,还有许许多多的后台服务(比如FTP服务,TCP服务等)也在同时运行。但奇怪的是你可能只有一个CPU,并且这个CPU只有一个核,那么它是如何提供让使用者看起来好像很多程序在同时的假象的呢?换言之,就是操作系统如何利用仅有一个CPU向你提供有很多个CPU的假象的呢?
这就是采用上下文切换机制,对CPU进行了虚拟化。
上下文切换机制是借助时分共享技术实现的。
时分共享:时分复用是指当多个程序或用户想要使用同一个资源时而采取的策略。每个程序或用户需要按照一定的顺序依次使用这个资源。
上面的定义看起来有点抽象?简单的说,就是允许资源(这里就是CPU)由一个实体使用一小段时间,再由另一个实体使用一小段时间,如此执行下去,只要这个时间段取的足够小让人肉眼感觉不到,就给人一种资源被多个程序同时使用的假象,也即达到了虚拟化的目的。
上面的策略看起来好像可以解决问题了,但是稍微自己考虑一下存在很多问题,比如现在有多个程序,如何决定要让谁先在CPU上运行,未运行的程序应该如何安置?有新任务进来该如何安置?占有CPU运行的程序运行结束后应该如何选择下一个占有者?
因此不难发现,我们需要一种策略来解决这些问题,也可以说要一组算法来合理安排各个程序的执行顺序。
这样我们就确定了CPU虚拟化 = 机制 + 策略
这里的机制就是上下文切换,它可以看做一种协议,规定了我们要实现目标所采取的的方法。策略则是为了实现上下文切换系统内部应该提供什么样的算法来进行辅助。
上面说到进程就是一个运行中的程序,那么具体来说进程和程序有什么区别呢?
程序要运行必须先进入到内存,因此必定有自己在内存中的位置信息。数据许多时候都是保存在寄存器中的,因此必定要包含所使用的的寄存器信息。此外还有一些必要的信息,比如下一条要执行的指令的地址(也称为程序计数器PC或指令指针寄存器IP)以及栈指针和一些调度所必须的信息(已经运行多长时间,等待了多长时间等),这都是进程所要包含的内容。不难看出,进程不但包含了程序还包括了一些程序运行所必须的额外的数据结构。
在现代操作系统中这个数据结构有一个另外的名字叫PCB(Process Control Block)。
进程控制块(PCB)是系统为了管理进程设置的一个专门的数据结构。系统用它来记录进程的外部特征,描述进程的运动变化过程。同时,系统可以利用PCB来控制和管理进程,所以说,PCB(进程控制块)是系统感知进程存在的唯一标志。
PCB一般包含三类信息:
1.标识信息。
用于唯一地标识一个进程,常常分由用户使用的外部标识符和被系统使用的内部标识号。几乎所有操作系统中进程都被赋予一个唯一的、内部使用的数值型的进程号,操作系统的其他控制表可以通过进程号来交叉引用进程控制表。常用的标识信息包括进程标识符、父进程的标识符、用户进程名、用户组名等。
2.现场信息。
用于保留一个进程在运行时存放在处理器现场中的各种信息,任何一个进程在让出处理器时必须把此时的处理器现场信息保存到进程控制块中,而当该进程重新恢复运行时也应恢复处理器现场。常用的现场信息包括通用寄存器的内容、控制寄存器(如PSW寄存器)的内容、用户堆战指针、系统堆饺指针等。
3.控制信息。
用于管理和调度一个进程。常用的控制信息包括:
(1) 进程的调度相关信息,如进程状态、等待事件和等待原因、进程优先级、队列指引元等
(2) 进程组成信息,如正文段指针、数据段指针
(3) 引进程间通信相关信息,如消息队列指针、信号量等互斥和同步机制
(4) 进程在辅存储器内的地址
(5) CPU资源的占用和使用信息,如时间片余量、进程己占用CPU的时间、进程己执行的时间总和,记账信息
(6) 进程特权信息,如在内存访问和处理器状态方面的特权…
3.进程
上面介绍了很多进程的概念,现在有一个很基本的问题:操作系统如何启动并运行一个程序,如何创建进程?
操作系统所做的第一步应该是将在磁盘的代码和数据加载到内存中。
看起来也是一个很简单的步骤,但是看起来简单不代表真的简单,一个值得思考的问题是加载到内存的过程是一次性完成还是按需加载(惰性加载)。这在编程语言中也有相同的问题,比如工厂类创建一个类的对象,是饱汉式还是懒汉式?感兴趣的可以了解下。而在操作系统中我们经常采用惰性加载的方式,具体是由分页和分段、交换等机制完成的,但现在不是我们关注的重点。
紧接着,你如果了解一门现代编程语言就应该知道,操作系统必须为程序分配栈空间和必要的堆空间等。然后你可能不了解的是操作系统还会为每个进程执行一些I/O相关的初始化操作。
以C语言为例,栈保存局部变量、函数的参数、返回地址。堆空间保存一些动态分配的数据(如malloc()函数)。而在UNIX类系统中,默认情况下操作系统会为每一个进程分配三个文件描述符,用来表示标准输入、输出和错误输出。顺便提一下,这三个描述符也是为什么你的程序默认输入都是在控制台,而默认输出也是在控制台,想要改变很简单只需要重定向到其它文件描述符就可以。
最后一切都准备就续了,并在它获取到了CPU执行权后,操作系统会将控制权转移到新创建的进程中,进而完成程序的执行。
在上面我们知道,为了更好的调度进程,进程会将其信息保存在PCB中,其中有一个重要的信息就是进程当前的状态。
进程状态
早期的计算机进程有三种状态:就绪(ready)、运行(running)、阻塞(blocked)。
运行:在运行状态下,进程正在处理器上运行。
就绪:进程已经完成初始化操作,只是没有获得CPU的运行权。
阻塞:进程由于执行了某种操作而被阻塞,直到等待的事件发生时才准备继续运行。例如:进程向磁盘发起I/O操作时会被阻塞。需要注意的是,阻塞状态下的进程不占有CPU,其它进程可以使用CPU。
一个典型的进程状态转换图如上,多了创建态(初始态)和终止态。此外还有一种被称为挂起的状态,有兴趣的可以了解一下。
对于创建态一个解释是:
1.进程申请一个空白PCB
2.向PCB中填写用于控制和管理进程的信息
3.为该进程分配运行所需要的的资源
4.将该进程转入就绪状态并插入就绪队列中
可以看出创建态描述的是进程处于资源分配的一个过程。
对于终止态的一个解释是:
1.等待操作系统进行善后处理(操作系统保留该进程的部分信息供其他的进程提取)
2.将该进程的PCB清零,并将PCB控件返还系统
可以看出终止态描述的是进程处于已经退出但尚未清理的状态。
在类UNIX系统中处于终止状态的进程有一个广为人知的名字:僵尸进程
这个状态是十分有用的一个状态,他允许其它进程检查进程的返回值来判断进程是否成功执行,然后在调用wait()或waitpid()来告诉操作系统释放这些资源。
僵尸进程与孤儿进程
在 类Unix系统中,正常情况下,子进程是通过父进程创建的,且两者的运行是相互独立的,父进程永远无法预测子进程到底什么时候结束。当一个进程调用 exit 命令结束自己的生命时,其实它并没有真正的被销毁,内核只是释放了该进程的所有资源,包括打开的文件、占用的内存等,但是留下一个称为僵尸进程的数据结构,这个结构保留了一定的信息(包括进程号 the process ID,退出状态,运行时间),这些信息直到父进程通过 wait()/waitpid() 来取时才释放。这样设计的目的主要是保证只要父进程想知道子进程结束时的状态信息,因此:
僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵死进程。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作。
僵尸进程虽然不占有任何内存空间,但如果父进程不调用 wait() / waitpid() 的话,那么保留的信息就不会释放,其进程号就会一直被占用,而系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害。
孤儿进程是没有父进程的进程,孤儿进程的释放就落到了 init(kernel启动后创建的第一个用户进程) 进程身上,init 进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init,而 init 进程会循环地 wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
僵尸进程的解决方案:
(1)方案一:父进程通过 wait 和 waitpid 等函数等待子进程结束,但这会导致父进程挂起,所以这并不是一个好办法,父进程如果不能和子进程并发执行的话,那我们创建子进程的意义就没有。同时一个 wait 只能解决一个子进程,如果有多个子进程就要用到多个 wait
(2)方案二:通过信号机制:
子进程退出时,向父进程发送 SIGCHILD 信号,父进程处理 SIGCHILD 信号,在信号处理函数中调用 wait 进行处理僵尸进程。
(3)方案三:fork两次:
原理是将进程成为孤儿进程,从而其的父进程变为 init 进程,通过 init 进程处理僵尸进程。具体操作为:父进程一次 fork() 后产生一个子进程随后立即执行 wait(NULL) 来等待子进程结束,然后子进程 fork() 后产生孙子进程随后立即exit(0)。这样子进程顺利终止(父进程仅仅给子进程收尸,并不需要子进程的返回值),然后父进程继续执行。这时的孙子进程由于失去了它的父进程(即是父进程的子进程),将被转交给Init进程托管。于是父进程与孙子进程无继承关系了,它们的父进程均为Init,Init进程在其子进程结束时会自动收尸,这样也就不会产生僵死进程了
(4)方案四:kill 父进程:
严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL 信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被 init 进程接管,init 进程会 wait() 这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。
上面我们已经说过了如何初步实现虚拟化。它的大致思想就是:运行一个进程一段时间,然后在运行另一个进程一段时间,这样这些抽象的进程就时分共享CPU,也即实现了虚拟化。
然而我们面对一个很严重的问题,进程拥有了CPU的控制权之后,操作系统能否收回?不要小看这个问题,如果进程拥有了CPU控制权之后操作系统无法回收,那么一个进程可以简单地无限制地运行下去。此外我们还有一个更普遍的问题:效率。无论何时在解决一个问题时我们都需要考虑效率。
接下来我们的任务就是在利用上面实现虚拟化的基本机制思想的指导下,结合一些进程控制策略,来完成既能保持控制权又能获得高性能的虚拟化CPU的任务。
在早期的操作系统中操作系统开发人员想出了一种技术——受限直接执行(limited direct execution)。直接执行很容易理解:进程获得CPU使用权后直接在CPU上运行程序即可。为什么要受限呢?因为为了确保效率和控制权问题。因此我们的重点是放在“受限”上。
直接执行的优势很明显:执行速度快。因为程序直接在硬件CPU上运行,因此执行速度很快。但是这就会产生新的问题,如果运行的进程要执行某些本没权利的请求(获取更多的资源)怎么办?因此必须要能够让进程处于受限的状态下运行。
硬件提供了这样的支持,硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)下,应用程序不能访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。通过提供系统调用来完成用户态到内核态的转换。
加入这样的机制后,正常情况下我们的程序时运行在用户模式下,此时进程不能直接处理I/O请求,而是在发出I/O请求后会执行系统调用引发中断陷入内核,内核态来完成具体的I/O请求。而整个过程对用户而言是透明的,就好像完全是在用户态完成一样。
这里有一个细节,控制权转移到内核之后,内核怎么知道执行什么代码来完成IO操作(回想一下,你在使用C语言编写IO请求时也没有指定具体访问细节)。
1.一个糟糕的解决方案是允许用户自己编写一些汇编指令来操控硬件,然后只需要再调用时将指令的地址通过参数传递,进而让内核来执行这些指令完成IO操作。为什么这样不好?因为你允许用户随意指定指令地址,那么等于你把控制权又完全交给用户了!!(想象一下,这样是不是相当于进入内核态后可以随意执行任何代码序列,此时受限也就不存在了。)
2.现代操作系统更多的是采用陷阱表(或称为中断向量表)来实现。机器启动时,在内核模式下执行,此时可以配置一些必要的信息。陷阱表就是在这个阶段完成的。陷阱表就是告诉硬件在发生某些异常事件时需要执行哪些代码(比如发生缺页中断、键盘中断、网卡中断、除0中断等),这些具体的代码是操作系统设计者早就已经写好的汇编指令!
简单总结一下,LDE协议一共有两个阶段。
第一个阶段(系统引导时):内核通过特权指令初始化陷阱表,并让硬件(CPU)记住它的位置方便后续调用。
第二个阶段(运行进程时):当进程希望执行某种特权操作时,会发出系统调用陷入操作系统,内核来代为完成一些操作,然后执行陷阱返回指令将控制权还给进程,直到进程完成任务后操作系统再完成相关的清理任务。
经过上面的阅读,我们已经解决了如何控制进程合法的访问资源,下一问题就是如何实现进程间的切换? 这里存在的问题是,当一个进程在CPU上运行时,控制权在进程这里,那么CPU此时如何重新夺回控制权来安排另一个进程获取CPU使用权呢?
仔细想想,我们在遇到问题时通常都有两种解决方案,一种是温和(给予对方充分信任)的方式,一种是暴力(给予对方充分不信任)的方式,那么操作系统应该采用什么方式呢?下面来看看这两种方式在操作系统中的使用:
1.协作方式
这种方式在一些很古老的操作系统(Macintosh、 Xerox Alto),这种模式下操作系统充分信任进程会合理运行。它认为运行时间过长的进程会通过系统调用定期放弃CPU。这种方式确实很理想,但是如果进程拒绝进行系统调用呢?更加可怕的是进程还执行了一个无限循环的例程,那操作系统永远也无法获取控制权了。
2.非协作方式
如果你还有印象,记得文章开头介绍虚拟化时我介绍过进程按照一定的时间段在CPU上运行。这里这个时间段我们可以给它一个更加专业的术语:时钟中断。时钟设备可以编程为每隔几毫秒产生一次中断,此时执行陷阱指令进入内核,内核查找陷阱表来处理时钟中断。因此我们可以明白:时钟中断例程可以完成操作系统想做的事情,停止当前进程,启动另一个进程。或者是继续运行当前进程。
在这里我再多说一句:无论是预先设置陷阱表还是启动时钟,都是特权指令,是在计算机启动处于内核态是就完成的。
上下文切换
上面我们说操作系统重新获取CPU控制权后会做出选择:停止当前进程,启动另一个进程。或者是继续运行当前进程。到底做出什么决定属于调度算法的范围(在另一篇文章讲解),这里我们先说如果做出了“停止当前进程,启动另一个进程”操作系统应该还行什么操作?亦或做出了“继续执行当前进程”应该执行什么操作?
这里我们必须说明一下中断或者陷阱操作的具体操作:
(1)中断请求:中断源向CPU发出中断请求
(2)中断响应
(3)保护断点和现场:以便在中断服务程序执行后正确的返回主程序。
(4)中断处理
(5)中断返回
关于中断的请求、响应应该如何设置寄存器这些细节我们不做讨论。我们主要说一下,中断响应之后要进行的操作,这个过程也称为上下文切换(中断上下文切换),它需要将正在运行的进程的寄存器信息保存到内核栈上,进而转到内核模式。接着中断返回过程中又会发生一次上下文切换,这次的目的是恢复之前保存的寄存器信息,并把控制权交给进程。**因此一次系统调用的过程发生了两次CPU上下文切换。**这也就回答了操作系统做出“继续执行当前进程”应该执行什么操作。
另一个问题操作系统做出了“停止当前进程,启动另一个进程”操作系统应该还行什么操作?
这里需要采取和上面有所不同的方法,因为上一个问题的过程只涉及一个用户进程,而这个问题涉及到两个用户进程。因此有必要保证两个进程的数据不会相互影响(试想如果不做相应处理,两个进程使用同一块内存地址,那么必然得到错误的结果。)
进程由内核管理和调度,进程的切换只能发生在内核态,**进程上下文不仅包括内核堆栈、寄存器等内核空间状态,还包括虚拟内存、栈、全局变量等用户空间资源。**每次进程上下文切换需要几十纳秒到数微秒的CPU时间。可以看出进程上下文切换额外做的一些操作时保存上一个叫进程的数据等资源。当这些信息被保存后,操作系统就可以切换内核栈将另一个进程的信息加载进来,进而执行另一个进程。
如图两个进程A和B,A在运行过程中被中断,硬件先保存它的寄存器信息到内核栈中,并进入内核。时钟中断例程中操作系统决定运行B,因此将当前寄存器的值保存到A的进程结构中,然后恢复B的信息。