第八章 进程控制

进程标识

每个进程都有一个非负整型表示的唯一进程ID。虽然是唯一的,但是进程ID是可复用的,当一个进程终止后,其进程ID就成为复用的候选者,大多数UNIX系统实现延迟算法,使得赋予新建进程的ID不同于最近终止进程所使用的ID

系统中有一些专用进程,但是具体细节随实现而不同。ID为0的进程通常是调度进程,常常被称为交换进程(swapper),该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID 1通常是init进程,init进程绝不会终止,它是一个普通的用户进程,但是他以超级用户特权运行。init会成为孤儿进程的父进程

下列函数返回进程的标识符

1
2
3
4
5
6
7
8
#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

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

1
2
3
4
5
#include <unistd.h>

pid_t fork(void);

子进程返回0,父进程返回子进程Id;若出错,返回-1

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

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

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

Linux 3.2.0提供clone系统调用,是fork的推广形式,允许调用者控制哪些部分由父进程和子进程共享

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信

fork之后标准IO库的缓存区也被复制,如果缓冲区中存在数据,则在子进程中也存在相应的数据,可能会输出两次

文件共享

重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中,就好像对每个文件描述符来说,执行了dup函数,父进程和子进程每个相同的打开描述符共享一个文件表项。

重要的一点是父进程和子进程共享同一个文件偏移量。如果不共享,则要实现父子进程都写到同一个文件则要困难的多。但如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步,那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的),但这并不是常用的操作模式

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

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

  • 父进程等待子进程完成。这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新
  • 父进程和子进程各自执行不同的程序段。这种情况下,在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
  • 子进程不继承父进程设置的文件锁
  • 子进程的未处理闹钟被清除
  • 子进程的未处理信号集设置为空

fork失败的两个主要原因是

  • 系统中已经有了太多的进程(通常意味着某个方面除了问题)
  • 该实际用户ID的进程总数超过了系统限制

fork有以下两种用法

  • 一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求
  • 一个进程要执行一个不同的程序。这对shell是常见的。在这种情况下,子进程从fork返回后立即调用exec

某些操作系统将第二种用法中的两个操作组合成一个操作,称为spawn,UNIX将这两个操作分开,使得子进程在fork和exec之间可以更改自己的树形,如IO重定向、用户ID、信号安排等

函数vfork

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

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。vfork和fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit,于是也就不会引用该地址空间。

不过在子进程调用exec或exit之前,它在父进程的空间运行,这种优化工作方式在某些UNIX系统的实现中提高了效率,但如果子进程修改数据(除了用于存放vfork返回值的变量)、进行函数调用、或者没有调用exec或exit就返回都可能带来未知的结果。

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

函数exit

进程5种正常终止方式如下

  • 在main函数内执行return语句,这等效于调用exit
  • 调用exit函数。此函数由ISO C定义,其操作包括调用各终止处理程序(调用atexit登记),然后关闭所有标准IO流等。因为ISO C并不处理文件描述符、多进程(父进程和子进程)以及作业控制,所以这一定义对UNIX系统而言是不完整的
  • 调用_exit和_Exit函数。ISO C定义_Exit函数其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。对于标准IO流是否进行冲洗,这取决于实现。在UNIX系统中_Exit和_exit是同义的,并不冲洗标准IO流。_exit函数由exit调用,它处理UNIX系统特定的细节。大多数UNIX系统实现中,exit是C库的一个函数,_exit是一个系统调用
  • 进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用做进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回
  • 进程的最后一个线程调用pthread_exit函数。如同前面一样,在这种情况中,进程终止状态总是0,这与传送给pthread_exit的参数无关

3种异常终止具体如下

  • 调用abort。它产生SIGABRT信号,这是下一种异常终止的一种特例
  • 当进程接收到某些信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生
  • 最后一个线程对“取消”(cancellation)请求做出相应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止

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

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

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

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

被init收养的进程不会变成僵死进程。因为init进程在子进程终止时会调用wait函数取得其终止状态

函数wait和waitpid

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

调用wait或waitpid的进程可能发生如下情况

  • 如果其所有子进程都还在运行,则阻塞
  • 如果一个子进程已终止,正等待父进程获取其状态,则取得该子进程的终止状态立即返回
  • 如果它没有任何子进程,则立即出错返回

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

