从fork到exit:剖析Linux进程的诞生、消亡机制

一、进程创建

复制代码
pid_t fork(); //创建一个子进程

关于fork函数更详细的介绍,请看硬件与软件的桥梁:冯诺依曼体系、操作系统和初始进程的深度解析从env到mm_struct:环境变量与虚拟内存的底层实现

我们都知道数据段在页表当中的权限是可读的 ,但是一旦创建了子进程,操作系统会把权限改为只读。当子进程尝试写入时,操作系统就会"报错"。

OS要对当前情况进行判断分类

1、真的是野指针吗如果是,就终止进程

2、不是a. 发生写时拷贝 b. 更改回权限(rw)

我们都知道进程是具有独立性的,但是父子进程的代码和数据是共享的。那么,有以下几个问题:

1. 为什么创建子进程之后,不直接把数据分开呢,直接拷贝不就完了,为什么要写时拷贝?

首先,直接拷贝将父子进程的数据分开是可以的 。但是,如果子进程对数据只是只读的呢,那就没有必要进行拷贝。父进程的代码和数据有100MB,每次创建子进程都要拷贝,那岂不是要浪费时间及内存资源。写时拷贝的本质就是一种按需获取

子进程读取父进程的数据,直接用父进程的就可以,子进程要进行写入,再给子进程申请,写时拷贝的本质也是一种惰性申请,提高内存使用率

2. 为什么要拷贝,直接开辟对应的空间不就好了?

一说拷贝,大多数人想到的都是对数据进行覆盖式的写入,但是如果是a = 20, a++呢?这是不是也是一种写入,在原数据上进行写入。所以我们需要对原始数据进行拷贝

扩展问题:从今天开始,你在C/C++上申请空间malloc or new的时候,需要在物理内存上开辟空间吗

不需要了。开辟虚拟地址空间即可,真正用的时候,OS才会做内存级申请,构建完整的映射关系。本质就是惰性申请

二、进程终止

提两个问题:

1. 进程终止,操作系统要做什么?

这个问题,大家应该可以回答出来,进程终止时,操作系统要释放进程的资源,停止CPU对进程的调度,更新进程状态

2. main() return 0; return 0什么意思?给谁了?

return 0我们把它叫做进程退出时的退出码。退出码会被"系统"获得,用来让系统辨别,该进程的执行情况。

那么,我们该如何来证明呢?写一段程序。

复制代码
echo $? //最近一个进程执行完的退出码

可以看到,这个退出码是被获得了的。那如果我们再echo一下呢?

咦?它为什么是0呢?别忘了echo本身也是一个进程。这一次获得的退出码不再是mycode.c的了

3. 为什么要有退出码呢?

我们创建进程就是为了让进程完成任务的,进程把任务完成的怎么样了,得有一个结果吧,那么,你怎么知道结果呢?

类似的道理,父进程交给子进程一个任务,父进程怎么知道子进程完成的怎么样呢?

0表示程序运行成功

!0表示失败。1, 2,3, 4,表明不同失败的原因

举个例子:前几天学校在考试,今天成绩出来了。你爸一看你的成绩99分,他是不会去问你为什么考这么多的,但是你要是考个鸡蛋回来,你爸不仅会噼里啪啦揍你一顿,还得问你为什么。

语言已经提供了一些错误原因。这些退出码(错误码)适合计算机去看,但却并不适合我们人来分析原因。

复制代码
char* strerror(int errnum) //将错误码转化为可读错误信息字符串

那么,语言为我们提供了多少个错误原因呢?我们可以测一下。



可以看到,一共有134个错误原因,剩下的都是未知的。

我们可以执行ls命令验证一下。

可以看到,在当前目录下是没有aabbcc.txt这个文件的,所以我们直接使用ls命令就报错了。退出码为2,是与我们前面的错误原因一致的。

