第8章 进程控制

8.1 引言

本章介绍UNIX系统的进程控制,包括创建新进程、执行程序和进程终止。还将说明进程属性的各种ID—实际、有效和保存的用户ID和组ID,以及它们如何受到进程控制原语的影响。本章还包括了解释器文件和system函数。本章最后讲述大多数UNIX系统所提供的进程会计机制,这种机制使我们能够从另一个角度了解进程的控制功能。

8.2 进程标识

每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。例如,应用程序有时就把进程 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 进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。本章稍后部分会说明init如何成为所有孤儿进程的父进程。

在Mac OS X 10.4中,init进程被launchd进程替代,执行的任务集与init相同,但扩展了功能。可参阅Singh[2006]在5.10节中的讨论来了解launchd是如何操作的。

每个UNIX系统实现都有它自己的一套提供操作系统服务的内核进程,例如,在某些UNIX的虚拟存储器实现中,进程ID 2是页守护进程(page daemon),此进程负责支持虚拟存储器系统的分页操作。

除了进程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

注意,这些函数都没有出错返回,在下一节讨论fork函数时,将进一步讨论父进程ID。在4.4节中已讨论了实际和有效用户ID及组ID。

8.3 函数fork

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

#include <unistd.h>

pid_t fork(void);

返回值:子进程返回0,父进程返回子进程ID;若出错,返回−1

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新建子进程的进程 ID。将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程 ID。fork 使子进程得到返回值 0 的理由是:一个进程只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID(进程ID 0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段(见7.6节)。

由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为替代,使用了写时复制(Copy-On-Write,COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。Bach[1986]的9.2节和 McKusick等[1996]的5.6节和5.7节对这种特征做了更详细的说明。

某些平台提供 fork 函数的几种变体。本书讨论的 4 种平台都支持下节将要讨论的vfork(2)。

Linux 3.2.0 提供了另一种新进程创建函数—clone(2)系统调用。这是一种fork的推广形式,它允许调用者控制哪些部分由父进程和子进程共享。

FreeBSD 8.0提供了rfork(2)系统调用,它类似于Linux的clone系统调用。rfork调用是从Plan 9操作系统(Pike等[1995])派生出来的。

Solaris 10提供了两个线程库:一个用于POSIX线程(pthreads),另一个用于Solaris线程。在这两个线程库中,fork 的行为有所不同。对于 POSIX 线程,fork 创建一个进程,它仅包含调用该fork的线程,但对于Solaris线程,fork创建的进程包含了调用线程所在进程的所有线程的副本。在Solaris 10中,这种行为改变了。不管使用哪种线程库,fork创建的子进程只保留调用线程的副本。Solaris也提供了fork1函数,它创建的进程只复制调用线程。还有forkall函数,它创建的进程复制了进程中所有的线程。第11章和第12章将详细讨论线程。

实例

图8-1程序演示了fork函数,从中可以看到子进程对变量所做的改变并不影响父进程中该变量的值。

如果执行此程序则得到:

图8-1 fork函数实例

$ ./a.out

a write to stdout

before fork

pid = 430, glob = 7, var = 89 子进程的变量值改变了

pid = 429, glob = 6, var = 88 父进程的变量值没有改变

$ a.out > temp.out

$ cat temp.out

a write to stdout

before fork

pid = 432, glob = 7, var = 89

before fork

pid = 431, glob = 6, var = 88

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。在图8-1程序中,父进程使自己休眠2 s,以此使子进程先执行。但并不保证2 s已经足够,在8.9节讲述竟争条件时还将谈及这一问题及其他类型的同步方法。在10.16节中,我们将说明在fork之后如何使用信号使父进程和子进程同步。

当写标准输出时,我们将buf长度减去1作为输出字节数,这是为了避免将终止null字节写出。strlen 计算不包含终止 null 字节的字符串长度,而 sizeof 则计算包括终止 null字节的缓冲区长度。两者之间的另一个差别是,使用 strlen 需进行一次函数调用,而对于sizeof 而言,因为缓冲区已用已知字符串进行初始化,其长度是固定的,所以 sizeof 是在编译时计算缓冲区长度。

注意图8-1所示的程序中fork与I/O函数之间的交互关系。回忆第3章中所述,write函数是不带缓冲的。因为在fork之前调用write,所以其数据写到标准输出一次。但是,标准I/O库是带缓冲的。回忆一下5.12节,如果标准输出连到终端设备,则它是行缓冲的;否则它是全缓冲的。当以交互方式运行该程序时,只得到该printf输出的行一次,其原因是标准输出缓冲区由换行符冲洗。但是当将标准输出重定向到一个文件时,却得到printf输出行两次。其原因是,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。在exit之前的第二个printf将其数据追加到已有的缓冲区中。当每个进程终止时,其缓冲区中的内容都被写到相应文件中。

文件共享

对图8-1程序需注意的另一点是:在重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项(见图3-9)。

考虑下述情况,一个进程具有3个不同的打开文件,它们是标准输入、标准输出和标准错误。在从fork返回时,我们有了如图8-2中所示的结构。

重要的一点是,父进程和子进程共享同一个文件偏移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由 shell 实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式的交互就要困难得多,可能需要父进程显式地动作。

图8-2 fork之后父进程和子进程之间对打开文件的共享

如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。虽然这种情况是可能发生的(见图8-2),但这并不是常用的操作模式。

在fork之后处理文件描述符有以下两种常见的情况。

(1)父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。

(2)父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用的。

除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:

•实际用户ID、实际组ID、有效用户ID、有效组ID

•附属组ID

•进程组ID

•会话ID

•控制终端

•设置用户ID标志和设置组ID标志

•当前工作目录

•根目录

•文件模式创建屏蔽字

•信号屏蔽和安排

•对任一打开文件描述符的执行时关闭(close-on-exec)标志

•环境

•连接的共享存储段

•存储映像

•资源限制

父进程和子进程之间的区别具体如下。

•fork的返回值不同。

•进程ID不同。

•这两个进程的父进程ID不同:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。

•子进程的tms_utime、tms_stime、tms_cutime和tms_ustime的值设置为0(这些时间将在8.17节中介绍)。

•子进程不继承父进程设置的文件锁。

•子进程的未处理闹钟被清除。

•子进程的未处理信号集设置为空集。

其中很多特性至今尚未讨论过,我们将在以后几章中对它们进行说明。

使fork失败的两个主要原因是:(a)系统中已经有了太多的进程(通常意味着某个方面出了问题),(b)该实际用户ID的进程总数超过了系统限制。回忆图2-11,其中CHILD_MAX规定了每个实际用户ID在任一时刻可拥有的最大进程数。

fork有以下两种用法。

(1)一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的—父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。

(2)一个进程要执行一个不同的程序。这对 shell 是常见的情况。在这种情况下,子进程从fork返回后立即调用exec(我们将在8.10节说明exec)。

某些操作系统将第 2 种用法中的两个操作(fork 之后执行 exec)组合成一个操作,称为spawn。UNIX系统将这两个操作分开,因为在很多场合需要单独使用fork,其后并不跟随exec。另外,将这两个操作分开,使得子进程在fork和exec之间可以更改自己的属性,如I/O重定向、用户ID、信号安排等。在第15章中有很多这方面的例子。

Single UNIX Specification在高级实时选项组中确实包括了spawn接口。但是该接口并不想替换fork和exec。它们的目的是支持难于有效实现fork的系统,特别是对存储管理缺少硬件支持的系统。

8.4 函数vfork

vfork函数的调用序列和返回值与fork相同,但两者的语义不同。

vfork 起源于较早的 2.9BSD。有些人认为,该函数是有瑕疵的。但是本书讨论的 4 种平台都支持它。事实上,BSD 的开发者在 4.4BSD 中删除了该函数,但 4.4BSD 派生的所有开放源码BSD版本又将其收回。在SUSv3中,vfork被标记为弃用的接口,在SUSv4中被完全删除。我们只是由于历史的原因还是把它包含进来。可移植的应用程序不应该使用这个函数。

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序(如上一节末尾的(2)中一样)。图1-7程序中的shell基本部分就是这类程序的一个例子。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是也就不会引用该地址空间。不过在子进程调用exec或exit之前,它在父进程的空间中运行。这种优化工作方式在某些UNIX系统的实现中提高了效率,但如果子进程修改数据(除了用于存放vfork返回值的变量)、进行函数调用、或者没有调用 exec 或 exit 就返回都可能会带来未知的结果。(就像上一节中提及的,实现采用写时复制技术以提高fork之后跟随exec操作的效率,但是不复制比部分复制还是要快一些。)

vfork和fork之间的另一个区别是:vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。)

实例

图8-3中的程序是图8-1中的程序的修改版,其中用vfork代替了fork,删除了对于标准输出的write调用。另外,我们也不再需要让父进程调用sleep,因为我们可以保证,在子进程调用exec或exit之前,内核会使父进程处于休眠状态。

图8-3 vfork函数实例

运行该程序得到:

$.la.out

before vfork

pid = 29039, glob = 7, var = 89

子进程对变量做增1的操作,结果改变了父进程中的变量值。因为子进程在父进程的地址空间中运行,所以这并不令人惊讶。但是其作用的确与fork不同。

注意,在图8-3程序中,调用了_exit而不是exit。正如7.3节所述,_exit并不执行标准I/O缓冲区的冲洗操作。如果调用的是exit而不是_exit,则该程序的输出是不确定的。它依赖于标准I/O库的实现,我们可能会看到输出没有发生变化,或者发现没有出现父进程的printf输出。

如果子进程调用 exit,实现冲洗标准 I/O 流。如果这是函数库采取的唯一动作,那么我们会见到这样操作的输出与子进程调用_exit所产生的输出完全相同,没有任何区别。如果该实现也关闭标准I/O 流,那么表示标准输出FILE 对象的相关存储区将被清 0。因为子进程借用了父进程的地址空间,所以当父进程恢复运行并调用 printf 时,也就不会产生任何输出,printf返回−1。注意,父进程的STDOUT_FILENO仍然有效,子进程得到的是父进程的文件描述符数组的副本(参见图8-2)。

大多数exit的现代实现不再在流的关闭方面自找麻烦。因为进程即将终止,那时内核将关闭在进程中已打开的所有文件描述符。在库中关闭这些,只是增加了开销而不会带来任何益处。

McKusick等[1996]的5.6节中包含了fork和vfork实现方面的更多信息。习题8.1和习题8.2将继续对vfork进行讨论。

8.5 函数exit

如7.3节所述,进程有5种正常终止及3种异常终止方式。5种正常终止方式具体如下。

(1)在main函数内执行return语句。如在7.3节中所述,这等效于调用exit。

(2)调用exit函数。此函数由ISO C定义,其操作包括调用各终止处理程序(终止处理程序在调用atexit函数时登记),然后关闭所有标准I/O流等。因为ISO C并不处理文件描述符、多进程(父进程和子进程)以及作业控制,所以这一定义对UNIX系统而言是不完整的。

(3)调用_exit或_Exit函数。ISOC定义_Exit,其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。对标准 I/O 流是否进行冲洗,这取决于实现。在 UNIX系统中,_Exit 和_exit 是同义的,并不冲洗标准 I/O 流。_exit 函数由 exit 调用,它处理UNIX系统特定的细节。_exit是由POSIX.1说明的。

在大多数UNIX系统实现中,exit(3)是标准C库中的一个函数,而_exit(2)则是一个系统调用。

(4)进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。

(5)进程的最后一个线程调用 pthread_exit 函数。如同前面一样,在这种情况中,进程终止状态总是0,这与传送给pthread_exit的参数无关。在11.5节中,我们将对pthread_exit做更多说明。

3种异常终止具体如下。

(1)调用abort。它产生SIGABRT信号,这是下一种异常终止的一种特例。

(2)当进程接收到某些信号时。(第10章将较详细地说明信号。)信号可由进程自身(如调用abort函数)、其他进程或内核产生。例如,若进程引用地址空间之外的存储单元、或者除以0,内核就会为该进程产生相应的信号。

(3)最后一个线程对“取消”(cancellation)请求作出响应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。在 11.5 节和 12.7 节,我们将详细讨论“取消”请求。

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。

对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于 3个终止函数(exit、_exit和_Exit),实现这一点的方法是,将其退出状态(exit status)作为参数传送给函数。在异常终止情况,内核(不是进程本身)产生一个指示其异常终止原因的终止状态(termination status)。在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数(将在下一节说明)取得其终止状态。

注意,这里使用了“退出状态”(它是传递给向3个终止函数的参数,或main的返回值)和“终止状态”两个术语,以表示有所区别。在最后调用_exit时,内核将退出状态转换成终止状态(回忆图7-2)。图8-4说明父进程检查子进程终止状态的不同方法。如果子进程正常终止,则父进程可以获得子进程的退出状态。

在说明fork函数时,显而易见,子进程是在父进程调用fork后生成的。上面又说明了子进程将其终止状态返回给父进程。但是如果父进程在子进程之前终止,又将如何呢?其回答是:对于父进程已经终止的所有进程,它们的父进程都改变为 init 进程。我们称这些进程由 init进程收养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1(init进程的ID)。这种处理方法保证了每个进程有一个父进程。

另一个我们关心的情况是,如果子进程在父进程之前终止,那么父进程又如何能在做相应检查时得到子进程的终止状态呢?如果子进程完全消失了,父进程在最终准备好检查子进程是否终止时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开文件。在 UNIX 术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程(zombie)。ps(1)命令将僵死进程的状态打印为Z。如果编写一个长期运行的程序,它fork了很多子进程,那么除非父进程等待取得子进程的终止状态,不然这些子进程终止后就会变成僵死进程。

236

某些系统提供了一种避免产生僵死进程的方法,这将在10.7中介绍。

最后一个要考虑的问题是:一个由init进程收养的进程终止时会发生什么?它会不会变成一个僵死进程?对此问题的回答是“否”,因为init被编写成无论何时只要有一个子进程终止, init 就会调用一个 wait 函数取得其终止状态。这样也就防止了在系统中塞满僵死进程。当提及“一个init的子进程”时,这指的可能是init直接产生的进程(如将在9.2节说明的getty进程),也可能是其父进程已终止,由init收养的进程。

8.6 函数wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。因为子进程终止是个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。对于这种信号的系统默认动作是忽略它。第10章将说明这些选项。现在需要知道的是调用wait或waitpid的进程可能会发生什么。

•如果其所有子进程都还在运行,则阻塞。

•如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。

•如果它没有任何子进程,则立即出错返回 。

如果进程由于接收到SIGCHLD信号而调用wait,我们期望wait会立即返回。但是如果在随机时间点调用wait,则进程可能会阻塞。

#include <sys/wait.h>

pid_t wait(int *statloc);

pid_t waitpid(pid_t pid, int *statloc, int options);

两个函数返回值:若成功,返回进程ID;若出错,返回0(见后面的说明)或−1

这两个函数的区别如下。

•在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项,可使调用者不阻塞。

•waitpid并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。

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

这两个函数的参数statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。

依据传统,这两个函数返回的整型状态字是由实现定义的。其中某些位表示退出状态(正常返回),其他位则指示信号编号(异常返回),有一位指示是否产生了core文件等。POSIX.1规定,终止状态用定义在<sys/wait.h>中的各个宏来查看。有4个互斥的宏可用来取得进程终止的原因,它们的名字都以WIF开始。基于这4个宏中哪一个值为真,就可选用其他宏来取得退出状态、信号编号等。这4个互斥的宏示于图8-4中。

图8-4 检查wait和waitpid所返回的终止状态的宏

在9.8节中讨论作业控制时,将说明如何停止一个进程。

实例

图8-5中的函数pr_exit使用图8-4中的宏以打印进程终止状态的说明。本书中的很多程序都将调用此函数。注意,如果定义了WCOREDUMP宏,则此函数也处理该宏。

图8-5 打印exit状态的说明

FreeBSD 8.0、Linux 3.2.0、Mac OS X 10.6.8以及Solaris 10都支持WCOREDUMP宏。但是如果定义了_POSIX_C_SOURCE常量,有些平台就隐藏这个定义(回忆2.7节)。

图8-6中程序调用pr_exit函数,演示终止状态的各种值。

图8-6 演示不同的exit值

运行该程序可得:

$ ./a.out

normal termination, exit status = 7

abnormal termination, signal number = 6 (core file generated)

abnormal termination, signal number = 8 (core file generated)

现在,我们可以从WTERMSIG中打印信号编号。可以查看<signal.h>头文件验证SIGABRT的值为6,SIGFPE的值为8。我们将在10.22节中看到一种可移植的方式进行信号编号到说明性名字的映射。

正如前面所述,如果一个进程有几个子进程,那么只要有一个子进程终止,wait 就返回。如果要等待一个指定的进程终止(如果知道要等待进程的ID),那么该如何做呢?在早期的UNIX版本中,必须调用wait,然后将其返回的进程ID和所期望的进程ID相比较。如果终止进程不是所期望的,则将该进程ID和终止状态保存起来,然后再次调用wait。反复这样做,直到所期望的进程终止。下一次又想等待一个特定进程时,先查看已终止的进程列表,若其中已有要等待的进程,则获取相关信息;否则调用wait。其实,我们需要的是等待一个特定进程的函数。POSIX.定义了waitpid函数以提供这种功能(以及其他一些功能)。

对于waitpid函数中pid参数的作用解释如下。

pid ==−1 等待任一子进程。此种情况下,waitpid与wait等效。

pid > 0 等待进程ID与pid相等的子进程。

pid == 0 等待组ID等于调用进程组ID的任一子进程。(9.4节将说明进程组。)

pid <−1 等待组ID等于pid绝对值的任一子进程。

waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态存放在由statloc指向的存储单元中。对于 wait,其唯一的出错是调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种出错。第10章将对此进行讨论)。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。

