《UNIX环境高级编程》第8章 进程控制

8.1 前言

本章介绍UNIX系统的进程控制,包括创建新进程、执行程序和进程终止。
还将说明进程属性的各种ID是如何受到进程控制原语的影响的。
还包括了解释器文件和system函数。
还讲述了UNIX系统所提供的进程会计机制,这种机制使得我们能够从另一个角度了解进程控制功能。

8.2 进程标识

每个进程都有一个非负整型标识唯一进程ID。虽然进程ID是唯一的但它是可复用的。当一个进程终止后其ID就成为复用的候选者,大多数UNIX系统实现延时复用算法,使得赋予新进程的ID不同于最近终止进程所用的ID。这防止了将新进程误认为是使用同一ID的某个已经终止的先前进程。
系统中有一些专用进程:
- ID=0的进程是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程
- ID=1的进程是init进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX早起版本中是/etc/init,新版本中是/sbin/init。此进程负责在自举内核后启动一个UNIX系统。init通常读取与系统有关的初始化文件(/etc/rc*或/etc/inittab或/etc/init.d文件夹中的文件),并将系统引导到一个状态。init进程绝不会终止。它是一个普通用户进程(与交换进程不同,它不是内核中的系统进程),但它是以超级用户特权运行的。

除了进程ID,每个进程还有一些其他标识,以下函数返回这些标识:

#include <unistd.h>
pid_t getpid(void); //返回调用进程的ID
pid_t getpid(void); //返回调用进程的父进程ID

pid_t getuid(void); //返回调用进程实际用户ID
pid_t geteuid(void);    //返回调用进程的有效用户ID

pid_t getgid(void); //返回调用进程的实际组ID
pid_t getegid(void);    //返回调用进程的有效组ID

8.3 函数fork

一个现有的进程可以调用fork函数创建一个新进程。

#include <unistd.h>
pit_t fork(void);

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的区别是子进程返回值是0,而父进程返回值是新建子进程的进程ID。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本,它从父进程获得了数据空间、堆和栈的副本,但这份副本是独立的,父子进程之间并不共享这些存储空间部分。父子进程共享正文段。(现在这些存储空间使用了写时复制技术,也就是父子进程共享一个副本,但这个副本是只读的,如果任何一方要写,那就复制一份那个要写内存块的副本,这通常是虚拟存储的一页。)

8.4 函数vfork

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。
vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或者exit),于是也就不会引用该地址空间。不过在子进程调用exec或exit之前它在父进程空间中运行。但是如果子进程修改了数据、进行函数调用或者没有使用exec或exit就返回都有可能会带来未知的结果。
vfork和fork的另一个区别是:vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖父进程的进一步动作,则会导致死锁。)

8.5 函数exit

之前所述,进程有5种正常终止及3种异常终止方式。不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。
对任一终止情形,我们都希望终止进程能够通知其父进程是如何终止的。
- 对于3个终止函数(exit 、_exit和_Exit),实现这一点的方法是,将其退出状态(exit status)作为参数传递给函数。
- 对于异常终止,内核产生一个指示异常终止原因的终止状态(termination status)
在任意状态下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态。
这里使用了“退出状态”来区别于“终止状态”,在最后调用_exit时,内核将退出状态装换成终止状态。


  • 如果子进程正常终止,则父进程可以获得子进程的退出状态。如果父进程在子进程前终止,则子进程的父进程都变为init进程。我们称这些进程由init进程收养。其操作过程大概是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止的进程的子进程,如果是,则该进程的父进程ID就更改为1(init的进程ID)。这种处理方法保证了每个进程都有一个父进程。
  • 另一个问题是:如果子进程先于父进程终止,那么父进程是如何在检查时得到子进程的终止状态的呢?如果子进程消失了,父进程在最终准备好检查子进程是否终止时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些消息。这些消息至少包括进程ID、该进程终止状态已经该进程使用的CPU时间总量。内核可以释放终止进程所使用的所以存储区,关闭所有打开文件。在UNIX术语中,一个已经终止、但其父进程尚未对其进行善后处理(取得终止子进程的有关信息、释放占用资源)的进程成为僵死进程(zombie)。
  • 最后一个问题:一个由init进程收养的进程终止时会发生什么?它会不会变成僵死进程?答案是当然不会,因为init进程被编写为无论什么时候只要有一个子进程终止,init进程就会调用一个wait函数取得其终止状态。这样也就防止了在系统中塞满僵死进程。一个init的子进程可以是init直接产生的进程也可以是其父进程已经终止,由init收养的进程。

8.6 函数wait和watipid

当一个进程正常或异常终止时,内核就像其父进程发送SIGCHLD信号。因为子进程终止是个异步事件,所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。对于这种信号的系统默认动作是忽略它。
调用wait或waitpid时进程可能会发生什么:
- 如果其所有子进程都还在运行,则阻塞;
- 如果一个子进程已经终止,正等待父进程获取其终止状态,则取得该子进程终止状态立即返回;
- 如果它没有任何子进程,则立即出错返回。

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);