1
2
3
4
5
6
#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不是一个空指针,则终止进程的终止状态就存放在statloc中,如果不关心终止状态,则可以将该参数设置为空指针

依据传统,这两个函数返回的整型状态字是由实现定义的,终止状态使用如下宏来查看

终止状态的宏
终止状态的宏

如果需要等待一个指定进程终止,在早期的UNIX系统中,必须调用wait,然后将其返回的进程ID和所期望的进程ID相比较。如果终止进程不是所期望的,则该进程ID和终止状态保存起来,然后再次调用wait,反复调用直到指定进程终止,下一次又想等待一个特定进程时,先查看已终止的进程列表或继续调用wait。

waitpid可以实现这一功能及其他功能,根据pid参数,作用如下

  • pid等于-1:等待任一子进程。此种情况下,waitpid和wait等效
  • pid大于0:等待进程ID与pid相等的子进程
  • pid等于0:等待组ID等于调用进程组ID的任一子进程
  • pid小于-1:等待组ID等于pid绝对值的任一子进程

对于wait其唯一的出错是调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种出错)。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错

参数options可以进一步控制waitpid的操作,此参数或者是0或者是如下常量按位或运算的结果

waitpid的options参数
waitpid的options参数

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

  • waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态
  • waitpid提供了一个wait的非阻塞版本
  • wait通过WUNTRACED和WCONTINUED选项支持作业控制

如果一个进程fork一个子进程,但不要它等待子进程终止,也不希望子进程处于僵死状态知道父进程终止,实现这一要求的诀窍是调用fork两次,子进程的子进程会被init收养

函数waitid

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

1
2
3
4
5
#include <sys/wait.h>

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

若成功,返回0;若出错,返回-1

与waitpid类似,waitid允许一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型,而不是将此与进程ID或进程组ID组合成一个参数

idtype类型如下

waitid的idtype常量
waitid的idtype常量

options是以下各标志的按位或运算,这些标志指示调用者关注哪些状态变化

waitid的options常量
waitid的options常量

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

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

函数wait3和wait4

大多数UNIX系统实现提供了另外两个函数wait3和wait4。该函数允许内核返回由终止进程机器所有子进程使用的资源概况

1
2
3
4
5
6
7
8
9
#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)手册页

竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件(race condition)。fork之后无法确定哪个进程先运行,因此需要某种形式的同步

如果一个进程需要等待其父进程终止,则可使用下列形式的循环

1
2
3
while (getppid() != 1) {
sleep(1);
}

这种形式的循环称为轮询(polling),它的问题是浪费了CPU的时间

为了避免竞争条件和轮询,在多个进程间需要某种形式的信号发送和接收的方法,比如使用信号和各种形式的进程间通信

函数exec

fork函数创建新的子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。

调用exec并不创建新进程,其进程ID并不会改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段

有7中不同的exec函数,常常被统称为exec函数。

fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止,这是我们需要的基本进程控制原语。我们可以用这些原语构造另外一些如popen和system之类的函数

1
2
3
4
5
6
7
8
9
#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[]);

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

  • 如果filename包含/,则就将其视为路径名
  • 否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件

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

fexecve函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需文件并且无竞争的执行该文件

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

最后一个区别与向新程序传递环境表相关。以e结尾的3个函数可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境。通常一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想要为子进程指定某一个确定的环境

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

7个exec函数之间的区别
7个exec函数之间的区别

每个系统对参数表和环境表的总长度都有一个限制。这种限制是由ARG_MAX给出的,此值至少是4096字节

执行exec后,进程ID没有改变,但新程序从调用进程继承了下列属性

  • 进程ID和父进程ID
  • 实际用户ID和实际组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 闹钟尚余留的时间
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 文件锁
  • 进程信号屏蔽
  • 资源限制
  • nice值
  • tms_utime、tms_stime、tms_cutime以及tms_cstime值

对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关。若设置了该标志,则在执行exec时关闭该描述符;否则该描述符仍打开。除非特地的用fcntl设置了该执行时关闭标志,否则系统的默认操作是在exec后仍保持这种描述符打开

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

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

在很多UNIX系统实现中只有execve是内核的系统调用,另外6个只是库函数,它们最终都要调用该系统调用

