type
status
date
slug
summary
tags
category
icon
password
宏观理解:
把操作系统想象成一个国家的“中央政府”或者一个大公司的“总负责人/CEO”。
- 计算机硬件 (CPU, 内存, 硬盘, 外设等):就像是国家的 土地、矿产、工厂、道路、港口等基础设施和自然资源,或者公司的 厂房、设备、资金、原材料等资产。它们是基础,但本身不会自动协调工作。
- 应用程序 (微信, 浏览器, 游戏等):就像是国家的 公民、企业、各种社会组织,或者公司的 各个部门、项目组、员工。它们有各种各样的需求,想要使用国家的资源或公司的资产来完成自己的任务。
- 操作系统 (OS):就是这个中央政府或公司 CEO 及管理层。它的核心职责就是管理和调度所有资源,制定规则,提供服务,确保整个国家(计算机)能够有序、高效、稳定地运行,让公民和企业(应用程序)能够安居乐业、顺利工作。
操作系统的核心职能(就像政府的核心部门或 CEO 的关键职责):
- 处理器管理 (CPU Scheduling) - “劳动力调配中心 / 工作任务分配”
- 做什么? 电脑里可能同时运行着很多程序,但 CPU(核心处理器,干活最快的主力)在某一瞬间通常只能干一件事。OS 负责决定哪个程序在什么时候使用 CPU,用多久,并且要在它们之间快速切换,让你感觉它们在“同时”运行(并发)。
- 政府比喻: 就像政府调配全国的核心劳动力或关键生产线,决定优先保障哪个项目(程序)的生产,并确保资源(CPU 时间)公平、高效地分配。或者像 CEO 决定哪个紧急项目优先获得核心团队的支持。
- 内存管理 (Memory Management) - “土地规划与房产管理”
- 做什么? 内存 (RAM) 是程序运行时临时存放数据和指令的地方,速度快但容量有限且断电丢失。OS 负责给每个运行的程序分配内存空间,确保它们互不干扰(你的微信数据不会写到游戏内存里去),并在程序关闭后回收内存。当内存不够时,还需要用硬盘空间模拟内存(虚拟内存)。
- 政府比喻: 就像政府进行国土空间规划,给每个城市、每个项目、每个居民分配土地(内存),确保界限清晰,并管理土地的买卖、租赁和回收。同时还要处理土地紧张时的安置问题(虚拟内存)。
- 文件系统管理 (File System Management) - “国家档案馆 / 图书馆 / 户籍管理”
- 做什么? 硬盘或 SSD 是长期存储数据的地方。OS 负责在这些存储设备上组织和管理文件(你的文档、照片、程序本身等)。它提供了文件夹(目录)结构让你方便查找,并记录了每个文件的位置、大小、权限等信息。
- 政府比喻: 就像国家建立档案馆、图书馆系统来保存所有文献资料,或者户籍管理系统来记录每个公民的信息。它提供一套规则和工具,让你能方便地存取、查找、管理这些信息(文件)。
- 设备管理 (Device Management) - “公共设施与接口管理”
- 做什么? 电脑连接了各种各样的设备(键盘、鼠标、显示器、打印机、网卡等)。OS 负责管理和驱动这些设备,提供统一的接口给应用程序使用,这样应用程序就不需要知道每种设备的具体硬件细节。这通常通过“驱动程序”(Driver)实现。
- 政府比喻: 就像政府管理国家的电力、供水、交通网络等公共基础设施。它确保这些设施正常运行,并提供标准的接口(比如插座标准、道路交通规则),让所有公民和企业都能方便地使用这些服务,而不需要自己去建电厂或修路。驱动程序就像是各个设施的专业维护和运营团队。
- 用户接口 (User Interface, UI) - “政府服务大厅 / 与民众沟通的渠道”
- 做什么? 这是 OS 提供给用户直接交互的方式。可以是图形界面(GUI),如 Windows 的桌面、图标、菜单;也可以是命令行界面(CLI),如 Linux 的 Shell 或 Windows 的命令提示符,用户通过输入命令来操作。
- 政府比喻: 就像政府设立的服务大厅、官方网站、市长热线等,提供渠道让公民(用户)能够提出请求、获取信息、办理业务。
- (底层但重要)提供系统调用 (System Calls) - “向政府申请服务的标准流程”
- 做什么? 应用程序不能直接操作硬件(太危险,容易搞乱),它们需要通过 OS 提供的“系统调用”接口来请求服务,比如“请帮我读取这个文件”、“请帮我分配一些内存”、“请帮我把数据显示在屏幕上”。OS 接到请求后,在内核态(权限更高的状态)去执行这些操作。
- 政府比喻: 就像公民或企业需要按照标准流程向政府提交申请(比如申请营业执照、申请用地许可),政府部门(OS 内核)审批并执行这些请求。
串联起来的印象:
操作系统就像一个承上启下的大管家。
- 对下 (硬件层面):它直接管理和控制着所有冰冷的硬件资源。
- 对上 (软件和用户层面):它为应用程序提供了一个稳定、一致的运行环境和丰富的服务接口(系统调用),同时为用户提供了方便的操作界面。
CPU是怎么执行程序的
想象一下,CPU是一位技艺精湛、速度极快的超级大厨,而程序就像一本菜谱(由一堆指令组成),数据则是食材。这位大厨的目标就是严格按照菜谱的指示,处理食材,最终做出美味佳肴(程序运行结果)。
整个过程可以概括为经典的 “取指-解码-执行”(Fetch-Decode-Execute) 循环。我们一步步来看:
第一幕:准备工作 - 程序和数据就位
- 菜谱入场(程序加载): 当你双击一个程序图标时,操作系统会把这个程序(编译好的、CPU能直接看懂的机器码指令,就像一本用特殊符号写的菜谱)从硬盘(大仓库)加载到内存(RAM,厨房里的大工作台/食材架)中。同时,程序运行需要的初始数据(食材)也可能被一起放进内存。
- 大厨上岗(CPU就绪): CPU大厨准备好开始工作。他有一个非常重要的小本本叫做程序计数器(Program Counter, PC),记录着下一条要执行的指令在内存(菜谱)中的页码(地址)。
第二幕:核心循环 - 大厨的烹饪魔法
这个循环由CPU内部的时钟(一个精准的节拍器)控制,每个节拍(时钟周期)推动操作进行。
- 取指令 (Fetch)
- 大厨看菜谱: CPU的控制单元(Control Unit, CU),相当于大厨的眼睛和大脑协调部分,会查看程序计数器(PC)指示的地址。
- 拿到指令: CU通过总线(Bus)(厨房里的传送带)去内存(工作台)的指定位置,把那条指令(菜谱上的一行字)取回来。
- 放入暂存: 取回来的指令暂时放在CPU内部一个叫做指令寄存器(Instruction Register, IR) 的小盘子里。
- 更新小本本: 同时,程序计数器(PC)自动指向下一条指令的地址,准备下次取用(除非当前指令是跳转指令,会特殊处理)。
- 解码 (Decode)
- 理解指令: 控制单元(CU)开始分析指令寄存器(IR)里的这条指令。它就像大厨在解读菜谱上的那句话:“哦,这是要我‘切碎’(操作码),把‘土豆’(操作数/数据地址)切成丁。”
- 准备工具和食材: CU根据指令的含义,准备好需要用到的CPU内部部件,比如负责计算的算术逻辑单元(Arithmetic Logic Unit, ALU)(像是大厨的刀、锅、搅拌器),并确定需要哪些数据(食材)。如果数据不在CPU内部的寄存器(Registers)(大厨手边的小碗,存取速度飞快)里,CU会指令去内存或缓存里取。
- 执行 (Execute)
- 动手操作: 控制单元(CU)发出指令,调动相应的部件开始干活。
- 如果是计算指令(如加法、减法),就让ALU(计算器)对寄存器里的数据进行运算。
- 如果是数据传输指令(如加载、存储),就控制数据在寄存器和内存/缓存之间移动。
- 如果是跳转/分支指令,就修改**程序计数器(PC)**的值,让下一条指令从新的地址开始取,而不是顺序执行(比如菜谱上说“如果汤太浓,跳到第10步加水”)。
- 保存结果: 执行完成后,结果通常会存回CPU内部的寄存器,或者写回内存中。
第三幕:加速!现代CPU的黑科技
上面的循环是基础,但现代CPU为了快如闪电,还加了很多“高级烹饪技巧”:
- 流水线 (Pipelining): 这就像一个烹饪流水线。一个指令在执行(切菜)的时候,下一个指令可以同时进行解码(看下一步怎么做),再下一个指令可以同时被取指(看下下步)。这样,多个指令的不同阶段重叠进行,大大提高了效率,单位时间内能完成更多“菜品”。
- 高速缓存 (Cache): CPU内部有一些比内存快得多的小容量存储,叫做高速缓存(L1, L2, L3 Cache)。这就像大厨手边的一个小型快速取用冰箱,存放着最常用或接下来很可能用到的指令和数据(热门食材)。CPU会先在Cache里找,找不到再去内存(大工作台)取,大大减少了去“大仓库”取东西的时间。
- 乱序执行 (Out-of-Order Execution): 如果当前指令需要等待数据(比如食材还没解冻好),聪明的CPU大厨不会干等着,他会先跳过去做后面其他不依赖这个数据的指令(先准备别的配菜),等数据准备好了再回来处理之前的指令。
- 分支预测 (Branch Prediction): 遇到条件跳转指令(比如“如果锅够热,就放油”),CPU会猜测条件是否满足,提前沿着最可能的分支去取指和执行。猜对了就省了时间,猜错了虽然需要撤销重来,但平均下来还是能提速。
- 多核 (Multi-Core): 现代CPU通常有多个核心(Core),相当于厨房里请了多位大厨,他们可以并行处理不同的程序或者同一个程序的不同部分(同时做几道菜),大大提升了整体处理能力。
外部的数据是什么?
这些“初始数据”主要包括以下几类:
- 程序自身携带的数据 (Data Segment):
- 已初始化的全局变量/静态变量 (Initialized Global/Static Variables):在代码里写好的、有明确初始值的全局变量或静态变量。比如你在代码里写了
int total_score = 0;
或者string default_message = "Welcome!";
。这些变量的初始值(0 和 "Welcome!")会作为数据存储在编译后的程序文件里,程序加载时会被放到内存的数据区。 - 常量数据 (Constants/Literals):程序中使用的字符串、数字常量等,特别是比较大的或者需要独立存储的。比如程序里需要用到一个固定的π值(3.14159...)或者一些固定的提示信息文本。这些也存储在程序文件的数据部分。
- 程序启动时外部传入的数据 (External Input at Startup):
- 命令行参数 (Command-Line Arguments):你在终端运行程序时,在程序名后面加的那些参数。比如
copy file1.txt file2.txt
,这里的file1.txt
和file2.txt
就是传给copy
程序的初始数据(告诉它要操作哪些文件)。程序启动后会去读取这些参数。 - 环境变量 (Environment Variables):操作系统环境里预设的一些变量,程序可以读取它们来获取配置信息。比如
PATH
环境变量告诉程序去哪里找其他可执行文件,或者一个API_KEY
环境变量包含程序访问某个服务需要的密钥。
- 需要从外部文件加载的数据 (Data Loaded from Files):
- 配置文件 (Configuration Files):很多程序启动时会去读取一个
.ini
,.json
,.xml
或其他格式的配置文件,里面包含了程序的设置,比如窗口大小、连接数据库的地址和密码、用户偏好等。这些配置文件的内容就是程序运行需要的初始数据。 - 初始输入文件 (Initial Input Files):如果程序的功能就是处理某个文件(比如一个文本编辑器打开一个文档,或者一个视频播放器打开一个视频),那么这个文件的内容就是程序一开始就需要处理的数据。
- 操作系统提供的信息 (OS-Provided Information):
- 操作系统在创建程序进程时,会提供一些基础信息,比如进程ID、分配的内存区域信息、标准输入/输出/错误流的句柄等。这些也是程序运行环境的一部分初始数据。
总结一下:
编译好的程序主要是指令(怎么做),但它要执行这些指令,往往需要操作一些对象(对什么做)。这些对象就是数据。
- 有些数据是程序自带的(像菜谱里写明的“盐少许”这种固定量,或者“初始温度设为180度”这种默认值)。
- 有些数据是启动时别人给的(像点菜时顾客指定“要辣的”,或者告诉厨师“给5号桌做”)。
- 有些数据需要程序自己去读(像菜谱要求“查阅今天的特供食材清单”)。
内核
在操作系统中,内核 (Kernel) 可以理解为操作系统的核心或心脏。它是整个操作系统最基础、最重要的组成部分。
想象一下操作系统是一座大城市,那么内核就是这座城市的市政府+基础设施管理中心。它的主要职责包括:
- 硬件管理 (Hardware Management):
- CPU 管理 (调度): 决定哪个程序(进程/线程)在什么时候使用 CPU,就像交通调度中心决定哪辆车可以上路。
- 内存管理 (Memory Management): 分配和管理计算机的内存(RAM),确保每个程序都有地方运行且互不干扰,就像规划局分配土地和管理建筑。
- 设备管理 (Device Management): 通过驱动程序控制和协调计算机连接的所有硬件设备(如硬盘、键盘、鼠标、显卡、网卡等),就像市政部门管理水电、道路等公共设施。
- 提供基础服务 (Providing Fundamental Services):
- 进程管理 (Process Management): 创建、运行、暂停、终止程序(进程)。
- 文件系统管理 (File System Management): 组织和管理存储设备(如硬盘)上的文件和目录,提供文件的读写访问。
- 网络通信 (Networking): 管理网络连接,处理数据包的收发。
- 进程间通信 (Inter-Process Communication, IPC): 允许不同的运行程序之间交换信息。
- 充当桥梁 (Acting as a Bridge):
- 内核是运行在用户态的应用程序(比如你使用的浏览器、文档编辑器)与底层物理硬件之间的接口和中介。应用程序不能直接操作硬件,必须通过内核提供的接口(称为系统调用 - System Calls)来请求服务。
- 安全与保护 (Security and Protection):
- 内核运行在计算机处理器的最高权限级别(通常称为内核模式或 Ring 0),可以直接访问所有硬件。用户程序运行在较低权限级别(用户模式或 Ring 3)。这种隔离保护了内核自身和硬件不被用户程序意外或恶意破坏。
- 管理用户和程序的权限,控制对文件和资源的访问。
简单来说,内核就是:
- 操作系统的控制中心。
- 直接与硬件打交道的软件层。
- 为上层应用程序提供必要服务和运行环境的管家。
- 保证系统稳定、安全运行的基石。
用户空间vs内核空间

