【Linux系统编程】进程控制

目录

进程创建

进程终止

退出码

进程终止的方式

进程等待

进程程序替换


在这篇文章中,将介绍4种控制进程的方法

进程创建

我们创建进程是调用fork这个系统调用,fork创建进程的过程在前面已经作出了详细的介绍,这里就不过多赘述了,现在重点介绍一下写时拷贝。

我们前面说过,父子进程的代码是共享的,但是数据是各自私有一份的。但是,通过上面这幅图可以看出,进程刚创建,还没有修改内容之前,父子进程的代码、数据都是共享的,为什么要这样做呢?因为子进程就算对数据进行修改,也不一定会全部修改,可能只是修改其中的一小部分,若子进程完全拷贝一份父进程的数据,会有大量数据冗余,导致大量空间浪费,并且若是子进程完全拷贝一份,在创建进程时效率太低了。

虽然父子进程的代码是共享的,但是子进程会从fork之后开始执行,因为子进程可以拿到父进程的pc指针。

在进程刚创建,还没有修改内容时,父子进程的代码、数据都是共享的。当子进程要对某个数据进行修改时,就会先申请一块空间,然后将要修改的数据拷贝下来,然后进行修改,这个过程就是写时拷贝

系统是怎么知道要进行写时拷贝的呢?
在父进程调用fork创建子进程时,会将页表中数据段的部分的权限设置为只读,因为子进程的页表拷贝自父进程,所以子进程的页表的数据段也是只读的。代码段一直都是只读,所以不需要管。当子进程要对数据进行写入时,因为页表中数据段是只读的,所以会触发系统错误(此时是因为权限问题引发的错误)。当触发系统错误后,系统就会再触发缺页中断,触发缺页中断后,系统就会进行检测。如果检测发现是朝代码段进行写入,那么就真的错了,如果检测发现是朝数据段进行写入的,因为数据区一般是读写的,但是此时是只读的,所以系统就会判定要发生写时拷贝,判定为发生写时拷贝后,申请空间,发生拷贝,修改页表的映射关系,恢复执行,恢复权限(将父子进程的数据段全休恢复成读写)

此时会有一个问题,就是写时拷贝时为什么要先拷贝,再对拷贝后的值进行修改?
因为你的写入操作!:对目标区域进行覆盖式的写入。如:count++

fork的常规用法:

1. 让子进程去执行父进程代码的一部分

2. 让子进程去执行新的进程

fork调用失败的原因:

1. 系统中有太多的进程

2. 实际用户的进程数超过了限制 

进程终止

退出码

退出码的作用主要用于判断一个进程的执行结果。我们在学习C/C++时,main函数都有一个返回值,这个返回值就算退出码,是返回给父进程或者系统的,让父进程或者系统根据返回值进行判断子进程的执行结果,以在发生错误时作出应对。



我们可以使用echo $?来获取最后一个程序退出时的退出码。

$?是一个变量,保存的是最后一个程序退出时的退出码。第一次查是10,这是./process退出时的退出码,第二次是0,这是因为上一个程序是echo。进程的退出码可以表示程序的运行结果,0表示成功,非零表示失败,不同的数字表示不同的失败原因。所以,有时候也直接称为错误码

C/C++提供了一些对于退出码的分析函数

perror:输出当前error对应的错误描述字符串(相当于strerror(error))

strerror:将错误码转换成错误描述

error:是一个全局变量,用于保存最近一次的错误码



当前目录下并没有log.txt这个文件,我们以只读的方式打开,不会创建,所以会打开失败。通过上面的代码,我们就可以看到一个错误码对于的错误描述。

我们可以打印出全部错误码对应的错误信息,下面的程序打印了200个错误码,实际上错误码并没有这么多,当超过了最大的错误码时,错误信息显示的是未知错误。

至于错误码的数量,这就与系统强相关了,windows、linux各不相同

我们来验证一下在Linux系统中,当发生错误时,打印出的错误信息是否与上面的相同

可以看到,No such file or directory的错误码就是2