7个exec函数之间的关系
7个exec函数之间的关系

更改用户ID和更改组ID

在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。同样如果需要降低权限,则也需要更换用户ID或组ID

一般而言,在设计应用时,我们总是视图使用最小特权(least privilege)模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。这降低了由恶意用户试图哄骗我们的进程以未预料的方式使用特权造成的安全性风险

可以用setuid函数设置实际用户ID和有效用户ID,与此类似,可以用setgid函数设置实际组ID和有效组ID

1
2
3
4
5
6
#include <unistd.h>

int setuid(uid_t uid);
int setgid(gid_t gid);

若成功,返回0;若出错,返回-1

更改用户ID的规则如下(适用于组ID)

  • 若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID(saved set-user-ID)设置为uid
  • 若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid,不更改实际用户ID和保存的设置用户ID
  • 如果上边两个条件都不满足,则errno设置为EPERM,并返回-1

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

关于内核维护的3个用户ID,还需要注意

  • 只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,有login程序设置的,而且绝不会改变它。login是一个超级用户进程
  • 仅当对程序文件设置了设置用户ID位时,exec函数才设置有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持其现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。
  • 保存的设置用户ID是由exec复制有效用户ID而得到的,如果设置了文件的设置用户ID位则在exec根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了
更改3个用户ID的不同方法
更改3个用户ID的不同方法

历史上BSD支持setreuid函数,其功能是交换实际用户ID和有效用户ID的值,具体用法参考手册

函数seteuid和setegid只更改有效用户ID和有效组ID,一个非特权用户可以将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户则可将有效用户ID设置为uid

1
2
3
4
5
6
#include <unistd.h>

int seteuid(uid_t uid);
int setegid(gid_t gid);

若成功,返回0;若出错,返回-1
设置不同用户ID的各函数
设置不同用户ID的各函数

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

解释器文件

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

1
#! pathname [optional-argument]

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

1
#! /bin/sh

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

解释器文件的第一行一般由长度限制,这包括#!、pathname、可选参数、终止换行符以及空格数。Linux 3.2.0中是128字节

当内核exec解释器时,argv[0]参数是该解释器的pathname,其后是解释器文件中的可选参数,其余参数是exec的pathname以及调用exec的参数(argv[1]及其后参数),此时调用exec的argv[1]和其后参数已经右移。注意内核取exec调用中的pathname而不是argv[0],因为一般而言pathname包含了比第一个参数更多的信息

解释器文件使得用户得到效率方面的好处,其代价是内核的额外开销(因为识别解释器文件的是内核)。

  • 有些程序是用某种语言写的脚本,解释器文件可以将这一事实隐藏起来。使用somecmd optional-arguments调用即可
  • 解释器脚本在效率方面也提供了好处。我们可以编写shell脚本来实现相同的功能,但要求做更多动作。首先shell读此命令,然后试图execlp此文件,因为shell脚本是一个可执行文件,但却不是机器可执行的,于是返回一个错误,execlp就认为该文件是一个shell脚本,然后执行/bin/sh,并以该shell脚本的路径名作为其参数。shell正确的执行我们的shell脚本,但是为了运行shell脚本中的程序(如awk程序),它调用fork、exec和wait,于是一个shell脚本代替解释器脚本需要更多的开销
  • 解释器脚本使我们可以使用除/bin/sh意外的其他shell来编写shell脚本

函数system

system函数可以执行给定的命令

1
2
3
#include <stdio.h>

int system(const char* cmdstring);

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

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

  • fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置errno
  • 如果exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)一样
  • 否则所有3个函数都成功,那么system的返回值是shell的终止状态,其格式已在waitpid中说明

system的安全性漏洞

如果在一个设置用户ID程序中调用system,会发生什么呢?这是一个安全性方面的漏洞,决不应该这样做

有些实现通过更改/bin/sh,当有效用户ID与实际用户ID不匹配时,将有效用户ID设置为实际用户ID

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

进程会计

大多数UNIX系统提供了一个宣讲以进行进程会计(process accounting)处理,启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含总量较小的二进制数据,一般包括命令名,所适用的CPU时间总量、用户ID和组ID、启动时间等

