02-进程与线程

进程

  我们的计算机经常在同一时间做许多事情,比如你的电脑上也许正在同时运行着微信和steam,但是某一瞬间CPU只能运行一条指令,这条指令可能来自于程序A,也可能来自于程序B,所以某一瞬间你的CPU其实只执行着一个程序中的指令,但是在较长的时间下,比如1秒钟,CPU通过快速切换不同程序的执行,支持着好多程序的运行,给我们一种并行的错觉,那我们到底该如何管理这些看上去并行的东西呢?操作系统的开发者开发了用于描述并行的概念模型,就是我们接下来要提到的进程。

我们在这一章中做的讨论都是针对于单核CPU的,至于单核与多核有什么不同,计算机组成与设计中已经提到过了,这里不再阐述。

进程模型

  在这个模型中,计算机上所有可运行的软件,通常也包括操作系统,被组织成若干顺序进程,简称为进程。一个进程就是一个正在执行的程序的实例,你现在就可以打开电脑上的任务管理器找到进程的页面,你可以在里面看到许许多多进程。

  按道理讲,每个进程的运行都需要CPU,但是这么多进程,CPU却只有一个,所以我们给每一个进程都安排一个虚拟的CPU,这样在研究任何一个进程时,只需要注意这个虚拟的CPU就可以了,而不需要注意真正的CPU实际上在众多进程中来回快速切换(这种快速切换被称作多道程序设计)。

  比如说我们现在内存中有四道程序,分别是A,B,C,D,那么这4道程序就会被抽象为4个拥有各自控制流程的进程,并且每个程序都独立地运行,这里所谓的“各自控制流程”,其实就是指每个程序都有他们自己的逻辑程序计数器,我们知道程序计数器能够指示程序运行到哪一步了,是程序控制非常好的体现,但是实际上只有一个物理程序计数器,只是在每个进程运行时,把它的逻辑程序计数器装入到实际的程序计数器中,就像是电脑阅卷,窗口永远都是那个窗口,但是窗口中呈现的试卷会来自不同的课程,要批哪一门课的卷子,就把那一门课的卷子调进窗口里看,非常相似的过程。那么等到程序结束或者暂停时,物理程序计数器中的值就会被保存到内存中该进程的逻辑程序计数器中。这样我们只要观察较长的一段时间,所有的进程都运行了,但是任意一个瞬间都只有一个进程在运行。

  这真的和电脑阅卷十分的相似,我愿把这个例子再提一遍,假设有个老师同时批阅四门课程的试卷,批阅不同课程的试卷可以理解为不同的进程,各个课程的批阅进度可以理解为不同进程的逻辑程序计数器,他在批阅课程A的试卷时,窗口里只需要显示课程A的批阅进度就可以了,等到批阅课程B的时候,窗口里再显示B的批阅进度,以此类推,我们明明只有一个阅卷窗口,但是却可以显示不同课程的批阅进度,而且在很长一段时间后,每一门课的试卷都被批阅了一些,但是任意一个瞬间他只是在批阅一门课程的试卷。

我非常高兴能有阅卷这个例子突然出现在我脑袋里,而且我突然想到似乎后文中的线程也可以用这个例子来解释,真是苍天助我。

  进程和程序之间的关系是十分微妙的,课本上有一个非常生动形象的例子,一位科学家要做蛋糕,那么做蛋糕的食谱就是程序,阅读食谱,取来各种原料以及烘制蛋糕这一系列动作就是进程,其实我们能够注意到并不是所有的程序都会在某一时刻对应一个进程,毕竟有食谱不一定要必须做那道菜,但是进程却一定对应着某个程序,要想做菜必须得有思路(食谱)对吧。

  综上其实有这样一个思想:进程其实是一类活动,它有着输入(做蛋糕的原料),输出(做出蛋糕),程序(食谱)以及状态(做到哪一步了)。我们的CPU可以被若干个进程共享,它使用某种调度算法来决定何时停止一个进程的工作,并转而向另一个进程提供服务。

  另外需要注意的是,如果一个程序被运行了两遍,那么会被算作两个进程,这也不难理解,今天做蛋糕与昨天做蛋糕都是做蛋糕,但肯定不可能是一模一样的蛋糕。

进程的创建