使用kill -9去杀进程,明明No such process是3,为什么echo $?打印出来是1呢?
实际上,上面打印出的错误码是系统提供的,我们自己写程序时,如果与系统强相关,我们可以使用系统的错误码,但是如果关联性不大,我们可以自己约定错误码。自己约定错误码的意思是,在子进程中返回自己约定的错误码,在父进程中对这些错误码进行检测,并作出相应的策略。

结论:
1. main函数结束表示进程结束,main函数的返回值代表进程的退出码

2. 进程的退出码可以由系统默认的退出码提供,也可以自己定义

进程终止的方式

在这里,总共介绍3种进程终止的方式

1. main函数的return

2. exit

eixt是C标准库中的一个库函数,它可以在代码的任何地方,让进程终止,参数是一个错误码


会发现此时只打印了一个hello world,并没有打印出"进程正常退出",因为进程一旦遇到exit,就直接终止了,并且exit里面的参数就是这个进程的退出码。在上面echo $?也可看到是100

我们再来看看在fun中使用return,而不使用exit的结果是如何的


可以看到,此时进程是正常退出的。因为return表示的是函数结束,而exit表示的是进程结束

3. _exit

_exit是一个系统调用

在C语言中,头文件可以写成unistd.h,也可以写成cunistd,但是系统调用的头文件只能.h


exit是对_exit的封装,但是通过上面的代码,可以看到,两者似乎并没有什么区别,两者真正的区别在于缓冲区。

与缓冲区的关联

我们来看看exit和_exit刷新缓冲区的差别


此时的现象是程序一运行就打印出了"进程运行结束",过了2秒运行结束,退出码是5

我们将上面printf中的'\n'去掉试试

去掉之后,程序一运行,什么都没有,2秒后,打印出了"进程运行结束",退出码是5。这说明,exit会在进程结束时,将缓冲区的数据刷新出来

我们去掉printf中的'\n',并将exit(5)换成_exit(5)

此时从进程开始运行到结束,都没有打印出"进程运行结束",退出码是5。也就是说,_exit在进程结束时,不会将缓冲区的数据刷新出来。加上'\n'则和exit是相同的。

exit是3号手册,是语言级别的,_exit是2号手册,是系统调用。操作系统为了方便上层操作操作系统,会在上层封装一层系统调用,系统调用之上会有一些C标准库,如glibc,glibc的核心功能之一就是封装系统调用,但它不仅仅是简单的封装,还提供了更高级的抽象、标准合规性以及额外的功能。刚刚说的exit就在这里,printf也在这里。所以,_exit和exit是上下级的关系

这里说的是我们之前所说的缓冲区,我们之前说的缓冲区全部都是C/C++自己的缓冲区,和系统没有关系,这个缓冲区叫做语言级缓冲区---C/C++。缓冲区的位置在哪里?通过刚刚exit和_exit刷新缓冲区的区别,可得知缓冲区一定不在操作系统内部。因为若是缓冲区在操作系统内部,那么printf打印输出的这个字符串也一定在操作系统中(因为会先打印到缓冲区中),当进程结束时,无论是exit,还是_exit,都应该刷新缓冲区。而事实上,_exit没有刷新缓冲区,而exit刷新了缓冲区。也就是说printf打印的这个字符串,还在标准库的内部,当我们调用exit时,会再调用fflushO将printf中的字符串刷新到操作系统内部,然后刷新出来,而对于_exit,数据还在语言级缓冲区中,调用_exit直接将进程结束了,数据还在语言级缓冲区中,就没有输出。

进程等待

前面我们说过,子进程退出,若父进程不管他,那么子进程就会变成僵尸进程。有了进程退出的概念,我们就可以写一个相对完整的进程创建的代码了

fork的返回值有3种情况

1. 返回>0。这是返回给父进程的,返回的数值是子进程的PID

2. 返回0。这是返回给子进程的,表示自己是子进程

3. 返回-1。这是返回给父进程的,表示创建子进程失败,并且会将errno设置为退出码


我们将这个程序运行起来,并用另外一个shell来监视它


会发现,最先开始父子进程都在运行,10秒后,子进程退出,父进程仍然在运行。但是,上面的代码是不合理的,因为子进程退出后,父进程并没有回收它,而是让其一直处于僵尸状态。父进程应该要回收处于僵尸状态的子进程,因此我们需要进行进程等待

