目录
- 什么是进程?
- 进程的状态
- Linux下进程的状态
- 进程地址空间
- 什么是进程地址空间
- 为什么需要进程地址空间?
- 进程控制
- 进程控制函数
- fork
- wait/waitpid 进程等待
- 进程替换,进程替换函数exe
今天我们来分享一下Linux下的进程和进程地址空间以及一些进程的控制函数。
什么是进程?
进程是操作系统分配资源的基本单位,一个进程是一个程序的一次执行过程。每启动一个进程,操作系统就会为它分配一块独立的内存空间,用于存储PCB、数据段、程序段等资源。每个进程占有一块独立的内存空间。简单来说,它就是可执行程序 + 该进程对应的内核的各种数据结构。
问:操作系统是怎么管理进程的呢?
再回答这个问题之前,我想问问学校是怎么管理学生的呢?,总不能一个一个盯着吧;高效的做法就是将学生的信息用一个类似结构体的东西给记录起来,然后用一个类似链表的结构给串起来,那么学校管理学生,其实就是对这个数据结构的增删查改的。
那么操作系统肯定也是类似的,将进程信息放进一个叫做进程控制块的数据结构中,其实就是个结构体,称为PCB,Linux操作系统下,这个数据结构名字叫做task_struct;它会被装载到RAM(内存)里并且包含着进程的信息。然后把一个一个的task_struct用链表组织起来进行管理。
进程的状态
什么叫做进程的状态呢?举个例子吧,学校的学生有在读,退学,停学,毕业等等,这些状态;那么为什么要标识这些状态呢?当然就是为了好管理啊;所以进程的状态也类似,也就是为了方便操作系统根据进程的状态进行管理。
但是不同的平台下,进程的状态也有所不同,但是无非也就是运行态,终止态,阻塞态,挂起态啊这些;在讲Linux操作系统下进程的状态之前,我们先来认识上面举例的这几个吧。
- 运行态:进程的PCB在运行队列就叫做运行态(代表进程条件已经就绪,随时可以被调度)
- 终止态:进程还在,只是不会再次运行了,随时等待被释放
- 阻塞态:当进程在等待外部资源时,该进程条件不就绪,代码不会执行,进入对应设备的等待队列,此时进入阻塞状态
- 挂起态:短期内不会被调度(该进程所等待的资源,短期内不会就绪)的进程;如果让它的代码和数据依旧在内存中,那就是浪费内存空间,OS就会把该进程的代码和数据置换到磁盘上(置换到磁盘的swap分区)。这个过程就叫做进程挂起
对于上面的解释,可能大家还不是很理解,那就跟着我解决几个问题,就能理解了
为什么需要终止态,既然这个进程都不会在运行了,直接退出,回收资源不行吗?
答:你进程执行的结果,执行是否成功,退出的信息,是不是要告诉OS或者是父进程呢?而且如果但是操作系统很忙,没空去释放你这个进程呢?所以就要先进入终止态,等待操作系统空闲了,再去读取进程的退出信息,释放进程。
怎么理解外部资源不就绪,进入阻塞状态呢?
一个进程在使用资源的时候,可不只是在申请CPU资源,可能还会申请其他资源,比如说磁盘啊,网卡啊,显示器啊等等;那么如果有多个进程其他资源已经就绪,但是要申请CPU资源,暂时无法满足,那就需要排队——运行队列。所以如果当进程访问某些资源时,如果该资源没有准备好或者正在给其他进程提供服务,此时当前进程就要从运行队列中移除;将当前进程的task_struct放入对应设备的描述结构体中的等待队列
Linux下进程的状态
我们直接来看看,Linux描述进程状态的源码
static const char *task_state_array[]={
"R (running)",
"S (sleeping)",
"D (disk sleep)",
"T (stopped)",
"T (tracing stop)",
"Z (zombie)",
"X (dead)"
};
- R(running):运行状态
- S(sleeping):阻塞状态,(这是浅度的睡眠,也叫做可中断睡眠,意思就是这个进程在等待资源,但是操作系统可以随时唤醒它,你也可以终止它)
- D(disk sleep):也是一种阻塞状态(一般来说,在Linux中,如果我们等待的是磁盘资源,我们进程阻塞所处的状态就是D);这个叫深度睡眠,也叫做不可被中断睡眠,这种状态下,除非关机重启,不然这个进程不会被杀死。
- Z(zombie):僵尸状态,在Linux系统下,进程退出时,一般不会直接进行X状态(死亡,资源可以立马回收),而是进入Z状态
- X(dead):死亡状态,表示进程可以被释放,随时等待被释放,这个状态只是一个返回状态,你不会在任务列表里看到这个状态
- T(stopped):停止状态,暂停;可以通过发送信号来暂停进程,暂停后进程就进入这个状态,也可以发送信号让这个进程继续执行
- T(tracing stop):这个也是一种暂停状态,gdb在调试程序时,gdb进程处于S状态等待输入,但是我们发现我们自己的进程所处的状态就是T(tracing stop)状态。
为什么呢要先进入Z状态呢?
首先明确进程为什么被创建出来,一定是因为要有任务让这个进程执行,当该进程退出时,我们怎么知道,这个进程把任务给完成了呢?所以一般需要将进程的执行结果告知给父进程和OS,所以进程进入Z状态,就是为了维护退出信息,可以让父进程或者os读取的.
长时间的僵尸状态会有什么问题呢?
如果没有人回收子进程的僵尸,该状态会一直维护,该进程的相关资源(task_struct)不会被释放,内存泄
露,一般必须要求父进程进行回收
这里在补充一个概念“孤儿进程”
什么叫做孤儿进程呢?
如果父进程提前退出,子进程还在运行,就叫孤儿进程,子进程就会被操作系统领养,有操作系统负责回收。
进程地址空间
进程地址空间,我们需要搞懂两个问题即可。第一个什么是进程地址空间?第二个就是为什么需要进程地址空间?
什么是进程地址空间
所谓进程地址空间,是操作系统上的概念,物理上并不真实存在。
每一个进程在启动时,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间。
其实进程地址空间就是内核的一个数据结构叫做,struct mm_struct。通过这个数据结构,维护进程需要的资源的区域,比如堆,栈等,这些区域记录的都是虚拟地址,然后可以通过页表将虚拟地址映射到物理内存地址
有了进程地址空间可以很好的保证进程的独立性(多进程运行,需要独享各种资源,多进程运行期间互不干扰);
这样干讲可能不太懂,我们来看图
那我们来讲讲进程地址空间也就是mm_struct里面的内容大概是什么吧,其实就是mm_struct中维护了一个链表,然后这个链表的结点都是每段空间的划分,比如 (栈区 start 和 end)-> (堆区 start 和 end) -> (正文代码 start 和 end),如果要执行这个进程的某段代码,就去 mm_struct 中维护的链表中找 (正文代码)对应的区域,然后通过页表可以得到相应的内存地址,将程序的代码和数据读取到 CPU 中进行执行而程序内部的地址通过加载的时候早已转化成了虚拟地址。
为什么需要进程地址空间?
- 保护内存
有了虚拟地址空间,相当于给访问内存添加了一层保护层,可以对转换过程进行审核,对于非法的访问,就直接拦截了
- 通过地址空间,进行功能模块的解耦
(1)如果没有虚拟内存空间,那么如果一个进程正在malloc来申请内存空间,就会去调用malloc的底层代码,进行内存管理要进行的工作
(2)而现在,如果malloc来申请空间,那么操作系统会将进程中虚拟空间中对应的堆区扩大相应的大小,允许用户访问这段空间,但是不会马上进行内存的分配。而是等到用户真正要使用这块内存空间时,才会触发内存管理,申请物理空间,建立映射关系,就可以进行两者的解耦。
(3)如果没有进程地址空间,进程直接访问物理内存,当进程退出时,内存管理需要尽快将该进程回收,在这个过程中必须保证内存管理得知道某个进程退出了,并且内存管理也得知道某个进程开始了,这样才能给他们及时的分配和回收资源,这就意味着内存管理和进程管理模块是强耦合的。
(4)如果有了进程地址空间,当一个进程需要资源的时候,通过页表映射去要就可以了,内存管理就只需要知道哪些内存区域(配置)是无效的,哪些是有效的(被页表映射的就是有效的,没有被页表映射的就是无效的),当一个进程退出时,它的映射关系也就没有了,此时没有了映射关系,物理内存这里就将该进程的数据设置为无效.就相当于,你存了1万块钱,你银行卡就有余额,银行管理你的1万块钱,你不用关心你1万块钱被拿去干嘛,你只需要在需要时,取就行了
- 让进程或者程序可以以统一的视角看待内存(方便一统一的方式来编译和加载所有的可执行程序,也就是说,无论是怎样的代码,都认为我们需要什么区域,都到各种的区域去,比如代码区,永远都在虚拟地址空间的那个区,而如果直接加载到内存,那么今天这个可执行程序的代码区可能加载到内存的这个位置,明天又可能是这个位置)
- .内存共享:虚拟地址空间允许多个进程访问同一块物理内存,这种机制可以提高进程间通信的效率和灵活性
- 进程间独立:每个进程拥有自己独立的地址空间,使得进程之间相互独立,避免了进程之间的影响和冲突。
进程控制
进程控制函数
fork
它有两个返回值,创建子进程成功返回给子进程0,失败返回-1;创建成功返回子进程Id给父进程,失败返回-1。
问:fork之后,操作系统会做什么?
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程的task_struct到系统进程列表当中
- fork返回,开始调度器调度
- 数据以写时拷贝的方式,来进行进程共享或独立
那什么是写时拷贝呢?
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本,重新建立了映射关系
这里我们就能解释为什么fork有两个返回值,pid_t id,同一个变量,怎么会有两个不同的值呢?
当id=fork()的时候,谁先返回,谁就要发生写时拷贝,所以,同一个变量,会有不同的内容值,本质就是因为虚拟地址是一样的,但是映射到物理地址的时候,就不一样了。
wait/waitpid 进程等待
为什么要进程等待?
- 子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,执行结果怎么样啊, 是否正常退出啊。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
wait方法:pid_t wait(int* status):
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULLwait()的方案可以解决回收子进程z状态,让子进程进入x
wait作用:等待任意一个退出的子进程
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)(等价于(status)&0x7F): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出);WEXITSTATUS(status)(等价于(status>>8)&0xFF): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
- options:WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程id;0表示阻塞等待
进程替换,进程替换函数exe
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
#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[]);
为什么需要进程替换?
我们想让创建出来的子进程,执行全新的程序,这个时候就需要程序的程序替换
进程替换的原理
- 将磁盘中的程序,加载入内存结构
- 重新建立页表映射,那个进程执行程序替换,就重新建立映射(子进程)
效果:让我们的父进程和子进程彻底分离,并让子进程执行一个全新的程序
需要注意的是,进程替换之后,原来共享的父进程代码就不会再执行了
** 子进程执行程序替换,会不会影响父进程呢,如果不影响,是怎么做到的呢?**
不会(进程具有独立性),数据层面发生写时拷贝,当程序替换的时候,我们可以理解成代码和数据都发生了写时拷贝完成父子分离。