options参数使我们能进一步控制waitpid的操作。此参数或者是0,或者是图8-7中常量按位或运算的结果。

FreeBSD 8.0和Solaris 10支持另一个非标准的可选常量WNOWAIT,它使系统将终止状态已由waitpid返回的进程保持在等待状态,这样它可被再次等待。

图8-7 waitpid的options常量

waitpid函数提供了wait函数没有提供的3个功能。

(1)waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。在讨论popen函数时会再说明这一功能。

(2)waitpid提供了一个 wait 的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。

(3)waitpid通过WUNTRACED和WCONTINUED选项支持作业控制。

实例

回忆8.5节中有关僵死进程的讨论。如果一个进程fork一个子进程,但不要它等待子进程终止,也不希望子进程处于僵死状态直到父进程终止,实现这一要求的诀窍是调用fork两次。图8-8程序实现了这一点。

图8-8 fork两次以避免僵死进程

第二个子进程调用sleep以保证在打印父进程ID时第一个子进程已终止。在fork之后,父进程和子进程都可继续执行,并且我们无法预知哪一个会先执行。在fork之后,如果不使第二个子进程休眠,那么它可能比其父进程先执行,于是它打印的父进程ID将是创建它的父进程,而不是init进程(进程ID 1)。

执行图8-8程序得到:

