系统进程API

进程(Process)就是系统中运行的程序,也是系统基本的调度单元。程序最初是以某种可执行格式驻留在磁盘上的。当操作系统运行程序时需要将程序代码和静态数据加载到内存中,同时创建进程并为其分配进程所需的系统资源,如:堆栈,程序计数器,寄存器等。

下文使用C语言来对由操作系统提供的三个主要进程API进行解释。

这三个API分别是:

  • fork()
  • wait()
  • exec()

fork()

该函数会克隆一个与当前进程几乎完全一样的子进程。也就是它会拷贝当前进程的堆栈,程序计数器,寄存器等其他内存空间然后创建一个新的进程。我们将创建出来新的进程叫做子进程,进行fork()调用的进程叫父进程。此时这两个进程都会从fork()调用的下一行代码同时运行

函数原型:

1
pid_t	fork(void);

如果子进程创建失败fork()返回-1,如果创建成功会返回两次值,分别在父进程中返回和在子进程中返回。同时这2个进程得到的是不一样的值,子进程得到的是0,而父进程得到的是子进程的进程标识符(PID)。该调用没有参数。

调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <unistd.h>

int main() {
printf("start fork!\n");
int pid = fork();
if (pid < 0) {
printf("fork failed!\n");
} else if (0 == pid) {
printf("this is child process\n");
} else {
printf("this is parent process, child process pid is %d\n", pid);
}
return 0;
}

第5行代码使用fork()创建一个子进程,一旦进程创建成功,子进程和父进程就会从第6行代码开始同时运行。此时变量pid在这两个进程中的值是不一样的,在父进程中得到的是子进程的PID,子进程中为0。由此我们可以通过fork()调用的返回值来判断当前执行的这个进程是父进程还是子进程。

输出结果:

1
2
3
start fork!
this is child process
this is parent process, child process pid is 1878

wait()

进程在正常退出或异常终止时,操作系统内核会向其父进程发送SIGCHLD信号。父进程可以选择调用wait()来捕获该信号或者选择忽略。在调用wait()后,父进程会进入阻塞状态,当子进程执行完毕后才继续执行父进程中的代码。wait()一次只能等待一个子进程,如果需要等待多个子进程,则需要对wait()进行多次调用。

函数原型

1
pid_t wait(int *status)

参数status是一个指向int类型的指针,系统会把子进程退出时的状态写入该指针所指向的地址空间。父进程可以根据子进程的退出状态来判断它是正常退出还是异常退出。如果不需要关注子进程的退出状态可以传入NULL。如果wait()调用成功返回子进程的PID,调用失败返回-1

系统提供了一些宏来根据子进程的退出状态获取一些进程退出时的信息:

  • WIFEXITED(status):判断是否为正常退出状态,当正常退出是返回1,异常退出返回0。
  • WEXITSTATUS(status):当子进程是正常退出时,该宏返回子进程的返回值。子进程使用exit()调用return来向父进程返回值。

调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
printf("start fork!\n");
int pid = fork();
if (pid < 0) {
printf("fork failed!\n");
} else if (0 == pid) {
sleep(2);
printf("this is child process\n");
exit(20);
} else {
int status = NULL;
int child_pid = wait(&status);
if (child_pid != -1 && WIFEXITED(status)) {
printf("this is parent process, child process pid is %d return value is %d \n", child_pid, WEXITSTATUS(status) );
}
}
return 0;
}

这里先使用fork()调用创建了子进程,然后通过返回值判断当前进程是父进程中还是在子进程中。在第11行代码中使用延时函数sleep()让子进程延时2秒。子进程结束后判断子进程退出状态是否为正常退出,如果正常退出就获取子进程的返回值。

输出结果:

1
2
3
start fork!
this is child process
this is parent process, child process pid is 1900 return value is 20

系统还提供了根据PID来等待指定子进程的调用:

1
pid_t waitpid(pid_t pid, int *status, int options);

调用方式与wait()类似,详细的介绍可以查询相关文档。

unix/linux中,子进程和父进程是并行运行的,子进程什么时候退出对于父进程来说是不知道的。当一个 进程完成它的工作终止之后,它的父进程需要调用wait()waitpid()来取得子进程的退出状态。

  • 孤儿进程:如果一个子进程的父进程退出后,这个子进程还在运行,那么这个子进程就会成为孤儿进程孤儿进程将会被系统中PID为1的init进程接管。
  • 僵尸进程:如果一个子进程退出后它的父进程没有通过wait()waitpid()来获取这个子进程的退出状态,那么这个子进程的进程描述符(进程相关信息)还会在系统中保留。这种进程称之为僵尸进程。

exec()

exec()用来执行磁盘文件中的程序。在使用fork()创建一个子进程以后,此时的子进程与父进程执行的是相同的程序,我们可以在子进程中调用exec()把让子进程换成其他的程序。exec()会从需要执行的那个程序中加载代码和静态数据,并且用它们覆写自己(当前进程)的代码段和静态数据,堆栈及其他内存空间也会被重新初始化。所以也就意味着当进程调用exec()成功以后,原先进程后面的代码将不会执行。当调用失败时返回-1,并且当前进程继续执行。

系统提供了一组不同版本的exec()调用。我拿其中execvp()来做演示。

函数原型

1
int execvp(const char * __file, char * const * __argv)

第一个参数__file用来指定程序的路径,如果程序已经在$PATH环境变量中的话直接使用程序名即可,如lscat

第二个参数__argv用来给指定程序的参数。它的类型是字符串数组,一个参数占用一个数组元素。数组的第一个元素通常和__file参数内容一致,同时数组最后一个元素必须是NULL

如果程序不存在就会导致调用失败。调用失败时返回-1,如果调用成功是拿不到返回值的因为之前进程的内存空间已经被新的程序所覆盖。

调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
printf("start fork!\n");
int pid = fork();
if (pid < 0) {
printf("fork failed!\n");
} else if (0 == pid) {
char *argv[3];
argv[0] = "uname";
argv[1] = "-a";
argv[2] = NULL;
printf("call execvp()\n");
int s = execvp(argv[0], argv);
if (-1 == s) {
printf("call failed!\n");
} else {
printf("never execute!\n");
}
} else {
wait(NULL);
}
return 0;
}

fork一个子进程然后在子进程中通过execvp()来调用系统的uname命令获取系统信息。第16行代码对execvp()进行了调用,第一个参数传入程序名uname,第二个参数是程序需要的参数,这个参数的第一个元素是和第一个参数一样的程序名,第二个参数-a,同时以NULL结尾。然后根据返回值判断是否调用失败,只有当调用失败原来子进程的代码才会继续执行。一旦调用成功,这个子进程的代码段和相应内存空间就会被新的程序所覆盖,所以第20行代码永远不会执行。

输出结果:

1
2
3
start fork!
call execvp()
Darwin codercatdeMacBook-Pro.local 19.3.0 Darwin Kernel Version 19.3.0: Thu Jan 9 20:58:23 PST 2020; root:xnu-6153.81.5~1/RELEASE_X86_64 x86_64

可以使用exec()来实现一个shell终端工具,它有一个主进程用来等待用户输入,然后当用户输入一个指令时,先fork()一个子进程然后在子进程中调用exec()来执行其他应用程序。