《求职》第四部分 - 操作系统篇 - 操作系统基础

1.计算机系统概述

1.1 基本构成

计算机的四个主要组件

  • 处理器:控制计算机的操作,执行数据处理功能。
  • 内存:存储数据和程序。
  • I/O模块:计算机内部和外部之间交换数据。
  • 系统总线:在CPU、内存和输入输出之间提供通信的设施。

1.2 指令的执行

基本指令周期,指令处理包括2步:

  • 处理器从存储器一次读一条指令;

  • 执行每条指令;

处理器中的PC(程序计算器)保存下一条指令的地址,IR(指令寄存器)保存当前即将执行的指令。

1.3中断

允许“其他模块”(I/O、存储器)中断“处理器”正常处理过程的机制。

1.3.1 目的

提高CPU利用率,防止一个程序垄断CPU资源。

1.3.2 类型

1)程序中断

2)时钟中断

3)I/O中断

4)硬件失效中断

1.3.3 中断控制流

中断:短I/O等待

  • 利用中断功能,处理器可以在I/O操作的执行过程中执行其它指令:用户程序到达系统调用WRITE处,但涉及的I/O程序仅包括准备代码和真正的I/O命令。在这些为数不多的几条指令执行后,控制返回到用户程序。在这期间,外部设备忙于从计算机存储器接收数据并打印。这种I/O操作和用户程序中指令的执行是并发的。

  • 当外部设备做好服务的准备时,也就是说,当它准备好从处理器接收更多的数据时,该外部设备的I/O模块给处理器发送一个中断请求信号。这时处理器会做出响应,暂停当前程序的处理,转去处理服务于特定I/O设备的程序,这个程序称为中断处理程序。在对该设备的服务响应完成后,处理器恢复原先的执行。

中断:长I/O等待

  • 对于如打印机等较慢的设备来说,I/O操作比执行一系列用户指令的时间长得多,因此在下一次I/O操作时,前一次I/O可能还为执行完。第二次WRITE调用时,第一次WRITE的I/O还为执行完,结果是用户程序会在这挂起,当前面I/O完成后,才能继续新的WRITE调用。

1.3.4 中断处理

中断激活了很多事件,包括处理器硬件中的事件及软件中的事件。
被中断程序的信息保存与恢复:

1.3.5 多个中断
在处理一个中断的过程中,可能会发生另一个中断,处理多个中断有2种方法:

  • 当正在处理一个中断时,禁止再发生中断:如果有新的中断请求信号,处理器不予理睬。通常在处理中断期间发生的中断会被挂起,当处理器再次允许中断时再处理。

  • 定义中断优先级:允许高优先级的中断处理打断低优先级的中断处理程序的允许

1.4存储器的层次结构

在这里插入图片描述

从上往下看,会出现以下情况: * 每“位”的价格递减 * 容量递增 * 存取时间递增 * 处理器访问存储器的频率递减(有效的基础是访问的局部性原理)。

1.4.1 高速缓存

内存的存储周期跟不上处理器周期,因此,利用局部性原理在处理器和内存间提供一个容量小而速度快的存储器,称为高速缓存。

上图中高速缓存通常分为多级:L1、L2、L3

1.5直接内存存取(DMA)

针对I/O操作有3种可能的技术

  • 可编程(程序控制)I/O(需处理器干预)
  • 中断驱动I/O(需处理器干预)
  • 直接内存存取。

当处理器正在执行程序并遇到一个I/O相关的指令时,它通过给相应的I/O模块发命令来执行这个指令:

1)使用可编程I/O时,I/O模块执行请求的动作并设置I/O状态寄存器中相应的位,但它并不进一步通 知处理器,尤其是它并不中断处理器,因此处理器在执行I/O指令后,还需定期检查I/O模块的状态。为了确定I/O模块是否做好了接收或发送更多数据的准备,处理器等待期间必须不断询问I/O模块的状态,这会严重降低整个系统的性能

2)如果是中断驱动I/O,在给I/O模块发送I/O命令后,处理器可以继续做其它事。当I/O模块准备好与处理器交换数据时,会中断处理器并请求服务,处理器接着响应中断,完成后再恢复以前的执行过程
尽管中断驱动I/O比可编程I/O更有效,但是处理器仍需要主动干预在存储器和I/O模块直接的数据传送,并且任何数据传送都必须完全通过处理器。由于需要处理器干预,这两种I/O存在下列缺陷:

  • I/O传送速度受限于处理器测试设备和提供服务的速度(数据传送受限于处理器)

  • 处理器忙于管理I/O传送工作,必须执行很多指令以完成I/O传送(处理器为数据传送需要做很多事)

3)因此,当需要移动大量数据时,需要使用一种更有效的技术:直接内存存取。DMA功能可以由系统总线中一个独立的模块完成,也可以并入到一个I/O模块中。

DMA的工作方式如下,当处理器需要读写一块数据时,它给DMA模块产生一条命令,发送下列信息:

  • 是否请求一次读或写
  • 涉及的I/O设备的地址
  • 开始读或写的存储器单元
  • 需要读或写的字数

之后处理器继续其它工作。处理器将这个操作委托给DMA模块,DMA模块直接与存储器交互,这个过程不需要处理器参与。当传送完成后,DMA模块发送一个中断信号给处理器。因此只有在开始和结束时,处理器才会参与

2.操作系统概述

操作系统特点:并发性、共享性、虚拟性、不确定性。

2.1操作系统的目标和功能

