威斯康星-CS537-操作系统笔记-全-

威斯康星 CS537 操作系统笔记(全)

001:操作系统简介 🖥️

在本节课中,我们将学习操作系统的基本概念、课程结构以及操作系统在现代计算中的核心作用。我们将从课程介绍开始,逐步深入到操作系统的定义、主要职责以及它如何管理计算机资源。

课程概述与介绍

欢迎来到CS 537操作系统导论课程。本课程旨在帮助你深入理解计算机系统如何作为一个整体协同工作。你将通过一系列项目和理论学习,掌握操作系统的核心原理。

本课程内容充实,包含大量实践项目。完成本课程后,你将深刻理解整个计算机系统的工作原理。

讲师介绍

我的名字是Andrea Arpaci-Dusseau。我于多年前在加州大学伯克利分校获得博士学位,研究方向是并行作业的隐式协调调度。之后,我在斯坦福大学进行了短暂的博士后研究。在威斯康星大学麦迪逊分校,我主要教授CS 537(操作系统导论)和CS 736(高级操作系统)。我还创建并偶尔教授CS 402课程,这是一门服务学习课程,教授如何向四五年级学生传授编程和计算机科学知识。

我的研究主要集中在文件和存储系统领域,与我的同事兼丈夫Remzi共同指导了许多博士生。我们在存储系统方面的工作曾获得SIGOPS Mark Weiser奖。

在课堂上,你可以称呼我为Andrea教授或直接叫我Andrea。

课程目标与内容

在本课程结束时,你将掌握以下知识和技能:

  • 理解操作系统:了解操作系统是什么及其在系统中的角色。
  • 掌握虚拟化:理解操作系统如何虚拟化物理资源(如内存、CPU、磁盘),并为用户级应用程序提供抽象。
  • 实现抽象:了解如何在现代硬件架构上实现这些抽象。
  • 并发编程:学习如何正确编写多线程应用程序,使用锁、条件变量和信号量等同步原语。
  • 持久化:理解文件系统如何使信息持久化,即使在任意时间点发生崩溃或断电。
  • C语言项目:完成多个C语言编程项目,其中一些需要独立完成,一些需要与伙伴合作。这些项目可能比你过去接触过的更具开放性。
  • 系统调用与内核:项目将涉及使用现有操作系统的系统调用,或修改一个名为XV6的小型模拟内核。

这是一门具有挑战性的课程,需要投入大量时间。如果你的学期课程负担很重,可能需要考虑在其他学期选修。

评分与考核

你的成绩将基于两部分:课堂内容和项目,各占50%。

课堂内容(50%)
这部分包括两次期中考试和一次期末考试。设置两次期中考试是为了降低每次考试的压力和风险。考试主要涵盖课堂上讨论的概念。我们可能会布置一些作业作为这50%的一部分,但目前尚未确定。本学期,我鼓励大家组建约5人的学习小组,共同讨论课程内容。

项目(50%)
本课程共有八个编程项目。通过这些项目,你将学习如何修改和使用操作系统。

课程形式

课程形式包括讲座和讨论课。

讲座
在讲座中,我将使用幻灯片进行较为正式的讲解。我非常欢迎大家提问,我也会向你们提出一些基础问题,以确保大家跟上进度。

讨论课
你们都已注册了一个讨论课部分。讨论课的目的是让你们在较小的群体中讨论项目,并获得查看代码的实践经验。助教将主导讨论课,你们通常需要携带笔记本电脑。讨论课内容可能包括项目讲解、答疑或考试前的复习。

教学团队与支持

本课程拥有强大的教学团队来帮助你们:

  • 七名助教:他们都是计算机科学系的研究生,对系统领域感兴趣,能够提供很大帮助。
  • 同伴导师:他们是上学期选修CS 537并取得优异成绩的学生。他们将在教学实验室提供大量的答疑时间,帮助你们解决代码和项目问题。

我鼓励喜欢本课程的同学考虑在下学期担任同伴导师。

课程资源与学术诚信

课程资源
我们将使用Canvas作为课程网页,上面会发布大纲、阅读材料、幻灯片和项目说明。课程使用的教材是我和Remzi合著的一本书,它与课程内容紧密结合。你们可以免费获取独立的PDF章节,也可以付费获取印刷版或完整PDF。

学术诚信
本课程有大量的编程作业。严禁抄袭其他学生的代码或在线查找往年解决方案。我们会使用自动化工具检测代码相似性,包括与本届其他学生以及往届学生代码的对比。一旦发现抄袭行为,相关作业将不予计分。

什么是操作系统?🤔

现在,让我们切换话题,探讨为什么操作系统是一个有趣且重要的主题。

我们可能对操作系统有一个直观的感受,因为我们使用过许多不同的操作系统。我们可以将操作系统定义为运行在笔记本电脑或台式机上的通用软件。

另一种定义方式是通过层次结构来看。我们知道这个结构中的其他部分:顶层的用户(人类),用户使用的应用程序(如网页浏览器、游戏),以及底层的硬件资源(内存、CPU、存储设备)。

操作系统是位于这两者之间的软件。它是将硬件转化为对应用程序更有用的东西的软件。在早期,应用程序可以直接在硬件上运行,但在这之间增加一个共享层(即操作系统)带来了许多优势。

操作系统的两大核心角色

操作系统主要扮演两个关键角色。

1. 标准库(抽象资源)
操作系统为硬件资源提供抽象,使其比硬件本身提供的原始接口更易于使用。

  • 资源:指系统中所有有价值的硬件。
  • 抽象示例
    • CPU -> 进程线程
    • 内存 -> 地址空间
    • 磁盘 -> 文件(也可以是键值存储或数据库)

为什么由操作系统提供这个标准库是好事?

  • 易于使用:避免每个应用程序重复编写相同功能(如内存分配器)。
  • 设备抽象:抽象底层设备细节,应用程序无需处理不同的设备驱动和汇编编程。
  • 提供高级功能:在低级设备(如按块读写的磁盘)之上提供更高级的概念(如文件和目录)。

这方面的挑战在于确定正确的抽象,以及应该向应用程序暴露多少硬件能力。

2. 资源管理器(虚拟化资源)
这是更有趣且我们将重点讨论的角色。操作系统不仅提供接口,还必须实际虚拟化这些资源,以便不同的应用程序能够以公平高效的方式共享资源。

  • 提供保护:确保进程不能阻止其他进程运行,也不能读取彼此的数据。
  • 确保公平:防止一个进程独占CPU,而其他进程必须等待。
  • 机制与策略
    • 机制:如何实现某事(例如,如何在竞争进程之间进行上下文切换)。
    • 策略:使用该机制来决定何时发生某事(例如,何时调度一个进程,分配多少内存)。

由操作系统执行资源管理是合理的,因为它运行在所有应用程序之下,并且能够做到这一点。但这将非常复杂,我们会面临许多有趣的挑战。

总结来说,操作系统有两个主要角色:一是作为标准库抽象资源;二是作为资源管理器虚拟化资源,让每个应用程序都以为自己独占CPU、内存和磁盘。

课程结构概览 📚

本课程将围绕三个核心部分来讨论操作系统,我们有时称之为“三个简单的部分”。

1. 虚拟化
我们将探讨如何为系统的不同组件(CPU、内存)提供抽象。这能让我们快速了解一切是如何工作的。

2. 并发
我们将深入研究多线程。当同一进程内的多个线程共享相同的地址空间和变量时,如何协调它们对共享变量的访问,以避免竞态条件并确保一切正确运行。

3. 持久化
我们将学习如何让进程能够读取另一个进程在过去写入的数据,即使在最糟糕的时间点发生了崩溃或故障。我们必须确保持久化数据是可靠和一致的。持久化数据结构与我们在内存中处理的数据结构有很大不同。

接下来,我们将通过演示来展示虚拟化的概念。

虚拟化演示

虚拟化的理念是,每个应用程序都应该认为自己拥有对每种资源的独占访问权或副本。

CPU虚拟化演示
我们使用 top 命令查看系统进程。可以看到系统中有许多进程,但只有少数在活跃运行。大多数进程处于睡眠状态。top 显示了CPU利用率、负载平均值等信息。

然后,我们运行一个简单的 cpu.c 程序,它执行一个忙循环。当我们运行一个实例时,它几乎占用100%的CPU。运行第二个实例时,CPU在两个进程间共享。当运行的进程数超过虚拟核心数(例如4个核心上运行5个进程)时,操作系统需要进行调度,每个进程获得的CPU时间比例会下降(例如各占约80%)。这演示了CPU如何被虚拟化给进程,操作系统隐藏了核心数量有限的细节。

内存虚拟化演示
我们运行 mem.c 程序。该程序分配一个堆变量和一个指向该变量的栈指针,然后打印进程ID和指针地址(即变量的虚拟地址)。

关键点在于:即使两个不同的进程有一个变量位于相同的虚拟地址,它们也是完全独立的。每个进程都有自己的地址空间。相同的虚拟地址会被映射到物理内存中的不同页,并包含完全不同的值。这就是内存虚拟化。

并发演示

现代应用程序被编写成多线程的,以利用所有可用的核心。但当多个线程同时运行时,我们需要非常小心它们如何访问同一地址空间中的共享变量,否则会得到错误的结果。

我们查看 threads-v0.c 程序。它创建两个线程,共享一个计数器变量。每个线程循环多次递增该计数器。

预期结果:如果每个线程循环N次,两个线程总共应递增计数器 2*N 次。
实际结果:对于较小的N值(如10, 100),程序可能输出正确结果(20, 200)。但随着N增大(如10000),结果开始出错,且运行次数越多,错误越明显。

问题根源:C语言中的 counter++ 看似是一条指令,但会被编译成三条汇编指令(从内存加载到寄存器、寄存器加1、存回内存)。如果调度器在这些指令之间切换线程,就会导致更新丢失。

解决方案:使用同步原语,如锁。我们查看 threads-v2.c,它在递增计数器前加锁,递增后解锁。这确保了临界区(递增操作)的原子性。现在,即使对于很大的N值,程序也能输出正确结果。

新挑战:使用锁会引入性能开销。无锁版本可能运行很快(如0.001秒),而有锁版本则慢得多(如0.5秒)。因此,如何高效地实现和使用锁是一个重要且有趣的问题。

持久化简介

持久化部分我们将稍后详细讨论。核心问题是如何更新磁盘上的块,使得即使在更新了部分数据但未完成全部更新时发生崩溃,文件系统元数据和目录结构也不会损坏,仍然保持一致。

持久化是一个特别有趣的问题,因为性能因素非常突出。磁盘(即使是固态硬盘)的速度远慢于CPU和内存。因此,我们需要精心设计文件系统以优化设备访问。

学习操作系统的意义

你可能想知道为什么要学习这门课程:

  • 成为操作系统开发者:虽然这可能只是少数人的目标,但如果你有志于此,本课程至关重要。
  • 理解整个系统:理解操作系统如何工作,有助于你理解系统的所有其他层面。无论你是应用程序开发者、编程语言或编译器设计者,了解操作系统都能帮助你理解应用程序的性能表现,并调试系统问题(例如,使用 top 查看进程为何未被调度)。
  • 与体系结构交互:如果你对计算机体系结构感兴趣,你需要理解操作系统将如何使用你提供的抽象功能。
  • 挑战与乐趣:操作系统代码庞大、混合了C和汇编、涉及并发,理解其行为比理解单个应用程序更具挑战性,但也更有趣。

总结与下一步 🚀

本节课我们一起学习了操作系统的定义及其两大核心角色:作为硬件的抽象层(标准库)和作为资源的虚拟化管理者。我们还预览了本课程的三大部分:虚拟化、并发和持久化,并通过演示初步感受了前两部分的概念。

下一步行动
在下次课之前,请务必查看Canvas课程页面,仔细阅读第一个编程项目的说明。你们下周有讨论课,并且第一个项目即将截止。

如果你在等待名单上,并且确定不想选修本课程,请尽快退课,以便其他同学加入。

期待下次与大家见面!

002:进程与CPU虚拟化

在本节课中,我们将学习操作系统如何虚拟化CPU,为应用程序提供易于使用的抽象。我们将深入探讨进程的概念、其生命周期,以及操作系统调度进程、处理系统调用和执行上下文切换的机制。


进程是什么?

上一节我们介绍了操作系统作为资源管理者的角色,本节中我们来看看它如何抽象CPU。这个抽象就是进程

一个程序是存储在磁盘上的静态代码和数据。当你运行这个程序时,它就变成了一个进程。进程是正在执行的程序实例,它包含了代码执行流以及所有相关的动态上下文信息。

进程的上下文包括:

  • 寄存器内容:如通用寄存器、程序计数器(PC/指令指针)、栈指针(SP)。
  • 内存状态:指向其地址空间中代码、堆、栈等区域的指针。
  • 其他系统状态:如打开的文件描述符、网络连接等。

一个程序可以对应多个进程(例如,同时打开多个终端窗口运行同一个程序)。每个进程都拥有自己独立的上下文和地址空间。


进程与线程

进程默认包含一个执行线程。线程(有时称为轻量级进程)是进程内的一个独立执行流。

以下是进程与线程的关键区别:

  • 地址空间:同一进程内的多个线程共享相同的地址空间(堆、全局变量)。不同进程拥有独立的地址空间。
  • 上下文:每个线程拥有自己独立的栈和寄存器状态(如程序计数器、栈指针),但共享进程的其他资源。

为了更直观地理解,请看以下代码示例对比:

示例1:两个独立进程

// 进程A设置 value = 11
// 进程B设置 value = 12
// 两个进程中的 `value` 变量互不影响,初始值都是10,修改后各自独立。

示例2:同一进程内的两个线程

// 线程1设置 shared_value = 13
// 线程2随后读取 shared_value,看到的是13,然后将其改为14
// 两个线程操作的是同一个全局变量 `shared_value`。

进程状态与调度

由于CPU核心数量有限,操作系统需要通过时间分片在多个进程间共享CPU。这是通过快速地在进程间进行上下文切换实现的。

一个进程在其生命周期中会经历几种基本状态:

  • 运行:正在CPU上执行指令。
  • 就绪:已准备好运行,正在等待被调度器选中。
  • 阻塞:因等待I/O等事件而暂停,即使CPU空闲也无法运行。

操作系统维护一个就绪队列,存放所有处于就绪状态的进程。调度器(属于OS)负责决定何时停止当前运行进程(使其进入就绪状态),并选择另一个就绪进程来运行(使其进入运行状态)。我们今天关注运行态和就绪态之间的转换机制。


受限直接执行与系统调用

最有效的虚拟化方式是让进程直接在CPU上运行,即直接执行。但这带来两个问题:

  1. 进程如何执行特权操作(如I/O)?
  2. 如何防止恶意或错误进程独占CPU?

解决方案是受限直接执行。大多数时间让进程直接运行,但通过硬件和操作系统协作,在必要时获得控制权。

问题1的解决:系统调用
硬件提供至少两种运行模式:

  • 用户模式:权限受限,无法执行特权指令。
  • 内核模式:操作系统代码运行于此,可以执行任何指令。

当用户进程需要执行特权操作(如读写文件)时,它必须通过系统调用请求操作系统代为执行。

系统调用流程如下:

  1. 进程执行一条特殊的陷阱指令(如 int 0x64),并事先在约定寄存器中放入系统调用编号(如 read 对应编号6)和参数。
  2. 硬件响应此陷阱:切换到内核模式,跳转到预设的陷阱处理程序
  3. 陷阱处理程序检查系统调用编号,在系统调用表中找到对应的内核函数(如 sys_read)并执行。
  4. 内核函数完成工作后,执行从陷阱返回指令,切换回用户模式,恢复用户进程执行。

关键公式:用户程序 -> trap -> 内核模式 -> 查找处理程序 -> 执行系统调用 -> return-from-trap -> 用户模式。


上下文切换机制

问题2的解决:抢占式调度
为了从非协作进程(如陷入无限循环)手中夺回CPU控制权,操作系统依赖定时器中断

定时器中断流程与上下文切换:

  1. 中断发生:硬件定时器定期(如每1毫秒)触发中断。
  2. 保存用户状态:硬件自动将当前运行进程的用户级寄存器保存到该进程的内核栈中(每个进程有独立的内核栈),然后切换到内核模式,跳转到定时器中断处理程序
  3. 执行OS调度策略:中断处理程序运行操作系统代码。此时,调度器策略决定是否进行上下文切换。
  4. 切换上下文(机制)
    • 如果决定切换,OS首先将当前进程A的内核级寄存器状态保存到其进程控制块中。
    • 然后,OS恢复目标进程B之前保存的寄存器状态(从其PCB),并切换到进程B的内核栈
    • 最后,OS执行 return-from-trap 指令。硬件从B的内核栈中恢复B的用户级寄存器,切换回用户模式,并跳转到B的用户程序继续执行。

关键点:上下文切换需要保存/恢复两套状态——用户级状态(由硬件自动处理)和内核级状态(由OS代码处理)。return-from-trap 指令的返回地址决定了接下来执行哪个进程。


机制与策略的分离

这是一个重要的设计原则:

  • 机制如何实现一个功能。例如,上下文切换的具体步骤、处理陷阱的硬件和软件例程。机制追求高效、低开销。
  • 策略何时以及选择哪个进程运行。例如,调度算法(先来先服务、轮转、优先级等)。策略根据不同的优化目标(吞吐量、响应时间、公平性)制定。

操作系统提供上下文切换的机制,而上层的调度策略则利用这个机制来实现更智能的决策。这种分离使系统更灵活、更易于维护。


本节课中我们一起学习了CPU虚拟化的核心概念。我们明确了进程作为CPU抽象的定义,区分了进程与线程。我们探讨了操作系统通过时间分片上下文切换来共享CPU,并深入分析了实现这一目标的两种关键机制:通过系统调用执行特权操作,以及利用定时器中断实现抢占式调度,从而从用户进程手中夺回控制权。最后,我们理解了机制与策略分离的设计哲学。在接下来的课程中,我们将重点讨论调度策略,即操作系统如何决定运行哪个进程。

003:CPU调度 🧠

在本节课中,我们将要学习操作系统如何决定哪个进程在CPU上运行,即CPU调度策略。我们将探讨不同的调度指标,如周转时间和响应时间,并学习多种调度算法,从简单的先来先服务到现代操作系统使用的多级反馈队列。


课程公告

以下是本周的一些重要通知。

  • 项目1 将于周一晚上截止。请尽早开始,避免在截止前匆忙完成。
  • Piazza答疑:为鼓励大家提前解决问题,助教和同伴导师将不会在周一下午回答任何新问题。你仍然可以查看历史问题和回答其他同学的问题。
  • 项目2 现已发布。它比项目1更具挑战性,涉及修改Xv6内核代码。请在下次讨论课之前查看项目说明,以便提出有针对性的问题。
  • 课程作业:我们发布了一个包含四个问题的简单测验,旨在帮助你练习考试题型。你可以多次尝试,系统会显示正确答案。
  • 期中考试日期更正:考试原定于10月9日(周三),但当天是假期。现更正为10月10日(周四)。如有冲突,可申请参加备用考试。

回顾:进程与状态

在上一讲中,我们介绍了进程的概念,它是操作系统虚拟化CPU的抽象。每个进程都以为自己独占CPU。

我们了解到,操作系统通过时间分片在竞争进程之间进行上下文切换。每个进程可以处于三种状态之一:

  • 运行:正在使用CPU。
  • 就绪:已准备好运行,但调度器尚未选择它。
  • 阻塞/等待:正在等待I/O操作(如磁盘读取或网络数据包)完成,此时无法进行有用的工作。

状态之间的转换由机制控制。例如,当一个运行中的进程发起I/O请求时,它会转入阻塞状态;当I/O完成时,它又回到就绪队列。

本节中,我们将重点关注调度策略:如何从就绪队列中选择下一个要运行的进程。


调度基础:工作负载与指标

在深入具体策略之前,我们需要定义一些基本概念。我们将进程的每个CPU执行片段称为一个“作业”。

一个工作负载由多个作业组成,每个作业可以用两个关键属性来描述:

  • 到达时间:作业进入系统并准备运行的时间。
  • 运行时间:作业完成当前CPU片段所需的时间(不包括I/O时间)。

调度器的任务就是决定就绪队列中哪个作业可以进入运行状态。

不同的系统优化目标不同,因此我们使用不同的指标来衡量调度器的好坏。本节课我们主要关注两个指标:

  1. 周转时间:作业完成时间减去其到达时间。公式为:T_周转 = T_完成 - T_到达。它衡量了作业从提交到完成的总体延迟。系统通常希望最小化平均周转时间。
  2. 响应时间:作业首次被调度的时间减去其到达时间。公式为:T_响应 = T_首次调度 - T_到达。它衡量了系统对交互式请求的反应速度。对于需要快速反馈的交互式作业(如编辑器、命令行),最小化响应时间至关重要。

其他可能关注的指标还包括吞吐量(单位时间完成的作业数)、CPU利用率、上下文切换开销和公平性。


调度策略(全):理想化假设下的算法

为了循序渐进,我们首先在几个简单(但不现实)的假设下分析调度策略,然后逐步放宽假设,使模型更贴近现实。

初始假设如下:

  1. 所有作业运行时间相同。
  2. 所有作业同时到达。
  3. 所有作业只使用CPU(无I/O)。
  4. 调度器预先知道每个作业需要运行多长时间(完美知识)。

先来先服务

最简单的策略是先来先服务。就像现实生活中的排队一样,先到达的作业先运行。

假设有三个作业A、B、C几乎同时到达(A略早于B,B略早于C),每个都需要运行10秒。FCFS的调度顺序是A -> B -> C。

计算平均周转时间:

  • 作业A:完成于10,周转时间 = 10 - 0 = 10
  • 作业B:完成于20,周转时间 = 20 - 0 = 20
  • 作业C:完成于30,周转时间 = 30 - 0 = 30
  • 平均周转时间 = (10 + 20 + 30) / 3 = 20

问题:当作业运行时间差异很大时,FCFS表现很差。如果一个长作业最先到达,它会阻塞后面所有短作业,导致平均周转时间变得很长。

最短作业优先

为了解决FCFS的问题,我们引入最短作业优先策略。调度器总是选择预计运行时间最短的作业先运行。

假设作业A需要100秒,B和C各需10秒。SJF的调度顺序是B -> C -> A。

计算平均周转时间:

  • 作业B:完成于10,周转时间 = 10
  • 作业C:完成于20,周转时间 = 20
  • 作业A:完成于120,周转时间 = 120
  • 平均周转时间 = (10 + 20 + 120) / 3 = 50

相比FCFS的平均110秒,SJF显著改善了周转时间。理论上可以证明,在非抢占式且追求最小平均周转时间的目标下,SJF是最优的。

新问题:饥饿。如果不断有短作业到达,长作业可能永远得不到调度。


调度策略(二):放宽“同时到达”假设

现在,我们放宽第二个假设,允许作业在不同时间到达。考虑以下工作负载:

  • 作业A:在时间0到达,需要运行100秒。
  • 作业B:在时间10到达,需要运行10秒。
  • 作业C:在时间10到达,需要运行10秒。

如果我们使用非抢占式的SJF,在时间0,只有A在就绪队列中,所以A开始运行。即使在时间10有更短的B和C到达,调度器也不能中断A,必须等A在时间100运行结束后,才能从B和C中选择最短的。这导致了很差的性能。

计算平均周转时间:

  • 作业A:完成于100,周转时间 = 100 - 0 = 100
  • 作业B:完成于110,周转时间 = 110 - 10 = 100
  • 作业C:完成于120,周转时间 = 120 - 10 = 110
  • 平均周转时间 ≈ 103.3

显然,当作业不同时到达时,我们需要抢占的能力。

最短完成时间优先

最短完成时间优先(又称最短剩余时间优先)是SJF的抢占式版本。调度器总是调度剩余运行时间最短的作业。

对于上面的工作负载:

  1. 时间0:只有A,调度A。
  2. 时间10:B和C到达。此时A剩余90秒,B和C剩余10秒。调度器会抢占A,选择B或C运行。
  3. 假设先运行B(10秒),然后运行C(10秒)。
  4. 时间30:B和C都已完成,再次调度A(剩余90秒)。

计算平均周转时间:

  • 作业B:完成于20,周转时间 = 20 - 10 = 10
  • 作业C:完成于30,周转时间 = 30 - 10 = 20
  • 作业A:完成于120,周转时间 = 120 - 0 = 120
  • 平均周转时间 = (10 + 20 + 120) / 3 = 50

STCF在作业不同时到达的情况下,依然能取得很好的平均周转时间。

关键点:STCF需要知道作业的剩余运行时间。目前我们仍假设这是已知的(完美知识)。


调度策略(三):处理I/O

现实中的进程并非一直使用CPU,它们会在CPU计算和I/O操作(如读写磁盘、等待网络)之间交替。我们将进程的每次连续CPU使用期称为一个“CPU片段”或“作业”。

一个好的调度器应该能在进程进行I/O等待时,将CPU让给其他就绪的进程。STCF策略能自然地处理这一点:当一个交互式进程(CPU片段很短)结束CPU使用并发起I/O时,它会进入阻塞状态。调度器随后会选择就绪队列中剩余时间最短的作业(可能是另一个交互式进程,也可能是一个计算密集型的长作业)来运行。当I/O完成时,该进程带着新的短CPU片段回到就绪队列,由于其剩余时间短,它通常会抢占当前运行的长作业。

这样,系统既能保证交互式进程的快速响应,又能让计算密集型作业在后台持续运行。


调度策略(四):放弃“完美知识”假设

到目前为止,我们假设调度器预先知道每个作业的运行时间。这在实际中是不可能的。现在,我们放弃这个假设。

时间片轮转调度

当我们不知道作业长短时,一个简单而有效的方法是时间片轮转调度。它为每个就绪作业分配一个小的、固定的CPU时间单元(称为时间片),并以循环的方式依次运行它们。

例如,假设时间片为1秒,有三个作业A(5秒)、B(2秒)、C(2秒)同时到达。RR的调度顺序可能是:A(1秒) -> B(1秒) -> C(1秒) -> A(1秒) -> B(1秒) -> C(1秒) -> A(剩余3秒)...

计算平均周转时间:

  • 作业B:完成于5,周转时间 = 5
  • 作业C:完成于6,周转时间 = 6
  • 作业A:完成于14,周转时间 = 14
  • 平均周转时间 = (5 + 6 + 14) / 3 ≈ 8.33

相比FCFS的12,RR有所改善。但RR并非总是更好:如果所有作业长度相同,RR会让所有作业几乎同时结束,导致平均周转时间变差,而FCFS则能让先到的作业尽早结束。

RR的优势在于响应时间。在RR下,每个作业最多等待(n-1)*时间片就能获得CPU,因此响应时间很短,非常适合交互式作业。

时间片长度的权衡

  • 时间片太短:上下文切换开销过大,缓存效率低。
  • 时间片太长:退化为FCFS,响应时间变差。
    现代系统中,时间片通常在10毫秒到200毫秒之间。

现代调度器:多级反馈队列

在实际的通用操作系统中(如Linux、Windows),作业是混合的:既有需要低延迟的交互式短作业,也有需要高吞吐量的计算密集型长作业。没有单一策略能完美兼顾所有指标。因此,现代系统普遍采用多级反馈队列(或多级优先级队列)调度器。

MLFQ的核心思想是:

  1. 设立多个优先级队列,从高到低排列(例如Q0最高,Q7最低)。
  2. 高优先级队列中的作业可以抢占低优先级队列中的作业
  3. 同一优先级队列内的作业采用RR调度
  4. 根据作业的过去行为(反馈)动态调整其优先级

MLFQ通过一组启发式规则来实现“反馈”:

  • 规则1:如果A的优先级 > B的优先级,则运行A。
  • 规则2:如果A的优先级 = B的优先级,则以RR方式运行A和B。
  • 规则3:新作业进入系统时,置于最高优先级队列(给予优待)。
  • 规则4a:如果作业在时间片用完前主动放弃CPU(如进行I/O),则其优先级保持不变(鼓励I/O密集型交互作业)。
  • 规则4b:如果作业用完了整个时间片(CPU密集型),则降低其优先级(移入低一级队列)。低优先级队列通常有更长的时间片。
  • 规则5:经过一段时间后,将所有作业的优先级提升至最高(或较高)级别,以防止饥饿。

工作流程示例

  1. 一个计算密集型长作业开始于最高优先级,用完时间片后被逐级降到底层队列,在那里获得长时间片安静运行。
  2. 一个交互式短作业进入系统,位于最高优先级。它很快用完短时间片并发起I/O,由于是主动放弃CPU,它保持在最高优先级。I/O结束后,它回到最高优先级队列,能很快再次被调度。
  3. 当底层长作业长时间未获得CPU时(可能因为高优先级作业不断到达),防饥饿规则会将其优先级提升,让它有机会运行。

