重点 第八章 异常控制流、进程线程、处理机调度算法、信号

主要了解应用如何与操作系统交互,这些交互都是围绕着异常控制流(ECF)的.

异常位于硬件和操作系统的交界部分。

异常控制流

  • 控制流

现代系统通过使控制流 发生突变对这些情况做出反应。我们称这种突变异常控制流Exceptional Control Flow,ECF)

异常控制流发生在系统的各个层次。

理解ECF很重要

  • 理解ECF将帮助你理解重要的系统概念。
  • 理解ECF将帮助你理解应用程序如何与操作系统交互 
    • 通过陷阱(trap)或者系统调用(system call)的ECF形式,向操作系统请求服务。
  • 理解ECF将帮助你编写有趣的应用程序
  • 理解ECF将帮助你理解并发
  • 理解ECF将帮助你理解软件异常如何工作。

8.1 异常

异常异常控制流的一种,一部分由硬件实现,一部分由操作系统实现。

  • 异常(exception)就是控制流中的突变,用来响应处理器状态的某些变化。
  • 状态变化又叫做事件(event)

    • 事件可能与当前执行指令有关 如:
      • 虚拟内存缺页,算术溢出
      • 除0
    • 也可能与当前执行指令无关 如:
      • I/O请求完成
      • 系统定时器产生信号
  • 在任何情况下,当处理器检测到有事件发生时,它就会通过异常表(exception table)的跳转表,进行一个间接过程调用,到专门用来设计处理这种事件的操作系统子程序(异常处理程序(exception handler))

  • 异常处理完成后,根据事件类型,会有三种情况

    • 返回当前指令,即发生事件时的指令。
    • 返回没有异常,所执行的下一条指令
    • 终止被中断的程序

8.1.1 异常处理

  • 系统为每个异常分配了一个非负的异常号(exception number)

    • 一些号码由处理器设计者分配(如被零除、缺页、内存访问违例、断点以及算术运算溢出)
    • 其他号码由操作系统内核的设计者分配。(系统调用、来自外部I/O设备的信号)
  • 系统启动时,操作系统分配和初始化一张称为异常表的跳转表(异常表是一张跳转表)。异常表中的异常条目0、1、2......k一一对应异常k的处理程序代码的地址。异常表的地址放在叫异常表基址寄存器的特殊CPU寄存器中。

  • 异常类似过程调用,不过有以下不同

    • 过程调用时,在跳转到处理程序之前,处理器先将返回地址压入栈中。对于异常,返回地址是当前指令,或下一跳指令。
    • 处理器会把额外的处理器状态压入栈中。
    • 如果控制从一个用户程序到内核,那么所有这些项目会被压入内核栈中,而不是用户栈。
    • 异常处理程序运行在内核模式下,这意味他们对所有系统资源有完全访问权限。

8.1.2 异常的类别

异常分为以下四类: 

  1. 中断

    • 中断异步发生,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成,从这个意义上它是异步的。
    • 硬件中断的异常处理程序通常称为中断处理程序(interrupt handle) 
      • I/O设备通过向处理器芯片的一个引脚发信号,并将异常号放到系统总线上,以触发中断。
      • 在当前指令执行完后,处理器注意到中断引脚的电压变化,从系统总线读取异常号,调用适当的中断处理程序。
      • 当处理程序完成后,它将控制返回给下一条本来要执行的指令。
      • 剩下的异常类型(陷阱,故障,终止)是同步发生,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction).
  2. 陷阱和系统调用

    • 陷阱有意的异常,是执行一个指令的结果。也会返回到下一跳本来要执行的指令。
    • 陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用 
      • 用户程序经常需要向内核请求服务。 
        • 读文件(read)
        • 创建进程(fork)
        • 新的程序(execve)
        • 终止当前进程(exit)
      • 为了运行对这些内核服务的受控访问,处理器提供了一条特殊的syscall n的指令
      • 系统调用是运行在内核模式下,而普通调用是用户模式下。
      • 执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序
  3. 故障

    • 故障由错误引起,可能被故障处理程序修正。

      • 如果能被修正,返回引起故障的指令。
      • 否则返回abort例程,进行终结。
  4. 终止

    • 终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM和SRAM被损坏。
    • 终止处理程序从不将控制返回给应用程序。而是返回一个abort例程。

8.1.3 Linux/IA32 系统中的异常

  • 有高达256种不同的异常

    • 0~31 由Intel架构师定义的异常,对任何IA32系统都一样。
    • 23~255 对应操作系统定义的中断和陷阱。

      1. Linux/IA32 故障和终止
    • 除法错误

    • 一般保护故障
    • 缺页
    • 机器检查

      1. Linux/IA32 系统调用

  • 在IA32系统中,系统调用是通过一条称为int n的陷阱指令完成,其中n可能是IA32异常表256个条目中任何一个索引,历史中,系统调用是通过异常128(0x80)提供的。

8.2 进程

进程与程序的区别

1、进程是动态的,程序时静态的

程序时有序代码的集合

进程是程序的执行,进程有核心态和用户态

2、进程是暂时的,程序时永久的

进程是一个状态变化的过程