$ ./a.out

$ second child, parent pid = 1

注意,当原先的进程(也就是exec本程序的进程)终止时,shell打印其提示符,这在第二个子进程打印其父进程ID之前。

8.7 函数waitid

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

#include <sys/wait.h>

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

返回值:若成功,返回0;若出错,返回−1

与 waitpid 相似,waitid 允许一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型,而不是将此与进程ID或进程组ID组合成一个参数。id参数的作用与idtype的值相关。该函数支持的idtype类型列在图8-9中。

图8-10 waitid的options常量

WCONTINUED、WEXITED或WSTOPPED这3个常量之一必须在options参数中指定。

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

本书讨论的4种平台中,Linux 3.2.0、Mac OS X 10.6.8和Solaris 10支持waitid。但要注意的是,Mac OS X 10.6.8并没有设置siginfo结构中的所有信息。

8.8 函数wait3和wait4

大多数UNIX系统实现提供了另外两个函数wait3和wait4。历史上,这两个函数是从UNIX系统的BSD分支延袭下来的。它们提供的功能比POSIX.1函数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);

两个函数返回值:若成功,返回进程ID;若出错,返回−1

资源统计信息包括用户CPU时间总量、系统CPU时间总量、缺页次数、接收到信号的次数等。有关细节请参阅 getrusage(2)手册页(这种资源信息与 7.11 节中所述的资源限制不同)。图8-11列出了各个wait函数所支持的参数。

图8-11 不同系统上各个wait函数所支持的参数

Single UNIX Specification的早期版本包括wait3函数。在SUSv2中,wait3被移到了遗留目录下,在SUSv3中,则删去了wait3。

8.9 竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件(race condition)。如果在 fork 之后的某种逻辑显式或隐式地依赖于在fork 之后是父进程先运行还是子进程先运行,那么 fork 函数就会是竞争条件活跃的滋生地。通常,我们不能预料哪一个进程先运行。即使我们知道哪一个进程先运行,在该进程开始运行后所发生的事情也依赖于系统负载以及内核的调度算法。

在图8-8程序中,当第二个子进程打印其父进程ID时,我们看到了一个潜在的竞争条件。如果第二个子进程在第一个子进程之前运行,则其父进程将会是第一个子进程。但是,如果第一个子进程先运行,并有足够的时间到达并执行exit,则第二个子进程的父进程就是init。即使在程序中调用sleep,也不能保证什么。如果系统负载很重,那么在sleep返回之后、第一个子进程得到机会运行之前,第二个子进程可能恢复运行。这种形式的问题很难调试,因为在大部分时间,这种问题并不出现。

如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止(如图8-8程序中一样),则可使用下列形式的循环:

while(getppid() != 1)

sleep(1);

这种形式的循环称为轮询(polling),它的问题是浪费了CPU时间,因为调用者每隔1 s都被唤醒,然后进行条件测试。

为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收的方法。在UNIX 中可以使用信号机制,在 10.16 节将说明它在解决此方面问题的一种用法。各种形式的进程间通信(IPC)也可使用,在第15章和第17章将对此进行讨论。

在父进程和子进程的关系中,常常出现下述情况。在fork之后,父进程和子进程都有一些事情要做。例如,父进程可能要用子进程 ID 更新日志文件中的一个记录,而子进程则可能要为父进程创建一个文件。在本例中,要求每个进程在执行完它的一套初始化操作后要通知对方,并且在继续运行之前,要等待另一方完成其初始化操作。这种情况可以用代码描述如下:

#include "apue.h"

TELL_WAIT(); /* set things up for TELL_xxx & WAIT_xxx*/

if ((pid = fork()) < 0) {

err_sys("fork error");

} else if (pid == 0) {      /* child*/

/* child does whatever is necessary ...*/

TELL_PARENT(getppid());     /* tell parent we're done*/

WAIT_PARENT();         /* and wait for parent*//* and the child continues on its way ...*/

exit(0);

}

/* parent does whatever is necessary ...*/

TELL_CHILD(pid);        /* tell child we're done*/

WAIT_CHILD();          /* and wait for child*/

/* and the parent continues on its way ...*/

exit(0);

假定在头文件 apue.h 中定义了需要使用的各个变量。5 个例程 TELLWAIT、TELL PARENT、TELL_CHILD、WAIT_PARENT以及WAIT_CHILD可以是宏,也可以是函数。

在后面几章中会说明实现这些TELL和WAIT例程的不同方法:10.16节中说明使用信号的一种实现,图15-7程序说明使用管道的一种实现。下面先看一个使用这5个例程的实例。

实例

图8-12程序输出两个字符串:一个由子进程输出,另一个由父进程输出。因为输出依赖于内核使这两个进程运行的顺序及每个进程运行的时间长度,所以该程序包含了一个竞争条件。

图8-12 带有竞争条件的程序

在程序中将标准输出设置为不带缓冲的,于是每个字符输出都需调用一次write。本例的目的是使内核能尽可能多次地在两个进程之间进行切换,以便演示竞争条件。(如果不这样做,可能也就决不会见到下面所示的输出。没有看到具有错误的输出并不意味着竞争条件不存在,这只是意味着在此特定的系统上未能见到它。)下面的实际输出说明该程序的运行结果是会改变的。

$ ./a.out

ooutput from child

utput from parent

$ ./a.out

ooutput from child

utput from parent

$ ./a.out

output from child

output from parent

修改图8-12中的程序,使其使用TELL和WAIT函数,于是形成了图8-13中的程序。行首标以+号的行是新增加的行。

图8-13 修改图8-12程序以避免竞争条件

运行此程序则能得到所预期的输出—两个进程的输出不再交叉混合。

图8-13中的程序是使父进程先运行。如果将fork之后的行改成:

else if (pid == 0) {

charatatime("output from child\n");

TELL_PARENT(getppid());

} else {

WAIT_CHILD();    /* child goes first */

charatatime("output from parent\n");

}

则子进程先运行。习题8.4将继续这一实例。

8.10 函数exec

8.3节曾提及用fork函数创建新的子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

有7种不同的exec函数可供使用,它们常常被统称为exec函数,我们可以使用这7个函数中的任一个。这些exec函数使得UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。在后面各节中将使用这些原语构造另外一些如popen和system之类的函数。

#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[]);

7个函数返回值:若出错,返回−1;若成功,不返回

这些函数之间的第一个区别是前4个函数取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。当指定filename作为参数时:

•如果filename中包含/,则就将其视为路径名;

•否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件。

PATH 变量包含了一张目录表(称为路径前缀),目录之间用冒号(:)分隔。例如,下列name=value环境字符串指定在4个目录中进行搜索。

PATH=/bin:/usr/bin:/usr/local/bin:.

最后的路径前缀.表示当前目录。(零长前缀也表示当前目录。在value的开始处可用:表示,在行中间则要用::表示,在行尾以:表示。)

出于安全性方面的考虑,有些人要求在搜索路径中决不要包括当前目录。请参见Garfinkel等[2003]。

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

fexecve函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。否则,拥有特权的恶意用户就可以在找到文件位置并且验证之后,但在调用进程执行该文件之前替换可执行文件(或可执行文件的部分路径),具体可参考3.3节TOCTTOU的讨论。

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

在使用ISO C原型之前,对execl、execle和execlp三个函数表示命令行参数的一般方法是:

char *arg0, char *arg1, ..., char *argn, (char *)0

这种语法显式地说明了最后一个命令行参数之后跟了一个空指针。如果用常量0来表示一个空指针,则必须将它强制转换为一个指针;否则它将被解释为整型参数。如果一个整型数的长度与char *的长度不同,那么exec函数的实际参数将出错。

最后一个区别与向新程序传递环境表相关。以e结尾的3个函数(execle、execve和fexecve)可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境(回忆7.9节及图7-8中对环境字符串的讨论。其中曾提及如果系统支持setenv和putenv这样的函数,则可更改当前环境和后面生成的子进程的环境,但不能影响父进程的环境)。通常,一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想要为子进程指定某一个确定的环境。例如,在初始化一个新登录的shell时,login程序通常创建一个只定义少数几个变量的特殊环境,而在我们登录时,可以通过shell启动文件,将其他变量加到环境中。