两个函数区别如下:
- 在一个子进程终止前,wait使其调用者阻塞,而waitpid有一个选项,可以使调用者不阻塞。
- waitpid并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。

如果子进程已经终止,并且是一个僵死进程,则wait立即返回并取得该子进程的状态;否则wait使其调用者阻塞,直到一个子进程终止。如果调用者阻塞,而且它有多个子进程,则在其某一个子进程终止时,wait就立即返回。因为wait返回终止子进程的ID,所以它总能了解是哪一个子进程终止了。

参数statloc是一个整形指针。如果statloc不是一个空指针,则进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可以将该参数指定为空指针。
依据传统,这两个函数返回的整形状态字是由实现定义的。其中某些位表示退出状态(正常返回),其他位则指示信号编号(异常返回),有一位指示是否产生了core文件等。
在sys/wait.h中有4个互斥的宏可以用来取得进程终止的原因,他们的名字都是以WIF开始:

说明
WIFEXITED(status) 若子进程正常终止,则为真。这时可执行WEXITSTATUS(status)取得子进程传送给exit或_exit参数的低8位。
WIFSIGNALED(status) 若子进程异常终止,则为真。这时可执行WTERMSIG(status)取得子进程终止信号编号,有些实现定义了宏WCOREDUMP(status),若已产生终止进程的core文件,则它返回真。
WIFSTOPPED(status) 若为当前暂停子进程的返回的状态,则为真。这时可执行WSTOPSIG(status),获取使子进程暂停的信号编号。
WIFCONTINUED(status) 在作业控制暂停后(第9章讨论作业控制)已经继续的子进程返回了状态,则为真()仅用于waitpid。

如果要等待一个指定的进程终止,可以使用wiatpid函数;waitpid函数中的pid参数作用解释如下:
- pid==-1:等待任一子进程,这种情况下waitpid和wait函数等效;
- pid>0 :等待进程号为pid的子进程;
- pid==0:等待组ID等于调用进程组ID的任一子进程;
- pid<-1:等待组ID等于pid绝对值的任一子进程。
对于wait函数,唯一出错是调用进程没有子进程;而waitpid如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都有可能出错。

options参数使我们能进一步控制wiatpid的操作。此参数值可以使0或者是以下常量按位或的结果。

常量 说明
WCONTINUED 若实现支持作用控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态。
WNOHANG 若pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时其返回值为0.
WNOTRACED 若实现支持作业控制,而pid所指定的子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。WIFSTOPPED宏确定返回值是否对应于一个停止的子进程。

waitpid函数提供了wait函数没有提供的3个功能:
- waitpid可以等待一个特定的进程。而wait则返回任一终止子进程的状态。
- waitpid提供了wait的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。
- waitpid通过WUNTRACED和WCONTINUED选项支持作业控制。


如果希望产生一个子进程,其父进程不需要等待它终止,同时此子进程也不希望处于僵死状态直到父进程终止(如果此子进程处于僵死状态,父进程没有回收子进程,那就要一直等到父进程僵死,被init回收后再由init回收该子进程),可以使用两次fork达到此效果。这就等于直接创建了一个父进程为init的子进程。这里如果在图形界面下运行,输出此子进程的ppid会发现不是init进程1,而是upstart进程号,只有在终端下运行才是init进程号1.

8.7 函数waitid

另一个取得进程终止状态的函数-waitid,此函数类似于waitpid,但提供了更多的灵活性。

#include <sys/wait.h>
int waitid(idtype_t idtype,id_t id,siginfo_t *infop,int options);

与waitpid类似,waitid允许一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型。id的作用与idtype有关。idtype类型如下表:

常量 说明
P_PID 等待一特定进程,id包含要等待子进程的ID.
P_PGID 等待一特定进程组中的任一子进程,id包含要等待进程组的进程组ID.
P_ALL 等待任一子进程,忽略id

options参数是以下表中各标志按位或运算:

常量 说明
WCONTINUED 等待任一进程,它曾被停止过,但又已继续,但尚未上报。
WWXITED 等待已退出的进程。
WNOHANG 如无可用的子进程退出状态,立即返回而非阻塞。
WSTOPPED 等待一进程,它已经停止,但其状态尚未报告。

infop参数是指向siginfo结构的指针。该结构包含了造成子进程状态改变有关信号的详细信息。

8.8 wait3 和wait4

大多数UNIX实现提供了wait3和wait4这两个函数,他们提供了比函数wait、waitpid和waitid的功能要多一个,这与附加参数有关。该参数允许内核返回由终止进程及其所有子进程使用的资源概况。

#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>