程序可长久保存

同一个程序的多次运行对应不同的进程

3、进程与程序的组成不同

进程的组成包括程序、数据和进程控制块

进程 经典定义:一个执行中的程序实例. 
系统中每个程序都是运行在某个进程的 上下文 中的。 
上下文是由程序正确运行所需的状态组成。

这个状态(进程的组成)包括存储器中的代码和数据,它的栈,通用目的寄存器,程序计数器,环境变量等。

进程执行需要的资源:

内存:保存代码和数据

CPU:  执行指令

进程提供的假象 :
一个独立的 逻辑控制流 。(独占处理器)
一个私有的 地址空间 。(独占内存)

进程的特点

(1)结构性:进程包含程序及其相关数据结构。进程的实体包含进程控制块(PCB),程序块、数据块和堆栈,又称为进程映像(Process Image)     

(2)动态性:进程是程序在数据集合上的一次执行过程,具有生命周期,由创建而产生,由调度而运行,由结束而消亡,是一个动态推进、不断变化的过程。而程序则不然,程序是文件,静态而持久地存在。      

(3)独立性:进程是操作系统资源分配、保护和调度的基本单位,每个进程都有其自己的运行数据集,以各自独立的、不可预知的进度异步运行。进程的运行环境不是封闭的,进程间也可以通过操作系统进行数据共享、通信.   

(4)并发性:在同一段时间内.若干个进程可以共享一个CPU。进程的并发性能够改进系统的资源利用率,提高计算机的效率。进程在单CPU系统中并发执行,在多CPU系统中并行执行。进程的并发执行意味着进程的执行可以被打断,可能会带来一些意想不到的问题,因此必须对并发执行的进程进行协调.

进程控制块(PCB)

 1)PCB(Processing Control Block)是系统为了管理进程设置的一个专门的数据结构。系统用它来记录进程的外部特征,描述进程的运动变化过程。同时,系统可以利用PCB来控制和管理进程,所以说,PCB(进程控制块)是系统感知进程存在的唯一标志。即:每个进程都有一个PCB,且是唯一的。
2)PCB一般包括进程ID、处理机状态、进程调度信息、进程控制信息,具体包括:
1.程序ID(PID、进程句柄):它是唯一的,一个进程都必须对应一个PID。PID一般是整型数字;
2.特征信息:一般分系统进程、用户进程、或者内核进程等 ;
3.进程状态:运行、就绪、阻塞,表示进程现的运行情况 ;
4.优先级:表示获得CPU控制权的优先级大小 ;
5.通信信息:进程之间的通信关系的反映,由于操作系统会提供通信信道 ;
6.现场保护区:保护阻塞的进程的上下文 ;
7.资源需求、分配控制信息 ;
8.进程实体信息,指明程序路径和名称,进程数据在物理内存还是在交换分区(分页)中 ;
9.其他信息:工作单位,工作区,文件信息等;

3)每个进程有一个PCB,如何组织众多的PCB
①链表队列模式:
对各个状态的PCB分别建立一个链表队列,同时保存4个指针,分别是,
第一个执行指针指向当前执行PCB;
第二个就绪队列指针指向就绪状态的PCB链表队列首元素;
第三个阻塞队列指针指向阻塞状态的PCB链表队列首元素;
第四个空闲队列指针指向空闲状态的PCB链表队列首元素;

②索引模式:
对各个状态PCB分别建立一个索引表,同时保存4个指针,分别是,
第一个执行指针直接指向当前执行PCB;
第二个就绪表指针指向就绪索引表的首地址;
第三个阻塞表指针指向阻塞索引表的首地址;
第二个空闲表指针指向空闲索引表的首地址;

4)创建进程需要的步骤:
1,申请空白PCB(进程控制块);
2,为新进程分派资源;

3,初始化PCB;

4,将新进程插入就绪队列。(即新创建的进程处于就绪状态)

进程有四种状态:执行、阻塞(等待)、就绪和挂起;

2)进程各个状态之间互相转换:

①就绪——执行:对就绪状态的进程,当进程调度程序按一种选定的策略从中选中一个就绪进程,为之分配了

处理机(计算机系统中存储程序和数据,并按照程序规定的步骤执行指令的部件)后,该进程便由就绪状态变为执行状态;

②执行——阻塞: 正在执行的进程因发生某等待事件而无法执行,如IO请求,则进程由执行状态变为等待状态,如进程提出输入/输出请求而变成等待外部设备传输信息的状态,进程申请资源(主存空间或外部设备)得不到满足时变成等待资源状态,进程运行中出现了故障(程序出错或主存储器读写错等)变成等待干预状态等等;
③阻塞——就绪: 处于等待状态的进程,在其等待的事件已经发生,如IO完成,资源得到满足或错误处理完毕时,处于等待状态的进程并不马上转入执行状态,而是先转入就绪状态,然后再由系统进程调度程序在适当的时候将该进程转为执行状态;
④执行——就绪: 正在执行的进程,因时间片用完而被暂停执行,或在采用抢先式优先级调度算法的系统中,当有更高优先级的进程要运行而被迫让出处理机时,该进程便由执行状态转变为就绪状态。
⑤**——>挂起分三种:
阻塞——挂起:其中阻塞可以称为活动阻塞,挂起称为静止阻塞;
就绪——挂起:其中就绪可以称为活动就绪,挂起称为静止就绪;
执行——挂起:其中执行不变,挂起称为静止就绪;
故可得出结论:挂起就是静止状态;
⑥挂起解除只有一种情况:挂起——就绪;

