本篇主要记录C语言环境下的进程控制方式、作用以及实现思路等。
1 进程创建
Linux为C语言提供了 'fork' 接口用于为某一进程创建子进程。
cpp
#include <unistd.h>
pid_t fork();
使用 'fork' 创建子进程后,父进程和子进程共用同一份代码,但它们的各项数据相互独立。创建子进程后,'fork' 会向子进程返回 '0',向父进程返回子进程的 'pid'。因此,我们可以用以下程序观察父子进程的各项编号关系。
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id<0)
{
printf("Create process failed!");
return -1;
}
else if(id>0)
{
printf("This is parent, pid: %d, ppid: %d, id: %d\n",getpid(),getppid(),id);
}
else
{
printf("This is son , pid: %d, ppid: %d, id: %d\n",getpid(),getppid(),id);
}
return 0;
}

关于 'fork' 的底层实现思路,已经在进程地址空间与物理内存之间的转换过程中详细分析,这里仅放传送门和示意图。

2 进程退出
进程退出的情况有三种,正常退出且结果正确 、正常退出但结果错误 、异常退出。
2.1 正常退出
正常退出 是指程序正常执行退出,正常退出的进程通常会向其父进程返回退出状态码,父进程可以根据其退出码判断其运行结果是否正确。
在Linux环境下,C语言的退出码通常为 8bit 长,即范围在 0~255 之间。退出码主要是用于向父进程传达运行结果是否正确,其中各个数值所代表的含义是由父进程自定义的,通常以 0 表示结果正确,其它每个值可以表示一种原因导致的错误。C语言的 'string.h' 库中提供了一些常用退出码对应的退出信息。我们可以使用以下程序获取。(实测已定义退出码范围为 0~133)
cpp
#include <stdio.h>
#include <string.h>
int main()
{
for(int i=0;i<200;++i)
printf("exitcode: %d, msg: %s\n",i,strerror(i));
return 0;
}

这里介绍三种常用的进程正常退出方法。
cpp
return;
'main' 函数运行完成就是一种进程的正常退出,而 'main' 函数的返回值就是以此方式退出的退出码。我们可以使用以下程序返回一个随机值,再使用 echo $? 命令获取最近一次执行结束的进程的退出码。(由于正常退出的退出码通常为 8bit 长,因此范围在 0~255之间,因此生成随机数后对 256 取模运算) 由于用户创建的进程的父进程默认为 bash 进程(即命令行进程),因此进程会将退出码返回给 bash,也就可以使用 echo 命令的方式查询。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
srand((size_t)time(NULL));
int x=rand();
printf("rand: %d\n",x);
printf("%d %% 256 = %d\n",x,x%256);
printf("exitcode: %d\n",x%256);
return x;
}
bash
echo $?

可以看见,'main' 函数的返回值即为进程退出码。
除了使用 'main' 函数直接返回的方式正常退出,还可以使用一些接口。以下为用于正常退出进程的常用接口。其中 'exit' 为库函数,'_exit' 为系统调用。
cpp
#include <stdlib.h>
void exit(int status); // 库函数
// status: 退出码
#include <unistd.h>
void _exit(int status); // 系统调用
// status: 退出码
这两个接口的共同点是,无论当前是否为 'main' 函数,一旦被调用,立刻退出进程。也就是说,这两个接口可以被用于程序中的任何函数,当执行结果发生错误时,可直接调用接口退出进程,并返回退出码。事实上,'main' 函数 'return n;' 的行为也可以等效为调用 'exit(n);'。
但是这两个接口之间是存在一定区别的。我们可以分别测试以下两段代码。
cpp
#include <stdio.h>
#include <stdlib.h>
void print(const char* msg)
{
printf("%s",msg);
exit(1);
}
int main()
{
const char* msg1="hello world";
const char* msg2="goodbye";
print(msg1);
print(msg2);
return 0;
}

cpp
#include <stdio.h>
#include <unistd.h>
void print(const char* msg)
{
printf("%s",msg);
_exit(1);
}
int main()
{
const char* msg1="hello world";
const char* msg2="goodbye";
print(msg1);
print(msg2);
return 0;
}