进程等待需要用到wait这个系统调用

一般而言,父进程创建子进程,父进程就要等待子进程,直到子进程结束。父进程创建子进程是为了完成某项任务的,当任务完成后,就需要由父进程来回收处于僵尸状态的子进程,并且获取子进程的退出信息、同步父子进程的执行顺序(父进程可能需要确保子进程完成特定任务后(如文件操作、数据计算等)再继续执行,避免竞争条件)、进行资源清理(子进程可能持有共享资源(如内存、文件描述符等))。父进程若要等待子进程,就需要调用wait这个系统调用。父进程在等待子进程时,若子进程没退,父进程就会阻塞在wait内部。wait的返回值大于0,说明成功回收了一个子进程,并且返回值就是这个回收的子进程的PID,wait的返回值小于0,说明回收失败了。所以,wait的作用就是回收任意一个子进程。




此时可以看到,最先开始父子进程都在运行,过了5秒,子进程运行完了,进程变成了僵尸状态,再过了5秒,父进程开始回收子进程。在这个代码中,没有体现父进程真正等待子进程的过程,若父进程没有sleep(10),一上来直接wait,在子进程没有跑完之前,父进程都会阻塞在wait内部的。

父进程除了要回收处于僵尸状态的子进程,还应该知道子进程的执行情况如何,所以使用更多的是waitpid

有3个参数,第一个参数pid > 0时,是等待特定的子进程,pid = -1时,是等待任意子进程,此时与wait相同。第二个参数表明子进程的退出信息。子进程的退出信息保存在子进程的PCB中,waitpid就会从子进程的PID找到子进程的PCB,然后从PCB中获取退出信息,并写到statue里面。当我们传入nullptr时,表示不关心子进程的退出信息。返回值是与wait相同的。

我们来看一下waitpid是否能拿到子进程的退出信息


回收成功了,可我们给子进程设置的退出码是1,为什么拿到的status是256呢?
实际上,进程结束分为正常结束和异常结束两种,当进程执行到主函数中的return或任意地方的exit结束都属于是正常结束,而若是因为野指针等结束都属于异常结束,只有当进程是正常结束时,退出码才有意义。status中保存的是进程的退出信息,并不是退出码,里面有退出码,但却不仅仅只有退出码,所以status本质是一个位图。status是一个整型变量,会有32个比特位,我们看前16个比特位即可。

在正常退出的情况下,只有8-15位是退出码,前7位都是0,刚刚我们的status是100000000,转换成十进制就是256,我们也可将刚刚的status右移8位,再取最低的8位看看是否是1


可以看到,status获取的子进程的退出码就是1

我们可不可以使用一个全局变量来获取子进程的退出码呢?
是不可以的,因为即便是父子进程,当子进程修改了全局变量的值后,父进程也是无法拿到的,这也正是为什么我们只能通过系统调用才能获取子进程的退出信息,子进程的退出信息保存在它的PCB中,只有系统调用可以获取PCB中的信息。

重谈进程退出

1. 代码跑完了,结果对,return 0

2. 代码跑完了,结果不对,return !0

3. 进程异常了 

我们来看一看进程异常的情况
此时子进程1秒就挂掉了,并且当进程异常,进程的退出码也是没有意义的,所以status的8到15位是0。虽然子进程挂掉了,但是父进程还是正常运行的,因为进程间具有独立性。

我们来看看野指针的情况

野指针在linux中报的错误是段错误,退出码是139

进程崩溃是因为先有的错误,然后被计算机中的某个地方发现了,如刚刚的野指针,指针p的值为空,指向0号地址,向0号地址写入,在页表转化时就识别到了错误,就进程终止了;除0会导致寄存器溢出,状态寄存器的溢出标记位识别到这个错误,直接就终止进程了,只要是错误,操作系统都会知道,操作系统知道了错误后,一般都是将进程杀掉,一般是通过信号将进程杀掉的。

野指针一般是进程收到了11号信号


除0收到的是8号信号

