进程控制-前篇
一.进程创建
1.1 fork 函数
在Linux中,fork()
函数用于从一个已存在的进程中创建一个新进程。新进程称为子进程,而原进程称为父进程。fork()
的实现涉及多个关键步骤,以下是详细的描述和代码示例:
fork()
的实现步骤:
分配新的内存块和内核数据结构给子进程:
内核为子进程分配一个新的
task_struct
结构,用于存储子进程的上下文信息。分配新的
mm_struct
结构,用于管理子进程的虚拟地址空间。将父进程部分数据结构内容拷贝至子进程:
复制父进程的虚拟地址空间。Linux内核使用写时复制(Copy-On-Write, COW)技术来优化这一过程。初始时,父进程和子进程共享相同的物理页面,只有当某个进程试图修改页面时,内核才会为该进程创建一个新的物理页面副本。
复制父进程的文件描述符表、信号处理信息等。
添加子进程到系统进程列表当中:
将子进程的
task_struct
结构添加到系统的进程列表中,以便调度器可以对其进行调度。
fork
返回,开始调度器调度:
在子进程中,
fork()
返回 0。在父进程中,
fork()
返回子进程的进程ID(PID)。如果发生错误,
fork()
返回 -1,并设置errno
以指示错误类型。
当⼀个进程调⽤fork之后,就有两个⼆进制代码相同的进程。⽽且它们都运⾏到相同的地⽅。但每个进程都将可以开始它们⾃⼰的旅程,看如下程序。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
pid_t pid;
printf("Before: pid is %d\n", getpid()); // 父进程会执行这行代码,但是子进程没有,因为代码是执行下去的,已经到此分流了。
if ((pid = fork()) == -1) {
perror("fork()");
exit(1);
}
printf("After: pid is %d, fork return %d\n", getpid(), pid); // 父进程和子进程都会执行这行代码
sleep(1);
return 0;
}
Before: pid is 43676
After: pid is 43676, fork return 43677
After: pid is 43677, fork return 0
这⾥看到了三⾏输出,⼀⾏before,两⾏after。进程43676先打印before消息,然后它有打印after。另⼀个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示:
关键点:
-
写时复制(COW):
fork()
使用COW技术优化内存拷贝,减少了内存使用并提高了性能。 -
进程列表:子进程的
task_struct
被添加到系统进程列表中,以便调度器可以对其进行调度。 -
返回值:
fork()
在子进程中返回0,在父进程中返回子进程的PID。
通过这些机制,fork()
实现了高效的进程创建和资源管理。
1.2写时拷贝的基本原理
这幅图和描述展示了Linux操作系统中写时复制(Copy-On-Write,COW)技术在进程间共享内存时的工作原理,特别是在使用fork()
创建子进程时。
修改内容之前:
-
父进程和子进程共享相同的虚拟内存区域,包括数据段和代码段。
-
两个进程的页表都指向相同的物理内存页。
-
页表项标记为“只读”,表示这些内存区域不能被修改。
修改内容之后:
-
当父进程或子进程尝试修改共享的内存页时,操作系统会触发写时复制机制。
-
操作系统为修改内存页的进程创建一个新的物理内存页副本。
-
修改操作只影响该进程的页表,使其指向新的物理内存页,而另一个进程的页表仍然指向原来的物理内存页。
-
这样,两个进程就有了独立的物理内存页副本,可以独立地进行修改。
我们上图修改内容之前的数据和代码是只读的原因是为了:
防止意外修改:代码段包含程序的指令,这些指令在运行时不应该被修改,以避免潜在的安全风险和程序错误。将代码段设置为只读可以防止程序意外或恶意地修改自身的指令。
内存共享优化:在多进程环境中,尤其是父子进程之间,很多数据和代码在一开始是共享的。通过将这些共享资源设置为只读,操作系统可以确保所有进程都看到相同的数据副本,直到某个进程需要修改数据。这种共享减少了内存的使用,因为只有当进程需要修改数据时,才会创建数据的副本。
但其实在单进程中,默认是代码只读就行。
写时拷贝的触发条件是:当一个进程尝试修改一个原本被标记为只读的内存页时。在fork系统中,父子进程最初共享相同的物理内存页,页表项被设置为只读。一旦父子进程中的任何一个试图写入这个共享的内存页,就会触发一个页错误,操作系统随后会创建该内存页的一个副本,并为引起写时拷贝的进程分配新的页表项,从而保证进程间的隔离和数据的独立性
过程:
父进程创建子进程时,子进程的页表项指向与父进程相同的物理内存页,这些页表项被标记为只读(read-only)。当任一进程(父或子)尝试对这些共享的只读页面进行写操作时,CPU会触发一个页错误(page fault)。操作系统的内存管理单元(MMU)捕获到页错误,并将其传递给内核进行处理。内核识别出页错误是由写时拷贝机制引起的,它会为发起写操作的进程创建一个新的物理内存页。内核将原始共享页面的数据复制到新分配的物理内存页中。这个操作确保了数据的一致性,并且允许修改。内核更新发起写操作的进程的页表项,使其指向新创建的物理内存页,并将该页表项的权限修改为可读写(read-write)。页错误处理完成后,内核恢复进程的执行,此时进程可以继续对新页面进行写操作,而不会影响其他进程的只读副本。未发起写操作的进程(如另一个进程或父进程)的页表项仍然指向原始的只读物理内存页,保持不变。
写时复制的优势:
-
节省内存:在没有进程尝试修改共享内存之前,不需要为每个进程分配独立的物理内存页,从而节省了内存资源。
-
提高效率:写时复制是一种延时申请技术,只有在实际需要时才分配额外的内存资源,提高了内存的使用效率。
-
进程独立性:通过写时复制,父子进程在修改共享内存时可以保持独立,互不影响,从而实现了进程的独立性和数据隔离。
1.3 fork 常规用法
我们创建一个子进程,一定是希望这个子进程帮助我们去完成某种事情的:
- ⼀个⽗进程希望复制⾃⼰,让子进程帮助自己完成自己代码的一部分,通过if-else进行分流,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客户端请求,⽣成⼦进程来处理请求。
- ⼀个进程要执⾏⼀个不同的程序。例如我们执行命令行命令的时候,我们其中的命令。启动自己写的程序,本质上就是一个进程,这个进程的父进程就是bash,所以当我们执行命令行的时候,我们就相当于bash进程创建了一个子进程,但是创建这个子进程不是执行bash的,而是为了执行我们新启动的命令或者程序的。例如⼦进程从fork返回后,调⽤exec函数。
1.4 fork 创建失败的原因
一个进程=内核数据结构+自己的代码和数据,进程创建失败的原因无非就两种:
操作系统创建内核数据结构失败了:PCB,虚拟地址空间还有页表对应的物理内存申请不出来了,因为毕竟我们的PCB,虚拟地址空间还有vm_areas_struct,包括页表,这些东西就是数据结构对象,是需要开辟空间的,只不过这批数据结构,这些结构体对象是在我们操作系统内定义的,他开辟失败了,另一个就是加载内存的时候失败了:比如说加载代码和数据的时候失败了。这些原因都是因为空间不足。我们一般是遇不到的,但实际上,操作系统在将要内存不足的时候,为了保证自己不要出现过多的内存不足,操作系统一般会在系统层面上和用户层面上会限制进程的数量:
- 系统中有太多的进程
- 实际⽤⼾的进程数超过了限制
二.进程终止
到这我们已经知道了,创建一个进程就是在操作系统内多了一个进程,多了一个进程就是多了PCB,虚拟地址空间,页表,甚至未来还要多代码和数据,因为要加载新的程序。可是当一个进程终止时,在做什么呢?
其实我们逆向想想就可以了:进程终⽌的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
我们之前学习过一种进程状态是僵尸状态,所以僵尸进程一旦终止时,他的PCB会维持起来,方便我们去获取该进程的退出信息。
2.1进程退出的场景
一个进程退出无非就三种情况:
- 代码运⾏完毕,结果正确:程序正常执行,逻辑正确,输入数据正确,最终得到了预期的结果。
- 代码运⾏完毕,结果不正确:程序正常执行,但逻辑有误或输入数据不符合预期,导致结果不符合预期。程序运行完毕,但结果不正确。
- 代码异常终⽌:程序在运行过程中遇到无法处理的错误或异常情况,导致程序非正常终止。这包括运行时错误(如除以零、访问非法内存等)。
2.2进程的退出方法以及相关概念
2.3.1正常终止:从main函数返回
在C语言或C++中,main
函数是一个特殊的函数,它是程序的入口点。main
函数的返回值是一个整数,通常用于向操作系统报告程序的执行状态。
当执行
return
语句时,程序会将返回值存储到特定的寄存器(如 x86 架构中的EAX
或RAX
),然后执行返回指令(如ret
),恢复调用者的上下文(包括指令指针和栈指针),并跳转到调用者的代码位置继续执行。这个过程确保了函数的返回值能够正确传递给调用者,同时恢复程序的执行流程。(我们默认不写return,在语言层面上,对于整型类型的返回,C/C++是默认可以返回整数的)
-
代码运行完毕,结果正确:返回值为
0
。
-
代码运行完毕,结果不正确:返回值为非零值(如
1
),表示程序运行完毕但结果不正确。
int main() {
int a = 5, b = 3;
int sum = a - b; // 错误的逻辑,应该是加法
printf("Sum: %d\n", sum); // 输出错误结果
return 1; // 返回非零值,表示结果不正确
}
我们可以使用不同的非0返回值来表明不同的出错原因!比如返回1是因为权限不够,返回2表明文件不存在等等。
我们有很多进程是没有打印结果的,比如说文件操作:
#include <stdio.h>
int main()
{
//printf("hello world\n");
//文件操作
FILE *fp = fopen("log.txt", "r");
if(fp == NULL)
{
return 1;
}
//读取
fclose(fp);
return 0;
}
我们观察到:执行之后是没有任何直接的有效信息的:
我们都不知道我们是否可以打开文件进行" r "操作。
实际上当我们的 ./proc 程序运行时,我们main函数的返回值一般都是返回给父进程的,如何去查看我们proc程序退出时的退出数字,我们可以:
echo $?
在 Linux 系统中,$?
是一个特殊的环境变量,用于保存上一个命令的退出状态。当执行 echo $?
命令时,它会输出上一个命令的返回值。如果返回值是 0
,表示上一个命令执行成功;如果返回值是非零值,则表示上一个命令执行过程中出现了错误。
例如:
ls -l
echo $?
如果 ls -l
命令执行成功,echo $?
将输出 0
;如果 ls -l
命令执行失败(例如文件不存在),echo $?
将输出一个非零值。
我们就可以知道我们刚刚执行的proc进程的返回情况了:
这种机制在编写脚本时非常有用,可以用来检查命令是否成功执行,并据此进行后续操作。
我们main函数的返回值就是进程退出码,这个进程退出码是会写到该进程的task_struct内部的:
在 Linux 系统中,
main
函数的返回值作为进程的退出码,会被存储在进程的task_struct
中。task_struct
是 Linux 内核中用于描述进程的控制块(PCB),它包含了进程的所有信息,包括进程状态、退出代码、退出信号等。当进程退出时,它的退出码会被记录在
task_struct
的exit_code
成员中。这个退出码随后可以被父进程通过wait
或waitpid
系统调用获取。父进程的task_struct
中并不直接存储子进程的退出码,而是通过wait
类型的系统调用从子进程的task_struct
中读取。(从僵尸进程的PCB读)因此,
main
函数的返回值(即进程退出码)是存储在该进程自己的task_struct
中的,而不是父进程的task_struct
中。
说到退出码,我们来具体谈谈 :
穿插概念:退出码
实际上我们系统已经给我们弄了一套退出码和退出码字符串的描述,计算机里面往往在处理错误的时候,喜欢使用0,1,2,3...这样的数字来表明进程出错和出错原因,但是我们用户读数字不方便直接看出表明的意义,我们更偏向读字符串,所以在C标准库当中,其实也为我们提供了错误码和错误码对应的字符串描述,可以使用如下转化关系:
#include <string.h>
char *strerror(int errnum);
strerror
函数接受一个错误码作为参数,并返回一个描述该错误的字符串。
我们通过下面代码可以知道错误码和错误原因是什么:
int main()
{
int i = 0;
//我们不知道Linux当中的错误码有几个,我们就直接给200个,不够再加
for( ;i < 200; i++)
{
printf("%d->%s\n", i ,strerror(i));
}
return 0;
}
我们./proc运行发现:
我们133往后就都是Unknow了,所以在Linux当中总共有134个【0,133】错误码。
我们man手册产看fopen可以发现:
所以,我们可以不适用return 1,而是:
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
//文件操作
FILE *fp = fopen("log.txt", "r");
if(fp == NULL)
{
return errno;
}
//读取
fclose(fp);
return 0;
}
退出码2就是打开文件或目录失败,我们当让也可以按照自己的意愿来自定自己的退出码,比如我就是要return 13;
在 Linux 中,退出码(Exit Code)是一个整数值,用于表示程序或命令执行后的状态。退出码的范围是 0 到 255,其中 0 表示成功,非 0 表示失败或某种错误。以下是一些常见的退出码及其含义:
退出码 | 解释 |
---|---|
0 | 命令成功执行 |
1 | 通用错误代码 |
2 | 命令(或参数)使用不当 |
126 | 权限被拒绝(或)无法执行 |
127 | 未找到命令,或 PATH 错误 |
128+n | 命令被信号从外部终止,或遇到致命错误 |
130 | 通过 Ctrl+C 或 SIGINT 终止(终止代码 2 或键盘中断) |
143 | 通过 SIGTERM 终止(默认终止) |
255/* | 退出码超过了 0-255 的范围,因此重新计算(超过 255 后,用退出取模) |
-
退出码 0 表示命令执行无误,这是完成命令的理想状态。
-
退出码 1 我们也可以将其解释为“不被允许的操作”。例如在没有 sudo 权限的情况下使用 yum;再例如除以 0 等操作也会返回错误码 1,对应的命令为
let a=1/0
。 -
130 (SIGINT 或 ^C) 和 143 (SIGTERM) 等终止信号是非常典型的,它们属于 128+n 信号,其中 n 代表终止码。
-
可以使用
strerror
函数来获取退出码对应的描述。
-
代码异常终止:返回值不确定,通常由操作系统决定。程序可能没有机会返回值。
下面我们来验证一下:看如下代码:
#include <stdio.h>
int main()
{
int a = 10;
a /= 0;
return 89;
}
就是一旦出现异常,那么退出码也就没有意义了。一般如果进程出现异常,为什么还能够退出呢?主要原因是:进程一旦出现异常(除零,越界,野指针,字符常量区进行写入...),一般是进程收到了信号:这个结论暂且反放这,等我们接下来谈到进程等待在拉出来谈谈,进程信号还没有真正认识到,但是我们使用过kill -9命令来杀死进程。
我们一个进程如果要退出,第一种退出方法就是main函数里return,main函数return就是main函数结束,main函数结束就是进程结束!!!其他函数return,只是表明自己函数调用完成,返回。
2.3.1正常终止:调用exit()
我们进程退出,除了从main函数里return,我们还可以在代码的任意地方调用 exit() 。
#include <stdlib.h>
void exit(int status);
这个接口的主要作用就是:引起一个进程终止
其中我们的int status就是退出码,自己爱返回什么返回什么。
#include <stdio.h>
#include <stdlib.h>
int main()
{
exit(23);
printf("你来呀!!!\n");
return 0;
}
我们也可以写这么一个代码测试:
#include <stdio.h>
#include <stdlib.h>
void fun()
{
printf("fun begin!\n");
exit(40);
printf("fun end!\n");
}
int main()
{
fun();
printf("main!\n");
exit(23);
}
所以:在任何地方调用exit(),都表示进程结束!!!并且子进程的退出码返回给父进程bash!!!
2.3.1正常终止:调用_exit()
除了我们进程退出时,有一个exit(),还存在一个叫做_exit()
#include <unistd.h>
void _exit(int status);
#include <stdlib.h>
void _Exit(int status);
用来终止一个调用进程,就是谁调我,我就杀死谁!!!你调我,你就退出。
这个int status也是进程的退出码,只不过头文件是<unistd.h>
我们来测试一下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void fun()
{
printf("fun begin!\n");
_exit(4);
printf("fun end!\n");
}
int main()
{
fun();
printf("main!\n");
exit(23);
}
我们可以看到一切都很符合预期,他也是进程退出,但是重点是在于exit和_exit有什么本质的不同:
exit( ) vs _exit( )
在 Linux 和其他类 Unix 系统中,exit()
和 _exit()
是两种不同的进程退出方式,它们的行为有显著区别。
exit()
函数的行为
exit()
是 C 标准库中的函数,用于正常终止程序。当调用 exit()
时,会执行以下操作:
-
执行清理函数:调用所有通过
atexit()
或on_exit()
注册的清理函数,这些函数按照注册的逆序执行。 -
关闭文件流:刷新所有打开的文件流缓冲区,并关闭这些流。
-
删除临时文件:删除由
tmpfile()
创建的临时文件。 -
调用
_exit()
:完成上述清理工作后,exit()
最终会调用_exit()
来终止进程。因为杀掉进程的只有操作系统,操作系统是进程的管理者,这是封装关系,库与系统调用的上下层关系。
_exit()
函数的行为
_exit()
是一个系统调用,用于立即终止进程,不执行任何清理操作。它的行为包括:
-
关闭文件描述符:关闭所有打开的文件描述符。
-
子进程处理:如果进程有子进程,这些子进程将被 1 号进程(通常是
init
)领养。 -
发送信号:向父进程发送
SIGCHLD
信号。 -
不刷新缓冲区:不会刷新文件流缓冲区,因此可能会导致数据丢失。
以下是一个简单的示例,展示 exit()
和 _exit()
的区别:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void cleanup() {
printf("Cleaning up resources...\n");
}
int main() {
atexit(cleanup); // 注册清理函数
printf("Program is running...\n");
// 使用 exit(),会调用 cleanup 函数并刷新缓冲区
printf("Using exit()...\n");
exit(0);
// 使用 _exit(),不会调用 cleanup 函数,也不会刷新缓冲区
// printf("Using _exit()...\n");
// _exit(0);
return 0;
}
-
如果使用
exit()
,输出结果为:Program is running... Using exit()... Cleaning up resources...
-
如果使用
_exit()
,输出结果为:Program is running... Using _exit()...
并且不会调用
cleanup()
函数。
总结来说,exit()
会执行清理操作并刷新缓冲区,而 _exit()
则直接终止进程,不执行任何清理操作。
库函数和系统调用函数之间存在紧密的联系,同时也具有明显的区别。系统调用是操作系统内核提供给用户程序的接口,用于请求操作系统的服务,运行在内核空间,执行效率较低,但功能强大且直接与硬件资源交互。而库函数是编程语言或应用程序的一部分,通常运行在用户空间,封装了系统调用或其他复杂逻辑,以提供更易用、更高效的接口。
许多库函数通过封装系统调用来实现功能,例如 fopen()
封装了系统调用 open()
,fwrite()
封装了 write()
。这种封装不仅简化了编程,还通过缓冲区等技术减少了系统调用的次数,从而提高了程序的运行效率。此外,库函数通常具有更好的跨平台移植性,因为它们在不同操作系统上提供了统一的接口。
总的来说,系统调用和库函数在功能上相互补充,系统调用提供了底层的硬件访问能力,而库函数则通过封装这些调用(是上下层的关系),为开发者提供了更高效、更易用且更具移植性的接口。
到此,我们之前谈论的缓冲区应该在哪里?或者,一定不在哪里?
我们之前谈论的缓冲区一定不是操作系统内部的缓冲区!!!,因为到头来是要进行系统调用,如果该缓冲区在系统内部,那么在正常情况下都应该刷新缓冲区,因为一个封装,一个直接是系统调用了,但是实际上并没有。要么是系统层,要么是用户层,所以,我们之前谈论的缓冲区只能在库级别的缓冲区,而不是系统级别的缓冲区。
三.进程等待
3.1进程等待的必要性
子进程与父进程的关系及问题:
-
僵尸进程问题:如果子进程退出,而父进程没有及时处理,子进程就会变成“僵尸进程”。僵尸进程无法被杀死(即使是
kill -9
也无效),因为它已经“死亡”,只是其退出信息还留在系统中,等待父进程去回收。这会导致系统资源(如进程表中的条目)无法释放,进而可能引发内存泄漏等问题。 -
任务完成情况的监控需求:父进程需要知道子进程任务的完成情况,例如子进程是否正常退出、运行结果是否正确等。这有助于父进程更好地管理任务和资源。
解决方案:
父进程可以通过进程等待的方式回收子进程资源(最重要的),并获取子进程的退出信息。
僵尸进程代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("我是一个子进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
//父进程
sleep(100);
return 0;
}
父进程没有正确处理子进程的退出,可能会导致子进程变成僵尸进程。
3.2进程等待的方法
3.2.1 wait
方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
返回值:
-
成功返回被等待的子进程的 PID。(目标Z进程的pid)
-
失败返回
-1
。
参数:
-
输出型参数,用于获取子进程退出状态。如果不关心子进程的退出状态,可以设置为
NULL
。
我们对于子进程的处理:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("我是一个子进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
sleep(10);
//父进程
//1.子进程退出
//2.子进程没有退出呢???
pid_t rid = wait(NULL);
if(rid > 0)
{
printf("wait sucess, rid:%d\n", rid);//rid为子进程的pid
}
sleep(10);//父进程等完了先别着急退。
return 0;
}
如果等待子进程,子进程没有退出,父进程会阻塞在wait调用处,可以想象为scanf/cin
3.2.2 waitpid
方法
pid_t waitpid(pid_t pid, int *status, int options);
这是wait的pro版本。
返回值:
-
正常返回时,
waitpid
返回收集到的子进程的进程 ID。 -
如果设置了选项
WNOHANG
,且调用中发现没有已退出的子进程可收集,则返回0
。 -
如果调用中出错,则返回
-1
,此时errno
会被设置成相应的值以指示错误所在。
参数:
pid
:
-
pid = -1
:等待任意一个子进程,与wait
等效。 -
pid > 0
:等待其进程 ID 与pid
相等的子进程。(这就是为什么fork()的时候,要给父进程返回子进程的pid,因为我们对应的父进程可能要通过pid来选择等待指定的子进程)
status
:输出型参数。
这里我们来具体聊聊什么是输出型参数:
输出型参数(Output Parameter)是指在函数调用过程中,用于将函数内部的结果传递回调用者的参数(像指针,还有引用)。与输入型参数(用于向函数传递数据)不同,输出型参数主要用于返回函数的执行结果或状态信息。
父进程为了获取子进程退出状态,然而进程退出就三种状态,就可以根据进程退出码,即main函数返回值来判定
我们就可以使用输出型参数的特点来修改代码来观察:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 3;
while(cnt)
{
printf("我是一个子进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);//这里我们子进程的退出码设置为1
}
//sleep(10);
//父进程
//1.子进程退出
//2.子进程没有退出呢???
//pid_t rid = wait(NULL);
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("wait sucess, rid:%d, status:%d\n", rid, status);//rid为子进程的pid
}
else
{
printf("wait file:%d: %s\n", errno, strerror(errno));
}
//sleep(10);
return 0;
}
我们./proc发现:
我们不是设置了子进程exit退出吗?不是退出码是1吗?为什么是这个256呢?
所以我们应该打开思维,我们看结果我们应该知道status里面一定不是只有进程退出码,这个status并不是我们所想的直接拿到一个整数退出码。
status
参数不仅仅是一个简单的整数,它实际上是一个位图,其中包含了多个状态位。这些位可以告诉我们子进程是如何结束的,例如:
-
正常终止:如果子进程正常结束,其退出状态(一个8位的值,即退出码)会被存储在
status
的次低8位中。(int是32位,前16位不用考虑) -
被信号杀死:如果子进程是被信号杀死的,那么
status
的第7位会被设置为1,表示这是一个信号终止的状态(这也是为什么异常退出的进程的退出码无意义)。第8位到第15位会存储导致子进程终止的信号编号。如果子进程产生了核心转储(core dump),那么第6位也会被设置为1。 (具体进程信号我们会在后面的进程信号篇更加深入理解)
所以我们是1后面跟了8个0,也就是 了,这就是为什么不是1,而是256的原因了。
我们可以通过如下操作,直接得到真正的退出码,还有退出信号了:
(status>>8)&0xFF//退出码
status&0x7F//异常终止信号
我们在子进程还没有结束,这时候父进程因为waitpid会一直等待,我们杀死子进程:
options
:用于阻塞控制的。
-
默认为
0
,表示阻塞等待。 -
WNOHANG
:若指定的子进程没有结束,则waitpid
函数返回0
,不予以等待;若正常结束,则返回该子进程的 ID,如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;(非阻塞调用,使用非阻塞轮询)
非阻塞轮询是一种在不阻塞当前线程或进程的情况下,通过不断循环检查某个条件或资源状态是否满足的机制。它适用于需要同时处理多个任务或资源的场景,但可能会因频繁轮询而浪费CPU资源。在进程管理中,WNOHANG
标志常用于wait
或waitpid
系统调用,实现非阻塞等待子进程退出。这种方式允许父进程在等待子进程结束的同时,继续执行其他任务,从而提高程序的响应性和资源利用率。
我们可以实现一个非阻塞的代码示例:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == -1)
{
perror("fork failed");
return 1;
}
if (id == 0)
{
// Child process
int cnt = 3;
while (cnt--)
{
sleep(3);
printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());
}
return 0; // Ensure child process exits cleanly
}
else
{
// Parent process
while (1)
{
int status = 0;
pid_t rid = waitpid(id, &status, WNOHANG);
if (rid > 0)
{
printf("wait success, rid: %d, exit_code: %d, exit_signal: %d\n", rid, WEXITSTATUS(status), status & 0x7F);
break;
}
else if (rid == 0)
{
printf("本轮调用结束,子进程没有退出。\n");
sleep(1); // For observation
}
else
{
printf("wait fail! errno: %d, error: %s\n", errno, strerror(errno));
break;
}
}
}
return 0;
}
我们./proc可以发现:
非阻塞调用可以让等待方,做自己的事情,那我们应该如何实现?
在非阻塞等待的过程中,可以通过回调函数的方式处理其他任务。将需要执行的任务封装成函数,并将这些函数指针存入数组。在每次轮询时,依次调用这些函数指针,从而实现多任务的并发处理。
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
//定义一个函数指针类型
typedef void (*func_t)();
#define NUM 5
func_t handlers[NUM + 1];
//如下是任务
void DownLoad()
{
printf("我是一个下载的任务...\n");
}
void Flush()
{
printf("我是一个刷新的任务...\n");
}
void Log()
{
printf("我是一个记录日志的任务...\n");
}
//注册
void registerHandler(func_t h[], func_t f)
{
int i = 0;
for(; i < NUM; i++)
{
if(h[i]==NULL)
{
break;
}
//
}
//满了
if(i == NUM)
{
return;
}
//没满
h[i] = f;
h[i + 1] = NULL;
}
int main()
{
registerHandler(handlers, DownLoad);
registerHandler(handlers, Flush);
registerHandler(handlers, Log);
pid_t id = fork();
if (id == -1)
{
perror("fork failed");
return 1;
}
if (id == 0)
{
// Child process
int cnt = 3;
while (cnt--)
{
sleep(3);
printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());
}
return 0; // Ensure child process exits cleanly
}
else
{
// Parent process
while (1)
{
int status = 0;
pid_t rid = waitpid(id, &status, WNOHANG);
if (rid > 0)
{
printf("wait success, rid: %d, exit_code: %d, exit_signal: %d\n", rid, WEXITSTATUS(status), status & 0x7F);
break;
}
else if (rid == 0)
{
//函数指针进行回调处理
int i = 0;
for(; handlers[i]; i++)
{
handlers[i]();
}
printf("本轮调用结束,子进程没有退出。\n");
sleep(1); // For observation
}
else
{
printf("wait fail! errno: %d, error: %s\n", errno, strerror(errno));
break;
}
}
}
return 0;
}
状态检查宏:
我们计算机并不是直接使用像上面的位操作呀,按位与呀等等的操作,而是定义了许多宏,我们重点要认识两个:
-
WIFEXITED(status)
:若为正常终止子进程返回的状态,则为真(查看进程是否是正常退出)。(就是检查看你的信号是否为0,为0就是真) -
WEXITSTATUS(status)
:若WIFEXITED
非零,提取子进程退出码(查看进程的退出码)。
我们就可以将上面的wait代码等价换成:
waitpid(-1, NULL, 0);
补充说明:
-
如果子进程已经退出,调用
wait
/waitpid
时,wait
/waitpid
会立即返回,并且释放资源,获得子进程退出信息。 -
如果在任意时刻调用
wait
/waitpid
,子进程存在且正常运行,则进程可能阻塞。 -
如果不存在该子进程,则立即出错返回。
扩展:
至此相关进程等待的知识,介绍的大致差不多了,也有一些同学可能会问到,为什么要通过系统调用来判断进程的状态呢?而不是直接使用一个全局变量status直接来观察呢?
这里我们就来解释一下为什么不能通过一个全局变量来判断进程状态,因为进程是独立的,所以对于每一个变量,都有一个自己的stauts,他们不能共享相同的status,因为进程要想修改一个共享的数据时,会引起写时拷贝的发生,所以他们各自都有一份自己的statues,所以不能仅仅通过一个简单的变量来观察进程的状态!
后篇在“进程控制-后篇”!!!