操作系统是控制应用程序执行的程序,并充当应用程序和计算机硬件之间的接口。

  • 作为用户/计算机接口;

  • 作为资源管理器(操作系统控制处理器使用其他系统资源,并控制其他程序的执行时机;

  • 易扩展性。

2.2操作系统的发展

  1. 串行处理:程序员直接与计算机硬件打交道,因为当时还没操作系统。这些机器在一个控制台上运行,用机器代码编写的程序通过输入设备载入计算机。如果发生错误使得程序停止,错误原因由显示灯指示。如果程序正常完成,输出结果出现在打印机中

  2. 简单批处理系统:中心思想是使用一个称为监控程序的软件。通过使用这类操作系统,用户不再直接访问机器,相反,用户把卡片或磁带中的作业提交给计算机操作员,由他把这些作业按顺序组织成一批,并将整个批作业放在输入设备上,供监控程序使用。每个程序完成处理后返回到监控程序,同时,监控程序自动加载下一个程序

  3. 多道批处理系统:简单批处理系统提供了自动作业序列,但是处理器仍经常空闲,因为对于I/O指令,处理器必须等到其执行完才能继续。内存空间可以保持操作系统和一个用户程序,假设内存空间容得下操作系统和两个用户程序,那么当一个作业需要等到I/O时,处理器可以切换到另一个可能不需要等到I/O的作业。进一步还可以扩展存储器保存三个、四个或更多的程序,并且在他们之间进行切换。这种处理称为多道程序设计或多任务处理,是现代操作系统的主要方案

  4. 分时系统:正如多道程序设计允许处理器同时处理多个批作业一样,它还可以用于处理多个交互作业。对于后一种情况,由于多个用户分享处理器时间,因而该技术称为分时。在分时系统中,多个用户可以通过终端同时访问系统,由操作系统控制每个用户程序以很短的时间为单位交替执行
    以下为多道批处理系统与分时系统的比较
    批处理多道程序设计 分时
    主要目标 充分使用处理器 减小响应时间
    操作系统指令源 作业控制语言;作业提供的命令 终端输入的命令

2.3现代操作系统

对操作系统要求上的变化速度之快不仅需要修改和增强现有的操作系统体系结构,而且需要有新的操作系统组织方法。在实验用和商用操作系统中有很多不同的方法和设计要素,大致分为以下几类:

  • 微内核体系结构
  • 多线程
  • 对称多处理
  • 分布式操作系统
  • 面向对象设计

大内核:至今为止大多数操作系统都有一个单体内核,操作系统应该提供的大多数功能由这些大内核提供,包括调度、文件系统、网络、设备管理器、存储管理等。典型情况下,这个大内核是作为一个进程实现的,所有元素共享相同的地址空间。

微内核:微内核体系结构只给内核分配一些最基本的功能,包括地址空间,进程间通信和基本的调度。其它操作系统服务都是由运行在用户态下且与其他应用程序类似的进程提供,这些进程可以根据特定应用和环境定制。这种方法把内核和服务程序的开发分离开,可以为特定的应用程序或环境要求定制服务程序。可以使系统结构的设计更简单、灵活,很适合于分布式环境。

3.进程

3.1进程的概念

定义:进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例,包括程序计数器,寄存器和变量的当前值。能分配给处理器并由处理器执行的实体。一个具有以下特征的活动单元:一组指令序列的执行、一个当前状态和相关的系统资源集。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

也可以把进程视为由程序代码、和代码相关联的数据集、进程控制块组成的实体。

进程控制块:由操作系统创建和管理。进程控制块包含了充分的信息,这样就可以中断一个进程的执行,并且在后来恢复执行进程时就好像进程未被中断过一样。进程控制块是操作系统能够支持多进程和提供多重处理技术的关键,进程控制块是操作系统中最重要的数据结构,每个进程控制块包含操作系统所需要的关于进程的所有信息

进程被中断时,操作系统会把程序计数器和上下文数据保存到进程控制块中的相应位置。

程序状态字(PSW):所有处理器设计都包括一个或一组通常称为程序状态字的寄存器,包含有进程的状态信息。

【注】一个进程接到来自客户端新的请求时,可以通过fork()复制出一个子进程让其来处理,父进程只需负责监控请求的到来,这样就能做到并发处理。根据写时拷贝(copy on write)的机制,分为两个进程继续运行后面的代码。fork分别在父进程和子进程中返回,在子进程返回的值永远是0,在父进程返回的是子进程的pid。

总结:

  1. 进程是指在系统中正在运行的一个应用程序,程序一旦运行就是进程;

  2. 进程可以认为是程序执行的一个实例,进程是系统进行资源分配的最小单位,且每个进程拥有独立的地址空间

  3. 一个进程无法直接访问另一个进程的变量和数据结构,如果希望一个进程去访问另一个进程的资源,需要使用进程间的通信,比如:管道、消息队列等

  4. 线程是进程的一个实体,是进程的一条执行路径;比进程更小的独立运行的基本单位,线程也被称为轻量级进程,一个程序至少有一个进程,一个进程至少有一个线程;

3.2进程的状态

3.2.1 进程的创建与终止

进程按以下步骤创建:

1.给新进程分配一个唯一的进程标识符

2.给新进程分配空间(包括进程映像中的所有元素)

3.初始化进程控制块

4.设置正确的连接(保存到相应队列)

会导致创建进程的事件:

会导致终止进程的事件:

3.2.2 两状态进程模型

在任何时候,一个进程要么在执行要么未执行,因此可以构建最简单的模型。如下图。

在这里插入图片描述

进程处于两种状态之一:运行或者未运行状态。如图(a)所示。操作系统创建一个新进程时,它将该进程以未运行态加入系统,操作系统知道这个进程的存在,并等待执行机会。时不时,当前正在运行的进程被中断,此时操作系统中的分派器将选择一个新进程运行。前一个进程运行态转为为未运行状态,后一个则进入运行状态。

前文用提到,包含进程最重要的信息是进程控制块,未运行的进程必须在某种类型的队列中,并等待执行时机。如图(b)所示,该结构有一个队列,队列中的每项指向进程的指针,或者队列可由数据块构成的链表组成,每个数据块表示一个进程。

我们可以用排队图分配进程的执行。

3.2.3 五状态进程模型

在这里插入图片描述

1)创建状态:进程正在被创建

2)就绪状态:进程被加入到就绪队列中等待CPU调度运行

3)执行状态:进程正在被运行

4)等待阻塞状态:进程因为某种原因,比如等待I/O,等待设备,而暂时不能运行。

5)终止状态:进程运行完毕

2.2.4 引入”挂起态“的进程模型

考虑一个没有使用虚拟内存的系统,每个被执行的进程必须完全载入内存,因此,2.3图b)中,所有队列中的所有进程必须驻留在内存中。

所有这些设计机制的原因都是由于I/O活动比计算速度慢得多,因此在单道程序系统中的处理器大多数时候是空闲的。但是2.3图b)的方案并未完全解决这个问题。在这种情况下,内存保存有多个进程,当一个进程正在等待时,处理器可以转移到另一个进程,但是处理器比I/O要快的多,以至于内存中所有的进程都在等待I/O的情况很常见。因此,即使是多道程序设计,大多数时候处理器仍然处于空闲。

因此,可以把内存中某个进程的一部分或全部移出到磁盘中。当内存中没有处于就绪状态的进程时,操作系统就把被阻塞的进程换出到磁盘中的”挂起队列“。操作系统在此之后取出挂起队列中的另一个进程,或者接受一个新进程的请求,将其纳入内存运行。

“交换”是一个I/O操作,因而也可能使问题更加恶化。但是由于磁盘I/O一般是系统中最快的I/O(相对于磁带或打印机I/O),所以交换通常会提高性能。

进程模型

  • 就绪/挂起->就绪:1)内存中没有就绪态进程,需要调入一个进程继续执行;2)处于就绪/挂起的进程具有更高优先级

  • 就绪->就绪/挂起:1)如果释放空间以得到足够空间的唯一方法是挂起一个就绪态的进程;2)如果操作系统确信高优先级的阻塞态进程很快将会就绪,那么可能会挂起一个低优先级的就绪态进程而不是一个高优先级的阻塞态进程

  • 新建->就绪/挂起:进程创建需要为其分配内存空间,如果内存中没有足够的空间分配给新进程,会使用”新建->就绪/挂起“转换

  • 阻塞/挂起->阻塞:比较少见。如果一个进程终止,释放了一些内存空间,阻塞/挂起队列中有一个进程比就绪/挂起队列中任何进程的优先级都要高,并且操作系统有理由相信阻塞进程的事件很快就会发生

  • 运行->就绪/挂起:如果位于阻塞/挂起队列中的具有较高优先级的进程变得不再阻塞,操作系统抢占这个进程,也可以直接把这个进程转换到就绪/挂起队列中,并释放一些内存

3.3进程的描述

操作系统为了管理进程和资源,必须掌握关于每个进程和资源当前状态的信息。普遍使用的方法是:操作系统构造并维护它所管理的每个实体的信息表:

内存表用于跟踪内(实)存和外存(虚拟内存)

使用进程映像来描述一个进程,进程镜像包括:程序、数据、栈和进程控制块(属性的集合)

3.4进程控制

3.4.1 执行模式

大多数处理器至少支持两种执行模式:

  • 用户态

  • 内核态(系统态、控制态):软件具有对处理器及所有指令、寄存器和内存的控制能力

使用两种模式的原因是很显然的,它可以保护操作系统和重要的操作系统表(如进程控制块)不受用户程序的干涉

处理器如何知道它正在什么模式下执行及如何改变模式?