在使用ISO C原型之前,execle的参数是:

char *pathname, char *arg0, ..., char *argn, (char *)0, char *envp[]

从中可见,最后一个参数是指向环境字符串的各字符指针构成的数组的指针。而在ISO C原型中,所有命令行参数、空指针和envp指针都用省略号(...)表示。

这7个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助。字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。v表示该函数取一个argv[ ]矢量。最后,字母e表示该函数取envp[ ]数组,而不使用当前环境。图8-14显示了这7个函数之间的区别。

图8-14 7个exec函数之间的区别

每个系统对参数表和环境表的总长度都有一个限制。在 2.5.2 节和图 2-8 中,这种限制是由ARG_MAX给出的。在POSIX.1系统中,此值至少是4 096字节。当使用shell的文件名扩充功能产生一个文件名列表时,可能会受到此值的限制。例如,命令

grep getrlimit /usr/share/man/*/*

在某些系统上可能产生如下形式的shell错误:

Argument list too long

由于历史原因,System V中此限制值是5 120字节。早期BSD系统的此限制值是20 480字节。当前系统中,此限制值要大得多。(如图2-14所示的程序的输出,图2-15总结列出了限制值。)

为了摆脱对参数表长度的限制,我们可以使用xargs(1)命令,将长参数表断开成几部分。为了寻找在我们所用系统手册页中的getrlimit,我们可以用

find /usr/share/man -type f -print | xargs grep getrlimit

如果所用的系统手册页是压缩过的,则可使用

find /usr/share/man -type f -print | xargs bzgrep getrlimit

对于find命令,我们使用选项-type f,以限制输出列表只包含普通文件。这样做的原因是, grep命令不能在目录中进行模式搜索,我们也想避免不必要的出错消息。

前面曾提及,在执行exec 后,进程ID没有改变。但新程序从调用进程继承了的下列属性:

• 进程ID和父进程ID

•实际用户ID和实际组ID

•附属组ID

•进程组ID

•会话ID

•控制终端

•闹钟尚余留的时间

•当前工作目录

•根目录

•文件模式创建屏蔽字

•文件锁

•进程信号屏蔽

•未处理信号

•资源限制

•nice值(遵循XSI的系统,见8.16节)

•tms_utime、tms_stime、tms_cutime以及tms_cstime值

对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关。回忆图3-7以及3.14节中对FD_CLOEXEC标志的说明,进程中每个打开描述符都有一个执行时关闭标志。若设置了此标志,则在执行exec 时关闭该描述符;否则该描述符仍打开。除非特地用fcntl设置了该执行时关闭标志,否则系统的默认操作是在exec后仍保持这种描述符打开。

POSIX.1明确要求在exec时关闭打开目录流(见4.22节中所述的opendir函数)。这通常是由 opendir 函数实现的,它调用 fcntl 函数为对应于打开目录流的描述符设置执行时关闭标志。

注意,在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。

在很多UNIX实现中,这7个函数中只有execve是内核的系统调用。另外6个只是库函数,它们最终都要调用该系统调用。这7个函数之间的关系示于图8-15中。

图8-15 7个exec函数之间的关系

在这种安排中,库函数 execlp 和 execvp 使用 PATH 环境变量,查找第一个包含名为filename的可执行文件的路径名前缀。fexecve库函数使用/proc把文件描述符参数转换成路径名,execve用该路径名去执行程序。

这描述了在FreeBSD 8.0和Linux 3.2.0中是如何实现fexecve的。其他系统采用的方法可能不同。例如,没有/proc和/dev/fd的系统可能把fexecve实现为系统调用,把文件描述符参数转换成i节点指针,把execve实现为系统调用,把路径名参数转换成i节点指针,然后把execve和fexecve中剩余的exec公共代码放到单独的函数中,调用该函数时传入执行文件的i节点指针。

实例

图8-16中的程序演示了exec函数。

图8-16 exec函数实例

在该程序中先调用execle,它要求一个路径名和一个特定的环境。下一个调用的是execlp,它用一个文件名,并将调用者的环境传送给新程序。execlp 在这里能够工作是因为目录/home/sar/bin 是当前路径前缀之一。注意,我们将第一个参数(新程序中的 argv[0])设置为路径名的文件名分量。某些shell将此参数设置为完全的路径名。这只是一个惯例。我们可将argv[0]设置为任何字符串。当login命令执行shell时就是这样做的。在执行shell之前,login在argv[0]之前加一个/作为前缀,这向shell指明它是作为登录shell被调用的。登录shell将执行启动配置文件(start-up profile)命令,而非登录shell则不会执行这些命令。

图8-16中的程序要执行两次的echoall程序如图8-17所示。这是一个很普通的程序,它回显所有命令行参数及全部环境表。

图8-17 回显所有命令行参数和所有环境字符串

执行图8-16中的程序得到:

$ ./a.out

argv[0]: echoall

argv[1]: myarg1

argv[2]: MY ARG2

USER=unknown

PATH=/tmp

argv[0]: echoall

$ argv[1]: only 1 arg

USER=sar

LOGNAME=sar

SHELL=/bin/bash

还有47行没有列出

HOME=/home/sar

注意,shell 提示符出现在第二个 exec 打印 argv[0]之前。这是因为父进程并不等待该子进程结束。

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);

两个函数返回值:若成功,返回0;若出错,返回−1

关于谁能更改ID有若干规则。现在先考虑更改用户ID的规则(关于用户ID我们所说明的一切都适用于组ID)。

(1)若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID(saved set-user-ID)设置为uid。

(2)若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid。不更改实际用户ID和保存的设置用户ID。

(3)如果上面两个条件都不满足,则errno设置为EPERM,并返回−1。

在此假定_POSIX_SAVED_IDS 为真。如果没有提供这种功能,则上面所说的关于保存的设置用户ID部分都无效。

在POSIX.1 2001版中,保存的ID是强制性功能。而在较早版本中,它们是可选择的。为了弄清楚某种实现是否支持这一功能,应用程序在编译时可以测试常量_POSIOX_SAVED_IDS,或者在运行时以_SC_SAVED_IDS参数调用sysconf函数。

关于内核所维护的3个用户ID,还要注意以下几点。

(1)只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login(1)程序设置的,而且决不会改变它。因为login 是一个超级用户进程,当它调用setuid时,设置所有3个用户ID。

(2)仅当对程序文件设置了设置用户ID位时,exec函数才设置有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持其现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。自然地,不能将有效用户ID设置为任一随机值。

(3)保存的设置用户ID是由exec复制有效用户ID而得到的。如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了。

图8-18总结了更改这3个用户ID的不同方法。

图8-18 更改3个用户ID的不同方法

注意,8.2节中所述的getuid和geteuid函数只能获得实际用户ID和有效用户ID的当前值。我们没有可移植的方法去获得保存的设置用户ID的当前值。

FreeBSD 8.0和LINUX 3.2.0提供了getresuid和getresgid函数,它们可以分别用于获取保存的设置用户ID和保存的设置组ID。

1.函数setreuid和setregid

历史上,BSD支持setreuid函数,其功能是交换实际用户ID和有效用户ID的值。

#include <unistd.h>

int setreuid(uid_t ruid, uid_t euid);

int setregid(gid_t rgid, gid_t egid);

两个函数返回值:若成功,返回0;若出错,返回-1

如若其中任一参数的值为−1,则表示相应的ID应当保持不变。

规则很简单:一个非特权用户总能交换实际用户ID和有效用户ID。这就允许一个设置用户ID程序交换成用户的普通权限,以后又可再次交换回设置用户ID权限。POSIX.1引进了保存的设置用户ID特性后,其规则也相应加强,它允许一个非特权用户将其有效用户ID设置为保存的设置用户ID。

seteuid和setregid两个函数都是Single UNIX Specification的XSI扩展。因此,可以期望所有UNIX系统实现都将对它们提供支持。

4.3BSD并没有上面所说的保存的设置用户ID特性,而是使用setreuid和setregid来代替。这就允许一个非特权用户交换这两个用户ID的值,但是要注意,当使用此特性的程序生成shell进程时,它必须在exec之前先将实际用户ID设置为普通用户ID。如果不这样做的话,实际用户ID就可能是具有特权的(由setreuid的交换操作造成),然后shell进程可能会调用setreuid交换两个用户ID值并取得更多权限。作为一个保护性的解决这一问题的编程措施,程序在子进程调用exec之前,将子进程的实际用户ID和有效用户ID都设置成普通用户ID。

2.函数seteuid和setegid

POIX.1包含了两个函数seteuid和setegid。它们类似于setuid和setgid,但只更改有效用户ID和有效组ID。

#include <unistd.h>

int seteuid(uid_t uid);

int setegid(gid_t gid);

两个函数返回值:若成功,返回0;若出错,返回−1

一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户则可将有效用户ID设置为uid。(这区别于setuid函数,它更改所有3个用户ID。)

图8-19给出了本节所述的更改3个不同用户ID的各个函数。

图8-19 设置不同用户ID的各函数

3.组ID

本章中所说明的一切都以类似方式适用于各个组 ID。附属组 ID 不受 setgid、setregid和setegid函数的影响。

实例

为了说明保存的设置用户 ID 特性的用法,先观察一个使用该特性的程序。我们所观察的是at(1)程序,它用于调度将来某个时刻要运行的命令。

在Linux 3.2.0上安装的at程序的设置用户ID是daemon用户。在FreeBSD 8.0、Mac OS X 10.6.8以及Solaris 10上安装的at程序的设置用户ID是root用户。这允许at命令对守护进程拥有的特权文件具有写权限,守护进程代表用户运行at命令。在Linux 3.2.0上,程序是用atd(8)守护进程运行的。在FreeBSD 8.0和Solaris 10上,程序通过cron(1M)守护进程运行。在Mac OS X 10.6.8上,程序通过launchd(8)守护进程运行。

为了防止被欺骗而运行不被允许的命令或读、写没有访问权限的文件,at命令和最终代表用户运行命令的守护进程必须在两种特权之间切换:用户特权和守护进程特权。下面列出了其工作步骤。

(1)程序文件是由root用户拥有的,并且其设置用户ID位已设置。当我们运行此程序时,得到下列结果:

实际用户ID=我们的用户ID(未改变)

有效用户ID=root

保存的设置用户ID=root

(2)at 程序做的第一件事就是降低特权,以用户特权运行。它调用 setuid 函数把有效用户ID设置为实际用户ID。此时得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=我们的用户ID

保存设置用户ID=root(未改变)

(3)at 程序以我们的用户特权运行,直到它需要访问控制哪些命令即将运行,这些命令需要何时运行的配置文件时,at 程序的特权会改变。这些文件由为用户运行命令的守护进程持有。at命令调用setuid函数把有效用户ID设为root,因为setuid的参数等于保存的设置用户ID,所以这种调用是许可的(这就是为什么需要保存的设置用户ID的原因)。现在得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=root

保存的设置用户ID=root(未改变)

因为有效用户ID是root,文件访问是允许的。

(4)修改文件从而记录了将要运行的命令以及它们的运行时间以后,at命令通过调用seteuid,把有效用户ID设置为用户ID,降低它的特权。防止对特权的误用。此时我们可以得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=我们的用户ID

保存的设置用户ID=root(未改变)

(5)守护进程开始用 root 特权运行,代表用户运行命令,守护进程调用 fork,子进程调用setuid将它的用户ID更改至我们的用户ID。因为子进程以root特权运行,更改了所有的ID,所以

实际用户ID=我们的用户ID

有效用户ID=我们的用户ID

保存的设置用户ID=我们的用户ID

现在守护进程可以安全地代表我们执行命令,因为它只能访问我们通常可以访问的文件,我们没有额外的权限。

以这种方式使用保存的设置用户ID,只有在需要提升特权的时候,我们通过设置程序文件的设置用户 ID 而得到的额外权限。然而,其他时间进程在运行时只具有普通的权限。如果进程不能在其结束部分切换回保存的设置用户ID,那么就不得不在全部运行时间都保持额外的权限(这可能会造成麻烦)。

8.12 解释器文件

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

#! pathname [ optional-argument ]

在感叹号和pathname之间的空格是可选的。最常见的解释器文件以下列行开始:

#! /bin/sh

pathname通常是绝对路径名,对它不进行什么特殊的处理(不使用PATH进行路径搜索)。对这种文件的识别是由内核作为 exec系统调用处理的一部分来完成的。内核使调用 exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。一定要将解释器文件(文本文件,它以#!开头)和解释器(由该解释器文件第一行中的pathname指定)区分开来。

很多系统对解释器文件第一行有长度限制。这包括#!、pathname、可选参数、终止换行符以及空格数。

在FreeBSD 8.0中,该限制是4 097字节。Linux 3.2.0中,该限制为128字节。Mac OS X 10.6.8中,该限制为513字节,而Solaris 10的限制是1 024字节。

实例

让我们观察一个实例,从中可了解当被执行的文件是个解释器文件时,内核如何处理 exec

函数的参数及该解释器文件第一行的可选参数。图8-20中的程序调用exec执行一个解释器文件。

图8-20 执行一个解释器文件的程序

下面先显示要被执行的该解释器文件的内容(只有一行),接着是运行图8-20中的程序得到的结果。

$ cat /home/sar/bin/testinterp

#!/home/sar/bin/echoarg foo

$ ./a.out

argv[0]: /home/sar/bin/echoarg

argv[1]: foo

argv[2]: /home/sar/bin/testinterp

argv[3]: myarg1

argv[4]: MY ARG2

程序echoarg(解释器)回显每一个命令行参数(它就是图7-4中的程序)。注意,当内核exec解释器(/home/sar/bin/echoarg)时,argv[0]是该解释器的pathname,argv[1]是解释器文件中的可选参数,其余参数是pathname(/home/sar/bin/testinterp)以及图8-20所示的程序中调用execl的第2个和第3个参数(myarg1和MY ARG2)。调用 execl时的argv[1]和 argv[2]已右移了两个位置。注意,内核取 execl 调用中的 pathname 而非第一个参数(testinterp),因为一般而言,pathname包含了比第一个参数更多的信息。

实例

在解释器pathname后可跟随可选参数。如果一个解释器程序支持-f选项,那么在pathname后经常使用的就是-f。例如,可以以下列方式执行awk(1)程序:

awk -f myfile

它告诉awk从文件myfile中读awk程序。

在UNIX System V派生的很多系统中,常包含有awk语言的两个版本。awk常常被称为“老awk”,它是与V7一起分发的原始版本。nawk(新awk)包含了很多增强功能,对应于在Aho、Kernighan和Weinberger[1988]中说明的语言。此新版本提供了对命令行参数的访问,这是下面的例子所需的。Solaris 10 提供了两个版本。

POSIX 1003.2标准现在是Single UNIX Specification中基本POSIX.1规范的一部分。在该标准中,awk程序是其中的一个实用程序。该实用程序的基础也是Aho、Kernighan和Weinberger[1988]中所描述的语言。

Mac OS X 10.6.8中的awk版本基于贝尔实验室版本,并已将其放在公共域(public domain)中。FreeBSD 8.0和Linux的某些发行版提供GNU awk(gawk),它链接至名字awk。gawk版本遵循POSIX标准,但也包括了一些扩展。因为gawk和贝尔实验室的awk版本比较新,所以较之nawk 或老版本的 awk 更受人欢迎。(贝尔实验室的 awk 版本可从 http://cm.bell-labs.com/cm/cs/awkbook/index.html获取。)

在解释器文件中使用-f选项,可以写成:

#!/bin/awk –f

(在此解释器文件中后跟随awk程序)

例如,图8-21展示了在/usr/local/bin/awkexample中的一个解释器文件程序。

图8-21 作为解释器文件的awk程序

如果路径前缀之一是/usr/local/bin,则可以用下列方式执行图 8-21 中的程序(假定我们已打开了该文件的执行位):

$ awkexample filel FILENAME2 f3

ARGV[0] = awk

ARGV[1] = file1

ARGV[2] = FILENAME2

ARGV[3] = f3

执行/bin/awk时,其命令行参数是:

/bin/awk -f /usr/local/bin/awkexample file1 FILENAME2 f3

解释器文件的路径名(/usr/local/bin/awkexample)被传送给解释器。因为不能期望解释器(在本例中是/bin/awk)会使用 PATH 变量定位该解释器文件,所以只传送其路径名中的文件名是不够的,要将解释器文件完整的路径名传送给解释器。当awk读解释器文件时,因为#是awk的注释字符,所以它忽略第一行。

可以用下列命令验证上述命令行参数。

$ /bin/su                  成为超级用户

Password:                  输入超级用户口令

# mv /usr/bin/awk /usr/bin/awk.save     保存原先的程序

# cp /home/sar/bin/echoarg /usr/bin/awk   暂时替换它

# suspend                  用作业控制挂起超级用户shell

[1] + Stopped     /bin/su

$ awkexample file1 FILENAME2 f3

argv[0]: /bin/awk

argv[1]: -f

argv[2]: /usr/local/bin/awkexample

argv[3]: file1

argv[4]: FILENAME2

argv[5]: f3

$ fg                     用作业控制恢复超级用户shell

/bin/su

# mv /usr/bin/awk.save /usr/bin/awk     恢复原先的程序

# exit                    终止超级用户shell

在此例子中,解释器的-f选项是必需的。正如前述,它告诉awk在什么地方找到awk程序。如果在解释器文件中删除-f选项,则在试图运行该解释器文件时,通常输出一条出错消息。该出错消息的精确文本可能有所不同,这取决于解释器文件存放在何处以及其余参数是否表示现有的文件等。因为在这种情况下命令行参数是:

/bin/awk /usr/local/bin/awkexample file1 FILENAME2 f3

于是awk企图将字符串/usr/local/bin/awkexample解释为一个awk程序。如果不能向解释器传递至少一个可选参数(在本例中是-f),那么这些解释器文件只有对 shell才是有用的。

是否一定需要解释器文件呢?那也不完全如此。但是它们确实使用户得到效率方面的好处,其代价是内核的额外开销(因为识别解释器文件的是内核)。由于下述理由,解释器文件是有用的。

(1)有些程序是用某种语言写的脚本,解释器文件可将这一事实隐藏起来。例如,为了执行图8-21程序,只需使用下列命令行:

awkexample optional-arguments

而并不需要知道该程序实际上是一个awk脚本,否则就要以下列方式执行该程序:

awk -f awkexample optional-arguments

(2)解释器脚本在效率方面也提供了好处。再考虑一下前面的例子。仍旧隐藏该程序是一个awk脚本的事实,但是将其放在一个shell脚本中:

awk ’BEGIN {

for (i = 0; i < ARGC; i++)

printf "ARGV[%d] = %s\n", i, ARGV[i]

exit

}’ $*

这种解决方法的问题是要求做更多的工作。首先,shell读此命令,然后试图execlp此文件名。因为shell脚本是一个可执行文件,但却不是机器可执行的,于是返回一个错误,execlp就认为该文件是一个 shell 脚本(它实际上就是这种文件)。然后执行/bin/sh,并以该 shell 脚本的路径名作为其参数。shell正确地执行我们的shell脚本,但是为了运行awk程序,它调用fork、exec和wait。于是,用一个shell脚本代替解释器脚本需要更多的开销。

(3)解释器脚本使我们可以使用除/bin/sh以外的其他shell来编写shell脚本。当execlp找到一个非机器可执行的可执行文件时,它总是调用/bin/sh来解释执行该文件。但是,用解释器脚本则可简单地写成:

#!/bin/csh

(在解释器文件中后跟随C shell脚本)

再一次,我们也可将此放在一个/bin/sh脚本中(然后由其调用C shell),但是要有更多的开销。如果3个shell和awk没有用#作为注释符,则上面所说的都无效。

8.13 函数system

在程序中执行一个命令字符串很方便。例如,假定要将时间和日期放到某一个文件中,则可使用6.10节中的函数实现这一点。调用time得到当前日历时间,接着调用localtime将日历时间变换为年、月、日、时、分、秒、周日的分解形式,然后调用strftime对上面的结果进行格式化处理,最后将结果写到文件中。但是用下面的system函数则更容易做到这一点:

system("date > file");

ISO C定义了system函数,但是其操作对系统的依赖性很强。POSIX.1包括了system接口,它扩展了ISO C定义,描述了system在POSIX.1环境中的运行行为。

#include <stdlib.h>

int system(const char *cmdstring);

返回值:(见下)

如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。在UNIX中,system总是可用的。

因为system在其实现中调用了fork、exec和waitpid,因此有3种返回值。

(1)fork失败或者waitpid返回除EINTR之外的出错,则system返回−1,并且设置errno以指示错误类型。

(2)如果 exec失败(表示不能执行 shell),则其返回值如同 shell执行了 exit(127)一样。

(3)否则所有3个函数(fork、exec和waitpid)都成功,那么system的返回值是shell的终止状态,其格式已在waitpid中说明。

如果 waitpid 被一个捕捉到的信号中断,则某些早期的 system 实现都返回错误类型值EINTR。但是,因为没有可用的策略能让应用程序从这种错误类型中恢复(子进程的进程ID对调用者来说是未知的)。POSIX后来增加了下列要求:在这种情况下system不返回一个错误。(10.5节中将讨论被中断的系统调用。)

图8-22中的程序是system函数的一种实现。它对信号没有进行处理。10.18节中将修改此函数使其进行信号处理。

图8-22 system函数(没有对信号进行处理)

shell的-c选项告诉shell程序取下一个命令行参数(在这里是cmdstring)作为命令输入(而不是从标准输入或从一个给定的文件中读命令)。shell对以null字节终止的命令字符串进行语法分析,将它们分成命令行参数。传递给shell的实际命令字符串可以包含任一有效的shell命令。例如,可以用<和>对输入和输出重定向。

如果不使用shell执行此命令,而是试图由我们自己去执行它,那将相当困难。首先,我们必须用execlp而不是execl,像shell那样使用PATH变量。我们必须将null字节终止的命令字符串分成各个命令行参数,以便调用execlp。最后,我们也不能使用任何一个shell元字符。

注意,我们调用_exit而不是exit。这是为了防止任一标准I/O缓冲(这些缓冲会在fork中由父进程复制到子进程)在子进程中被冲洗。

用图8-23中的程序对这种实现的system函数进行测试(pr_exit函数定义在图8-5程序中)。

图8-23 调用system函数

运行图8-23程序得到:

$ ./a.out

Sat Feb 25 19:36:59 EST 2012

normal termination, exit status = 0   对于date

sh: nosuchcommand: command not found

normal termination, exit status = 127 对于无此种命令

sar   console Jan 1 14:59

sar   ttys000 Feb 7 19:08

sar   ttys001 Jan 15 15:28

sar   ttys002 Jan 15 21:50

sar   ttys003 Jan 21 16:02

normal termination, exit status = 44  对于 exit

使用system而不是直接使用fork和exec的优点是:system进行了所需的各种出错处理以及各种信号处理(在10.18节中的下一个版本system函数中)。

在UNIX的早期系统中,包括SVR3.2和4.3BSD,都没有waitpid函数,于是父进程用下列形式的语句等待子进程:

while ((lastpid = wait(&status)) != pid && lastpid != -1)

;

如果调用 system 的进程在调用它之前已经生成子进程,那么将引起问题。因为上面的while语句一直循环执行,直到由system产生的子进程终止才停止,如果不是用pid标识的任一子进程在pid子进程之前终止,则它们的进程ID和终止状态都被while语句丢弃。实际上,由于wait 不能等待一个指定的进程以及其他一些原因,POSIX.1 Rationale 才定义了waitpid函数。如果不提供waitpid函数,popen和pclose函数也会发生同样的问题(见15.3节)。

设置用户ID程序

如果在一个设置用户 ID 程序中调用 system,那会发生什么呢?这是一个安全性方面的漏洞,决不应当这样做。图8-24程序是一个简单程序,它只是对其命令行参数调用system函数。

图8-24 用system执行命令行参数

将此程序编译成可执行目标文件tsys。

图8-25所示的是另一个简单程序,它打印实际用户ID和有效用户ID。

图8-25 打印实际用户ID和有效用户ID

将此程序编译成可执行目标文件printuids。运行这两个程序,得到如下结果:

$ tsys printuids           正常执行,无特权real uid = 205, effective uid = 205

normal termination, exit status = 0

$ su                  成为超级用户

Password:               输入超级用户口令

# chown root tsys           更改所有者

# chmod u+s tsys           增加设置用户ID

# ls -l tsys              检验文件权限和所有者

-rwsrwxr-x 1 root   7888 Feb 25 22:13 tsys

# exit                 退出超级用户shell

$ tsys printuids

real uid = 205, effective uid = 0   哎呀! 这是一个安全性漏洞

normal termination, exit status = 0

我们给予tsys程序的超级用户权限在system中执行了fork和exec之后仍被保持下来。

有些实现通过更改/bin/sh,当有效用户ID与实际用户ID不匹配时,将有效用户ID设置为实际用户ID,这样可以关闭上述安全漏洞。在这些系统中,上述示例的结果就不会发生。不管调用system的程序设置用户ID位状态如何,都会打印出相同的有效用户ID。

如果一个进程正以特殊的权限(设置用户ID或设置组ID)运行,它又想生成另一个进程执行另一个程序,则它应当直接使用fork和exec,而且在fork之后、exec之前要更改回普通权限。设置用户ID或设置组ID程序决不应调用system函数。

这种警告的一个理由是:system调用shell对命令字符串进行语法分析,而shell使用IFS变量作为其输入字段分隔符。早期的shell版本在被调用时不将此变量重置为普通字符集。这就允许一个恶意的用户在调用system之前设置IFS,造成system执行一个不同的程序。

8.14 进程会计

大多数UNIX系统提供了一个选项以进行进程会计(process accounting)处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。本节将较详细地说明这种会计记录,这样也使我们得到了一个再次观察进程的机会,以及使用5.9节中所介绍的fread函数的机会。

任一标准都没有对进程会计进行过说明。于是,所有实现都有令人厌烦的差别。例如,关于I/O的数量,Solaris 10使用的单位是字节,FreeBSD 8.0和Mac OS X 10.6.8使用的单位是块,但又不考虑不同的块长,这使得该计数值并无实际效用。Linux 3.2.0则完全没有保持I/O统计数。

每种实现也都有自己的一套管理命令去处理这种原始的会计数据。例如,Solaris 提供了runacct(1m)和acctcom(1),FreeBSD则提供sa(8)命令处理并总结原始会计数据。

一个至今没有说明的函数(acct)启用和禁用进程会计。唯一使用这一函数的是accton(8)命令(这是在几种平台上都类似的少数几条命令中的一条)。超级用户执行一个带路径名参数的accton命令启用会计处理。会计记录写到指定的文件中,在FreeBSD和Mac OS X中,该文件通常是/var/account/acct;在Linux中,该文件是/var/account/pacct;在Solaris中,该文件是/var/adm/pacct。执行不带任何参数的accton命令则停止会计处理。

会计记录结构定义在头文件<sys/acct.h>中,虽然每种系统的实现各不相同,但会计记录样式基本如下:

typedef u_short comp_t;    /* 3-bit base 8 exponent; 13-bit fraction*/

