首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Linux操作系统-深入fork与进程终止:揭秘进程的优雅终局

Linux操作系统-深入fork与进程终止:揭秘进程的优雅终局

作者头像
海棠蚀omo
发布2026-01-12 17:23:29
发布2026-01-12 17:23:29
1290
举报

一.深入fork函数

在进程(一)时我们就已经讲解了fork函数的大部分知识,了解了其作用并解决了一些问题,而今天要讲解的是更深入层的fork函数相关的知识,更深刻地去理解fork函数后究竟会发生什么。

1.1写时拷贝

抛出这个知识点的时候是在进程(一)中我们在解决fork函数的最后一个问题时,当时没并没有详细的去讲解到底什么是写时拷贝,下面我们就来看看:

首先我们先看:在执行fork函数执行之前,这是父进程的相关结构,虚拟内存中的代码段和数据段经过页表的映射,指向了物理内存中的相应地址,并且在页表中设置了代码段和数据段的权限,代码段只有r,而数据段是rw。

而在fork函数执行过后形成了父子进程,而子进程的相关结构都是拷贝父进程而来,所以就形成了上图的结构,并且系统此时会将代码段和数据段均改为只读。

这张图在进程(一)中我们简单的展示过,不过当时的图比较简单,并不能显示出这之间的关系,上图就是比较完整的结构了。

并且注意此时是修改内容之前!!!,此时父子进程的虚拟地址通过页表映射的物理地址是一样的。

那要是要修改内容了呢?我们下面来看:

上面我们讲了在fork函数执行后代码段和数据段的权限都变为只读,那么此时子进程要尝试写入,会发生什么呢?

操作系统此时就会“ 报错 ”,就会去看看是怎么个事,也就是要对当时的情况进行判断分类:

1.判断是否为野指针

如果操作系统判断后发现你就是个野指针,想去访问数据段的内容,那操作系统断然不会同意,此时操作系统就会直接终止进程。

这种现象相信我们在日常写代码中是遇到过的,因为我们的误操作,导致某个指针变成了野指针,并且我们还去访问这个野指针中的内容,那么此时启动程序后,程序直接就崩了,那就是操作系统终止了进程,这种情况和上面的类似。

2.不是野指针

操作系统判断时发现这个地址是合法的,确实是代码段中的地址,只是碍于权限是只读的而没有办法去写入数据,那么此时就会发生写时拷贝:

操作系统就会在物理内存中重新找一块空间,和你要修改的内容的空间一样大,并将里面的内容拷贝进新空间,之后修改页表中的映射关系,使子进程中的虚拟地址经过页表映射后指向新的物理地址。

而这就是写时拷贝的过程,讲到这里才算是彻底解决了我们在进程(一)中fork函数的最后一个问题,当时只是简单讲了一下,讲完这里我们就知道为什么当时的id既==0,又>0,因为父子进程对应数据的虚拟地址经过页表映射后的物理地址就不是一块空间。

当然,上面只是写时拷贝,工作还并没有完成,从上面我们也看到了修改内容之后父子进程的数据段的权限又重新变为了读写,这就是要做的第二步工作,更改回权限

什么叫更改回权限呢?

我们知道代码段和数据段的权限是由页表决定的,而在子进程修改内容之前,这两个的权限都是只读。但是子进程要修改数据时,经过操作系统判断后,虽然页表中的权限是只读,但是在进程所对应的mm_struct,也就是虚拟地址空间所对应的结构体中,记录的数据段的权限是读写。故操作系统将其改为读写其实就是改回原来的权限。

上面的两步操作就是子进程要修改内容时的完整过程,经过上面两步操作后就形成了最后一张图所示的结构。

而经过上面的讲解,相信大家心中肯定还有问题,这里我用两个问题来概括:

1.为什么创建子进程后,直接将数据都拷贝一份不就好了,不就可以省略写时拷贝这步操作了吗?为什么不这样做,而要进行写时拷贝呢?

我们要知道写时拷贝的本质是“按需获取”,在这之前我问大家一个问题:子进程创建后,会去调用父进程的所有资源吗?