程序状态字(PSW)中有一位表示执行模式,这一位应某些事件的要求而改变。在典型情况下,

  • 当用户调用一个操作系统服务或中断触发系统例程的执行时,执行模式被设置为内核态

  • 当从系统服务返回到用户进程时,执行模式被设为用户态

3.4.2 进程切换

在下列事件中,进程可能把控制权交给操作系统:

  • 系统中断

中断:与当前正在运行的进程无关的某种类型的外部事件相关。控制首先转移给中断处理器,做一些基本的辅助工作后,转到与已经发生的特定类型的中断相关的操作系统例程

陷阱:与当前正在运行的进程所产生的错误或异常条件相关。操作系统首先确定错误或异常条件是否是致命的。1)如果是,当前进程被换到退出态,发生进程转换;2)如果不是,动作取决于错误的种类或操作系统的设计,可能会进行一次进程切换或者继续执行当前进程

  • 系统调用:转移到作为操作系统代码一部分的一个例程上执行。通常,使用系统调用会把用户进程置为阻塞态

进程切换步骤如下: 1. 保存处理器上下文环境(包括程序计数器和其它寄存器) 2. 更新当前处于运行态进程的进程控制块(状态和其它信息) 3. 将进程控制块移到相应队列 4. 选择另一个进程执行 5. 更新所选择进程的进程控制块(包括将状态变为运行态) 6. 更新内存管理的数据结构 7. 恢复处理器在被选择的进程最近一次切换出运行状态时的上下文环境

进程切换一定有模式切换;模式切换不一定有进程切换(中断会发生模式切换,但是在大多数操作系统中,中断的发生并不是必须伴随着进程的切换的。可能是中断处理器执行之后,当前正在运行的程序继续执行);

3.5进程的调度

进程调度算法:先来先服务调度算法、短作业优先调度算法、非抢占式优先级调度算法、抢占式优先级调度算法、高响应比优先调度算法、时间片轮转法调度算法;

3.6进程间通信

  • 管道:用于具有亲缘关系进程间的通信
    • 由pipe函数创建,调用pipe函数时在内核中开辟一块缓冲区用于通信,它有一个读端一个写端,然后通过filedes参数传出给用户程序两个文件描述符,filedes[0]指向管道的读端,filedes[1]指向管道的写端。
    • 所以管道在用户程序看来就像一个打开的文件,通过read(filedes[0])或者write(filedes[1]);向这个文件读写数据其实是在读写内核缓冲区。
    • 管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道文件描述符。

① 半双工的,具有固定的读端和写端;

② 只能用于具有亲属关系的进程之间的通信;

③ 可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write函数。但是它不是普通的文件,并不属于其他任何文件系统,只能用于内存中。

④ Int pipe(int fd[2]);当一个管道建立时,会创建两个文件文件描述符,要关闭管道只需将这两个文件描述符关闭即可。

  • FIFO和Unix Domain Socket
    • 利用文件系统中的特殊文件来标识内核提供的通道
    • FIFO和Unix Domain
      Socket文件在磁盘上没有数据块,仅用来标识内核中的一条通道,各进程可以打开这个文件进行read和write,实际上实在读写内核通道,这样就实现了进程间通信。
    • FIFO又名有名管道,每个FIFO有一个路径名与之关联,从而允许无亲缘关系的进程访问同一个FIFO。半双工。

① FIFO可以再无关的进程之间交换数据,与无名管道不同;

② FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中;

③ Int mkfifo(const char* pathname,mode_t mode);

  • fork和wait
    • 父进程通过fork可以将打开的文件描述符传递给子进程
    • 子进程结束时,父进程调用wait可以得到子进程的终止信息
  • 信号
    • 信号又称软中断,通知程序发生异步事件,程序执行中随时被各种信号中断,进程可以忽略该信号,也可以中断当前程序转而去处理信号,
  • 信号量
    • 分为命名和匿名信号量。命名信号量通常用于不共享内存的进程之间(内核实现);匿名信号量可以用于线程通信(存放于线程共享的内存,如全局变量),或者用于进程间通信(存放于进程共享的内存,如System
      V/ Posix 共享内存)。
    • 信号量的使用主要是用来保护共享资源,使得资源在一个时刻只有一个进程(线程)所拥有。
      信号量的值为正的时候,说明它空闲。所测试的线程可以锁定而使用它。若为0,说明它被占用,测试的线程要进入睡眠队列中,等待被唤醒。

① 信号量是一个计数器,信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据;

② 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存;

③ 信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作;

  • 消息队列
    • Linux
      中的消息可以被描述成在内核地址空间的一个内部链表,每一个消息队列由一个IPC
      的标识号唯一地标识。

① 消息队列,是消息的连接表,存放在内核中。一个消息队列由一个标识符来标识;

② 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级;

③ 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除;

④ 消息队列可以实现消息的随机查询

  • 共享文件

    • 几个进程可以在文件系统中读写某个共享文件,也可以通过给文件加锁来实现进程间同步
  • 共享内存:通过mmap函数实现,几个进程可以映射同一内存区

① 共享内存,指两个或多个进程共享一个给定的存储区;

② 共享内存是最快的一种进程通信方式,因为进程是直接对内存进行存取;

③ 因为多个进程可以同时操作,所以需要进行同步;

④ 信号量+共享内存通常结合在一起使用。

3.7进程之间私有和共享的资源

  • 私有:地址空间、堆、全局变量、栈、寄存器
  • 共享:代码段,公共数据,进程目录,进程 ID

4.线程

4.1线程的概念

线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位。一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。

4.2进程与线程

  • 进程是操作系统进行资源分配的基本单位;

  • 线程是调度的基本单位;

进程中的所有线程共享该进程的状态和资源,进程和线程的关系如下图:
在这里插入图片描述

从性能上比较,线程具有如下优点:

1.在一个已有进程中创建一个新线程比创建一个全新进程所需的时间要少许多,研究表明,线程创建要比在Unix中创建进程快10倍;

2.终止一个线程比终止一个进程花费的时间少;

3.同一进程内线程间切换比进程间切换花费的时间少;

4.线程提高了不同的执行程序间通信的效率(在大多数操作系统中,独立进程间的通信需要内核的介入,以提供保护和通信所需要的机制。但是,由于在同一个进程中的线程共享内存和文件,它们无须调用内核就可以互相通信)。

线程和进程各自有什么区别和优劣呢?

  • 进程是资源分配的最小单位,线程是程序执行的最小单位。
  • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
  • 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
  • 但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。
  • 进程有严格的父进程和子进程的概念,而且它们之间有很多的联系,父进程可以很容易地了解到子进程出现问题退出了,子进程退出的行为很多时候可以不用交给程序来处理,操作系统就可以做的很好,充分利用这种机制可以获得很好的系统可靠性。
  • Linux系统提供了丰富的进程间通信机制。在Linux下进程的执行效率与线程的执行效率基本相当。
  • 在完全不需要数据同步的基于UDP协议的大数据量读取应用(流式视频播放器)下,线程更为简单、方便且高效。

4.3线程状态

和进程一样,线程的关键状态有运行态、就绪态和阻塞态。一般来说,挂起态对线程没有什么意义。这是由于此类状态是一个进程级的概念。特别地,如果一个进程被换出,由于它的所有线程都共享该进程的地址空间,因此它们必须都被换出

