进程控制
进程终止
在编写 C 语言程序时,main 函数往往会 return 0;但是为什么需要 return 0 呢?return 的 0 被谁接收了呢?首先 main 函数的返回值是退出码,往往用 0 表示运行成功正常退出,非零值表示失败,不同的退出码表示不同的失败原因。main 函数的退出码是直接被操作系统接收的,用来让操作系统辨别该进程的执行情况,子进程的退出码则由父进程接收。
在 Linux 中我们使用命令 echo $? 可以显示出最近一个进程的退出码,C 语言已经提供了一些内置的错误原因。

使用函数 strerror 可以显示该错误码对应的错误信息。

进程终止无外乎三种情况:
- 代码跑完,结果对
- 代码跑完,结果不对
- 代码没跑完,进程异常
对于前两种情况的区分是由退出码来决定的,而如果代码出现异常,那么退出码本身是没有意义的。 退出码有三种方法表示:
- main 函数进行
return n,n 为退出码 - 直接调用
exit(n),n 为退出码 - 直接调用
_exit(n)
三种方法的比较
return VS exit
return 表示函数调用结束,main 函数 return 表示进程退出,exit 表示进程结束,任何地方调用都会导致进程退出。
exit VS _exit
exit 是库函数会主动刷新缓冲区,而 _exit 是系统调用不会刷新缓冲区,所以 exit 底层是有 _exit 的。而由此我们可以知道输出缓冲区一定不在操作系统内部,因为倘若在,那么 _exit 也会刷新缓冲区,由此可以得知输出缓冲区是在库当中的,也就是库缓冲区。
所以进程退出的最佳实践为 exit。
进程等待
为什么要等待进程?
子进程退出,父进程如果不管不顾,就可能造成'僵尸进程'的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,那就无法被杀死,kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息,其中回收资源是父进程必须做的事,而获取子进程退出信息是可选的。
进程等待是让父进程通过等待的方式回收子进程的 PCB 与资源,如果需要就获取子进程的退出信息。

父进程调用 wait 函数表示等待任意一个子进程,如果子进程没有退出,父进程等待时就会阻塞,如果子进程退出,父进程 wait 时,wait 就会返回,让系统自动解决子进程的僵尸问题,等待成功时返回子进程的 pid。
waitpid
pid_t waitpid(pid_t pid, int *status, int options);
返回值:当正常返回的时候 waitpid 返回收集到的子进程的进程 ID;如果设置了选项 WNOHANG,而调用中 waitpid 发现没有已退出的子进程可收集,则返回 0;如果调用中出错,则返回 -1,这时 errno 会被设置成相应的值以指示错误所在。
参数:
- pid: Pid=-1,等待任一个子进程。与 wait 等效。Pid>0,等待其进程 ID 与 pid 相等的子进程。
- status: 输出型参数。WIFEXITED(status):若为正常终止子进程返回的状态,则为真(查看进程是否是正常退出)。WEXITSTATUS(status):若 WIFEXITED 非零,提取子进程退出码(查看进程的退出码)。
- options: 默认为 0,表示阻塞等待。WNOHANG:若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。若正常结束,则返回该子进程的 ID。
status 参数,该参数是一个输出型参数,由操作系统填充,如果传递 NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程,status 不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低 16 比特位)。

正常终止的前八位为子进程的退出码,所以退出码的范围一般为 0~255,而如果进程出现异常,前八位就没用了,后七位为信号编号,代表异常的信息。
通过 status 我们可以知道子进程的退出码为多少,如果子进程退出码为 1,那么 status 则为 0000000100000000,化为整形则为 256,可以通过 (status>>8)&0xFF 来提取退出码,需要 &0xFF 的原因为消除高 16 位的影响,只考虑后 8 位。同样的如果出现异常,需要知道异常的原因,用 status&0x7F 来获取信号编号。

