Linux--5.进程的控制

当我们在了解了进程的基本概念之后,我们接下来需要对进程进行更深层次的学习,在这一章我们来认识一下进程的控制,理解进程底层相关的操作

进程的创建

fork函数初识

我们之前对于fork函数的认知仅仅停留在了在父进程中开启子进程的层面,今天我们来对fork进行更深层次地剖析

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

#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回 0 ,父进程返回子进程 id ,出错返回-1
进程调用 fork ,当控制转移到内核中的 fork 代码后,内核做
分配新的内存块和内核数据结构给子进程(内存,PCB,页表等)
将父进程部分数据结构内容拷贝至子进程(子进程是以父进程为模板进行拷贝)
添加子进程到系统进程列表当中
fork 返回,开始调度器调度

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

 

 我们可以看到的是,父进程返回了子进程的pid,子进程返回了0

首先我们需要澄清一个概念:为什么fork会有两个返回值?

其实内核中fork()是一个函数,里面含有创建进程的代码,例如:

pid_t fork{
                //创建进程代码

                //最后return 

                return   pid;

        }

而因为在返回前已经将子进程创建好了,子进程也会执行这条return语句,再加上原来父进程的return,所以就会有两个return

而我们给子进程返回0,父进程返回子进程的pid目的是为了让父进程方便找到子进程,因为子进程可以有多个,父进程只能有一个

现在我们再来看看我们的结果

 我们可以看到,虽然有两个进程在执行,但是Now pid却只执行了一次,这是为什么呢?

其实原因是父进程先执行,打印了fork前的代码,当执行到fork时,才创建了一个子进程,此时两个进程共享代码,if else进行分流,分别打印自己的语句,最后走到return返回结束

所以我们可以看到,其实我们的子进程是从fork之后才以父进程为模板进行拷贝的,所以当我们的父进程运行到fork时,此时父进程的PCB中的程序计数器也在fork之后的位置,拷到子进程中不会改变,所以我们的子进程不会打印fork之前的语句

所以, fork 之前父进程独立执行, fork 之后,父子两个执行流分别执行。注意, fork 之后,谁先执行完全由调度器决定。

写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本
代码共享:父子进程PCB中保存的代码地址相同

 我们可以看到,我们的数据段虽然是父子进程私有的,但其实开始时是子进程直接拷贝的父进程的数据,而后如果需要自己对数据进行重新写入,此时才重新改变页表与物理内存的对应关系,在物理内存中重新开辟一小段存储改变的数据,但在这个过程中虚拟内存不改变

上面的过程中我们可以分析两点:

1.写时拷贝:其实写时拷贝就是当父子进程需要对数据进行写入时,才进行一系列的开辟空间,改变页表对应关系,拷贝数据,写入数据,这个拷贝可以理解为即用即拷

2.为什么要有写时拷贝:本质上还是为了节约操作系统的空间与提高效率,因为我们创建进程时产生的的数据是很多的,有一些并不会使用,如果我们每次创建进程都提前将所有的数据进行拷贝,那么创建进程的效率会很低,浪费时间,站在节约空间与时间的角度,写时拷贝更高效

3.写时拷贝的数据:写时拷贝其实针对的是需要改变,进行写入的数据,而不会对原来不变的数据进行拷贝,我们写一小段数据,改一小段映射关系,这样在我们在用我们的虚拟地址进行只读操作时依然是连续的,且很高效

fork常规用法

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

fork调用失败的原因

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

进程终止

进程退出场景

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

进程常见退出方法

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

 从main函数return返回

return n//n就代表了退出码

调用exit

#include<unistd.h>//头文件
void exit(int status)//status 退出码
exit 最后也会调用 exit, 但在调用 exit 之前,还做了其他工作:
1. 执行用户通过 atexit on_exit 定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写入
3. 调用 _exit

 

调用_exit

#include<unistd.h>//头文件
void _exit(int status)//status 退出码

说明:虽然 status int ,但是仅有低 8 位可以被父进程所用。所以 _exit(-1) 时,在终端执行 $? 发现返回值
255
总的来说:其实我们的exit与_exit的区别在于有无对资源清理与刷新缓冲,我们来观察下面的代码
 

 

 当我们直接用exit时,在exit之前的语句可以打印出来,进行了缓存刷新

 

 我们使用_exit操作系统就不会对缓存进行刷新,直接退出了

 注:我们在hello fork后面不能加\n,因为\n也会对缓存进行刷新,影响结果

事实上,我们的return,在本质上也是调用的exit函数
异常退出:
ctrl + c ,信号终止

进程等待

进程等待必要性

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

总的来说,我们必须让父进程对结束的子进程完成“收尸”工作,否则就可能引起内存泄漏,因为只有父进程可以对死掉的子进程进行信息采集与资源回收,所以我们就需要让父进程在子进程完成之后再完成,从而就出现了进程等待

进程等待的方法

wait方法

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:
成功返回被等待进程 pid ,失败返回 -1
参数:
输出型参数,获取子进程退出状态 , 不关心则可以设置成为 NULL

 我们可以看到,我们将子进程设置为3秒后退出,子进程变为了僵尸进程,而后父进程5秒后执行wait命令,对其进行了清理,最后只剩下了父进程,由此可见,wati命令可以回收子进程的资源,防止资源泄漏