报错会有编译时报错、连接时报错、运行时报错,运行时报错一定是编译器的问题,运行时报错就会导致进程异常,因为编译、连接时我们的代码还没有形成进程,运行时已经形成进程了,一旦进程异常,操作系统就会使用信号取终止这个进程。

所以,status的退出信息中,既包含了退出码,也包含了终止信号(也叫退出信号),当终止信号是0时,表示进程是正常退出的,此时就可以根据退出码来判断进程的退出状态,当终止信号不为0时,说明进程异常了,是被操作系统使用信号杀掉的。所以,判断一个进程是否是正常退出,直接根据退出信号即可。

我们来同时获取status中的退出码和终止信号。先来看看进程正常退出的


再来看看进程异常的情况


我们将子进程改成死循环,然后使用信号来杀掉子进程


所以,有了waitpid之后,父进程创建子进程,子进程的执行情况父进程就一清二楚了

是否需要waitpid是不一定的

1. 父进程想要知道子进程的执行结果,就需要status

2. 父进程不想要知道子进程的执行结果,就不需要status,传入nullptr即可

但无论是否使用status,回收僵尸进程都是必须要做的,所以waitpid的调用是必须的

进程退出时,退出码和终止信号都在进程的PCB中,waitpid就是到进程的PCB中获取对应的信息

我们若想获取status中的退出信息,可以不用不需要自己使用位运算来获取,可以使用已经定义好的宏来获取

WIFEXITED(status):若为正常终止的进程,返回真。(查看一个进程是否正常退出)

WEXITSTATUS(status):若WIFEXITED为真,提取子进程的退出码。(查看进程的退出码)

我们来使用一下这两个宏

我们现在再来看两个问题

1. 我们之前说,父进程创建子进程是为了让子进程完成任务的,能不能举一个父进程让子进程完成任务的例子呢?

这个程序正在做的事情是一直往全局变量data写入数据。假设我们现在有一个需求:现在每隔十秒就需要将data中的数据备份一下,备份到文件当中,并且每次备份的文件还需要不一样。

此时很容易想到这样,但是现在要求备份的时候不能影响到主逻辑,也就是说,在备份的时候,data中的数据还必须增加。所以,备份的过就不能由这个进程来做了,而是应该创建一个子进程来完成备份的工作。



这样就成功做到了创建一个子进程来备份。子进程做备份时,父进程想怎么改就怎么改,因为有写时拷贝。并且备份时会操作文件,创建一个子进程可以让父进程更加的安全,因为进程之间具有独立性。文件以时间戳命名。

2. 阻塞与非阻塞问题

我们前面说了,我们想让子进程在备份时,不影响父进程的主逻辑,让其继续往data中写入数据,很明显,我们上面的代码并没有做到。因为父进程创建子进程后,父进程一直处于等待状态,直到子进程备份完,父进程才继续执行自己的代码,这显然是不符合预期的,也是不够高效的。


实际上,进程等待有2种方式,这就与waitpid的第三个参数有关了。第三个参数传0时,表示阻塞等待,传入WNOHANG时,表示非阻塞等待,这是一个宏。

什么是阻塞等待,什么是非阻塞等待?举两个例子帮助理解
阻塞等待:去宿舍楼下等朋友吃饭,打了电话叫朋友下来,但是电话一直不挂,直到朋友下来
非阻塞等待:去宿舍楼下等朋友吃饭,打了电话叫朋友下来,挂掉电话后,在朋友下楼的期间,我们每隔一段时间就打个电话催促一下,在没打电话的时候,我们可以做自己的事情。每一次打电话都是在检测朋友的状态,若朋友还没有下来,也就把电话挂了,过一会再打。
所以,非阻塞等待不是穿个WNOHANG就完了,需要由程序员自己循环调用非阻塞接口,完成轮询检测,相比于阻塞等待的好处是可以让父进程做更多自己的事情。

当传入第三个参数为WNOHANG时,返回值:
> 0:等待成功,返回子进程的PID

== 0:等待成功,但是子进程还没有退出

< 0:等待失败

我们来使用一下非阻塞的waitpid

当我们将程序跑起来后,父进程就会持续地检测子进程是否退出

此时我们使用信号将子进程杀掉


