操作系统笔记
关于操作系统的笔记。
写在前面:本笔记侧重于概念和底层的理解。建议先夯实了基础再做题,事半功倍
第一章:操作系统引论
第一部分:操作系统是什么?它有什么特征?
可以把计算机硬件(CPU、内存、硬盘)想象成一家拥有各种精密机床的超级工厂,而操作系统就是这家工厂的大管家。
1. 操作系统的作用与功能
没有OS的计算机就是一堆废铁,普通人根本无法使用。OS的作用主要体现在三个层面:
作为用户和计算机硬件之间的接口(对外): 不需要用二进制代码去控制硬盘磁头读取数据,只需要双击鼠标打开一个文件。OS把复杂的硬件操作封装成了简单的命令。
作为计算机系统资源的管理者(对内): 工厂里有很多任务要干,但资源有限。OS的四大主要功能(1.5节)就是管好这些资源:
处理机管理(管CPU): 决定现在哪个程序可以使用CPU运行。
存储器管理(管内存): 决定哪个程序的数据可以放在内存里,放在哪里,谁也不能越界。
设备管理(管I/O): 管好键盘、鼠标、打印机等设备的输入输出。
文件管理(管数据): 把硬盘上的物理数据块组织成能看懂的文件夹和文件。
扩充裸机(抽象): 在裸机(纯硬件)上覆盖一层软件,把它变成一台功能更强大、更容易使用的“虚拟机”。
2. 操作系统的四大基本特性 (1.3节 - 🌟 极其重要,考研和面试必考)
这是OS区别于普通软件(比如微信、Word)的最核心特征。
① 并发 - 最难理解的痛点!
原理: 指两个或多个事件在同一时间间隔内发生。在宏观上看,好像有多个程序在同时运行;但在微观上(单核CPU),任何一个时刻其实只有一个程序在运行,CPU只是在它们之间极速切换,制造了“同时”的假象。
⚠️ 易混淆风险: 必须区分并发和并行。并行是指两个或多个事件在同一时刻发生(比如多核CPU,每个核同时跑一个程序)。
② 共享
原理: 系统中的资源可供内存中多个并发执行的进程共同使用。
分为两种:互斥共享(比如打印机,我用的时候你不能用,必须排队)和同时访问(比如硬盘上的一段公共代码,多个程序可以宏观上“同时”读取)。
③ 虚拟
原理: 把一个物理上的实体,变成若干个逻辑上的对应物。
例子: 物理上只有一个CPU,但通过操作系统的“时分复用”技术(大家轮流快速用),每个程序都觉得自己独占了一个CPU。物理内存只有8GB,但通过“空分复用”技术,可以跑总计几十GB的程序。
④ 异步
原理: 因为系统中有多个程序在并发执行,大家都在争抢资源。所以程序不是一气呵成跑完的,而是走走停停。每个程序何时开始、何时暂停、何时结束,以怎样的速度推进,都是不可预知的。
第二部分:操作系统是如何演进的?它的底层运行机制是什么?
1. 发展过程:是被“逼”出来的 (1.2节)
操作系统的发展史,本质上是一部“如何榨干CPU每一滴性能”的血泪史。只需要记住几个关键节点的突破:
单道批处理 -> 多道批处理系统 :这是质的飞跃。
痛点: 以前CPU很快,但输入输出(I/O,比如读写磁带)极慢。如果一个程序在等I/O,CPU就只能干瞪眼(空闲)。
解决原理(引入并发): 内存里同时放多个程序。程序A等I/O的时候,OS立刻把CPU切给程序B用。这就是我们上一节说的并发的起源。目的只有一个:提高资源利用率。
多道批处理 -> 分时系统 :为了“人机交互”。
痛点: 批处理系统虽然效率高,但用户把任务交上去后就失去控制权了,没法在中途修改。
解决原理(时间片轮转): 像切蛋糕一样把CPU时间切成极短的“时间片”(比如几十毫秒)。每个用户轮流用一个时间片。这就好比你在电脑上挂着后台脚本,同时还能流畅地打游戏,因为系统在极速切换,给你一种你独占电脑的错觉。
2. 操作系统的运行环境 (1.4节 - 🌟 本章最核心原理)
这里我们要解释一个极其重要的风险问题:如何防止用户写的烂代码(或恶意代码)把整个系统搞崩溃?
为了安全,CPU在硬件级别提供了支持,这就是双重工作模式 (1.4.3)。
用户态(目态): 这是普通应用程序(比如你写的Vue前端代码、或者跑的Python脚本)运行的环境。在这个状态下,CPU只能执行非特权指令(比如加减乘除)。
核心态(管态/内核态): 这是操作系统内核运行的环境。在这个状态下,CPU可以执行特权指令(比如清空内存、直接控制硬盘读写、设置时钟)。
⚠️ 关键原理与规则: 如果用户态的程序企图执行特权指令(比如直接去读写别人的内存区域),CPU硬件会立刻发现,并强制停止该程序,防止系统被破坏。
那么问题来了,用户程序在用户态,操作系统在核心态。如果用户程序真的需要读写硬盘(比如保存一个文件),它自己又没有权限,怎么办? 这就必须依赖1.4.4 中断与异常。
你之前如果玩过类似ESP32这样的单片机硬件开发,对“中断”的概念一定不陌生。在操作系统中:
中断 是操作系统夺回CPU控制权的唯一途径!
广义的中断分为两种:
外中断(狭义的中断): 来自CPU外部的硬件信号。比如你敲击了一下键盘,或者定时器到点(时间片用完)了。
内中断(异常): 来自CPU内部执行指令时产生的问题。比如程序算术除以0,或者程序主动发起特殊的指令请求操作系统帮忙(这叫“陷入指令”)。
核心铁律:只要发生中断或异常,CPU会立刻暂停当前的用户程序,将工作模式从“用户态”自动切换到“核心态”,然后把控制权交给操作系统的中断处理程序。
第二章:进程的描述与控制
第一部分:进程基础与状态转换
1. 为什么要有“进程”?它和“程序”有什么区别?
程序:是静态的。它就是你写完后放在硬盘里的一堆代码文件(比如你写的那些Vue组件、或者编译好的 C++ 可执行文件)。它是一个没有生命的实体。
进程:是动态的。当你双击运行这个程序,操作系统把它加载到内存中,并分配了资源开始执行时,它就成了一个进程。进程是操作系统进行资源分配和调度的基本单位。
2. “快照”到底是什么?—— PCB(2.2.4 进程管理中的数据结构)
操作系统要同时管理几百个并发的进程,它必须有一套账本。所谓“快照”,在操作系统中被称为 PCB(进程控制块)。
可以把PCB想象成一个极其庞大的对象结构体,它里面记录了这个进程的所有“案底”和“生命体征”:
进程标识符: 也就是进程的身份证号。
处理机状态: 当进程被剥夺CPU时,CPU里面的各种寄存器(比如程序计数器 PC 记录着下一条要执行的代码行号、通用寄存器里算到一半的数据)必须全部原封不动地保存到 PCB 中。这就好比你在折腾 ESP32 单片机时,一旦外部硬件中断触发,系统必须立刻把当前寄存器的状态压入栈中保护起来,处理完中断再恢复。
进程调度信息: 这个进程的优先级是多少?它已经等了多久了?
进程控制信息: 它的代码和数据在内存的哪个物理位置?它打开了哪些文件?
铁律: PCB 是进程存在的唯一标志。创建一个进程,本质上就是为它申请一个空白的 PCB 并填入信息;杀死一个进程,本质上就是把它的 PCB 抹除。
3. 进程的状态与转换 (2.2.2 - 2.2.3 🌟 考研重中之重)
进程不是一直在一刻不停地运算的。因为它要和别的进程抢 CPU,还要等硬盘、等网络,所以它的一生都在几个状态之间不断切换。
最核心的是三个基本状态(一定要死死咬住它们的区别):
运行态: 进程不仅拥有自己需要的所有资源,而且正在 CPU 上执行代码。(在单核电脑上,任何时刻只能有一个进程处于运行态)。
就绪态: 万事俱备,只欠东风。进程已经拥有了除 CPU 之外的所有资源,代码和数据都在内存里准备好了,只要分给它时间片,它立刻就能跑。所有的就绪进程会排成一个“就绪队列”。
阻塞态: 进程在等待某个事件发生(比如等待玩家输入一段文字、等待网络下载一张图片、等待硬盘读取数据)。注意最核心的区别:在阻塞态下,即使我现在把 CPU 白送给你,你也无法运行,因为你在等的东西还没来。
举个打游戏的例子:
你正在全神贯注操作角色打 Boss(运行态)。
突然游戏提示“正在连接服务器...”,你在等网络数据包,这时候你什么都操作不了,CPU在游戏里也无事可做(阻塞态)。
网络数据包到了,连接成功!但此时恰好系统把 CPU 时间片分给了后台正在下载文件的迅雷。你的游戏虽然可以继续玩了,但还没轮到它用 CPU(就绪态)。
✅ 真正严谨的“阻塞态”类比: 菜炒到一半,你突然发现没盐了。为了继续这道菜,你必须掏出手机点个外卖送盐。 在外卖小哥(慢速的外部 I/O 设备,如硬盘、网络)把盐送达厨房之前,你完全无法继续炒这道菜。此时这道菜只能被迫停火,盖上锅盖放在一边(进入阻塞态)。 在这个漫长的等待期里,为了不浪费时间,你转身去切另一道菜的配料(CPU 发生了上下文切换,去执行另一个就绪态的进程)。等外卖员敲门(硬件中断),这道菜才重新拥有了继续做下去的条件,回到就绪态排队。
进阶:“挂起”到底是个什么鬼?
书上图 2-8 把 3 个状态扩展成了 5 个甚至 7 个,加入了“静止就绪(挂起)”和“静止阻塞(挂起)”。为什么要把简单问题复杂化?
痛点与风险:
假设你开了 100 个网页,还开了个 3A 大作。此时内存爆满了。
那些处于“阻塞态”(比如正在等网络下载)或者“就绪态”(排队等 CPU)的进程,它们的代码和数据还霸占着宝贵的物理内存,什么活也不干!这会拖死整个系统。
解决原理(这就是挂起):
操作系统非常冷酷。它会把那些暂时用不到的进程,连同它们的 PCB 和数据,直接从内存“踢”到硬盘的交换区里去,腾出物理内存给当前急需的进程用。
被踢到硬盘上的就绪进程:静止就绪。
被踢到硬盘上的阻塞进程:静止阻塞。
当内存空余了,或者该它运行了,操作系统再把它从硬盘读回内存,这叫激活。
第二部分:并发风险与进程通信
现在,我们把目光转向剩下的内容:前趋图与并发的风险(2.1 节),以及进程通信(2.4 节)。这部分是真正考验你架构思维的地方。
1. 并发执行的客观风险:为什么要画“前趋图”?
并发看起来很美好(宏观上同时运行),但它引入了极高的系统复杂度和风险。
封闭性和可再现性被破坏: 这是并发编程最让人头疼的难点。如果程序 A 和程序 B 并发执行,并且共享同一个变量
x。由于它们谁先运行、谁被中断是完全不可控的(异步性),同一个程序运行两次,可能会得到完全不同的结果!这在大型软件工程中是灾难性的。前趋图的作用: 图片 1 里的那些圆圈和箭头,就是为了解决这个风险。它是一个有向无环图,严格规定了哪些代码必须在另一些代码之前执行完成。即使是并发,也必须遵守这套严格的先后秩序,否则系统逻辑就会崩溃。
2. 进程通信
核心矛盾: 回忆第一章我们讲的“双重模式”和安全保护。操作系统为了防止流氓软件,把每个进程的内存都像堡垒一样严格隔离了。进程 A 绝对碰不到进程 B 的内存案板。 但是,现代软件不可能单打独斗。比如你在玩游戏时,一个进程负责计算物理碰撞,另一个进程负责播放音效,它们必须合作。被隔离的进程怎么交换数据?这就是 2.4 节要解决的问题。
操作系统提供了三种主要的“合法”通信渠道:
共享存储: 操作系统在内存里划出一块“公共黑板”。进程 A 把数据写在黑板上,进程 B 直接去读。
特点: 速度最快,因为不需要把数据拷来拷去。
风险: 极度危险。如果 A 正在写,B 同时跑去读,读到的就是错乱的半截数据。必须引入复杂的互斥锁(下一章的难点)。
管道通信: 相当于在进程之间接了一根“单向水管”。一个进程在水管这头疯狂倒水(写字节流),另一个在水管那头接水(读数据)。
- 特点: 只能单向流动(半双工),自带同步机制(没水了接水的一方会自动阻塞等待)。
消息传递: 进程把数据打包成一个个特定格式的“信件”,通过操作系统内核里的“邮局”发送给目标进程。
场景问题
假设你正在构思一个对画面帧率和计算性能要求极高的系统(比如底层用 C++ 写的图形渲染引擎),其中:
进程 A: 负责疯狂计算每秒几十万个多边形顶点的物理坐标变化。
进程 B: 负责接收这些坐标数据,并调用显卡立刻渲染到屏幕上。
这两个独立进程每秒需要交换海量的数据。 根据讲解的原理,你会为它们选择哪种进程通信方式(共享存储、管道、还是消息传递)? 请客观说明:
为什么选这个?
在代码实现层面,使用这种方式会面临的最大风险和难度是什么?(请运用并发和隔离的直觉来回答)
✅ 正确答案:共享存储
为了实现最高效的大数据量传输,必须选择共享存储。
原理解释: 操作系统大管家会在物理内存中划出一块“公共地盘”,然后把它同时映射到进程 A 和进程 B 的虚拟地址空间里。 这就好比 A 和 B 之间原本隔着一堵墙(内存隔离),现在 OS 在墙上开了一扇窗,在窗台上放了一块共享案板。
- 绝对的性能碾压: 进程 A 把算好的坐标直接写在共享案板上,进程 B 瞬间就能直接读到。全程不需要操作系统的内核插手,不需要来回拷贝数据,也没有用户态和核心态的切换。 它的速度基本等同于访问本地内存的速度。
⚠️ 客观风险与极高的开发难度
既然共享存储这么快,为什么不所有通信都用它?这就是你刚才提到的“时间差”问题,在专业术语里这叫竞态条件。
使用共享存储最大的风险和难度在于:它没有任何自带的保护和同步机制!
灾难场景: 进程 A 正在往共享内存里写入一个怪物模型的坐标。刚写完上半身,还没来得及写下半身,CPU 的时间片用完了。此时进程 B 被调度运行,它跑去共享内存一读——糟糕!它读到了一个只有上半身的新坐标和旧的下半身坐标,渲染到屏幕上就是模型被撕裂的画面。
开发难度: 开发者必须自己写极其严谨的代码来当“交警”。比如引入互斥锁或信号量。A 在写的时候必须上锁,B 即使想读也必须在外面等(进入阻塞态),等 A 写完解锁了,B 才能进去读。一旦锁的逻辑写错一点,就会导致整个系统死锁,两个进程永远互相等待,游戏直接画面冻结。
第三部分:关于线程
1. 为什么要有线程?(被逼出来的痛点)
前面学了,进程很好,它通过内存隔离把系统保护得严严实实。但这种“重装甲”也带来了极大的性能开销。
想象正在进入一个包含很多效果的网页:
监听并响应用户的鼠标滑动和点击。
渲染那些极其丝滑的 CSS 粒子动画。
在后台通过网络请求拉取最新的数据。
如果按照第一章的“并发”思路,把这三件事变成三个独立的进程会怎样?
切换太慢: 每次从“画动画”切换到“响应鼠标”,操作系统都要保存上一个进程庞大的 PCB,还要切换整个虚拟内存的映射表。这就像让一个重装步兵频繁换装一样,极其消耗 CPU 资源。
通信太难: 动画进程想要根据网络进程拉取的数据来改变形态,它们俩被厚厚的“内存墙”隔开,必须求助操作系统大管家,用我们刚才说的“共享存储”或“管道”来通信,开发极其繁琐。
为了解决这个问题,计算机科学家在进程的内部,又切分出了更细的执行单位——线程。
2. 掰开揉碎:进程 vs. 线程 的本质区别
这是所有大厂面试和考研专业课必考中的必考,你只需要记住这个核心比喻:
进程:是操作系统分配资源的基本单位。 它就像是一个封闭的车间(拥有独立的内存空间、打开的文件、各种硬件资源)。
线程:是 CPU 调度和执行的基本单位。 它就像是车间里的流水线工人。
关键特征与原理:
资源归属: 线程自己几乎不拥有什么系统资源(除了一点点必不可少的“私房钱”,也就是存它自己执行进度的 TCB(线程控制块)和少量的寄存器/栈空间)。它吃穿用度全靠它所在的那个进程。
天然共享: 同一个进程下的所有线程,完全共享这个进程的所有内存和资源!它们同在一个车间,抬头不见低头见,线程 A 算出来的变量结果,线程 B 可以直接从内存里拿来用,完全不需要操作系统内核插手通信! 这速度极快。
轻装上阵: 因为线程共享内存,所以在同一个进程内切换两个线程时,操作系统不需要切换内存映射表,只需要换一下几个寄存器的值。切换速度比切换进程快了几个数量级。
3. 客观存在的极高风险与难度
不要以为线程全是优点。在实际的代码工程中,多线程编程是无数程序员的噩梦。
风险一:极度脆弱(一损俱损) 因为进程里的线程互相不设防(没有隔离),如果其中一个负责拉取网络数据的线程,因为写了段烂代码导致了“内存访问越界”(比如试图去读不属于自己的内存地址),操作系统会立刻捕捉到这个严重异常。 后果是毁灭性的: 操作系统才不管你这个进程里还有几个正常工作的线程,它会直接把整个进程强制连根拔起杀死!你的整个网页或游戏会瞬间白屏闪退。
风险二:并发冲突(踩踏事件) 既然大家共享同一个内存案板,如果线程 A 正在往变量
x里写数据,线程 B 同时跑去读x,或者它们俩同时对x进行加一操作,数据就会彻底错乱。这就是多线程开发中最难啃的骨头:线程同步与互斥锁。你必须自己在代码里写极其严谨的“交通规则”,否则系统跑起来就是一个随机崩溃的定时炸弹。
第三章:处理机调度
这一章是整本操作系统的算法核心。如果说第一章是告诉你操作系统是“大管家”,第二章告诉你大管家手底下管着很多“进程(员工)”,那么第三章就是要解决一个极其残酷的现实问题:CPU 只有一个(或者少数几个),但嗷嗷待哺的进程有几百个,大管家到底该把 CPU 分给谁?先给谁?后给谁?给多久?
这就是调度。我们掰开揉碎来看 3.1 和 3.2 节的核心。
第一部分:调度的三个层次
操作系统调度不是一刀切的,它分为三个级别,就像医院看病一样:
① 高级调度(作业调度):从硬盘 -> 内存
- 原理: 硬盘里有一堆程序排队等着运行。高级调度就是决定把哪些程序从硬盘调入内存,给它们创建 PCB,变成“就绪态”。
- 比喻: 医院大门外的保安,决定今天放哪些病人进医院大厅排队。发生频率最低(几分钟一次)。
② 中级调度(内存调度):内存 <-> 硬盘(挂起区 Swap)
- 原理: 这个你已经学过了,就是第二章里的挂起。内存不够了,把暂时不跑的进程踢到硬盘上;内存空了,再把它们接回来。
- 比喻: 医院大厅人太多了,护士把一些病情不急的病人先赶到院子外的长椅上等,大厅空了再叫进来。发生频率中等。
③ 低级调度(进程调度):内存(就绪队列) -> CPU
- 原理: 这是整个操作系统最核心、最频繁的动作。 决定从就绪队列中挑哪一个进程,把 CPU 的控制权交给它。
- 比喻: 诊室里的医生,喊门外排队的人:“下一个,张三!”。发生频率极高(每秒钟成百上千次)。
⚠️ 风险提示: 考试或面试时,不要把这三个搞混。我们这章后续讨论的各种算法,绝大多数都是指“低级调度(进程调度)”。
第二部分:调度算法:天下没有免费的午餐
怎么挑下一个进程?这就是书上 3.2 节列出的一大堆算法。每一个算法都是为了解决上一个算法的痛点而发明的,但同时又会引入新的风险。这里挑最核心的三个建立直觉:
① 先来先服务
- 原理: 谁先排队,谁就先用 CPU,用到结束或者用到自己阻塞为止。
- 致命风险: “饥饿”现象(护航效应)。假设一个极其耗时的视频渲染进程(需要 10 小时)排在第一,后面跟着 100 个只需要 1 毫秒就能完成的打字响应进程,这 100 个轻量级进程会被完全卡死,系统会显得极度卡顿。
② 短作业优先
- 原理: 预测哪个进程需要的运行时间最短,就先给谁安排 CPU。
- 优势: 数学上可以证明,这种算法的平均等待时间最短。
- 致命风险: 长作业饿死。如果系统里源源不断地涌入短任务,那个需要算 10 小时的视频渲染进程可能永远也分不到 CPU。而且操作系统其实根本无法准确预测一个进程到底要跑多久。
③ 时间片轮转 —— 现代操作系统的基石
- 原理: 绝对公平。大家排成一个环。系统给每个人发一个固定大小的“时间片”(比如 10ms)。时间一到,不管有没有算完,强制剥夺 CPU(通过硬件定时器中断),把进程踢到队伍末尾重新排队。
- 优势: 解决了 FCFS 的护航效应,也解决了 SJF 的饿死问题。用户交互体验通常更好。
- 致命风险: 上下文切换开销。如果时间片太短(比如 0.1ms),CPU 会花大量时间在保存 PCB、恢复 PCB 上,真正用于业务计算的时间反而变少,系统总效率会明显下跌。
拆解:优先级调度算法
顾名思义,就是给每个进程发一个“VIP 等级”。调度时永远先挑 VIP 等级最高的进程执行。
核心考点与风险分析:
抢占式优先级 vs 非抢占式优先级
- 假设正在运行一个 VIP 3 的进程,此时就绪队列突然来了一个 VIP 5。
- 如果是非抢占式:VIP 5 只能排队,等 VIP 3 运行完。
- 如果是抢占式:操作系统立刻触发中断,把 VIP 3 踢开(保存 PCB),直接让 VIP 5 上 CPU。在实时性要求极高的系统(例如自动驾驶)中,通常必须采用抢占式优先级。
致命系统风险:饥饿
- 这是优先级调度的最大痛点。如果系统不断涌入高优先级进程(比如 VIP 4、VIP 5),低优先级进程可能在就绪队列里永远等不到 CPU。
- 如何解决饥饿:老化机制。一个进程在就绪队列里等待越久,系统就逐步提高它的优先级。比如 VIP 1 等 10 分钟提到 VIP 2,等 1 小时再提到 VIP 5。这样只要等待足够久,低优先级进程最终也能得到执行机会。
补一块考试计算:周转时间与带权周转时间
调度算法大题通常不是让你背概念,而是让你画时间轴、算平均值。
你只需要记住两个公式:
1 | 周转时间 = 完成时刻 - 到达时刻 |
周转时间回答的是:这个作业从进系统到彻底完成,一共在系统里待了多久。
带权周转时间回答的是:它实际等了多久,相对于它自己本来需要的运行时间来说,亏不亏。
举个小例子:
1 | P1:到达 0,运行 8 |
如果用非抢占优先级调度,并且数值越小优先级越高,假设优先级顺序是:
1 | P2 -> P1 -> P4 -> P3 |
时间轴就是:
1 | 0 6 14 18 30 |
完成时刻:
1 | P2 = 6 |
因为它们都在 0 时刻到达,所以周转时间就等于完成时刻。
平均周转时间:
1 | (6 + 14 + 18 + 30) / 4 = 17 |
带权周转时间:
1 | P2 = 6 / 6 = 1 |
平均带权周转时间:
1 | (1 + 1.75 + 4.5 + 2.5) / 4 = 2.4375 |
做这类题的铁律:先画时间轴,再算完成时刻,最后套公式。 不要直接在脑子里硬算。
现代操作系统(例如 Linux、Windows)通常不会使用单一算法,而是把多种策略组合起来,核心思路是设置多个就绪队列:
队列 1(最高优先级,时间片很短,比如 10ms)
- 所有新任务先进入队列 1。
- 效果: 短任务可以在前几个时间片内快速完成,实现低延迟响应。
队列 2(中等优先级,时间片较长,比如 50ms)
- 如果任务在队列 1 的时间片内反复跑不完,系统会把它降级到队列 2。
队列 N(最低优先级,常用 FCFS 或超长时间片)
- 连续多次跑不完的长任务最终会落到底层队列。当上层短任务处理完后,系统给它足够长的 CPU 时间,保证吞吐量。
这就是调度混合策略的核心:既保证短任务秒回,也保证长任务最终能完成,而且不必预先知道任务到底要跑多久。
第四章:进程同步与互斥
欢迎来到全书真正的深水区,也是最容易出 Bug、最难调试的地方:第 4 章:进程同步与互斥。
前三章讲的是“大管家如何分配资源”,而这一章开始直面并发世界里最现实的风险:
程序可能每次运行结果都不一样,甚至半夜随机死机,翻遍日志也找不到原因。
第一部分:并发的血之代价——为什么要“同步”?
在第二章讲线程的时候,我们提过一个极其危险的好处:同一个进程下的多个线程,完全共享同一块物理内存。 这速度极快,但这也意味着它们之间没有任何隔离墙。
我们用一个《原神》或《英雄联盟》服务器后端的真实场景,来把这种危险“掰开揉碎”。
1. 灾难场景
灾难场景: 假设游戏服务器内存里有一个共享变量 Boss_HP = 1000(Boss的血量)。
现在,玩家 A(线程 A)和玩家 B(线程 B)同时放了一个技能,各自对 Boss 造成了 100 点伤害。
理论上,Boss 最终的血量应该是 1000 - 100 - 100 = 800。
但在底层 CPU 看来,“扣血”这个动作绝不是一瞬间完成的,它在汇编语言级别被拆分成了三个微观步骤:
- Read(读): 把内存里的
Boss_HP(1000) 读进 CPU 的寄存器里。 - Sub(减): 在 CPU 寄存器里执行减法,算出来结果是 900。
- Write(写): 把寄存器里的 900 写回内存的
Boss_HP地址。
由于线程 A 和线程 B 是并发执行的,别忘了我们第三章讲的硬件时钟中断!操作系统的调度大管家随时可能在任何一个微观步骤中间,强行把 CPU 抢走。
第二部分:破局的两个核心概念
为了防止这种“互相踩踏”的灾难,计算机科学家引入了两个极其严谨的概念,你必须把它们的区别刻在脑子里:
1. 临界资源与临界区
- ① 临界资源: 指的是一次仅允许一个进程(或线程)使用的共享资源。 在刚才的例子里,内存里的变量
Boss_HP就是临界资源。现实中,共享的打印机、共享的文件包、你做 GIF 播放器时那块全局的屏幕显存,都是临界资源。 - ② 临界区: ⚠️ 极其容易和临界资源搞混! 临界区指的是你写的代码中,访问临界资源的那一段代码。 在刚才的例子里,包含 Read、Sub、Write 这三步操作的几行 C++ 或 Python 代码,就叫临界区。
操作系统的终极铁律:对于临界区,必须保证多个进程“互斥”地进入。 意思是:既然这段代码会操作危险的共享变量,那么只要线程 A 一只脚踏进了这段代码,大门就必须立刻死死锁上!如果这时候 CPU 切换到了线程 B,线程 B 想进这段代码,对不起,它必须在门外乖乖进入阻塞态 等待,直到线程 A 出来把门解锁。
2. 课堂追问与回答
请回答我: 请具体描述出一种特定的底层微观执行顺序(例如:线程 A 先执行步骤 1...然后发生中断...),向我证明:明明 A 和 B 都造成了 100 点伤害,但最终内存里的 Boss_HP 变量的值却是 900,而不是 800,凭空吃掉了一次伤害!
答:假如boss血量是临界资源,线程A计算出了伤害,修改了boss血量,那么这个过程,血量是不是被锁住了,如果触发中断,线程B计算出了伤害,但是实际扣血量这个过程是不是因为A占用了资源而被锁了呢
3. 标答与误区纠正
标答:⚠️ 打住!你产生了一个极其致命、但也极其经典的错觉!
但实际上是,你以为操作系统或者底层硬件会“自动”识别出临界资源,并“自动”帮你把它锁住?大错特错!
这就是为什么无数初学者在写多线程并发代码时,程序跑出完全无法复现的 Bug 的根本原因。
4. 真实的底层物理世界
在操作系统大管家和 CPU 的眼里,根本不存在所谓的“Boss 血量”或者“临界资源”这种高级的业务概念。
在微观层面,Boss_HP 仅仅只是物理内存条上的一个地址(比如 0x7FFF1A2B)。
物理内存就像是厨房里的一块公共大案板,它没有任何自我保护能力。操作系统也绝对不会在你读写这个地址时,好心好意地帮你派个保安守着。
真实的物理情况是: 如果不加任何人为的代码干预,当线程 A 正在读取或者修改这个内存地址时,只要硬件时钟中断一响,CPU 瞬间被剥夺,切换给线程 B。线程 B 如果代码里写了要读写这个地址,CPU 会毫不犹豫地、毫无阻拦地直接冲进去读写!
案板上的肉(数据)会被两把菜刀(线程)同时乱砍,没有任何人会被“自动锁在门外”。
5. 为什么不自动加锁?(底层原理)
你可能会问:既然这么危险,为什么不设计成“只要有人在读写内存,硬件就自动锁住”?
- 性能灾难: 内存里有几十亿个地址,如果 CPU 每次读写都要去检查一下“这个地址有没有被别人锁住”,电脑的运行速度会瞬间慢几千倍。
- 逻辑真空: 硬件根本不知道你哪几行代码是一个“不可分割的整体动作”。它怎么知道 Read、Sub、Write 这三步必须连在一起做?在硬件看来,这就是三条完全独立的普通汇编指令。
结论:要想锁住资源,绝对不能指望硬件或操作系统的自动怜悯。必须由你(程序员)亲自在代码里,手动把这三步操作用“互斥锁”或者“信号量”给强行包裹起来!
现在,请你假设你没有写任何加锁的代码,重新回到我刚才布置的灾难现场,推演一次“吞伤害”的微观过程。
6. 时间线推演
已知前提条件:
- 内存里
Boss_HP = 1000。 - 扣血动作在微观分为三步:① Read (从内存读到CPU寄存器) -> ② Sub (在寄存器里减100) -> ③ Write (把寄存器的新值写回内存)。
- 线程 A 的 CPU 寄存器和线程 B 的 CPU 寄存器是物理隔离、互不干扰的(这是在第二章保存/恢复上下文时保证的)。
请你顺着下面这个时间线,填空并推演,向我证明最终内存里的血量为什么是 900,而不是 800:
- 时刻 1: 线程 A 开始运行,执行了步骤 ①。此时,线程 A 的寄存器里记下的数值是
_____。 - 时刻 2: ⚠️ 致命的硬件时钟中断发生!线程 A 还没来得及执行减法,就被迫交出了 CPU,进入就绪态。它的寄存器数据被妥善保存。
- 时刻 3: 线程 B 获得 CPU 开始运行。它也执行步骤 ①。此时,内存里的
Boss_HP依然是 1000。所以线程 B 读到自己寄存器里的数值是_____。 - 时刻 4: 线程 B 顺畅地执行了步骤 ②和步骤 ③。此时,线程 B 把
_____这个数值写回了内存。此时内存里的Boss_HP变成了_____。 - 时刻 5: 时钟中断再次发生!线程 B 被切走,线程 A 重新获得 CPU,恢复上下文。
- 时刻 6 (最关键的一步): 线程 A 从它被中断的地方继续往下执行!它接下去要执行步骤 ②。请问,它是用什么数值减去 100?减完之后,它执行步骤 ③,把什么数值写回了内存?
最终,内存里的 Boss_HP 定格在了多少?
答:线程A实际上拿到的是旧版血量数据,也就是1000,线程B计算的900血量不能及时更新同步进去,所以线程A拿的是最开始的1000血量减掉100血量,最后更新进内存里的也是900血量
第三部分:信号量机制
为了消灭竞态条件,荷兰计算机科学家 Dijkstra 提出了一个极其优雅且暴力的方案。
你可以把信号量(通常在代码里用变量 S 表示)想象成保安大叔手里的“许可牌”数量。
而 P 操作和 V 操作,是操作系统提供的两个不可分割的原子操作(原语 Primitive)。
什么是原语?意思是:只要代码开始执行 P 或 V,底层硬件会强行关闭所有中断!天王老子来了,也不能在 P/V 执行一半时把 CPU 切走。这就从物理上绝对保证了加锁过程本身的安全性。
我们掰开揉碎来看看这两个操作到底在干嘛:
1. P 操作:进门前拿牌子
当线程走到临界区门口,必须执行 P(S) 向保安申请许可牌。
- 保安二话不说,先把牌子总数减 1:
S = S - 1。 - 如果减完之后
S >= 0:说明刚才手里还有牌子,申请成功!线程拿着牌子大摇大摆进入临界区执行代码。 - 如果减完之后
S < 0:说明牌子已经发完了,现在是超发状态。保安会直接把这个线程强行按在门外,剥夺 CPU,让它进入阻塞态,并在阻塞队列里排队,直到有人出来还牌子。
2. V 操作:出门后还牌子
当线程执行完临界区代码,出门时必须执行 V(S) 把牌子还给保安。
- 保安先把牌子总数加 1:
S = S + 1。 - 如果加完之后
S <= 0:注意这个反直觉的数学逻辑!加了 1 之后还是小于等于 0,说明什么?说明刚才S是个负数,也就是说门外还有在排队等候的倒霉蛋。此时,保安会从阻塞队列里唤醒排在最前面的一个线程,把它塞进就绪队列,告诉它:“轮到你了!” - 如果加完之后
S > 0:说明门外根本没人排队,保安直接把牌子收好拉倒。
3. 如何解决 Boss 血量被吞的问题?(互斥模型)
非常简单。Boss 血量是临界资源。同时只能有 1 个人进去改血量,所以我们设置一个互斥信号量 Mutex = 1(保安手里初始只有 1 块牌子)。
正确的代码结构必须是这样的,像三明治一样把危险代码夹起来:
有了这两道铁门,即使 CPU 在 Sub 100 的时候触发了时钟中断,把 CPU 切给了线程 B。线程 B 一运行到 P(Mutex),发现牌子被 A 拿走了,它也会瞬间被操作系统阻塞死,绝对进不去!直到 A 恢复运行,执行完 Write 并调用 V(Mutex),B 才会被唤醒。伤害再也不会丢了。
第四部分:死锁
既然你已经掌握了如何用信号量(牌子)来控制并发,我们必须直面第 4 章的最终灾难。信号量如果用得不对,不仅不能保命,还会让整个系统瞬间暴毙。这就是死锁。
为了把这个极其抽象的灾难具象化,想象这样一个场景:
- CPU = 唯一的厨师(你)。
- 进程/线程 = 两道正在并发烹饪的菜(任务 A 和任务 B)。
- 临界资源(且互斥) = 厨房里只有一口炒锅(对应信号量
Mutex_Wok = 1)和一把锅铲(对应信号量Mutex_Spatula = 1)。
为了做菜,你必须在代码里申请资源(拿牌子):
- 任务 A(番茄炒蛋): 它的代码逻辑是,必须先拿到炒锅,再拿锅铲,才能开炒。
P(炒锅)->P(锅铲)-> 狂炒 ->V(锅铲)->V(炒锅) - 任务 B(红烧肉): 它的代码逻辑是,习惯先紧紧把锅铲握在手里准备好,再去端炒锅。
P(锅铲)->P(炒锅)-> 狂炒 ->V(炒锅)->V(锅铲)
⚠️ 极其关键的物理前提: 当你因为时间片用完,被强制切换去做另一道菜时,你之前那道菜申请到的炒锅或锅铲,是绝对不会自动退还的! 它们会被那道菜死死“霸占”着(这叫资源的非抢占性)。
1. 灾难时间线
- 时钟滴答,任务 A(番茄炒蛋)获得了 CPU 时间片,你成功执行了
P(炒锅)。此时炒锅被任务 A 锁死,你把炒锅端到了 A 的案板上。 - 就在你准备去拿锅铲(还没来得及执行下一个 P 操作)的瞬间,硬件时钟中断触发!
- CPU 被强制剥夺,大管家让你切换去执行任务 B(红烧肉)。
- 你来到 B 的案板前,执行任务 B 的第一行代码,成功执行了
P(锅铲)。锅铲被任务 B 锁死,你把锅铲握在了手里。 - 紧接着,任务 B 按照代码逻辑往下走,准备去端炒锅,于是它执行了
P(炒锅)。
2. 四个必要条件
我们现在就把你刚才的厨房大白话,直接“翻译”成考卷上的专业术语。只要这四个条件同时成立,死锁就必然发生:
- 互斥条件: 炒锅和锅铲都是独占的,你用的时候我绝对不能用。(这是物理性质,改不了)
- 请求和保持条件: “拿了一半工具”。任务 A 已经保持了炒锅,同时还在贪婪地请求锅铲,且在拿到锅铲前,绝不撒手。
- 不剥夺条件: “前置条件在别人手里”。操作系统大管家非常讲武德,任务 A 没主动放回炒锅前,大管家绝对不能用暴力把炒锅从 A 手里抢过来给 B 用。
- 环路等待条件: “互相限制”。A 等 B 的锅铲,B 等 A 的炒锅,形成了一个完美的闭环死结。
只要打破这四个条件中的任何一个,死锁的魔咒就会瞬间解除!
3. 如何对付死锁?(4.5 节后续)
既然知道了死锁是怎么发生的,作为未来的高级架构师,你必须要设计机制来防范它。操作系统书上给出了三种主流的应对策略(难度递增):
死锁预防:掐死在摇篮里
- 原理: 制定极其严苛的厨房帮规,从物理上直接破坏掉上面那 4 个条件中的某一个。
- 代价: 规矩太严,会导致厨房的炒菜效率(系统吞吐量)大幅降低。
死锁避免:传说中的“银行家算法”
- 原理: 帮规不那么严。每次你(进程)向大管家申请锅铲时,大管家会在脑子里疯狂进行一次“沙盘推演”:如果我把锅铲借给你,接下来厨房会不会陷入死锁?如果推演结果是安全的,就借给你;如果发现有死锁风险,大管家就无情拒绝:“现在不能借,你先在旁边给我等着!”
银行家算法的核心不是“有资源就给”,而是“给了之后系统还能不能找到一条安全退场路线”。
你可以把系统想象成银行,进程是来借钱的客户。银行不是看柜台上现在有没有钱,而是要判断:如果这笔钱借出去,未来是不是还能让所有客户按某个顺序顺利还款。
手算时固定看四个量:
- 最大需求:每个进程最多可能要多少资源。
- 已分配:现在已经拿走多少资源。
- 还需要:还差多少资源才能完成。
- 可用资源:银行柜台上现在还剩多少资源。
其中:
1
还需要 = 最大需求 - 已分配
判断安全状态时,按这个流程走:
- 先看当前可用资源。
- 找一个“还需要”不超过当前可用资源的进程。
- 假装让它运行到结束。
- 它结束后,会归还自己已经占有的全部资源。
- 可用资源变多,再找下一个能完成的进程。
- 如果所有进程都能排进这个完成顺序,系统就是安全的。
- 如果找着找着卡住了,剩下进程谁都满足不了,系统就是不安全的。
这个完成顺序就叫安全序列。
资源请求题也一样。先假装把资源借给它,再重新做一次安全性检查。如果还能找到安全序列,就可以分配;如果找不到,就不能分配。
所以银行家算法的答题句式通常是:
1
2
3试分配后,重新计算可用资源与还需要资源。
若存在安全序列,则该请求可以满足。
若不存在安全序列,则该请求不能满足,因为会使系统进入不安全状态。死锁检测与解除:亡羊补牢
- 原理: 随便你们怎么拿厨具,大管家平时根本不管。但是!大管家每隔 10 分钟会来厨房巡视一圈,画一张“谁拿了什么、谁在等什么”的关系图。一旦在图里发现了“闭环(环路等待)”,说明已经死锁了。
- 暴力解除: 大管家的手段极其残忍。直接挑选一个最倒霉的厨师(进程),把他的菜连锅端倒进垃圾桶(强行杀死进程 Process Termination),剥夺他所有的厨具分给别人。这样死结就解开了。
第五章:存储器管理
前四章我们解决的是 CPU(算力)的分配问题。但 CPU 跑得再快,如果没有数据喂给它,它也就是个空转的加热器。内存就是 CPU 的“唯一饭碗”。
这一章的核心矛盾极其尖锐:物理内存极其昂贵、空间极其狭小,但嗷嗷待哺的进程个个都是贪得无厌的吞金兽。
为了解答我在上一节结尾给你留的那个悬念(为什么 16G 物理内存能跑几个 10G 的大作),我们必须先打破你对写代码时“内存指针”的幻想。
第一部分:逻辑地址与物理地址
你在写 C++ 时,肯定用过指针;你在逆向破解别人的软件找基址时,肯定看到过类似 0x00400000 这样的内存地址。
⚠️ 极其残酷的物理真相: 你在所有高级语言、汇编语言里,以及在逆向工具里看到的内存地址,全部都是假的!是操作系统和 CPU 联手为你编造的幻觉!
逻辑地址(相对地址 / 虚拟地址): 当你把一个 C++ 代码编译成
.exe时,编译器根本不知道你未来会在哪台电脑上运行,也不知道运行的时候物理内存条上哪一块是空闲的。 所以,编译器只能“假装”你的程序拥有整台电脑的内存。它统一规定:我这个程序的代码和数据,永远从地址0开始往后排。 这个从0开始的假地址,就是逻辑地址。物理地址(绝对地址): 这是主板上那根 DDR5 内存条上实实在在的、用万用表能测出电平的物理硅单元编号。
为什么要造假?(物理防线) 如果允许用户程序直接操作真实的物理地址,那流氓软件只要写一句 pointer = 0x0000; *pointer = 0;,就能瞬间把操作系统内核的最关键数据清零,你的电脑当场蓝屏死机。
造假,是为了绝对的隔离与安全。
第二部分:重定位
既然程序里写的都是假地址(从 0 开始),那当这个程序真的被装进内存(比如装在了物理地址 10000 的位置),CPU 执行代码时,是怎么找到真正的数据的?
这就必须依靠一套地址转换机制,专业术语叫重定位。
它分为两种流派:
静态重定位(老掉牙的办法): 在程序装入内存的那一瞬间,操作系统大管家把程序里所有的假地址,全部加上一个偏移量(比如加上
10000)。- 致命缺陷: 一旦装进去,这个进程在内存里就绝对不能再移动了。回想我们在第 2 章和第 3 章学的挂起机制!如果进程不能移动,大管家怎么把它踢到硬盘上再接回来?所以现代操作系统绝对不用这个。
动态重定位(现代操作系统的基石): 程序里的地址永远是假的。哪怕它被装进了内存,里面的代码依然写着去读
0号地址。微观魔法:MMU (内存管理单元) 的诞生。 CPU 内部专门集成了一块纯硬件电路,叫 MMU,它里面有一个极其关键的硬件寄存器:重定位寄存器(基址寄存器)。
运行原理: 每次分派器把 CPU 交给一个进程时,会把这个进程在物理内存中的“真实起点地址”偷偷塞进这个基址寄存器里。 当进程执行
读取地址 100的指令时,MMU 硬件会以光速在底层做一个加法:假地址 (100) + 基址寄存器的值 (10000) = 真实的物理地址 (10100),然后才去物理内存里取数据。
这就完美解释了隔离性: 只要操作系统大管家控制着那个基址寄存器,你的程序不管怎么越界,最终加出来的物理地址也只会被死死限制在你自己的沙盒里。
第三部分:连续内存分配算法
在正式讲分页之前,考试经常会先考一类“空闲分区分配”题:内存里有几个空洞,作业一个个进来,问首次适应和最佳适应怎么放。
这其实就是大管家给客人安排房间。
假设现在有两个空闲区:
1 | F1 = 200KB |
作业依次到来:
1 | A = 30KB |
1. 首次适应
首次适应的规矩是:从低地址开始找,遇到第一个装得下的空闲区就放进去。
它不追求最省,只追求“从前往后第一个能住就住”。
对于上面的例子:
1 | A 先看 F1,F1 能装,于是放入 F1,F1 剩 170KB。 |
首次适应通常速度快,因为它不用把所有空闲区都看完。
但它容易把低地址区域切得很碎,前面会出现一堆小碎片。
2. 最佳适应
最佳适应的规矩是:找所有能装下它的空闲区里,最小的那个。
它像一个很抠门的宿管:大房间尽量留给大客人,小客人塞进刚好够住的小房间。
对于上面的例子:
1 | A = 30KB:F1 能装,F2 也能装,选更小的 F2,F2 剩 30KB。 |
最佳适应看起来省空间,但它经常制造特别小、特别尴尬的碎片。比如剩下 1KB、2KB 的空洞,看着还在,实际很难再用。
3. 最坏适应
最坏适应每次挑最大的空闲区。
它的想法是:切也从最大的地方切,剩下的碎片至少还比较大,不至于马上变成废料。
但它也有问题:大空闲区会被很快切小,后面真正的大作业来了,可能反而没地方放。
4. 这类题怎么答
不要只写“能放”或“不能放”。考试要看过程。
标准写法:
1 | 当前空闲区:F1=200KB, F2=60KB |
本质上这类题和银行家算法很像:别凭感觉,按规则一步步更新状态。
第四部分:内存碎片与分页
有了 MMU,大管家开始给进程分配内存了。最简单的想法是:连续分配。 程序 A 需要 10MB,就在内存里找一块连续的 10MB 塞进去;程序 B 需要 20MB,紧挨着 A 塞进去。
这种天真的做法会引发一场恐怖的物理灾难:外部碎片。
想象一下:
你的物理内存有 100MB。
你依次打开了三个程序:A (20MB), B (30MB), C (20MB)。此时内存还剩最后连续的 30MB 空间。
现在,你关掉了中间的程序 B。B 占用的 30MB 被释放了。
此时,物理内存里总共有两块空闲地盘:中间的 30MB,和最后的 30MB。总空闲内存是 60MB。
此时你想打开一个 40MB 的 3A 大作。
灾难降临: 操作系统大管家看着明明有 60MB 的空闲容量,却绝望地告诉你“内存不足”!因为它找不到一块连续的 40MB 空间!
那些空闲着、总容量很大、但因为被切碎了而无法使用的内存缝隙,就叫外部碎片。为了解决这个问题,大管家只能进行“紧凑”(把所有运行的程序强行暂停,在内存里把它们挪到一边靠拢),但这极其极其消耗 CPU 算力。
格子分配: 10KB 的程序,分给它 3 个 4KB 的格子(共计 12KB)。这虽然彻底消灭了致命的“外部碎片”,但最后那个格子浪费了 2KB。这在操作系统中被称为内部碎片。大管家认为,相比于挪动整个内存造成的卡死,这种微小的空间浪费是完全可以接受的代价。
秘密账本: “记录程序使用了哪些格子”的账本,就是操作系统中最伟大的数据结构——页表!
场景推理题: 假设你正在开发一款极其吃配置的 3A 大作,或者在跑一个庞大的 AI 训练脚本。
这个进程的虚拟空间大小是 4GB(逻辑地址很大)。
操作系统的物理格子(页框)大小是 4KB。
每记录一行账本(一个页表项),需要占用 4个字节 (4B) 的物理内存。
请问: 仅仅是为了这一个进程,操作系统大管家需要在这本“页表账本”上写多少行记录?这本账本本身会占用多少 MB 的物理内存?(这是一个非常简单的除法和乘法)
第五部分:为什么还要有“分段”?
分页是物理导向的,而分段是逻辑导向的。
痛点:分页太“野蛮”了。 分页是硬生生地把内存切成 4KB 的死板方块。它不管你的代码逻辑。
- 如果你的一段核心和弦逻辑正好被切在了两个格子的边缘,而其中一个格子被踢到了硬盘,另一个还在内存。这种物理上的支离破碎,对程序的共享和保护极其不利。
解决原理:分段。 分段是按照程序员的逻辑意图来切分内存。比如:
段 0: 你的主程序代码区。
段 1: 全局变量数据区。
段 2: 函数调用的栈区。
区别与联系:
页: 是物理单位。大小固定,对用户不可见。目的是消灭碎片,提高内存利用率。
段: 是逻辑单位。长度不固定,对用户可见。目的是方便编程、方便共享和保护。
账本升级:段表 段表和页表很像,但多了一个维度。页表只记“物理块号”,而段表要记“这段的起点”和“这段的长度”。因为每一段的长短都不一样,如果你的程序试图读写超过这段长度的地址,MMU 会立刻报错,这就是你经常听到的 “段错误”。
第六部分:段页式存储
现在的操作系统觉得分页和分段各有优缺点,于是干了一个极其丧心病狂的操作:段页式管理。
微观物理过程:
先按照逻辑,把程序分成一个个段(代码段、数据段)。
在每一个段内部,再强行切成一个个 4KB 的页。
地址转换(三重套娃): CPU 要找一个数据,先去查段表,找到该段对应的页表起始地址;再去查页表,找到具体的物理块号;最后加上偏移量拿到数据。
代价: 虽然解决了所有碎片和共享问题,但现在每读一个数据,物理上要去内存跑三次! 这就埋下了一个新问题:地址转换太慢,后面必须靠硬件缓存来救场。
第七部分:盲点整合——碎片与双表关系
引入页表(分页机制)之前: 比如连续分配、或者是纯粹的分段机制,物理内存是按需划出一大块一大块的。这必然会导致外部碎片(空间总和够,但被切碎了,装不下新程序)。
引入页表(分页机制)之后: 物理内存被死死地切成了固定大小的“死格子”(通常是 4KB)。只要你程序需要,我随便从天南海北给你凑齐所需数量的格子,根本不需要连续。所以,外部碎片被彻底、永久地消灭了!
代价: 代价就是内部碎片。程序的大小不可能刚好是 4KB 的整数倍。比如程序要 13KB,系统只能给它分配 4 个格子(16KB)。最后一个格子里没装满的 3KB,就被浪费了。但这 3KB 都在进程自己的“肚子里”(内部),所以叫内部碎片。大管家认为,相比于卡死系统,浪费这点边角料完全可以接受。
你问段表和页表是不是独立的数据结构,还是互相整合的?
答案是:它们本质上全都是静静躺在物理内存里的“一维数组”,但在不同的管理模式下,它们的“指针指向”完全不同!
在纯分页系统里: 只有页表。页表这个数组里存的是:
物理块号。在纯分段系统里: 只有段表。段表这个数组里存的是:
这一段在物理内存的真实起点地址+这一段的长度。在段页式系统里(终极缝合怪):两者互相整合,形成了一套“连环指针”!
在这个系统里,段表里存的不再是物理内存的起点地址了!
段表里存的是:这个段所专属的那个“页表”的物理地址!
也就是说:段表指向页表,页表才指向真正的物理内存。 它们在物理上是好几块分开的数组,但在逻辑上被一根无形的链条锁在了一起。
第八部分:地址转换全流程(宏观与微观)
这是你提的最硬核的要求。我们现在就把操作系统(软件)和 MMU(纯硬件)的配合,像放电影一样逐帧拆解。
假设你写了一个 Vue.js 的打包脚本进程。它有一个核心代码段。
1. 第一幕:大管家铺路(将数据写入内存,由 OS 软件完成)
逻辑切分: 当你双击运行这个程序,操作系统首先看它的文件头,发现它分为代码段(段 0)、数据段(段 1)等。
物理切碎: 操作系统毫不留情地把“代码段”切成一个个 4KB 的逻辑页(页 0, 页 1...)。
塞入内存: 操作系统去物理内存里找空闲的格子(比如找到了物理块 105, 物理块 208)。把代码段的第一页塞进 105,第二页塞进 208。
建立账本(极其关键):
OS 在内存里创建一张页表,记录:页 0 → 块 105,页 1 → 块 208。
OS 在内存里创建一张段表,在第 0 行(代表段 0)写下:刚才那张页表的物理起始地址,以及这段代码的总长度(防越界)。
移交权力: OS 把这张段表的起始地址,存进这个 Node.js 进程的 PCB 里。然后把 CPU 交给这个进程。
2. 第二幕:CPU 狂飙寻址(读取数据,完全由 MMU 硬件自动完成,OS 闭嘴)
现在,CPU 正在执行代码,突然遇到一条指令:读取 段0,第 5000 字节 位置的数据。(这就是逻辑地址:段号=0,段内偏移=5000)。
启动硬件: 发生进程切换时,OS 已经提前把 PCB 里的“段表起始地址”塞进了 CPU 内部的一个硬件寄存器(段表寄存器)。
查段表(第一次访问内存): MMU 硬件根据寄存器,直接去内存里找到段表的第 0 行。
MMU 检查:5000 字节有没有超过这个段的长度限制?(如果超过,立刻触发硬件异常,OS 接管并杀死进程,报
Segmentation Fault)。如果没有越界,MMU 从段表里拿到了属于段 0 的那张页表的起始地址。
算页号: MMU 硬件在内部极速计算:5000 字节到底在第几页?(5000 / 4096 = 1...余 904)。所以它在页 1,页内偏移是 904。
查页表(第二次访问内存): MMU 顺着刚才拿到的页表地址,去查页表的第 1 行。它发现,页 1 对应的真实物理块号是 208。
拼装真实地址: MMU 把 物理块号 208 转换成真实物理地址(208 * 4096 = 851968),然后加上刚才的页内偏移 904。
- 最终绝对物理地址 = 851968 + 904 = 852872。
拿到数据(第三次访问内存): CPU 根据 852872 这个真正的物理地址,去内存条里把数据读出来,放进寄存器参与运算。
3. 幕后花絮:先认识 TLB 快表
你看了上面的过程,肯定会觉得疯了:CPU 为了读一个数据,物理上竟然去内存里跑了整整三次(查段表 → 查页表 → 读数据)!这性能简直是灾难。
所以,CPU 内部专门准备了一块极速的 TLB 快表。
它可以理解成地址转换的“小抄本”:页表和段表是完整账本,放在内存里,查起来慢;TLB 是硬件缓存,放在 CPU 附近,只记录最近用过的地址转换结果,查起来极快。
当 MMU 第一次极其痛苦地经历完上面 6 步,算出 段0,页1 对应 物理块208 之后,它会把这个结果立刻死死记在 TLB 里。
下一秒钟,当你代码里的 for 循环要读取 5004 字节的数据时,MMU 直接在 TLB 里瞬间查到它依然在物理块 208,直接省略掉查段表和查页表的过程,直接去内存拿数据!
第六章:虚拟存储器
第一部分:虚拟内存与请求分页
“按需加载”,在专业术语里叫请求分页。
底层逻辑: 操作系统大管家对进程撒了一个弥天大谎。它告诉进程:“你有 10GB 空间。”但实际上,大管家只在物理内存里给了它 100MB。
物理直觉: 就像练习钢琴改编曲,你的大脑(内存)不需要同时背下整本几十页的复杂琴谱。你只需要在当下这一小节(当前指令)弹奏时,把这一页放在谱架上。等你弹完了,再翻下一页(从外存调入新页)。
核心机制——缺页中断: 当 CPU 执行代码,发现页表上记录的这一页数据不在内存里时,CPU 会立刻触发内中断,操作系统介入处理。
第二部分:页面置换算法
我们上一节刚刚推演到这一步,但这里有一个第六章最核心的矛盾:
如果发生缺页中断时,物理内存的页框已经 100% 全满了,大管家该怎么办?
大管家别无选择,只能当一个冷酷的房东:必须从内存里强行揪出一个正在使用的页面,把它踢回硬盘,腾出格子给新来的页面用。
整个 6.2.3 和 6.3,讨论的本质就是:到底该把谁踢出去?
1. 手动推演模板(3 个页框,访问序列:7 -> 0 -> 1 -> 2 -> 0 -> 3)
初始装入:
- 读 7,缺页,内存:[7, 空, 空]
- 读 0,缺页,内存:[7, 0, 空]
- 读 1,缺页,内存:[7, 0, 1]
读 2,缺页且内存已满:
- 若按 FIFO(先进先出),踢掉最先进入的 7,内存变为:[2, 0, 1]
紧接着读 0:
- 0 在内存中,属于命中
最后读 3:
- 若按 LRU(最近最久未使用),此时在 [2, 0, 1] 中,1 距离上次访问最久,踢掉 1
2. 关键陷阱:LRU 不等于 LFU
- LRU: 只看“最近有没有被访问过”,看的是时间。
- LFU: 看的是累计访问次数(频率)。
两者会在很多场景得出不同结论,考场和面试经常把这两个概念混在一起挖坑。
3. 常见算法族谱(提炼)
- OPT(最佳置换): 需要预知未来,现实不可实现,只作为理论上界。
- FIFO: 实现简单,但可能踢掉马上要用的页面。
- LRU: 贴近局部性原理,但精确实现开销高。
- Clock / NRU: 用访问位和循环指针近似 LRU,是工程上常见方案。
第三部分:抖动与工作集
第一部分:灾难降临 —— 什么是“抖动”?
我们刚才推演了,内存满了就踢人。但如果大管家踢人的频率太高了,会发生什么?
想象这样一个场景:假设你正在弹钢琴。这首曲子总共有 20 页的乐谱(进程的虚拟逻辑空间),但是你面前的谱架(分配给该进程的物理内存页框)极其狭小,只能同时摆下 2 页谱纸。
灾难发生: 在演奏高潮段落时,由于左手和弦与右手主旋律距跟极大(程序频繁进行跳转访问),你刷弈完第 1 页,马上需要看第 8 页,于是你只能停下双手,把第 1 页撤下换上第 8 页(触发缺页中断,换入换出)。紧接着下一小节,旋律又回到了第 1 页,你又得停下来把第 1 页换回来。
宏观表现: 从旁观者的视角看,你坐在钢琴前,手忙脚乱地疯狂抽换着乐谱,用来真正按琴键(CPU 执行代码计算)的时间几乎为零。
在操作系统中,这种“刚被换出的页面立别又要被访问,导致系统把几乎所有的时间都花在把页面换入换出硬盘上,而 CPU 实际利用率断崖式跌近 0%”的恐怖现象,就叫做抖动。
第二部分:破局之法 —— 工作集
为什么会发生抖动?根本原因是:操作系统分配给这个程序的物理格子太少了,根本装不下它“当前正在密集使用的那些页面”。
为了用数学和架构的手段解决这个问题,计算机科学家 Denning 提出了工作集模型。
1. 物理直觉:什么是工作集?
工作集指的是,在最近的一段固定时间窗口(Δ)内,程序实际真正访问过的页面集合。
比如在演奏前奏的 1 分钟内,你其实只需要反复看第 1、2、3 页。那么 {1, 2, 3} 就是你这段时间的工作集。你需要 3 个格子的物理内存。
当进入副歌部分,你的工作集可能会平滑过渡变成 {4, 5, 6, 7}。此时你需要 4 个格子的物理内存。
2. 大管家的防抖动铁律
操作系统大管家在给进程分配物理内存格子(这叫驻留集)时,必须遵守一个绝对底线:
驻留集的大小,必须大于或等于工作集的大小。
(给你的谱架容量,必须装得下你最近几分钟要频繁看的那几页谱子)。
如果这个条件长期不成立,进程就会高概率进入抖动。
3. 残酷的断臂求生
如果物理内存总共只有 100 个格子,而当前运行的 10 个程序的工作集加起来需要 120 个格子。大管家此时如果心慈手软,让 10 个程序一起跑,系统必然陷入全局抖动,直接死机。
此时,大管家必须动用我们在第二章学过的终极手段:中级调度(挂起)。
大管家会冷酷地挑选 1 到 2 个进程,把它们的所有页面全部踢出内存,强制转移到硬盘的挂起队列中,从而腾出足够的物理格子,保全剩下的程序能稳定运行。
第七章:输入输出系统
前面几章我们一直在讲 CPU、内存、进程和页面。可是计算机真正和现实世界发生关系,靠的是输入输出设备:键盘、鼠标、显示器、打印机、网卡、磁盘。
这章的核心矛盾只有一句话:
CPU 太快,外设太慢;外设种类太多,程序又不应该知道每个设备的脾气。
所以操作系统必须先当一个翻译官和调度员:上层程序只说“我要输入”“我要输出”“我要打印”,操作系统负责把这句话翻译成具体设备能听懂的动作。
第一部分:输入输出的功能
你可以把 CPU 想象成一个反应极快的厨师,而外设像一堆性格完全不同的供应商:
- 键盘一次只送来一个字符。
- 磁盘一次适合搬一整块数据。
- 打印机慢得像排队盖章。
- 网卡的数据可能突然成批涌进来。
如果让用户程序直接和这些设备打交道,程序员就会崩溃。你写一个保存文件的功能,还得懂磁盘控制器怎么转、磁头怎么寻道、缓冲区怎么排队,这显然不合理。
所以操作系统在中间加了一层抽象:
- 用户程序只面对统一接口,比如读、写、打开、关闭。
- 操作系统把请求交给设备驱动程序。
- 设备驱动程序再去指挥具体硬件。
这就是输入输出系统的第一条主线:把复杂硬件包装成统一、稳定、好用的接口。
第二部分:设备控制器与设备驱动程序
1. 设备控制器:硬件世界的前台
CPU 不会直接伸手去拧磁盘马达,也不会直接控制打印机喷头。它面对的是设备控制器。
设备控制器就像每个外设前面的“前台窗口”。CPU 把命令交给前台,前台再去指挥真正的设备。
设备控制器里通常有三类寄存器:
- 数据寄存器:存放要输入或输出的数据。
- 状态寄存器:告诉 CPU 设备现在忙不忙、有没有出错、数据准备好了没有。
- 控制寄存器:CPU 往这里写命令,比如开始读、开始写、复位设备。
所以,输入输出并不是 CPU 对设备喊话,而是 CPU 对控制器的寄存器读写。
2. 设备驱动程序:操作系统里的专职翻译
不同设备控制器的命令格式不同。磁盘有磁盘的说法,打印机有打印机的说法,网卡有网卡的说法。
如果操作系统内核每次都亲自记住所有设备细节,它会臃肿到无法维护。
于是就有了设备驱动程序。
驱动程序的作用是:
- 接收操作系统发来的通用请求。
- 把通用请求翻译成具体设备命令。
- 处理中断和错误。
- 把结果再交还给操作系统。
你可以把它理解为“专业设备翻译”。操作系统大管家说:“读这块数据。”磁盘驱动程序负责把这句话变成磁盘控制器能执行的一组命令。
3. 设备独立性
设备独立性的意思是:应用程序不应该依赖具体设备。
比如你写:
1 | 打印这份文档 |
程序不应该关心现在接的是惠普打印机、佳能打印机,还是虚拟打印机。只要操作系统提供统一接口,底层换设备时,上层程序尽量不用改。
这和第一章讲的“数据独立性”很像:底层可以变,上层尽量稳。
第三部分:输入输出控制方式
这一节是考试爱考概念区分的地方。你只要抓住一个问题:CPU 到底要不要一直守着设备?
1. 程序直接控制:CPU 像个焦虑保安
最原始的方法是:CPU 不停地问设备控制器:
1 | 好了没? |
如果设备还没准备好,CPU 就继续原地等。
这叫程序直接控制。
优点:简单。
致命缺点:浪费 CPU。外设很慢,CPU 却一直傻等,像一个大厨站在门口等外卖,不去做其他菜。
2. 中断控制:外设好了再敲门
更聪明的方法是:CPU 发出输入输出命令后,不再傻等,而是去执行别的进程。
等设备完成任务后,它通过硬件中断告诉 CPU:
1 | 我好了,你来处理一下。 |
这就是中断控制。
它解决了 CPU 空等的问题,是操作系统并发能力的重要基础。
但它也有代价:如果每传一个字节就中断一次,CPU 会被频繁打断,像刚坐下写作业就被叫起来开门,效率仍然不高。
3. 直接内存访问:搬运工绕开 CPU
如果要从磁盘读一大块数据,最蠢的方式是:磁盘先把一个字节交给 CPU,CPU 再把它放进内存。这样 CPU 变成了搬运工。
直接内存访问的思路是:让专门的控制器负责在设备和内存之间批量搬数据。
CPU 只需要一开始交代清楚:
1 | 从哪里读,读多少,放到内存哪里。 |
然后控制器自己搬。搬完之后,再用一次中断通知 CPU。
这就像大管家不亲自搬箱子,只写一张搬运单,让搬家公司去干活。箱子搬完了,搬家公司再敲门汇报。
4. 通道方式:更专业的搬运队长
在大型系统里,还可以有更强的输入输出处理部件。它不只是搬数据,还能执行一小段输入输出程序,自己安排多个设备的输入输出。
这叫通道方式。
你可以把它理解为:直接内存访问只是一个搬运工,通道则是一个小队长,能看懂任务清单,能组织一批搬运工干活。
考试记忆顺序:
1 | 程序直接控制 -> 中断控制 -> 直接内存访问 -> 通道方式 |
方向是:CPU 越来越少被琐碎输入输出拖住,设备系统越来越独立。
第四部分:缓冲技术
1. 为什么要缓冲?
缓冲区就是内存里专门开出来的一块中转区。
它解决的是速度不匹配问题。
比如你看视频:
- 网卡一阵一阵地收数据。
- 播放器希望稳定地读数据。
如果没有缓冲,网络稍微抖一下,画面就卡一下。
有了缓冲,操作系统先把数据放进内存中的缓冲区,应用程序再从缓冲区平稳读取。
缓冲区就像厨房备菜台。供应商送菜不是匀速来的,但厨师做菜希望手边一直有菜。备菜台越合理,厨房越不容易乱。
2. 单缓冲
单缓冲就是只有一个中转托盘。
设备往托盘里放数据,CPU 或进程从托盘里拿数据。
问题是:放和拿经常互相等待。设备正在往托盘里放东西时,进程可能没法拿;进程正在拿时,设备可能没法继续放。
3. 双缓冲
双缓冲准备两个托盘。
一个托盘给设备写,另一个托盘给进程读。等双方完成后交换。
这就像两个快递箱轮流使用:快递员往 A 箱塞包裹时,你从 B 箱取包裹;下一轮反过来。
双缓冲比单缓冲更能让设备和 CPU 并行工作。
4. 循环缓冲与缓冲池
如果数据流更连续,就可以准备多个缓冲区连成一个环,或者由操作系统统一维护一个缓冲池。
缓冲池的本质是:别让每个进程自己乱申请缓冲区,而是由操作系统集中管理,谁需要谁借,用完归还。
这和信号量里的“许可牌”很像:缓冲区是有限资源,不能随便抢。
第五部分:假脱机技术
假脱机技术最典型的场景是打印。
打印机很慢,但用户程序不能一直卡在那等打印机吐纸。否则你点一下打印,整个系统就像被绑架了。
操作系统的做法是:
- 用户程序把要打印的内容先交给磁盘上的输出井。
- 用户程序以为“打印请求已经提交”,可以继续干别的事。
- 后台打印进程慢慢从输出井里取任务,按顺序喂给打印机。
这就是假脱机。
它把独占设备改造成了“看起来可以共享”的设备。
打印机物理上一次只能服务一个任务,但很多用户可以把打印任务先排进队列。就像奶茶店只有一个出杯口,但所有人可以先拿号,后台按号制作。
考试如果问假脱机的核心作用,记住两点:
- 提高设备利用率。
- 把独占设备改造成逻辑上的共享设备。
第六部分:输入输出系统本章自测
题 1:为什么中断控制比程序直接控制更高效?
因为程序直接控制会让 CPU 一直等待设备完成,而中断控制允许 CPU 在设备工作期间去执行其他任务。设备完成后再用中断通知 CPU。
题 2:直接内存访问为什么适合磁盘这类高速块设备?
因为磁盘一次常常传输一大块数据。如果每个字节都经过 CPU,CPU 会被搬运工作拖死。直接内存访问能让控制器在设备和内存之间批量搬运,只在开始和结束时打扰 CPU。
题 3:假脱机为什么能把独占设备改造成共享设备?
因为多个进程不再直接抢打印机,而是把任务先排到磁盘队列里。后台进程再按顺序慢慢输出到打印机。物理设备仍然独占,但逻辑上多个用户都能提交任务。
第八章:文件管理
输入输出系统解决的是“设备怎么把数据送进来、送出去”,文件管理解决的是另一个问题:这些数据长期放在硬盘上时,怎样让用户看到的是文件,而不是一堆冰冷的磁盘块。
可以把文件系统理解成硬盘上的档案室。磁盘只提供格子,文件系统负责贴标签、建目录、记录位置、检查权限,让用户能用“文件名”和“路径”找到东西。
第一部分:文件系统到底在管什么?
文件系统解决的是:硬盘上只有一堆块,用户想看到的是文件和目录。
硬盘的物理世界是:
1 | 块 0、块 1、块 2、块 3 ... |
用户眼里的世界是:
1 | D:\项目\blog\source\_posts\OS笔记.md |
文件系统的任务,就是把这些冰冷的块,组织成有名字、有路径、有权限、有结构的文件。
1. 文件是什么?
文件是具有名字的一组相关信息的集合。
这句话很抽象,翻译成人话就是:
文件不是一堆散落的字节,而是操作系统承认的一份完整资料。
比如:
- 一篇笔记。
- 一张图片。
- 一个程序。
- 一段视频。
这些在硬盘上可能都被切成很多块,但用户不需要知道。用户只需要知道文件名。
2. 文件控制块
文件控制块是文件系统里的“户口本”。
它不一定保存文件内容本身,而是保存文件的管理信息,例如:
- 文件名。
- 文件类型。
- 文件大小。
- 文件在磁盘上的位置。
- 创建时间、修改时间。
- 访问权限。
考试经常拿它和进程控制块对比。
进程控制块是进程的身份证和病历,记录进程状态、寄存器、调度信息、打开文件等。
文件控制块是文件的户口本,记录文件名、位置、大小、权限等。
千万别混:进程控制块管“正在运行的程序”,文件控制块管“硬盘上的文件”。
3. 目录是什么?
目录本质上也是一种特殊文件。
它里面保存的是“文件名到文件控制块”的映射关系。
你双击一个文件名时,操作系统不是凭空找到文件内容,而是先查目录:
1 | 这个名字对应哪个文件控制块? |
所以目录不是一个漂亮的文件夹图标,而是一张索引表。
第二部分:路径名
1. 绝对路径
绝对路径从根目录开始,一路写到目标文件。
例如:
1 | D:\项目\blog\source\_posts\OS笔记.md |
它像完整家庭住址:哪个城市、哪条路、哪栋楼、哪一户,全都写清楚。
试卷里说“文件的绝对路径名由什么组成”,答案就是:
1 | 从根目录到该文件所经历的全部目录名 + 文件名 |
不要只写文件名,也不要只写当前目录名。
2. 相对路径
相对路径不是从根开始,而是从当前目录开始。
如果你当前就在:
1 | D:\项目\blog |
那么:
1 | source\_posts\OS笔记.md |
就是相对路径。
相对路径像“从你现在的位置往前走两步左转”。它必须依赖当前所在目录。
第三部分:文件的逻辑结构与物理结构
1. 逻辑结构:用户看到的样子
文件的逻辑结构是用户和程序眼里的组织方式。
常见有两类:
- 无结构文件:就是一串字节流。很多普通文本、二进制文件都可以这样看。
- 有结构文件:由一条条记录组成,比如学生记录、借阅记录。
逻辑结构回答的是:用户怎么理解这个文件。
2. 物理结构:磁盘上怎么放
文件的物理结构回答的是:文件内容在磁盘块里怎么摆。
常见三种:
连续分配:
文件占用一段连续磁盘块。
优点是顺序访问极快,因为磁头可以一路读下去。
缺点是容易产生外部碎片,而且文件变大时很麻烦:后面如果没空位,就得搬家。
链接分配:
文件的数据块可以分散在磁盘各处,每一块里保存下一块的位置。
优点是不要求连续,文件增长方便。
缺点是随机访问很慢。你想读第 100 块,必须从第 1 块一路顺藤摸瓜找到第 100 块。
索引分配:
操作系统给文件单独准备一个索引块。索引块里记录这个文件所有数据块的位置。
优点是既不要求连续,又支持较快随机访问。
缺点是索引块本身要占空间;小文件可能显得有点浪费,大文件可能需要多级索引。
把它们放进生活直觉里:
- 连续分配像整本书页码连续装订。
- 链接分配像寻宝地图,每一页告诉你下一页在哪。
- 索引分配像目录页,先查目录,再直达目标页。
第四部分:打开文件的底层过程
为什么系统要有“打开文件”这个动作?为什么不能每次读写都从路径重新找?
因为从路径找文件很慢。
比如你要读:
1 | D:\项目\blog\source\_posts\OS笔记.md |
操作系统要一级一级查目录:
1 | D: -> 项目 -> blog -> source -> _posts -> OS笔记.md |
如果每读一小段内容都这么查一次,效率会很差。
所以第一次打开文件时,操作系统会:
- 根据路径查目录。
- 找到文件控制块。
- 检查权限。
- 把文件的关键管理信息放进打开文件表。
- 返回一个文件句柄给进程。
之后进程读写文件时,不再每次从路径开始找,而是拿这个句柄直接访问打开文件表。
这就像你第一次去图书馆借书,要查书名、找书架、登记借阅;之后你手里有借书单,继续续借或归还就不用重新找整栋楼。
第五部分:文件管理本章自测
题 1:文件控制块和进程控制块有什么区别?
文件控制块记录文件的管理信息,例如文件名、大小、位置、权限。进程控制块记录进程运行所需的管理信息,例如进程状态、寄存器、调度信息、打开文件。一个管文件,一个管进程。
题 2:绝对路径名由什么组成?
由从根目录到该文件所经历的全部目录名,以及该文件名组成。
第九章:磁盘存储器管理
文件管理站在用户角度看“文件怎么组织”,磁盘存储器管理则往下看一层:磁盘这个慢速设备本身怎样读写才更快。
这章的核心不是背算法名字,而是抓住一个物理事实:磁头移动很慢。只要让磁头少来回跑,磁盘访问就会快很多。
第一部分:磁盘结构与磁盘访问时间
磁盘题看起来像数学题,其实先要理解物理动作。
磁盘读写一次数据,大致花三段时间:
- 寻道时间:磁头移动到目标磁道。
- 旋转延迟:盘片转到目标扇区。
- 传输时间:真正把数据读出来或写进去。
其中最容易被调度算法优化的是寻道时间。
因为磁头移动很慢。如果请求顺序乱七八糟,磁头就会在盘面上来回跑,像一个快递员在同一栋楼里一会儿跑 1 楼,一会儿跑 20 楼,一会儿又回 2 楼。
磁盘调度的目标就是:少走冤枉路。
第二部分:磁盘调度算法
1. 先来先服务
最公平,谁先来处理谁。
问题是磁头可能疯狂来回跑。
例如当前磁头在 58,访问请求是:
1 | 130, 42, 180, 15, 199 |
如果按先来先服务:
1 | 58 -> 130 -> 42 -> 180 -> 15 -> 199 |
总移动距离就是每一段差值相加。
优点是公平,缺点是可能很慢。
2. 最短寻道时间优先
每次选择离当前磁头最近的请求。
它像一个懒得走远路的快递员:先送离自己最近的那一单。
优点是平均寻道距离通常更短。
缺点是远处请求可能一直等。如果新来的请求总在磁头附近,远处那一单就可能被长期冷落。
3. 扫描算法
扫描算法像电梯。
电梯不会因为楼下有人按了按钮就立刻掉头。它会沿着当前方向一直走,顺路服务请求,到头后再反向。
磁盘扫描算法也是这样:
- 磁头沿当前方向移动。
- 遇到沿途请求就处理。
- 到达边界后再反向。
试卷里常给这种题:
1 | 磁道号 0 到 199 |
如果约定 0 是最外侧,199 是最内侧,那么“向内侧”就是磁道号变大。
处理顺序:
1 | 58 -> 130 -> 180 -> 199 -> 42 -> 15 |
移动距离:
1 | (130-58) + (180-130) + (199-180) + (199-42) + (42-15) |
注意这里不要漏掉从 199 掉头到 42 的那一大段。很多人错就错在只算访问请求,不算磁头实际走过的路。
4. 循环扫描
循环扫描像单向环线公交。
它只朝一个方向服务,到达终点后直接回到起点,再继续同方向服务。
这样做的好处是等待时间更均匀,不会让某一端请求总是占便宜。
5. LOOK 与 C-LOOK
LOOK 是扫描算法的改进。
普通扫描会一路走到磁盘边界,哪怕边界处没有请求也会走过去。
LOOK 更聪明:它只走到当前方向上最远的请求,然后掉头。
C-LOOK 则是循环扫描的聪明版:只在有请求的范围内循环,不一定跑到物理边界。
考试若没有特别说明,按题目要求使用指定算法,不要自己切换成改进版。
第三部分:磁盘存储器管理本章自测
题 1:磁盘访问时间由哪几部分组成?
主要由寻道时间、旋转延迟和传输时间组成。其中磁盘调度最主要优化的是寻道时间,也就是尽量让磁头少跑冤枉路。
题 2:磁盘扫描算法为什么像电梯?
因为它沿一个方向持续移动,顺路处理请求,到边界或最远请求后再掉头。它不会因为反方向来了一个请求就立刻乱掉头。