深度理解进程 ---- 进程创建、进程终止、进程等待、进程替换 -> Shell的底层原理

目录

进程创建

引入一个小知识

fork函数的认识

写时拷贝

fork函数常规用途

fork函数调用失败的原因

进程终止

进程终止场景

进程终止的常见方法

正常终止

[exit() 与 _exit()的核心区别](#exit() 与 _exit()的核心区别)

异常终止

退出码

进程等待

进程等待必要性

进程等待的方法

1.wait方法

2.waitpid方法

[wait 与 waitpid 的对比表](#wait 与 waitpid 的对比表)

进程替换

进程替换的诞生原因

进程替换函数

理解进程替换函数的返回值

快速记忆进程替换函数

[系统调用函数 execve](#系统调用函数 execve)

Shell的底层原理


进程创建

引入一个小知识

在Linux中fork函数是一个非常重要的函数,它从已经存在的进程中创建一个新进程来完成特定的事情。新进程被称为子进程,已经存在的进程被称为父进程。

由此可知,在 Linux 下我们自己写的可执行程序要想执行起来,就必定会成为一个子进程,那么我们执行的可执行程序的父进程是谁呢?

cpp 复制代码
  1 #include <stdio.h>
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6     printf("我的父进程pid:%d,我的pid:%d\n", getppid(), getpid());                                                                
  7     return 0;
  8 }

我的父进程pid:32747,我的pid:706

我的父进程pid:32747,我的pid:707

我的父进程pid:32747,我的pid:707

对上述代码的多次运行,可以看到一个现象,父进程pid永远不变,子进程pid不断变化

对于子进程pid不断变化,很好理解,因为操作系统会创建其他的进程占有了原来的pid,从而分配其他的pid,对于父进程pid永远不变,那么我们执行的可执行程序是由同一个父进程来管理的,那这个父进程是谁呢?

通过这张图,我们可以很好地看到,原来我们执行的可执行程序都是通过bash进程来创建子进程完成特定任务的,我们还可以看到,我们在Linux下使用的命令也都是一个进程,并且父进程也为bash进程。

fork函数的认识

返回值:对于子进程返回0,对于父进程返回子进程 id ,创建进程失败返回 -1

在 深度理解进程 ---- 进程状态 这篇文章中,我们简单理解了三个问题。

  1. 为什么要给⼦进程返回 0 ,⽗进程返回⼦进程 pid ?

  2. 为什么⼀个函数 fork 会有两个返回值?

  3. 为什么⼀个 id 即等于 0 ,⼜⼤于 0 ?

那让我们再深刻地重新理解一下这三个问题

对于问题1,因为一个父进程可以创建多个子进程,但一个子进程只能拥有一个父进程,可以得出父进程 : 子进程 = 1 : n ,且父进程要对子进程的退出信息做管理,那么父进程就必须知道它所创建的子进程,所以给父进程返回子进程 pid ,子进程返回0,是一个约定的标识,来表示我是子进程。

对于问题2和问题3一起说

进程调用fork,执行到fork函数内部中,内核做了什么?

分配新的内存块和内核数据结构给子进程

将父进程部分数据结构内容拷贝到子进程

添加子进程到系统进程列表中

fork返回,开始调度器调度

fork之前父进程独立执行,fork之后父子进程分别执行。

注意:fork之后,父子进程都处于就绪状态,有操作系统的调度器决定哪个进程先获得CPU时间片开始执行,没有任何机制保证父进程一定先运行,也没有办法强制让子进程先运行。

回答问题2和3

子进程刚创建时,子进程和父进程的代码和数据共享,但fork函数内部会进行判断,如果是父进程返回子进程pid,如果是子进程返回0,此时数据被修改,会发生写时拷贝。所以fork函数会有2个返回值,导致父子进程得到id的值也不同了。

写时拷贝

写时拷贝是一种延迟拷贝技术,目的是避免不必要的内存复制,提高效率。

通常,父子代码共享,父子不进行对数据的修改时侯,数据也是共享的,但是当任意一方准备写入数据时,便以写时拷贝的形式,使父子进程各有一份独立的数据。所以父子进程各自执行,互不影响,完成了进程的独立性。

fork函数常规用途

1.一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。

2.一个进程要执行一个不同的程序。例如子进程通过exec系列函数进行进程替换,来完成独立于父进程的任务。

fork函数调用失败的原因

1.系统中进程数量太多,内存剩余空间很少。

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


进程终止

进程终止:本质是释放系统资源,就是释放创建一个进程所申请的内核数据结构和对应的代码和数据。

进程退出:主动、正常结束,程序自己执行完、调用 exit() / return 等结束。

进程终止场景

1.代码运行完毕,结果正确

2.代码运行完毕,结果错误

3.代码异常终止

进程终止的常见方法

正常终止

注意:可以通过echo $? 查看最近一个进程的退出码

1.从main函数返回

2.调用exit()函数(库函数,底层会调用 _exit()函数)

3.调用_exit()函数(系统调用)

exit() 与 _exit()的核心区别
特性 exit()(库函数) _exit()(系统调用)
缓存区处理 关闭所有打开的文件描述符,会刷新用户级缓存区 同样关闭文件描述符,不会刷新用户级缓存区
适用场景 正常程序退出,需要保证数据写入磁盘 异常终止,或需要立刻结束进程的场景

异常终止

  1. ctrl + c

  2. 信号终止

退出码

退出码:对于一个进程,进程执行结束后,用来记录最后一次执行命令的状态的数值就叫做退出码(退出状态),进程退出时,会把退出码保存到自身 task_struct 中,然后父进程通过系统调用来获取子进程的退出信息,从而判断子进程是否正常完成任务。

约定规则:

退出码0:执行成功、正常结束

非0任意值:执行失败、异常退出

退出码取值范围:0, 255

在Linux系统中,每一个退出码都被规定为对应一个错误,可以采用下列方式来获取退出码对应的描述。

cpp 复制代码
  1 #include <stdio.h>
  2 #include <string.h>
  3 
  4 int main()
  5 {
  6     for(int i = 0; i <= 255; ++i)                                                                                                
  7     {
  8         printf("error[%d]->%s\n", i, strerror(i));
  9     }
 10     return 0;
 11 }

进程等待

进程等待必要性

1.子进程退出,父进程如果对子进程的退出信息不处理,待父进程退出后,子进程的task_struct没有回收,此时子进程就变成了僵尸进程,从而造成内存泄漏。

2.对于僵尸进程,除了重启操作系统,没有任何办法对僵尸进程的task_struct进行回收,kill -9 也不起作用。

3.对于父进程,父进程分配给子进程任务,父进程肯定要知道子进程是否正常完成任务。

4.父进程通过进程等待,获取子进程退出信息,释放子进程资源

进程等待的方法

1.wait方法

功能:等待任意一个子进程退出

头文件和系统调用

#include <sys/types.h>

#include <sys/wait.h>

pid_t wait(int *status);

参数:

输出型,获取子进程的退出码,如果父进程不关心子进程的退出码,则可以设置为NULL

函数返回值:

等待子进程退出成功,返回子进程的pid,失败返回-1

2.waitpid方法

功能:等待子进程退出

头文件和系统调用

#include <sys/types.h>

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

参数:

pid:

pid = -1 等待任意一个子进程,与wait一样。

pid = 指定子进程pid 等待指定子进程

status:输出型参数

WIFEXITED(status):判断子进程是不是正常退出(退出码不一定为0)

WEXITSTATUS(status):获取子进程的退出码

WIFSIGNALED(status):判断子进程是否被信号杀死

WTERMSIG(status):获取杀死子进程的信号编号

cpp 复制代码
// 宏的主要使用方法

if (WIFEXITED(status)) 
{
    // 进程正常退出,获取退出码
    printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
} 
else 
{
    // 非正常退出(被信号杀死),获取终止信号
    printf("子进程被信号杀死,信号编号:%d\n", WTERMSIG(status));
}

options:

options = 0 表示阻塞等待(父进程会一直卡在wait这里,直到对应子进程退出才会继续执行,适合父进程没有其他任务,只需等待子进程结束的场景)

options = WNOHANG 表示非阻塞等待(父进程扫描一下子进程列表,如果子进程退出,则回收子进程,如果没有子进程退出,则父进程继续向下执行代码),常用于父进程需要一边处理自己的任务,一边定期回收子进程的长期,避免父进程阻塞在waitpid上

返回值

>0 : 成功回收的子进程PID

= 0:有子进程还在运行,没有退出(一般用于非阻塞等待)

-1:出错(比如没有子进程可以等待或者等待的子进程PID不存在)

wait 与 waitpid 的对比表

特性 wait() waitpid()
等待对象 任意一个子进程 可指定 PID ,或任意子进程
阻塞方式 只能阻塞等待 支持阻塞 / 非阻塞(WNOHANG)
灵活性 低 高,可自定义等待策略
底层实现 封装了 waitpid(-1, &status, 0) 系统调用,更底层

进程替换

进程替换的诞生原因

fork()创建子进程之后,子进程完全拷贝父进程的代码、数据、堆栈等,子进程只能执行父进程的代码片段,无法直接执行一个全新的程序。

假如我用fork()创建了一个子进程,我不想让子进程执行父进程的代码,我想让它执行一个全新的程序,那必须用进程替换来解决,使子进程抛弃自己原来的代码和数据,加载一个全新的程序到内存中。

进程替换:通过特定的接口,用一个新程序,替换当前进程的代码段、数据段、堆栈段,进程的PID不变,PPID、文件描述符等属性保持不变。

注意:

1.PID不改变

2.程序完全替换

3.没有创建新进程,只是换程序执行

进程替换函数

#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 execvpe(const char *file, char *const argv\[\], char *const envp\[\]);

注意,argv数组必须以NULL为结尾,否则会导致越界访问

理解进程替换函数的返回值

1.这些函数如果调用新程序成功,则加载新的程序从新的程序的启动代码开始执行,不再返回,exec后面的代码不会被执行

2.如果调用出错,则返回-1,继续执行后面的代码

3.exec系列函数只有出错的返回值,没有成功的返回值

cpp 复制代码
// 正确写法:必须处理 exec 失败的情况
execl("/bin/ls", "ls", "-al", NULL);
// 只有 exec 失败,才会执行下面两行
perror("execl 调用失败");
// 必须主动退出,否则会继续执行原程序代码
exit(1); 

快速记忆进程替换函数

l(list):表示参数采用列表

v(vector):参数采用数组

p(path):表示自动搜索环境变量PATH(也支持全路径的写法)

e(env):表示自己维护环境变量

cpp 复制代码
#include <unistd.h>

int main()
{
    char *const argv[] = {"ls", "-al", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
    
    // 列表形式传参
    execl("/bin/ls", "ls", "-al", NULL);

    // 带p的,可以使用环境变量PATH,无需写全路径

    execlp("ls", "ls", "-al", NULL);

    // 带e的,需要自己手动传递环境变量
    execle("/bin/ls", "ls", "-al", NULL, envp);
    
    // 数组形式传参
    execv("/bin/ls", argv);

    execvp("ls", argv);

    execvpe("/bin/ls", argv, envp);

    return 0;
}

你可能会好奇:为什么会有6个exec系列函数呢?

实际上,只有execve()是Linux内核提供的真正系统调用,上述6个函数均为C库提供的封装,它们的底层都会调用execve()。

系统调用函数 execve

NAME

execve - execute program

SYNOPSIS

#include <unistd.h>

int execve(const char *filename, char *const argv\[\], char *const envp\[\]);


Shell的底层原理

Shell 本质是一个死循环程序,它会一直重复以下流程。

读入用户输入的命令 -> 对读入的命令进行解析 -> 判断是否为内建命令(如果是内建命令则执行) ->

创建子进程执行外部命令 -> 子进程发生进程替换 -> 等待子进程 -> 回到循环开头,等待下一条命令输入

内建命令(如 cd、exit、echo等):这类命令直接在Shell进程内执行,不需要创建新进程。

外部命令(如 ls、ps、gcc等):这些命令是独立的可执行文件,Shell会通过创建子进程,再用exec系列函数实现进程替换。

相关推荐
且从容.23 分钟前
ubuntu安装flink
linux·ubuntu·flink
数字新视界2 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落2 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨2 小时前
WSL2的安装与使用
linux·wsl
微小的鱼2 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng2 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮3 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
上火的金鱼妹3 小时前
k8s生产实际问题排查(网关,数据库读写等)
运维·服务器
孙克旭_3 小时前
k8s基础组件与创建资源
运维·docker·容器·kubernetes
孙克旭_4 小时前
云原生基础之Containerd容器运行时
运维·云原生