序言
在前两篇文章中都使用到了名为 fork
的函数,我们简单地介绍了他可以创建一个子进程。所以,在这篇文章中,除了进程的创建,还会介绍进程的退出,进程的等待,进程的替换等内容,帮助大家更好地去了解进程的控制😊。
1. 进程的创建
1.1 简述 fork
函数的使用
我们可以使用 fork
函数创建一个子进程,该函数不需要参数,若创建成功,会返回两个返回值:
- 在父进程中:
fork
返回新创建的子进程的进程ID(PID)
。 - 在子进程中:
fork
返回0
。 - 如果
fork
调用失败,则在父进程中返回-1
,并设置全局变量errno
以指示错误原因。
举个栗子:
1 #include <stdio.h>2 #include <unistd.h>3 4 int main(){5 int val = 1;6 pid_t pid = fork();7 8 // 创建子进程失败9 if(pid < 0){10 printf("Failed to create child process.");11 return 1;12 }13 // 子进程14 else if(pid == 0){15 val += 1;16 printf("I am child process, pid is %d, val = %d, &val = %p.\n", getpid(), val, &val);17 }18 // 父进程19 else{ 20 printf("I am parent process, pid is %d, val = %d, &val = %p.\n", getpid(), val, &val);21 }22 23 return 0;24 }
注: 在上一篇文章中,我们详细介绍了该函数为何会存在两个返回值。
👉 点击查看
1.2 简述子进程创建过程
- 复制进程:
fork
函数会复制当前进程的上下文来创建一个新的进程。这个复制过程包括进程的PCB
内的部分信息、虚拟地址空间(写时复制
)、页表、文件描述符、环境变量等。 - 共享与独立:虽然子进程是父进程的复制品,但两者在操作系统中被视为独立的进程,拥有各自的进程
ID(PID)
。它们各自独立地执行程序,且可以通过不同的返回值来区分是父进程还是子进程。
看到这里有些同学可能会觉得矛盾😟,进程之间怎么会独立又共享呢,这不是矛盾吗?这是因为,实质上这是进程在不同方面的特性,就比如我们从两个角度来看待:
独立
:
- 数据独立:父子进程之间采用写时拷贝,一方的改变并不会影响另一方的数据。
- 地址空间独立:每个进程都拥有自己独立的地址空间,确保了进程之间的互不干扰,提高了系统的稳定性和安全性。
- …
共享
:
- 环境变量:子进程在创建时会继承父进程的环境变量(除非显式修改)。环境变量提供了一种在进程间共享配置信息的方式。
- 代码:代码本身在运行时就是不可被修改的,所有父子进程共享一份代码资源。
- …
2. 进程的退出
大家在平时编程的过程中,是不是习惯于在 main函数
的结尾处 return
一个值,这个值通常都是 0 / 1
,大家有想过这是干什么的吗,有什么意义吗?
2.1 程序运行完毕
当程序运行完毕时(通俗的说就是代码跑完了),最终只有两种情况:
- 代码确实是按照我们的计划执行的,这时返回
0
- 代码执行的逻辑不正确,不是预期的,这时返回
非零的数
举个栗子:
1 #include <stdio.h>2 3 int main(){4 int A, B;5 printf("请输入分子分母:");6 scanf("%d %d", &A, &B);7 8 // 正常执行9 if(B != 0){10 printf("A / B = %d.\n", A / B);11 return 0;12 }13 // 不合法输入14 else{15 printf("分母不能为0 !!!\n"); 16 return 1;17 }18 }
2.2 程序异常终止
当我们执行程序时,程序会受到多种因素的影响导致产生异常终止程序,就来一个最熟悉的场景:
1 #include <stdio.h>2 3 int main(){4 5 int *ptr = NULL;6 int a = *ptr; 7 8 return 0;
}
这段程序就不会被执行完,当他对 NULL
进行操作时,就被检测出异常,导致程序的终止。
那这时,程序就没有任何的返回信息吗?不是的,系统会返回一个终止信号,告诉你异常的原因。
2.3 程序的退出信息
总结一下:一个程序的执行无外乎三种情况:
- 有异常:直接退出
- 无异常:执行正确
- 无异常:执行错误
那怎么来描述呢?是采用的一个 unsigned int
就够了:
高 8 位描述的是退出信号,低 7 位描述的是退出码,中间一位我们不涉及。
该值的取值规则是:
- 有异常:高 8 位为异常信息,低 8 位无效置 0
- 无异常:高 8 位无效置 0,低 7 位为退出信息
3. 进程的等待
3.1 为什么要等待?
之前我们提过,当子进程执行结束时,父进程一定要读取子进程的退出信息,回收子进程资源,避免造成僵尸进程的产生。
之前我们提到过,为什么非要读取子进程的退出信息才让他退出。这是因为,我们需要得到子进程的执行情况,到底是执行完啦,还是跑一半就挂啦?执行完了,是对的呢?还是错的呢?我们都需要知道。
3.2 waitpid
函数介绍
现在先介绍这个函数 pid_ t waitpid(pid_t pid, int *status, int options);
返回值
:
- 当正常返回的时候
waitpid
返回收集到的子进程的进程ID
; - 若子进程还未返回正在执行,返回
0
- 如果调用中出错,则返回
-1
,这时errno
会被设置成相应的值以指示错误所在;
参数
:
pid
:Pid = -1
,等待任一个子进程。Pid > 0
.等待其进程ID
与pid
相等的子进程。
status
:
WIFEXITED(status)
: 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status)
: 若WIFEXITED
非零,提取子进程退出码。(查看进程的退出码)
options
(等待方式):
WNOHANG
: 若 pid
指定的子进程没有结束,则waitpid()函数返回 0
,不予以等待。若正常结束,则返回该子进程的 ID
。
3.1 阻塞式等待 options = 0
该方式最为简单,父进程就什么也不做,只是等待子进程的执行状态。当子进程执行结束时,立马就把他回收了。
场景带入:就像你等着抄你同桌的作业,你一直问追问你同桌,你写好了吗?你写好了吗?你写好了吗?当你同桌说写好了的一刹那,你立马就将他的作业拿了过来。
所以说,进程阻塞可以理解为等待资源或者是等待某种状态发生。
代码实例:
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/wait.h>4 5 int main(){6 // 创建子进程7 pid_t pid = fork();8 9 // 创建失败10 if(pid == -1){11 printf("Failed to create child process.");12 }13 // 子进程14 else if(pid == 0){15 int cnt = 5;16 while(cnt--){17 printf("I am child process, pid is %d, cnt is %d.\n", getpid(), cnt);18 sleep(1);19 }20 21 return 0;22 23 }24 // 父进程25 else{26 int status = 0;27 // 阻塞式等待28 pid_t rid = waitpid(pid, &status, 0);29 // 等待成功并且退出信号无异常30 if(rid > 0 && WIFEXITED(status)){31 printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));32 }33 else{34 printf("waited child process failed!\n");35 return 1; 36 }37 }38 39 return 0;40 }
这段代码的逻辑可以看作是:
- 创建子进程,判断是否创建成功
- 子进程每隔一秒打印内容,一共执行五秒退出
- 父进程阻塞等待子进程的退出信息
- 当子进程退出时,查看相应的退出信息
- 程序执行完毕,退出
3.2 非阻塞等待 options = WNOHANG
这个方式也不难理解,父进程获取到子进程还在执行的信息时,不需要持续等待,而是去执行其他任务,当任务完成时,再次回来查看子进程的状态,直到子进程执行完成。
代码实例:
24 // 父进程25 else{ 26 int status = 0;27 pid_t rid = 0;28 // 非阻塞式等待29 while(!rid){30 rid = waitpid(pid, &status, WNOHANG);31 DoOtherThings();32 }33 // 等待成功并且退出信号无异常 34 if(rid > 0 && WIFEXITED(status)){ 35 printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status)); 36 } 37 else{ 38 printf("waited child process failed!\n"); 39 return 1; 40 } 41 }
主要的变化在父进程里,可以看到,当 rid == 0
(子进程还在执行)时,父进程执行其他的事。
4. 进程的替换
在上述所有举的例子中,子进程和父进程都是执行的同一个代码,只是不同的分支选择,那有没有一个办法让子进程去执行一个新的程序呢?
肯定是有的,先带着大家看一个现象:
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/wait.h>4 5 6 int main(){7 printf("Program begin ... \n");8 execl("/usr/bin/ls", "ls", "-a", NULL); 9 printf("Program end ... \n");10 return 0;11 }
这段程序的输出结果是:
Program begin …
. … Main.exe makefile Test.cpp
我们可以得出以下现象:
- 程序执行了第一个
printf
- 程序执行了
ls -a
的指令 - 程序没有执行最后一个
printf
这里就不让大家猜发生了哈,我们直接上结论: 执行的程序被 execl函数
内的内容替换了。程序都被替换了,自然原来程序后面的内容自然也不会执行了。
4.1 替换的原理
当进程调用 execl
一类的函数时,该进程的用户空间代码
和数据
完全被新程序替换,从新程序的启动例程开始执行。调用 execl 并不创建新进程
,所以调用 execl
前后该进程的 id
并未改变。
再次强调:并没有创建新的进程,而是替换了原本进程中的数据和代码!!!
4.2 替换函数的介绍
一共六种以 exec
开头的函数,统称 exec
函数:
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 execve(const char *path, char *const argv[], char *const envp[]);
注意:该类函数执行失败返回 -1,执行成功不返回
别看着这么复杂,只要我们掌握其中几个比较典型的那么就没啥问题,触类旁通嘛。
在这里我先总结一下,其实他们的思路都是差不多的,只是表达形式不同罢了。他们的思路都可以概括为:
1. execl
该函数需要我们传递该程序的 地址,执行方式
,举个栗子:
execl("/usr/bin/ls", "ls", "-a", NULL);
在这里我们想要执行以 ls -a
的方式执行命令,首先我给出了该指令的地址,其次就是执行的方式,最后以 NULL
结尾告诉函数我的参数就这么多。你可以根据你的需要任意增删参数项,来调整你的执行方式,如:
execl("/usr/bin/ls", "ls", "-al", NULL);
2. execlp
该函数和上一个的唯一区别是无需写全你的指令路径,若你的指令是存在于环境变量中的话,因为它会自动在环境变量里搜索你的指令:
execlp("ls", "ls", "-a", NULL);
3. execlv
该函数需要我们传递地址,并且以数组的方式传递执行方式:
7 char* const argv[] =8 {9 "ls",10 "-a",11 "-l",12 NULL13 };14 15 16 execlv("/usr/bin/ls", argv);
4.3 总结
我们可以根据函数末尾的字母来掌握规律:
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
5.总结
😃在这篇文章中主要向大家介绍了进程的控制方式,希望大家有所收获。