Linux——进程控制
- fork()
- 缺页中断
- 进程终止
- 进程异常
- exit
- _exit
- 进程等待
- wait
- waitpid
- status
- WIFEXITED
- 多进程等待
- 阻塞等待和非阻塞等待
- 进程替换
- 单进程的进程替换
- execl
- execlp
- execv
- execle
fork()
我们之前是接触过这个函数的,这个函数我们之前是要来创建子进程的函数,我们今天来深入学习一下这个函数:
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
//返回值:自进程中返回0,父进程返回子进程id,出错返回-1
返回值:子进程中返回0,父进程返回子进程id,出错返回-1。
那么fork做了哪些事情呢?
- 创建子进程: 调用 fork() 函数时,操作系统会创建一个几乎与父进程完全相同的子进程。这意味着子进程继承了父进程的内存、文件描述符、环境变量等。
- 返回值: fork() 函数在父进程中返回子进程的进程ID(PID),而在子进程中返回0。这是为了让父进程和子进程能够区分彼此。通常,父进程可以通过检查返回值来确定它是父进程还是子进程。
- 复制父进程: 子进程是通过复制父进程的地址空间来创建的,这包括父进程的代码、数据和堆栈。子进程独立于父进程运行,但它们之间的数据不会共享,除非使用进程间通信(Inter-Process Communication,IPC)机制。
- 并行执行: 一旦子进程创建成功,父进程和子进程可以并行执行,各自独立运行。
我们可以写段代码来验证一下:
我们可以看到第一个29374是父进程的pid,返回值29375是子进程的id,而下面的29375是子进程,返回值是0。
这个过程是怎样的呢?
在调用 fork() 函数后,父进程会继续执行其后的代码。fork() 函数创建了一个新的子进程,该子进程是在调用 fork() 时父进程的几乎完全复制。父进程和子进程将几乎同时开始执行下面的代码。
缺页中断
我们之前讲过进程地址空间,我们说父进程和子进程都是一个相同的虚拟地址,然后映射到不同的物理内存中。
但在子进程修改自己的值之前,父进程和子进程都是映射到相同的物理内存上,那就有一个问题:操作系统怎样知道在哪个时间完成对子进程的修改呢?
其实父子进程任意一方想修改的时候,这个时候会发生写时拷贝,同时父子进程的权限都会改为只读。
这个时候我们修改,就会报错,但是操作系统过来看看,发现原本是读写的权限,被改为了只读,就会判定这个不是错误,会在物理内存中重新找一块空间重新建立映射关系到父或子进程。但这个过程我们是不知道的。
我们称这样的机制为缺页中断:
缺页中断(Page Fault)是计算机操作系统中的一个重要概念,它发生在程序试图访问虚拟内存中的一个页面(或页)时,但该页面当前不在物理内存中。当发生缺页中断时,操作系统会介入处理,以便将需要的页面加载到物理内存中,从而使程序能够继续执行。
下面是关于缺页中断的一些重要信息:
- 虚拟内存和分页系统: 大多数现代操作系统使用虚拟内存和分页系统来管理内存。这意味着每个进程看到的内存都是虚拟内存,而不是物理内存。虚拟内存被划分成固定大小的页面(通常为4KB),这些页面被映射到物理内存。
- 页面不在物理内存中: 当程序尝试访问一个在虚拟内存中但不在物理内存中的页面时,会触发缺页中断。这可能是因为该页面从物理内存中换出(置换)以腾出空间给其他页面,或者是因为该页面是程序首次访问的部分。
- 操作系统响应: 当发生缺页中断时,操作系统会介入,根据程序访问的虚拟地址,找到相应的页面,并将其加载到物理内存中的合适位置。这通常涉及从磁盘上的页面文件或其他存储设备中读取数据。一旦页面被加载到物理内存中,程序可以继续执行,就好像该页面一直在内存中一样。
- 页表: 缺页中断的处理依赖于页表,页表是操作系统维护的数据结构,它将虚拟地址映射到物理地址。操作系统使用页表来确定缺页中断的地址映射关系,以便正确地加载页面。
性能影响: 缺页中断是计算机性能的重要因素之一。频繁的缺页中断会导致程序执行速度减慢,因为加载页面需要时间。因此,操作系统会尽量减少缺页中断的发生,通过使用高效的页面置换算法和合理的内存管理策略来优化性能。
那么什么时候会发生缺页中断呢?
- 首次访问页面: 当程序首次访问虚拟内存中的一个页面时,该页面通常不在物理内存中,因此会触发缺页中断。这是因为操作系统采用了一种"延迟加载"策略,只有在页面被首次访问时才将其加载到物理内存,从而节省内存空间。
- 页面被置换出: 如果操作系统需要腾出物理内存空间以容纳其他页面,它会选择一些不常用的页面进行置换(将其移出物理内存)。当程序再次访问这些页面时,会触发缺页中断,因为它们不再在物理内存中。
- 页面被交换到磁盘或其他存储设备: 某些操作系统具有交换(Swap)机制,这允许将页面从物理内存交换到磁盘或其他存储设备以释放内存。当程序尝试访问已经交换到磁盘上的页面时,会触发缺页中断,因为它们不在物理内存中。
- 多任务操作系统切换进程: 在多任务操作系统中,当操作系统决定切换执行另一个进程时,当前进程的页面可能会被置换出物理内存。当再次切换回该进程时,会触发缺页中断,因为所需的页面不再在内存中。
- 共享内存: 如果多个进程共享某些内存区域,而其中一个进程对共享内存进行了修改,而其他进程需要访问这个修改后的数据,可能会触发缺页中断。这是因为修改后的数据需要被加载到其他进程的物理内存中。
我们的父子进程属于第5种情况。
进程终止
我们之前写程序的时候,总会在最后一行写:
其实我们都大概可以猜得到,这个0可能代表了状态,我们的父进程bash会接收这个返回值,我们可以用**$?**来查看这个返回值:
其实我们返回值,通常是想知道这个程序到底运行的怎么样。我们一般有三种状态:
- 运行完毕,结果正确。
- 运行完毕,结果不正确。
- 运行异常。
我们先来看前两种情况,我们一般来说,返回值为0表示运行完毕结果正确,非0值表示运行完毕,结果不正确。
其中,不同的值对应不同的状态,我们可以用strerror来查看不同数字所对应的不同的退出状态。
果然和我们猜的一样,0代表成功。其他数字都代表了不同的错误状态。
我们把这些代表不同状态的数字叫做退出码。
其中 ** ?**这个字符会保存上一个操作的退出码:
但是这个可能不准,所以不能太依赖这个。
有时候,程序运行并不一定会成功,这个时候我们可以把程序在执行过程中返回的数字储存起来,到最后我们可以打印出来看:
这个用来储存的东西叫做errno
进程异常
我们之前讨论过,程序的三种状态跑完 正确,跑完 程序不正确,程序异常,这里我们来看看程序异常:
程序异常,跟前面两种情况都不是很一样,前面两种情况,起码还有一个返回值(成功为0,不成功为非0),但是如果程序异常,直接结束,下面的代码直接不执行。
我们来模拟一下最常见的异常:
我们发现我们之前写的printf那一句话,没有机会执行,因为a /= 0的异常,程序直接终止掉了。
这里程序终止的原因是因为,这个进程在出现异常时,会向操作系统发出异常的信号,让操作系统直接杀掉这个进程。
我们看一下,会有哪些信号会让操作系统杀掉进程:
这些信号发给操作系统,操作系统就会杀掉这个进程。前面的数字代表了相应的异常,我们可以向操作系统发出相应的异常信号,帮我们杀掉某些进程:
我们模拟一个正确的场景:
我们向操作系统发送了25127有11号异常,操作系统会以这个11号异常的名义杀掉25127这个进程。
最后退出码和信号就是两个数字,通过这两个数字我们可以推断这个程序的状态。
exit
我们之前讨论过退出码这个概念,那么如果在不同的部分退出,会有什么不同的现象呢?
现在我们函数中有一个退出码,主函数中有一个退出码,我们看最后程序是哪个退出码?
我们看到最后程序是以主函数的退出码退出的,那表明了一件事就是:函数中的退出码只是表示函数调用的结束,并不能代表最后程序的状态。
我们之前也接触过** exit() **这个函数,我们来看看这个函数是不是和return n的效果是一样的:
我们发现退出码变成了0,其实我们可以查一下exit的手册:
手册上面提到,exit可以引起一个正常进程的结束,我们exit中的这个参数就是我们程序返回的退出码,程序遇到exit直接结束而且以exit中的这个参数作为退出码的。
_exit
其实我们除了exit,还有一个函数跟它长得很像,_exit(),我们可以查一下它的手册:
这个_exit也是可以立即结束一个进程,我们来试试:
那么这个函数和exit有什么区别呢?我们来看看:
注意一下我们这里的printf是带有行刷新的,这个对于观察之后的现象有比较关键的作用。
现象很正常,我们现在把换行符去掉:
这时候一开始是什么也没有的:
三秒钟过后:
我们发现I am a process被刷出来了,说明exit是会强制刷新缓冲区的。
那我们来看看_exit:
也符合我们的预期,我们现在来试试将换行符去掉:
一开始也是什么也没有:
三秒钟过后:
我们发现了,_exit不会强制刷新缓冲区。
其实exit是由_exit封装的,两者的区别一个是库函数,一个是系统函数之所以exit会刷新缓冲区,是因为缓冲区就在这个库里面,exit可以直接对这个库进行操作。
进程等待
进程等待呢,在我的理解下,就是进程等待相应资源的过程。
那么为什么要进行进程等待呢?以我们现有的知识水平来看,有那么以下几点:
- 解决相应的僵尸进程的内存泄漏问题。
- 可以收集对应的子进程完成任务的状态。
了解到上面两点之后,我们再来看看进程等待的函数wait和waitpid。
wait
我们先来看看wait的手册:
返回值:
我们发现wait要传一个int*的指针参数,我们暂时先不管,先传NULL值使用一下:
我们来写一段测试代码:
这里子进程结束了之后,父进程会休眠5秒,这个时候子进程就会变成僵尸进程,我们写一段监控脚本来监视一下:
我们看到在某个时刻子进程变成了僵尸进程。因为这时候父进程在睡眠,没有回收子进程。
在五秒结束之后,父进程回收了子进程,这个时候子进程的僵尸状态会被父进程回收。僵尸状态消失。
这里顺便说一下,如果这里我们的父进程不睡眠的话,这时候我们的父进程会阻塞等待。直到回收子进程。
waitpid
了解了wait之后,我们来了解一下waitpid:
waitpid的一个参数是pit_t pid 这个可以表示我们要指定回收哪个进程,比如这个数字是12345,我就要回收12345号进程。
后面两个参数我们暂时先不用管,用NULL和0先顶替着。
我们测试只需改变这一行:
我们来测试一下:
我们看到是和wait一模一样的:
status
我们之前在wait和waitpid中有一个int* status 的指针,这个参数是干嘛的呢?其实这个参数是输出型参数,意思是我们先把这个参数输进去,之后它又会返回给我们:
我们可以来试试,这里为了测试,我们将子进程的退出码设为10:
测试一下:
我们看到status的返回值是2560,这跟我们的10差的有点远啊,这是为什么呢?
其实我们的status被输入进去之后,被划分了区域:
我们的次低8位描述的是退出状态,第七位是core dump标志,剩下的位数表示终止信号。
我们的0这个数字有32个比特位,就被分成了这样几个区域:
如果我们想拿到我们的退出状态和终止信号的话我们得对我们的status进行一些位运算来帮助我们拿到真实的数字。
测试一下:
我们看到end state退出状态是10,退出信号是0,跟我们的逻辑是符合的。
我们来试试程序异常会怎么样:
WIFEXITED
如果上面的方法有点过于复杂了,我们定义了一个WIFEXITED的宏帮我们判断退出状态:
如果有异常,返回的值就不为1:
多进程等待
我们进程等待,我们可以多进程等待:
阻塞等待和非阻塞等待
我们waitpid还有最后一个参数没有讲解:options
这里我们的options我们有两种选择:
0:阻塞等待
WNOHANG:以非阻塞方式等待。
啥叫阻塞等待呢,阻塞等待就是操作系统很老实,在等待资源的过程中,它不会去做其他的事情,就乖乖的等待资源。非阻塞就不一样,在等待资源的过程中,抽空去做其他的事情。
我们可以写一段代码来验证:
我们也可以把父进程具体在做什么写的清楚一点:
进程替换
我们之前创建的子进程都是父进程的一部分,现在我们想要让子进程独立于父进程,是一个全新的进程,那该怎么办呢?
单进程的进程替换
在进行进程替换前,我们的了解一下,进程替换的接口:
这些都是进程替换的接口,我们一个一个来试:
execl
我们看到execl是要指定文件路径,加上可变参数列表:
我们发现我们写的程序可以和ls指令有相同的效果,并且我们发现我们最后一行的printf并没有打印出来,这是为什么呢?这个我们暂时先放在一边,我们先来看看多进程的进程替换:
这里程序替换之后,我们的子程序就和原来的程序没有任何的关系了,变成了一个不依赖父进程的独立程序,所以在被替换之后,之后的代码子进程也不会管,所以最后一个printf没有打印,但是,子进程虽然不依赖父进程了。但是依然存在父子关系,所以父进程可以等待成功。
我们可以获取一下execl的退出值:
这个时候我们发现,没有返回值,因为进程都被替换了,不执行后面的代码了。
如果我们输入指令的地址根本就是错的,或者这个指令本来就是错的:
这个时候就有返回值了,并且是-1:
execlp
除了我们的execl这种方法之外,我们还有其他的方法来进行进程替换,比如,我们来看execlp:
这个的名字中带了个p,表示它是在环境变量中去找的:
execv
这里的argv是一个指针数组,用于存放我们要执行的命令:
现在我们都替换的是系统的程序,我们可不可以调用我们自己写的程序呢?
我们创建一份C++代码:
修改一下我们的Makefile
execle
我们已经了解了带 l 的,还有带v的,现在我们来看看带e的:
看这个样子的话,应该是把父进程的环境变量导入到子进程中,这个过程中我们会用到putenv这个函数:
这个函数是方便我们在程序之中添加我们的环境变量:
这个时候我们不调用execle这个函数,我们看看子进程mytest的变化:
我们发现在环境变量表的最后发现了我们添加的环境变量,诶?但是我们还没有向子进程导入我们的环境变量呀?
这里要注意一下子进程本身就会继承父进程的环境变量,不受进程替换的影响,其实不需要我们调用什么接口去继承。但是既然提供了,我们还是可以用一下:
顺便可以把命令行参数打印出来:
执行一下:
我么看到子进程继承了父进程的环境变量。
注意我们也可以传我们自己的表,但这会覆盖我们原有的环境变量表,所以如果我们想新增的话,我们的先提前加好才可以。