操作系统原理(OS)复习知识点

目录

复习重点:

概念性问题:

  1. 什么是操作系统?怎么理解操作系统?【管理软硬件资源、方便用户使用、让用户程序运行起来的软件的集合】

    计算机操作系统是指控制和管理计算机的软、硬件资源,合理组织计算机的工作流程,运行用户软件,方便用户使用的程序集合。

    从那两个视角来理解操作系统?【资源管理的角度、程序运行的角度】

    从用户角度看,操作系统是一个控制程序。控制程序执行过程,防止错误和计算机的不当使用;执行用户程序,给用户程序提供各种服务;方便用户使用计算机系统。

    从程序角度看,操作系统是一个资源管理器。是应用程序与硬件之间的中间层,管理各种计算机软硬件资源;提供访问计算机软硬件资源的高效手段;解决资源访间冲突,确保资源公平使用。

    什么是操作系统

  2. 什么是特权指令?【只能由操作系统执行的指令,这些指令都是机器指令】

    特权指令是只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机

    为什么设计特权指令?

    特权指令具有执行敏感操作的权限,如果允许应用程序直接进行这些操作,会影响系统的稳定性和安全性。计算机运行时,内核是被信任的第三方,设计只可以由内核执行的特权指令。

    如何判断是操作系统还是用户程序在执行?-->用户态、核心态

    什么是用户态【CPU正在执行用户程序】、什么是核心态【CPU正在执行操作系统程序】?

    用户态:运行用户程序,权限级别低,不能访问特权指令
    内核态:运行操作系统程序,权限级别高,可以执行任何一条指令,包括特权指令

    特权指令

  3. 多道程序设计。【很多程序都在内存中,同时在运行中】

    多道程序设计指很多程序都在内存中,同时在运行中。它是现代操作系统的重要特性,利用多道程序设计技术,让每个用户都觉得有一个计算机专门为他服务。

    并发【在一个时间段内,多个程序同时运行】与并行【在一个时间点上,多个程序同时运行】。

    并发指一段时间内多个程序运行;而并行是指一个时间点上多个程序运行,要求多个CPU。

    临界区【包含临界资源的程序】与临界资源【一次只能允许一个进程使用的资源】。

    临界资源指一次只能允许一个进程使用的资源。临界区指进程中的一段访问临界资源并且需要互斥执行的代码(当另一个进程处于相应代码区域时,便不会被执行的代码区域)。

    操作的原子性、函数的原子性。【原子性:某一个操作/函数,要么执行完,要么不执行,中间不允许被打断】

    原子性:某一个操作/函数,要么执行完,要么不执行,中间不允许被打断

    [原子操作(Atomic Operator)](#原子操作(Atomic Operator))

    如何理解每个进程拥有自己独立的地址空间?【两个进程同一个变量,不在同一个地址空间中】《开放题目》

    每个进程拥有自己独立的地址空间是指,该进程在运行时,操作系统为它分配了一个虚拟的内存空间,这个空间是该进程独占的,其他进程不能直接访问这个空间中的数据。
    当使用 fork() 系统调用创建一个子进程时,子进程会获得父进程的一个副本。这个副本包括父进程的内存空间、环境、文件描述符等。但是,子进程获得的并不是父进程内存的物理副本,而是逻辑上的副本,子进程中的变量地址通常与父进程中的变量地址是不同的。

  4. 什么是实时系统?【有时间约束】

    实时系统的正确性依赖于其时间功能两方面,强调时间约束的及时性(deadlines),即:必须在要求时间内完成工作。速度和平均性能相对不重要。

    实时系统

  5. 进程间的同步与互斥。

    同步与互斥的概念是什么?

    互斥可以保护共享资源,保证一个进程/线程在临界区执行时,其他进程/线程应该被阻止进入临界区。
    同步协调多个进程间的操作顺序,在一些关键点上进程间互相等待与互通消息。

    什么情况下需要同步?什么情况下需要互斥?

    在多个进程间有资源共享且同一时刻只能被统一进程/线程访问或写入等情况下需要互斥,在进程间需要按照某种特定顺序执行时、生产者不在消费者取走数据前覆盖数据、消费者不在没有数据时取走数据等情况下,需要同步。

  6. 死锁。

    什么是死锁?【各个进程由于竞争资源,导致若干个进程均不能向下运行,在无外力的情况下无法解决】

    由于竞争资源或者通信关系,两个或更多线程在执行中出现,永远相互等待只能由其他进程引发的事件(只能由外力摆脱死锁)。

    产生死锁的四个必要条件是什么?

    互斥:在一个时间只能有一个进程使用一个资源
    持有并等待:进程保持至少一个资源正在等待获取其他进程持有的额外资源
    无抢占:一个资源只能被进程资源主动释放
    循环等待:进程0等待进程1的资源、进程1等待进程2的资源、……进程n-1等待进程n的资源、进程n等待进程0的资源

  7. 活锁。

    什么是活锁?

    活锁(Live Lock)是并发编程中的一种状态,在这种状态下,两个或多个进程/线程无限期地相互等待对方释放资源,但每个进程/线程都在不断改变其状态,导致没有任何一个进程/线程能够继续向前推进。

    举一个活锁的例子。【哲学家吃饭】

    image-20240823182006756

    问题在于:大家同时拿起左边叉子,又同时放下,如此循环。造成活锁。

  8. 内存碎片。【碎片:系统中可用但进程无法使用的内存,因内存管理不好导致的】

    碎片是系统中空闲但不能利用的内存。

    什么是外部碎片?什么是内部碎片?

    外部碎片:分配单元之间的未被使用的内存。
    内部碎片:分配单元内部的未被使用的内存。

  9. 虚拟存储的思想。【只需要将程序的一部分放入内存,其他部分仍在外存中】

    虚拟存储只需要将进程的一部分放入内存,进程的内容可以分块地在内存和外存之间进行交换。

    虚拟存储的基础是什么?【程序的局部性】

    什么是程序的局部性?什么是时间局部性?什么是空间局部性?

    程序的局部性原理(principle of locality):指程序在执行过程中的一个较短时期,所执行的指令地址和指令的操作数地址,分别局限于一定的区域。
    时间局部性:一条指令的一次执行和下次执行,一个数据的一次访问和下次访问都集中在一个较短时期内 ;
    空间局部性:当前指令和邻近的几条指令,当前访问的数据和邻近的几个数据都集中在一个较小区域内。

  10. 文件系统与文件的概念不同。

    文件系统:一种用于持久性存储的系统抽象,操作系统中管理持久性数据的子系统,提供数据存储的访问功能
    文件:文件系统中的一个单元的相关数据在操作系统中的抽象,是文件系统的基本数据单位,是具有符号名,由字节序列构成的数据项集合

    什么是文件系统?【管理文件和磁盘设备的软件】

    举例说明常见的文件系统。【FAT-32、NTFS……】

    以下是一些常见的文件系统类型:
    FAT(File Allocation Table):包括FAT16和FAT32,是最早的文件系统之一。通常用于USB闪存驱动器、硬盘驱动器和其他可移动存储设备。支持的文件大小和分区大小有限。
    NTFS(New Technology File System):主要用于Windows操作系统。支持更大的文件和分区大小,提供文件加密、压缩和权限控制。
    EXT(Extended File System):包括EXT2、EXT3和EXT4,主要用于Linux操作系统。EXT4是EXT文件系统的最新版本,提供了改进的性能和更大的文件系统支持。
    HFS+(Hierarchical File System Plus):主要用于Mac OS X操作系统。支持大文件和长文件名。
    APFS(Apple File System):是MacOS的最新文件系统,旨在替代HFS+。优化了SSD存储,支持快照和克隆等高级功能。
    ReiserFS:是Linux操作系统的另一种文件系统,特别适合于处理大量小文件。
    XFS:是一个高性能的文件系统,主要用于Linux操作系统。适合于大型企业和数据中心使用

  11. 驱动程序的作用。

    驱动程序是使计算机的操作系统与硬件设备通信、管理硬件设备的软件。它们通常位于操作系统的内核之下、硬件之上,因为它们需要直接与硬件设备进行交互。

    驱动程序位于什么位置?【操作系统之下、硬件之上,管理具体的设备】

  12. 空间换时间【查表法,查表计算三角函数值】、时间换空间举例说明。

    空间换时间:预先计算出三角函数的值,使用时查表而不是计算。
    时间换空间:多级页表增加了内存访问次数和开销,但是节省了保存页表的空间(时间换空间,然后在通过TLB来减少时间消耗)。

  13. 文件存放到磁盘上有哪几种存储方式?【连续、链接、索引】

    这些存储方式的基本思想是什么?

    文件分配是如何表示分配给一个文件数据块的位置和顺序的问题。
    连续分配:给出起点,顺序地分配连续空间;
    链式分配:各数据块依次指向下一块;
    索引分配:分配一个存放文件分配信息的块(存放哪些块有信息、块的顺序)。

  14. 文件的搜索过程(牵扯到目录文件的概念)。

    名字解析:逻辑名字转换成物理资源(如文件)的过程,遍历文件目录直到找到目标文件。
    举例:解析 “/bin/ls”:
    1、读取根目录(root)的文件头(在磁盘固定位置)
    2、在root的数据块中搜索 “bin” 项,其指向下一级目录的数据块
    3、读取bin的文件头
    4、在bin的数据块中搜索 “ls” 项,其指向下一级目录的数据块
    5、读取ls的文件头,找到文件。

    什么是目录文件?【存放目录项的文件,目录项中包括名字、id号等】

    目录是一类特殊的文件,目录的内容是文件索引表:<文件名,指向文件的指针>。

应用性问题:

  1. 给出两个程序A和B,其执行CPU、I/O的时间给出,问串行时CPU利用率是多少?并发运行时,CPU利用率是多少?

    【例】两个程序:A程序按顺序使用CPU10秒,使用设备甲5秒,使用CPU5秒,使用设备乙10秒,最后使用CPU10秒。B程序顺序使用设备甲10秒,使用CPU10秒,使用设备乙5秒,使用CPU5秒,使用设备乙10秒。

    计算:(1)顺序环境下执行A程序和B程序,CPU的利用率是多少?(2)多道程序环境下,CPU的利用率是多少?

    image-20240826143626652

    程序A和程序B顺序执行时,程序A执行完毕,程序B才开始执行。两个程序共耗时80秒,其中占用CPU时间为40秒。故顺序执行时CPU利用率为50%。
    多道程序环境下,两个程序并发的执行,如图所示。可以看出两个程序共耗时看出两个程序共耗时45秒,其中占用CPU时间为40秒。故此时CPU利用率为40/45=88.89%。

  2. 给出一个fork的例程,问那一部分是父程序?哪一部分是子程序?程序的执行结果(打印)是什么?

    image-20240826144102194

  3. 实验题:创建一个进程家族树,或者xxxx的进程家族树。

    【例】下列 C 语言代码创建了一棵进程家族树,请画出该进程 家族树,并简单说明创建过程(不考虑出现孤儿进程的情况)。

    image-20240826144134990

    image-20240826144222134

  4. 进程之间有几个状态?他们之间如何进行转化?

    image-20240821221800381

    → 创建:一个新进程被产生出来执行一个程序。
    创建 → 就绪: 当进程创建完成并初始化后,一切就绪准备运行时,变为就绪状态。是否会持续很久?很快。
    就绪 → 运行:处于就绪态的进程被进程调度程序选中后,就分配到处理机上来运行。(怎么选中取决于后面的调度算法)
    运行 → 结束:当进程表示它已经完成或者因出错,当前运行进程会由操作系统作结束处理。
    运行 → 就绪:处于运行状态的进程在其运行过程中,由于分配它的处理机时间片用完而让出处理机。谁完成?OS。
    运行 → 阻塞: 当进程请求某样东西且必须等待时。例如?等待一个计时器的到达、读 / 写文件 比较慢等。
    阻塞→ 就绪:当进程要等待某事件到来时,它从阻塞状态变到就绪状态。例如?事件到达等。谁完成?OS。

    如果,系统中有n个进程,最多有几个就绪状态?最多有几个等待状态?最多有几个执行状态?

    最多有n-1个就绪态,最多有n个等待态(阻塞态),最多有1个执行态(对单核处理机)。

  5. 程序的执行序列。例如,给出两个程序A和B,各有m和n条指令,可能有多少种执行序列?

    由于进程间执行时间完全随机,但进程内语句的执行是顺序的,这相当于在m+n个位置中挑选m(或n)个,即\(C^m_{m+n}=C^n_{m+n}\)种可能。

  6. 如果锁操作不具有原子性,会出现什么问题?例如,链表操作中,内存泄漏的例子。还有,两个进程并发运行count++时会出现什么问题?

    如果锁操作不具有原子性,当多个线程同时尝试获取锁时,可能出现多个线程同时认为自己获得了锁的情况。这将导致共享数据不一致的问题。
    例如,链表操作中,因为顺序混乱地进行头部增加节点和删除节点的操作,从而导致拟添加的节点未能正确添加,而成为没有指针指向的节点,无法释放内存,造成内存泄漏。
    又例如,两个进程并发执行count++,因为锁操作没有原子性,其执行顺序混乱造成最终结果不正确。

    image-20240826150019735

    image-20240826150034622

    image-20240826150049509

    image-20240826150111203

    img

    image-20240826150125363

  7. 调度程序。给出多个进程,给出其优先级、执行时间、到达时间,使用不同的调度算法时,画出甘特图,计算其等待时间、平均周转时间。

    处理机调度算法

  8. 页面置换算法。给出一个执行序列,系统中可能有n个页面,用不同的算法,画出整个过程,什么时候缺页?等等

    页面置换算法

概述

学习的主要内容

  • 操作系统结构

    操作系统软件的结构

  • 中断及系统调用

    中断:操作系统与硬件的接口

    系统调用:操作系统为上层应用提供的服务

  • 内存管理

    内存的分配与回收

  • 进程及线程

  • 处理机调度

    进程的调度

  • 同步互斥

    进程间通信

  • 文件系统

    文件的组织(方便用户的读、写、查找)

  • I/O子系统

    提供统一的接口管理庞大种类外部设备的连接与控制

什么是操作系统

不同角度看待操作系统

  • 用户角度:操作系统是一个控制程序
    • 一个系统软件
    • 控制程序执行过程,防止错误和计算机的不当使用
    • 执行用户程序,给用户程序提供各种服务
    • 方便用户使用计算机系统(例如,shell中给出提示)
  • 程序角度:操作系统是一个资源管理器
    • 应用程序与硬件之间的中间层
    • 管理各种计算机软硬件资源
    • 提供访问计算机软硬件资源的高效手段
    • 解决资源访间冲突,确保资源公平使用

处于的架构层次

位于硬件之上,应用程序之下。

操作系统组成

  • 外壳(shell)

  • 内核(kernel)

    • CPU调度器

      进程线程管理

    • 物理内存管理

    • 虚拟内存管理

      为应用提供相对独立、虚拟的内存

    • 文件系统管理

      在内存上抽象出文件系统

    • 中断处理与设备驱动

      底层硬件的控制

    image-20240819220919840

内核特征

  • 并发

    (并发指一段时间内多个程序运行;而并行是指一个时间点上多个程序运行,要求多个CPU)

    • 计算机系统中同时存在多个运行的程序, 需要OS管理和调度
  • 共享

    资源管理

    • “同时”共享(宏观上)
    • 互斥共享(微观上)
  • 虚拟

    • 利用多道程序设计技术,让每个用户都觉得有一个计算机专门为他服务
    • 抽象(虚拟化)
      • 将CPU抽象成进程
      • 将磁盘抽象成文件
      • 将内存抽象成地址空间
  • 异步

    • 程序的执行不是一贯到底,而是走走停停,向前推进的速度不可预知(与程序的调度有关)
    • 只要运行环境相同,OS需要保证程序运行的结果也要相同

操作系统的结构

  • 简单结构
    例如:MS-DOS

    不分模块的单体内核,内部通过函数调用访问。

    缺点:复杂,紧耦合,无保护设计,易受攻击。

    image-20240816173702246

  • 分层结构

    例如:Unix、uCore

    将操作系统分为多层,每层建立在低层之上

    最底层(layer0)是硬件,最高层(layerN)是用户界面

    每一层仅使用更低一层的功能(操作)和服务

    随着层次增加,依赖关系逐渐复杂,导致内核庞大,效率低下。

  • 微内核
    尽可能把内核功能移植到用户态,内核中只有最基本的功能(进程通信、硬件支持),其余功能放在外部以进程来实现。

    应用程序想与内核通信时,必须先进入内核,再返回用户态(功能在用户态)

    优点:灵活与安全。缺点:性能低。

    image-20240816173300078

  • 外核

    在内核中放更少的内容。内核分配机器的物理资源给多个应用程序,每个程序自行决定如何处理这些资源。

    外核中仅有安全绑定(什么资源分给哪个程序)。

    优点:应用与OS紧耦合,速度较高。

  • 虚拟机
    VMs(虚拟机)->VMM(虚拟机监视器)->物理机硬件

    虚拟出多个机器,从而使多操作系统共享硬件资源。

    管理器(VMM)负责资源的隔离,OS运行在VMs上,负责资源的管理。

    image-20240816173544990

基础操作(启动、中断/异常/系统调用)

操作系统的启动

  1. 当电脑通电时,段寄存器CS和指令寄存器IP能够确定一个内存地址,例如CS:IP = 0xf000:fff0,用于执行BIOS(Basic I/O System)程序;
  2. BIOS就从这个内存地址开始执行,POST(加电自检内存、显卡等关键部件的存在和工作状态),执行系统BIOS进行系统检测(检测和配置系统中安装的即插即用设备),更新CMOS中的ESCD(扩展系统配置数据),进而按照指定的(ESCD中)的启动顺序,从软盘、硬盘或光驱启动;
  3. 硬盘启动时:将BootLoader从磁盘的磁盘的引导扇区(512字节)加载到0x7c00;跳转到CS:IP=0000:7c00的内存区域;
  4. Bootloader将操作系统的代码和数据从硬盘加载到内存中;跳转到操作系统的起始地址。

问题:为什么不直接使用BIOS将操作系统读入内存?

原因:有多种可选的文件系统,出厂时(烧入BIOS时)无法限定使用何种文件系统、也无法做到可识别所有的文件系统(BIOS程序体量有限),因此,只是单纯的将加载程序(BootLoader)读入内存,用加载程序进一步识别文件系统,并加载操作系统内核映像。

image-20240819215551280

主引导记录、活动分区:现在的电脑中往往不止一个分区,甚至在多个分区中安装了不同的操作系统。因此,必须先读取主引导记录,进而读取活动分区的引导扇区代码,才能将加载程序放入内存。

image-20240819215640079

中断、异常、系统调用

为什么需要系统调用

  • 计算机运行时,内核是被信任的第三方
  • 只有内核可以执行特权指令
  • 为了方便应用程序【系统调用提供使用内核服务的窗口,但同时要保证内核的安全】

为什么需要中断和异常

  • 外设连接计算机,不知道外设什么时候有输入进来,需要对其进行响应处理

  • 应用程序遇到意想不到的行为时,需要操作系统进行干预处理

三者区别

image-20240819215518533

产生的源头:

  • 中断:硬件对操作系统提出的处理请求(键盘/鼠标/网卡/声卡/显卡,可以产生各种事件)
  • 异常:应用程序意想不到的行为(e.g.异常,恶意程序,应用程序需要的资源未得到满足)
  • 系统调用(system call):应用程序请求操作提供服务(e.g.打开/关闭/读写文件,发送网络包)

相应方式:

  • 中断:异步;【应用程序被中断后,等待中断服务程序完成后,才能继续执行,但应用程序不会感受到中断的存在】
  • 异常:同步;【必须处理完产生异常的指令导致的问题,才能继续】
  • 系统调用:同步或异步。【应用程序发出请求后,可能持续等待直到操作系统完成服务,也可能切换到其他任务,直到操作系统完成服务再继续当前任务】

处理机制:

  • 中断:持续进行,对用户应用程序时透明的
  • 异常:处理当前的问题,杀死或者重新执行意想不到的应用程序指令
  • 系统调用:等待和继续

中断和异常处理机制

硬件:

  • 设置中断使能标志(CPU初始化)【使能前不会响应中断】
  • 依据内部/外部事件设置中断标志【通常是依据上升沿/下降沿或高低电平】
  • 依据中断向量调用相关的中断服务程序

内核软件(操作系统):

  • 保存当前处理状态(现场保存)
  • 中断服务程序处理
  • 清除中断标记
  • 恢复之前保存的处理状态

中断嵌套

image-20240819214423039

系统调用概念与实现

image-20240819215849558

image-20240819215929351

三种最常用的应用程序编程接口(API):

  • Win32 API 用于Windows
  • POSIX API 用于 POSIX-based systems(包括UNIX,LINUX,Mac OS X)
  • Java API 用于JAVA虚拟机(JVM 跨平台)

系统调用的实现

image-20240819220044834

系统调用与函数调用的不同

系统调用:触发CPU从用户态到内核态的特权级转换,切换程序和内核的堆栈,需要一定的开销

而函数调用只是在一个栈空间完成参数的传递以及返回。

image-20240819220609027

何时选取何种调用?

  • 系统调用比函数调用更安全,但具有更大的开销。

  • 开销:

    • 建立中断/异常/系统调用号与对应服务例程映射关系的初始化开销(建表)

    • 建立内核堆栈

    • 验证参数(为了确保安全)

    • 内核态映射到用户态的地址空间(更新页面映射权限)

    • 内核态独立地址空间(TLB部分失效)

特权指令

用户态:运行用户程序,操作系统运行中,级别特别低,不能访问特权指令

内核态:运行操作系统程序,操作硬件,操作系统运行中,级别很高,可以执行任何一条指令,包括特权指令

特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机

非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)

用户态—>内核态:唯一途径是通过中断、异常、陷入机制(访管指令)

内核态—>用户态:设置程序状态字(PSW)

进程与线程管理

进程

概念

进程和程序是什么关系

  • 进程:一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程。

image-20240821214117003

进程的组成

  • 程序的代码;
  • 程序处理的数据;
  • 程序计数器中的值,指示下一条将运行的指令;
  • 一组通用的寄存器的当前值,堆,栈;
  • 一组系统资源(如打开的文件)

image-20240821214201895

进程的特点

  • 动态性:可动态地创建,结束进程;
  • 并发性:程序只能一个指令指针,而多个进程可以交替运行,进程可以被独立调度并占用处理机运行;(并发:一段,并行:一时刻)
  • 独立性:不同进程的工作不相互影响;(页表是保障措施之一)
  • 制约性:因访问共享数据 / 资源或进程间同步而产生制约。

image-20240821214647883

进程与程序的异同

进程和程序的联系:

  • 进程是操作系统处于执行状态程序的抽象
    • 程序=文件(静态的可执行文件)【程序是产生进程的基础】
    • 进程=执行中的程序=程序+执行状态【进程是程序功能的体现】
  • 同一个程序的每次运行构成不同的进程
  • 进程执行需要的资源
    • 内存:保存代码和数据
    • CPU:执行指令
  • 通过多次执行,一个程序可以对应多个进程;通过调用关系,一个进程可包括多个程序。

进程和程序的区别:

  • 进程是动态的, 程序是静态的
    • 程序是有序代码的集合
    • 进程是程序的执行,进程有核心态 / 用户态的切换
  • 进程是暂时的,程序是永久的
    • 进程是一个状态变化的过程
    • 程序可以长久保存
  • 进程和程序的组成不同
    • 进程的组成包括程序,数据和进程控制块(进程状态信息)

进程控制块

描述进程的数据结构:进程控制块 (Process Control Block)。

操作系统为每个进程都维护了一个PCB,用来保存与该进程有关的各种状态信息,从而控制和管理进程运行的过程。

PCB概念

进程控制块: 操作系统管理控制进程运行所用的信息集合。

  • 操作系统用PCB来描述进程的基本情况以及运行变化的过程
  • PCB是进程存在的唯一标志
    • 每个进程在操作系统中都有一个对应的PCB

PCB使用

  • 进程的创建:为该进程生成一个PCB
  • 进程的终止: 回收它的PCB
  • 进程的组织管理: 通过对PCB的组织管理来实现

PCB内容

image-20240821215618931

