操作系统:进程控制

文章目录

  • 并行与并发
  • 进程的状态
  • 进程关系
  • 进程的控制结构
  • 三个特殊的进程
    • 进程PID == 0的进程
    • 进程ID ==1的进程
    • 进程ID ==2的进程
  • 获取与进程相关的各种ID的函数
  • 程序运行过程
    • 父子进程各自执行的代码
    • 父子进程共享操作
    • 子进程会继承父进程的哪些属性
    • exec
    • system
  • 回收进程资源
    • 僵尸进程
    • 孤儿进程
    • wait
    • 父进程获取子进程终止状态
    • 从进程终止状态提取信息
    • 编译型语言与解释型语言程序的运行
      • 运行编译型语言
      • 运行解释型语言
  • 进程切换
  • 线程
    • 线程切换

并行与并发

  • 并发:单个CPU在多个任务之间不断切换,切换的速度非常快,给用户一种任务并行执行的错觉,被称为伪并行调度

  • 并行:在现代计算机中CPU有多核,可以在同一时间同时执行多项任务,达到真正的并行

进程的状态

那么什么原因会导致进程会被创建,从而生成PCB(进程管理块Process Control Block)呢?常见的有以下几种
1.系统初始化
2.用户通过系统提供的APl创建新进程
3.批处理作业初始化(什么是批处理作业)
4.由现有进程派生子进程
一个进程,因为某种原因被创建了,那么它可以按照以下步骤进行一系列的初始化
1.给新进程分配一个进程ID
2.分配内存空间
3.初始化PCB
4.进入就绪队列

操作系统:进程控制_第1张图片

如图,进入就绪队列,其状态就会变为就绪态。各个状态之间的关系描述如下:

  • 就绪 —>运行:当操作系统内存在着调度程序,当需要运行一个新进程时,调度程序选择一个就绪态的进程,让其进入运行态。
  • 运行 —>就绪:运行态的进程,会占有CPU (参照一开始的饼状图)。每个进程会被分配一定的执行时间,当时间结束后,重新回到就绪态。
  • 运行 —>阻塞:进程请求调用系统的某些服务,但是操作系统没法立即给它(比如这种服务可能要耗时初始化,比如V/0资源需要等待),那么它就会进入阻塞态。
  • 阻塞 —>就绪:当等待结束了,就由阻塞态进入就绪态。
  • 运行 —>终止:当进程表示自己已经完成了,它会被操作系统终止。

这便是对于单个进程,经典的五状态模型。当存在多个进程时,由于同一时间只能有一个进程在执行,那么如何去管理这一系列的处于阻塞态和就绪态的进程呢?一般来说,会使用就绪队列,和阻塞队列,让处于阻塞态和就绪态的进程进入队列,排队执行。

进程关系

  • 父子关系
    已有进程调用fork创建出一个新的进程,那么这两个进程之间就是父子进程关系,子进程会继承父进程的很多属性。

  • 进程组
    多个进程可以在一起组成一个进程组,其中某个进程会担任组长,组长进程的pid就是整个进程组的组ID.

    • 进程组的生命周期
      进程组是否存在与组长存在与否没关系,就算进程组的组长终止了,只要进程组中还有一个进程存在,这个进程组就存在。
      进程组从开始被创建,到进程组最后一个进程结束,这段时间就是进程组的生命周期。
  • 会话期关系
    多个进程组在一起,就组成了会话期。

进程的控制结构

对于一个被执行的程序,操作系统会为该程序创建一个进程。进程作为一种抽象概念,可将其视为一个容器,该容器聚集了相关资源,包括地址空间,线程,打开的文件,保护许可等。而操作系统本身是一个程序,有一句经典的话程序=算法+数据结构,因此对于单个进程,可以基于一种数据结构来表示它,这种数据结构称之为进程控制块(PCB)
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个PCB,如果进程消失了,那么PCB也会随之消失。