答案是不会,这就像你父亲手里面有10个亿,你问你爸要这10个亿,他会全部给你吗?

当然不会,所以子进程要调用父进程的所有资源也是不现实的,子进程之会去调用一部分,甚至不去调用任何资源,这都是有可能的。

那么如果按我们上面所说将数据都拷贝一份,上面我们只展示了两块数据,但实际上是有很多数据的,都拷贝一份,而你又不用,或者说你只用一小部分,那不就会浪费资源吗?

而我们上面所说的“按需获取”指的是子进程要用哪些数据,我再把哪些数据通过写时拷贝的方式给你,这就叫“需”,这样不就节省了资源了吗?

而在“按需获取“的基础上,又会产生另外一种现象:比如此时子进程要用一些数据,系统就会去物理内存中申请空间,那么你申请我立刻就会给你吗?

答案并不会,我明明都申请空间了,为什么不会立刻给我呢?举个例子:

比如你在代码的第1行申请了空间,但是你在代码的第1000行才用这块空间,之前都没有用这块空间,要是你申请直接就给你开了这块空间,那在1000行之前不就是典型的占着茅坑不拉屎吗?

话糙理不糙,给你资源你又不用,那我要是把这块资源先给别人用,不就能提高内存的利用率吗?

而上面的这种做法就叫做”惰性“申请,你申请资源,我不会立刻给你,而是在你要用的时候再给你,而”惰性“申请的目的就是为了提高内存的利用率。

2.为什么要将原来物理地址内的内容拷贝过去呢?直接开辟一块空间不就好了吗?

举个简单例子:子进程如果通过虚拟地址进行a++的操作,怎么办?

如果我们只是重新开辟一块空间,上面的操作是a++,是对原有的数据作出修改,而你都没有原有的数据,怎么作出修改?这不就出问题了嘛,所以才要把原有的数据拷贝过去。

1.2fork的常规用法

第一点:⼀个⽗进程希望复制⾃⼰,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客⼾端请求, ⽣成⼦进程来处理请求。

这就像你父亲是个生意人,家里面有两个厂,等你长大了,你父亲让你帮忙去管理其中一个厂,和上面就是一个道理,父子进程分工合作。

第二点:⼀个进程要执⾏⼀个不同的程序。例如⼦进程从fork返回后,调⽤exec函数。

和上面的例子一样,不过不同的是你父亲虽然让你去管理厂,但是你不干,你非得去自己创业,去干别的事,父子进程干的是不一样的事。

1.3 fork调用失败的原因

fork函数会调用失败吗?

当然了,就像我们使用malloc或者new申请空间时同样也会申请失败是一样的,而fork调用失败的原因有下面两种:

1.系统中有太多的进程

fork函数的本质不就是在内存中加入一个进程嘛,那么内存空间肯定会面临空间不足的情况,此时你用fork函数去创建子进程可能就会失败,没有多余的资源来让fork函数创建子进程。

2.实际⽤⼾的进程数超过了限制

这个我用现实中的例子来帮助大家理解:我们在进游戏时,有时就会发现服务器崩了,我们通常都认为是人太多了导致服务器崩了,其实不然。

服务器崩的原因就是进程太多了,超出了限制,每个玩家都相当于一个进程,而在linux中每个用户能创建的进程数也是有限的,超出这个限制就会出问题。

最后我在问大家一个扩展问题:了解了上面的知识后,在C/C++上用malloc或new来申请空间的时候,需要在物理内存中开辟空间吗?

答案肯定是不需要了,只需要开辟虚拟地址空间就可以了,只有在真正用的时候,操作系统才会给你做内存级申请,从而构建完整的映射关系。

二.进程终止

2.1进程终止的背景知识

在讲解进程终止的相关知识前,我们先来了解一下进程终止的一些背景知识,两个问题:

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

2.main()函数中最后的return 0是什么意思?

第一个问题:这个问题想必大家都清楚答案是什么,为非就是回收进程的资源,就比如:PCB(task_struct),mm_struct(虚拟地址空间),页表等资源,这些就不解释了,我们今天要讨论的重点也不在这里。