struct acct

{

char  ac_flag;        /* flag (see Figure 8.26)*/

char  ac_stat;        /* termination status(signal & core flag only)*/

/* (Solaris only)*/

uid_t ac_uid;        /* real user ID*/

gid_t ac_gid;        /* real group ID*/

dev_t ac_tty;        /* controlling terminal*/

time_t ac_btime;       /* starting calendar time*/

comp_t ac_utime;       /* user CPU time*/

comp_t ac_stime;       /* system CPU time*/

comp_t ac_etime;       /* elapsed time*/

comp_t ac_mem;        /* average memory usage*/

comp_t ac_io;         /* bytes transferred (by read and write)*/

comp_t ac_rw;         /* blocks read or written*/

char  ac_comm[8];      /* command name: [8] for Solaris,*/

/* "blocks" on BSD systems*/

/* (not present on BSD systems)*/

/* [10] for Mac OS X, [16] for FreeBSD, and*/

/* [17] for Linux*/

};

在大多数的平台上,时间是以时钟滴答数记录的,但FreeBSD以微秒进行记录的。ac_flag成员记录了进程执行期间的某些事件。这些事件见图8-26。

图8-26 会计记录中的ac_flag值

会计记录所需的各个数据(各CPU时间、传输的字符数等)都由内核保存在进程表中,并在一个新进程被创建时初始化(如fork之后在子进程中)。进程终止时写一个会计记录。这产生两个后果。