MLFQ通过这种机制,无需预先知道作业性质,就能自动将交互式作业“筛选”到高优先级队列(获得快速响应),将计算密集型作业“沉降”到低优先级队列(获得高吞吐量),并利用防饥饿规则保证公平性。


其他调度策略:比例份额调度

除了MLFQ,还有其他适用于特定场景的调度思想。例如,在云计算环境中,我们可能希望按照用户支付的费用比例来分配CPU资源。这就是比例份额调度

一个有趣的比例份额调度算法是彩票调度

  • 每个作业根据其重要性获得一定数量的“彩票”。
  • 调度器定期(如每个时间片开始前)举行一次“抽奖”,随机选中一张彩票。
  • 持有中奖彩票的作业获得下一个时间片的运行权。
  • 拥有彩票越多(付费越多)的作业,中奖概率越大,从而获得更多的CPU时间。

彩票调度的优点是实现简单、难以被恶意程序预测和利用、易于实现权限转让。其缺点是分配具有随机性。一种确定性的变体是步长调度,它能精确地按彩票比例分配CPU时间。


总结与回顾

本节课我们一起学习了CPU调度的核心概念与策略。

  • 我们首先明确了调度指标,特别是周转时间响应时间,它们分别针对批处理作业和交互式作业。
  • 在理想化假设下,我们学习了先来先服务最短作业优先及其抢占式版本最短完成时间优先。SJF/STCF在最小化平均周转时间方面是最优的,但需要预知作业运行时间且可能导致饥饿。
  • 当放弃“完美知识”假设后,时间片轮转调度通过公平分配时间片,获得了优异的响应时间,但可能损害周转时间。
  • 现实中的通用操作系统使用多级反馈队列调度器。它通过动态调整作业优先级,巧妙地结合了RR和STCF的优点,既能快速响应交互式请求,又能高效处理后台计算任务,并通过防饥饿机制保证公平性。
  • 我们还简要了解了适用于资源比例分配的彩票调度思想。

最后记住,没有一种调度算法是万能的。最好的策略取决于你的工作负载特征和优化目标。现代操作系统的调度器(如Linux的CFS)都是非常复杂和精妙的工程实现,其核心思想大多源于我们今天讨论的这些基础算法。

请务必关注项目截止日期,并提前开始项目2的探索。我们下周再见!

004:CPU与内存虚拟化

在本节课中,我们将完成CPU虚拟化的讨论,并开始学习操作系统如何虚拟化内存。我们将探讨进程创建、多级反馈队列调度器,以及内存虚拟化的基本概念和早期方法。

CPU虚拟化收尾

上一节我们介绍了CPU调度的基本机制,本节中我们来看看进程创建的具体方式以及多级反馈队列调度器的细节。

进程创建 🧬

操作系统通常通过两种方式创建新进程。

选项一:从零创建
此方法从头构建一个全新的空进程,然后加载指定的可执行文件。以下是其工作流程:

  1. 从文件系统加载指定可执行文件的代码和静态数据到内存。
  2. 创建一个初始为空的调用栈。
  3. 初始化进程控制块(PCB)。
  4. 将PCB放入就绪队列,使其状态看起来像是刚完成一次上下文切换。

此方法的优点是直观且工作量最小。缺点是需要为进程配置大量环境信息(如权限、环境变量、I/O设置),导致参数列表复杂。

选项二:Unix的 forkexec
Unix系统采用复制现有进程(fork)然后替换其内存映像(exec)的方式。

  • fork:创建调用进程的完整副本,包括其地址空间、文件描述符表等。为区分父子进程,内核会修改子进程栈上的返回值(设为0),而父进程获得的返回值是子进程的PID。
  • exec:用指定的新可执行文件覆盖当前进程的代码段和数据段。

此方法的优点是子进程继承了父进程的所有环境配置,设置简单。缺点是复制整个地址空间可能造成浪费,因为随后调用 exec 会丢弃这些副本。现代操作系统采用写时复制等技术来优化。

以下是模拟Shell工作的伪代码,展示了 forkexec 的典型用法:

while (1) {
    // 读取用户命令
    char *cmd = read_command();
    // 创建子进程
    int pid = fork();
    if (pid == 0) {
        // 子进程:执行命令
        exec(cmd);
        // 如果exec返回,说明执行失败
        exit(1);
    } else {
        // 父进程:等待子进程结束(前台进程)
        wait(pid);
    }
}

fork 之后、exec 之前,Shell有机会修改子进程的执行环境,例如实现I/O重定向。

文件描述符与I/O重定向 📁

文件描述符是进程用于访问已打开文件的整数索引。每个进程拥有独立的文件描述符表。默认情况下,标准输入(stdin)、标准输出(stdout)和标准错误(stderr)分别对应描述符0、1、2。

I/O重定向(如 ls > file.txt)就是利用此机制实现的。Shell在调用 exec 执行 ls 前,会进行以下操作:

  1. 关闭子进程的标准输出(文件描述符1)。
  2. 打开目标文件 file.txt。操作系统会分配最低可用的文件描述符,此时就是刚刚释放的1。
  3. 此后,子进程中所有写入标准输出的操作,实际都会写入 file.txt

多级反馈队列调度器回顾 🔄

多级反馈队列(MLFQ)是一种旨在同时优化响应时间和周转时间的调度算法。其核心规则如下:

  1. 系统维护多个优先级队列,优先级从高到低。
  2. 新进程进入最高优先级队列。
  3. 同一优先级队列中的进程采用轮转调度。
  4. 进程用完其所在队列的时间片后,优先级降低(移入下一级队列)。
  5. 进程在时间片用完前主动放弃CPU(如进行I/O操作),则保持其当前优先级。
  6. 经过一段时间后,将所有进程的优先级提升至最高,以防止低优先级进程饥饿。

通过结合优先级调整、时间片划分和防饥饿机制,MLFQ能有效处理交互式短任务和后台长任务。

内存虚拟化入门

现在,我们开始探讨操作系统如何虚拟化内存。目标是为每个进程提供独占整个内存地址空间的假象,同时实现隔离、保护和共享。

为什么需要内存虚拟化? 🤔

早期系统不进行内存虚拟化,单个程序直接访问物理内存并与操作系统共享空间。这带来了严重问题:

  • 缺乏保护:程序错误可能破坏操作系统。
  • 无法支持多道程序:无法同时安全地运行多个进程。

因此,现代操作系统需要实现内存虚拟化,关键目标包括:

  • 透明性:进程无需感知内存被共享。
  • 保护性:进程不能访问其他进程或操作系统的内存。
  • 效率:高效利用物理内存,减少碎片,避免性能开销。
  • 共享:允许进程间有选择地共享部分内存区域。

地址空间 🗺️

进程的地址空间是其对内存的视图,主要包括:

  1. 代码段:存放程序指令,静态且大小固定。
  2. 数据段:存放全局变量和静态变量。
  3. :用于动态内存分配(如 malloc),向高地址增长。
  4. :用于函数调用、局部变量,向低地址增长。

堆和栈被置于地址空间的两端,相向生长,以最大化利用可用地址范围。

动态内存分配有两种主要方式:

  • 栈分配:后进先出(LIFO),管理简单,无碎片,通过一个栈指针即可管理。
  • 堆分配:允许任意顺序的分配和释放,由用户级库(如 malloc 的实现)管理,可能产生外部碎片。

地址转换示例 💻

考虑以下C代码片段及其可能的汇编指令:

int x = 10;
x = x + 3;

对应的汇编指令可能如下(地址为虚拟地址):

100: mov 0x8(%ebp), %eax  // 从栈地址(ebp+8)加载x到寄存器eax
105: add $0x3, %eax       // eax加3
108: mov %eax, 0x8(%ebp)  // 将eax存回栈地址(ebp+8)

执行这三条指令共涉及5次内存访问

  1. 取址100处的指令。
  2. 执行该指令,从虚拟地址 ebp+8 加载数据。
  3. 取址105处的指令。
  4. 执行该指令(无内存访问,仅寄存器操作)。
  5. 取址108处的指令并执行,将数据存回虚拟地址 ebp+8

早期内存虚拟化方法 🧱

1. 时分复用(交换)
将整个进程的内存映像在物理内存和磁盘之间来回换入换出。虽然实现了隔离,但性能极差,因为复制大量数据速度很慢。

2. 静态重定位
在进程运行前,由操作系统或加载器重写其可执行文件,将所有内存地址修改为预定的物理地址。虽然运行效率高,但缺乏保护(进程仍可生成任意地址),且一旦放置后无法移动。

动态重定位:基址-界限寄存器 ⚙️

为克服上述方法的缺点,需要硬件支持。内存管理单元(MMU)是负责将进程生成的逻辑(虚拟)地址转换为物理地址的硬件。

最简单的MMU支持是基址-界限寄存器

  • 基址寄存器:存放进程地址空间在物理内存中的起始地址。
  • 界限寄存器:存放进程地址空间的大小(或最大有效偏移量)。
  • 转换过程物理地址 = 逻辑地址 + 基址。在转换前,硬件会检查 逻辑地址 < 界限,若越界则触发异常(段错误),由操作系统处理(通常终止进程)。

操作系统的职责

  • 决定进程在物理内存中的放置位置,并设置其基址和界限值。
  • 在上下文切换时,保存旧进程的基址/界限值到其PCB,并将新进程的基址/界限值加载到MMU寄存器中。

优点

  • 支持动态重定位(可移动进程)。
  • 实现简单、高效。
  • 提供了基本的地址空间隔离。

缺点

  • 地址空间必须连续:需要为堆和栈之间的可能增长预留大量连续物理内存,即使它们实际很小,导致内部碎片严重。
  • 难以实现灵活共享:进程间只能共享整个地址空间(类似线程),无法共享特定部分(如代码库)。

总结

本节课中我们一起学习了:

  1. 进程创建的两种模式:从零创建和Unix的 fork/exec 模型,以及后者如何利用文件描述符实现I/O重定向。
  2. 多级反馈队列调度器的核心规则与行为。
  3. 内存虚拟化的目标:透明性、保护、效率、共享。
  4. 进程地址空间的布局:代码、数据、堆、栈。
  5. 早期低效的内存虚拟化方法:时分复用静态重定位
  6. 利用硬件支持的动态重定位基础方案:基址-界限寄存器。它通过简单的地址转换和越界检查实现了基本隔离,但仍存在需要连续分配和内部碎片的问题。

在接下来的课程中,我们将探索更先进的内存虚拟化技术,如分段、分页以及它们的组合,以解决基址-界限模型的局限性。

005:分段与分页 🧠

在本节课中,我们将继续学习内存虚拟化。我们将深入探讨分段和分页这两种核心的内存管理技术,分析各自的优缺点,并详细了解分页机制中页表的工作原理和结构。


课程回顾 📚

上一节我们介绍了内存虚拟化的基本概念。用户进程看到的是一块逻辑地址空间,其中包含代码段、堆和栈。操作系统的任务是将这些逻辑地址映射到实际的物理内存中。我们已经探讨了几种简单的映射方法。

已探讨的映射方法

以下是几种我们已了解的内存映射方法:

  1. 时间共享:一次只将一个进程完整地加载到物理内存中运行,然后换出到磁盘,再换入另一个进程。这种方法缺乏保护。
  2. 静态重定位:在程序加载时,由链接器或加载器修改程序中的所有地址,使其指向物理内存位置。这种方法工作量大且不灵活。
  3. 动态重定位(基址寄存器):通过硬件(MMU)将一个基址寄存器加到逻辑地址上来得到物理地址。这种方法简单,但无法防止进程访问超出其地址空间的内存。
  4. 动态重定位(基址+界限寄存器):在基址寄存器的基础上增加一个界限寄存器,用于检查逻辑地址是否越界。这提供了基本的保护,但要求整个地址空间在物理内存中连续分配,可能导致外部碎片


分段机制 🧩

为了解决基址+界限机制中地址空间必须连续分配的问题,我们引入了分段

分段的核心思想

分段的思想非常直观。我们不再将整个地址空间视为一个整体,而是将其划分为几个逻辑段(如代码段、堆段、栈段)。每个段可以独立地、非连续地放置在物理内存的不同位置。

每个段都有自己的一对基址界限寄存器。这样,堆和栈可以独立地增长或缩小,而无需预先知道其最终大小。此外,我们还可以为不同的段设置不同的保护权限(例如,代码段设置为只读)。

地址转换过程

在分段机制下,一个逻辑地址由两部分组成:段标识符段内偏移量。通常,逻辑地址的高位用于指定段。

假设我们有一个14位的逻辑地址,其中高2位用于选择4个段之一,低12位是段内偏移。

转换公式
物理地址 = 段基址 + 段内偏移量

在访问前,硬件会检查偏移量是否超出段的界限,以及当前操作是否符合段的权限(如读、写)。如果检查失败,会触发一个错误(段错误)。

分段的优缺点

优点

  • 允许稀疏使用地址空间,更高效地利用物理内存。
  • 支持不同段独立增长。
  • 可为不同段设置不同的访问权限。
  • 便于在进程间共享特定的段(如代码库)。

缺点

  • 每个段自身在物理内存中仍需连续存储。一个大段可能因为找不到足够大的连续空闲区域而无法分配,这仍然是外部碎片问题。

分页机制 📄

为了彻底解决外部碎片问题,我们引入了分页机制。

分页的核心思想

分页将逻辑地址空间和物理地址空间都划分为固定大小的单元,分别称为。页的大小通常是2的幂(如4KB)。关键点在于,一个逻辑页可以映射到物理内存中的任何一个物理帧,它们不需要连续。

地址转换过程

在分页机制下,逻辑地址被分为两部分:虚拟页号页内偏移

转换公式
物理地址 = (物理帧号 * 页大小) + 页内偏移量

虚拟页号到物理帧号的映射关系,存储在一个称为页表的数据结构中。每个进程都有自己独立的页表。

页表详解

最简单的页表是一个线性数组。数组的索引是虚拟页号,数组的内容是对应的页表项。一个页表项通常包含:

  • 物理帧号:最重要的部分,指出页存放在物理内存的哪个帧。
  • 有效位:指示该页是否在物理内存中。
  • 保护位:指示页的读、写、执行权限。
  • 脏位:指示页的内容是否被修改过。
  • 其他位:如访问位、缓存禁用位等。

操作系统通过一个页表基址寄存器来告知硬件当前运行进程的页表在物理内存中的位置。

分页的性能问题

分页引入了一个显著的性能开销:每次内存访问现在需要两次物理内存访问

  1. 第一次访问:查询页表,获取物理帧号。
  2. 第二次访问:使用得到的物理地址访问实际的数据或指令。

这会使程序运行速度理论上减慢一倍。我们将在后续课程中通过转换后备缓冲器来解决这个问题。

分页的存储开销问题

让我们计算一下页表可能有多大。

  • 假设:32位地址空间,4KB页大小,每个页表项占4字节。
  • 计算:
    • 页内偏移需要 log2(4KB) = 12 位。
    • 虚拟页号有 32 - 12 = 20 位。
    • 虚拟页数量为 2^20 = 1,048,576 个。
    • 页表大小 = 页表项数 * 每个项大小 = 1,048,576 * 4 字节 = 4 MB

这意味着每个进程的页表就可能占用4MB连续的物理内存!这显然不切实际。我们将在下节课探讨如何通过多级页表等技术来解决页表过大的问题。

分页的优缺点

优点

  • 完全消除了外部碎片:任何空闲的物理帧都可以使用。
  • 分配和释放内存非常高效(只需操作位图)。
  • 易于与磁盘交换(换页)配合工作,因为页大小通常与磁盘块大小匹配。

缺点

  • 可能产生内部碎片(一个页未被完全利用)。
  • 地址转换慢(需要额外的内存访问)。
  • 简单的线性页表占用内存过大。

本节总结 🎯

本节课我们一起深入学习了内存虚拟化的两种关键技术:分段和分页。

  • 分段将地址空间按逻辑单元划分,提高了灵活性和保护性,但未能解决外部碎片问题。
  • 分页使用固定大小的页,彻底消除了外部碎片,是现代操作系统的基础。然而,它带来了地址转换的性能开销和页表存储开销两大新挑战。

在接下来的课程中,我们将探索如何优化分页机制,例如使用TLB来加速地址转换,以及使用多级页表来减少页表的内存占用。

006:虚拟化内存与TLB 🚀

在本节课中,我们将继续探讨如何虚拟化内存。我们已经学习了多种虚拟化内存的方法,如静态重定位、分时、基址寄存器、基址-界限保护、分段和分页。今天,我们将重点讨论在分页基础上引入TLB,以实现快速的内存地址转换。


课程公告与回顾 📢

上一节我们介绍了分页机制。分页将虚拟地址空间划分为固定大小的,并将这些页映射到物理内存的任何位置。我们使用页表来记录这些映射关系。常见的页大小为4KB,且必须是2的幂次方。

然而,分页机制存在一个性能问题:每次内存访问都需要先访问页表(存储在物理内存中),这导致了一次额外的、较慢的内存访问。本节我们将解决这个问题。


分页地址转换流程 🔄

让我们回顾一下使用简单线性页表进行地址转换的步骤。假设我们有一个虚拟地址,需要找到其对应的物理地址。

  1. 提取虚拟页号:从虚拟地址中提取出虚拟页号。这通常是通过查看地址的高位比特完成的。
  2. 计算页表项地址:根据页表基址寄存器的值和虚拟页号,计算出对应页表项在物理内存中的地址。公式为:页表项地址 = 页表基址 + (虚拟页号 * 页表项大小)
  3. 访问页表项:从计算出的物理地址读取页表项。这一步需要访问物理内存,速度较慢
  4. 提取物理页号:从页表项中提取出物理页号。
  5. 构建物理地址:将物理页号与虚拟地址中的页内偏移量拼接,形成最终的物理地址。
  6. 访问目标数据:使用构建好的物理地址访问实际的数据。这一步同样需要访问物理内存

在上述步骤中,步骤3(访问页表)是主要的性能瓶颈。


引入TLB:地址转换旁路缓冲器 ⚡

为了解决访问页表慢的问题,我们引入TLB。TLB是CPU内部的一个小型、快速的缓存,专门用于存储最近使用过的虚拟页号到物理页号的映射关系。

TLB的工作原理如下:

  • TLB查询:当需要转换一个虚拟地址时,CPU首先将虚拟页号发送到TLB进行查询。
  • TLB命中:如果在TLB中找到匹配的条目,则直接获得物理页号,无需访问内存中的页表。这非常快。
  • TLB缺失:如果在TLB中未找到匹配的条目,则称为TLB缺失。此时,CPU必须执行上述完整的页表查找流程(步骤1-6)。在从页表获得映射关系后,会将该映射存入TLB中,替换掉一个旧的条目(根据替换策略,如LRU或随机)。

TLB条目通常包含:

  • 标签:虚拟页号。
  • 数据:对应的物理页号。
  • 有效位:指示该条目是否有效。
  • 保护位:访问权限信息。
  • 地址空间标识符:用于支持多进程,下文会详细说明。

TLB性能与访问模式 📈

TLB的性能高度依赖于程序的内存访问模式

1. 顺序访问(空间局部性)
考虑一个顺序访问数组的程序:

for (i = 0; i < 2048; i++) {
    sum += A[i]; // 假设数组A起始于虚拟地址0x3000,int为4字节
}

访问的虚拟地址序列为:0x3000, 0x3004, 0x3008, ...。假设页大小为4KB,则每1024次访问才会跨越到一个新页。

  • TLB行为:首次访问一个新页时,会发生TLB缺失并加载该页的映射。随后对该页的1023次访问都会TLB命中
  • 缺失率:极低。对于这个2048次的循环,只会有2次缺失(访问两个不同的页),缺失率 = 2 / 2048。

2. 伪随机访问(时间局部性)
考虑一个先随机访问数组,再重复同样随机序列的程序:

srand(1234); // 固定随机种子
for (i = 0; i < N; i++) {
    index = rand() % N;
    temp += A[index];
}
// ... 稍后再次执行相同的循环
  • 第一次循环:会产生大量TLB缺失,因为访问的页是随机的。
  • 第二次循环:由于访问序列完全相同,且TLB中可能还缓存着第一次循环的映射,因此会产生大量TLB命中。这体现了时间局部性——最近访问过的数据很可能再次被访问。

3. 步长访问(对TLB不友好)
考虑以固定的大步长(如4KB)访问内存,每次访问都落在不同的页上,且不重复访问同一页。

  • TLB行为:每次访问都是一个新的页,导致每次都是TLB缺失。即使TLB有多个条目,在访问序列长度超过TLB容量后,旧的映射会被挤出,无法被再次利用。
  • 替换策略的影响:对于这种“工作集大于TLB容量”的流式访问,传统的LRU策略效果很差,有时随机替换策略反而可能更好。

多进程与TLB管理 🔄

TLB中存储的映射是进程相关的。进程A的虚拟页号1可能映射到物理页号5,而进程B的虚拟页号1可能映射到物理页号9。因此,在多进程环境下,我们需要管理TLB内容。

解决方案1:上下文切换时清空TLB

  • 最简单的方法是在每次进行进程上下文切换时,清空整个TLB
  • 缺点:新进程开始运行时,TLB是空的,会导致大量初始缺失,增加了上下文切换的开销

解决方案2:为TLB条目添加地址空间标识符

  • 现代CPU通常采用这种方法。每个TLB条目除了虚拟页号标签外,还包含一个地址空间标识符
  • ASID是一个较小的数字,用于区分不同进程的地址空间。
  • 上下文切换时,操作系统只需将CPU的当前ASID寄存器改为新进程的ASID即可,无需清空TLB
  • TLB查询时,需要同时匹配虚拟页号和ASID。这样,进程A的条目就不会被进程B的错误匹配。

即使使用ASID,上下文切换仍会带来性能影响,因为新进程的活跃页映射可能不在TLB中,需要逐渐填充,这被称为TLB预热


总结与拓展 🎯

本节课我们一起学习了:

  1. 回顾了分页机制及其在地址转换中的性能瓶颈——额外的页表内存访问。
  2. 引入了TLB作为页表的高速缓存,用于加速地址转换。TLB命中则快速返回物理页号;TLB缺失则需查找页表并更新TLB。
  3. 分析了TLB性能与程序访问模式的关系。具有良好空间局部性(顺序访问)或时间局部性(重复访问)的程序TLB命中率高。步长访问等模式对TLB不友好。
  4. 探讨了多进程下的TLB管理。通过使用地址空间标识符,可以在上下文切换时保留TLB内容,减少性能开销。

拓展知识:

  • 现代系统通常有独立的指令TLB数据TLB
  • 为了提升TLB的有效覆盖范围,可以使用大页,例如2MB或1GB的页,这样单条TLB条目就能映射更大的内存区域。

在下一讲中,我们将继续探讨如何解决页表自身可能过大而占用过多内存的问题。

007:更小的页表 🗂️

在本节课中,我们将继续学习内存虚拟化,重点关注如何减小页表的大小。页表是内存管理的关键数据结构,但其本身可能占用大量内存。我们将探讨几种优化页表大小的方法,包括分段页表、多级页表和反向页表。


回顾:TLB与地址转换

上一节我们介绍了转换后备缓冲器(TLB),它通过缓存最近使用的页表项来加速地址转换。本节中,我们来看看页表本身存在的另一个问题:它们可能非常庞大。

地址转换的基本步骤如下:

  1. 从虚拟地址中提取虚拟页号(VPN)。
  2. 使用页表基址寄存器(PTBR)和VPN计算页表项(PTE)的地址:PTE地址 = PTBR + (VPN * PTE大小)
  3. 从内存中读取PTE,获取物理页号(PPN)。
  4. 将PPN与页内偏移量组合,形成物理地址。

虽然TLB解决了每次转换都需要访问内存(步骤3)的性能问题,但页表本身在内存中占用大量空间的问题依然存在。


页表为何如此庞大?🤔

页表庞大的根本原因在于其简单的线性结构。对于一个进程,其虚拟地址空间通常包含代码段、堆和栈,它们之间可能存在大量未使用的“空洞”。然而,在简单的线性页表中,即使这些区域无效,也需要为它们预留页表项,以确保地址算术正确。这导致了内存的浪费。

我们的目标是找到一种数据结构,能够避免为这些未使用的虚拟地址空间分配页表项。


方法一:分段页表

解决稀疏地址空间问题的一个思路是借鉴分段的思想。我们可以为地址空间的不同部分(如代码、堆、栈)分别维护页表,而不是使用一个巨大的线性页表。

以下是其工作原理:

  • 虚拟地址被划分为:段号、段内虚拟页号(VPN)和页内偏移量。
  • 系统为每个进程维护一个段表。段表中的每一项包含对应段的页表基址和界限(有效页表项的数量)。
  • 进行地址转换时,首先使用段号在段表中找到对应段的页表基址。然后,使用段内VPN在该页表中查找,获得物理页号(PPN)。

分段页表示例

假设虚拟地址格式为:4位段号,8位VPN,12位偏移量(4KB页)。物理内存布局如下:

段表(每个进程一个)

段号 页表基址(物理) 界限(项数) 权限
0 0x200 0x0F R/W
1 0x300 0x0A R
2 0x001 0x10 R/W

物理地址 0x200 处的页表(对应段0)

VPN PPN 有效位
0x00 0x10 1
0x01 0x23 1
0x02 0x04 1
... ... ...

转换虚拟地址 0x02070

  1. 拆分地址:段号=0, VPN=0x02, 偏移量=0x070。
  2. 查段表:段0的页表基址为0x200,权限为R/W。
  3. 查页表:在基址0x200的页表中,找到VPN 0x02对应的项,得到PPN 0x04。
  4. 组合物理地址:物理地址 = (PPN << 12) | 偏移量 = 0x04070

这种方法优点在于,只为实际使用的段分配页表,避免了中间“空洞”造成的浪费。同时,它保留了分页的所有优点(无外部碎片,页面可交换)。


方法二:多级页表 🌳

分段页表在地址空间非常稀疏时效果很好。但是,如果某个段(如堆)本身很大且被完全使用,其页表仍然会很大。为了解决这个问题,我们引入多级页表的核心思想:将页表本身进行分页

多级页表将单一大页表拆分成多个较小的页表,并将它们组织成树状结构。常用的形式是两级页表:

  • 页目录:第一级表。每个进程有一个页目录,其每一项指向一个第二级页表。
  • 页表:第二级表。每个第二级页表恰好占用一个物理页帧,其中包含实际的虚拟页到物理页的映射。

地址转换与多级页表

虚拟地址被重新解释为:页目录索引(PDX)页表索引(PTX)页内偏移量

转换步骤:

  1. 使用页目录基址寄存器(PDBR)和PDX,找到第二级页表的基址。
  2. 使用该基址和PTX,在第二级页表中找到页表项(PTE),获得物理页号(PPN)。
  3. 将PPN与偏移量组合成物理地址。

关键优势在于,如果虚拟地址空间的某个区域完全未使用,那么在页目录中对应的项可以标记为无效,从而完全不需要分配第二级页表。这极大地节省了内存。

多级页表示例

假设虚拟地址格式:4位PDX,4位PTX,12位偏移量。已知当前进程页目录基址(PDBR)对应的物理页内容,以及相关第二级页表内容。

转换虚拟地址 0x1350

  1. 拆分地址:PDX=0x1, PTX=0x3, 偏移量=0x350。
  2. 查页目录:在PDBR指向的页中,找到索引0x1的项,假设它告诉我们第二级页表位于物理页0x92。
  3. 查页表:在物理页0x92中,找到索引0x3的项,假设它给出PPN=0x55。
  4. 组合物理地址:物理地址 = 0x55350

现代系统(如64位地址空间)通常使用三级或四级页表,以确保每一级页表都能装在一个物理页内。计算每级索引位数的方法是:索引位数 = log2(页大小 / PTE大小)。例如,对于4KB页和8字节PTE,每级页表可有512项,因此索引需要9位。


TLB与多级页表的协同

多级页表引入了一个代价:一次地址转换可能需要进行多次内存访问(每一级一次)。这使得TLB变得更加重要。

工作流程如下:

  1. 首先,用完整的VPN(所有级索引位的组合)查询TLB。
  2. 如果TLB命中,则直接获得PPN,无需访问任何级页表。
  3. 如果TLB未命中,则必须进行“页表遍历”,依次查询每一级页表,最终找到PPN,并将其载入TLB。

因此,TLB的命中率对于系统性能至关重要,它能避免昂贵的多级页表遍历。


方法三:反向页表

另一种思路是改变数据结构的核心。传统页表以虚拟地址为索引,条目数与虚拟页数相关。反向页表则以物理地址为索引,条目数等于物理页帧的数量。

