自学内容网 自学内容网

Linux:进程控制

前言

  前面我们学习了进程的一系列概念,接下来就继续来学习有关进程的操作。

1.进程创建

1.1 初识fork函数

  在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
//返回值:自进程中返回0,父进程返回子进程id,出错返回-1

  进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表当中
  • fork返回,开始调度器调度

  当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。

int main()
{
 pid_t pid;
 printf("Before: pid is %d\n", getpid());
 if ( (pid=fork()) == -1 )perror("fork()"),exit(1);
 printf("After:pid is %d, fork return %d\n", getpid(), pid);
 sleep(1);
 return 0;
}

运行结果:
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0

  上一篇文章的图,偷个小懒。

  fork函数返回值:

  • 子进程返回0
  • 父进程返回的是子进程的pid

1.2 写时拷贝

  通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:在这里插入图片描述
  当父进程形成子进程,子进程进行写入时,发生写时拷贝,此时需要重新申请空间,修改页表,进行拷贝。但是这个过程中子进程正在写入,操作系统是如何识别此时需要进行写时拷贝呢?

  其实在初始化子进程的页表之前,父进程会先将自己的页表权限全部改成只读权限,不管你是什么数据,都只能读,不能写,然后再创建子进程。
  而当子进程进行数据写入时,由于页表的权限是只读,因此就会出错。而此时由于出错,操作系统就可以介入了,操作系统就会过来看看怎么个事,然后发现不是真的出错了,而是触发了进行重新申请内存拷贝内容的策略机制,然后才会真正的将数据进行写入。
  因此页表权限出错实际有两种情况:

  1. 真的是因为权限出错了。
  2. 不是出错了,而是触发了进行重新申请内存拷贝内容的策略机制(写时拷贝)

  为什么是写时拷贝呢?也就是说为什么要先拷贝再修改呢?

  这是因此一块空间可能并不是只存储一个数据,有可能我们进行的只是某一个数据的修改,所以需要先拷贝再修改,而不是之间在新空间之间进行写入。(覆盖与拷贝+修改是不一样的)

1.3 fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

1.4 fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

2.进程终止

  我们平时写的main函数最后都会写一句return 0,这个0是什么意思呢?为什么要返回0呢?而我们创建子进程也会为了协助父进程完成一些工作,那么我们如何知道子进程完成的怎么样呢?

  因此每个进程都需要给父进程返回自己的完成信息。有三个退出信息:

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止
      

  在操作系统中,使用了一系列的数字代表了不同的退出信息,比如我们平时写的return 0,就是一种退出信息,它代表着成功退出。那我们就来看看退出信息都有什么:在这里插入图片描述
在这里插入图片描述
  Linux中的退出信息有很多,strerror函数是用来专门将每个数字所代表的退出信息显示出来,小伙伴们可以通过strerror来自己查看一下。

  但是这是有return的情况,我们可以知道退出信息是什么,那么对于没有return的进程呢?

  我们可以通过echo $?指令来查看最近退出进程的退出信息:在这里插入图片描述
  上一个执行的是clear,它成功执行了,所以查看的t退出码就是0。

  所以我们平时直接写return 0是有一些小问题的,应该根据不同的现象返回不同的值,通过什么现象呢?
  我们知道C语言定义了一个全局变量errno(错误码),专门用来保存退出信息的。

  错误码和退出码:

  • 错误码通常是衡量一个库函数或者是一个系统调用一个函数的调用情况
  • 退出码通常是一个进程退出时,它的退出结果。

在这里插入图片描述在这里插入图片描述

  进程常见退出方法

  正常终止(可以通过 echo $? 查看进程退出码):

  1. 从main返回
  2. 调用exit
  3. _exit

  异常退出

  • ctrl + c,信号终止

  前面说的都是代码正常运行完毕了,而如果一个进程因为代码错误异常终止,那么此时它的退出码还有意义吗?

  如果一个代码出了异常,它的退出码是没有意义的。举个例子:比如小明取考试,考试完了,考的不好,那么它的家人就会问没考好原因是什么,而如果是考试作弊导致0分,它的父母还会关系为什么你考了0分吗?
  而一个进程如异常了,操作系统机会将其转化为一种信号,从而将它终止。所以异常终止的进程一般是不会运行完的,在中途就会被操作系统终止。在这里插入图片描述
  进程出异常的本质是,进程收到了对应的信号,自己终止了。所以一个进程是否出异常,我们只需要看有没有收到信号(0就代表没有收到信号,表示正常退出)。
  所以父进程只需要检查子进程是否收到信号以及它的退出码,来判断一个子进程是否成功运行。

  进程退出除了return,还可以用exit():