第一,我们不能获取永远不终止的进程的会计记录。像init这样的进程在系统生命周期中一直在运行,并不产生会计记录。这也同样适合于内核守护进程,它们通常不会终止。

第二,在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。为了确定启动顺序,需要读全部会计文件,并按启动日历时间进行排序。这不是一种很完善的方法,因为日历时间的单位是秒(见 1.10 节),在一个给定的秒中可能启动了多个进程。而墙上时钟时间的单位是时钟滴答(通常,每秒滴答数在60~128)。但是我们并不知道进程的终止时间,所知道的只是启动时间和终止顺序。这就意味着,即使墙上时钟时间比启动时间要精确得多,仍不能按照会计文件中的数据重构各进程的精确启动顺序。

会计记录对应于进程而不是程序。在fork之后,内核为子进程初始化一个记录,而不是在一个新程序被执行时初始化。虽然exec并不创建一个新的会计记录,但相应记录中的命令名改变了,AFORK标志则被清除。这意味着,如果一个进程顺序执行了3个程序(A exec B、B exec C,最后是C exit),只会写一个会计记录。在该记录中的命令名对应于程序C,但CPU时间是程序A、B和C之和。

实例

为了得到某些会计数据以便查看,我们按图8-27编写了测试程序。

测试程序的源代码如图8-28所示。该程序调用4次fork。每个子进程做不同的事情,然后终止。