在反向页表中,每个条目记录占用该物理页帧的虚拟页号(VPN)和进程ID。进行地址转换时,需要根据目标VPN和进程ID,搜索反向页表以找到对应的物理页帧。显然,线性搜索效率极低。

因此,实际实现通常采用哈希表。对(VPN, ASID)进行哈希,得到一个索引,指向哈希桶。在桶中查找匹配的条目,即可获得物理页帧号。这种方法将页表大小与物理内存大小而非虚拟地址空间大小绑定,适用于某些特定场景。


总结

本节课我们一起学习了如何设计更紧凑的页表结构。

  • 我们首先分析了简单线性页表占用内存过大的问题。
  • 接着,探讨了分段页表,它通过为地址空间的不同段分别维护页表来节省空间。
  • 然后,深入讲解了多级页表,这是现代操作系统的通用解决方案,通过将页表分页并组织成层次结构,极大地减少了内存占用,并保持了灵活性。
  • 最后,简要介绍了反向页表这一替代设计思路。

所有这些方法都与TLB协同工作,TLB负责加速频繁的地址转换,而这些页表结构则负责高效地管理映射关系。下一讲,我们将探讨当物理内存不足时,操作系统如何通过交换和分页技术来管理内存。

008:内存交换

在本节课中,我们将要学习虚拟内存的最后一个核心概念:交换。当多个进程的地址空间总和,或者单个进程的地址空间,超过了系统物理内存的总量时,操作系统如何管理内存?我们将探讨支持这一功能的机制,以及操作系统用于决定哪些页面应保留在内存中的策略。

课程公告与回顾

在深入新内容之前,我们先回顾一下重要的课程安排和之前的知识点。

项目与期中考试安排

项目3将于本周四截止。助教们已经发布了一些示例测试供大家运行。请注意,项目的核心目标是实现 forkexec、重定向以及后台作业处理等功能。虽然测试中可能包含一些错误处理案例,但请不要过度纠结于极端情况的处理。我们的隐藏测试将主要考察主流功能。

期中考试将于下周四晚上举行,地点分为两个教室。明天的讨论课将进行期中复习,助教会带领大家回顾已发布的两份样卷。在考试前的剩余课程安排如下:今天(周二)和周四完成虚拟内存的讲解;下周二进行课程总复习;下周四讲解并发和锁(这部分内容属于期中考试2的范围)。期中考试为闭卷考试,形式为选择题。

虚拟地址翻译回顾

上一节我们详细介绍了虚拟地址到物理地址的翻译过程。每次程序进行内存引用(取指令或加载/存储数据)时,都需要进行此翻译。其核心步骤如下:

  1. 从虚拟地址中提取虚拟页号。
  2. 硬件并行查询TLB。
  3. 若TLB命中,则获得物理页框号。
  4. 若TLB未命中,则硬件或操作系统需要遍历页表。
  5. 计算并查找页表项,获取物理页框号(对于多级页表,此过程可能重复多次)。
  6. 将新的VPN到PPN的映射关系替换到TLB中。
  7. 将物理页框号与原始虚拟地址中的偏移量组合,得到最终的物理地址。

TLB未命中需要访问内存,有一定开销,但远低于我们今天要讨论的访问磁盘的开销。

多级页表设计

我们使用多级页表的主要目标是让每一级页表的大小恰好能放入一个物理页框中。这样做有两个好处:一是对于无效的页表项,可以完全不分配对应的页表部分,节省空间;二是这些页表可以像普通用户页面一样,被灵活地放置在物理内存的任何位置。

关键的计算在于根据页大小和页表项大小,确定每一级页表索引使用的位数。例如,如果页大小为4KB(偏移量12位),每个页表项为4字节,那么一个页框可以容纳 4096 / 4 = 1024 个页表项,这需要10位(2^10 = 1024)来索引。我们持续用10位来划分虚拟地址,直到最外层的页目录,剩余不足10位的部分即作为最外层页表的索引。

交换:机制与策略

现在,让我们进入今天的核心主题:交换。我们的目标是支持运行总地址空间大于物理内存的多个进程,或者单个大地址空间进程。这不仅要在功能上正确,还要在性能上可行。

交换的基本思想

操作系统需要为每个进程营造一个假象:它们拥有与其虚拟地址空间一样大(甚至和磁盘一样大)的物理内存。为了实现这个假象,我们需要程序访问具有局部性,并且硬件提供一些记账支持。

现代程序通常会链接许多庞大的标准库,但实际可能只调用其中的少数例程。因此,操作系统可以采用按需调页的策略:进程启动时,只将最可能用到的代码(如主函数和常用库)加载到物理内存中。当进程访问到尚未加载的虚拟页时,就会触发一个缺页异常。操作系统会捕获这个异常,将所需的页面从磁盘读入物理内存,这个过程称为调入。反之,当需要腾出空间时,操作系统会将物理内存中的页面写回磁盘,这个过程称为调出

由于磁盘访问非常缓慢(约10毫秒),为了获得良好性能,程序必须展现出良好的空间局部性(倾向于访问相邻地址)和时间局部性(倾向于重复访问最近访问过的地址)。研究表明,程序90%的时间通常只运行在10%的代码上,这为交换策略的有效性提供了基础。

内存层次结构

系统的内存层次结构体现了速度、容量和成本的权衡:

  • 寄存器:最快,但容量最小,成本最高。
  • 高速缓存:速度、容量和成本介于寄存器和主存之间。
  • 主存:作为高速缓存的后备存储。
  • 磁盘:容量大,成本低,但速度非常慢。

数据在这些层次间移动的决策者不同:编译器决定什么数据放入寄存器;硬件决定什么数据放入高速缓存;而操作系统则负责决定什么页面应保留在主存中,什么应交换到磁盘上。

缺页处理机制

为了实现交换,页表项中需要增加一些状态位。除了我们之前见过的有效位、保护位和物理页框号,现在还需要:

  • 存在位:指示该页当前是否在物理内存中。若为0,表示该页目前在磁盘上。
  • 脏位:指示该页自调入内存后是否被修改过。如果被修改过(脏),在换出时就必须写回磁盘;如果未被修改(干净),则可以直接丢弃,因为磁盘上有相同副本。

当进程访问一个存在位为0的页面时,会触发缺页异常,陷入操作系统。操作系统需要执行以下步骤:

  1. 找到一个空闲的物理页框。如果没有,则需选择一个牺牲页换出。
  2. 如果牺牲页的脏位为1,则将其内容写回磁盘。
  3. 从磁盘读入所需页面到刚腾出的物理页框中。
  4. 更新页表项:设置存在位为1,更新物理页框号,并重置脏位和引用位。
  5. 重新执行触发缺页的指令。

由于磁盘操作非常耗时(约10毫秒),在发生缺页时,当前进程会被阻塞,操作系统调度器会切换到另一个就绪进程运行。当磁盘I/O完成后,原进程会被标记为就绪状态,等待再次被调度。

页面置换策略

缺页处理成本高昂,因此减少缺页次数至关重要。操作系统需要制定两个关键策略:页面选择(何时调入页面)和页面置换(选择哪个页面作为牺牲页换出)。

页面选择策略

  • 按需调页:仅在进程实际访问某个页面时才将其调入内存。这是最懒惰的策略,但可能导致进程启动初期频繁缺页,性能出现“卡顿”。
  • 预调页:预测进程将要访问的页面,并提前将其调入内存。例如,如果检测到进程正在顺序访问数组,可以预取接下来的几个页面。预调页的风险在于预测错误会导致不必要的磁盘I/O,并可能换出真正需要的页面。现代系统通常以按需调页为基础,结合对顺序访问模式的检测进行适度的预调页。程序员也可以通过 madvise 系统调用向操作系统提供访问模式的提示。

页面置换算法

接下来,我们重点讨论如何选择牺牲页。假设我们有一个“先知”能预知未来的页面访问序列,那么最优算法总是选择在未来最长时间内不会被访问,或者永远不会再被访问的页面进行置换。这给出了缺页次数的下限,但在现实中无法实现。

实践中常用的启发式算法包括:

先进先出算法

FIFO算法选择在内存中驻留时间最长的页面进行置换。它实现简单,但性能往往不佳,因为它忽略了页面的访问历史。更反直觉的是,在某些特定访问序列下,增加物理内存反而可能导致FIFO的缺页次数增加,这种现象称为Belady异常。

最近最少使用算法

LRU算法基于“过去是未来的良好预测”这一局部性原理,选择最久未被访问的页面进行置换。它的性能通常接近最优算法。然而,完美实现LRU的成本很高(无论是维护精确的访问时间链表还是硬件时间戳)。

时钟算法

由于完美LRU难以实现,现代操作系统广泛使用其近似算法——时钟算法

  • 它为每个页设置一个使用位。当页面被访问时,硬件自动将该位置1。
  • 操作系统维护一个类似钟表指针的循环链表,指向下一个候选牺牲页。
  • 当需要置换页面时,操作系统检查指针指向的页面的使用位:
    • 如果为1,则将其清0,指针移向下一个页面。
    • 如果为0,则选择该页面作为牺牲页。
  • 该算法实质上是寻找一个最近未被访问过的页面(使用位为0),是LRU的一种高效近似。

实际系统中的时钟算法还会有更多优化,例如:

  • 维护一个空闲页框列表,提前批量换出页面以提高磁盘I/O效率。
  • 在扫描时优先选择干净页(脏位为0)作为牺牲页,避免昂贵的写磁盘操作。
  • 使用多个位(如访问计数)来更精确地区分页面的“热度”。

其他算法

还有一些算法尝试结合访问频率和访问新近度,例如LRU-K算法(考察倒数第K次访问的时间)或使用两个队列分别管理新页面和频繁访问的页面。这些算法更复杂,常见于数据库等对缓存管理要求极高的系统。

工作集与系统颠簸

无论算法多么精妙,一个根本前提是:进程的工作集(即其在短时间内活跃访问的页面集合)必须能够装入物理内存。如果物理内存不足以容纳工作集,进程将陷入频繁的缺页和换页中,这种现象称为颠簸。此时,系统大部分时间都花在磁盘I/O上,CPU利用率急剧下降,性能会变得极差。

因此,解决性能问题最直接有效的方法往往是增加物理内存。

总结与思考

本节课中,我们一起学习了操作系统如何处理大于物理内存的地址空间,即交换技术。我们首先回顾了虚拟地址翻译和多级页表。然后,深入探讨了交换的机制,包括页表项中的存在位和脏位,以及缺页异常的处理流程。接着,我们分析了多种页面置换策略,从理论上的最优算法,到简单的FIFO,再到基于局部性的LRU及其高效近似——时钟算法。最后,我们指出了工作集概念的重要性以及系统颠簸的成因。

留给大家一个思考题:假设系统中有多个进程,它们交替执行CPU计算和I/O操作,并且每个进程都需要相当数量的内存。随着系统中进程数量的增加,系统的吞吐量(每秒完成的作业数)会如何变化?是持续上升,达到峰值后下降,还是会出现其他情况?我们将在下节课讨论并发时揭晓答案。

009:P9 期中考试复习 🧠

在本节课中,我们将回顾期中考试所涵盖的核心概念,包括进程管理、内存虚拟化(特别是分页机制)以及相关的硬件支持。我们将通过解答常见问题、分析示例和回顾关键机制来帮助你准备考试。


课程公告与项目更新

上一节我们介绍了课程的整体结构,本节中我们来看看近期的项目安排和考试须知。

首先,关于项目进度:

  • P2 已评分,成绩可在 Canvas 和提交目录中查看。如有重大评分问题,请与助教沟通。
  • P3 已提交。请注意,教学服务器偶尔会在截止时间附近遭遇拒绝服务攻击,导致提交困难。建议不要等到最后一刻才提交作业。使用 scp -P 命令可以保留文件的时间戳,有助于在出现问题时验证你的工作。
  • P4 已发布。你将在 Xv6 操作系统中实现一个支持不同时间片长度和优先级的调度器。该项目工作量较大,强烈建议与项目伙伴合作完成。如果你没有伙伴,可以通过今天发布的表单进行匹配。

关于期中考试:

  • 考试时间为周四晚上,时长两小时,在不同教室进行(根据讨论课分区)。
  • 考试形式为机读答题卡。请务必携带 2B 铅笔学生证(或牢记学号)。
  • 正确填写答题卡至关重要:需要填写姓名并涂写对应圆圈,以及填写并涂写学号。考试可能包含版本代码,也需按要求涂写。

考试范围涵盖截至上周四讲座的所有材料,包括教科书内容、测验和项目。项目相关的问题将是高层次的概念性问题,例如理解 Shell 或 Fork/Exec 的工作原理。


核心概念回顾:虚拟化

在操作系统课程中,我们核心学习了虚拟化——操作系统如何为用户进程提供它们独占整个机器的抽象。这主要涉及两个方面:

  1. CPU 虚拟化:通过进程抽象,让每个执行实体感觉自己在独占CPU。
  2. 内存虚拟化:通过地址空间抽象,让每个进程感觉自己在独占物理内存。

对于每种抽象,我们都讨论了机制策略

  • 机制是实现抽象的低级“如何做”,例如上下文切换(保存/恢复寄存器状态)。
  • 策略是在机制之上做出的智能决策,例如选择接下来调度哪个进程。

在内存虚拟化方面,我们探讨了多种实现方式,从简单的静态重定位(无硬件支持),到动态重定位(基址-界限寄存器),再到更复杂的分段分页机制。


进程启动流程剖析

以下是启动一个用户进程时涉及的关键步骤。请思考每个步骤主要由硬件操作系统还是用户进程自身负责:

  1. 创建进程控制块并将其加入进程列表。
  2. 为进程分配内存。
  3. 将程序加载到内存中。
  4. 设置用户栈。
  5. 填充内核栈。
  6. 执行“从陷阱返回”指令。
  7. 从内核栈恢复寄存器。
  8. 从内核模式切换到用户模式。
  9. 跳转到 main 函数。
  10. 用户进程在 main 中运行。
  11. 调用系统调用库包装函数。
  12. 执行陷阱指令。
  13. 硬件处理陷阱,切换到内核模式。
  14. 操作系统处理陷阱(系统调用)。
  15. 操作系统调用“从陷阱返回”。
  16. 硬件切换回用户模式。
  17. 用户进程继续执行。
  18. 最终调用 exit

答案分析

  • 步骤 1-5、14-15:由操作系统负责。OS 管理进程元数据、资源分配和初始化。
  • 步骤 6-8、12-13、16:由硬件负责。硬件执行特权指令、处理模式切换和陷阱。
  • 步骤 9-11、17-18:由用户进程负责。这是进程自身的代码执行流程。

进程控制与同步问题

以下是关于进程控制(fork, exec, wait)的一些常见问题:

问题:能否在不使用 wait 的情况下,确保子进程先打印“hello”,父进程后打印“goodbye”?

  • 使用 wait 可以轻松实现:子进程打印后退出,父进程调用 wait 等待子进程结束,然后打印。
  • 不使用 wait,仅凭目前所学很难可靠实现。可以尝试 sleep,但这会引入竞态条件。后续课程中将学习的信号量、条件变量等同步原语可以解决此类问题。

问题:子进程可以等待父进程吗?

  • 不可以。等待关系是单向的,总是父进程等待子进程

问题:父进程调用 exec 而非子进程,这有意义吗?(就像 Shell 所做的那样)

  • 这没有意义。父进程通常拥有更多关于子进程的信息(如 PID),而父子关系的设计就是父进程管理子进程。

关于文件描述符:调用 fork 后,子进程会获得父进程文件描述符表的副本,两者最初指向操作系统内相同的打开文件表。如果子进程关闭了一个文件描述符,这不会影响父进程对同一文件的访问,因为它们是独立的描述符表项。


地址转换基础

从程序(用户进程)视角看到的所有地址都是虚拟地址。这些地址由编译器在编译时决定,用于布局代码、静态数据和堆栈。

示例:给定一段汇编代码,首先需要确定它生成的虚拟地址访问序列,然后才能进一步转换为物理地址。
假设汇编指令如下:

10: load (BP+8)
13: add ...
19: store (BP+8)

假设基址指针 BP 的虚拟地址为 200。

  1. 取指地址 10 的指令(1次内存访问)。
  2. 执行 load 指令,从虚拟地址 200 + 8 = 208 加载数据(第2次访问)。
  3. 取指地址 13 的指令(第3次访问,该指令不访问内存)。
  4. 取指地址 19 的指令(第4次访问)。
  5. 执行 store 指令,存储到虚拟地址 208(第5次访问)。
    因此,总共生成 5 次虚拟内存访问,访问的虚拟地址为:10, 208, 13, 19, 208。

内存虚拟化方案比较

我们学习了多种内存虚拟化方案:

  1. 分时共享:一次只将一个进程的完整内存装入 RAM。
  2. 静态重定位:在加载时重写程序代码中的地址。
  3. 动态重定位(基址寄存器):硬件将虚拟地址加上一个进程专用的基址。
  4. 界限检查:硬件验证地址是否在有效范围内。
  5. 分段:每个进程有多对基址-界限寄存器,对应不同段(代码、堆、栈)。

问题:哪些方案需要硬件支持?

  • 方案 1 和 2 可以在没有专用硬件支持(MMU)的软件中实现,尽管效率低下。
  • 方案 3、4、5 都需要内存管理单元硬件支持,以高效地进行地址转换和越界检查。

在基址-界限模型中,MMU 负责转换。当 CPU 处于用户模式时,MMU 会对生成的虚拟地址进行界限检查并加上基址。当处于内核模式时(OS 在运行),MMU 通常不进行转换,允许 OS 访问所有物理内存。


分页机制深入

分页是现代操作系统管理内存的核心机制。我们首先分析了简单的线性页表

关键计算:给定系统参数,计算页表大小。

  • 已知:32 位虚拟地址空间,4KB 页大小,页表项(PTE)大小为 4 字节。
  • 计算
    1. 页内偏移位数:4KB = 2^12 B -> 12 位
    2. 虚拟页号位数:32 - 12 = 20 位
    3. 虚拟页数量:2^20 个。
    4. 页表大小:2^20 * 4 字节 = 4 MB

线性页表可能非常大(如本例中的 4MB),为了节省空间,我们引入了多级页表。其核心思想是让每一级页表的大小恰好能放入一个物理页帧中,并且只为实际使用的地址空间部分分配页表。

多级页表示例(常见于 32 位系统,4KB页,4字节PTE):

  • 页内偏移:12 位。
  • 每页可容纳 PTE 数:4KB / 4B = 1024 = 2^10。因此,第一级页表(页目录)使用 10 位索引。
  • 虚拟地址剩余部分:32 - 12 - 10 = 10 位,这 10 位用作第二级页表的索引。如果地址空间更大,可以继续增加级数。
  • 优点:如果地址空间中间大部分未使用,对应的外层页目录项可标记为无效,从而根本不分配内层页表,节省大量空间。

上下文切换:在分页系统中,上下文切换时,除了保存/恢复寄存器状态,还必须切换 MMU 中的页表基址寄存器(指向新进程的页目录),以便硬件能正确进行地址转换。


综合练习:多级页表地址转换

让我们通过一个简化示例(类似作业题)来演练多级页表下的地址转换全过程。这是考试中可能出现的较难题型。

已知系统参数

  • 页大小:32 字节 -> 页内偏移 5 位 (2^5=32)。
  • 虚拟地址空间:1000 页 -> 虚拟页号 10 位 (2^10=1024 > 1000)。虚拟地址共 15 位。
  • 物理内存:128 页 -> 物理页号 7 位 (2^7=128)。物理地址共 12 位 (5+7)。
  • 页表项大小:1 字节。其中最高位为有效位,低 7 位为物理页号。
  • 采用二级页表。外层是页目录。

问题:转换虚拟地址 0x611C 的内容。

  1. 分析虚拟地址格式:15 位虚拟地址。低 5 位是偏移,中间 5 位是内层页表索引,高 5 位是页目录索引。
    • 0x611C 的二进制:0110 0001 0001 1100
    • 页目录索引(高5位):01100 = 十进制 12
    • 内层页表索引(中5位):00010 = 十进制 2
    • 页内偏移(低5位):11100 = 十进制 28
  2. 查找页目录:已知该进程的页目录位于物理页 108。
    • 找到页目录第 12 项的内容。假设其值为 0xA1 (二进制 1010 0001)。
    • 最高位 1 表示有效。低 7 位 010 0001 = 十进制 33。这意味着该页表位于物理页 33。
  3. 查找内层页表
    • 前往物理页 33,找到其第 2 项(索引为2)。假设其值为 0xB5 (二进制 1011 0101)。
    • 最高位 1 表示有效。低 7 位 011 0101 = 十进制 53。这意味着目标虚拟页映射到物理页 53。
  4. 访问数据
    • 前往物理页 53,找到该页内偏移为 28 的字节。假设该字节值为 0x08
    • 因此,加载虚拟地址 0x611C 得到的结果是 8

此过程涉及三次内存访问(读页目录项、读页表项、读数据)。硬件中的 TLB 正是为了缓存 VPN -> PPN 的映射,避免每次都要进行这种耗时的多级查找。


TLB 与页故障处理

  • TLB 命中:完全由硬件处理,速度极快。
  • TLB 未命中:可由硬件或软件处理。
    • 硬件处理:硬件 MMU 知道页表格式和基址,能自动遍历多级页表,找到转换并填充 TLB。
    • 软件处理(较少见):操作系统可以定义更灵活的数据结构,由 OS 陷阱处理程序负责查找转换并更新 TLB。

页表项中的标志位

  • 有效位:表示该虚拟页是否已分配给进程(例如,通过 sbrk 系统调用)。访问无效页会导致段错误
  • 存在位:表示该页当前是否在物理内存中。
    • 如果为 1,页在内存,可正常访问。
    • 如果为 0,页已被换出到磁盘。访问会导致页故障。操作系统会处理此陷阱:从磁盘读入该页,可能需换出其他页,然后更新页表项并重试指令。

页替换策略异常:我们讨论了如 OPT(最优)、LRU(最近最少使用)、FIFO(先进先出)等页替换算法。通常,增加物理页帧数会减少缺页次数。但 FIFO 算法存在 Belady 异常:在某些特定的页面访问序列下,增加可用页帧数反而会导致更多的缺页。这是一个需要理解的反直觉现象。


总结

本节课中我们一起回顾了期中考试的核心内容:

  1. 进程管理:理解了进程启动的完整流程、fork/exec/wait 的语义与同步问题。
  2. 地址转换基础:学会了分析汇编指令生成的虚拟地址访问序列。
  3. 内存虚拟化演进:比较了从基址-界限到分段再到分页的各种方案及其硬件需求。
  4. 分页机制核心:掌握了虚拟地址格式分析、线性/多级页表大小计算、以及多级页表下的地址转换演练。
  5. 相关概念:明确了 TLB 的作用、页故障与段错误的区别、以及页替换策略的异常情况。

请务必熟悉这些概念和计算过程,它们将是考试的重点。祝你复习顺利,考试成功!

010:并发编程入门

在本节课中,我们将完成对虚拟内存的讨论,并开始学习一个全新的重要主题:并发编程。我们将了解为什么需要并发,线程是什么,以及编写多线程程序时可能遇到的典型问题。

虚拟内存回顾

上一节我们介绍了交换技术。交换或换页到磁盘的基本思想是,我们需要支持总地址空间大于机器物理内存量的情况。操作系统为应用程序提供一种假象,让它们认为自己独占整个机器,拥有所有可用内存。虽然由于共享,它们的性能可能略有下降,但其正确性和行为应与作为机器上唯一进程运行时相同。

本质上,我们将使用磁盘或其他持久存储,将进程地址空间的部分内容换出到磁盘。如果我们策略得当,能够确定哪些页面应该换入换出,最终可以获得与拥有更大内存、整个地址空间始终驻留物理内存时相同的性能。

地址转换流程回顾

当需要将虚拟地址转换为物理地址时,具体会发生什么?以下是步骤:

  1. 首先在TLB中查找,看虚拟页号是否匹配。如果匹配,即TLB命中,则转换完成。你将知道物理页号,无需进一步查找,只需将虚拟页的偏移量附加到物理页上即可。这是快速且常见的情况。
  2. 如果虚拟页号不在TLB中,即TLB未命中,则可能由硬件或操作系统负责遍历页表,以确定该虚拟页在物理内存中的位置。当然,该页可能实际上并不在物理内存中,这取决于页表中对应页面的“存在位”是否被设置。
  3. 如果页面存在,处理相对简单。
  4. 如果页面不存在,即“存在位”未设置,则会发生缺页中断。处理缺页中断非常耗时,需要毫秒级时间,因为我们必须从磁盘获取该页。

我们为每个页表项引入了一些新的标志位:

  • 使用位:显示特定页面最近是否被使用,例如在时钟算法或LRU算法中,会查看使用位以选择保留在内存中或换出到磁盘的最佳页面。
  • 脏位:显示自页面从磁盘加载后是否被修改过。如果两个副本不匹配,当需要从内存中逐出该页时,我们必须将其内容写回磁盘以保持同步。

记住,发生缺页中断时,处理需要很长时间。在此期间,导致中断的进程将被阻塞,不会被调度,因为它没有有用的工作可做。我们会调度另一个已就绪、有工作可做的进程,同时I/O系统处理缺页中断并从磁盘读取数据。最后,当页面从磁盘读回后,我们将更新页表以显示其所在的物理页,并设置存在位,以便未来访问时知道该页已在内存中。最后一步是标记该进程已就绪,然后调度器可能会选择该进程作为下一个运行的进程,但也可能继续运行其他进程,这取决于调度策略。

性能考量与颠簸

上次讲座结束时留下的问题是:思考一个工作负载的性能,其中进程在I/O和CPU之间交替运行,并且可能具有相当大的内存占用或工作集,需要访问大量内存才能完成有用的工作。

随着运行进程数量的增加,吞吐量(例如每秒完成的工作量或CPU利用率)会如何变化?以CPU利用率为指标,当进程数为零时,利用率为零。添加一个进程时,利用率取决于其计算时间与I/O时间的比例。随着添加更多进程,利用率会上升,直到达到某个最大值。然而,在某个临界点之后,性能会急剧下降。这是因为此时,所有运行进程所需的工作集总和超过了机器的物理内存量。于是,系统开始将所有时间花在“颠簸”上,即不断处理缺页中断。这些缺页中断并非有用工作,不会增加机器的CPU利用率。因此,性能会急剧下降。如果你看到系统性能像这样断崖式下跌,通常是因为内存不足,导致所有时间都花在处理缺页中断上。

软件模拟脏位

这是一个思考题:如果机器架构没有在页面被写入时自动设置脏位,能否在软件中模拟?假设我们有一台硬件填充TLB的机器,发生TLB未命中时,硬件理解页表格式并完成所有遍历,操作系统不参与地址转换。但操作系统需要跟踪某个页面刚被写入。

一个巧妙的方法是:操作系统何时能从硬件获得控制权?是在进程没有权限写入或访问特定页面时。因此,如果操作系统想模拟脏位,它可以修改硬件正在遍历的页表,使其看起来像是进程没有该页的写入权限。这样,硬件会产生段错误,操作系统得以处理。操作系统维护一个秘密的数据结构,记录该进程实际上确实有写入权限,它只是在尝试模拟脏位。然后操作系统获得控制权,可以设置其内部的脏位,进行所需的记账操作,并恢复进程执行。它会修改页表,显示进程现在确实拥有该页的写入权限,这样未来的访问就不会再次触发陷阱。这只在需要首次设置脏位时发生,因此对性能影响不大。

虚拟内存总结

至此,我们完成了对虚拟内存的讨论。我们探讨了如何让每个进程产生独占所有物理内存的假象。我们讨论了如何在地址空间中放置代码段、堆段和栈段。我们研究了几种不同的地址转换方法,重点在于那些允许动态重定位的方法:从简单的基址寄存器,到基址-界限寄存器,再到分段,最后是分页以及分页与分段的结合。分页在现代系统中能工作的关键在于TLB,它使得大多数转换可以非常快速地完成。另一个复杂但有趣的部分是如何让页表本身也适合放在页面中,这就是我们使用多级页表的原因。

上一讲主要解决了当虚拟内存地址空间总和超过物理内存时如何处理的问题。虚拟内存是理解操作系统如何工作的基础。

并发编程导论

接下来,我们进入一个全新的主题:并发。

为什么需要并发?

这张图显示了自1978年某台机器以来,处理器速度性能的相对增长。在80年代和90年代,性能呈指数级增长。然而,由于晶体管的缩放特性达到物理极限,性能增长开始急剧放缓。现在,每年的增长非常小。因此,如果我们无法让单个CPU变得更快,要获得更高的性能,就必须开始使用更多的CPU并行处理任务。这就是并发的动机。即使在单台机器内,我们也会有多个核心。

如何利用多核?