只有进程才能创建进程,我们下面会很好地阐述这一点。在阐述之前,我们先来看看有哪些事件会导致进程的创建,主要是以下四种事件:

  • 1.系统初始化
  • 2.正在运行的程序执行了创建进程的系统调用
  • 3.用户请求创建一个新进程
  • 4.一个批处理作业的初始化

  我们接下来会一个一个详细解释这四种事件:

  系统初始化
  在启动操作系统时,通常会创建若干个进程,其中有些是前台进程,就是同用户交互并完成些工作的进程,比如说你打开电脑时往往要输入开机密码,这就是一个进程;还有些是后台进程,这些进程往往有些专门的功能,比如说用来接收电子邮件的进程,这个进程可能一天的大部分时间都在睡眠,只有电子邮件到达时才会被唤醒。

  正在运行的程序执行了创建进程的系统调用

  一个正在运行的进程经常发出系统调用以便创建一个或者多个新进程来协助其工作,就比如说,你在浏览器中打开了某个网盘的链接,你想下载链接中的文件,那么这时浏览器就会打开你电脑上网盘的客户端,让你在客户端中下载这个文件(对对对,我说的就是百度网盘)。

  用户请求创建一个新进程

  这个要好理解的多,比如说你想打开你电脑上的微信,那么你就会点击一下微信的图标,这样这个程序就启动了,进程就被创建了。

  一个批处理作业的初始化

  该情形仅在大型机的批处理系统中应用。这里不提了,我也没见过大型机,不懂不懂。

  那么我们为什么说是进程创建了进程呢?当然哈,这里要排除第一个进程,第一个进程是操作系统启动时内核创建的,其他的进程都是由进程创建的,这里可能会有疑惑的地方是,为什么用户请求创建一个新进程也算是由进程创建的呢,难道用户也是进程吗?当然不是,这里需要注意的是,用户只是请求创建这个进程,也就是说,用户只是发送了一个请求而已,至于创建这个进程的则是操作系统,准确地说,是操作系统内核。

进程的终止

  进程在创建之后,开始运行,完成其工作,之后呢?已经完成工作了,就没必要继续留下来了,所以会被终止。进程的终止通常由下列条件引起:

  • 1.正常退出(自愿的)
  • 2.出错退出(自愿的)
  • 3.严重错误(非自愿)
  • 4.被其他进程杀死(非自愿)

  正常退出非常好理解,进程已经完成了它的工作,可以自动关闭或者我们手动关闭它;出错退出是指在进程运行过程中发现了严重的错误,比如说老师想批阅某个课程的卷子但是卷子还没有被扫描出来,那只能放弃批阅,是一样的道理;严重错误是指进程引起的错误,通常是由于程序中的错误所致,比如执行了什么非法指令,这时候不得不终止掉它,就像老师阅卷时打翻了水杯把水洒在了笔记本键盘上,不得不停下批阅而抢救自己的电脑;至于被其他进程杀死,这也是很有例子的,你的杀毒软件总是想关闭它认为有毒的程序对吧,其实就是杀毒软件所对应的进程想结束那些它认为有毒的程序所对应的进程。

进程的层次结构

进程的状态

  尽管每一个进程都是一个独立的实体,有其自己的程序计数器和内部状态,但是进程之间经常需要相互作用,比如进程A的输出是进程B的输入,这时就会出现一种情况:进程B已经准备好了随时可以开始了,但是进程A还没有结束,所以进程B得不到输入去运行,只好把进程B“阻塞”,直到输入的到来。

  当一个进程在逻辑上不能继续运行时,它就会被阻塞,除了刚才提到的因为等待输入而阻塞的情况外,还有一种情况,就是操作系统调度另一个进程占用了CPU。这两种情况完全不相同,前者是因为程序自身的原因(要等待输入等待什么什么的),后者是因为系统技术上的原因(没有足够的CPU)。

  从上面我们能看出进程的三种状态:

  • 运行态(该时刻进程实际占用CPU)
  • 就绪态(可运行,但因为其他进程正在运行而暂停终止)
  • 阻塞态(除非某种外部事件发生,否则进程不能运行)

  前两种状态在逻辑上是类似的。处于这两种状态的进程都可以运行,只是对于第二种状态暂时没有CPU分配给它,但是如果有CPU空闲了,就绪态进程就会有可能运行,但是对于阻塞态进程,它不能运行,即便CPU空闲下来了也不能。

  进程的三种状态之间有四种可能的转换关系:

  • 运行态 $\rightarrow$ 阻塞态:发生于操作系统发现进程不能继续运行下去时
  • 运行态 $\rightarrow$ 就绪态:调度程序选择另一个进程
  • 就绪态 $\rightarrow$ 运行态:调度程序选择该进程
  • 阻塞态 $\rightarrow$ 就绪态:出现有效输入

  这里提到了进程调度程序,它是操作系统的一部分,进程甚至感觉不到它的存在。当系统认为一个运行进程占用处理器的时间已经过长了,它就会让其他进程来使用CPU,当所有其他的进程都享受到了公平待遇之后,它就会再让第一个进程去使用CPU,这就是调度程序的作用,它决定应当运行哪个进程、何时运行及它应该运行多长时间。

  使用进程模型使得我们易于想象系统内部的操作状况。