有4种与线程相关的基本操作:

  • 派生:在典型情况下,当派生一个新进程时,同时也为该进程派生了一个线程。随后,进程中的线程可以在同一进程中派生另一个线程,并为新线程提供指令指针和参数;新线程拥有自己的寄存器上下文和栈空间,且被放置在就绪队列中

  • 阻塞:当线程需要等待一个事件时,它将被阻塞(保存它的用户寄存器、程序计数器和栈指针),此时处理器转而执行另一个处于同一进程中或不同进程中的就绪线程

  • 解除阻塞:当阻塞一个线程的事件发生时,该线程被转移到就绪队列中

  • 结束:当一个线程完成时,其寄存器上下文和栈都被释放

4.4线程分类

线程的实现可以分为两大类:

  • 用户级线程:有关线程管理的所有工作都由应用程序完成(使用线程库),内核意识不到线程的存在。

  • 内核级线程:有关线程管理的所有工作都由内核完成,应用程序部分没有进行线程管理的代码。

4.3.1 用户级线程

在用户级线程中,进程和线程的状态可能有如下转换:

a)->b):线程2中执行的应用程序代码进行系统调用,阻塞了进程B。例如,进行一次I/O调用。这导致控制转移到内核,内核启动I/O操作,把进程B置于阻塞状态,并切换到另一个进程。在此期间,根据线程库维护的数据结构,进程B的线程2仍处于运行状态。值得注意的是,从处理器上执行的角度看,线程2实际上并不处于运行态,但是在线程库看来,它处于运行态。

a)->c):时钟中断把控制传递给内核,内核确定当前正在运行的进程B已经用完了它的时间片。内核把进程B置于就绪态并切换到另一个进程。同时,根据线程库维护的数据结构,进程B的线程2仍处于运行态

a)->d):线程2运行到需要进程B的线程1执行某些动作的一个点。此时,线程2进入阻塞态,而线程1从就绪态转换到运行态。进程自身保留在运行态

在前两种情况中,当内核把控制切换回进程B时,线程2会恢复执行

还需注意,进程在执行线程库中的代码时可以被中断,或者是由于它的时间片用完了,或者是由于被一个更高优先级的进程所抢占。因此在中断时,进程可能处于线程切换的中间时刻。当该进程被恢复时,线程库得以继续运行,并完成线程切换和把控制转移给另一个线程

用户级线程的优点

1.由于所有线程管理数据结构都在一个进程的用户地址空间中,线程切换不需要内核态特权,节省了两次状态转换的开销

2.调度可以是应用程序相关的(一个应用程序可能更适合简单的轮转调度,另一个可能更适合基于优先级的调度),可以为应用量身定做调度算法而不扰乱底层操作系统调度程序

3.可以在任何操作系统中运行,不需要对底层内核进行修改以支持用户级线程

用户级线程的缺点

1.当用户级线程执行一个系统调用时,不仅这个线程会被阻塞,进程中的所有线程都会被阻塞;

2.一个多线程应用程序不能利用多处理技术。内核一次只把一个进程分配给一个处理器,因此一次进程中只有一个线程可以执行(事实上,在一个进程内,相当于实现了应用程序级别的多道程序)。

4.3.2 内核级线程

内核级线程的优点

1.内核可以同时把同一进程中的多个线程调度到多个处理器中同时运行;

2.如果进程中一个线程被阻塞,内核可以调度其它线程;

3.内核例程自身也可以使用多线程。

内核级线程的缺点

把控制从一个线程转移到用一进程的另一线程时,需要到内核的状态切换。

4.3.3 混合方案

可以混合使用用户级和内核级线程。在混合方案中,同一应用程序中的多个线程可以在多个处理器上并行地运行,某个会引起阻塞的系统调用不会阻塞整个进程。

如果设计正确,该方法将会结合纯粹用户级线程和内核级线程方法的优点,同时克服它们的缺点。

4.5线程之间的通信方式

  • 锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
    • 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。
    • 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。
    • 自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持着是否已经释放锁。
    • 条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
  • 信号量机制(Semaphore)
    • 无名线程信号量
    • 命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理
  • 屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制

4.6线程之间私有和共享的资源

  • 私有:线程栈,寄存器,程序寄存器
  • 共享:堆,地址空间,全局变量,静态变量

4.7多进程与多线程间的对比、优劣与选择

对比
对比维度 多进程 多线程 总结
数据共享、同步 数据共享复杂,需要用 IPC;数据是分开的,同步简单 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU 利用率低 占用内存少,切换简单,CPU 利用率高 线程占优
创建销毁、切换 创建销毁、切换复杂,速度慢 创建销毁、切换简单,速度很快 线程占优
编程、调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会互相影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布式 进程占优
优劣
优劣 多进程 多线程
优点 编程、调试简单,可靠性较高 创建、销毁、切换速度快,内存、资源占用小
缺点 创建、销毁、切换速度慢,内存、资源占用大 编程、调试复杂,可靠性较差
选择
  • 需要频繁创建销毁的优先用线程
  • 需要进行大量计算的优先使用线程
  • 强相关的处理用线程,弱相关的处理用进程
  • 可能要扩展到多机分布的用进程,多核分布的用线程
  • 都满足需求的情况下,用你最熟悉、最拿手的方式

多进程与多线程间的对比、优劣与选择来自:[多线程还是多进程的选择及区别](

4.8协程

  • 协程是什么?
    • 子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。
    • 所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。
    • 子程序调用总是一个入口,一次返回,调用顺序是明确的。
    • 而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
    • 在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似CPU的中断
  • 协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
    • 最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
    • 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
  • 因为协程是一个线程执行,那怎么利用多核CPU呢?
    • 最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
    • Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。
  • 来看例子:
    • 传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。
    • 如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:
import time

def consumer():
  r = ''
  while True:
	  n = yield r
	  if not n:
		  return
	  print('[CONSUMER] Consuming %s...' % n)
	  time.sleep(1)
	  r = '200 OK'

def produce(c):
  c.next()
  n = 0
  while n < 5:
	  n = n + 1
	  print('[PRODUCER] Producing %s...' % n)
	  r = c.send(n)
	  print('[PRODUCER] Consumer return: %s' % r)
  c.close()

if __name__=='__main__':
  c = consumer()
  produce(c)

执行结果:
[PRODUCER] Producing 1…
[CONSUMER] Consuming 1…
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2…
[CONSUMER] Consuming 2…
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3…
[CONSUMER] Consuming 3…
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4…
[CONSUMER] Consuming 4…
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5…
[CONSUMER] Consuming 5…
[PRODUCER] Consumer return: 200 OK

注意到consumer函数是一个generator(生成器),把一个consumer传入produce后:

  1. 首先调用c.next()启动生成器;
  2. 然后,一旦生产了东西,通过c.send(n)切换到consumer执行;
  3. consumer通过yield拿到消息,处理,又通过yield把结果传回;
  4. produce拿到consumer处理的结果,继续生产下一条消息;
  5. produce决定不生产了,通过c.close()关闭consumer,整个过程结束。

整个流程无锁,由一个线程执行,produce和consumer协作完成任务,所以称为"协程",而非线程的抢占式多任务。

5.并发

5.1互斥

可以根据进程相互之间知道对方是否存在的程度,对进程间的交互进行分类:

  • 进程间的资源竞争:每个进程不影响它所使用的资源,这类资源包括I/O设备、存储器、处理器时间和时钟。首先需要提供互斥要求(比方说,如果不提供对打印机的互斥访问,打印结果会穿插)。实施互斥又产生了两个额外的控制问题:死锁和饥饿

进程间的资源竞争:每个进程不影响它所使用的资源,这类资源包括I/O设备、存储器、处理器时间和时钟。首先需要提供互斥要求(比方说,如果不提供对打印机的互斥访问,打印结果会穿插)。实施互斥又产生了两个额外的控制问题:死锁和饥饿

  • 进程间通过共享的合作:进程可能使用并修改共享变量而不涉及其他进程,但却知道其他进程也可能访问同一数据。因此,进程必须合作,以确保共享的数据得到正确管理。由于数据保存在资源中(设备或存储器),因此再次涉及有关互斥、死锁、饥饿等控制问题,除此之外,还有一个新要求:数据的一致性

  • 进程间通过通信的合作:由于在传递消息的过程中,进程间未共享任何对象,因而这类合作不需要互斥,但是仍然存在死锁和饥饿问题(死锁举例:两个进程可能都被阻塞,每个都在等待来自对方的通信;饥饿举例:P1,P2,P3,P1不断试图与P2,P3通信,P2和P3都试图与P1通信,如果P1和P2不断交换信息,而P3一直被阻塞,等待与P1通信,由于P1一直是活跃的,P3处于饥饿状态)

5.1.1 互斥的硬件支持

1) 中断禁用(只对单处理器有效):为保证互斥,只需保证一个进程不被中断即可

