前言
上次我们已经介绍了操作系统的基本概念:啃碎操作系统(一):操作系统概念
今天来看看操作系统的核心概念:进程
正文
所有现代计算机经常会在同一时间做许多事情,在任何多道程序设计系统中,CPU由一个进程快速切换到另一个进程,使每个进程各运行几十或几百毫秒。严格来说,在某一个瞬间,CPU只能运行一个进程。但在1秒钟内,它可以运行多个进程,这样就产生并行的错觉。
进程模型
计算机上所有可运行的软件、通常也包括操作系统,被组织成若干顺序进程,简称进程
。一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。
假设一台单核多道程序计算机的内存中有4道程序:
从上图可以看出在任意给定的瞬间仅有一个进程真正在运行。
进程的创建
操作系统需要有一种方式来创建进程,有4种主要事件会导致进程的创建:
- 系统初始化
在启动操作系统的时候,通常会创建若干个进程。其中有些是前台进程
,也就是同用户交互并且替他们完成工作的那些进程。其它的是后台进程,这些进程与特定的用户没有关系,相反,却具有某些专门的功能。例如,设计一个后台进程来接收发来的电子邮件,这个进程在一天的大部分时间都在睡眠,当电子邮件到达时才会被唤醒。这种停留在后台处理任务的进程称为守护进程
。
- 正在运行的程序执行了创建进程的系统调用
除了在系统启动阶段创建进程之外,新的进程也可以由进程创建。一个正在运行的进程经常发出系统调用,以便创建一个或多个新进程协助其工作。例如,如果有大量的数据要通过网络调取并进行顺序处理,那么创建一个进程读取数据,并把数据放入到共享缓冲区中,而让第二个进程取走数据并处理,应该比较容易。在多核计算机中,让每个进程在不同的CPU上运行会使整个作业运行得更快。
- 用户请求创建一个新进程
在交互式系统中,输入一个命令或者双击一个图标就可以启动一个程序。
- 一个批处理作业的初始化
最后一种创建进程的情形仅在大型机的批处理系统中应用。在操作系统认为资源科运行另一个作业时,它创建一个新的进程,并运行其输入队列中的下一个作业。
从技术上来看,所有这些情形中,新进程都是由于一个与存在的进程执行了一个用于创建进程的系统调用而创建的。这个进程可以是一个运行的用户进程、一个有键盘或鼠标启动的系统进程或者一个批处理管理进程。
在UNIX系统中,只有一个系统调用可以用来创建新进程:fork
。这个系统调用会创建一个与调用进程相同的副本。在调用了fork
之后,这两个进程(父进程和子进程)拥有相同的内存映像、同样的环境字符串和同样的打开文件。通常子进程会接着执行execve
或一个类似的系统调用,以修改其内存映像并运行一个新的程序。
进程的终止
一个进程不会永恒执行,它通常由下列条件引起终止:
- 正常退出(自愿的)
- 出错退出(自愿的)
- 严重错误(非自愿)
- 被其它进程杀死(非自愿)
多数进程是由于完成了它们的工作而终止。当编译器完成了所给定程序的编译之后,编译器执行一个系统调用,通知操作系统它的工作已经完成。在UNIX中该调用是exit
。在其它一些软件比如Internet浏览器中总有一个供用户点击的图标或菜单项,用来通知进程删除它所打开的任何临时文件,然后终止。
进程终止的第二个原因是进程发生了严重错误。例如,如果用户键入命令:cc foo.c
要编译程序foo.c,但是该文件并不存在,于是编译器就会退出。
进程终止的第三个原因就是由进程引起的错误,通常是由于程序中的错误所致。例如,执行了一条非法指令、引用不存在的内存,或是除数是零等。有些系统中(如UNIX),进程可以通知操作系统,它希望自行处理某些类型的错误,在这类错误中,进程会收到信号(被中断),而不是在这类错误出现时终止。
第四种终止进程的原因是,某个进程执行一个系统调用通知操作系统杀死某个其它进程。在UNIX中,这个系统调用是kill
。在这种情形下必须获得确定的授权才能杀死其它进程。
进程的层次结构
某些系统中,当进程创建了另一个进程后,父进程和子进程就以某种形式继续保持关联。子进程自身可以创建更多的进程,组成一个进程的层次结构。
在UNIX中,进程和它的所有子进程以及后裔共同组成一个进程组。当用户从键盘发出一个信号时,该信号被送给当前与键盘相关的进程组中的所有成员(它们通常是在当前窗口创建的所有活动进程)。每个进程可以分别捕获该信号、忽略该信号或采取默认的动作。
进程的状态
尽管每个进程是一个独立的实体,有其自己的程序计数器和内部状态。但是,进程之间经常需要相互作用。一个进程的输出结果可能作为另一个进程的输入。
比如下面这条shell指令
cat chapter1 chapter2 chapter3 | grep tree
第一个进程运行cat,将三个文件连接并输出。第二个进程运行grep,它从输入中选择所有包含单词"tree"的那些行。根据这两个进程的相对速度(这取决于这两个程序的相对复杂度和各自分配到的CPU时间),可能发生这种情况:grep准备就绪可以运行,但输入还没有完成,于是必须阻塞grep,直到输入到来。
当一个进程在逻辑上不能继续运行时,它就会被阻塞,典型的例子是它在等待可以使用的输入。还可能有这样的情况:一个概念上能够运行的进程被迫停止,因为操作系统调度另一个进程占用了CPU。这两种情况是完全不同的。在第一种情况下,进程挂起是程序自身导致的原因(在键入用户命令行之前,无法执行命令)。第二种情况则是由系统技术上的原因引起的(由于没有足够的CPU,所以不能使每个进程都有一台私用的处理器)。在下图中可以看到显示进程三种状态的状态图:
进程有三种状态,分别是:
(1)运行态
(该时刻进程实际占用CPU)
(2)就绪态
(可运行,但因为其它进程正在运行而暂时停止)
(3)阻塞态
(除非某种外部事件发生,否则进程不能运行)
前两种状态在逻辑上市类似的。处于这两种状态的进程都可以运行,只是对于第二种状态暂时没有CPU分配给它。第三种状态与前两种不同,处于该状态的进程不能运行,即时CPU空闲也不行。
从上面图中我们可以看出进程的三种状态之间有四种可能的转换关系。首先在操作系统发现进程不能运行下去时,会发生 运行态
到 阻塞态
的转换。比方说在某些系统中,进程可以执行一个诸如 pause
的系统调用来进入阻塞状态。
运行态
和 就绪态
的相互转换是由进程调度程序引起的,进程调度程序是操作系统的一部分,进程甚至感觉不到调度程序的存在。系统认为一个运行进程占用CPU的时间已经过长,决定让其它进程使用CPU时间时,会发生 运行态
到 就绪态
的转换。在系统已经让所有其它进程享有了它们应有的公平待遇而重新轮到第一个进程再次占用CPU运行时,会发生 就绪态
到 运行态
的转换。调度程序的主要工作就是决定应当运行哪个进程、何时运行及它应该运行多长时间,这是很重要的一点,我们会在后续章节再进行讨论。
当进程等待的一个外部事件发生时(如一些输入到达),则发生 阻塞态
到 就绪态
的转换。如果此时没有其它进程运行,则立刻触发就绪态到运行态的转换,该进程便可以运行,否则该进程将处于就绪态,等待CPU空闲并且轮到它运行。
进程的实现
为了实现进程模型,操作系统维护着一张表格,即 进程表
。每个进程占用一个进程表项(有些作者称这些表项为进程控制块)。该表包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其它在进程由运行态转换到就绪态或阻塞态时必须保存的信息。从而保证该进程随后能再次启动,就像从未被中断过一样。
下图给出了进程表的一些关键字段:
这里我们要介绍一个新的概念:中断向量
。
在系统里面有一个 中断向量表
用来为每种设备配以相应的中断处理程序,把该程序的入口地址放在中断向量表的一个表项中,并规定一个中断号用于设备的中断请求。
假设当一个磁盘中断发生时,用户进程正在运行,则中断硬件将程序计数器、程序状态字、有时还有一个或多个寄存器压入堆栈,计算机从中断向量表中取得地址然后跳转。这些都是硬件完成的所有操作,然后软件就接管一切剩余的工作。
一个进程在执行过程中可能被中断数千次,但关键是每次中断结束后,被中断的进程都返回到与中断发生前完全相同的状态。
线程
看下维基百科关于线程的定义:
线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈,自己的寄存器环境,自己的线程本地存储。
线程的使用
我们为什么需要线程呢?
人们需要多线程的主要原因是,在许多应用中同时发生着多种活动。其中某些活动随着时间的推移会被阻塞。通过将这些应用程序分解成并行运行的多个线程,程序设计模型会变得更加简单。
第二个关于需要多线程的理由是,由于线程比进程更轻量级,所以它们比进程更容易创建,也更容易销毁。在许多系统中,创建一个线程较创建一个进程要快10\~100倍。在有大量线程需要快速修改时,具有这一特性是很有用的。
需要多线程的第三个原因涉及性能方面的讨论。若多个线程都是CPU密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠进行,从而会加快应用程序执行的速度。
如果没有多线程的话会怎么样呢?现在考虑在没有多线程的情形下,如何编写Web服务器。一种可能的方式是,使其像一个线程一样运行。Web服务器接收请求,并且在接收下一个请求之前完成整个工作。在等待磁盘操作时,CPU就空转,并且不处理任何接收到的其它请求,这样的话导致每秒钟只有很少的请求被处理。使用多线程较好地改善了Web服务器的性能。
经典的线程模型
进程有存放程序正文和数据以及其它资源的地址空间。这些资源中包括打开的文件、子进程、即将发生的定时器、信号处理程序、账号信息等。把它们都放到进程中可以更容易管理。
在线程中有一个程序计数器
,用来记录接着要执行哪一条指令。线程拥有寄存器,用来保存线程当前的工作变量。线程还拥有一个堆栈
,用来记录执行历史,其中每一帧保存了一个已调用的但是还没有从中返回的过程。尽管线程必须在某个进程中执行,但是线程和它的进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。
上图可以看到三个传统的进程,每个进程有自己的地址空间和线程,每一个线程都在不同的地址空间中运行。
上图则可以看到一个进程带有三个线程,这三个线程全部都在相同的地址空间中运行。
我们之前已经看到了进程的多道程序设计师如何工作的。通过在多个进程之间来回切换,系统制造了不同的顺序进程并行地假象。多线程的工作方式也是类似的。CPU在线程之间的快速切换,制造了线程并行的假象。
进程中的不同线程不像不同进程之间那样存在很大的独立性。所有的线程都有完全一样的地址空间,这意味着它们也共享同样的全局变量。由于各个线程都可以访问进程地址空间中的每一个内存地址,所以一个线程可以读、写或甚至清除另一个线程的堆栈。
下图第一列给出了在一个进程中所有线程共享的内容,第二列给出了每个线程自己的内容。
和传统进程一样,线程可以处于若干种状态的任何一个:运行、阻塞、就绪或终止。正在运行的线程拥有CPU并且是活跃的。被阻塞的线程正在等待某个释放它的事件。例如,当一个线程执行从键盘读入数据的系统调用时,该线程就被阻塞直到键入了输入位置。线程可以被阻塞,以便等待某个外部事件的发生或者等待其它线程来释放它。就绪线程可被调度运行。线程状态之间的转换和进程状态之间的转换是一样的。
有一点需要我们注意的是:每个线程都拥有自己的堆栈
每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程使用。在该栈帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。例如,如果过程X调用过程Y,而Y又调用Z,那么当Z执行时,供X、Y和Z使用的栈帧会全部存放在堆栈中。通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程需要有自己的堆栈的原因。
在多线程的情况下,进程通常会从当前的单个线程开始。这个线程可以通过一个库函数(如thread\_create)创建新的线程,新线程会自动在创建线程的地址空间中运行。
当一个线程完成工作后,可以通过调用一个库过程(如thread\_exit)退出。该线程接着消失,不再可调度。在某些线程系统中,通过调用一个过程,例如thread\_join,一个线程可以等待一个特定线程退出,这个过程阻塞调用线程直到那个特定线程退出。
另一个常见的线程调用是thread\_yield,它允许线程自动放弃CPU从而让另一个线程运行。这样一个调用是很重要的,因为不同于进程,我们无法利用中断强制让线程让出CPU。所以设法使线程自动交出CPU,以便让其它线程有机会运行就变得非常重要。
通常而言,线程是有益的,但是线程也在程序设计模式中引入了某种程度的复杂性。考虑一下UNIX中的fork系统调用。如果父进程有多个线程,那么它的子进程也应该拥有这些线程吗?如果不是,则该子进程可能会工作不正常,因为在该子进程中的线程都是绝对必要的。
如果一个线程关闭了某个文件,而另一个线程还在该文件上进行读操作时会怎样?假设有一个线程注意到几乎没有内存了,并开始分配更多的内存。在工作一半的时候,发生线程切换,新线程也注意到几乎没有内存了,并且也可以分配更多的内存。这样,内存可能会被分配两次。总之,要使多线程的程序正确工作,就需要仔细思考和设计。
进程间通信
进程经常需要与其它进程通信。例如,在一个shell管道中,第一个进程的输入必须传送给第二个进程。
竞争条件
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件。调试包含竞争条件的程序是一件头痛的事,大多数的测试运行结果都很好,但在极少数情况下会发生一些无法解释的奇怪现象。
临界区
怎样避免竞争条件?实际上凡涉及共享内存、共享文件以及共享任何资源的情况都会引发与前面类似的错误,要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。换言之,我们需要的是互斥
,即以某种手段确保当一个进程在使用一个共享变量或文件时,其它进程不能做同样的操作。
在某些时候进程可能需要访问共享内存或共享文件,或执行另外一些会导致竞争的操作。我们把对共享内存进行访问的程序片段称作临界区
。如果我们能够适当地安排,使得两个进程不可能同时处于临界区中,就能够避免竞争条件。
尽管这样的要求避免了竞争条件,但它还不能保证使用共享数据的并发过程能够正确和高效地进行协作。对于一个好的解决方案,需要满足以下4个条件:
- 任何两个进程不能同时处于其临界区
- 不应对CPU的速度和数量作出假设
- 临界区外运行的进程不得阻塞其它进程
- 不能使进程无限期等待进入临界区
睡眠与唤醒
当一个进程想进入临界区时,先检查是否允许进入,若不允许,则该进程将原地等待,直到允许为止。
这种方法不仅浪费了CPU时间,而且还可能引起预想不到的结果。考虑一台计算机有两个进程,H优先级较高,L优先级较低。调度规则规定,只要H处于就绪态就可以运行。在某一时刻,L处于临界区中,此时H变到就绪态,准备运行。现在H开始等待,由于当H就绪时L不会被调度,也就无法离开临界区,所以H将永远等待下去。这种情况有时被称为优先级反转问题。
现在我们来了解几条进程间通信原语,它们将在无法进入临界区时阻塞,而不是忙等待。最简单的是 sleep
和 wakeup
。 sleep
是一个将引起调用进程阻塞的系统调用,即被挂起,直到另外一个进程将其唤醒。wakeup
用来唤醒一个线程。
生产者-消费者问题
我们来考虑生产者-消费者问题:两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,将数据放入缓冲区。另外一个是消费者,从缓冲区取出数据。
问题在于当缓冲区已满,而此时生产者还想放入数据到缓存区中。其解决办法是让生产者睡眠,等待消费者从缓冲区中取出一个或多个数据时再唤醒它。同样地,当消费者试图从缓存区取数据而发现缓冲区为空时,消费者就睡眠,直到生产者向其中放入一些数据再将其唤醒。
这个方法听起来简单,但是也存在和前面一样的问题。为了跟踪缓冲区中的数据项,需要一个变量count
。如果缓冲区最多存放N个数据项,则生产者代码将首先检查count
是否达到N,若是,则生产者睡眠,否则生产者向缓冲区放入一个数据项并增加count
的值。
消费者的代码与此类似:首先检查count
是否为0,若是,则睡眠,否则从中取走一个数据项并递减count
的值。
伪代码如下:
这里可能会出现问题的原因是对count
的访问未做限制。有可能出现以下情况:缓冲区为空,消费者刚刚读取count
的值发现它为0。此时调度程序决定暂停消费者并启动运行生产者。生产者向缓冲区加入一个数据项,count
加1。现在count
的值变成了1。它推断认为由于刚才count
为0,所以消费者此时一定在睡眠,于是生产者调用wakeup
来唤醒消费者。
但是,此时消费者并未睡眠,所以wakeup
信号丢失。当消费者下次运行时,因为之前读到count
值为0,于是消费者进入睡眠。生产者迟早会填满整个缓冲区,然后睡眠。这样一来,两个进程将永远睡眠下去。
信号量
信号量
指的是使用一个整型变量来累计唤醒次数。一个信号量的取值可以为0(表示没有保存下来的唤醒操作)或者为正值(表示有一个或多个唤醒操作)。
假设有down
和up
两种操作,对一信号量执行down
操作,则是检查其值是否大于0。若该值大于0,则将其值减1,若该值为0,则进程将睡眠。这里要注意的是:检查数值、修改变量值以及可能发生的睡眠操作均为一个单一的、不可分割的原子操作。保证一旦一个信号量操作开始,则在该操作完成或阻塞之前,其它进程均不允许访问该信号量。这种原子性对于解决同步问题和避免竞争条件是绝对必要的。
互斥量
互斥量是一个可以处于两态之一的变量:解锁和加锁。实现上常常使用一个整型量,0表示解锁,其它的值表示加锁。
当一个线程或进程需要访问临界区时,它调用mutex_lock
,如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock
。如果多个线程被阻塞在该互斥量上,则随机选择一个线程并允许它获得锁。
总结
有关进程的知识就到这里,有什么不对的地方请多多指教!
参考
《现代操作系统》