我们再来看一段代码

 

 我们这段代码其实就是设置wati让父进程等待,进入阻塞状态,等待子进程结束后再执行后面的代码

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 ,子进程存在且正常运行,则进程可能阻塞。
如果不存在该子进程,则立即出错返回。

 

 

获取子进程status

wait waitpid ,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。
如果传递 NULL ,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status 不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 16 比特位):

 

 我们可以看到,当我们的进程退出时,,需要先判断是否正常退出,判断status第7位(去掉了core dump这一位)是否为0,(status&0x7f)

1.等于0则正常退出,再获取退出码((status>>8)&0xff)

退出码等于0,说明代码运行结果正常,不等于0则说明代码运行结果不正确,退出码的不同代表着不同的错误,看具体业务

2.不等于0,异常退出,获取终止信号(status & 0x7f)

 异常退出返回8

正常退出返回0

 

正常退出返回10 

两种可以直接获取退出信息的宏:

  • WIFEXITED(status):若为正常终止子进程返回真。(查看进程是否正常退出)
  • WEXITSTATUS(status):若 WIFEXITED(status)为正(正常退出),获取退出码。(查看进程退出码)

进程阻塞式等待实现

 进程非阻塞式等待实现

 这两种进程等待方式我们可以类比为,爸妈一直看着你写作业与爸妈隔一会看一下个一会看一下,总之,非阻塞式实现就是一直监控子进程,退出就进行回收资源,而阻塞式则是时间段式进行监控,这两种方式从资源消耗来看是阻塞式更节约资源,不过站在我们操作系统中,大部分的进程都是非阻塞式等待的,原因是这种方式较为简单

总结:

进程等待是什么?

父进程通过调用wait与waitpid系统接口,等待子进程状态的一种现象

为什么要等待进程?

1.防止子进程进入僵尸状态,致使内存泄漏

2.获取子进程的退出结果

怎么等待进程?

wait/waitpid

进程程序替换

替换原理

fork 创建子进程后执行的是和父进程相同的程序 ( 但有可能执行不同的代码分支 ), 子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种exec 函数时 , 该进程的用户空间代码和数据完全被新程序替换 , 从新程序的启动例程开始执行。调用exec 并不创建新进程 , 所以调用 exec 前后该进程的 id 并未改变

 替换函数

其实有六种以 exec 开头的函数 , 统称 exec 函数
#include <unistd.h>`
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[])
 

 我们替换函数的参数列表是有严格要求的,第一列参数永远是需要替换程序的绝对路径或者文件名,之后到envp环境变量前都是想要怎么执行它

函数解释

这些函数如果调用成功则加载新的程序从启动代码开始执行 , 不再返回。
如果调用出错则返回 -1
所以 exec 函数只有出错的返回值而没有成功的返回值。

 

 注:最后envp之前要以NULL结尾

命名理解

l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : p 自动搜索环境变量 PATH
e(env) : 表示自己维护环境变量

 其实只有我们的execve是真正的系统调用,其他最终调的都是execve

 补充:一般的exec函数都是子进程去执行的,这样不会破坏父进程,因为进程具有独立性

模拟实现xshell

启动一个父进程,父进程从标准输入里读取用户输入的内容
父进程区分用户输入的内容当中那部分是命令,那部分是命令行参数
ls -a (ls 是命令 -a 是命令行参数)
父进程fork出子进程后,要让子进程进行进程程序替换,执行用户输入的命令。父进程进行进程等待,等到子进程结束
等到子进程退出之后,则进入循环获取用户输入
输入格式: ls -a(中间是空格)

      #include <stdio.h>                                                                                                                                                                                                                                       
    2 #include <stdlib.h>
    3 #include <unistd.h>
    4 #include <string.h>
    5 #include <fcntl.h>
    6 #define MAX_CMD 1024
    7 char command[MAX_CMD];
    8 int do_face()
    9 {
   10   memset(command, 0x00, MAX_CMD);
   11   printf("minishell$ ");
   12   fflush(stdout);
   13   if (scanf("%[^\n]%*c", command) == 0) {
   14     getchar();
   15     return -1;
   16   }
   17   return 0;
   18 }
   19 char **do_parse(char *buff)
   20 {
   21   int argc = 0;
   22   static char *argv[32];
   23   char *ptr = buff;
   24   while(*ptr != '\0') {
E> 25     if (!isspace(*ptr)) {
   26       argv[argc++] = ptr;
E> 27       while((!isspace(*ptr)) && (*ptr) != '\0') {
   28         ptr++;
   29       }
   30     }else {
E> 31       while(isspace(*ptr)) {
   32         *ptr = '\0';
   33         ptr++;
   34       }
   35     }
   36   }
   37   argv[argc] = NULL;
   38   return argv;
   39 }
   40 int do_exec(char *buff)
   41 {
   42   char **argv = {NULL};
   43   int pid = fork();
   44   if (pid == 0) {
   45     argv = do_parse(buff);
   46     if (argv[0] == NULL) {
   47       exit(-1);
   48     }
   49     execvp(argv[0], argv);
   50   }else {
E> 51     waitpid(pid, NULL, 0);
   52   }
   53   return 0
exec/exit 就像 call/return
一个 C 程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return 系统进行通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。 Linux 鼓励将这种应用于程序之内的模式扩展到程序之间。如下图

 

一个 C 程序可以 fork/exec 另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过wait &ret )来获取 exit 的返回值。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值