🧑💻作者: @情话0.0
📝专栏:《Linux从入门到放弃》
👦个人简介:一名双非编程菜鸟,在这里分享自己的编程学习笔记,欢迎大家的指正与点赞,谢谢!
进程退出和等待
- 前言
- 一、进程创建
- 1.1 fork函数
- 1.2 写时拷贝
- 1.3 fork常规用法
- 1.4 fork调用失败的原因
- 二、进程退出
- 2.1 进程退出场景
- 2.1.1 查看退出码
- 2.1.2 退出码的含义
- 2.2 如何理解进程退出?
- 2.3 进程退出的方式
- 三、进程等待
- 3.1 进程等待的原因
- 3.2 什么是进程等待?
- 3.3 进程等待的方式
- 3.3.1 wait方法
- 3.3.2 waitpid方法
- 3.4 子进程退出状态
- 3.5 非阻塞式等待
- 总结
前言
之前的几篇博客已经是对进程的相关概念做了详细了解,现阶段对进程的定义为内核数据结构加上该进程对应的代码和数据,操作系统对进程通过先描述再组织的方式做管理。有了这些预备知识,接下来就是要学习如何控制进程,也就是在操作上该怎么做。
一、进程创建
1.1 fork函数
关于fork函数的知识,此篇博客有详细介绍:进程创建
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
在调用fork函数之后,系统会将父进程的代码拷贝一份给子进程,同时会有两个执行流分别执行父进程和子进程,要注意的是子进程不会去执行fork之前的代码。
1.2 写时拷贝
父子进程代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
在修改内容之前,父子进程的在物理内存页的数据、代码指向同一块位置,如子进程对数据进行修改,,那么此时就会发生写时拷贝,在物理内存页重新开辟一块空间将修改后的数据存入其中。
因为在操作系统是不允许空间的浪费,所以不会将父进程的所有代码数据都在物理内存中重新拷贝一份,而是通过写时拷贝的方式在子进程需要使用(修改)数据的时候才会重新开辟空间,它是一种按需申请资源的策略。
1.3 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程退出
2.1 进程退出场景
a. 正常运行完毕(1. 结果正确 2. 结果不正确)
b. 崩溃了(进程异常) 崩溃的本质:进程因为某些原因,导致进程收到了来自操作系统的信号(kill -9)
2.1.1 查看退出码
我们一般在写C语言程序都会在main函数结束时返回 0,这个0代表着该进程的退出码,在linux中,可通过这样的指令查看进程的退出码:echo $?
。看下面代码:
int add_to_top(int num)
{
int sum=0;
for(int i=1;i<=num;i++)
{
sum+=i;
}
return sum;
}
int main()
{
int ret=add_to_top(100);
if(ret==5050)
return 1;
else
return 0;
}
上面的代码要实现的功能:从1加到100,若和为5050,则返回1,否则返回0。通过下图可以看到该进程的退出码为1,表示结果正确。但是奇怪的是,后两次的查看退出码都为了0,这是因为该指令只会保留最近一次执行的进程的退出码!后两次代表着该条指令执行后的退出码。
2.1.2 退出码的含义
我们看到的退出码都是数字,对于程序员来说,我们可能知道一些退出码所代表的含义,但是对于一般人来说看到这些数字并不了解所蕴含的意义。所以对于一般人来说,如果你只给他退出码是没有价值,因为他并不知道这些退出码代表的含义。关于退出码的含义我们可以自定义,下面看一下C语言所提供的退出码的含义。
int main()
{
for(int i=0;i<200;i++)
{
printf("%d:%s\n",i,strerror(i));
}
return 0;
}
这只是前二十个,后面还有更多。当然这是在linux操作系统下,在windows下所提供的退出码含义是不同的。
2.2 如何理解进程退出?
关于进程的退出,可以理解的是操作系统内少了一个进程,操作系统要释放进程对应的内核数据结构+代码和数据。
2.3 进程退出的方式
- main函数return。而其他函数的return仅仅代表该函数的返回。对于这种方式来说,进程执行本质是main执行流执行,当main函数执行完时代表着进程也就结束了。
- exit函数退出。exit函数所包含的数字为该进程的退出码,在函数任意位置调用直接使进程退出。
- _exit函数退出。直观感觉上和exit的功能是一样的,但是在一些细节是不一样的。exit函数在退出的时候会自动刷新缓冲区,而_exit函数不会刷新缓冲区。它们两个的关系是一种包含和被包含的关系。从下面这个图可以得到一个暗藏的点:缓冲区不在操作系统内。
三、进程等待
3.1 进程等待的原因
- 之前讲过若子进程先退出,而父进程并没有读取子进程状态,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 我们为什么要创建子进程,目的就是为了让子进程帮助我们去完成某些事情,关于父进程派给子进程的任务完成的情况,可能我们不会关心完成的对不对,也可能会关心子进程运行完成的结果对还是不对,亦或是否正常退出。
- 避免内存泄漏(必)
- 获取子进程的执行结果。(可能)
关于子进程的退出结果,有三种可能性:
a. 代码跑完,结果对;
b. 代码跑完,结果不对;
c. 代码运行异常;
关于结果对或不对,可以通过退出码的方式判别,代码运行异常则是收到某种信号。因此衡量一个进程运行的怎样是通过退出码+信号的方式来执行的。
3.2 什么是进程等待?
通过系统调用,获取子进程退出码或者退出信号的方式,同时释放内存问题。
3.3 进程等待的方式
3.3.1 wait方法
pid_t wait(int *status);
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设为NULL
//代码功能:父进程在休眠5秒的过程中子进程先运行2秒,然后子进程退出,2秒之后,父进程对子进程做进程等待操作。
int main()
{
pid_t ret=fork();
if(ret==0)
{
//子进程
int cnt=2;
while(cnt--)
{
printf("我是子进程,我现在活着呢,我离死亡还有%d秒,pid:%d,ppid:%d\n",cnt,getpid(),getppid());
sleep(1);
}
_exit(0);
}
sleep(5);
//父进程
pid_t ret_id=wait(NULL);
printf("我是父进程,等待子进程成功,pid:%d,ppid:%d\n",getpid(),getppid());
return 0;
}
在运行代码之后我们应该观察到的现象:父子进程的状态最开始都为运行状态,子进程经2秒输出2条语句,然后退出变为僵尸状态,父进程依然为运行状态,再过3秒之后,父进程对子进程等待回收,然后全部退出。
3.3.2 waitpid方法
pid_ t waitpid(pid_t pid, int *status, int options);
返回值: 当正常返回的时候waitpid返回收集到的子进程的进程ID; 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0; 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数 pid :
- pid=-1,等待任意一个子进程,与wait等效。
- pid>0,等待其进程ID与pid相等的子进程。
参数 status:
- WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
- WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
参数 options:
- WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
3.4 子进程退出状态
- 在 wait 和 waitpid 中,都有一个status参数,该参数是一个输出型参数,由操作系统填充。它的功能是为了获取子进程的退出状态。如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
- 通过对上图理解,我们应该明白关于子进程的退出状态。如果进程是正常退出,那么status位图的低八位为0,次低八位为进程的退出状态,也就是通过这次低八位获取进程的退出码。如果进程是被某种信号所杀而导致的异常退出,则只需要关心低七位,读到的结果为导致该进程退出的终止信号所对应的数字,coredump标志位目前不需要了解。
int main()
{
pid_t id=fork();
if(id==0)
{
//子进程
int cnt=2;
while(cnt--)
{
printf("我是子进程,我现在活着呢,我离死亡还有%d秒,pid:%d,ppid:%d\n",cnt,getpid(),getppid());
sleep(1);
}
// int a=10;
// a/=0;
_exit(123);
}
sleep(5);
int status=0;
pid_t ret_id=waitpid(id,&status,0);
printf("我是父进程,等待子进程成功,pid:%d,ppid:%d,status signal:%d,status code:%d\n",getpid(),getppid(),(status&0x7F),((status>>8)&0xff));
return 0;
}
看上面这段代码,如果按照这样的逻辑,那么最终的运行结果为(只看退出状态):父进程获取到子进程的退出信号肯定为0,因为是正常退出,退出状态则为数字123;若将那两条注释的代码取消,那么子进程就会因为除0操作导致异常退出,那么此时父进程就会读到对应的退出信号,输出结果为该信号对应的数字。
- 父进程是如何获取子进程的退出状态信息的呢?子进程有自己的PCB、地址空间、页表和内存,而在PCB的内部会有两个属性:exit_code、exit_signal。当子进程执行完毕时将main函数的返回值写到 exit_code 中,如果出现异常操作系统则将遇到信号所对应的数字编号写到 exit_signal 中。当子进程退出后,操作系统会将这份PCB维护起来,所以就需要通过wait/waitpid这样的系统调用接口将从这份PCB读到的这两个属性以上面那种位图的方式设置到status参数中。
- 父进程在wait的时候,如果子进程没退出,那父进程在干什么?在子进程没有退出的时候,父进程只能一直在调用waitpid进行等待——阻塞等待。
3.5 非阻塞式等待
waitpid(id,&status,WNOHANG)
上一小节的 waitpid 方法为阻塞等待,而非阻塞等待与阻塞等待的区别在于第三个参数的不同,阻塞等待是在子进程还没有退出的时候父进程只能一直等待直到子进程退出,非阻塞等待是子进程还没有退出时,父进程可以干一些其他事情而不是什么事情不干就在等待子进程退出。
下面这段代码将通过非阻塞的形式让父进程在还未等待到子进程的退出信息的时候去执行其他事情。
#define TASK_NUM 10
void sync_disk()
{
printf("这是一个刷新数据的任务!\n");
}
void sync_log()
{
printf("这是一个同步日志的任务!\n");
}
void net_send()
{
printf("这是一个进行网络发送的任务!\n");
}
typedef void (*func_t)();
func_t other_task[TASK_NUM] = {NULL}; //函数指针数组
int LoadTask(func_t func)
{
int i = 0;
for(; i < TASK_NUM; i++){
if(other_task[i] == NULL) break;
}
if(i == TASK_NUM) return -1;
else other_task[i] = func;
return 0;
}
void InitTask()
{
for(int i = 0; i < TASK_NUM; i++) other_task[i] = NULL;
LoadTask(sync_disk);
LoadTask(sync_log);
LoadTask(net_send);
}
void RunTask()
{
for(int i = 0; i < TASK_NUM; i++)
{
if(other_task[i] == NULL) continue;
other_task[i]();
}
}
int main()
{
pid_t id=fork();
if(id==0)
{
//子进程
int cnt=5;
while(cnt--)
{
printf("我是子进程,我现在活着呢,我离死亡还有%d秒,pid:%d,ppid:%d\n",cnt,getpid(),getppid());
sleep(1);
}
_exit(123);
}
InitTask();
while(1)
{
int status=0;
pid_t ret_id=waitpid(id,&status,WNOHANG);
if(ret_id==-1)
{
printf("等待错误!\n");
break;
}
else if(ret_id==0)
{
//子进程还未退出,父进程执行RunTask函数
RunTask();
sleep(1);
}
else
{
if(WIFEXITED(status))//正常退出
{
printf("我是父进程,等待子进程成功,pid:%d,ppid:%d,status signal:%d,status code:%d\n",getpid(),getppid(),(status&0x7F),WEXITSTATUS(status));
}
else//非正常退出
printf("我是父进程,等待子进程成功,pid:%d,ppid:%d,status signal:%d,status code:%d\n",getpid(),getppid(),(status&0x7F),((status>>8)&0xff));
break;
}
}
return 0;
}
在子进程正常退出并且父进程等待成功的时候可以通过宏的方式来获取子进程的退出码,之前的方法优雅度或者可扩展性都不太好,当
WIFEXITED(status)
为真的时候,通过WEXITSTATUS(status)
获取退出码,若不为真也就是异常退出时只能使用以前的方法。
总结
总结:
本文深入探讨了操作系统中进程管理的三个核心方面:进程的创建、退出和等待。首先,我们了解了进程创建的过程,它涉及到操作系统如何为新进程分配必要的资源,包括内存空间和处理器时间,并初始化进程表以跟踪和管理进程状态。接着,我们讨论了进程退出的不同方式,如正常退出、异常退出以及由于接收到信号导致的退出,每种方式都对系统稳定性和资源管理产生不同的影响。
最后,我们详细分析了进程等待的概念,即一个进程可能需要暂停执行,直到满足特定条件。这可能包括等待I/O操作完成、等待获取资源或等待其他进程的结束。文章强调了实现有效等待机制的重要性,并指出了同步和通信在确保系统资源合理利用和进程间顺畅协作中的关键作用。
通过这篇博客,我们不仅学习了关于进程操作的基本知识,还加深了对于操作系统内部机制如何协同工作的理解。这些内容为我们进一步研究计算机科学的其他领域打下了坚实的基础。