文章目录
- [1. 进程创建](#1. 进程创建)
-
- [1.1 fork系统调用](#1.1 fork系统调用)
- [1.2 写时拷贝](#1.2 写时拷贝)
- [1.3 fork常规用法](#1.3 fork常规用法)
- [1.4 fork调用失败的原因](#1.4 fork调用失败的原因)
- [2. 进程终止](#2. 进程终止)
本节我们讲解进程控制:依次分为进程创建、进程终止、进程等待和进程程序替换。
1. 进程创建
关于进程创建其实我们在之前的文章中已经讲解的差不多了,这里我们快速回顾一下,并进行一些补充
1.1 fork系统调用
在 linux 中 fork 函数是非常重要的函数,它从已存在进程中创建⼀个新进程。新进程为子进程,原进程为⽗进程。
fork成功的话,在父进程中返回子进程的PID,在子进程中返回0。失败的话,-1在父进程中返回,不会创建任何子进程,并且正确设置了errno
思考:为什么fork给子进程返回0,给父进程返回子进程的PID?
首先,返回值的不同使得我们可以区分父子进程,从而实现分流。
其次,一个进程可以创建很多个子进程,而父进程通过子进程的PID就可以唯一地找到一个子进程,对其管理/控制。
给子进程返回 0 是一个简洁的标识,表明我是子进程,我被成功创建了。
进程调用fork创建子进程,内核做:
• 分配新的内存块和内核数据结构给子进程
• 将父进程部分数据结构内容拷贝给子进程
• 添加子进程到系统进程列表当中
• fork 返回,子进程开始被调度

所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。
1.2 写时拷贝
一个进程调用fork创建子进程之后,父子代码共享,父子在不写入时,数据也是共享的,当任意一方试图写入时,便以写时拷贝的方式各自拷贝一份副本。
具体过程如下:
未调用fork之前
只有父进程自己,它的代码和数据通过页表映射到物理内存的特定区域。代码是只读的,数据是可读可写的。
然后调用fork创建子进程之后,父子进程代码共享,数据也先共享(未发生修改)。
但是,此时操作系统会把会把父子进程对于数据的权限改为只读,注意这里修改的是页表中的权限,不会修改vm_area_struct中的权限(VMA 权限仍然是可写的)
回看上一篇文章
这样做的目的是什么呢?
不是为了限制访问,而是作为陷阱:当任何一方尝试写入时,CPU 的 MMU 会触发缺页异常(保护异常),让内核有机会介入。
什么意思呢?我们继续往下看
后来,父子进程中的某一方比如子进程,修改了某个数据。但是这时发现页表中的权限是只读!
那么就会触发异常,此时内核介入进行判断处理,发现该虚拟地址所在的 vm_area_struct 中是有 VM_WRITE 权限(即 VMA 允许写)
并且内核通过某种方式识别出这个只读的页表项并不是因为权限错误,而是因为写时拷贝(COW)而故意设为只读的。
然后就会执行写时拷贝:
内核分配一个新的物理页。
将原物理页的内容复制到新页。
修改触发写入的进程(子进程)的页表项:指向新页,并恢复可读可写权限。
父进程的页表项保持不变 :
仍然指向原来的物理页。
仍然为只读(因为父进程可能还会和别的子进程共享,或直到父进程自身尝试写入该页时,才会通过缺页异常处理来升级权限)
写时拷贝之后:
写入进程现在有了自己独占的可写副本,可以安全修改,保证了进程数据层面的独立性!
因为有写时拷贝技术的存在,所以父子进程得以彻底分离离!完成了进程独立性的技术保证!
写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
问题:为什么非要先复制旧内容,再修改?直接分配新页写入新值,不行嘛?
因为写入可能不只是像g_val=新值这样的修改,也有可能是++g_val。
或者是一个数组,你只修改其中是arr[2]=新值。
所以,先拷贝,再修改!
1.3 fork常规用法
⼀个父进程希望复制自己,使父子进程同时执行不同的代码段(分流)。例如,父进程等待客户端请求,生成子进程来处理请求。
让子进程执行一个全新的程序。(后面讲)
1.4 fork调用失败的原因
系统中有太多的进程
实际用户的进程数超过了限制
2. 进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程终止的场景
一个进程终止,无非就三种情况
代码运行完毕,结果正确
代码运行完毕,结果不正确
进程异常终止
如果一个进程把代码跑完了
那结果对还是不对,如果不对,出了什么问题呢?
如何区分它是哪种情况呢?
🆗,通过进程的退出码就可以知悉。
那如果代码没有跑完,异常终止呢?
那此时退出码其实就没有意义了。
就好比现在有一场考试,有个同学考试作弊,没考完就被监考老师带走了,那此时他的考试成绩还有意义吗?还能代表他学习好坏了?
当然不能,就没有意义了。
当然异常终止时,退出码也不会被正常设置了。
这时候,我们应该把注意力放到这个进程为什么会出异常上,一旦出现异常,操作系统一般就立即知道了,然后通常的做法就是直接把这个进程杀掉(可以通过给进程发送信号的方式)。
所以,如果知道这个进程收到了什么信号,我们就可以推断出它出异常的原因(关于信号我们后面有专门的章节来讲解)。
2.2 进程常见的退出方法
2.2.1 return 退出
return呢相信大家都不陌生:
return关键字主要用于函数中,使用return进行函数返回值的返回,一旦执行return,就代表函数调用结束终止,不再执行 return 后面的任何代码。
返回类型为 void 的函数不返回任何值。其中 return 语句可以省略(函数结束时自动返回,只不过不给调用者返回具体的返回值)
不过呢,main函数中的return相对比较特殊一点:
我们以前写C/C++程序的时候,main函数通常最后我们会写一个
return 0;
比如我们来随便写一个程序
那这个return 0是什么意思呢?这个0返回给谁了啊?
上面我们介绍了普通函数中return的作用,但是main函数中的return语句稍微要特殊一点,main函数一旦执行return语句,进程就退出了
那退出就退出呗,返回一个0是什么意思?
🆗,这个问题其实我们C语言的第一篇文章简单提到过:
当程序运行结束时,main函数中的return语句会将一个整数值(进程退出码)返回给操作系统(最终会被其父进程获取),表示程序的执行状态。一般来说,返回值为0表示程序执行成功,非零值表示程序执行出现错误或异常。
main 的返回值先给运行时库,再通过系统调用传给内核,最终被父进程或 shell (命令行启动的大部分程序的父进程)获取,并可通过echo $?查看
我们来查看一下:
echo $?:打印上一条命令的退出状态码(也就是返回值)
我们也可以修改一下main函数的返回值
这下再来查看退出码
就也变成了100
不过注意:进程的退出状态码占8 位(0 到 255)。超出这个范围的值会被截断,只保留 值 % 256 的结果。
2026%256=234
退出码
那为什么需要有退出码呢?
因为我们创建一个进程,通常是让他帮我们完成某些任务的。就是帮忙办事的!
那事办得怎么样,我需要知道!
所以,通过进程退出码来反馈事有没有办成功,如果没有,也要知道出了什么问题。
而退出码0,就代表执行无误,这是进程执行的理想状态。
一件事情,办成功了,一般我们不太关心为什么成功了。就比如
你这次考试考了100分,你爸爸一般不会问你:为什么?,为什么你这次考了100分?
但是如果,你失败了,你考了个不及格,20分!。你爸爸大概会很生气,会问你原因:怎么回事,啊?天天在学校都干啥了!为什么考这么低!
那同样的
如果一个进程执行某项任务失败了,通常需要知道它为什么执行失败了,获取失败的原因。
退出码如果是非0,通常就代表失败了。
非0有很多值,所以也对应了,失败的原因是很多的!
不同的退出码代表不同的退出原因。
这里正式给出退出码的概念:
在 Linux 中,退出码(Exit Code),也称为退出状态(Exit Status)或返回值,是一个进程在终止时返回的整数值(范围 0--255)。它用于告知父进程该进程的执行结果。
0 通常表示成功(正常结束,无错误)。
非 0表示失败或某种特定状态。
那0我们了解了,非0的退出码有哪些呢?分别代表什么意思呢?
上面提到退出码的范围是
0~255,那是不是一共有256种退出码呢?如果不是,那有效的退出码有几种呢?
那下面我们可以通过程序来看一下:
怎么做呢?
C语言种提供了一个全局的错误变量------errno,C语言的库函数在调用失败时会产生错误码,这个错误码会保存到errno中
另外C语言还提供了一共库函数------strerror(我们之前的文章是讲过这两个东西的),它可以将C语言中的错误码转化为对应的错误信息,并返回对应错误信息字符串的首地址。
接收错误码,返回错误码对应的错误信息字符串
那现在我们就可以借用这个函数来看一看所以退出码对应的退出信息
还不知道具体有几个(0~255只是范围),试一试
我们看到,有效的是0~133,即134个
每一个退出码都对应不同的退出信息。
举个例子:
使用ls命令查看一下不存在的文件,我们看到报的错误信息就是
No such file or directory
对应的错误码是2,我们可以验证一下
没有问题
此外:
当一个进程退出时,它的task_struct中会保存这个退出码,等待父进程获取其退出结果
在task_struct的定义中,我们能找到
exit_code这个字段,它就用来存储进程的退出码
2.2.2 exit
除了main函数中return来退出一个进程,我们还可以使用C标准库中的一个函数------exit:
其实在我们之前C/C++的文章中是用过这个函数的。
exit函数接收一个整形的参数,在程序的任何地方 调用exit函数,都会导致进程立即退出,这个参数就是进程的退出码(等同于进程的退出码,相当于调用了 exit(main的返回值))!
试一下:
return方式 vs 函数exit
使用return来退出一个进程,这个return一定是main函数中进行return。
但是,如果使用exit,在程序中的任何地方调用exit,都会导致进程退出(不在main函数中也可以)
下面通过代码体会两者的差异:
先来看这段代码
代码很简单,无需解释
看结果
没有问题,符合预期
修改一下代码
把自定义函数return 10改成exit(10)
再看结果
没有打印"程序正常结束"
因为:
2.2.3 _exit
还有第三种方法:
使用系统调用------
_exit
参数和exit函数一样
但是:
从目前的演示来看,exit和_exit好像没看出有啥区别啊?
参数也一样,然后都是在任何地方调用都直接退出进程。
那他们到底有什么区别呢?
exit函数 vs 系统调用_exit
下面通过代码验证两者的区别:
首先,再来回顾一下
这是我们上面验证过的,这里我们看不到两者的区别。
现在我们把代码改一下,首先看exit:
还是打印一个字符串,但是这次没有\n换行
看现象
先休眠三秒
然后打印了hello world,因为我们没有加\n,所以命令提示符直接跟在后面
再看_exit:
修改代码
只把上面的exit改成_exit
来看现象
还是先休眠三秒
然后
并没有打印hello world,直接打印了命令行提示符
下面,依次来解释这里大家可能疑惑的地方:
第一点:为什么先休眠,再打印?
回到第一次的代码
使用exit是打印了hello world字符串的
但是?
我们是先调用printf,再调用sleep的
为什么结果是先休眠,再打印字符串呢?
如果这个问题你不知道原因,请你去复习我们之前的进度条那篇文章(也是Linux系统编程专栏中的)
不过我们这里还是再给大家说一下原因吧,一起复习一下:
原因在于:
当进行输入或输出操作时,数据先暂时存储在缓冲区中,然后再批量地传输到目标位置或从源位置读取出来。这样可以减少对源位置或目标位置的直接读写次数,从而提高数据传输效率。
我们调用printf函数打印hello world字符串,并不是直接就把它打印输出到显示器上了。而是这个字符串先被放到了缓冲区中。
缓冲区被用来暂时存储要输出或被读取的数据,直到达到一定条件后才会将其发送到目标位置(如屏幕、文件、网络等)。这个条件通常是缓冲区满了、遇到换行符 、或者主动进行缓冲区刷新的操作。
如果你加上换行就会发现,是先打印字符串,然后休眠!
所以,这里为什么先休眠呢?
因为我们没有加\n换行,所以就暂时不会触发缓冲区的刷新,因此我们看到先进行了休眠,后面才打印了字符串(使用exit的时候)
第二个问题:为什么使用exit最后打印了字符串,但是使用_exit最后没有打印呢?
其实原因就很明显了:
exit在结束进程之前,会刷新缓冲区的内容;
而_exit直接结束进程,不会刷新缓冲区!
上面说了两者的区别,那它们有什么联系吗?
exit是一个C语言的库函数,_exit是系统调用,那我们之前讲了:
库函数和系统调用其实是一个上下层的关系
所以,exit底层也调用了_exit,本质就是对_exit进行了一个封装,只不过在最终调用_exit之前做了一些诸如刷新缓冲区这样的操作!
不管我们采用哪种方式来退出进程,最终一定要通过系统调用陷入内核,由内核完成最后的资源回收和状态更新。
exit()是库函数:它会在调用 _exit() 系统调用之前,先执行用户态的清理工作,其中就包括刷新所有标准I/O流的缓冲区 。然后再调用 _exit() 陷入内核进行相关的各种操作(比如进程相关资源的释放等)
_exit()是系统调用:它直接进入内核,不会进行任何用户态的缓冲区刷新。因此,如果程序直接调用 _exit() 退出,仍在用户态缓冲区中的数据就会丢失。
有了上面的理解,我们再来提出一个问题:
缓冲区在哪里呢?
目前我们还不知道缓冲区到底底层实现是什么东西?但是我们能感知到:
缓冲区底层一定要维护一段内存空间,因为它可以把我们要输出的数据暂时保存起来等待刷新。
那缓冲区应该在哪里呢?或者先回答:它一定不在哪里?
🆗,缓冲区一定不在操作系统内核中!
因为如果缓冲区是在操作系统内部的,那exit和_exit都是在上层的,那为什么exit可以刷新缓冲区,而_exit不刷新呢?
给出结论:缓冲区是C标准库维护!
那具体是怎么维护的?我们来带大家简单理解一下:
上面说了我们使用
printf打印一个字符串,并不是直接输出到显示器上的,而是先会暂存到缓冲区,等待合适的时机刷新到显示器"文件中"(大家可能听过一句话------Linux下一切皆文件)。
其实我们使用scanf从键盘读数据也是一样,从键盘输入的数据也是先被放到缓冲区的,后续再从缓冲区中拿到这些数据。
我们C语言文件操作的文章中提到过:
对于任何一个C程序,只要运行起来,就会默认打开3个流:
stdin------标准输入流:键盘
stdout------标准输出流:显示器
stderr------标准错误流:显示器
而且这三个流的类型都是:FILE *(文件指针)
那FILE是什么呢,一个结构体:
VS2013编译环境提供的stdio.h头文件中有对文件类型FILE的申明:
这个结构体中其实就维护了我们所说的缓冲区 !
其中:char *_base:就指向缓冲区的起始地址。缓冲区是一块由 malloc 或静态分配的内存区域。
这就是我们所说的C标准库维护的缓冲区!(暂时理解到这里就可以了)
下篇文章我们继续讲解进程控制的:进程等待、进程程序替换









