pid_t wait3(int *statloc,int options,struct rusage *rusage);
pid_t wait4(pid_t pid,int *statloc,int options,struct rusage *rusage);

资源统计信息包括用户CPU时间总量、系统CPU时间总量、缺页次数、收到信号的次数等(getrusage-get resource usage)。

8.9 竞争条件

当多个进程企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们就认为发生了竞争条件(race condition)。
如果父进程需要等待子进程终止,则它必须使用wait函数中的一个。如果一个子进程需要等待父进程终止,则可以使用以下形式:

while(getppid()!=1) 
sleep(1);

这种形式的循环称为轮询(polling),它的问题是浪费了CPU时间,因为调用者每隔1s都被唤醒,然后进行调节测试。
为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收方法。在UNIX中可以使用信号机制,各种形式的进程间通信(IPC)也可以使用。

8.10 函数exec

当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆和栈。
有7种不同的exec函数可供使用,它们被统称为exec函数:

#include <unistd.h>
int execl(const char *pathname,const char *arg0,.../*(char*)0*/);   //以空指针结尾参数列表
int execv(const char *pathname,char *const argv[]); //以指针数组作为参数
int execle(const char *pathname,const char *arg0,.../*(char*)0*/,char *const envp[]);   //以空指针结尾参数列表
int execve(const char *pathname,char *const argv[],char *const envp[]);

int execlp(const char *filename,const char *arg0,.../*(char*)0*/);  //以空指针结尾参数列表
int execvp(const char *filename,char *const argv[]);
int fexecve(int fd,char *const argv[],char *const envp[]);
  1. 第一个区别是前4个函数取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。当指定filename作为参数时:
    • 如果filename中包含/,则就将其视为路径名;
    • 否则就按PATH环境变量,在它指定的各目录中搜寻可执行变量。

如果execlp或execvp使用路径前缀(PATH变量中的一个成员)中的一个找到了一个可执行文件,但是该文件不是由连接器产生的机器可执行文件,则就认为该文件是一个脚本,于是尝试用/bin/sh并以filename作为shell的输入。

fexecv函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。否则,拥有特权的用户就可以在找到文件并验证之后,在调用该文件之前替换可执行文件。


  1. 第二个区别与参数表的传递有关:
    l表示列表list,v表示矢量vector。
    • 函数execl、execlp、execle要求新程序的的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。
    • 函数execv、execvp、execve、fexecve,则应先构造一个指向各参数的指针数组,然后讲该数组地址作为这4各函数的参数。

  1. 第三个区别是向新程序传递环境变量表相关。
    • 以e结尾的函数(execle、execve、fexecve)可以传递一个指向环境变量字符串指针数组的指针 。其他4各函数则使用调用进程中的environ变量作为新程序复制现有的环境。

    • 这7个exec函数很难记,函数名中的字符会给一些帮助:
    • p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。
    • l表示该函数取一个参数表,它与字母v互斥。
    • v表示该函数取一个argv[]矢量。

很多UNIX实现中,这7个函数中只有execve是内核的系统调用。另外6个只是库函数,他们最终都要调用该系统调用。这7个函数之间的关系如下:这里写图片描述
在这种规划中,库函数fexecve使用/proc/self/fd 把文件描述符参数转换成路径名,execve使用该路径名去执行程序。

8.11 更改用户ID和更改组ID

在UNIX系统中,特权(如改变当前日期时间等)以及访问控制(如读写一个特定的文件),是基于用户ID和组ID的。
当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。
一般而言,再设计应用时,我们总是试图使用最小权限(least privilege)模型。
我们可以使用setuid函数设置实际用户ID和有效用户ID。与此类似,可以用setgid函数设置实际组ID和有效组ID。

#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);

特权以及访问控制这块内容比较绕,还是具体看书吧。

8.12解释器文件

所有的UNIX系统都支持解释器文件(interpret file)。这种文件是文本文件,其起始行的形式是:

#! pathname [optional-argument]
//最常见的解释器文件是以下列行开始的
#! /bin/sh

pathname通常是绝对路径名,对它不进行什么特殊处理(不适用PATH进程路径搜索)。
对这种文件的识别是由内核作为exec系统调用处理的一部分来完成的。内核使调用exec函数的进程实际执行的并不是该解释器文件,而是该解释器文件中第一行pathname中所指定的文件。
当内核exec解释器时,argv[0]是该解释器的pathname,argv[1]是该解释器的可选参数,然后才是exec的第2和第3个参数。(exec的第一个参数去哪呢?没搞清楚、以后再研究吧)

8.13 函数system

ISO C定义了system函数,system函数对操作系统的依赖性很强。

#include <stdlib.h>
int system(const char *cmdstring);

如果cmdstring是一个空指针,如系统支持命令处理程序,system返回非0,这一特征可以确定在一个操作系统上是否支持system函数。
一种简单的system函数实现如下:

int system(const char *cmdstring)
{
    pid_t pid;
    int status;

    if(cmdstring==NULL) return(1);    //命令为空,返回1,可用于测试系统

    if((pid=fork())<0) status=-1;
    else if(pid==0){                  //子进程
    execl("/bin/sh","sh","-c",cmdstring,(char *)0);  //执行命令
    _exit(127);
    }else{                            //父进程
    while(waitpid(pid,&status,0)<0){  //等待子进程返回
    if(errno!=EINTR){
    status=-1;
    break;
    }
    }
return (status);                         //返回
}
  • shell的-c选项搞死shell程序取下一个命令行参数(这里是cmdstring)作为命令输入(而不是标准输入或从文件中读取命令)。
  • shell对以null字节终止的命令字符串进行语法分析,将他们分解成命令行参数。传递给shell的实际命令字符串可以包含任一有效的shell命令,例如<和>对输入和输出重定向。
  • 如果不使用shell执行此命令,而是试图由我们自己去执行它,那将相当困难。必须要自己分解命令行参数。
  • 调用_exit而不是exit,是为了防止任一标准IO缓冲在子进程中被冲洗。

8.14 进程会计

大多数UNIX系统提供了一个选项以进行进程会计(process accouting)处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。
这使我们得到了一个再次观察进程的机会。
具体的看书,不多说了。

8.15 用户标识

任一进程都可以得到其实际用户ID和有效用户ID及组ID。但是,我们有时希望得到运行该程序用户的登录名。可以使用getlogin函数获得此登录名。

#include <unistd.h>
char *getlogin(void);

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon)。

8.16 进程调度

UNIX系统历史上对进程提供的只是基于调度优先级的粗粒度控制(相对于精细控制)。调度策略和调度优先级是由内核确定的。
进程可以通过调整nice值(调高)选择以更低优先级运行(通过调整nice值降低它对CPU的占有,因此该进程对CPU来说是“友好的”)。只有特权进程允许提高调度权限(调低nice值)。
注:这里的意思是nice”友好地”值越高,进程对CPU越“友好”,优先级越低。
POSIX实时扩展增加了在多个调度类别中选择的接口以进一步细调行为。这里我们只讨论用于调整nice值的接口。SUS中nice值的范围在0-(2*NZERO)-1之间,linux3.2.0可以使用sysconf参数_SC_NZERO来访问NZERO的值。
进程可以通过nice函数获取或更改它的nice值:

#include <unistd.h>
int nice(int incr);

incr参数被增加到进程的nice值上,如果incr太大,系统直接把它降到最大合法值,不给出提示。如果incr太小,系统也会把它提高到最小合法值。
由于-1也是nice合法的成功返回值,如果调用nice成功并返回-1,那么errno任然为0.如果errno不为0,说明nice调用失败。

8.17 进程时间

在1.10节中说了我们可以度量的3个时间:墙上时钟时间、用户CPU时间和系统CPU时间。任一进程都可以调用times函数获得它自己以及已终止子进程的上述值。

#include <sys/times.h>
clock_t times(struct tms *buf);

此函数填写由buf指向的tms结构,该结构定义如下:

struct tms{
clock_t tms_utime;
clock_t tms_stime;
clock_t tms_cutime;
clock_t tms_cstime;
};

此结构没有包含墙上时钟时间。其获取的方法是两次调用times,根据返回值之差算出墙上时钟时间。
clock_t使用的是系统滴答时钟,可以使用sysconf函数通过_SC_CLK_TCK常量取得系统滴答数,再转换成秒数。
此结构中有两个针对子进程的字段包含了wait函数族已经等到的各子进程的值。

int elapse_time,user_time,sys_time;
int tims_start,tim_end;
struct tms tms_start,tms_end;

tim_start=times(&tms_start);
//do somthing
tim_end=times(&tms_end);
elapse_time=(tim_end-tim_start)/(double)sysconf(_SC_CLK_TCK);

user_time=(tms_end.utime-tms_start.utime)/(double)sysconf(_SC_CLK_TCK);

sys_time=(tms_end.stime-tms_start.stime)/(double)sysconf(_SC_CLK_TCK);

8.18 小结

对在UNIX环境中的高级编程而言,完整地了解UNIX的进程控制时非常重要的。
- 其中必须掌握的几个函数如fork、exec系列、wait和waitpid系列,很多程序都使用这些简单的函数。
- 本章还说明了system函数和进程会计,这使我们能进一步了解所有这些进程控制函数。
- 还说明了exec函数的另一种变体:解释器文件以及他们的工作方式。
- 对各种不同的用户ID和组ID的理解,对编写安全的设置用户ID程序时至关重要的。

发布了55 篇原创文章 · 获赞 13 · 访问量 3万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章