第二个问题:这个问题相信大家初识C语言时就想过,但是想必并没有去深究过这个return 0到底是什么东西,为什么要return 0,今天我们就来看看这个return 0到底有什么门道。

这个return 0叫做进程退出时的退出码,这个退出码会被” 系统 “获得,准确来说是父进程,用来辨别该进程的执行情况。

而我们在进程(二)中讲解进程状态时,我们在讲解Z状态使用形象的例子来说明了为什么进程结束后不会立刻被回收所有的资源。而这个退出码就是父进程要从子进程中提取的信息之一,所以这个退出码也就保存在子进程的task_struct中。

而0就代表程序运行成功了,并且我们可以通过一些方式来看到进程的退出码:

我们可以通过打印?这个变量来观察最近的一个进程的退出码,不必惊讶,?确实也是linux中的一个变量,这个例子我们看着不明显,我们再看一个:

此时我们就可以看到最近的一个进程也就是test的退出码就是123,那上面为什么说这个例子不明显呢?我们来看一个有意思的现象:

当我们再次执行上面的命令后,发现此时打印出的内容就不再是test进程的退出码123,而是0,为什么呢?

这个问题也很好理解,因为echo是命令,同样也是进程啊,相当于就是它打印了自己的退出码。

有了上面的介绍,相比我们心中还有一个问题:为什么要有进程码呢?

我们想一想,子进程被父进程创建出来不就是让它来办事的吗?那办的怎么样,你得知道吧。

所以才有了退出码的存在,就是告诉父进程你事办的怎么样,而在这些退出码中:

0:表示程序运行成功

!0(1,2,3,4,5......):表示失败

一件事办成功了,我们不关心事是怎么办成功,但是如果失败了,那我们肯定会把重心放在事情为什么失败了,我们得知道失败的原因。

而非0的数字就表示失败的原因,因为失败的原因有很多,就比如:你期末考试考砸了,回到家你父母问你为什么考砸了,你的回答可能是:没睡好,没吃饱,没状态,肚子疼,头疼等等。成功的原因我们不追究,失败的原因就会有很多,所以我们用0以外的数字来表示不同的失败原因。

相信我们在日常写代码时也会设置不同退出码,0表示成功就不说了,有时我们程序运行失败了会用1,会用2等退出码,而这些原因我们可以自己去制定,就比如某个值在运行过程中经过判断==0,我们此时就return 1,那么失败的原因就是这个数==0,不是我们想要的结果,我就让它结束了。

上面我们自己制定失败原因的例子还有很多,这里就不一一列举了,而在系统中也同样内置了一些错误原因:

我们通过strerror这个函数,输入相应的退出码就可以看到错误原因是什么,并且我们观察到,系统中内置的错误原因总共有134个,下面我们来看一个例子:

这里我们用ls来显示一个不存在的文件,此时ls就会报上面的错误,我们通过查找上图可以看到对应的退出码是2,也就是在ls这个二进制文件的底层实现中,当找不到目标文件时,就return 2。

有了上面的背景知识,我们下面就来讲解进程终止的相关知识。

2.2进程退出场景

我们回想一下我们在日常写代码时,程序执行完都有哪些情况,是不是就是下面三种情况: 1.代码执行完毕,结果正确

2.代码运行结束,结果不正确

3.代码异常终止

上面这三种情况就概括了进程的退出场景,我们在进程终止这里只讲前面两种,最后一种我们在后面的进程等待章节会讲,因为进程终止指的就是进程的正常退出,区别于异常终止的情况。

说回正题,一个问题:我们是怎么知道程序执行完毕后,结果正确还是不正确?

可能有人会说通过printf函数打印内容,但这是给用户看的,那我要是不让你用printf函数呢?

所以正确的答案就是上面讲的退出码,也就是说程序执行完毕后,结果正确还是不正确是由退出码来决定的,大家可以想一想是不是这个道理。

2.3进程退出的常见的方式

2.3.1 return退出

这种退出进程的方式是我们最常见的进程退出方式,而为什么退出码不是prinf函数的1而是main函数的0呢?