一种方法就像教学实验室那样,让许多不同的用户使用机器,运行许多不同的进程或应用程序。它们之间并不协作,但当你有更多CPU时,可以投入更多作业。但这不会加速单个应用程序。因此,我们真正需要关注的是如何编写能够自行利用多个CPU的高性能、复杂的应用程序。

选项一:多进程
我们已经了解如何使用进程。你可以将应用程序拆分为多个进程,让它们协作完成工作。例如,Web浏览器通常为每个标签页使用不同的进程,这样可以隔离不同的请求,互不干扰,并带来一些安全优势。但是,如果这些进程需要相互通信,就必须使用相对缓慢的方式,如共享消息或管道。此外,跨进程的上下文切换开销很大,因为需要完全切换地址空间,这意味着刷新TLB并承受缓存性能损失。

选项二:多线程(我们将重点学习)
这里的想法是,我们有一个单一的进程,其中包含多个可以并发执行的线程。这些线程可以在同一个CPU上运行,或者调度器可以将每个线程调度到不同的核心上运行,从而实现我们期望的并行性。线程就像进程一样,是一串运行的指令流,但同一进程内的所有线程共享相同的地址空间。因此,在切换线程时无需刷新TLB,它们都可以访问相同的代码、堆以及通过malloc动态分配的数据或全局变量。这使得访问相同的内存变得非常容易。

我们将学习如何将想要加速的任务拆分到协作的线程中。关键点在于这些是协作线程。在多进程方法中,我们不能假设不同进程是协作的,必须确保它们之间的隔离。而在这里,我们明确假设同一进程内的线程是协作的,这会使某些事情更容易实现。

多线程应用模式

编写多线程应用程序时,会看到几种常见的风格模式:

  • 生产者-消费者模式:一些线程生产工作(例如,对数据进行初步处理或总结),并将其放入共享队列。下一阶段的工作线程(消费者)从队列中取出工作并进行进一步处理。我们将在本课程中详细讨论如何实现生产者与消费者之间的同步。
  • 流水线模式:程序中有多个工作阶段,每个阶段都类似。例如,在使用Unix管道时,一个命令的输出作为下一个命令的输入,理论上,只要有待处理的工作,所有这些组件都可以并行运行。每个组件可以看作一个线程,等待工作、执行任务并为流水线中的下一个任务产生更多工作。
  • 前台/后台工作模式:有些关键工作需要立即完成,而有些后台工作不那么紧急,可以推迟到CPU空闲时或积累了一批工作后一次性完成。例如,在虚拟内存的交换机制中,将寻找空闲页(牺牲页)的工作放到后台线程进行,可以提前准备好空闲页,当缺页中断发生时就不必立即执行这项耗时的工作。

线程共享与独享的状态

我们需要明确同一进程内的线程共享哪些状态,不共享哪些状态。

共享状态

  • 页目录:因为它们共享相同的地址空间,所以必须有相同的地址转换映射。
  • 地址空间:代码、堆、全局变量。
  • 进程ID、打开的文件描述符、环境变量(如当前工作目录)、用户/组权限、进程限制等。

独享状态(每个线程私有)

  • 线程ID
  • 寄存器组:包括程序计数器、栈指针以及其他通用寄存器。上下文切换时需要保存和恢复这些寄存器,但无需切换与地址空间相关的寄存器(如页目录基址寄存器)。
  • :每个线程有自己的调用栈,用于存储局部变量和函数调用帧。虽然从地址空间角度看栈在同一个空间内,但协作线程应遵守约定,不互相干扰对方的栈。

线程库与基本操作

有许多用于创建和管理线程的库,本课程将假设使用POSIX线程。最基本的必要操作包括:

  • 创建线程 (pthread_create)。
  • 线程退出 (pthread_exit):线程可以自行终止而不导致整个进程退出。只有当所有线程都退出后,进程才会退出。
  • 等待线程 (pthread_join):一个线程可以等待另一个线程结束。这类似于进程中的wait函数。

用户级线程与内核级线程

线程的概念由来已久,但最初操作系统并不直接支持。在没有OS支持的时代,可以在用户级完全实现一个线程库。即,一个进程只有一个内核线程,用户级库负责将用户认为的多个线程映射到这唯一的内核线程上,并在用户级完成所有上下文切换代码。

用户级线程的优点

  1. 无需操作系统支持。
  2. 可以实现更专门的调度策略,可能获得更好的性能(例如,减少缓存失效)。
  3. 上下文切换和同步无需陷入内核,速度更快。

用户级线程的缺点

  1. 无法利用多处理器(多核)。
  2. 操作系统不知道用户级线程的存在。如果一个用户级线程阻塞(例如,在I/O或页错误上),操作系统会阻塞整个进程,即使其他用户级线程有工作可做。

现代系统通常提供内核级线程支持,由内核进行线程的上下文切换和调度。其优缺点与用户级线程相反:可以利用多处理器,当一个线程阻塞时操作系统可以调度其他线程,但同步和上下文切换需要陷入内核,开销稍大。

一个简单的多线程程序示例

让我们看一个简单的多线程C程序示例(main-thread-version0.c)。该程序创建两个工作线程,每个线程都循环递增一个全局共享变量balance

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

volatile int balance = 0; // 全局共享变量

void* my_thread(void* arg) {
    char* letter = (char*) arg;
    int i;
    for (i = 0; i < 1000000; i++) {
        balance++; // 关键操作:递增共享变量
    }
    return NULL;
}

int main(int argc, char* argv[]) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, my_thread, "A");
    pthread_create(&t2, NULL, my_thread, "B");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("balance = %d\n", balance);
    return 0;
}

volatile关键字告诉编译器,该变量可能被多个线程修改,不能将其长时间保存在寄存器中,必须每次都从内存读取/写入。

如果程序正确,两个线程各递增100万次,最终balance应为200万。然而,运行程序后,我们可能得到诸如1,287,234之类的错误结果。这是因为balance++这个C语句在汇编层面并非原子操作,它通常对应三条指令:

  1. balance的值从内存加载到寄存器(如EAX)。
  2. 将寄存器中的值加1。
  3. 将结果存回balance所在的内存地址。

如果在这三条指令执行期间发生线程切换(上下文切换),另一个线程可能读取到旧的balance值,从而导致更新丢失。这种因执行顺序不确定而导致结果错误的情况称为竞态条件

竞态条件与临界区

我们通过分析不同指令交错执行的时序,可以看到有时能得到正确结果,有时则不能。问题的关键在于balance++对应的三条指令需要作为一个不可分割的单元连续执行。我们称这样的代码段为临界区。更一般地说,我们需要为临界区提供互斥访问。这意味着在执行临界区代码时,不能切换到另一个也要操作同一共享变量(如balance)的线程,但切换到执行无关代码的线程是可以的。

解决方案:锁

为了解决竞态条件,我们需要一种机制来保护临界区,确保互斥访问。最基本的同步原语就是

在POSIX线程中,锁的基本用法如下:

pthread_mutex_t lock; // 声明一个锁
pthread_mutex_init(&lock, NULL); // 初始化锁

pthread_mutex_lock(&lock);   // 获取锁(进入临界区前)
// ... 临界区代码 ...
pthread_mutex_unlock(&lock); // 释放锁(离开临界区后)

我们将锁应用于之前的程序(main-thread-version1.c):

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
volatile int balance = 0;

void* my_thread(void* arg) {
    char* letter = (char*) arg;
    int i;
    for (i = 0; i < 2000000; i++) {
        pthread_mutex_lock(&lock);
        balance++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

现在,当一个线程持有锁并执行balance++时,如果发生上下文切换,另一个线程尝试获取锁会被阻塞,直到第一个线程释放锁。这保证了临界区代码的原子性,程序总能得到正确结果(4,000,000)。

性能考虑:将锁放在循环内部(细粒度锁)保证了并发性,但锁操作开销很大。另一种方法是将锁放在循环外部(粗粒度锁),这样整个循环成为临界区,虽然失去了并发性,但大大减少了锁操作次数,可能整体更快。在实际编程中,我们需要在正确性和性能之间权衡,尽量减小临界区范围以提高并发度,同时避免过多的锁开销。

总结

本节课中,我们一起学习了以下内容:

  1. 回顾了虚拟内存的核心机制,包括地址转换、TLB、缺页中断和页面置换策略,并讨论了内存不足导致的性能颠簸问题。
  2. 开启了并发编程的新篇章,理解了在多核时代利用并发提高性能的必要性。
  3. 学习了线程的概念,比较了多进程与多线程模型的优劣,明确了线程间共享与私有的状态。
  4. 通过一个简单的计数器程序,直观地认识了多线程编程中的竞态条件问题,其根源在于对共享数据的非原子操作。
  5. 引入了临界区互斥的概念,并介绍了使用这一基本同步原语来保护临界区、确保程序正确性的方法。

在接下来的课程中,我们将深入探讨如何实现锁以及其他更高级的同步机制(如条件变量、信号量),并学习如何正确、高效地构建并发程序。

011:并发与锁 🔒

在本节课中,我们将继续学习并发编程的核心概念,特别是如何通过锁(Locks)来实现线程间的互斥访问,从而避免数据竞争和不一致的结果。我们将从为什么需要锁开始,探讨几种实现锁的方法,并分析它们的优缺点。


为什么需要锁? 🤔

上一讲我们介绍了并发的基本概念,看到了当多个线程同时访问和修改共享变量(如一个全局的balance)时,由于指令交错执行,可能导致非确定性的错误结果。这是因为像balance++这样的操作在底层对应多条汇编指令(加载、计算、存储),而这些指令的执行可能被操作系统的调度器在任意时刻打断。

核心问题:我们需要确保一段代码(称为临界区)能够原子地执行,即在其执行过程中不被其他线程中断。这被称为互斥


锁的基本接口 🔑

锁(或互斥锁,Mutex)是操作系统或线程库提供的一种同步原语,用于实现互斥。其基本操作非常简单:

  • lock()acquire():尝试获取锁。如果锁已被其他线程持有,则调用线程会等待(阻塞或自旋),直到锁被释放。
  • unlock()release():释放锁,允许其他等待的线程获取它。

以下是一个使用Pthreads锁保护共享变量的示例代码框架:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化锁
int balance = 0; // 共享变量

void* thread_function(void* arg) {
    for (int i = 0; i < 10000; i++) {
        pthread_mutex_lock(&lock);   // 进入临界区前加锁
        balance++;                   // 临界区代码
        pthread_mutex_unlock(&lock); // 离开临界区后解锁
    }
    return NULL;
}

通过使用锁,我们保证了balance++这个操作是原子的,从而得到正确的结果。


一个更复杂的例子:并发链表操作 📝

为了更深入地理解锁的必要性,我们来看一个并发操作链表的例子。假设我们有一个简单的链表,支持插入和查找操作。

typedef struct __node_t {
    int key;
    struct __node_t *next;
} node_t;

typedef struct __list_t {
    node_t *head;
    pthread_mutex_t lock; // 为每个链表关联一个锁
} list_t;

void List_Init(list_t *L) {
    L->head = NULL;
    pthread_mutex_init(&L->lock, NULL);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/b8a88378b271494f8fffe2466093286e_1.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/b8a88378b271494f8fffe2466093286e_3.png)

void List_Insert(list_t *L, int key) {
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        return;
    }
    new->key = key;

    // 只有修改链表结构(next指针和head)时需要互斥
    pthread_mutex_lock(&L->lock);
    new->next = L->head;
    L->head = new;
    pthread_mutex_unlock(&L->lock);
}

int List_Lookup(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *curr = L->head;
    while (curr) {
        if (curr->key == key) {
            pthread_mutex_unlock(&L->lock);
            return 1; // 找到
        }
        curr = curr->next;
    }
    pthread_mutex_unlock(&L->lock);
    return 0; // 未找到
}

关键点

  1. 锁的粒度:我们为每个链表分配一个锁,而不是使用全局锁。这样,对不同链表的操作可以并发进行。
  2. 临界区最小化:在List_Insert中,只有真正修改链表结构的几步(设置new->nextL->head)需要放在锁内。内存分配(malloc)和设置节点key可以放在锁外,因为malloc本身是线程安全的,且key在插入前是私有数据。
  3. 查找操作:在这个简化模型中(仅插入和查找,无删除),查找操作理论上可以不加锁,因为它总是看到一个“某一时刻”的正确链表快照。然而,在实际包含删除操作的复杂数据结构中,查找通常也需要加锁以保证安全。这里为了安全起见,我们给查找也加上了锁。


如何实现锁? ⚙️

理解了锁的用途后,我们来看看如何从零开始实现一个锁。一个正确的锁实现需要满足几个目标:

  1. 互斥:基本要求。
  2. 进展(无死锁):如果一个锁被释放,那么至少有一个等待的线程能获得它。
  3. 有限等待:等待锁的线程最终都能获得它,避免“饿死”。
  4. 性能:加锁、解锁操作本身开销要小,尤其是在无竞争的情况下。

我们将探讨三种实现思路。

方法一:关闭中断(仅限单处理器)❌

思路:在进入临界区前关闭中断,离开时再打开。这样,时钟中断不会发生,操作系统就不会进行线程切换。

问题

  • 多处理器无效:其他CPU上的线程不受影响。
  • 恶意线程:一个线程获得锁后关闭中断,可能永远不释放CPU。
  • 影响系统:线程无法进行I/O或处理页错误等需要内核介入的操作。
  • 权限问题:用户态程序通常无权关闭中断。

结论:这不是一个通用、可行的方案。

方法二:仅用Loads/Stores实现(软件方法)🤯

思路:尝试只用普通的加载和存储指令来实现一个“测试并设置”的逻辑。例如,用一个布尔变量flag表示锁是否被持有。

typedef struct __lock_t {
    int flag;
} lock_t;

void init(lock_t *lock) {
    lock->flag = 0; // 0表示锁空闲
}

void acquire(lock_t *lock) {
    while (lock->flag == 1) // 测试:锁被持有吗?
        ; // 自旋等待
    lock->flag = 1; // 设置:现在锁被我持有了
}

void release(lock_t *lock) {
    lock->flag = 0;
}

问题acquire中的“测试”和“设置”是两个独立的操作,不是原子的。考虑以下交错执行:

  1. 线程A测试flag为0(锁空闲)。
  2. 操作系统切换至线程B。
  3. 线程B也测试flag为0。
  4. 线程B设置flag=1,进入临界区。
  5. 切换回线程A,线程A(基于旧的判断)也设置flag=1,进入临界区。
  6. 互斥被破坏!

结论:仅用非原子指令无法正确实现锁。存在一些复杂的软件算法(如Peterson算法、Dekker算法),它们通过巧妙的变量组合可以在两个线程间实现互斥,但这些算法在现代多处理器架构上可能因内存序问题而失效,且难以扩展到多个线程,因此不实用。

方法三:利用硬件原子指令 ✅

现代处理器提供了特殊的原子硬件指令,它们能不可分割地完成“读-改-写”操作。这是构建所有现代同步原语的基础。

1. 测试并设置 (Test-and-Set) / 原子交换 (Atomic Exchange)

这条指令原子地完成以下操作:返回指定内存地址的旧值,同时将该地址设置为新值。

伪代码描述

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr; // 获取旧值
    *old_ptr = new;     // 写入新值
    return old;         // 返回旧值
}
// 注意:以上三步是原子执行的,不可中断。

用Test-and-Set实现自旋锁

typedef struct __lock_t {
    int flag;
} lock_t;

void init(lock_t *lock) {
    lock->flag = 0;
}

void acquire(lock_t *lock) {
    while (TestAndSet(&lock->flag, 1) == 1) // 原子地“测试并设置”
        ; // 自旋等待
    // 当TestAndSet返回0时,表示我们成功获得了锁
}

void release(lock_t *lock) {
    lock->flag = 0;
}

工作原理

  • 初始时flag=0
  • 第一个线程调用acquireTestAndSet(&flag, 1)原子地返回0(旧值),并将flag设为1。循环条件不成立,线程获得锁。
  • 第二个线程调用acquire:此时flag已是1,TestAndSet原子地返回1,并将flag保持为1。因此线程在while循环中自旋。
  • 第一个线程调用release:将flag设为0。
  • 第二个线程的TestAndSet现在可能返回0,从而退出循环,获得锁。

2. 比较并交换 (Compare-and-Swap, CAS)

另一条常用的原子指令。它原子地完成:如果指定内存地址的值等于期望值,则将其设置为新值。无论是否交换,都返回该内存地址的旧值。

伪代码描述

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected)
        *ptr = new;
    return actual;
}

用CAS实现自旋锁

void acquire(lock_t *lock) {
    while (CompareAndSwap(&lock->flag, 0, 1) == 1)
        ; // 自旋等待
}

其逻辑与Test-and-Set类似。

3. 获取并增加 (Fetch-and-Add)

这条指令原子地返回一个变量的旧值,并增加该变量。

伪代码描述

int FetchAndAdd(int *ptr) {
    int old = *ptr;
    *ptr = old + 1;
    return old;
}

用Fetch-and-Add实现票号锁 (Ticket Lock)
票号锁解决了简单自旋锁的公平性问题,它像银行或熟食店一样,给每个请求锁的线程发一个号,按号服务。

typedef struct __lock_t {
    int ticket; // 当前发放的票号
    int turn;   // 当前允许进入的票号
} lock_t;

void init(lock_t *lock) {
    lock->ticket = 0;
    lock->turn = 0;
}

void acquire(lock_t *lock) {
    int myturn = FetchAndAdd(&lock->ticket); // 原子地取号
    while (lock->turn != myturn) // 等待叫号
        ; // 自旋
}

void release(lock_t *lock) {
    lock->turn = lock->turn + 1; // 叫下一个号
}

优点:保证了先到先得的公平性,避免了线程饿死。


自旋锁的性能与优化 🚀

我们目前实现的锁都是自旋锁:当一个线程无法获得锁时,它会在一个循环中不断检查锁的状态(忙等待)。

自旋锁的优缺点

  • 优点:在锁被持有时间非常短,或线程在专属CPU上等待时(多处理器系统),自旋等待避免了上下文切换的开销,可能效率更高。
  • 缺点
    1. 单处理器上浪费CPU:如果锁被另一个线程持有,而该线程正处于不运行状态(例如,被调度出去了),那么自旋的线程将浪费整个时间片,因为锁持有者根本无法运行来释放锁。
    2. 不公平:简单的自旋锁(非票号锁)不能保证等待时间长的线程先获得锁。
    3. 缓存一致性流量:多个核心频繁地读取同一个锁变量,会产生大量的缓存一致性通信,影响性能。

初步优化:主动让出CPU
在自旋等待中,如果发现锁被持有,线程可以主动调用yield()系统调用,自愿放弃CPU,让调度器去运行其他线程(很可能就是锁的持有者)。

void acquire(lock_t *lock) {
    while (TestAndSet(&lock->flag, 1) == 1) {
        yield(); // 放弃CPU
    }
}

这比纯自旋要好得多,特别是在单处理器或高竞争场景下。它减少了无用的CPU时间浪费,从浪费O(线程数 * 时间片)降低到大约O(上下文切换开销)

然而,yield()并不是终极方案。线程被再次调度时,可能锁仍然未被释放,它又会调用yield(),导致多次不必要的上下文切换。更理想的机制是:让操作系统知道我们在等待什么,当锁可用时再唤醒我们。这就需要锁的实现与调度器进行更深入的协作,涉及到队列让线程休眠的机制,这将是下一讲关于条件变量信号量的主要内容。


总结 📚

本节课我们一起深入探讨了并发编程中的核心同步机制——锁。

  1. 为什么需要锁:为了防止多个线程同时进入临界区导致的数据竞争和不一致,我们需要互斥。
  2. 锁的接口:通过acquire()release()来保护临界区。
  3. 锁的实现挑战:关键在于实现“测试并设置”的原子性。
  4. 错误的实现:关闭中断(不通用)和纯软件Loads/Stores(无法保证原子性)都不可行。
  5. 正确的实现基础:必须依赖硬件提供的原子指令,如Test-and-SetCompare-and-SwapFetch-and-Add
  6. 自旋锁:基于原子指令实现,在等待时忙等待。我们实现了简单自旋锁和更公平的票号锁
  7. 性能考量:自旋锁在锁持有时间短、多处理器环境下可能高效,但在单处理器或高竞争下会浪费CPU。通过调用yield()可以初步优化。

在下一讲中,我们将学习如何实现让线程在无法获得锁时主动阻塞,从而彻底避免CPU浪费的锁机制,并引入更强大的同步原语:条件变量信号量

012:并发、队列锁与条件变量

在本节课中,我们将学习并发编程中的两个核心概念:队列锁条件变量。我们将探讨如何更高效地实现锁,避免CPU空转,并学习如何使用条件变量来控制线程间的执行顺序。


锁的回顾与问题

上一节我们讨论了如何实现锁来为协作线程提供互斥访问。但我们之前的方法效率不高,因为线程在等待锁时会进行忙等待,这会浪费CPU时间片。

一个正确的锁实现需要满足三个要求:

  1. 互斥:一次只能有一个线程获取锁。
  2. 进展:系统必须是无死锁的。如果有线程想获取锁,系统必须允许至少一个线程成功获取。
  3. 无饥饿:理想情况下,任何线程都不应无限期等待锁。

我们之前看过的票据锁是一种公平的实现,它通过ticketturn两个变量,严格按照线程请求的顺序分配锁。其核心代码如下:

// 票据锁结构
typedef struct __lock_t {
    int ticket;
    int turn;
} lock_t;

void lock(lock_t *lock) {
    int myturn = FetchAndAdd(&lock->ticket); // 原子操作,获取票据号
    while (lock->turn != myturn) // 等待叫号
        yield(); // 让出CPU,比忙等待好
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/d3b699e98bf7ecea72c72a565a9038fd_1.png)

void unlock(lock_t *lock) {
    lock->turn = lock->turn + 1; // 叫下一个号
}

虽然票据锁通过yield()优化了忙等待,但线程在等待时仍然处于可运行状态,调度器仍可能选择它,导致不必要的上下文切换开销。


队列锁:通过阻塞实现高效等待

本节中,我们来看看一种更高效的锁实现方式:队列锁。其核心思想是,当线程无法获取锁时,不是忙等待,而是将自己阻塞并放入一个与该锁关联的队列中。这样,调度器就只运行那些真正可以执行的线程。

以下是队列锁的基本逻辑(初始版本,尚不完整):

typedef struct __lock_t {
    int flag; // 锁是否被持有
    queue_t q; // 等待该锁的线程队列
} lock_t;

void lock(lock_t *m) {
    if (m->flag == 1) { // 锁被持有
        add self to m->q; // 将自己加入队列
        park(); // 阻塞自己,让出CPU
    } else {
        m->flag = 1; // 获取锁
    }
}

void unlock(lock_t *m) {
    if (!empty(m->q)) { // 队列中有等待线程
        remove thread from m->q;
        unpark(thread); // 唤醒该线程,使其变为可运行
    } else {
        m->flag = 0; // 没有等待者,释放锁
    }
}

直觉理解:线程在lock中如果发现锁被持有,就排队并阻塞自己。当持有锁的线程在unlock中释放锁时,它会检查队列。如果队列不为空,则唤醒队首的线程,该线程将直接获得锁的所有权。


队列锁的正确性挑战与修复

上述简单实现存在竞态条件。主要问题在于,测试锁状态(flag)和后续操作(加入队列/阻塞)必须是原子的。否则,一个线程可能在加入队列后、阻塞前被切换出去,而另一个线程可能在此期间释放锁并尝试唤醒它,导致信号丢失。

为了解决这个问题,我们引入一个保护锁内部操作的自旋锁 guard

typedef struct __lock_t {
    int flag;
    queue_t q;
    int guard; // 保护flag和队列操作的自旋锁
} lock_t;

void lock(lock_t *m) {
    while (TestAndSet(&m->guard, 1) == 1) // 获取guard锁(自旋)
        ; // 空循环,但临界区极短,可以接受
    if (m->flag == 1) {
        add self to m->q;
        m->guard = 0; // 必须先释放guard,才能阻塞
        park(); // 可能在此处永久阻塞(竞态条件!)
    } else {
        m->flag = 1;
        m->guard = 0;
    }
}

void unlock(lock_t *m) {
    while (TestAndSet(&m->guard, 1) == 1)
        ;
    if (!empty(m->q)) {
        remove thread from m->q;
        unpark(thread);
        // 注意:这里没有设置 m->flag = 0!
        // 锁被直接“传递”给了被唤醒的线程。
    } else {
        m->flag = 0; // 没有等待者,才释放锁
    }
    m->guard = 0;
}

关键点解析

  1. 为什么可以自旋等待guard 因为guard保护的临界区非常短(只有几行代码),线程持有它的时间极短,因此自旋的代价很小。
  2. 为什么在unlockelse分支才设置flag=0 如果队列中有等待线程,锁的所有权被直接“传递”给下一个线程。如果此时将flag设为0,另一个新来的线程可能趁机获取锁,导致两个线程同时进入临界区。
  3. 仍然存在的竞态条件:在lock中,线程释放guard后、调用park()前,如果恰好被切换,并且另一个线程调用了unlockunpark,那么信号会丢失。当原线程恢复执行并调用park()时,将永远阻塞。
  4. 最终修复:操作系统提供了setpark()系统调用。线程可以在持有guard时调用setpark(),表明“我打算去公园(阻塞)了”。如果之后在调用park()之前收到了unpark(),那么park()会直接返回而不阻塞。这消除了上述竞态条件。

自旋与阻塞的权衡

何时使用自旋锁,何时使用阻塞锁?这取决于临界区的长度上下文切换的成本(C)

  • 单处理器:总是使用阻塞锁。因为持有锁的线程不可能在另一个CPU上运行,忙等待毫无意义。
  • 多处理器:需要权衡。
    • 如果锁持有时间(T) < 上下文切换成本(C),自旋等待更高效。
    • 如果T > C,则阻塞更高效。

由于我们无法预知未来,实践中常用两阶段等待策略:先自旋一段时间,如果还没获得锁,再阻塞。理论分析表明,这种策略在最坏情况下的性能不会超过最优策略(能预知未来)的两倍,是2-竞争的。


条件变量:控制线程执行顺序

除了互斥,我们还需要协调线程的执行顺序。条件变量就是用于此目的的同步原语。它允许一个线程等待某个条件成立,而另一个线程在条件成立时发出通知。

条件变量本身只是一个等待队列,没有内部状态。它总是与一个互斥锁配合使用。

核心操作:

  • wait(cond, mutex):调用前必须持有mutex。该操作会释放mutex并将线程阻塞在cond的队列上。当被唤醒时,在返回前会重新获取mutex
  • signal(cond):唤醒一个在cond上等待的线程(如果有)。
  • broadcast(cond):唤醒所有在cond上等待的线程。

条件变量使用模式:以线程连接(join)为例

以下是使用条件变量实现thread_join正确模式

int done = 0; // 共享状态:子线程是否完成
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;

void thread_exit() {
    pthread_mutex_lock(&m);
    done = 1;          // 1. 修改状态
    pthread_cond_signal(&c); // 2. 发送信号
    pthread_mutex_unlock(&m);
}

void thread_join() {
    pthread_mutex_lock(&m);
    while (done == 0) // 必须用while循环检查
        pthread_cond_wait(&c, &m); // 释放锁,阻塞,被唤醒时重新获得锁
    pthread_mutex_unlock(&m);
}

使用条件变量的关键规则

  1. 总是与共享状态关联:条件变量本身无状态,必须有一个共享变量(如done)来表示条件。
  2. 修改状态和发信号时必须持有锁:确保检查状态、进入等待、修改状态和发送信号这些操作是原子的。
  3. 等待时必须使用while循环:当线程从wait返回时,条件可能不再成立(例如,被broadcast唤醒,或遇到虚假唤醒)。必须重新检查条件。

生产者-消费者问题

这是条件变量的经典应用场景。我们有一个有限大小的缓冲区。生产者向缓冲区放入数据,消费者从中取出数据。

初始错误尝试(单条件变量)
生产者等待条件“缓冲区非满”,消费者等待条件“缓冲区非空”。如果只用一个条件变量,唤醒的可能是错误类型的线程(例如,生产者唤醒了另一个生产者)。

正确解决方案:使用两个条件变量,分别对应不同的等待条件。

int buffer[MAX];
int fill = 0, use = 0, count = 0; // 计数:缓冲区中数据项数

pthread_cond_t empty, full; // 两个条件变量
pthread_mutex_t mutex;

void put(int value) {
    buffer[fill] = value;
    fill = (fill + 1) % MAX;
    count++;
}

int get() {
    int tmp = buffer[use];
    use = (use + 1) % MAX;
    count--;
    return tmp;
}