PCB含有以下三大类信息:

  • 进程标志信息。
    • 本进程的标志;【ID】
    • 本进程的产生者标志(父进程标志);
    • 用户标志;
  • 处理机状态信息保存区。
    • 保存进程的运行现场信息:
    • 用户可见寄存器; 用户程序可以使用的数据,地址等寄存器。
    • 控制和状态寄存器; 如程序计数器(PC),程序状态字(PSW)。【PC,指示进程运行指令的位置】
    • 栈指针;过程调用,系统调用,中断处理和返回时需要用到它。【SP,指示函数调用时本进程的栈顶】
  • 进程控制信息
    • 调度和状态信息;调度进程和处理机使用情况【依据状态进行调度】,用于操作系统调度进程并占用处理机使用。【调度优先级】
    • 进程间通信信息;为支持进程间与通信相关的各种标志,信号,信件等,这些信息都存在接收方的进程控制块中。
    • 存储管理信息;包含有指向本进程映像存储空间的数据结构。【记录存储对应的位置,用完后要释放】
    • 进程所用资源;说明由进程打开,使用的系统资源。 如打开的文件等。
    • 有关数据结构的链接信息;进程可以连接到一个进程队列中,或连接到相关的其他进程的PCB。

PCB组织方式

  • 链表:同一状态的进程其PCB成一链表,多个状态对应多个不同的链表。

    • 各状态的进程形成不同的链表:就绪链表,阻塞链表
  • 索引表:同一状态的进程归入一个index表(由index指向PCB),多个状态对应多个不同的index表

    • 各状态的进行形成不同的索引表:就绪索引表,阻塞索引表

image-20240821215642619

进程状态

进程的生命周期划分