我们可以发现信号是没有 0 的,因为信号为 0 代表进程是正常运行,没有出现异常。虽然 status 的后 7 位代表信号编号,但是由于操作系统本身的原因是只设置了 1~64。
所以进程终止的三种情况,信号编号与退出码分别如下:
- 代码跑完,结果对,0,0
- 代码跑完,结果不对,0,!0
- 代码没跑完,进程异常,!0,无意义
由此我们终于明白了进程退出的整个流程: 当子进程退出时,子进程的 exit_code 与 exit_signal 会写入到子进程的 PCB 中,所以当子进程退出进入到僵尸状态时,OS 不会释放其 PCB,因为父进程会提取子进程的两个数据整合到 status 中,所以 waitpid 的本质也是获取子进程 PCB 内的属性数据,和 getpid 没区别,在调用完成后,也会让 OS 释放目标的 PCB。
最终关心的也就只有两个数字,分别为退出码与信号,所以已经提供了两个宏来帮助判断,WIFEXITED(status) 如果是正常终止则返回真,WEXITSTATUS(status) 提取退出码。
在 waitpid 的参数中最后一个参数为 option,意味选项,当选项为 0 时代表阻塞等待,还存在一个宏 WNOHANG 意味非阻塞等待,非阻塞等待不会因为条件没有就绪而阻塞,而是立即返回非阻塞,又被称为非阻塞轮询方案。
运行以下非阻塞等待代码:
int main(){
pid_t id = fork();
if(id == 0){
int cnt = 10;
while(cnt--){
printf("I am child,%d\n",cnt);
sleep(1);
}
exit(10);
}
pid_t rid = waitpid(-1,NULL,WNOHANG);
if(rid == id)printf("等待成功\n");
else if(rid == 0)printf("子进程未结束\n");
else printf("等待失败\n");
return 0;
}

可以看到结果发现进程只有一个了,因为此时是非阻塞等待,父进程等待一次后发现子进程并未结束,所以父进程运行完之后的代码直接结束,此时子进程变成了孤儿进程,用 ps 命令查看可以发现此时该子进程的父进程变为了 1,也就是 bash,也可以发现此时 Ctrl+C 无法终止,因为孤儿进程此时与显示器无关了,只能用信号杀死或者等待其自然结束。所以非阻塞等待被称为非阻塞轮询方案,因为非阻塞情况下,父进程不可能只等待一次,需要多次循环等待。
与下方代码类似。

而非阻塞轮询的意义就是不会卡住父进程,父进程可以在等待的过程中做其他事情,会更高效!
程序替换
fork() 之后,父子各自执行父进程代码的一部分如果子进程就想执行一个全新的程序呢?进程的程序替换来完成这个功能!
程序替换的原理
子进程调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变。调用 exec 函数进行替换时,子进程原本共享父进程的代码和数据,会发生写时拷贝,会从磁盘加载目标程序到物理内存中,至此子进程就有了一套独立的代码和数据,保持了进程之间的独立性。
以最简单的一个 exec 函数为例
int execl(const char *path, const char *arg, ...),path 为目标程序路径,arg 为目标程序名称,…为可变参数,运行以下代码


关联 Linux 的命令,就可以知道命令行解释器的运行原理,先利用 fork 创建子进程,将命令用 exe 系列加载到物理内存中,这时父进程也就是 bash 会 waitpid 进行等待子进程。
exec 家族函数

exec 家族的名称如下表示:
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有 p 自动搜索环境变量 PATH
- e(env) : 表示自己维护环境变量
例如 execl 函数就是用列表而不是数组表示选项;execlp 函数执行指定的命令,需要让 execlp 自己在环境变量 PATH 中寻找指定的程序!所以其就不用带路径了直接带名字就行;对于 execv 函数是用数组来表示选项;对于 execvp 表示不带路径,用数组表示选项;对于 execle 则是可以自己传递环境变量表,当然也可以传递自带的 environ,如果想在自带的 environ 中增加新的自己的环境变量,可以通过 putenv 函数在原先的环境变量表中增加新的。
以上的六种函数都是在 man 手册的第三页,也就是说是库函数而非系统调用,他们的底层都是有 execve 这个系统调用的,也就是说这六种函数在被调用的时候会去调用 execve 这个系统调用来完成任务