其中第一段代码使用的是库函数,而第二段代码使用的是系统调用。可以发现,两段代码在相同位置分别调用了这两个接口,其中调用库函数进行退出的进程成功输出了 'msg1',而调用系统调用进行退出的进程则没有输出任何信息。
这里面涉及到一个缓冲区的问题,这里不展开,只讲原因:在Linux环境下,'printf' 函数在向显示器写入数据时,事实上会先向缓冲区写入,在缓冲区中的数据不会立刻显示在显示器上,直到遇见换行或者使用 'fflush' 接口,系统才会将缓冲区中的数据刷新至显示器。由于上述代码中的输出内容并未发生 '\n' 换行,因此 'printf' 函数将内容写入缓冲区后并不会直接刷新至显示器。
而 'exit' 和 '_exit' 的区别在于,'exit' 在退出进程前,会先刷新缓冲区,关闭数据流等,待处理完这些事项后,再退出进程;而 '_exit' 则是不进行任何处理,直接退出进程。这也就导致了我们所看到的,第一段程序运行后显示了 'msg1',而第二段程序运行后没有显示任何内容。
2.2 异常退出
进程异常退出的本质是进程受到了异常信号。
我们先来看一个经典的异常进程。
cpp
#include <stdio.h>
int main()
{
int x=10;
printf("x: %d\n",x);
x = x/0;
printf("x: %d\n",x);
return 0;
}

这段代码存在很明显的错误,那就是把 '0' 作为除数。 当然,并不存在语法错误,因此可以编译运行。但在运行过程中,操作系统当然会发现这个错误,并在此终止进程,不再执行后续代码。同时,这个异常进程也向 bash 进程返回了一个退出码,但由于该进程已经发生了异常,因此我们认为这个退出码无效。
上述现象还不是很好理解,我们继续来看下面的现象。创建一个能正常运行的进程,在其运行过程中,于另一个终端使用 kill 的 -8 选项杀死该进程。
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
int i=0;
while(1)
{
sleep(1);
printf("I'm running, pid: %d, %ds\n",getpid(),++i);
}
return 0;
}

可以看到,进程异常退出,且报错与之前的除 0 错误一样。事实上,这两个测试中进程的异常退出,都是由于接收到了同样的异常信号。我们可以使用 kill -l 查看系统提供的异常信号。
bash
kill -l

其中 8 号信号就是刚刚使用的浮点数错误。其它常用信号包括 9 号强制退出信号,11 号段错误信号等。我们使用 'CTRL + C' 快捷键强制退出进程,实际上就是在向当前进程发出 9 号异常信号。
3 进程等待
我们知道,子进程退出前会进入僵尸状态,等待向父进程返回退出状态。在僵尸状态下的进程无法被杀死,只有等到父进程执行完它对应的代码,退出并回收僵尸进程。但是如果父进程的工作量太多,甚至处于长期运行状态,那么僵尸进程将难以被回收,造成内存泄露。
为了解决由于僵尸进程引发的内存泄露问题,操作系统为父进程提供了进程等待的方法,这里介绍两个系统调用接口。
3.1 wait
cpp
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
pid_t waitpid(pid_t pid, int* status, int options);
'wait' 比较简单,当父进程调用 'wait' 时,父进程会阻塞,直到接收到一个子进程的退出信息,将退出信息保存在 'status' 中,然后返回这个子进程的退出码。因此,在创建子进程后,父进程只需要调用 'wait' 等待子进程运行结束,并回收子进程,就不会出现僵尸进程的问题了。
这里需要注意,'wait' 等待的子进程是随机的,也就是说如果父进程创建了多个子进程,'wait' 只会等到第一个子运行结束的子进程,然后返回其 'pid'。若等待失败则返回 '-1'。
3.2 status参数
'status' 主要用于向父进程返回子进程正常退出的退出码和异常退出的退出信号,其具体结构如下图。虽然是使用 int 指针变量指向一个 4 字节大小的空间,但这里我们只讨论其低 2 字节的空间结构。其中 0~6 号这 7 个 bit 用于表示子进程的异常信号,8~15 这 8 个 bit 用于表示子进程的退出码。这里的异常信号就与 kill -l 命令所查看的信号一一对应,其中 kill -l 中并没有 0 号信号,这也说明当异常信号为 0 时,子进程为正常退出,此时父进程可通过退出码判断子进程结果是否正确;换言之,如果异常信号不为 0,则子进程发生异常,其退出码也就无效了。

