进程控制(八)

引言

本章主要介绍了UNIX系统的进程控制

  • 创建新进程、执行程序和进程终止
  • 进程属性的各种ID,他们如何受到进程控制原语的影响
  • 解释器文件和system函数
  • 进程会计机制

进程标识

每个进程都有一个非负整数表示的唯一进程ID,该ID唯一,但进程ID是可以复用的。当一个进程终止后,其进程ID就成为复用的候选者。大多数UNIX系统实现延迟复用算法,使得赋予新建进程的ID不同于最近终止进程所使用的ID。防止将新进程误认为是使用同一ID的某个已终止的先前进程。
一些专用进程

  • ID为0,通常是调度进程,也被称为交换进程。
  • ID为1,通常是init进程,在自举过程结束时由内核调用。此进程负责在自举内核后启动一个UNIX系统。init通常读取与系统有关的初始化文件(/etc/rc*或/etc/inittab或/etc/init.d文件)。并将系统引导到一个状态(如多用户)。init进程决不会终止。它是一个普通的用户进程(不是内核中的系统进程),但是它以超级用户特权运行。
  • 某些UNIX的虚拟存储器实现中,进程ID2是页守护进程(page daemon),此进程负责支持虚拟存储器系统的分页操作。

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

1
2
3
4
5
6
7
#include <unistd.h>
pid_t getpid(void); //调用进程的进程ID
pid_t getppid(void); //调用进程的父进程ID
pid_t getuid(void); //调用进程的实际用户ID
pid_t geteuid(void); //调用进程的有效用户ID
pid_t getgid(void); //调用进程的实际组ID
pid_t getegid(void); //调用进程的有效组ID

函数fork

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

1
2
3
#include <unistd.h>
pid_t fork(void);
//返回值:子进程返回0,父进程返回子进程ID;若出错返回-1

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。子进程返回0,父进程返回子进程的进程ID。

父进程返回子进程的ID的原因:

  • 因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。

子进程返回0的原因:

  • 一个进程只会有一个父进程,所以子进程总是可以调用getppid以获取其父进程的进程ID(进程ID0总是由内核交换程序使用,所以一个子进程的进程ID不可能为0)

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间。父进程和子进程共享正文段
由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、堆和栈的完全副本。作为替代,使用了写时复制技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "apue.h"
int globvar = 6;
char buf[] = "a write to stdout\n";

int main(void)
{
int var;
pid_t pid;

var = 99;
if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1)
err_sys("write error");
printf("before fork\n");
if((pid = fork())<0) {
err_sys("fork error");
} else if(pid == 0) {
printf("这是子进程:\n");
globvar++;
var++;
} else {
printf("这是父进程:\n");
sleep(2);
}
printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var);
exit(0);
}

figure8.1
一般来说子进程和父进程的执行顺序是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。在上面的程序中父进程使进程休眠2s,以使子进程先执行。但不保证2s足够,第二次 的结果父进程并没有休眠2s。父进程先执行了。在10章将会说明在fork之后如何使用信号使父进程和子进程同步。
当写标准输出时,我们将buf长度减1作为输出字节数,这是为了避免将终止null字节写出。strlen计算不包含终止null字节的字符串长度,而sizeof计算包括终止null字节的缓冲区长度。两者之间的另一个区别是,使用strlen需进行一次函数调用,而对于sizeof而言,因为缓冲区已用已知字符串进行初始化,其长度是固定的,所以sizeof是在编译时计算缓冲区长度。

文件共享

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

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

父进程和子进程共享同一个文件偏移量。考虑如下情况:

  • 一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由shell实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式的交互就要困难很多,可能需要父进程显示地动作。
  • 如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么他们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。虽然这种情况是可能发生的,但这并不是常见的操作模式。

在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(在下面的小结中介绍)
    • 子进程不继承父进程设置的文件锁
    • 子进程的未处理闹钟被清除
    • 子进程的未处理信号集设置为空集

使fork失败的两个主要原因是:

  1. 系统中已经有了太多的进程(通常意味着某个方面出了问题)
  2. 该实际用户ID的进程总数超过了系统限制(CHAILD_MAX规定了每个实际用户ID在任意时刻可拥有的最大进程数)

fork有以下两种用法

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

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

函数vfork

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

  • 可移植的应用程序不应该使用这个函数

vfork和fork的区别:

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

函数exit

如第7章所述,进程有5种正常终止及3种异常终止方式。
5种正常终止方式具体如下:

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

3种异常终止具体如下:

  1. 调用abort。它产生SIGABRT信号,这是下一种异常终止的一种特例。
  2. 当进程接收到某种信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生。例如,若进程引用地址空间之外的存储单元、或者除以0,内核就会为该进程产生相应的信号。
  3. 最后一个线程对“取消”请求作出响应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。对于上述任意一种终止情形,我们希望终止进程能够通知其父进程它是如何终止的:

  • 对于3个终止函数(exit,_exit,_Exit),实现这一点的方法是,将其退出状态(exit status)作为参数传递给函数。
  • 在异常终止情况,内核(不是进程本身)产生一个指示其异常终止原因的终止状态。
  • 在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态