PCB包含以下信息:

  • 进程管理部分:
    • 进程标识符信息:进程标识符用于唯一的标识一个进程。一个进程通常有以下两种标识符。
      • 外部标识符。由创建者提供,通常是由字母、数字组成,往往是用户(进程)访问该进程使用。外部标识符便于记忆。
      • 内部标识符(进程ID):为了方便系统使用而设置的。在所有的OS中,都为每一个进程赋予一个唯一的整数,作为内部标识符。它通常就是一个进程的符号,为了描述进程的家族关系,还应该设置父进程标识符以及子进程标识符。还可以设置用户标识符,来指示该进程由哪个用户拥有。
    • 寄存器
      • 通用寄存器。又称为用户可视寄存器,可被用户程 序访问,用于暂存信息。
      • 指令寄存器。存放要访问的下一条指令的地址。
      • 程序状态字PSW。其中含有状态信息。(条件码、 执行方式、中断屏蔽标志等)
      • 程序计数器
    • 堆栈指针:每个用户进程有一个或若干个与之相 关的系统栈,用于存放过程和系统调用参数及调用地址。栈指针指向该栈的栈顶。
    • 进程调度信息
      • 进程状态
      • 进程优先级
      • 调度参数
      • 事件。这是进程由执行状态转变为阻塞状态所等待发生的事件。(阻塞原因)
    • 进程上下文:是进程执行活动全过程的静态描述。包括计算机系统中与执行该进程有关的各种寄存器的值、程序段在经过编译之后形成的机器指令代码集、数据集及各种堆栈值和PCB结构。可按一定的执行层次组合,如用户级上下文、系统级上下文等。
    • 信号
  • 存储管理部分
    • Text段指针
    • 数据段指针
    • 堆栈段指针
  • 文件管理部分
    • 根目录
    • 工作目录
    • 文件描述符
    • 用户ID
    • 组ID

PCB通常是用链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列

  • 将所有处于就绪状态的进程链在一起,称为就绪队列;
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;

另外,对于运行队列在单核CPU系统中则只有一个运行指针了,因为单核CPU在某个时间,只能运行一个程序。

操作系统:进程控制_第2张图片

三个特殊的进程

os运行起来后有三个特殊的进程,他们的PID分别是0、1、2。0、1、2这个三个进程,是os启动起来后会一直默默运行的进程,直到关机os结束运行。

进程PID == 0的进程

  1. 作用
    这个进程被称为调度进程,功能是实现进程间的调度和切换,该进程根据调度算法,该进程会让CPU轮换的执行所有的进程。
    当pc指向不同的进程时,cpu就去执行不同的进程,这样就能实现切换。
  2. 这个进程怎么来的
    这个进程就是有os演变来的,os启动起来后,最后有一部分代码会持续的运行,这个就是PID==0的进程。
    由于这个进程是os的一部分,凡是由os代码演变来的进程,都称之为系统进程。

进程ID ==1的进程

  1. 作用

    • 作用1:初始化
      这个进程被称为init进程,这个进程的作用是,他会去读取各种各样的系统文件,
      使用文件中的数据来初始化os的启动,
      让我们的os进入多用户状态,也就是让os支持多用户的登录。

    • 作用2:托管孤儿进程
      什么事孤儿进程,怎么托管的,有关这个问题后面会详细介绍。

    • 作用3:原始父进程
      原始进程—>进程—>进程—>终端进程—>a.out进程

  2. 这个进程怎么运行起来的?
    这个进程不是os演变来的,也就是说这个进程的代码不属于os的代码,这个进程是一个独立的程序,程序代码放在了/sbin/init下,当os启动起来后,os会去执行init程序,将它的代码加载到内存,这个进程就运行起来了。

进程ID ==2的进程

  1. 作用
    页精灵进程,专门负责虚拟内存的请页操作。精灵进程也叫守护进程。
    • 怎么理解换页操作,我们说当os支持虚拟内存机制时,加载应用程序到内存的那部分代码,当这部分代码运行完毕后,会再拷贝另一部分需要运行的代码到内存中,拷贝时是按照一页一页来操作的,每一页大概4096字节,这就是换页操作。
  2. 这个进程怎么运行起来的?
    与调度进程一样,也是一个系统进程,代码属于os的一部分。

