王道操作系统——进程篇

进程的定义,组成,组织方式,特征

进程的定义:

程序是静态的,进程是动态的,进程是程序运行的一次过程,进程是操作系统调度和分配资源的独立单元

进程的组成:

程序段, 数据段, PCB;

PCB:本质上一个数据结构,操作系统使用PCB对运行的程序(进程)进行控制和管理

创建进程就是新建一个PCB,撤销进程就是删除一个PCB

PCB的组成:
  • 进程描述信息: 进程标识符 PID,用户标识符 UID
  • 进程控制信息: 当前进程状态,进程优先级
  • 资源分配清单:程序段指针,数据段指针,键盘,打印机等
  • 处理机相关信息:各种寄存器的值,用于上下文切换 保存处理机现场和 恢复处理机现场

进程的组织:

在一个系统中可能会有很多的进程,那么就有很多的PCB,操作系统组织PCB的方式一般有两种:

1. 链接方式:


按照进程状态将进程分为多个队列:执行队列,就绪队列,阻塞队列操作系统持有这些队列指针

2. 索引方式:

按照进程的状态建立多个索引表,这些索引表中存放着对应的PCB,操作系统持有这些表的指针;

进程的特征:

进程的状态与转换

进程的状态:

  • 就绪:无CPU,有其他所有资源
  • 运行:有CPU,有其他所有资源
  • 阻塞:无CPU,无其他资源,等待其他资源
  • 新建:进程正在被创建,操作系统为进程分配资源,分配PCB
  • 销毁:进程正在被撤销,操作系统进行资源回收,撤销PCB

这里补充一下,除了上述的五中状态外,还有另外一种状态挂起状态,为了提高内存利用率,可以通过中级调度将进程挂起

进程状态的切换:

  1. 就绪->运行:进程被调度
  2. 运行->就绪:CPU的时间片用完
  3. 运行->阻塞:进程的主动行为,进程可以通过系统调用让内核访问IO资源,等待事件的发生
  4. 阻塞 ->就绪:进程的被动行为,申请的资源或等待事件发生

就绪态不能直接到阻塞态,阻塞态也不能直接进入运行态;

进程状态切换的实现:

进程的切换必须时原子操作,操作系统提供了原语来保证内核的原子操作

原语的实现通过两个特权指令关中断,开中断指令,用这两个指令保证CPU执行指令的原子性,在这两个指令之间执行的指令,不会进行中断信号的判断;

进程管理的相关原语:
创建原语

撤销原语
阻塞原语
唤醒原语
切换原语

无论是那种进程控制的原语,无非都是做了三件事:

  • 更新PCB中的信息(更新进程状态字段,保存当前进程的CPU现场,恢复CPU现场等)
  • 将PCB放到正确的队列/索引表中
  • 分配/回收资源

进程通信

在说进程通信之前,先了解一下,为什么需要进程通信手段,因为进程是系统分配资源的单位,每一个进程分配的内存都是独立的,即不共享的,CPU在运行进程A时,是肯定不能访问进程B的内存的,因为访问内存时会进行地址转换,地址转换的时候会通过界地址寄存器判断访问的内存是否越界,所以当进程和进程之间需要通信时,必须通过某种进程通信手段;

进程通信:

操作系统层面上的进程通信大概分为三种方式:

1. 共享内存:

需要通信的两个进程可以共同访问一块公用内存,对于公共内存的访问必须是互斥的,互斥的实现一般通过互斥同步工具P,V操作

2. 消息传递:

进程间数据的传递通过一个格式化的消息(Message),通过操作系统提供的发送原语、接收原语传递这个Message;

消息传有两种实现方式:

  • 直接通信方式:每一个进程都有一个消息缓冲队列,操作系统通过发送、接收原语将Message添加到目标进程的消息缓冲队列中;
  • 间接通信方式:设置一个中间缓冲,一般称为信箱,也叫信箱通信,消息要先放入信箱中,接收进程再通过原语从信箱中取,计算机网络中的电子邮件系统就是通过这种方式实现的
3. 管道机制:

管道的本质是一个pipe文件,两个进程通过对一个文件的读写实现通信

  1. 管道通信是半双工通信,一段时间内只能一个数据发送或者读取,如果需要全双工需要设置两个管道;
  2. 只有管道数据写满的时候才能读,只有数据读空的时候才能写;
  3. 数据一旦被读出,就会从管道丢弃,再也找不回来了,所以读进程只能有一个;