while(true){
     /* 禁用中断 */
     /* 临界区   */
     /* 启用中断  */
     /* 其余部分  */
 }

问题

  • 处理器被限制于只能交替执行程序,因此执行的效率将会有明显的降低。

  • 该方法不能用于多处理器结构中。

2) 专用机器指令

  • 比较和交换指令

  • 交换指令

在硬件级别上,对存储单元的访问排斥对相同单元的其它访问。基于这一点,处理器的设计者提出了一些机器指令,用于保证两个动作的原子性。在指令执行的过程中,任何其它指令访问内存将被阻止

比较和交换指令

 int bolt;
 void P(int i)
 {
     while(true){
         while(compare_and_swap(&bolt,0,1) == 1)
             */***不做任何事***/*;
         */***临界区***/*
         bolt = 0;
         */***其余部分***/*
     }
 }

 int compare_and_swap(int *word,int testval,int newval)
 {
     int oldval;
     oldval = *word;
     if(oldval == testval) *word = newval;
     return oldval;
 }

交换指令

int bolt;
 void P(int i)
 {
     int keyi = 1;
     while(true){
         do exchange (&keyi,&bolt);
         while(keyi != 0);
         /*临界区*/
         bolt = 0;
         /*其余部分*/
     }
 }

 void exchange (int *register,int *memory)
 {
     int temp;
     temp = *memory;
     *memory = *register;
     *register = temp;
 }

优点

  • 适用于单处理器或共享内存的多处理上的任何数目的进程

  • 简单且易于证明

  • 可用于支持多个临界区(每个临界区可以用它自己的变量定义)

缺点

  • 使用了忙等待(进入临界区前会一直循环检测,会销毁处理器时间)

  • 可能饥饿(忙等的进程中可能存在一些进程一直无法进入临界区)

  • 可能死锁(P1在临街区中时被更高优先级的P2抢占,P2请求相同的资源)

5.1.2 互斥的软件支持

软件支持包括操作系统和用于提供并发性的程序设计语言机制,常见如下表:

1)信号量

通常称为计数信号量或一般信号量

可把信号量视为一个具有整数值的变量,在它之上定义三个操作:

1.一个信号量可以初始化为非负数(表示发出semWait操作后可立即执行的进程数量)

2.semWait操作使信号量减1。若值为负数,执行该操作进程被阻塞。否则进程继续执行

3.semSignal操作使信号量加1。若值小于或等于0,则被semWait阻塞的进程被解除阻塞

信号量原语的定义:

struct semaphore{
     int count;
     queueType queue;
 };

 void semWait(semaphore s)
 {
     s.count--;
     if(s.count < 0){
         /*把当前进程插入到队列当中*/;
         /*阻塞当前进程*/;
     }
 }

 void semSignal(semaphore s)
 {
     s.count++;
     if(s.count <= 0){
         /*把进程P从队列中移除*/;
         /*把进程P插入到就绪队列*/;
     }
 }

2)二元信号量

二元信号量是一种更特殊的信号量,它的值只能是0或1。

可以使用下面3种操作:

1.可以初始化为0或1。

2.semWaitB操作检查信号的值,如果为0,该操作会阻塞进程。如果值为1,将其改为0后进程继续执行。

3.semSignalB操作检查是否有任何进程在信号上阻塞。有则通过semSignalB操作,受阻进程会被唤醒,如果没有,那么设置值为1。

二元信号量的原语定义:

struct binary_semaphore{
     enum {zero,one} value;
     queueType queue;
 };

 void semWaitB(binary_semaphore s)
 {
     if(s.value == one)
         s.value = zero;
     else{
         /*把当前进程插入到队列当中*/;
         /*阻塞当前进程*/;
     }
 }

 void semSignalB(binary_semaphore s)
 {
     if(s.queue is empty())
         s.value = one;
     else
     {
         /*把进程P从等待队列中移除*/;
         /*把进程P插入到就绪队列*/;
     }
 }
  • 强信号量:队列设计为FIFO,被阻塞最久的进程最先从队列中释放(保证不会饥饿)。

  • 弱信号量:没有规定进程从队列中移出顺序。

使用信号量的互斥(这里是一般信号量,不是二元信号量)

 const int n = */***进程数***/*
 semaphore s = 1;

 void P(int i)
 {
     while(true){
         semWait(s);
         */***临界区***/*;
         semSignal(s);
         */***其它部分***/*;
     }
 }

 void main()
 {
     parbegin(P(1),P(2),...,P(n));
 }

下图为三个进程使用了上述互斥协议后,一种可能的执行顺序:

信号量为实施互斥及进程间合作提供了一种原始但功能强大且灵活的工具,但是,使用信号量设计一个正确的程序是很困难的,其难点在于semWait和semSignal操作可能分布在整个程序中,却很难看出这些在信号量上的操作所产生的整体效果(详见1.3 经典互斥问题中的“生产者/消费者“问题)

3)互斥量

互斥量和二元信号量关键的区别在于:互斥量加锁的进程和解锁的进程必须是同一进程

4)管程

管程是一个程序设计语言结构,它提供了与信号量同样的功能,但更易于控制。它是由一个或多个过程一个初始化序列局部数据组成的软件模块,主要特点如下:

1.局部数据变量只能被管程的过程访问,任何外部过程都不能访问

2.一个进程通过调用管程的一个过程进入管程

3.在任何时候,只能有一个进程在管程中执行,调用管程的其它进程都被阻塞,等待管程可用

为进行并发处理,管程必须包含同步工具(例如:一个进程调用了管程,并且当它在管程中时必须被阻塞,直到满足某些条件。这就需要一种机制,使得该进程在管程内被阻塞时,能释放管程,以便其它进程可以进入。以后,当条件满足且管程在此可用时,需要恢复进程并允许它在阻塞点重新进入管程)

管程通过使用条件变量提供对同步的支持,这些条件变量包含在管程中,并且只有在管程中才能被访问。有2个操作:

  • cwait©:调用进程的执行在条件c上阻塞,管程现在可被另一个进程使用

  • csignal©:恢复执行在cwait后因某些条件被阻塞的进程。如果有多个则选择其一;如果没有则什么也不做

管程的结构如下:

管程优于信号量之处在于,所有的同步机制都被限制在管程内部,因此,不但易于验证同步的正确性,而且易于检查出错误。此外,如果一个管程被正确编写,则所有进程对保护资源的访问都是正确的;而对于信号量,只有当所有访问资源的进程都被正确地编写时,资源访问才是正确的