获取与进程相关的各种ID的函数

#include 
#include 
pid_t getpid(void);
ppid_t getppid(void);
uid_t getuid(void);
gid_t getgid(void);
  1. 功能
    • getpid函数:获取调用该函数进程的进程ID。
    • getppid函数:获取调用该函数进程的父进程ID,第一个P是parent,第二个process.
    • getuid函数:获取调用该函数进程的用户ID。
      在什么用户下运行的该进程,得到的就是该用户的用户TD,查看/etc/passed文件,可以找到该UID对应的用户名。
    • getgid函数:获取用户组的ID,也就是调用该函数的那个进程,它的用户所在用户组的组ID。
  2. 返回值:返回各种ID值,不会调用失败,永远都是成功的。

程序运行过程

程序如何运行起来的?

  1. 在内存中划出一片内存空间
  2. 将硬盘上可执行文件中的代码(机器指令)拷贝到划出的内存空间中
  3. pc指向第一条指令,cpu取指运行

当有os时,以上过程肯定都是通过调用相应的API来实现的。
在Linux下,os提供两个非常关键的API,一个是fork,另一个是exec.

fork:开辟出一块内存空间
exec:将程序代码(机器指令)拷贝到开辟的内存空间中,并让pc指向第一条指令,CPU开始执行,进程就运行起来了,运行起来的进程会与其它的进程切换着并发运行。

#include 
pid_t fork (void);
  • 函数功能
    从调用该函数的进程复制出子进程,被复制的进程则被称为父进程,复制出来的进程称为子进程。
    复制后有两个结果:

    1. 依照父进程内存空间样子,原样复制地开辟出子进程的内存空间
    2. 由于子进程的空间是原样复制的父进程空间,因此子进程内存空间中的代码和数据和父进程完全相同
      其实复制父进程的主要目的,就是为了复制出一块内存空间,只不过复制的附带效果是,子进程原样的拷贝了一份父进程的代码和数据,事实上复制出子进程内存空间的主要目的,其实是为了exec加载新程序的代码。
  • 返回值
    由于子进程原样复制了父进程的代码和数据,因此父子进程都会执行fork函数,当然这个说法有些欠妥,但是暂且这么理解。
    1)父进程的fork,成功返回子进程的PID,失败返回-1,errno被设置。
    2)子进程的fork,成功返回0,失败返回-1,errno被设置。

父子进程各自执行的代码

#include 
int main(void)
{
    /*fork前代码*/
    pid_t ret = fork();
    if(ret > 0){
    	...
    }
    else if(ret == 0){
        ...
    }
    /*fork后代码*/
}
  • 父进程
    1. 执行fork前的代码
    2. 执行fork函数
      父进程执行fork函数时,调用成功会返回值为子进程的PID,进入if(ret > 0){}中,执行里面的代码。if(ret > 0){}中的代码只有父进程才会执行。
    3. 执行fork函数后的代码
  • 子进程
    1. fork前的代码,尽管子进程复制了这段代码,但是子进程并不会执行,子进程只从fork开始执行。
    2. 子进程调用fork时,返回值为0,注意0不是PID。
      进入if(ret == 0){ },执行里面的代码。if(ret == 0) { }中的代码只有子进程执行。
    3. 执行fork后的代码

父子进程共享操作

子进程会继承父进程已经打开的文件描述符,如果父进程的3描述符指向了某个文件,子进程所继承的文件描述符3也会指向这个文件.像这种继承的情况,父子进程这两个相同的"文件描述符"指向的是相同的文件表。
由于共享的是相同的文件表,所以拥有共同的文件读写位置,不会出现覆盖的情况。

但是如果是文件说父子进程单独打开的(在fork之后),那么父子进程的文件描述符指向的文件表不同,会相互覆盖。

