文章目录
- 一、进程创建
- 1.1 认识系统调用 fork
- 1.2 理解 fork 的返回值
- 1.3 写时拷贝策略
- 二、进程终止
- 2.1 main 函数的返回值
- 2.2 进程退出的几种情况(🌟)
- 2.3 进程退出码
- 2.4 终止正常进程:return、exit、_exit ⭐
- 2.5 站在 OS 角度:理解进程终止
- 三、进程等待
- 3.1 进程等待的必要性
- 3.2 如何「进程等待」:wait、waitpid 函数
- ① wait 函数
- ② waitpid 函数 ⭐
- 1) status 参数:
- 1、获取子进程的退出码
- 2、获取子进程的终止信号
- 3、代码实现:一个完整的进程等待 ⭐
- 2) options 参数:
- waitpid 的两种等待方式:阻塞 & 非阻塞
- 补充:如何理解阻塞/等待
- 补充:内核源码中的退出码和终止信号
- ③ 总结
- 四、进程的程序替换
- 4.1 前言
- 4.2 替换原理
- 4.3 如何替换:exec 系列函数
- ①execl 函数
- 命令行循环查看进程
- ② execv 函数
- ③ execlp 函数
- ④ execle 函数(用的很少)
- 五、如何编写Makefile文件使之形成多个可执行文件
一、进程创建
目前学习到的进程创建的两种方式:
- 命令行启动命令(程序、指令等) 。
- 通过程序自身,调用 fork 函数创建出子进程。
1.1 认识系统调用 fork
Linux 中的系统接口 fork 函数是非常重要的函数,它从已存在进程(父进程)中创建一个新进程(子进程):
#include<unistd.h>
pid_t fork(void); // 返回值:子进程中返回0,父进程中返回子进程id,出错返回-1
进程调用 fork,当控制转移到内核中的 fork 函数代码后,操作系统内核会做:
- 分配新的内存块和内核数据结构(task_struct)给子进程。
- (以父进程为模板)将父进程内核数据结构中的部分内容拷贝至子进程。
- 添加子进程到系统进程列表当中(因为进程要被调度和执行)。
- fork 函数返回后,开始调度器调度。
fork 的常规用法:
-
一个父进程希望复制自己,使父子进程同时执行不同的代码段。
例如:父进程等待客户端请求,生成子进程来处理请求。
-
一个进程要执行一个不同的程序。
例如:子进程从 fork 返回后,调用 exec 函数。
fork 调用失败的原因:
- 系统中有太多的进程,系统资源不足。
- 实际用户的进程数超过了限制。
1.2 理解 fork 的返回值
当一个进程调用 fork 之后,在不写入的情况下,用户的代码和数据是父子进程共享的。就有两个二进制代码相同的进程。子进程运行fork()下面的代码,但是父进程上面的代码也被子进程继承了,并且可以被子进程访问,但不推荐访问,看如下程序。
#include<stdio.h> // perror
#include<unistd.h> // getpid, getppid, fork
int main()
{
// ...
pid_t ret = fork(); // 返回时发生了写时拷贝
if (ret == 0) {
// child process
while (1) {
printf("child process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else if (ret > 0) {
// father process
while (1) {
printf("father process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else {
// failure
perror("fork");
}
return 0;
}
fork 之前父进程独立执行,fork 之后父子进程分别执行。注意:fork 之后谁先执行完全由调度器决定。
画图理解 fork 函数:
思考:
-
为什么 fork 有两个返回值,从而使父子进程进入不同的业务逻辑。为什么 fork 的返回值会返回两次呢?
父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。
也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。
-
返回值 ret 变量名相同,为什么会有两个不同的值呢?
变量名相同,有两个不同的值,本质是因为被映射到了不同的物理地址处。
-
为何要给子进程返回0,给父进程返回子进程的pid?
一个父进程可以有多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的。站在父进程立场,子进程需要标识,因为创建子进程是要执行任务的,父进程需要能区分子进程。
1.3 写时拷贝策略
当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
写时拷贝是一种延时操作的策略,为什么要有写时拷贝呢?写时拷贝的好处是什么?
- 为了保证父子进程的独立性!(数据各自私有一份)【为什么要有写时拷贝】
- 不是所有的数据,都有必要被拷贝一份(比如只读的数据)。写时拷贝可以节约资源,不写入时父子进程共享数据:父子进程对应的页表指向的是同一块物理内存 。
- fork 时,如果把所有的数据都拷贝一份,是需要花费时间的,降低了效率。写时拷贝可以提高 fork 执行的效率。
- fork 创建子进程本身就是向操作系统要资源,如果把所有的数据都拷贝一份,要更多的资源,更容易导致 fork 失败。写时拷贝可以减少 fork 失败的概率。
为什么不在创建子进程的时候就进行数据的拷贝?
- 子进程不一定会使用父进程的所有数据,并且在子进程不需要对数据写入修改时,没有必要对数据进行拷贝,子进程需要数据的时候,按需分配。延时分配:本质,可以高效使用任何内存空间。【进程不一定会立刻被调度,调度时再分配空间】
补充:代码基本不会写时拷贝,不代表不能。(进程替换)
二、进程终止
2.1 main 函数的返回值
我们在写 C/C++ 代码时,main 函数里面我们总是会返回 0,比如:
#include<stdio.h>
int main()
{
printf("hello world\n");
return 0;
}
思考:为什么 main 函数中总是会返回 0 ( return 0;
)呢?
- main 函数中的这个返回值叫做:「进程退出码」,用来表示进程退出时,其执行结果是否正确。
- 返回的 0 是给操作系统看的,来确认进程的执行结果是否正确。(0 通常表示成功)
用户可以通过命令 echo $?
查看最近一次执行的程序的「进程退出码」,比如:
[yzy@VM-4-4-centos day9]$ ./test
hello world
[yzy@VM-4-4-centos day9]$ echo $? # 查看最近一次执行的程序的退出码
0
2.2 进程退出的几种情况(🌟)
-
代码跑完,结果正确。(退出码:0)
-
代码跑完,结果不正确。(一般是代码逻辑有问题,但没有导致程序崩溃,退出码:非0)
-
代码异常终止。(这种情况下,退出码已经没有意义了,是由信号来终止,比如 ctrl+c)
代码异常终止 -》 程序崩溃 -》 退出码没有意义
2.3 进程退出码
父进程创建子进程的目的是为了让子进程给我们完成任务,父进程需要通过「子进程的退出码」知道子进程把任务完成的怎么样。
比如在生活中,网页打不开时,用户需要通过返回的一串错误代码得知网页出错的原因:
退出码可以人为的定义,也可以使用系统的错误码列表(错误码 (int) 与错误码描述 (string) 之间的映射表)
比如:C 语言库中提供一个接口strerror,可以把「错误码」转换成对应的「错误码描述」,程序如下:
#include<stdio.h>
#include<string.h> // strerror
int main()
{
for (int i = 0; i < 10; i++) {
printf("%d -- %s\n", i, strerror(i)); // char *strerror(int errnum);
}
return 0;
}
运行结果:
[yzy@VM-4-4-centos day9]$ ./test
0 -- Success
1 -- Operation not permitted
2 -- No such file or directory
3 -- No such process
4 -- Interrupted system call
5 -- Input/output error
6 -- No such device or address
7 -- Argument list too long
8 -- Exec format error
9 -- Bad file descriptor
注意:错误码描述不止有10个,这里放大 i 的取值范围可以看到更多对应的描述信息。
2.4 终止正常进程:return、exit、_exit ⭐
注意:
- 只有 main 函数中的 return 表示的是终止进程,非 main 函数中的 return 不是终止进程,而是结束函数,代表函数返回值。
- 在任何函数中调用 exit 函数,都表示直接终止该进程。
库函数:exit:
使进程退出,参数是退出码,在任意地方调用都是终止进程,exit 或者main return 本身就会要求系统进行缓冲区刷新!
#include <stdlib.h>//头文件
void exit(int status); // 终止正常进程
// 参数 status: 定义了进程的终止状态,父进程通过 wait 函数来获取该值
系统调用:_exit:
终止进程,强制终止进程,不进行进程的后续收尾工作,比如不刷新缓冲区。
#include <unistd.h>
void _exit(int status); // 终止正在调用的进程
系统调用接口 _exit
的功能也是终止正在调用的进程,它和库函数 exit
有什么区别呢?
- exit:在进程退出的时候,会进行后续资源处理(比如刷新缓冲区)。
- _exit:在进程退出的时候,不会进行后续资源处理,直接终止进程。
补充:
其实,库函数 exit
最后也会调用系统接口 _exit
,但在调用 _exit
之前,还做了其他工作:
- 执行用户通过 atexit 或 on_exit 定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用
_exit
。
2.5 站在 OS 角度:理解进程终止
站在操作系统角度,如何理解进程终止?
-
“ 释放 ” 曾经为了管理该进程,在内核中维护的所有数据结构对象(PCB)。
注意:这里的 “ 释放 ” 不是真的把这些数据结构对象销毁,即占用的内核空间还给 OS;而是设置成不用状态,把相同类型的对象归为一类(如进程控制块就是一类),保存到一个 “ 数据结构池 ” 中,凡是有不用的对象,就链入该池子中。
我们知道在内核空间中维护一个内存池,减少了用户频繁申请和释放空间的操作,提高了用户使用内存的效率,但每次从内存池中申请和使用一块空间时,还需要先对这块空间进行类型强转,再初始化。
现在有了这些 “ 数据结构池 ” ,比如:当创建新进程时,需要创建新的 PCB,不需要再从内存池中申请一块空间,进行类型强转并初始化,而是从 “ 数据结构池 ” 中直接获取一块不用的 PCB 覆盖初始化即可,减少了频繁申请和释放空间的过程,提高了使用内存的效率。
这种内存分配机制在 Linux 中叫做 slab 分配器。
-
释放程序代码和数据占用的内存空间。
注意:这里的释放不是把代码和数据清空,而是把占用的那部分内存设置成「未使用」就可以了。
-
取消曾经该进程的链接关系。
总结
进程退出,0S层面做了什么呢?
系统层面,少了一个进程,所以要 free PCB,free mm_struct,free页表和各种映射关系,代码 + 数据中请的空间也要给释放掉!
三、进程等待
3.1 进程等待的必要性
-
子进程退出,父进程还在运行,但父进程没有读取到子进程状态,就可能造成「僵尸进程」的问题,进而导致内存泄漏。
退出状态本身要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB) 中,换句话说,僵尸进程一直不退出,它对应的 PCB 就要一直维护。
-
另外,进程一旦变成僵尸状态,命令
kill -9
也无能为力,因为没有办法杀死一个已经死去的进程。 -
最后,父进程需要知道派给子进程的任务完成的如何。(如:子进程运行完成,运行结果对不对,有没有正常退出,还有根据进程退出信息制定出错时的一些策略)
思考:为什么要有进程等待?
-
获取子进程的退出信息,能够得知子进程执行结果。—— 不是必须的,需要就获取,不需要就不获取。
因为父进程需要知道派给子进程的任务完成的如何,有没有正常退出,还可以根据进程退出信息制定出错时的一些策略。
-
尽量保证父进程要晚于子进程退出,可以规范化的进行资源回收。—— 这是编码方面的要求,并非系统。
-
子进程退出的时候会先进入僵尸状态,造成内存泄露的问题,需要通过父进程wait,释放该子进程占用的资源(kill -9 杀不掉僵尸进程)
总结:父进程通过进程等待的方式:回收子进程资源,防止内存泄漏;获取子进程的退出信息。⭐
3.2 如何「进程等待」:wait、waitpid 函数
系统调用 wait
,waitpid
- 等待任意一个子进程改变状态,子进程终止时,函数才会返回。(其实就是等待进程由 R/S(运行/睡眠) 状态变成 Z(僵尸) 状态,然后父进程读取子进程的状态,操作系统回收子进程)
① wait 函数
#include <sys/types.h>//头文件
#include <sys/wait.h>//头文件
pid_t wait(int *status);
/*
* wait() 系统调用:暂停正在调用的进程的执行,直到它的一个子进程终止(状态变化)。
* 调用 wait(&status) 等价于 waitpid(-1, &status, 0);
*/
参数:
- status:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。
返回值:
- 返回一个pid_t类型,返回你等待成功并且终止的进程id,出错时,返回 -1。
👉 实例1:等待一个子进程
#include<stdio.h>
#include<stdlib.h> // exit
#include<sys/types.h> // getpid, getppid
#include<sys/wait.h> // wait
#include<unistd.h> // fork, sleep, getpid, getppid
int main()
{
pid_t cpid = fork();
if (cpid == 0) { // child process
int count = 5;
while (count) { // 子进程运行5s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit...!\n");
exit(1); // 终止子进程
}
else if (cpid > 0) { // father process
printf("father is waiting...\n");
pid_t ret = wait(NULL); // 等待子进程终止,不关心子进程退出状态
printf("father waits for success, cpid: %d\n", ret); // 输出终止子进程的pid
}
else { // fork failure
perror("fork");
return 1; // 退出码设为1,表示fork失败
}
return 0;
}
运行结果
👉 实例2:等待多个子进程
#include<stdio.h>
#include<stdlib.h> // exit
#include<sys/types.h> // getpid, getppid
#include<sys/wait.h> // wait
#include<unistd.h> // fork, sleep, getpid, getppid
int main()
{
for (int i = 0; i < 5; i++) { // 创建5个子进程
pid_t cpid = fork();
if (cpid == 0) { // child process
int count = 5;
while (count) { // 子进程运行5s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit!\n");
exit(0); // 终止子进程
}
else if (cpid < 0) { // fork failure
perror("fork");
return 1;
}
}
sleep(7); // 休眠7s
// 父进程进行进程等待
for (int i = 0; i < 5; i++) {
printf("father is waiting...\n");
pid_t ret = wait(NULL); // 等待任意一个子进程终止,不关心子进程退出状态
printf("father waits for success, ret: %d\n", ret); // 输出终止子进程的id
sleep(2);
}
printf("father quit!\n"); // 父进程退出
return 0;
}
运行结果:
可以看到子进程退出后,因为父进程在休眠,没有进行进程等待,子进程全部变成了僵尸进程,随着父进程进行进程等待,5 个僵尸进程被操作系统一一回收。
[yzy@VM-4-4-centos study]$ ./test child is running: 5s, pid: 19466, ppid: 19465 child is running: 5s, pid: 19470, ppid: 19465 child is running: 5s, pid: 19467, ppid: 19465 child is running: 5s, pid: 19468, ppid: 19465 child is running: 5s, pid: 19469, ppid: 19465 child is running: 4s, pid: 19466, ppid: 19465 child is running: 4s, pid: 19470, ppid: 19465 child is running: 4s, pid: 19467, ppid: 19465 child is running: 4s, pid: 19468, ppid: 19465 child is running: 4s, pid: 19469, ppid: 19465 child is running: 3s, pid: 19466, ppid: 19465 child is running: 3s, pid: 19470, ppid: 19465 child is running: 3s, pid: 19467, ppid: 19465 child is running: 3s, pid: 19468, ppid: 19465 child is running: 3s, pid: 19469, ppid: 19465 child is running: 2s, pid: 19466, ppid: 19465 child is running: 2s, pid: 19470, ppid: 19465 child is running: 2s, pid: 19467, ppid: 19465 child is running: 2s, pid: 19468, ppid: 19465 child is running: 2s, pid: 19469, ppid: 19465 child is running: 1s, pid: 19466, ppid: 19465 child is running: 1s, pid: 19470, ppid: 19465 child is running: 1s, pid: 19467, ppid: 19465 child is running: 1s, pid: 19468, ppid: 19465 child is running: 1s, pid: 19469, ppid: 19465 child quit! child quit! child quit! child quit! child quit! father is waiting... father waits for success, ret: 19466 father is waiting... father waits for success, ret: 19467 father is waiting... father waits for success, ret: 19468 father is waiting... father waits for success, ret: 19469 father is waiting... father waits for success, ret: 19470 father quit!
总结:一般而言,我们在 fork 之后,是需要让父进程进行进程等待的。⭐
上述例子,父进程只是等待子进程终止,并没有关心子进程的退出状态。
② waitpid 函数 ⭐
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
/*
* waitpid() 系统调用:暂停正在调用进程的执行,直到 pid 参数指定的子进程改变状态。
* 默认情况下,waitpid() 仅等待终止的子进程,但此行为可以通过 options 参数进行修改,如下所述。
*/
参数:
-
pid:代表你要等待的那个进程。
-
pid = -1,等待任意一个子进程,与 wait 等效。
-
pid > 0,等待其进程 ID 与 pid 相等的子进程,即传入进程 ID,等待指定的子进程。
思考下,fork 函数在父进程中返回子进程的 ID,是为什么呢?
为了方便父进程等待指定的子进程。
-
-
status:输出型参数,通过这个参数反馈子进程退出是哪一种情况,父进程拿到什么status结果,一定和子进程如何退出强相关!
- NULL:表示不关心子进程的退出状态信息。
- 宏函数
WIFEXITED(status)
:如果子进程正常终止,则返回 true。(查看进程是否是正常退出) - 宏函数
WEXITSTATUS(status)
:若WIFEXITED
非零,说明子进程正常终止,返回子进程的退出码。(查看进程的退出码)
-
options:
- 如果设为 0,默认是阻塞式等待,与 wait 等效。
- 如果设为
WNOHANG
:是非阻塞等待。w no hang- 若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。(说明这一次等待失败了,需要再次等待)
- 若正常结束,则返回该子进程的 ID。(说明等待成功了)
注意:wait(&status)
等价于 waitpid(-1, &status, 0)
。
返回值:
- 成功时,返回状态已更改的子进程 ID,
- 如果参数 options 指定了
WNOHANG
(非阻塞等待),并且存在一个或多个由参数 pid 指定的子进程,尚未更改状态,则返回 0,轮询检测。 - 出错时,返回 -1。
1) status 参数:
wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。
-
如果传递 NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
-
status 不能简单的当作整型来看待,可以当作位图来看待,具体细节如图(只研究 status 低16比特位):
status 变量:
注:一般进程提前(异常)终止,本质是该进程收到了操作系统发送的信号。
所以:
-
我们通过检测 status 参数的次低 8 位,可以得到该进程的退出码。
-
我们通过检测 status 参数的低 7 位,可以知道该进程是否被信号所杀,以及被哪个信号所杀。
信号是从 1 号开始的,没有 0 号。如果低 7 位全为 0,说明该进程一定是正常终止的,没有收到任何退出信号;如果 status 参数的低 7 位不为 0,说明该进程是被信号终止的。
-
core dump是指当程序出错而异常中断,为0代表没有异常
1、获取子进程的退出码
通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 次低 8 位的值,即子进程退出码:
(status >> 8) & 0xFF
。0xff == 00…1111 1111
比如下面代码:
#include<stdio.h>
#include<stdlib.h> // exit
#include<sys/types.h> // wait, getpid, getppid
#include<sys/wait.h> // wait
#include<unistd.h> // fork, sleep, getpid, getppid
int main()
{
pid_t cpid = fork();
if (cpid == 0) { // child process
int count = 5;
while (count) { // 子进程运行5s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit...!\n");
exit(123); // 终止子进程,退出码为123 👈
}
else if (cpid > 0) { // father process
int status = 0; // 进程退出状态
pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止
int exit_code = (status >> 8) & 0xff; // 计算子进程的退出码 👈
// 输出子进程id、退出码
printf("father waits for success, ret: %d, exit code: %d\n", ret, exit_code);
// 通过子进程退出码判断子进程把事情办的怎么样
if (exit_code == 0) printf("子进程把事情办成了!\n");
else printf("子进程没有把事情办成!\n");
}
else {
// fork failure
}
return 0;
}
运行结果:
父进程通过 waitpid 函数的 status 参数拿到了「子进程的退出码」。
思考两个问题:
-
为什么操作系统要通过 waitpid 函数的 status 参数把「子进程的退出码」反馈给父进程,而不是定义一个全局变量作为「子进程的退出码」,然后反馈给父进程呢?
因为用户数据被父子进程各自私有。
-
「子进程的退出码」是如何被填充到 waitpid 函数的 status 参数中的呢?
子进程的 task_struct 中保存的有子进程的退出信息,所以 wait/waitpid 函数通过子进程的 PCB 拿到退出码和终止信号,填充到 status 参数中。
2、获取子进程的终止信号
通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 低 7 位的值,即子进程终止信号:
status & 0x7F
。0xff == 00…0111 1111
如下代码:
int main()
{
pid_t cpid = fork();
if (cpid == 0) { // child process
int count = 5;
while (count) { // 子进程运行5s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit...!\n");
exit(123); // 终止子进程,退出码为123
}
else if (cpid > 0) { // father process
int status = 0; // 进程退出状态
pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止
int exit_code = (status >> 8) & 0xff; // 计算子进程的退出码
int sign = status & 0x7f; // 计算子进程的终止信号 👈
// 输出子进程id、退出码、终止信号
printf("father waits for success, ret: %d, exit code: %d, sign: %d\n", ret, exit_code, sign);
}
else {
// fork failure
}
return 0;
}
运行结果:
父进程通过 waitpid 函数的 status 参数拿到了「子进程的终止信号」。
3、代码实现:一个完整的进程等待 ⭐
一个完整的进程等待过程应该如何编写呢?
没改进之前的代码:
#include<stdio.h>
#include<stdlib.h> // exit
#include<sys/types.h> // wait, getpid
#include<sys/wait.h> // wait
#include<unistd.h> // fork, sleep, getpid
int main()
{
pid_t cpid = fork();
if (cpid == 0) { // child process
int count = 5;
while (count) { // 子进程运行5s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit...\n");
exit(123); // 终止子进程
}
else if (cpid > 0) { // father process
int status = 0; // 进程退出状态
pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止
// 判断父进程是否等待成功
if (ret > 0) { // waitpid返回值大于0,父进程等待成功
printf("father waits for success, ret: %d\n", ret); // 输出子进程id
// 判断子进程是否正常终止
if ((status & 0x7f) == 0) { // 子进程正常终止(终止信号为0)
// 输出退出码
printf("child process exits normally, exit_code: %d\n", (status >> 8) & 0xff);
}
else { // 子进程异常终止(终止信号不为0)
// 输出终止信号
printf("child process exits abnormally, sign: %d\n", status & 0x7f);
}
}
else {
// wait failure
}
}
else {
// fork failure
}
return 0;
}
运行结果:
注意:
每次都要这样判断子进程是否正常终止((status & 0x7f) == 0
),以及计算退出码((status >> 8) & 0xff
),太麻烦了,有没有什么更便捷的方法呢?
系统中定义了一堆的宏(函数),可以用来判断退出码、退出状态。
父进程中 waitpid 函数调用结束后,把它的第二个参数 status 传递给宏函数:
- 宏函数
WIFEXITED(status)
:如果子进程正常终止,则返回 true。(查看进程退出信号)w if exited - 宏函数
WEXITSTATUS(status)
:若 WIFEXITED 非零,说明子进程正常终止,返回子进程的退出码。(查看进程的退出码)w exit status
👉 实际中,一般都是使用宏函数来检测子进程的退出状态和获取子进程的退出码。
改进后的一个完整的进程等待:⭐
int main()
{
pid_t cpid = fork();
if (cpid == 0) { // child process
// do something
exit(123); // 终止子进程
}
else if (cpid > 0) { // father process
int status = 0; // 进程退出状态
pid_t ret = waitpid(-1, &status, 0); // 等待子进程终止
// 判断父进程是否等待成功
if (ret > 0) {
printf("father waits for success, ret: %d\n", ret);
// 判断子进程是否正常终止,判断退出信号👈
if (WIFEXITED(status)) { // 子进程正常终止
printf("child process exits normally\n");
printf("exit_code: %d\n", WEXITSTATUS(status)); // 输出退出码👈
}
else { // 子进程异常终止
printf("child process exits abnormally\n");
printf("pid: %d, sig: %d\n", ret, status & 0x7F); // 输出终止信号
}
}
else {
// wait failure
}
else {
// fork failure
}
return 0;
}
2) options 参数:
options:
- 如果设为 0,默认是阻塞式等待,与 wait 等效。
- 如果设为
WNOHANG
:是非阻塞等待。- 若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。(说明这一次等待失败了,需要再次等待,此时父进程可以去干别的事情)
- 若正常结束,则返回该子进程的 ID。(说明等待成功了)
waitpid 的两种等待方式:阻塞 & 非阻塞
- 阻塞等待(给 options 参数传 0)
- 非阻塞等待(给 options 参数传
WNOHANG
)
例子1:
张三做菜缺酱油,叫李四去买,相当于张三调了一个买酱油的函数,若李四还没回来,则函数就没结束,而李四在买酱油期间,张三一直被卡住,不继续做菜。这就是「阻塞等待」。
注意:我们目前的大多数接口,都是阻塞函数(调用 --> 执行 --> 返回 --> 结束),因为都是单执行流,同时实现起来也比较简单。
阻塞等待:调用方需要一直等着,不能做其他事情,直到函数返回。
例子2:
张三打电话问李四作业写完没,李四说没有,过了一会儿,张三又打电话问李四作业写完没,李四说没有……,张三多次打电话问李四作业写完没,直到李四作业写完,张三才会停止打电话。
上述例子的本质是,张三打电话不会把张三一直卡住,张三可以忙忙自己的事情,通过间隔多次打电话,检测李四的状态。张三每一次打电话,称之为「非阻塞等待」。多次打电话的过程,称之为「非阻塞轮询检测方案」。
为什么自然界一般选择非阻塞呢 —— 因为更加高效一些,不会一直卡在那里不做事。
非阻塞等待:调用方不需要一直等着,可以边轮询检测边做自己的事情。
进程的阻塞等待:
- 父进程中的 wait 和 waitpid 函数默认是阻塞调用,调用该函数后,只要子进程没有退出,父进程就得一直等,什么事情都做不了,直到子进程退出,函数才返回。
进程的非阻塞等待:
-
想让父进程中的 waitpid 函数是非阻塞调用(即父进程边运行边调用),需要将函数的第三个参数设为
WNOHANG
。这里的失败,有两种情况:
- 并非真的等待失败,而是子进程此时的状态没有达到预期。
- 真的等待失败了。
父进程中 waitpid 函数如果是非阻塞调用,返回值有三种情况:
- 等待失败:此次等待失败,需要再次检测。
- 等待失败:真的失败。
- 等待成功:已经返回。
代码实现:进程的非阻塞等待方式:⭐
#include<stdio.h>
#include<stdlib.h> // exit
#include<sys/types.h> // wait, getpid
#include<sys/wait.h> // wait
#include<unistd.h> // fork, sleep, getpid
int main()
{
pid_t cpid = fork(); // 创建子进程
if (cpid == 0) { // child process
int count = 3;
while (count) { // 子进程运行3s
printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
sleep(1);
}
printf("child quit...\n");
exit(123); // 终止子进程
}
else if (cpid > 0) { // father process
int status = 0; // 进程退出信息
while (1) {
pid_t ret = waitpid(cpid, &status, WNOHANG); // 进程等待 👈
if (ret == 0) { // 此次等待失败,需要再次等待 👈
sleep(1);
printf("wait next...\n");
printf("father do something...\n"); // do something
}
else if (ret > 0) { // 等待成功,输出子进程id和退出码
printf("wait for success, ret: %d, exit_code: %d\n", ret, WEXITSTATUS(status));
break;
}
else { // 等待失败
printf("waiting for the failure!\n");
break;
}
}
}
else {
// fork failure
}
return 0;
}
运行结果:
补充:如何理解阻塞/等待
-
如何理解进程等待:即父进程在等待子进程终止,而子进程在跑自己的代码。
-
如何理解进程在 “ 阻塞 / 等待 ”:阻塞是进程被卡住了,没有被 CPU 执行。
本质:操作系统将当前进程放入等待队列,并把进程状态设置为非 R(运行) 状态(S状态),暂时不会被 CPU 执行,当需要的时候,会唤醒等待(即把进程从等待队列移出,放回运行队列,并把进程状态设置为 R(运行) 状态,让 CPU 去调度)。
比如:我们电脑上运行的软件太多,发现某个软件卡起了,其实是当前运行队列中的进程太多,系统资源不足,把一些进程放入等待队列中了。
返回的本质:进程的PCB从等待队列拿到 运行(R)队列,从而被CPU调度
补充:内核源码中的退出码和终止信号
上面说到,父进程中的 wait/waitpid 函数通过子进程的 PCB 拿到退出码和终止信号,填充到 status 参数中。
进程控制块(PCB)中保存的退出码和终止信号:
struct task_struct {
...
/* task state */
int exit_state;
int exit_code, exit_signal; // 退出码和终止信号
int pdeath_signal; /* The signal sent when the parent dies */
...
}
比如:我们写的 main 函数,返回的 0 会被写入到该进程的 PCB 中的 exit_code 变量中。
int main()
{
// ...
return 0;
}
③ 总结
- 如果子进程已经退出,调用 wait / waitpid 时,wait / waitpid 会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用 wait / waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
四、进程的程序替换
4.1 前言
我们创建子进程的目的:if else 让子进程指向父进程代码的一部分!
如何让子进程执行一个全新的程序?
进程不变,仅仅替换当前进程的 代码 和 数据 的技术叫做进程的程序替换
思考:什么是进程替换?
通过 exec* 函数,把磁盘中的其它程序(代码+数据)加载到内存中,替换当前进程的代码和数据,让页表重新构建映射关系,这期间不会创建新的进程。
思考:为什么要进程替换?
因为创建子进程的目的一般是这两个:
- 执行父进程的部分代码,完成特定功能。
- 执行其它新的程序。——> 需要进行「进程替换」,用新程序的代码和数据替换父进程的代码和数据,让子进程执行。
拓展
程序本质就是一个文件 文件 = 程序代码 + 程序数据
文件在磁盘里,该过程当中没有创建任何新的程序,只是将新程序的代码和数据替换进去。
思考:操作系统是如何做到重新建立映射的呢?
- 操作系统可以对父进程的全部代码和数据进行写入,子进程会自动触发写时拷贝,开辟新的空间,再把磁盘中第三方程序的代码和数据写入到其中,子进程页表重新建立映射关系。
- 最终结果是:父进程指向自己的代码和数据,而子进程指向第三方程序的代码和数据。
思考:在进行程序替换的时候,有没有创建新的进程?
没有。进程的程序替换,不改变内核相关的数据结构,只修改部分的页表数据,将新程序的代码和数据加载带内存,重新构建映射关系,和父进程彻底脱离。
4.2 替换原理
用 fork 创建子进程后,执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用 exec 函数以执行另一个程序*。
- 当进程调用一种 exec 函数时,该进程的用户空间的代码和数据完全被磁盘中新程序的代码和数据替换,并更改页表的部分映射关系,但当前进程的内核相关的数据结构(PCB、地址空间等)不会发生改变。
- 从新程序的启动例程开始执行。
- 调用 exec 函数并不会创建新进程,所以调用 exec 函数前后,该进程的 id 并未改变。
- 这样我们就可以不用去创建新的进程,而直接将磁盘上的可执行程序加载到内存中,进行执行。
4.3 如何替换:exec 系列函数
有 6 种 exec 系列的库函数,统称为 exec 函数,功能:执行文件
#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[]);
系统调用 execve 函数,功能:执行文件名 filename 指向的程序,文件名必须是一个二进制的 exe 可执行文件。
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
其实,只有 execve 是真正的系统调用,其它 6 个函数都是库函数,最终都是调用的 execve,所以 execve 在 man 手册的第 2 节,其它函数在 man 手册第 3 节。
exec 函数命名理解,这些函数原型看起来很容易混,但只要掌握了规律就很好记:
- l (list):表示参数采用列表(可变参数列表)
- v (vector):参数采用数组
- p (path):自动在环境变量 PATH 中搜索可执行程序(不需要带可执行程序的路径)
- e (env):可以传入默认的或者自定义的环境变量给目标可执行程序
函数名 | 参数格式 —— l和v | 是否带路径 —— 与p有关 | 是否使用当前环境变量 —— 与e有关 |
---|---|---|---|
execl | 列表 l | 带 | 是 |
execlp | 列表 l | 不带 | 是 |
execle | 列表 l | 带 | 不是,需自己去传环境变量 |
execv | 数组 v | 带 | 是 |
execvp | 数组 v | 不带 | 是 |
execve | 数组 v | 带 | 不是,需自己去传环境变量 |
exec 函数解释:
- 这些函数如果调用成功,则加载新的程序从接口函数开始执行,不再返回。
- 如果调用出错则返回 -1。
- 所以 exec 函数只有出错的返回值而没有成功的返回值。
①execl 函数
execl 函数介绍:
#include <unistd.h>
/*
* path: 要执行程序的路径,路径中要包括程序名,比如:usr/bin/ls
* arg: 要执行的程序名/命令名
* ...: 可变参数列表,必须以NULL结尾,表示参数传入完毕
*/
int execl(const char *path, const char *arg, ...);
excel 第一个参数path:你要执行的目标程序的全路径:所在路径/文件名
第二个参数是可变参数列表,要执行的目标程序在命令行上怎么执行,这里参数就怎么 一个一个 的传递进去,必须以NULL作为参数传递的结束
execl 函数调用,举例如下(单个进程):
#include<stdio.h>
#include<unistd.h> // exec
int main()
{
printf("my process begin...\n");
execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 进程的程序替换
printf("my process end...\n");
return 0;
}
运行结果分析:
[yzy@VM-4-4-centos day10]$ ls -l -a total 60 drwxrwxr-x 2 yzy yzy 4096 Oct 1 10:39 . drwxrwxr-x 13 yzy yzy 4096 Oct 3 21:30 .. -rwxrwxr-x 1 yzy yzy 8664 Oct 1 10:36 execl -rw-rw-r-- 1 yzy yzy 796 Oct 3 21:31 execl.c -rwxrwxr-x 1 yzy yzy 8416 Oct 1 10:35 exec_test -rw-rw-r-- 1 yzy yzy 183 Oct 1 10:21 exec_test.c -rwxrwxr-x 1 yzy yzy 8560 Oct 1 10:39 execv -rw-rw-r-- 1 yzy yzy 574 Oct 1 10:39 execv.c -rw-rw-r-- 1 yzy yzy 183 Oct 1 10:39 Makefile
注意:没有打印出 “my process end…”,如果打印了,就说明函数调用失败
注意:
上述程序,因为只有一个进程,所以发生进程替换后,该进程自己就被替换了,不能去做自己的事情了。所以我们一般是让父进程创建子进程,让子进程通过进程替换,去执行其它程序,而父进程去检测执行结果和等待回收子进程。
#include<stdio.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> #include<stdlib.h> int main() { printf("my process begin...\n"); pid_t id = fork(); if(id == 0)//child { printf("running, I am child,pid:%d\n",getpid()); execl("/usr/bin/pwd","pwd",NULL); printf("end...\n"); exit(1); } else if(id > 0)//parent { printf("running, I am father, pid:%d\n",getpid()); int status = 0; pid_t ret = waitpid(id,&status,0); if(ret > 0) { //等待成功,ret是子进程id printf("father waits for success,ret:%d,exitcode:%d,sign:%d\n", ret, (status>>8)&0xff,status&0x7f); } else { printf("father wait failed!\n"); } } else { printf("fork failed!\n"); } return 0; }
运行结果:
命令行循环查看进程
while : ; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep; sleep 1; echo “###############”; done
总结:
-
调用 exec 函数,不用考虑当前进程的返回值,因为 exec 函数下面的代码不会被执行(因为当前进程的代码和数据已经被替换了),意味着exec*函数,成功的时候,不需要返回值检测!所以如果当前进程返回了,则说明 exec 函数调用失败了。
-
exec 函数有点像特殊的加载器,把程序的代码数据加载到内存中,然后执行。
-
为什么子进程调用execl不影响父进程的代码?
进程具有独立性!子进程要修改代码区的代码就要发生写时拷贝
② execv 函数
在功能上和 execl 没有任何区别,只在传参的方式上有区别。
// ...
int main()
{
pid_t cpid = fork();
if (cpid == 0) {
// child
printf("I'm child process, pid: %d\n", getpid());
char* const my_argv[] = { // 字符指针数组
"ls",
"-l",
"-a",
NULL
};
execv("/usr/bin/ls", my_argv); // 进程替换
exit(1);
}
else if (cpid > 0) {
// father
// ...
}
else {
// fork failure
}
return 0;
}
③ execlp 函数
在功能上和 execl 没有任何区别,唯一区别是,只需要给出要执行程序的名称即可,自动去 PATH 中搜索,不需要给出绝对路径。
但是:只有系统的命令,或者自己的命令(前提是已经导入到 PATH 中了),才能够找到。
// ...
int main()
{
pid_t cpid = fork();
if (cpid == 0) {
// child
printf("I'm child process, pid: %d\n", getpid());
execlp("ls", "ls", "-l", "-a", NULL); // 进程替换
exit(1);
}
else if (cpid > 0) {
// father
// ...
}
else {
// fork failure
}
return 0;
}
④ execle 函数(用的很少)
函数介绍:
/*
* 调用 execle 或 execve 函数进行进程替换(执行 xxx 程序)时,可以把在当前程序中定义的环境变量传递给要替换的程序 xxx,此时在 xxx 程序中通过 getenv 就可以获取到这些环境变量
*/
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
示例:
/* my_cmd.c */
int main()
{
// 获取环境变量
printf("my_cmd process is running, getenv --> MYENV: %s\n", getenv("MYENV"));
return 0;
}
如果单独执行 my_cmd 程序,运行结果为空,系统中没有这个环境变量:
my_cmd process is running, getenv --> MYENV: (null)
如果在 exec_cmd 程序中,调用 execle 函数进行进程替换(执行 my_cmd 程序)时,可以把在 exec_cmd 程序中定义的环境变量通过传递给要替换的 my_cmd 程序,如下:
/* exec_cmd.c */
int main()
{
pid_t cpid = fork();
if (cpid == 0) {
// child
printf("I'm child process, pid: %d\n", getpid());
char* const my_env[] = { // 定义环境变量MYENV
"MYENV=hello world!",
NULL
};
/*
* 通过进程替换,执行my_cmd程序,同时把定义的环境变量传递给了my_cmd程序
* 这样我们执行my_cmd程序,就可以获取到环境变量MYENV了
*/
execle("./my_cmd", "my_cmd", NULL, my_env); // 进程替换
exit(1);
}
else if (cpid > 0) {
// father
// ...
}
else {
// fork failure
}
return 0;
}
运行 exec_cmd 程序,进行进程替换(执行 my_cmd 程序),发现在 my_cmd 中获取到了环境变量:
my_cmd process is running, getenv --> MYENV: hello world!
回答一个问题:环境变量具有全局属性,可以被子进程继承,那么它是如何做到被子进程继承的呢?
所有进程在运行的时候,会自动通过 execle 函数执行新程序的时候,把系统的环境变量传给了新程序。
my_cmd process is running, getenv --> MYENV: hello world!
回答一个问题:环境变量具有全局属性,可以被子进程继承,那么它是如何做到被子进程继承的呢?
所有进程在运行的时候,会自动通过 execle 函数执行新程序的时候,把系统的环境变量传给了新程序。
五、如何编写Makefile文件使之形成多个可执行文件
一次性形成两个(多个)目标程序的 Makefile 文件编写:
.PHONY:all # 定义伪目标 all
all:my_cmd exec_cmd # 依赖项,all 依赖于 my_cmd exec_cmd 这两个目标程序
# 然后根据依赖关系,会形成 my_cmd exec_cmd 这两个目标程序
# 最后再来形成 all,但因为 all 没有依赖方法,
my_cmd:my_cmd.c
gcc -o $@ $^ -std=c99
exec_cmd:exec_cmd.c
gcc -o $@ $^ -std=c99
.PHONY:clean # 定义伪目标,clean总是可以被执行的
clean: # 依赖项为空
rm -f exec_cmd my_cmd # 依赖方法
执行 make 命令,可以看到,形成了两个目标程序:
[yzy@VM-4-4-centos day10]$ make
gcc -o my_cmd my_cmd.c -std=c99
gcc -o exec_cmd exec_cmd.c -std=c99