线程和多线程模型

引入进程的目的是为了程序的并发,而进入线程的目的是为了进程的并发,一个进程需要同时做几件事情,就需要通过线程来完成;

引入线程带来的变化:

  • 资源分配:传统机制中进程是接收系统资源分配和调度的单元;现在进程是系统资源分配的单元,线程是系统调度的单元
  • 并发性:传统只有进程的并发,引入线程后进程的内部也可以进行并发,并且线程的并发不需要切换进程的上下文环境,更好的并发;

线程的实现:

1. 用户级线程:

用户级线程由应用程序通过内核库实现,用户级线程的创建,切换都由应用程序在用户空间完成,操作系统内核是感知不到这个用户级线程的;

2. 内核级线程:

内核级线程是由操作系统内核提供的线程,线程的创建和调度都由内核来完成;

处理机调度调度的实际对象是内核级线程

线程模型:

1. 多对一模型:

多个用户及线程映射到一个内 核级线程。每个用户进程只对应一个内核级线程。

特点:
  • 进程管理的开销小,用户级线程的切换不需要在内核态完成
  • 当一个用户级线程阻塞时会造成内核线程的阻塞,而一个进程只对应一个内核线程,那么这个进程就被阻塞了
2. 一对一模型

一个用户级线程对应一个内核线程

特点:
  • 线程的切换必须在核心态实现,开销相对大
  • 当一个线程被阻塞不会造成整个进程的阻塞;
3. 多对多模型:

m个用户级线程对应n个内核级线程,m>n

特点:
  • 综合了多对一模型并发度不高和一对一模型开销大特点

处理机调度

在另一篇进程管理中有详细讲,这里简单总结一下
  • 高级调度作业调度:根据某种调度算法从外存后备队列中选择一个作业,为它创建进程,分配资源,使其获得竞争处理机的权利,进程进入就绪队列排队;
  • 中级调度内存调度:将内存中不能运行的进程调出内存,当它可以运行时在调入内存,提高内存的利用率
  • 低级调度进程调度:根据某种调度算法在就绪队列中选出一个进程,给它分配CPU的时间片,运行

进程调度的时机:

1.主动放弃:
  • 进程正常运行结束
  • 主动申请IO,请求阻塞
  • 进程遇到致命异常
2.被动放弃:
  • 时间片用完
  • 优先级更高的进程进入队列
  • CPU检测到中断信号

不可以调度的时机:

  • 中断的处理过程,
  • 进程在操作系统内核临界区
  • 原子操作,原语

这里详细解释一下第二点,先看两个判断题

  1. 临界资源:需要进程互斥访问的系统资源,一段时间内只能被一个进程访问的资源,我的理解是进程安全的系统资源
  2. 临界区:进程访问临界资源的那一块代码;

这里区分一下内核进程临界区用户进程临界区内核进程临界区访问的一般是操作系统内核的核心数据,比如就绪队列,阻塞队列等,在访问这些数据的时候,如果发生进程切换就会影响这些数据的内容(队列里面的进程会发生改变,造成进程不安全),所以操作系统规定内核程序在访问临界区的时候不可以进行进程调度,反观用户进程,它们只能访问一些无关紧要的数据(或者说这些数据和进程调度的实现没有关系),比如打印机,用户进程在执行临界区访问打印机的代码时,打印机会很慢,会造成CPU空闲,所以此时应该使用进程调度阻塞当前用户进程;

进程调度方式:

  • 非抢占式:只能进程自己主动释放
  • 抢占式:操作系统可以剥夺当前进程的CPU,分配给其他进程

进程切换

  1. 保存原来运行进程的处理机现场到PCB
  2. 从当前进程的PCB恢复其处理机现场

进程的切换是有成本的,频繁的进程切换会降低CPU的吞吐率;

调度算法

调度算法的评价指标:

非抢占式

1. 先来先服务:

按照FIFO思想,从队列中选择最先进入队列的进程(作业)

特点:
  • 非抢占式
  • 不会产生饥饿问题
  • 对短作业不利

2. 短作业优先:

从队列中选择执行时间最短的进程

特点:
  • 可以获得较少的平均等待时间、平均周转时间
  • 会引起长作业的饥饿问题

3. 最高响应比:

根据执行时间和等待时间计算出一个响应比,优先选择响应比高的队列 (最高响应比 = 1 + 等待时间/作业时长)

