在异常控制流一篇中从操作系统角度总结了进程和信号等内容。本篇和接下来几篇对进程、信号和线程从编程角度做进一步总结。本篇总结进程概念,包括进程执行、进程控制和进程间关系等部分。
Contents
- 进程
- 进程ID
- 进程的资源限制
- 进程时间
- 进程控制
- 创建进程
- 执行程序
- 解释器文件
- system函数
- 终止进程
- 回收进程
- 竞争条件
- 进程关系
- 进程组
- 会话
- 控制终端
- 非本地跳转
- 自动、寄存器和易失变量
进程
进程ID
每个进程都有一个唯一的非负整数进程ID(PID)作为标识,它是可重用的。
有一些专用进程。ID为0的进程是调度进程,常称为交换进程、系统进程,它是内核的一部分。ID为1的进程为 init 进程,在自举结束后由内核调用,它是一个普通的用户进程,以超级用户权限运行,从不终止,并且是所有孤儿进程的父进程。
可以获得进程的ID,以及前面提到的实际/有效用户ID、实际/有效组ID。
#include <sys/types.h> #include <unistd.h> /* 返回进程ID */ 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和组ID,一般赋给进程需要的最小特权,可以根据任务需要改变特权。
setuid 和 setgid 可以分别设置实际/有效/设置用户ID和实际/有效/设置组ID。但有一些规则:
- 若进程有超级用户特权,则同时设置实际ID、有效ID和保存的设置ID。
- 若进程没有超级用户特权,但设置值等于实际ID或保存的设置ID,则设置有效ID,其他两个不变。
- 否则将 errno 设为 EPERM ,返回-1。
根据这些规则,非超级用户进程可以通过保存的设置用户ID获得额外的特权。
保存的设置用户/组ID是由 exec 复制有效用户/组ID得来的。
#include <sys/types.h> #include <unistd.h> /* 设置实际用户ID和有效用户ID * @return 成功返回0,出错返回-1 */ int setuid(uid_t uid); /* 设置实际组ID和有效组ID * @return 成功返回0,出错返回-1 */ int setgid(gid_t gid);
还有两个只设置有效id的函数,它们也遵循前面的三条规则。
#include <sys/types.h> #include <unistd.h> /* 设置有效用户ID * @return 成功返回0,出错返回-1 */ int seteuid(uid_t euid); /* 设置有效组ID * @return 成功返回0,出错返回-1 */ int setegid(gid_t egid);
非特权用户总能交换实际ID和有效ID, setreuid 和 setregid 可以交换实际ID和有效ID。
#include <sys/types.h> #include <unistd.h> /* 交换实际用户id和有效用户id * @return 成功返回0,出错返回-1 */ int setreuid(uid_t ruid, uid_t euid); /* 交换实际组id和有效组id * @return 成功返回0,出错返回-1 */ int setregid(gid_t rgid, gid_t egid);
进程的资源限制
进程都有一组资源限制,通常在系统初始化时由进程0建立,由后续进程继承。可以用 getrlimit 和setrlimit 函数调整。
#include <sys/time.h> #include <sys/resource.h> /* 查询和更改进程的资源限制 * @return 成功返回0,出错返回非0值 */ int getrlimit(int resource, struct rlimit *rlim); int setrlimit(int resource, const struct rlimit *rlim);
参数说明:
- resource
- 可取的资源限制选项,选项和对应含义具体参看手册。
- rlim
- 指向限制值的 rlimit 结构的指针。
rlimit 结构的定义为:
struct rlimit { rlim_t rlim_cur; /* 软限制 */ rlim_t rlim_max; /* 硬限制,rlim_cur的上限 */ };
进程可以在硬限制值下任意调整软限制值,或在软限制值上任意降低硬限制值(不可逆),只用超级用户进程才能提高硬限制值。
对资源限制的调整会影响调用进程并被子进程继承。BASH中有内置的 ulimit 命令。
进程时间
进程时间用来度量进程使用的CPU资源,进程时间以时钟滴答计算。用 time 命令可以测量命令的进程时间。
有三个进程时间值:
- (墙上)时钟时间。它是进程运行的时间总量,值和系统中同时运行的进程数有关。
- 用户CPU时间。它是执行用户指令所用的时间。
- 系统CPU时间。它是为进程执行内核指令所用的时间。
用户CPU时间和系统CPU时间之和称为CPU时间。
用 times 函数可以获得进程和已终止子进程的进程时间。
#include <sys/times.h> /* 获取进程时间 * @return 成功返回流逝的墙上时钟时间,出错返回-1 */ clock_t times(struct tms *buf);
函数将CPU时间写入 tms 结构,该结构的定义如下:
struct tms { clock_t tms_utime; /* 用户CPU时间 */ clock_t tms_stime; /* 系统CPU时间 */ clock_t tms_cutime; /* 子进程的用户CPU时间 */ clock_t tms_cstime; /* 子进程的系统CPU时间 */ };
注意这些时间值都是相对同一时刻,使用时需要用开始和结束的两次结果求差取相对值。
进程控制
Linux使用 fork 创建进程,用 exec 执行新程序,用 exit 终止进程,用 wait 等待终止,这些函数构成了Linux的进程控制原语。
创建进程
用 fork 函数创建新进程。
#include <unistd.h> /* 创建新进程 * @return 父进程中返回子进程ID,子进程中返回0,出错返回-1 */ pid_t fork(void);
父子进程共享程序可执行文件的正文,但不共享数据空间、栈和堆等。共享打开的文件,因此输出会相互混合。处理共享的文件描述符通常有两种常见情况:
- 子进程读写,父进程等待子进程完成。
- 父子进程各自关闭不需要的文件描述符,各自处理不同的,互不影响。
父进程还被子进程继承的属性包括:实际用户ID、有效用户ID、实际组ID、有效组ID、附加组ID、进程组ID、会话ID、设置用户ID标志、设置组ID标志、控制终端、当前工作目录、根目录、文件模式创建屏蔽字、信号屏蔽和安排、执行时关闭标志、环境、连接的共享存储段、存储映射、资源限制。
父子进程不同的地方为:
- 进程ID不同。
- 进程的父进程ID不同。
- 子进程的 tms_utime 、 tms_stime 、 tms_cutime 、 tms_ustime 被置为0。
- 父进程的文件锁不被继承。
- 子进程的未处理闹钟被清除。
- 子进程的未处理信号集被清空。
还有一个 vfork 函数。和 fork 不同的是,用它创建新进程是为了 exec 一个新程序,不复制父进程的地址空间。在子进程调用 exec 或 exit 之前,它在父进程的空间中运行,而父进程在子进程调用 exec 或 exit之后才会运行。
#include <sys/types.h> #include <unistd.h> /* 返回和fork相同 */ pid_t vfork(void);
执行程序
进程可以调用 exec 函数族执行另一个程序,这时进程执行的程序被替换为新程序,并从 main 函数开始执行。
#include <unistd.h> /* 执行新程序 * @return 成功不返回,出错返回-1 */ int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]);
其中 execve 是内核的系统调用,其他5个函数最后都调用它。
函数名中带 p 的表示以文件名作为参数,其他4个函数以路径名作为参数。以文件名作为参数时,若 file参数中有 / ,则视为路径名,否则根据 PATH 环境变量查找可执行文件。
函数名中的 l 和 v 分别表示参数表用列表或数组表示。第一个参数表参数是任意的,通常设为文件名。使用列表方式时,最后一个参数表参数必须为 (char *)NULL 。使用数组方式时,不需要强制转换 NULL 。
函数名中带 e 的表示传递环境表,其他的4个函数则复制现有的环境。
执行 exec 之后,进程的特征基本都被保留,包括创建进程时不变和变化的那些属性。有两个除外:
- 若打开的文件描述符开启了执行时关闭标志,则关闭该描述符。
- 有效用户ID和有效组ID取决于执行的文件的设置用户ID位和设置组ID位是否设置,若已设置,则分别变为程序文件的用户ID和组ID。
解释器文件
Linux系统中有一种解释器文件( #! 文件),第一行的语法是:
#!path [arg ...]
使用 exec 函数执行它时,参数表的第一个参数是 path ,接下来是后面的 arg 等参数,然后是 exec 的第一个参数,再后是 exec 参数表的第二个及以后的参数。
在shell中执行可执行程序时,如 ./a.out arg1 arg2 相当于 execl("./a.out", "a.out", "arg1", "arg2", (char *)0) ,所以执行 #!path arga argb 开头的解释器文件 interfile :
$ ./interfile arg1 arg2 # 当前路径为/home/yeolar
相当于:
execl("path", "arga", "argb", "/home/yeolar/interfile", "arg1", "arg2", (char *)0);
system函数
system 函数是C标准库函数,但依赖于系统环境。在Linux中,它调用 fork 、 exec 、 waitpid 来实现。
终止进程
进程有5种正常终止方式:
- 从 main 返回。
- 调用 exit 。
- 调用 _exit 或 _Exit 。
- 最后一个线程从启动代码返回,这种情况进程的终止状态为0。
- 最后一个线程调用 pthread_exit ,进程的终止状态为0。
还有3种异常终止方式:
- 调用 abort 。
- 收到一个信号并终止。
- 最后一个线程对取消请求做出响应。
exit 和 _exit 、 _Exit 用户正常终止程序,区别是 exit 会先进行一些清理,包括执行终止处理程序,关闭所有标准I/O流等,而后两个函数则直接进入内核,在Linux中它们同义。
#include <stdlib.h> /* 正常终止程序 */ void exit(int status); /* 正常终止程序,直接进入内核 */ void _Exit(int status); #include <unistd.h> /* 正常终止程序,直接进入内核 */ void _exit(int status);
status 为终止状态,正常默认为0,若调用这些函数时未指定、 main 执行无返回值的 return 语句或 main未声明返回类型为整型,则终止状态未定义。
return 0 等价于 exit(0) 。
C标准要求一个进程最多可登记32个函数,即终止处理程序,由 exit 自动调用。用 atexit 函数登记这些函数, exit 按照相反的顺序进行调用。
#include <stdlib.h> /* 登记终止处理程序 * @return 成功返回0,出错返回非0值 */ int atexit(void (*function)(void));
function 参数为登记的函数的地址。
如果父进程在子进程之前终止,子进程成为孤儿进程,它的父进程改为 init 进程。
如果子进程在父进程之前终止,内核为终止子进程保留一定信息,包括进程ID、终止状态、使用的CPU时间总量等等,父进程在调用 wait 等函数时可以获得这些信息。内核释放终止子进程的存储,关闭打开的文件。如果父进程没有对终止子进程进行处理,进程就会变为僵死进程。
回收进程
用 wait 和 waitpid 函数可以回收子进程。调用它们时,如果所有子进程都还在运行,则阻塞;如果一个子进程已终止,在等待父进程获取终止状态,则取得该子进程的终止状态返回;如果没有子进程,则出错返回。
#include <sys/types.h> #include <sys/wait.h> /* 回收子进程 * @return 成功返回进程ID,出错返回-1 */ pid_t wait(int *status); /* 回收子进程 * @return 成功返回进程ID,NOHANG选项时可能返回0,出错返回-1 */ pid_t waitpid(pid_t pid, int *status, int options); /* 回收子进程 * @return 成功返回0,出错返回-1 */ int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
waitpid 可以通过选项选择不阻塞,并可以指定等待的子进程,还支持作业控制。
参数说明:
- status
-
若 status 参数不为 NULL ,则终止进程的终止状态存放在它指向的位置。可以用四个宏查看进程终止的原因:
- WIFEXITED(status) :若为正常终止则返回真,可执行 WEXITSTATUS(status) 获取退出状态(exit 函数族参数的低8位)。
- WIFSIGNALED(status) :若为异常终止(未捕获信号使子进程终止)则返回真,可执行WTERMSIG(status) 获取该信号编号, WCOREDUMP(status) 检查是否产生core文件。
- WIFSTOPPED(status) :若为暂停(即停止)则返回真,可执行 WSTOPSIG(status) 获取使进程暂停的信号编号。
- WIFCONTINUED(status) :若在作业控制暂停后已经继续则返回真。
- pid
-
有四种情况:
- > 0 时,等待进程ID等于 pid 的子进程。
- = 0 时,等待组ID等于调用进程组ID的任一子进程。
- = -1 时,等待任一子进程。
- < -1 时,等待组ID等于 pid 绝对值的任一子进程。
若指定的进程或进程组不存在,或进程不是子进程则也出错返回。
- options
-
可以设为0,或以下值的按位或:
- WNOHANG :指定的子进程还未可用,则不阻塞,函数返回0。
- WUNTRACED :指定的子进程已处于暂停状态,则不阻塞。
- WCONTINUED :指定的子进程在暂停后已经继续,则不阻塞。
waitid 更加灵活。可以用 idtype 指定等待的子进程的类型, P_PID 表示等待指定子进程, P_PGID 表示等待指定进程组的任一子进程, P_ALL 表示等待任一子进程。 infop 参数指向的结构包含引起子进程状态变化的信号的信息。 options 参数可用的常量包括 WCONTINUED 、 WEXITED 、 WNOHANG 、 WNOWAIT 、WSTOPPED ,可以用它们的按位或。
有个技巧可以使子进程和父进程分离成为 init 的子进程,即 fork 两次,例:
#include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include "error.h" int main(void) { pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { /* first child */ if ((pid = fork()) < 0) err_sys("fork error"); else if (pid > 0) exit(0); /* parent from second fork == first child */ /* wait for parent changed to init */ sleep(2); printf("second child, parent pid = %d\n", getppid()); exit(0); } if (waitpid(pid, NULL, 0) != pid) /* wait for first child */ err_sys("waitpid error"); exit(0); }
还有两个用于回收子进程的函数。
#include <sys/types.h> #include <sys/time.h> #include <sys/resource.h> #include <sys/wait.h> /* 回收子进程,可获取终止进程及其所有子进程的使用的资源信息 */ pid_t wait3(int *status, int options, struct rusage *rusage); pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);
如果 rusage 参数设为 NULL ,则它们分别相当于:
waitpid(-1, status, options); waitpid(pid, status, options);
竞争条件
当多个进程都想对共享数据进行处理,而结果又取决于进程运行的顺序时,就发生了竞争条件。上节的例子中就有竞争条件。可以用某种循环(轮询)来测试条件,但这会大量浪费资源。为了避免竞争条件和轮询,需要使用信号机制或进程间通信(IPC)解决。
一种典型的情况是:父子进程的每个进程在执行完它的初始化操作后要通知对方,并等待对方完成初始化操作,然后再运行。
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 are done */ WAIT_PARENT(); /* wait for parent */ /* CHILD CONTINUES ON ITS WAY ... */ exit(0); } /* PARENT DOES WHATEVER IS NECESSARY ... */ TELL_CHILD(pid); /* tell child we are done */ WAIT_CHILD(); /* wait for child */ /* PARENT CONTINUES ON ITS WAY ... */ exit(0);
后面会给出不同方法的 TELL_WAIT 、 TELL_XXX 、 WAIT_XXX 的实现。
进程关系
进程组
进程组是一个或多个进程的集合。通常这些进程和同一作业相关联,可以接收来自同一终端的信号。进程组有唯一的正整数的进程组ID。每个进程都属于一个进程组。
进程组有一个组长进程,它的进程组ID等于进程ID,组长进程可以创建进程组,创建组中进程,然后终止,进程组依然存在。
下面的函数可以获取和设置进程的进程组ID。进程只能设置自己和子进程的进程组ID,子进程调用 exec 之后,进程就不能再设置它的进程组ID了。
#include <unistd.h> /* 获取当前进程或指定进程的进程组ID * @return 成功返回进程组ID,出错返回-1,getpgrp()总是成功 */ pid_t getpgid(pid_t pid); pid_t getpgrp(void); pid_t getpgrp(pid_t pid); /* 设置当前进程或指定进程的进程组ID,加入或创建进程组 * @return 成功返回0,出错返回-1 */ int setpgid(pid_t pid, pid_t pgid); int setpgrp(void); int setpgrp(pid_t pid, pid_t pgid);
pid 等于0时表示为当前进程, pgid 等于0时表示使用pid指定的进程ID作进程组ID。
会话
会话是一个或多个进程组的集合。
#include <unistd.h> /* 获取会话首进程的进程组ID * @return 成功返回会话首进程的进程组ID,出错返回-1 */ pid_t getsid(pid_t pid); /* 若进程不是进程组的组长,则创建新会话,否则出错返回 * @return 成功返回进程组ID,出错返回-1 */ pid_t setsid(void);
使用 setsid 创建新会话时,进程成为新会话的首进程;进程成为新进程组的组长进程,进程组ID为进程ID;进程没有控制终端或中断它。
控制终端
一个会话可以有一个控制终端,即登录到其上的终端设备或伪终端设备。建立和控制终端连接的会话首进程称为控制进程。会话中的几个进程组可被分为一个前台进程组和几个后台进程组。若会话有控制终端,则有一个前台进程组,其他进程组为后台进程组。
键入终端的中断键( ^C )会将中断信号发送给前台进程组中的所有进程,键入终端的退出键( ^\ )会将退出信号发送给前台进程组中的所有进程。若终端检测到连接断开,则将挂断信号发送给控制进程。
可以用 tcgetpgrp 和 tcsetpgrp 函数获取和设置前台进程组的进程组ID。
用 tcgetsid 函数可以获取会话首进程的进程组ID。
#include <termios.h> /* 获取在fd上打开的终端的会话首进程的进程组ID * @return 成功返回会话首进程的进程组ID,出错返回-1 */ pid_t tcgetsid(int fd);
作业控制允许在终端上启动多个作业,即进程组,它控制哪个作业可以访问终端和哪些作业在后台运行。
如果进程组中的每个成员的父进程或者是该组的成员或者不是该组所属会话的成员,该进程组即为孤儿进程组。POSIX.1要求向新孤儿进程组中的每个停止进程发送挂断信号 SIGHUP ,然后发送继续信号 SIGCONT。
非本地跳转
C中的 goto 语句不能跨越函数,跨越函数的跳转使用 setjmp 和 longjmp 函数,这种跳转跨越函数的栈帧。
自动、寄存器和易失变量
非本地跳转通常不能恢复函数中自动变量和寄存器变量的原值,这是不确定的。可以将自动变量定义为有volatile 属性,使其值不恢复。全局或静态变量的值在跳转时不变。
例:
#include <stdio.h> #include <stdlib.h> #include <setjmp.h> static void f1(int, int, int, int); static void f2(void); static jmp_buf jmpbuffer; static int globval; int main(void) { int autoval; register int regival; volatile int volaval; static int statval; globval = 1; autoval = 2; regival = 3; volaval = 4; statval = 5; if (setjmp(jmpbuffer) != 0) { printf("after longjmp:\n"); printf("globval = %d, autoval = %d, regival = %d," " volaval = %d, statval = %d\n", globval, autoval, regival, volaval, statval); exit(0); } globval = 95; autoval = 96; regival = 97; volaval = 98; statval = 99; f1(autoval, regival, volaval, statval); exit(0); } static void f1(int i, int j, int k, int l) { printf("in f1():\n"); printf("globval = %d, autoval = %d, regival = %d," " volaval = %d, statval = %d\n", globval, i, j, k, l); f2(); } static void f2(void) { longjmp(jmpbuffer, 1); }
分别不优化和优化进行编译:
$ gcc testjmp.c $ ./a.out in f1(): globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99 after longjmp: globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99 $ gcc -O testjmp.c $ ./a.out in f1(): globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99 after longjmp: globval = 95, autoval = 2, regival = 3, volaval = 98, statval = 99
可以看到全局、静态和易失变量不受影响,因为它们始终存放在存储器中,而自动和寄存器变量在优化后放在寄存器中。