3)引入挂起的原因:用户的需求(用户需要挂起一部分进程)、父进程需求(父进程需要考察和修改子进程)、负载均和需求(挂起一部分进程缓解负载)、操作系统需求(操作系统挂起一部分进程进行检查)。

 用户模式和内核模式

处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式内核模式

处理器通过控制寄存器中的一个模式位来提供这个功能。

该寄存器描述了进程当前享有的特权。 
1、设置了 模式位 后,进程就运行在内核模式中(有时也叫 超级用户模式
内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。
2、没有设置 模式位 时,进程运行在用户模式。 
用户模式不允许程序执行特权指令。 
  
  1. 不允许用户模式的进程直接引用地址空间的内核区代码和数据。
  2. 任何尝试都会导致保护故障
  3. 用户可以通过系统调用接口间接访问内核代码和数据。

用户态切换到内核态的3种方式:

通过中断,故障,陷井系统调用这样的异常。在异常处理程序中会进入内核模式。退出后,又返回用户模式。

a、系统调用

这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如前例中fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

b、 异常

当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

c、 外围设备的中断

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

总结:这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

2、进程切换

进程切换的原因:中断的发生、更高优先级进程的唤醒、进程消耗完时间片或阻塞的发生;

进程切换的步骤:
①保存处理器的上下文,包括程序计数器和其它寄存器;
②用新状态和其它相关信息更新正在运行进程的PCB;
③把原来的进程移至合适的队列-就绪、阻塞;
④选择另一个要执行的进程;
⑤更新被选中进程的PCB;
⑥从被选中进程中重装入;


8.2.1 逻辑控制流

  • PC值是包含在可执行目标文件中的指令或动态链接到程序的共享对象中的指令
  • PC(程序计数器)值的序列叫做逻辑控制流,或者简称逻辑流
  • 图中处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。其实三个逻辑流的执行是交错的。
  • 一个进程执行他的控制流的一部分所占的时间段叫做时间片,如时间A占用了两个时间片。

 并行与并发

答:并发与并行是两个不同的概念:并发是指多个请求向处理器同时请求,服务器的响应过程是依次响应,或者轮转响应的;并行是多个请求向多个处理器,各自处理各自的请求。

或者说:在单CPU系统中,系统调度在某一时刻只能让一个线程运行,虽然这种调试机制有多种形式(大多数是时间片轮巡为主),但无论如何,要通过不断切换需要运行的线程让其运行的方式就叫并发(concurrent)。而在多CPU系统中,可以让两个以上的线程同时运行,这种可以同时让两个以上线程同时运行的方式叫做并行。

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发

你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行

并发的关键是你有处理多个任务的能力,不一定要同时。

并行的关键是你有同时处理多个任务的能力。

8.2.3 私有地址空间

进程 好像独占了系统地址空间。

  • 一个进程为每个程序提供它自己的私有地址空间
  • 不同系统一般都用相同的结构。

8.3 系统调用错误处理

  • 当Unix系统级函数遇到错误时,他们典型地返回-1,并设置全局变量errno来表示什么出错了。

    if((pid=fork()<0){
            fprintf(stderr,"fork error: %s\n", strerror(errno));
            exit(0);
    }
  • strerror 函数返回一个文本串,描述了个某个errno值相关联的错误。

8.4 进程控制

8.4.1 获取进程ID

#include<sys/types.h>
#include<unistd.h>

pid_t getpid(void);
pid_t getppid(void);
  • PID是每个进程唯一的正数ID。
  • getpid()返回调用进程的PID,getppid()返回它的父进程的PID。
  • 返回一个类型pid_t的值,在Linux系统下在type.h被定义为int

8.4.2 创建和终止进程

进程总是处于下面三种状态

  • 运行。进程要么在CPU中执行,要么等待执行(阻塞),最终被内核调度。
  • 停止。进程的执行被挂起,且不会被调度。

    • 收到SIGSTOP,SIGTSTP,SIDTTIN或者SIGTTOU信号,进程就会停止。
    • 直到收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
    • 信号是一种软件中断的形式。 
正常终止有5种、非正常终止2种,见笔记P36
子进程

父进程通过调用fork函数创建一个新的运行子进程

#include<sys/types.h>
#include<unistd.h>

pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;

新创建的子进程几乎但不完全与父进程相同。

  • 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。

    • 包括文本,数据和bss段,堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。
    • 意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
    • 父进程和新创建的子进程之间最大的区别在于有不同的PID
  • fork()函数被调用一次 ,返回两次,一次在父进程,一次在子进程。

    • 返回值用来明确是在父进程还是在子进程中执行。

  • 调用一次,返回两次。 
    • 对于具有多个fork实例的需要仔细推敲了
  • 并发执行

    • 父进程和子进程是并发运行的独立进程。
    • 内核可能以任意方式决定执行他们的顺序。
    • 不能对不同进程中指令的交替执行做任何假设。
  • 相同但是独立的地址空间

    • 在刚调用时,几乎什么都是相同的。
    • 但是它们都有自己的私人空间,之后对x的改变是相互独立的。
  • 共享文件

    • 父进程和子进程都把他们的输出显示在屏幕上。
    • 子进程继承了父进程所有打开的文件。当父进程调用fork时,stdout文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。

画进程图会有帮助。


8.4.3 回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终结的状态,直到被它的父进程 回收(reap)。

当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。

一个终止了但还未被回收的进程叫做僵死进程

如果父进程没有回收已经终止了的进程,那么内核安排init进程来回收它们。

  • init进程的的PID为1,在系统初始化时由内核创建的。
  • 长时间运行的程序,如shell,服务器,总是应该回收他们的僵死子进程

一个进程可以通过调用waitpid函数来等待它的子进程终止或停止

#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则返回子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.

waitpid函数有点复杂。默认(option=0)时,waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止,如果等待集合的一个子进程在调用时刻就已经终止,那么waitpid立即返回。在这两种情况下,waitpid返回导致waitpid返回的已终止子进程的PID,并且将这个已终止的子进程从系统中去除。

  • 判断等待集合的成员

    等待集合的成员通过参数pid确定

    • 如果pid>0,那么等待集合就是一个独立的子进程,它的进程ID等于PID
    • 如果pid=-1,那么等待集合就是由父进程所有的子进程组成的。
    • waitpid函数还支持其他类型的等待集合,包括UNIX进程组等,不做讨论。
  • 修改默认行为 

    可以通过将options设置为常量WHOHANGWUNTRACED的各种组合,修改默认行为。

    • WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么立即返回(返回值为0) 
    • WUNTRACED:挂起调用进程的执行,知道等待集合中的一个进程变为已终结或被停止。 
    • WCONTINUED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被终止的进程收到SIGCONT信号重新开始执行。 
  • 检查已回收子进程的退出状态

    如果status 参数是非空的,那么waitpid就会在status参数中放上关于导致返回的子进程的状态信息。wait.h 头文件定义解释status参数的几个宏(函数宏):

    • WIFEXITED(status) : 如果子进程通过调用exit或者一个返回(return)正常终止,就返回真。
    • WEXITSATUS(status): 返回一个正常终止的子进程的退出状态。只有在WIFEXITED定义为真是,才会定义这个状态。
    • WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的,那么就返回真
    • WTERMSIG(status): 返回导致子进程终止的信号的数目,只有在WIFSIGNALED返回真时,才会定义这个状态。
    • WIFSTOPPED(status): 如果引起返回的子进程当前是被停止的,那么就返回真。
    • WSTOPSIG(status): 取得引发子进程暂停的信号代码,只有在WIFSTOPPED为真,才定义这个状态。
  • 错误条件

    • 调用进程没有子进程,那么waitpid返回-1,并且设置errnoECHILD
    • 如果waitpid函数被一个信号中断,那么它返回-1,并且设置errnoEINTR
  • wait 函数

    wait函数是waitpid函数的简单版本:

    #include<sys/types.h>
    #include<sys/wait.h>
    
    pid_t wait(int *status);

    调用wait(&status)等价于调用waitpid(-1,&status,0)

  • waitpid实例,按顺序回收僵死进程

8.4.4 让进程休眠

  • sleep函数将一个进程挂起一段指定时间

    #include <unistd.h>
    
    unsigned int sleep (unsigned int secs);
    返回:如果请求的时间到了则返回了,如果sleep()函数被一个信号中断而过早的返回,则返回还需要休眠的秒数
  • pause 让调用进程休眠,直到该进程收到一个信号

    #include<unistd.h>
    
    int pause(void);
  • 8.4.5 加载并运行一个程序

execve函数在当前进程的上下文中加载并运行了一个新程序。

#include <unistd.h>

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

execve函数加载并运行可执行目标文件filename,且带参数argv和环境变量列表envp

只有当出现错误时,execve才会返回到调用程序

  • *argv[]参数列表数据结构表示

    • 指向一个以null结尾的指针数组
    • 每个指针指向一个参数串。 
      • 一般来说,argv[0]是可执行目标文件的名字。
  • *envp[]环境列表数据结构表示类似

    • 指向一个以null结尾的指针数组
    • 每个指针指向一个环境变量串。 
      • 每个串都是形如KEY=VALUE的 键值对

execve加载filename以后,调用7.9节的启动代码,启动代码设置用户栈。并将控制传递给新程序的主函数。

  • 主函数有如下原型

    int main(int argc,char **argv,char **envp);
    int main(int argc,char *argv[],char *envp[]);
  • 当开始执行时,用户栈如图。

    • argc: 命令行参数个数
    • argv: 命令行指针数组的地址
    • envp: 环境变量指针数组的地址

Unix提供一下几个函数来操作环境数组。

  • getenv

    #include<stdlib.h>
    char *getenv(const char *name);
    //getenv函数在环境变量搜索字符串“name=value"。如果找到了,它就返回一个指向value的指针,否则返回NULL。
  • setenvunsetenv

    #include<stdlib.h>
    int setenv(const char *name,const char *newvalue,int overwrite);
    //成功返回0,错误返回-1
    void unsetenv(const char *name);
    
    //如果环境数组包含一个形如"name=oldvalue"的字符串,那么unsetenv会删除它
    //,而setenv会用newvalue代替oldvalue,但是只有在overwirte非零时才会这样。
    //如果name不存在,那么setenv就把”name=newvalue"添加进指针数组。
    

 forkexecve区别

  • fork:在新的子进程运行相同的程序。 
    • 新进程是父进程的复制品。
  • execve:在当前进程的上下文加载并运行一个新的程序。 
    • 覆盖当前进程的地址空间。
    • 但没有创建新进程。
    • 新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。

8.4.6 利用fork和execve运行程序

Unix shellWeb服务器 这样的程序大量使用forkexecve函数。

shell是一种交互型的应用级程序,代表用户运行其他程序。

  • 最早的shellsh程序。
  • 后面出现了csh,tcsh,ksh,bash
  • shell执行一系列 read/evaluate 
    • read:读取来自用户的命令。
    • evaluate:解析命令,并代表用户执行程序。

临界资源

答:在操作系统中,将互斥共享资源成为临界资源,即该资源可共享,但每次只能一个进程访问,例如输入机、打印机、磁带机等。每个进程中访问临界资源的那段代码称为临界区,在操作系统中,有临界区的概念。临界区内放的一般是被1个以上的进程或线程(以下只说进程)共用的数据。临界区内的数据一次只能同时被一个进程使用,当一个进程使用临界区内的数据时,其他需要使用临界区数据的进程进入等待状态。操作系统需要合理的分配临界区以达到多进程的同步和互斥关系,如果协调不好,就容易使系统处于不安全状态,甚至出现死锁现象。

5、进程饥饿

答:由于别的并发进程持久占有所需资源,使某个异步过程在一定时间内不能被激活,从而导致进程推进和响应受到明显影响时,称发生了进程饥饿。当饥饿到一定程度,进程所赋予的任务即使完成也不再具有实际意义时称该进程被饿死。

6、孤儿进程、僵尸进程、守护进程

答:定义如下:
(1)僵尸进程:一个子进程在其父进程还没有调用wait()或waitpid()的情况下退出。这个子进程就是僵尸进程;
(2)孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作;
注:僵尸进程将会导致资源浪费,而孤儿则不会。
(3)守护进程:守护进程(Daemon Process),也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程是个特殊的孤儿进程,这种进程脱离终端,为什么要脱离终端呢?之所以脱离于终端是为了避免进程被任何终端所产生的信息所打断,其在执行过程中的信息也不在任何终端上显示。

进程与线程

答:线程和进程的区别联系:

(1)进程有自己独立的进程资源(如:地址空间),线程则共享资源;

(2)进程是并行工作的基本单位和资源分配基本单元,线程是CPU调度的基本单位;

(2)二者均可并发执行;

(3)一个程序至少有一个进程,一个进程至少有一个线程,它们是包含关系;

根本区别就一点:进程有自己独立的进程资源(如:地址空间),线程则共享资源。

线程私有与共享:

1)线程私有包括:栈、寄存器、状态、程序计数器;

2)线程间共享的有:堆,地址空间,全局变量,静态变量;