5)消息传递

最小操作集:

  • send(destination,message)
  • receive(source,message)

阻塞:

  • 当一个进程执行send原语时,有2种可能:

– 发送进程被阻塞直到这个消息被目标进程接收

– 不阻塞

  • 当一个进程执行receive原语后,也有2种可能:

– 如果一个消息在此之前被发送,该消息被正确接收并继续执行

– 没有正在等待的消息,则a)进程阻塞直到等待的消息到达,b)继续执行,放弃接收的努力

消息传递过程中需要识别消息的源或目的地,这个过程称为寻址,可分为两类: 1. 直接寻址 * 对于send:包含目标进程的标识号 * 对于receive:1)进程显示指定源进程;2)不可能指定所希望的源进程时,通过source参数保存相应信息 2. 间接寻址(解除了发送者/接收者的耦合性,更灵活) * 消息发送到一个共享数据结构,称为”信箱“。发送者和接收者直接有”一对一“、”多对一“、”一对多“和”多对多“的对应关系(典型的”多对一“如客户端/服务器,此时”信箱“就是端口)

消息传递实现互斥(消息函数可视为在进程直接传递的一个令牌):

const int n = */***进程数***/*;
 void P(int i)
 {
     message msg;
     while(true){
         receive(box,msg);
         */***临界区***/*;
         send(box,msg);
         */***其它部分***/*;
     }
 }

 void main()
 {
     create mailbox (box);
     send(box,null);
     parbegin(P(1),P(2),...,P(n));
 }

可以使用消息传递处理”生产者/消费者问题“,可以有多个消费者和生产者,系统甚至可以是分布式系统,代码见1.3

5.1.3 经典问题

在设计同步和并发机制时,可以与一些经典问题联系起来,以检测该问题的解决方案对原问题是否有效

1)生成者/消费者问题

有一个或多个生产者生产某种类型的数据,并放置在缓冲区中;有一个消费者从缓冲区中取数据,每次取一项;

任何时候只有一个主体(生产者或消费者)可以访问缓冲区。要确保缓存满时,生产者不会继续添加,缓存为空时,消费者不会从中取数据

实现代码:

  • 当缓冲无限大时(二元信号量,对应图5.10;信号量,对应图5.11)

  • 当缓冲有限时(信号量,对应图5.13;管程,对应图5.16;消息传递,对应图5.21)

2)读者/写者问题

有一个由多个进程共享的数据区,一些进程只读取这个数据区中的数据,一些进程只往数据区中写数据;此外还满足以下条件:

  • 任意多的读进程可以同时读

  • 一次只有一个进程可以写

  • 如果一个进程正在写,禁止所有读;

实现代码:

  • 读优先:只要至少有一个读进程正在读,就为进程保留对这个数据区的控制权(信号量,对应图5.22)

  • 写优先:保证当有一个写进程声明想写时,不允许新的读进程访问该数据区(信号量,对应图5.23)

5.2死锁

5.2.1死锁的概念

死锁定义:指多个进程因竞争共享资源而造成的一种僵局,若无外力作用,这些进程都将永远不能再向前推进。

假设两个进程的资源请求和释放序列如下:

下图是相应的联合进程图,显示了进程竞争资源的进展情况:

敏感区域:路径3,4进入的区域。敏感区域的存在依赖于两个进程的逻辑关系。然而,如果另个进程的交互过程创建了能够进入敏感区的执行路径,那么死锁就必然发生

死锁问题中的资源分类

  • 可重用资源:一次只能供一个进程安全地使用,并且不会由于使用而耗尽的资源(包括处理器、I/O通道、内外存、设备等)

  • 可消耗资源:可以被进程创建和消耗的资源。通常对某种类型可消耗资源的数目没有限制,一个无阻塞的生产进程可以创建任意数目的这类资源(包括中断、信号、消息和I/O缓冲中的信息)

资源分配图

  • 进程到资源:进程请求资源但还没得到授权

  • 资源到进程:请求资源已被授权

  • 资源中的“点”:表示该类资源的一个实例

5.2.2死锁的条件

1.互斥:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。

2.占有且等待:当进程因请求资源而阻塞时,对已获得的资源保持不放。

3.不可抢占:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。

4.循环等待:存在一个封闭的进程链,使得每个进程至少占有此链中下一个进程所需的一个资源。

条件1~3是死锁的必要条件,条件4是前3个条件的潜在结果,即假设前3个条件存在,可能发生的一系列事件会导致不可解的循环等待。这个不可解的循环等待实际上就是死锁的定义。之所以不可解是因为有前3个条件的存在。因此,4个条件连在一起构成了死锁的充分必要条件。

【注】死锁产生的原因:

  • 系统资源不足;
  • 资源分配不当;
  • 进程运行推进顺序不合适。

5.2.3死锁预防

死锁预防是通过约束资源请求,使得4个死锁条件中的至少1个被破坏,从而防止死锁发生

  • 间接的死锁预防(防止死锁条件1~3)

  • 预防互斥:一般来说,不可能禁止

  • 预防占有且等待:可以要求进程一次性地请求所有需要的资源,并且阻塞进程直到所有请求都同时满足。这种方法在两个方面是低效的:1)为了等待满足其所有请求的资源,进程可能被阻塞很长时间。但实际上只要有一部分资源,就可以继续执行;2)分配的资源有可能有相当长的一段时间不会被使用,且在此期间,这些资源不能被其它进程使用;除此之外,一个进程可能事先并不会知道它所需要的所有资源。

  • 预防不可抢占:有几种方法:1)如果占用某些资源的进程进一步申请资源时被拒,则释放其占用的资源;2)如果一个进程请求当前被另一个进程占有的一个资源,操作系统可以抢占另一个进程,要求它释放资源(方法2只有在任意两个进程优先级不同时,才能预防死锁);此外,通过预防不可抢占来预防死锁的方法,只有在资源状态可以很容易保存和恢复的情况下才实用。

  • 直接的死锁预防(防止死锁条件4)

    • 预防循环等待:可以通过定义资源类型的线性顺序来预防,如果一个进程已经分配到了R类型的资源,那么它接下来请求的资源只能是那些排在R类型之后的资源;这种方法可能是低效的,会使进程执行速度变慢,并且可能在没有必要的情况下拒绝资源访问都会导致低效的资源使用和低效的进程运行。

5.3.4死锁避免

死锁避免允许3个必要条件,但通过明智选择,确保永远不会到达死锁点。

由于需要对是否会引起死锁进行判断,因此死锁避免需要知道将来的进程资源请求的情况。

2种死锁避免的方法:

1.进程启动拒绝:如果一个进程的请求会导致死锁,则不启动此进程

2.资源分配拒绝:如果一个进程增加的资源请求会导致死锁,则不允许此分配

1)进程启动拒绝

一个有n个进程,m种不同类型资源的系统。定义如下向量和矩阵:

从中可以看出以下关系成立:

对于进程n+1,仅当对所有j,以下关系成立时,才启动进程n+1:

2)资源分配拒绝(银行家算法)

当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程直到同意该请求后系统状态仍然是安全的。

  • 安全状态:至少有一个资源分配序列不会导致死锁(即所有进程都能运行直到结束)

  • 不安全状态:非安全的一个状态(所有分配序列都不可行)

下图为一个安全序列:

下图为一个不安全序列:

这个不安全序列并不是一个死锁状态,仅仅是有可能死锁。例如,如果P1从这个状态开始运行,先释放一个R1和R3,后来又再次需要这些资源,一旦这样做,则系统将到达一个安全状态

