斯坦福-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 等
-
非常大(数据中心)
- 协调数千台机器共同工作
-


浙公网安备 33010602011771号