如果父进程在子进程之前终止:

  • 对于父进程已经终止的所有进程,它们的父进程都改为init进程。我们称这些进程由init进程收养。其操作过程:
    • 在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1.这种处理方法保证了每个进程有有一个父进程。

如果子进程在父进程之前终止:

  • 内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。

一个由init进程收养的进程终止时会发生什么:

  • init被编写成无论何时只要有一个子进程终止,init就会调用一个wait函数取得其终止状态。防止了在系统中塞满僵尸进程。

函数wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。子进程终止是一个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。
调用wait或waitpid会发生什么:

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

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

1
2
3
4
#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不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将参数指定为空指针。

POSIX.1 定义了waitpid函数,函数中pid参数说明如下:

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

waitpid和wait出错返回

  • waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态存放在由statloc指向的存储单元中。对于wait。
  • wait,其唯一的出错是调用进程没有子进程。而waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。

options参数或者是0,或者是如下图中常量按位或运算的结果:

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

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

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

fork两次避免僵尸进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "apue.h"
#include <sys/wait.h>
int main(void)
{
pid_t pid;

if ((pid = fork())<0){
err_sys("fork error");
} else if (pid == 0){
//子进程
if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid > 0) //子进程 或 孙进程的父进程
exit(0);

//孙进程
sleep(2);
//打印孙进程的 父ID
printf("second child, parent pid = %ld\n",(long)getppid());
exit(0);
}
//父进程
if (waitpid(pid, NULL, 0) != pid)
err_sys("waitpid error");

exit(0);
}


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

函数waitid

waitid类似于waitpid,但更加灵活

1
2
3
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
//成功,返回0;出错,返回-1

该函数与waitpid类似,waitid允许一个进程指定要等待的子进程。
id参数的作用与idtype的值相关,idtype类型在下表中

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

options参数是如下图中个标志的按位或运算

常量 说明
WCONTINUED 等待一进程,它以前曾被停止,此后又继续,但其状态尚未报告
WEXITED 等待已退出的进程
WNOHANG 如无可用的子进程退出状态,立即返回而非阻塞
WNOWAIT 不破坏子进程退出状态。该子进程退出状态可由后续的wait、waitid或waitpid调用取得
WSTOPPED 等待一进程,它已经停止,但其状态尚未报告

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

函数wait3和wait4

这俩函数比上面3个函数提供的功能多一个

1
2
3
4
5
6
7
#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时间总量、缺页次数、接收到信号的次数等。

竞争条件

竞争发生的原因:当多个进程都企图对共享数据进行某种处理,而最后结果又取决于进程运行的顺序时。
在上一小节中的程序。当孙进程打印其父ID时,有一个潜在的竞争条件:

  • 如果孙进程在子进程之前运行,孙进程的父进程就是子进程。但是如果子进程先运行,并有足够的时间到达并执行exit,则孙进程的父进程就是init。

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

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

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

函数exec

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

7种不同的exec函数原型

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[]);
//出错返回-1,成功不返回

这些函数的区别如下:

  • 前4个函数取路径名作为参数,后2个函数取文件名作为参数,最后一个取文件描述符作为参数。当指定filename作为参数时
    • 如果filename 中包含 /, 则就将其视为路径名
    • 否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件
  • PATH变量包含了一张目录表,如下所示:最后的路径前缀.表示当前目录
    1
    PATH=/bin:/usr/bin:/usr/local/bin:.

出于安全性考虑在搜索路径中决不要包括当前目录

  • 如果execlp或execvp使用路径前缀中的一个找到了一个可执行文件,但是该文件不是由连接编辑器产生的机器可执行文件,则就认为该文件是一个可执行文件,于是试着调用/bin/sh,并以该filename作为shell的输入
  • fexecve函数依赖调用进程,调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件

另一个区别与参数表有关:

  • 函数execl、execlp和execle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。对于另外4个函数(execv,execvp,execve和fexecve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这4个函数的参数。

最后一个区别

  • 以e结尾的3个函数(execle、execve和fexecve)可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境。通常,一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想要为子进程指定某一个确定的变量。例如,在初始化一个新登录shell时,login程序通常创建一个只定义少数几个变量的特殊环境,而我们在登录时,可以通过shell启动文件,将其他变量加到环境中。
    • 在ISO C原型中,所有命令行参数、空指针和envp指针都用省略号(…)表示

POSIX.1 要求在exec时关闭打开目录流。这通常是由opendir函数实现的,它调用fcntl函数为对应于打开目录流的描述符设置执行时关闭标志。
在很多UNIX实现中,这7个函数中只有execve是内核的系统调用。其他6个只是函数库,他们最终都要调用该系统调用


更改用户ID和更改组ID

在UNIX系统中,特权以及访问控制,是基于用户ID和组ID的。所以我们需要合适的权限访问资源
setuid函数设置实际用户ID和有效用户ID,setgid函数设置实际组ID和有效组ID

1
2
3
4
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
//成功返回0,出错返回-1

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

(未完待续)

感谢土豪
0%