我们可以用以下代码测试上述结论。创建子进程后,父进程调用 'wait' 进入等待,等到子进程运行结束后,获取到子进程的 'pid' 、退出码和异常信号。
cpp
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id==0)
{
int t=0;
while(t<10)
{
sleep(1);
printf("Son has runnig for %ds, pid: %d, ppid: %d\n",++t,getpid(),getppid());
}
exit(22);
}
else
{
printf("Parent is waiting, pid: %d, ppid: %d\n",getpid(),getppid());
int sta;
pid_t sid = wait(&sta);
printf("Parent waits success, pid: %d, ppid: %d\n",getpid(),getppid());
printf("Son's id: %d, exitcode: %d, exitsignal: %d\n",sid,(sta>>8)&0xff,sta&0x7f);
}
return 0;
}
这里我使用了位运算的方式来提取子进程的退出信息,其实系统提供了对应的宏来获取各项信息。
cpp
#define WIFEXITED(status) ((status)&0x7f==0)
// 若进程正常退出,即异常信号为0,则返回真
#define EXITSTATUS(status) (((status)>>8)&0xff)
// 若进程正常退出,则提取退出码

同样的代码,如果我在子进程运行过程中,使用 kill -9 命令向子进程发送异常信号,可以看到子进程异常退出,父进程获取到异常信号为 9。

3.3 waitpid
接下来看看 'waitpid' 的用法。首先,其返回值规则与 'wait' 相同,参数 'status' 的使用规则也是与 'wait' 相同的,实际上这两个调用的不同点就是在于另外两个参数,即 'pid' 和 'options' 上。
cpp
pid_t waitpid(pid_t pid, int* status, int options);
其中,'pid' 就是指定的进程 'pid',当该参数大于 0 时,'waitpid' 会等待 'pid' 对应的进程,而不是等待一个随机进程。当然,如果该参数的值为 -1,'waitpid' 就会和 'wait' 一样,等待一个随机进程退出。
'options' 用于控制 'waitpid' 的执行模式,一般默认设置为 0。当该参数被设置为 0 时,进程调用 'waitpid' 时,会和 'wait' 一样进入阻塞状态 ,直到其所等待的进程向其返回退出信息后才会解除阻塞,继续运行后续代码。除了 0 以外,该参数还可以被设置为 WNOHANG ,意为非阻塞询问 ,如果该参数被设置为 WNOHANG,则调用 'waitpid' 时,进程不会进入阻塞状态,而是发起一次询问,询问其所等待的目标进程是否已经退出并返回退出信息。如果目标进程已经返回退出信息,则回收之,防止其保持僵尸状态占用资源;否则继续执行后续代码。
有了这种等待方式,我们可以进一步提高父进程的运行效率,防止其因为长时间等待子进程而被阻塞,无法执行后续操作,以下为常用的非阻塞轮询结构。创建子进程后,父进程通过循环每隔一段时间就使用 'waitpid' 系统调用,并将 'options' 参数设置为 WNOHANG,这样一来每次调用时若子进程未执行完成,父进程就会继续执行后续代码,待下一轮循环时继续调用 'waitpid' 来询问子进程是否执行完成。
cpp
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id==0)
{
int t=0;
while(t<10)
{
sleep(1);
printf("Son has been running for %ds\n",++t);
}
printf("Son exits");
exit(10);
}
else if(id>0)
{
int t=0;
int ret = 0;
int sta;
while(ret<=0)
{
printf("Parent has waited for son for %ds\n",t);
sleep(3);
t+=3;
ret = waitpid(id,&sta,WNOHANG);
if(ret==0)
printf("Parent fails to wait for son this time!\n");
}
printf("Parent succeeds in waiting for son! exitcode: %d\n",WEXITSTATUS(sta));
}
return 0;
}