操作系统:进程控制_第3张图片

子进程会继承父进程的哪些属性

  • 子进程继承如下性质
    1. 用户ID,用葴组ID
    2. 进程组ID
    3. 会话期ID
    4. 控制终端
    5. 当前工作目录
    6. 根目录
    7. 文件创建方式屏蔽字
    8. 环境变量
    9. 打开的文件描述符
      等等
  • 子进程独立的属性
    1. 进程ID。
    2. 不同的父进程ID。
    3. 父进程设置的锁,子进程不能被继承。
      等等

exec

#include 
int execve(const char *filename,char **const argv,char **const envp);
  • 功能:向子进程空间加载新程序代码(编译后的机器指令)。
  • 参数:
    • filename:新程序(可执行文件)所在的路径名
      可以是任何编译型语言所写的程序,比如可以是c、ct+、汇编等,这些语言所写的程序被编译为机器指令后,都可以被execve这函数加载执行。
      正是由于这一点特性,我们才能够在c语言所实现的os上,运行任何一种编译型语言所编写的程序。
    • argv:给main函数的参数,比如我可以将命令行参数传过去
    • envp:环境变量表
  • 返回值:函数调用成功不返回,失败则返回-1,且errno被设置。

system

#include            
int system(const char *command); 
  • 功能:创建子进程,并加载新程序到子进程空间,运行起来。
  • 参数:新程序的路径名

回收进程资源

进程运行终止后,不管进程是正常终止还是异常终止的,必须回收进程所占用的资源。

僵尸进程

子进程终止了,但是父进程还活着,父进程在没有回收子进程资源之前,子进程就是僵尸进程。
子进程已经终止不再运行,但是父进程还在运行,它没有释放子进程占用的资源,所以就变成了占着资源不拉屎僵尸进程。

孤儿进程

没爹没妈的孩子就是孤儿,子进程活着,但是父进程终止了,子进程就是孤儿进程。
为了能够闻收孤进程终止后的资源,孤儿进程会被托管给我们前面介绍的pid=1的init进程,每当被托管的子进程终止时,init会立即主动回收孤儿进程资源,回收资源的速度很快,所以孤儿进程没有变成僵死进程的机会。

wait

#include 
#include 
pid_t wait(int *status);
  • 功能:获取子进程的终止状态,主动释放子进程占用的资源
  • 参数:用于存放"进程终止状态"的缓存
  • 返回值:成功返回子进程的PID,失败返回-1,errno被设置。

作用:父进程调用这个函数的功能有两个,

  1. 主动获取子进程的“进程终止状态”。

  2. 主动回收子进程终止后所占用的资源。

(1)退出状态与"进程终止状态”
returnexit_exit的返回值称为"进程终止状态",严格来说应该叫"退出状态",return (退出状态)exit(退出状态)_exit(退出状态)
当退出状态被_exit函数交给os内核,os对其进行加工之后得到的才是"进程终止状态",父进程调用wait函数便可以得到这个“进程终止状态”。
(2)os是怎么加工的?
1)正常终止
进程终止状态=(终止原因(正常终止)<<8)|(退出状态的低8位)
不管return、exit、exit返回的返回值有多大,只有低8位有效,所以如果返回值太大,只取低8位的值。
2)异常终止
进程终止状态=是否产生core文件位│终止原因(异常终止)<<8│终止该进程的信号编号

父进程得到进程终止状态后,就可以判断子进程终止的原因是什么,如果是正常终止的,可以提取出返回值,如果是异常终止的,可以提取出异常终止进程的信号编号。

父进程获取子进程终止状态

(1)父进程调用wait等子进程结束,如果子进程没有结束的话,父进程调用wait时会一直休眠的等(或者说阻塞的等)。