特点:
  • 综合了先来先服务和短作业优先,比较公平
  • 不会产生长作业的饥饿问题

抢占式:

1. 时间片轮转

将CPU的执行分成多个很小的时间片,每一个进程按照先来先服务顺序获得一个时间片,时间片用完回到队列的尾部排队
由时钟装置发出时钟中断来通知内核CPU时间片用完切换进程

特点:
  • 公平,响应快
  • 进程的频繁切换会降低CPU的吞吐率
  • 无法处理紧急任务

2. 优先级调度算法

每一个进程分配一个优先级,调度时选择优先级最高的进程

特点:
  • 可以区分进程的紧急程度,重要程度
  • 可能造成优先级低的进程的饥饿问题

3. 多级反馈

  1. 设置多个优先级进程队列,优先级由高到低,时间片由小到大
  2. 新进程先进入第一个队列,然后按照FIFO排队执行,当时间片用完后还是没有结束就进入第二队列,第一队列无进程时,再去调度第二队列,以后同理,只有k队列为空时才会调度k+1队列
  3. 当新的进程进入前面队列时,抢占CPU
特点:
  • 可以动态的调整进程的优先级
  • 相对公平,用到了FIFO
  • 短进程可以较少的时间完成,长作业后面分配的时间片越来越大

进程同步

异步性:各个进程按照各自不同和不可预知的顺序推进
进程同步:在计算机的运行中,进程必须相互配合完成工作,即各个进程的执行必须按照一定的顺序,我们可以使用同步机制来保证进程的同步执行
进程互斥:对临界资源的访问需要互斥的进行 ,即同一时间只能被一个进程访问 进程安全

操作系统软件层实现资源互斥:
操作系统硬件层实现资源互斥:
信号量机制:

