第八章:进程控制
8.1:引言
本章介绍Unix的进程控制,包括创建新进程、执行程序和进程终止。还将说明进程属性的各种ID--实际、有效和保存的用户和组ID,以及它们如何受到进程控制原语的影响。还包括解释器文件和system函数,最后讲述大多数Unix系统所提供的进程会计机制。
8.2:进程标识符
每个进程都有一个非负整型表示的唯一进程ID。虽然是唯一的,但是进程ID却可以重用,当一个进程终止后,其进程ID就可以再次使用了。Unix使用延迟重用算法,避免新进程的ID等于最近终止的进程的ID。
除了进程ID,每个进程还有一些其他的标识符。下列函数返回这些标识符:
#include <unistd.h> pid_t getpid(void); // 返回调用进程的进程ID pid_t getppid(void); // 返回调用进程的父进程ID uid_t getuid(void); // 返回调用进程的实际用户ID uid_t geteuid(void); // 返回调用进程的有效用户ID gid_t getgid(void); // 返回调用进程的实际组ID gid_t getegid(void); // 返回调用进程的有效组ID
8.3:fork函数
一个现有进程可以调用fork函数创建一个新进程:
#include <unistd.h> pid_t fork(void); // 创建新进程,父进程中返回子进程ID,子进程中返回0,小于0,则出错
一般来说,在fork之后是父进程先运行还是子进程先运行,取决与系统内核的调度算法。如果要求父子进程之间相互同步,则需要某种形式的进程间通信。
fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。父子进程的每个相同的打开描述符共享一个文件表项。这种共享文件的方式使父子进程对同一文件使用了一个文件偏移量。
在fork之后处理文件描述符有两种常见的情况:
1.父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程结束后,其曾进行过读写操作的任一共享描述符的文件偏移量已执行了相应更新。
2.父子进程各自执行不同的程序段。在这种情况下,父子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。
除了文件打开描述符之外,父进程的其他许多属性也由子进程继承,包括:
实际用户ID、实际组ID、有效用户ID、有效组ID 附加组ID 进程组ID 会话ID 控制终端 设置用户ID标志和设置组ID标志 当前工作目录 根目录 文件模式创建屏蔽字 信号屏蔽与安排 针对任一文件描述符的在执行时关闭(close-on-exec)标志 环境 连接的共享存储段 资源限制
父子进程之间的区别:
fork的返回值
进程ID不同
两个进程具有不同的父进程ID
子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0
父进程设置的文件锁不会被子进程继承
子进程的未处理的闹钟被清除
子进程的未处理信号集设置为空
fork有两种用法:
1.一个父进程希望复制自己,使父子进程执行不同的代码段。这在网络服务进程中是最常见的--父进程等待客户端的请求连接。当这种请求到达时,父进程调用fork,使子进程处理此请求,父进程则继续等待请求连接。
2.一个进程要执行不同的程序。这对shell是常见的情况。在这种情况下。子进程从fork返回后立即调用exec函数。
8.4:vfork函数
vfork函数的调用序列和fork函数相同,但两者语义不同。
vfork用于创建一个新进程,而该进程的目的是exec一个新程序。vfork和fork一样都创建一个新进程,但它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec,于是也就不会访问该地址空间。相反,在子进程调用exec或者exit之前,它在父进程的空间中运行。
vfork和fork的另一个区别是,vfork保证子进程先运行,在它调用exec或者exit之后父进程才可能被调度运行。
由于vfork调用之后,子进程调用exec或者exit之前,它在父进程空间中执行,所以,子进程对变量的修改会影响到父进程。
8.5:exit函数
如7.3节所述,进程有下面5种正常终止方式:
1.在main函数内执行return语句,这等效于调用exit函数。
2.调用exit函数。此函数由ISO C定义,其操作包括调用各终止处理程序(终止处理程序在atexit函数时登记),然后关闭所有标准IO流。
3.调用_exit函数或_Exit函数。ISO C定义_Exit,其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。对标准IO流是否冲洗,取决于实现。在Unix系统中,_Exit和_exit是同义的,并不冲洗标准IO流。_exit函数被exit函数调用,它处理Unix特定的细节。_exit是由POSIX定义的。
4.进程的最后一个线程在其启动例程中执行返回语句。但是,该进程的返回值不会作为进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。
5.进程的最后一个线程调用pthread_exit函数。如同前面一样,这种情况,进程的终止状态总是0.
三种异常终止方式如下:
1.调用abort函数。
2.当进程接收到某些信号时。
3.最后一个线程对“取消”做出相应。
不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应的进程关闭所有打开文件描述符,释放它使用的存储器等。
对于任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于三个终止函数(exit、_exit、_Exit),实现这一点的方法是,将其退出状态作为参数传递给函数。在异常终止情况下,内核(不是进程本身)产生一个指示其异常终止原因的终止状态(termination status)。在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态。
注意,这里使用了“退出状态”(它是传给exit或_exit的参数,或main的返回值)和终止状态两个术语,以表示有所区别。在最后调用_exit时,内核将退出状态转换为终止状态。
在Unix术语中,一个已经终止,但是其父进程尚未对其处理的进程被称为僵死进程。
8.6:wait和waitpid函数
当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。因为子进程终止是一个异步事件(这可以在父进程运行的任时候发生),所以这种信号也是内核向父进程发的异步通知。
调用wait或waitpid时可能发生的情况有:
1.如果其所有子进程都还在运行,则阻塞。
2.如果一个i子进程已经终止,正等待父进程获取其终止状态啊,则取得该进程的终止状态并立即返回。
3.如果它没有任何子进程,则立即出错返回。
#include <unistd.h> pid_t wait(int *staloc); // 等待子进程终止 pid_t waitpid(pid_t pid, int *staloc, int options); 等待指定子进程终止
这两个进程的区别如下:
1.在一个进程终止前,wait使其调用者阻塞,而waitpid有一个选项,可以使调用者不阻塞。
2.waitpid并不等待在其调用后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
整型指针staloc用于获取终止状态,如果不关心终止状态,则可以为空。
对于waitpid函数中pid参数的作用解释如下:
pid == -1 等待任一子进程。就这一方面而言,waitpid与wait等效。
pid > 0 等待其进程ID与pid相同的子进程
pid == 0 等待其组ID等于调用进程组ID的任一子进程。
pid < -1 等待其组ID等于pid绝对值的任一子进程。
waitpid返回终止子进程的进程ID,并将子进程的终止状态存放在由staloc指向的存储单元中。对于wait,其唯一的出错是调用进程没有子进程。
waitpid函数中options常量的
WCONTINUED 若实现作业控制,那么由pid指定的任一子进程在暂停后已经继续,但其状态尚未报告,则返回其状态。
WNOHANG 若由pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时其返回值为0
WUNTRACED 若某实现支持作业控制,而由pid指定的任一子进程已处于暂停状态,并且其状态自暂停以来还未报告过,则返回其状态。
waitpid函数提供了wait函数没有提供的三个功能:
1.waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。
2.waitpid提供了一个wait函数的非阻塞版本。
3.waitpid提供作业控制。
实例:
如果一个进程fork一个子进程,但不要它等待子进程终止,也不希望子进程处于僵死状态直到父进程终止,实现这一需求的技巧是调用fork两次。程序如下:
8.7:waitid函数
Single Unix Specification的XSI扩展包括另一个取进程终止状态的函数--waitid,此函数类似于waitpid,但提供更高的灵活性。
#include <sys/wait.h> int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
与waitpid相似,waitid允许一个进程指定要等待的子进程。
waitid的idtype常量
P_PID 等待一个特定的进程:id包含等待子进程的进程ID
P_PGID 等待一个特定进程组的任一子进程:id包含要等待进程组的组ID
p_ALL 等待任一子进程,id忽略
waitid的options常量,这些标志按位或。
WCONTINUED 等待一个进程,它以前曾被暂停,此后又已继续,但其状态尚未报告
WEXITED 等待已退出的进程
WNOHANG 如无可用的子进程退出状态,立即返回而非阻塞
WNOWAIT 不破坏子进程的退出状态。该子进程退出状态可由后续的wait、waitid或waitpid调用取得。
WSTOPPED 等待一个子进程,它已暂停,但状态尚未报告
infop是指向siginfo结构的指针。该结构包含了有关引起子进程状态改变的生成信号的详细信息。
只有Solaris支持waitid
8.8:wait3和wait4函数
大部分Unix系统实现提供了另外两个函数wait3和wait4,它们提供的功能比wait、waitpid、waitid要多一个,这与附加参数rusage有关,它要求内核返回由终止进程及其所有子进程使用的资源汇总。
资源统计包括用户CPU时间总量、系统CPU时间总量、页面出错次数、接收到的信号次数等。
#include <sys/types.h> #include <sys/wait.h> #include <sys/time.h> #include <sys/resource.h> pid_t wait3(int *staloc, int options, struct rusage *rusage); pid_t wait4(pid_t pid, int *staloc, int options, struct rusage *rusage);
8.9:竞争条件
当多个进程都企图对共享数据进行某种处理,而处理结果又依赖于进程的顺序时,我们认为这发生了竞争条件。
如果一个进程要等待子进程终止,可以调用wait函数,而一个进程如果要等待其父进程终止,则可以使用下列形似的循环:
while(getppid() != 1) { sleep(1); }
这种方式称为轮询,它的问题是浪费了cpu的时间。
为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收方法。在Unix中可以使用信号机制,也可以使用各种形式的进程间通信(IPC)。
在父子进程关系中,常常出现下述情况。在调用fork之后,父子进程都有一些事情要做。例如,父进程可能要用子进程ID更新日志文件中的一个记录,而子进程则可能要为父进程创建一个文件。在本例中,要求每个进程在执行完它的一套初始化操作代码后要通知对方,并在继续运行之前,要等待另一方完成其初始化操作。这种方案可以用代码描述如下:
TELL_WAIT(); // set things up for TELL_xxx & WAIT_xxx if ((pid = fork()) < 0) { perror("Fork error"); } else if (pid == 0) { // child process /* child do sth necessary */ TELL_PARENT(getppid()); // tell parent we're done WAIT_PARENT(); /* child continue */ exit(0); } /* parent do sth necessary */ TELL_CHILD(pid); // tell child we're done WAIT_CHILD(); // wait for child /* continue */ exit(0);
其中的5个例程可以是宏也可以是函数。
实例:8-6
8.10:exec函数
当进程调用一种exec函数后,该进程执行的程序完全替换为新程序,调用exec函数并不创建新的进程,所以进程ID并未改变。exec只是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。
有6种不同的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[]);
这写函数之间的第一个区别是,前面四个函数使用路径作为参数,后面两个函数使用文件名作为参数,当指定filename时,如果filename中含有‘/’,则将视其为路径名。否则就按PATH环境变量中的目录中搜索。
第二个区别是与参数的传递有关系(l标识list,v标识vector)。函数execl、execlp、execle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数以空指针结尾。对于另外三个函数(execv、execvp、execve),则应该首先构造一个指向各参数的指针数组,然后将该数组地址作为这个三个函数的参数。
这个6个函数很难记忆,函数名中的字符会给我们一些帮助。字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。字母e表示该函数取envp[]数组,而不是使用当前环境变量。
实例:8-8 exec实例
实例:8_9 回送所有命令行参数和所有环境自趺床
8.11:更改用户ID和组ID
可以用setuid函数设置实际用户ID和有效用户ID,用setgid函数设置实际组ID和有效组ID。
#include <unistd.h> int setuid(uid_t uid); int setgid(gid_t gid); // 若成功则返回0,若出错返回-1
关于谁能更改ID有若干规则:
1.若进程有超级用户特权,则setuid函数将实际用户ID、有效用户ID、以及保存的设置用户ID设置为uid。
2.若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid。不改变实际用户ID和保存的设置用户ID。
3.如果上面两个条件都不满足,则将errno设置为EPERM,并返回-1.
关于内核所维护的三个用户ID,还有注意一下几点:
1.只有超级用户可以修改实际用户ID。通常实际用户ID是在用户登录时,由login程序设置的,login是一个超级用户进程,当它调用setuid时设置所有三个用户ID。
2.仅当程序文件设置了设置用户ID位时,exec函数才会设置有效用户ID。任何时候都可以将有效用户ID设置为实际用户ID或者保存的设置用户ID。自然,不能将有效用户ID设置为任意随机值。
3.保存的设置用户是由exec复制有效用户ID而得来的。如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID后,就将其保存起来。
三种改变用户ID的方法:
ID | exec | setuid | ||
设置用户ID位关闭 | 设置用户ID位打开 | 超级用户 | 非特权用户 | |
实际用户ID | 不变 | 不变 | 设为uid | 不变 |
有效用户ID | 不变 | 设置为程序文件的用户ID | 设为uid | 设为uid |
保存的设置用户ID | 从有效用户ID复制 | 从有效用户ID复制 | 设为uid | 不变 |
seteuid、setegid,类似与setuid、setgid,但是它们只修改有效用户ID、有效组ID。
#include <unistd.h> int seteuid(uid_t uid); int setegid(gid_t gid);
一个非特权用户可将其有效用户ID设置为实际用户ID或保存的设置用户ID,一个特权用户可以将有效用户ID设置为uid。
8.12:解释器文件
现今所有的Unix系统都支持解释器文件,这种文件是文本文件,其起始形式是: #!pathname [option-argument]
pathname通常是绝对路径,对它不进行什么特殊处理。
实例:8-10 执行一个解释器文件的程序。
8.13:system函数
#include <stdlib.h> int system(const char *cmdstring);
system函数用于执行一个命令。当命令可用时返回非0。
实例:8-12 system函数
8.14:进程会计
8.15:用户标识
获取运行改程序的登录用户名。我们可以使用 getpuid(getuid()) ,但是如果一个用户有多个登录名,这些登录名对应着一个用户ID,那么又如何呢?
系统通常记录用户登录时使用的名字,getlogin函数可以获取此登录名。
#include <unistd.h> char* getlogin(void); // 成功返回用户名,失败返回NULL
8.16:进程时间
在1.10节说明了我们可以测量三种时间:墙上的时钟时间、用户cpu时间和系统cpu时间。任一进程都可以使用times函数获得它自己及已终止子进程的上述值。
#include <sys/times.h> clock_t times(struct tms *buf); // 若成功,返回流逝的墙上时钟时间。若出错返回-1
此函数填写有buf指向的tms结构,该结构定义如下:
struct tms { clock_t tms_utime; // user cpu time clock_t tms_stime; // system cpu time clock_t tms_cutime; // user cpu time, terminated children clock_t tms_cstime; // system cpu time, terminated children };
注意:此结构没有包含墙上时钟时间的任何测量值,作为代替,times函数返回墙上时钟时间作为其函数值。此值是相对与过去的某一时刻测量的,所以不能用其绝对值,而必须使用其相对值。例如:调用times函数,保存其返回值,在以后的某个时刻再次调用times函数,从新值减去以前的返回值,此差值就是墙上时钟时间。
所有由此函数返回的clock_t值都用_SC_CLK_TCK变换成秒数。
实例:8-18 时间以及执行所有命令行参数
8.17:小结
对于Unix环境中的高级编程而言,完整的了解Unix进程控制是非常重要的。其中必须熟练掌握的只有几个函数--fork、exec族、_exit、wait、和waitpid。