文章目录
前言
本文主要围绕Liunx下的进程空间地址进行介绍,初步引入虚拟地址和页表的概念。同时将之前没细说如何避免僵尸进程的这个问题又拿出来进行讨论,从而引出了进程等待的概念。
1.进程空间地址
1.从编程语言角度理解地址的划分
对于学过C/C++的人来说上述图都不会陌生,这是C/C++中的内存区域划分。
但是我们来思考一个这个问题上面的内存区域到底是怎么来划分得到的呢?
下面将小例子来帮组大家理解这个区域划分。张三是李四是同桌,张三是男生,李四是女生。有一天李四对张三说画个38线,彼此不能僭越自己的区域范围。桌子刚好100cm,于是李四说从"0到50是属于我的范围,50到100是你的范围。"于是当确定开始区间start和结束区间end就能确定一个区域范围。那么我们将实际的物理的空间划分也是按照这样方式确定一个start和end区间不就能实现区域的划分了。于是为了更好的管理实际的内存空间,就可以用一个struct mm 结构体来表示空间地址。我们将实际的物理空间地址映射到这个结构体对应的字段上,每种区域有一个对应的start和end表示某个区间范围,这样上面的栈区 堆区 等等就划分来开了。
关于这个虚拟地址的概念我们在初时进程的时候,就提到过。接下来我们将站在进程的角度来理解一下这个虚拟地址。
2.进程虚拟地址
我们知道进程组成是pcb数据结构+代码和数据,那么当进程在内存中被创建出来后,需要为其分配空间。空间地址信息也是属于进程属性的一部分肯定也是保存在pcb中的,那么我们知道子进程的大部分信息都是从父进程pcb那里继承过来的,那么也就是说这个空间地址信息也是被进程过来了。但是我们知道进程之间是具有独立性的,既然具有独立性那么也就是说各个进程的数据都是独属于自己的,
这个时候就有个问题了,既然空间地址信息也被子进程继承过来了,那子进程怎么保证自己的独立性呢?
页表便应运而生了。
之前说了物理地址是可以和虚拟地址建立映射关系的,那么只要进程空间地址也是采用的是虚拟地址,同时为每个进程建立独有的虚拟地址和物理地址之间的映射关系就可以保证进程空间的独立。
这种映射关系就会保存页表中。
对于进程来说,它不关心自己的数据的放在哪里,只需要保证进程可以正常访问自己的数据同时这个数据专属于自己即可。
那么我们现在就理解了之前提到的写时拷贝的问题和代码了。
哪怕子进程和父进程空间的变量的地址都是0x7ffecfb6a588,但是每个进程的页表的映射关系不一样,父进程0x7fffecfb6a588对应的可能是0x1111的物理地址,子进程0x7fffecfb6a588对应的可能是0x2222物理地址。
3.扩展
1.为啥要有虚拟地址这种东西呢?
为啥要有虚拟地址这种东西呢?不能直接采用物理地址对应的方式来给进程分配吗?其实,早期的时候这直接采用的物理地址来分配进程空间的,但是会出现如下问题。
所以采用虚拟地址更加安全保护了进程和物理空间,页表的存在也将内存管理和进程管理解耦合。
这个就有点像,小时候妈妈怕我们小不能合理的使用压岁钱,就会先帮我们保存起来。当我们想买本子笔的时候,妈妈就会取出压岁钱的一部分给我们,如果我们想买个玩具车,妈妈会拒绝我们的要求。通过妈妈可以更加合理的管理管理压岁钱。
2.那么我们程序被编译的时候没有加载到内存中有地址吗?
其实是有的,之前就是编程语言角度介绍虚拟地址。在编译的时候程序会以虚拟地址的形式进行地址编码,当我们程序被加载到内存的时候,就很自然的拿到了物理地址。这个时候就可以建立起虚拟地址和物理地址对应的页表关系了。那我们cpu需要访问某个数据的的时候就可以根据页表拿到数据。
那么从这个角度讲,进程采用虚拟地址可以让进程以和程序统一样的视角来看待代码和数据。
3.maollc出来的空间一开始就会为为其分配物理地址吗?
其实这个问题就是当我们向操作系统申请空间资源的时候,操作系统是立马给我们还是等我们需要的时候在给我们呢?
答案是后者,为啥呢?操作系统不允许任何不高效的行为和资源的浪费。当我们申请空间的时候,不会马上给我们物理空间。这几个时候会先建立好页表但是页表中只有虚拟地址,这个物理地址暂时不会分配。只有我们真正需要使用物理的地址,才会在页表中写入对应的物理地址。
其实这个也很好理解,这样一来就会先把物理空间给有需要的进程,大大提高了空间的利用效率。操作系统不支持占着茅坑不拉屎的行为。所以从这个角度理解编程语言也选用虚拟地址也是为了更好的配合操作系统来提高资源的利用效率。这种操作被称为页表中断。
4.总结
1.进程空间总结
进程被创建后操作系统要为其分配独有的内核数据结构+存放数据和代码的内存空间。为了维护进程空间操作系统通过strcut mm结构体来管理进程空间相关信息。
虚拟地址和物理建立起映射关系,保护了进程和物理空间,同时解耦合进程管理和内存管理。进程采用虚拟地址可以让进程以和程序统一样的视角来看待代码和数据。
2.再谈写时拷贝
fork出子进程后,需要为子进程分配内核数据结构和代码数据,子进程的数据和代码大部分都是从父进程处继承过来的。当开始的时候子进程没有写操作的时候,两者的提供共享同一个物理空间的这样既保证了进程的独立性也提高了空间利用效率,假如子进程没有写操作或者只有很少的写操作这样处理方式就可以避免造成一些空间资源的浪费。一旦产生写操作,就会进行写时拷贝,操作系统会在内存找一块新的空间分配給子进程。
写时拷贝本质是一种资源筛选。
2.进程终止(退出)
进程终止无非一下几种情况:
1.正常执行完了;2.进程崩溃(进程异常)
:崩溃的本质是进程因为某些原因导致进程收到来自操作系统的某些信号比如之前的kill-9从而结束进程.正常执行完了还分两种情况:执行结果正确,执行结果不正确。
之前就提到过进程退出码的概念,我们可以通过进程退出码来判断进程的执行结果。我们可以通过echo$?查看进程退出码
比如我胡乱输入了指令lll 查看的时候就是127,我再次输入查看的进程退出码其实是echo的退出码 echo正确打印结果了 所以退出码就是0 ,
echo $?只显示最近一次的进程退出码。
C语言中也提供进程退出码对应标识我们可以去查看一下每个退出码所表示的含义。
这里C语言给我们提供了200个退出码,
我们可以看到退出码0对应的是成功,也就是正常退出执行结果正确。
这里用ls指令查看一个不存在的文件 这里查看对应的退出码就是2 文件或者目录不存在。
当然退出码的对应的含义我们自己也可以定义。这个不是固定不变的
那么如何理解进程退出呢?
操作系统中退出一个进程,操作系统就要释放掉进程对应的内核数据结构+代码和数据(如果有独立的话)。
那么进程退出有哪些方式呢?常见的就是main函数return ,其他函数return仅代表进函数返回,进程执行的本质是main函数执行流执行.
我们还可以调用C语言中的exit函数执行进程退出。
exit函数的int参数就是进程退出码 等价于mian函数的return 后跟的数字。如果我们在其他函数体内调用exit,也表示进程退出,不用走到mian函数的return。
除此以外还有_exit函数这是系统提供的接口,它的使用方式和_exit一样。exit和_exit貌似等价但是还是有区别的。exit在底层会冲刷缓冲区,同时会调用_exit,exit相当于把系统接口又给封装了一层。
总结
现在我们可以简单理解进程退出就是下面几种状况:
代码跑完结果对 代码跑完结果错 代码异常。前两种情况可以用退出码的方式表示出来,最后一种情况可以由信号表示。
也就是说操作系统提供了信号+退出码
方案来管理进程退出状态。
3.进程等待
1.为啥需要进程等待
之前我们提到子进程退出后需要被父进程读取退出码然后由父进程回收,如果子进程迟迟没有被读取到退出码就会成为僵尸进程。所以在进程的退出的时候我们需要通过等待的方式接收进程的退出吗从而回收进程。
因此等待是为了避免出现僵尸进程,从而造成内存泄漏,其次等待可以接收到进程退出码从而知道进程执行结果。前者是必要的,后者是非必要的。
2.进程等待两种方式wait和waitpid
系统给我们提供了两个接口用于进程等待,这两个接口是由父进程调用来等待子进程的。
wait方法返回值: 成功返回被等待进程pid,失败返回-1。参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
关于参数这里后面介绍waitpid的时候会说。waitpid返回值:当正常返回的时候waitpid返回收集到的子进程的进程ID; 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0; 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;参数: pid: pid=-1,等待任一个子进程,与wait等效。 pid>0.等待其进程ID与pid相等的子进程。
关于waitpid参数详解下面会慢慢介绍的。
关于int*stat_loc参数
这个参数的是输出型参数,所谓输出型参数简单来说就是后续要拿到这个stat_loc的值,这个参数值在函数调用过后可能会发生改变需要往外输出显示。这个stat_loc我们不要简单将其看做为int*它已其实是一种位图结构,所谓位图结构就是用一个或者多个比特位表示一些状态,比如之前说过的Liunx文件权限。
我们知道一个整形有32个比特位,进程信号也才30多种,进程退出码200多种,我们分别用8个比特位表示就行了,于是参数stat_loc 8到15的比特位表示进程退出码,0到7的比特位表示信号。
如果进程正常退出0到7的比特位表示的是0,8到15的比特位是是退出码。
我们只有在信号为0的时候才看进程退出码,如果是异常退出进程码已经没有查看的必要了。我们可以通过
(stat_loc>>8)&0xFF得到退出码 (stat_loc)&0x7F得到终止信号。
3.父进程怎么通过等待获得子进程退出信息的?
我们知道程操作系统都会为进程创建pcb结构体,这个结构体中有两个字段int code int signal,这两个字段是用来保存进程退出码和进程退出信号的。当父进程调用系统接口来等待的时候,系统接口函数就会通过进程pid找到该进程拿到该进程pcb中的code signal字段,从而获得进程退出信息。
4.那么如果子进程还没有退出的时候,父进程等待期间在干什么呢?
子进程在没有退出的时候,父进程会一直调用系统接口waitpid/waitpid进行等待,这种等待方式被称为阻塞等待。
如何理解阻塞等待呢?我们看看下图:
也就是说父进程在阻塞等待的时候会卡在waitpid函数调用那里,直到子进程退出为止才会继续向下执行。
5.如果父进程不想被阻塞等待呢?
在将这个之前,我先说个小故事。张三和女朋友约着出门吃饭,在出门前张三女朋友需要化妆,到了约定的时间,张三给女朋友打电话问她出门没,他女朋友说等一会就出去了。这个时候张三就挂了电话自己刷了一会视频,然后又给女朋友打了个电话问她出门没。他女朋友说在过一会就出去了,这个时候张三就又挂了电话,自己打了一把游戏然后然后又去打电话问他女朋友出门没,他女朋友说出门了,张三这个时候挂了电话就也出门了。这个故事中张三等女朋友出门的时候没有没有一直守在电话旁边等他对象回复他出门没,而是过一会问一次,在等待期间还做了自己的事情,这种等待方式就是
非阻塞轮询。过一会检测一下等待对象的状态,在等待期间可以干自己的事,一旦等待道结果后就结束等待。
阻塞等待有两种状态:等待到结果,等待出错,非阻塞轮询有3种状态:等待到结果 没出结果接着等 等待出错。
上述故事中张三打电话后挂断就是对女朋友做了一次状态检测,多次状态检测就是阻塞轮询。
怎么去进行非阻塞轮询
这里就要提到waitpid第3个参数,
这个参数如果给的的是0就是默认进行阻塞等待,如果是WNOHANG就是非阻塞轮询。
在非阻塞轮询的时候如果函数调用返回值是-1说明等待出错,如果是0说明没有等到结果还要继续等待,如果返回值是大于0这个时候说明等待道结果这个大于0的值是被等待进程的pid.
6.练习小demo
我们知道如果父进程是采用非阻塞轮询的方式进行等待,父进程在等待期间是可以做一些事情的,所以我们可以写出下面的小练习。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#define TASK_NUM 10
// 预设一批任务
void sync_disk()
{
printf("这是一个刷新数据的任务!\n");
}
void sync_log()
{
printf("这是一个同步日志的任务!\n");
}
void net_send()
{
printf("这是一个进行网络发送的任务!\n");
}
typedef void (*func_t)(); //将函数指针类型重定义为fun_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 count= 5;
while (count--)
{
printf("我是子进程,我还活着呢,我还有%dS, pid: %d, ppid%d\n",
count, getpid(), getppid());
sleep(1);
}
exit(0);
}
InitTask();
while (1)
{
int status = 0;
pid_t ret_id = waitpid(id, &status, WNOHANG); //非阻塞轮询等待
if (ret_id < 0)
{
printf("waitpid error!\n");
exit(1);
}
else if (ret_id == 0)
{
RunTask();
sleep(1);
continue;
}
else
{
if (WIFEXITED(status)) // 是否收到信号
{
printf("wait success, child exit code: %d\n", WEXITSTATUS(status));
}
else
{
printf("wait success, child exit signal: %d\n", status & 0x7F);
}
break;
}
}
return 0;
}
上述代码通过函数指针简单实现了一个回调函数模拟不同进程任务的执行。
WIFEXITED
是一个宏,表示如果没有收到系统发送的异常终止信号就会为真否则就为假。WEXITSTATUS
也是一个宏可以直接获得进程退出码,不用在进行位运算了。通常我们使用这两个宏来判断接收的进程信号和获得进程退出码。
4.总结
以上便是进程空间地址和进程等待相关内容,最后补充一个小点:
fork调用的失败的原因:系统中有太多的进程,实际用户的进程数超过了限制。
操作系统的资源是有限的,不能无限制增加进程。以上内容如有问题,欢迎指正!