会计记录所需的各个数据都由内核保存在进程表中,并在一个新进程被创建时初始化,进程终止时写一个会计记录。这产生两个后果

  • 我们不能获取永远不终止的进程的会计记录。如init或内核守护进程
  • 会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。

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

用户标识

一个人在口令文件中可以有多个登录项,它们的用户ID相同,但登录shell不同(因此通过getpwuid(getuid())不可行)。可以使用getlogin函数可以获取登录名。如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败,通常称这些进程为守护进程(daemon)

进程调度

UNIX系统历史上对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级是由内核确定的。进程可以通过调整nice值选择以更低优先级运行。只有特权进程允许提高调度权限。

nice值的返回在0~(2*NZERO)-1之间,nice值越小,优先级越高。NZERO是系统默认的nice值。

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

1
2
3
4
5
#include <unistd.h>

int nice(int incr);

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

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

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

1
2
3
4
5
#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设置为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用域多个进程,则返回所有作用进程中优先级最高的(最小的nice值)

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

1
2
3
4
5
#include <sys/resource.h>

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

若成功,返回0;若出错,返回-1

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

遵循XSI的系统要求进程调用exec后保留nice值。如Linux 3.2.0、MacOS等

进程时间

我们可以度量的时间有三个:墙上时钟时间、用户CPU时间和系统CPU时间。任一进程都可以调用times获得它自己以及已终止进程的上述时间

1
2
3
4
5
#include <sys/times.h>

clock_t times(struct tms* buf);

若成功,返回流逝的墙上时钟时间(以时钟滴答数为单位);若出错,返回-1

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

1
2
3
4
5
6
struct tms {
clock_t tms_utime; /* user CPU time */
clock_t tms_stime; /* system CPU time */
clock_t tms_cutime; /* user CPU time of children, all waited-for terminated children*/
clock_t tms_cstime; /* system CPU time of children, all waited-for terminated children */
};

times函数返回墙上时钟时间作为其函数值。次值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用其相对值。例如,调用times两次,其差值就是墙上时钟时间

结构中两个针对子进程的字段包含了此进程用wait函数族已等待到的各子进程的值。所有由此函数返回的clock_t值都用_SC_CLK_TCK转换成秒数

大多数实现提供了getrusage(2)函数,该函数返回CPU时间以及指示资源使用情况的另外14个值

第九章 进程关系

终端登录和网络登录

我们可以经由终端登录至UNIX系统,终端的类型可以是基于字符的终端、图形终端等

系统使用伪终端(pseudo terminal),来处理终端登录和网络登录

进程组

每个进程除了有一进程ID外,还属于一个进程组。进程组是一个或多个进程的集合。通常它们是在同一作业中结合起来的,同一进程组中各进程接收来自同一终端的各种信号。每个进程组有一个唯一的进程组ID。进程组ID类似于进程ID,它是一个正整数,并可存放在pid_t数据类型中。函数getpgrp返回调用进程的进程组ID

1
2
3
4
5
#include <unistd.h>

pid_t getpgrp(void);

返回调用进程的进程组ID

getpgid返回指定进程的进程组ID

1
2
3
4
5
#include <unistd.h>

pid_t getpgid(pid_t pid);

若成功,返回进程组ID;若出错,返回-1

若pid是0,返回调用进程的进程组ID,即getpgid(0)等价于getpgrp()

每个进程组有一个组长进程,组长进程的进程组ID等于其进程ID,进程组组长可以创建一个进程组、创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间区间称为进程组的生命期。某个进程组中的最后一个进程可以终止,也可以转移到另一个进程组

进程调用setpgid可以加入一个现有的进程组或者创建一个新的进程组

1
2
3
4
5
#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);

若成功,返回0;若出错,返回-1

setpgid将pid进程的进程组ID设置为pgid。如果这两个参数相等,则由pid指定的进程变成进程组组长。如果pid是0,则使用调用者的进程ID。另外,如果pgid是0,则由pid指定的进程ID用作进程组ID

一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用exec之后,它就不再更改该子进程的进程组ID

大多数作业控制shell中,在fork之后调用此函数,使父进程设置其子进程的进程组ID,并且也使子进程设置其自己的进程组ID,这两个调用有一个是冗余的,但让父进程和子进程都这样可以保证,在父进程和子进程认为子进程已进入了该进程组之前,这确实已经发生了。如果不这样做,在fork之后,由于父子进程运行的先后次序不确定,会导致子进程的组员身份不确定。