优点

  • 不需要死锁预防中的抢占和回滚进程,并且比死锁预防的限制少。比死锁预防允许更多的并发

缺点

  • 必须事先声明每个进程请求的最大资源;
  • 所讨论的进程必须是无关的,也就是说,他们执行的顺序必须没有任何同步要求的限制;
  • 分配的资源数目必须是固定的;
  • 在占有资源时,进程不能退出。

5.2.5死锁检测

死锁检测不限制资源访问或约束进程行为。只要有可能,被请求的资源就被分配给进程。操作系统周期性地执行一个算法检测死锁条件4(循环等待)

常见死锁检测算法

这种算法的策略是查找一个进程,使得可用资源可以满足该进程的资源请求,然后假设同意这些资源,让该进程运行直到结束,再释放它的所有资源。然后算法再寻找另一个可以满足资源请求的进程

这个算法并不能保证防止死锁,是否死锁要取决于将来同意请求的次序,它所做的一切是确定当前是否存在死锁

恢复

一旦检测到死锁,就需要某种策略以恢复死锁,有下列方法(复杂度递增):

  • 取消所有死锁进程(操作系统最常用)
  • 回滚每个死锁进程到前面定义的某些检测点
  • 连续取消死锁进程直到不再存在死锁(基于某种最小代价原则)
  • 连续抢占资源直到不再存在死锁(基于代价选择,每次抢占后需重新调用算法检测,被抢占的进程需回滚)

5.2.6经典问题(哲学家就餐问题)

就餐需要使用盘子和两侧的叉子,设计一套算法以允许哲学家吃饭。算法必须保证互斥(没有两位哲学家同时使用同一把叉子),同时还要避免死锁和饥饿

方法一(基于信号量,可能死锁):每位哲学家首先拿起左边的叉子,然后拿起右边的叉子。吃完面后,把两把叉子放回。如果哲学家同时拿起左边的叉子,会死锁

方法二(基于信号量,不会死锁):增加一位服务员,只允许4位哲学家同时就座,因而至少有一位哲学家可以拿到两把叉子

方法三(基于管程,不会死锁):和方法一类似,但和信号量不同的是,因为同一时刻只有一个进程进入管程,所以不会发生死锁

6.内存管理

单道程序设计中:内存被划分为两部分,一部分供操作系统使用(驻留监控程序、内核),一部分供当前正在执行的程序使用

多道程序设计中:必须在内存中进一步细分“用户”部分,以满足多个进程的要求,细分的任务由操作系统动态完成,称为内存管理

内存管理的需求

重定位:程序在从磁盘换入内存时,可以被装载到内存中的不同区域

保护:处理器必须保证进程以外的其它进程不能未经授权地访问该进程的内存单元

共享:任何保护机制都必须具有一定灵活性,以允许多个进程访问内存的同一部分

逻辑组织

物理组织

内存管理中的地址

逻辑地址:指与当前数据在内存中的物理分配地址无关的访问地址,执行对内存访问前必须转换成物理地址

相对地址:逻辑地址的一个特例,是相对于某些已知点(通常是程序开始处)的存储单元

物理地址**(绝对地址)**:数据在内存中的实际位置

虚拟地址:虚拟内存中的逻辑地址

内存管理单元**(MMU)**:CPU中的一个模块,将虚拟地址转换成实际物理地址

6.1内存管理中的数据块

页框:内存中一个固定长度的块

:二级存储(如磁盘)中一个固定长度的数据块

:二级存储中一个变长的数据块

6.2内存分区

6.2.1 固定分区

系统生成阶段,内存被划分成许多静态**(大小,容量固定不变)**分区,两种固定分区:

分区大小相等

分区大小不等

放置策略

对于分区大小相等的固定分区

– 只要存在可用分区,就可以分配给进程

对于分区大小不等的固定分区

每个进程分配到能容纳它的最小分区:每个分区维护一个队列(较多小进程时,大分区会空闲)

每个进程分配到能容纳它的最小可用分区:只需一个队列

存在内部碎片;活动进程数固定

6.2.2 动态分区

并不进行预先分区,在每次需要为进程分配时动态划分

外部碎片(随着时间推移,内存中产生了越来越多”空洞“):

可以使用压缩解决外部碎片,但是非常耗时

放置算法:由于压缩十分耗时,因而需要巧妙地把进程分配到内存中,塞住内存中的”洞“

最佳适配:选择与要求大小最接近的块(通常性能最差,尽管每次浪费的空间最小,但结果却使得内存中很快产生许多碎片)

首次适配:选择大小足够的第一个块(不仅最简单,通常也是最好、最快的;容易在首部产生碎片)

下次适配:从上次放置的位置起,第一个大小足够的块(比首次适配差,常常会在尾部产生碎片)

维护复杂,且会产生外部碎片

6.2.3 伙伴系统

内存最小块和最大块的尺寸是M和L。在为一个进程分配空间时,如果需要的内存大于L/2,则分配L的内存,否则,将大小为L的块分成两个L/2的块,继续上述步骤;如果两个相邻的块(伙伴)都未分配出去(如前面的进程释放后),则将其合并

下图为一个伙伴系统的例子:

伙伴系统是一种折中方案,克服了固定分区和动态分区方案的缺陷。但在当前操作系统中,基于分页和分段机制的虚拟内存更好。伙伴系统在并行系统中有很多应用

6.2.4 分区中的地址转换

逻辑地址->物理地址的转换如下

基址寄存器:被载入程序在内存中的起始地址

界限寄存器:程序的终止位置

这种转换方式适用于程序运行时,被加载到内存中连续区域的情况。对于分页和分段,由于一个程序可以加载到内存的不同区域,所以需要使用另外的机制进行转换

6.3分页

用户程序的地址空间被划分成若干固定大小的区域,称为"页",相应地,内存空间分成若干个物理块,页和块的大小相等。可将用户程序的任一页放在内存的任一块中,实现了离散分配。将整个内存划分成许多大小相等的页面,每个进程的地址空间可以由多个页面构成。

内存被划分为大小固定的块,且块相对比较小,每个进程也被分成同样大小的小块,那么进程中称为页的块可以指定到内存中称为页框的可用块。和固定分区的不同在于:一个程序可以占据多个分区,这些分区不要求连续

使用分页技术在内存中每个进程浪费的空间,仅仅是最后一页的一小部分(内部碎片)

6.3.1 分页中的地址转换

由于进程的页可能不连续,因此仅使用一个简单的基址寄存器是不够的,操作系统需要为每个进程维护一个页表。页表项是进程每一页与内存页框的映射

6.4分段

将用户程序地址空间分成若干个大小不等的段,每段可以定义一组相对完整的逻辑信息。存储分配时,以段为单位,段与段在内存中可以不相邻接,也实现了离散分配。

将整个内存划分为大小不同的段,每个进程的地址空间处于不同的独立段中。

段有一个最大长度限制,但不要求所有程序的所有段长度都相等。分段类似于动态分区,区别在于:一个程序可以占据多个不连续的分区

分段同样会产生外部碎片,但是进程被划分成多个小块,因此外部碎片也会很小

6.4.1 分段中的地址转换

由于进程的段可能不连续,因此也不能仅靠一个简单的基址寄存器,地址转换通过段表实现。由于段的大小不同,因此段表项中还包括段的大小