对进程在整个生命周期可能出现的事件的划分:(在不同OS中划分可能不同

  • 进程创建

    进程控制块等资源的准备过程。

    image-20240821222907674

    引起进程创建的3个主要事件:

    • 系统初始化;【初始化完成后,创建第一个进程】
    • 用户请求创建一个新进程;【根据用户所需的功能】
    • 正在运行的进程执行了创建进程的系统调用。
  • 进程运行

    内核选择一个就绪的进程,让它占用处理机并执行(后续的调度算法)

    image-20240821222933002

    • 如何选择?

      根据系统的调度算法

  • 进程等待

    image-20240821222958252

    在以下情况下,进程等待(阻塞):

    • 请求并等待系统服务,无法马上完成;
    • 启动某种操作,无法马上完成;【例如:读写磁盘】
    • 需要的数据没有到达。

    进程只能自己阻塞自己,因为只有进程自身才能知道何时需要等待某种事件的发生。

  • 进程抢占

    image-20240821222801761

    • 优先级高的进程就绪后,抢占正在运行的低优先级进程而运行;
    • 当前进程的执行时间用完。
  • 进程唤醒

    image-20240821222843600

    唤醒进程的原因:

    • 被阻塞进程需要的资源可被满足;
    • 被阻塞进程等待的事件到达。

    将该进程的PCB插入到就绪队列,进程只能被别的进程或操作系统唤醒。

  • 进程结束

    将占用的所有资源还给操作系统。

    image-20240821223150483

    在以下四种情况下,进程结束:

    • 正常退出(自愿的)
    • 错误退出(自愿的)
    • 致命错误(强制性的)
    • 被其他进程所杀(强制性的)

【例子】sleep()系统调用对应的进程状态变化:

该程序的功能:运行后,暂停2s,然后退出。

1

注意:延时2s时,该进程设置好定时器后,进入等待状态。2s定时到后,会产生中断,操作系统处理后,会唤醒该进程,进入就绪状态。

进程切换

image-20240821221659466

三状态进程模型

进程的三种基本状态:

进程在生命结束前处于三种基本状态之一。

  • 运行状态(Running):当一个进程正在处理机上运行时
  • 就绪状态(Ready):一个进程获得了除处理机之外的一切所需资源,一旦得到处理机即可运行
  • 等待状态(阻塞状态 Blocked):一个进程正在等待某一时间而暂停运行时。 如等待某资源,等待输入 / 输出完成。

image-20240821221737878

进程两种辅助状态:

  • 创建状态(New):一个进程正在被创建,还没被转到就绪状态之前的状态。
  • 结束状态(Exit): 一个进程正在从系统中消失时的状态,这是因为进程结束或由于其它原因所导致。

可能的状态变化如下:

image-20240821221800381

​ → 创建:一个新进程被产生出来执行一个程序。

创建 → 就绪: 当进程创建完成并初始化后,一切就绪准备运行时,变为就绪状态。是否会持续很久?很快。

就绪 → 运行:处于就绪态的进程被进程调度程序选中后,就分配到处理机上来运行。(怎么选中取决于后面的调度算法)

运行 → 结束:当进程表示它已经完成或者因出错,当前运行进程会由操作系统作结束处理。

运行 → 就绪:处于运行状态的进程在其运行过程中,由于分配它的处理机时间片用完而让出处理机。谁完成?OS。

运行 → 阻塞: 当进程请求某样东西且必须等待时。例如?等待一个计时器的到达、读 / 写文件 比较慢等。

阻塞→ 就绪:当进程要等待某事件到来时,它从阻塞状态变到就绪状态。例如?事件到达等。谁完成?OS。

挂起进程模型

Why?为了合理且充分地利用系统资源。

进程在挂起状态时,意味着进程没有占用内存空间,处在挂起状态的进程映像在磁盘上。(把进程放到磁盘上)

image-20240821221818461

挂起状态

  • 阻塞挂起状态(Blocked-suspend):进程在外存并等待某事件的出现。
  • 就绪挂起状态(Ready-suspend):进程在外存,但只要进入内存,即可运行。

与挂起相关的状态转换

挂起(Suspend): 把一个进程从内存转到外存,可能有以下几种情况:

  • 阻塞到阻塞挂起:没有进程处于就绪状态或就绪进程要求更多内存资源时,会进行这种转换,以提交新进程或运行时就绪进程。
  • 就绪到就绪挂起:当有高优先级阻塞(系统认为会很快就绪的)进程和低优先级就绪进程时,系统会选择挂起低优先级就绪进程。
  • 运行到就绪挂起:对抢先式分时系统,当有高优先级阻塞挂起进程因事件出现而进入就绪挂起时,系统可能会把运行进程转导就绪挂起状态。

在外存时的状态转换:

  • 阻塞挂起到就绪挂起:当有阻塞挂起因相关事件出现时,系统会把阻塞挂起进程转换为就绪挂起进程。

解挂 / 激活: 把一个进程从外存转到内存;可能有以下几种情况:

  • 就绪挂起到就绪:没有就绪进程或挂起就绪进程优先级高于就绪进程时,会进行这种转换。
  • 阻塞挂起到阻塞:当一个进程释放足够内存时,系统会把一个高优先级阻塞挂起(系统认为会很快出现所等待的事件)进程转换为阻塞进程。

状态队列

问题:OS怎么通过PCB和定义的进程状态来管理PCB,帮助完成进程的调度过程?

image-20240821221945994

image-20240821221954576

线程

Why线程

【案例】编写一个MP3播放软件。

核心功能模块有三个:
(1) 从MP3音频文件中读取数据;
(2) 对数据进行解压缩;
(3) 把解压缩后的音频数据播放出来。

单进程的实现方法

main()
{
    while (TRUE) 
    {
        Read(); // I/O
        Decompress(); // CPU
        Play();
	}
}
Read() {...}
Decompress() {...}
Play() {...}
/*
	问题:
	播放出来的声音能否连贯? 
	各个函数之间不是并发执行,影响资源的使用效率。
*/

多进程的实现方法

// 程序1
main()
{
    while (TRUE) 
    {
		Read();
	}
}
Read() {...}
// 程序2
main()
{
    while (TRUE) 
    {
		Decompress();
	}
}
Decompress() {...}
// 程序3
main()
{
    while (TRUE) 
    {
		Play();
	}
}
Play() {...}
// 问题:进程之间如何通信,共享数据?另外,维护进程的系统开销较大;
// 创建进程时,分配资源,建立PCB;撤销进程时,回收资源,撤销PCB;进程切换时,保存当前进程的状态信息

怎么来解决这些问题?

需要提出一种新的实体,满足以下特征:

  • 实体之间可以并发执行;
  • 实体之间共享相同的地址空间。

这实体就是线程(Thread)。

What's 线程

线程的概念

Thread:进程当中的一条执行流程。

从两个方面重新理解进程:

  • 从资源组合的角度:进程把一组相关的资源组合起来,构成了一个资源平台(环境),包括地址空间(代码段、数据段)、打开的文件等各种资源;
  • 从运行的角度:代码在这个资源平台上的一条执行流程(线程)。

image-20240822153116204

线程 = 进程 - 共享资源

线程的优缺点

线程的优点:

  • 一个进程中可以同时存在多个线程;
  • 各个线程之间可以并发的执行;
  • 各个线程之间可以共享地址空间和文件等资源。

线程的缺点:

  • 一个线程崩溃,该进程的所有线程崩溃。

【线程的优点也是线程的缺点,由于共享资源,安全性得不到保障。进程和线程有各自的特点,需要根据应用具体情况选择合适的模式设计程序。

使用线程:很强调性能、执行代码相对统一的高性能计算(天气预报、水利、空气动力);

使用进程:Chrome浏览器,一个进程打开一个网页,某一个网页崩溃之后不会影响到其他进程网页的浏览。】

操作系统对线程的支持

image-20240822153521630

线程与进程的比较

image-20240822153601595

  • 进程是资源分配单位,线程是CPU调度单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪,阻塞和执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销:
    • 线程的创建时间比进程短;(直接利用所属进程的一些状态信息)
    • 线程的终止时间比进程短;(不需要考虑资源的释放问题)
    • 同一进程内的线程切换时间比进程短;(同一进程不同线程的切换不需要切换页表)
    • 由于同一进程的各线程之间共享内存和文件资源,可直接进行不通过内核的通信。(直接通过内存地址数据传递)

进程的实现

主要有三种线程的实现方式:

  • 用户线程:在用户空间实现;

    POSIX Pthreads,Mach C-threads,Solaris threads

  • 内核线程:在内核中实现;

    Windows,Solaris,Linux

  • 轻量级进程:在内核中实现,支持用户线程。

    Solaris(LightWeight Process)

image-20240822153912520

用户线程

image-20240822154240887

在用户空间实现的线程机制,它不依赖于操作系统的内核,操作系统只能看到进程,看不到线程,由一组用户级的线程库函数来完成线程的管理,包括进程的创建、终止、同步和调度等。

优点:

  • 由于用户线程的维护由相应的进程来完成(通过线程库函数),不需要操作系统内核了解用户进程的存在,可用于不支持线程技术的多进程操作系统;
  • 每个进程都需要它自己私有的线程控制块(TCB)列表,用来跟踪记录它的各个线程的状态信息(PC,栈指针,寄存器),TCB由线程库函数来维护;
  • 用户线程的切换也是由线程库函数来完成,无需用户态 / 核心态切换,所以速度特别快;
  • 允许每个进程拥有自定义的线程调度算法。

缺点:

  • 阻塞性的系统调用如何实现?如果一个线程发起系统调用而阻塞,则整个进程在等待;
  • 当一个线程开始运行时,除非它主动地交出CPU的使用权,否则它所在的进程当中的其他线程将无法运行;
  • 由于时间片分配给进程,所以与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会较慢。

内核线程

image-20240822154254163

内核线程是指在操作系统的内核当中实现的一种线程机制,由操作系统的内核来完成线程的创建,终止和管理。一个进程的PCB会管理其TCB的list。

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息(PCB和TCB);
  • 线程的创建,终止和切换都是通过系统调用 / 内核函数的方式来进行,由内核来完成,因此系统开销较大;
  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
  • 时间片分配给线程,多线程的进程获得更多CPU时间;
  • Windows NT 和 Windows 2000/XP 支持内核线程。

轻量级进程

它是内核支持的用户线程。一个进程可以有一个或多个轻量化进程,每个轻量级进程由一个单独的内核线程来支持。(Solaris / Linux)

image-20240822154441581

经实践验证,这种方法的收益并不理想。目前常用的依然是内核线程。

进程控制

进程切换

进程切换(也称为上下文切换,Context Switch):停止当前运行进程(从运行状态变成其他状态),并且调度其他进程(转变为运行状态)

  • 必须在切换之前保存进程上下文
  • 必须在切换之后恢复进程上下文,所以进程不能显示它曾经被暂停过
  • 必须快速(上下文切换时非常频繁,一般使用汇编实现)

需要存储什么上下文?

  • 寄存器(PC,SP…)
  • CPU状态
  • 内存地址空间(但由于每个进程各自对应一块地址空间,因此内存中的内容大部分不需要切换)

image-20240822155217162

PCB为进程切换提供的支持:

  • 操作系统为活跃进程准备了进程控制块(PCB)
  • 操作系统将进程控制块(PCB)放置在一个合适的队列里
    • 就绪队列
    • 等待I/O队列(每个设备的队列)
    • 僵尸队列

image-20240822155359087

【题目】两个进程各有m和n条语句,那么在进程可切换的情况下,有多少种执行的顺序可能?

【答案】由于进程间执行时间完全随机,但进程内语句的执行是顺序的,这相当于在m+n个位置中挑选m(或n)个,即\(C^m_{m+n}=C^n_{m+n}\)种可能。

进程创建

不同系统下的API

image-20240822160415502

Unix:fork()

image-20240822160444900

image-20240822160609895

进程加载

exec()

系统调用exec()加载程序取代当前运行的进程。

In the parent process:
main()
...
int pid = fork(); // 创建子进程
if (pid == 0) {   // 子进程
	exec_status = exec("calc", argc, argv0, argv1, ...);
	printf("Why would I execute?");
} else { // 父进程。合理设计:else if (pid > 0)
	printf("Whose your daddy?");
	...
	child_status = wait(pid);
}
if (pid < 0) { /* error occurred */

执行完exec()后的图示:

image-20240822160828620

pid的id变化了,open files的路径也改变了。

执行完exec()后的图示:

image-20240822161018817

PCB中的代码段完全被新的程序calc所替换,且执行地址发生了变化。

  • exec()调用允许一个进程“加载”一个不同的程序并且在main开始执行

  • 它允许一个进程指定参数的数量(argc)和它字符串参数数组(argv)

  • 如果调用成功

    • 它是相同的进程
    • 但是它运行一个不同的程序
  • 代码,stack & heap 重写

fork()带来的问题

fork()的简单实现:

  • 对子进程分配内存
  • 复制父进程的内存和CPU寄存器到子进程(有一个寄存器例外)
  • 开销昂贵!!

在99%的情况下,我们在调用fork()之后调用exec(),这将导致:

  • 在fork()操作中内存复制是没有作用的
  • 子进程将可能关闭打开的文件和连接
  • 开销因此是最高的
  • 为什么不能结合它们在一个调用中(OS/2,windows)?

vfork()

  • 一个创建进程的系统调用,不需要创建一个同样的内存映像
  • 一些时候称为轻量级fork()
  • 子进程应该几乎立即调用exec()
  • 现在不再使用(虚fork,早期的Unix系统提供的一种手段,只复制一小部分内容)
    • 目前使用 Copy on Write(COW)技术
      • 可减少不必要的资源分配fork创建出的子进程,与父进程共享内存空间【子进程的代码段、数据段、堆栈都是指向父进程的物理空间】。也就是说,如果子进程不对内存空间进行写入操作的话,内存空间中的数据并不会复制给子进程【当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间】,可减少分配和复制大量资源时带来的瞬间延时这样创建子进程的速度就很快了!【不用复制,直接引用父进程的物理空间】
      • 不采用COW技术时,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。
      • 并且如果在fork函数返回之后,子进程第一时间exec一个新的可执行映像,那么也不会浪费时间和内存空间了。exec后,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。
      • 技术实现原理:fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会 把触发的异常的页复制一份,于是父子进程各自持有独立的一份。
      • 有没有缺点?如果在fork()之后,父子进程都还需要继续进行写操作,那么会产生大量的分页错误(页异常中断page-fault),这样就得不偿失。

进程等待与退出

父进程等待wait()

wait()系统调用是被父进程用来等待子进程的结束。

  • 一个子进程结束时通过exit()向父进程返回一个值,运行exit()后,子进程进入僵尸状态;
  • 父进程必须通过wait()接受这个值并处理 (子进程通过exit()虽然可以释放大部分资源(内存空间、占有的其他资源(如文件系统等)),但无法释放掉自己的PCB【既然你执行了exit()操作,证明你还“存活”,因此释放资源后,虽然无法在用户态再执行,但在内核中PCB依然存在,操作系统依然需要维护。当然,如果没有父进程进行这一操作,操作系统中的0号进程可以代为完成。】,父进程在子进程执行结束后,接收返回值,帮助子进程释放内存中的PCB等资源)

wait()系统调用实现以下功能:

  • 【父进程先执行了wait()】它使父进程进入等待状态来等待子进程的结束。

    当一个子进程调用exit()的时候,操作系统解锁父进程,将exit()的返回值作为wait()调用的一个结果,父进程处理这一返回值;

  • 【子进程先执行了exit()】有僵尸子进程等待时,wait()立即返回其中一个值(并且解除僵尸状态);

  • 如果这里没有子进程存活(或者子进程已经结束),wait()立刻返回。

如果只有一个子进程被终止,那么 wait() 返回被终止的子进程的进程ID。

如果多个子进程被终止,那么 wait() 将获取任意子进程并返回该子进程的进程ID。

wait的目的之一是通知父进程子进程结束运行了,它的第二个目的是告诉父进程子进程是如何结束的。wait返回结束的子进程的PID给父进程。父进程如何知道子进程是以何种方式退出的呢?

答案在传给wait的参数之中。父进程调用wait时传一个整型变量地址给函数。内核将子进程的退出状态保存在这个变量中。如果子进程调用exit退出,那么内核把exit的返回值存放到这个整数变量中;如果进程是被杀死的,那么内核将信号序号存放在这个变量中。这个整数由3部分组成,8个bit记录子进程exit值,7个bit记录信号序号,另一个bit用来指明发生错误并产生了内核映像(core dump)。

#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>

int main()
{
    pid_t cpid;
    int status;
    int high_8, low_7, bit_7;

    if (fork()== 0)
    {   
        printf("this is child process, the id is %d\n", getpid());
        exit(18);                                       /* terminate child */
    }
    else
    {
        printf("status is %d\n", status);
        cpid = wait(&status);                           /* reaping parent */
        high_8 = status >> 8;                           /* 1111 1111 0000 0000 */
        low_7  = status & 0x7F;                         /* 0000 0000 0111 1111 */
        bit_7  = status & 0x80;                         /* 0000 0000 1000 0000 */

        printf("status is %d\n", status);
        printf("high_8 is %d, low_7 is %d, bit_7 is %d\n", high_8, low_7, bit_7);
    }
    printf("Parent pid = %d\n", getpid()); 
    printf("Child pid = %d\n", cpid); 
  
    return 0; 
} 

执行结果:

status is 0
this is child process, the id is 5412
status is 4608
high_8 is 18, low_7 is 0, bit_7 is 0
Parent pid = 5411
Child pid = 5412

在本例中,wait() 将子进程的退出状态存储到status变量中,4608的二进制格式为0001 0010 0000 0000,前8位用10进制表示为18,也就是子进程exit的值。

有序退出exit()

进程结束执行之后,它调用exit(),完成进程资源的回收。

这个系统调用的功能:

  • 将调用参数作为进程的“结果”(即上一节例子中的&status,例如,成功返回0,不成功返回错误码;

  • 关闭所有打开的文件、连接等资源

  • 释放分配的内存

  • 释放大部分支持进程的操作系统结构

  • 检查是否父进程是存活着的:

    • 如果是的话,它保留结果的值直到父进程需要它;在这种情况里,进程没有真正死亡,但是它进入了僵尸(zombie/defunct)状态
    • 如果没有,它释放所有的数据结构,这个进程死亡(Root进程会定期扫描僵尸队列,判断僵尸状态的子进程的父进程是否存在)
  • 清理所有等待的僵尸进程

  • 进程终止是最终的垃圾收集(资源回收)。

加入僵尸状态后的系统状态图

image-20240822164256491

注意以下要点:

  • 执行exec()后,进程可能处于不同的状态。例如,可能继续处于运行态,也可能因为等待程序、数据的加载,而处于阻塞态。

处理机调度

概念

  • 处理机调度:操作系统中用于管理处理机执行能力这一资源的功能;
  • 进程切换:实现处理机资源的时分复用;
  • 调度算法:挑选下一个占用CPU运行的进程;对多喝处理机,从多个可用CPU中,挑选出一个供下一个进程使用;
  • 调度程序:通过一些调度策略挑选进程/线程的内核函数。【调度算法的实现】

调度时机

对于非抢占系统而言,当进程从一个状态到另一个状态的时候会发生调度。具体来说:

  • 一个进程从运行状态切换到等待状态,导致CPU资源空闲
  • 一个进程被终结了,导致CPU资源空闲

总之,进程不会主动放弃资源。

而对于抢占系统来说,还有额外的情况:

  • 有中断服务请求或系统调用,将当前进程转为就绪态(被抢占),具体来说:
    • 时间片轮转调度中,进程时间片用完,定时器中断导致调度
    • 某一进程从等待切换到就绪,其具有更高优先级,导致调度

总之,进程会被抢占而失去对资源的占有。

调度准则

调度策略

  • 调度策略的作用

    确定如何从就绪队列中选择下一个执行进程

  • 调度策略要解决的具体问题

    • 【挑哪个?】挑选就绪队列中的哪一个进程?
    • 【怎么挑?】通过什么样的准则来选择?
  • 调度算法是什么

    在调度程序中实现的调度策略

  • 比较调度算法的准则

    哪一个策略/算法较好?

预备知识:处理机的资源使用模式

为了确定比较调度算法的准则,必须先了解处理机的资源使用模式。

image-20240822201734062

由上图可知,每次执行通常情况下时间较短。

处理机的资源使用模式:程序在CPU计算和I/O操作间交替

  • 每个调度决定都是关于在下一个CPU突发时将哪个进程交给CPU;【肯定要给就绪进程,但如果有多个就绪进程,怎么挑?】
  • 在时间片轮转算法下,如果分配的时间片过短,则进程可能在结束当前CPU计算前被迫放弃CPU,对进程执行不力。【要选取合适的时间片长度】

比较调度程序的准则

从系统利用率的角度:

  • CPU使用率

    CPU处于忙状态所占时间的百分比

  • 吞吐量

    在单位时间内完成的进程数量

从用户的角度:

  • 周转时间

    一个进程从初始化到结束,包括所有等待时间所花费的总时间

  • 等待时间

    进程在就绪队列中的总时间【等待时间由于外部设备、事件导致,处理机调度中无法缩短这部分时间】

  • 响应时间

    从一个请求被提交到产生第一次响应所花费的总时间

准则间矛盾:吞吐量 vs 延迟

  • 调度算法需要满足:人们通常都需要 ”更快“ 的服务

  • 什么是更快?

    • 传输文件时的高带宽【高吞吐量】

    • 玩游戏时的低延迟【低响应延迟】

    • 这两个因素是独立的

  • 和水管类比
    • 低延迟:喝水的时候想要一打开水龙头水就流出来(响应时间)
    • 高带宽:给游泳池充水时希望从水龙头里同时流出大量的水,并且不介意是否存在延迟(吞吐量)

处理机调度策略的目标

响应时间目标:

响应时间是操作系统的计算延时。

低延迟调度改善用户的交互体验。

  • 减少响应时间:及时处理用户的输出并且尽快将输出提供给用户
  • 减少平均响应时间的波动:在交互系统中,可预测性比高差异性低平均更重要

吞吐量目标:

吞吐量是操作系统的计算带宽。

操作系统要保证吞吐量不受用户交互的影响。操作系统必须不时进行调度,即使存在很多交互任务。

  • 增加吞吐量

    • 减少开销(操作系统开销,上下文切换)

    • 系统资源的高效利用(CPU,I/O设备)

  • 减少等待时间:减少每个进程的等待时间

公平性目标:

不同场合下对公平的度量是不同的。

  • 保证每个进程占用相同的CPU时间

    • 这公平吗?如果一个用户比其他用户运行更多的进程怎么办
  • 保证每个进程都等待相同的时间

  • 公平通常会增加平均响应时间【使用一定的开销来保证公平】

其实这些指标是有矛盾的,比如很难同时满足 最小响应时间 和 最大吞吐量,要么只顾及某一点,要么对两点进行折中。

处理机调度算法

分类:

  • 就绪队列怎么排?
    • 先来先服务
    • 短进程优先(执行时间的长短)
    • 最高响应比优先(依据就绪队列中的等待时间)
  • 每一次执行时间长短的控制
    • 时间片轮转(FCFS排队,但时间片有最大限制)
  • 综合算法
    • 多级反馈队列(多个子就绪队列,不同队列有不同算法)
    • 公平共享调度(按照进程占用资源的情况调度,保证每个资源公平占用资源)

先来先服务(First Come First Served, FCFS)

依据进程进入就绪状态的先后顺序排列

  • 如果进程进入阻塞或结束状态时,就绪队列中的下一个进程会得到CPU

image-20240822203915898

优点

  • 简单
    • 依据容易获得
    • 排队状态不需要变化

缺点

  • 平均等待时间波动较大

    • 当短进程排在长进程后面时,等待时间较长
  • 可能导致I/O和CPU之间的重叠处理

    • CPU密集型进程会导致I/O设备闲置时,I/O密集型进程也在等待

短进程优先算法(SPN (SJF) / SRT)

在就绪队列排队中考虑到进程执行时间的一系列算法。

SPN:Shortest Process Next(短进程优先算法)

SJF:Shortest Job First(短作业优先算法)

SRT:Shortest Remaining Time(短剩余时间优先算法)

选择下一个最短的进程(短任务优先)排在就绪队列最前方。

但执行时间在执行前是未知的,需要按照预测的完成时间来将任务入队。

变种:短剩余时间优先算法(SRT),是SPN算法的可抢占改进。当有一个新进程进入就绪队列,且其执行时间比正在运行中的进程的剩余时间还短,则允许该进程抢先。

image-20240822205019049

SRT的例子:

image-20240822214232401

非抢占式(SPN/SJF):

简单。每次调度发生在当前进程结束时,从就绪队列中选取执行时间最短的。

image-20240822214244604

抢占式(SRT):

较复杂。除了按上述规则进行调度外,每当有新进程就绪时,必须比较新进程的执行时间与正在执行进程剩余执行时间,若新进程执行时间更短,将进行抢占。

image-20240822214315166

image-20240822215435760

优点:

短进程优先算法具有最优平均周转时间。

【例子】修改SPN的执行顺序,求其执行时间有没有可能缩短?

注:题目中\(c_i\)是第\(i\)个进程的执行时间。

image-20240822205527148

\[\because &&c_3<c_4<c_5\\ \therefore &&c_4+c_5-2c_3\ge0 \]

缺点:

  • 可能导致饥饿

    • 连续的短任务流会使长任务饥饿(无法获得CPU资源)
  • 需要预测未来

    • 怎么预估下一个CPU计算的持续时间

    • 方法1:简单的解决:询问用户

      • 如果用户欺骗就杀死进程(这里指的是,按照进程给出的执行时间分配时间片,如果到时间还未完成,则将其终止)

      • 如果不知道怎么办:执行时间预估

        执行时间预估

        image-20240822212055272

优先级调度算法

其实在就绪队列中的排队都属于是优先级,这里更偏向于一种预先设定的优先级。

image-20240822220534843

  • 非抢占式优先级调度算法

    系统为每个进程设置一个优先数(对应一个优先级),把所有的就绪进程按优先级从大到小排序,调度时从就绪队列中选择优先级最高的进程投入运行,仅当占用CPU的进程运行结束或因某种原因不能继续运行时,系统才进行重新调度。

    • 优点:

      • 低开销,进程切换少
    • 问题:

      • 饥饿:低优先级进程可能永远不会运行

        • 解决:老化——随着时间的推移,该过程的优先级会增加。
      • 优先级倒置:已准备就绪的优先级较高的进程必须等待正在运行的优先级较低的进程完成

        • 解决:

          优先级继承

          优先级天花板

    image-20240822220710303

  • 抢占式优先级调度算法

    系统为每个进程设置一个优先数(对应一个优先级),把所有的就绪进程按优先级从大到小排序,调度时从就绪队列中选择优先级最高的进程投入运行,当系统中有另一优先级更高的进程变成就绪态时,系统应立即剥夺现运行进程占用处理机的权力,使该优先级更高的进程投入运行。

    • 优点:反映了进程的优先级特征,使系统能及时处理紧急事件。
    • 缺点:系统开销较大。

    image-20240822220844868

  • 优先级分组法

    • 保留非剥夺式优先级和剥夺式优先级各自的优点,克服其缺点。
    • 方法: 组间可剥夺,组内不可剥夺(组内相同优先级则按FCFS处理)

另一种分类:

  • 静态优先级算法

    • 按进程的重要程度给每个进程分配一个优先级,该优先级在进程的整个运行过程中不再改变。该方法中优先级的设定是一次性的,故一定要慎重、合理。
    • 一般用于系统中每个进程的重要程度事先知道的系统。
    • 简单
  • 动态优先级算法

    • 进程的优先级会动态调整
    • 如何调整?例如:伴随进程的等待时间增长,增加进程的优先级(HRRN)

最高响应比优先算法(Highest Response Ratio Next, HRRN)

SPN当存在连续的短任务流时,会使长任务饥饿。因此,不光考虑执行时间,还考虑等待时间后:

选择就绪队列中响应比R值最高的进程

image-20240822193854501

特点:

  • 在短进程优先算法的基础上改进
  • 不可抢占
  • 关注进程的等待时间
  • 防止无限期推迟

时间片轮转算法(Round Robin,RR)

  • 时间片

    • 进程调度的基本时间单位,分配处理机资源的基本时间单元

    image-20240822222204602

  • 算法思路

    • 时间片结束时,按FCFS算法切换到下一个就绪进程
    • 每隔 ( n – 1 )个时间片进程执行一个时间片 q
  • 例如:设置时间片为 20 ,每个进程轮流的占用时间片:

    image-20240822222406359

  • RR 算法花销:

    • 靠时间中断强行切换进程,导致额外的上下文切换(保证每个进程都有机会执行)
  • 时间片太大

    • 等待时间过长
    • 极限情况退化成FCFS(一次执行肯定结束时)
  • 时间片太小

    • 反应迅速,但产生大量上下文切换
    • 吞吐量由于大量的上下文切换开销受到影响
  • 目标

    • 选择一个合适的时间量子
    • 经验规则:维持上下文切换开销处于1%以内(时间片约10 ms)

【例题】

image-20240822222905855

注:BestFCFS和WorstFCFS指的是FCFS的两种特殊情况,其实对应于最短进程优先和最长进程优先。

看起来好像FCFS更好一点,因为在FCFS中没有频繁的上下文切换,所以它总等待时间反而还会降低,但是我们还要权衡,因为在FCFS中它达不到像RR一样每一个进程及时得到相应。

公平性会带来一定的代价。

  • 不同目标间的PK:利用率 vs 响应时间

    • \[CPU利用率=\frac{时间片}{时间片+进程切换时间} \]

      image-20240822224413272

    • 响应时间要求时间片尽可能短

  • 总结:

    • 一般情况下比SPN/SJF的等待时间会长
    • 取得了更好的响应速度

多级反馈队列算法(Multilevel Feedback Queues, MFQ)

将两类算法进行组合。事实上,RR算法已经是将时间片划分和FCFS相结合的算法。

引入:多级队列调度算法(MQ)

  • 就绪队列被划分成多个独立的队列

    • 如:前台(交互,要求时间片短),后台(批处理,可采用FCFS)
  • 每个队列拥有自己的调度策略

    • 如:前台 — RR,后台 — FCFS
  • 调度必须在队列间进行

    • 方案一:固定优先级

      • 先处理前台,然后处理后台
      • 缺点:可能导致饥饿
  • 方案二:时间切片(时间片轮转)

    • 每个队列都得到一个确定的能够调度其进程的CPU总时间

    • 如:80%给使用RR的前台,20%给使用FCFS的后台【保证前台即时响应,后台也不至于一直饥饿】

在多级队列中,各个队列之间是没有交互的,进一步改进,进程可在不同队列间移动的多级队列算法。

多级反馈队列算法(MFQ)

可以根据情况(反馈)调整进程的优先级、队列,即进程可在不同优先级队列间移动。

image-20240822223743036

  • 时间片大小随优先级级别增加(数字增加,即优先级降低)而增加
  • 如进程在当前时间片没有完成,则降到下一优先级(即执行时间越长,优先级越低)

优点

  • CPU密集型任务的优先级下降很快,时间片增大,切换的开销变小;
  • I/O密集型任务停留在高优先级。

公平共享调度算法(Fair Share Scheduling, FSS)

FSS控制用户对系统资源的访问

  • 一些用户组比其他用户组更重要,会分到更大的资源份额
  • 保证不重要的组无法垄断资源
  • 未使用的资源按照每个组所分配的资源的比例来分配
  • 没有达到资源使用率目标的组获得更高的优先级

image-20240822223906140

调度算法总结

  • 先来先服务算法(FCFS)

    • 不公平,平均等待时间较差
  • 短进程优先算法(SPN(SJF) / SRT)

    • 不公平,但是平均等待时间最小

    • 需要精确预测计算时间

    • 可能导致饥饿

  • 最高响应比优先算法(HRRN)

    • 考虑等待时间不能过长

    • 基于SPN调度改进

    • 不可抢占

  • 轮循算法(RR)

    • 公平,但是平均等待时间较差
    • 交互性好(响应速度快)
  • 多级反馈队列算法(MFQ)

    • 和SPN类似,多种算法的集成
  • 公平共享调度算法(FSS)

    • 公平是第一要素

实时系统调度

实时系统

  • 定义

    • 正确性依赖于其时间功能两方面的一个操作系统
  • 性能指标

    • 时间约束的及时性(deadlines),必须在要求时间内完成工作
    • 速度和平均性能相对不重要
  • 主要特征

    • 时间约束的可预测性,必需知道在什么情况下可以达到时间约束
  • 分类

    • 强实时系统

      需要在保证时间内完成重要的任务,必须完成

    • 弱实时系统

      要求重要的进程的优先级更高,尽量完成,并非必须

实时任务

单个实时任务

image-20240822225040480

周期实时任务

image-20240822225935354

使用率越高越好,但不能要求100%,否则可能无法满足实时性要求。

硬时限和软时限

  • 硬时限(Hard deadline)

    • 如果错过了最后期限,可能会发生灾难性或非常严重的后果

    • 必须验证:在最坏的情况下也能够满足时限吗?

    • 保证确定性

  • 软时限(Soft deadline)

    • 理想情况下,时限应该被最大满足。如果有时限没有被满足,那么降级提供服务

    • 尽最大努力去保证,不是必需

可调度性

可调度性:一个实时系统能够满足任务时限要求

例如,给出三个周期性的实时任务,可否满足可调度性?

image-20240822230231884

  • 需要确定实时任务的执行顺序,当其能满足所有任务对时限的要求,才说明可调度。

    • 静态优先级调度

      事先排出执行顺序,理论上可以保证满足可调度性

    • 动态优先级调度

      执行过程中才能确定执行顺序,在此过程中必须判断能否可调度

速率单调调度算法(Rate Monotonic, RM)

最佳静态优先级调度

  • 通过周期安排优先级
    • 频率越高(周期越短)的优先级越高
    • 执行周期最短的任务(短周期任务优先)
  • 可以证明,在一定的使用率条件下,该算法能满足可调度性要求。

最早截止时间优先算法 (Earliest Deadline First, EDF)

最佳动态优先级调度

  • 截止时间越早优先级越高
  • 执行截止时间最早的任务

什么情况下可调度?自行阅读文献。

多处理器调度

  • 多处理机调度的特征

    • 多个处理机组成一个多处理器系统

      image-20240822230836866

    • 处理机之间可以负载共享

  • 使用最多的:对称多处理器(SMP)

    • 每个处理器运行自己的调度程序

    • 调度程序在对共享资源的访问需要同步

      • 同步是多处理机系统中的重要问题
    • 进程分配方法

      • 静态进程分配

        image-20240822231023437

      • 动态进程分配

        image-20240822231229449

image-20240822230815181

优先级反转

优先级反转可以发生在任何基于优先级的可抢占的调度机制;指的是高优先级进程长时间等待低优先级进程所占用资源的现象。

概念与出现原因

优先级反转的持续时间取决于其它不相关任务的不可预测的行为,在这种情况下,高优先级可能比低优先级任务晚完成。

例如下图,优先级T2>T3>T1。

image-20240822231543038

  • 首先T1运行,访问了共享资源L1;
  • T2抢占运行,由于需要共享资源L1,让给T1以运行完后释放资源;
  • 但此时T3出现了,它的优先级更高,所以T1被抢占,必须等待T3运行完才能运行T1;
  • T1运行完,释放L1,方能运行T2。
  • 这里出现了个奇怪的现象,也就是优先级更高的T2,需要等T3运行完才能执行。

解决方法

优先级继承

  • 当高优先级进程需要使用已阻塞的低优先级进程已占用的共享资源时,低优先级进程继承高优先级进程的优先级。
    • 必须该低优先级占用的资源被需要时才提升;
  • 在此例中,此时T3的优先级会动态的得到提升,此时T2无法抢占T3。

image-20240822195852308

优先级天花板

  • 占有资源的进程的优先级提升到所有可能申请该资源的进程的最高优先级;

    • 无论是否发生等待,都提升优先级;

    • 可能存在优先级滥用。

      除非当前进程的优先级高于系统中所有被锁定的资源的优先级的上限,否则任务尝试执行临界区的时候会被阻塞。

同步与互斥

Why Need同步与互斥

对于独立进程:

  • 不和其他进程共享资源或状态
  • 确定性 => 输入状态决定结果
  • 可重现 => 能够重现起始条件
  • 调度顺序不重要

而对于并发进程:

  • 在多个进程间有资源共享(例如,时分CPU、按区域划分内存)
  • 不确定性
  • 不可重现
  • 不确定性和不可重现意味着可能存在间歇性发生的bug
  • 有时需要进程间通信,根据通信情况来确定自身的行为

既然如此,可否不进行并发?

显然不行。进程 / 线程之间及与计算机 / 设备之间需要合作,这将带来大量好处:

  • 共享资源

    • 一台电脑,多个用户

    • 一个银行存款余额,多台ATM机均可存取

    • 嵌入式系统(机器人控制:手臂和手的协调控制)

  • 加速

    • I/O操作和计算可以重叠(并行)

    • 多处理器:将程序分成多个部分并行执行

  • 模块化

    • 将大程序分解成小程序

      • 以编译为例,gcc会调用cpp,cc1,cc2,as,ld
    • 使系统易于扩展

值得一提的是,多道程序设计(multi-programming)是现代操作系统的重要特性。我们不能不进行并发设计。

并发可能带来的问题示例

image-20240822233349155

而当某种情况下两个进程执行顺序是这样时:

image-20240822233403814

由于整个操作的四条机器指令执行过程中,产生了一次上下文切换,使得整个的结果和我们预期不一致了。

调度的时机点可以在四条语句的任何一个位置产生切换,会得到很多不一样的结果,这种交叉切换性会有很多种情况,也就意味着最终的结果具有 不确定性 和 不可重复性。

然而,我们要求:

  • 无论多个线程的指令序列怎样交替执行,程序都必须正常工作

    • 多线程程序具有不确定性和不可重现的特点

    • 不经过专门设计,调试难度很高

  • 不确定性下依然要求并行程序的正确性

    • 先思考清楚问题,把程序的行为设计清楚

    • 切忌给予着手编写代码,碰到问题再调试

我们必须要有一些新的机制来保证能够达到最终确定的结果,后面会引入同步互斥机制解决这种不确定性的问题。

概念

竞态条件(Race Condition)

  • 系统缺陷:结果依赖于并发执行或者时间的顺序 / 时间

    • 不确定性

    • 不可重现

  • 怎么样避免竞态?

原子操作(Atomic Operator)

  • 原子操作是指一次不存在任何中断或者失败的执行

    • 该执行成功结束

    • 或者根本没有执行

    • 并且不应发生任何部分执行的状态

  • 实际上操作往往不是原子的

    • 有些看上去是原子操作,实际上不是
  • x++这样的简单语句,实际上是由三条指令构成的

操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作。

临界区(Critical section)

临界区是指进程中的一段访问临界资源并且需要互斥执行的代码(当另一个进程处于相应代码区域时,便不会被执行的代码区域)。

互斥(Mutual exclusion)

互斥是指当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源。

死锁(Dead lock)

死锁是指两个或以上进程,在相互等待完成特定任务,而最终没法将自身任务进行下去,形成循环等待。

饥饿(Starvation)

饥饿是指一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行。

临界区

临界区的标准访问模式

image-20240822234312555

  • 临界区(critical section)

    • 进程中访问临界资源的一段需要互斥执行的代码
  • 进入区(entry setcion)

    • 检查是否进入临界区的一段代码

    • 如可进入,设置相应 “正在访问临界区” 标志

  • 退出区(exit section)

    • 清除 “正在访问临界区” 标志
  • 剩余区(remainder section)

    • 代码中的其余部分

临界区的访问规则

  • 空闲则入

    • 没有进程在临界区时,任何进程可以进入
  • 忙则等待(互斥)

    • 有进程在临界区时,其他进程均不能进入临界区
  • 有限等待

    • 等待进入临界区的进程不能无限期的等待
  • 让权等待(可选)

    • 不能进入临界区的进程,应释放CPU(如转换到阻塞状态)

临界区的实现方法

  • 禁用中断
  • 软件方法
    • 进程间对等协调
  • 更高级的抽象方法
    • 引入管理者,不再是对等协调的方法
  • 不同的临界区实现机制的比较
    • 性能:并发级别

实现一:禁用硬件中断

  • 没有中断,没有上下文切换,因此没有并发

    • 硬件将中断处理延迟到中断被启用之后

    • 大多数现代计算机体系结构都提供指令来实现

  • 进入临界区

    • 禁用中断
  • 离开临界区

    • 开启中断

用这种方法确实可以解决这个问题,但它还有一些缺点:

  • 一旦中断被禁用,线程就无法被停止

    • 整个系统都会为你停下来

    • 可能导致其他线程处于饥饿状态

  • 要是临界区可以任意长怎么办?

    • 无法确定响应中断所需的时间(可能存在硬件影响)
  • 要小心使用

    • 在不能不使用的情况下才用,适用于临界区很小的情况

在多CPU的情况下存在一定局限性,无法解决互斥问题。

实现二:基于软件的解决方案

image-20240822234711887

在进入区和退出区,提供一些共享变量的修改(设置和判断),来同步他们的行为。

第一次尝试

image-20240822234736068

  • 满足 “忙则等待”
  • 有时不满足 “空闲则入”
    • \(T_i\)不在临界区,\(T_j\)想要继续运行,但是必须等待\(T_i\)进入过临界区后

第二次尝试

image-20240822234913947

  • 满足 “空闲则入”(含单个进程连续进入和多线程交替进入)
  • 不满足 “忙则等待”(两个线程同时判断对方未占用,随后同时设置自身flag,随后同时进入)

第三次尝试

image-20240822234936849

  • 满足 “忙则等待”
  • 不满足 “空闲则入”(两个同时判断对方已设置flag,进而都不进入临界区)

两进程间互斥:Peterson算法

满足进程\(T_i\)\(T_j\)之间互斥的经典的基于软件的解决方法(1981年)

  • 共享变量

    • int turn; // 指示该谁进入临界区
    • boolean flag[]; // 指示进程是否准备好进入临界区
  • 进入区代码

    flag[i] = TRUE;
    turn = j;
    while (flag[j] && turn == j);
    
  • 退出区代码

    flag[i] = FALSE;
    

注意:与前面第三种方法有什么区别?【都是先设置自身,再判断对方】

关键是两个进程同时设置一个变量turn

  • 如果另一个进程此时不访问(没有设置flag[j]):可以进入,while的第一个条件不满足

  • 如果另一个进程同时也在访问:由于对turn的操作是往存储单元写数据,两个线程并发写时,必然有先后之分。

    • 后写入turn的进程,while的两个条件均满足,无法进入临界区

    • 先写入turn的进程,while的第二个条件不满足,可以进入临界区,执行完后,在退出区将自身flag[i]置为false

      此时,后写入turn的进程while的第一个条件不再满足,可以进入临界区

进程\(T_i\)的算法

do {
	flag[i] = true;
	turn = j;
	while (flag[j] && turn == j);
	//CRITICAL SECTION
	flag[i] = false;
	//REMAINDER SECTION
} while (true);

上述算法能够满足互斥、前进、有限等待三种特性;可用反证法来证明。

过渡:Dekkers算法

有了两个进程间的互斥访问算法(Peterson算法)后,能否从中得到多个进程互斥访问共享资源的算法呢?

首先将Peterson算法进行改写,得到Dekkers算法:

进程\(T_i\)的算法

flag[0] := false flag[1] := false turn:= 0 // or 1
do {
	flag[i] = true;
	while flag[j] == true {
		if turn != i {
			flag[i] := false
			while turn != i {}
			flag[i] := true
		}
	}
	//CRITICAL SECTION
	turn := j
	flag[i] = false;
	//REMAINDER SECTION
} while (TRUE);

N进程间互斥:Eisenberg和McGuire算法

image-20240822235418974

N进程间互斥:Bakery算法

人为产生一个顺序:

  • 进入临界区之前,进程接收一个数字
  • 得到的数字最小的进入临界区
  • 如果进程 \(P_i\)\(P_j\)收到相同的数字,那么如果\(i < j\)\(P_i\)先进入临界区,否则 \(P_j\)先进入临界区
  • 编号方案总是按照枚举的增加顺序生成数字

基于软件方法的总结

  • 复杂

    • 需要两个进程的共享数据项
  • 需要忙等待

    • 浪费CPU时间
  • 没有硬件保证的情况下无真正的软件解决方案

    • Peterson算法需要原子的LOAD和STORE指令

实现三:更高级的抽象方法

基于硬件的同步原语来实现的同步方法

  • 硬件提供了一些同步原语

    • 像中断禁用,原子操作指令等

    • 大多数现代体系结构都这样

  • 操作系统提供更高级的编程抽象来简化并行编程

    • 例如:锁,信号量
    • 从硬件原语中构建

锁(lock)

  • 锁(lock) 是一个抽象的数据结构

    • 一个二进制状态(锁定,解锁),两个原子操作

    • Lock::Acquire():锁被释放前一直等待,然后得到锁

    • Lock::Release():锁释放,唤醒任何等待的进程

  • 使用锁来编写临界区

    • 前面的例子变得简单起来:

      lock_next_pid->Acquire();
      new_pid = next_pid++;
      lock_next_pid->Release();
      

原子操作指令

  • 大多数现代体系结构都提供特殊的原子操作指令

    • 通过特殊的内存访问电路
    • 针对单处理器和多处理器
  • 测试和置位(Test-and-Set)指令

    • 从内存中读取值

    • 测试该值是否为1(然后返回真或假)

    • 内存值设置为1

      • 当测试值为0,为置位操作
      • 当测试值为1,为测试操作

      伪码如下:

      boolean TestandSet(boolean *target) 
      {
      	boolean rv = *target;
      	*target = true;
      	return rv;
      }
      
  • 交换指令(exchange)

    • 交换内存中的两个值

      伪码如下:

      void Exchange(boolean *a,boolean *b) 
      {
      	boolean tmp = *a;
      	*a = *b;
      	*b = tmp;
      }
      
    • 使用这一原子指令,可使得两个进程同时调用fork()时,能正确执行pid++的操作

基于TS指令实现自旋锁(spinlock)

image-20240823174816038

该方法存在进程的忙等待情况,该如何改进呢?

无忙等待锁

使处于忙等的进程睡眠,在临界区执行完的进程将睡眠的进行唤醒。

image-20240823174835396

如果临界区执行时间短,选择忙等方式;如果临界区执行时间长,选择无忙等待方式。

基于Exchange指令实现:

  • 共享数据(初始化为0)

    int lock = 0;
    
  • 线程\(T_i\)

    int key;
    do {
    	key = 1;
    	while (key == 1) exchange(lock, key);
    	//critical section
    	//lock = 0;//个人觉得这样写不对,应该保证此时对lock的写操作为原子操作,如下:
        exchange(lock,key);//此时key为0
    	//remainder section
    } while (1);
    

    其思路为,进入区代码判断lock是不是1,如果是,那么exchange的结果将使得key依然为1,此时会持续等待;而当lock为0时,exchange结果将使得key为0,此时立刻进入临界区,在临界区结束后,将lock置为0。

原子操作指令锁的特征

优点

  • 适用于单处理器或者共享主存的多处理器中任意数量的进程同步(不论多少个进程同步,其形式都是简单的,不会像软件方法一样进程越多越复杂)

  • 简单并且容易证明

  • 支持多临界区

缺点

  • 忙等待锁会消耗处理器时间

  • 可能导致饥饿

    • 进入临界区时,进程会通过while循环来不停判断来抢lock【靠竞争实现】,由于多个进程在随机抢lock,可能导致某个进程一直抢不到,导致饥饿
  • 死锁

    • 拥有临界区的低优先级进程,被高优先级进程抢占而失去处理器;
    • 请求访问临界区的高优先级进程获得处理器并等待临界区(采用忙等待方法时始终占据处理器);
    • 此时,两个进程互相等待,进入死锁。

基本同步方法总结

image-20240823175602494

锁是更高等级的编程抽象

  • 互斥可以使用锁来实现

  • 通常需要一定等级的硬件支持(比如:原子操作的指令)

常用的三种实现方法

  • 禁用中断(仅限于单处理器)

  • 软件方法(复杂)

  • 原子操作指令(常用)(单处理器或多处理器均可)

可选的实现内容

  • 有忙等待

  • 无忙等待

信号量

Why Need信号量

引入锁机制后,能解决并发问题中竞争条件(竞态条件)对资源的争夺,解决互斥的问题。

但我们还需要解决同步的问题,以及一些场景下需要多个进程进入临界区的情况(例如,多个进程读临界区,这是应该被允许的)。

此时,只是用锁机制是不够的。

同步的实现需要高层次的编程抽象的实现和底层硬件支持。

信号量是与锁机制在同一个层次上的编程方法。

互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。

同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

image-20240823180756655

信号量概念

信号量是操作系统提供的一种协调共享资源访问的方法。用信号量来表示一类系统资源,而信号量的取值表示该资源的数量

软件同步是平等进程间的一种同步协商机制。而信号量是OS提供的,其地位高于进程。

有了管理者之后,协调将变得容易。

信号量由Dijkstra在20世纪60年代提出。

信号量是早期操作系统中常用的主要同步机制(如Unix),但目前较少使用。

  • 信号量(semaphore)是一种抽象数据类型

    一个整形变量(sem)和两个原子操作

    • P ( )【荷兰语Prolaag,减少】 :sem 减 1,如果sem<0,等待,否则继续

    • V ( )【荷兰语Verhoog,增加】:sem 加 1,如果sem≤0,唤醒一个等待的 P

      很多人问,V 操作 中 sem <= 0 的判断是不是写反了?
      没写反,我举个例子,如果 sem = 1,有三个线程进行了 P 操作:

      • 第一个线程 P 操作后,sem = 0;
      • 第二个线程 P 操作后,sem = -1;
      • 第三个线程 P 操作后,sem = -2;

      这时,第一个线程执行 V 操作后, sem 是 -1,因为 sem <= 0,所以要唤醒第二或第三个线程。

  • 信号量类似铁路

    • 初始化 2 个资源控制信号灯

      xinhaol

      进入临界区的进程执行P() 操作,当临界区已经有2 个进程时,信号量不够,变为红灯,再来的进程只能等待,直到某一个进程离开了临界区,变为绿灯,此时进程执行V() 操作,将等待的进程唤醒,进入临界区。

信号量特性

  • 信号量是被保护的整型变量

    • 初始化完成后,唯一改变一个信号量的值的办法是通过P() 和V()
    • PV操作的原子性由操作系统保证(操作系统的优先级高于用户进程,可以保证PV操作不会被用户进程打断)
  • P ( ) 能够阻塞,V ( )不会阻塞

    • P操作有可能由于没有资源,而使进程阻塞
    • V操作只会释放资源,因此不会造成进程阻塞
  • 我们通常假定信号量是 “公平的”

    • 线程不会被无限期的阻塞在P操作上(一般会设置最大等待时间,超过则退出)
    • 假定信号量等待按照先进先出排队

自旋锁(Spinlock)能否是FIFO类型?

不能。自旋锁靠进程竞争获得,因此无法保证获得的顺序。【自旋锁可能导致进程饥饿】

信号量实现

image-20240823180229694

注意:与软件方法有什么区别?【为什么这里可以简单的使用sem加减、继而判断的方法?】

关键在于,软件方法中sem加减与判断之间可能中断,而在信号量机制中,由操作系统保证不会中断。

信号量的分类

  • 两种类型信号量

    • 二进制信号量:可以是 0 或 1

    • 资源信号量:可取任何非负值

    • 两者等价(基于一种可以实现另一种)

  • 信号量可以用在 2 个方面

    • 互斥(实现临界区的互斥访问)
    • 条件同步(调度约束:实现线程间的事件等待)、死锁与进程通信

用信号量实现临界区的互斥访问

每类临界资源设置一个信号量,初值设为1。

mutex = new Semaphore(1);

mutex->P();
...
Critical Section;
...
mutex->V();

使用说明:

  • 进程使用P操作获取信号量,P操作后信号量≥0则进入,否则阻塞;
  • 进程使用完临界区后V操作使信号量+1,如果此时信号量≤0,说明有其他进程在等待,此时需唤醒一个阻塞中的进程。

使用要求:

image-20240823214825635

  • 如果不申请就释放:导致多个进程同时进入临界区
  • 如果只申请不释放:导致临界区空但无进程能进入

用信号量实现条件同步

每个条件同步设置一个信号量,初值为0。

保证线程B的X操作后,线程A的M操作才能进行。

  • 如果B先执行完X,线程A进行P操作后信号量为0,此时可以直接执行;
  • 如果A先执行完M,进行P操作后信号量为-1,此时阻塞,进入等待状态,直到B执行完X后执行V操作,使信号量为0,并唤醒A。

image-20240823215153361

condition = new Semaphore(0);

// Thread A
...
condition->P(); // 等待线程B某一些指令完成之后再继续运行,在此阻塞
...

// Thread B
...
condition->V(); // 信号量增加唤醒线程A
...

实际例子:生产者消费者问题

  • 一个线程等待另一个线程处理事情的情况

    • 比如生产东西或消费东西(生产者消费者模式)
    • 互斥(锁机制)是不够的
  • 例如:有界缓冲区的生产者-消费者问题

    • 一个或者多个生产者产生数据将数据放在一个缓冲区里

    • 单个消费者每次从缓冲区取出数据

    • 在任何一个时间只有一个生产者或消费者可以访问该缓冲区

      image-20240823180609498

  • 问题分析:正确性要求如下

    • 在任何一个时间只能有一个线程操作缓冲区(互斥访问)【相当于临界区】

    • 当缓冲区为空时,消费者必须等待生产者(调度/条件同步约束),有数据后才能读【等待1】

    • 当缓存区满,生产者必须等待消费者(调度/条件同步约束),有空间后才能写数据【等待2】

  • 用信号量描述约束:每个约束用一个单独的信号量

    • 二进制信号量互斥【临界区需要】
    • 一般信号量fullBuffers【等待1需要】
    • 一般信号了emptyBuffers【等待2需要】
class BoundedBuffer {
	mutex = new Semaphore(1);
	fullBuffers = new Semaphore(0);  // 说明缓冲区初始为空
 	emptyBuffers = new Semaphore(n); // 当前生产者可以往buffer中存入n个数据(size)
}
// 生产者 添加数据
BoundedBuffer::Deposit(c){
	emptyBuffers->P();  //1P
	mutex->P();
	//Add c to the buffer;
    //...
	mutex->V();
	fullBuffers->V();   //2V
}
// 消费者 取出数据
BoundedBuffer::Remove(c){
	fullBuffers->P();   //2P
	mutex->P();
	//Remove c from buffer; 
    //...
	mutex->V();
	emptyBuffers->V();  //1V
}

【问题】P、V操作的顺序有影响吗?

如果先执行mutex->P()再执行emptyBuffers->P()fullBuffers->P()会怎样?

【答案】会带来死锁。

由于先申请了互斥访问,占用了临界区,而此时再发现缓冲区为空或满时,需要对方访问临界区才能解决,但本进程占用了临界区,缓冲区无法被对方访问,造成死锁。

使用信号量的困难

  • 读/开发代码比较困难

    • 程序员必须非常精通信号量
  • 容易出错

    • 使用的信号量已经被另一个线程占用

    • 忘记释放信号量

  • 不能够处理死锁问题

    • 必须在写程序时予以解决

为了解决这一困难,引入管程。

管程

Why Need管程

为了改进信号量在处理临界区时面临的一些问题。例如,在生产者、消费者问题中:

  • 信号量的P、V操作分散在生产者、消费者(两个不同进程)中;
  • 这导致P、V操作的配对困难;
  • 将P、V操作集中到一起处理,提出“管程”这一方法。
    • 为了实现管程方法,要增加条件变量,这是在管程内部使用的一种同步机制。

image-20240823221250188

管程的概念

管程是一种用于多线程互斥访问共享资源的程序结构。

  • 采用面向对象方法,将P、V操作集中到一起,简化线程间的同步控制;
  • 任一时刻最多只有一个线程执行管程代码;(与临界区相同)
  • 正在管程中的线程可临时放弃管程的互斥访问,等待时间出现时恢复(与临界区不同,临界区中执行的线程,必须执行到退出临界区,才能退出临界区的互斥访问)

管程的使用

  • 在要同步的对象/模块中,收集共享的数据
  • 定义对这些共享数据的访问方法

有了管程机制后,在其他部分使用共享变量时,不再需要使用同步、互斥等操作。

管程的组成

image-20240823223112122

一个锁:将其视为临界区,在入口处加一个互斥访问的锁

  • 入口队列处只允许一个线程在管程中访问(与临界区相同)
  • Lock::Acquire():等待直到锁可用,然后抢占锁
  • Lock::Release():释放锁,唤醒等待者如果有

0或者多个条件变量:需要共享资源时,必须得到相应的条件变量,才可以使用管程内互斥的操作(管程的操作成员函数)

  • 0个时,等同于临界区

  • ≥1个时,管程特有(不同于临界区),用于管理共享数据的并发访问

  • 条件变量是管程内的等待机制

    • 进入管程的线程因为资源占用而进入等待状态(等在条件变量的等待队列上)
    • 每个条件变量对应一种等待原因,对应一个等待队列
  • 两个操作

    • Wait()等待操作
      • 将自己阻塞在管程内部的等待队列中
      • 允许另一个线程进入管程:唤醒一个等待者,或者放弃管程的互斥访问
    • Signal()释放操作
      • 将等待队列中的一个线程唤醒
      • 如果等待队列为空,则为空操作

条件变量的实现

image-20240823181222928

  • 条件变量有一个整型变量和一个等待队列
    • 与信号量机制不同,信号量的初值与资源数一致,而管程中的条件变量初值为0
    • 正数表示有队列处于等待状态

【TODO】这里的numWaiting是指入口队列吗?还是管程内部的等待队列?应该是内部的等待队列

代码实现:

class Condition {
	int numWaiting = 0;
	WaitQueue q;
}

Condition::Wait(lock) {
	numWaiting++;
	//Add this thread t to q;
    //...
	release(lock);
	schedule(); // need mutex
    //有了调度后,就可以切换到另外的进程来访问
	require(lock);
}

Condition::Signal() {
	if (numWaiting > 0) {
		//Remove a thread t from q;
        //...
		wakeup(t); // need mutex
		numWaiting--;
	}
}

管程解决生产者-消费者问题

image-20240823181257447

  • 两个条件变量:notFullnotEmpty
  • 一个入口的锁:lock(一个入口等待队列)
  • 管程内部的值:count(用来表示缓冲区中数据的数目)【这里千万不能跟上一节中的int变量numWaiting混淆,那里是介绍条件变量(也即此处的notFullnotEmpty)的实现机制,是条件变量这个类中的值】

管程将生产者、消费者封装成管程内部的函数,其他位置使用时,只需要调用这两个函数即可。

  • 生产者函数:Deposit
  • 消费者函数:Remove

【问题】在信号量机制中,必须先检查信号量状态,再申请互斥操作,否则会出现死锁。在管程中,不会出现这一问题吗?

【答案】不会。因为管程机制中,内部检查信号量状态不满足时,还可以放弃对管程的互斥访问,而等待在管程内部条件变量的等待队列中。

与信号量机制相比,管程机制将P、V操作集中到一个模块【信号变量类的对象】中,从而简化和降低同步机制的实现难度。

同时,进入管程的进程可以临时放弃互斥访问权,所使用的函数也已封装成管程类内部的函数。

实现代码:

class BoundedBuffer {
    ...
	Lock lock;
	int count = 0; // buffer为空
	Condition notFull, notEmpty;
};

BoundedBuffer::Deposit(c) {
	lock->Acquire(); // 管程的定义:只有一个线程能够进入管程
	while (count == n)
		notFull.Wait(&lock); // 1W,缓冲区满则等待
	//Add c to the buffer;
    //...
	count++;
	notEmpty.Signal();       // 2S
	lock->Release();
}

BoundedBuffer::Remove(c) {
	lock->Acquire();
	while (count == 0)
		notEmpty.Wait(&lock);// 2W
	//Remove c from buffer;
    //...
	count--;
	notFull.Signal();        // 1S
	lock->Release();
}

管程条件变量的释放处理方式

image-20240823181326095

  • Hansen管程,使当前正在执行的进程更优先【更高效,实际系统中使用】
  • Hoare管程,使等待中的进程更优先【更合理,教材中使用】

【问题】为什么会一个使用while,一个使用if?

【答案】

image-20240823181334888

经典同步问题

本节讨论使用信号量和管程机制来解决经典的同步问题。

哲学家就餐问题

image-20240823181929337

方案1:直观的思考,使用信号量

image-20240823181947387

大部分情况下没问题,但在极端情况下,如果5个人同时拿起左边叉子,大家都准备拿起右边叉子时,出现死锁。

方案2:稍改进后的方案

image-20240823182006756

问题在于,可能循环:大家同时拿起左边叉子,又同时放下,如此循环。造成活锁。

方案3:较为极端的方案

当无法确保资源能可靠的互斥访问时,最差的方案可以将所有资源打包,同一时间只有一个进程可以获取这个资源简单的互斥访问方案

image-20240823182047887

虽然解决了死锁问题,但还是存在缺点:

  • 它把就餐(而不是叉子)看成是必须互斥访问的临界资源,因此会造成(叉子)资源的浪费;

  • 从理论上说,如果有 5 把叉子,应允许 2 个不相邻的哲学家同时就餐。

方案4:等待时间随机变化的方案

image-20240823182028646

由于时间随机,存在很多不确定性,并不完美。

方案5:防止死锁的方法

image-20240823232915154

正确的解决方案

1、人的思路:要么不拿,要么就拿两个叉子。

image-20240823182227362

2、计算机程序怎么解决?

不能浪费CPU时间,而且线程之间要相互通信。

image-20240823182240425

3、怎么样来编写程序?

① 必须有数据结构,来描述每个哲学家当前状态;

② 该状态是一个临界资源,对它的访问应该互斥地进行;【互斥访问】

③ 一个哲学家吃饱后,可能要唤醒邻居,存在同步关系。【线程同步】

注意,我们考虑的对象,也就是共享资源,是哲学家的状态,而不是叉子的状态。

image-20240823233514315

一个哲学家所有操作的实现:

image-20240823233552764

S2-S4拿叉子的实现:

注意,因为涉及到对共享状态量的访问,所以需要上锁。

image-20240823233622950

其中,看看能不能拿两把叉子的操作:

image-20240823233638348

注意,这里的V操作和之前的P操作对应上了。

image-20240823233654520

S6-S7放下叉子的实现:

注意,因为涉及到对共享状态量的访问,所以需要上锁。

image-20240823233717918

代码实现:

// 1.必须有一个数据结构,来描述每个哲学家的当前状态;
#define N 5                 // 哲学家个数
#define LEFT i              // 第i个哲学家的左邻居
#define RIGHT (i + 1) % N   // 第i个哲学家的右邻居
#define THINKING 0			// 思考状态
#define HUNGRY   1 			// 饥饿状态
#define EATING   2 			// 进餐状态
int state[N];				// 记录每个人的状态
// 2.该状态是一个临界资源,对它的访问应该互斥地进行
semaphore mutex;			// 互斥信号量,初值1
// 3.一个哲学家吃饱后,可能要唤醒邻居,存在着同步关系
semaphore s[N];				// 同步信号量,初值0

// 函数philosopher的定义
void philosopher(int i) 	// i的取值:0~N-1
{
    while (TRUE) 			// 封闭式循环
    {
        think();			// S1:思考中...
        take_forks(i);		// S2-S4:拿到两把叉子或被阻塞
        eat();				// S5:吃面条中...
        put_forks(i);		// S6-S7:把两把叉子放回原处
    }
}

// 函数take_forks的定义
// 功能:要么拿到两把叉子,要么被阻塞起来。
void take_forks(int i)				// i的取值:0到N-1
{
	P(mutex);						// 进入临界区
	state[i] = HUNGRY;				// 我饿了!
	test_take_left_right_forks(i);	// 试图拿两把叉子
	V(mutex);						// 退出临界区
	P(s[i]);						// 没有叉子便阻塞
}

// 函数test_take_left_right_forks的定义
void test_take_left_right_forks(int i)	// i取0到N-1
{
	if (state[i] == HUNGRY &&		// i:我自己,or其他人
		state[LEFT] != EATING &&
		state[RIGHT] != EATING)
	{
		state[i] = EATING;	// 两把叉子到手
		V(s[i]);			// 通知第i人可以吃饭了(通知自己)
	}
}

// 函数put_forks的定义
// 功能:把两把叉子放回原处,并在需要的时候唤醒左右邻居。
void put_forks(int i)	// i的取值:0到N-1
{
	P(mutex);				// 进入临界区
	state[i] = THINKING;	// 交出两把叉子
	test_take_left_right_forks(LEFT);	// 看左邻居能否进餐
	test_take_left_right_forks(RIGHT);	// 看右邻居能否进餐
	V(mutex);							// 退出临界区
}

读者-写者问题

  • 动机

    • 共享数据的访问
  • 两种类型的使用者

    • 读者(不修改数据)

    • 写者(读取和修改数据)

  • 问题的约束

    • “读-读”允许:允许同一时间有多个读者
    • “读-写”互斥:
      • 当没有写者时,读者才能访问数据
      • 当没有读者时,写者才能访问数据
    • “写-写”互斥:在任何时候只有一个写者
    • 在任何时候只能有一个线程可以操作共享变量
  • 信号量来描述约束

    • 信号量WriteMutex

      • 控制读写互斥、写写互斥
      • 初始化为1

      与临界区相似、但不同:此处允许多个读者访问

    • 读者计数Rcount

      • 初始化为 0(当前读者个数)
    • 信号量CountMutex

      • 保护读者计数的互斥修改(同一时间只能有一个进程修改读者计数)
      • 初始化为 1

优先策略

  • 读者优先策略
    • 只要有读者读,后面的读者持续进入,写者等待
    • 如果读者持续进入,写者饥饿
  • 写者优先策略
    • 只要有写者就绪,写者尽快执行写操作
    • 如果写者持续进入,读者饥饿
  • 公平策略
    • 读者就绪,后来的写者不能进入
    • 写者就绪,后来的读者不能进入

设计1:读者优先设计,信号量实现

只要有一个读者处于活动状态, 后来的读者都会被接纳。如果读者源源不断的出现,那么写者使用处于阻塞状态。【后来的读者,早于先来的写者进行读操作】

image-20240823234706878

image-20240823181650271

// Writer
sem_wait(WriteMutex);
write;
sem_post(WriteMutex);

// Reader
sem_wait(CountMutex);
if (Rcount == 0)
	sem_wait(WriteMutex); // 确保后续不会有写者进入
++Rcount;
read;
--Rcount;
if(Rcount == 0)
	sem_post(WriteMutex); // 全部读者全部离开才能唤醒写者
sem_post(CountMutex);

设计2:写者优先设计,管程实现

一旦写者就绪,那么写者会尽可能的执行写操作。如果写者源源不断的出现的话,那么读者就始终处于阻塞状态。

两个基本方法:

image-20240823181723986

// Writer
Database::Write() {
	Wait until readers/writers;
	write database;
	check out - wake up waiting readers/writers;
}
// Reader
Database::Read() {
	Wait until no writers;
	read database;
	check out - wake up waiting writers;
}

管程的状态变量:

image-20240823181747319

// 全局变量
AR = 0; // # of active readers
AW = 0; // # of active writers
WR = 0; // # of waiting readers
WW = 0; // # of waiting writers
Condition okToRead;
Condition okToWrite;
Lock lock;

其中,ARAW只会有一个大于0,其余都有可能大于0。

读者具体实现:

image-20240823181810741

写者优先的体现:

  • while ((AW + WW) > 0),有正在写的和等待写的,则读者就要等待。
  • if (AR == 0 && WW > 0),没有正在读的读者,以及有等待写的时,唤醒写者。
// Reader
Public Database::Read() {
	// Wait until no writers;
	StartRead();
	read database;
	// check out - wake up waiting writers;
	DoneRead();
}

Private Database::StartRead() {
	lock.Acquire();
	while ((AW + WW) > 0) { // 关注等待的Writer,体现出写者优先
		WR++;
		okToRead.wait(&lock);
		WR--;
	}
	AR++;
	lock.Release();
}

Private Database::DoneRead() {
	lock.Acquire();
	AR--;
	if (AR == 0 && WW > 0) { // 只有读者全部没有了,才需要唤醒
		okToWrite.signal();
	}
	lock.Release();
}

写者具体实现:

image-20240823181836270

写者优先的体现:

  • while ((AW + AR) > 0),有正在写的和正在读的,写者才等待,不等待等待写的。

  • if (WW > 0) {
    		okToWrite.signal();
    	}
    

    优先唤醒等待写的写者,没有等待写的写者时,才唤醒读者。

// Writer
public Database::Write() {
	// Wait until no readers/writers;
	StartWrite();
	write database;
	// check out - wake up waiting readers/writers;
	DoneWrite();
}

private Database::StartWrite(){
	lock.Acquire();
	while ((AW + AR) > 0) {
		WW++;
		okToWrite.wait(&lock);
		WW--;		
	}
	AW++;
	lock.Release();
}

private Database::DoneWrite() {
	lock.Acquire();
	AW--;
	if (WW > 0) {
		okToWrite.signal();
	}
	else if (WR > 0) {
		okToRead.broadcast(); // 唤醒所有reader 
	}
	lock.Release();
}

代码实现:

// Writer
public Database::Write() {
	// Wait until no readers/writers;
	StartWrite();
	write database;
	// check out - wake up waiting readers/writers;
	DoneWrite();
}

private Database::StartWrite(){
	lock.Acquire();
	while ((AW + AR) > 0) {
		WW++;
		okToWrite.wait(&lock);
		WW--;		
	}
	AW++;
	lock.Release();
}

private Database::DoneWrite() {
	lock.Acquire();
	AW--;
	if (WW > 0) {
		okToWrite.signal();
	}
	else if (WR > 0) {
		okToRead.broadcast(); // 唤醒所有reader 
	}
	lock.Release();
}

总结

很多同步互斥问题都有难度,在本次课程的两个实现中,我们的方法流程如下:

① 一般人怎么解决这个问题,写出伪代码;

② 伪代码化为程序流程;

③ 设定状态变量,保证互斥或同步,使用信号量和管程等哪种手段;

④ 逐步细化方式,完成整个处理过程。一般来讲,申请资源、释放资源都是匹配的。

死锁

死锁概念

由于竞争资源或者通信关系,两个或更多线程在执行中出现,永远相互等待只能由其他进程引发的事件。

生活中的示例:

image-20240824143140872

可能出现的问题:

  • 死锁:对向行驶的车辆在桥上相遇
  • 饥饿:某一方向持续有车流,造成另一个方向的车无法通过

资源、资源访问和分配

系统资源分类

系统中存在多种类型的资源(CPU、内存、I/O等),每类资源可能有多个实例。

资源的分类如下:

  • 可重用资源(Reusable Resource)

    • 在一个时间只能有一个进程使用且不能被删除
    • 进程释放资源后,可由其他进程重用
    • 示例:
      • 硬件:处理器、I/O通道、主和副存储器、设备等
      • 软件:文件、数据库和信号量等数据结构
    • 如果每个进程拥有一个资源并请求其他资源,可能出现死锁
  • 消耗资源(Consumable Resource)

    • 有资源创建和销毁的过程
    • 示例:
      • 在I/O缓存区的中断,信号,消息,信息
    • 如果通信双方互相等待对方的消息,可能出现死锁

进程访问资源的流程

  • 请求/获取:进程申请系统中空闲的资源
  • 使用/占用:进程占用资源
  • 释放:资源由占用变成空闲

资源分配图

资源分配图是描述资源和进程的分配和占用关系的有向图。

image-20240824144341807

image-20240824144316358

规则如下:

  • 如果图中不包含循环

    • 没有死锁。

      image-20240824144655616

  • 如果图中包含循环

    • 如果每个资源类只有一个实例,那么死锁。

      image-20240824144645887

    • 如果每个资源类有几个实例,不会死锁。

      image-20240824144636326

出现死锁的必要条件

死锁出现一定会出现以下四个条件,但是出现以下四个条件不一定死锁:

  • 互斥:在一个时间只能有一个进程使用资源

  • 持有并等待:进程保持至少一个资源正在等待获取其他进程持有的额外资源

  • 无抢占:一个资源只能被进程资源主动释放

  • 循环等待:

    image-20240824144808194

死锁的处理方法分类

死锁有以下三种处理方式:

  • 死锁预防(Deadlock Prevention):确保系统永远不会进入死锁状态。【部分区域禁止任何形式的用火】
  • 死锁避免(Deadlock Avoidance):在使用前进行判断,只允许不会出现死锁的进程请求资源。【用火前进行严格的资格审查】
  • 死锁检测和恢复(Deadlock Detection & Recovery):在检测到运行系统进入死锁状态后,进行恢复。【预备好消防队随时灭火】

死锁的处理方:操作系统往往忽略死锁,而由应用进程处理。

死锁预防:限制申请方式,破坏死锁必要条件

死锁预防采用某种策略,限制并发进程对资源的请求,使系统在任何时刻都不满足死锁的必要条件。

针对死锁的必要条件,作以下限制:

  • 互斥

    • 将互斥的共享资源封装成可以同时访问的
      • 实例:打印机的缓冲区
  • 持有并等待

    • 进程请求资源时,要求它不持有任何其他资源

    • 意味着进程必须在一开始一次性申请并分配其需要的所有资源【相当于一次性拨付所有工程款】

    • 缺点:资源利用率低

  • 非抢占

    • 如果进程占有某些资源,并请求其他不能被立即分配的资源,则释放当前正占有的资源【单向桥梁两车相遇时,全部放弃对桥的使用而倒回,随后再申请】

    • 只有进程可以同时获得所有需要的资源时,才可以执行分配操作

  • 循环等待

    • 对所有资源类型进行排序,并要求每个进程按照固定的资源顺序进行申请。
    • 缺点:资源利用率低(可能先申请到的资源很久后才使用)

死锁避免:分配时判断避免死锁,可提高资源利用率

死锁避免利用额外的先验信息,在分配资源时判断是否会出现死锁,只在不会出现死锁时分配资源。【银行利用多方面的资信条件,判断能否提供贷款】

该方法依赖于先验知识的准确性。

具体做法如下:

  • 要求每个进程声明它可能需要的每个类型资源的最大数目

  • 限定提供与分配的资源数量,确保能满足进程的最大需求时,才能分配给该进程资源

如何得知进程所需的资源数目能否分配?

  • 动态检查的资源分配状态,以确保永远不会有一个环形等待状态(利用资源分配图)

当一个进程请求可用资源,系统必须判断立即分配是否能使系统处于安全状态,具体的检查方法:

  • 系统处于安全状态指:针对所有进程已占用的资源,存在安全的执行序列。

  • 序列 <P1,P2,...,Pn> 是安全的:

    image-20240824151228869

    在进程i之前所有已占用的资源与此时可用的资源之和,必须能满足进程i所需

    • 当前空闲资源就能满足,则直接分配
    • 如果当前空闲资源不能满足,但加上进程i之前所有已占用的资源能满足,则进程i必须等待它之前所有已占用资源的进程完成后,才能分配

安全与死锁之间的关系:

【形象地理解,银行判断出不能给予贷款的人(不安全),并不一定都不能按时还款(死锁),这就是不安全不一定死锁】

image-20240824151754397

  • 如果系统处于安全状态 => 无死锁

  • 如果系统处于不安全状态 => 可能死锁

  • 避免死锁:确保系统永远不会进入不安全状态

死锁检测和恢复

死锁检测和恢复算法有以下特点:

  • 允许系统进入死锁状态
  • 系统维护资源分配图
  • 周期性调用死锁检测算法来搜索途中是否存在死锁
  • 出现死锁时,用死锁恢复机制进行恢复

下面介绍两种常用的死锁处理算法。

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

银行家算法(Banker’s Algorithm)是一个死锁避免的著名算法,是由艾兹格 · 迪杰斯特拉在1965年为T. H. E系统设计的一种避免死锁产生的算法。

它以银行借贷系统的分配策略为基础,判断并保证系统的安全运行。

背景

在银行系统中,客户完成项目需要申请贷款的数量是有限的,每个客户在第一次申请贷款时要声明完成该项目所需的最大资金量,在满足所有贷款要求并完成项目时,客户应及时归还。

银行家在客户申请的贷款数量不超过自己拥有的最大值时,都应尽量满足客户的需要。

在这样的描述中,银行家就好比操作系统,资金就是资源,客户就相当于要申请资源的进程。

数据结构

image-20240824152427933

安全状态判断

把现在可用的资源复制一下,作为进行判断的工作场所。

在此状态下,是否可用满足当前已分配资源线程的总需求。

如果能满足,表明可用在用户使用完成后回收资源。

反之,不安全。

image-20240824152842162

简单讲,就是能找到一个安全序列,使得资源能满足所有线程的需要。

银行家算法及示例

image-20240824153203705

示例1如下:

image-20240824153325305

此时,可用分配给T2一个R3吗?

【此处对应安全状态判断中的第2步】可以看到,T2的请求资源只剩下一个R3,而当前可用中也有一个R3,先分配给T2,随后即可回收分配给T2的所有资源。因此,先给T2分配一个R3。

image-20240824153809914

此时可以发现,当前的可用资源满足任一个线程等待分配的资源需求,因此,其余线程的分配需求也是可以满足的。

示例2如下:

image-20240824154014082

此时,线程T1请求分配R1、R3资源各一个实例:

image-20240824154105605

分配后,系统中的可用资源无法满足任一个进程的后续分配【没办法使任何一个进程分配到足够的资源以运行到退出】,因此,不能分配。

此时,必须拒绝这一请求,否则系统将不安全。

【用人话说,就是先假设满足你的需求,然后构造一个分配后的状态,用安全状态判断算法来校验这种情况下能否找出一个安全序列,如果有,则可以给你分配,反之,不能分配。】

死锁检测和恢复算法

算法特点

死锁检测和恢复算法有以下特点:

  • 允许系统进入死锁状态
  • 系统维护资源分配图
  • 周期性调用死锁检测算法来搜索途中是否存在死锁
  • 出现死锁时,用死锁恢复机制进行恢复

死锁检测的数据结构

与银行家算法中的安全状态判断相比,没有最大资源请求量的判断,其余部分相似。

image-20240824155045600

死锁检测算法

image-20240824155201800

死锁检测算法需要\(O(m\times n^2)\)次检测系统是否处于死锁状态。

当系统中资源数量和线程数量较大时,周期性的检测将带来较大的负担。这是操作系统一般不处理死锁的原因。

示例1:

image-20240824155651714

示例2:

image-20240824155751188

死锁检测算法的使用

  • 死锁检测的时间和周期选择依据

    • 死锁多久可能会发生?【不能到系统无法运行再检测】

    • 多少进程需要被回滚?【回滚的进程太多消耗太大】

  • 资源图中可能有多个循环

    • 此时无法分辨出造成死锁的关键进程【找出关键进程可以减少需要进程的数目】

检测算法在实际的运行环境中很难使用,更多是用在开发阶段来判断系统是否正确。

死锁恢复:进程终止

终止进程:

  • 终止所有的死锁进程

  • 一次只终止一个进程,直到死锁消除

  • 终止进程的顺序应该是

    • 进程的优先级,优先级低的先终止

    • 进程运行了多久以及需要多少时间才能完成,已经运行时间久的先终止

    • 进程占用的资源

    • 进程完成需要的资源

    • 多少进程需要被终止,数目越小越好

    • 进程是交互还是批处理,一般保证用户的交互进程能够继续执行

如何终止?

  • 抢占进程所占用的资源

    • 选择一个成本最小的(最小成本目标)
  • 进程回退

    • 返回到一些安全状态,重启进程到安全状态
  • 可能会出现饥饿

    • 同一进程可能一直被选作被抢占者

进程通信

概述

进程通信也称为进程间通信(Inter-Process Communication, IPC),是进程间进行通信和同步的机制。

IPC提供两个基本操作:

不同的通信机制后面的参数和内容会不一样。

  • 发送操作:send (message) - 消息大小固定或者可变
  • 接收操作:receive (message)

进程通信的流程:

如果 P 和 Q 想通信,需要:

  • 在它们之间建立通信链路

  • 通过 send/recevie 交换消息

不同通信机制间,通讯链路的特征不同:

  • 物理链路(例如,共享内存,硬件总线)

  • 逻辑链路(例如,逻辑属性)

通信方式分类

image-20240824163916319

间接通信

间接通信:依赖操作系统内核完成的通信。

首先:在内核和通信间进程中建立相应的机构(如消息队列)以支持通信。

进程将消息发送到内核的消息队列中,另一个进程从消息队列中读出。

通信过程中,两个进程的生命周期可以不一样。(甚至A发信息时B可以还没创建,而B接收时A已经关闭)

具体实现:

  • 通过操作系统维护的消息队列实现进程间的消息接收和发送。

    • 每个消息队列都有一个唯一的ID【用来描述与哪个消息队列通信,某种角度上,间接通信是进程与内核之间的通信】

    • 只有它们共享了一个消息队列,进程才能够通信【可以通过操作系统内核的安全机制,保证两个进程之间通信的保密性】

  • 通信链路的属性

    • 只有进程共享一个共同的消息队列,才建立链路

    • 链接可以与许多进程相关联

    • 每对进程可以共享多个通信链路

    • 链接可以是单向或者双向

  • 通信流程

    • 创建一个新的消息队列

    • 通过消息队列发送和接收消息

    • 销毁消息队列【否则,内核中会存在多个无人使用的消息队列】

  • 基本通信操作

    • send(A, message) - 发送消息到队列 A

    • receive(A, message) - 从队列 A 接受消息

直接通信

直接通信:两个进程间直接建立通信(共享信道)。

两个进程必须同时存在,才能完成通信。

具体实现:

  • 进程必须正确的命名对方:

    • send(P, message)- 发送消息到进程 P

    • receive(Q, message) - 从进程 Q 接收信息

  • 通信链路的属性

    • 自动建立链路

    • 一条链路恰好对应一对通信进程

    • 每对进程之间只有一个链路存在

    • 链路可以是单向的,但通常是双向的

阻塞与非阻塞通信

进程通信可划分为阻塞(同步)或非阻塞(异步)通信。

  • 阻塞通信

    • 阻塞发送

      发送者在发送消息后进入等待,直到接收者成功收到。

    • 阻塞接收

      接收者在请求接收消息后进入等待,直到成功接收。

  • 非阻塞通信

    • 非阻塞发送

      发送者在消息发送后,可立刻进行其他操作。

    • 非阻塞接收

      没有消息发送时,接收者在请求接收消息后,接收不到任何消息。【收不到消息立刻返回,不会等待必须接收到消息】

二者各有其应用场合,需要选择合适的方式。

通信链路缓冲特征

进程发送的消息在链路上可能有三种缓冲方式:

  • 0 容量 - 0 message

    发送方必须等待接收方

  • 有限容量 - n messages的有限长度

    如果队列满,发送方必须等待

  • 无限容量 - 无限长

    发送方不需要等待

下面是进程通信机制的具体实现。

信号

信号的概念

将中断(CPU执行指令中的异常处理机制)借鉴到进程通信中来。进程执行中有正常的执行流程,当发生异常情况时,如何处理?——使用信号。

信号是进程间的软件中断通知和处理机制。

例如,Ctrl+C可以使进程退出,但在代码中,并没有对Ctrl+C进行处理的代码,何处实现的?

操作系统编译用户程序时,会缺省地加上对信号的处理例程。其中,包含Ctrl+C的处理。如果用户想指定自己的信号处理例程,则需在程序中给出相应的实现。

常见的信号:SIGKILL、SIGSTOP、SIGCONT等

信号的处理机制有以下几种情况:

  • 捕获(Catch):进程执行时的信号处理例程由用户指定,每一个进程有自己不同的处理方式,指定信号处理函数被调用。

  • 忽略(Ignore):执行操作系统的默认操作

    例如:进程终止与进程挂起。

  • 屏蔽(Mask):禁止进程接受和处理信号

    例如:登录程序按Ctrl+C是无法停下来的。

    可能是暂时的(当处理同样类型的信号)

不足:传送的信息量小,只有一个信号类型,不能传输要交换的任何数据。

信号仅用来实现一种快速响应的机制。

信号的实现

image-20240824170902100

示例:

image-20240824161958194

  • 主函数前两行是注册信号处理函数
  • 功能:
    • Ctrl+C时:打印提示已禁用Ctrl+C
    • Ctrl+\时:退出该进程

管道

概念

管道是进程间基于内存文件的通信机制。【两个进程想通信,在内存中建立一个临时文件,将数据放在临时文件中】

  • 子进程从父进程继承文件描述符
  • 缺省的文件描述符: 0 stdin,1 stdout,2 stderr

进程不知道(或不关心!)另一端【间接通信机制】。

  • 可能从键盘,文件,程序读取
  • 可能写入到终端,文件,程序

与管道相关的系统调用

  • 读管道:read(fd, buffer, nbytes)

    创建完管道,即有一个文件描述符fd

    • scanf()是基于它实现的
  • 写管道:write(fd, buffer, nbytes)

    • printf()是基于它实现的
  • 创建管道:pipe(rgfd)

    • rgfd是2个文件描述符组成的数组

    • rgfd[0]是读文件描述符

    • rgfd[1]是写文件描述符

    利用继承的关系,在两个进程中,使用不同的文件描述符(一个读、一个写),实现两者的通信。

管道示例

% ls | more

Shell创建管道,创建两个进程子进程ls、more, 管道相当于是内存的一块buffer,对于ls来说是stdout,管道写端,对于more来说是stdin,管道读端。

image-20240824162017994

因为ls和more有一个共同的父进程Shell,所以这两个子进程可以继承父进程的一些资源,管道是以文件形式存在的。

消息队列

消息队列的概念

消息队列(Message Queues)是由操作系统维护的以字节序列为基本单位的间接通信机制。

image-20240824172337742

  • 每个消息(Message)是一个字节序列
  • 相同标识的消息按先进先出顺序组成一个消息队列

消息队列的系统调用

  • msgget(key, flags)

    • 获取消息队列标识
  • msgsnd(QID, buf, size, flags)

    • 发送消息
  • msgrcv(QID, buf, size, type, flags)

    • 接收消息
  • msgctl(…)

    进程结束时,其占用的资源会被释放。但消息队列是独立于进程的(方便后续进程读取该消息队列的内容等等),因此需要有专门的系统调用完成对消息队列的创建和删除。

    • 消息队列控制

共享内存

共享内存的概念

管道和消息队列可以理解为间接通信方式,共享内存是一种直接通信方式。

共享内存是把同一个物理内存区域同时映射到多个进程的内存地址空间的通信机制,【从某种角度说,是一种内存的共享机制】在最开始的时候就创建好了一块或者多块共享区域,使多个进程可以共享。

这一机制在进程与线程中是不同的:

  • 进程

    • 每个进程都有私有地址空间

    • 每个地址空间内,需要明确地设置共享内存段,才能实现内存的共享

  • 线程

    • 同一进程中的线程总是共享相同的内存地址空间,当通信双方是同一进程中的两个线程时,其具有天然的共享属性,不需要额外的机制。

优缺点:

  • 优点

    最快的方法,方便地共享数据

    • 一个进程写进去,另一个进程很快能读取
    • 不需要进行用户和内核间的切换
    • 没有系统调用的干预
    • 没有数据复制
  • 不足

    必须同步数据访问,但不提供同步。

    同步必须由程序员实现,使用额外的同步机制来协调。

    【仅靠共享内存无法实现完整的通信,必须加同步机制,防止一个进程还未写完,另一个进程已开始读】

共享内存的实现

image-20240824173351875

  • 将一块物理内存的区域,通过进程中的页表项,映射到两个进程。

    不同的页表项在进程地址空间里可以有相同或不同的逻辑地址,但其对应的物理地址是同一个。

共享内存系统调用

以下指令实现共享关系的建立,对共享数据的访问,只需要普通的读写指令即可,不需要系统调用。

  • shmget(key, size, flags)

    • 创建共享段
  • shmat(shmid, *shmaddr, flags)

    • 把共享段映射到进程地址空间
  • shmdt(*shmaddr)

    • 取消共享段到进程地址空间的映射
  • shmctl(…)

    • 共享段控制

必须注意,为保证数据的完整性,需要信号量等机制协调共享内存的访问冲突。

内存管理

计算机体系结构/内存层次

计算机中除了处理能力,还有存储能力。也即有一系列的基本存储介质,其中存储代码与数据。

计算机体系结构中约定了何处可以存放数据,不同的存放位置其容量、速度、价格等指标有显著差异。

为了合理化使用不同指标的存储介质,组成了一个内存层次结构。操作系统要对内存进行管理,最基本的是根据进程的需要分配存储空间,并在进程终止时回收这块空间。

计算机体系结构

image-20240824191330065

计算机中的存储介质:

  • CPU中:

    • 寄存器,容量非常小,大概几十到几百字节
    • 高速缓存:提高读写效率
  • 内存:

    • 最小访问单元:字节,8bit

      通常计算机是32位总线,牵扯到内存对齐,不能随意访问一个地址

内存分层体系

image-20240824191355099

CPU可以访问的内存包括两大类 : 寄存器 / cache(L1缓存 / L2缓存),Cache的使用由硬件控制,操作系统无法显式感知。

主存(物理内存)/ 磁盘(虚拟内存)主存是在运行程序时所需要保存的数据空间,而磁盘是用于持久化数据保存的数据空间。

内存的访问需要操作系统控制,当内存缺页时,还需要到外存中寻找。

从CPU寄存器到磁盘,读写速度不断降低,容量不断增大。

操作系统的内存管理

物理设备基本情况

  • 内存:以字节为单位访存,每个字节有自己的地址(物理地址)
  • 外存:以扇区编号,每个扇区512字节

内存管理的目标

在程序中,我们有若干个进程,各个进程有共同的地址空间(操作系统内核),而每个进程又有自己独立的地址空间。我们希望在编程时,各个进程间的地址空间不会互相影响(地址可以重叠)。

如何在现有的物理条件下,实现上述目标?

在二者间,设置存储管理单元(MMU)来完成物理地址到逻辑地址的转换。操作系统内核的地址空间往往位于内存的头部,而用户进程的地址空间,根据其所处状态的不同,可能在内存或外存中。

概括地讲,存储管理单元应该达到以下效果:

  • 抽象:逻辑地址空间(将物理地址空间抽象成逻辑地址空间)
    • 物理地址空间:主存和硬盘
    • 逻辑地址空间:运行的程序
  • 保护:独立地址空间(每个进程只能访问自己的地址空间)
  • 共享:访问相同内存(不能共享的话,操作系统内核代码要有多份,各自存在于不同进程的地址空间中,非常低效)
  • 虚拟化:更多的地址空间(物理地址空间不同,但每个进程的视角下,都是一致的地址空间。甚至可以有于物理内存的逻辑地址空间。)

image-20240824191448545

操作系统的内存管理方式

  • 重定位(relocation)

    在最早的操作系统中,直接使用总线上的物理地址来读写某个内存单元,在程序中见到的就是其物理地址。这带来了很大的局限性,导致程序只能在指定的物理机上运行。

    重定位可以整块搬移程序中的地址(段地址+偏移),那么只需要修改段地址,就可以实现在不同物理机上运行。

    为了实现重定位,程序与操作系统中都必须有响应的支持。

  • 分段(segmentation)

    在重定位中,一个进程分配的空间是一个连续的空间,不允许两个进程的地址空间交错。这带来了较大的限制。因为在程序中,我们将其地址空间分为数据、代码、堆、栈,这些部分是相对独立的。

    分段提供了将程序的地址空间分为几部分存储的能力。

  • 分页(paging)

    在分段中,仍然要求段内的地址空间连续。这个要求依然较高。

    分页是将内存分为最基本的单位【可以类比建筑房屋用的方砖】,从这一基本单位完成存储空间的构建。

    【问题】为什么不使用字节作为最基本的单位?

    【答案】字节的粒度太细,管理难度大,开销大。因此,要选取合适的基本单位大小。

  • 虚拟存储(virtual memory)

    在上述基础上,希望能将数据存储到硬盘上,外存与内存间的倒换由操作系统实现,希望看到一个逻辑的地址空间,甚至其大小大于物理内存。

    这一需求由虚拟存储实现。

    • 目前多数系统(如 Linux)采用按需分页虚拟内存

上述的这些内存管理方式的实现高度依赖于硬件,与MMU的结构、CPU能识别的页表结构等息息相关:

  • 必须知道内存架构(与计算机存储架构紧耦合)
  • MMU(内存管理单元):硬件组件负责处理CPU的内存访问请求

地址空间与地址生成

从程序中写的符号到物理总线上的地址,其转换过程是什么?

地址空间

  • 物理地址空间【总线上的地址】

    • 硬件支持的地址空间(address: \([0,MAX_{sys}\)])

    • 例如,4GB内存,其物理地址空间是从0 到 4G-1

    • 从存储单元的角度上将,这个地址是唯一的

  • 逻辑地址空间

    • 在CPU运行的进程所看到的地址空间(address: \([0,MAX_{prog}\)])

image-20240824191641177

【问题】进程中要访问的内存单元的地址从哪来?

【答案】根据一定的方法,从逻辑地址转换而来。

逻辑地址生成

逻辑地址生成过程

image-20240824191709086

  • .c file 函数名、变量名即逻辑地址。

  • .s file 汇编语言中更加贴近机器语言,但是依然用符号名字代替地址。

  • .o file 机器语言中,起始地址都是从0开始的,把变量名转换为地址。

    注意:如果程序中调用了另一个模块中的函数,在汇编完成后,当前程序并不知道另一个模块中函数的位置(地址)。在下一步(链接)执行后,将多个模块和函数库排成一个线性的序列,才能知道其他模块中函数的位置。

  • 链接把多个.o file变成一个单一的.exe file

    .exe file 中,地址已经做了全局的分布,不同的.o file程序的地址在单一程序中已经有各自的定义。

  • .exe file 通过loader放到内存中运行,会有一定的偏移量(重定位),所以程序依照偏移量来进行正确数据的访问和指令的操作。

逻辑地址生成时机

在实际应用中,地址的生成有以下时机:

  • 编译时

    例如,功能手机(不允许自行安装程序)由于功能是固定的,其地址也是确定的。

    • 假设起始地址已知
    • 如果起始地址改变,必须重新编译
  • 加载时

    例如,只能手机可以自行下载、安装程序,因此程序开发者无法得知程序会被加载到何地址运行。

    通常在可执行文件中,有一个重定位表,在加载时将其中的值改成绝对地址后,即可运行。

    • 如编译时起始位置未知,编译器需生成可重定位的代码(relocatable code)
    • 加载时,生成绝对地址
  • 执行时

    出现在使用虚拟存储的系统中。

    只有当执行到某一条指令时,才将其映射到内存中的某处,才能知道其地址。

    • 优势:执行时代码(在物理存储中的位置)可移动、灵活性高

      【对于前面两种情况,是不允许执行时移动的。例如,加载时生成逻辑地址后,如果因为地址空间不足移动了存储该段程序的位置,会导致重定向表中已生成的绝对地址指向错误。】

    • 需地址转换(映射)硬件支持

物理地址生成

image-20240824191746631

  • CPU根据指令,查找逻辑地址的物理地址在什么地方
  • 根据什么查找?MMU将逻辑地址映射到物理地址。
  • 谁生成映射关系?操作系统建立。

地址安全检查

image-20240824191807904

操作系统建立逻辑地址到物理地址的映射,这个关系可以放到内存中,由CPU进行缓存,加快访问过程。

  • 使应用程序在内存中正常执行,同时保证在内存中不同的应用程序之间不会相互破坏,因为操作系统会进行地址安全检查。

例子中是一个mov指令的执行过程,由于访问的是数据段,会进行段长度检查(如果偏移量超过段长度,则访问非法)。

操作系统可以设置起始(段基址,base)和最大(limit)逻辑地址空间。

连续内存分配

在没有其他支持的情况下,分配给进程的内存空间是连续的。给进程分配一块不小于指定大小的连续的物理内存区域,称之为连续内存分配。

在这种情况下,希望分配的位置有适当的选择,以提高利用效率,这带来一系列的动态分配算法。

而因为每个进程的执行时间不同,在部分进程终止后,会在内存中留下一部分碎片空间,对后续分配有影响,需要进行处理。

内存碎片问题

image-20240824204103315

  • 内存碎片

    • 空闲但不能利用的内存。

    • 外部碎片:分配单元之间的未被使用的内存

      • 两个进程地址空间之间的小的空闲内存,由于空间过小,进程申请的内存空间都比它大,导致无法使用
    • 内部碎片:分配单元内部的未被使用的内存

      • 由于分配单元的大小进行取整,导致的小的无法利用的空闲内存

        例如,需要510字节,但必须分配2的整数幂,分配了512字节,则有2个字节的内部碎片

动态分区分配

  • 当一个程序准许运行在内存中时,分配一个进程指定大小可变的(用户指定的)分区(块、内存块)

  • 分区的地址是连续的

image-20240824204151191

image-20240824214854963

当部分进程结束而释放分区后,再有进程需要分配时,操作系统必须维护这样一个数据结构:

  • 已分配的分区(位置、大小、分配给谁)
  • 空闲的分区(位置、大小)

不同的动态分配方式下,上述数据结构的组织形式是不同的。不同的组织形式下,分配和释放的开销是不同的。

分区的动态分配方式有以下三种 :

  • 最先适配:在内存中找到第一个比需求大的空闲块,分配给应用程序
  • 最佳适配:在内存中找到最小、最适合的空闲块,分配给应用程序
  • 最差适配:在内存中找到最大的空闲块,分配给应用程序

最先匹配(First Fit Allocation)策略

image-20240824204251543

原理 & 实现

  • 按地址顺序排序的空闲块列表
  • 分配时,搜索第一个合适的分区
  • 释放分区时,检查是否可以合并于相邻的空闲分区(若有)

优点

  • 简单
  • 在高地址空间有大块的空闲分区,不会将所有空间都切成小块,造成大块分区无法分配

缺点

  • 外部碎片

    每次分配时,只要大小不合适就切除一块适合要求大小的分区,容易产生大量的小的外部碎片

  • 分配大块时较慢

    低地址空间中外部碎片过多,造成为大块寻找空间时,需要较长的搜索时间

最佳匹配(Best Fit Allocation)策略

image-20240824204406008

为了避免分割大空闲块,最小化外部碎片产生的尺寸。

原理 & 实现

  • 空闲分区列表按照从小到大顺序排序
  • 分配需要寻找一个合适的分区
  • 释放时,查找并且合并临近【注意,是地址临近,不是大小临近】的空闲分区(如果找到),并调整空闲分区列表顺序【需要满足从小到大的顺序】

优点

  • 大部分分配的尺寸较小时,效果很好
    • 可避免大的空闲分区被拆分
    • 可减小外部碎片的大小
    • 相对简单

缺点

  • 外部碎片

    剩下的外部碎片越小,越难以利用

  • 释放分区较慢

    分区列表按大小顺序排列,当需要按地址临近合并空闲分区时,开销较大

  • 容易产生很多无用的微小碎片

最差匹配(Worst Fit Allocation)策略

image-20240824204500658

为了避免有太多微小的碎片。

原理 & 实现

  • 空闲分区列表按照从大到小顺序排序
  • 分配很快(选最大的分区)
  • 释放时,检查是否可与临近的空闲分区合并,进行可能的合并,并调整空闲分区列表顺序【需要满足从大到小的顺序】

优点

  • 中等大小的分配较多时,效果最好

    用掉的块比较大,方便分配,剩余部分也往往能利用(中等大小分配多,剩下的空间往往不是太小)

  • 避免出现太多的小碎片

缺点

  • 外部碎片

  • 释放分区较慢

    分区列表按大小顺序排列,当需要按地址临近合并空闲分区时,开销较大

  • 容易破坏大的空闲分区,因此后续难以分配大的分区

分配方式的区别

分配方式 第一适配分配 最优适配分配 最差适配分配
原理
&
实现
1. 按地址排序的空闲块列表
2. 分配需要寻找一个合适的分区
3. 重分配需要检查是否可以合并相邻空闲分区
1. 按尺寸从小到大排序的空闲块列表
2. 分配需要寻找一个合适的分区
3. 重分配需要检查是否可以合并相邻空闲分区
1. 按尺寸从大到小排序的空闲块列表
2. 分配最大的分区
3. 重分配需要检查是否可以合并相邻空闲分区
优势 简单 / 易于产生更大空闲块 比较简单 / 大部分分配是小尺寸时高效 分配很快 / 大部分分配是中尺寸时高效
劣势 产生外部碎片 / 分配大块时较慢 产生外部碎片 / 重分配慢 / 产生很多没用的微小碎片 产生外部碎片 / 重分配慢 / 易于破碎大的空闲块以致大分区无法被分配

三种分配方式并无优劣之分,因为我们无法判断内存请求的大小。

碎片整理

Why Need碎片整理

可以看到的是,三种分区动态分配的方式都会产生外部碎片,因此我们可以对碎片进行一定的整理来解决碎片问题。

当新进程需要内存空间,但内存空间不足、或仅有不满足使用要求的小碎片时,必须进行碎片整理。

碎片整理的概念

碎片整理通过调整进程占用的分区位置来减少或避免分区碎片。

紧凑(compaction)式碎片整理

image-20240824205418932

碎片紧凑通过移动分配给进程的内存分区,以合并外部碎片。

由于进程中可能存在对绝对地址的引用,因此碎片紧凑的执行是有条件的。

  • 要求所有程序是可动态重定位的

碎片紧凑是有开销的。

  • 内存拷贝
  • 重定位

何时能进行碎片紧凑?

  • 通常对处于等待状态的进程进行紧凑
  • 并非为了很小的一块区域就会对大量进程进行挪动

分区对换(Swapping in/out)式碎片整理

在碎片紧凑中,无论如何挪动,分区都在内存中。在一些情况下,依然无法满足新进程对内存的需求。

分区对换通过抢占并回收【将其存入外存中的对换区】处于等待状态进程的分区,以增大可用内存空间。使得更多的进程可以在有限内存的系统中交替运行。

在Unix或Linux系统中,有一个swap分区。在最早期时,只有一个进程能在内存中运行,因此,通过swap分区,可以实现两个进程交替在内存中运行。

但是,必须注意,交替运行的代价是巨大的。因为外存与内存的速度差异很大。但在当初计算机系统硬件价格高昂时,这样做是可以接受的。

image-20240824205500084

连续内存分配实例:伙伴系统(Bubby System)

伙伴系统比较好的折中了分配和回收过程当中 合并和分配块位置的 碎片问题。

image-20240824205525116

最大的碎片大小:\(2^U-1\),也即只比1/2大一个字节,但分配了将近其二倍的大小。

伙伴系统的实现

image-20240824205541841

  • 数据结构

    二维数组

    • 第一维:空闲块的大小
    • 第二维:空闲块的起始地址

伙伴系统中的内存分配

image-20240824205604157

image-20240824223901409

合并条件中,注意第三个条件:

低地址必须是块大小的2倍的整数倍。公式表达:\(2^{i+1}\)

这是为了避免如上图中的D=256K释放后与其左侧的256K空闲块合并。

目前在我们用到的Linux、 Unix都有 Buddy System 的实现,它是用来做内核里的存储分配。

更多信息:http://en.wikipedia.org/wiki/Buddy_memory_allocation

既然选择了连续,就避免不了产生碎片。

非连续内存分配

Why Need非连续

连续内存分配要求分配给一个进程物理上连续的一块内存区域,这给分配带来了困难。

连续内存分配的缺点

  • 分配给程序的物理内存必须连续(难以达到)
  • 需要指定大小、但内存中没有指定大小的连续区域,则分配失败
  • 有外碎片 / 内碎片的问题
  • 内存分配的动态修改(程序在执行过程中对内存的需求发生变化)困难
  • (最终导致)内存利用率较低

能否通过新的一些手段来改善这些情况?=> 非连续内存分配。

非连续内存分配必然涉及到基本单元大小的问题。

  • 一个字节?太小了,不合适。
  • 不同的基本大小带来了两种基本形式:
    • 段式(块比较大)
    • 页式(块比较小,导致从逻辑地址到物理地址的对应关系比较复杂,有页表专门解决)
    • 段页式(二者结合)

非连续分配的设计目标

提高内存利用效率和管理灵活性

  • 允许一个程序使用非连续的物理地址空间(找到合适空间的概率增加)
  • 允许共享代码和数据(进程间有些代码是共同的,有些数据是共用的,通过共享来减少内存使用量。例如,多个进程都需要使用函数库,那么只在内存中放一份函数库。)
  • 支持动态加载和动态链接(方便程序在执行过程中对内存的需求发生变化时,动态调整分配的内存)

非连续内存分配的实现

非连续内存分配需要解决的问题

  • 如何实现虚拟地址和物理地址的转换(相比于连续分配时只需要基地址和偏移量,这将变得复杂的多)

    实际实现中,有两种选择:

    • 软件实现(灵活,开销大)
    • 硬件实现(够用,开销小)

    (例如,排序程序,实现不知道数据的总量,预先分配多少都不合适。数据机构中的解决方案:外排序,先读入一部分,排序完成后,存入硬盘。继而再读入一部分……在当前问题中,也有类似的做法。比如,程序不足以一次性存入,可以先将一部分读入内存,执行完后,存入外存。再将其他部分读入内存。这一过程,可以用软件,也可以用硬件实现。)

  • 非连续分配硬件辅助机制

    • 如何选择非连续分配中的内存分块大小

      分成多大一个单元?还会存在碎片问题吗?

      • 段式存储
      • 页式存储

非连续内存分配的优点

  • 一个程序的物理地址空间是非连续的
  • 更好的内存利用和管理
  • 允许共享代码与数据(共享库等…)
  • 支持动态加载和动态链接

非连续内存分配的缺点

建立虚拟地址和物理地址的转换难度大

  • 软件方案(开销相当大)

  • 硬件方案(采用硬件辅助机制)

    分段(Segmentation)

    分页(Paging)

段式存储管理(Segmentation)

段地址空间

image-20240824230753307

  • 进程的段地址空间是由各个组成的:

    • 在代码执行方面会看到有主程序、子程序,还有共享的一些库 形成代码不同的分段

    • 数据方面:栈段、堆段 还有一些共享的数据段

    • 不同的段之间有不同的属性

  • 段式存储管理的目的: 更细粒度和灵活的分离和共享

段式地址空间的不连续二维结构

程序的分段地址空间如下图所示 :

image-20240824231153576

特点:

  • 段内是连续的,依然可以通过基地址+偏移量访问
  • 段与段直接是不连续的,因为很少有通过一个段的基地址访问另一个段的数据的情况

段地址空间的逻辑视图

把左边运行程序的逻辑地址空间看成一个连续一维的线性数组,通过段机制的映射关系把不同的内存块(如:代码、数据、堆、栈)分别映射到不同的内存中的段,可以看到映射到物理地址空间后位置不一样,变得不连续了。

image-20240824210137857

段访问机制

  • 段的概念

    • 段表示访问方式和存储数据等属性相同的一段地址空间
    • 这一段内是连续的
    • 若干个段组成进程的逻辑地址空间
  • 段访问:逻辑地址由二元组(s, addr)表示

    • s —— 段号

    • addr —— 段内偏移

      image-20240824231620352

段访问的硬件实现

image-20240824231951671

  1. 用段号查段表,段号对应一个段描述符,得到其基址和长度,段表由操作系统在寻址前完成设置;
  2. MMU(硬件)将偏移和段长度做比较,检查是否越界?
  3. 不越界时,在MMU中利用段基址和偏移,从物理地址中访问。

页式存储管理

分页地址空间

页帧(帧、物理页面,Frame,Page Frame)

  • 把物理地址空间划分为大小相同的基本分配单位(帧)

  • 大小是2的幂,e.g.,512 / 4096 / 8192

    在32位Windows系统中,4096B是常用的分配单位。

页面(页、逻辑页面,Page)

  • 把逻辑地址空间也划分为相同大小的基本分配单位(页)
  • 大小是2的幂,e.g.,512 / 4096 / 8192
  • 帧和页的大小必须是相同的

页面到页帧的转换(pages to frames)

用于:转换逻辑地址为物理地址

  • 页表
  • MMU / TLB(快表)

页帧(Frame)

每个物理内存单元(页帧)的地址是一个二元组\((f,o)\),其中:

  • \(f\)frame-number,帧号,为\(F\)位,意味着有\(2^F\)个帧
  • \(o\)off-set,帧内偏移,为\(S\)位,意味着每个帧有\(2^S\)个字节

因此,物理地址为:\(2^S\times f+o\)

image-20240824210335279

计算实例:

image-20240824210419791

分页和分段的最大区别 : 这里的 S 是一个固定的数【意味着每个页帧的大小固定】,而分段中的长度限制不定。

页面(Page)

每个进程的逻辑地址表示为二元组\((p,o)\),其中:

  • \(p\)page-number,页号,为\(P\)位,意味着有\(2^P\)个页,与帧号可能不同
  • \(o\)off-set,页内偏移,为\(S\)位,意味着每个页有\(2^S\)个字节,与帧内偏移相同

因此,逻辑地址为:\(2^S\times p+o\)

image-20240824210443337

页寻址机制

页表实际上就是一个大的数组/hash表。它的index是 页号,对应的value是 页帧号。

寻址过程:

  1. 首先根据逻辑地址计算得到一个 页号,也就是index;
  2. 在页表中找到对应的 页帧号;
  3. 根据 页帧号 和 偏移地址 计算得到物理地址。

由于他们的页/帧内偏移相等,所以页表不需要保存这个数据。

除此之外,页表中还有一些flags标志位,见下一节。

image-20240824210502179

页式存储中的地址映射

一般情况逻辑地址空间要大于物理地址空间,因此不是所有的页都有对应的帧。

映射到物理地址空间存放是不连续的,因此,逻辑地址中的页号是连续的,而物理地址中的帧号是不连续的带来的好处是有助于减少碎片的产生。

image-20240824210521150

页表(Page Table)

页表实际上就是一个大的数组/hash表。它的index是 页号,对应的value是 页帧号。使用页表实现从逻辑地址到物理地址的转换。

页表结构

页表的使用:

CPU中保存页表基址寄存器(PTBR),将其加上页号,查询页表。

页表中的内容:

  • 标志位

    • 存在位(resident bit)

    • 修改位(dirty bit)

      指示内存中的页面是否已修改,在虚拟存储中,对于未修改的页面,可直接标记为删除,而对于已修改的页面,还要将其写入外存后才能删除

    • 引用位(clock/reference bit)

      指示该页面在过去一段时间内,是否被访问。用于页面置换算法

  • 帧号:\(f\)

image-20240824210641312

页表地址转换实例

image-20240825110614808

逻辑地址(4,0),页号4的flag是100。根据上图,可知它的dirty bit是1,resident bit是0,clock/reference是0;因此可以知道逻辑地址(4,0)在物理地址中实际是不存在的。如果CPU访问这个逻辑地址会抛出一个内存访问异常;

逻辑地址(3,1034),页号3的flags是011。根据上图,可知它的dirty bit是0,resident bit是1,clock/reference是1;因此可以知道逻辑地址(3,1034)在物理地址中存在。再复习一下,页表里存放的是什么。因为由于逻辑地址的页内偏移和物理地址的帧内偏移是一样的,所以页表不需要保存偏移。根据页表,页号3对应的页帧号是4,再加上它们的偏移量相等,所以逻辑地址(3,1034)对应的物理地址是(4,1023)。

这个页表是由操作系统维护。

分页机制的性能问题

空间代价、时间开销

  • 访问一个内存单元需要2次内存访问

    • 一次用于获取页表项

    • 一次用于访问数据

  • 页表可能非常大

    • 例如,64位机器如果每页1024字节,那么需要多少个页表项?(\(2^{64} / 2^{10} = 2^{54}\)

      页内偏移是10位,一个逻辑地址的长度等于计算机位数,也就是64位,因此剩下的54位是留给页号的;因此页表的个数是2^54。

      54/8=7,加上标志位,每个页表需要8B,因此,页表的大小高达\(2^{57}\)B。

    • 每一个运行的程序都需要有一个页表

      每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

  • 如何处理?

    • 缓存(Caching):快表(TLB)

    • 间接(Indirection)访问:多级页表

快表TLB:解决时间问题

快表(Translation Look-aside Buffer,TLB)是一个缓冲区,CPU中的MMU(内存管理单元)都有一个 快表 TLB 的特殊区域,这段缓存保存的内容是 页表 的一部分,是经常访问到的那部分页表,其余不常用的页表内容保存在内存中。

能够应用TLB的原因是,程序具有局部性。理想情况下,每当访问完一页的所有地址后,才会发生一次TLB未命中(TLB miss)。

TLB表项本身是由一种快速查询的存储器:关联内存(associative memory) 实现的,它的速度很快,可以并发的进行查找,但容量是有限的。

工作机制:

  1. 当CPU得到一个逻辑地址的时候,首先会去TLB中查,如果查到这个key => p 则直接返回对应的value => f
  2. 没查到再去页表中查,并将该项更新到TLB中去。

image-20240824210908089

多级页表:解决空间问题

多级页表解决空间问题的方法:时间换空间

多级页表增加了内存访问次数和开销,但是节省了保存页表的空间(时间换空间,然后在通过TLB来减少时间消耗)。

二级页表

  • 将页号分为两个部分(p1、p2),页表分为两级,一级页号对应一级页表,二级页号对应二级页表。
  • 一级页号查表获得在二级页表的起始地址,地址加上二级页号的,在二级页表中获得帧号。
  • 节约了一定的空间,在一级页表中如果 resident bit = 0,可以使得在二级页表中不存储相关index,而只有一张页表的话,这一些index都需要保留。

image-20240824211026539

多级页表

image-20240824211043156

反向页表(inverted page table)

Why Need反向页表?

有没有一种方法使得页表大小与 逻辑地址大小 没有那么大的关系,尽量与 物理地址大小 建立对应关系?

传统页表中,对于大地址空间,有很大的问题:

  • 对于大地址空间,前向映射页表变得繁琐
    • 例如,64位系统采用5级页表
  • 逻辑地址空间增长速度快于物理地址空间

如何解决上述问题?

反向页表,也就是index是物理地址,value是逻辑地址,它的大小会小于传统页表。

基于页寄存器(Page Registers)的方案

image-20240824211121531

存储(帧号,页号)使得表大小与物理内存大小相关,而与逻辑内存关联减小。

image-20240824211137726

页寄存器的优缺点:

页寄存器的优点:

  • 转换表的大小相对于物理内存来说很小;
  • 转换表的大小跟逻辑地址空间的大小无关。

页寄存器的缺点:

  • 需要的信息对调了,如何根据帧号找到页号呢?

    必须进行反向页表的遍历。

为了提高遍历速度,提出两种方案:

基于关联内存(associative memory)的方案

  • 如果帧数较少,页寄存器可以被放置在关联内存中;
  • 在关联内存中查找逻辑页号,成功了,帧号就被提取出来;失败了,页错误异常page fault。
  • 限制这种方案的因素包括,大量的关联内存非常昂贵、耗电量也较大。

image-20240824211206610

基于哈希(hash)查找的方案

image-20240824211232816

设计方法:

  • 在哈希函数的基础上再加一个参数PID,标记当前运行程序的编号,根据h(PID,p)作为input来设计出一个比较简洁的哈希函数,算出它对应的frame-number。
  • 这种方式可以有效缓解映射的开销,需要硬件帮助,但会出现哈希冲突,用上面的 PID 解决。

工作机制:

image-20240824211330982

哈希冲突的实例:

image-20240824211347105

  1. 将PID、逻辑地址做哈希运算,查哈希索引表,得到0x0为可能的物理地址;
  2. 查反向页表0x0,看0x0中对应的逻辑地址是否为0x1;
  3. 发现对应的逻辑地址不是0x1,出现哈希冲突,反向页表中指示下一个地址可能是0x18F1B;
  4. 查反向页表0x18F1B,发现对应的逻辑地地址为0x1;
  5. 由帧号0x18F1B和偏移量构成物理地址。

这种方式还是需要把反向页表放到内存中,做哈希计算时也需要到内存中取值,内存的开销还是很大,所以还需要有一个类似TLB的机制缓存起来,降低访问的时间。

目前来说,这种机制只在高端CPU中存在,好处:

  • 容量可以做的很小,只和物理空间关联;
  • 对于之前的结构,每一个运行程序都需要有一个 page table,但对于这种反向结构,整个系统只需要一个,因为它用的是物理内存的帧号作为index而建的表,而这个表与我们有多少个进程无关,所以它占的空间节省很多;但它是有代价的,它需要以一种很高速的哈希计算、硬件处理机制、高效函数以及解决冲突的机制才可以使访问的效率得到保障;
  • 这种机制有硬件、相应的操作系统软件配合可以在空间和时间上取得比较好的结果。

段页式存储管理

  • 段式存储在内存保护方面有优势(每个段是相同或相近的数据),页式存储在内存利用和优化转移到后备存储方面有优势。
  • 段式存储、页式存储能否结合?

段页式存储管理方法

在段式存储管理的基础上,给每个段加一级页表。

image-20240824211502049

段页式存储管理可以方便实现内存共享

image-20240824211517423

虚拟存储

Why Need虚拟存储

程序规模的增长大于存储器容量的增长。

  • 理想的存储器:更大,更快,更便宜,非易失性存储。

  • 实际的存储器:呈现层次化的结构:

    image-20240825145054506

那么,将外存(如硬盘)的空间利用起来,可以获得更大的存储空间。将不常用的放在硬盘上,常用的放在内存上。

image-20240825145219029

物理内存不够用了怎么办?

在计算机系统中,尤其是在多道程序运行的环境下,可能会出现内存不够用的情况,怎么办?

  • 如果是程序太大,超过了内存的容量,可以采用手动的覆盖(overlay)技术,只把本进程中一部分需要的指令和数据保存在内存当中;【需要程序员在熟悉计算机内部组成的前提下进行精巧的设计】

  • 如果是程序太多,超过了内存的容量,可以采用自动的交换(swapping)技术,把暂时不能执行的程序送到外存中,实现多个程序在内存中的交替运行;

  • 有没有更好的不需要程序员干预、又能部分交换的技术?

    如果想要在有限容量的内存中,以更小的页粒度为单位装入更多更大的程序,可以采用虚拟内存存储技术

覆盖技术

覆盖技术目标

覆盖技术可在较小的可用内存中运行较大的程序,注意此处的程序大到自己无法全部放入内存中运行。

该技术常用于多道程序系统,与分区存储管理配合使用。

覆盖技术原理

把程序按照其自身逻辑结构,划分为若干个功能上相对独立的程序模块,那些不会同时执行的模块共享同一块内存区域,按时间先后来运行。

  • 必要部分(常用功能)的代码和数据常驻内存;
  • 可选部分(不常用功能)在其他程序模块中实现,平时存放在外存中,在需要用到时才装入内存;
  • 不存在调用关系的模块不必同时装入到内存,从而可以相互覆盖,即这些模块共用一个分区。

覆盖技术示例

image-20240825145918473

覆盖技术的问题

  • 由程序员来把一个大的程序划分为若干个小的功能模块,并确定各个模块之间的覆盖关系,费时费力,增加了编程的复杂度;
  • 覆盖模块并从外存装入内存,实际上是以时间延长来换取空间节省。

交换技术

交换技术目标

  • 多道程序在内存时,让正在运行的程序或需要运行的程序交替存放在内存中,以获得更多的内存资源。

交换技术原理

  • 可将暂时不能运行的程序送到外存,从而获得空闲内存空间。
  • 操作系统把一个进程的整个地址空间的内容保存到外存中(换出 swap out),而将外存中的某个进程的地址空间读入到内存中(换入 swap in)。 换入换出内容的大小为整个程序的地址空间。

image-20240825150204810

交换技术的实现细节

  • 交换时机的确定:何时需要发生交换?

    • 只当内存空间不够或有不够的危险时换出;
  • 交换区的大小:

    • 必须足够大以存放所有用户进程的所有内存映像的拷贝,必须能够对这些内存映像进行直接存取;
  • 程序换入时的重定位:换出后再换入的内存位置一定要在原来的位置上吗?(寻址可能出现问题)

    • 最好采用动态地址映射的方法。

与覆盖技术的比较

  • 覆盖只能发生在那些相互之间没有调用关系的程序模块之间,因此程序员必须给出程序内的各个模块之间的逻辑覆盖结构
  • 交换技术是以在内存中的程序大小为单位进行的,它不需要程序员给出各个模块之间的逻辑覆盖结构。交换技术对程序员透明,减轻了编程负担,但是系统的开销变大了。
  • 换言之,交换发生在内存中程序与管理程序或操作系统之间,而覆盖则发生在运行程序的内部。

交换技术的问题

  • 交换技术:以进程作为交换的单位,需要把进程的整个地址空间都换入换出,增加了处理器的开销。

虚拟内存管理

Why Need虚拟内存管理

  • 覆盖技术那样,不是把程序的所有内容都放在内存中,因而能够运行比当前的空闲内存空间还要大的程序。 但做的更好,由操作系统自动来完成,无需程序员的干涉。

  • 交换技术那样,能够实现进程在内存与外存之间的交换,因而获得更多的空闲内存空间。 但做的更好,只对进程的部分内容在内存和外存之间进行交换。

为什么能采用虚拟内存管理:程序的局部性

程序的局部性原理(principle of locality):指程序在执行过程中的一个较短时期,所执行的指令地址和指令的操作数地址,分别局限于一定的区域。

  • 时间局部性:一条指令的一次执行和下次执行,一个数据的一次访问和下次访问都集中在一个较短时期内 ;
  • 空间局部性:当前指令和邻近的几条指令,当前访问的数据和邻近的几个数据都集中在一个较小区域内。

程序的局部性原理表明,从理论上来说,虚拟存储技术是能够实现的,且能够取得一个满意的效果。

注意:编程方式的不同会影响程序的局部性,实例如下:

页面大小为4k ,在一个进程中,定义了如下的二维数组 int A[1024][1024]。 该数组按行存放在内存,每一行放在一个页面中。

考虑一下程序的编写方法对缺页率的影响?

//程序编写方法1:(发生了1024*1024次缺页中断)
//访问顺序:第1、2、……、1024页面,重复1024次
for (j = 0; j < 1024; j++)
		for (i = 0; i < 1024; i++)
				A[i][j] = 0;

//程序编写方法2:(发生了1024次缺页中断)
//访问顺序:第1、1、……、1页面,第2、2、……、2页面,……
for (i = 0; i < 1024; i++)
		for (j = 0; j < 1024; j++)
				A[i][j] = 0;

虚拟存储的概念

虚拟存储可以在页式或段式内存管理的基础上实现。

  • 在装入程序时,不必将其全部装入内存,而只需将当前需要执行的部分页面或段装入到内存中,就可以让程序开始执行;
  • 在程序执行过程中,如果需执行的指令或访问的数据尚未在内存中(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
  • 另一方面,操作系统将内存中暂时不使用的页面或段调出保存在外存上,从而腾出更多空闲内存空间存放将要装入的程序以及将要调入的页面或段。

虚拟存储的基本特征

  • 大的用户空间:通过把物理内存和外存相结合,提供给用户的虚拟内存空间通常大于实际的物理内存,即实现了这两者的分离。 如32位的虚拟地址理论上可以访问4GB,而可能计算机上仅有256M的物理内存,但硬盘容量大于4GB。
  • 部分交换:与交换技术相比较,虚拟存储的调入和调出是对部分虚拟地址空间(部分页,而不是整个进程的所有地址空间)进行的;(交换粒度更小,效率更高)
  • 不连续性:物理内存分配的不连续性,虚拟地址空间使用的不连续性。

具体实现:虚拟页式内存管理

大部分虚拟存储系统都采用虚拟页式存储管理技术,即在页式存储管理的基础上,增加 请求调页 和 页面置换 功能。

工作机制

  • 当一个用户程序要调入内存运行时,不是将该程序的所有页面都装入内存,而是只装入部分的页面,就可启动程序运行。
  • 在运行的过程中,如果发现要运行的程序或要访问的数据不再内存,则向系统发出缺页的中断请求,系统在处理这个中断时,将外存中相应的页面调入内存,使得该程序能够继续运行。
  • 页面置换功能实现的好坏决定了整体的效率,后面专门讲些设计有效的置换算法。

页表表项(flags)

image-20240825151637920

虚拟页式存储示例

image-20240825151743349

缺页中断的处理

  1. 如果在内存中有空闲的物理页面,则分配一物理页帧f,然后转第4步; 否则转到第2步;
  2. 采用某种页面置换算法,选择一个将被替换的物理页帧f,它所对应的逻辑页为q。如果该页在内存期间被修改过,则需要把它写回外存;
  3. 对q所对应的页表项修改,把驻留位置为0;
  4. 将需要访问的页p装入到物理页面f当中;
  5. 修改p所对应的页表项的内容,把驻留位置为1,把物理页帧号置为f;
  6. 重新运行被中断的指令。

image-20240825152032351

后备存储(Backing Store)

image-20240825152256497

其中,注意代码段在外存中本来就是可执行二进制文件,因此没必要再次拷贝;代码段直接映射到库文件。

虚拟页式存储的性能

image-20240825152445333

解释一下为什么最后一项要乘(1+q)?

原因很简单:因为对于已被修改的页面(dirty page),必须额外访存一次外存,先将其写回,才能再搬运新的页面进入内存。

页面置换功能实现的好坏决定了虚拟页式存储的效率,下面将介绍页面置换算法。

页面置换算法

功能与目标

  • 功能:当缺页中断发生,需要调入新的页面而内存已满时,选择内存当中哪个物理页面被置换。
  • 目标:尽可能地减少页面的换进换出次数(即缺页中断的次数)。 具体来说,把未来不再使用的或短期内较少使用的页面换出,通常只能在局部性原理指导下依据过去的统计数据来进行预测。

另外,需要保护部分页面不能被换出:

  • 页面锁定(frame locking):用于描述必须常驻内存的操作系统的关键部分或时间关键(time-critical)的应用进程。

    实现的方法是:在页表中添加锁定标记位(lock bit)。

评价方法

image-20240825154545279

局部页面置换算法

最优页面置换算法(OPT)

  • 基本思路:当一个缺页中断发生时,对于保存在内存当中的每一个逻辑页面,计算在它的下一次访问之前,还需等待多长时间,从中选择等待时间最长的那个,作为被置换的页面。

  • 这是一种理想情况,在实际系统中是无法实现的,因为操作系统无法知道每一个页面要等待多长时间以后才会再次被访问。

  • 可用作其他算法的性能评价的依据,这也是OPT算法存在的意义。(在一个模拟器上运行某个程序,并记录每一次的页面访问情况,在第二遍运行时即可使用最优算法得出最优解,随后以这个算法为基准,评价其他算法)

示例:

image-20240825154819664

先进先出算法(FIFO)

  • 基本思路:选择在内存中驻留时间最长的页面淘汰。 具体来说,系统维护着一个链表,记录了所有位于内存当中的逻辑页面。 从链表的排列顺序来看,链首页面的驻留时间最长,链尾页面的驻留时间最短。 当发生一个缺页中断时,把链首页面淘汰出去,并把新的页面添加到链表的末尾。

  • 性能较差,调出的页面有可能是经常要访问的页面。 并且有Belady现象。 FIFO算法很少单独使用。

示例:

image-20240825155431287

最近最久未使用算法(LRU)

  • 基本思路:当一个缺页中断发生时,选择最久未使用的页面淘汰

  • 它是对最优页面置换算法的一个近似,其依据是程序的局部性原理,即在最近一小段时间、最近几条指令内,如果某些页面被频繁地访问,那么再将来的一小段时间内,他们还可能会再一次被频繁地访问。 反过来说,如果过去某些页面长时间未被访问,那么在将来它们还可能会长时间地得不到访问。

实例:

image-20240825155552579

LRU算法需要记录各个页面使用时间的先后顺序,开销比较大。具体的实现有以下两种:

  • 系统维护一个页面链表,最近刚刚使用过的页面作为首结点,最久未使用的作为尾结点。 每一次访问内存时,找出相应的页面,把它从链表中摘下来,再移动到链表之首。 每次缺页中断发生时,淘汰链表末尾的页面。

  • 设置一个活动页面栈,当访问某页时,将此页号压入栈顶,然后,考察栈内是否有与此页面相同的页号,若有则抽出。 当需要淘汰一个页面时,总是选择栈底的页面,它就是最久未使用的。

    image-20240825155752383

时钟页面置换算法(Clock)

Clock页面置换算法,是对LRU的近似,在FIFO的一种改进。

基本思路:

  • 需要用到页表项的访问位(access bit),当一个页面被装入内存时,把该位初始化为 0。 然后如果这个页面被访问,则把该位置设为 1;

  • 把各个页面组织成环形链表(类似钟表面),把指针指向最老的页面(最先进来);

  • 当发生一个缺页中断时,考察指针所指向的最老页面,

    • 若它的访问位为 0,立即淘汰;

    • 若访问位为 1,则访问位置为 0,然后指针往下移动一格。

    • 如此下去,直到找到被淘汰的页面,然后把指针移动到下一格。

实现:

  • 维持一个环形页面链表保存在内存中

    • 用一个时钟(或者使用 / 引用)位来标记一个页面是否经常被访问

    • 当一个页面被引用的时候,这个位被设置(为1)

  • 时钟头扫遍页面寻找一个带有used bit=0 (assess bit)

    • 替换在一个周转内没有被引用过的页面
  • 如果访问页在物理内存中,访问位置为1。

  • 如果不在物理页,从指针当前指向的物理页开始,如果访问位为0,替换当前页,指针指向下一个物理页;如果访问位为1,置0以后访问下一个物理页再进行判断。 如果所有物理页的访问位都被清零了,又回到了第一次指针所指向的物理页进行替换。

实例:

image-20240825160544448

算法的改进:避免将修改过的页替换出去(避免两次读写外存带来的性能损失)

改进的Clock算法(Enhanced Clock)

  • 修改Clock算法,使它允许脏页总是在一次时钟头扫描中保留下来
    • 同时使用脏位使用位来指导置换

image-20240825160709514

相当于说,替换的优先级,没有读写也没写过,那么直接走;如果写过或者访问过,那么给你一次机会;如果又写过,又访问过,那么就给你两次机会。

示例:

image-20240825160735887

最不常用算法(LFU)

  • 基本思路:当一个缺页中断发生时,选择访问次数最少的那个页面,并淘汰。

  • 实现方法:对每一个页面设置一个访问计数器,每当一个页面被访问时,该页面的访问计数器加1。 当发生缺页中断时,淘汰计数值最小的那个页面。

  • LRU和LFU的对比:LRU考察的是多久未访问,时间越短越好;而LFU考察的是访问的次数或频度,访问次数越多越好。

示例:

image-20240825160829949

存在的问题:

一个页面在进程开始时使用得很多,但以后就不使用了,LFU统计的是整体的访问次数,所以此时这个页面还会被保留在内存中。

解决方法:

定期把次数寄存器右移一位(二进制 >> 1 相当于 / 2)。

它最主要的问题是只考虑频率而没考虑时间,我们可以隔一段时间砍掉一半的次数,进而改善这个问题。

Belady现象

  • 概念:给了更多的物理资源,可产生的缺失次数变得更多了。
  • 例如,在采用FIFO算法时,有时会出现分配的物理页面数增加,缺页率反而提高的异常现象;
  • 原因:FIFO算法的置换特征与进程访问内存的动态特征是矛盾的,与置换算法的目标是不一致的(即替换较少使用的页面),因此,被他置换出去的页面不一定是进程不会访问的。

示例:

image-20240825161235196

更多的问题:

  • 时钟 / 改进的时钟页面置换是否有Belady现象?

    没有。

  • 为什么LRU页面置换算法没有Belady现象?

    简单解释:LRU符合一类叫称之为 栈算法 的特点。

局部置换算法的比较

  • LRU算法和FIFO本质上都是先进先出的思路

    • LRU依据页面的最近访问时间排序。

    • LRU需要在每一次页面访问的时候动态地调整各个页面之间的先后顺序(有一个页面的最近访问时间变了)。

    • FIFO依据页面进入内存的时间排序。

    • FIFO的页面进入时间是固定不变的,各个页面之间的先后顺序是固定的。

  • LRU可退化成FIFO

    • 如果一个页面进入内存后没有被访问,那么它的最近访问时间就是它进入内存的时间。

      换句话说,如果内存当中的所有页面都未曾访问过,那么LRU算法就退化为了FIFO算法。

    • 例如:给进程分配3个物理页面,逻辑页面的访问顺序为 1、2、3、4、5、6、1、2、3…

  • LRU算法性能较好, 但系统开销较大。

  • FIFO算法系统开销较小,但会发生Belady现象。

  • Clock算法是它们的折中

    • 页面访问时,不动态调整页面在链表中的顺序,仅做标记

    • 缺页时,再把它移动到链表末尾。

  • 对于未被访问的页面,Clock和LRU算法的表现一样好。

  • 对于被访问过的页面,Clock算法不能记录准确访问顺序,而LRU算法可以。

Clock模仿LRU,LRU在某种状态下会退化为FIFO,也意味着Clock也可能会退化为FIFO,从这点可以看出来算法只是我们页替换的一个环节,如果要有效减少缺页产生的次数,除了算法本身之外,我们还要对访问序列有一定的要求,访问序列最好是具有局部性的访问特征,那么LRU、Clock算法才会发挥特征,如果序列不具有局部性,那么LRU、Clock、FIFO就没什么区别了。

全局页面置换算法

Why Need全局置换算法

在局部置换算法中,没有考虑应该给进程分配多少个页面。而在一定的情况下,分配的页面数目对缺页次数有很大影响。

例如:

image-20240825162146315

另外,程序在运行过程中可能会有阶段性,它可能一开始访问需要很多内存,中间时候需要很少,结束的时候又需要很多,它是动态变化的过程,它对物理页帧的需求是可变的。

最后,局部置换算法中没有考虑到其他进程的影响。事实上,由于存在大量进程,这也要求尽量能够灵活调整每个进程分配物理页帧的数目。

思路与要解决的问题

思路

  • 全局置换算法为进程分配可变数目的物理页面

全局置换算法要解决的问题

  • 进程在不同阶段的内存需求是变化的
  • 分配给进程的内存也需要在不同阶段有所变化
  • 全局置换算法需要确定分配给进程的物理页面数

工作集置换算法

工作集置换算法是局部置换算法中最优置换算法的近似。

工作集的定义

image-20240825162526805

示例:

image-20240825162606014

工作集的特征

image-20240825162643841

  • 工作集大小的变化:进程开始执行后,随着访问新页面逐步建立较稳定的工作集。
  • 当内存访问的局部性区域的位置大致稳定时,工作集大小也大致稳定;
  • 局部性区域的位置改变时,工作集快速扩张和收缩过渡到下一个稳定值。

常驻集的定义

常驻集是指在当前时刻,进程实际驻留在内存当中的页面集合。

  • 工作集与常驻集的关系

    • 工作集是进程在运行过程中固有的性质

    • 常驻集取决于系统分配给进程的物理页面数目和页面置换算法

  • 缺页率与常驻集的关系

    • 常驻集 ⊇ 工作集时(一个进程的整个工作集都在内存当中),缺页较少

    • 工作集发生剧烈变动(过渡)时,缺页较多

    • 进程常驻集大小达到一定数目后,再给它分配更多的物理页面,缺页率也不会明显下降

工作集置换算法

思路:

  • 换出不在工作集中的页面

窗口大小 τ:

  • 当前时刻前 τ 个内存访问的页引用是工作集, τ 被称为窗口大小

实现方法:

  • 访存链表:维护窗口内的访存页面链表

  • 访存时,换出不在工作集的页面【注意,不必等到缺页中断】;更新访存链表

  • 缺页时,换入页面;更新访存链表

示例:

image-20240825163513425

特点:

  • 它在当前物理内存中放哪些页,取决于是否在工作集窗口之内,这样可以确保在物理内存中始终有足够多的页存在,这样可以给其它运行程序提供更多的内存,进一步减少页面置换的次数,在整个系统层面,多个程序的缺失数会降低。

缺页率置换算法

引入

工作集置换算法在每次页面访问时,都会进行页面的调整,带来了较大的开销。引入缺页率的概念,根据缺页的情况,决定是否对工作集大小进行调节。

缺页率(page fault rate)

概念:

表示 为“缺页次数 / 内存访问次数”(比率)或 “缺页平均时间间隔的倒数”。

影响缺页率的因素:

  • 页面置换算法
  • 分配给进程的物理页面数目
  • 页面本身的大小
  • 程序的编写方法

缺页率置换算法

思路:

image-20240825164022040

具体实现:

image-20240825164215452

示例:

image-20240825164322872

抖动(thrashing)和负载控制

  • 抖动

    • 如果分配给一个进程的物理页面太少,不能包含整个的工作集,即常驻集 ⊂ 工作集;

    • 那么进程将会造成很多的缺页中断,需要频繁的在内存与外存之间替换页面;

    • 从而使进程的运行速度变得很慢,我们把这种状态称为 ”抖动“。

  • 产生抖动的原因

    • 随着驻留内存的进程数目增加,分配给每个进程的物理页面数不断就减小,缺页率不断上升。
  • 操作系统需要在并发水平和缺页率之间达到一个平衡

    • 选择一个适当的进程数目和进程需要的物理页面数。

如何改善抖动问题?使用负载控制

  • 通过调节并发进程数进行负载控制,每个进程分配的页面数量通过置换算法确定。

  • 使工作集的总数与内存的大小相等。

  • 并发进程数与CPU利用率存在以下关系:

    image-20240825165353573

    \(N_{max}\)处CPU利用率最高,但难以确定该点位置。

  • 改用平均缺页间隔时间(MTBF)和缺页异常处理时间(PFST)的比例来考察。当二者的比大于1时,系统能够完成对缺页的处理。

    \(\frac{MTBF}{PFST}=1\)的点称为\(N_{IO-BLANCE}\),我们在该点的左侧寻找并发进程数的限制。

文件管理

文件系统的概念

文件系统与文件

文件系统和文件的概念

  • 文件系统:一种用于持久性存储的系统抽象

    操作系统中管理持久性数据的子系统,提供数据存储的访问功能

    • 在存储器上:组织,控制,导航,访问和检索数据

    • 大多数计算机系统包含文件系统

    • 个人电脑,服务器,笔记本电脑

    • ipad,Tivo / 机顶盒,手机 / 掌上电脑

    • Google可能也是由一个文件系统构成的

  • 文件:文件系统中的一个单元的相关数据在操作系统中的抽象,是文件系统的基本数据单位

    具有符号名,由字节序列构成的数据项集合

    • 文件系统的基本数据单位

    • 文件名是文件的标志符号

    Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。

文件系统的功能

  • 分配文件磁盘空间

    • 管理文件块(哪一块属于哪一个文件,要知道位置和顺序)

    • 管理空闲空间(哪一块是空闲的,只需知道位置)

    • 分配算法(策略)

  • 管理文件集合

    • 定位:文件及其内容(通过文件名,找到文件的位置,读出文件的内容)

    • 命名:通过名字找到文件(机器中存储的文件名是数字,必须能够给文件一个人能理解的名字)

    • 文件系统结构:文件组织方式(将文件组织成一个整体结构,不同需求下的组织方式不同)

  • 提供数据可靠和安全保护

    • 安全:分层来保护数据安全

    • 可靠性 / 持久性:

      • 保持文件的持久性
      • 避免发生崩溃、媒体错误、攻击等

文件属性(对文件的描述)和文件的组成

  • 文件属性

    为方便文件的访问而存储的一些信息:

    • 名称、类型、位置、大小、保护(谁能读写?)、创建者、创建时间、最久修改时间…

为了更好地方便访问,将文件的内容分为两部分:

  • 文件头:文件系统元数据中保存的文件的信息

    有了文件头的信息,就能知道需要读写的文件数据在哪里存放。

    • 保存文件属性

    • 文件存储位置和顺序

  • ???

Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。

  • 文件控制块,索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
  • 目录节点,目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。

目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。

另外,目录项与目录并不是一个东西。

  • 目录是个文件,持久化存储在磁盘。和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。

    目录格式哈希表

  • 目录项是内核一个数据结构,缓存在内存。如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。

文件的内部结构

  • 无结构

    • 单词、比特的队列
  • 简单记录结构

    • 分列:文件分成若干列

    • 固定长度

    • 可变长度

  • 复杂结构

    • 格式化的文档(如,MS word, PDF),操作系统不关系具体的格式

    • 可执行文件与非可执行文件,操作系统给予某种程度的识别支持

文件系统分层组织与目录

文件多到一定数目后,为进行有效的管理,必须引入分层的结构。分层的文件系统中,文件以目录的形式组织起来。

目录概念

  • 目录是一类特殊的文件

    • 目录的内容是文件索引表<文件名,指向文件的指针>
  • 目录和文件的树型结构

    • 早期的文件系统是扁平的(只有一层目录)
  • 层次名称空间

    image-20240825231214713

    方便用户的访问(方便用户的记忆和分类)

目录操作

  • 典型操作
    • 搜索文件:寻找目录项
    • 创建文件:增加目录项
    • 删除文件:删除目录项
    • 列目录:列举有哪些子目录和文件
    • 重命名文件:更改目录项中的相关属性
    • 遍历路径:从根目录寻找一个文件
  • 操作系统应该只允许内核模式修改目录
    • 确保映射的完整性
    • 应用程序通过系统调用来操作目录

目录实现

  • 文件名的线性列表,包含了指向数据块的指针
    • 编程简单
    • 执行耗时(表很大时检索和增删耗时长)
  • Hash表 - hash数据结构的线性表
    • 减少目录搜索时间
    • 碰撞 - 两个文件名的hash值相同
    • 目录表中每一项固定大小

目录格式哈希表

文件别名(多个文件名关联同一个文件)

image-20240825232426571

文件别名的目的:方便共享,减少共享文件的存储空间占用

实现方法:

  • 硬链接:多个文件项指向一个文件

    • 删除操作?

      删除到最后一个指向时,删除文件。

  • 软链接:以“快捷方式”指向其他文件

    • 文件描述出来依然互相独立

    • 链接文件中存储真实文件的完整路径

    • 删除操作?

      文件与别名的删除与其他文件相同。

      • 删除别名,不影响文件。
      • 删除文件,别名指向的文件不存在。

目录中的循环

image-20240825232551657

  • 如何保证没有循环?

    • 只允许到文件的链接,不允许到目录的链接

    • 增加链接时,用循环检测算法确定是否合理

      类似于死锁检测,可以使用银行家算法,但开销较大

  • 实际做法

    • 限制路径可遍历文件目录的数量,超出指定长度不再检索

路径遍历(名字解析)

  • 名字解析:逻辑名字转换成物理资源(如文件)的过程

    • 在文件系统中:到实际文件的文件名(路径)

    • 遍历文件目录直到找到目标文件

  • 举例:解析 “/bin/ls”

    • 读取根目录(root)的文件头(在磁盘固定位置)

    • 读取root的数据块:搜索 “bin” 项,其指向下一级目录的数据块

    • 读取bin的文件头

    • 读取bin的数据块:搜索 “ls” 项,其指向下一级目录的数据块

    • 读取ls的文件头

  • 当前工作目录(PWD)

    给每个进程一个缺省的目录,从这个目录开始向下解析。使得进程不需要切换工作目录时,无需从根目录重新查找。

    • 每个进程都会指向一个文件目录用于解析文件名

    • 允许用户指定相对路径来代替绝对路径,如,用 PWD=“/bin” 能够解析 “ls”

文件系统挂载

文件系统运行后,只有一个根目录。要访问的数据所在的文件系统必须挂载后,才能访问。

  • 一个文件系统需要先挂载才能被访问

  • 一个未挂载的文件系统被挂载在挂载点

    将要挂接文件系统的根目录对应到文件系统根目录下的某一个目录。

image-20240825233852278

文件系统种类

  • 磁盘文件系统

    • 文件存储在数据存储设备上,如磁盘
    • 不同文件系统根据所存数据、使用场景的不同会进行相应的优化
    • 不同文件系统的安全要求不同,安全要求越高,访问效率下降。对于安全性要求不高的文件系统,可以将安全要求减弱甚至取消。
    • 上述两个原因,导致存在大量磁盘文件系统。例如:FAT,NTFS,ext2/3,ISO9660 等
  • 数据库文件系统

    • 文件特征是可被寻址(辨识)的

    • 例如:WinFS

  • 日志文件系统

    • 记录文件系统所有的修改/事件,避免操作未能完成时导致数据丢失
    • 对文件系统的修改必须以原子操作进行
    • 例如:银行账户的系统
  • 网络 / 分布式文件系统

    • 将文件保存到远端服务器

    • 不同的网络访问方式构成了不同的网络/分布式文件系统,例如:NF,SMB,AFS,GFS

  • 特殊 / 虚拟文件系统

    • 例如:进程间通信用的管道

特别地,介绍网络 / 分布式文件系统:

  • 文件可以通过网络被共享

    • 文件位于远程服务器

    • 客户端远程挂载服务器文件系统

    • 标准系统文件访问被转换成远程访问

    • 标准文件共享协议:NFS for Unix,CIFS for Windows

  • 分布式文件系统的挑战

    • 客户端和客户端上的用户辨别起来很复杂

      • 例如,NFS是不安全的
    • 一致性问题更难处理

    • 错误处理模式更加复杂

虚拟文件系统

虚拟文件系统的概念

虚拟文件系统的概念

为应对上述不同的文件系统,而通过操作系统向上提供一系列统一的接口,提出虚拟文件系统的概念。

虚拟文件系统的分层结构

image-20240825235444757

  • 顶层:文件 / 文件系统API
  • 上层:虚拟(逻辑)文件系统 (将所有设备IO,网络IO全抽象成为文件,使得接口一致)
  • 底层:特定文件系统模块

虚拟文件系统的功能

  • 提供相同的文件和文件系统接口
  • 管理所有文件和文件系统关联的数据结构
  • 高效查询例程,遍历文件系统
  • 与特定文件系统模块的交互

文件系统的组织

文件系统的基本数据结构

  • 卷控制块(UNIX:“superblock”)

    • 每个文件系统一个

    • 描述文件系统详细信息:

      块、块大小、空余块、计数 / 指针等

  • 文件控制块(UNIX:“vnode” or “inode”)

    • 每个文件一个

    • 描述文件详细信息:

      许可、拥有者、大小、数据库位置等

  • 目录节点(Linux:“dentry”)

    • 每个目录项一个(其中对应着目录和文件)

    • 将目录项数据结构及树形布局编码成树形数据结构

    • 包括:指向文件控制块、父节点、项目列表等

文件系统的组织视图

image-20240826000126227

文件系统的存储结构

  • 文件系统的基本数据结构持续存储在外存中

    • 存储在分配在存储设备中的数据块中
  • 当需要时加载进内存

    • 卷控制块:当文件系统挂载时进入内存

    • 文件控制块:当文件被访问时进入内存

    • 目录节点:在遍历一个文件路径时,遇到该目录则进入内存

文件系统的存储视图

image-20240826000421693

文件的缓存

多种磁盘缓存的位置

从磁盘读数据到内存最终的CPU,中间有多种缓存。

image-20240826001007636

  • 内存虚拟盘:用内存虚拟出一块磁盘使用。
  • 数据块缓存:操作系统中讨论的缓存。

数据块的缓存

  • 数据块按需读入内存

    • 提供read()操作

    • 预读:预先读取后面的数据块

  • 数据块使用后被缓存(局部性原理)

    • 假设数据将会再次被使用

    • 写操作可能被缓存和延迟写入

      可能造成一定的风险,比如攒了两次准备一起写,但出现了故障,但系统可能认为第一次已写入。

  • 两种数据块缓存方式

    • 数据块缓存

    • 页缓存:统一缓存数据块和内存页

      类比虚拟内存,可以理解成反向的操作

数据块缓存

标记内存中一个区域为磁盘的缓存,每当查找时,先在内存中查找,找不到再找内存。

image-20240826001754923

页缓存

虚拟页式存储:将虚拟页面映射到本地的外存文件中。

  • 分页要求

    • 当需要一个页时才将其载入内存
  • 支持存储

    • 一个页(在虚拟地址空间中)可以被映射到一个本地文件中(在外存中)

image-20240826002143825

反向使用:文件数据块的页缓存。

image-20240826002210608

  • 文件数据块的页缓存

    • 在虚拟内存中文件数据块被映射成页
    • 文件的读/写操作被转换成对内存的访问
    • 文件访问是可能导致缺页和/或设置为脏页(页面状态发生变化)
    • 导致问题:页置换算法需要协调虚拟存储和页缓存间的页面数。

    image-20240826002524689

文件的打开与使用

为了使用好数据块缓存机制,所有打开的文件都必须维护好在操作系统中的数据结构,以记录缓存的状态。

打开文件

使用程序必须在使用前先“打开”文件,例如:

f = open(name, flag);
...
... = read(f, ...);
...
close(f);

文件描述符

文件描述符和打开文件表

文件描述符:

内核跟踪进程打开的所有文件,维护打开的文件的相关信息。

image-20240825222555130

  • 内核为每个进程维护一个打开文件表
  • 文件描述符是打开的文件的标志

【问题】为什么不直接使用文件的标识呢?

【答案】已打开的文件与所有的文件有数量级上的巨大差异,使用文件描述符而不是文件标识,可以简化描述。

打开文件表:

  • 一个进程一个
  • 一个系统级的
  • 每个卷控制块也会保存一个列表
  • 所以如果有文件被打开将不能被卸载

image-20240826000641921

文件描述符的内容

文件描述符包含以下信息:

  • 文件指针

    • 指向最近的一次读写位置

    • 每个进程分别维护自己的打开文件指针

      多个进程访问同一个文件时,自己的文件指针不因其他进程的读写而发生变化

  • 文件打开计数

    • 记录文件打开的次数
    • 当最后一个进程关闭了文件时,将其从打开文件表中移除
  • 文件磁盘位置

    • 会将一部分文件内容缓存到内存中,保存缓存数据访问信息、文件在磁盘的位置,避免已缓存的数据还去磁盘中读取
  • 访问权限

    • 每个程序访问模式信息

      只读?读写?

文件的用户视图和系统视图

用户视图与系统视图

必须注意到,在用户视图和系统视图中的文件是不同的。文件系统的作用就是沟通二者之间的差异,实现对文件的使用。

  • 文件的用户视图(进程的角度)

    • 持久的数据结构
  • 操作系统如何看待进程对文件的访问?

    提供系统访问文件的接口(如何把文件数据和磁盘块对应起来)

    • 文件只不过是字节序列的集合(UNIX)

    • 系统不会关心你想存储在磁盘上的任何的数据结构

  • 操作系统内部视角

    • 文件是数据块的集合(数据块是逻辑存储单元,而扇区是物理存储单元)

    • 数据块大小不一定等于扇区大小,一般数据块包括几个扇区。

      在UNIX中,数据的大小是4KB

用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了。

视图间的转换

二者之间的转换应该怎么做?以文件的读写为例:

  • 当用户说:给我2-12字节空间时会发生什么?(进程读文件)

    • 获取字节所在的块
    • 返回快内对应部分
  • 如果要写2-12字节?(进程写文件)

    • 获取块
    • 修改块内对应部分
    • 写回块
  • 在文件系统中的所有操作都是在整个块空间上进行的(基本操作单位是数据块)

    • 举个例子,getc() 、putc(): 即使每次只访问1字节的数据,也会缓存目标数据4096字节(一个磁盘块)

进程对文件的访问模式

为了更好地进行文件管理,操作系统需要了解进程如何访问文件。

  • 顺序访问:按字节依次读取

    • 几乎所有的访问都是这种方式
  • 随机访问:从中间读写

    • 不常用,但是仍然重要。

      例如:虚拟内存支持文件,内存页存储在文件中,此时的访问是随机的。

    • 对系统的性能影响很大

  • 索引访问:通过数据特征,索引文件位置

    • 通常操作系统不完整提供索引访问,需要完整的索引访问需要自行建立数据库
    • 数据库是建立在索引内容的磁盘访问上(需要高效的随机访问)

    索引访问文件示例:

    image-20240825224904485

    预先根据文件内容建立一个体现文件特征的索引文件,在需要访问文件时,不是直接去找这个文件的位置,而是去索引文件中找,找到后通过索引中的位置,去访问文件。

文件共享与访问控制

  • 多用户系统中的文件共享是很必要的

  • 访问控制

    • 谁能够获得哪些文件的哪些访问权限
    • 访问模式:读,写,执行,删除,列举等
  • 文件访问控制列表(ACL)

    记录每个用户对每个文件的访问权限

    • <文件实体, 权限>
  • 实例:Unix模式

    • <用户|组|所有人,读|写|可执行>
    • 用户ID识别用户,表明每个用户所允许的权限及保护模式
    • 组ID允许用户组成组,并指定了组访问权限

多用户共同访问时的语义一致性问题

  • 指定多用户 / 客户如何同时访问共享文件:

    • 和过程同步算法相似
    • 因磁盘IO和网络延迟而设计简单
  • 方法1及其实例1:UNIX文件系统(UFS)语义

    基本没有协调的机制,将一致性的问题交给应用程序解决。

    • 对打开文件的写入内容立即对其他打开同一文件的其他用户可见
    • 共享文件指针允许多用户同时读取和写入文件
  • 方法2:会话语义

    • 写入内容只有当文件关闭时可见

      能保证语义完整,但效率降低。

  • 方法3:读写锁

    • 一些操作系统和文件系统提供基本的互斥访问锁,由应用程序自行决定是否使用及如何使用

    • 强制和劝告:

      • 强制 - 根据锁保持情况和需求拒绝访问

      • 劝告 - 进程可以查找锁的状态来决定怎么做

文件分配

文件的大小分布

文件分配需要考虑文件大小的分布。

  • 大多数文件都很小

    • 需要对小文件提供强力的支持

    • 块空间不能太大

  • 一些文件非常大

    • 必须支持大文件(64-bit 文件偏移)

    • 大文件访问需要相当高效

进行文件分配

什么是文件分配

文件分配是如何表示分配给一个文件数据块的位置和顺序的问题。

文件分配方式

  • 连续分配:给出起点,顺序地分配连续空间
  • 链式分配:各数据块依次指向下一块
  • 索引分配:分配一个存放文件分配信息的块(存放哪些块有信息、块的顺序)

文件分配的评价指标

  • 存储效率:
    • 不考虑内部碎片(最小分配单位是块,因此无法处理内碎片,只能合理化选择数据块大小)
    • 存储的利用率与选择的分配算法相关(例如,连续分配中外部碎片过多,会导致无法给大文件分配位置。链式、索引分配无外碎片。)
  • 读写性能:
    • 访问速度
      • 顺序分配随机读取速度快
      • 链式分配随机读写速度慢

连续分配

image-20240826084228492

  • 文件头(在文件控制块中)指定起始块和长度

  • 分配策略

    • 最先匹配,最佳匹配,…(与内存分配类似)
  • 优势

    • 文件读取表现好

    • 高效的顺序和随机访问

  • 劣势

    • 碎片较多

    • 文件增长问题:文件增长时,怎么存放?

      • 预分配?预留部分空间

      • 按需分配?需要时搬移后面的内容

链式分配

image-20240826084303383

  • 文件以数据块链表方式存储

  • 文件头包含了到第一块和最后一块的指针

  • 优点

    • 创建,增大,缩小很容易

    • 没有碎片

  • 缺点

    • 不可能进行真正的随机访问:找中间某一块要多次查找

    • 可靠性差

      • 破坏一个链,后面的数据块都丢失

索引分配

image-20240826084326562

  • 为每个文件创建一个名为索引数据块的非数据数据块

    • 指向文件数据块的指针列表
  • 文件头包含了索引数据块的指针

  • 优点

    • 创建,增大,缩小很容易

    • 没有碎片

    • 支持直接访问

  • 缺点

    • 当文件很小时,存储索引的开销不可忽视(一块就能存下,再分配一个索引块,索引占比高达50%)

    如何处理大文件?文件大到一个索引块存不下。

    • 链式索引块

      image-20240826084418501

    • 多级索引块

      image-20240826084431032

      示例:早期Unix系统UFS的多级索引分配:

      image-20240826084501586

      文件头包含13个指针

      • 前10个指针指向数据块

      • 第11个指针指向索引块

      • 第12个指针指向二级索引块

      • 第13个指针指向三级索引块

      效果

      • 提高了文件大小限制阈值

      • 动态分配数据块,文件扩展很容易

      • 小文件开销小

      • 只为大文件分配间接数据块,大文件在访问数据块时需要大量查询

空闲空间列表

  • 跟踪在存储中的所有未分配的数据块
  • 空闲空间列表存储在哪里?
  • 空闲空间列表的最佳数据结构是什么样的?

位图

  • 用位图代表空闲数据块列表:

    • 1111111111110011101001010111...

    • 如果\(D_i = 0\)表明数据块 i 是空闲的,反之则已分配

  • 使用简单但是可能会是一个很大的向量表:

    • 例如,4KB为一块时,\(160GB\ disk → 40M\ blocks → 5MB\ worth\ of\ bits\),如果需要频繁修改,修改量很大。

    • 然而,如果空闲空间在磁盘中均匀分布,那么在找到 “0” 之前需要扫描n / r

      • n=磁盘上数据块总数

      • r=空闲块的数目

列表

image-20240826093130749

冗余磁盘阵列RAID

  • 使用多个并行磁盘来增加

    • 吞吐量(通过并行)

    • 可靠性和可用性(通过冗余)

  • 冗余磁盘阵列 - RAID(Redundant Array of Inexpensive Disks)

    • 各种磁盘管理技术

    • RAID levels:不同RAID分类(如,RAID-0,RAID-1,RAID-5)

      • RAID-0:分块存储,提高访问速度。注意,对于小数据量,没有提升。
      • RAID-1:磁盘镜像,可靠性成本增长,读取性能成倍增长。
      • RAID-4:单独的盘存储奇偶校验位,支持从任一个数据磁盘的故障中恢复。校验盘读写非常频繁而出现瓶颈。
      • RAID-5:RAID-4的基础上,将校验数据分布式地存在各个磁盘上。
      • RAID-6:每个条带块(可以理解成数据块)有两个冗余块,支持从最多两个磁盘错误中恢复。
      • RAID嵌套:多种RAID技术的嵌套。
  • 实现

    • 软件:在操作系统内核:存储 / 卷管理

    • 硬件:RAID硬件控制器(I/O)

设备管理

I/O设备特点

三种常见的设备接口类型

  • 字符设备
    • 访问特征:以字节为单位顺序访问,每次只能输入/输出一个字节(例如,同时按下两个键盘,系统识别为他们的组合而不是两个按键)
    • I/O命令:get()put()等,通常将设备封装成一个文件,使用文件访问接口和语义
    • 如:键盘 / 鼠标,串口等
  • 块设备
    • 访问特征:均匀的数据块访问
    • I/O命令:原始I/O(可以提高访存性能,如总线记录设备不使用文件系统)或文件系统接口、将文件映射到内存中,通过内存映射文件访问
    • 如:磁盘驱动器、磁带驱动器、光驱等
  • 网络设备
    • 访问特征:格式化报文交换
    • I/O命令:sendreceive网络报文,通过网络接口支持多种网络协议
    • 如:以太网、无线、蓝牙等

同步与异步I/O

阻塞I/O

  • 读数据(read)时,进程将进入等待状态,直到完成数据读出
  • 写数据(write)时,进程将进入等待状态,直到设备完成数据写入处理

image-20240826095222486

非阻塞I/O

  • 立即从read或write系统调用返回,返回值为成功传输字节数
  • read或write的传输字节数可能为零(可能传输失败或传输不完整)

image-20240826095312787

异步I/O

  • 读数据时,使用指针标记好用户缓冲区,立即返回;稍后内核将填充缓冲区并通知用户
  • 写数据时,使用指针标记好用户缓冲区,立即返回;稍后内核将处理数据并通知用户

image-20240826095349669

I/O结构

实例:计算机中的南桥

image-20240826095458435

CPU与设备的连接

  • 设备控制器

    • CPU和I/O设备间的接口

    • 向CPU提供特殊指令和寄存器

  • I/O地址

    • CPU用来控制I/O硬件

    • 翻译为内存地址或端口号

      • I/O指令

      • 内存映射I/O

  • CPU与设备的通信方式

    • 轮询、设备中断和DMA

image-20240826095553557

I/O指令与内存映射I/O

  • I/O指令

    • 通过I/O端口号访问设备寄存器

    • 特殊的CPU指令

      • out 0x21, AL
  • 内存映射I/O

    • 设备的寄存器/存储被映射到内存地址空间中

      【TODO:存疑,课件上说是映射到物理地址中,但总感觉应该是虚拟地址,后续查证】

    • 通过内存 load/store 指令完成I/O操作

    • MMU设置映射,硬件跳线或程序在启动时设置地址

内核I/O结构

image-20240826095913921

I/O请求生存周期

image-20240826095957849

I/O数据传输

CPU与设备控制器的数据传输

  • 程序控制I/O(PIO,Programmed I/O)

    • 通过CPU的in/out或者load/store 传输所有数据

    • 特点

      • 硬件简单,编程容易

      • 消耗的CPU时间和数据量成正比

    • 适用于简单的、小型的设备I/O

  • 直接内存访问(DMA)

    • 设备控制器可直接访问系统总线

    • 控制器直接与内存互相传输数据

    • 特点

      • 设备传输数据不影响CPU

      • 需要CPU参与设置

    • 适用于高吞吐量I/O

实例:直接I/O寻址读取磁盘数据

image-20240826100230695

I/O设备通知操作系统的机制

操作系统需要了解设备状态:

  • I/O操作完成时间

  • I/O操作遇到错误

两种方式:

  • CPU主动轮询

    • I/O 设备在特定的状态寄存器中放置状态和错误信息

    • 操作系统定期检测状态寄存器

    • 特点

      • 简单

      • I/O操作频繁或不可预测时,开销大和延时长

  • 设备中断

    image-20240826100707571

    • 设备中断处理流程

      • CPU在I/O之前设置任务参数

      • CPU发出I/O请求后,继续执行其他任务

      • I/O设备处理I/O请求

      • I/O设备处理完成时,触发CPU中断请求

      • CPU接收中断,分发到相应中断处理例程

    • 特点

      • 处理不可预测事件效果好

      • 开销相对较高

    • 一些设备可能结合了轮询和设备中断

      如:高带宽网络设备

      • 第一个传入数据包到达前采用中断

      • 轮询后面的数据包直到硬件缓存为空

实例:磁盘调度与缓存

为什么需要调度

磁盘的旋转、前后移动都是机械运动操作,速度对于内存中的电子单元 慢了几个数量级,所以优化机械访问的开销是很重要提升性能的因素。

  • 读取或写入时,磁头必须被定位在期望的磁道,并从所期望的扇区开始

  • 寻道时间

    • 定位到期望的磁道所花费的时间
  • 旋转延迟

    • 从扇区的开始处到到达目的处花费的时间

image-20240826100919366

image-20240826101010172

image-20240826101018582

磁盘调度:优化寻道时间\(T_s\)

  • 通过优化磁盘访问请求顺序来提高磁盘访问性能
    • 寻道时间是性能上区别的原因
    • 对单个磁盘,会有一个I/O请求数目
    • 如果请求是随机的,那么会表现很差

先来先服务算法(FIFO)

  • 按顺序处理请求
  • 公平对待所有进程
  • 在有很多进程的情况下,接近随机调度的性能

image-20240826101436651

简单,但不高效

最短寻找时间算法(SSTF)

  • 选择从磁臂当前位置需要移动最少的I/O请求
  • 总是选择最短寻道时间

image-20240826101519569

虽然移动磁头开销会比较小,但会产生饥饿。

扫描算法(SCAN)

  • 磁臂在一个方向上移动,满足所有为完成的请求,直到磁臂到达该方向上最后的磁道
  • 调换方向

image-20240826101553432

从一端到另一端,再从另一端回到起始端,会比较公平的让所有的请求都能得到访问。

循环扫描算法(C-SCAN)

  • 限制了仅在一个方向上扫描
  • 当最后一个磁道也被访问过了后,磁臂返回到磁盘的另外一端再次进行扫描

这种单方向的方式对于某些访问请求更加公平,而且效率会更高。

C-LOOK算法

  • C-SCAN的改进版本
  • 磁臂先到达该方向上最后一个请求处,然后立即反转

N步扫描算法(N-step-SCAN)

  • 磁头粘着(Arm Stickiness)现象

    • SSTF、SCAN及CSCAN等算法中,都可能出现磁头停留在某处不动的情况

    • 如:进程反复请求对某一磁道的I/O操作

  • N步扫描算法

    • 将磁盘请求队列分成长度为 N 的子队列

    • 按FIFO算法依次处理所有子队列

    • 扫描算法处理每个队列

  • 当正在处理某子队列时,如果又出现新的磁盘I/O请求,便将新请求进程放入其他队列,这样就可避免出现粘着现象。

双队列扫描算法(FSCAN)

  • FSCAN算法实质上是N步SCAN算法的简化

    • FSCAN只将磁盘请求队列分成两个子队列
  • FSCAN算法

    • 把磁盘I/O请求分成两个队列,交替使用扫描算法处理一个队列

    • 一个是由当前所有请求磁盘I/O的进程形成的队列,由磁盘调度按SCAN算法进行处理

    • 在处理某队列期间,将新出现的所有请求磁盘I/O的进程,放入另一个等待处理的请求队列

    • 这样,所有的新请求都将被推迟到下一次扫描时处理

磁盘缓存

为什么要磁盘缓存

  • 缓存

    • 数据传输双方访问速度差异较大时,引入的速度匹配中间层
  • 磁盘缓存是磁盘扇区在内存中的缓存区

    • 磁盘缓存的调度算法很类似虚拟存储调度算法

    • 磁盘的访问频率远低于虚拟存储中的内存访问频率

    • 通常磁盘缓存调度算法会比虚拟存储复杂

单缓存与双缓存

单缓存:一方读写时,另一方只能等待。

image-20240826102002045

双缓存:有两个缓存区,一方在一个写,另一方在另一个读。之后交换。

image-20240826102010509

访问频率置换算法(Frequency-based Replacement)

问题与思路:

  • 问题

    在一段密集磁盘访问后,LFU算法的引用计数变化无法反映当前的引用情况

  • 算法思路

    • 考虑磁盘访问的密集特征,对密集引用不计数

    • 在短周期中使用LRU算法,而在长周期中使用LFU算法

算法实现:

  • 把LRU算法中的特殊栈分成三部分,并在每个缓存块增加一个引用计数

    image-20240826102215489

    • 新区域(New Section)

    • 中间区域(Middle Section)

    • 旧区域(Old Section)

  • 栈中缓存块被访问时移到栈顶;如果该块在新区域,引用计数不变;否则,引用计数加1

    image-20240826102237945

    • 在新区域中引用计数不变的目的是避免密集访问对引用计数不利影响

    • 在中间区域和旧区域中引用计数加1是为了使用LFU算法

  • 未缓存数据块读入后放在栈顶,引用计数为1

    image-20240826102322159

  • 在旧区域中引用计数最小的缓存块被置换

    • 中间区域的定义是为了避免新读入的缓存块在第一次出新区域时马上被置换,有一个过渡期
posted @   HAN_Letisl  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· [翻译] 为什么 Tracebit 用 C# 开发
· 腾讯ima接入deepseek-r1,借用别人脑子用用成真了~
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· DeepSeek崛起:程序员“饭碗”被抢,还是职业进化新起点?
· RFID实践——.NET IoT程序读取高频RFID卡/标签
点击右上角即可分享
微信分享提示