// 生产者
void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        pthread_mutex_lock(&mutex);
        while (count == MAX)           // 用while,不是if
            pthread_cond_wait(&empty, &mutex); // 等待“缓冲区非满”
        put(i);
        pthread_cond_signal(&full);    // 通知消费者“缓冲区非空”
        pthread_mutex_unlock(&mutex);
    }
}

// 消费者
void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        pthread_mutex_lock(&mutex);
        while (count == 0)             // 用while,不是if
            pthread_cond_wait(&full, &mutex); // 等待“缓冲区非空”
        int tmp = get();
        pthread_cond_signal(&empty);   // 通知生产者“缓冲区非满”
        pthread_mutex_unlock(&mutex);
        printf("%d\n", tmp);
    }
}

为什么必须用while
假设消费者C1等待,生产者P放入数据后唤醒C1。但在C1重新获得锁之前,另一个消费者C2抢先获得锁并消费了数据。当C1最终获得锁并从wait返回时,缓冲区已经为空。如果用if,C1会错误地执行get。用while会让C1重新检查count == 0,发现条件不满足,于是继续等待。


总结

本节课中我们一起学习了:

  1. 队列锁:通过将等待线程放入队列并阻塞,避免了忙等待的CPU浪费,实现了更高效的锁。其实现需要小心处理竞态条件,通常用一个自旋锁保护内部数据结构。
  2. 自旋与阻塞的权衡:锁持有时间与上下文切换成本的比较决定了最佳策略。两阶段等待是一种实用的折中方案。
  3. 条件变量:用于线程间顺序同步的核心原语。必须与互斥锁及共享状态变量结合使用,并且等待端必须使用while循环来重新检查条件。
  4. 生产者-消费者模式:展示了条件变量的典型应用,强调了使用多个条件变量和while循环的重要性。

管程本质上是语言层面将互斥锁和条件变量封装在一起的机制,提供了更高级别的同步抽象。下一讲我们将学习另一个强大的同步原语:信号量

013:并发控制之信号量 🚦

在本节课中,我们将学习并发控制的另一个核心概念:信号量。我们将探讨信号量的定义、基本操作,并将其与之前学过的锁和条件变量进行对比。通过一系列经典同步问题的实例,我们将掌握如何使用信号量来实现互斥、同步以及更复杂的协调逻辑。


课程公告与回顾 📢

大家好,新的一周开始了。有几个事项需要通知。

项目4的提交截止时间是今天下午5点。如果网络或计算机在夜间出现问题,官方截止时间是5点。但非正式地,只要一切正常,你可以在第二天早上7点前提交。那是我起床并关闭AFS提交目录权限的时间。当然,我们更希望你尽早完成。

项目5将在明天早上发布。它同样是关于XV6操作系统的,主题是内存系统,涉及物理内存分配以及进程页表的操作。

我强烈建议你为项目5找一个项目伙伴,即使你过去从未合作过。这个项目的工作量比之前的要大得多。

期中考试2将在两周后的明天举行。考试内容将主要集中在并发控制,并包含少量关于课程前半部分抽象概念的回顾。

我的办公时间今天比平时稍晚一些。关于行政事务有任何问题吗?

所有考试的总分占你最终成绩的50%。加上作业的5%加分,三次考试的权重大致相等。


并发控制基础回顾 🔄

上一节我们介绍了条件变量,本节中我们来看看信号量。首先,让我们回顾一下已学的并发控制知识。

我们讨论了多线程环境。线程共享一些状态和变量,我们需要协调它们对这些共享状态的访问。

我们有两种基本的并发原语:

  1. 提供互斥的原语:例如。它确保一次只有一个线程能获取锁并执行临界区代码。
  2. 提供顺序的原语:例如条件变量。它规定一个线程必须完成某些工作后,另一个线程才能继续。这提供了线程或进程间的同步、调度或顺序控制。

条件变量本身不提供互斥。我们仍然需要将锁与条件变量结合使用,以便在保证顺序的同时也实现互斥。


条件变量操作详解 ⚙️

条件变量的操作接口很简单。初始化一个条件变量只是创建一个空队列。

线程可以在持有特定互斥锁(mutex)的情况下,对一个条件变量调用 waitwait 的工作方式是:调用时持有锁,然后在该调用中等待,直到其他线程调用 signal 并且本线程重新获取到锁后才会返回。

signal 会唤醒一个在该条件变量上等待的线程。如果没有线程在等待,则什么都不做。

理解条件变量的实现很重要。想象有线程在条件变量队列上等待。当某个线程调用 signal 时,会唤醒队列中的一个线程。但被唤醒的线程不会立即从 wait 返回,它必须等待重新获取关联的锁。因此,在它获取锁之前,其他线程可能抢先获取锁并改变状态。这就是为什么我们在使用条件变量时,通常需要在 wait 返回后用一个 while循环 重新检查条件,而不是用 if 语句。


条件变量应用实例 📝

以下是使用条件变量的两个关键示例:

线程等待(Join)

我们需要父线程等待子线程调用 exit 后再从 join 返回。这需要的是顺序,而非互斥。

我们使用了一个额外的状态变量 done 来记录子线程是否已完成。父线程在检查 done 和调用 wait 时必须持有锁,形成一个临界区。同样,子线程在设置 done 和调用 signal 时也必须持有同一个锁。这防止了在检查和等待之间发生上下文切换导致的竞态条件。

生产者-消费者(单缓冲)

生产者和消费者共享一个缓冲区。生产者需要等待缓冲区为空,消费者需要等待缓冲区为满。

我们使用了两个单独的条件变量(cond_emptycond_full)以及一个互斥锁。关键点在于:

  • 检查和等待操作必须在持有锁的临界区内进行。
  • 等待返回后必须用 while 循环重新检查条件,因为被唤醒后、获取锁之前,状态可能已被其他线程改变。

条件变量使用准则 ✅

根据以上例子,我们总结出使用条件变量的经验法则:

  1. 总是需要维护额外的状态变量来跟踪程序状态,不能盲目地 waitsignal
  2. 调用 waitsignal 时必须持有相关联的互斥锁。
  3. wait 唤醒后,通常需要用 while 循环重新检查条件,以防状态被其他线程改变。

条件变量和锁是紧密协作的。在 pthreads 等接口中,调用 condition_wait 时需要传入关联的互斥锁。该实现会在等待时释放锁,并在收到信号后、返回前重新获取锁。


信号量介绍 🆕

条件变量本身没有状态(除了等待队列),应用程序总是需要额外的状态变量。信号量则将状态嵌入到了自身内部。

一个信号量是一个具有整数值的对象,用户程序不能直接访问这个值,只能通过定义良好的原语来操作它。

有趣的是,信号量与(锁 + 条件变量)在功能上是完全等价的。选择使用哪一种很大程度上是个人偏好问题,某些问题用其中一种表达可能稍显简洁。


信号量基本操作 🎯

信号量需要用一个初始值进行初始化。

之后有两个基本操作:

  • sem_wait (或 P 操作):等待,直到信号量的值大于0。一旦大于0,就将其值减1。如果值不大于0,调用线程通常会阻塞。
  • sem_post (或 V 操作):将信号量的值加1。如果有线程正在该信号量上等待,则会唤醒其中一个。

用信号量实现锁 🔐

我们可以用信号量来实现锁。

思路是:锁的 acquire 对应 sem_wait,锁的 release 对应 sem_post。关键在于初始化信号量的值。

如果将信号量初始化为 1,那么第一个调用 sem_wait 的线程会发现值大于0,将其减为0并继续执行(即获取锁)。此时第二个线程调用 sem_wait 会发现值为0,于是阻塞等待。当第一个线程调用 sem_post 将值加回1时,会唤醒等待的线程,使其得以继续并获取锁。这完美模拟了互斥锁的行为。


用信号量实现线程同步(Join) 🤝

现在,我们用信号量来实现线程 join 的同步(顺序控制)。

join 中,父线程需要等待子线程执行完毕。使用信号量后,代码变得非常简洁,不再需要单独的锁和状态变量 done

关键还是初始化。我们需要父线程在子线程调用 post 之前,在 wait 处阻塞。

因此,我们将信号量初始化为 0。如果父线程先调用 join 中的 sem_wait,会看到值为0而阻塞。当子线程调用 exit 中的 sem_post 将值加到1时,会唤醒父线程,使其继续执行。这实现了所需的同步顺序。


用锁和条件变量实现信号量 🧱

为了证明信号量与(锁+条件变量)的等价性,我们现在用锁和条件变量来实现一个信号量。

信号量内部需要维护:

  • 一个整数值 value
  • 一个互斥锁 lock,用于保护对 value 的访问。
  • 一个条件变量 cond,用于管理等待该信号量的线程队列。

初始化时,设置 value 为用户指定的初始值,并初始化锁和条件变量。

sem_wait 的实现:

void sem_wait(sem_t *s) {
    lock_acquire(&s->lock);
    while (s->value <= 0) {
        condition_wait(&s->cond, &s->lock); // 等待时会释放锁
    }
    s->value--;
    lock_release(&s->lock);
}

sem_post 的实现:

void sem_post(sem_t *s) {
    lock_acquire(&s->lock);
    s->value++;
    condition_signal(&s->cond); // 唤醒一个等待者
    lock_release(&s->lock);
}

在这个实现中,锁提供了对内部值 value 进行原子修改所需的互斥,而条件变量提供了线程等待和通知的机制。这清晰地展示了信号量如何结合了互斥和同步两种功能。


生产者-消费者问题(信号量版) 🏭➡️🛒

现在,我们用信号量来解决经典的生产者-消费者问题。

单缓冲情况

假设只有一个生产者和一个消费者,共享一个缓冲区。

生产者需要等待缓冲区为空,消费者需要等待缓冲区为满。我们使用两个信号量:

  • empty_buffers:表示空缓冲区的数量。
  • full_buffers:表示满缓冲区的数量。

初始化是关键:

  • empty_buffers 初始化为 1(因为一开始有1个空缓冲区)。
  • full_buffers 初始化为 0(因为一开始没有满缓冲区)。

这样,生产者可以先运行一次填充缓冲区,然后消费者才能运行取出缓冲区。

多缓冲(N个元素)情况

现在假设缓冲区有N个槽位,但仍然是单生产者和单消费者。

初始化变为:

  • empty_buffers 初始化为 N
  • full_buffers 初始化为 0

生产者可以连续运行N次填满所有缓冲区,然后消费者才能开始消费。当然,调度可以是交错的,生产者生产一些,消费者消费一些,只要不超过缓冲区容量即可。

多生产者/多消费者情况

这才是更现实的情况:多个生产者线程和多个消费者线程共享一个大小为N的缓冲区。

我们最初的直觉代码可能如下:

// 生产者
void producer() {
    while(1) {
        sem_wait(&empty_buffers); // 等空位
        int my_i = find_empty_buffer(); // 找一个空缓冲区索引
        fill_buffer(my_i); // 填充
        sem_post(&full_buffers); // 通知有满缓冲区
    }
}
// 消费者类似

这段代码有问题。find_empty_buffer 函数不是原子的。两个生产者可能同时找到同一个“空”缓冲区索引 my_i,导致数据被覆盖。我们需要互斥来保护查找并标记缓冲区的过程。

然而,简单地用锁包裹整个 find_empty_bufferfill_buffer 会限制并发性(同一时间只能有一个生产者操作缓冲区)。更好的做法是让临界区尽可能小,只保护查找和标记缓冲区状态的操作,而将实际的填充(fill_buffer)和消费(use_buffer)操作放在临界区外,因为这些操作可能比较耗时,且操作的是不同的缓冲区元素,可以并行。

因此,正确的结构需要结合信号量(用于资源计数)和互斥锁(用于保护缓冲区状态数组的查找和更新)。


哲学家就餐问题 🍽️

这是一个经典的同步问题,用于阐释如何避免死锁。问题描述:N个哲学家围坐圆桌,每个哲学家需要左右两把叉子才能吃饭,每把叉子被两个邻居共享。

简单(但有死锁)的方案

为每把叉子创建一个初始值为1的信号量。哲学家 i 的代码:

void philosopher(int i) {
    while(1) {
        think();
        sem_wait(&chopstick[i]); // 拿左边叉子
        sem_wait(&chopstick[(i+1)%N]); // 拿右边叉子
        eat();
        sem_post(&chopstick[i]); // 放左边叉子
        sem_post(&chopstick[(i+1)%N]); // 放右边叉子
    }
}

这个方案会导致死锁:如果所有哲学家同时拿起左边的叉子,那么每个人都会永远等待右边的叉子,形成循环等待。

避免死锁的方案

打破循环等待。例如,让大多数哲学家先拿左边叉子,再拿右边叉子,但让其中一个哲学家(比如编号最大的)以相反顺序拿取(先右后左)。这样可以避免全局性的循环依赖,但会限制并发性(同一时间可能只有少数哲学家能吃饭)。

最大化并发的方案(基于状态)

为了同时保证安全(无冲突)和活跃(最大化并发),我们为每个哲学家引入状态:THINKING, HUNGRY, EATING

安全要求:没有两个相邻的哲学家同时处于 EATING 状态。
活跃要求:如果一个哲学家是 HUNGRY,并且他的两个邻居都不在 EATING 状态,那么他应该能够进入 EATING 状态。

实现使用一个互斥锁 mutex 保护所有状态变量,并为每个哲学家设置一个信号量 may_eat[i],初始化为0。

当哲学家 i 想吃饭时:

  1. 持有 mutex,设置自己的状态为 HUNGRY
  2. 检查是否可以吃饭(即自己为 HUNGRY 且左右邻居都不在 EATING 状态)。
  3. 如果可以,设置自己的状态为 EATING,并调用 sem_post(&may_eat[i]) 将自己的信号量加1。
  4. 释放 mutex
  5. 调用 sem_wait(&may_eat[i])。由于第3步已经加了1,所以会立即通过,开始吃饭。

当哲学家 i 放下叉子时:

  1. 持有 mutex,设置自己的状态为 THINKING
  2. 检查左右邻居的活跃性:对于每个邻居,如果他处于 HUNGRY 状态且他的两个邻居都不在 EATING 状态,则将该邻居的状态设为 EATING,并调用 sem_post(&may_eat[邻居]) 唤醒他。
  3. 释放 mutex

这个方案通过集中管理状态,优雅地实现了安全性和最大程度的活跃性。


读者-写者锁 📖✍️

最后,我们简要介绍读者-写者锁。它与普通锁不同,允许多个读者同时持有锁(因为读操作不冲突),但只允许一个写者持有锁,并且写者持有锁时,不能有任何读者或其他写者。

实现读者-写者锁有多种策略,主要区别在于优先级:

  • 读者优先:只要还有读者在读,新来的读者可以直接加入,写者可能被饿死。
  • 写者优先:一旦有写者在等待,新来的读者必须等待,优先保证写者执行。

其实现思路与哲学家就餐问题有相似之处,需要跟踪读者和写者的数量,并使用信号量或条件变量进行协调。我们将在下一讲详细讨论。


总结 📚

本节课中,我们一起学习了:

  1. 信号量的概念及其 wait (P) 和 post (V) 操作。
  2. 信号量与锁+条件变量的功能等价性,并相互实现了对方。
  3. 使用信号量解决线程同步(Join)和生产者-消费者问题。
  4. 深入探讨了哲学家就餐这一经典同步问题,分析了死锁成因,并学习了基于状态、能最大化并发性的解决方案。
  5. 简要介绍了读者-写者锁的概念及其优先级问题。

信号量提供了一种将资源计数与线程等待/唤醒机制结合在一起的优雅抽象,是并发编程中的重要工具。理解其原理和等价性,有助于我们根据具体问题选择合适的同步原语。

014:并发编程(读者-写者锁、死锁)🔒

在本节课中,我们将学习并发编程的最后几个核心概念。我们将深入探讨如何使用信号量实现读者-写者锁,并分析并发程序中常见的原子性、顺序性和死锁问题及其解决方案。课程内容将帮助你巩固对并发原语的理解,并为实际编程中的并发问题提供解决思路。


课程回顾与公告 📢

上一节我们介绍了信号量和条件变量的基本概念。本节中,我们来看看如何应用这些知识解决更复杂的问题。

首先是一些课程公告:

  • 项目4已提交,整体进展顺利。
  • 项目5现已发布。与最初计划相比,该项目已大幅简化,更直接明了。这是本学期全新的项目,将于下周一截止。
  • 你可以选择新的项目伙伴。建议优先选择你熟悉且合作顺畅的人。
  • 第二次期中考试即将到来,主要涵盖所有并发主题,并少量回顾虚拟化内容。
  • 下次课程将花一些时间复习并发,然后开始进入下一个主题:I/O与持久化。


并发编程目标回顾 🔄

在并发编程中,我们通常追求两个核心属性:

  1. 互斥:确保一次只有一个线程或进程进入临界区执行特定代码。这通常通过来实现。
    • 公式/代码表示lock_acquire(&mutex);lock_release(&mutex); 之间的代码区域即为临界区。

  1. 顺序:确保代码B在代码A执行完毕后才运行。这可以通过条件变量信号量来实现。
    • 条件变量:用于在某个条件满足时唤醒等待的线程。
    • 信号量:通过一个内部整数值来协调线程间的执行顺序。


条件变量与信号量的核心区别 ⚖️

上一节我们对比了条件变量和信号量。本节中,我们来明确它们的核心区别:

  • 条件变量:本身不存储任何状态信息,仅作为一个等待队列。使用时必须与一个互斥锁关联。调用 cond_wait() 时必须持有该锁,此调用会释放锁并使线程进入睡眠,直到被 cond_signal() 唤醒。唤醒后,线程需要重新获取锁才能从 wait 返回。

  • 信号量:内部维护一个整数值,该值决定了信号量的行为。
    • 用于顺序控制时,通常初始化为 0。调用 sem_wait() 的线程会等待,直到其他线程调用 sem_post() 将值增加到 1 或以上。
    • 用于互斥时,通常初始化为 1。这确保了只有一个线程能通过 sem_wait(),其行为类似于锁。


哲学家就餐问题与死锁 🍽️

为了理解死锁,我们回顾了经典的哲学家就餐问题

问题描述:有N位哲学家围坐一桌,每人面前有一碗饭,每两人之间有一根筷子。哲学家需要同时拿到左右两边的筷子才能吃饭。

初始实现:每根筷子用一个初始值为 1 的信号量表示。哲学家先拿左边筷子,再拿右边筷子。

问题:当所有哲学家同时拿起左边的筷子时,每个人都会等待右边的筷子被放下,导致死锁——所有线程都持有部分资源并等待其他资源,系统无法推进。

解决方案一:打破循环依赖
让其中一位哲学家以相反的顺序获取筷子(先右后左)。这样就破坏了资源请求的循环等待条件,避免了死锁。

解决方案二:安全与活性检测(更优方案)
不直接为每根筷子加锁,而是引入一个中央协调机制。哲学家在“拿筷子”前,先在一个互斥锁保护下检查其左右邻居是否正在吃饭。只有邻居都不在吃饭时,他才被允许“吃饭”。这个方案通过一个额外的信号量来通知哲学家可以开始吃饭,实现了更高的并发度。


读者-写者锁 📖✍️

传统锁对所有线程一视同仁,但某些场景下,我们可以区分读者写者,以提升并发性能。

读者-写者锁规则

  • 多个读者可以同时持有锁(因为他们不修改数据)。
  • 一次只能有一个写者持有锁。
  • 写者持有锁时,不能有任何读者持有锁。

实现读者-写者锁时,需要权衡读者优先还是写者优先

读者优先的实现

以下是使用信号量实现读者优先锁的核心逻辑:

// 全局变量
sem_t lock;      // 保护读者计数的互斥信号量,初始化为1
sem_t writelock; // 写锁,初始化为1
int readers = 0; // 当前读者数量

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_82.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_84.png)

void acquire_readlock() {
    sem_wait(&lock);
    readers++;
    if (readers == 1) { // 第一个读者需要获取写锁
        sem_wait(&writelock);
    }
    sem_post(&lock);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_86.png)

void release_readlock() {
    sem_wait(&lock);
    readers--;
    if (readers == 0) { // 最后一个读者释放写锁
        sem_post(&writelock);
    }
    sem_post(&lock);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_88.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_89.png)

void acquire_writelock() {
    sem_wait(&writelock); // 写者直接竞争写锁
}

void release_writelock() {
    sem_post(&writelock);
}

工作流程

  1. 第一个读者会获取 writelock,阻止写者。
  2. 后续读者只需增加计数,可并发进入。
  3. 写者必须等待 writelock
  4. 最后一个读者离开时释放 writelock,唤醒等待的写者。

潜在问题:如果读者持续到达,写者可能被饿死

写者优先的实现

写者优先的实现更为复杂,它确保一旦有写者在等待,新到达的读者必须等待,直到所有等待的写者完成。

其核心思想是使用多个信号量(如 okToRead, okToWrite,初始化为0)和状态变量(activeReaders, waitingWriters等),在一个互斥锁保护下检查状态,并只在条件满足时对相应信号量调用 sem_post() 来唤醒等待的线程。释放锁时,会优先检查是否有等待的写者并唤醒他们。

两种策略的对比

  • 读者优先:可能饿死写者。
  • 写者优先:可能饿死读者。
  • 没有完美的策略,需根据具体应用场景(读多写少还是写多读少)进行选择。

现实中的并发BUG 🐛

编写正确的并发程序非常困难,现实中的大型软件(如MySQL、Apache)也存在并发漏洞。主要分为三类:

  1. 原子性违规:本应原子执行的操作被打断。
  2. 顺序违规:代码执行顺序不符合预期。
  3. 死锁:多个线程相互等待对方持有的资源。

原子性违规示例与修复

问题代码(MySQL中的一个真实Bug)

// 线程1
if (thd->proc_info) {
    ... // 使用 thd->proc_info
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_115.png)

// 线程2
thd->proc_info = NULL;

问题:线程1检查 proc_info 非空后,线程2将其置为 NULL,接着线程1继续使用它,导致空指针解引用。

修复:使用同一个锁保护检查和使用该指针的代码区域,确保其原子性。

顺序违规示例与修复

问题代码

// 线程1 (父线程)
void *mThread;
pthread_create(&mThread, NULL, worker, NULL);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_124.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_126.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs537-os/img/19b27f59ecfbe3f6a774612de786eaf9_128.png)

// 线程2 (子线程)
void *worker(void *arg) {
    pthread_t myTid = mThread; // 可能读到未初始化的 mThread!
    ...
}

问题pthread_create 返回并设置 mThread 之前,新创建的线程可能已经开始执行并读取 mThread

修复:使用条件变量确保顺序。父线程在设置 mThread 后发出信号,子线程在读取前等待该信号。

死锁示例与修复

死锁的四个必要条件

  1. 互斥:资源不能共享。
  2. 持有并等待:线程持有资源的同时等待其他资源。
  3. 不可抢占:资源不能被强制剥夺。
  4. 循环等待:存在一个线程资源的环形等待链。

简单死锁示例

// 线程1
lock(L1);
lock(L2);
...
// 线程2
lock(L2);
lock(L1);
...

修复:定义全局的锁获取顺序(例如,总是按内存地址从低到高获取),并遵循该顺序。

隐蔽的死锁示例(库函数)

void set_intersection(Set* s1, Set* s2) {
    lock(s1->lock);
    lock(s2->lock);
    // 计算交集...
    unlock(s2->lock);
    unlock(s1->lock);
}

问题:如果线程A调用 set_intersection(A, B),同时线程B调用 set_intersection(B, A),就可能形成 A等B锁,B等A锁 的死锁。

修复:同样,为所有 Set 的锁定义一个全局获取顺序(例如,比较锁对象的地址),并确保函数总是按此顺序加锁。


无锁编程:避免死锁的另一条路径 🛡️

消除“互斥”条件可以避免死锁。无锁编程利用硬件提供的原子原语(如 Compare-and-Swap, CAS)来构建并发操作,而无需使用传统的锁。

CAS操作伪代码

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected) {
        *ptr = new;
    }
    return actual; // 通常返回旧值,或一个表示是否交换成功的布尔值
}

使用CAS实现原子加法

void AtomicIncrement(int *value, int amount) {
    do {
        int old = *value;
        int new = old + amount;
    } while (CompareAndSwap(value, old, new) != old); // 如果value被其他线程修改,则重试
}

优点:避免了死锁。
缺点:可能出现活锁——线程不断重试却总失败。解决方案通常是引入指数退避等策略,在重试前等待一段时间。

无锁算法也被用于实现复杂数据结构,如无锁链表插入,这将在下次课程中详细讨论。


总结 📚

本节课中我们一起学习了:

  1. 读者-写者锁的原理与两种优先策略的实现,理解了在并发控制中权衡读者与写者权限的重要性。
  2. 现实并发程序中常见的三类BUG:原子性违规、顺序违规和死锁,并掌握了使用锁、条件变量和规定锁顺序等方法来修复它们。
  3. 无锁编程的基本思想,通过硬件原子指令(如CAS)来避免使用锁,从而从根本上防止死锁,同时也认识了其可能带来的活锁挑战。

并发编程是复杂但强大的工具。理解这些核心概念和模式,是构建正确、高效并发系统的关键。下次课程我们将完成无锁链表的讨论,并开始为期中考试进行复习。

015:并发、死锁与复习 🧠

在本节课中,我们将完成对并发主题的讨论,重点探讨死锁问题,并为下周的期中考试进行复习。我们将学习如何避免死锁,并回顾课程前半部分的核心概念。

项目与考试安排 📅

项目5现已发布,截止日期为下周一。该项目分为三个难度级别,旨在确保每位同学都能有所收获。期中考试将于下周三举行,考试内容主要涵盖并发和虚拟化,但也会包含部分与第一次期中考试相似的题目,以检验对前期知识的掌握。

并发同步原语回顾 🔄

上一节我们介绍了并发编程中的核心问题。本节中,我们来看看用于解决这些问题的两种主要同步原语。

互斥与排序

并发编程需要提供两种基本保障:

  1. 互斥:确保两个进程不会同时访问同一临界区。这可以通过信号量来实现。
  2. 排序:确保一个线程执行某些操作(如生成数据)后,另一个线程才能执行后续操作。这可以通过条件变量信号量来实现。

条件变量详解

条件变量用于线程间的等待与通知。使用条件变量时,必须持有一个相关联的互斥锁。

  • wait(cond, mutex): 调用此函数时,线程会释放互斥锁并进入睡眠状态,直到另一个线程对同一条件变量调用 signal
  • signal(cond): 唤醒一个正在等待该条件变量的线程。如果没有线程在等待,此调用不会产生任何效果,也不会被“记住”。

需要注意的是,从 wait 返回并重新获取锁的过程不是原子的。线程被唤醒后,可能因为锁被其他线程持有而需要等待。因此,通常需要在 wait 调用外使用一个 while 循环来重新检查条件是否真正满足,因为在线程被唤醒到重新获得锁的这段时间里,共享状态可能已经改变。

监视器

监视器是一种语言级别的同步机制,它封装了锁和条件变量,旨在减少程序员因显式调用锁操作而犯的错误。在支持监视器的语言中,使用 synchronized 关键字标记的方法会自动关联一个隐式锁,在方法开始时获取,在方法结束时释放。虽然监视器简化了编程,但死锁和排序等问题依然存在。

信号量

与条件变量不同,信号量本身具有状态(一个整数值)。其行为取决于初始值:

  • 初始值为 1:用作互斥锁
  • 初始值为 0:用作排序原语。第一个调用 wait 的线程将阻塞,直到另一个线程调用 post 将值增为 1。
  • 初始值为 N:用作计数信号量,可以跟踪资源数量(例如,生产者-消费者问题中空缓冲区或满缓冲区的数量)。

信号量的 waitpost 操作内部处理了锁的获取与释放。

并发错误与死锁 🚫

在复杂的多线程应用中,即使经过充分测试,也常出现并发错误。我们已讨论了竞态条件(缺乏互斥)和违反顺序的错误。现在,我们重点来看死锁。

死锁发生在多个线程互相等待对方持有的资源时,导致所有线程都无法继续执行。产生死锁需要同时满足四个必要条件:

  1. 互斥:资源一次只能被一个线程占用。
  2. 持有并等待:线程在持有一个资源的同时,等待获取另一个资源。
  3. 不可抢占:资源不能被强制从持有它的线程中夺走。
  4. 循环等待:存在一个线程资源的循环等待链。

只要破坏其中任何一个条件,即可预防死锁。以下是几种策略:

1. 破坏互斥条件:无锁数据结构

通过避免使用锁来消除互斥。这通常借助硬件提供的原子指令(如 compare-and-swap)来实现。

compare-and-swap 指令的伪代码描述如下:

int compare_and_swap(int *address, int expected, int new) {
    if (*address == expected) {
        *address = new;
        return 1; // 成功
    }
    return 0; // 失败
}