就可看到父进程等待子进程成功
非阻塞等待最大的好处是父进程在等待子进程的同时,可以做自己的事情,我们来模拟一下父进程在等待子进程的同时做自己的事情的情况


假设我们现在想让父进程在等待子进程的同时,执行这3个任务



可以看到,父进程在等待子进程的时候,也完成了自己的任务

进程程序替换

1. 快速见一见  ---  单进程

通过刚刚的程序,可以看到,创建出的子进程执行的代码全部都是父进程的一部分,如果子进程想要执行新的代码呢?
此时就需要使用到exec系列接口了,一共有7个接口。

我们先来看看最简单的execl。execl的第一个参数是可执行文件的完整路径,第二个参数是程序的名称(通常为第一个参数最后面那个部分),后面是带的选项,最后要以nullptr结尾。也可以将第二个参数开始理解成在命令行中怎么写,就怎么传参,最后以nullptr结尾。



可以看到,我们用自己的程序,去跑了ls -l -a,这就是进程程序替换

我们来看看进程程序替换的原理。

当我们./myexec后,就创建了进程,这个进程会有自己的PCB、虚拟内存、页表,并且程序的代码也要从磁盘加载到物理内存的代码段和数据段,当程序跑到execl时,就会使用里面填写路径的可执行程序的代码去替换原先的代码段、数据段,并且会将栈、堆清空,实现进程程序替换。

注意,ll是跑不起来的,即使能够跑起来,也与execl无关,而与操作系统有关,因为ll是一个别名,是存在于bash进程中的

我们先来看两个问题:
a. 进程替换是否创建了新的进程?
execl可执行系统的可执行程序,因此同样可以执行我们自己的可执行程序


我们将other.cc编译形成可执行程序other,这样就能获取一个进程的PID了

这样,我们只需要看打印出的两个PID是否相同即可,若相同,则没有创建新进程

可以看到,进程程序替换并没有创建新进程

b. execl的返回值是什么
我们直接试一下就知道了


会发现并没有打印出返回值。实际上,execl调用成功时是没有返回值的,execl调用成功时,程序的代码和数据都被替换了,是没有返回值的。失败则返回-1,所以,只要返回,就是失败。exit也是类似的。

所以,execl做的事情就是将可执行程序从磁盘放到内存,也就是前面所说的加载。

2. 快速见一见  ---  多进程


这个程序当子进程的退出码是1时,表示进程程序替换失败

所以,我们可以先使用cin让用户输入,然后根据用户的输入去使用fork创建子进程,就可以让子进程灵活地去执行命令。若我们再在最外层加上一个while(1),然后让用户使用cin输入,再fork,让子进程根据cin的内容去调用fork,这就是一个简易版本的命令行解释器shell。实际上,Linux中所有的程序都是fork,再execl跑起来的。所以,系统将我们的程序跑起来就是先fork,再execl。

一个进程是先有内核数据结构,还是先有代码和数据?
是先有内核数据结构的,因为一个进程必然是由其父进程创建的,也就是说一个进程必然先有其父进程,父进程会使用fork来创建子进程,子进程的PCB会继承于其父进程(部分会进行修改),但是子进程的代码和数据是与父进程完全一模一样的,直到execl替换之后。所以,一个进程是先有内核数据结构,然后再加载程序的。

其他语言的程序执行后也会变成进程。但与C/C++的有些不一样,其他语言写的代码是放在一个脚本文件中的,进行进程替换时,不是替换成脚本文件中的代码,而是替换成相应语言的解释器,然后将脚本文件传递给解释器,让解释器去解释脚本文件中的代码。

这是两个python解释器和两个shell脚本解释器。


分别写了一个python代码和一个shell脚本代码,现在运行它们

在这里,test.py和test.sh称为脚本文件,它们都有自己的解释器。我们以python为例,当我们在终端输入python test.py并按下回车时,命令行解释器bash会解析命令,识别出python是解释器,test.py是脚本文件,bash会调用fork创建子进程,该子进程将执行python test.py,子进程通过execvp()或类似系统调用加载命令行解释器(如/usr/bin/python),也就是将命令行解释器作为代码替换过来,并将test.py作为参数传递,python解释器读取test.py这个脚本文件,然后对其内容进行解释,并将结果打到命令行上。我们可以使用C/C++代码进行程序替换,替换成python代码