。
1. 两个世界:用户空间 vs 内核空间
想象一下计算机系统里有两个“世界”:
- 内核空间 (Kernel Space):这是操作系统的核心(内核)居住和工作的地方。这里拥有最高权限,可以直接访问和控制计算机的所有硬件资源(CPU、内存、硬盘、网卡等),就像是总统套房+中央控制室,掌握着一切命脉。内核需要这种权限来管理整个系统。
- 用户空间 (User Space):这是普通应用程序(比如你的浏览器、游戏、Word文档)运行的地方。这里的程序权限受限,它们不能随意直接访问硬件,也不能随意访问其他程序的内存,更不能直接操作内核的关键数据。这就像是普通客房,住客(应用程序)有很多限制,不能随便闯入控制室或别人的房间。
为什么要这样划分?
主要是为了安全和稳定。
- 安全:防止恶意程序直接破坏硬件或窃取其他程序的数据。
- 稳定:防止有 Bug 的应用程序意外搞垮整个系统(比如错误地操作了硬件)。如果每个程序都能直接操作硬盘,一个错误就可能导致所有数据丢失!
2. 应用程序的需求 vs 权限限制
现在问题来了:运行在用户空间的应用程序虽然权限受限,但它们确实需要执行一些需要高权限的操作才能完成工作。比如:
- 要读取或保存文件,就需要访问硬盘。
- 要发送或接收网络消息,就需要控制网卡。
- 要显示图形,就需要与显卡交互。
- 要运行起来,就需要向操作系统申请内存。
- 要同时做几件事,可能需要创建新的进程或线程。
这些操作都涉及到直接或间接地与硬件打交道,或者需要访问受保护的系统资源,这些都是用户空间程序被禁止直接做的事情。
3. 系统调用:连接两个世界的“服务窗口”
怎么办呢?操作系统内核提供了一套正式的、受控的接口,允许用户空间的程序请求内核来替它们完成这些高权限的操作。这个请求机制就是系统调用 (System Call)。
打个比方:
- 你(用户空间的应用程序)住在一个管理严格的小区(操作系统)的普通房间(用户空间)。
- 你想去小区的**机房(硬件)检查一下线路,或者想动用小区的广播系统(系统资源)**发个通知。
- 但你没有权限直接进入机房或使用广播,小区的**物业管理处(内核)**才有这个权限。
- 于是,你不能自己闯进去,而是要去物业管理处的服务窗口(系统调用接口),提交一个申请(发起系统调用),告诉物业管理员(内核)你想做什么(比如,“请帮我读取某某文件”)。
- 物业管理员(内核)会检查你的请求是否合规、你是否有权提出这个请求。
- 如果一切没问题,管理员会亲自去机房帮你检查线路,或者使用广播系统帮你发布通知(内核执行实际的操作)。
- 完成后,管理员会把结果告诉你(比如文件内容读出来了,或者通知已发送)。
系统调用是用户空间与内核空间之间沟通的桥梁,是应用程序获取操作系统服务的标准方式。
内存管理
第一章:每个程序都有一个“私人魔法书目” (虚拟地址空间)
- 每个程序先生/女士一进来,图书管理员(操作系统内核)就会发给他一本看起来无限大、完全私人的魔法书目(虚拟地址空间)。目录从第 0 页开始,整齐排列,要多少页有多少页!程序觉得:“哇,整个图书馆都是我的!我想在哪页写笔记就在哪页写!”
- 但实际上,图书馆(物理内存 RAM)的真实书架(物理页框)是有限的。魔法书目只是个美好的幻象!
第二章:整理术!页面与页框 (Paging)
- 为了方便管理,管理员把魔法书目(虚拟地址空间)和真实的书架(物理内存)都划分成同样大小的标准格子,比如每格放 4096 个字。书目上的格子叫**“页面”(Page),书架上的格子叫“页框”(Frame)**。
第三章:核心机密 - “寻宝地图”(页表 Page Table)
- 管理员(内核)为每个程序都保管着一份秘密的“寻宝地图”(页表)。这张地图记录着:你的魔法书目里的哪一页(虚拟页面),实际放在了图书馆的哪个书架的哪个格子里(物理页框)。
- 地图上还有各种小标记:
- “在馆/离馆?” (Present Bit):这页书在书架上还是在地下仓库?
- “能涂改吗?” (Protection Bits):这页是只能看(只读)还是可以写笔记(读写)?是普通读者能碰还是管理员专用(用户/内核)?
- “最近翻过没?” (Accessed Bit):判断这本书是不是热门书。
- “被写过笔记没?” (Dirty Bit):如果被写过笔记,还回仓库前得先把笔记备份好(写回磁盘)。
第四章:光速查找员 - “小助手MMU”与“记忆便签TLB”
- CPU 每次要找书(访问内存),都得先查地址。但总翻那本大地图(页表)太慢了!于是,图书馆配备了一个光速小助手(MMU 硬件)。
- 这个小助手还有个私人“记忆便签”(TLB 缓存),上面记着最近经常查的几页书放在哪个书架。程序要找书时,小助手先光速瞄一眼便签:
- “便签上有!” (TLB Hit):太棒了,直接告诉 CPU 书在哪,快如闪电!
- “便签上没有...” (TLB Miss):哎呀,得去翻那本大地图(访问内存中的页表)了,找到后赶紧把位置也记到便签上,下次就能快了!
第五章:懒人哲学 - “你需要时我再拿”(按需分页 Demand Paging)
- 图书馆管理员其实有点“懒”。他不会一开始就把程序魔法书目里的所有书都从**“地下大仓库”(硬盘/SSD)搬到书架(RAM)上。而是奉行:“你啥时候想看哪一页,我再给你搬哪一页”**。
第六章:“哎呀!书不在架上!”(缺页中断 Page Fault)
- 当程序先生想看魔法书目的第 X 页,但小助手 MMU 查地图发现那一页的标记是**“离馆”(Present Bit = 0),它就会大喊一声:“报告管理员!缺页啦!”(触发缺页中断)**。
- 这时,管理员(内核)登场:
- 先检查:“嗯?你想看的这页是你该看的吗?权限对不对?”(检查访问合法性)。如果不对,直接把程序请出去(段错误!)。
- 如果合法,管理员就亲自去地下仓库(磁盘)找这本书。
- 看看书架上有没有空位(空闲页框)。
- *没空位咋办?**管理员得挑一本“倒霉蛋”书送回仓库(页面替换算法)。是挑最老的那本(FIFO)?还是挑最久没人看的(LRU)?或者用个聪明的“时钟”转盘法(Clock 算法)看看谁的“最近翻过”标记是旧的?
- 如果被挑中的书上有笔记(Dirty Bit = 1),还得先把笔记抄录备份回仓库(写回磁盘)。
- 把程序想要的那页新书放到腾出来的书架格子里。
- 更新“寻宝地图”(页表),标记这一页“在馆”,写上书架号。
- 最后告诉程序:“好了,书来了,你可以继续看了!”
第七章:图书馆“大堵塞”(颠簸 Thrashing)
- 如果图书馆太小(物理内存不足),而程序们想看的书又太多太分散,管理员就会忙得团团转,不停地在书架和仓库之间搬书(疯狂发生页错误和页面替换),结果谁也看不成书,效率极低,这就是**“图书馆大堵塞”(颠簸)**。
第八章:私人空间神圣不可侵犯!(保护 Protection)
- 寻宝地图上的“权限”标记可不是摆设!小助手 MMU 每次都会检查。你想偷偷看隔壁程序的私密日记(访问其他进程内存)?或者想闯进管理员办公室(访问内核空间)?没门!直接被保安(OS)架出去!
第九章:好东西要分享!(共享 Sharing)
- 有些工具书(共享库 .so / .dll)大家都要用,管理员就在一个公共书架上放一份。所有需要这本书的程序的“寻宝地图”都指向这个同一个物理副本,超级省地方!
- 还有个**“复印魔法”(写时复制 Copy-on-Write)**:比如程序 A 创建了分身 B(
fork()
),一开始管理员让 B 的地图指向 A 的所有书(物理页面)。只要他们都只看不写,就一直共享。直到 B 忍不住想在某页书上写笔记时,管理员才“咔嚓”一下,复印这一页,给 B 一个单独的副本让他写,A 还是用原来的。这样就避免了一开始就傻乎乎地把所有书都复印一遍。
第十章:图书管理员的“仓库管理学” (内存分配)
- 管理员内部(内核空间):管理大片空书架(大块内存)用“好友配对法”(Buddy System),管理常用小物件(内核数据结构)用“分类收纳盒”(Slab/SLUB 分配器),整齐高效!
- 给程序用(用户空间):程序喊:“我要点空间放草稿!”(
malloc
)。图书馆的 C 助手(C 库)会先向管理员批发一大块区域(brk
或mmap
),然后在里面精打细算地切割小块给程序,还得操心别弄出太多没法用的“边角料”(内部/外部碎片)。
第十一章:“空间杀手”——碎片 (Fragmentation)
- 格子内部浪费(内部碎片):书很小,但非得占一个标准大格子,格子里的空白就算浪费了。
- “到处都是小空隙,就是没大块地儿”(外部碎片):虽然书架上零零散散有很多空格子,但你想放一本大地图册(需要连续大内存),却找不到足够大的连续空格。这在 C 助手的“批发区”里比较容易发生。
虚拟内存作用
- 内存保护/隔离 (Protection/Isolation):
- 给每个程序一个独立的、私有的地址空间,防止程序间相互干扰或破坏操作系统。
- “扩充”物理内存 (Apparent Memory Expansion):
- 让程序可以使用的地址空间远大于实际物理内存。利用硬盘作为后备存储,使得可以运行非常大的程序。
- 提高效率 (Efficiency):
- 按需加载 (Demand Paging): 只在程序实际需要某部分代码或数据时,才将其从硬盘加载到物理内存,加快程序启动速度,提高物理内存利用率。
- 简化程序开发 (Simplification):
- 程序员面对的是一个巨大、连续、线性的地址空间,无需关心物理内存的碎片、具体位置等复杂问题,简化了链接和加载过程。
- 内存共享 (Sharing):
- 允许多个程序安全、高效地共享同一份物理内存(例如,公共的库代码),节省内存资源。
核心思想: 操作系统为每个进程提供了一个“虚拟”的、理想化的内存视图,屏蔽了底层物理内存管理的复杂性和局限性。
分段和分页的区别
特性 | 内存分段 (Segmentation) | 内存分页 (Paging) |
划分单位 | 逻辑段 (Code, Data, Stack),大小可变 | 逻辑页 (Page),大小固定 |
物理内存 | 分配连续的可变大小区域 | 划分成固定大小的页框 (Frame) |
地址 | (段号, 段内偏移) | (页号, 页内偏移) |
映射工具 | 段表 (Segment Table) | 页表 (Page Table) |
碎片 | 外部碎片 (主要问题) | 内部碎片 (最后一页可能浪费空间) |
优点 | 逻辑清晰,易于共享和保护 | 无外部碎片,分配简单,易于实现虚拟内存 |
缺点 | 外部碎片严重,内存分配复杂 | 内部碎片,页表可能占用额外内存 |
- 分页:就像把一整块大披萨,不管上面有什么料,都严格地切成同样大小的 8 块。你要吃的时候说:“我要第 3 块”。
- 分段:就像一个厨师做一顿饭,包含主菜(代码)、配菜(数据)、汤(栈)。厨师会用大小不同、形状各异的盘子来分别装这三样东西。主菜盘子可能很大,汤碗可能比较小。你要吃的时候说:“我要吃主菜盘子里的那块牛排”
分段的外部内存碎片问题
想象一下,你的内存只有 10 格 那么大:
[ _ _ _ _ _ _ _ _ _ _ ]
(10格空闲)- 程序A来了:它需要 4格 连续的空间(比如它的代码段)。
内存变成:[AAAA _ _ _ _ _ _ ] (A占了前4格,剩下6格空闲)
- 程序B来了:它需要 3格 连续的空间(比如它的数据段)。
内存变成:[AAAABBB _ _ _ ] (A占4格,B占了紧挨着的3格,剩下3格空闲)
- 程序A走了:它不再需要内存,所以它占的 4格 空间被释放了。
内存变成:[ _ _ _ _ BBB _ _ _ ] (前面4格空了,中间B占3格,后面3格空了)
关键时刻来了!
现在内存里有两块空闲的地方:
- 前面有 4格 空闲。
- 后面有 3格 空闲。
- 总共空闲格子 = 4 + 3 = 7格。
- 程序C来了:它需要 5格 连续的空间。
问题: 内存总共有 7 格空闲,够不够程序C的 5 格? 够!
但是! 操作系统能不能给程序C分配空间呢? 不能!
为什么? 因为程序C需要的是 连续的5格。
- 看看内存里空闲的地方:前面一块只有 4格 连续,不够5格。
- 后面一块只有 3格 连续,也不够5格。
结果: 尽管总空闲内存(7格)大于程序C所需(5格),但因为这些空闲内存不连续,被程序B隔开了,所以无法分配给程序C。
这种**“总空闲空间够,但没有一块足够大的连续空闲空间”**的情况,导致内存空间被浪费(那7格空闲现在都没法给C用),就叫做 外部碎片 (External Fragmentation)。就像停车场里有很多小空位,但停不进一辆大卡车。
分页