因为main函数比较特殊,它的return就表示进程退出,剩下的代码就不解释了,都能看懂。

2.3.2 exit()函数退出

第二种方式就是借助exit函数来实现进程退出,从上图可以看到exit函数的作用就是导致正常的进程退出,并且进程的退出码也是我们输入的2,剩下代码和上面是一样的,这里就不解释了。

2.3.3 _exit()函数退出

同样的,_exit函数和上面的exit函数作用是一样的,都是将一个进程退出,剩下的就和上面的是一样的。

讲了这三种方式,相比大家心中都有一个疑问:这三种方式有什么区别呢?下面我们就来看看它们之间的区别。

2.4 return vs exit

我们正常写的return和exit函数有什么区别呢?下面我们来看一个例子:

这是我们用return来执行进程退出的输出结果,很简单,下面我们来看exit的:

到这里就能体现出区别了,我只是将print函数中的return改为了exit函数,就发现程序执行完print函数就结束了,并且进程的退出码是exit函数中4,而不是main函数中0,为什么呢?

这就是return与exit的区别,不管在代码的什么地方执行了exit,进程就会立刻终止,从上面我们也证明了这一点,在print函数执行到exit进程就直接结束了,就没有执行main函数中后面的代码。

而与之对应的return就只是main函数执行到了最后return才会使进程退出。

2.5 exit vs _exit

这两个函数我们直接看的话,它们的区别就是exit是C库函数,而_exit是系统调用,这就是他们浮现在表面的区别,下面我们通过一个例子更深层次的探究它们的区别:

这是用exit函数的代码,看起来和上面并没有什么区别,无非就是在printf函数后面将" \n "给去掉了,剩下都一样,输出的结果也在意料之中,那么我们再来看:

当我们将exit函数换成_exit函数后发现了问题:怎么什么都没有输出出来呢?

这就是要讲的exit函数与_exit之间的区别,我们知道,如果printf函数后面不加上“ \n ”,那么printf中要输出的内容就会暂存在输出缓冲区中,只有程序执行到最后的return等才会自动刷新缓冲区,这样我们就会看到要输出的内容了。

讲到这里想必大家就明白了exit和_exit之间的区别了:exit终止进程,会自动刷新缓冲区;_exit终止进程,不会刷新缓冲区

这就是为什么上面我们用_exit退出进程后,显示器上并没有显示要输出的内容,因为内容还在缓冲区中。

其实想必当介绍了exit和_exit这两个函数时,大家心中都会有个疑问:这两个函数这么像,两者有什么关系呢?

我们上面说了两者都能结束掉进程,而exit能够自动刷新缓冲区,_exit不能刷新缓冲区,而_exit又是系统调用,exit是C库函数,说到这里答案已经昭然若揭了:那就是exit的底层实现中封装了_exit,也就是在_exit的基础上扩展了一些功能,就比如自动刷新缓冲区,进而形成了exit。

那又说到了缓冲区,那输出缓冲区到底在哪儿呢?

这个问题可能一时还真不知道这输出缓冲区到底在哪儿,我们换个问法:输出缓冲区不可能在哪儿?

要解决这个问题,我们先来看:

上面就显示了库,系统调用,操作系统三者之间的层级关系,我们想一下:缓冲区会在os中吗?

答案肯定是不会的,因为如果在os内部,那_exit就不可能不支持刷新缓冲区的功能,_exit是系统调用,系统调用就是操作系统给外界提供服务的窗口,那不在os会在哪儿呢?

答案已经很明显了:里面啊,因为只有在库里面,exit才支持自动刷新缓冲区的功能而_exit却不支持,并且printf函数要将输出的内容放在缓冲区中,说明缓冲区一定得是一段内存空间,这样才能保存内容。

这缓冲区既然在库中,那自然就叫库缓冲区,那具体的库缓冲区是怎么个事呢?这点我们在后面讲解文件系统时会介绍,这里大家只要知道缓冲区在库中,叫做库缓冲区即可。

以上就是深入fork与进程终止:揭秘进程的优雅终局的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档