会话

会话(session)是一个或多个进程组的集合。

会话中的进程组安排
会话中的进程组安排

进程调用setsid函数建立一个新会话

1
2
3
4
5
#include <unistd.h>

pid_t setsid(void);

若成功,返回进程组ID;若出错,返回-1

如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。会发生以下3件事

  • 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话中的唯一进程
  • 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID
  • 该进程没有控制终端。如果在调用setsid之前该进程有一个控制终端,那么这种联系也被切断

如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程继续,因为子进程继承了父进程的进程组ID,而其进程ID则是新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长

getsid函数返回会话首进程的进程组ID。会话首进程总是一个进程组的组长进程,所以可以将“会话首进程的进程组ID”称为会话ID

1
2
3
4
5
#include <unistd.h>

pid_t getsid(pid_t pid);

若成功,返回会话首进程的进程组ID;若出错,返回-1

如若pid是0,getsid返回调用进程的会话首进程的进程组ID。出于安全方面的考虑,一些实现有如下限制:如果pid并不属于调用者所在的会话,那么调用进程就不能得到该会话首进程的进程组ID

控制终端

会话和进程组还有一些其他特性

  • 一个会话可以有一个控制终端(controlling terminal)。这通常是终端设备(终端登录)或伪终端设备(网络登录)
  • 建立与控制终端连接的会话首进程被称为控制进程(controlling process)
  • 一个会话中的几个进程组可被分成一个前台进程组(foreground process group)以及一个或多个后台进程组(background process group)
  • 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组
  • 无论何时键入终端的中断键(常常是Delete或Ctrl+C),都会将中断信号发送至前台进程组的所有进程
  • 无论何时键入终端的退出键(常常是Ctrl+\),都会将推出信号发送至前台进程组的所有进程
  • 如果终端接口检测到调制解调器(或网络)已经断开连接,则将挂断信号发送至控制进程(会话首进程)
控制终端
控制终端

通常我们不必担心控制终端,登录时,将自动建立控制终端

可以open文件/dev/tty(在内核中,此设备是控制终端的同义词)来与控制终端对话,如果程序没有终止终端,则对于此设备的open将失败

函数tcgetpgrp、tcsetpgrp和tcgetsid

需要有一种方法来通知内核哪一个进程组是前台进程组,这样终端设备驱动程序就能知道将终端输入和终端产生的信号发送到何处

1
2
3
4
5
6
7
8
9
#include <unistd.h>

pid_t tcgetpgrp(int fd);

若成功,返回前台进程组ID;若出错,返回-1

int tcsetpgrp(int fd, pid_t pgrpid);

若成功,返回0;若出错,返回-1

函数tcgetpgrp返回前台进程组ID,它与在fd打开的终端相关联

如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpid。pgrpid值应当是在同一会话中的一个进程组的ID。fd必须引用该会话的控制终端

大多数应用程序并不直接调用这两个函数。它们通常由作业控制shell调用

给出控制TTY的文件描述符,通过tcgetsid函数,应用程序就能获得会话首进程的进程组ID

1
2
3
4
5
#include <termios.h>

pid_t tcgetsid(int fd);

若成功,返回会话首进程的进程组ID;若出错,返回-1

需要管理控制终端的应用程序可以调用tcgetsid函数识别出控制终端的会话首进程的会话ID(它等价于会话首进程的进程组ID)

作业控制

作业控制允许在一个终端上启动多个作业(进程组),它控制哪一个作业可以访问控制终端以及哪些作业在后台运行。作业控制要求以下3中形式的支持

  • 支持作业控制的shell
  • 内核中的终端驱动程序必须支持作业控制
  • 内核必须提供对某些作业控制信号的支持

我们可以在终端上键入影响前台作业的特殊字符

  • 中断字符(一般采用Delete或Ctrl+C)产生SIGINT
  • 退出字符(一般采用Ctrl+\)产生SIGQUIT
  • 挂起字符(一般采用Ctrl+Z)产生SIGTSTP

孤儿进程组

一个其父进程已终止的进程称为孤儿进程(orphan process),这种进程由init进程收养。整个进程组也可成为孤儿