内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高
多级页表
先回顾一下基础分页的问题:
- 在基础分页里,每个程序都有一个页表 (Page Table)。
- 这个页表的作用就像一个大数组,程序的每个逻辑页号是索引,对应的值是该页存放的物理内存页框号。
- 问题来了: 如果一个程序的逻辑地址空间非常大(比如现代 64 位系统,地址空间理论上可以达到 2^64 字节,是个天文数字!),那么它可能拥有的逻辑页数量也会非常非常多。
- 举例(简化版32位): 假设一个 32 位系统 (4GB 地址空间),页大小是 4KB (2^12 Bytes)。那么总共可能有 2^(32-12) = 2^20 个页,也就是 一百多万个页!
- 如果页表中的每个条目 (Page Table Entry, PTE) 需要 4 字节来存储物理页框号和其他信息(如权限位),那么仅仅一个程序的页表就需要 2^20 * 4 Bytes = 4MB 的内存!
- 对于 64 位系统,这个数字会变得完全不切实际,页表本身可能比物理内存还大!
- 另一个问题: 大多数程序并不会用到它全部的逻辑地址空间,很多地址范围是稀疏 (Sparse) 的,也就是根本没被程序使用。但如果用单级页表,你还是得为这些没用到的地址空间预留页表项,造成巨大浪费。
多级页表的解决方案:像查字典一样分层查找
多级页表的核心思想是:不要一次性创建一个巨大无比的、包含所有页映射的单一表格,而是把这个大表格拆分成多个小的、有层级关系的表格。
想象一下你要查一本超级厚的字典(巨大的地址空间):
- 单级页表(不好的方法):相当于字典的索引页直接列出了每一个词(每一个逻辑页)在哪一页(物理页框)。如果字典有几百万个词,这个索引本身就会厚得吓人。
- 多级页表(聪明的方法):
- 第一级页表(类似“字母索引”或“部首索引”,常叫 Page Directory/页目录):这一级不告诉你具体某个词在哪一页。它只告诉你一个大概范围。比如,它告诉你:“所有 A 开头的词,请去看 第 1 部分的详细索引”,“所有 B 开头的词,请去看 第 2 部分的详细索引”,等等。
- 第二级页表(类似“具体字母下的词条索引”,常叫 Page Table/页表):当你根据第一级索引找到“第 1 部分的详细索引”后,这里才真正记录了 A 开头的具体词(比如 Apple, Ant)分别在哪一页正文(物理页框)。
- (可能还有更多级):对于 64 位系统,层级可能更多,比如先按首字母分,再按第二个字母分,等等,形成三级、四级页表。
它是如何工作的?
- CPU 要访问一个逻辑地址时,会把这个地址拆分成几部分。比如对于一个两级页表:
逻辑地址 = [一级页表索引] [二级页表索引] [页内偏移量]
- 第一步:用
[一级页表索引]
去查第一级页表 (Page Directory)。查到的条目并不直接指向物理内存页框,而是指向下一级(第二级)页表的地址。
- 第二步:拿到第二级页表的地址后,再用
[二级页表索引]
去这个第二级页表 (Page Table) 里查找。这次查到的条目才包含最终的物理内存页框号。
- 第三步:最后,把得到的物理页框号和页内偏移量组合起来,形成最终的物理地址,去访问内存。
多级页表的好处:
- 大大节省内存! 这是最核心的优点。
- 如果一个程序根本没用到地址空间中某个大范围(比如 B 开头的词条区域),那么对应的第二级页表就根本不需要创建和加载到内存中! 在第一级页表里,对应 B 的那个条目可以标记为“无效”或“不存在”。
- 只有当程序实际需要访问某个地址范围时,操作系统才需要分配并填充相应的低层级页表。
- 这样,对于地址空间使用稀疏的程序,页表占用的内存可以从“可能非常巨大”变成“只占用实际需要的部分”,大大减少了内存开销。
- 页表可以像普通数据一样被分页: 第一级页表通常需要常驻内存,但第二级及更低级的页表本身也可以被看作是数据,可以被换入换出内存(如果它们当前不被频繁使用的话),进一步节省物理内存。
多级页表的缺点:
- 增加了访问时间: 原来访问一次内存只需要查一次页表(单级),现在可能需要查两次、三次甚至四次页表(取决于层级数),每次查表都需要访问内存,这会显著增加内存访问的延迟。
如何缓解缺点? TLB!
- 为了解决多次查表带来的性能问题,CPU 内部有一个高速缓存叫做 TLB (Translation Lookaside Buffer)。它缓存了最近使用过的逻辑页到物理页框的映射关系。
- 当 CPU 要访问内存时,它会先查 TLB。如果 TLB 命中 (TLB Hit),就能立刻得到物理地址,避免了多次访问内存去查多级页表。只有当 TLB 未命中 (TLB Miss) 时,才需要慢速地去内存里查询多级页表,并将查到的结果放入 TLB 供后续使用。
- 由于程序的内存访问通常具有局部性原理(即倾向于在一段时间内集中访问某些区域),TLB 的命中率通常很高,从而使得多级页表的性能损失在实际中大大降低。
总结:
多级页表是一种用空间换时间(牺牲一点查找时间)来大大节省页表存储空间的技术。它通过建立页表的层级结构,使得只有程序实际用到的地址范围所对应的低层级页表才需要存在于内存中,极大地解决了单级页表在巨大地址空间下面临的存储爆炸问题。而其带来的查询时间开销则主要通过 TLB 硬件缓存来有效缓解。
malloc怎么分配内存?
用户空间内存从低到高分别是 6 种不同的内存段:

可以把
malloc
想象成一个非常能干的仓库管理员,这个仓库就是程序的**堆(Heap)**区域。堆是程序虚拟地址空间里的一块特殊区域,专门用来存放程序在运行时动态申请的内存(不像全局变量或栈上的局部变量那样在编译时或函数调用时大小就确定了)。malloc
的工作流程大致如下:- 接收请求:
- 当你的程序调用
malloc(size)
时,比如malloc(100)
,意思是:“仓库管理员,我需要一块连续的、大小为 100 字节的空间。”
- 检查“库存”(内部管理):
malloc
(仓库管理员)并不是每次都需要向操作系统(仓库的“大老板”)要空间。它自己维护着一个记账本(元数据/Metadata),记录着仓库里哪些区域(内存块)是空闲的,哪些已经被占用了,以及它们的大小。- 通常,它会维护一个或多个空闲链表 (Free List),把所有空闲的内存块链接起来,方便查找。
- 寻找合适的空闲块:
- 管理员开始在它的空闲链表里寻找一个足够大的空闲块。这里有不同的查找策略:
- 首次适应 (First-Fit):从头开始找,找到第一个大小 >= 申请大小的空闲块就用。速度快,但可能留下很多小的、难以利用的碎片。
- 最佳适应 (Best-Fit):遍历所有空闲块,找到大小 >= 申请大小,并且差值最小的那一块(最“合身”的)。能减少当前分配造成的浪费,但查找慢,且容易留下非常小、几乎无法使用的碎片。
- 下次适应 (Next-Fit):类似首次适应,但从上次查找结束的位置开始找,试图让碎片分布更均匀。
- (还有更复杂的策略,如伙伴系统、slab 分配器等,用于优化特定场景)。
- 操作系统(OS)是 “乐高公司”:
- 乐高公司生产一种标准大小的绿色底板(比如 32x32 大小)。这就是**“页” (Page) / “页框” (Frame)**。内存世界的基础就是这些标准底板。
- 乐高公司(OS)的主要工作就是管理这些标准底板,比如记录哪些底板放在哪个架子上(物理内存),哪个程序拥有哪些底板的使用权(虚拟内存)。
- 你的程序是 “乐高玩家”,
malloc
是你的 “乐高小助手”: - 你想用乐高积木搭一个大小不一的城堡、汽车或者任何东西。
malloc
就是帮你管理积木和搭建过程的小助手。- 第一步:你需要地方搭积木
- 当你的程序第一次调用
malloc
,或者小助手 (malloc
) 发现手头没有足够大的空地(空闲块)来放你想要的积木块时,小助手会去找乐高公司 (OS)。 - 小助手说:“老板,我需要几块标准底板 (Pages),给我批点地方。”
- 第二步:乐高公司给你底板
- 乐高公司 (OS) 不会问你要搭多大的积木,它只按标准单位给你东西。它拿出整块的标准底板 (Pages) 说:“喏,这几块底板归你了,你可以在上面玩。” (OS 分配的是虚拟内存页)
- 第三步:小助手在底板上搭积木
- 现在,小助手 (
malloc
) 拿到了这些标准大小的底板。 - 你调用
malloc(100)
,告诉小助手:“我需要一个能放 100 个小颗粒的积木块 (Block)”。 - 小助手就在它拥有的底板 (Page) 上,找个空位,“咔嚓”一声,给你划出一块能放 100 个颗粒的地方(这就是一个“块” Block),并记住这里被占用了。这个 100 大小的块可能只占了一块底板的一小部分。
- 你又调用
malloc(5000)
,告诉小助手:“我需要一个能放 5000 个小颗粒的大积木块 (Block)”。 - 这个块太大了,可能需要跨越多块标准底板才能放下。小助手会在它拥有的连续底板空间上(虽然底板在物理上可能不连续,但 OS 让它们看起来是连续的),给你划出这么大一块地方。
- 当你调用
free()
,就是告诉小助手:“这块积木我不用了,拆掉吧”。小助手就在底板上把这块区域标记为空闲,以后可以在这里搭新的积木。 - 乐高公司 (OS):只关心标准底板 (Page) 的分配和管理。它按整块整块的底板来思考和操作。这是底层的、粗粒度的管理。
- 乐高小助手 (
malloc
):在乐高公司给的底板上工作。它关心的是你需要的各种大小不一的积木块 (Block)。它在底板上进行精细化的、大小灵活的划分和管理。这是上层的、细粒度的管理。
怎么理解空闲块这个概念:
想象一下:
现在看它们怎么一起工作:
关键区别点:
- 分配与处理:
- 找到合适的块后:
- 大小刚好? 如果找到的空闲块大小正好等于请求的大小,完美!直接把这个块从空闲链表里拿掉,标记为“已使用”,然后把指向这块内存区域起始位置的指针返回给你的程序。
- 块太大? 这是常见情况。比如你需要 100 字节,但找到一个 200 字节的空闲块。管理员会执行分割 (Splitting) 操作:
- 从 200 字节的块里切出 100 字节。
- 把这 100 字节标记为“已使用”,返回指针给你。
- 剩下的那 100 字节仍然是空闲的,管理员会把它作为一个新的、更小的空闲块放回到空闲链表里。
- 记录元数据: 在返回给你的指针指向的内存区域旁边(通常是紧邻的前面几个字节),
malloc
会悄悄存储一些管理信息(元数据),比如这个分配出去的块实际有多大。这在你调用free
时至关重要。
- 库存不足?向“大老板”申请:
- 如果管理员在自己的空闲列表里找不到任何足够大的空闲块,它就没法满足你的请求了。
- 这时,
malloc
会向操作系统(内核)发出请求,要求扩大仓库(堆)的面积。它通过调用系统调用 (System Call) 来实现: brk()
或sbrk()
:这是比较传统的方式,用来移动堆顶指针 (program break),向高地址方向扩大或缩小堆的边界。malloc
通常一次性向 OS 申请一块比较大的内存(比如几 MB),而不是你需要多少它就要多少,这样可以减少频繁进行系统调用的开销(系统调用相对耗时)。mmap()
:更现代的方式,尤其用于分配非常大的内存块。它可以直接向操作系统申请一块独立的虚拟内存区域(匿名映射),不一定和原来的堆连续。- 拿到 OS 批下来的新“地皮”后,
malloc
会把这块新的大内存区域初始化,加入到自己的空闲链表里,然后再从中切出一块满足你最初的malloc(size)
请求。
brk vs mmap