线程的互斥量(锁)与信号量

答:(1)互斥量用于线程的互斥,信号量用于线程的同步;这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。

互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的;

同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入

资源的情况必定是互斥的,少数情况是指可以允许多个访问者同时访问资源;

(2) 互斥量值只能为0/1,信号量值可以为非负整数;

(3)互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

即:互斥量是针对一个线程的状态而言的,信号量是针对多个线程状态的;例如:

当信号量用于临界资源的共享时,信号量初始值为1,取值范围为(-1, 0,1),

当信号量为1时,表示两个进程皆未进入需要互斥的临界区;

当信号量为0时,表示有一个进程进入临界区运行,另一个必须等待;

当信号量为-1时,表示有一个进程正在临界区运行,另一个进程因等待而阻塞在信号量队列中,需要当前已在临界区运行的进程退出时唤醒。

进程与线程间的通信方式    见APUE笔记

处理机调度算法

作业(job)是操作系统中一个常见的概念,所谓作业是指用户在一次计算过程或者事务处理过程中,要求计算机系统所作工作的集合。

设计作业调度算法时应达到如下目标:

•    (1) 某段时间内尽可能运行更多的作业,应该优先考虑短作业。

•    (2) 使处理机保持繁忙,应该优先考虑计算量大的作业,即计算型作业。