图8-27 会计处理实例的进程结构

图8-28 产生会计数据的程序

在Solaris上运行该测试程序,然后用图8-29中的程序从会计记录中选择一些字段并打印出来。

图8-29 打印从系统会计文件中选出的字段

BSD 派生的平台不支持 ac_stat 成员,所以我们在支持该成员的平台上定义了HAS_AC_STAT 常量。基于特性而非平台定义的符号常量使代码更易读,也使我们更容易修改程序。修改的方法是对编译命令增加新的定义。替代方法可以是使用:

#if !defined(BSD) && !defined(MACOS)

但是,当将应用移植到其他平台上时,这种方法会带来很大的不便。

我们定义了类似的常量以判断该平台是否支持ACORE和AXSIG会计标志。我们不能直接使用这两个标志符号,其原因是,在Linux中,它们被定义为enum类型值,而在#ifdef表达式中不能使用此种类型值。

为了进行测试,执行下列操作步骤。

(1)成为超级用户,用accton命令启用会计处理。注意,当此命令结束时,会计处理已经启用,因此在会计文件中的第一个记录应来自这一命令。

(2)终止超级用户shell,运行图 8-28程序。这会追加6个记录到会计文件中(超级用户shell一个、父进程一个、4个子进程各一个)。

在第二个子进程中,execl并不创建一个新进程,所以对第二个进程只有一个会计记录。(3)成为超级用户,停止会计处理。因为在accton命令终止时已经停止会计处理,所以不会在会计文件中增加一个记录。

(4)运行图8-29程序,从会计文件中选出字段并打印。

第4步的输出如下面所示。在每一行中都对进程追加了说明,以便后面讨论。

accton  e =  1, chars =  336, stat =  0:    S

sh    e = 1550, chars = 20168, stat =  0:    S

dd    e =   2, chars =  1585, stat =  0:       第二个子进程

a.out  e =  202, chars =   0, stat  =  0:       父进程

a.out  e =  420, chars =   0, stat  = 134:  F    第一个子进程

a.out  e =  600, chars =   0, stat  =  9:   F    第四个子进程

a.out  e =  801, chars =   0, stat  =  0:   F    第三个子进程

墙上时钟时间值的单位是每秒滴答数。从图2-15中可见,本系统的每秒滴答数是100。例如,在父进程中的 sleep(2)对应于墙上时钟时间 202 个时钟滴答。对于第一个子进程,sleep(4)变成420时钟滴答。注意,一个进程休眠的时间总量并不精确。(第10章将返回到sleep函数。)调用fork和exit也需要一些时间。

注意,ac_stat成员并不是进程的真正终止状态。它只是8.6节中讨论的终止状态的一部分。如果进程异常终止,则此字节包含的信息只是core标志位(一般是最高位)以及信号编号数(一般是低7位)。如果进程正常终止,则从会计文件不能得到进程的退出(exit)状态。对于第一个子进程,此值是128+6。128是core标志位,6是此系统信号SIGABRT的值(它是由调用abort产生的)。第四个子进程的值是9,它对应于SIGKILL的值。从会计文件的数据中不能分辨出,父进程在退出时所用的参数值是2,第三个子进程退出时所用的参数值是0。

dd进程将文件/etc/passwd复制到第二个子进程中,该文件的长度是777字节。而I/O字符数是此值的2倍,其原因是读了777字节,然后又写了777字节。即使输出到空设备,但仍对I/O 字符数进行计算。dd 命令还有 31 个附加字节,用于报告读写字节数的摘要信息,该摘要信息也会在stdout上打印输出。

ac_flag值与我们所预料的相同。除调用execl的第二个子进程以外,其他子进程都设置了F 标志。父进程没有设置F 标志,其原因是执行父进程的交互式 shell调用 fork,然后执行a.out文件。第一个子进程调用abort,abort产生信号SIGABRT,产生了core转储。该进程的X标志和D标志都没有打开,因为Solaris不支持它们;相关信息可从ac_stat字段导出。第四个子进程也因信号而终止,但是SIGKILL信号并不产生core转储,它只是终止该进程。

最后要说明的是:第一个子进程的 I/O 字符数为 0,但是该进程产生了一个 core 文件。其原因是写core文件所需的I/O并不由该进程负责。

8.15 用户标识

任一进程都可以得到其实际用户ID和有效用户ID及组ID。但是,我们有时希望找到运行该程序用户的登录名。我们可以调用getpwuid(getuid()),但是如果一个用户有多个登录名,这些登录名又对应着同一个用户ID,又将如何呢?(一个人在口令文件中可以有多个登录项,它们的用户 ID 相同,但登录 shell 不同。)系统通常记录用户登录时使用的名字(见 6.8 节),用getlogin函数可以获取此登录名。

#include <unistd.h>

char *getlogin(void);

返回值:若成功,返回指向登录名字符串的指针;若出错,返回NULL

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon),第 13章将对这种进程专门进行讨论。

给出了登录名,就可用getpwnam在口令文件中查找用户的相应记录,从而确定其登录shell等。

为了找到登录名,UNIX系统在历史上一直是调用ttyname函数(见18.9节),然后在utmp文件(见6.8节)中找匹配项。FreeBSD和Mac OS X将登录名存放在与进程表项相关联的会话结构中,并提供系统调用获取该登录名。

System V提供cuserid函数返回登录名。此函数先调用getlogin函数,如果失败则再调用getpwuid(getuid())。IEEE标准1003.1-1988说明了cuserid,但是它以有效用户ID而不是实际用户ID来调用。POSIX.1的1990版本删除了cuserid函数。

环境变量LOGNAME通常由login(1)以用户的登录名对其赋初值,并由登录shell继承。但是,用户可以修改环境变量,所以不能使用LOGNAME来验证用户,而应当使用getlogin函数。

8.16 进程调度