系统调用 | malloc 中主要用途 | 特点 | 好处(对malloc而言) |
brk / sbrk | 中小型内存请求(低于阈值) | 扩展连续的主堆区 | 开销分摊,适合malloc内部精细管理小块 |
mmap (匿名) | 大型内存请求(高于阈值) | 创建独立的虚拟内存区 | 避免主堆碎片,易于将大块内存完全归还给 OS |
free(ptr)
的作用:- 当你调用
free(ptr)
时,你告诉管理员:“嘿,地址ptr
指向的那块空间我用完了,还给你。” - 管理员会根据
ptr
(以及它旁边隐藏的元数据)找到这块内存,并把它标记为“空闲”。 - 关键一步:合并 (Coalescing)。管理员会检查这块刚刚释放的内存块,看看它前面和/或后面的邻居是不是也是空闲的。如果是,它会把这些连续的空闲块合并成一个更大的空闲块。这对于减少“外部碎片”(仓库里有很多小空位但没有大空位)非常重要。
- 合并后(或无需合并),这个空闲块会被放回空闲链表中,等待下次
malloc
使用。
总结一下
malloc
的过程:- 它是 C 库提供的用户空间内存管理器。
- 它管理进程的堆内存。
- 内部通过元数据和空闲链表追踪内存使用情况。
- 收到请求时,尝试在现有空闲块中查找(使用 First-Fit, Best-Fit 等策略)。
- 可能需要分割大空闲块。
- 如果现有空间不足,通过系统调用 (
brk
/mmap
) 向 OS 申请更多内存。
free
负责标记内存为可用,并尝试合并相邻空闲块以对抗碎片。
内存快满了会发生什么?
当物理内存(RAM)快要满的时候,现代操作系统(如 Windows, Linux, macOS)不会立刻就崩溃或者停止工作,它会采取一系列越来越激烈的措施来尝试腾出空间,维持系统运行。这个过程大致是这样的:
- 清理缓存 (Cache Reclaiming) - 温和手段
- 操作系统首先会尝试回收那些容易重新获得的内存。最主要的就是各种缓存,特别是文件系统缓存(Page Cache)。
- 如果缓存中的数据没有被修改过(“干净”的页—》和硬盘里的一样),可以直接丢弃,因为需要时可以再从硬盘读回来。
- 如果数据被修改过(“脏”的页),操作系统会先把它们写回硬盘,然后再释放这部分内存。
- 这个阶段通常对用户影响不大,只是后续访问某些文件可能需要重新从硬盘读取,稍微慢一点。
- 交换/页面置换 (Swapping/Paging Out) - 开始动真格
- 如果清理缓存还不够,操作系统就要开始动用虚拟内存的核心机制了——把一些不常用的内存页暂时**“请”到硬盘**的一个特殊区域(Swap 分区/交换分区 on Linux, Paging File/页面文件 on Windows)。
- 操作系统有一些算法(比如 LRU - 最近最少使用算法 或其变种)来判断哪些内存页最不常用,优先把它们换出去。
- 当这些页被换出后,它们占用的物理内存页框就被释放出来,可以给更需要的程序使用。
- 后果:如果之后程序又需要访问那个被换到硬盘上的页,就会发生缺页中断 (Page Fault)。操作系统必须:
- 找到一个空闲的物理页框(可能需要再换出另一个页)。
- 把需要的页从硬盘读回物理内存。
- 更新页表,恢复程序运行。
- 这个“换入换出”的过程因为涉及到硬盘读写(即使是 SSD,也比内存慢得多得多),会导致系统明显变慢、卡顿。你会感觉程序响应迟钝,硬盘灯可能狂闪。
- 颠簸/内存抖动 (Thrashing) - 系统接近瘫痪
- 如果内存极度不足,需要频繁地进行页面换入换出,系统可能会进入**“颠簸”**状态。
- 这时,操作系统可能刚把 A 页换出,程序马上又要用 A,于是操作系统赶紧把 B 页换出,把 A 换回来;结果程序紧接着又要用 B... 系统大部分时间都花在了倒腾内存页面上,几乎无法执行任何有效的计算任务。
- 用户感受到的就是系统极其卡顿,甚至暂时失去响应,硬盘持续高速读写。
- 内存不足杀手 (Out Of Memory Killer / OOM Killer) - 下狠手
- 如果情况继续恶化,连基本的页面换入换出都难以进行,或者内核自身也需要内存但无法分配时,操作系统会采取最后手段。
- Linux 系统有一个著名的 OOM Killer 机制。它会根据一套评分系统(通常考虑内存占用大小、运行时间、优先级等因素)选择一个或多个它认为“最该死”的进程,并强制杀死它们,以此快速释放大量内存,试图让系统恢复正常。
- Windows 也有类似的机制来处理极端内存不足的情况,可能表现为应用程序突然崩溃或消失。
- 用户会看到某个(通常是占用内存最多或最近行为异常的)应用程序突然被关闭了。
总结一下用户可能感受到的现象:
- 初期:可能感觉不明显,或只是偶尔访问文件慢一点。
- 中期:系统运行缓慢、卡顿,程序响应迟钝,硬盘读写指示灯频繁闪烁或常亮。
- 极端情况:系统几乎无法使用,鼠标移动都困难,或者某个/某些应用程序突然崩溃或消失。
在 4GB 物理内存的机器上,申请 8G 内存会怎么样
我们假设这是一个现代的 64 位操作系统(比如 64 位的 Windows, Linux, macOS),因为这是目前最常见的情况。
情况分析:
- 内存申请 (
malloc
/new
) 阶段: - 程序调用
malloc(8 * 1024 * 1024 * 1024)
或类似的new
操作。 - 关键点: 程序申请的是虚拟地址空间 (Virtual Address Space),而不是直接的物理内存 (Physical RAM)。
- 在 64 位系统上,一个进程的可用虚拟地址空间是极其巨大的(理论上是 2^64 字节,实际上通常是几百 TB 或 PB 级别)。
- 因此,向操作系统申请 8GB 的虚拟地址空间是完全可行的。操作系统只需要在进程的虚拟地址空间中标记出“这段 8GB 的地址范围现在归你了”,并做一些内部记录。
- 结果:
malloc
或new
很可能调用成功,并返回一个指向这 8GB 虚拟地址空间起始位置的有效指针!程序会认为自己成功拿到了 8GB 内存。 - 重要: 在这个阶段,通常几乎没有物理内存被消耗。操作系统采用**惰性分配(Demand Paging)**策略,它只承诺了地址,但不会立刻就分配 8GB 的物理内存或交换空间。页表里对应的条目可能被创建了,但会标记为“不存在于物理内存中”。
- 内存访问 (Access) 阶段:
- 程序开始尝试读取或写入它刚刚申请到的 8GB 内存区域。
- 当程序第一次访问这 8GB 区域中的某个字节时,CPU 会发现这个字节所在的虚拟页在页表中被标记为“不存在”。这会触发一个 缺页中断 (Page Fault)。
- 操作系统介入处理缺页中断:
- 查找空闲物理内存: OS 会去找一个空闲的物理内存页框 (Page Frame)。
- 分配物理内存: 如果找到空闲页框,OS 会分配它,通常会清零(出于安全考虑),然后更新页表,将程序的那个虚拟页映射到这个物理页框。之后,程序就能正常访问那个字节了,并且可以继续访问该页内的其他字节而不再触发缺页中断。
- 物理内存耗尽: 由于物理内存只有 4GB,当程序持续访问这 8GB 虚拟内存的不同部分时,物理内存很快就会被填满(不仅是这个程序的,还有操作系统本身、其他程序、文件缓存等)。
- 启动交换 (Swapping): 一旦物理内存不足,操作系统会启用页面置换(或称交换)机制。它会选择一些当前物理内存中不常用的页(可能属于这个 8GB 的程序,也可能属于其他程序或缓存),把它们的内容写入到硬盘上的交换空间(Swap Space / Paging File),然后把腾出来的物理页框分配给当前正要访问的那个页。
- 严重性能下降与颠簸 (Thrashing): 因为程序试图活跃使用远超物理内存容量的内存(8GB > 4GB),操作系统将不得不极其频繁地在内存和硬盘之间换入换出页面。硬盘 I/O 非常慢,这将导致灾难性的性能下降,系统变得极度卡顿。这种情况被称为**“颠簸 (Thrashing)”**。
- 交换空间耗尽 / OOM Killer: 如果程序的活跃工作集(它真正在同时使用的内存量)超过了 物理内存 + 交换空间的总和,或者交换活动过于频繁导致系统无法正常工作,操作系统可能会采取最终措施:触发 OOM (Out Of Memory) Killer。它会强制杀死这个消耗内存过多的进程(或其他某个进程),以释放内存,保护系统不至于完全崩溃。
总结一下:
- 在 64 位系统上,申请 8GB 内存的调用本身很可能会成功,因为虚拟地址空间足够大。
- 关键在于程序如何使用这 8GB 内存。如果只是申请了但不怎么用,或者只用到其中一小部分(比如少于 4GB),系统可能还能正常运行(可能会有一些交换,但还能接受)。
- 如果程序尝试活跃地使用大部分或全部 8GB 内存,远超 4GB 物理内存和可用交换空间的总和,那么:
- 系统将发生大量页面交换。
- 性能会急剧下降,出现颠簸 (Thrashing)。
- 最终,该程序极有可能被操作系统强制杀死 (OOM Killer)。
如果是 32 位系统:
- 用户进程的虚拟地址空间通常只有 2GB 或 3GB。
- 申请 8GB 内存会直接失败,
malloc
/new
会返回NULL
或抛出std::bad_alloc
异常,因为虚拟地址空间本身就不够大。
进程管理
想象一下操作系统是一位极其能干的餐厅总管 (OS),而你运行的每一个程序,都是一位厨师 (Process)。
1. 什么是进程 (Process)?—— 不只是菜谱,是忙碌的厨师!
- 一个程序本身(比如你安装的 Word 应用程序文件)就像一本菜谱 (Program Code)。它只是静态的指令。
- 当你启动 Word 时,总管(OS)就请来一位厨师(创建了一个进程),给他分配了一个专属厨房 (独立的虚拟地址空间),并告诉他:“开始按照这本 Word 菜谱做菜!”
- 这个正在忙碌工作的厨师才是进程 (Process)。他不仅仅有菜谱,还有:
- 当前状态:知道自己正在做第几步(程序计数器 PC)。
- 手里的工具:正在使用的寄存器里的临时数据。
- 临时的笔记和草稿:栈 (Stack),记录函数调用和局部变量。
- 准备好的食材和调料:数据段 (Data Segment),存放全局变量。
- 需要额外的工作台:堆 (Heap),用于动态申请的内存(比如
malloc
)。 - 正在使用的特定厨具:打开的文件、网络连接等资源。
简单说:进程 = 程序代码 + 当前活动状态 + 相关资源。它是一个“活”的、正在执行的程序实例。
2. 进程状态 (Process State) —— 厨师在忙啥?
厨师在工作时会有不同的状态,总管需要知道:
- 新建 (New):总管刚找到厨师,正在给他分配厨房、准备菜谱和初始工具。(进程正在被创建)。
- 就绪 (Ready):厨师已经万事俱备(厨房、菜谱、初始食材都有了),就等着空闲的灶台 (CPU) 来施展厨艺。他和其他准备好的厨师一起在“等候区”排队。
- 运行 (Running):厨师终于抢到了一个灶台 (CPU),正在奋力炒菜(执行程序指令)。
- 等待/阻塞 (Waiting/Blocked):厨师在炒菜过程中,发现需要等某个东西,比如等烤箱预热完成(等待 I/O 操作,如读取硬盘数据),或者等另一位厨师递给他特定的调料(等待某个事件或资源)。这时他会暂时离开灶台,去旁边等着,即使灶台空了他也不会去用,直到等待的东西来了为止。
- 终止 (Terminated):厨师做完了菜(程序正常结束),或者总管发现他把厨房弄得一团糟(程序出错或被强制关闭),于是解雇了他。总管需要回收厨房、工具等资源。
这些状态会根据情况相互转换(比如运行中需要等待 I/O 就变成等待态,等待的 I/O 完成了就回到就绪态,等待 CPU)。
3. 进程控制块 (Process Control Block, PCB) —— 厨师的“工作档案”
总管怎么管理这么多厨师呢?他给每个厨师都准备了一个详细的工作档案夹 (PCB)。这是操作系统中极其重要的数据结构,包含了关于一个进程的所有关键信息:
- 厨师编号 (Process ID, PID):唯一标识这个厨师(进程)。
- 厨师当前状态 (Process State):是就绪、运行、等待还是其他?
- 菜谱进度 (Program Counter, PC):记录厨师做到菜谱的哪一步了,下次能接着做。
- 手里的工具状态 (CPU Registers):记录厨师当前寄存器里的值。
- 厨房信息 (Memory Management Info):这个厨师的厨房(地址空间)有多大,页表在哪里等。
- 使用的特殊厨具列表 (List of Open Files):记录厨师打开了哪些文件或设备。
- 优先级、记账信息:比如这个厨师是不是 VIP (优先级高),用了多久灶台 (CPU 时间) 等。
总管通过查阅和更新这些 PCB 来跟踪和控制每一个进程。PCB 是进程存在的唯一标识。
4. 进程的创建与终止 —— 招聘与解雇厨师
- 创建 (Creation):
- 在 Unix/Linux 系统中,通常像细胞分裂:一个老厨师 (父进程) 调用
fork()
,完美复制出一个和他当前状态一模一样的新厨师 (子进程)。两个厨师可以接着干同样的事,也可以让新厨师(子进程)调用exec()
换一本全新的菜谱来做不同的菜。 - 在 Windows 中,则是通过
CreateProcess()
这个更直接的方式来指定要运行哪个程序(菜谱)并创建一个新厨师。
- 终止 (Termination):
- 厨师正常做完菜 (
exit()
)。 - 厨师做菜时发生严重错误(比如把盐当成糖)。
- 总管(OS)或餐厅老板(用户)决定强行解雇厨师(比如通过任务管理器结束进程)。
- 终止时,总管需要确保回收所有资源(关闭文件、释放内存等)。
5. 进程间通信 (Inter-Process Communication, IPC) —— 厨师们如何合作?
有时候,不同的厨师需要合作才能完成一道大菜。比如厨师 A 需要厨师 B 准备好的酱料。他们不能直接跑到对方厨房(内存保护),需要通过总管(OS)提供的安全途径来沟通:
- 共享内存 (Shared Memory):总管在两个厨房之间开辟一小块公共区域(共享内存段)。厨师 A 把酱料放在这里,厨师 B 自己去取。速度快,但需要厨师们自己协调好,别同时去抢或者弄乱了(需要同步机制)。
- 消息传递 (Message Passing):厨师 A 把酱料打包好,写上“给厨师 B”,交给总管。总管通过内部的**传送带(管道 Pipe、消息队列 Message Queue、套接字 Socket 等)**把包裹准确送达给厨师 B。更安全、简单,但速度可能慢一点,需要总管参与传递。
6. 进程调度 (Process Scheduling) —— 分配灶台(CPU)的艺术
餐厅里的灶台 (CPU 核心) 数量总是有限的,但想用灶台的厨师(就绪态进程)可能很多。调度器 (Scheduler)(总管的一个部门)的工作就是决定下一个让哪个厨师上灶台,以及用多久。
- 目标:尽量公平(别饿死某个厨师),提高吞吐量(单位时间做更多菜),减少响应时间(客人点菜后尽快上第一道)。
- 调度算法:总管使用的规则,常见的有:
- 先来先服务 (FIFO):谁先排队谁先用,简单但可能导致后面的厨师等很久。
- 时间片轮转 (Round Robin, RR):每个厨师上灶台用一小段时间(时间片),时间到了就下来排到队尾,让下一个用。很公平,保证了响应时间。
- 优先级调度 (Priority Scheduling):VIP 客户的菜(高优先级进程)优先使用灶台。
- 上下文切换 (Context Switch):当总管决定让厨师 A 下来,换厨师 B 上灶台时,需要:
- 保存厨师 A 的状态:把他做到哪一步、手里工具状态等所有信息记在他的档案 (PCB) 里。
- 加载厨师 B 的状态:从厨师 B 的档案 (PCB) 里读取他上次做到哪一步、工具状态等,恢复他的工作环境。 这个切换过程本身需要一点时间(不能用来炒菜),是操作系统进行多任务处理的必要开销。
7. 线程 (Threads) —— 厨房里的帮厨
- 有时候,一个厨师(进程)做一道复杂的菜,可以雇佣几个帮厨 (Threads) 在同一个厨房里协同工作。
- 这些帮厨共享厨房(地址空间)、主要食材(数据)、厨具(文件资源)。
- 但每个帮厨有自己的:当前负责的步骤 (PC)、手里的小工具 (Registers)、自己的临时小本子 (Stack)。
- 好处:雇佣帮厨比重新建一个厨房请新厨师成本低得多(创建/切换线程比进程快),他们之间共享东西也更方便(不需要复杂的 IPC)。能让一道菜的不同部分(程序的不同任务)真正地并行处理(如果灶台够多的话)。
并发vs并行
特点 | 并发 (Concurrency) | 并行 (Parallelism) |
核心关注点 | 处理多个任务(逻辑上) | 同时执行多个任务(物理上) |
时间角度 | 任务在一段时间内交替进行 | 任务在同一时刻真正同时进行 |
硬件要求 | 单核 CPU 即可(通过时间分片) | 必须多核 CPU 或多个处理单元 |
目标例子 | 提高系统响应性、资源利用率 | 加快计算密集型任务的速度 |
咖啡店比喻 | 一个咖啡师在多个订单间切换 | 多个咖啡师同时做不同的订单 |
调度
目标通常是希望能达到:
- 公平性 (Fairness):别让某个病人永远等下去。
- CPU 利用率 (CPU Utilization):尽量让诊室一直有病人在看(CPU 别闲着)。
- 吞吐量 (Throughput):单位时间内能看完多少个病人(完成多少个进程)。
- 周转时间 (Turnaround Time):病人从挂号到看完病离开的总时间尽量短。
- 等待时间 (Waiting Time):病人在候诊室排队等候的时间尽量短。
- 响应时间 (Response Time):对于需要交互的病人(比如只是问个问题),从他提出问题到医生开始回答的时间尽量短。
这些目标常常是相互冲突的,比如要绝对公平可能牺牲效率,要最短平均等待时间可能饿死长任务。所以不同的算法有不同的侧重。
以下是一些经典的、重要的调度算法:
1. 先来先服务 (First-Come, First-Served, FCFS)
- 规则:就像在银行排队,谁先到就绪队列,谁就先获得 CPU,一直运行到它自己结束或者因为 I/O 操作而阻塞为止。
- 特点:非抢占式 (Non-preemptive)。
- 优点:实现简单,逻辑清晰,很“公平”(按到达顺序)。
- 缺点:平均等待时间可能很长。如果一个需要运行很久的“长作业”先到了,后面很多“短作业”就得一直等(称为 护航效应 Convoy Effect),用户体验差。不适合交互式系统。
2. 最短作业优先 (Shortest Job First, SJF)
- 规则:每次从就绪队列中选择估计运行时间最短的那个进程来运行。
- 特点:可以是非抢占式(选定后就运行到结束或阻塞),也可以是抢占式(最短剩余时间优先, Shortest Remaining Time First, SRTF:如果一个新来的进程比当前正在运行进程的剩余时间还短,就抢占 CPU)。
- 优点:被证明在平均等待时间上是最优的。
- 缺点:
- 难以预测未来:无法精确知道一个进程下次需要运行多久,只能靠历史数据估计,估计不准效果就差。
- 可能饿死长作业:如果不断有短作业进来,长作业可能永远没有机会运行(饥饿 Starvation)。
3. 优先级调度 (Priority Scheduling)
- 规则:为每个进程分配一个优先级,调度器总是选择优先级最高的就绪进程运行。
- 特点:可以是抢占式(高优先级来了可以抢低优先级的 CPU),也可以是非抢占式。优先级可以静态指定,也可以动态改变。
- 优点:灵活,可以根据任务的重要性来分配 CPU 资源。
- 缺点:
- 可能饿死低优先级进程:如果总有高优先级进程就绪,低优先级的可能永远运行不了。
- 优先级如何确定是个问题。
- 解决饥饿:可以采用老化 (Aging) 技术,让等待时间过长的低优先级进程逐渐提高其优先级。
4. 时间片轮转 (Round Robin, RR)
- 规则:这是抢占式的 FCFS。所有就绪进程按 FCFS 排队,但每个进程被分配一个固定的、很短的 CPU 时间段,称为时间片 (Time Quantum / Time Slice)。当一个进程运行完一个时间片后,即使它还没完成,也会被强制剥夺 CPU(抢占),然后移动到就绪队列的末尾,让下一个进程运行。如果进程在一个时间片内提前结束或阻塞,也会立刻进行切换。
- 特点:抢占式,公平。
- 优点:实现简单,公平性好,响应时间快,特别适合分时系统和交互式应用。不会饿死进程。
- 缺点:
- 性能依赖于时间片大小:
- 时间片太小:频繁进行进程切换(上下文切换 Context Switch),切换本身的开销会变得很大,降低系统效率。
- 时间片太大:响应时间变慢,极端情况下退化成 FCFS。
- 没有考虑任务的优先级或长度。
5. 多级队列调度 (Multilevel Queue Scheduling)
- 规则:将就绪队列划分成多个独立的队列。比如:
- 前台队列 (Foreground Queue):存放交互式进程,优先级高,可能使用 RR 算法。
- 后台队列 (Background Queue):存放批处理进程,优先级低,可能使用 FCFS 算法。
- 调度器首先处理高优先级队列,只有当高优先级队列为空时,才处理低优先级队列。每个队列内部用自己的算法。
- 特点:进程通常被固定分配到某个队列。
- 优点:更灵活,能为不同类型的进程提供不同的调度策略。
- 缺点:如果进程类型判断错误或进程行为改变,可能不适应。队列间的调度策略需要设计。
6. 多级反馈队列调度 (Multilevel Feedback Queue Scheduling)
- 规则:这是多级队列的升级版,允许进程在不同队列之间移动。
- 工作方式:
- 设置多个队列,优先级从高到低,时间片从小到大。
- 新进程先进入最高优先级队列(时间片最小)。
- 如果在时间片内完成,就离开系统;如果没完成,就被降级到下一个较低优先级的队列(时间片可能更长)。
- 如果在某个队列等待 I/O 而阻塞,当 I/O 完成后,可能会被提升回一个较高优先级的队列(因为它表现出了交互性)。
- 目标:试图同时满足各种需求:对交互式进程(I/O 密集型)提供快速响应(留在高优先级队列),对 CPU 密集型进程降低优先级但给更长的时间片(减少切换次数)。能自动适应进程的行为,防止饥饿。
- 优点:非常灵活和自适应,是现代操作系统中常用的调度策略基础。
- 缺点:设计和调优非常复杂,需要确定队列数量、各队列算法、时间片大小、升级降级规则等众多参数。
进程间通信
进程间通信 (Inter-Process Communication, IPC) 是操作系统中一个非常重要的概念。我们知道,为了安全和稳定,操作系统让每个进程都拥有自己独立的内存空间(虚拟地址空间),就像给每个厨师分配了独立的厨房一样。这带来一个问题:如果不同的进程(厨师)需要协作、共享数据或相互通知,它们不能直接访问对方的内存(厨房),怎么办?
以下是一些常见且重要的进程间通信方式:
1. 管道 (Pipes)
- 匿名管道 (Anonymous Pipes):
- 比喻:想象两个紧挨着的工位(通常是父子进程关系)之间有一条单向的传送带。工位 A 可以把东西放上传送带,工位 B 可以从传送带末端取下来。
- 特点:
- 通常用于有亲缘关系的进程之间(如父进程和子进程)。
- 半双工(数据只能单向流动),或者需要建立两条管道实现双向通信。
- 存在于内核内存中,生命周期随进程结束而结束。
- 简单易用。
- 命名管道 (Named Pipes / FIFOs):
- 比喻:更像是一个公共的气动传输管道系统,在墙上有个带名字的收发口。任何知道这个名字(路径名)的进程都可以往里发送或从中接收东西。
- 特点:
- 允许无亲缘关系的进程之间通信。
- 以文件系统中的特殊文件形式存在,有路径名。
- 生命周期可以独立于进程(只要文件没被删除)。
- 同样是先进先出 (FIFO) 的流式传输。
2. 消息队列 (Message Queues)
- 比喻:像一个公共的邮政信箱区域。进程可以把打包好的、带有类型标签的“包裹”(消息)投递到指定的信箱(队列)里。其他进程可以根据类型或顺序从信箱里取出“包裹”。
- 特点:
- 消息是以固定格式的块(而不是字节流)进行传输。
- 内核维护消息队列,提供异步通信(发送方和接收方不需要同时在线)。
- 消息可以带优先级。
- 克服了管道容量有限、只能字节流传输的缺点。
- 生命周期也可以独立于进程。
3. 共享内存 (Shared Memory)
- 比喻:操作系统在几个进程的厨房之间开辟了一块共享的白板或工作台。所有被授权的进程都可以直接在这块白板上读写信息。
- 特点:
- 将同一块物理内存区域映射到多个进程的虚拟地址空间中。
- 速度最快的 IPC 方式,因为数据不需要在内核和用户空间之间复制。进程像访问自己的内存一样访问共享区域。
- 关键缺点:需要进程自行负责同步!比如用互斥锁 (Mutexes) 或信号量 (Semaphores) 来确保同一时间只有一个进程在写,防止数据混乱(Race Condition)。操作系统只负责提供内存,不负责管理访问冲突。
4. 信号量 (Semaphores)
- 比喻:更像是一种计数器或许可证。比如一个共享资源(如图书馆的几本特定图书)只能同时被 N 个进程访问。信号量就像这 N 本书的借阅卡。进程想访问资源时,先尝试获取一个借阅卡(执行 P 操作或
wait()
,计数减 1);如果没卡了,就得等。用完资源后,归还借阅卡(执行 V 操作或signal()
,计数加 1),唤醒可能在等待的进程。
- 特点:
- 主要用于进程或线程间的同步与互斥,控制对共享资源的访问,而不是直接传输大量数据。
- 通常与共享内存等机制配合使用。
- 由内核管理,保证 P/V 操作的原子性。
5. 套接字 (Sockets)
- 比喻:像是在每个进程那里安装了一部电话或网络接口。进程可以通过这个接口与本机上的其他进程(使用 Unix Domain Sockets)或网络上其他机器的进程建立连接并进行双向通信。
- 特点:
- 通用性最强的 IPC 机制。
- 既可用于同一台机器上的进程间通信,也是网络通信的基础。
- 支持面向连接(如 TCP,可靠,有序)和无连接(如 UDP,不可靠,数据报)的通信方式。
- 比管道等更灵活,但也更复杂。
6. 信号 (Signals)
- 比喻:像是一种简单的、异步的通知铃或警报。一个进程可以向另一个进程发送一个预定义的信号(比如
SIGINT
表示中断,SIGTERM
表示请求终止)。
- 特点:
- 用于传递简单的、预定义的事件通知。
- 能传递的信息量非常有限(通常只是信号编号本身)。
- 异步发生,接收进程需要预先注册信号处理函数来响应该信号。
- 常用于进程控制(如 kill 命令)、异常通知等。
选择哪种方式?
取决于具体需求:
- 简单父子通信?-> 匿名管道
- 无关系进程间简单流通信?-> 命名管道
- 需要传输结构化消息,异步?-> 消息队列
- 追求最高速度,能自行处理同步?-> 共享内存 (+ 信号量/互斥锁)
- 需要同步控制?-> 信号量
- 需要本机或网络间通用、灵活通信?-> 套接字
- 只需要简单的异步事件通知?-> 信号
进程vs线程