可以看到,执行结果与我们所分析的相同。当然,使用非阻塞轮询的方式执行父进程的后续代码时,不应执行过于复杂,耗费时间多的工作,否则同样有可能导致子进程退出后,父进程仍在保持长期运行状态,导致子进程保持僵尸状态。
4 进程程序替换
使用 'fork' 函数可以为当前进程创建一个子进程,但其有一个特点,被创建出来的子进程会拷贝父进程的代码和数据,因此和父进程使用同一份代码。如果想让子进程执行其他程序的代码,就需要用到进程程序替换。
Linux为C语言提供了一批用于进程程序替换的接口如下,这批接口以 exec (execute缩写)开头,后面的 'l'、'v'、'p'、'e' 可以认为是不同的选项。
cpp
#include <unistd.h>`
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 execvpe(const char *file, char *const argv[], char *const envp[]);
4.1 作用与原理
这里先以 execl 为例,先简单解释一下进程程序替换的使用和实现思路。execl 的第一个参数为用于替换的可执行程序的绝对路径,以便于操作系统找到该程序。之后为一个可变参数列表,用于依次传入执行用于替换的程序的命令和选项,且以 NULL 作为结束标志。下面为一个简单的测试程序。
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
int t=0;
while(t<5)
{
sleep(1);
printf("Process is running for %ds, pid: %d\n",++t,getpid());
}
execl("/usr/bin/ps","ps","xj",NULL);
printf("Process exited, pid: %d\n",getpid());
return 10;
}

在这段程序里调用了 execl 接口,并令其使用系统内的 ps 命令的程序替换了当前进程。我们可以看到,在 execl 被调用前,进程正常执行代码中的语句,但调用 execl 后,进程确实执行了替换进程 ps,但执行完 ps 后似乎就不继续向后执行了。而且我们发现,执行 ps 命令的进程 'pid' 与原本进程的 'pid' 相同。
这里就要说到程序替换了。事实上,在这个过程中只有一个进程在工作,而调用 execl 接口后,操作系统实际上是将用于替换的程序(如本例中的 ps)的代码和数据换入当前进程,而不是创建一个新进程。程序替换完成后,进程会重新开始运行。这样一来,原程序的后续代码就不会被执行了。如果调用成功,则 execl 不再返还;若调用失败则返回 -1。

4.2 其它选项作用
首先来说说 'l' 与 'v' 的区别。
cpp
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[]);
'l' 可以理解为 'list',代表可变参数列表。向这类接口中传参时,除了第一个参数固定为替换程序绝对地址,第二个参数为调用命令外,后续可填入多个参数,作为命令的参数,注意所有参数都填入后,需要将 NULL 填入作为最后一个参数表示结束。
cpp
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
'v' 可以理解为 'vector',可以看到与 'l' 系列接口的区别在于,'v' 系列接口不采用可变参数列表的形式传入命令和参数,而是采用字符串数组的形式,这与 main 函数传入命令行参数的方式类似。
cpp
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
'p' 系列接口的特点是其第一个参数可以直接填入程序名,而不要求为程序的绝对路径。前面说过,第一个参数的作用是用于操作系统查找程序,而 'p' 系列接口如果填入非绝对路径的话,操作系统会自动查找环境变量 'PATH' 中的目录里是否存在该程序,若存在则替换程序。
cpp
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
'e' 系列接口可以用于传入自定义环境变量表。如果不使用 'e' 系列接口,子进程的环境变量一般直接继承父进程的环境变量。如果在当前进程调用 execlp,系统则会查找当前环境变量表中的 'PATH' 路径。而如果使用 'e' 系列接口,则可以传入自定义环境变量表,系统在查找程序时只会在自定义环境变量表中查找,而不会查找当前进程的环境变量表。