斯坦福-CS140-操作系统讲座笔记-全-

斯坦福 CS140 操作系统讲座笔记(全)

介绍

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 操作系统的演变,第 1 阶段:

    • 硬件昂贵,人力廉价

    • 一次一个用户,直接在控制台上工作

    • 第一个“操作系统”:用户共享的 I/O 子程序库

    • 简单的批处理监视器:让用户远离计算机。操作系统 = 加载和运行用户作业的程序,进行转储。

    • 数据通道、中断、I/O 和计算的重叠。

    • 内存保护和重定位实现多任务处理:多个用户共享系统

    • 操作系统必须管理交互、并发性

    • 到了 1960 年代中期,操作系统变得庞大、复杂。

    • 操作系统领域作为一个重要学科出现,具有原则

  • 操作系统的演变,第 2 阶段:

    • 硬件便宜,人力昂贵

    • 交互式分时共享

      • 花哨的文件系统

      • 响应时间、抖动问题

    • 个人电脑:计算机便宜,所以在每个终端上放一个。

    • 网络:允许计算机之间共享和通信。

    • 嵌入式设备:将计算机放入手机、立体声播放器、电视机、灯开关中

    • 所有为分时共享开发的花哨功能仍然需要吗?

  • 操作系统的未来:

    • 非常小(设备)

    • 非常大(数据中心、云)

  • 当前操作系统的特征:

    • 巨大:数百万行代码,100-1000 个工程师年

    • 复杂:异步、硬件特殊性、性能至关重要。

    • 理解不足

  • 大部分操作系统功能属于协调类别:有效、公平地让多个事物一起工作:

    • 并发性:允许多个不同任务同时进行,就像每个任务都有一台私人机器。为了跟踪一切,进程和线程被发明出来。

    • I/O 设备。不希望 CPU 在 I/O 设备工作时空闲。

    • 内存:如何让单个内存被多个进程共享?

    • 文件:允许许多不同用户的许多文件在同一物理磁盘上共享空间。

    • 网络:允许计算机组成群体一起工作。

    • 安全性:如何在保护每个参与者免受其他人滥用的同时允许交互?

线程、进程和调度

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 操作系统:原理与实践这个主题的阅读:第四章。

线程和进程

  • 线程:一个顺序执行流

    • 按顺序执行一系列指令(一次只发生一件事)。
  • 进程:一个或多个线程,以及它们的执行状态。

    • 执行状态:一切可能影响或受线程影响的东西:

      • 代码、数据、寄存器、调用堆栈、打开文件、网络连接、当天时间等。
    • 进程状态的一部分是线程私有的

    • 部分信息在进程中所有线程之间共享

  • 操作系统进程模型的演变:

    • 早期操作系统支持一次只能运行一个进程的单线程(单任务)。它们运行批处理作业(一次一个用户)。

    • 一些早期个人计算机操作系统使用单任务处理(例如 MS-DOS),但这些系统今天几乎听都没听说过。

    • 到了 20 世纪 70 年代末,大多数操作系统都是多任务系统:它们支持多个进程,但每个进程只有一个线程。

    • 在 20 世纪 90 年代,大多数系统转换为多线程:每个进程内有多个线程。

  • 进程和程序是一样的吗?

调度

  • 几乎所有计算机今天都可以同时执行多个线程:

    • 每个处理器芯片通常包含多个核心

    • 每个核心包含一个完整的 CPU,能够执行线程

    • 许多现代处理器支持超线程:每个物理核心表现得好像实际上是两个核心,因此它可以���时运行两个线程(例如,在一个线程等待缓存未命中时执行另一个线程)。

    • 例如,一个服务器可能包含 2 个 Intel Xeon E5-2670 处理器,每个处理器有 8 个支持 2 路超线程的核心。总体而言,这台计算机可以同时运行 32 个线程。

    • 可能有比核心更多的线程

    • 在任何给定时间,大多数线程不需要执行(它们正在等待某些事情)。

  • 操作系统使用进程控制块来跟踪每个进程:

    • 每个线程的执行状态(保存的寄存器等)

    • 调度信息

    • 有关此进程使用的内存信息

    • 有关打开文件的信息

    • 会计和其他杂项信息

  • 在任何给定时间,一个线程处于以下 3 种状态之一:

    • 运行中

    • 阻塞:等待事件(磁盘 I/O,传入网络数据包等)

    • 就绪:等待 CPU 时间

  • 调度程序:运行在每个核心上的操作系统的最内部部分:

    • 运行一个线程一段时间

    • 保存它的状态

    • 加载另一个线程的状态

    • 运行它...

  • 上下文切换:通过首先保存旧进程的状态,然后加载新线程的状态来更改当前在核心上运行的线程。

  • 注意:调度程序本身不是一个线程!

  • 核心一次只能做一件事。如果一个线程正在执行,调度程序不是:操作系统失去了控制。操作系统如何重新获得核心的控制?

  • 陷阱(发生在当前线程中导致控制权转移到操作系统的事件):

    • 系统调用。

    • 错误(非法指令,寻址违规等)。

    • 页面错误。

  • 中断(发生在当前线程之外的事件,导致状态切换到操作系统):

    • 在键盘上键入的字符。

    • 完成磁盘操作。

    • 定时器:确保操作系统最终获得控制。

  • 调度程序如何决定下一个要运行的线程?

    • 计划 0:从前面搜索进程表,运行第一个准备好的线程。

    • 计划 1:将准备好的线程链接成队列。调度程序从队列中获取第一个线程。当线程准备就绪时,插入到队列的末尾。

    • 计划 2:为每个线程分配优先级,根据优先级组织队列。或者,可能有多个队列,每个队列对应一个优先级类。

进程创建

  • 操作系统如何创建进程:

    • 将代码和数据加载到内存中。

    • 创建和初始化进程控制块。

    • 使用调用堆栈创建第一个线程。

    • 为线程提供“保存状态”的初始值

    • 使调度程序知道线程;调度程序“恢复”到新程序的起始点。

  • UNIX 中用于进程创建的系统调用:

    • fork 复制当前进程,带有一个线程。

    • exec 用给定可执行文件的代码和数据替换内存。不返回("返回"到新程序的起始点)。

    • waitpid 等待给定进程退出。

    • 例子:

      int pid = fork();
      if (pid == 0) {
          /* Child process  */
          exec("foo");
      } else {
          /* Parent process */
          waitpid(pid, &status, options);
      }
      
      
    • 优点:可以在调用 exec 之前修改进程状态(例如更改环境,打开文件)。

    • 缺点:浪费工作(大部分 forked 状态被丢弃)。

  • Windows 中用于进程创建的系统调用:

    • CreateProcess 结合了 fork 和 exec

      BOOL CreateProcess(
          LPCTSTR lpApplicationName,
          LPTSTR lpCommandLine,
          LPSECURITY_ATTRIBUTES lpProcessAttributes,
          LPSECURITY_ATTRIBUTES lpThreadAttributes,
          BOOL bInheritHandles,
          DWORD dwCreationFlags,
          PVOID lpEnvironment,
          LPCTSTR lpCurrentDirectory,
          LPSTARTUPINFO lpStartupInfo,
          LPPROCESS_INFORMATION lpProcessInformation
      );
      
      
    • 必须传递父子进程之间任何状态更改的参数。

  • Pintos 中的进程创建:exec 结合了 UNIX 的 fork 和 exec。

并发性

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 本主题的阅读材料来自操作系统:原理与实践:第五章至第 5.1 节。

独立和协作线程

  • 独立线程:不能影响或被宇宙的其他部分影响的线程。

    • 它的状态不会以任何方式被其他线程共享。

    • 确定性:仅由输入状态确定结果。

    • 可重现的。

    • 可以停止并继续而没有不良影响(只是时间变化)。

  • 有许多不同的方式可以在计算机上执行一组独立线程:

    • 单任务处理:每个线程在下一个开始之前运行完成。

    • 一个核心上的多任务处理,由多个线程共享。调度顺序会影响行为吗?

    • 多核心的多任务处理(多处理):在单独的核心上并行运行线程。

      • 给定线程一次只在一个核心上运行。

      • 一个线程可能在不同时间在不同核心上运行(移动状态,假设处理器是相同的)。

      • 从线程的角度来看,无法区分一个核心和多个核心。

  • 协作线程:共享状态的线程。

    • 行为是不确定的:取决于相对执行顺序,无法提前预测。

    • 行为可能是不可重现的。

  • 例子:一个线程向控制台窗口写入“ABC”,另一个同时写入“CBA”。

  • 为什么允许线程合作?

  • 协作线程的基本假设是某些操作的顺序是无关紧要的;某些操作与其他某些操作无关。例如:

    • 线程 1:A = 1;

      线程 2:B = 2;

    • 线程 1:A = B+1;

      线程 2:B = 2*B;

原子操作

  • 在我们讨论任何关于协作线程的事情之前,我们必须知道某些操作是原子的:它要么完全发生而没有中断,要么根本不发生。不能在中间被中断。

    • 几乎所有系统中的引用和赋值都是原子的。A=B 将始终读取 B 的干净值,并为 A 设置一个干净值(但对于数组或记录来说不一定是真的)。

    • 在单处理器系统中,中断之间的任何操作都是原子的。

    • 如果没有原子操作,你无法创建一个。幸运的是,硬件设计者给了我们原子操作。

    • 如果你有任何原子操作,你可以用它来生成更高级的结构,并使并行程序正确运行。这是我们在这门课上要采取的方法。

“牛奶太多”问题

  • 基本问题:

              Person A                       Person B
    3:00      Look in fridge: no milk
    3:05      Leave for store
    3:10      Arrive at store                Look in fridge: no milk
    3:15      Leave store                    Leave home
    3:20      Arrive home, put milk away     Arrive at store
    3:25                                     Leave store
    3:30                                     Arrive home: too much milk!
    
    
  • 正确的行为是什么?

  • 更多定义:

    • 同步:使用原子操作确保协作线程的正确操作。

    • 临界区:代码段或操作集合,在其中一次只能执行一个线程。例如购物。

    • 互斥:用于创建临界区的机制。

  • 通常,通过 锁定 机制来实现互斥:阻止其他人做某事。例如,在购物之前,在冰箱上留个便条:如果有备注就不要购物。

  • 第一次尝试计算机化购买牛奶(假设原子读写):

    1 if (milk == 0) {
    2   if (note == 0) {
    3     note = 1;
    4     buy_milk();
    5     note = 0;
    6   }
    7 }
    
    
  • 第二次尝试:更改备注的意义。如果没有备注,A 就买,如果有备注,B 就买。

    Thread A
    1 if (note == 0) {
    2   if (milk == 0) {
    3     buy_milk();
    4   }
    5   note = 1;
    6 }
    
    Thread B
    1 if (note == 1) {
    2   if (milk == 0) {
    3     buy_milk();
    4   }
    5   note = 0;
    6 }
    
    
  • 第三次尝试:A 和 B 使用单独的备注。

    Thread A
    1 noteA = 1;
    2 if (noteB == 0) {
    3   if (milk == 0) {
    4     buy_milk();
    5   }
    6 }
    7 noteA = 0;
    
    Thread B
    1 noteB = 1;
    2 if (noteA == 0) {
    3   if (milk == 0) {
    4     buy_milk();
    5   }
    6 }
    7 noteB = 0;
    
    
  • 第四次尝试:只需找到一种方法来决定当两者都留下备注时谁来买牛奶(必须有人留下来确保任务完成):

    Thread B
    1 noteB = 1;
    2 while (noteA == 1) {
    3   // do nothing;
    4 }
    5 if (milk == 0) {
    6 	buy_milk();
    7 }
    8 noteB = 0;
    
    
    • 这个解决方案有效,但有两个缺点:

      • 不对称(且复杂)的代码。

      • 当 B 在等待时,它在消耗资源(忙等待)。

    • 要了解一个没有忙等待的对称解决方案,请参阅彼得森算法