在这里插入图片描述
在这里插入图片描述
  那么它们有什么区别呢?我们来看代码:

  1. 示例一:在这里插入图片描述
    在这里插入图片描述
  2. 示例二:
    在这里插入图片描述
    在这里插入图片描述

  由此我们可以看出,exit是直接终止掉整个进程,不会继续执行exit后面的代码,而return不会,它还会继续执行,直到main运行完成。

  而从上面我们还看到,进程退出的方法还有一个_exit(),那么它与exit(),又有什么区别呢?

  1. 示例一:在这里插入图片描述在这里插入图片描述  我们知道加上\n会直接刷新缓冲区,因此它会直接显示出来
  2. 示例二:在这里插入图片描述在这里插入图片描述  去掉\n后,它三秒之后因为进程结束才会刷新出来。
  3. 示例三:在这里插入图片描述在这里插入图片描述  我们发现它不会没有打印出内容。

  结论:

  1. exit是库函数,_exit是系统调用
  2. exit终止进程的时候会自动刷新缓冲区,_exit终止进程的时候不会刷新缓冲区。

在这里插入图片描述
  由这个图我们就可以推断出缓冲区一定不在操作系统内部,否则的话_exit应该也是可以刷新缓冲区的。

3.进程等待

  进程等待的重要性:

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
  • 最后,父进程派给子进程的任务完成的如何,我们需要知道。如:子进程运行完成,结果对还是不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息

  什么是进程等待?

  通过wait/waitpid的方式,让父进程(一般)对子进程进行资源回收的等待过程。

  为什么要进行等待?

  1. 解决子进程僵尸问题带来的内存泄露问题。
  2. 父进程为什么要创建子进程?想让子进程来完成任务,父进程想要得知子进程任务完成的如何,就需要通过进程等待的方式,获取子进程的退出信息。

3.1 wait

在这里插入图片描述
  使用方法:

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:
     成功返回被等待进程pid,失败返回-1。
参数:
     输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
  1. 示例一:在这里插入图片描述  子进程会运行五秒,父进程会执行十秒。五秒后子进程结束,但是父进程还在睡眠,无法回收子进程,子进程就会变为僵尸状态,再过五秒后,睡眠结束,由wait回收子进程的资源。在这里插入图片描述
    在这里插入图片描述
  2. 示例二:在这里插入图片描述  父进程不睡眠,也就是子进程会运行五秒,而父进程会直接运行完毕,那么父进程会在wait等待子进程呢?还是直接运行结束呢?在这里插入图片描述在这里插入图片描述  由此我们可以得出结论:如果子进程根本就没有退出,父进程必须在wait上进行阻塞等待,知道子进程变成僵尸状态,wait自动回收,结束。

3.2 waitpid

  使用方式:

pid_ t waitpid(pid_t pid, int *status, int options);
  返回值:
  当正常返回的时候,waitpid返回收集到的子进程的进程ID(等待成功);
  如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0(等待是成功的,但是对方还没有退出);
  如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在(等待失败);
参数:
pid:
  Pid= -1,等待任一个子进程。与wait等效。
  Pid>0,等待其进程ID与pid相等的子进程。
status:
  WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
  WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
  WNOHANG:若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

  • 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
  • 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
  • 如果不存在该子进程,则立即出错返回。在这里插入图片描述

  上面的看的不太明白,我们来看实际操作。

  1. 示例代码:
    在这里插入图片描述在这里插入图片描述
      那status代表什么意思呢?我们来看下面的图解:
    在这里插入图片描述
      因此第二个参数实际上是用来返回子进程的退出信息的。如此,父进程就可以拿到子进程的退出信号和退出码了:在这里插入图片描述在这里插入图片描述
      比如我们来一个错误,来看看这里的信号:在这里插入图片描述在这里插入图片描述
      这样我们就可以看到错误信号是8,再查一下就知道这是除零错误。

  wait和waitpid是如何知道子进程的退出信号和退出码的呢?

  子进程退出的时候,要将状态修改为Z,同时会将自己的退出信号和退出码写入到自己的PCB中,而wait和waitpid就会从该子进程的PCB中获得这个进程的退出信号和退出码。

  为什么不用全局变量获取子进程的退出信息?而是使用系统调用??

  进程具有独立性,父进程无法直接获得子进程的退出信息(写时拷贝)。

  我们还有一个options参数没说,我们来看一下。

  如果它的值是0的话,就是阻塞等待。如果是WNOHANG的话,就是以非阻塞的方式等。
  顾名思义就是不阻塞,遇到子进程还没有执行完成,不去等它,而是直接返回,等过一段时间再来访问这个子进程,如果还没有执行结束,就再等一段时间再访问子进程。这就是轮询+非阻塞的方案进行等待进程。
  好处:因为没有阻塞,父进程就可以顺便做一下自己的占据时间并不多的事情。

在这里插入图片描述
在这里插入图片描述
  我们就会发现,当子进程还没结束的时候,父进程可以做自己的事情,如果是阻塞等待的话,那么只有子进程退出了,父进程才会继续做自己的事情。