信号量其实是一个变量,操作系统通过一对原语来对信号量进行控制,根据信号量的值判断当前资源的状态,从而实现资源互斥进程同步;(原语是在CPU层面通过关中断和开中断特权指令实现的锁机制

实现信号量机制的两个原语:

wait(S)原语signal(S)原语,wait表示临界资源的进入区,signal表示临界资源的退出区,S是信号量,用来表示资源的信息,是否可以访问;这两个原语还有一个名字 P(S),V(S)两个科学家的名字命名的


信号量机制有两种:

1. 整型信号量:
    static int S = 1;   // int s 用来记录系统可用的资源数

    // 申请临界资源
    void wait(int s){
        while (s<1);  // 当无可用资源,自旋等待
        S = s-1;   //  等到有可用资源,s--
    }

    // 释放临界资源
    void signal(int s){
        S = s+1;   //  释放资源直接s++
    }

wait和signal是原语,上面是这个原语的大概实现的意思;S用来记录可用资源数量,当S<1时,表示无可用资源,那么申请这个资源的进程自旋等待(Java的概念,不知道操作系统里能不能这么叫);

问题:

  • 无法做到让权等待,即无法申请资源的时候处于CPU空循环状态
2. 记录型信号量:
创建一个结构体(这里用java的类代替)
class Semaphore {
    static int S = 1;  // 信号量,s>0:有可用资源,S表示资源可用数,s<0:无可用资源,S表示等待的进程数
    static LinkedList waitQueue = new LinkedList();  // 等待队列
}
PV操作
    void wait(Semaphore semaphore){
        semaphore.S--;  // 有进程申请资源就将S--
        if (semaphore.S<0){
            block(curProcess); // 如果S<0,表示当前无可用资源,阻塞当前进程,添加到等待队列
            waitQueue.add(curProcess);
        }
    }

    void signal(Semaphore semaphore) {
        semaphore.S++;   //  有进程释放资源就将S++
        if (semaphore.S>0){  
            wakeUp(curProcess);  // 如果S>0 表示当前有可用资源,唤醒等待队列中进程
            waitQueue.remove(curProcess);
        }
    }

block和wakeUp是进程控制的原语
可以使用记录型信号量实现操作系统的进程同步和互斥,一般如果没有特别说明,PV操作指的是记录型信号量;

用信号量机制实现进程同步:

所谓进程的同步就是将原本异步推进的进程按照一定的顺序执行

现在有两个正在执行的进程,如果不实现同步机制,那么代码123和代码456他们的执行时随机的,下面通过信号量机制保证代码12在代码4之前执行:

  1. 设置信号量为0
  2. 先执行的代码后面进行V操作
  3. 后执行的代码前面进行P操作

当我们随机调度时,因为代码12前没有任何的操作,可以直接运行,但是代码4前面有一个P申请信号量资源的原语,当代码12没有执行时,信号量为0,P操作会阻塞进程2,当12运行后再,V操作,信号量变成有资源状态,代码4才可以运行,这就是通过信号量机制实现操作系统的进程同步过程

生产者消费者问题:

操作系统中有一组生产者进程和一组消费者线程,它们互斥的共享一块缓冲区,生产者生产一个产品放入缓冲区,当缓冲区满时生产者进程阻塞,无法再向缓冲区放产品,消费者进程从缓冲区取出产品,当缓冲区为空时,消费者进程阻塞,无法获取产品;

问题分析:
  1. 生产者和消费者进程互斥访问缓冲区
  2. 当缓冲区为空,生产者生产后消费者才能消费,同步1
  3. 当缓冲区满时,消费者消费后生产者才能生产,同步2

根据上面的思路,我们可以总结生产者消费者问题的核心就是实现一个互斥和两个同步,下面我们使用信号量机制实现:

    int S_mutex = 1;  // 互斥信号量
    int S_full = 0;   // 满的时候,先消费后生产,同步互斥量,表示非空闲的数量
    int S_empty = n; // 空的时候,先生产后消费,同步互斥量,表示空闲的数量

    void Creator(){

        // 生产

        P(S_empty);  //  放入缓冲区之前,空闲数量--

        P(S_mutex);
        // 放入缓冲区
        V(S_mutex);

        V(S_full);  // 放入缓冲区后,非空闲数量++

    }

    void Consumer(){

        P(S_full);  //  取之前,非空闲数量--

        P(S_mutex);
        // 从缓冲区取
        V(S_mutex);

        V(S_empty);  // 取之后,空闲数量++

        // 消费
    }

哲学家进餐问题

现在有五个哲学家,他们每两个人的中间有一根筷子,哲学家会做两件事,吃饭和思考,一个哲学家只有同时拿到他左右两根筷子的时候才可以吃饭,思考的时候互相不影响;

问题分析:

每一个哲学家是一个进程,他们左右的两个筷子是互斥资源;给哲学家编号,0,1,2,3,4;将筷子设置成信号量chopsticks[5] = {1,1,1,1,1};这样设计

解决方法:
  1. 限制进餐人数,如果一个时间只有四个人进餐,那么肯定有一个人能吃到饭
  2. 要求奇数先拿左边筷子,偶数的先拿右边筷子,拿不到筷子的哲学家阻塞,那么剩下的哲学家再去拿第二根的时候肯定可以拿到;
  3. 拿左边和右边筷子这件事必须互斥的进行
    int[] chopsticks = {1,1,1,1,1};
    int mutex = 1;
    void Pi(int i){  // 第i个哲学家
        
        P(mutex); 
        P(chopsticks[i]);
        P(chopsticks[(i+1)%5]);
        V(mutex);
        // 吃饭
        V(chopsticks[i]);
        V(chopsticks[(i+1)%5]);

    }

mutex信号量保证了同一时刻只能有一个哲学家去拿筷子,比如哲学家0在拿筷子时被阻塞,其他哲学家也不会去拿筷子(因为mutex被持有了),吃饭的哲学家释放筷子时(这里释放的筷子是导致哲学家0阻塞的那根),哲学家0就会继续推进

管程:

操作系统中使用信号量机制来实现互斥同步的过程比较复杂,于是引入了管程的概念,管程是为了更方便的实现同步和互斥;

管程的组成:
  • 局限于管程内部的数据
  • 提供操作数据的方法,管程内的数据只能通过该方法访问
  • 一段时间内只能有一个进程在管程内执行

个人理解:管程就是对信号量机制的封装java的synchronize就是管程思想的一种体现

死锁:

必要条件:必须要同时满足四个条件才会发生死锁
  1. 资源互斥:只有访问的资源时互斥资源才会导致死锁,比如哲学家的筷子,打印机资源
  2. 占有等待:一个进程占有一个互斥资源,并且等待另一个被其他进程占有的资源
  3. 不可剥夺:进程占有的资源不可以被剥夺,只能由进程主动释放
  4. 循环等待:形成一个循环链,每一个进程占有的资源是另一个进程等待的资源
死锁发生的原因:
  • 进程数大于资源数
  • 资源的获取和释放顺序不合理

死锁的处理策略:

1. 预防死锁:破坏四个必要条件

破坏资源互斥:将互斥资源改变成可以共享的资源,比如SpooLing技术就可以将打印机资源改成共享资源;

缺点:很多情况下,考虑到操作系统的安全性,大部分资源是无法做到共享的,

破坏不可剥夺:进程被占有的进程可以被剥夺,破坏不可剥夺这种方式有两种实现方法:
  1. 当进程无法请求一个资源时,他必须释放自己已经持有的资源,比如一个哲学家已经拿了左边的筷子,现在要拿右边的筷子,可是右边筷子没有了,那么他必须把左边的筷子放下;
  2. 操作系统可以根据进程的优先级去主动干预进程占有的资源,将优先级低的进程占有的互斥资源剥夺给优先级高的进程
    缺点:
  • 进程释放资源可能导致前面的工作实效,降低CPU的吞吐率
  • 实现比较复杂
  • 可能会导致低优先级进程的饥饿问题
破坏占有等待:在进程运行前,一次性分配需要的所有的资源,如果当前系统资源条件不满足就等待,分配后后就一直让他使用,之后该进程不会请求其他资源,直到运行结束;

缺点:

  • 资源利用率会降低,可能某些资源只要使用一点点时间,但是会被一直占用
  • 可能会导致进程的饥饿问题
破坏循环等待:给系统资源编号,按照编号递增顺序请求资源,编号递减的顺序释放资源,做到资源有序分配

缺点:

  • 必须按照顺序请求释放资源,编程会很麻烦
  • 如果增加新的资源,需要重新编号;

2. 避免死锁:银行家算法

安全序列:系统按照这种顺序分配资源,则每一个进程都能安全完成,系统处于安全状态,如果一次资源分配后,系统找不到安全序列,系统就处于不安全状态,注意,不安全状态不一定代表死锁的发生;

系统现在有五个进程 P0~P4,有三种资源,系统持有资源数用向量表示(10,5,7),各个进程的最大需求量和当前已分配的资源数如下;


系统还剩下(3,3,2),可以满足P1的需求,给P1使用,当P1结束了就会释放所有的资源,P1运行结束后,系统可用资源为:(5,3,2),此时满足P3,P4,按照刚刚的思路,找出一个可以让五个进程都能正常运行结束的顺序,就是安全序列,可能有几种安全序列;

银行家算法:当系统为进程分配资源时,会预判此次分配是否会导致系统进入不安全状态,如果会导致系统进入不安全状态,就会阻塞当前进程;

  • Max矩阵:各个进程对资源的最大需求量
  • Allocation矩阵:系统已经分配给各个进程的资源数
  • Need矩阵:各个进程还需要分配的资源数
  • Available数组:系统剩下的可用资源数
  • Request数组:当前进程申请的资源数
  1. 系统首先会检测本次申请的资源是否大于最多还需要, Request
  2. 再判断当前剩余的资源够不够分配给他,如果不够,则阻塞等待,如果够,进入3
  3. 先假装分配给他,计算分配后的资源数,新的Available数组,判断新的Available数组能否满足剩下进程的最大需求,如果不能,说明系统进入不安全状态,则停止分配,阻塞,下一个;如果能,才正式分配;

3. 死锁的检测和解除:允许死锁发生,操作系统会检测出死锁并解除死锁

检测死锁:


创建一个图的数据结构,节点分为进程节点和资源节点,进程节点的边表示进程所需的资源,资源节点的边表示已经分配给该进程的资源;
上图中R1节点表示R1资源有三个,已经分配个P1两个,P21个,当前无可用资源,R2节点表示R2资源有两个,一个已经分配给P2,系统剩一个可用资源,P1表示已经持有两个R1,正在向系统请求一个R2;

系统用上面这个图表示当前进程和资源的状态,通过这个图可以分析出是否发生了死锁;如果系统的剩余资源交给进程可以满足他的所有需求,这个进程就可以把所有的边去掉,即:运行结束,归还所有资源,不需要再请求资源了;如果最后所有的边都能去掉,表示没有死锁发生;

解除死锁:
  1. 剥夺资源:挂起一个死锁进程,将它的资源分配给另一个死锁进程,注意避免进程饥饿问题

  2. 撤销进程:撤销进程,会降低CPU的效率,因为进程在之前已经运行了一段时间了;

  3. 进程回退:系统记录进程的历史信息,设置还原点;

你可能感兴趣的:(王道操作系统——进程篇)