需求分页

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 操作系统:原理与实践这一主题的阅读材料:第九章。

  • 需求分页:进程的虚拟地址空间不需要一次性全部加载到主内存中。每个页面可以是以下情况之一:

    • 在内存中(物理页框)

    • 在磁盘上(后备存储

页错误

  • 当进程引用位于后备存储器中的页面时会发生什么?

    • 对于位于后备存储器中的页面,页表条目中的存在位被清除。

    • 如果不存在设置,则对页面的引用会导致陷入操作系统。

    • 这些陷阱称为页错误

    • 要处理页面错误,操作系统

      • 找到内存中的一个空闲页框

      • 从后备存储器中读取页面到页框中

      • 更新页面表条目,设置存在

      • 恢复线程的执行

  • 操作系统如何确定生成错误的页面?

    • x86:硬件保存导致故障的虚拟地址(CR2 寄存器)

    • 在早期的机器上,操作系统只获得故障指令的地址,必须模拟指令并尝试每个地址找到生成故障的地址。

  • 在页面错误后重新启动进程执行很棘手,因为错误可能发生在指令的中间。

    • 如果指令是幂等的,只需重新启动错误指令(硬件在页面错误期间保存指令地址)。

    • 非幂等指令更难重新启动:

      MOV +(SP), R2
      
      
    • 没有硬件支持,可能无法安全地在页面错误后恢复进程。硬件必须跟踪副作用:

      • 页面错误期间是否撤消所有副作用?

      • 保存关于副作用的信息,用于重新启动“中间”的指令。

页面获取

  • 一旦基本的页面错误机制运行起来,操作系统需要做出两个调度决策:

    • 页面获取:何时将页面带入内存。

    • 页面替换:要从内存中抛出的页面。

  • 总体目标:使物理内存看起来比实际更大。

    • 局部性:大多数程序大部分时间使用其代码和数据的一小部分。

    • 保留正在使用的信息在内存中。

    • 在磁盘上的分页文件(也称为后备存储或交换空间)中保留未使用的信息

    • 理想情况下:分页产生具有主存性能和磁盘成本/容量的内存系统!

  • 大多数现代操作系统使用需求获取

    • 开始进程时不加载页面,直到引用它时才将页面加载到内存中。

    • 进程的页面分为三组:

      • 只读代码页面:在需要时从可执行文件中读取。

      • 初始化的数据页面:首次访问时,从可执行文件中读取。一旦加载,保存到分页文件中,因为内容可能已更改。

      • 未初始化的数据页面:首次访问时,只需将内存清除为所有零。在分页时,保存到分页文件中。

  • 预取:尝试预测何时需要页面,并提前加载它们以避免页面错误。

    • 需要预测未来,所以很难做到。

    • 一种方法:当发生页面错误时,读取多个页面而不仅仅是一个(如果程序按顺序访问内存,则胜出)。

页面替换

  • 一旦所有内存都被使用,每次发生页面错误时都需要丢弃一个页面。

  • 随机:随机选择任意页面(效果出奇的好!)

  • 先进先出(FIFO):丢弃在内存中存在时间最长的页面。

  • MIN:最佳算法要求我们预测未来。

  • 最近最少使用(LRU):利用过去来预测未来。

  • 实现 LRU:需要硬件支持来跟踪最近使用的页面。

    • 完美的 LRU?

      • 为每个页面保留一个硬件寄存器,在每次内存引用时将系统时钟存储到该寄存器中。

      • 为选择放置页面,扫描所有页面以找到最老的时钟。

      • 在分页的早期,硬件成本过高;此外,在替换过程中扫描所有页面也很昂贵。

      • 没有机器实际实现过这一点。

    • 当前计算机采用一种高效的近似方法。只需找到一个旧页面,不一定是最老的。

  • 时钟算法(也称为二次机会算法):为每个页面帧保留引用位,硬件在读取或写入页面时设置引用位。选择放置页面的方法:

    • 按顺序(循环方式)遍历页面。

    • 如果下一个页面已被引用,则不要替换它;只需清除引用位并继续到下一个页面。

    • 如果自上次检查以来未引用该页面,则替换该页面。

  • 脏位:每个页面帧一个位,当页面被修改时由硬件设置。如果替换了脏页面,则必须在重用其页面帧之前将其写入磁盘。

  • 时钟算法通常会额外偏好脏页面。例如,如果页面的引用位未设置,但脏位已设置,则现在不要替换此页面,而是清除脏位并开始将页面写入磁盘。

  • 空闲页面池:许多系统保留一个小的干净页面列表,这些页面可以立即用于替换。

    • 在替换过程中,选择在空闲池中存在时间最长的页面,然后运行替换算法以向空闲池添加新页面。

    • 空闲池中的页面的当前位关闭,因此对这些页面的任何引用都会导致页面错误

    • 如果发生页面错误,且页面在空闲池中,则将其从空闲池中移除并重新投入使用;比从磁盘读取要快得多。

    • 如果我们做出了不良的页面替换决策,提供了额外的恢复机会。

  • 当系统中有多个进程运行时如何实现页面替换?

    • 全局替换:所有进程的所有页面都被合并到一个替换池中。每个进程与所有其他进程竞争页面帧。

    • 每进程替换:每个进程都有一个单独的页面池。一个进程中的页面错误只能替换该进程的一个页面帧。这消除了其他进程的干扰。

    • 不幸的是,每个进程的替换会产生一个新的调度困境:为每个进程分配多少页框?如果这个决定做错了,可能会导致内存使用效率低下。

    • 大多数系统使用全局替换。

抖动和工作集

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 来自操作系统:原理与实践第 9.7 节的本主题阅读。

  • 通常,如果一个线程发生页面错误并必须等待页面从磁盘读取,操作系统会在 I/O 发生时运行另一个线程。因此页面错误是“免费”的?

  • 如果内存过度分配会发生什么?

    • 假设当前线程活动使用的页面不能全部适应物理内存。

    • 每次页面错误导致一个活动页面移至磁盘,因此很快会发生另一个页面错误。

    • 系统将花费所有时间读写页面,而不会完成太多工作。

    • 这种情况称为抖动;在早期需求分页系统中是一个严重的问题。

  • 如何处理抖动?

    • 如果单个进程太大而无法放入内存,操作系统无能为力。该进程将简单地抖动。

    • 如果问题是由几个进程的总和引起的:

      • 弄清楚每个进程需要多少内存以防止抖动。这称为其工作集

      • 一次只允许少数进程执行,以使其工作集适合内存。

  • 页面错误频率:计算工作集的一种技术

    • 在任何给定时间,每个进程被分配一个固定数量的物理页面帧(假设每个进程替换)。

    • 监控每个进程发生页面错误的速率。

    • 如果某个进程的速率过高,假设其内存已过度分配;增加其内存池的大小。

    • 如果某个进程的速率过低,假设其内存池可以缩小。

  • 使用工作集进行调度

    • 如果所有进程的所有工作集之和超过内存大小,则暂停运行其中一些进程一段时间。

    • 将进程分为两组:活动和非活动:

      • 当进程活动时,其整个工作集必须始终在内存中:永远不要执行其工作集不在驻留的线程。

      • 当进程变为非活动状态时,其工作集可以迁移到磁盘。

      • 不会为非活动进程的线程调度执行。

      • 所有活动进程的集合称为平衡集

      • 系统必须有一个机制逐渐将进程移入和移出平衡集。

      • 随着工作集的变化,平衡集必须进行调整。

  • 这些解决方案都不是很好:

    • 一旦进程变为非活动状态,它必须保持非活动状态很长时间(许多秒),这会导致用户响应不佳。

    • 调度平衡集是棘手的。

  • 实际上,今天的操作系统不太担心抖动:

    • 使用个人计算机,用户可以注意到抖动并自行处理:

      • 通常,只需购买更多内存

      • 或者,手动管理平衡集

    • 内存足够便宜,没有必要在内存略微过度分配的范围内运行机器;最好只是购买更多内存。

    • 对于具有数十或数百用户的分时共享计算机而言,抢占资源是一个更大的问题:

      • 为什么我要停止我的进程,只是为了让你取得进展呢?

      • 系统必须自动处理抢占。

存储设备

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 操作系统:原理与实践 第 12.1 节中关于此主题的阅读。

磁盘(硬盘)

  • 基本几何:

    • 1-5 盘片,每个表面都有磁性涂层

    • 盘片以 5000-15000 RPM 旋转

    • 执行器臂 定位 磁头,可以在磁性表面上读写数据。

    • 磁盘包的整体尺寸:1-8 英寸。

  • 磁盘数据的组织:

    • 对应于执行器臂的特定位置的圆形 轨道

    • 当前的典型密度:每英寸径向 200,000 条轨道。

    • 轨道分为 512 字节的 扇区。典型轨道包含几千个扇区。

    • 典型总驱动器容量:100GB-2TB

      • 100GB 约等于 50M 页的双倍行距文本。
    • 磁盘技术是最快发展的技术之一:容���增长速度超过摩尔定律。

  • 读取和写入:

    • 寻道:将执行器臂移动到所需轨道上方的位置。典型寻道时间:2-10ms。

    • 选择特定磁头。

    • 旋转延迟:等待所需扇区经过磁头。平均半个磁盘旋转(7500RPM 时为 4ms)

    • 传输:在磁头下经过时读取或写入一个或多个扇区。典型传输速率:100-150 MBytes/秒。

    • 延迟 指寻道时间加上旋转延迟的总和;通常为 5-10ms。

  • 磁盘的 API:

    read(startSector, sectorCount, physAddr)
    write(startSector, sectorCount, physAddr)
    
    
  • 在旧日子里,磁盘的轨道和表面结构对软件可见:

    read(track, sector, surface, sectorCount, physAddr)
    
    
  • 现在轨道结构隐藏在磁盘内部:

    • 内部轨道的扇区少于外部轨道

    • 如果某些扇区损坏,磁盘软件会自动将它们重新映射到备用扇区。

卷盘式磁带

  • 旧技术,在 1960 年代和 1970 年代很受欢迎。

  • 磁带:

    • 磁带上的 9 条轨道(一个字节加奇偶校验位),

    • 宽 1/2 英寸,长 2400 英尺

    • 可变长度记录(20-30000 字节);读取或写入块,但不能在中间写入

    • 记录密度高达每英寸 6250 字节(磁带最大为 180 MBytes)

    • 磁带移动速度为 20-200 英寸/秒(最高可达几 Mbytes/秒)

    • 有趣的物理设计,例如真空柱在快速启动和停止期间缓冲磁带。

与 I/O 设备通信

  • 设备寄存器

    • 每个设备在机器的物理地址空间中显示为几个称为 设备寄存器 的字。

    • 操作系统读取和写入设备寄存器以控制设备。

    • 设备寄存器中的位有 3 个目的:

      • CPU 提供给设备的参数(例如要读取的第一个扇区的数量)

      • 设备向 CPU 提供的状态位(例如“操作完成”或“发生错误”)。

      • 控制位由 CPU 设置(例如“启动磁盘读取”)以启动操作。

    • 设备寄存器不像普通内存位置那样行为:

      • “开始操作”位可能始终读取为 0。

      • 位可能在未经 CPU 写入的情况下发生变化(例如“操作完成”位)。

  • 编程 I/O:所有与 I/O 设备的通信都通过设备寄存器进行。

    • CPU 写入设备寄存器以启动操作(例如读取)

    • CPU 轮询设备寄存器中的准备位

    • 当操作完成时,设备设置为就绪。

    • CPU 从一个或多个设备寄存器读取数据,将数据写入内存。

    • 这种方法存在问题:

      • CPU 在等待数据准备就绪时浪费时间;一次只能让一个设备保持繁忙。

      • CPU 在调解所有数据传输时成本高昂;对于一些快速设备,CPU 跟不上设备的速度。

  • 中断:允许 CPU 在设备操作时做其他工作。

    • CPU 开始 I/O 操作,然后处理其他事务。

    • 当设备需要注意(操作完成)时,它会中断 CPU:

      • 强制将特定地址调用到内核。
    • 操作系统找出哪个设备发生了中断,为该设备提供服务。

    • 操作系统从中断返回到之前正在处理的任务。

    • 中断使操作系统更加高效;例如,可以同时让许多设备保持繁忙,同时也运行用户代码。

  • 直接内存访问(DMA):

    • 设备可以在没有 CPU 帮助的情况下将数据复制到内存,并从内存中复制数据。

    • CPU 在开始操作之前将缓冲区地址加载到设备寄存器中(例如,从磁盘读取数据的位置)。

    • 设备直接在内存中移动数据。

    • 当传输完成时,设备向系统发出中断。

    • 如今,DMA 是 I/O 设备的标准(控制器硬件价格便宜)。

锁和条件变量

CS 140 课程笔记

2014 年春季

约翰·奥斯特豪特

  • 操作系统:原理与实践 中有关此主题的阅读材料:5.2-5.4 节。

  • 需要:提供更高级别的同步机制,以提供

    • 互斥:易于创建临界区

    • 调度:阻塞线程直到发生某个期望的事件

  • :只能由单个线程在任何给定时间拥有的对象。锁的基本操作:

    • acquire:将锁标记为当前线程所拥有;如果其他线程已经拥有锁,则首先等待锁被释放。锁通常包含一个队列来跟踪多个等待线程。

    • release:将锁标记为自由状态(它目前必须由调用线程拥有)。

  • 使用 Pintos API 的带有锁的过多牛奶解决方案:

    struct lock l;
    ...
    lock_acquire(&l);
    if (milk == 0) {
      buy_milk();
    }
    lock_release(&l);
    
    
  • 更复杂的例子:生产者/消费者。

    • 生产者向缓冲区中添加字符

    • 消费者从缓冲区中移除字符

    • 字符将以添加的相同顺序被移除

    • 版本 1:

      char buffer[SIZE];
      int count = 0, putIndex = 0, getIndex = 0;
      struct lock l;
      lock_init(&l);
      
      void put(char c) {
          lock_acquire(&l);
          count++;
          buffer[putIndex] = c;
          putIndex++;
          if (putIndex == SIZE) {
              putIndex = 0;
          }
          lock_release(&l);
      }
      
      char get() {
          char c;
          lock_acquire(&l);
          count--;
          c = buffer[getIndex];
          getIndex++;
          if (getIndex == SIZE) {
              getIndex = 0;
          }
          lock_release(&l);
          return c;
      }
      
      
    • 版本 2(处理空/满情况):

      char buffer[SIZE];
      int count = 0, putIndex = 0, getIndex = 0;
      struct lock l;
      lock_init(&l);
      
      void put(char c) {
          lock_acquire(&l);
          while (count == SIZE) {
              lock_release(&l);
              lock_acquire(&l);
          }
          count++;
          buffer[putIndex] = c;
          putIndex++;
          if (putIndex == SIZE) {
              putIndex = 0;
          }
          lock_release(&l);
      }
      
      char get() {
          char c;
          lock_acquire(&l);
          while (count == 0) {
              lock_release(&l);
              lock_acquire(&l);
          }
          count--;
          c = buffer[getIndex];
          getIndex++;
          if (getIndex == SIZE) {
              getIndex = 0;
          }
          lock_release(&l);
          return c;
      }
      
      

条件变量

  • 同步机制不仅需要互斥,还需要一种等待另一个线程执行某些操作的方法(例如等待将字符添加到缓冲区中)

  • 条件变量:用于等待特定条件变为真(例如缓冲区中的字符)。

    • wait(condition, lock):释放锁,使线程进入睡眠状态,直到条件被信号激活;当线程再次唤醒时,在返回之前重新获取锁。

    • signal(condition, lock):如果有任何线程在条件上等待,则唤醒其中一个。调用者必须持有锁,该锁必须与 wait 调用中使用的锁相同。

    • broadcast(condition, lock):与 signal 相同,唤醒所有等待的线程。

    • 注意:在发出信号后,发出信号的线程保留锁,被唤醒的线程进入等待锁的队列。

    • 警告:在 cond_wait 后,线程唤醒时不能保证所需的条件仍然存在:另一个线程可能已经偷偷地进入。

  • 生产者/消费者,第三版(带有条件变量):

    char buffer[SIZE];
    int count = 0, putIndex = 0, getIndex = 0;
    struct lock l;
    struct condition dataAvailable;
    struct condition spaceAvailable;
    
    lock_init(&l);
    cond_init(&dataAvailable);
    cond_init(&spaceAvailable);
    
    void put(char c) {
        lock_acquire(&l);
        while (count == SIZE) {
            cond_wait(&spaceAvailable, &l);
        }
        count++;
        buffer[putIndex] = c;
        putIndex++;
        if (putIndex == SIZE) {
            putIndex = 0;
        }
        cond_signal(&dataAvailable, &l);
        lock_release(&l);
    }
    
    char get() {
        char c;
        lock_acquire(&l);
        while (count == 0) {
            cond_wait(&dataAvailable, &l);
        }
        count--;
        c = buffer[getIndex];
        getIndex++;
        if (getIndex == SIZE) {
            getIndex = 0;
        }
        cond_signal(&spaceAvailable, &l);
        lock_release(&l);
        return c;
    }
    
    

监视器

  • 当锁和条件变量一起使用时,结果被称为监视器

    • 一组操作共享数据结构的过程。

    • 必须在访问共享数据时持有的一个锁(通常每个过程在开始时获取锁,在返回之前释放锁)。

    • 一个或多个条件变量用于等待。

  • 除了锁和条件变量之外,还有其他的同步机制。一定要阅读书籍或 Pintos 文档中关于信号量的内容。

调度

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 有关此主题的阅读材料来自操作系统:原理与实践:第七章直至第 7.2 节。

  • 资源分为两类:

    • 非可抢占性:一旦给予,直到线程归还为止都不能再次使用。例如文件空间、终端,可能还有内存。

    • 可抢占性:处理器或 I/O 通道。可以取走资源,将其用于其他用途,然后稍后归还。

  • 操作系统对资源做出两种相关的决策:

    • 分配:谁得到什么。给定一组对资源的请求,应该给哪些进程分配哪些资源,以便最有效地利用这些资源?

    • 调度:它们可以持续多久。当请求的资源比可以立即授予的资源更多时,应该以何种顺序处理这些请求?例如,处理器调度(一个处理器,多个线程),虚拟内存系统中的内存调度。

  • 分派/调度线程状态的提醒:

    • 运行中

    • 就绪:等待核心可用

    • 阻塞:等待其他事件(磁盘 I/O,传入的网络数据包等)。

简单调度算法

  • 先来先服务(FCFS)调度(也称为 FIFO 或非抢占性):

    • 将所有就绪线程保留在一个称为就绪队列的单个列表中。

    • 当线程变为就绪状态时,将其添加到就绪队列的末尾。

    • 运行队列中的第一个线程,直到它退出或阻塞。

    • 问题:一个线程可以垄断一个核心。

  • 解决方案:限制线程在没有上下文切换的情况下运行的最长时间。这段时间称为时间片

  • 轮转调度:运行一个时间片的线程,然后返回到就绪队列的末尾。每个线程都获得核心的平等份额。大多数系统使用这种的某种变体。

    • 典型的时间片值:10-100 毫秒

    • 时间片的长度也称为时间量子

  • 我们如何确定调度算法是否好?

    • 使用户满意(最小化响应时间)。

    • 高效地利用资源:

      • 充分利用:保持核心和磁盘忙碌。

      • 低开销:最小化上下文切换

    • 公平性(公平地分配 CPU 周期)

  • 轮转调度比先来先服务好吗?

  • 最佳调度:STCF(最短完成时间优先);也称为 SJF(最短作业优先)。

    • 运行将最快完成的线程,而且不中断它。

    • STCF 的另一个优点:提高了整体资源利用率。

      • 假设一些作业是 CPU 密集型,一些是 I/O 密集型。

      • STCF 将优先考虑 I/O 密集型作业,这样可以使磁盘/网络尽可能忙碌。

  • 关键思想:可以利用过去的性能来预测未来的性能。

    • 行为往往是一致的。

    • 如果一个进程长时间执行而没有阻塞,那么它很可能会继续执行。

基于优先级的调度

  • 优先级:大多数真实的调度程序支持每个线程的优先级:

    • 总是运行具有最高优先级的线程。

    • 如果出现相等情况,优先选择最高优先级线程中的轮转调度。

    • 使用优先级来实现各种调度策略(例如,近似最短剩余时间优先)

  • 指数队列(或多级反馈队列):同时解决效率和响应时间问题。

    • 每个优先级级别都有一个就绪队列。

    • 较低优先级队列具有较大的时间片(随着优先级降低,时间片加倍)

    • 新的可运行线程从最高优先级队列开始。

    • 如果在不阻塞的情况下达到时间片的末尾,则移至下一个较低的队列。

    • 结果:I/O 密集型线程保持在最高优先级队列中,CPU 密集型线程迁移到较低优先级队列

    • 这种方法存在哪些问题?

  • 4.4 BSD 调度器(用于第一个项目):

    • 保持每个线程的最近 CPU 使用情况的信息

    • 给予最近使用 CPU 时间最少的线程最高优先级。

    • 交互和 I/O 密集型线程将使用很少的 CPU 时间并保持高优先级。

    • 随着 CPU 时间的累积,CPU 密集型线程最终会获得较低的优先级。

多处理器调度

  • 多处理器调度基本上与单处理器调度相同:

    • 在所有核心之间共享调度数据结构。

    • 在 k 个核心上运行 k 个最高优先级线程。

    • 当一个线程变为可运行状态时,查看其优先级是否高于当前正在运行的最低优先级线程。如果是,则抢占该线程。

    • 然而,如果有很多核心,单个就绪队列可能会导致争用问题。

  • 多处理器的两个特殊问题:

    • 处理器亲和性:

      • 一旦一个线程在特定核心上运行,将其移动到另一个核心是昂贵的(硬件缓存将需要重新加载)。

      • 多处理器调度器通常会尽量使一个线程尽可能在同一个核心上运行,以最小化这些开销。

    • 团体调度:

      • 如果进程内的线程频繁通信,那么单独运行一个线程而不与其他线程通信是没有意义的:它将立即在与另一个线程通信时阻塞。

      • 解决方案:同时在不同核心上运行一个进程的所有线程,以便它们可以更有效地进行通信。

      • 这被称为团体调度

      • 即使一个线程阻塞,将其保留在其核心上加载的假设下可能是有意义的,假设它将在不久的将来解除阻塞。

结论

  • 调度算法不应影响系统的行为(无论调度如何,结果都相同)。

  • 然而,这些算法确实会影响系统的效率和响应时间。

  • 最佳方案是自适应的。要达到最佳,我们必须预测未来。

文件系统

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 《操作系统:原理与实践》这一主题的阅读:第十一章,第 13.3 节(直至第 561 页)。

  • 现代文件系统解决的问题:

    • 磁盘管理:

      • 快速访问文件(最小化寻址)

      • 用户之间共享空间

      • 高效利用磁盘空间

    • 命名:用户如何选择文件?

    • 保护:用户之间的隔离,受控共享。

    • 可靠性:信息必须在操作系统崩溃和硬件故障时幸存。

  • 文件:存储在持久存储设备(如磁盘)上的命名字节集合。

  • 文件访问模式:

    • 顺序:信息按顺序处理,一个字节接着一个字节。

    • 随机访问:可以直接寻址文件中的任何字节,而无需通过其前导。例如,需求分页的数据集,还有数据库。

    • 键控(或索引):搜索具有特定内容的块,例如哈希表,关联数据库,字典。通常由数据库提供,而不是操作系统。

  • 要考虑的问题:

    • 大多数文件很小(几千字节或更少),因此每个文件的开销必须很低。

    • 大多数磁盘空间用于大文件。

    • 许多 I/O 操作用于大文件,因此大文件的性能必须良好。

    • 文件可能随时间不可预测地增长。

文件描述符

  • 包含有关文件的信息的操作系统数据结构(在 Linux 中称为inode

    • 与文件数据一起存储在磁盘上。

    • 在文件打开时保留在内存中。

  • 文件描述符中的信息:

    • 文件占用的扇区

    • 文件大小

    • 访问时间(最后读取,最后写入)

    • 保护信息(所有者 ID,组 ID 等)

  • 磁盘扇区应如何用于表示文件的字节?

  • 连续分配(也称为“基于范围的”):像分段内存一样分配文件(连续的扇区运行)。保留未使用磁盘区域的空闲列表。创建文件时,让用户指定其长度,一次性分配所有空间。描述符包含位置和大小。

    • 优点:

      • 易于访问,顺序和随机

      • 简单

      • 少量寻址

    • 缺点:

      • 碎片化将使磁盘空间难以有效利用;大文件可能不可能

      • 难以预测文件创建时的需求。例如:IBM OS/360。

  • 链接文件

    • 将磁盘分成固定大小的块(512 字节?)

    • 保持所有空闲块的链接列表。

    • 在文件描述符中,只需保留指向第一个块的指针。

    • 文件的每个块包含指向下一个块的指针。

    • 优点?

    • 缺点?示例(或多或少):TOPS-10,Xerox Alto。

  • Windows FAT:

    • 类似于链接分配,但不要将链接保留在块本身。

    • 将所有文件的链接保存在称为文件分配表的单个表中

      • 表在正常操作期间驻留在内存中

      • 每个 FAT 条目是文件中下一个块的磁盘扇区号

      • “文件中的最后一个块”,“空闲块”的特殊值

      • 文件描述符存储文件中第一个块的编号,大小

    • 最初,每个 FAT 条目为 16 位。

    • FAT32 支持更大的磁盘:

      • 每个条目有 28 位扇区号

      • 磁盘地址指向:相邻扇区的组合。

      • 簇大小为 2-32 K 字节;对于任何特定的磁盘分区是固定的。

    • 优点?

    • 缺点?

  • 索引文件:为每个��件保留一个块指针数组。

    • 文件创建时必须声明最大长度。

    • 分配数组来保存指向所有块的指针,但不分配块。

    • 在文件写入时动态填充指针。

    • 优点?

    • 缺点?

  • 多级索引(4.3 BSD Unix):

    • 块大小为 4 K 字节。

    • 文件描述符=14 个块指针,初始为 0(“无块”)。

    • 前 12 个指向数据块(直接块)。

    • 下一个条目指向一个间接块(包含 1024 个 4 字节块指针)。

    • 最后一个条目指向一个双间接块

    • 文件的最大长度是固定的,但很大。

    • 直接块在需要时才分配。

    • 优点?

块缓存

  • 使用主存的一部分来保留最近访问的磁盘块。

  • 最近最少使用替换。

  • 经常引用的块(例如,大文件的间接块)通常在缓存中。

  • 这解决了对大文件的慢速访问问题。

  • 最初,块缓存是固定大小的。

  • 随着内存变得更大,块缓存也变得更大。

  • 许多系统现在统一块缓存和虚拟内存页池:任何页面都可以用于任何一个,基于最近最少使用的访问。

  • 当缓存中的块被修改时会发生什么?

    • 同步写入:立即写入磁盘。

      • 安全:如果机器崩溃,数据不会丢失。

      • 速度慢:进程无法继续直到磁盘 I/O 完成。

      • 可能是不必要的:

        • 对同一块进行许多小写入。

        • 一些文件被快速删除(例如,临时文件)。

    • 延迟写入:不立即写入磁盘:

      • 等待一段时间(30 秒?)以防有更多对块的写入或块被删除。

      • 快速:写入立即返回。

      • 危险:系统崩溃后可能丢失数据。

空闲空间管理

  • 管理磁盘的空闲空间:许多早期系统只是使用一个空闲块的链表。

    • 每个块包含许多指向空闲块的指针,以及指向下一个指针块的指针。

    • 开始时,空闲列表是排序的,因此文件中的块是连续分配的。

    • 空闲列表很快变得混乱,因此文件分布在整个磁盘上。

  • 4.3 BSD 对空闲空间的处理方法:位图

    • 维护一个位数组,每个块对应一个位。

    • 1 表示块是空闲的,0 表示块正在使用。

    • 在分配期间,搜索位图以找到与文件的上一个块接近的块。

    • 如果磁盘未满,这通常运行得相当好。

    • 如果磁盘几乎满了,这将变得非常昂贵,并且不会产生很多局部性。

    • 解决方案:不要让磁盘填满!

      • 假装磁盘容量比实际容量少 10%。

      • 如果磁盘使用率达到 90%,告知用户磁盘已满,并禁止写入更多数据。

块大小

  • 许多早期文件系统(例如 Unix)使用的块大小为 512 字节(一个扇区)。

    • I/O 效率低:更多不同的传输,因此更多的寻道。

    • 更庞大的文件描述符:只有 128 个指针在一个间接块中(指针将占据磁盘空间的 1%)。

  • 增加块大小(例如,在 FAT32 中使用 2KB 簇)?

  • 4.3BSD 解决方案:多个块大小

    • 大块为 4 K 字节;大多数块都很大

    • 碎片是 512 字节的倍数,适合放在一个大块内

    • 文件中的最后一个块可能是一个碎片。

    • 一个大块可以容纳来自多个文件的碎片。

    • 空闲块的位图基于碎片。

磁盘调度

  • 如果有几个磁盘 I/O 等待执行,最佳执行顺序是什么?

    • 目标是最小化寻道时间。
  • 先来先服务(FCFS,FIFO):简单,但对优化寻址没有帮助。

  • 最短寻道时间优先(SSTF):

    • 选择下一个请求尽可能靠近上一个请求。

    • 有助于最小化寻址,但可能导致某些请求饥饿。

  • 扫描("电梯算法")。

    • 与 SSTF 相同,只是磁头在磁盘上沿着一个方向移动。

    • 一旦到达磁盘边缘,就寻找到最远处的块并重新开始。

目录和链接

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 此主题的阅读材料来自《操作系统:原理与实践》:第 13.1-13.2 节。

  • 命名:用户如何引用他们的文件?操作系统如何根据名称找到文件?

  • 第一步:文件描述符必须存储在磁盘上,以便它在系统重新启动后仍然存在。

  • 早期的 UNIX 版本:所有描述符都存储在固定大小的数组中。

  • 最初,整个描述符数组位于磁盘的外缘。结果:描述符和文件数据之间的长时间查找。

  • 后来的改进:

    • 将描述符数组放在磁盘中间。

    • 许多小的描述符数组分布在磁盘上,因此描述符可以靠近文件数据。

  • 磁盘初始化时,描述符的空间是固定的,无法更改。

  • UNIX/Linux/Pintos 术语:

    • 文件描述符称为i-节点

    • 描述符数组中的 i-节点索引:i-编号。操作系统内部使用 i-编号作为文件的标识符。

  • 当文件打开时,其描述符保存在主存储器中。当文件关闭时,描述符会存储回磁盘。

  • 文件命名:用户希望使用文本名称引用文件。使用称为目录的特殊磁盘结构将名称映射到描述符索引。

  • 目录管理的早期方法:

    • 整个磁盘只有一个目录:

      • 如果一个用户使用特定名称,则其他人都不能使用。

      • 许多早期个人计算机是这样工作的。

    • 每个用户只有一个目录(例如 TOPS-10):

      • 避免用户之间的问题,但仍然使信息组织变得困难。
  • 现代系统支持分层目录结构。UNIX/Linux 方法:

    • 目录与常规文件一样存储在磁盘上(即,文件描述符具有 14 个指针等),只是文件描述符设置了特殊标志位以指示它是一个目录。

    • 每个目录以无特定顺序包含<名称i-编号>对。

    • i-编号指向的文件可能是另一个目录。因此,得到层次树结构。名称使用斜杠分隔树的级别。

    • 有一个特殊的目录,称为根目录。这个目录没有名字;它的 i-编号为 2(i-编号 0 和 1 有其他特殊用途)。

    • 在某些系统上,用户程序可以像常规文件一样读取目录。

    • 只有操作系统可以写入目录。

工作目录

  • 不断为所有文件指定完整路径名是很繁琐的。

  • 让操作系统记住每个进程一个特殊的目录,称为工作目录

  • 如果文件名不以"/"开头,则从工作目录开始查找。

  • 以"/"开头的名称从根目录开始查找。

链接

  • UNIX 硬链接

    • 可能有多个目录条目指向单个文件。

    • UNIX 使用文件描述符中的引用计数来跟踪引用文件的硬链接。

    • 当最后一个目录条目消失时,文件被删除。

    • 必须防止循环。

  • 符号链接

    • 一个内容是另一个文件名的文件。

    • 存储在磁盘上,像普通文件一样,但在描述符中设置了特殊标志。

    • 如果在文件查找过程中遇到符号链接,则切换到符号链接中指定的目标,然后从那里继续查找。

实现锁

CS 140 课程讲义

2014 年春季

John Ousterhout

  • 本主题来自操作系统:原理与实践的阅读:第 5.5 节。

  • 如何在操作系统内部实现锁和条件变量?

  • 单处理器解决方案:只需禁用中断。

    struct lock {
        int locked;
        struct queue q;
    };
    
    void lock_acquire(struct lock *l) {
        intr_disable();
        if (!l->locked) {
            l->locked = 1;
        } else {
            queue_add(&l->q, thread_current());
            thread_block();
        }
        intr_enable();
    }
    
    void lock_release(struct lock *l) {
        intr_disable();
        if (queue_empty(&l->q) {
            l->locked = 0;
        } else {
            thread_unblock(queue_remove(&l->q));
        }
        intr_enable();
    }
    
    
  • 在多处理器上实现锁:关闭中断还不够。

    • 硬件提供某种原子性的读-修改-写指令,可用于构建更高级别的同步操作,如锁。

    • 例子: 交换:原子性地读取内存值并用给定值替换它:返回旧值。

  • 尝试 #1:

    struct lock {
        int locked;
    };
    
    void lock_acquire(struct lock *l) {
        while (swap(&l->locked, 1)) {
            /* Do nothing */
        }
    }
    
    void lock_release(struct lock *l) {
        l->locked = 0;
    }
    
    
  • 尝试 #2:

    struct lock {
        int locked;
        struct queue q;
    };
    
    void lock_acquire(struct lock *l) {
        if (swap(&l->locked, 1) != 0) {
            queue_add(&l->q, thread_current());
            thread_block();
        }
    }
    
    void lock_release(struct lock *l) {
        if (queue_empty(&l->q) {
           l->locked = 0;
        } else {
            thread_unblock(queue_remove(&l->q));
        }
    }
    
    
  • 尝试 #3:

    struct lock {
        int locked;
        struct queue q;
        int sync;         /* Normally 0\. */
    };
    
    void lock_acquire(struct lock *l) {
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (!l->locked) {
            l->locked = 1;
            l->sync = 0;
        } else {
            queue_add(&l->q, thread_current());
            l->sync = 0;
            thread_block();
        }
    }
    
    void lock_release(struct lock *l) {
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (queue_empty(&l->q) {
            l->locked = 0;
        } else {
            thread_unblock(queue_remove(&l->q));
        }
        l->sync = 0;
    }
    
    
  • 尝试 #4:

    struct lock {
        int locked;
        struct queue q;
        int sync;         /* Normally 0\. */
    };
    
    void lock_acquire(struct lock *l) {
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (!l->locked) {
            l->locked = 1;
            l->sync = 0;
        } else {
            queue_add(&l->q, thread_current());
            thread_block(&l->sync);
        }
    }
    
    void lock_release(struct lock *l) {
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (queue_empty(&l->q) {
            l->locked = 0;
        } else {
            thread_unblock(queue_remove(&l->q));
        }
        l->sync = 0;
    }
    
    
  • 最终解决方案:

    struct lock {
        int locked;
        struct queue q;
        int sync;         /* Normally 0\. */
    };
    
    void lock_acquire(struct lock *l) {
        intr_disable();
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (!l->locked) {
            l->locked = 1;
            l->sync = 0;
        } else {
            queue_add(&l->q, thread_current());
            thread_block(&l->sync);
        }
        intr_enable();
    }
    
    void lock_release(struct lock *l) {
        intr_disable();
        while (swap(&l->sync, 1) != 0) {
            /* Do nothing */
        }
        if (queue_empty(&l->q) {
            l->locked = 0;
        } else {
            thread_unblock(queue_remove(&l->q));
        }
        l->sync = 0;
        intr_enable();
    }
    
    

死锁

CS 140 课堂笔记

2014 年春季

John Ousterhout

  • 操作系统:原理与实践中关于此主题的阅读:第 6.1-6.2 节。

  • 死锁问题:

    • 线程经常需要同时持有多个锁。

    • 简单示例:

      Thread A               Thread B
      lock_acquire(l1);      lock_acquire(l2);
      lock_acquire(l2);      lock_acquire(l1);
      ...                    ...
      lock_release(l2);      lock_release(l1);
      lock_release(l1);      lock_release(l2);
      
      
    • 死锁定义:

      • 一组线程都被阻塞了。

      • 每个线程都在等待另一个线程拥有的资源。

      • 由于所有线程都被阻塞,没有一个能释放它们的资源。

  • 死锁的四个条件:

    • 有限访问:资源不能共享。

    • 无抢占。一旦分配,资源就不能被收回。

    • 多个独立请求:线程不会一次性请求所有资源(在等待时持有资源)。

    • 请求和所有权图中的循环性。

  • 复杂性:

    • 死锁可能发生在任何导致等待的事物上:

      • 网络消息

      • 磁盘驱动器

      • 内存空间耗尽

    • 死锁可能发生在不同的资源(例如锁)或单个资源的部分(内存页)上。

    • 一般来说,不知道线程将需要哪些资源。

  • 解决方案#1:死锁检测

    • 确定系统何时发生死锁

    • 通过终止其中一个线程来打破死锁

    • 在操作系统中通常不实用,但在数据库系统中经常使用,其中事务可以重试

  • 解决方案#2:死锁预防:消除死锁的必要条件之一

    • 不允许独占访问?对大多数应用程序来说不合理。

    • 创建足够的资源,使其永远不会用完?对于像磁盘空间这样的东西可能有效,但用于同步的锁数量是有意限制的。

    • 允许抢占?对一些资源有效,但对另一些资源无效(例如,无法抢占锁)。

    • 要求线程同时请求所有资源;要么全部获取,要么全部等待。

      • 实现起来有些棘手:必须等待几个事物而不锁定任何一个。

      • 对线程不方便:难以提前预测需求。可能需要线程过度分配资源以确保安全。

    • 打破循环性:所有线程按相同顺序请求资源(例如,总是先锁 l1 再锁 l2)。这是操作系统中最常用的方法。

链接器和动态链接

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 来自操作系统:原理与实践这个主题的阅读材料:无。

  • 当进程运行时,它的内存是什么样子?一组称为部分的区域。 Linux 和其他 Unix 系统的基本内存布局:

    • 代码(或 Unix 术语中的“文本”):从位置 0 开始

    • 数据:立即在代码上方开始,向上增长。

    • 栈:从最高地址开始,向下增长

  • 参与管理进程内存的系统组件:

    • 编译器和汇编器:

      • 为每个包含该源文件信息的源代码文件生成一个目标文件

      • 信息是不完整的,因为每个源文件通常引用其他源文件中定义的一些内容。

    • 链接器:

      • 将一个程序的所有目标文件合并为一个单独的目标文件。

      • 链接器的输出是完整且自给自足的。

    • 操作系统:

      • 将目标文件加载到内存中。

      • 允许多个不同的进程同时共享内存。

      • 为进程在启动后获取更多内存提供设施。

    • 运行时库:

      • 与操作系统一起工作,提供动态分配例程,例如 C 中的 malloc 和 free。
  • 链接器(或链接编辑器,在 Unix 中为 ld,在 Windows 上为 LINK):将程序的许多独立部分组合在一起,重新组织存储分配。通常由编译器隐式调用。

  • 链接器的三个功能:

    • 将程序的所有部分组合在一起。

    • 找出一个新的内存组织,使所有部分都能组合在一起(组合类似的部分)。

    • 触及地址,以便程序可以在新的内存组织下运行。

  • 结果:存储在新的名为可执行文件的目标文件中的可运行程序。

  • 链接器必须解决的问题:

    • 汇编器在单独组装文件时不知道外部对象的地址。例如 printf 例程在哪里?

      • 汇编器只是为每个未知地址在目标文件中放置零。
    • 汇编器不知道它正在组装的东西将在内存中的位置。

      • 假设事物从地址零开始,让链接器重新排列。
  • 每个目标文件由以下组成:

    • 部分,每个部分包含一种不同类型的信息。

      • 典型的部分:代码(“文本”)和数据。

      • 对于每个部分,目标文件包含部分的大小和当前位置,以及初始内容(如果有)。

    • 符号表:变量或过程的名称和当前位置,可以在其他目标文件中引用。

    • 重定位记录:关于在此目标文件中引用的地址的信息,链接器在知道最终内存分配后必须调整的信息。

    • 用于调试的附加信息(例如,从源文件中的行号到代码部分中位置的映射)。

  • 链接器执行三次传递:

    • 第 1 步:读取部分大小,计算最终内存布局。

    • 第 2 步:读取所有符号,创建内存中完整的符号表。

    • 第 3 步:读取部分和重定位信息,更新地址,写出新文件。

动态链接

  • 最初,所有程序都是以静态方式链接的,如上所述:

    • 所有外部引用都已经 解析

    • 每个程序都是完整的

  • 自 20 世纪 80 年代末以来,大多数系统都支持共享库动态链接

    • 对于常见的库包,只在内存中保留一个副本,供所有进程共享。

    • 在运行时不知道库加载在哪里;必须在程序运行时动态解析引用。

  • 实现动态链接的一种方式:跳转表

    • 如果被链接的文件中有任何共享库,链接器实际上不会在最终程序中包含共享库代码。相反,它包含两个实现动态链接的内容:

      • 跳转表:一个数组,其中每个条目都是一个包含无条件分支(跳转)的单个机器指令。

        • 对于程序使用的共享库中的每个函数,跳转表中都有一个条目,该条目将跳转到该函数的开头。
      • 动态加载器:在启动时调用的库包,用于填充跳转表。

    • 对于指向共享库中函数的重定位记录,链接器会替换跳转表条目的地址:当调用函数时,调用者将“调用”跳转表条目,该条目将调用重定向到真正的函数。

    • 最初,所有跳转表条目都跳转到零(未解析)。

    • 当程序启动时,动态加载库被调用:

      • 它调用操作系统的 mmap 函数将每个共享库加载到内存中。

      • 它会为共享库中每个函数的正确地址填充跳转表。

文件系统崩溃恢复

CS 140 课程讲义

春季 2014

约翰·奥斯特豪特

  • 从《操作系统:原理与实践》这个主题的阅读材料:第十四章直到第 14.1 节。

  • 问题:崩溃可能发生在任何地方,甚至在关键部分的中间:

    • 丢失数据:在主存储器中缓存的信息可能尚未写入磁盘。

      • 例如原始 Unix:最多 30 秒的更改
    • 不一致:

      • 如果修改涉及多个块,当一些块已写入磁盘但其他块尚未写入时,可能会发生崩溃。

      • 例子:

        • 添加块到文件:空闲列表已更新以指示块正在使用,但文件描述符尚未写入指向块。

        • 创建文件的链接:新目录条目引用文件描述符,但文件描述符中的引用计数未更新。

    • 理想情况下,我们希望像原子操作一样,多块操作要么完全发生,要么根本不发生。

方法#1:在重新启动期间检查一致性,修复问题

  • 例如:Unix fsck("文件系统检查")

    • 每次系统启动时都会执行 fsck。

    • 检查磁盘是否干净关闭;如果是,则无需进行更多工作。

    • 如果磁盘没有干净关闭(例如,系统崩溃、断电等),则扫描磁盘内容,识别不一致之处,修复它们。

    • 例如:文件中的块也在空闲列表中

    • 例如:文件描述符的引用计数与目录中的链接数不匹配

    • 例如:块在两个不同的文件中

    • 例如:文件描述符的引用计数> 0,但在任何目录中都没有引用。

  • fsck 的限制:

    • 恢复磁盘一致性,但不能防止信息丢失;系统最终可能无法使用。

    • 安全问题:一个块可能从密码文件迁移到其他随机文件。

    • 可能需要很长时间:今天读取中等大小磁盘中的每个块需要 1.5 小时。在 fsck 完成之前无法重新启动系统。随着磁盘变大,恢复时间会增加。

方法#2:有序写入

  • 通过按特定顺序进行更新来防止某些类型的不一致。

    • 例如,当向文件添加块时,首先写回空闲列表,以便它不再包含文件的新块。

    • 然后编写文件描述符,参考新块。

    • 崩溃后系统状态如何?

    • 一般来说:

      • 在初始化指向的块之前永远不要写入指针(例如,间接块)。

      • 在将所有现有指针置空之前,永远不要重复使用资源(inode、磁盘块等)。

      • 在设置新指针之前,永远不要清除对活动资源的最后一个指针(例如 mv)。

  • 结果:重新启动时无需等待 fsck

  • 问题:

    • 可能会泄漏资源(在后台运行 fsck 以回收泄漏的资源)。

    • 需要大量同步元数据写入,这会减慢文件操作速度。

  • 改进:

    • 实际上不同步写入块,而是在缓冲区缓存中记录依赖关系。

    • 例如,在向文件添加块后,在文件描述符块和空闲列表块之间添加依赖关系。

      • 当需要将文件描述符写回磁盘时,请确保空闲列表块已经被首先写入。
    • 很难做到正确:可能最终出现块之间的循环依赖。

方法#3:预写式日志记录

  • 也称为日志文件系统

  • 在 Linux ext3 和 NTFS(Windows)中实现。

  • 与数据库系统中的日志类似;允许在重新启动期间快速纠正不一致性。

    • 在执行操作之前,在一个特殊的只追加日志文件中记录有关操作的信息;在修改任何其他块之前将此信息刷新到磁盘。

    • 例如:向文件添加一个块

      • 日志条目:“我将在块索引 93 处向文件描述符 862 添加块 99421”
    • 然后实际块更新可以稍后进行。

    • 如果发生崩溃,请重放日志以确保所有更新都已在磁盘上完成。

    • 保证一旦操作开始,它最终会完成。

    • 问题:日志随时间增长,因此恢复可能很慢。

    • 解决方案:检查点

      • 偶尔停止并刷新所有脏块到磁盘。

      • 一旦完成此操作,日志就可以被清除。

    • 通常日志仅用于元数据(空闲列表、文件描述符、间接块),而不用于实际文件数据。

  • 日志记录的优势:

    • 恢复速度更快。

    • 消除诸如在文件之间混淆的块之类的不一致性。

    • 日志可以在磁盘的一个区域中本地化,因此写入速度更快(无需寻道)。

    • 元数据写入可以延迟很长时间,以获得更好的性能。

  • 日志记录的缺点:

    • 每个元数据操作之前同步磁盘写入。

仍然存在问题

  • 在崩溃后仍然可能丢失最近写入的数据

    • 解决方案:应用程序可以使用 fsync 强制数据写入磁盘。
  • 磁盘故障

    • 大型数据中心中问题的最大原因之一

    • 解决方案:复制或备份副本(例如,磁带上)

  • 磁盘写入不是原子性的:

    • 如果在崩溃时正在写入块,则可能会使其处于不一致状态(既不是旧内容也不是新内容)。

    • 在扇区级别,不一致性是可检测的;崩溃后,扇区将是

      • 旧内容

      • 新内容

      • 无法读取的垃圾

    • 但是,块通常是多个扇区。崩溃后:

      • 块的第 0-5 扇区可能包含新内容。

      • 块的第 6-7 扇区可能包含旧内容。

    • 例如:追加到日志

      • 如果向现有日志块添加新的日志条目,崩溃可能导致块中的旧信息丢失。
    • 解决方案:

      • 复制日志写入(如果崩溃损坏其中一个日志,则另一个仍将是安全的)。

      • 添加校验和和/或版本以检测不完整的写入。

  • 结论:

    • 为了获得最高性能,必须放弃一些崩溃恢复能力。

    • 必须决定要从中恢复哪些类型的故障。

保护

CS 140 讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 操作系统:原理与实践中此主题的阅读:无。

  • 保护:防止系统意外或故意滥用的机制。

    • 事故:通常更容易解决(使它们不太可能发生)

    • 恶意滥用:要消除的难度更大(不能留下任何漏洞,不能使用概率)。

  • 保护机制的三个方面:

    • 身份验证:确定每个操作背后的负责任方(主体)。

    • 授权:确定哪些主体被允许执行哪些操作。

    • 访问执行:结合身份验证和授权以控制访问。在这些领域中的任何微小缺陷都可能危及整个保护机制。

身份验证

  • 通常使用密码

    • 用于建立用户身份的秘密信息。

    • 密码应该相对较长和晦涩(只有难以猜测才有用)。

    • 密码数据库是一个漏洞,必须小心保护;例如,不要以直接可读形式存储密码(使用单向转换)。

  • 另一种身份验证形式:徽章或钥匙。

    • 不必保密。

    • 可以被盗取,但所有者会知道是否被盗。

    • 不应该是可伪造或可复制的。

  • 悖论:密钥必须便宜制作,难以复制。

  • 身份验证完成后,必须保护主体的身份免受篡改,因为系统的其他部分将依赖于它。

  • 登录后,您的用户 ID 与在该登录下执行的每个进程相关联:每个进程从其父进程继承用户 ID。

授权

  • 目标:确定哪些主体可以在哪些对象上执行哪些操作。

  • 逻辑上,授权信息表示为访问矩阵

    • 每个主体一行。

    • 每个对象一列。

    • 每个条目指示该主体对该对象可以做什么。

  • 在实践中,完整的访问矩阵会太庞大,因此以两种压缩方式之一存储:访问控制列表或功能。

  • 访问控制列表(ACL):按列组织。

    • 对于每个对象,存储关于允许哪些用户执行哪些操作的信息。

    • 最一般的形式:<用户,特权>对的列表。

    • 为简单起见,用户可以组织成组,一个整个组的单个 ACL 条目。

    • ACL 可以非常通用(Windows)或简化(Unix)。

    • UNIX:每个文件 9 位:

      • 所有者,组,任何人

      • 读取,写入,执行权限对以上每个权限

      • 此外,用户“root”对所有内容���具有所有权限

    • ACL(访问控制列表)简单且几乎在所有文件系统中使用。

  • 功能:按行组织。

    • 对于每个用户,指示可以访问哪些对象以及以何种方式。

    • 与每个用户一起存储一个<对象,特权>对列表。这称为功能列表

    • 通常,功能也充当对象的名称:不能命名未在您的功能列表中引用的对象。

  • 基于 ACL 的系统鼓励对象的可见性:共享的公共命名空间。

  • 能力系统不鼓励可见性;命名空间默认为私有。

  • 能力已经在试图保持安全的实验系统中使用过。然而,它们被证明难以使用(共享事物痛苦),因此它们在管理文件等对象方面大多已经不受青睐。

访问强制执行

  • 系统的某部分必须负责执行访问控制并保护身份验证和授权信息。

  • 系统的这部分拥有完全的权限,因此应尽可能小而简单。例如:设置页表的系统部分。

  • 一种可能的方法:安全内核

    • 操作系统的内部层强制执行安全性;只有这一层拥有完全的权限。

    • 大多数操作系统没有安全内核:整个操作系统拥有无限的权限。

杂项问题

  • 权限放大

    • 一种机制,使被调用者获得比其调用者更多(或不同的)权限。

    • 简单示例:内核调用

    • 另一个例子:Unix 设置用户标识(setuid):

      • 每个文件都有一个额外的保护位"s"(用于 setuid)。

      • 通常,每个进程都以创建它的进程相同的用户标识运行。

      • 如果一个可执行文件被调用时设置了 setuid,那个进程的有效用户标识将更改为可执行文件的所有者。

      • 典型用法:将用户设置为 root 以安全且受控的方式执行受保护的操作。

  • 要使所有这些机制都能够正常运行,且没有任何可以被恶意分子利用的漏洞是极其困难的。学习 CS 155 可以了解更多。

虚拟内存

CS 140 课程讲义

2014 年春季

约翰·奥斯特豪特

  • 本主题的阅读材料来自操作系统:原理与实践:第八章。

  • 如何使一个内存被多个并发进程共享?

  • 单任务(无共享):

    • 最高内存保存操作系统。

    • 进程从 0 开始分配内存,直到操作系统区域。

    • 例如:早期批处理监视器只能一次运行一个作业。它可能会破坏操作系统,操作员会重新启动操作系统。一些早期个人计算机类似。

  • 共享内存的目标:

    • 多任务:允许多个进程同时驻留在内存中。

    • 透明性:没有进程应该意识到内存是共享的事实。每个进程必须运行,无论进程的数量和/或位置如何。

    • 隔离:进程不能相互破坏。

    • 效率(CPU 和内存)不应该因共享而严重降低。

  • 加载时重定位:

    • 最高内存保存操作系统。

    • 第一个进程加载在 0 处;其他进程填充空白空间。

    • 当加载一个进程时,重新定位它,使其能够在其分配的内存区域中运行,类似于链接:

      • 链接器在可执行文件中输出重定位记录

      • 类似于目标文件中的信息:指示哪些位置包含内存地址

      • 操作系统在加载进程时会修改地址(添加基地址)

    • 这种方法存在哪些问题?

动态内存重定位

  • 在加载程序时静态重定位程序时,添加硬件(内存管理单元)在每次内存引用时��态更改地址。

  • 每个进程生成的地址(称为虚拟地址)在硬件中被转换为物理地址。这在每次内存引用时发生。

  • 导致内存的两种视图,称为地址空间

    • 虚拟地址空间是程序看到的内容

    • 物理地址空间是内存的实际分配

基址和绑定重定位

  • 两个硬件寄存器:

    • 基址:对应于虚拟地址 0 的物理地址。

    • 绑定:最高允许的虚拟地址。

  • 每次内存引用时,虚拟地址与绑定寄存器进行比较,然后加上基址寄存器以生成物理地址。绑定违规会导致操作系统陷入陷阱。

  • 每个进程似乎有一个完全私有的内存,其大小由绑定寄存器确定。

  • 进程彼此之间和操作系统之间隔离。

  • 加载进程时不需要进行地址重定位。

  • 每个进程都有自己的基址和绑定值,这些值保存在进程控制块中。

  • 操作系统在关闭重定位的情况下运行,因此可以访问所有内存(处理器状态字中的一个位控制重定位)。

    • 必须防止用户关闭重定位或修改基址和绑定寄存器(PSW 中的另一个位用于用户/内核模式)。
  • 问题:操作系统一旦放弃控制权,如何重新获得控制权?

  • 基址和绑定是便宜的(只有 2 个硬件寄存器)和快速的:加法和比较可以并行进行。

  • 基址和绑定重定位有什么问题?

多个段

  • 每个进程分布在几个可变大小的内存区域中,称为段。

    • 例如,一个段用于代码,一个段用于堆,一个段用于栈。
  • 段表保存进程的所有段的基址和限制,以及每个段的保护位:读写对比只读。

  • 内存映射过程包括表查找+添加+比较。

  • 每个内存引用必须指示一个段号偏移量

    • 地址的高位选择段,低位选择偏移量。

    • 例如:PDP-10 使用高阶地址位选择高段和低段。

    • 或者,段可以由指令隐式选择(例如代码与数据,堆栈与数据,或 8086 前缀)。

  • 分段的优点:灵活性

    • 分别管理每个段:

      • 可以独立地增长和缩小

      • 交换到磁盘

    • 可以在进程之间共享段(例如,共享代码)。

    • 可以将段移动到紧凑的存储器并消除碎片。

  • 分段存在哪些问题?

分页

  • 将虚拟内存和物理内存划分为称为页面的固定大小块。最常见的大小是 4 K 字节。

  • 对于每个进程,页表定义了该进程每个页面的基址,以及只读和“存在”位。

  • 页表存储在连续的内存中(硬件中的基址寄存器)。

  • 翻译过程:页面号始终直接来自地址。由于页面大小是 2 的幂,不需要比较或添加。只需进行表查找和位替换。

  • 易于分配:保持一个可用页面的空闲列表并获取第一个。易于交换,因为一切都是相同大小,通常与磁盘块大小相同。

  • 问题:对于现代计算机,页表可能非常庞大:

    • 考虑 x86-64 寻址架构:64 位地址,4096 字节页面。

    • 理想情况下,每个页表应该适合一页。

    • 大多数进程很小,因此大多数页表条目未使用。

    • 即使是大型进程也会稀疏地使用它们的地址空间(例如,代码在底部,堆栈在顶部)

  • 解决方案:多级页表。Intel x86-64 寻址架构:

    • 64 位虚拟地址,但实际上只使用了低 48 位。

    • 4 K 字节页面:虚拟地址的低 12 位保存页面内的偏移量。

    • 4 级页表,每个索引使用 9 位虚拟地址。

    • 每个页表适合一个页面(页表条目为 8 字节)。

    • 可以省略空页表。

  • 下一个问题:页表太大,无法加载到重定位单元中的快速存储器中。

    • 页表保留在主存储器中

    • 重定位单元保存顶级页表的基址

    • 使用 x86-64 架构,必须进行 4 次内存引用才能翻译虚拟地址!

翻译后备缓冲区(TLB)

  • 解决页面翻译开销的方案:创建一个最近翻译的小型硬件高速缓存。

    • 每个缓存条目存储虚拟地址的页号部分(对于 x86-64 为 36 位)和相应的物理页号(对于 x86-64 为 40 位)。

    • 典型 TLB 大小:64-2048 条目。

    • 在每次内存引用时,将虚拟地址中的页面号与每个 TLB 条目中的虚拟页号进行比较(并行进行)。

    • 如果有匹配的话,使用相应的物理页码。

    • 如果没有匹配,则执行完整的地址转换,并将信息保存在 TLB 中(替换现有条目中的一个)。

    • TLB 的“命中率”通常为 95%或更高。

  • TLB 的复杂性:

    • 在上下文切换时,必须使 TLB 中的所有条目无效(映射将对下一个进程不同)。当页表基址寄存器被更改时,芯片硬件会自动执行此操作。

    • 如果当前进程的虚拟内存映射发生变化(例如,页面移动),必须使一些 TLB 条目无效。为此有特殊的硬件指令。

杂项主题

  • 操作系统如何从用户内存中获取信息?例如 I/O 缓冲区、参数块。请注意,用户向操作系统传递的是虚拟地址

    • 在一些系统中,操作系统只是无映射地运行。

      • 在这种情况下,它会读取页面表,并在软件中转换用户地址。

      • 在虚拟地址空间中连续的地址在物理上可能不是连续的。因此,I/O 操作可能需要被拆分为多个块。

    • 大多数较新的系统将内核和用户内存包含在同一虚拟地址空间中(但内核内存在用户模式下不可访问)。这让内核的生活变得更加容易,尽管它并没有解决 I/O 问题。

  • 分页的另一个问题是:内部碎片

    • 无法分配部分页面,因此对于小块信息,只有页面的一部分会被使用。

    • 结果:一些页面两端会有浪费的空间。

    • 在今天的系统中并不是一个大问题:

      • 对象(如代码或堆栈)往往比页面大得多。

      • 由于碎片化造成的浪费空间百分比很小。

    • 如果页面大小增长会发生什么?

动态存储管理

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 本主题的阅读材料来自操作系统:原理与实践:无。

  • 静态内存分配简单方便,但并不适用于所有情况。

  • 动态存储管理中的两个基本操作:

    • 分配给定字节数

    • 释放先前分配的块

  • 动态存储分配的两种一般方法:

    • 堆栈分配(分层):受限制,但简单高效。

    • 堆分配:更通用,但实现更困难,效率较低。

堆栈分配

  • 当内存分配和释放部分可预测时,可以使用堆栈:内存释放的顺序与分配相反。

  • 例子:过程调用。X 调用 Y 再次调用 Y。

  • 堆栈还可用于许多其他用途:树遍历,表达式求值,自顶向下递归下降解析器等。

  • 基于堆栈的组织将所有空闲空间集中在一个地方。

堆分配

  • 当分配和释放不可预测时必须使用堆分配

  • 内存由已分配区域和空闲区域(或空洞)组成。最终会有许多空洞。

  • 目标:重复使用空洞中的空间,使空洞数量少,大小大。

  • 碎片化:由于许多小空洞而导致内存使用效率低下。堆栈分配是完美的:所有空闲空间都在一个大空洞中。

  • 堆分配器必须跟踪未使用的存储:空闲列表

  • 最佳适配:保持空闲块的链接列表,在每次分配时搜索整个列表,选择最接近满足分配需求的块,保存多余部分以备后用。在释放操作期间,合并相邻的空闲块。

  • 首次适配:只需扫描列表以找到足够大的第一个空洞。释放多余部分。释放时还要合并。大多数首次适配实现都是旋转首次适配。

  • 问题:随着时间的推移,空洞往往会碎片化,接近最小分配对象的大小

  • 位图:自由列表的替代表示,如果存储以固定大小的块(例如磁盘块)出现,则很有用。

    • 保持大数组位,每个块一个位。

    • 如果位为 0,则表示块正在使用中,如果位为 1,则表示块是空闲的。

  • :为每个常用大小保留单独的链接列表。

    • 分配快速,无碎片化。

    • 这有什么问题吗?

存储回收

  • 我们如何知道何时可以释放动态分配的内存?

    • 当一个块只在一个地方使用时很容易。

    • 当信息被共享时,回收变得困难:直到所有用户完成后才能回收。

    • 通过存在指向数据的指针来指示使用。没有指针,无法访问(找不到)。

  • 回收中的两个问题:

    • 悬空指针:最好不要在仍在使用时回收存储。

    • 内存泄漏:存储“丢失”,因为没有人释放它,即使它再也不能被使用。

  • 引用计数:记录每个内存块的未解引用指针数量。当数量为零时,释放内存。例如:Smalltalk,Unix 中的文件描述符。

  • 垃圾回收:存储空间不是显式释放(使用 free 操作),而是隐式释放:只需删除指针。

    • 当系统需要存储空间时,它会搜索所有指针(必须能够找到它们所有!)并收集未使用的内容。

    • 如果结构是循环的,那么这是回收空间的唯一方法。

    • 垃圾收集器通常会压缩内存,将对象移动以合并所有空闲空间。

  • 实现垃圾回收的一种方法:标记和复制

    • 必须能够找到所有对象。

    • 必须能够找到所有指向对象的指针。

    • 第一步:标记。遍历所有静态分配和过程局部变量,寻找指针()。标记指向的每个对象,并递归标记它指向的所有对象。编译器必须保存关于结构中指针位置的信息以便合作。

    • 第二步:复制和压缩。遍历所有对象,将活动对象复制到连续内存中;然后释放任何剩余空间。

  • 垃圾回收通常很昂贵:

    • 在使用垃圾回收的系统中,占用 10-20%的 CPU 时间。

    • 内存使用效率低:过度分配 2-5 倍。

管理闪存

CS 140 的讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 本主题的阅读来自操作系统:原理与实践:第 12.2 节。

  • 固态(半导体)存储,取代许多应用中的磁盘(例如手机和其他设备)。主要优势:

    • 非易失性(不像 DRAM):即使设备断电,值仍然保持不变

    • 比磁盘更好:

      • 没有移动部件,因此更可靠

      • 更快的访问

      • 更抗震击

    • 比磁盘贵 5-10 倍

    • 比 DRAM 便宜 5-10 倍

  • 两种风格,NAND 和 NOR;NAND 是今天最流行的:

    • 今天的总芯片容量高达 8 G 字节

    • 存储��为擦除单元(通常为 256 K 字节),这些单元被细分为(通常为 512 字节或 4 K 字节)

    • 存储以页为单位读取

    • 两种写入方式:

      • 擦除:将擦除单元中的所有位设置为 1。

      • 写入:修改单个页,只能将位清除为 0(写入 1 没有效果)。

      • 可以重复写入以清除更多位。

    • 磨损:一旦一页被擦除多次(通常约为 10 万次,在一些新设备中低至 1 万次),它就不再可靠地存储信息。

  • 典型的闪存性能:

    • 读取性能:20-100 微秒延迟,100-500 M 字节/秒。

    • 擦除时间:2 毫秒

    • 写入性能:200 微秒延迟,100-200 M 字节/秒。

  • 实际上,大多数闪存设备都打包了一个闪存转换层(FTL):

    • 管理闪存设备的软件

    • 通常提供类似磁盘的接口(读取和写入块)

    • 与现有文件系统软件一起使用

  • FTL 是有趣的软件组件,但今天大多数 FTL 并不是很好:

    • 牺牲性能

    • 浪费容量

  • FTL 的一种可能方法:直接映射(例如,一些廉价的闪存棒)

    • 虚拟块 i 存储在闪存设备的第 i 页上

    • 读取很简单

    • 要写入虚拟块 i

      • 读取包含页 i 的擦除单元

      • 擦除整个单元

      • 用修改后的页重写擦除单元

    • 这种方法有什么问题?

  • 要避免这些问题,必须在闪存中将虚拟块号与物理位置分开,以便给定的虚拟块可以随着时间在闪存中占据不同的页。

  • 保留一个块映射,将虚拟块映射到物理页

    • 读取必须首先查找块映射中的物理位置

    • 对于写入:

      • 找到一个空闲和擦除的页

      • 将虚拟块写入该页

      • 更新块映射到新位置

      • 标记前一页的虚拟块为自由

    • 这引入了额外的问题

      • 如何管理映射(是否存储在闪存设备上?)

      • 如何管理空闲空间(例如磨损平衡)

  • 一种方法:将块映射保留在内存中,在启动时重建:

    • 不要将块映射存储在闪存设备上

    • 闪存上的每一页包含额外的页头:

      • 虚拟块号

      • 自由/已用位(1 => 自由)

      • 预验证/有效位(1 => 预验证)

      • 有效/废弃位(1 => 有效)

    • F-P-O 位跟踪页面的生命周期:

      • 刚擦除:1-1-1

      • 即将写入数据:0-1-1

      • 成功写入块:0-0-1

      • 块已删除(新副本写入其他位置):0-0-0

      • 为什么需要 0-1-1 状态?

    • 在启动时,读取闪存内存的全部内容以重建块映射(8GB 需要 32 秒,128GB 需要 512 秒)。

  • 为了减少块映射的内存利用率,将块映射存储在闪存中,部分缓存在内存中

    • 每个闪存页的标头指示该页是数据页还是映射页。

    • 在内存中保留映射页的位置(映射-映射)

    • 在启动时扫描闪存以重新创建映射-映射

    • 在写入期间,必须写入新的映射页和新的数据页。

    • 有些读取可能需要 2 个闪存操作。

  • 废弃的块堆积在擦除单元中,这降低了有效容量。

  • 解决方案:垃圾回收

    • 找到具有许多空闲页的擦除单元。

    • 将活动页复制到干净的擦除单元(更新块映射)。

    • 擦除并重新使用旧擦除单元

    • 注意:必须始终保留至少一个干净的擦除单元用于垃圾回收!

  • 磨损平衡:

    • 希望所有擦除单元的擦除速度大致相同。

    • 使用垃圾回收在“热”和“冷”页面之间移动数据。

  • 很难同时实现良好的性能、良好的利用率和长寿命:

    • 如果闪存设备利用率为 90%,写入成本将增加 10 倍:

      • 为了为一个新页面腾出空间,必须垃圾回收 10 个旧页面。

      • 9 仍然有效,必须被复制。

      • 写入 1 个新页面

      • 总计:读取 9 次,写入 10 次才能写入 1 个新页面!

      • 这被称为 写放大

    • 低利用率使得写入更便宜,但浪费空间。

    • 频繁的垃圾回收(例如因为高利用率)也会更快地耗损设备。

    • 理想情况:热数据和冷数据

      • 有些擦除单元只包含从不修改的数据(“冷”数据),因此它们总是满的,从不需要进行垃圾回收。

      • 其他擦除单元包含的数据很快被覆盖;我们可以等待所有页面都被覆盖,然后免费进行垃圾回收。

      • 有方法鼓励这种双峰分布。

  • 将闪存内存作为带有 FTL 的类磁盘设备整合是低效的:

    • 复制:

      • 操作系统已经为文件保留了各种索引结构:

      • 这些等同于块映射

      • 如果操作系统可以直接管理闪存,它可以将块映射与文件索引合并。

    • 缺乏信息:

      • FTL 不知道操作系统何时释放了一个块;只有在块被重写时才发现。

      • 因此,FTL 在垃圾回收期间可能会重写已失效的块!

      • 较新的闪存设备提供了 trim 命令,允许操作系统指示删除(但必须修改操作系统文件系统)。

  • 更好的长期解决方案:专门为闪存设计的新文件系统。

    • 许多有趣的问题和设计替代方案

    • 已被研究团队探索,但没有广泛使用的实现

    • 需要绕过 FTL 的能力

    • 有趣的机会

虚拟机监视器

CS 140 讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 来自操作系统:原理与实践第 10.2 节的本主题阅读。

  • 操作系统为进程提供的抽象是什么?

    • (虚拟)内存

    • 底层机器的指令集的一个子集

    • 大多数(但不是全部)的硬件寄存器

    • 一组具有特定参数的用于文件 I/O 等的内核调用。

    • 整体上:底层机器的一部分设施,通过操作系统实施的额外机制进行增强。

  • 如果我们为进程实施了一个与底层硬件完全相同的抽象会怎样:

    • 底层机器的完整指令集

    • 物理内存

    • 内存管理单元(页表等)

    • I/O 设备

    • 陷阱和中断

    • 没有预定义的系统调用

  • 这种抽象被称为虚拟机

    • 对于一个“进程”,它看起来好像拥有自己的私有机器。

    • 多个“进程”可以共享单个机器,每个进程都认为自己在运行自己的私有机器。

    • 这个操作系统被称为虚拟机监视器

    • 可以在虚拟机内运行完整的操作系统:称为客户操作系统

    • 每个虚拟机可以运行不同的客户操作系统。

实施虚拟机监视器

  • 一种方法:模拟

    • 编写模拟指令执行的程序,类似于 Bochs。

    • 同样模拟内存,I/O 设备。

    • 示例:

      • 使用一个大文件来保存“磁盘”的内容

      • 模拟内核/用户位、中断向量等。

    • 问题:太慢了

      • CPU/内存的减速 100 倍

      • I/O 减速 2 倍

  • 更好的方法:使用 CPU 模拟自身。

    • 像用户进程一样运行虚拟机客户操作系统(在非特权模式下)。

    • 大多数指令以 CPU 的全速执行。

    • 任何“异常”都会导致陷入虚拟机监视器,后者会模拟适当的行为。

  • 特殊情况:

    • 特权指令(例如 HALT):

      • 由于虚拟机运行在用户模式下,这些导致“非法指令”陷入 VMM。

      • VMM 捕获这些陷阱,模拟适当的行为。

    • 客户操作系统中的内核调用:

      • 在客户操作系统下运行的用户程序发出内核调用指令。

      • 陷阱总是进入 VMM(而不是客户操作系统)。

      • VMM 分析陷阱指令,模拟对客户操作系统的系统调用:

        • 将 VMM 栈中的陷阱信息移动到客户操作系统的栈中

        • 在客户操作系统的内存中找到中断向量

        • 切换模拟模式为“特权”

        • 从 VMM 返回到客户操作系统中的中断处理程序。

      • 当客户操作系统从系统调用返回时,也会陷入到 VMM 中(用户模式下的非法指令);VMM 模拟返回到客户用户级别。

    • I/O 设备:

      • 客户操作系统写入 I/O 设备寄存器

      • VMM 已经安排好包含页面出错

      • VMM 接收页面故障,识别地址为 I/O 设备寄存器

      • VMM 模拟指令及其对模拟 I/O 设备的影响

      • 当实际 I/O 操作完成时,VMM 模拟中断进入客户操作系统

      • 为了更好的性能,编写新的设备驱动程序,直接调用 VMM(使用系统调用)。

    • 虚拟内存:VMM 使用页表模拟客户操作系统中的虚拟内存映射。

      • 三级内存:

        • 客户虚拟地址空间

        • 客户物理地址空间

        • VMM 物理内存

      • 客户操作系统创建页表,但实际硬件不使用这些页表。

      • VMM 管理真实页表,每个虚拟机一个集合。这些被称为影子页表

      • VMM 管理物理内存

      • 最初所有(影子)页表条目的 present 都为 0。

      • 当发生页错误时,VMM 找到物理页和相应的客户页表条目。两种可能性:

        • 在客户页表条目中 present 为 0:这个故障必须反映到客户操作系统:

          • 为客户操作系统模拟页错误(类似于内核调用)。

          • 客户操作系统调用 I/O 将页面加载到客户物理内存中。

          • 客户操作系统在客户页表条目中将 present 设置为 1。

          • 客户操作系统从页错误返回,再次陷入 VMM(类似于从内核调用返回)。

          • VMM 看到客户页表条目中的 present 为 1,找到相应的物理页,创建影子页表中的条目。

          • VMM 从原始页错误返回,导致客户应用程序重试引用。

        • 在客户页表条目中 present 为 1:客户操作系统认为页面存在于客户物理内存中(但 VMM 可能已经将其交换出去)。

          • VMM 定位相应的物理页,如果需要,将其加载到内存中。

          • VMM 在影子页表中创建条目。

          • VMM 从原始页错误返回,导致客户应用程序重试引用。

          • 在这种情况下,页错误对客户操作系统是不可见的。

      • 如果客户操作系统修改其页表,导致页错误,VMM 更新影子页表以匹配。

  • 潜在问题:

    • VMM 必须陷入任何需要模拟的行为。

      • 特殊内存位置?使用页错误。

      • 特殊指令?必须陷入

    • 病态情况:

      • 在用户模式和内核模式下都有效的指令

      • 但是,在用户模式下行为不同

      • 例子:“读取处理器状态”(其中内核/用户模式位在状态字中)

    • 可虚拟化:没有这种特殊情况的机器

    • 直到最近,很少有机器是完全可虚拟化的(例如,直到最近的 x86)

  • 动态二进制翻译:不可虚拟化机器的解决方案:

    • VMM 分析在虚拟机中执行的所有代码

    • 用陷阱替换不可虚拟化指令

    • 非常棘手:如何找到所有代码?

  • 在实践中,VMM 增加了多少额外开销?

    • CPU 密集型应用程序:< 5%

    • I/O 密集型应用程序:约 30%

虚拟机的历史/用途

  • IBM 在 1960 年代末发明

  • 原始用法:

    • 每个用户一个虚拟机

    • 每个用户运行不同的客户操作系统

    • 单个共享硬件平台

  • 兴趣在 20 世��80 年代和 90 年代消失:

    • 每个用户有一个私人机器
  • 由斯坦福大学的 Mendel Rosenblum 和研究生们重新发明,并实用化,形成了 VMware。

  • 软件开发:

    • 需要在不同的操作系统版本上测试软件:

    • 每个操作系统版本保留一个虚拟机。

    • 使用一台机器测试所有版本。

  • 数据中心:

    • 问题:许多机器,每台只运行一个应用程序

      • 需要单独的机器进行隔离:应用程序崩溃可能导致整台机器崩溃

      • 大多数应用程序只需要机器资源的一小部分。

    • 解决方案:数据中心整合

      • 每个应用程序一个虚拟机

      • 在一台机器上运行多个虚拟机

      • 减少机器数量

  • 封装:

    • VMM 可以将虚拟机的整个状态封装在一个文件中。

    • 可以保存、继续、恢复旧状态。

    • 数据中心示例:

      • 可以在机器之间迁移虚拟机以平衡负载
    • 软件开发:

      • 测试可能破坏机器的状态

      • 解决方案:

        • 在虚拟机中运行测试

        • 始终从保存的虚拟机配置开始测试

        • 测试后丢弃虚拟机状态

        • 结果:可重现的测试

  • 还有许多其他用途:

    • 在同一台机器上运行 MacOS 和 Windows

    • 安全性:可以监视虚拟机内外的所有通信。

技术与操作系统

CS 140 讲座笔记

2014 年春季

约翰·奥斯特豪特

  • 许多操作系统中的基本思想是在 30-40 年前开发的,当时的技术非常不同。 这些思想今天和未来是否仍然相关?

  • 过去 25 年的技术变化:

    • CPU 速度:15 兆赫 -> 2.5 吉赫(增加 167 倍)

    • 记忆体大小:8 兆字节 -> 4 千兆字节(增加 500 倍)

    • 磁盘容量:30 兆字节 -> 500 千兆字节(增加 16667 倍)

    • 磁盘传输速率:2 兆字节/秒 -> 100 兆字节/秒(增加 50 倍)

    • 网络速度:10 兆位/秒 -> 1 吉位/秒(增加 100 倍)

  • 分页的作用:

    • 最初提出时(1960 年代):

      • 磁盘速度:80 毫秒延迟,每秒 250 千字节传输

      • 记忆体大小:256 千字节(64 页)

      • 替换所有记忆体的时间:

        • 6.4 秒(随机访问)

        • 1 秒(顺序)

    • 如今:

      • 磁盘速度:10 毫秒延迟,每秒 100 兆字节传输

      • 记忆体大小:4 千兆字节(1,000,000 页)

      • 替换所有记忆体的时间:

        • 10,000 秒(3 小时)(随机访问)

        • 40 秒(顺序)

    • 除非它将长时间处于闲置状态,否则无法负担将某些内容换出。

    • 分页还有意义吗?

      • 增量加载进程的机制?

        • 为什么不一次读取整个二进制文件?

        • 10 MB 的二进制文件需要 0.1 秒。

      • 临时紧急情况的安全阀?

        • 或许,但在“系统根本不分页”和“系统完全无法使用”之间没有太多空间。
    • 虚拟内存仍然非常有用:

      • 简化物理记忆管理

      • 允许受控共享

      • 记忆体映射文件

      • 虚拟机

    • 页大小太小了:

      • 替换的随机访问成本太高。

      • 没有足够的 TLB 覆盖率。

  • 磁盘:

    • 容量增加速度比访问时间更快。

    • 实际上无法访问您可以存储在磁盘上的所有信息!

    • 频繁访问的信息必须移到其他地方

  • TLB:

    • 记忆体大小没跟上

    • 64 项 -> 256 千字节覆盖范围

    • 在 80 年代中期,这在记忆体中占了相当大的一部分(8 兆字节)。

    • 如今 TLB 只能覆盖极小的记忆体部分

    • 一些 TLB 支持更大的页大小:

      • 1 兆字节

      • 1 吉字节

      • 但是,这使内核记忆管理变得复杂。

  • 多核心

    • 多年来,芯片技术的改进使处理器时钟速率迅速提高。

    • 不幸的是,更快的时钟频率意味着更多的功耗;现在的功率限制限制了时钟频率的改进。

    • 芯片设计师现在正在利用技术在芯片上放置更多的处理器(核心)。

    • 结果:

      • 所有操作系统现在都必须是多处理器操作系统

      • 不清楚如何利用所有这些核心:应用程序开发人员现在必须编写并行程序?

      • 编写并行程序非常困难

  • 当前操作系统开发的热点领域:

    • 非常小(设备)

      • Android、iPhone 等
    • 非常大(数据中心)

      • 协调数千台机器共同工作
posted @ 2026-02-20 16:44  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报