(一)进程族系
就如同人类的族系一样,系统中众多的进程也存在族系关系:由父进程创建子进程,子进程再创建子进程,从而构成一棵树形的进程族系图,如图2-9所示。图中结点代表进程。
图2-9 进程创建的层次关系
在开机时,首先引导操作系统,把它装入内存。之后生成第一个进程(在UNIX中称作0号进程),由它创建1号进程及其它核心进程;然后1号进程又为每个终端创建命令解释进程(shell进程);用户输入命令后又创建若干进程。这样便形成了一棵进程树。树的根结点(即第一个进程0#)是所有进程的祖先。上一层结点对应的进程是其直接相连的下一层结点对应进程的父进程,例如1#进程是P20,P21,P2n这些进程的父进程。
(二)进程管理
进程管理主要包括创建进程、终止进程、阻塞进程、唤醒进程和更换进程映像等。
· 创建进程
一个进程可以动态地创建新进程,前者称作父进程,后者称作子进程。引发创建进程的事件通常是调度新的批作业、交互式用户登录、操作系统提供服务和现有进程派生新进程。
创建新进程时要执行创建进程的系统调用(如UNIX/Linux系统中的fork),其主要操作过程有如下4步:
(1)申请一个空闲的PCB。从系统的PCB表中找出一个空闲的PCB项,并指定惟一的进程标识号PID(即进程内部名)。
(2)为新进程分配资源。根据调用者提供的所需内存的大小,为新进程分配必要的内存空间,存放其程序和数据及工作区。
(3)将新进程的PCB初始化。根据调用者提供的参数,将新进程的PCB初始化。这些参数包括新进程名(外部标识符)、父进程标识符、处理机初始状态、进程优先级、本进程开始地址等。一般将新进程状态设置为就绪状态。
(4)将新进程加到就绪队列中。一个进程派生新进程后,有两种可能的执行方式:
① 异步方式。即:父、子进程分道扬镳——父进程继续运行,子进程也可以被调度运行。
② 同步方式。即:父进程睡眠,等待它的某个或全部子进程终止,然后再继续运行。
建立子进程的地址空间也有两种可能的方式:
① 子进程复制父进程的地址空间;
② 把新的程序装入子进程的地址空间。
不同的操作系统采用不同的实现方式来创建进程。例如在UNIX系统中,每个进程有惟一的进程标识号(即PID),父进程利用fork系统调用创建新进程。父进程创建子进程时,把自己的地址空间制作一个副本,其中包括user结构、正文段、数据段、用户栈和系统栈。这种机制使得父进程很容易与子进程通信。两个进程都可以继续执行fork系统调用之后的指令,但有一个差别:fork的返回值(即子进程的PID)不等于0时,表示父进程在执行;而返回值等于0时,表示子进程在执行。linux系统中也采用这种方式。
· 终止进程
当一个进程完成自己的任务后要终止自己,这是正常终止。如果进程在运行期间由于出现某些错误和故障而被迫终止,这是非正常终止。也可应外界的请求(如父进程请求)而终止进程。
一旦系统中出现要求终止进程的事件后,便调用进程终止原语(原语是其操作不可分割的一段系统程序)。终止进程的主要操作过程是:
(1)从系统的PCB表中找到指定进程的PCB。若它正处于运行态,则立即终止该进程的运行。
(2)回收该进程所占用的全部资源。
(3)若该进程还有子孙进程,则还要终止其所有子孙进程,回收它们所占用的全部资源。
(4)释放被终止进程的PCB,并从原来队列中摘走。
· 阻塞进程
正在运行的进程因提出的服务请求未被操作系统立即满足,或者所需数据尚未到达等原因,只能转变为阻塞态,等待相应事件出现后再把它唤醒。
正在运行的进程通过调用阻塞原语(如UNIX / Linux系统的sleep),主动地把自己阻塞。进程阻塞的过程如下:
(1)立即停止当前进程的执行;
(2)将现行进程的CPU现场送到该进程的PCB现场保护区中保存起来,以便将来重新运行时恢复此时的现场;
(3)把该进程PCB中的现行状态由"运行"改为阻塞,把它插入到具有相同事件的阻塞队列中。
(4)然后转到进程调度程序,重新从就绪队列中挑选一个合适进程投入运行。
· 唤醒进程
当阻塞进程所等待的事件出现时(如所需数据已到达,或者等待的I/O操作已经完成),则由另外的、与阻塞进程相关的进程(如完成I/O操作的进程)调用唤醒原语(如UNIX /Linux系统的wakeup),将等待该事件的进程唤醒。可见,阻塞进程不能唤醒自己。
唤醒原语执行过程是:
(1)首先把被阻塞进程从相应的阻塞队列中摘下;
(2)将现行状态改为就绪态,然后把该进程插入到就绪队列中;
(3)如果被唤醒进程比正在运行进程有更高的优先级,则设置重新调度标志。
阻塞原语与唤醒原语恰好是一对相反的原语:调用前者是自己去睡眠,调用后者是把“别人”唤醒。使用时也要成对,前边有睡的,后边要有叫醒的。否则,前者就要“长眠”了。
· 进程映像的更换
子进程被创建之后,通常处于“就绪态”。以后,被进程调度程序选中才可以运行,即取得CPU的控制权。在有些系统(如UNIX/Linux)中,由于创建子进程时是把父进程的映像复制给子进程,所以父子进程的映像基本相同。如果子进程不改变自己的映像,就必然重复父进程的过程,这不是我们所要求的。为此,要改变子进程的映像,使它执行自己的特定程序。
改变进程映像的工作很复杂,其主要过程是:
(1)释放子进程原来的程序和数据所占用的内存空间;
(2)从磁盘上找出子进程所要执行的程序和数据(通常以可执行文件的形式存放);
(3)分配内存空间,装入新的程序和数据;
(4)为子进程建立初始的运行环境——主要是对各个寄存器初始化,返回到用户态,运行该进程的程序。
应当指出,以上几个进程管理原语是基本的功能性描述。在不同的操作系统中有不同的实现方式。
(三)Linux进程管理
1.Linux进程状态
在Linux系统中,进程有下述5种状态:
(1)运行态(TASK_RUNNING)。此时,进程正在运行(即系统的当前进程)或准备运行(即就绪态)。当前进程由运行指针所指向。
(2)可中断等待态(TASK_INTERRUPTIBLE)。此时进程在“浅度”睡眠——等待一个事件的发生或某种系统资源,它能够被信号或中断唤醒。当所等待的资源得到满足时,它也被唤醒。
(3)不可中断等待态(TASK_UNINTERRUPTIBLE)。进程处于“深度”睡眠的等待队列中,不能被信号或中断唤醒,只有所等待的资源得到满足时,才能被唤醒。
(4)停止态(TASK_STOPPED)。通常由于接收一个信号,致使进程停止。正在被调试的进程可能处于停止态。
(5)僵死态(TASK_ZOMBIE)。由于某些原因,进程被终止了,但是该进程的控制结构task_struct仍然保留着。
如图2-10所示是Linux进程状态的变化。
图2-10 Linux进程状态的变化
2.进程的模式和类型
在Linux系统中,进程的执行模式划分为用户模式(用户态)和内核模式(核心态)。如果当前运行的是用户程序、应用程序或者内核之外的系统程序,那么对应进程就在用户模式下运行;如果在用户程序执行过程中出现系统调用或者发生中断事件,就要运行操作系统(即核心)程序,进程模式就变成内核模式。在内核模式下运行的进程可以执行机器的特权指令,并且该进程不受用户的干预,即使是root用户也不能干预内核模式下进程的运行。
按照进程的功能和运行的程序来分,进程划分为两大类:一类是系统进程,只运行在内核模式,执行操作系统代码,完成一些管理性的工作,如内存分配和进程切换。另一类是用户进程,通常在用户模式中执行,并通过系统调用或在出现中断、异常时进入内核模式。
用户进程既可以在用户模式下运行,也可以在内核模式下运行,如图2-11所示。
图2-11 用户进程的两种运行模式
3.Linux进程结构
· task_struct结构
Linux系统中的每个进程都有一个名为task_struct的数据结构,它相当于“进程控制块”。系统中有一个进程向量数组task,该数组的长度默认值是512B,该数组的元素是指向task_struct结构的指针。在创建新进程时,Linux就从系统内存中分配一个task_struct结构,并把它的首地址 加入task数组。当前正在运行的进程的task_struct结构用current指针指示。
task_struct结构包含下列信息:
(1)进程状态。
(2)调度信息。调度算法利用这个信息来决定系统中的哪一个进程需要执行。
(3)标识符。系统中每个进程都有唯一的一个进程标识符(PID)。PID并不是指向进程向量的索引,仅仅是一个数字而已。每个进程还包括用户标识符(UID)和用户组标识符(GID),用来确定进程对系统中文件和设备的存取权限。
(4)内部进程通信。Linux系统支持信号、管道、信号量等内部进程通信机制。
(5)链接信息。在Linux系统中,每个进程都与其他进程存在联系。除初始化进程外,每个进程都有父进程。该链接信息包括指向父进程、兄弟进程和子进程的指针。
(6)时间和计时器。内核要记录进程的创建时间和进程运行所用的CPU时间。Linux系统支持进程的时间间隔计时器。
(7)文件系统。进程在运行时可以打开和关闭文件。task_struct结构中包括指向每个打开文件的文件描述符的指针,并且包括两个指向VFS(虚拟文件系统)索引节点的指针。第一个索引节点是进程的根目录,第二个节点是当前的工作目录。两个VFS索引节点都有一个计数字段,该计数字段记录访问该节点的进程数。
(8)虚拟内存。大多数进程都使用虚拟内存空间。Linux系统必须了解如何将虚拟内存映射到系统的物理内存。
(9)处理器信息。每个进程运行时都要使用处理器的寄存器及堆栈(参见下文关于堆栈的讲解)等资源。当一个进程挂起时,所有有关处理器的内容都要保存到进程的task_struct中。当进程恢复运行时,所有保存的内容再装回到处理器中。
· 进程系统堆栈
在Linux系统中,每个进程都有一个系统堆栈,用来保存中断现场信息和进程进入内核模式后执行子程序(函数)嵌套调用的返回现场信息。每个进程的系统堆栈和task_struct数据结构之间存在紧密联系,因而二者物理存储空间也连在一起,如图2-12所示。
图2-12 进程的系统堆栈和task_struct结构
由图2-12中可以看出,内核在为每个进程分配task_struct结构的内存空间时,实际上是一次分配两个连续的内存页面(共8KB),其底部约1KB的空间用于存放task_struct结构,而上面的约7KB的空间用于存放进程系统堆栈。
另外,系统空间堆栈的大小是静态确定的,而用户空间堆栈可以在运行时动态扩展。
(四)对进程的操作命令
在Linux中,通常执行任何一个命令都会创建一个或多个进程,即命令是通过进程实现的。当进程完成了预期的目标,自行终止时,该命令也就执行完了。不但用户可以创建进程,系统程序也可以创建进程。可以说,一个运行着的操作系统就是由许许多多的进程组成的。
1.ps命令
ps命令是查看进程状态的最常用的命令,它可以提供关于进程的许多信息。操作者可以根据显示的信息确定哪个进程正在运行,哪个进程是被挂起或出了问题,进程已运行了多久,进程正在使用的资源情况,进程的相对优先级以及进程的标识号(PID)。所有这些信息对用户都很有用,对于系统管理员来说更为重要。
ps命令的一般格式是: ps [选项]
例如,不带选项的ps命令可以列出每个与当前shell有关的进程的基本信息:
$ ps
PID |
TTY |
TIME |
CMD |
1788 |
pts/1 |
00:00:00 |
bash |
1822 |
pts/1 |
00:00:00 |
ps |
其中,各字段的含义如下:
PID 进程标识号。
TTY 该进程建立时所对应的终端,“?”表示该进程不占用终端。
TIME 报告进程累计使用的CPU时间。注意,尽管有些命令(如sh)已经运转了很长时间,但是它们真正使用CPU的时间往往很短。所以,该字段的值往往是00:00:00。
CMD 执行进程的命令名,command的缩写。
Ps命令的常用选项有:
-a 显示系统中与tty相关的(除会话组长之外)所有进程的信息。
-e 显示所有进程的信息。
-f 显示进程的所有信息。
-l 以长格式显示进程信息。
-r 只显示正在运行的进程。
-u 显示面向用户的格式(包括用户名,CPU及内存使用情况、进程运行状态等信息)。
-x 显示所有终端上的进程信息。 例如,下面的命令行可以显示系统中所有进程的全面信息:
$ ps -ef
UID |
PID |
PPID |
C |
STIME |
TTY |
TIME |
CMD |
root |
1 |
0 |
1 |
20:42 |
? |
00:00:05 |
init [5] |
root |
2 |
1 |
0 |
20:42 |
? |
00:00:00 |
[keventd] |
root |
3 |
1 |
0 |
20:42 |
? |
00:00:00 |
[ksoftirqd_CPU0] |
…… |
|||||||
mengqc |
1823 |
1788 |
0 |
21:39 |
pts/1 |
00:00:00 |
ps -ef |
各项的含义是(只标出与前例不同的部分,其余含义相同):
UID 进程属主的用户ID号。
PPID 父进程的ID号。
C 进程最近使用CPU的估算。
STIME 进程开始时间,以“小时:分:秒”的形式给出。
2.kill命令
信号(signal,也称作软中断)机制是在软件层次上对中断机制的一种模拟。异步进程可以通过彼此发送信号来实现简单通信。系统预先规定若干个不同类型的信号(如x86平台中Linux内核设置了32种信号,而现在的Linux和POSIX.4定义了64种信号),各表示发生了不同的事件,每个信号对应一个编号。进程遇到相应事件或者出现特定要求时(如进程终止或运行中出现某些错误——非法指令和地址越界等),就把一个信号写到相应进程task_struct结构的signal位图(表示信号的整数)中。接收信号的进程在运行过程中要检测自身是否收到了信号,如果已收到信号,则转去执行预先规定好的信号处理程序。在处理之后,再返回原先正在执行的进程。
kill命令是通过向指定进程发送指定的信号来终止相应进程。终止一个前台进程可以使用<Ctrl+C>键,也可以使用kill命令。但是,对于一个后台进程就只能用kill命令来终止。
kill命令的一般格式是:
kill [-s 信号|-p ] 进程号…
kill -l [信号]
其中,各选项的含义如下:
-s 指定要发送的信号——可以是信号名(如SIGKILL),也可以是对应信号的编号(如9)。
-p 指定kill命令只是显示进程的PID(进程标识号),并不真正发出终止进程的信号。
-l 显示信号名称列表,这也可以在/usr/include/linux/signal.h文件中找到。
使用kill命令时应注意:
(1)kill命令可以带信号,也可以不带。如果没有带信号,kill命令就会发出终止信号(编号为15),这个信号可以被进程捕获,使得进程在退出之前清理并释放资源。也可以用kill向进程发送特定的信号,例如:kill -2 123 。它的效果等同于:当在前台运行PID为123的进程时,按下<Ctrl+C>键。但是,普通用户使用kill命令时不要带信号,或者至多带信号编号9。
(2)kill可以用进程ID号作为参数。当用kill向这些进程发送信号时,必须是这些进程的主人。如果试图撤销一个没有权限撤销的进程或撤销一个不存在的进程,就会得到一个错误信息。
(3)可以向多个进程发信号或终止它们。
(4)当kill成功地发送了信号后,shell会在屏幕上显示出进程的终止信息。有时这个信息不会马上显示,只有当按下键使shell的命令提示符再次出现时,才会显示出来。
(5)应注意,信号使进程强行终止,这常会带来一些副作用,如数据丢失或者终端无法恢复到正常状态。发送信号时必须小心,只有在万不得已时,才用SIGKILL信号(编号为9),因为进程不能首先捕获它。
要撤销所有的后台作业,可以输入kill 0。因为有些在后台运行的命令会启动多个进程,跟踪并找到所有要杀掉的进程的PID是件很麻烦的事。这时,使用kill 0 来终止所有由当前shell启动的进程,是个有效的方法。
一般可以用kill命令来终止一个已经挂起的进程或者一个陷入死循环的进程。例如:首先执行以下命令,人为建立一个好像陷入死循环的后台进程:
$ find / -name core -print > /dev/null 2>&1&
这是一条后台命令,执行时间较长。其功能是:从根目录开始搜索名为core的文件,将结果输出(包括错误输出)都定向到 /dev/null文件。
现在决定终止该进程。为此,运行ps命令来查看该进程对应的PID。例如,该进程对应的PID是1651,现在用kill命令“杀死”这个进程:
$ kill 1651
再用ps命令查看进程状态时,就可以看到,find进程已经不存在了。
3.sleep命令
sleep命令的功能是使进程暂停执行一段时间。其一般使用格式是:
sleep 时间值
其中,“时间值”参数以秒为单位,即让进程暂停由时间值所指定的秒数。此命令大多用于shell程序设计中,使两条命令执行之间停顿指定的时间。
例如,下面的命令使进程先暂停100秒,然后查看用户mengqc是否在系统中:
$ sleep 100; who | grep 'mengqc'
(五)有关进程控制的系统调用
1.系统调用的使用方式
虽然在一般应用程序的编制过程中,利用系统提供的库函数就能很好地解决问题,但在处理系统底层开发、进程管理等方面的涉及系统内部操作的问题时,利用系统调用编程就很必要,而且程序执行的效率会得到改进。
在UNIX/Linux系统中,系统调用和库函数都是以C函数的形式提供给用户的,它有类型、名称和参数,并且要标明相应的文件包含。例如,open系统调用可以打开一个指定文件,其函数原型说明如下:
#include <sys/types.h>
#include <sys/stat.h>
#include
int open(const char *path, int oflags);
不同的系统调用所需要的头文件(又称前导文件)是不同的。这些头文件中包含了相应程序代码中用到的宏定义、类型定义、全称变量及函数说明等。对C语言来说,这些头文件几乎总是保存在/usr/include及其子目录中。系统调用依赖于所运行的UNIX/Linux操作系统的特定版本,所用到的头文件一般放在/usr/include/sys或者 /usr/include/linux目录中。
在C语言程序中,对系统调用的调用方式与调用库函数的方式相同,即:调用时提供的实参的个数、出现的顺序和实参的类型应与原型说明中形参表的设计相同。例如,要打开在目录/home/mengqc下面的普通文件myfile1,访问该文件的模式为可读可写(用符号常量O_RDWR表示),则代码片段为:
int fd;
…
fd=open("/home/mengqc/myfile1",O_RDWR);
…
虽然从感觉上系统调用类似于库函数调用:都由程序代码构成,使用方式相同——调用时传送参数。但两者有实质差别:库函数属于用户层,它只能在用户态下运行,不能进入核心态;而系统调用属于操作系统核心层,在核心态下运行,并且可以实现处理机从用户态到核心态的转变。
2.有关进程控制的系统调用
常用的有关进程控制的系统调用有:fork,exec,wait,exit,getpid,sleep和nice等。查看表2-1列出了这些系统调用的格式和功能说明 。
表2-1 有关进程控制的系统调用的格式和功能说明
格式 |
功能 |
#include <unistd.h> |
创建一个子进程。pid_t表示有符号整型量。若执行成功,在父进程中,返回子进程的PID(进程标识符,为正值);在子进程中,返回0。若出错,则返回?1,且没有创建子进程 |
#include <unistd.h> |
getpid返回当前进程的PID,而getppid返回父进程的PID |
#include <unistd.h> |
这些函数被称为"exec函数系列",其实并不存在名为exec的函数。只有execve是真正意义上的系统调用,其他都是在此基础上经过包装的库函数。该函数系列的作用是更换进程映像,即根据指定的文件名找到可执行文件,并用它来取代调用进程的内容。换句话说,即在调用进程内部执行一个可执行文件。其中,参数path是被执行程序的完整路径名;argv和envp分别是传给被执行程序的命令行参数和环境变量;file可以简单到仅仅是一个文件名,由相应函数自动到环境变量PATH给定的目录中去寻找;arg表示argv数组中的单个元素,即命令行中的单个参数 |
#include <unistd.h> |
终止调用的程序(用于程序运行出错)。参数status表示进程退出状态(又称退出值、返回码、返回值等),它传递给系统,用于父进程恢复。_exit函数比exit函数简单些,前者使得进程立即终止;后者在进程退出之前,要检查文件的打开情况,执行清理I/O缓冲的工作 |
#include <sys/types.h> |
wait( )等待任何要僵死的子进程;有关子进程退出时的一些状态保存在参数status中。如成功,返回该终止进程的PID;否则,返回1 而waitpid( )等待由参数pid指定的子进程退出。参数option规定了该调用的行为:WNOHANG表示如没有子进程退出,则立即返回0;WUNTRACED表示返回一个已经停止但尚未退出的子进程的信息。可以对它们执行逻辑"或" |
#include <unistd.h> |
使进程挂起指定的时间,直至指定时间(由seconds表示)用完或者收到信号 |
#include <unistd.h> |
改变进程的优先级。普通用户调用nice时,只能增加进程的优先数(inc为正值);只有超级用户才能减少进程的优先数(inc为负数)。如成功,返回0;否则,返回1 |
3.应用示例
【例2-1】子进程被创建后,一般会使用一个exec函数来更换自己的映像,即用一个程序(如可执行文件)取代原来内存空间中的内容,然后开始执行。此后,父、子进程就各行其道了。父进程可以创建多个子进程。当子进程运行时,如果父进程无事可做,就执行wait系统调用,把自己插入睡眠队列中,等待子进程的终止。
下面这个C程序展示了Linux系统中父进程创建子进程以及各自分开活动的情况。
【例2-2】每个进程都有唯一的进程ID号(PID)。PID通常在数值上逐渐增大,因此,子进程的PID一般要比其父进程大。当然,PID的值不可能无限大,当它超过系统规定的最大值时,就反转回来使用最小的尚未使用的PID值。
如图2-9所示,进程族系呈树型结构。除0#外,任何进程都必须有父进程,不允许出现“孤儿”进程。如果父进程先于子进程死亡或退出,则子进程会被指定一个新的父进程init(其PID为1)。
下面的程序利用fork( )创建子进程,利用getpid( )和getppid( )分别获得本进程的PID和父进程的PID,使用sleep( )将相关进程挂起几秒钟。本程序的可执行文件名为proc1。
/*proc1.c演示有关进程操作*/
#include
#include
#include
#include
int main(int argc,char **argv)
{
pid_t pid,old_ppid,new_ppid;
pid_t child,parent;
parent=getpid(); /*获得本进程的PID*/
if((child=fork())<0){ /*如果新创建子进程的PID小于0*/
fprintf(stderr,"%s:fork of child failed:%s\n",argv[0],strerror(errno)); /*输出创建子进程失败*/
exit(1); /*终止,返回码为1*/
}
else if(child==0){ /*此时是子进程被调度运行*/
old_ppid=getppid(); /*取得父进程的PID*/
sleep(2); /*睡眠2秒钟,以便调度其他进程运行*/
new_ppid=getppid(); /*当再次被调度运行后,取得新父进程的PID*/
}
else { /*父进程运行*/
sleep(1); /*睡眠1秒钟,以便调度其他进程运行*/
exit(0); /*被调度运行时,父进程终止*/
}
/*下面仅子进程运行*/
printf("Original parent:%d\n",parent); /*输出最初父进程的PID*/
printf("Child:%d\n",getpid()); /*取得并输出子进程的PID*/
printf("Child's old ppid:%d\n",old_ppid); /*输出父进程的PID*/
printf("Child's new ppid:%d\n",new_ppid); /*输出新父进程的PID*/
exit(0); /*程序终止*/
}
程序运行的结果如下:
$ ./proc1
$ Original parent:2009
Child:2010
Child's old ppid:2009
Child's new ppid:1
请读者根据输出结果自行分析程序的执行情况。注意,进程是并发执行的;当子进程被成功调度后,调度程序的返回值是0。如果父进程先终止,则其子进程的父进程就被系统指定为init进程(其PID为1)。
如若转载,请注明出处:https://www.wuctw.com/10310.html