将来这个退出码会被写到子进程PCB里的。现在,你知道为什么父进程要拿到子进程的pid了吗父进程通过子进程的pid就可以找到子进程,获取子进程的信息,这也是为什么子进程在退出时,状态设置为僵尸状态,不能立即释放所有资源,要保留PCB的原因

进程退出,无非就是三种情况:

1、代码跑完,结果正确

2、代码跑完,结果不对

3、代码没跑完,进程异常了

代码跑完了,结果对不对,我们可以根据退出码来判断。

代码异常了,退出码还有意义吗 ?答案是没有了

例子:今天在考试,只有三种情况,考试正常结束,成绩好不好,考试作弊了,直接终止考试,那么成绩还有用吗?

所以,进程异常了,退出码是没有意义的。我们的问题应该是进程为什么会异常?

如果是除0错误,那么CPU会发生错误(硬件错误),如果是野指针呢,那么就是软件错误 。而操作系统作为软硬件资源的管理者,进程异常了,操作系统首先要知道。

操作系统一般都是杀掉这个进程 ,通过退出信号杀掉进程的

例子:学校里有人打架斗殴,校长要知道这件事情。情节严重的,是要开除的。

4. 进程退出的方法

方法一main 函数进行 return n,n表示该进程的退出码

方法二直接调用exit(n),n表示进程的退出码

复制代码
void exit(int status);

方法三直接调用系统调用_exit(n)

复制代码
void _exit(int status);

那么,看到这里大家应该会有疑问吧。

4.1. return vs exit

作为一个深谙C语言的你,应该清楚 return 一般表示函数调用结束main函数 return ,表示进程退出

exit 表示进程结束,在你的代码中,任何地方调用,都会导致进程退出

可以看到,进程的退出码是1,而不是10。那如果我们改为exit呢?结果还会一样吗。

可以看到,这一次进程的退出码不再是1而是10,说明调用exit函数直接就让进程结束了。

4.2. exit vs _exit

可以看到,在前三秒内是没有打印在显示器上的,所以,数据是被写入到缓冲区里的

main函数中return表明进程结束输出缓冲区,在进程结束的时候,会自动被刷新

exit终止进程,也会主动刷新缓冲区

那么 _exit 呢?

_exit直接终止进程,不会刷新缓冲区

最佳实践:exit(n)用来进程退出

我们为什么要学习_exit呢?我们已经知道库和系统调用是上下层的关系系统调用的存在就是为了保证用户安全的访问操作系统,也就是说,只有系统调用才可以和操作系统交互

进程退出时,需要释放资源,包括代码和数据,页表,虚拟地址空间和PCB。这些工作都是由操作系统完成的,我们调用了C语言的库函数exit,为什么可以让进程退出呢?不是说好只有系统调用才可以访问操作系统吗。

进程终止,绝对要调用系统调用,必须让操作系统完成真正的进程删除退出!所以,库函数的底层一定封装了系统调用

我们在使用printf("我是一个进程!");向显示器打印的时候,不就是在用软件访问硬件吗,访问硬件就必须经过操作系统。我们常说的缓冲区到底是什么呢?它又在哪里呢?

首先,我们所说的缓冲区肯定不是操作系统内部的缓冲区 。因为,如果是的话,exit和_exit都应该会刷新缓冲区里的数据。那么,它应该是库缓冲区,在库里面,所谓缓冲区其实就是一段内存空间

例如:在C语言中,我们使用过

复制代码
FILE* fopen(const char* path, const char* mode);

打开文件,那么fopen函数内部一定会打开,关闭文件,其次它的返回值FILE*是一个文件指针,那么FILE是什么呢?

不卖关子了,FILE就是C语言提供的结构体,它在库里面,那么fopen函数的返回值既然是FILE*,也就意味着C函数内部自己申请了FILE结构体,自己维护的结构体内部,可以存在一个大的内存空间(毕竟linux下一切皆文件)char inbuffer[1024]; char outbuffer[1024];,这不就是输入输出缓冲区吗

今天的文章分享到此结束,感觉还不错的给个一键三连吧。