4. 进程程序替换

  我们现在所创建的所有子进程,它执行的代码都是父进程的代码的一部分,如果我们想让子进程执行新的程序呢?执行全新的代码和访问全新的数据,不在和父进程有瓜葛,我们要怎么办呢?那就是通过程序替换的方法。
  我们先来看看什么是程序替换,我们通过execl函数先来看一看:
在这里插入图片描述
  这个函数的第一个参数是路径,后面是可变参数列表,意思是你可以随意传多个参数,那我们就传入ls指令的路径,以及它的一些选项。
在这里插入图片描述
  我们发现运行我们我们自己写的代码,结果它就跑去执行系统中的ls指令。
  我们将execl成为程序替换函数:
在这里插入图片描述
  但是不知道大家发没发现:
在这里插入图片描述
  这一条语句并没有执行,这是为什么呢?
在这里插入图片描述
  这是单进程的情况,我们再来看看多进程下的情况。
在这里插入图片描述
在这里插入图片描述
  这就进一步验证了,当我们切换进程时,并不是创建新的进程来执行(但是其中PCB的有些属性可能会被修改,比如虚拟地址的映射,因为两个不同的程序,它的虚拟地址的区域划分时不同的)。
  在单进程中是直接将代码和数据进行覆盖,但是在多进程下,由于要保证父子进程之间的独立性,所以当子进程修改数据时,是会发生写时拷贝的。在之前讲,由于子进程只是修改数据,所以只会对数据进行写时拷贝,而现在是代码和数据都要修改,所以代码和数据都会进行写时拷贝,然后再用新进程的代码和数据进行覆盖。
  那么子进程是怎么知道要从新的程序的最开始执行?

  这是因为在.exe可执行程序的中有一个entry的字段,记录了可执行程序的入口地址,execl函数就会找到entry,然后从程序的开头执行。

  那我们来就完整的看一下exec*系列的函数:
在这里插入图片描述
  我们一个一个来看。

  • execl:

在这里插入图片描述

  • execlp:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

  • execv:在这里插入图片描述在这里插入图片描述在这里插入图片描述

  • execvp在这里插入图片描述在这里插入图片描述在这里插入图片描述
      那么进程替换可以替换我们自己写的程序吗?
    在这里插入图片描述
      我们可以通过我们写的C程序调用C++可执行程序吗?在这里插入图片描述在这里插入图片描述
    在这里插入图片描述
      我们发现成功了。exec* 叫做进程程序替换,不管是什么语言写的代码,在运行时都会变成一个进程,所以才可以使用C语言调用C++的程序(java,脚本,python都是可以的)。这已经不是语言层面的角度了,而是系统层面的角度。
      exec*就是程序加载到内存中的一个重要的功能,它也可以叫做程序加载器。

  • execvpe
    在这里插入图片描述  不难看出,最后一个参数就是之前所学习过的环境变量。
      前面我们学习了如何在命令行下添加自己的环境变量,那么要如何在程序中添加自己的环境变量呢?在这里插入图片描述  可以通过putenv函数来完成在程序中添加自己的环境变量。在这里插入图片描述 h
    在这里插入图片描述
      通过观察我们发现确实是添加上了。在这里插入图片描述
      但是我们发现在bash中却看不到这个环境变量。所以我们得出结论,环境变量只会被子进程继承,而不会被父进程继承。
    在这里插入图片描述
      并且我们在bash中添加的环境变量,会被子进程test.c继承,但是在其中发生了程序替换,切换成了mytest.cc,依旧可以打印出这个环境变量,这就说明环境变量被子进程继承是一种默认行为,不受程序替换的影响。   为什么呢?进程替换不是会将原来的数据和代码都进行覆盖吗?
    为什么环境变量不会受到程序替换的影响呢?这是因为程序替换,只替换新程序的代码和数据,环境变量不会被替换。可以想象成新程序中并没有环境变量,所以对于之前程序的环境变量不会去覆盖。
      来看看它的使用:
    在这里插入图片描述在这里插入图片描述在这里插入图片描述

  前面介绍的都是函数接口,实际上它们在底层所调用的都是execve这个系统调用。
在这里插入图片描述
  既然都是它,那为什么还要弄那么多函数接口呢?主要还是为了满足各种调用的场景。

总结

  本章主要讲解了进程是如何进行工作的,尤其是进程替换部分,可以让大家对进程的工作有一个全新的理解。
  如果大家发现有什么错误的地方,可以私信或者评论区指出喔。我会继续深入学习Linux,希望能与大家共同进步,那么本期就到此结束,让我们下期再见!!觉得不错可以点个赞以示鼓励!!


原文地址:https://blog.csdn.net/qq_62321047/article/details/136280786

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!