进程控制
1 引言
本章介绍UNIX系统的进程控制,包括创建新进程、执行进程和进程终止。还将说明进程属性的各种ID——实际,有效和保存的用户ID和组ID,以及它们如何受进程控制原语影响。本章还包括了解释器文件和system函数。本章最后讲述了大多数UNIX系统所提供的进程会计机制,这种机制使我们能够从另一个角度了解进程的控制功能。
2 进程标识(进程ID)
每个进程都有一个非负整数作为其唯一标识,称为进程ID。
虽然是唯一的,但是进程ID是复用的。当一个进程结束时,其进程ID就成为复用的候选者。大多数UNIX系统实现延迟复用算法,以保证刚刚结束的进程ID不会被新执行的进程使用作为其进程ID,这避免了新的进程被认为是某个已终止的旧的进程。
UNIX系统中有一些专用进程,但其随具体实现不同而不同。进程ID为0的进程通常是调度进程,常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行磁盘上的任何程序,因此也被称为系统进程。进程ID1通常是init进程,在自举过程结束后,由内核调用。该进程负责在自举内核后启动一个UNIX系统。init通常读取与系统有关的初始化文件(/etc/rc*文件或/etc/inittab文件,以及在/etc/init.d中的文件),并将系统引导到一个状态(如多用户),但是它以超级用户特权运行。
除了进程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
3. 函数fork
一个现有的进程可以通过fork
函数创建一个新的进程。
pid_t fork(void);
// 返回值:在子进程中返回0,在父进程中返回子进程ID
fork
函数创建一个新进程,称为子进程。fork
函数调用一次,但返回两次,分别在父进程和子进程返回。在父进程中返回子进程的进程ID,因为没有方法能获取父进程的所有子进程;在子进程中返回0,因为子进程能够通过getppid
的方式获取父进程ID,同时0是内核交换进程的进程ID,不可能被其他进程使用。
fork
函数创建子进程时,会拷贝数据空间,堆栈的副本供子进程使用,父进程与子进程共享正文段,不共享存储空间。
由于在fork
后经常跟随exec
,所以现在的很多实现并不执行一个进程的数据段,堆栈的完全副本。作为替代,使用写时复刻(Copy-On-Write, COW)技术.这些区域由父进程和子进程共享,而且内核将它们的访问权限改为只读,如果父进程或者子进程中的任一个试图改变这些区域,则内核只为修改区域的那部分内存区域制作一个副本。
一般来说,先执行父进程还是先执行子进程是不一定的,这取决与内核所使用的调度算法。如果要求父进程和子进程相互同步,则要求某种形式的进程间通信。
当写标准输出时,我们将buf
的长度减一作为输出字节数,这里我们不期望写终止null字节。strlen
计算不包含终止null字符的字符串长度,而sizeof
则计算包含终止null字符的字符串长度。两者的另一差别是,使用strlen
需要进行一次函数调用,而sizeof
是在编译时计算缓冲区长度,因为编译时,缓冲区已用已知字符串进行初始化,其长度是固定的。
write
函数是不带缓冲的,在fork前调用write
函数,仅写标准输出一次。printf
写标准输出流,当标准输出流关联设备/控制台时,是行缓冲的,所以缓冲区遇换行符冲洗,printf
数据中有换行符,直接进行无缓冲I/O。当标准输出流关联文件时,是全缓冲的,当缓冲区满或者主动冲洗时才进行实际I/O。所以fork
函数调用时,同样拷贝了标准输出流的缓冲区,所以在实际I/O时,由父进程/子进程各输出一次。
文件共享
fork
的一个特性是父进程打开的所有文件描述符都复制到子进程中。我们说“复制”是因为对于每个文件描述符都好像使用了dup
函数。父进程和子进程都拥有自己的文件描述符,它们位于各自的进程表项中文件描述符项,但是它们关联相同的文件表项,这意味着父进程,子进程共享文件表项,包括文件状态标注,当前文件偏移量和v节点指针。
说明文件表项由内核管理,不属于进程数据空间。
父进程,子进程对当前文件偏移量的修改相互影响。
在fork
后处理文件描述符由以下两种情况,
- 父进程等待子进程完成。这种情况下,父进程无需对文件描述符做任何处理。当子进程终止后,它所读、写的任一共享文件描述符的当前文件偏移量都以做了相应更新。
- 父进程,子进程执行不同的程序段。在
fork
后,父进程,子进程关闭各自不需要的文件描述符,以免相互影响。这种方法是网络服务经常使用的。
除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:
- 实际用户ID,实际组ID,有效用户ID,有效组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 设置用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 对任一打开文件描述符的执行时关闭(exec to close)标志
- 环境
- 连接的共享存储段
- 存储印象
- 资源限制
父进程和子进程的区别如下:
fork
的返回值不同;- 进程ID不同
- 父进程ID不同
- 子进程的
tms_utime
,tms_stime
,tms_cutime
,tms_ustime
的值设置为0 - 子进程不继承父进程设置的文件锁
- 子进程的未处理闹钟被清除
- 子进程的未处理信号集设置为空集
fork
有以下两种用法:
(1) 一个父进程希望复制自己,使在父进程和子进程中执行不同的程序段。这在网络服务中是常见的,父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。
(2) 一个进程要执行一个不同的程序。在这种情况下,子进程从fork
返回后,立即调用exec
。
4. 函数exit
进程有5种正常终止方式和3种异常终止方式,5种正常终止方式如下:
(1) 从main
函数中执行return
语句返回,这种情况等同于调用exit(main)
。
(2) 调用exit
函数。exit
函数属于ISO C标准,执行终止处理程序,并关闭打开的标准I/O流,清理I/O缓冲区。因为ISO C并不关闭文件描述符,多进程(父进程和子进程)以及作业控制,所以这一定义对于UNIX系统是不完整的。
(3) 调用_exit
和_Exit
函数。_Exit
函数属于ISO C标准,目的是为了提供一种不执行终止处理程序和和信号处理程序的终止进程的方式,关于是否冲洗标准I/O流则取决于实现。_exit
函数属于POSIX.1标准,在UNIX系统中,其作用与_Exit
函数相同,并不冲洗标准I/O流。_exit
函数由exit
调用,它负责处理UNIX系统特定的细节。
在大多数UNIX系统的实现中,
exit(3)
是标准C库中的一个函数,_exit(2)
是一个系统调用。
(4) 进程的最后一个线程在其启动例程中执行return
语句返回。但是,其线程的返回值不作为进程的返回值,当最后一个线程从启动例程返回时,该进程以终止状态0返回。
(5) 进程中的最后一个线程调用pthread_exit
函数。如同前面一样,进程以终止状态0返回,与传递给pthread_exit
的参数无关。
3种异常终止方式如下:
(1) 调用abort
。它产生SIGABRT信号,这是下一种异常终止的一种特例。
(2) 当进程接收到某县信号时。信号可由进程本身,其他进程,内核产生。
(3) 最后一个线程对取消请求作出响应。默认情况下,取消以延时方式发生:一个进程要求取消另一个进程,一段时间后,目标进程终止。
不管进程时如何终止的,最后都会执行内核中一段相同的代码,这段代码为进程关闭所有文件描述符,并释放它所使用的存储器。
对于上述的任何一种终止方式,我们都希望其父进程知道子进程是如何终止的。父进程能通过子进程的终止状态了解子进程的终止情况。终止状态是调用_exit
函数时产生的状态,退出状态为exit
,_exit
,_Exit
函数调用时传入的参数。在通过调用exit
,_exit
,_Exit
函数终止进程的情况下,内核将退出状态转化为终止状态。在异常终止进程时,内核产生一个异常终止原因的终止状态。在任一种情况下,父进程调用wait
或waitpid
获得子进程的终止状态(正常终止获得退出状态)。
父进程调用fork
函数产生子进程,子进程终止后,如过父进程还在运行,子进程将终止状态返回父进程;如果父进程在子进程之前结束,则init
进程变为该子进程的父进程。init
进程在每一个进程终止时,都会查询其子进程是否还在运行,如果仍在运行,则将这个进程的父进程设为init
进程,这使得每个进程都有一个父进程。
init的父进程是什么?
当子进程终止时,关闭标准I/O流,释放占用存储区,但子进程还保留其他一些信息供父进程查询,其至少包括子进程ID,终止状态和CUP时间总量。父进程调用wait
或waitpid
获取子进程的保留信息,并且清理其仍占有的资源,如果父进程不调用wait
或waitpid
则会导致子进程终止后仍占有的资源无法释放,这样的子进程被称为僵死进程(zombie)。内核会释放子进程调用的终止进程所占用的资源(使用的所有存储区),关闭所有打开的文件(关闭文件描述符)。
当子进程中终止后被完全清理了,父进程就无法访问子进程的终止状态。
对于init
进程收养的进程终止时,init
进程会调用一个wait函数获取其终止状态。
5. 函数wait
和waitpid
函数wait
和waitpid
用于当进程终止后,父进程获取子进程的终止状态。
当进程终止时,无论是正常终止还是异常终止,内核都会向其父进程发送一个SIGCHLD
信号,用于通知。子进程的终止对于父进程来说是异步发生的,可能发生于父进程运行期间的任何时间点。所以父进程需要对信号进行异步处理。可以设置SIGCHLD
信号处理程序,当捕获到此信号时,调用此程序。对于父进程来说,其默认处理方式是忽略该信号。
对于wait
和waitpid
函数:
当所有子进程都处于运行状态,则阻塞;
当有进程终止,则获取进程终止状态并返回;
当没有子进程时,立刻返回出错。
对于wait
函数,当父进程捕获到SIGCHLD
信号时执行,期望立刻返回,非此时间点调用,则阻塞。
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
// 返回值,如成功返回子进程ID,否则返回0或-1
wait
和waitpid
的区别:
wait
获取调用等待后第一个终止的子进程的终止状态,waitpid
等待获取指定pid
子进程的终止状态。wait
正常调用后,必然阻塞,waitpid
使用选项可以不阻塞。
statloc
参数指定终止子进程终止状态的存储位置,如果不关心终止状态则传入空指针。
对于waitpid
函数:
pid == -1
:等待任一子进程,等价于wait
。
pid > 0
:等待进程ID与pid
相等的进程。
pid == 0
:等待组ID等于调用进程组ID的任一子进程。
pid < -1
:等待组ID等于pid
绝对值的任一子进程。
options
参数控制waitpid
函数的操作。此参数值为0,或为下列常量安慰或的运算结果:
WCONTINUED
WNOHANG
:若由pid
指定的子进程并不是立即可用的,则不阻塞,此时返回值为0WUNTRACED
waitpid
提供wait
函数没有的3个功能:
(1) 等待指定pid
的子进程终止。
(2) 子进程不可用时,不阻塞当前进程。
(3) 支持作业控制。
6. 竞争条件
当多个进程试图在同一时间对共享数据做某种处理,就会导致出现竞争条件。其最终的结果取决进程执行时的内核调度。在进程开始运行后,其机器指令的执行时间点取决与系统负责和内核的调度算法。
7. 函数exec
函数exec
用于将当前进程替换为一个新的进程执行,调用exec
函数后,将当前进程的正文段,数据段,堆和栈全部替换为新进程的相应部分,并从新进程的main
函数开始执行。函数exec
并不创建新的进程,进程其他属性保持不变,如进程ID。
#include <unistd.h>
int execl(const char *pathname, const char *arg0, const char *arg1... /*(char *)0*/);
int execv(const char *pathname, char * const argv[]);
int execle(const char *pathname, const char *arg0, const char *arg1... /*(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.若成功,不返回
有三种方式确定执行进程:
- 执行路径名:输入执行程序的路径,
- 执行程序文件名:若filename中含有"/",则视为路径名,否则,视为程序文件名,其执行程序搜索路径为$PATH。
execlp
或execvp
中找到的文件不是连接编译器产生的可执行程序,则尝试视为shell脚步。 - 文件描述符:主要用于使用文件描述符判断执行程序是否符合期望,避免
TOCTTOU
错误,在执行程序前,程序文件被替换。
调用进程传参使用参数列表或者参数表数组。
调用进程环境表若调用exec
时传入,则使用传入环境表,否则使用调用进程环境表。
在执行exec
后,进程ID没有改变。但新程序从调用进程继承以下属性:
- 进程ID和父进程ID
- 实际用户ID和实际组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 闹钟尚余留的时间
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 文件锁
- 进程信号屏蔽
- 未处理信号
- 资源限制
- nice值
tms_utime
,tms_stime
,tms_cutime
,tms_ustime
对打开文件的处理,与每个文件描述符的执行时关闭(close-on-exec)标志值有关。若使用fcntl
设置了执行时关闭标志,则在执行exec
时关闭该文件描述符,否则遵循系统默认设置,不设置该标志,执行exec
后仍保持文件描述符打开。
POSIX.1明确要求在执行exec
时关闭目录流。这通常是由opendir
函数实现的,它调用fcntl
为对应打开目录流的函数的文件描述符设置执行时关闭标志。
在exec
前后实际用户ID和实际组ID保持不变,而有效用户ID和有效组ID是否取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变为程序文件所有者的ID;否则,有效用户ID不变。对组ID的处理方式与此相同。
总结
UNIX系统内核如何执行一个程序:
无论是从终端执行exec
命令,还是在进程中执行exec
函数,所有程序的执行都是从exec
开始,可以调用execl
, execv
, execle
, execlp
, execvp
, fexecve
函数,也可以直接进行execve
系统调用。
执行exec后,内核生成一个启动例程,负责将参数列表,环境变量表传入进程。
进程从main
函数开始执行,执行用户调用。
调用exit
函数族中的函数,进程退出。所有进程的退出最终都是调用_exec
系统调用(POSIX.1)或者_Exec
函数(ISO C),其关闭所有打开的文件描述符。函数exit
会在进程退出前调用使用atexit
函数注册的终止处理函数,其执行顺序为FILO
,然后清理(冲洗)所有标准I/O的缓冲区,然后再调用_exit
。从main函数返回时会隐式调用exit
。
进程调用fork
函数时已创建一个新的进程,称为该进程的一个子进程。子进程拷贝父进程的正文段,数据段(初始化数据段和未初始化数据段) ,堆,栈的副本,作为自己的执行环境,除了需要在父/子进程中区分的进程属性(如进程ID),其他属性全部与父进程相同。
每个进程都有自己的父进程(init进程的父进程是自己),父进程负责在子进程退出时,对子进程的保留资源做接收清理工作。父进程调用wait
或waitpid
函数等待某个/指定子进程退出,获得退出子进程的进程ID后,清理子进程的剩余占用资源。对于一个子进程,如果不调用wait
函数族处理,则该进程的父进程结束后,其变为孤儿进程,由init
守护进程收养,作为其父进程,在其退出时,调用wait
。
exec
函数族以文件形式(二进制可执行文件或者可执行脚本)执行一个进程,其进程将原进程的正文段,数据段(初始化和未初始化数据段),堆,栈替换为自己的对应数据,然后开始执行,新进程与原进程进程ID相同,如果不指定运行环境将使用原进程的执行环境,同时新进程继承原进程的所有进程属性。