•    (3) 使I/O设备保持繁忙,应该优先考虑I/O繁忙的作业,即I/O型作业。

•    (4) 对所有的作业尽可能公平合理的。这就要求对每个作业尽可能公平对待,不无故地或无限期地拖延一个作业的执行。

(一)作业调度离不开具体调度算法,常用的几种作业调度算法:

(1)先来先服务调度算法

该算法优先考虑在系统中等待时间最长的作业,而不考虑作业运行时间的长短。如下例,作业运行的次序为:1,2,3,4:


(2)短作业优先调度算法

短作业优先调度算法(shortest job first,SJF)总是从作业的后备队列中挑选运行时间最短的作业,作为下—个调度运行的对象。如下例,作业运行的次序为:1,3,4,2:


(3)响应比高者优先调度算法

所谓响应比高者优先调度算法,就是在每次调度作业运行时,先计算后备作业队列中每个作业的响应比,然后挑选响应比最高者投入运行。响应比R定义如下:

R=作业的响应时间/作业的运行时间

作业的响应时间=作业的等待时间+作业的运行时间

因此,作业的响应比为:R=1+作业的等待时间/作业的运行时间。

从以上公式中可从看出,一个作业的响应比随着等待时间的增加而提高。这样,只要系统中的某作业等待了足够长的时间,它总会成为响应比最高者而获得运行的机会。如下例,作业运行的次序为:1,3,2,4:


(4)最高优先数调度算法

此算法根据作业的优先数调度作业进入系统运行。为每个作业确定一个优先数,资源能满足且优先数高的作业优先被选中,当几个作业有相同的优先数时,对这些具有相同优先数的作业再按照先来先服务原则进行调度。作业优先数的确定各系统有所不同,有些系统由系统本身根据作业对资源的要求确定其优先数,有的由用户自行确定自己作业的优先数。

优先数一般在系统中是根据进程运行情况动态确定的,但也有静态优先数调度;

(5)均衡调度算法

   这种调度算法根据作业对资源的要求进行分类,作业调度从各类作业中挑选,尽可能地使使用不同资源的作业同时执行。这样不仅可以使系统中的不同类型的资源都在被使用,而且可以减少作业等待使用相同资源的时间,从而加快作业的执行。

(6)时间片轮转调度算法

        时间片轮询算法,这是对FIFO算法的改进,目的是改善短程序(运行时间短)的响应时间,其方法就是周期性地进行进程切换。这个算法的关键点在于时间片的选择,时间片过大,那么轮转就越接近FIFO,如果太小,进程切换的开销大于执行程序的开销,从而降低了系统效率。因此选择合适的时间片就非常重要。选择时间片的两个需要考虑的因素:一次进程切换所使用的系统消耗以及我们能接受的整个系统消耗、系统运行的进程数。
        时间片轮询看上起非常公平,并且响应时间非常好,然而时间片轮转并不能保证系统的响应时间总是比FIFO短,这很大程度上取决于时间片大小的选择,以及这个大小与进程运行时间的相互关系。

8.5 信号

研究一种更高层次的软件形式的异常, 也是一种软件中断,称为Unix信号,它允许进程中断其他进程。

一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。

Linux系统支持30多种信号。

每种信号类型对应于某种系统事件

  • 底层的信号。

    • 当底层发生硬件异常,信号通知 用户进程 发生了这些异常

      • 除以0:发送SIGILL信号。
      • 非法存储器引用:发送SIGSEGV信号
    • 较高层次的软件事件

      • 键入ctrl+c:发送SIGINT信号
      • 一个进程可以发送给另一个进程SIGKILL信号强制终止它。
      • 子进程终止或者停止,内核会发送一个SIGCHLD信号给父进程。

8.5.1 信号术语

传送一个信号到目的进程有两个步骤。

  • 发送信号: 内核通过更新目的进程上下文的某个状态,就说发送一个信号给目的进程。

  • 接收信号: 当目的进程 被内核强迫以某种方式对信号的发送做出反应。目的进程就接收了信号

    • 进程可以忽略这个信号,终止。
    • 或者通过一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。 

一个只发出而没有被接收的信号叫做待处理信号(pending signal)

  • 一种类型至多只有一个待处理信号。 
    • 如果一个进程有一个类型为k待处理信号
    • 那么接下来发送到这个进程类型为k的信号都会被简单的丢弃

一个进程可以有选择性地阻塞接收某种信号

  • 它仍然可以被发送。但是产生的待处理信号不会被接收。

8.5.2 发送信号

