第三章 进程
面对计算机系统中少数几个CPU,多个程序在执行时都想占有它并独自在上面运行,但CPU本身并没有分身术,因此互不相让的程序之间可能会厮打起来。作为管理者的操作系统,决不能袖手旁观。于是,操作系统的设计者发明了进程这一概念。
3.1 进程介绍
现在所有计算机都能同时做几件事情。 例如,用户一边运行浏览器程序上网浏览信息,一边运行字处理程序编辑文档。在一个多程序系统中,CPU由这道程序向那道程序切换,使每道程序运行几十或几百毫秒。然而严格地说,在一个瞬间,CPU只能运行一道程序。但在一段时期内,它却可能轮流运行多个程序,这样就给用户一种并行的错觉。有时人们称其为伪并行——就是指CPU在多道程序之间快速地切换,以此来区分它与多处理机(两个或更多的CPU共享物理存储器)系统真正的硬件并行。由于人们很难对多个并行的活动进行跟踪。因此,经过多年的探索,操作系统的设计者抽象出了进程这样一个逻辑概念,使得并行更容易被理解和处理。
3.1.1 程序和进程
程序是一个普通文件,是机器代码指令和数据的集合,这些指令和数据存储在磁盘上的一个可执行映象(Executable Image)中。所谓可执行映象就是一个可执行文件的内容,例如,你编写了一个C源程序,最终这个源程序要经过编译、连接成为一个可执行文件后才能运行。源程序中你要定义许多变量,在可执行文件中,这些变量就组成了数据段的一部分;源程序中的许多语句,例如“i++;for (i=0;i<10;i++)
”等,在可执行文件中,它们对应着许多不同的机器代码指令,这些机器代码指令经CPU执行,就完成了你所期望的工作。可以这么说,程序代表你期望完成某工作的计划和步骤,它还浮在纸面上,等待具体实现。而具体的实现过程就是由进程来完成的,可以认为进程是运行中的程序,它除了包含程序中的所有内容外,还包含一些额外的数据。
我们知道,程序装入内存后才得以运行。在程序计数器的控制下,指令被不断地从内存取至CPU中运行。实际上,程序的执行过程可以说是一个执行环境的总和,这个执行环境包括程序中各种指令和数据外,还有一些额外数据,比如寄存器的值、用来保存临时数据(例如传递给某个函数的参数、函数的返回地址、保存的临时变量等)的堆栈、被打开的文件及输入输出设备的状态等等。上述执行环境的动态变化表征了程序的运行。为了对这个动态变化的过程进行描述,程序这个概念已经远远不够,于是就引入了“进程”概念。进程代表程序的执行过程,它是一个动态的实体,随着程序中指令的执行而不断地变化。在某个时刻进程的内容被称为进程映像(Process Image)
Linux是多任务操作系统,也就是说可以有多个程序同时装入内存并运行,操作系统为每个程序建立一个运行环境即创建进程。从逻辑上说,每个进程拥有它自己的虚拟CPU。当然,实际上真正的CPU在各进程之间来回切换。但如果我们想研究这种系统,而去跟踪CPU如何在程序间来回切换将会是一件相当复杂的事情,于是换个角度,集中考虑在(伪)并行情况下运行的进程集就使问题变得简单、清晰得多。这种快速的切换称作多道程序执行。在一些Unix书籍中,又把“进程切换”(Process Switching)称为“环境切换”或“上下文切换”(Context Switching)。这里“进程的上下文”就是指进程的执行环境。
进程运行过程中,还需要其他的一些系统资源,例如,要用CPU来运行它的指令、要用系统的物理内存来容纳进程本身和它的有关数据、要在文件系统中打开和使用文件、并且可能直接或间接的使用系统的物理设备,例如打印机、扫描仪等。由于这些系统资源是由所有进程共享的,所以操作系统必须监视进程和它所拥有的系统资源,使它们可以公平地拥有系统资源以得到运行。
由此,我们对进程作一明确定义:所谓进程是由正文段(text)、用户数据段(user segment)以及系统数据段(system segment)共同组成的一个执行环境,如图3.1所示。
(1)正文段(text):存放被执行的机器指令。这个段是只读的,它允许系统中正在运行的两个或多个进程之间能够共享这一代码。例如,有几个用户都在使用文本编辑器,在内存中仅需要该程序指令的一个副本,他们全都共享这一副本。
(2)用户数据段(user segment):存放进程在执行时直接进行操作的所有数据,包括进程使用的全部变量在内。显然,这里包含的信息可以被改变。虽然进程之间可以共享正文段,但是每个进程需要有它自己的专用用户数据段。例如同时编辑文本的用户,虽然运行着同样的程序—编辑器,但是每个用户都有不同的数据:正在编辑的文本。
(3)系统数据段(system segment):该段有效地存放程序运行的环境。事实上,这正是程序和进程的区别所在。如前所述,程序是由一组指令和数据组成的静态事物,它们是进程最初使用的正文段和用户数据段。作为动态事物,进程是正文段、用户数据段和系统数据段的信息的交叉综合体,其中系统数据段是进程实体最重要的一部分,之所以说它有效地存放程序运行的环境,是因为这一部分存放有进程的控制信息。系统中有许多进程,操作系统要管理它们、调度它们运行,就是通过这些控制信息。Linux为每个进程建立了task_struct数据结构来容纳这些控制信息。
假设有三道程序A、B、C在系统中运行。程序一旦运行起来,我们就称它为进程,因此称它们为三个进程Pa、Pb、Pc。假定进程Pa执行到一条输入语句,因为这时要从外设读入数据,于是进程Pa主动放弃CPU。此时操作系统中的调度程序就要选择一个进程投入运行,假设选中Pc,这就会发生进程切换,从Pa切换到Pc。同理,在某个时刻可能切换到进程Pb。从某一时间段看,三个进程在同时执行,从某一时刻看,只有一个进程在运行,我们把这几个进程的伪并行执行叫做进程的并发执行。
在Linux系统中我们还可以使用ps命令来查看当前系统中的进程和进程的一些相关信息。使用这个命令我们就可以查看系统中所有进程的状态。该命令可以确定有哪些进程正在运行和运行的状态、进程是否结束、进程有没有僵死、哪些进程占用了过多地资源等等。例如:ps -e
$ ps -e
PID TTY TIME CMD
1 ? 00:00:00 init
2 ? 00:00:00 kthreadd
2102 ? 00:04:04 firefox-bin
2206 pts/0 00:00:00 bash
2211 pts/0 00:00:05 fcitx
2809 ? 00:00:01 stardict
3317 ? 00:00:05 qq
……
这里只是截取了部分进程和部分信息,即进程的进程号、进程相关的终端(?表示进程不需要终端)、进程已经占用CPU的时间和启动进程的程序名。在后面的学习中还要使用这个命令来查看进程的其它信息。
3.1.2 进程的层次结构
进程是一个动态的实体,它具有生命周期,系统中进程的生生死死随时发生。因此,操作系统对进程的描述模仿人类活动。一个进程不会平白无故的诞生,它总会有自己的父母。在Linux中,通过调用fork系统调用来创建一个新的进程。新创建的子进程同样也能执行fork,所以,有可能形成一颗完整的进程树。注意,每个进程只有一个父进程,但可以有0个、1个、2个或多个子进程。
从身边的例子体验进程树的诞生,比如Linux的启动。Linux在启动时就创建一个称为init的特殊进程,顾名思义,它是起始进程,是祖先,以后诞生的所有进程都是它的后代——或是它的儿子,或是它的孙子。init进程为每个终端(tty)创建一个新的管理进程,这些进程在终端上等待着用户的登录。当用户正确登录后,系统再为每一个用户启动一个shell进程,由shell进程等待并接受用户输入的命令信息,如图3.2是一颗进程树。
此外,init进程还负责管理系统中的“孤儿”进程。如果某个进程创建子进程之后就终止,而子进程还“活着”,则子进程成为孤儿进程。init进程负责“收养”该进程,即孤儿进程会立即成为init进程的儿子,也就说,init进程承担着养父的角色。这是为了保持进程树的完整性。
在Linux系统中可以使用pstree命令来查看系统中的树形结构;pstree将所有进程显示为树状结构,以清楚地表达程序间的相互关系。从该命令的显示结果可以看到,init进程是系统中唯一一个没有父进程的进程,它是系统中的第一个进程,其它进程都是由它和它的子进程产生的。
另外ps命令也可以显示进程的树形结构,例如:
$ ps –ejH
3.1.3 进程状态
为了对进程从产生到消亡的这个动态变化过程进行捕获和描述,就需要定义进程各种状态并制定相应的状态转换策略,以此来控制进程的运行。
因为不同操作系统对进程的管理方式和对进程的状态解释可以不同,所以不同操作系统中描述进程状态的数量和命名也会有所不同,但最基本的进程状态有三种:
(1) 运行态: 进程占有CPU,并在CPU上运行。
(2) 就绪态: 进程已经具备运行条件, 但由于CPU忙而暂时不能运行
(3) 阻塞态(或等待态): 进程因等待某种事件的发生而暂时不能运行。(即使CPU空闲, 进程也不可运行)。
进程在生命期内处于且仅处于三种基本状态之一,如图3.3。
这三种状态之间有四种可能的转换关系:
① 运行态阻塞态: 进程发现它不能运行下去时发生这种转换。这是因为进程发生I/O请求或等待某件事情。
② 运行态就绪态:在系统认为运行进程占用CPU的时间已经过长,决定让其它进程占用CPU时发生这种转换。这是由调度程序引起的。调度程序是操作系统的一部分,进程甚至感觉不到它的存在。
③ 就绪态运行态:运行进程已经用完分给它的CPU时间,调度程序从处于就绪态的进程中选择一个投入运行。
④ 阻塞态就绪态:当一个进程等待的一个外部事件发生时(例如输入数据到达),则发生这种转换。如果这时没有其它进程运行,则转换③立即被触发,该进程便开始运行。
3.1.4进程举例
Linux系统中,用户在程序中可以通过调用fork 系统调用来创建进程。调用进程叫父进程(parent),被创建的进程叫子进程(child)。现在举一个简单的C程序forktest.c,说明进程的创建及进程的并发执行。
#include <sys/types.h> /* 提供类型pid_t的定义,在PC机上与int型相同 */
#include <unistd.h> /* 提供系统调用的定义 */
#include <stdio.h> /* 提供基本输入输出函数,如printf */
void do_something(long t)
{
int i = 0;
for (i = 0; i < t; i++)
for (i = 0; i < t; i++)
for (i = 0; i < t; i++)
;
}
int main()
{
pid_t pid;
/*此时仅有一个进程*/
printf("PID before fork():%d\n", getpid());
pid = fork();
/*此时已经有两个进程在同时运行*/
pid_t npid = getpid();
if (pid < 0 )
perror("fork error\n");
else if (pid == 0) { /*pid == 0 表示子进程*/
while (1) {
printf("I am child process, PID is %d\n", npid);
do_something(10000000);
}
} else if (pid >= 0) { /*pid > 0 表示父进程*/
while (1) {
printf("I am father process, PID is %d\n", npid);
do_something(10000000);
}
}
return 0;
}
在Linux运行的每个进程都有一个唯一的进程标识符PID(Process Identifier)。从进程ID的名字就可以看出,它就是进程的身份证号码,每个人的身份证号码都不会相同,每个进程的进程ID也不会相同。系统调用getpid()就是获得进程标识符。pid_t是用于定义进程PID的一个类型,而实际上就是int型的。
先来编译并运行这个程序:
$ ./fork_test
PID before fork():3991
I am child process, PID is 3992
I am child process, PID is 3992
I am father process, PID is 3991
I am child process, PID is 3992
I am father process, PID is 3991
I am child process, PID is 3992
....
可以看到这里输出了“child proces”和“father process”,它们的PID是不一样的,而且是在“不规则”的交替出现。这其实这就是进程的创建和并发执行了。
从概念上讲,fork()就像细胞的裂变,调用fork()的进程就是父进程,而新裂变出的进程就是子进程。新创建的进程与父进程几乎完全相同,只有少量属性必须不同,例如,每个进程的PID必须是唯一的。调用fork()后,子进程被创建,此时父进程和子进程都从这个系统调用内部继续运行。为了区分父/子进程,fork()给两个进程返回不同的值。对父进程,fork()返回新创建子进程的进程标识符(PID),而对子进程,fork()返回值0,这一概念表示在图3.4中。
当上面那个程序运行时,它会不断的输出信息。第一行将显示fork()被执行前进程的PID,其余的输出行将在fork()执行后由父进程和子进程产生,也就是说,当执行到fork()这个系统调用时,一个进程裂变为两个进程,这两个进程并发执行,到底哪个进程先执行,我们在这里没有控制,不过,系统一般默认子进程先执行。
再键入ps命令查看一下目前系统中进程的状态和关系:
$ ps lf
UID PID PPID PRI NI STAT TTY TIME COMMAND
1000 3836 2204 20 0 Ss pts/2 0:00 bash
1000 4008 3836 20 0 R+ pts/2 0:00 _ ps lf
1000 2206 2204 20 0 Ss pts/0 0:00 bash
1000 3391 2206 20 0 R+ pts/0 0:00 _ ./fork_test
1000 3392 3391 20 0 R+ pts/0 0:00 _ ./fork_test
…
为了清晰起见,删除了部分列。这里主要说明其中的PID和PPID列,它们分别表示本进程的PID和父进程的PID。可以看到PID为3391的fork_test和PID为3392的fork_test,尽管名字相同,因PID不同实际上是两个不同的进程。3391的父进程是PID为2206的bash进程,3392的父进程就是3391.
通过这个简单的例子使读者对进程有初步的认识,尤其是初步感受一下进程的并发执行。对这个例子的进一步理解请看3.6节。