进程的实现

  前面我们讲过了进程模型,看上去似乎很简单就能实现出来,不就是切换过来切换过去吗,这有什么难的,实则不然,举个例子,当你同时进行好几道菜的制作时,你必须精确的计算每道菜做到哪一步了,花费了多长时间了,用了多少原料了,放在哪个位置了,还有多长时间就要下一步了等等,很麻烦对吧,相比于进程的切换,后者要记录更多的信息,完成更加精确的控制,所以只会更麻烦。

  操作系统维护着一张表格(一个结构数组)来记录进程状态的重要信息,这张表也被称为进程表(进程控制块,进程描述符,进程属性),是用于管理控制进程的一个专门数据结构,每个进程都会占用一个进程表项(也叫做进程控制块,PCB)。表项里记录着进程的各种属性,包括程序计数器,堆栈指针,内存分配情况,所打开文件的状态,账号和调度信息,以及其他在进程由运行态转换到就绪态或者阻塞态时必须保存的信息,从而保证该进程随后能够再次启动,就像从未被中断过一样。

  值得注意的是,PCB是系统感知进程存在的唯一标志,进程与PCB是一一对应的,进程表也就是所有进程的PCB集合。

结合我们之前学习的数据结构,这里的进程表其实是一种链表。

  那PCB应该包含哪些信息呢?

  •   1.进程描述信息,包括进程标识符(process ID),唯一,通常是一个整数,就跟你的学号差不多,操作系统依靠进程标识符来找到特定的进程;进程名,通常基于可执行文件名,不唯一,这里可以直接打开你电脑上的任务管理器来查看进程名;用户标识符(user ID),用来表示这个进程是谁创建的,用来决定权限,管理员的权限当然会比普通用户高;进程组关系,有些进程可以组成一个“组”,方便统一管理。

  •   2.CPU现场信息,包括寄存器值(通用寄存器,程序计数器PC,程序状态字PSW,栈指针)以及指向该进程页表的指针。这里要注意区分CPU现场信息与CPU内部信息,CPU现场信息是某一进程上一次被切换时保存下来的信息,包括前面提到的那些,而CPU内部信息则是正在运行的进程的实时信息,这两个并不一样。

  •   3.所拥有的资源和使用情况,包括虚拟地址空间的状况以及打开的文件列表。

  •   4.进程控制信息,包括当前状态,优先级,代码执行入口地址,程序的磁盘地址,运行统计信息(执行时间、页面调度),进程间同步和通信,进程的队列和指针,进程的消息队列指针。

进程的控制

  我们前面已经提到过进程的控制与终止,但是进程的操作远不止这些,还有进程的删除、复制,进程各状态之间的转换以及进程属性的改变等等,这些操作相当重要,由具有特定功能的原语来完成,防止出现不可预知的问题,也就是说,关于进程的任何一次操作,都必须一口气完成,不能做到一半就停下休息,拿进程创建举例,如果一个进程在创建的时候被打断了,那么此时就有可能PCB才建立了一半,内存也只分配到一半,这是很扯的,会很大影响系统的正确性。

原语是完成某种特定功能的一段程序,具有不可分割性或不可中断性,即原语的执行必须是连续的,在执行过程中不允许被打断。前面我们提到过,中断发生时可能导致当前进程被暂停并且切到其他进程,所以在原语的执行过程中就必须关中断,避免调度和进程切换,使得原语能够连续执行。但也就是因为原语不可被打断,所以实时操作系统要特别注意原语的使用,实时OS要求在规定时间内响应事件,高优先级任务要被立即执行,不能有拖延,但是原语的执行不能被打断,所以会有冲突。

  那我们有哪些原语呢?有且不限于:进程创建原语,进程撤销原语,阻塞原语,唤醒原语,挂起原语,激活原语以及改变进程优先级原语等等。

  在我们已经讲过PCB之后,我们就可以看一些关于进程控制的具体过程,比如说进程的创建:

  • 第一步,给新进程分配一个唯一标识以及进程控制块
  • 第二步,为进程分配地址空间
  • 第三步,初始化进程控制块
  • 第四步,设置默认值
  • 第五步,将新进程加到就绪队列链表当中