以下是一个无锁递增的例子:

void atomic_increment(int *value, int amount) {
    int old;
    do {
        old = *value; // 读取当前值
    } while (compare_and_swap(value, old, old + amount) == 0);
}

其工作原理是:不断尝试将 value 的值从读取到的 old 原子地更新为 old + amount。如果在此期间值被其他线程修改(即 *value != old),则 compare_and-swap 失败,循环重试。

无锁算法同样可以用于复杂数据结构的操作,例如在链表头部插入节点:

void insert(int value) {
    node_t *n = malloc(sizeof(node_t));
    n->value = value;
    do {
        n->next = head; // 记录当前头节点
    } while (compare_and_swap(&head, n->next, n) == 0); // 尝试将头指针指向新节点
}

其核心思想是:确保在将 head 指向新节点 n 时,head 的值仍然等于之前记录的 n->next。如果不相等,说明有其他线程修改了链表,操作需要重试。

2. 破坏持有并等待条件:全局锁(元锁)

要求线程在获取所有需要的资源之前,必须先获取一个全局的“元锁”。这样,同一时刻只有一个线程在尝试获取资源,从而避免了持有一个资源等待另一个资源的情况。缺点是严重限制了并发性。

3. 破坏不可抢占条件:尝试锁与回退

当线程无法立即获取所需的所有锁时,它主动释放已经持有的所有锁,等待一段时间后再重试。这可以避免死锁,但可能导致活锁——线程不断重复“获取-释放”的过程,看似在运行,实则没有进展。常见的解决方法是采用指数退避策略,逐渐增加重试前的等待时间。

4. 破坏循环等待条件:锁排序

为系统中所有的锁定义一个全局的、严格的获取顺序。线程必须按照这个顺序来申请锁。例如,在文件系统中,规定必须先获取目录锁,再获取文件inode锁,最后获取数据块锁。这是实践中最常用且有效的方法。

总结:对于死锁,初期的正确性比性能更重要。可以从使用粗粒度锁(如一个全局锁)开始,随着对系统理解的深入,再细化为更精细的锁策略。在复杂系统中,明确采用一种死锁预防策略(如无锁编程、锁排序)是至关重要的。

期中考试复习要点 📚

以下是需要重点复习的内容概览:

虚拟化部分

  • CPU虚拟化:进程抽象、上下文切换机制、调度策略(如轮转、多级反馈队列)。
  • 内存虚拟化:地址空间抽象、动态重定位(基址-界限)、分段、分页、多级页表、TLB、交换。

并发部分

  • 线程与进程:理解堆共享、栈和局部变量私有等区别。
  • 同步原语
    • 互斥:锁、信号量、监视器。
    • 排序:条件变量、信号量。
  • 锁的实现:自旋锁(基于原子指令)、阻塞锁(将线程放入队列)。
    • 选择策略:短临界区适合自旋,长临界区适合阻塞。
  • 经典同步问题
    • 生产者-消费者问题。
    • 读者-写者问题(读者优先 vs 写者优先实现)。
    • 哲学家就餐问题(简单防死锁版 vs 保证安全与活跃性的并发版)。
  • 死锁:四个必要条件及预防策略。

实践与代码分析

  • 能够分析涉及 fork 的进程创建代码,判断输出和变量值。
  • 能够分析多线程代码,区分共享变量(堆、全局变量)和私有变量(栈),并推理可能的执行结果和竞态条件。
  • 掌握基于“时间片”的代码执行模型,能够分析给定调度序列下,涉及锁、条件变量、信号量的代码执行路径和状态。

本节课中我们一起学习了死锁的原理与四种预防策略,并系统回顾了操作系统在虚拟化和并发方面的核心概念与机制,为应对期中考试做好了准备。

016:持久性 - I/O设备 🖥️💾

在本节课中,我们将要学习操作系统如何与I/O设备交互以实现数据持久性。我们将从I/O设备的基本交互协议开始,逐步探讨性能优化方法,并深入了解硬盘驱动器的工作原理及其性能特性。最后,我们将介绍几种磁盘调度算法,以优化I/O请求的处理顺序。


课程概述与公告 📢

首先是一些课程公告。项目三的成绩已公布在Canvas上,如有问题请联系助教。关于项目五的测试脚本,我们正在努力尽快发布,请关注后续通知。此外,期中考试将在周三晚上举行,主要考察并发性,复习材料已提供。


持久性简介与I/O的重要性 🔄

上一节我们介绍了虚拟化和并发性,本节中我们来看看课程的第三部分——持久性。持久性的核心问题是确保系统重启后数据依然可用。这涉及到操作系统如何与I/O设备交互,以及如何优化这些交互。

I/O至关重要。一个没有输入的程序每次只能执行相同的计算,而没有输出的程序则无法展示其结果。我们通常需要记录应用程序的状态,以便后续使用。


I/O设备交互基础 🛠️

操作系统需要与各种I/O设备通信,但又不希望为每种设备编写特定代码。因此,我们通过一组标准化的寄存器与设备交互。

以下是设备交互的基本概念:

  • 状态寄存器:用于读取设备当前状态(如忙碌或空闲)。
  • 命令寄存器:用于向设备写入要执行的操作命令(如读/写磁盘块)。
  • 数据区域:用于传输要读取或写入的实际数据。

设备内部有自己的硬件(如专用CPU和RAM)来执行具体功能。


基本I/O协议与优化 🔄

最初的I/O协议效率低下。当一个进程想进行I/O操作时,它需要循环读取状态寄存器(忙等待),直到设备空闲,然后写入数据和命令,接着再次忙等待操作完成。这期间CPU被完全占用,无法执行其他任务。

为了改进,我们引入了中断机制。进程在发起I/O请求后可以阻塞自身,让出CPU给其他进程运行。当设备完成操作后,会触发一个中断,操作系统随后唤醒等待的进程。这避免了CPU在等待期间的浪费。

然而,中断并非总是最佳选择。对于非常快速的设备,轮询(忙等待)可能更高效,因为上下文切换的开销可能超过等待时间。此外,中断洪流可能导致活锁,因此在处理中断时通常会暂时禁用其他中断。批处理中断请求也能提高效率。


直接内存访问 🚀

在基本协议中,CPU需要亲自将数据从内存复制到设备的寄存器,这称为编程式I/O。对于大数据传输(如网络或磁盘操作),这非常低效。

直接内存访问 解决了这个问题。使用DMA时,CPU只需告诉设备数据在内存中的位置(指针),专门的DMA引擎会负责在内存和设备之间传输数据,从而释放CPU去执行其他任务。

DMA涉及一些复杂问题,例如需要固定内存页面,以及地址是虚拟地址还是物理地址。


设备寻址方式与驱动程序 🔌

操作系统如何向设备的命令寄存器写入数据?主要有两种方式:

  1. 特殊指令:架构提供专门的in/out指令来访问特定端口。
  2. 内存映射I/O:将设备寄存器映射到进程的地址空间中。对特定内存地址的读写实际上是对设备寄存器的操作。页表条目中会有特殊标志位来标识这些区域不应被缓存。

由于设备协议各异,操作系统通过设备驱动程序来抽象底层细节。设备制造商负责提供驱动程序,这样操作系统核心就无需为每个新设备修改代码。


操作系统I/O栈 📚

应用程序通过系统调用与I/O子系统交互。整个I/O栈从上到下包括:

  1. 应用程序:在用户层运行。
  2. 文件系统:提供文件和目录等抽象。应用程序也可以通过原始设备接口直接访问存储块。
  3. 通用块层:为上层文件系统提供统一的线性块数组视图,并负责请求调度与合并。
  4. 设备驱动:包含与特定硬件设备交互的低级代码。

本节课我们将重点讨论栈底层的设备特性。


硬盘驱动器详解 💽

硬盘驱动器是机械设备,速度远慢于CPU和内存。访问磁盘需要毫秒级时间,而CPU在此期间可以执行数百万条指令。

磁盘将存储空间组织为线性扇区数组(通常每个扇区512字节),文件系统在此基础上构建文件和目录等抽象。

以下是硬盘的关键术语:

  • 盘片:存储数据的圆形碟片,双面都有记录介质。
  • 主轴:盘片围绕其旋转。
  • 磁道:盘片表面的同心圆环。
  • 柱面:所有盘片上相同半径的磁道组成的集合。
  • 扇区:磁道上可寻址的最小单元。
  • 磁盘臂/磁头:每个盘面有一个磁头,所有磁头安装在同一个磁盘臂上。

磁盘访问时间计算 ⏱️

一次磁盘读/写操作的时间由三部分组成:

  1. 寻道时间:将磁头移动到正确磁道所需的时间。
  2. 旋转时间:等待所需扇区旋转到磁头下方所需的时间。
  3. 传输时间:实际读取或写入数据所需的时间。

对于随机工作负载,平均寻道距离约为最大寻道距离的1/3。平均旋转时间是磁盘旋转半圈所需的时间。传输时间通常很短,可以忽略。

顺序工作负载(连续访问大块数据)性能很高,因为只需支付一次寻道和旋转开销,后续传输很快。
随机工作负载(访问分散的小数据块)性能很差,因为每次访问都要支付寻道和旋转开销。

通过计算可以量化这种差异。例如,一个磁盘顺序吞吐量可能达到100 MB/s,而随机吞吐量可能只有2.5 MB/s。


磁盘内部优化 🧠

现代磁盘采用了一些优化技术:

  • 磁道偏斜:相邻磁道的扇区起始位置会错开,以便磁头在切换磁道后,目标扇区不会“错过”,从而避免等待整整一圈旋转。
  • 分区记录:为了保持恒定的记录密度,外圈磁道会比内圈磁道容纳更多的扇区。因此,将顺序文件分配在外圈可以获得更高吞吐量。
  • 磁盘缓存
    • 磁道缓冲区:磁盘读取整个磁道数据到内部缓冲区,后续对同一磁道的请求可以直接从缓冲区读取,速度更快。
    • 写缓冲:写入操作可能先确认完成,但数据实际还留在磁盘缓存中,稍后才写入介质。这提高了速度,但在系统崩溃时可能导致数据丢失。对数据一致性要求高的应用需要禁用此功能。


I/O调度算法 🚦

当有多个I/O请求排队时,调度器决定其服务顺序,目标是最小化寻道和旋转时间。这与CPU调度不同,后者主要关注作业长度,而I/O调度关注请求的“空间”位置。

以下是几种调度算法:

先来先服务

  • 描述:按照请求到达的顺序服务。
  • 优点:公平。
  • 缺点:性能可能很差,完全取决于工作负载。

最短寻道时间优先

  • 描述:总是服务离当前磁头位置最近的请求(按磁道号)。
  • 优点:减少了寻道时间。
  • 缺点:可能导致饥饿,远离磁头的请求可能永远得不到服务。

最短定位时间优先

  • 描述:同时考虑寻道时间和旋转延迟,选择总定位时间最短的请求。
  • 优点:比SSTF更优。
  • 缺点:操作系统通常无法精确知道旋转状态,因此该算法主要由磁盘内部实现。同样存在饥饿问题。

电梯算法

  • 描述:磁头像电梯一样单向移动(如从外圈到内圈),服务沿途的所有请求,到达一端后反向移动。
  • 优点:避免了饥饿。
  • 缺点:对中间区域的请求响应较快,但对两端区域的请求公平性稍差。

循环扫描算法

  • 描述:磁头从一端移动到另一端并服务请求,到达另一端后立即快速返回起始端(不服务请求),然后开始新的扫描。
  • 优点:比普通电梯算法更公平,所有请求的等待时间上限更一致。

有限窗口调度

  • 描述:将请求分批(如每128个一批),在每批内部使用SSTF或SPTF等算法,但必须处理完当前批的所有请求后才能处理下一批。
  • 优点:通过调整窗口大小,可以在性能和公平性之间取得平衡。

最优调度

  • 描述:考虑所有请求的组合,找出总服务时间最短的顺序。
  • 现实:这是NP难问题,计算开销巨大,且无法预知未来请求,因此不实用。

贪婪算法(如SSTF、SPTF)并非最优,因为它们只关注下一步最短,可能留下一些代价高昂的请求最后处理。


总结 📝

本节课中我们一起学习了操作系统持久性中关于I/O设备的核心内容。我们了解了操作系统与设备交互的基本协议、如何通过中断和DMA进行优化,以及设备驱动程序和I/O栈的作用。我们深入探讨了硬盘驱动器的工作原理、性能特性及其访问时间的计算,认识到顺序访问与随机访问的巨大性能差异。最后,我们介绍了几种重要的磁盘调度算法,分析了它们在减少寻道时间、旋转延迟以及避免饥饿等方面的权衡。

在下一讲中,我们将继续探讨操作系统的I/O调度,并开始介绍其他类型的存储设备,如RAID或固态硬盘。

017:持久化 - RAID

在本节课中,我们将学习持久化存储的一个重要概念:RAID(独立磁盘冗余阵列)。我们将探讨为什么需要使用多个磁盘,RAID的不同级别(0、1、4、5),以及它们如何在可靠性、容量和性能等不同指标上表现各异。


课程概述

上一讲我们介绍了持久化的基础,并开始讨论实际的存储设备硬件。我们主要讨论了硬盘。本节中,我们将继续深入,探讨RAID技术。我们将了解使用多个磁盘的动机,以及如何通过不同的RAID级别来平衡可靠性、容量和性能。


RAID简介

RAID代表“独立磁盘冗余阵列”。其核心思想是,文件系统将看到一个单一的大型逻辑磁盘(一个线性块数组),而实际上数据被映射和分布在多个物理磁盘上。这种方式对上层文件系统完全透明,易于部署。

使用廉价磁盘而非单一昂贵磁盘的原因在于规模经济。大量生产的廉价组件成本更低。通过使用多个廉价磁盘并协同工作,我们可以获得更高的容量、更好的性能,并通过冗余提高可靠性。

RAID设备看起来像一个线性块数组,就像单个磁盘一样。然后,通过一个映射将逻辑块地址转换为物理磁盘上的块地址。与虚拟内存的动态映射不同,RAID使用静态映射,通常通过简单的算术计算来完成。

除了映射,RAID还引入了冗余。拥有更多磁盘意味着单个磁盘故障的概率增加。因此,在不同物理磁盘上保存数据的多个副本变得很重要。副本数量会影响可靠性、性能和空间效率。


RAID级别与评估指标

在本讲中,我们将讨论四个RAID级别:0、1、4和5。我们将通过不同的工作负载(顺序与随机、读与写)和指标(容量、可靠性、性能)来评估它们。

以下是评估时使用的通用变量:

  • N: 磁盘数量
  • C: 单个磁盘的容量
  • S: 单个磁盘的顺序吞吐量
  • R: 单个磁盘的随机吞吐量
  • D: 单个磁盘的延迟

RAID 0:条带化

RAID 0,也称为条带化,是最简单的方法,它没有冗余。文件系统看到的逻辑块被轮流(轮询)放置在所有磁盘上。

映射公式

  • 磁盘号 = 逻辑块地址 % N
  • 磁盘内偏移 = 逻辑块地址 / N

性能分析

  • 容量: N * C
  • 可容忍的磁盘故障数: 0
  • 延迟: D
  • 顺序吞吐量: N * S
  • 随机吞吐量: N * R

RAID 0在容量和性能(吞吐量)方面表现出色,但没有任何容错能力。任何一个磁盘故障都会导致数据丢失。


RAID 1:镜像

RAID 1通过镜像提供冗余。每个逻辑块在多个磁盘上保存完整的副本。通常,镜像会与条带化结合使用(例如RAID 10)。

我们假设磁盘是“故障即停”的:它们完全停止工作,不会返回错误数据。

性能分析

  • 容量: (N * C) / 2 (假设两两镜像)
  • 可容忍的磁盘故障数: 至少1个(取决于故障发生在哪组镜像上)
  • 延迟: D
  • 随机读吞吐量: N * R (所有磁盘都可服务读请求)
  • 随机写吞吐量: (N * R) / 2 (需要更新所有副本)
  • 顺序读/写吞吐量: 通常为 (N * S) / 2,但如果有很多并发的顺序请求,也可能达到 N * S

RAID 1提供了良好的可靠性,但以牺牲一半容量为代价。随机读性能优秀,但随机写性能会下降。

一致性问题:在写入过程中发生崩溃可能导致镜像对之间的数据不一致。昂贵的硬件RAID控制器使用非易失性RAM来记录操作(日志),以便在崩溃后恢复一致性。


RAID 4:专用奇偶校验磁盘

RAID 4引入了一种更空间高效的冗余方式:奇偶校验。它使用一个专用磁盘来存储奇偶校验信息,该信息是同一“条带”内其他所有数据块的异或(XOR)结果。

奇偶校验计算P = D0 XOR D1 XOR D2 ... XOR Dk
如果一个数据磁盘故障,可以通过读取其他所有数据盘和奇偶校验盘,并重新计算XOR来恢复丢失的数据。

写入放大问题:更新一个数据块需要更新对应的奇偶校验块。最直接的方法是读取所有其他数据块来计算新奇偶校验,这成本很高。优化方法是:读取旧数据、读取旧奇偶校验,计算数据的变化量,然后相应地更新奇偶校验。这导致一次逻辑写操作需要 两次读(旧数据、旧奇偶校验)和两次写(新数据、新奇偶校验)

性能分析

  • 容量: (N - 1) * C
  • 可容忍的磁盘故障数: 1
  • 延迟: 读为 D,写更高(涉及更多操作)
  • 顺序读/写吞吐量: (N - 1) * S (数据盘可并行工作,奇偶校验盘能跟上)
  • 随机读吞吐量: (N - 1) * R
  • 随机写吞吐量: 约 R / 2 (奇偶校验盘成为瓶颈,需处理两倍I/O)

RAID 4的主要问题是专用奇偶校验盘在随机写工作负载下成为严重瓶颈,性能甚至可能差于单个磁盘。


RAID 5:分布式奇偶校验

RAID 5解决了RAID 4的瓶颈问题,方法是将奇偶校验块循环分布到所有磁盘上,而不是固定在一个磁盘上。

布局策略:采用“左对称”布局,确保在进行大块顺序访问时,所有磁盘都能均匀参与,从而获得最大带宽。

性能分析

  • 容量: (N - 1) * C
  • 可容忍的磁盘故障数: 1
  • 延迟: 与RAID 4类似
  • 顺序读/写吞吐量: (N - 1) * S
  • 随机读吞吐量: (N - 1) * R (所有磁盘都包含数据,可服务请求)
  • 随机写吞吐量: (N * R) / 4 (每次逻辑写仍放大为4次物理I/O,但负载均匀分布,吞吐量随N增长)

RAID 5在可靠性、容量和性能(尤其是随机写)之间取得了很好的平衡,因此非常流行。


总结与对比

本节课中我们一起学习了四种RAID级别。关键结论是:没有一种RAID级别在所有方面都是最好的,选择取决于具体的工作负载和需求优先级。

  • 只追求最大容量和性能,不关心可靠性:选择 RAID 0
  • 追求最高可靠性和优秀读性能,能承受容量损失:选择 RAID 1(镜像)。
  • 寻求成本、可靠性和性能的良好平衡,适用于混合工作负载:选择 RAID 5
  • RAID 4 由于专用奇偶校验盘的瓶颈问题,通常不被采用。

RAID之所以有效,是因为它向上层文件系统提供了一个标准的块设备接口,使其无法察觉底层是单个磁盘还是磁盘阵列。这种透明性使得部署非常方便。值得注意的是,RAID可以构建在任何提供块接口的存储设备(如硬盘或下一讲将介绍的固态硬盘)之上。

018:持久化 - 文件系统API 🗂️

在本节课中,我们将继续学习文件系统的持久化部分,重点关注用户进程如何与文件系统交互。我们将学习文件系统API,包括文件的命名方式、目录的表示、硬链接与软链接的区别,以及如何确保数据真正写入磁盘。


RAID回顾

上一节我们介绍了RAID(独立磁盘冗余阵列)的不同级别。本节中,我们快速回顾一下核心概念,以便更好地理解存储性能。

RAID通过组合多个磁盘来提高性能、可靠性和容量。以下是几种常见RAID级别的关键特性:

  • RAID 0 (条带化):数据被分割并交替存储在多个磁盘上。它提供了最高的读写带宽,但没有冗余,任何磁盘故障都会导致数据丢失。
    • 性能公式:随机读/写带宽 = N * 单盘带宽;顺序读/写带宽 = N * 单盘带宽。
  • RAID 1 (镜像):数据被完整地复制到两个或多个磁盘上。它提供了良好的读取性能和容错能力,但写入性能较低,因为每次写入都需要更新所有副本。
    • 性能公式:随机读带宽 = N * 单盘带宽;顺序读带宽 ≈ (N/2) * 单盘带宽;随机/顺序写带宽 = (N/2) * 单盘带宽。
  • RAID 5 (带分布式奇偶校验的条带化):数据和奇偶校验信息分布在所有磁盘上。它比RAID 1更有效地利用存储空间,并能容忍单个磁盘故障。然而,随机写入性能较差,因为每次写入都需要更新数据和奇偶校验块。
    • 性能公式:随机读带宽 = (N-1) * 单盘带宽;顺序读带宽 = (N-1) * 单盘带宽;随机写带宽 ≈ (N/4) * 单盘带宽;顺序写带宽 = (N-1) * 单盘带宽。

理解这些性能差异有助于我们根据应用需求选择合适的存储方案。


文件系统API基础

现在,我们来看看用户进程如何与文件系统交互。文件系统为用户提供了一个抽象层,使得我们可以通过名称来组织和管理数据,而无需关心底层磁盘的细节。

什么是文件?

文件是文件系统的基本构建块。它是一个命名的字节序列,操作系统通过文件系统来管理这些文件的存储、检索和访问控制。现代系统(如数据库、键值存储)通常构建在文件抽象之上。

文件系统这个词有两种常见用法:

  1. 指代存储在磁盘上的文件和目录的静态集合(例如,“我的文件系统”)。
  2. 指代操作系统中负责管理文件和目录的软件组件。

本节课我们主要讨论本地文件系统,如Linux上的ext2、ext4等。尽管底层实现不同,但它们向用户提供的文件抽象是相似的。


文件的命名方式

用户需要通过名称来访问文件。文件系统内部使用一种称为inode号的标识符来唯一标识每个文件。我们将探讨三种命名文件的方式,最终的文件描述符方法结合了前两者的优点。

方式一:通过Inode号

每个文件在文件系统中都有一个唯一的inode号。Inode是存储文件元数据(如大小、权限、数据块位置等)的数据结构。你可以通过ls -i命令查看文件的inode号。

$ ls -i
1234567 hello.txt

为什么这不是一个好接口?

  • 人类不擅长记忆数字。
  • 缺乏组织性,无法通过有意义的名称来管理文件。
  • 需要处理多个进程或线程访问同一文件时的偏移量问题。

尽管用户不直接使用inode号,但它是文件系统内部的核心构建块。

方式二:通过路径名

我们更习惯使用有意义的路径名来访问文件。文件系统通过目录来实现从路径名到inode号的映射。

目录是什么?
目录本质上是一个特殊的文件,其内容是一系列映射条目,将文件名关联到对应的inode号。例如,一个目录条目可能包含 ("hello.txt", 1234567)

路径名解析过程
当用户请求打开文件 /etc/bashrc 时,文件系统需要执行路径名遍历:

  1. 从根目录(通常inode号为2)开始,读取其inode和目录数据。
  2. 在根目录数据中查找 etc,找到其inode号。
  3. 读取 etc 目录的inode和数据。
  4. etc 目录数据中查找 bashrc,找到其inode号。
  5. 读取 bashrc 文件的inode和数据。

这个过程可能需要多次磁盘I/O,效率较低。每个路径分量(目录)都需要两次读取(inode和数据)。

目录的特殊条目
每个目录都包含两个特殊条目:

  • . (点):指向当前目录本身。
  • .. (点点):指向父目录。

这使得在目录树中导航变得简单。

方式三:通过文件描述符(推荐方式)

为了优化性能,我们只在首次访问文件时执行昂贵的路径名解析。操作系统提供了文件描述符抽象。

工作流程

  1. 用户调用 open("/path/to/file", ...)
  2. 操作系统执行路径名遍历,找到文件的inode。
  3. 操作系统在进程的打开文件表中创建一个条目(称为文件描述符对象),记录inode指针和当前读写偏移量(初始为0)。
  4. 操作系统返回一个整数(文件描述符),作为该条目在进程文件描述符数组中的索引。
  5. 后续的 read, write, lseek 操作都使用这个文件描述符,无需再次解析路径。

文件描述符的共享

  • 同一进程内多次打开同一文件:每次 open 都会创建一个新的文件描述符对象,拥有独立的偏移量。
  • 使用 dup 复制文件描述符dup 创建的新文件描述符指向同一个文件描述符对象,因此共享偏移量。
  • fork 创建子进程:子进程继承父进程的所有文件描述符,它们指向相同的文件描述符对象,因此共享偏移量。

文件偏移量与 lseek
lseek 系统调用用于移动文件的当前读写位置。

off_t lseek(int fd, off_t offset, int whence);
  • whence 参数:
    • SEEK_SET:将偏移量设置为 offset
    • SEEK_CUR:将偏移量设置为当前位置加上 offset
    • SEEK_END:将偏移量设置为文件末尾加上 offset

链接:硬链接与软链接

链接允许一个文件有多个名称。有两种类型的链接:硬链接和软链接(符号链接)。

硬链接

硬链接是目录中指向同一inode的多个条目。创建硬链接会增加inode的引用计数

创建硬链接

$ echo "Hello" > hello.txt
$ ln hello.txt goodbye.txt  # 创建硬链接
$ ls -i hello.txt goodbye.txt
1234567 hello.txt
1234567 goodbye.txt  # 相同的inode号
$ cat goodbye.txt
Hello
$ echo "World" >> goodbye.txt
$ cat hello.txt
Hello
World

删除文件
删除一个文件名(使用 unlink 系统调用或 rm 命令)只会减少inode的引用计数。只有当引用计数降为0时,文件的数据块才会被真正释放。

限制:不能创建指向目录的硬链接,因为这可能导致目录树中出现循环,使得遍历工具陷入无限循环。

软链接(符号链接)

软链接是一个特殊的文件,其内容是要链接到的目标文件的路径名。它有自己的inode,其中设置了一个特殊标志位表明它是符号链接。

创建软链接

$ echo "Hello" > hello.txt
$ ln -s hello.txt link_to_hello  # 创建软链接
$ ls -l link_to_hello
lrwxrwxrwx ... link_to_hello -> hello.txt
$ cat link_to_hello
Hello

与硬链接的关键区别

  1. 基于名称:软链接存储的是路径名。如果目标文件被移动或重命名,软链接就会“断裂”(成为悬空链接)。
  2. 可以链接到目录:因为文件系统能识别符号链接的特殊性,并在遍历时进行特殊处理,通常工具可以选择不跟随符号链接以避免循环。
  3. 不影响引用计数:创建或删除软链接不会影响目标文件的inode引用计数。

软链接断裂示例

$ rm hello.txt
$ cat link_to_hello
cat: link_to_hello: No such file or directory


数据持久化:缓冲区缓存与 fsync

为了提高性能,操作系统使用缓冲区缓存在内存中缓存最近读写过的磁盘块。

  • 读取:如果数据在缓存中,则直接从内存返回,无需访问磁盘。
  • 写入:当进程调用 write 时,数据通常只被写入缓冲区缓存,函数就返回了。操作系统会在后台某个时间点将脏数据写回磁盘。

这种延迟写入策略带来了性能提升,但也意味着数据可能不会立即持久化到磁盘上。

fsync 系统调用
如果你的应用程序需要确保数据已经物理写入磁盘(例如,数据库事务),你必须显式调用 fsync

int fsync(int fd); // 确保文件描述符fd关联的所有数据写入磁盘
  • close 一个文件描述符不会自动触发 fsync。数据可能仍在缓冲区缓存中。
  • 即使调用了 fsync,数据也可能停留在磁盘自身的缓存中。要确保数据写入物理介质,可能需要额外的磁盘配置。

原子重命名 rename
rename 系统调用用于原子地更改文件名。它在许多应用程序中用于实现原子更新:

  1. 将新数据写入一个临时文件(如 file.txt.tmp)。
  2. 调用 fsync 确保临时文件数据落盘。
  3. 调用 rename("file.txt.tmp", "file.txt") 原子地将临时文件替换为原文件。

这样,其他进程读取 file.txt 时,要么看到完整的旧版本,要么看到完整的新版本,永远不会看到处于不一致中间状态的文件。


其他重要概念

权限与访问控制

文件inode中存储了权限位,控制谁可以读、写或执行该文件。权限分为三组:文件所有者、所属组和其他用户。