Unix系统 提供大量向进程发送信号的机制。所有这些机制都是基于进程组(process group)。

  1. 进程组

    • 每个进程都属于一个进程组

      • 由一个正整数进程组ID来标示

        • getpgrp()函数返回当前进程的进程组ID:

          #include<unistd.h>
          pid_t getpgrp(void)

      • 默认,一个子进程和它的父进程同属于一个进程组

        • 一个进程可以通过setpgid()将进程pid的进程组改为pgid。

          #include<unistd.h>
          int setpgid(pid_t pid,pid_t pgid);
          如果pid是0 ,那么使用当前进程的pid作为他的进程组ID即pgid。
          如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。
          
          例如:进程15213调用setpgid(0,0)
          那么会创建一个新的进程组,其进程组ID是15213,进程15213会加入进程组15213

  2. /bin/kill 程序发送信号

    • /bin/kill可以向另外的进程发送任意的信号

      • 比如

        unix>/bin/kill -9 15213
        
        •  发送信号9(SIGKILL)给进程15213。
      • 一个为负的PID会导致信号被发送到进程组PID中的每个进程。

        unix>/bin/kill -9 -15213

        发送信号9(SIGKILL)给进程组15213中的每个进程。

    • /bin/kill的原因是,有些Unix shell 有自己的kill命令

  3. 键盘发送信号(课本p529)

    • 键盘输入ctrl-c 会发送一个SIGINT信号到外壳。外壳捕获该信号。然后发送SIGINT信号到这个前台进程组的每个进程。在默认情况下,结果是终止前台作业

      类似,输入ctrl-z会发送一个SIGTSTP信号到外壳,外壳捕获这个信号,并发送SIGTSTP信号给前台进程组的每个进程,在默认情况,结果是停止(挂起)前台作业(还是僵死的)

  4. kill函数发送信号

    • 进程通过调用kill函数发送信号给其他进程,类似于bin/kill

      int kill(pid_t pid, int sig);
    • pid>0,发送信号sig给进程pid

    • pid<0,发送信号sig给进程组abs(pid)

    • 事例:kill(pid,SIGKILL)

  5. alarm函数发送信号

    进程可以通过调用alarm函数向它自己发送SIGALRM信号。

    #include<unistd.h>
    
    unsigned int alarm(unsigned int secs);
    
    返回:前一次闹钟剩余的秒数。
    
    •  

    alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程

    • 如果secs=0 那么不会调度闹钟,当然不会发送SIGALRM信号。

    • 在任何情况,对alarm的调用会取消待处理(pending)的闹钟,并且会返回被取消的闹钟还剩余多少秒结束。如果没有pending的话,返回0

    • alarm函数只负责说多少秒之后会有个ALARM信号会来,并不影响后面的语句的执行

    一个例子: 

    输出

    unix> ./alarm
    BEEP
    BEEP
    BEEP
    BEEP
    BEEP
    BOOM!
    //handler是一个自己定义的信号处理程序,通过signal函数捆绑。
    
    •  

8.5.3 接收信号

当内核从一个异常处理程序(把进程p从内核模式切换到用户模式时)返回,准备将控制传递给进程p时,它会检查进程p未被阻塞的待处理信号的集合(pening&~blocked)。

  • 如果这个集合为空,内核将控制传递到p的逻辑控制流的下一条指令。
  • 如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为。一旦进程完成行为,传递到p的逻辑控制流的下一条指令。

    • 每个信号类型都有一个预定义的默认类型,以下几种.

      • 进程终止
      • 进程终止并转储存器(dump core)
      • 进程停止直到被SIGCONT信号重启
      • 进程忽略该信号
    • 进程可以通过使用signal函数修改和信号相关联的默认行为。

      • SIGSTOP,SIGKILL是不能被修改的例外。

        #include<signal.h>
        typedef void (*sighandler_t)(int);
        
        sighandler_t signal(int signum,sighandler_t handler);
      • signal函数通过下列三种方式之一改变和信号signum相关联的行为。

        • 如果handlerSIG_IGN,那么忽略类型为signum的信号
        • 如果handlerSIG_DFL,那么类型为signum的信号恢复为默认行为。
        • 否则,handler就是用户定义的函数地址,这个函数称为信号处理程序 
          • 只要进程接收到一个类型为signum的信号,就会调用handler。
          • 调用信号处理程序,叫捕获信号
          • 执行信号处理程序,叫处理信号
    • 当处理程序执行它的return语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。

信号处理程序是计算机并发的又一个示例。信号处理程序的执行中断,类似于底层异常处理程序中断当前应用程序的控制流的方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数并发地运行。


自我思考:信号是一种异常/中断,当接收到信号的时候,会停下当前进程所做的事,立马去执行信号处理程序。并不是多线程/并行,但确是并发的。从下面这张图,可见一斑。

阻塞和解除阻塞信号

隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。

显示阻塞机制:应用程序可以使用sigpromask函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。

通过sigprocmask函数来操作。

#include<signal.h>

int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
  • sigprocmask函数改变当前已阻塞信号的集合(8.5.1节描述的blocked位向量)。

    • 具体行为依赖how

      • SIG_BLOCK:添加set中的信号到blocked中。
      • SIG_UNBLOCK: 从blocked删除set中的信号。
      • SIG_SETMASKblocked=set
    • 如果oldset非空,block位向量以前的值会保存到oldset中。

还有以下函数操作set集合

#include<signal.h>

int sigemptyset(sigset_t *set);
//置空
int sigfillset(sigset_t *set);
//每个信号全部填入
int sigaddset(sigset_t *set,int signum);
//添加
int sigdelset(sigset_t *set,int signum);
//删除
//成功输出0,出错输出-1
int sigismember(const sigset_t *set,int signum);
//判断

//若signum是set的成员,输出1,不是输出0,出错输出-1。下面的程序展示了如何利用sigpromask来临时阻塞接收SIGINT信号p539练习题8.8很好的题,必做。

正确的信号处理

未处理信号是不排队的。(即再次出现相同未处理信号时直接被丢弃,故编程时要人为的使未处理信号排队)