UNIX 系统历史上对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级是由内核确定的。进程可以通过调整nice值选择以更低优先级运行(通过调整nice值降低它对CPU的占有,因此该进程是“友好的”)。只有特权进程允许提高调度权限。

POSIX实时扩展增加了在多个调度类别中选择的接口以进一步细调行为。我们这里只讨论用于调整nice值的接口,这些包括在POSIX.1的XSI扩展选项中。关于实时调度扩展更多的信息,可参考Gallmeister[1995]。

Single UNIX Specification 中 nice 值的范围在 0~(2*NZERO)-1 之间,有些实现支持 0~2*NZERO。nice值越小,优先级越高。虽然这看起来有点倒退,但实际上是有道理的:你越友好,你的调度优先级就越低。NZERO是系统默认的nice值。

注意,定义 NZERO 的头文件因系统而异。除了头文件以外,Linux 3.2.0 可以通过非标准的sysconf参数(_SC_NZERO)来访问NZERO的值。

进程可以通过nice函数获取或更改它的nice值。使用这个函数,进程只能影响自己的nice值,不能影响任何其他进程的nice值。

#include <unistd.h>

int nice(int incr);

返回值:若成功,返回新的nice值NZERO;若出错,返回−1

incr参数被增加到调用进程的nice值上。如果incr太大,系统直接把它降到最大合法值,不给出提示。类似地,如果incr太小,系统也会无声息地把它提高到最小合法值。由于−1是合法的成功返回值,在调用nice函数之前需要清楚errno,在nice函数返回−1时,需要检查它的值。如果nice调用成功,并且返回值为−1,那么errno仍然为0。如果errno不为0,说明nice调用失败。

getpriority函数可以像nice函数那样用于获取进程的nice值,但是getpriority还可以获取一组相关进程的nice值。

#include <sys/resource.h>

int getpriority(int which, id_t who);

返回值:若成功,返回-NZERO~NZERO-1之间的nice值;若出错,返回−1

which参数可以取以下三个值之一:PRIO_PROCESS 表示进程,PRIO_PGRP 表示进程组, PRIO_USER表示用户ID。which参数控制who参数是如何解释的,who参数选择感兴趣的一个或多个进程。如果who参数为0,表示调用进程、进程组或者用户(取决于which参数的值)。当which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用于多个进程,则返回所有作用进程中优先级最高的(最小的nice值)。

setpriority函数可用于为进程、进程组和属于特定用户ID的所有进程设置优先级。

#include <sys/resource.h>

int setpriority(int which, id_t who, int value);

返回值:若成功,返回0;若出错,返回−1

参数which和who与getpriority函数中相同。value增加到NZERO上,然后变为新的nice值。

nice 系统调用起源于早期 Research UNIX 系统的 PDP-11 版本。getpriority 和setpriority函数源于4.2BSD。

Single UNIX Specification没有对在fork之后子进程是否继承nice值制定规则,而是留给具体实现自行决定。但是遵循XSI的系统要求进程调用exec后保留nice值。

在FreeBSD 8.0、Linux 3.2.0、MacOS X 10.6.8以及Solaris 10中,子进程从父进程中继承nice值。

实例

图8-30的程序度量了调整进程nice值的效果。两个进程并行运行,各自增加自己的计数器。父进程使用了默认的nice值,子进程以可选命令参数指定的调整后的nice值运行。运行10 s后,两个进程都打印各自的计数值并终止。通过比较不同nice值的进程的计数值的差异,我们可以了解nice值时如何影响进程调度的。

图8-30 更改nice值的效果

执行该程序两次:一次用默认的nice值,另一次用最高有效nice值(最低调度优先级)。程序运行在单处理器Linux系统上,以显示调度程序如何在不同nice值的进程间进行CPU的共享。否则,对于有空闲资源的系统,如多处理器系统(或多核CPU),两个进程可能无需共享CPU(运行在不同的处理器上),就无法看出具有不同nice值的两个进程的差异。

$ ./a.out

NZERO = 20

current nice value in parent is 20

current nice value in child is 20, adjusting by 0

now child nice value is 20

child count = 1859362

parent count = 1845338

$ ./a.out 20

NZERO = 20

current nice value in parent is 20

current nice value in child is 20, adjusting by 20

now child nice value is 39

parent count = 3595709

child count = 52111

当两个进程的nice值相同时,父进程占用50.2%的CPU,子进程占用49.8%的CPU。可以看到,两个进程被有效地进行了平等对待。百分比并不完全相同,是因为进程调度并不精确,而且子进程和父进程在计算结束时间和处理循环开始时间之间执行了不同数量的处理。

相比之下,当子进程有最高可能nice值(最低优先级)时,我们看到父进程占用98.5%的CPU,而子进程只占用1.5%的 CPU。这些值取决于进程调度程序如何使用 nice 值,因此不同的 UNIX系统会产生不同的CPU占用比。

8.17 进程时间

在1.10节中说明了我们可以度量的3个时间:墙上时钟时间、用户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,从新返回的值中减去以前返回的值,此差值就是墙上时钟时间。(一个长期运行的进程可能其墙上时钟时间会溢出,当然这种可能性极小,见习题1.5)。

该结构中两个针对子进程的字段包含了此进程用本章开始部分的wait函数族已等待到的各子进程的值。

所有由此函数返回的clock_t值都用_SC_CLK_TCK(由sysconf 函数返回的每秒时钟滴答数,见2.5.4节)转换成秒数。

大多数实现提供了getrusage(2)函数,该函数返回CPU时间以及指示资源使用情况的另外14个值。它起源于BSD系统,所以BSD派生的实现与其他实现比较,支持的字段要多一些。

实例

图8-31中的程序将每个命令行参数作为shell命令串执行,对每个命令计时,并打印从tms结构取得的值。

图8-31 计时并执行所有命令行参数

运行此程序可以得到:

$ ./a.out "sleep 5" "date" "man bash >/dev/null"

command: sleep 5

real: 5.01

user: 0.00

sys: 0.00

child user: 0.00

child sys: 0.00

normal termination, exit status = 0

command: date

Sun Feb 26 18:39:23 EST 2012

real: 0.00

user: 0.00

sys: 0.00

child user: 0.00

child sys: 0.00

normal termination, exit status = 0

command: man bash >/dev/null

real: 1.46

user: 0.00

sys: 0.00

child user: 1.32

child sys: 0.07

normal termination, exit status = 0

在前两个命令中,命令执行时间足够快避免了以可报告的精度记录CPU时间。但在第3个命令中,运行了一个处理时间足够长的命令来表明所有的CPU时间都出现在子进程中,而shell和命令正是在子进程中执行的。

8.18 小结

对在UNIX环境中的高级编程而言,完整地了解UNIX的进程控制是非常重要的。其中必须熟练掌握的只有几个函数—fork、exec系列、_exit、wait和waitpid。很多应用程序都使用这些简单的函数。fork函数也给了我们一个了解竞争条件的机会。

本章说明了system函数和进程会计,这也使我们能进一步了解所有这些进程控制函数。本章还说明了exec函数的另一种变体:解释器文件及它们的工作方式。对各种不同的用户ID和组ID(实际、有效和保存的)的理解,对编写安全的设置用户ID程序是至关重要的。

在了解进程和子进程的基础上,下一章将进一步说明进程和其他进程的关系——会话和作业控制。第10章将说明信号机制并以此结束对进程的讨论。

习题

8.1 在图8-3程序中,如果用exit调用代替_exit调用,那么可能会使标准输出关闭,使printf返回−1。修改该程序以验证在你所使用的系统上是否会产生此种结果。如果并非如此,你怎样处理才能得到类似结果呢?

8.2 回忆图7-6中典型的存储空间布局。由于对应于每个函数调用的栈帧通常存储在栈中,并且由于调用vfork后,子进程运行在父进程的地址空间中,如果不是在main函数中而是在另一个函数中调用vfork,此后子进程又从该函数返回,将会发生什么?请编写一段测试程序对此进行验证,并且画图说明发生了什么。

8.3 重写图8-6中的程序,把wait换成waitid。不调用pr_exit,而从siginfo结构中确定等价的信息。

8.4 当用$./a.out 执行图 8-13 中的程序一次时,其输出是正确的。但是若将该程序按下列方式执行多次,则其输出不正确。

$ ./a.out ; a.out ;./a.out

output from parent

ooutput from parent

ouotuptut from child

put from parent

output from child

utput from child

原因是什么?怎样才能更正此类错误?如果使子进程首先输出,还会发生此问题吗?

8.5 在图 8-20 所示的程序中,调用 execl,指定pathname为解释器文件。如果将其改为调用execlp,指定testinterp的filename,并且如果目录/home/sar/bin是路径前缀,则运行该程序时,argv[2]的打印输出是什么?

8.6 编写一段程序创建一个僵死进程,然后调用system执行ps(1)命令以验证该进程是僵死进程。8.7 8.10节中提及POSIX.1要求在exec时关闭打开目录流。按下列方法对此进行验证:对根目录调用opendir,查看在你系统上实现的DIR结构,然后打印执行时关闭标志。接着打开同一目录读并打印执行时关闭标志。