(2)子进程终止返回内核,内核构建"进程终止状态”
如果,
1)子进程是调用returnexit_exit正常终止的,将退出状态返回给内核后,内核会通过如下表达式构建"进程终止状态”
进程终止状态–终止原因〈正常终止)<<8 |退出状态的低8位
2)子进程是被某个信号异常终止的,内核会使用如下表达式构建"进程终止状态”
进程终止状态=是否产生core文件位│终止原因(异常终止)<<8│终止该进程的信号编号

(3)内核向父进程发送SIGCHLD信号,通知父进程子进程结束了,你可以获取子进程的"进程终止状态"了。

如果父进程没有调用wait函数的话,会忽略这个信号,表示不关心子进程的"进程终止状态"。

如果父进程正在调用wait函数等带子进程的"进程终止状态”的话,wait会被SIGCHLD信号唤醒,并获取进程终止状态"

从进程终止状态提取信息

系统提供了相应的带参宏,使用这个带参宏就可以从"进程终止状态”中提取出我们要的信息。
提取原理:相应屏蔽字&进程终止状态,屏蔽掉不需要的内容,留下的就是你要的信息。
哪里能查到这些带参宏,man查案wait的函数手册,即可看到。

  • WIFEXITED(status):提取出终止原因,判断是否是正常终止
    • 如果表达式为真:表示进程是正常终止的
      此时使用WEXITSTATUS(status),就可以从里面提取出return/exit/_exit返回的"退出状态”。
    • 为假:不是正常终止的
  • WIFSIGNALED(status):提取出终止原因,判断是否是被信号杀死的(异常终止)
    • 如果表达式为真:是异常终止的
      此时使用WTERMSIG(status),就可以从里面提取出终止该进程的信号编号。
    • 为假:不是异常终止的

编译型语言与解释型语言程序的运行

运行编译型语言

  1. 父进程(命令行终端窗口、图形界面)会fork复制出子进程空间
  2. 调用exec加载器,直接将编译后代码拷贝到子进程空间,然后被CPU执行时,整个程序就运行起来了。

运行解释型语言

  1. 父进程(命令行窗口、图形界面)会fork复制出子进程空间
  2. 调用exec加载java虚拟机程序,将虚拟机程序的代码拷贝到子进程空间中
  3. 当java虚拟机程序运行起来后,会自动的去解释编译得到的java字节码文件,将字节码翻译为机器指令,cpu再去执行翻译得到的机器指令。
    每解释一句,cpu就会执行一句,在虚拟机的翻译下,整个java进程就开始运行起来了。

其实最简单的理解就是,java虚拟机就代表了java进程。
当你运行另一个java程序时,又会自动地启动一个虚拟机程序来解释java字节码,此时另一个java进程又诞生了。
也就是说你执行多少个java进程,就会运行多少个java虚拟机,当然java虚拟机程序在硬盘上只有一份,只不过被多次启动而已。

进程切换

当一个正在运行中的进程被中断,操作系统指定另一个就绪态的进程进入运行态,这个过程就是进程切换,也可以叫上下文切换。该切换过程一般涉及以下步骤:

  1. 保存处理器上下文环境:将CPU程序计数器和寄存器的值保存到当前进程的私有堆栈里
  2. 更新当前进程的PCB(包括状态更变)
  3. 将当前进程移到就绪队列或者阻塞队列
  4. 根据调度算法,选择就绪队列中一个合适的新进程,将其更改为运行态
  5. 更新内存管理的数据结构
  6. 新进程内对堆栈所保存的上下文信息载入到CPU的寄存器和程序计数器,占有CPU

进程上下文切换的场景

  • 为了保证所有进程可以得到公平调度,CPU时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待CPU的进程运行;
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行
  • 当进程通过睡眠函数sleep这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;·发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核中的中断服务程序;

线程

线程是进程当中的一条执行流程。
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

操作系统:进程控制_第4张图片

线程是CPU调度的基本单位,而进程是资源拥有的基本单位。

所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。对于线程和进程,我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

线程切换

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

所以,线程的上下文切换相比进程,开销要小很多。

你可能感兴趣的:(计算机,服务器,网络,linux)