可以看到,程序替换的时候替换的并不是程序,而是解释器,程序是作为参数传递的。

C/C++不需要解释器的原因是,C/C++的代码通过gcc/g++编译成的可执行文件是二进制可执行文件,可以直接给CPU运行。而python并不是去编译源代码,而是使用python解释器去逐行解析源代码

所以,多进程与单进程是类似的,只是多进程在替换时,替换的是子进程的代码和数据而已
需要注意的是,子进程刚创建时进程与代码与父进程是相同的,当子进程发生替换后,此时就会发生写时拷贝。前面我们说的都是数据会发生写时拷贝,代码也是会的,只是我们前面的进程中父进程和子进程的代码是共享的,所以看不出来。所以,有了进程替换之后,进程就可以彻底独立了。

进程替换时堆、栈不用担心,若曾经使用过堆、栈,会对堆、栈进行初始化,若没有使用过,会重新形成。所以,并不会和父进程互相干扰
在Linux中,当对子进程进行进程替换(通过exec系列函数)时,子进程的堆区和栈区会被新程序的堆区和栈区替换,而不是与父进程共享。

3. 认识全部的接口

一共有7个接口


execl上面已经介绍过了,现在来看一下execv。第一个参数是带路径的可执行程序,第二个参数是想怎么执行,就是将execl的可变参数列表变成了一个指针数组。


所以,当我们在命令行中输入ls -la时,bash进程就会将输入的字符串打散形成一个指针数组,再使用fork创建子进程,并将这个指针数组表,以参数的形式传递给execv,至于execv的第一个参数,操作系统会根据指针数组知道可执行程序在哪里。
execv会将这个指针数组,以参数的形式传递给可执行程序,从而完成调用;
execl也是一样的,会将可变参数列表中的值形成一个指针数组,再传递给可执行程序。
所以,execl和execv本质并没有区别。带l是list的意思,带v是vector的意思

再来看看execlp。第一个参数是可执行程序的名字,不需要带路径。第二个参数与execl是相同的


为什么可以不带路径呢?
因为每一个进程都有环境变量,环境变量中有PATH,会根据第一个参数到PATH中查找。如果在PATH的多个路径中都有的话,是搜索到第一个
execvp与execlp是类似的

再来看看execvpe。前两个参数与execvp是相同的。第三个参数是环境变量。
一个进程被替换后,原先的环境变量还在吗?
还在的,因为环境变量具有全局性。也就是说,进程替换并不影响环境变量,虽然环境变量是数据,但是不会被替换,因为环境变量必须保证自己的全局属性。



我们将这个文件编译形成可执行程序,作用就是打印出一个进程的环境变量

我们使用env打印出bash进程的环境变量

再运行other,打印出bash创建的子进程的环境变量

我们再创建一个bash的子进程,让这个子进程创建子进程,并将子进程的程序替换成other


可以看到,这3个环境变量是完全相同的。所以,进程替换后,环境变量是没有变化的

execvpe是可以自己传入环境变量的


所以,程序替换时,传入环境变量就可以改变替换后进程的环境变量

关于环境变量:
1.让子进程继承父进程全部的环境变量
2.如果要传递全新的环境变量(自己定义,自己传递)
3.新增环境变量呢?

getenv:获取环境变量
putenv:修改或创建一个环境变量,那个进程调用它,就是修改或创建这个进程的环境变量 

我们此时给父进程增加了一个环境变量 

可以看到,子进程也有这个环境变量,因为当我们没有用execvpe给子进程新的环境变量时,子进程的环境变量继承与其父进程。但是,bash进程就不知道这里的父进程有新增一个环境变量

除非我们想给子进程一个全新的环境变量,否则不会主动传。若想减少环境变量,直接将环境变量置为空即可

所以,这几个接口的区别在于参数的区别,以满足不同的应用场景。前6个都是C标准库封装的接口,只有第7个是真正的系统调用。前6个都是对第七个的封装。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值