更复杂的分布式文件系统(如AFS)使用访问控制列表,可以提供更精细的权限控制。

挂载文件系统

Linux等系统允许将不同的存储设备或网络文件系统透明地集成到单一的目录树中,这通过 mount 操作实现。例如,你的家目录(/home/yourname)可能位于网络文件系统(AFS)上,而临时目录(/tmp)则位于本地磁盘上。用户无需关心数据实际存储在哪里。


总结

本节课我们一起学习了文件系统API的核心内容:

  1. 文件命名:理解了inode作为文件系统内部标识符的作用,以及用户如何通过路径名和文件描述符来访问文件。
  2. 目录与遍历:目录是将文件名映射到inode号的特殊文件。路径名解析可能涉及多次磁盘I/O。
  3. 文件描述符:这是高效访问文件的关键。open 返回文件描述符,后续操作使用它,避免了重复的路径解析。我们学习了文件描述符的创建、复制(dup)、继承(fork)以及偏移量管理(lseek)。
  4. 链接:区分了硬链接(多个目录条目指向同一inode,增加引用计数)和软链接(存储目标路径名的特殊文件)。硬链接不能用于目录,而软链接可以。
  5. 数据持久化:了解了缓冲区缓存如何提升性能,以及为什么需要调用 fsync 来确保数据真正写入磁盘。还学习了如何使用 rename 实现原子文件更新。
  6. 其他概念:简要介绍了文件权限和文件系统挂载。

这些知识是进行系统级编程的基础。下一讲,我们将深入文件系统内部,探讨inode的详细结构、数据块分配策略以及文件系统如何在磁盘上组织这些元数据。

019:文件系统结构 🗂️

在本节课中,我们将深入探讨文件系统的内部工作原理,学习其核心数据结构、如何进行空间分配以及如何执行实际的文件操作。我们将从回顾文件系统API开始,逐步深入到磁盘上的数据结构,并分析不同文件分配策略的优缺点。


回顾:文件系统API与文件描述符

上一节我们介绍了文件系统为用户提供的接口。用户通过路径名打开文件,系统返回一个文件描述符。后续的读写操作都使用这个文件描述符作为快捷方式。

打开文件是一个相对昂贵的操作,因为它需要遍历路径、读取目录项并查找对应的索引节点(inode)。一旦获得文件描述符,它就指向了特定的inode和该进程当前的读写偏移量。

每个打开的文件描述符都关联一个独立的偏移量。如果同一个文件被打开两次,即使在同一进程中,也会创建两个独立的描述符对象,拥有各自的偏移量。而通过 dup 系统调用复制的文件描述符则会共享同一个描述符对象和偏移量。

删除文件(如使用 rm 命令或 unlink 系统调用)实际上并不立即删除数据。它只是移除目录中的一个名称(目录项),并减少对应inode的引用计数。只有当引用计数(包括硬链接和打开的文件描述符)降为零时,文件系统才会真正释放该inode及其数据块。


文件数据块分配策略

本节中,我们来看看如何将文件映射到磁盘上的数据块。这与操作系统将逻辑地址空间映射到物理内存的任务非常相似。我们将磁盘视为一个线性的块数组(例如,64个块,每个块4KB),并探讨几种分配策略。

以下是几种不同的文件数据块映射方法及其评估:

1. 连续分配

  • 方法:每个文件的数据块必须在磁盘上连续存放。
  • 元数据:inode中只需记录起始块号和文件大小(块数)。
  • 优点
    • 顺序访问性能极佳:数据在磁盘上物理连续。
    • 随机访问计算快:通过起始块号+偏移量即可直接定位。
    • 元数据开销极小
  • 缺点
    • 外部碎片严重:难以找到足够大的连续空间分配新文件或扩展现有文件。
    • 文件增长困难:除非文件末尾有连续空闲空间,否则无法扩展。

2. 区段(Extent)分配

  • 方法:允许文件由少量(如2-6个)连续的数据块区域(区段)组成。
  • 元数据:inode中记录每个区段的起始块号和大小。
  • 优点
    • 相比连续分配,碎片化问题有所改善
    • 顺序访问性能仍然较好(取决于区段数量)。
    • 随机访问计算快
    • 元数据开销适中
  • 缺点
    • 区段数量固定且较少,文件增长能力依然有限

3. 链表分配

  • 方法:将文件数据块组织成链表。每个数据块末尾包含一个指向下一个块的指针。
  • 元数据:inode中只需记录第一个数据块的块号。
  • 优点
    • 无外部碎片:可以利用任何空闲块。
    • 文件增长容易
  • 缺点
    • 随机访问性能极差:必须从链表头开始遍历才能找到目标块。
    • 空间浪费:每个数据块都需存储指针,减少了有效数据空间。
    • 顺序访问性能取决于数据块在磁盘上的实际分布。

4. 文件分配表(FAT)

  • 方法:链表分配的改进。将所有块的“下一个指针”集中存储在一个独立的文件分配表(FAT)中,数据块内不再存储指针。
  • 元数据:inode记录起始块号。系统维护一个全局FAT,其每个条目对应一个磁盘块,存储该块所属文件的下一个块号(或结束标记)。
  • 优点
    • 继承了链表分配的优点(无外部碎片,易于增长)。
    • 若FAT常驻内存,随机访问性能得到改善(只需在内存中遍历FAT)。
  • 缺点
    • FAT本身需要持久化存储,占用磁盘空间。
    • 若FAT未缓存,随机访问可能需两次磁盘读(读FAT条目 + 读数据块)。

5. 索引分配(多级索引)

  • 方法:inode直接包含指向数据块的指针数组。为支持大文件,采用多级索引树。
  • 元数据(以经典UNIX FFS为例)
    • inode包含:12个直接数据块指针、1个一级间接块指针、1个二级间接块指针、1个三级间接块指针。
    • 间接块是专门存储数据块指针的完整磁盘块。
  • 优点
    • 无外部碎片
    • 文件增长灵活,支持超大文件。
    • 随机访问性能好:通过计算即可定位所需索引层级。
    • 优化了小文件:小文件仅使用直接指针,无需额外间接块开销。
  • 缺点
    • 大文件需要多次间接访问,可能引入轻微性能开销(但间接块可被缓存)。
    • 元数据结构相对复杂。

多级索引容量计算示例
假设块大小4KB,指针4字节。

  • 直接指针:12 * 4KB = 48KB
  • 一级间接:1 * (4KB / 4B) * 4KB = 1 * 1024 * 4KB ≈ 4MB
  • 二级间接:1 * 1024 * 1024 * 4KB ≈ 4GB
  • 三级间接:1 * 1024 * 1024 * 1024 * 4KB ≈ 4TB
    总支持文件大小可达数TB级别。

现代文件系统(如EXT2/3/4的基础)大多采用此类多级索引或其变种(如Extent树)来平衡大小文件的性能。


磁盘上的文件系统结构

了解了分配策略后,我们来看看这些数据结构在磁盘上是如何布局的。一个文件系统映像通常包含以下部分:

超级块(Superblock)

存储整个文件系统的元数据,如块大小、inode总数、数据块总数、各种结构(如位图、inode表)的起始位置等。系统启动时首先读取超级块来了解文件系统布局。

inode位图(inode Bitmap)

一个位图,每个位对应一个inode,表示该inode是空闲(0)还是已分配(1)。用于快速查找和分配空闲inode。

数据块位图(Data Block Bitmap)

一个位图,每个位对应一个数据块,表示该数据块是空闲(0)还是已分配(1)。用于管理数据块空间。

inode表(inode Table)

一个连续的区域,存储所有inode。每个inode大小固定(如128或256字节),因此一个4KB的磁盘块可以存放多个inode(如32个)。对单个inode的读写必须在其所在的整个磁盘块层面进行。

数据块区域(Data Blocks)

文件系统的大部分空间。用于存储:

  • 文件内容
  • 目录内容(本质上是一种特殊文件,存储<文件名, inode号>的列表)
  • 间接块、二级间接块等(用于多级索引)

inode内容详解
一个inode通常包含以下信息:

  • 文件类型:普通文件、目录、符号链接等。
  • 权限:读、写、执行权限。
  • 所有者与组ID
  • 文件大小(字节)
  • 占用块数
  • 时间戳:访问时间、修改时间、状态改变时间等。
  • 链接计数:指向此inode的硬链接数量。
  • 数据块指针:直接指针和间接指针。


文件系统操作流程示例

让我们通过几个具体操作,看看文件系统如何运用上述数据结构。

操作1:创建文件 /foo/bar

目标:在目录/foo下创建一个新的空文件bar

  1. 读根目录inode:已知根目录inode号(通常为2),读取其inode。
  2. 读根目录数据块:根据inode中的指针,读取根目录内容,查找名为foo的目录项,获得其inode号。
  3. foo目录inode
  4. foo目录数据块:查找是否已存在名为bar的条目。
  5. 读inode位图:查找一个空闲的inode,假设找到inode k
  6. 写inode位图:将inode k对应的位标记为已分配,并写回磁盘。
  7. 初始化inode k:读取inode k所在的磁盘块到内存,初始化其字段(类型为文件,链接计数为1,大小为0等),然后写回磁盘。
  8. 更新foo目录:在内存中的foo目录数据块里添加条目<bar, k>,然后写回磁盘。

操作2:打开文件 /foo/bar

目标:获取文件bar的文件描述符。

  1. 重复操作1的步骤1-4,找到bar的inode号 k
  2. 读inode k:将inode信息载入内存。
  3. 创建内核文件描述符对象:在内存中创建一个结构,包含指向inode k的指针和当前偏移量(初始为0)。
  4. 分配文件描述符:在进程的文件描述符表中找一个空闲槽位,指向刚创建的内核对象,并将该槽位索引(如3)返回给用户进程。
    注意:打开操作本身通常不修改任何磁盘上的持久化数据(除非更新访问时间,但常被禁用以提高性能)。

操作3:向文件 /foo/bar 写入数据

前提:文件已打开,已知其inode k和当前偏移量。

  1. 读inode k(可能已缓存):获取当前文件大小和已有的数据块指针。
  2. 读数据块位图:查找空闲数据块,假设找到块 m
  3. 写数据块位图:将块 m 标记为已分配,写回磁盘。
  4. 写数据块 m:将用户数据写入磁盘块 m
  5. 更新inode k
    • 增加文件大小。
    • 在适当的数据块指针位置(根据偏移量计算)添加指向块 m 的指针。如果文件增长需要新的间接块,还需分配并初始化间接块。
    • 将修改后的inode写回磁盘。

操作4:关闭文件 /foo/bar

在磁盘层面无需进行任何操作。关闭操作仅在内存中进行:

  • 释放进程文件描述符表中的对应槽位。
  • 减少内核文件描述符对象的引用计数。如果引用计数降为零,则释放该内存对象。
  • 文件系统的持久化状态(磁盘数据)不受影响。

性能考量与缓存

显然,如果每个文件操作都触发多次磁盘读写,性能将无法接受。因此,操作系统广泛使用缓存:

  • 缓冲区缓存(Buffer Cache):缓存最近访问过的磁盘块(包括inode、目录数据、间接块、文件数据、位图等)。后续访问若命中缓存,则无需磁盘I/O。
  • 延迟写入(Delayed Write):对元数据(如inode、位图)的修改可能在内存中缓存一段时间,批量写回磁盘,以提高性能并减少磨损(对SSD尤其重要)。但这带来了数据一致性问题,需要通过日志(Journaling)或写时复制(Copy-on-Write)等技术来保证崩溃一致性,这将是后续课程的主题。

本节课中我们一起学习了文件系统的核心磁盘数据结构,包括超级块、位图、inode表和数据块区域。我们详细分析了多种文件数据块分配策略,并重点讲解了现代文件系统普遍采用的多级索引方法。最后,我们通过一系列操作示例,了解了文件系统如何协调这些数据结构来完成创建、打开、读写和关闭文件等任务。理解这些基础是后续学习文件系统性能优化、崩溃一致性及日志机制的关键。

020:持久化 - 伯克利快速文件系统 (FFS) 🚀

在本节课中,我们将学习伯克利快速文件系统(FFS)。我们将探讨如何通过测量现有系统、识别问题并进行优化来改进一个复杂的系统。课程将涵盖FFS如何解决旧版Unix文件系统的性能瓶颈,包括块大小选择、内部碎片处理以及数据块和索引节点的分配策略。


操作系统:第20讲:文件系统结构回顾 📚

上一节我们介绍了文件系统的基本概念,本节中我们来回顾一下文件系统的核心数据结构,为理解FFS的优化奠定基础。

一个典型的文件系统镜像包含以下部分:

  • 超级块:描述整个文件系统的元数据,例如块大小、索引节点总数及其位置。
  • 位图:一种高效的数据结构,用于追踪哪些索引节点或数据块是空闲的。每个位对应一个资源(索引节点或数据块),0表示空闲,1表示已分配。
  • 索引节点表:存储所有索引节点的区域。
  • 数据块:存储文件实际内容、目录条目以及间接指针块。

大多数磁盘空间都分配给了数据块。数据块可以存储三种内容:

  1. 用户文件数据。
  2. 目录数据(即文件名到索引节点号的映射)。
  3. 间接指针块(包含指向其他数据块的指针)。

操作系统:第20讲:索引节点与目录结构 🔍

理解了整体布局后,我们来看看描述单个文件的索引节点结构。

一个索引节点存储了文件的元数据,如类型、所有者、大小等。最关键的部分是指向文件数据块的指针。其结构通常包括:

  • 直接指针:直接指向数据块的指针,数量固定(例如12个)。
  • 间接指针:指向一个特殊的“间接块”,该块本身存储了更多指向数据块的指针。这允许文件超越直接指针的数量限制。
  • 双重间接指针:指向一个“双重间接块”,该块包含指向多个间接块的指针,从而支持更大的文件。
  • 三重间接指针(某些系统):进一步扩展文件大小。

这种分层结构非常高效:小文件可以通过直接指针快速访问,而大文件则可以通过间接指针扩展,无需为小文件浪费过多元数据。

索引节点通常小于磁盘块,因此一个磁盘块可能包含多个索引节点。这意味着修改一个索引节点时,需要读取整个块,在内存中修改目标索引节点,然后将整个块写回磁盘。

接下来,我们看看目录是如何组织的。

目录本质上是一种特殊文件,其数据块中存储的是目录条目。每个目录条目包含一个文件名索引节点号的映射。当你在目录中查找文件时,系统会遍历这些条目以找到匹配的文件名,然后使用对应的索引节点号来访问文件的实际数据和元数据。

当删除一个文件(unlink操作)时,系统会从目录中移除对应的条目,并减少该索引节点的引用计数。如果引用计数降为0,表示没有其他名称指向该文件,系统便会释放该索引节点及其所有数据块。


操作系统:第20讲:文件系统操作流程回顾 ⚙️

在深入FFS的优化之前,让我们通过几个核心操作来巩固对文件系统工作流程的理解。以下是执行常见操作所需的磁盘I/O步骤:

创建新文件(例如 /foo/bar

  1. 路径遍历:读取根目录的索引节点和数据块,找到foo的索引节点号。读取foo的索引节点和数据块。
  2. 分配索引节点:读取索引节点位图,找到一个空闲位,将其标记为已分配,然后写回位图。
  3. 初始化索引节点:读取包含新索引节点(bar)的磁盘块,初始化元数据(大小、引用计数等),然后写回该块。
  4. 更新父目录:将新条目(bar -> 索引节点号)写入foo目录的数据块。
  5. 更新父目录元数据:更新foo索引节点中的目录大小信息。

打开现有文件(例如 /foo/bar

  1. 路径遍历:与创建文件的第一步相同,目的是获取文件bar的索引节点号。
  2. 获取索引节点后,即可在内存中创建文件对象,后续的读写操作都直接通过该索引节点进行,无需再次遍历路径。

向文件写入数据(追加写入)

  1. 检查空间:根据文件当前大小和待写入数据量,判断是否需要分配新数据块。
  2. 分配数据块(如需):读取数据块位图,找到空闲块,标记后写回位图。
  3. 写入数据:将数据写入新分配的数据块。
  4. 更新索引节点:更新索引节点中的文件大小,并(如果分配了新块)添加指向新数据块的指针。

如果写入的数据能放入文件的最后一个数据块(未写满的部分),则无需分配新块,但可能需要读取旧数据块、合并数据后再写回,并更新索引节点中的文件大小。

读取文件

  1. 通过索引节点找到数据块指针。
  2. 从磁盘读取相应的数据块。

关闭文件

  • 关闭操作通常只释放内存中的数据结构,不涉及磁盘I/O。

操作系统:第20讲:旧版Unix文件系统的问题与FFS的诞生 🐌

上一节我们回顾了基础操作,本节中我们来看看FFS要解决的具体问题。FFS的开发过程是系统构建的典范:测量现有系统,识别瓶颈,然后进行改进。

当时使用的旧版Unix文件系统(有时称为“系统V文件系统”)存在严重性能问题。在完全顺序读写的工作负载下,其带宽仅能达到磁盘峰值带宽的2%。主要原因有三点:

  1. 老化与碎片化:系统使用空闲链表来管理空闲块。初始时链表有序,能分配连续块。但随着文件不断创建和删除,释放的块被简单地加到链表头部,导致链表变得杂乱无章。后续分配时,一个连续文件的数据块可能散布在磁盘各处,造成大量随机寻道,性能急剧下降。
  2. 块大小过小:数据块大小仅为512字节(一个扇区)。虽然小尺寸减少了内部碎片(未使用的块内空间),但意味着读写任何数据都需要更多的I/O操作和寻道。
  3. 布局不佳:索引节点表集中在磁盘开头,数据块在之后。访问文件时,需要在索引节点区域和数据块区域之间进行长距离寻道。

FFS的目标就是创建一个“磁盘感知”的文件系统,其设计充分考虑磁盘的机械特性(寻道时间长,顺序访问快)。


操作系统:第20讲:FFS的核心优化:位图与柱面组 🗺️

针对旧系统的问题,FFS引入了几项关键创新。

首先,FFS用位图取代了空闲链表。位图可以快速查找连续的空闲块,并且更新(分配或释放)效率很高。读取一个4KB的位图块就能掌握32768个块的状态,便于在内存中快速搜索。

其次,也是最重要的创新,是引入了柱面组(现代文件系统中常称为“块组”)的概念。FFS将整个磁盘划分为多个柱面组,每个组都像一个完整的迷你文件系统,拥有自己的超级块副本、索引节点位图、数据块位图、索引节点表和一部分数据块。

超级块复制:每个组都保存一份超级块副本,这提高了文件系统的可靠性,防止因单个超级块损坏导致整个磁盘无法访问。

这种设计的核心思想是将相关的数据放在一起,以减少寻道时间:

  • 将文件的索引节点与其数据块放在同一个组内。
  • 将同一目录下的所有文件的索引节点放在同一个组内(便于ls -l等操作)。

操作系统:第20讲:FFS的智能分配策略 🧠

有了柱面组的结构,接下来需要制定具体的分配策略来决定将新数据放在哪个组、哪个块。

文件创建策略

  • 新文件的索引节点应与其父目录的索引节点放在同一个柱面组中。
  • 新文件的数据块应尽可能靠近其索引节点分配,并尽量保持连续性。

目录创建策略

  • 当在一个目录下创建新的子目录时,该子目录的索引节点应放入一个新的、较空的柱面组。这样做的目的是将不同的目录树分支分散开,避免单个组被塞满。选择新组时,会优先选择空闲索引节点较多的组。

大文件处理策略

  • 对于非常大的文件,如果将其所有数据块都放在一个组里,会挤占该组空间,影响同目录下其他小文件的性能。
  • 因此,FFS规定,当文件需要分配间接块时(即文件大小超过了直接指针能寻址的范围),就将后续的数据块分配到一个新的柱面组中。选择新组时,会优先选择空闲数据块较多的组。
  • 这样,大文件的数据被分段存储在不同的组里。虽然段与段之间需要寻道,但每一段内部是连续读取的,能够很好地分摊寻道开销。这比让许多小文件被迫去其他组访问数据要高效。

操作系统:第20讲:FFS的其他特性与总结 🏁

除了核心的组结构和分配策略,FFS还引入了其他重要特性:

片段:为了解决大块(如4KB)导致小文件内部碎片严重的问题,FFS允许将块进一步划分为更小的片段(如1KB)。文件末尾不足一个块的部分可以只占用一个片段,并与其他文件的片段共享同一个物理块。这需要在位图中追踪片段的分配状态。

现代影响:FFS的设计思想(位图、块组、智能分配)深刻影响了后来的文件系统,如Linux的Ext2/Ext3/Ext4系列。

性能考量:FFS的设计体现了“基于底层特性进行设计”的原则。对于机械硬盘,它优化了顺序访问,减少了寻道。当我们为其他存储介质(如下一讲会提到的闪存)设计文件系统时,就需要根据其不同的性能特征(快速随机读、擦除块限制等)采取不同的优化策略。


本节课中我们一起学习了伯克利快速文件系统的设计精髓。我们回顾了基础文件系统结构,分析了旧系统性能低下的原因,并详细探讨了FFS如何通过引入位图、柱面组和智能分配策略来解决这些问题,从而构建了一个真正磁盘感知的高性能文件系统。理解这些原理是学习更高级主题(如下一讲的文件系统一致性)的重要基础。

021:崩溃一致性 💥

在本节课中,我们将要学习操作系统文件系统中的一个核心挑战:崩溃一致性。当系统在更新文件系统数据结构的中间阶段意外崩溃(如断电、内核恐慌)时,如何确保磁盘上的文件系统状态仍然保持一致,而不是处于一个损坏或矛盾的中间状态。我们将探讨两种主要方法:离线文件系统检查器(FSCK)和日志记录(Journaling),并理解它们如何工作以及各自的优缺点。


课程安排与项目更新 📅

上一讲我们讨论了文件系统的基本操作,但忽略了操作的顺序问题。本节中,我们来看看如何通过特定的协议来保证这些操作的原子性,从而确保崩溃一致性。

以下是近期的课程安排:

  • 项目6:正在进行中,将于本周五截止。
  • 项目7:最后一个项目,将基于XV6文件系统,可能涉及实现更多现代文件系统功能或文件系统检查器,预计在课程最后一天截止。
  • 补考测验:针对项目4得分较低的同学开放。
  • 内容测验:现有关于文件系统操作的测验,有助于准备考试。

崩溃一致性的动机 🤔

为什么保证崩溃一致性如此复杂?因为文件系统包含许多相互关联的数据结构,如inode位图目录块。一个操作(例如向文件追加数据)可能需要更新多个结构,但磁盘只保证单个512字节扇区的写入是原子的。如果崩溃发生在多个扇区写入之间,文件系统就可能处于不一致状态。

考虑向文件 foo/bar 追加数据的例子,需要三个写入步骤:

  1. 更新数据块位图:分配一个新的数据块。
  2. 更新inode:将新数据块的指针加入inode。
  3. 写入数据:将实际数据写入新分配的数据块。

如果崩溃导致只完成了部分写入,就会产生问题。以下是所有可能的不完整写入组合及其后果:

写入完成的部分 后果分析
仅数据块 文件系统结构完好,但用户数据丢失。
仅inode inode指向垃圾数据,且因位图未更新,该数据块可能被其他文件重用,导致隐私泄露和数据混淆。
仅位图 数据块被标记为已用但未被引用,造成空间泄露
inode + 位图 结构一致,但inode指向未写入的垃圾数据。
inode + 数据块 inode指向新数据,但位图显示该块空闲,导致数据块被其他文件共享,造成数据混淆
位图 + 数据块 数据已写入但无法被访问(空间泄露),且丢失了此次追加操作。

显然,我们需要一种机制来确保这三个更新要么全部完成,要么全部不发生


方法一:文件系统检查器 (FSCK) 🔍

第一种方法是事后修复。系统启动时,运行一个离线工具 fsck,扫描整个磁盘,检测不一致性并尝试修复。其核心思想是:利用文件系统数据结构中固有的冗余信息进行交叉验证。

例如,超级块记录了文件系统的总块数 N,那么所有inode中的数据块指针值都应在 0N-1 的范围内。如果发现指针值超出此范围,即可判定为不一致。

fsck 会执行一系列检查,以下是一些典型的修复场景:

  • 场景1:位图与inode不一致
    • 问题:一个数据块被inode引用,但位图中标记为空闲。
    • 修复:将位图中对应的位设置为1(已分配)。
  • 场景2:链接计数不一致
    • 问题:两个目录项指向同一个inode编号,但该inode的链接计数为1。
    • 修复:将inode的链接计数修正为2。
  • 场景3:孤儿inode
    • 问题:存在一个有效的inode,但没有任何目录项指向它(链接计数可能为0或1)。
    • 修复:在 lost+found 目录下为其创建一个新的目录项,从而“找回”该文件。
  • 场景4:重复数据块
    • 问题:两个不同的inode指向同一个数据块。
    • 修复:为其中一个inode分配新的数据块,复制原数据,让两个文件各自拥有数据副本。
  • 场景5:非法指针
    • 问题:inode包含指向磁盘范围之外的块指针。
    • 修复:清除该非法指针。

FSCK的局限性

  1. 结果可能非预期fsck 的目标是达到某个一致状态,而不一定是正确的状态。它可能无法准确推断崩溃前的操作意图。
  2. 速度极慢:需要扫描整个磁盘的元数据,对于大磁盘耗时很长(例如,600GB磁盘约需70分钟)。

因此,fsck 通常仅作为最后手段,在检测到严重错误时运行。


方法二:日志记录 (Journaling) 📝

更现代、高效的方法是日志记录(又称预写日志,Write-Ahead Logging)。其核心思想借鉴了数据库事务:在真正修改磁盘上的元数据之前,先将所有打算进行的更改描述(日志)顺序地写入磁盘上一个特定的区域——日志区

基本概念与流程

  1. 日志写入 (Journal Write):将本次操作要修改的所有元数据块(甚至包括数据块)及其新内容,作为一个事务,顺序写入日志区域。事务以描述符块开始,以提交块结束。
  2. 提交 (Commit):当提交块成功写入磁盘后,意味着事务已持久化,可以保证被恢复。
  3. 检查点 (Checkpoint):将日志事务中的内容,真正更新到文件系统在磁盘上的实际位置(即inode表、位图等)。
  4. 释放 (Free):当事务中的所有更新都已检查点化后,就可以在日志中标记该事务空间为可重用。

崩溃恢复策略

  • 崩溃发生在提交前:日志中的事务不完整(无提交块)。恢复时忽略该事务,相当于操作从未发生。
  • 崩溃发生在提交后、检查点前:日志中有完整且已提交的事务。恢复时重放该事务,将更改应用到实际位置,从而完成被中断的操作。

这种方法确保了操作的原子性:要么事务全部生效(通过重放),要么全部不生效。

性能优化

基本的日志记录性能不佳,因为涉及大量等待磁盘确认的屏障操作。以下是关键优化:

  1. 校验和替代顺序屏障
    • 问题:必须确保事务数据块(描述符块、元数据)在提交块之前写入。
    • 优化:计算事务数据块的校验和,并将其写入提交块。恢复时,重新计算校验和进行比对。这样,所有数据块和提交块可以一次性发出,无需等待屏障,提升了并发性。
    • 公式提交块.checksum = CRC(事务中所有数据块的内容)
  2. 批量处理与分组提交
    • 不是每个操作都立即写日志,而是将一段时间(如5秒)内的多个操作缓冲在内存中,合并成一个大的事务组一起提交。这减少了日志写入次数,并摊销了开销。
  3. 元数据日志与有序模式
    • 数据日志:将用户数据也写入日志,一致性最强,但性能差(数据写两次)。
    • 元数据日志(有序模式)只将元数据写入日志,这是最常见的方式。为了保持一致性,它强制一个顺序:必须先确保用户数据写入其最终磁盘位置,然后才能提交包含指向该数据的元数据的日志事务
    • 这样,恢复后元数据要么指向旧数据(一致),要么指向已成功写入的新数据(一致)。虽然用户可能看到新旧数据混合,但文件系统结构总是自洽的。
  4. 逻辑日志 vs 物理日志
    • 物理日志:在日志中存储待修改块的完整新映像。
    • 逻辑日志:在日志中只存储修改操作的逻辑描述(如“在inode X的字段Y写入值Z”)。当实际修改很小时,逻辑日志更高效。

总结 🎯

本节课中我们一起学习了确保文件系统崩溃一致性的两种主要技术:

  1. FSCK (文件系统检查器):一种离线、事后修复方法。通过扫描整个磁盘,利用数据结构的冗余信息检测并修复不一致性。其优点是实现相对直接,但缺点是速度慢且修复结果可能不符合用户预期。
  2. 日志记录 (Journaling):一种在线、预防性的方法。通过预写日志机制,将更改先记录到日志区,再应用到实际位置,从而保证操作的原子性。现代文件系统普遍采用有序元数据日志模式,在保证一致性和性能之间取得了良好平衡。关键优化包括使用校验和分组提交逻辑日志

需要强调的是,即使使用了日志,现代文件系统通常仍会保留 fsck 工具,以处理日志本身无法解决的磁盘介质错误或软件缺陷导致的损坏。

下一讲,我们将探讨另一种保证一致性的设计哲学:写时复制,并以日志结构文件系统为例进行深入分析。

022:持久化 - LFS与写时复制文件系统 📚

在本节课中,我们将学习本地文件系统实现崩溃一致性的另一种方法——写时复制(Copy-on-Write, CoW)文件系统,特别是日志结构文件系统(Log-Structured File System, LFS)。我们将探讨其工作原理、数据组织方式、崩溃恢复机制以及垃圾回收策略。


概述 📋

在上一讲中,我们介绍了日志(Journaling)技术,它通过将更新先写入一个日志区域来确保文件系统在崩溃后能恢复到一致状态。本节我们将探讨另一种思路:写时复制文件系统。其核心思想是,永远不覆盖磁盘上的旧数据,而是将新数据写入新的位置,然后原子性地更新指向这些数据的指针。这种方法旨在提升写入性能,并简化崩溃恢复。

从日志到写时复制 🔄

上一节我们介绍了日志技术,它通过“先写日志,再提交”的两阶段方式保证一致性。然而,这种方法需要将数据写入两次(先到日志,再到原位),影响了性能。

写时复制方法则不同。它不修改旧数据块,而是将所有更新(包括元数据和数据)写入磁盘的新位置,形成一个连续的日志。然后,通过原子性地更新一个高层指针(如inode映射表),使系统“看到”新版本的数据。这样,每个数据块在持久化过程中只被写入一次。

核心公式新状态 = 写入新位置 + 原子更新指针

日志结构文件系统(LFS)深入 🧱

LFS是写时复制文件系统的一个经典实现。它将整个磁盘视为一个仅追加(append-only)的日志

写入如何工作 ✍️

  1. 缓冲更新:文件系统的修改(如创建文件、追加数据)首先在内存中缓冲。
  2. 组装段(Segment):当积累了大量更新(例如几MB到几十MB)后,LFS将它们组装成一个。一个段内混合包含了这段时间内所有修改的数据块、inode、位图、目录项等
  3. 顺序写入:将这个段顺序地写入磁盘的下一个空闲位置。
  4. 更新Inode映射表(Imap):由于inode被写到了新的位置,需要更新一个称为Imap的特殊数据结构,以记录每个inode编号当前所在的磁盘块地址。
  5. 周期性检查点:将Imap的指针信息定期写入磁盘上固定的检查点区域。这样,系统重启后可以从这里快速找到Imap,进而找到所有inode。

关键点:LFS通过Imap这一层间接寻址,解决了inode位置动态变化带来的查找难题。目录项中只存储inode编号,通过查询Imap找到其最新位置。

读取如何工作 📖

  1. 从固定的检查点区域读取当前Imap的位置信息。
  2. 在内存中缓存Imap(或其部分)。
  3. 当需要打开文件/a/b.txt时:
    • 从根目录的inode(通过Imap查找)开始,读取其数据块(目录内容)。
    • 在目录数据块中找到条目b.txt及其inode编号。
    • 查询Imap,找到该inode编号对应的最新磁盘块地址。
    • 读取该inode,获得指向文件数据块的指针。
    • 读取数据块。

崩溃恢复 💥

LFS的崩溃恢复相对简单:

  1. 系统启动后,读取磁盘上两个检查点区域(采用双副本防止写入时崩溃),选择时间戳更新的有效副本。
  2. 从有效的检查点区域中,恢复出指向最新Imap片段的指针。
  3. 利用恢复的Imap,系统就能访问所有最新的文件数据。那些在崩溃前已写入段但尚未更新到Imap和检查点的数据,由于不可达,将被视为“旧”数据,在后续垃圾回收中清理。

垃圾回收 🗑️

由于LFS只追加写入,旧数据会占用空间。因此需要垃圾回收(Garbage Collection, GC) 来回收不再使用的块(即被新版本覆盖的数据)。

  1. 清理(Cleaning):GC以为单位进行。系统选择一些存活数据比例较低的旧段进行清理。
  2. 识别存活数据
    • 每个段都有一个段摘要块,记录了段内每个数据块属于哪个inode以及其偏移量。
    • 对于段内的一个数据块,通过段摘要找到其所属的inode编号和偏移量。
    • 查询当前的Imap,找到该inode的最新版本。
    • 检查这个最新inode中,对应偏移量指向的是否是另一个数据块地址。如果是,则说明当前段内的这个数据块是旧版本,可以被回收。
  3. 写入新段:将识别出的所有存活数据块(及其inode)从多个旧段中读出,合并写入一个新的段
  4. 释放空间:更新元数据后,旧的段被标记为空闲,可供后续顺序写入使用。

GC策略:倾向于清理存活数据少(即更“空”)和数据较冷(长时间未修改)的段,因为等待也不会释放更多空间。

日志(Journaling)与LFS(CoW)对比 ⚖️

以下是两种方法的核心对比:

特性 日志文件系统 (如ext3/ext4) 写时复制文件系统 (如LFS, ZFS, btrfs)
写入模式 随机写入(更新原位数据)与顺序写入(日志)混合。 近乎纯顺序写入(追加到日志末尾)。
数据位置 数据存放在固定、预先规划的位置,注重逻辑局部性。 数据存放在写入时的顺序位置,体现时间局部性。
读取性能 如果读取模式匹配预先规划的逻辑局部性,性能好。 如果读取顺序与写入顺序一致,性能好;否则可能随机读取,性能下降。
写入放大 在数据日志模式下,数据写入两次(日志+原位)。元数据日志模式较好。 数据只写入一次,但存在垃圾回收带来的写放大
空间开销 需要额外的日志空间。 需要预留空间进行垃圾回收,整体空间利用率可能更低。
特性支持 标准文件系统功能。 天然支持快照和版本化,因为旧数据仍然保留在磁盘上。
适用场景 通用场景,读写混合负载。 写入密集型负载,或需要高效快照功能的场景。

重要说明:虽然LFS理论上每个用户数据只写一次,但其垃圾回收过程需要移动存活数据,产生了额外的写入开销。在实际满负载运行时,其整体写入性能可能与元数据日志模式的文件系统相近。

总结 🎯

本节课我们一起学习了:

  1. 写时复制(CoW)文件系统的基本原理:通过写入新位置并原子切换指针来避免原地更新,提升写入性能并简化一致性。
  2. 日志结构文件系统(LFS)的具体实现:将磁盘视为顺序日志,通过Imap管理动态变化的inode位置,通过段摘要组织数据。
  3. LFS的崩溃恢复机制:依赖于检查点区域来保存Imap指针。
  4. LFS的垃圾回收必要性及过程:以段为单位,通过查询Imap和段摘要来识别并回收死数据。
  5. 对比了日志写时复制两种崩溃一致性方案的优缺点,理解了它们各自不同的性能特征和适用场景。

写时复制文件系统代表了文件系统设计中的一个重要方向,特别是在需要高性能写入和强大快照功能的现代存储系统中得到了广泛应用。

023:固态硬盘的“潜规则” 🚀

在本节课中,我们将要学习固态硬盘的工作原理,以及操作系统和应用程序应如何与这种新型存储设备交互,以获得最佳性能和可靠性。我们将从闪存的基本单元开始,逐步深入到SSD内部的复杂管理机制,并总结出高效使用SSD的五条核心规则。


固态硬盘概述

固态硬盘是一种基于闪存技术的存储设备,没有机械运动部件。与传统的机械硬盘相比,SSD在读写速度和延迟方面具有显著优势。然而,其内部工作机制更为复杂,涉及页面、块、通道等概念,以及一个关键的闪存转换层来管理逻辑地址到物理地址的映射。

闪存基础:单元、页面与块

要理解SSD,首先需要了解其构建基础——闪存。

  • 单元:存储数据的基本单位。一个单元可以存储一个比特(单层单元,SLC)或多个比特(多层单元,MLC)。SLC更昂贵、更耐用,而MLC容量更大但耐用性稍差。
  • 页面:读写操作的最小单位。一个页面通常包含多个单元。
  • :擦除操作的最小单位。一个块由多个页面组成。关键限制是:要重写一个页面,必须先擦除其所在的整个块

闪存的读写接口与硬盘不同:

  • 读取:可以读取单个页面。
  • 编程/写入:可以将已擦除块(内容全为1)中的特定页面从1改为0。
  • 擦除:将整个块的内容重置为全1,这是一个相对昂贵且耗时的操作。

这种特性导致了写入放大问题:为了修改一小部分数据,可能需要读取、修改、擦除并重写整个块。

闪存转换层:SSD的智能核心

直接在闪存上运行未修改的文件系统(如FFS)效率很低,因为其细碎的写入模式会触发大量昂贵的擦除和块重写操作。

因此,现代SSD内部集成了一个闪存转换层。FTL为上层(如文件系统)提供了一个简单的逻辑块地址空间抽象,就像传统的硬盘接口一样。FTL的核心职责是:

  1. 动态地址映射:维护一个映射表,将文件系统看到的逻辑块/页面地址,动态映射到闪存上的实际物理位置。
  2. 写入优化:采用写时复制策略。当需要更新一个逻辑页面时,FTL会找到一个空闲的物理页面进行写入,然后更新映射表,将旧的物理页面标记为无效。这避免了原地更新带来的昂贵擦除操作。
  3. 垃圾回收:定期回收那些包含大量无效页面的块。这个过程涉及将块中仍有效的页面复制到新位置,然后擦除整个旧块,使其变为空闲状态。
  4. 磨损均衡:通过FTL的智能映射,将写入操作均匀分布到所有闪存块上,防止某些块因过度写入而过早失效,从而延长SSD的整体寿命。

FTL的映射策略对性能至关重要。纯粹的页级映射表会占用大量内存,而纯粹的块级映射则在处理部分页面更新时效率低下。因此,许多SSD采用混合映射策略,结合了两种方式的优点。

高效使用SSD的五条“潜规则”

基于SSD的内部工作原理,我们可以总结出以下规则,以帮助文件系统和应用程序获得最佳性能和可靠性。

上一节我们介绍了SSD内部FTL的复杂工作,本节中我们来看看如何与它高效协作。

规则一:发送大量或大型请求 📨

SSD内部有多个可以并行工作的通道(或称为平面、存储体)。为了充分利用所有通道的带宽,你应该同时向SSD发送多个I/O请求,或者发送一个跨越多个通道的大型连续请求。

核心原理总带宽 ≈ 单个通道带宽 × 活跃通道数。如果请求只涉及少数通道,就无法达到峰值吞吐量。

规则二:保持访问的局部性 🎯

FTL的映射表可能无法全部缓存在SSD的RAM中。频繁访问相同的逻辑块,有助于相关的地址映射信息保留在FTL的缓存中,从而加快地址转换速度。

核心原理:访问映射表的缓存命中率直接影响读取延迟。局部性访问能提高缓存命中率。

规则三:进行对齐的顺序写入 🔢

这个规则与SSD的混合映射和垃圾回收机制密切相关。以下是三种常见的合并操作,其效率由写入模式决定:

  • 完全合并:当随机更新一个块内的少数页面时,FTL需要将新旧数据合并到一个新块,涉及大量数据拷贝,性能最差。
  • 部分合并:稍好一些,但仍需拷贝部分数据。
  • 切换合并最佳情况。当你按顺序、从头开始完整地写入一个块的所有页面时,FTL可以简单地将整个逻辑块的映射指向这个新物理块,无需任何数据拷贝。

核心原理顺序且对齐的写入 → 触发切换合并 → 最小化内部数据搬运 → 最高写入性能

规则四:将生命周期相近的数据分组写入 ⏳

数据的“生命周期”指的是它被逻辑覆盖或删除的时间。如果同一个物理块内的所有页面都在大致相同的时间变为无效(即“同时死亡”),那么垃圾回收过程会非常简单:直接擦除整个块即可,无需搬迁任何有效数据。

反之,如果一个块内混合了“长寿”数据和“短命”数据,垃圾回收时就必须搬迁那些仍然有效的“长寿”数据,产生额外的写入开销,并可能导致性能波动。

规则五:确保数据具有均匀的写入寿命 ♻️

这条规则关注的是磨损均衡。如果某些数据(如日志文件)被频繁重写,而另一些数据(如存档照片)几乎从不更新,那么存储频繁重写数据的闪存块会更快磨损。

理想的状况是所有数据都具有相似的重写频率。这样,磨损会自然均匀分布。否则,FTL必须主动进行磨损均衡操作,在后台移动那些“冷”数据,这也会消耗资源和带宽,可能影响前台性能。

核心原理均匀的写入负载 → 自然的磨损均衡 → 更高的设备可靠性和更稳定的性能


总结

本节课中我们一起学习了固态硬盘的核心知识。我们了解到SSD并非简单的“更快硬盘”,其基于闪存的特性带来了独特的接口(擦除-编程)和挑战(写入放大、磨损)。SSD内部的闪存转换层通过动态地址映射、写时复制、垃圾回收和磨损均衡等复杂机制,向上层提供了简单的块设备接口。

最重要的是,我们总结了高效使用SSD的五条规则

  1. 利用并行性:发送大量或大型请求
  2. 利用缓存:保持访问局部性
  3. 优化内部操作:进行对齐的顺序写入以触发切换合并。
  4. 简化垃圾回收:将生命周期相近的数据分组
  5. 促进磨损均衡:尽量让数据具有均匀的写入寿命

对于文件系统开发者(如LFS这类日志结构文件系统就与SSD非常契合)和应用程序员(如数据库设计)而言,遵循这些“潜规则”是释放SSD全部潜力的关键。

024:NFS与AFS 🗂️

在本节课中,我们将学习分布式文件系统,特别是NFS和AFS。我们将探讨它们的设计理念、协议细节以及缓存一致性模型,并理解它们如何解决分布式环境下的文件共享问题。

概述

分布式系统由多台机器协作解决特定问题。我们主要关注客户端-服务器模型,其中多个客户端连接到一个中心化的文件服务器。使用分布式系统的主要动机包括:提升处理能力、增加存储容量、提高容错性以及实现数据共享。

然而,分布式环境也带来了新的挑战,例如节点故障、网络通信问题以及跨机器的并发控制。我们将重点分析当客户端或服务器崩溃时,系统应如何正确恢复。

本地文件系统与分布式文件系统

在深入分布式文件系统之前,我们回顾一下本地文件系统。它仍然是所有分布式文件系统的基础构建块。即使在分布式环境中,服务器端的文件系统(如EXT4)仍然负责从本地磁盘读写数据。

在单机上,多个进程共享同一文件时,由于它们共享相同的缓冲区缓存,一个进程的写入能立即被其他进程看到。但在分布式环境中,不同客户端上的进程读写同一文件时,语义将变得复杂得多。我们将探讨NFS和AFS如何定义这种跨客户端的文件可见性。

设计目标

我们对分布式文件系统的期望目标包括:

  • 简单性:系统应易于理解和实现。
  • 高性能:读写操作应快速。
  • 容错性:客户端或服务器崩溃后,系统应能正确恢复。
  • 透明性:访问远程文件应尽可能像访问本地文件一样。
  • 可扩展性:系统应能支持从数十到数百个客户端的并发访问。

接下来,我们将看到NFS和AFS如何以不同的方式尝试达成这些目标。

NFS:网络文件系统

NFS更像一个协议规范,而非单一实现。我们将重点讨论NFS版本2(v2),其核心创新在于无状态设计。

架构概览

NFS采用经典的客户端-服务器架构:

  • 服务器:单台机器,运行本地文件系统(如EXT4)来存储数据。
  • 客户端:多台机器,可能拥有本地磁盘。它们通过远程过程调用(RPC)与服务器通信。
  • 缓存:客户端会在本地缓存最近读写过的文件数据块,以减少网络访问和服务器负载。

客户端通过挂载协议将远程文件系统的目录整合到本地的目录树中。例如,/home目录可能映射到NFS服务器,而/tmp目录则存储在本地磁盘。对应用程序而言,访问本地文件和远程文件使用的是相同的系统调用(如read, write),虚拟文件系统(VFS)层负责将调用路由到正确的底层文件系统。

协议演进:走向无状态

最初的设想是让服务器代理执行所有客户端发来的文件操作(如open, read)。但这要求服务器为每个客户端维护文件描述符状态(包括inode号和当前偏移量)。如果服务器崩溃后重启,这些内存中的状态将丢失,导致恢复困难。

NFS v2的解决方案是设计一个无状态协议。服务器不记忆任何与客户端会话相关的状态。每个从客户端发往服务器的请求都必须自我描述,包含执行操作所需的全部信息。

这意味着需要摒弃基于文件描述符的接口。我们尝试了几种方案:

  1. 方案一(失败):每次读写都发送完整路径名。这会导致每次操作都需进行昂贵的路径名查找。
  2. 方案二(改进):客户端发送open请求,服务器执行路径查找后,返回一个文件句柄(file handle)。后续的read/write操作直接使用该句柄和客户端提供的偏移量。文件句柄通常由卷ID、inode号和生成号组成,确保其唯一性,防止inode重用导致的安全和一致性问题。
  3. 最终方案:客户端负责跟踪文件偏移量等状态。服务器完全无状态,只处理自描述的读写请求。

幂等操作与RPC语义

NFS构建在RPC层之上,该层提供“至少一次”的调用语义。这意味着,如果RPC调用成功返回,该操作可能在服务器上执行了一次或多次。

为了适应这种语义,NFS协议必须只包含幂等操作。幂等操作是指无论执行一次还是多次,结果都相同的操作。

  • 幂等操作示例read(读取特定偏移量的数据)、write(向特定偏移量写入数据)。
  • 非幂等操作示例append(追加写),多次执行会导致数据重复。

因此,NFS协议本身不提供append操作。客户端应用程序调用append时,客户端的NFS库会将其转换为一个计算好偏移量的write操作,从而在协议层面实现幂等性。

缓存与一致性模型

为了提高性能,NFS客户端会缓存文件数据块。但这带来了缓存一致性问题:一个客户端的修改何时对其他客户端可见?

NFS采用一种“尽力而为”但语义宽松的一致性模型:

  1. 写回时机:客户端修改数据后,不会立即同步到服务器。通常,在文件close()时,所有修改会被刷新回服务器。在内存压力下,也可能提前刷新。
  2. 更新可见性:客户端在读取缓存的数据块前,会先向服务器查询该文件的属性(如修改时间),以检查缓存是否过期。为了减少服务器负载,NFS客户端不会频繁查询,而是采用一个属性缓存,通常每3秒使缓存失效并重新查询一次。

因此,在NFS中:

  • 一个客户端的写入,在其他客户端看来,可能最多有约3秒的延迟才会可见。
  • 如果多个客户端同时写入并关闭同一文件,服务器最终接收到的文件版本取决于网络时序,可能导致不可预测的结果。

这种模型简单,能容忍服务器崩溃(因为无状态),但牺牲了强一致性和可扩展性(大量getattr请求会压垮服务器)。

上一节我们介绍了NFS的无状态设计和其宽松的一致性模型。本节中,我们来看看另一种设计哲学——AFS,它如何通过引入状态来获得更好的可扩展性和一致性。

AFS:安德鲁文件系统

AFS旨在解决NFS面临的可扩展性差和缓存一致性模型怪异的问题。其核心思想是以状态换取可扩展性和更清晰的语义

关键设计决策

  1. 全文件缓存:与NFS缓存单个数据块不同,AFS客户端在open()文件时,会传输并缓存整个文件。后续的readwrite操作都在本地缓存副本上进行,无需与服务器通信。这基于一个观察:大多数情况下,文件是被整体访问的。
  2. 基于会话的一致性:客户端在从openclose的整个会话期间,都使用同一份文件缓存副本。这为应用程序提供了稳定的文件视图,不会出现在会话中间文件内容意外改变的情况。
  3. 服务器维护状态(回调):AFS服务器会跟踪哪些客户端缓存了哪些文件。当某个客户端close()文件并将修改写回服务器后,服务器会主动通知(回调中断)所有缓存了该旧版本文件的其他客户端,告知它们缓存已失效。
  4. 更新传播时机:客户端只有在close()文件时,才将完整的修改后的文件写回服务器。服务器按顺序处理这些写入,后写入的版本会覆盖先写入的版本(“最后写入者胜”)。

工作流程对比

通过一个例子对比NFS和AFS的行为:

假设文件初始内容为“A”。

  • 时间 10:客户端A打开文件并读取。
  • 时间 20:客户端B打开文件,写入“B”,但不关闭
  • 时间 30:客户端A再次读取文件。
  • 时间 40:客户端B关闭文件。
  • 时间 50:客户端A再次读取文件。

在NFS中

  • 时间30的读取:客户端A会查询服务器,由于B未关闭文件,修改未刷新,服务器回复文件未变,因此A读到“A”。
  • 时间50的读取:此时B已关闭文件,修改已刷新。如果距离A上次属性查询超过3秒,A会重新查询并发现文件已变,从而获取新数据“B”。否则,可能仍读到“A”。

在AFS中

  • 时间30的读取:客户端A使用其在时间10获取的整个文件缓存,读到“A”。它不受B写入的影响。
  • 时间40:B关闭文件,将“B”写回服务器。服务器中断A的回调。
  • 时间50的读取:此时A再次open()文件。由于回调已中断,A发现缓存无效,于是从服务器获取最新版本,读到“B”。

AFS的优势

  • 可扩展性:极大地减少了客户端-服务器间的交互(仅在openclose时),使服务器能处理更多客户端。
  • 一致性模型更直观:客户端在文件会话期内看到一致的快照。更新的传播界限清晰(下次open时)。
  • 性能:读写操作完全本地化,速度极快。

AFS的代价

  • 服务器有状态:需要维护回调状态,服务器崩溃恢复更复杂。
  • 网络开销:即使只读一小部分,也需要传输整个文件,对小文件或随机读取不友好。
  • 更新冲突:对于并发写入,采用“最后写入者胜”策略,可能导致更新丢失,需要应用层协调。

总结

本节课中我们一起学习了两种经典的分布式文件系统:NFS和AFS。

  • NFS:采用无状态服务器设计,通过幂等操作实现容错性。其缓存一致性模型宽松(约3秒延迟),通过客户端轮询服务器来验证缓存有效性。这种设计简单,但可扩展性有限,一致性语义较弱。
  • AFS:采用有状态服务器设计,通过全文件缓存服务器回调机制,大幅减少了客户端-服务器通信。它提供了基于会话的、更清晰的一致性模型(在open-close会话内文件视图稳定),并获得了更好的可扩展性。代价是服务器逻辑更复杂,且不适合所有访问模式。

两者代表了在分布式系统设计中,在状态管理一致性强度可扩展性之间进行权衡的不同思路。理解这些基本原理,有助于我们评估和设计适合不同场景的分布式存储解决方案。

025:持久性复习

概述

在本节课中,我们将回顾本学期关于操作系统持久性部分的核心概念,涵盖从I/O设备到分布式文件系统的关键知识点。我们将通过解答常见问题来巩固理解。

课程内容回顾

虚拟化与并发

上一节我们介绍了操作系统的三大角色。首先,操作系统提供虚拟化,为每个应用程序提供独占CPU和内存资源的假象。CPU的抽象是进程或线程,内存的抽象是地址空间。关键机制包括受限直接执行、CPU调度策略(如最短作业优先、轮转调度)、地址转换(分段、分页、TLB)以及页面置换策略(如LRU)。

其次,操作系统管理并发,确保并行运行的线程或进程能有序访问共享状态。我们学习了信号量条件变量,以及如何基于测试并设置等原子硬件指令实现它们,并用其解决哲学家就餐或读者-写者锁等问题。

持久性核心目标

本节中我们来看看持久性。操作系统的核心目标是确保数据在重启、断电或不同应用程序间能够长期存在。这带来了处理崩溃和保证文件系统数据结构一致性的挑战,同时还需考虑底层设备的性能与可靠性特性。

I/O设备与磁盘

以下是关于I/O设备和磁盘的关键点:

  • 设备交互协议:操作系统通过状态寄存器轮询设备,先写入数据,再写入命令。顺序不能颠倒。
  • 性能优化:引入中断DMA可以避免CPU空转,提升性能。
  • 磁盘结构:磁盘由盘片、磁头、磁道、扇区和柱面组成。双面盘片每面都有一个读写头。
  • 访问时间:磁盘访问时间 = 寻道时间 + 旋转时间 + 传输时间。顺序访问性能远优于随机访问。
  • 磁盘调度:调度算法(如SCAN(电梯算法)C-SCAN)旨在减少寻道时间。工作守恒调度器在有工作时绝不空闲,而预见性调度等非工作守恒调度器可能为了更好的整体性能而暂时等待。

RAID(独立磁盘冗余阵列)

以下是关于RAID的关键点:

  • 核心指标吞吐量(每秒数据量)、延迟(单次操作时间)、容量和可靠性。
  • RAID级别
    • RAID 0:条带化,无冗余。
    • RAID 1:镜像,提供冗余。
    • RAID 4:专用奇偶校验盘,写操作存在瓶颈。
    • RAID 5:分布式奇偶校验(左对称布局),通过异或运算实现冗余,能承受单块磁盘故障,随机写性能约为单盘的1/4。

文件系统实现

以下是关于文件系统实现的关键点:

  • 分配方式链接分配(如FAT表)将指针存入数据块或专用表,利于缓存但随机访问性能差。
  • 关键结构i节点存储文件元数据和数据块指针。间接块包含指向数据块的指针。目录是包含(文件名,i节点号)映射的特殊文件。
  • 更新粒度:由于i节点小于磁盘块,修改i节点需要读-改-写整个块。
  • 文件大小计算:根据i节点中直接指针、间接指针、双重间接指针的数量和块大小,可以计算文件系统支持的最大文件大小。
  • 文件创建流程:创建文件涉及读取目录i节点和数据块、分配i节点(更新位图)、初始化新文件i节点、更新目录数据块和目录i节点等多个步骤。

FFS(快速文件系统)

FFS通过柱面组将i节点与其数据块就近存放,以提升性能。策略包括:为新目录选择i节点空闲较多的组,为大文件(使用间接块时)选择数据块空闲较多的组。此外,由于早期系统延迟,FFS采用扇区交错放置以获取一半的峰值带宽。

日志与一致性

文件系统需确保元数据在崩溃后保持一致。日志(如ext3)通过以下步骤实现:

  1. 日志写入:将事务(包含描述块、元数据/数据、提交块)顺序写入日志区域。
  2. 提交:写入提交块,标志事务日志已安全落盘。
  3. 检查点:将日志中的内容写回文件系统的实际位置(检查点区域)。

恢复时,若找到完整提交的事务则重做(redo),否则丢弃。仅元数据日志是常见优化,其中有序模式确保用户数据先于元数据写入日志,防止指向无效数据。

LFS(日志结构文件系统)与闪存

LFS采用写时复制,将所有更新(包括数据和新i节点)顺序写入日志结构的段中。它非常适合闪存设备,因为闪存需要先擦除大块再编程页,随机写会导致严重的写放大。顺序写的LFS能减少擦除次数,提升闪存寿命和性能。闪存内部的FTL(闪存转换层) 负责磨损均衡等管理。

分布式文件系统:NFS与AFS

以下是关于NFS和AFS协议的关键点:

  • NFS(无状态)
    • 打开:客户端通过lookup从服务器获取文件句柄。
    • 读取:客户端缓存数据块,并通过getattr定期检查服务器上文件的修改时间(属性)以验证缓存有效性(如3秒内无需检查)。
    • 写入/关闭:保证在close时将脏数据写回服务器。
    • 一致性:基于属性轮询的弱一致性模型。
  • AFS(有状态)
    • 打开:服务器执行路径遍历,将整个文件发送给客户端,并建立回调承诺,若其他客户端修改文件将通知该客户端。
    • 一致性:基于回调的更强一致性模型,可扩展性更好,但更复杂。

总结

本节课中我们一起回顾了操作系统持久性部分的核心内容。我们从I/O设备和磁盘的基本原理出发,探讨了RAID如何提供冗余和性能,深入分析了文件系统的实现细节(如i节点、目录、FFS策略),学习了如何通过日志机制保证崩溃一致性,比较了LFS与传统文件系统在闪存上的优劣,最后剖析了NFS和AFS这两种分布式文件系统在设计哲学和一致性模型上的根本区别。理解这些概念对于构建可靠、高效的数据存储系统至关重要。

posted @ 2026-03-29 09:47  布客飞龙III  阅读(5)  评论(0)    收藏  举报