预备知识
首先你要清楚,进程管理为内核的功用之一。
接着我们遵循教科书中的定义:进程是程序执行时的一个实例。
再进一步解释,程序(program)为存储在硬盘上的二进制文件,而将其装载进内存之后即为进程(process),同时还将获得一个 PID(process ID)用于唯一标识该进程。
进程被产生,他们有或多或少的生命,他们也可以继续产生一个或多个子进程,但最终都要死亡。与人类不同的是,进程没有性别,每个进程都只有一个父进程。
创建新进程 fork()
fork() 通过复制调用进程的方式创建一个新的进程,新进程被称为子进程,调用进程则被称为父进程。
函数原型如下:
pid_t fork(void);
创建一个进程时,它几乎与父进程相同。就像 fork() 帮助手册上所写,子进程就是对父进程的精确复制。
The child process is an exact duplicate of the parent process
也就是说,子进程将复制父进程所拥有的资源,即拷贝父进程的整个地址空间。之后这两个进程将执行相同的程序文本,但彼此将拥有独立的栈、堆及数据。
// 代码示例
int main(int argc, char *argv[])
{
switch ( fork() ) {
case -1: // Handle error
perror("fork error");
exit(1);
case 0: // Perform actions specific to child
printf("This is child process, PID is %d\n", getpid());
printf("My PPID is %d\n", getppid());
break;
default: // Perform actions specific to parent
printf("This is parent process, PID is %d\n", getpid());
}
printf("This will be shown in both parent and child! %d\n\n", getpid());
return 0;
}
当系统调用 fork() 成功创建子进程后,将在父进程中返回子进程的 PID,并在子进程中返回 0。
因此我们才需要通过 fork() 的返回值来区分父、子进程,但 fork() 之后你不应对父、子进程的执行顺序做任何假设,这时你就需要后文的 wait() 函数。
此处需要理解的关键是,完成一次 fork() 调用后,将存在两个进程,并且每个进程都将从 fork() 的返回处继续执行。
代码执行结果如上所示,先抛开父、子进程执行顺序的问题,运行结果确是如我们预期。
细心观察的你会发现,我们无意间产生了一个 PPID(Parent PID) 为 1 的进程,这就是所谓的孤儿进程。因其父进程先终止,而变为孤儿进程,接着孤儿进程便会被 PID 为 1 的 systemd 进程接管。
进程的终止 _exit() 与 exit()
这两种退出进程的方式,都是进程的正常退出。不同的是 _exit() 函数是更为底层的系统调用,而 exit() 函数则是 stdlib.h(standard library)C 标准库中的函数。
程序一般不会直接调用 _exit() 而是调用 exit(),因其经过封装后会在执行 _exit() 前进行一些清理操作。
void exit(int status);
exit() 函数没有返回值,整型参数 status 接收进程退出时的状态码。
C 程序 main() 函数里的return 0
等同于执行exit(0)
的调用,因为 main 的返回值将作为 exit() 的参数来退出程序。
等待子进程 wait()
系统调用 wait() 用于推迟调用进程的执行(即父进程挂起自己),直至它任一子进程的终止。
pid_t wait(int *wstatus);
执行成功将返回被终止的子进程 PID,错误返回 -1。而至于他的整型 wstatus 参数,我们这里直接给它传入 NULL 即可。
int main(int argc, char *argv[])
{
int j;
if ( argc < 2 || strcmp(argv[1], "--help") == 0)
{
printf("try again! and type at lease one argument.\n");
exit(1);
}
for (j = 1; j < argc; j++) // Create one child for each argument
{
switch (fork()) {
case -1:
perror("fork()");
exit(1);
case 0: // 因子进程的 fork() 返回 0 而进入此条件
printf("the PID of child %d is: %d\n", j, getpid());
printf("My parent is %d\n", getppid());
sleep(5);
printf("I (child) wake up!\n\n");
exit(0);
default: // 父进程成功生成子进程后,因父进程的 fork() 返回子进程 PID 大于零,因此父进程进入 default,从>而执行以下语句,wait 着子进程的释放,以开始新一轮的循环
printf("I am the parent, my PID: %d\n", getpid());
// 上面 printf 语句与 wait() 的顺序决定了,父进程是先等待还是先输出 PID 内容
printf("the return value of wait is: %d\n\n", wait(NULL));
// 若在此处注释 wait() 语句,则子进程的 PPID 将显示为 1,即成为孤儿进程,被 init 接管
}
}
// Now, just in the parent.
printf("I (parent) finished my jobs.\n");
return 0;
}
上面的程序将根据命令行中传入的参数个数,来创建同等数量的子进程。父进程在打印提示信息后,将 wait() 着子进程的 sleep(),你将根据输出顺序来搞清楚,父、子进程的创建、阻塞逻辑关系。
wait() 的另一直观感受——我们终端上的 shell 正是在这样做:只有当执行命令的子进程退出后,shell 才会打印出命令提示符。
加载新程序 execve()
系统调用 execve(),丢弃旧程序,后加载一个新程序到当前进程内存,即进程的栈、堆及数据都将被替换。
int execve(const char *filename, char *const argv[], char *const envp[]);
filename 指向即将被加载到当前进程空间的程序,可以是二进制可执行文件,也可以是脚本文件。
argv[] 是将传递给新程序的数组型的参数,在需要以 NULL 结尾的同时,argv[0] 的值应与将执行的文件名相同。
envp 则指定了新程序的环境列表。execve() 在成功时不返回任何值,仅在失败时返回 -1。
库函数 exec()
接下来的 exec() 函数族便是建构在 execve() 之上,为 execve() 的执行提供了多种选择方式。
这里我们先以 execvp() 为例:
int execvp(const char *file, char *const argv[]);
int execv(const char *path, char *const argv[]);
execvp() 接受以数组形式的参数传递(需要以 NULL 结尾),同时允许用户只提供程序名,系统将会在环境变量 PATH 所指定的目录下来寻找相应的可执行文件。
// execvp_pro.c
int main(int argc, char *argv[])
{
pid_t childPid;
pid_t waitReturnNum;
char *arg_list[] = {
"ls",
"-lh",
"--color=tty",
"./",
NULL
};
switch ( childPid = fork() ) {
case -1:
perror("fork");
exit(1);
case 0: // child, do execvp job.
printf("the PID of the child: %d\n", getpid());
printf("My parent is %d\n", getppid());
execvp("ls", arg_list); // 数组的形式接收参数,并在 PATH 路径下寻找 ls 程序文件
// if run the next line.
printf("*********************************** Just a test line.\n");
// NO
// The exec() family of functions replaces the current process image with a new process image.
default: // paretn, do wait job.
waitReturnNum = wait(NULL);
printf("\nParent after wait, the waitReturnNum:%d\n", waitReturnNum);
printf("the childPid: %d\n", childPid);
}
return 0;
}
接下来对比 execv() 函数,以下代码仅仅列出修改后的部分
// execv_pro.c
char *arg_list[] = {
NULL
};
execv("hello", arg_list); // hello 为当前目录下已有的可执行程序文件
接着我们根据以上文件名,顺序执行如下编译命令:
gcc execvp_pro.c -g -o hello;
gcc execv_pro.c -g -o exec
# 此时的 hello 程序,即为 execv_pro.c 文件中 execv() 将要接收的参数
执行结果如下所示:
第一次 ./hello
的执行,是在子进程中装载 /bin/ls
程序(在 PATH 中寻找所得。)接下来 ./exec
的执行,是在当前目录下寻找到的 hello
程序的装载。
以上所示,便为两个函数的 const char *file
与 const char *path
参数的区别。
总体来看进程的生命
父进程(程序 A)的执行,或将引发 fork() 操作。
子进程完成对父进程的复制后的 execve() 为可选操作,因为有时我们希望子进程执行与父进程相同的操作,有时我们却希望子进程能丢弃现有程序并加载一个新的程序。
同样父进程的 wait() 操作也为可选,我们可以挂起父进程等待着子进程的终止,当然也可以让父进程对子进程不管不顾。
图片来源:《UNIX系统编程手册 上》.((德)Michael Kerrisk ).[PDF]
到最后了,还记得刚刚那个接管孤儿进程的那个 1 号进程吗?
这是 Linux 系统启动后所创建的 第一个用户态进程,因此根据前面的知识我们便会知道,后续的所有进程都称得上的 1 号进程的子孙。
如果你想验证的话,不妨执行 pstree -p
看看吧。
Reference
- 《深入理解 Linux 内核》
- 《UNIX系统编程手册 上》.((德)Michael Kerrisk ).[PDF] :http://libgen.is/book/index.php?md5=4B83F6C2F3F5C73FF81391A98679E8CE