区别:
例子:同时运行网络浏览器、视频播放器和文字处理软件
- 资源分配情况:
- 浏览器进程获得:2GB内存空间、网络访问权限、100个文件描述符
- 视频播放器获得:1GB内存空间、声卡访问权限、GPU加速权限、10个文件描述符
- 文字处理软件获得:500MB内存空间、文档目录访问权限、20个文件描述符
- 隔离效果:
- 如果浏览器崩溃,操作系统会回收其使用的2GB内存和所有文件描述符
- 视频播放器和文字处理软件继续正常运行,不受影响
- 用户可以重新启动浏览器,系统会为新的浏览器进程重新分配资源
cpu调度例子:
假设视频播放器有三个线程:
- UI线程:负责显示控制按钮、进度条等用户界面
- 解码线程:负责解压缩视频数据
- 渲染线程:负责将解码后的视频帧显示到屏幕上
CPU调度过程:
- 时间片1:CPU执行解码线程,解压缩下一帧视频数据
- 时间片2:CPU切换到UI线程,处理用户点击暂停按钮的操作
- 时间片3:CPU切换到渲染线程,将已解码的帧显示到屏幕
- 时间片4:CPU再次切换回解码线程,继续解压下一帧
这种快速切换使得用户感觉这些操作是同时进行的,实际上是CPU在不同线程间快速切换执行。
综合例子:网页浏览器的多进程多线程模型
现代浏览器(如Chrome)采用多进程架构:
- 浏览器主进程:
- 系统分配:管理UI、用户输入、书签等资源
- 包含多个线程:UI线程、网络线程、存储线程等
- CPU调度各线程执行不同任务
- 渲染进程(每个标签页一个):
- 系统分配:独立的内存空间、渲染引擎权限
- 包含多个线程:
- 主线程(处理JavaScript、DOM更新)
- 合成线程(将页面元素合成为图像)
- 工作线程(Web Workers执行后台任务)
- CPU在这些线程间调度,确保页面渲染流畅
- 插件进程:
- 系统分配:隔离的内存空间、有限的系统访问权限
- 包含执行插件代码的线程
- CPU根据需要调度这些线程
实际运行情况:
- 打开一个含有视频、JavaScript和Flash插件的网页
- 系统为浏览器主进程、渲染进程和插件进程分配不同资源
- 如果Flash插件崩溃,只有插件进程终止,其资源被回收
- 标签页和浏览器主界面继续正常运行
- CPU持续在所有存活进程的各线程间进行调度
多线程冲突
多线程是提升程序性能和响应能力的好方法,但它也带来了一个经典且棘手的问题——线程冲突 (Thread Conflicts),也常被称为并发问题 (Concurrency Issues)。
想象一下你和你的朋友们(多个线程 Threads)被派到一个共享的厨房(进程的共享内存空间,特别是堆 Heap 和全局变量区) 里一起做一道大菜。你们共享同一个冰箱(数据)、同一个灶台(资源)、甚至可能需要共同更新一个写在墙上**白板(共享变量)**上的购物清单。