情形:当程序正在处理一个子进程结束信号SIGCHLD时,第二个SIGCHLD信号到达,会被阻塞。而当第三个SIGCHLD信号到达时,会被直接丢弃。

关键思想是:如果存在一个未处理的信号就表明至少有一个未处理的信号到达了。

8.5.5 可移植的信号处理

不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用是重启,还是永久放弃)是Unix信号系统的一个缺陷。

为了处理这个问题,Posix标准定义了sigaction函数,它允许与LinuxSolaris这样与Posix兼容的系统上的用户,明确指明他们想要的信号处理语义。

#include<signal.h>

int sigaction(int signum,stuct sigaction *act,struct sigaction *oldcat);
//若成功则为1,出错则为-1。

sigaction函数应用不广泛,它要求用户设置多个结构条目


一个更简洁的方式,是定义一个包装函数,称为Signal,它调用sigaction。 

  • 它的调用方式与signal函数的调用方式一样。
  • Signal包装函数设置了一个信号处理程序,其信号处理语义如下(设置标准): 
    • 只有这个处理程序当前正在处理的那种类型被阻塞。
    • 和所有信号实现一样,信号不会排队等待。
    • 只要可能,被中断的系统调用会自动重启
    • 一旦设置了信号处理程序,它就会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。 
      • 在某些比较老的Unix系统,信号处理程序被使用一次后,又回到默认行为。

8.5.7 同步流以避免讨厌的并发错误

如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。

  • 流可能交错 的数量是与指令数 量呈指数关系 
    • 有些交错会产生正确结果,有些可能不会。

所谓同步流就是。以某种方式同步并发流,从而得到 最大的可行交错的集合 ,每个交错集合都能得到正确的结果。

并发编程是一个很深奥,很重要的问题。在第12章详细讨论。

现在我们只考虑一个并发相关的智力挑战。

code

如果发生以下情况,会出现同步错误

  • 父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程。
  • 在父进程再次运行前,子进程已经终止,变成僵死进程,需要内核一个SIGCHLD信号给父进程
  • 父进程处理信号,调用deletejob.
  • 调用addjob

显然deletejob必须在addjob之后,不然添加进去的job永久存在。这就是同步错误


这是一个称为竞争(race)的经典同步错误的示例。

  • main中的addjob和处理程序中调用deletejob之间存在竞争。
  • 必须addjob赢得进展,结果才是正确的,否则就是错误的。但是addjob不一定能赢,所以有可能错误。即为同步错误。
  • 因为内核的调度问题,这种错误十分难以被发现。难以调试。

Q:如何消除竞争?

A:可以在fork之前,阻塞SIGCHLD信号,在调用addjob后取消阻塞。

  • 注意,子进程继承了阻塞,我们要小心地接触子进程中的阻塞。
  • 消除竞争的原则就是,让该赢得竞争的对象在任何情况下都能赢。

8.6 非本地跳转

C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump)

  • 它将控制直接从一个函数转移到另一个当前正在执行的函数。不需要经过正常的调用-返回序列。
  • 非本地跳转是通过setjmplongjmp函数来提供。

    #include<setjmp.h>
    
    int setjmp(jmp_buf env);
    int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用
    //参数savesigs若为非0则代表搁置的信号集合也会一块保存 
    • setjmp函数在env缓冲区保存当前调用环境,以供后面longjmp使用,并返回0, setjmp返回的值不能被赋值给变量

      • 调用环境包括程序计数器栈指针通用目的寄存器

8.7 操作进程的工具

  • STRACE(痕迹):打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。

    • -static编译,能得到一个更干净,不带有大量共享库相关的输出的轨迹。
  • PS(Processes Status): 列出当前系统的进程(包括僵死进程)

  • TOP(因为我们关注峰值的几个程序,所以叫TOP):打印当前进程使用的信息。

  • PMAP(rePort Memory map of A Process): 查看进程的内存映像信息

  • /proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构。

    • 用户程序可以读取这些内容。
    • 比如,输入"cat /proc/loadavg,观察Linux系统上当前的平均负载。

8.8 小结

  • 异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。

  • 硬件层异常是处理器中的事件触发的控制流中的突变。控制流传递给一个异常处理程序,该处理程序进行一些处理,然后返回控制被中断的控制流。

    • 有四种不同类型的异常:中断,故障,终止和陷阱。

      • 定时器芯片或磁盘控制器,设置了处理器芯片上的中断引脚时,中断异步发生。返回到Inext
      • 一条指令的执行可能导致故障终止同时出现。

        • 故障可能返回调用指令。
        • 终止不将控制返回。
      • 陷阱用于系统调用。结束后,返回Inext(下一条指令)

  • 操作系统层,内核用ECF提供进程的基本概念。进程给应用两个重要抽象:

    • 逻辑控制流(它提供给每个程序一个假象,好像它是在独占的使用处理器)
    • 私有地址空间(它提供给每个程序一个假象,好像它是在独占的使用内存
  • 操作系统和应用程序接口处,有子进程,和信号

  • 应用层,C语言的非本地跳转 完成应用程序层面的异常处理。

至此,异常贯穿了从底层硬件,到抽象的软件层次。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值