如果偏移大于段的长度,则这个地址无效.

  • 应用场景
    • 进程与进程之间可以让虚拟地址相同,但是物理地址不同而达到空间上的真正分离。
    • 进程自己并不能看到自己的真实物理地址,而且即便物理地址不存在,也可以通过页面交换技术让它存在,那么操作系统就可以欺骗进程拥有很多的内存可用。
    • 利用页面交换技术,可以将一个文件映射到内存中,使得mmap这样的系统调用可以实现。
    • 将虚拟地址转换成相同的物理地址,就可以做到数据的共享,线程就是这么干的。
    • 将硬件设备的控制存储区域反映到虚拟内存上,就可以实现通过内存访问就达到控制硬件的目的。
  • 分页与分段的主要区别
    • 页是信息的物理单位,分页是为了实现非连续分配,以便解决内存碎片问题,或者说分页是由于系统管理的需要.段是信息的逻辑单位,它含有一组意义相对完整的信息,分段的目的是为了更好地实现共享,满足用户的需要.
    • 页的大小固定,由系统确定,将逻辑地址划分为页号和页内地址是由机器硬件实现的.而段的长度却不固定,决定于用户所编写的程序,通常由编译程序在对源程序进行编译时根据信息的性质来划分.
    • 分页的作业地址空间是一维的.分段的地址空间是二维的。

6.5页面置换算法

  • 最佳置换算法OPT:不可能实现
  • 先进先出FIFO
  • 最近最久未使用算法LRU:最近一段时间里最久没有使用过的页面予以置换.
  • clock算法

6.6内存安全

6.6.1 缓冲区溢出

缓冲区溢出是指输入到一个缓冲区或者数据保存区域的数据量超过了其容量,从而导致覆盖了其它区域数据的状况。攻击者造成并利用这种状况使系统崩溃或者通过插入特制的代码来控制系统

被覆盖的区域可能存有其它程序的变量、参数、类似于返回地址或指向前一个栈帧的指针等程序控制流数据。缓冲区可以位于堆、栈或进程的数据段。这种错误可能产生如下后果:

1.破坏程序的数据

2.改变程序的控制流,因此可能访问特权代码

最终很有可能造成程序终止。当攻击者成功地攻击了一个系统之后,作为攻击的一部分,程序的控制流可能会跳转到攻击者选择的代码处,造成的结果是被攻击的进程可以执行任意的特权代码(比如通过判断输入是否和密码匹配来访问特权代码,如果存在缓冲区漏洞,非法输入导致存放“密码”的内存区被覆盖,从而使得“密码”被改写,因此判断为匹配进而获得了特权代码的访问权)

缓冲区溢出攻击是最普遍和最具危害性的计算机安全攻击类型之一

6.6.2 预防缓冲区溢出

广义上分为两类:

  • 编译时防御系统,目的是强化系统以抵御潜伏于新程序中的恶意攻击

  • 运行时预防系统,目的是检测并终止现有程序中的恶意攻击

尽管合适的防御系统已经出现几十年了,但是大量现有的脆弱的软件和系统阻碍了它们的部署。因此运行时防御有趣的地方是它能够部署在操作系统中,可以更新,并能为现有的易受攻击的程序提供保护

7.其他

7.1 Linux与windows

  • Linux:
    • 以进程为主,强调任务的独立性
    • 线程方面的处理:NPTL原生POSIX线程库
      • 一个线程与一个内核的调度实体一一对应
      • 新的线程同步机制:futex(快速用户空间互斥体)
    • Linux处理进程和线程的机制就是是否开启COW
      • 子进程先跟父进程共享内存,采用COW及术后,子进程还需要拷贝父进程的页面表。
  • Windows
    • 以线程为主,强调任务的协同性
  • windows的调度实体就是线程,进程只是一堆数据结构。而Linux不是。Linux将进程和线程做了同等对待,进程和线程在内核一级没有差别,只是通过特殊的内存映射方法使得它们从用户的角度上看来有了进程和线程的差别。
  • Windows至今也没有真正的多进程概念,创建进程的开销远大于创建线程的开销。Linux则不然。Linux在内核一级并不区分进程和线程,这使得创建进程的开销和创建线程的开销差不多。
  • Windows和Linux的任务调度策略也不尽相同。Windows会随着线程越来越多而变得越来越慢,这也是为什么Windows服务器在运行一段时间后必须重启的原因。Linux可以持续运行很长时间,系统的效率也不会有什么变化。

7.2内核态和用户态

  • 内核态和用户态的区别
    • 当进程执行系统调用而陷入内核代码中执行时,我们就称进程处于内核状态。此时处理器处于特权级最高的(0级)内核代码。当进程处于内核态时,执行的内核代码会使用当前的内核栈。每个进程都有自己的内核栈。
    • 当进程在执行用户自己的代码时,则称其处于用户态。即此时处理器在特权级最低的用户代码中运行。
    • 当正在执行用户程序而突然中断时,此时用户程序也可以象征性地处于进程的内核态。因为中断处理程序将使用当前进程的内核态。
    • 内核态与用户态是操作系统的两种运行级别,跟intel
      cpu没有必然联系,intel
      cpu提供Ring0-Ring3三种级别运行模式,Ring0级别最高,Ring3级别最低。Linux使用了Ring3级别运行用户态。Ring0作为内核态,没有使用Ring1和Ring2。Ring3不能访问Ring0的地址空间,包括代码和数量。Linux进程的4GB空间,3G-4G部分大家是共享的,是内核态的地址空间,这里存放在整个内核代码和所有的内核模块,以及内核所维护的数据。用户运行一程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过write,send等系统调用,这些系统会调用内核中的代码来完成操作,这时必须切换到Ring0,然后进入3GB-4GB中的内核地址空间去执行这些代码完成操作,完成后,切换Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。
  • 用户态和内核态的转换
    • 用户态切换到内核态的3种方式
      • 系统调用
        • 这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的ine
          80h中断。
      • 异常
        • 当CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此异常的内核相关程序中,也就到了内核态,比如缺页异常。
      • 外围设备的中断
        • 当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
    • 具体的切换操作
      • 从出发方式看,可以在认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一样的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断处理机制基本上是一样的,用户态切换到内核态的步骤主要包括:
      • (1)从当前进程的描述符中提取其内核栈的ss0及esp0信息。
      • (2)使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈找到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。
      • (3)将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

7.3变量存储区域

  • 栈:
    • 由编译器在需要的时候分配,在不需要的时候自动清楚的变量的存储区。
    • 地址是不固定的。
    • 存储的变量通常是局部变量、函数参数等。
  • 堆:
    • 由new分配的内存块,它们的释放编译器不去管,而是由应用程序去控制,一般一个new就要对应一个delete。
    • 如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  • 自由存储区:
    • 由malloc等分配的内存块,和堆是十分类似,不过它是用free来结束自己的生命的。
  • 全局存储区(静态存储区):
    • 全局变量和静态变量的存储是放在一块的。
    • 初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。
    • 程序结束后由系统释放。
  • 常量存储区:
    • 这是一块比较特殊的存储区,位置是固定的。
    • 这里面存放的是常量,不允许修改。

7.4 Linux 内核的同步方式

原因

在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。

同步方式

  • 原子操作
  • 信号量(semaphore)
  • 读写信号量(rw_semaphore)
  • 自旋锁(spinlock)
  • 大内核锁(BKL,Big Kernel Lock)
  • 读写锁(rwlock)
  • 大读者锁(brlock-Big Reader Lock)
  • 读-拷贝修改(RCU,Read-Copy Update)
  • 顺序锁(seqlock)

欢迎访问我的网站:

BruceOu的哔哩哔哩
BruceOu的主页
BruceOu的博客
CSDN博客

接收更多精彩文章及资源推送,请订阅我的微信公众号:

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/u013162035/article/details/106469872