冲突是怎么发生的?
冲突的根源在于:多个线程并发(或并行)地访问和修改共享资源,而这些访问操作的执行顺序是不可预测的(由操作系统调度器决定),并且很多看似简单的操作(比如更新白板上的数字)实际上不是原子性的 (Non-Atomic)。
最核心的问题:竞争条件 (Race Condition)
- 比喻:假设白板上写着当前需要购买的苹果数量 "5"。你(线程 A)和你的朋友(线程 B)都发现需要再多买一个苹果。
- 你(A)看到是 "5",心里想好要改成 "6"。
- 就在你拿起笔准备写的时候,CPU 时间片用完了,操作系统切换让你朋友(B)先干活。
- 你朋友(B)也看到白板上是 "5",他也想好要改成 "6"。
- 他拿起笔,在白板上写下了 "6"。
- 现在轮到你了(A),你不记得朋友已经改过了,你按照自己之前心里想的,也拿起笔在白板上写下了 "6"。
- 结果:本来应该需要买 5 + 1 + 1 = 7 个苹果,但最后白板上写的却是 "6"!这就是竞争条件:最终结果取决于你们俩谁先完成“读取-修改-写入”这个完整操作的不可预测的执行时序。
为什么会这样?非原子操作
像
count++
这样的操作,在底层可能被分解成三步:- 从内存读取
count
的当前值到 CPU 寄存器。
- 在 CPU 寄存器中将值加 1。
- 将寄存器中的新值写回内存中的
count
。
线程切换可能发生在这三步之间的任何时刻,导致上面白板例子里的混乱。
如何解决冲突?—— “锁”和同步!
为了避免这种混乱,我们需要确保当一个线程正在访问和修改共享资源(比如读写白板)时,其他线程不能同时进行干扰。这块访问共享资源的代码区域被称为 临界区 (Critical Section)。我们需要实现 互斥 (Mutual Exclusion),即同一时间只允许一个线程进入临界区。
操作系统和编程库提供了多种同步原语 (Synchronization Primitives) 来实现互斥和协调:
- 互斥锁 (Mutex / Mutual Exclusion Lock)
- 比喻:给那块共享的白板配一把唯一的笔。任何人想写白板,必须先拿到这支笔(获取锁 Acquire/Lock)。拿到笔的人才能写,写完后必须把笔放回原处(释放锁 Release/Unlock)。如果笔已经被别人拿走了,想写的人就得等着,直到笔被放回来。
- 作用:保证同一时刻只有一个线程能执行临界区代码。这是最常用、最基本的保护共享数据的方法。
- 信号量 (Semaphore)
- 比喻:更像是一种许可证或计数器。比如,厨房里有 3 个特殊的搅拌碗(共享资源,最多允许 3 个线程同时使用)。信号量就像是这 3 个碗的可用标记(初始值为 3)。
- 线程想用碗,就尝试获取一个标记 (
wait()
或P()
操作,计数减 1)。如果计数大于 0,成功获取,计数减 1;如果计数为 0(没碗了),线程就得等待。 - 线程用完碗,就释放标记 (
signal()
或V()
操作,计数加 1),并可能唤醒一个正在等待的线程。 - 作用:可以实现互斥(当计数值为 1 时,称为二元信号量,作用类似互斥锁),也可以控制对有限数量资源的访问(计数信号量),还可以用于线程间的同步(比如一个线程等待另一个线程完成某事)。
- 条件变量 (Condition Variable)
- 比喻:通常与互斥锁配合使用。想象一个休息室,里面有块通知板。线程 A 拿到笔(获取互斥锁),检查白板发现条件还不满足(比如苹果数量还没到 10),它不能一直占着笔干等。于是它暂时放下笔(释放互斥锁),进入休息室睡觉等待特定通知(在条件变量上
wait()
)。当线程 B 拿到笔,把苹果数量改成 11 后,它除了放回笔,还会去休息室的通知板喊一声(signal()
或broadcast()
条件变量):“苹果数量更新了!” 正在休息室等待这个通知的线程 A 就会被唤醒,然后它会再次尝试去拿笔(重新获取互斥锁),再次检查条件,满足了就继续干活。 - 作用:允许线程在持有锁的情况下,因为某个条件不满足而高效地、原子地释放锁并等待,直到其他线程满足了该条件并通知它。避免了线程在持有锁的情况下进行“忙等待”(不断循环检查条件,浪费 CPU)。
- 原子操作 (Atomic Operations)
- 对于非常简单的操作(如计数器加 1、比较并交换值),现代 CPU 提供了硬件级别的原子指令。这些指令能确保“读取-修改-写入”这几步作为一个不可分割的整体完成,中间不会被其他线程打断。
- 优点:非常高效,没有锁的开销。
- 缺点:只适用于非常有限的简单操作。
哲学家问题
哲学家就餐问题 (Dining Philosophers Problem)!这是计算机科学中一个非常经典和著名的并发同步问题,由著名的计算机科学家艾兹赫尔·迪科斯彻(Edsger Dijkstra)提出。它被用来形象地说明在多进程(或多线程)环境下,共享有限资源时可能出现的死锁 (Deadlock) 和饥饿 (Starvation) 问题。
问题的场景设置:
- 有五位哲学家,围坐在一张圆桌旁。
- 每两位哲学家之间放着一把叉子,所以桌上总共有五把叉子。
- 桌子中央放着一大碗意大利面(或者米饭,总之是需要两把叉子才能吃的东西)。
- 每位哲学家的人生只有两件事:思考 (Thinking) 和 吃饭 (Eating)。
- 当哲学家饿了,想要吃饭时,他必须同时拿起他左手边和右手边的两把叉子才能吃到面。
- 哲学家一次只能拿起一把叉子。
- 他不能拿起已经被邻居(旁边的哲学家)拿走的叉子。
- 吃完饭后,他会放下手中的两把叉子,继续思考。
问题出在哪里?—— 死锁的风险
这个看似简单的场景隐藏着并发编程中的经典难题。想象一下这种情况:
- 五位哲学家同时停止思考,都感觉饿了,想要吃饭。
- 每一位哲学家都非常绅士(或者说,都遵循同一个简单策略),首先伸出手去拿自己左手边的那把叉子。
- 成功! 因为刚开始所有叉子都在桌上,所以每一位哲学家都成功拿到了自己左手边的叉子。
- 现在,每一位哲学家都需要拿起自己右手边的叉子才能开始吃饭。
- 糟糕! 当他伸手去拿右边的叉子时,发现那把叉子(也就是他右边邻居的左手叉)已经被他右边的邻居拿走了!
- 结果: 每一位哲学家都左手拿着一把叉子,并且永远地等待着被邻居拿走的右手叉。没有人能拿到第二把叉子,没有人能吃饭,也没有人会放下手中的叉子(因为还没吃完),所有哲学家都陷入了无限期的等待。这就是典型的死锁 (Deadlock)。整个系统(所有哲学家)都被卡住了,无法继续运行。
除了死锁,还可能有什么问题?
- 饥饿 (Starvation):可能某个哲学家运气特别差,每次他想吃饭时,他左右两边的叉子总有一把(或两把)被别人拿走了。虽然系统没有完全死锁,但他可能永远也吃不到饭。
- 活锁 (Livelock):如果哲学家们尝试一些简单的避免死锁的策略,比如“拿起左叉,如果右叉拿不到就立刻放下左叉,等一会再试”,可能会导致所有哲学家同步地拿起、放下、拿起、放下... 都在忙碌,但没有人真正吃到饭。
为什么这个问题很重要?
哲学家就餐问题是对现实世界中许多并发资源分配问题的抽象建模。
- 哲学家 -> 代表需要资源的进程或线程。
- 叉子 -> 代表需要互斥访问的共享资源(比如打印机、数据库连接、文件锁、内存缓冲区等)。一个进程可能需要同时获得多个资源才能工作。
它告诉我们,在设计并发系统时,必须非常小心地设计资源获取策略,以避免死锁和饥饿。
如何解决?(一些思路)
解决哲学家就餐问题的方案有很多,它们体现了不同的同步思想:
- 限制同时进餐的哲学家数量:比如,最多只允许四位哲学家同时尝试去拿叉子。可以设置一个“餐厅服务员”(比如一个信号量,初始值为 4),哲学家想拿叉子前必须先获得服务员的许可。这样可以保证至少有一位哲学家最终能拿到两把叉子。
- 规定拿叉子的顺序(资源分级/非对称):给所有叉子从 1 到 5 编号。规定所有哲学家必须先尝试拿编号较低的那把叉子,再尝试拿编号较高的叉子。这样就破坏了死锁产生的“循环等待”条件。(例如,4 号哲学家需要 4 号和 5 号叉,他必须先拿 4 号再拿 5 号;而 5 号哲学家需要 5 号和 1 号叉,他必须先拿 1 号再拿 5 号,这样 5 号哲学家就不会和 4 号哲学家同时等待对方手里的叉子了)。
- 同时拿起两把叉子(原子操作):设计一种机制,让哲学家要么一次性拿起左右两把叉子(如果它们都可用的话),要么一把都拿不到就等待。这通常需要一个全局的锁(比如一个互斥锁)来保护所有叉子的状态,在检查和拿起两把叉子的整个操作期间加锁。
- 使用监视器 (Monitor):利用更高级的同步结构(监视器)来封装叉子的状态和操作(
拿起叉子
、放下叉子
)。监视器内部会自动处理互斥和条件等待,程序员只需调用相应的方法即可。
死锁
死锁的定义
死锁是指在多进程(或多线程)环境下,两个或多个进程(或线程)因争夺共享资源而造成的一种互相等待的僵局。在这种状态下,每个进程都在等待另一个进程释放它所需要的资源,但没有任何一个进程能够继续执行下去,也无法释放自己持有的资源,导致所有涉及的进程都无限期地被阻塞,系统整体性能下降甚至停滞。
一个简单的比喻:就像前面提到的哲学家就餐问题中,所有哲学家都拿着左手叉,等待右手叉的情况。或者,两个人过独木桥,都走到桥中间,互相挡住对方,谁也不肯退回去,结果谁也过不去。
死锁产生的四个必要条件 (Coffman Conditions)
一个死锁的发生,必须同时满足以下四个条件。如果能破坏其中任何一个条件,死锁就不会发生。
- 互斥 (Mutual Exclusion):至少有一个资源是非共享的,即一次只能被一个进程使用。如果别的进程请求该资源,请求进程必须等待,直到资源被释放。 (比如打印机、锁)。
- 持有并等待 (Hold and Wait):一个进程至少持有一个资源,并且还在请求获取其他进程当前持有的资源。它不会释放自己已有的资源,只会等待。
- 非抢占 (No Preemption):资源不能被强制性地从持有它的进程中夺走。资源只能由持有它的进程在完成任务后自愿释放。
- 循环等待 (Circular Wait):存在一个进程集合 {P0, P1, ..., Pn},其中 P0 在等待 P1 持有的资源,P1 在等待 P2 持有的资源,...,Pn-1 在等待 Pn 持有的资源,而 Pn 又在等待 P0 持有的资源,形成一个等待环路。
如何处理死锁?(避免或解决)
主要有以下几种策略:
1. 死锁预防 (Deadlock Prevention)
这是最直接的方法,通过设计系统规则来破坏死锁产生的四个必要条件之一或多个,从而在结构上确保死锁永远不会发生。
- 破坏互斥:尽量使用可共享资源。但这对于打印机、锁等本身就具有排他性的资源是不可行的。
- 破坏持有并等待:
- 方法一:要求进程在开始执行前,一次性申请它所需的所有资源。如果不能同时获得所有资源,就一个都不分配,进程等待。缺点:资源利用率低(很多资源申请了但暂时不用),可能饿死(需要的资源组合一直凑不齐),并且很难预知所有需要的资源。
- 方法二:允许进程只获取部分资源就开始运行,但如果它需要进一步申请资源而无法得到满足时,必须释放它当前持有的所有资源,然后再重新申请。缺点:代价高,可能导致进程反复申请和释放。
- 破坏非抢占:允许操作系统强制剥夺进程已持有的资源。如果一个进程持有资源 A,申请资源 B 失败,操作系统可以抢占资源 A 给其他进程。缺点:实现复杂,只适用于状态容易保存和恢复的资源(如 CPU、内存),不适用于打印机等。
- 破坏循环等待:这是最常用且可行的预防方法。对所有资源类型进行线性排序(例如,给所有锁编号),并强制所有进程必须按这个顺序申请资源。例如,如果一个进程需要资源 R1 和 R2(假设 R1 编号 < R2 编号),它必须先申请 R1,再申请 R2。这样就不会形成循环等待链。
2. 死锁避免 (Deadlock Avoidance)
这种方法不试图破坏必要条件,而是在运行时,在每次资源分配前,先判断这次分配是否会导致系统进入一个“不安全状态”(即可能导致死锁的状态)。如果分配会导致不安全状态,则拒绝分配,让进程等待。
- 核心思想:确保系统始终处于安全状态。安全状态是指存在一个安全的执行序列,使得所有进程都能最终完成执行。
- 需要的信息:通常要求进程在运行前声明它可能需要的每种资源的最大数量。
- 著名算法:银行家算法 (Banker's Algorithm)。操作系统就像一个银行家,它知道每个客户(进程)的最大贷款额度(最大资源需求)和当前已贷款额(已分配资源)。在批准新的贷款申请(资源分配)前,银行家会计算,即使批准了这笔贷款,银行是否仍有足够的储备金(剩余资源)能保证至少有一个客户可以完成其所有贷款(达到最大需求),然后还款(释放资源),从而使得银行能逐步满足所有客户的需求。如果计算表明存在这样的安全序列,就批准贷款;否则,拒绝贷款申请,让客户等待。
- 优缺点:比死锁预防允许更高的并发度,但需要预先知道最大资源需求(往往不现实),且每次分配都要执行检查算法,开销较大。
3. 死锁检测与恢复 (Deadlock Detection and Recovery)
这种策略允许死锁发生,但系统会定期检测是否存在死锁,一旦检测到,就采取措施解除死锁。
- 检测:操作系统周期性地检查系统的资源分配图,看是否存在循环等待。
- 恢复:检测到死锁后,需要打破循环:
- 剥夺资源 (Resource Preemption):强制从一个或多个死锁进程中抢占资源,分配给其他死锁进程,直到打破循环。需要考虑选择哪个进程、哪个资源,以及如何让被抢占资源的进程能够恢复(回滚代价可能很高)。
- 终止进程 (Process Termination):最简单粗暴的方法。强制杀死一个或多个参与死锁的进程,释放它们占有的资源。需要选择牺牲哪个进程(代价最小的?优先级最低的?已运行时间最短的?)。
- 优缺点:资源利用率和并发度可以很高,但检测本身有开销,恢复代价(尤其是终止进程)可能很大,丢失工作。
4. 忽略死锁 (Ignoring the Problem / Ostrich Algorithm)
- 鸵鸟策略:像鸵鸟一样把头埋进沙子里,假装问题不存在。
- 理由:如果死锁发生的概率非常低,而预防、避免或检测恢复的成本(性能开销、实现复杂度)又非常高,那么可能“忽略”它是最经济的选择。如果真的发生了死锁,就依靠用户或管理员手动重启系统或杀死进程。
- 适用性:在某些桌面操作系统或不那么关键的系统中可能被接受,但在高可靠性、高可用性系统中是不可接受的。
锁
类型 | 等待方式 | 主要特点 | 适用场景 | 核心思想/类别 |
互斥锁 | 阻塞/睡眠 | 基本互斥,一次一个线程 | 通用临界区保护 | 悲观锁 |
自旋锁 | 忙等待/自旋 | 非阻塞等待,CPU空转 | 锁持有时间极短、多核、低竞争 | 悲观锁 |
读写锁 | 阻塞/睡眠 | 读共享、写独占 | 读多写少 | 悲观锁 |
悲观锁 | (依赖具体实现) | 先加锁,假设冲突会发生 | 冲突频繁,数据一致性要求高 | 策略/思想 |
乐观锁 | 重试 (通常) | 不加锁,提交时检查冲突(版本号/CAS) | 冲突稀少,读密集,性能要求高 | 策略/思想 |
乐观锁 (Optimistic Lock)
- 是什么? 这也是一种并发控制的策略或思想,与悲观锁相对,它对数据冲突持乐观态度。
- 核心思想:假设并发冲突是很少发生的。因此,不首先加锁就去读取和操作数据。在最后要提交更新的时候,才去检查在这期间是否有其他线程修改了这份数据。
- 实现方式:通常使用版本号 (Versioning) 或 时间戳 (Timestamp) 机制。
- 读取数据时,同时读取其版本号 V1。
- 进行计算或修改(在本地进行,不影响原始数据)。
- 准备写回更新时:
- 再次读取数据的当前版本号 V2。
- 比较 V1 和 V2。如果 V1 == V2,说明在你操作期间没有其他线程修改过它,于是原子地(比如使用 CAS - Compare-And-Swap 指令)将你的修改写回,并将版本号更新为 V3。
- 如果 V1 != V2,说明在你操作期间数据已经被别人修改了(发生冲突),你的更新失败。程序通常需要重试整个过程(重新读取、计算、尝试提交)。
- 比喻:先斩后奏,事后检查。你先在自己的草稿纸上基于共享数据算出了结果,准备去更新共享数据时,先看一下共享数据是不是还是你当初看到的样子(版本号没变),如果是,就更新;如果不是,说明别人动过了,你的计算白做了,得重新来过
文件系统
问题:海量信息如何安放?
想象一下,你的硬盘或 SSD 就像一片巨大无比、但最初是荒芜一片的土地 (Storage Device)。你想在这片土地上存放海量的书籍、文档、照片、音乐(数据),而且希望以后能:
- 轻松地找到任何一本书(文件)。
- 给书分门别类,整理得井井有条。
- 知道每本书的作者、大小、上次阅读时间等信息。
- 安全地保护书籍,不让别人随便乱动。
- 高效地添加新书或丢弃旧书。
直接把书(数据)随便扔在土地(裸磁盘)上显然是不行的,会乱作一团。
解决方案:建造并管理一座图书馆! (The File System)
文件系统,就是操作系统在这片“土地”上设计、建造并管理的一整座大型图书馆的规则和结构的总称。它把底层的、混乱的存储空间(磁盘扇区、块)变成了我们熟悉的、有组织的文件和文件夹。
核心概念与图书馆组件:
- 文件 (File) —— 图书馆里的书
- 这是我们最熟悉的基本单位,就像图书馆里的每一本书。
- 它包含了实际的内容(书的正文 - 数据)以及属性 (Attributes / Metadata),如同书的:
- 书名 (Filename):唯一标识这本书的名字。
- 类型 (File Type):小说、技术、杂志?(如
.txt
,.jpg
,.exe
)。 - 大小 (Size):书有多少页(文件有多少字节)。
- 位置信息:这本书放在哪个书架的哪个位置(指向数据块的指针)。
- 作者/所有者 (Owner):这本书是谁的。
- 借阅权限 (Permissions):谁可以读?谁可以修改?
- 出版/修改日期 (Timestamps):创建、最后修改、最后访问时间。
- 目录/文件夹 (Directory/Folder) —— 图书馆的区域/房间
- 为了不让所有书都堆在一起,图书馆划分了不同的区域或房间,比如“小说区”、“科学区”、“历史区”。这就是目录。
- 目录本身也是一种特殊的文件,它的“内容”是一个列表,记录了这个房间里有哪些书(文件名)以及通往其他子区域(子目录)的门牌号。
- 这样就形成了我们熟悉的树状层级结构,方便我们组织和查找文件。
- 文件系统结构(图书馆的内部构造)
- 引导块 (Boot Block):图书馆入口处的一块牌子,可能有启动计算机所需的基本指令(如果这个“图书馆”是系统盘的话)。
- 超级块 (Superblock) / 卷控制块 (Volume Control Block):图书馆的总控制室/核心蓝图!记录了这个图书馆的所有关键元数据:图书馆类型(比如是现代图书馆 ext4 还是老式阅览室 FAT32),总容量(总共有多少书架空间),还有多少空位,重要区域(如下面提到的 inode 区、数据区)的入口在哪里等等。这个块如果损坏,整个图书馆就可能瘫痪!
- 索引节点 (Inode) / MFT (Master File Table in NTFS):图书馆的卡片目录系统!每本书(文件)都有一个唯一的卡片 (Inode),这张卡片详细记录了这本书的所有属性(上面提到的那些),最重要的是,它记录了这本书的每一页具体放在了哪个书架的哪个精确位置(指向数据块的指针列表)。目录(房间)里只记录“书名 -> 卡片编号”,具体的书的信息和内容位置都在卡片上。这种元数据与名字分离的设计非常重要!
- 数据块 (Data Blocks):图书馆里真正用来存放书页内容的书架空间。文件系统把存储设备划分成固定大小的块(比如 4KB),文件内容就存储在一个或多个这样的数据块中。
- 空闲空间管理 (Free Space Management):图书馆需要知道哪些书架位置是空的,才能放新书。管理空闲块的方法通常有:
- 位图 (Bitmap):一张巨大的清单,上面列出了图书馆的每一个书架位置(数据块),并标记它是“空闲”还是“已占用”。查找空闲位置很直观。
- 空闲块链表 (Free Block List/Chain):只把空闲的书架位置用链条串起来。记录第一个空闲位置,它指向下一个空闲位置,以此类推。添加删除块时操作链表。
- 文件内容存放方式(分配方法 Allocation Methods)
- 图书馆怎么存放一本有很多页的书(一个大文件)的内容呢?
- 连续分配 (Contiguous Allocation):要求这本书的所有页必须放在一个连续的书架上。优点:读取速度快(像翻书一样连续)。缺点:容易产生外部碎片(书架上有很多小的空隙,但放不下一本新书),文件难以增长。
- 链式分配 (Linked Allocation):这本书的每一页(数据块)末尾都写着下一页放在哪个书架位置。优点:没有外部碎片,文件可以方便地增长。缺点:随机访问性能极差(想看第 500 页,必须先读前 499 页找到指针),如果中间一个指针损坏,后面的内容就都找不到了。
- 索引分配 (Indexed Allocation):最常用的方式(通过 Inode 实现)。书的卡片 (Inode) 上有一个索引表,直接列出了这本书第 1 页、第 2 页、第 3 页...分别存放在哪些书架位置(数据块地址)。优点:没有外部碎片,支持高效的随机访问,也方便文件增长。缺点:索引表本身需要占用空间;对于非常大的文件,一个索引表可能不够,需要多级索引或链式索引块。
- 性能优化:缓存 (Caching)
- 图书馆管理员(OS)非常聪明,他会把最近经常被借阅的书(或书的某些章节) 放在靠近服务台的临时书架(内存 Page Cache) 上。下次再有人借这本书,直接从临时书架拿就行,不用跑去里面的书库(硬盘/SSD)找了,速度快得多!这是提升文件系统性能的关键。
- 可靠性:日志/日记 (Journaling)
- 为了防止突然停电(系统崩溃)导致图书馆信息混乱(比如书刚从 A 架搬到 B 架,卡片还没来得及改),负责任的管理员会使用日志(Journaling)。
- 在做任何重要操作(如移动书、修改卡片)之前,管理员会先在工作日志里写下:“我打算把书 X 从 A 移到 B,并更新卡片 Y”。
- 写完日志后,才真正开始搬书和改卡片。
- 如果中途停电,等电力恢复后,管理员先检查日志,就能知道之前有哪些操作没做完,然后根据日志把未完成的操作重做或回滚,确保图书馆状态是一致和完整的。这大大提高了文件系统的可靠性和崩溃后的恢复速度。常见的 ext3, ext4, NTFS, APFS 都使用了日志技术。
- 兼容性:虚拟文件系统 (Virtual File System, VFS)
- 世界上有各种不同设计风格的图书馆(如 ext4 图书馆, NTFS 图书馆, FAT32 老式阅览室, 甚至网络图书馆 NFS)。
- VFS 就像一位能和所有图书馆馆长沟通的“超级馆长”。它为操作系统内核和用户程序提供了一个统一的、标准的接口(比如
open
,read
,write
命令)。用户只需要告诉超级馆长想做什么,VFS 会负责把命令翻译成对应具体图书馆能听懂的语言去执行。这样,你的程序就能在不知道底层文件系统细节的情况下,访问各种不同的文件系统了。
设备管理
。
咱们这次用一个大型现代化工厂的车间主任来比喻操作系统,车间里有各种机器和工具(硬件设备)。
1. 核心任务:管好形形色色的机器
- 隐藏复杂性:每台机器(比如一台是老式车床,一台是最新款的激光切割机)内部构造和操作方式都不同。车间主任(OS)的目标是让普通工人(应用程序)不需要关心每台机器的具体型号和按钮,只需要下达统一的指令(比如“切割这个零件”)。
- 提供统一接口:无论是对键盘(输入字符)、硬盘(读写数据块)还是打印机(发送打印页),应用程序都希望用类似的方式(比如
read
,write
)来和它们交互。
- 管理设备资源:确保机器被合理分配和使用,处理设备发生的各种情况(比如完成任务、出错、需要维护)。
- 提高效率:尽量让机器运转起来,减少等待,提高数据传输速度。
2. 关键组件与概念:
- 设备控制器 (Device Controller) vs. 设备 (Device)
- 比喻:一台高精尖的数控机床(设备本身)通常配有一个复杂的电子控制面板和电路板(设备控制器)。车间主任(OS)主要是通过控制面板来操作机床,而不是直接去拧机床上的螺丝。
- 关系:设备控制器是连接设备和计算机总线(数据通道)的电子部件,它接收来自 CPU 的命令,控制设备执行具体操作(如读写磁盘扇区),并通过中断通知 CPU 结果。
- 设备驱动程序 (Device Driver) —— 机器的“专属说明书+翻译官”
- 这是设备管理的核心软件! 对于车间里每一种型号的机器,都有一本专门的操作手册和翻译指南(设备驱动程序)。
- 作用:
- 翻译:当主任下达一个通用指令(比如应用程序调用
write
函数写硬盘)时,对应的硬盘驱动程序会把这个通用指令翻译成那款特定硬盘控制器能听懂的具体电子信号和命令序列。 - 细节隐藏:驱动程序隐藏了硬件的所有复杂细节。操作系统的其他部分(以及应用程序)只需要和驱动提供的标准接口打交道,不必关心具体的硬件型号和操作方式。
- 特点:通常是操作系统内核的一部分(或可加载的内核模块),由设备制造商或 OS 开发者编写,与特定硬件紧密相关。更新驱动程序就像是给机器的操作手册更新换代。
- I/O 操作的关键机制
- 中断 (Interrupts):
- 比喻:机器完成任务(比如打印完一页)或者发生故障(卡纸了)时,机器上的指示灯会闪烁并发出提示音(产生中断信号)。
- 作用:设备通过中断来通知 CPU 它需要关注了(比如数据准备好了可以取走,或者出错了需要处理)。CPU 会暂停当前正在做的事,转去处理这个中断(运行中断服务程序),处理完再回来。这比让 CPU 不断去问“你好了没?”(轮询)要高效得多。
- 直接内存访问 (Direct Memory Access, DMA):
- 比喻:对于需要搬运大量原材料(数据)的机器(如高速硬盘、网卡),主任(OS)可以授权一个自动化的搬运机器人(DMA 控制器)。机器人可以直接在**仓库(内存)和机器(设备)**之间搬运,不需要主任(CPU)亲自参与每一次搬运。
- 作用:允许设备控制器直接与主内存进行数据传输,绕过 CPU。CPU 只需在传输开始和结束时介入。这极大减轻了 CPU 的负担,提高了大数据量 I/O 操作的效率。
- I/O 软件分层结构 (Layered I/O Software)
- 比喻:工厂里的管理层级和工作流程:
- 用户层 (Application):客户下订单(“我要 100 个零件 A”)。
- 设备无关的 OS 软件层:车间调度员接到订单,进行权限检查、资源预留、启动 I/O 请求(发出通用指令“生产 100 个零件 A”),提供统一的接口(如
read
/write
),进行初步的缓冲、错误处理。他不关心具体用哪台机器。 - 设备驱动程序层:调度员把指令交给负责特定机器(比如“3 号切割机”)的操作员。操作员查阅机器说明书(驱动程序),把通用指令翻译成这台机器的控制命令。
- 中断处理层:机器完成或出错,指示灯亮(中断),调度员被通知,进行后续处理。
- 硬件层:机器本身和它的控制器。
- 好处:分层结构使得系统模块化,易于管理和扩展。上层软件不需要关心底层硬件细节(设备无关性)。
- 提高性能的技术
- 缓冲 (Buffering):
- 比喻:在机器旁边放一个临时的物料小推车。数据从内存过来先放推车里,机器慢慢取;或者机器生产出的零件先放推车里,等攒够一车再运走。
- 作用:缓解 CPU/内存与慢速设备之间的速度差异,处理数据块大小不匹配的问题,或者把数据攒成一批再处理。
- 缓存 (Caching):
- 比喻:把最常用的工具或标准零件(经常访问的数据块)放在工作台旁边的架子上(内存 Page Cache),而不是每次都跑回大仓库(硬盘)去拿。
- 作用:利用高速内存缓存慢速设备的数据,如果请求的数据在缓存中,直接从缓存读取,避免访问慢速设备,极大提高性能。(我们之前讨论内存管理时提到的 Page Cache 就是文件系统 I/O 的缓存)。
- 假脱机/后台打印 (Spooling - Simultaneous Peripheral Operations On-Line):
- 比喻:车间里有一台非常慢、且一次只能给一个人用的专用设备(比如老式打印机)。工人们(应用程序)不直接排队等着用这台机器,而是把他们的打印任务(文件和指令)快速地扔到机器旁边的一个任务收集箱 (Spool Directory / Queue) 里。有一个专门的助手(后台 Spooler 进程)会按照顺序从箱子里取出任务,在机器空闲时逐一送给机器去执行。
- 作用:让应用程序能快速地“完成”对慢速、独占设备的输出操作(把任务交给 Spooler 就行了),然后继续做其他事,不必等待设备真正完成。提高了系统整体效率和用户体验。
整体串联起来
现在,我们来看一个简单的场景:你双击运行一个“文本编辑器”程序,并用它打开一个文件
mydoc.txt
,进行编辑,最后保存。看看这四大模块是如何协作的:- 启动程序(进程管理 + 内存管理 + 文件系统)
- 你双击图标,这是一个用户请求。某个现有进程(如桌面环境)通知进程管理部门:“启动‘文本编辑器’项目!”
- 进程管理:立项!创建一个新的进程(新的电影项目),分配一个唯一的项目编号(PID),建立项目档案(PCB)。
- 进程管理 向 内存管理 申请:“给这个新项目准备一个场地(虚拟地址空间)!”
- 内存管理:分配虚拟地址空间,建立初始的场地地图(页表)。
- 进程管理 需要加载程序的代码(剧本)。它向 文件系统(资料库)请求:“找到‘文本编辑器’的程序文件!”
- 文件系统:在存储设备上找到程序文件,读取其内容。
- 进程管理 与 内存管理 协作,将程序代码加载到新进程的虚拟地址空间中(可能只先加载一小部分到物理内存 - 场地管理只先搭好第一个场景的景)。
- 进程管理:将新进程状态设为“就绪”(项目组人员到位,等待拍摄设备)。
- 程序运行与调度(进程管理)
- 进程管理 的调度器看到文本编辑器进程就绪了,并且当前 CPU(核心拍摄设备)空闲(或者轮到它了)。
- 进行上下文切换:保存上一个项目的进度,加载文本编辑器项目的状态(PC 指针指向代码入口)。
- 文本编辑器进程开始在 CPU 上运行。
- 打开文件(进程 -> 文件系统 -> 设备管理 -> 内存管理 -> 进程管理)
- 你在编辑器里选择“打开”,选中
mydoc.txt
。编辑器进程发起一个open("mydoc.txt")
的系统调用。 - 控制权交给操作系统内核。请求首先到达 设备无关 I/O 层。
- 文件系统 接到请求:“找到
mydoc.txt
!”。它根据路径查找目录,找到文件的 Inode(卡片)。检查权限(可能需要 进程管理 提供用户信息)。 - 文件系统 从 Inode 得知文件内容存储在磁盘的哪些逻辑块上。它可能先尝试在 内存管理 维护的 Page Cache(场地旁边的常用道具架)里查找,如果找到了(缓存命中),太棒了!直接从内存拿数据。
- 如果缓存里没有,文件系统 就需要从硬盘读取。它向 设备管理 发出请求:“读取硬盘上的第 X, Y, Z 逻辑块!”
- 设备管理:找到对应的硬盘驱动程序(操作手册)。
- 驱动程序:将逻辑块请求翻译成硬盘控制器能懂的命令。
- 设备管理:启动硬盘读操作(可能使用 DMA - 自动搬运机器人)。
- 进程管理:由于硬盘读取很慢,文本编辑器进程会被标记为“等待/阻塞”(导演等素材),并让出 CPU。调度器会运行其他“就绪”的进程。
- 设备管理:硬盘完成读取(DMA 把数据放入内存的缓冲区),硬盘控制器发出中断(设备上的提示灯亮了)。
- CPU 响应中断,设备管理 的中断处理程序运行,得知数据已准备好。
- 设备管理 通知 进程管理:“文本编辑器等的数据到了!”
- 进程管理:将文本编辑器进程重新设为“就绪”状态,放入就绪队列。
- 编辑与显示(进程管理 + 内存管理 + 设备管理)
- 轮到文本编辑器再次运行时,它从内存管理提供的内存缓冲区中读取
mydoc.txt
的内容。 - 你进行编辑,修改了内存中的数据。
- 编辑器需要将内容显示在屏幕上。它调用绘图相关的系统调用,最终通过设备管理(显卡驱动)将像素数据发送给显示器。
- 保存文件(进程 -> 文件系统 -> 设备管理 -> 内存管理 -> 进程管理)
- 你点击“保存”。编辑器进程发起
write()
系统调用,将修改后的数据写入mydoc.txt
。 - 过程与打开文件类似,但方向相反:
- 数据可能先写入 内存管理 的 Page Cache(标记为“脏”页)。
- 文件系统 更新文件的 Inode(比如修改时间和大小),并记录哪些数据块需要写回。
- 文件系统 可能通过日志 (Journaling) 机制先记录这次修改操作,保证掉电不丢失。
- 在某个时刻(可能立刻,也可能稍后),文件系统 通知 设备管理 将 Page Cache 中的脏页数据写回硬盘。
- 设备管理 通过驱动程序控制硬盘写入(可能也用 DMA)。
- 这个过程可能也涉及进程的短暂等待(虽然通常写缓存后应用就认为完成了)。
- 关闭程序(进程管理 + 所有其他模块)
- 你关闭文本编辑器。进程调用
exit()
。 - 进程管理:开始进行资源回收。
- 通知 文件系统:关闭所有该进程打开的文件(归还资料库里的书)。
- 通知 内存管理:释放该进程的所有虚拟地址空间和物理内存页框(拆除摄影棚,清理场地)。
- 通知 设备管理:释放该进程占用的设备(归还器材)。
- 进程管理:销毁进程的 PCB(销毁项目档案),进程彻底消失。
- 进程是核心活动者,它需要内存运行,需要CPU执行(由进程管理调度),通过文件系统访问持久化数据,而数据最终存储在由设备管理控制的硬件上。
- 一个简单的 I/O 操作(如读文件)会依次穿过进程管理(发起/等待/唤醒)、文件系统(定位/缓存)、设备管理(驱动/硬件交互)、内存管理(数据缓冲区/页缓存) 这些环节。