上一节概要地介绍了CPU的编程接口,从计算机工程的角度来看,硬件的上一层是操作系统(OS)。操作系统的设计目的,是利用硬件暴露出来的接口,实现一些基础功能,然后再把更高级的接口暴露给上层应用。
操作系统要给上层应用提供哪些接口呢?第一个要提及的,就是进程。
进程已成为现代计算机中最重要的基础概念,它是为了解决“多任务”的问题衍生出来的解决方案。如前节所述,CPU其实只能很简单机械地操作指令流水,正常情况下,流水顺序进行,当遇到跳转或中断的时候,它就跳到一个新的地址,并且从新的地址开始继续机械地执行下去。至于当前执行的程序代表了什么含义,它的输入是什么,输出是什么,要执行多长时间,CPU其实完全不关心。换句话说,CPU眼中没有“任务”,只有指令流。
任务,是人类思维中的概念。为了让CPU能够完成我们预想的任务,我们需要根据目标编写相应的指令流(编程),然后再把指令流交给CPU去执行。例如,假设每个任务都可以独立编写为一个指令流,而我们有三个任务要让CPU去执行,那么可以采用下图的方式。
我们在每个任务的指令流的最后,放置一个跳转语句,跳转到下一个任务的指令流的起始处。这样,当CPU执行完一个任务的时候,就可以自动跳转到下一个任务继续执行。严格来讲,这并不能算真正意义上的“多任务”执行。因为在任意时间点上,事实上只有一个任务被执行,其他任务都处在排队的状态,充其量只能算单任务串行。
那什么情况下,需要多任务的“并行”呢?前面讲过,指令当中的很大一部分是读写相关的操作,但读写指令(尤其外部设备的读写)在很多时候并不需要CPU亲自执行,而是通过一些外部设备来完成的。为啥要这样?因为相对于CPU的执行速度,这些设备的读写操作太“慢”了,为了不浪费CPU的“宝贵”资源,通常CPU会把外部设备的读写操作委托给这些设备自己的控制器。以硬盘读取为例,当CPU在执行任务一的过程中需要读取硬盘数据,它把这个命令传递给硬盘控制器,告诉它要从哪里读数据,读多少。然后,CPU就可以去忙别的事情了(比如,跳转去执行下一个任务二)。硬盘控制器会根据CPU的指令,将要读的数据一点一点地读取出来,放到内存的缓冲区中,全部读取完了以后,它再把正在埋头于执行任务二的CPU唤回来,告诉它数据已经读好了。CPU拿到了要读的数据,就再次跳转回任务一继续执行硬盘读取后面的指令。
在这个过程如下图所示,我们看到,由于在任务一种遇到了读取硬盘的指令,而这个指令消耗的时间比较长,CPU为了提高效率,就把这个指令委托给硬盘控制器来执行,而自己则暂时放弃了任务一,转而去执行任务二。当硬盘控制器读取完成以后,CPU又放下任务二,再回过头来继续任务一。
想象一下,如果任务一中有好多读取命令,那么上述过程会频繁发生,CPU就会不断地在任务一和任务二之间来回切换,从这个时间轴上来看,在足够长的一段时间里,两个任务的指令流都在被向前执行,就好像CPU在同时执行两个任务一样。这就是所谓“多任务”的本质。
在操作系统中,“任务”最终演化成了“进程”的概念,简单起见,我们暂时可以把进程理解为等同于上面所述的“任务”。操作系统的一个重要设计目标,就是能够同时支持多个进程的“同时”执行。本节我们将重点描述,操作系统是如何充分利用CPU的接口特性,辅以一些必要的数据结构,来实现诸如:创建进程、销毁进程、进程切换等功能。
如前所述,进程在本质上可以对应为一段指令流,N个进程则可能对应到N段指令流,而所谓的多进程同步执行,就是CPU在这N个指令流之间来回穿梭,每次都在某个流上执行几条指令,从而达到在一段时间内,所有指令流都向前进展的目的。
那么,为了实现这个目标,操作系统首先需要一块空间,来同时保存这N个指令流的代码,以及每个指令流当前被执行到的位置,以便CPU在来回穿梭的过程中可以快速定位到目的地。
如上图所示,这块保存进程相关信息的空间,我们称之为进程槽。每当系统创建一个新的进程,都会在空间里为它分配一个空槽用来保存相关信息。例如,该指令流当前被执行指令的位置,就是一个需要保存的重要信息。
接下来,还有个问题。CPU在执行一个进程的指令流的时候,在什么情况下会发生“进程切换”呢?以前面的例子来说,CPU执行进程一的时候遇到了读硬盘指令,于是跳转(切换)到进程二开始执行。问题是,当编写进程一的程序的时候,是不知道是否有进程二或其他进程的,那怎么才能做到让CPU遇到读取指令的时候,能准确地切换到其他进程呢?
在上一节我们曾经介绍过,改变顺序执行有两个方法,一个跳转指令,另一个是中断。两者的区别是,跳转指令,需要在编程的时候就知道要跳转的位置,而中断则不然,中断通常是由硬件来执行的跳转动作,它的跳转目标通常是一个提前约定好的固定位置。另外值得一提的是,虽然在之间的介绍中,中断是由CPU的硬件接口来引发的,但事实上,CPU通常也都设计了中断指令,可以通过指令代码来引发中断。
有了这个前提,我们就可以这样设计进程切换的逻辑:
在进程代码中,当遇到硬盘访问这样耗时操作的时候,我们就插入一条特定的中断指令(现实中,通常是由操作系统自动插入的)。这条中断指令的跳转地址是事先约定好的,操作系统提前在那个地址上写入了一段处理进程切换的程序。这段程序的主要逻辑是:
1、在进程槽中找到当前进程(也就是跳转之前的进程,例如:进程一)的信息,更新当前执行到的指令的位置(也就是原指令流中,中断指令的下一条指令)
2、依次查看其他槽位,找出另外一个可以执行的进程(例如:进程二)
3、从进程二的槽位中取出它的当前指令的地址,进行跳转,进而从那个位置开始执行进程二的指令流。
这样,就完成了从进程一到进程二的切换。当硬盘完成读写后,会发生一个硬件中断,这个中断的类型跟前面提及的自动插入的中断指令的类型是一样的,所以也会跳转到同一个中断处理地址(也就是进程切换程序的地址)。于是,进程切换程序再次被执行:
1、在进程槽中找到当前进程(这次是进程二)的信息,更新当前执行到的指令的位置(进程二指令流中下一条指令的位置)
2、依次查看其他槽位,找出另外一个可以执行的进程(如果系统中只有进程一和进程二,则这次会找到进程一)
3、从进程一的槽位中取出它的当前指令的地址(就是之前保存的地址),进行跳转,进而从上次硬盘访问中断处继续执行。
这样,就实现了从进程二切换回进程一的过程。
回顾实现进程切换的整个逻辑,有两个非常关进的要素。第一个是需要保存进程的“相关信息”,例如指令流的位置,现实世界中,要保存的信息显然更多,包括进程号、优先级、进程状态、权限,等等。Linux把这些信息保存在一个叫做task_struct的结构体当中。另一个要素是“中断”,因为有中断,CPU才能够在各个任务之间进行切换,且不依赖于每个任务的具体实现(换句话说,不依赖跳转语句,跟指令流的具体实现无关)。
从编程接口的角度,操作系统在进程管理方面主要提供了进程创建、进程销毁、进程调度三种功能支持。
进程的创建与销毁,在本质上对应了进程数据结构(Linux下是task_struct)的创建和销毁。虽然在具体实现上,不同的操作系统在细节上差异很大(比如Linux的fork()和Windows的 CreateProcess()在设计理念上都很迥异),但在进程数据结构层面又有很多相似性。进程数据结构中保存着描述进程状态的一切信息,它就好像游戏的存档一样,即使我们不再运行游戏(就好像CPU切出了进程,不再运行),但等下次再打开游戏加载回存档(就好像CPU再次切回进程,继续执行),就会跟之前的进度一样,好像我们从来没有离开过。
进程的调度,是操作系统的一个重要策略。我们前面描述的硬盘读写的例子,只是进程调度中一种非常简单的情况。现实世界中,即使进程中不涉及读写操作,也有可能因为时间片耗尽、或者等待事件等原因而发生切换。
需要特别强调一点,准确来说,进程调度是一种系统策略,而并非是一种接口功能。接口,是面向使用者的。进程的创建和销毁,都可以看做是接口功能(系统调用函数),使用者(程序员)可以使用它们来在操作系统中创建和销毁一个进程。但对进程调度来说,并不严格地存在某种系统调用能直接干预进程的调度状态,我们可以通过一些系统调用使得CPU暂停某个进程的执行(例如Wait()函数),但却没有一种很明确的方法能够让CPU立刻切换到某个进程继续执行,这取决于进程当时的状态以及系统的进程调度策略本身。(这一段有点晦涩,看不懂也没关系)
有关进程,本节就先聊到这里。我只关注基本原理,不纠缠细节。至于更具体的 task_struct的字段、进程调度的策略细节,等等,需要的话哪里都能查得到,原理懂了,一切不在话下。