type
status
date
slug
summary
tags
category
icon
password
I/O设备概述设备管理*总线层次结构(简要了解即可)单总线结构模型三级总线结构模型南北桥基于通道的服务器总线模型标准设备模型标准协议(轮询)中断机制直接内存访问 DMAI/O处理器(I/O通道)*I/O软件的层次结构(初学者可以跳过)I/O中断处理程序设备驱动程序独立于设备的I/O软件用户空间的I/O软件*I/O缓冲(初学者可以跳过)单缓冲双缓冲循环缓冲设备独立性*独占型外围设备的分配(初学者可以跳过)*SPOOLing系统(初学者可以跳过)*磁盘(硬件内容, 可以略读或跳过)一些名词对外接口基本内部结构延迟计算单磁道旋转延迟多磁道寻道时间传输时间磁盘调度移臂调度: 减少寻道最短寻道时间优先(SSTF)扫描算法(SCAN/LOOK)分步SCAN(FIFO和SCAN的混合策略)FSCAN最短定位时间优先(SPTF)旋转调度: 减少旋转循环排序优化分布*廉价冗余磁盘阵列 RAID(硬件内容, 可以略读或跳过)对外接口基本内部结构故障模型RAID 0: 条带化RAID 1: 镜像RAID 10: 混合策略RAID 4: 奇偶校验RAID 5: 旋转奇偶校验总结 文件和目录API文件目录文件系统API创建文件读写文件文件重命名文件元数据删除文件创建目录读取目录删除目录硬链接符号链接创建和挂载文件系统文件系统实现极简文件系统 VSFS数据区域inode位图超级块目录组织创建文件删除文件打开文件读取文件写入文件关闭文件文件共享缓存和缓冲*主存映射文件(初学者可以跳过)快速文件系统FFS旧文件系统的局部性问题FFS: 柱面组文件和目录的分配策略其他设计文件系统检查程序FSCK预写日志概述物理日志结构元数据日志日志自身的原子写入使用日志进行恢复块复用问题其他方案日志结构文件系统LFS概述顺序写入设计查找inode读取文件目录数据垃圾收集崩溃恢复和日志虚拟文件系统(VFS)VFS的数据结构组成文件系统在VFS中的注册与注销文件系统在VFS中的挂载与卸载
💡
食用指南: 本章补充知识较庞杂, 主干之外的部分已在标题中用*标注.

I/O设备概述

设备管理

设备管理的功能:
  • 设备中断处理
  • 缓冲区管理
  • 设备分配和去配
  • 设备驱动调度
  • 虚拟设备的实现
设备管理的目标:
  • 解决设备和CPU速度不匹配的问题, 使得主机和设备充分并行工作, 提高设备使用效率;
  • 屏蔽设备的物理细节和操作过程, 配置驱动程序, 提供统一界面(抽象为裸设备或设备文件).

*总线层次结构(简要了解即可)

💡
越快的总线就越短, 连接的设备越少, 反之越长的总线连接的设备越多, 速度越慢(可以类比存储器层次结构).

单总线结构模型

💡
将CPU, 主存和所有I/O接口连接到同一条总线上.
优点: 结构简单易于扩充;
缺点: 设备多时总线竞争压力大, 传输时延长, 慢速设备占用带宽多.
notion image

三级总线结构模型

局部总线连接CPU和Cache(实则集成在CPU内了),
主存总线连接主存和Cache,
主存总线和扩展总线上的I/O设备间通过扩展总线接口缓冲.
notion image
优点: 主存与I/O之间的数据传送、处理器的内存活动分离; 可以支持更多的I/O设备.
缺点: 所有I/O设备(不论慢速/快速)共用一条总线, 不适用于I/O设备数据速率相差太大的情形.

南北桥

存储总线连接连接CPU与主存, PCI总线连接高速I/O设备, E(ISA)总线连接低速I/O设备.
北桥已经集成在现代CPU内部.
notion image

基于通道的服务器总线模型

  • 支持CPU、主存和多个I/O通道之间的数据传送;
  • 支持I/O通道和I/O控制器, 以及I/O控制器和设备之间的数据传送.
notion image

标准设备模型

💡
不是具体设备, 可以视作I/O设备的通用接口模型.
通用设备模型
通用设备模型
内部实现被封装, 接口暴露给上层系统, 遵循交互协议.
操作系统访问I/O设备的方式: 使用明确的I/O专用指令(一般为特权指令)/内存映射, 将I/O设备寄存器编入本机地址空间, 使用通用的内存存取指令读写.
notion image
设备控制器是CPU与设备之间的接口, 接受并识别CPU或I/O通道发来的命令, 实现数据交换, 发现并记录设备及自身的状态信息供CPU处理时使用, 连接多台设备时用于识别设备地址.

标准协议(轮询)

轮询过程中一直占用CPU, 等到I/O操作完成之后处理器才可以进行其他操作, 比较低效.

中断机制

💡
在等待时找点别的事做, CPU就能将更多的时间用来计算了.
进程向设备发出请求之后让进程睡眠, 切换执行其他任务. 当设备完成请求后由I/O接口抛出硬件中断, 调用对应的中断处理程序结束请求并唤醒睡眠的进程. 这种机制允许计算与等待I/O并行进行, 提高了CPU利用率.
💡
若进程支持异步I/O, 则可能一遍等待该I/O一遍执行当前进程的后续任务;
若不支持, 则改进程将在这个中断上被挂起, 处理器切换到其他进程.
notion image
特例: 对于高速I/O设备, 往往轮询只需要等待很短的时间, 然而若使用中断则需要频繁进行上下文切换, 摊销效率很低, 代价反而更高. 由于设备速度未知, 可以考虑混合策略, 先尝试轮询一小段时间, 若设备没有就绪再进行中断(类比两阶段锁, 先轮询后休眠).

直接内存访问 DMA

💡
将I/O控制的主要逻辑移交出去, CPU就能将更多的时间用来计算了.
DMA控制器/引擎: 系统中的一个特殊设备, 处理内存和设备之间的数据传递, 不需要CPU介入(地位类似于专门处理I/O的一个协处理器). CPU通知DMA控制器数据位置, 数据量和数据传送目标, 就可以转而处理其他请求. DMA控制器完成任务之后再中断通知CPU.
CPU只在I/O开始和结束时参与, 不参与主存数据交换.
notion image
DMA的工作方式: 周期窃取(DMA和CPU同时通过总线访问内存, CPU会把总线的占有权让给DMA一个/几个主存周期)
notion image
周期窃取对CPU与主存的数据交换影响不大:
  • 数据传送过程是不连续的和不规则的;
  • CPU大部分情况下与Cache进行数据交换, 直接访问内存较少.
💡
一个运行示例:
  1. 进程对已打开文件的文件描述符执行读库函数;
  1. 与设备无关的I/O软件检查参数正确性. 若高速缓存中有要读的信息块, 则从缓冲区直接读到用户区, 完成I/O请求;
  1. 若数据不在缓冲区, 执行物理I/O, 实现将设备逻辑名转换成物理名, 检查对设备操作的权限, 将I/O请求排队, 阻塞进程等待I/O完成;
  1. 内核启动设备驱动程序, 分配存放读出块的缓冲区, 准备接收数据, 且向设备控制寄存器发启动命令, 或建立DMA传输, 启动I/O;
  1. 设备控制器操作设备, 执行数据传输;
  1. DMA控制器控制一块传输完成, 硬件产生I/O结束中断;
  1. CPU响应中断, 转向磁盘中断处理程序.
  1. 当应用进程被再次调度执行时, 从I/O系统调用的断点恢复执行.

I/O处理器(I/O通道)

💡
用于完成逻辑上独立的I/O任务.
采用四级连接: 处理器, 通道, 控制器, 设备.
可以控制多台同类/不同类的设备.
处理器不再执行I/O指令, 而是在主存中组织通道程序, 由I/O通道执行.
工作流程:
  • CPU遇到I/O任务, 组织通道程序, 置通道程序地址字CAW, 启动指定通道;
  • 通道从CAW获得通道程序, 控制I/O设备进行操作, CPU执行其他任务;
  • I/O操作完成后, I/O通道发出中断, CPU处理中断, 并从通道程序状态字CSW获得通道执行情况, 处理I/O操作.
CPU与通道高度并行工作.
💡
I/O通道和DMA的区别
I/O通道和DMA都是为了提高I/O操作效率、减轻CPU负担而出现的技术. 它们在功能和实现层面上有所不同, 但目标一致: 让CPU从繁重的I/O数据传输中解脱出来.
什么是 I/O 通道(I/O Channel)?
I/O通道可以被理解为一种具有特殊功能的独立处理器, 专门负责管理I/O操作和实现主存与外围设备之间的数据交换. 它拥有自己的一套通道指令(Channel Instruction), 这些指令构成了一个独立的I/O程序, 可以在CPU的干预下启动, 然后独立执行I/O任务.
主要特点:
  • 独立性: I/O通道是一个相对独立的硬件单元, 拥有自己的控制器、寄存器和指令集. 它可以独立于CPU执行I/O任务, 而无需CPU的频繁干预.
  • 并行性: 当CPU启动通道后, CPU可以继续执行自己的计算任务, I/O通道则并行地处理数据传输, 实现了CPU和I/O操作的并行工作.
  • 复杂性: 相较于DMA, 通道的功能更强大、更复杂. 它不仅能进行数据传输, 还能执行一些简单的I/O控制和数据处理, 比如对数据进行格式转换、错误检查等.
  • 分层管理: I/O通道通常连接到多个I/O控制器, 每个控制器又连接到多个I/O设备. 形成了一个层次化的I/O管理结构.
  • 高级抽象: 对于CPU而言, 它只需要向通道发出一个高级别的I/O命令(例如“从磁盘读取100KB数据到内存的某个位置”), 而通道会负责将这个高级命令分解成一系列底层的设备操作, 并完成数据传输.
形象比喻:
如果CPU是公司的CEO, 那么I/O通道就像是一个独立的I/O部门经理. CEO只需要给I/O经理下达一个宏观的指示(比如“把某批货物从仓库A搬到仓库B”), I/O经理就会自己去调度卡车、叉车工人等, 并监督整个搬运过程, 而CEO可以继续处理其他重要的公司事务.
什么是 DMA(Direct Memory Access)?
直接存储器访问(Direct Memory Access, DMA)是一种允许某些硬件子系统(如硬盘控制器、网卡、显卡等)在无需CPU介入的情况下, 直接在内存和I/O设备之间传输数据的技术.
主要特点:
  • 直接性: DMA控制器负责协调数据在内存和I/O设备之间的直接传输, CPU只负责初始化传输任务和接收传输完成的中断信号.
  • 减轻CPU负担: DMA的主要目的是将大量数据传输的负担从CPU身上卸载下来, 让CPU可以专注于更复杂的计算任务.
  • 传输单位: DMA通常是以块(block)为单位进行数据传输, 而不是一个字节一个字节地传输.
  • 控制器: 实现DMA需要一个专门的DMA控制器(DMA Controller, DMAC). 当CPU需要进行DMA传输时, 它会向DMAC发出指令, 告知源地址、目的地址、传输长度和传输方向. DMAC获得总线控制权后, 会自行完成数据传输, 传输完成后再向CPU发送中断信号.
形象比喻:
如果CPU是公司的CEO, DMA控制器就像是一个专门的搬运工(或者自动搬运机器人). CEO告诉搬运工: “把仓库A的这批货物搬到仓库B的某个位置”, 然后搬运工就自己去搬了, CEO可以在自己的办公室继续工作, 直到搬运工完成任务后给他一个信号.
二者区别
特征
I/O 通道(I/O Channel)
DMA(Direct Memory Access)
层级/智能
更高层级的智能I/O处理器, 有自己的指令集和程序.
较低层级的控制器, 主要负责数据传输, 相对“傻瓜”一些.
功能范围
负责整个I/O过程的管理、调度和数据传输, 可执行复杂操作.
主要负责数据在内存和设备之间的直接传输.
CPU干预
CPU只需启动通道程序, 通道会自行执行一系列I/O指令.
CPU需要初始化DMA控制器, DMA传输过程中CPU无需干预.
编程模型
CPU向通道发出高级I/O命令, 通道执行通道程序.
CPU设置DMA控制器寄存器, 指示传输细节.
发展阶段
出现在大型机和早期高性能计算机中, 是I/O子系统进化的结果.
广泛应用于各种计算机系统, 是现代计算机的标准功能.
复杂性
更复杂, 成本更高.
相对简单, 成本较低.
资源占用
可能有自己的少量内存和寄存器.
通常是CPU和内存之间的中间控制器, 通过总线进行操作.
典型应用
大型机、超级计算机的I/O子系统.
个人电脑、嵌入式系统中的硬盘、网卡、显卡、声卡等数据传输.
总结:
DMA是I/O通道的基础技术, I/O通道可以看作是DMA的更高级、更智能的演进.
  • DMA解决了CPU在数据传输过程中需要逐字节或逐字干预的问题, 将数据传输的控制权交给DMA控制器, 从而解放了CPU.
  • I/O通道则更进一步, 它不仅能实现数据传输, 还能独立地执行I/O程序, 管理多个I/O设备, 处理更复杂的I/O请求, 将整个I/O子系统从CPU中分离出来, 实现了I/O操作和CPU计算的高度并行化.
在现代个人电脑中, 通常使用的是DMA技术来优化I/O性能. 而I/O通道这种更高级、更复杂的概念, 则更多地出现在大型机和服务器等需要处理海量I/O操作的高端系统中.

*I/O软件的层次结构(初学者可以跳过)

每个设备都有专用接口, 使用驱动程序将其纳入通用的操作系统.
设备驱动程序封装与设备交互的细节, 对上层提供相对统一的接口. 上层只管发出请求, 将其分发到对应的设备驱动程序, 由它来完成底层操作.
目标: 高效率, 通用性(统一管理标准).
notion image
I/O软件的层次结构及其功能
I/O软件的层次结构及其功能
自底向上解析各层:

I/O中断处理程序

位于OS底层, 与硬件设备密切相关, 与系统其余部分联系尽可能少;
进程请求I/O操作时通常被阻塞, 设备完成数据传输后产生I/O中断, CPU响应请求并转入中断处理程序. 中断处理程序检查设备状态寄存器, 判断中断原因, 决定是错误则重试/成功则唤醒等待进程/启动下一个I/O请求等.

设备驱动程序

  • 包括与设备密切相关的所有代码, 从上层(独立于设备的I/O软件)接收I/O请求, 把用户提交的逻辑I/O请求转化为物理I/O操作的启动和执行;
  • 监督设备是否正确执行, 访问数据缓冲区, 进行必要的纠错处理;
  • 控制设备的初始化, 启动, 数据传输, 组织通道程序, 处理中断.
每个设备驱动程序在原则上只处理一种或一类紧密相关的设备, 可以分层实现(高层/底层驱动程序). 驱动程序的代码在内核中的占比越来越大, 而且是内核崩溃的主要贡献者.
示例驱动程序:

独立于设备的I/O软件

提供所有设备的常用I/O功能, 向用户层软件提供一致性接口, 包括设备命名, 设备权限保护, 提供与设备无关的数据单位, 缓冲技术, 分配和设备状态跟踪, 错误处理和报告等.
缺陷: 通用的接口可能不能覆盖某些特殊功能, 这样接口就会成为功能种类的瓶颈.
设备接口实例:
配套交互协议:
  • 等待驱动就绪: 读取状态寄存器(0x1F7)直到驱动 READY 而非忙碌.
  • 向命令寄存器写入参数: 写入扇区数, 待访问扇区对应的逻辑块地址(LBA), 并将驱动编号(master=0x00, slave=0x10, 因为 IDE 允许接入两个硬盘)写入命令寄存器(0x1F2-0x1F6).
  • 开启 I/O: 发送读写命令到命令寄存器. 向命令寄存器(0x1F7)中写入 READ-WRITE 命令.
  • 数据传送(针对写请求): 等待直到驱动状态为 READY 和 DRQ(驱动请求数据), 向数据端口写入数据.
  • 中断处理: 在最简单的情况下, 每个扇区的数据传送结束后都会触发一次中断处理程序. 较复杂的方式支持批处理, 全部数据传送结束后才会触发一次中断处理.
  • 错误处理: 在每次操作之后读取状态寄存器. 如果 ERROR 位被置位, 可以读取错误寄存器来获取详细信息.

用户空间的I/O软件

库函数: 一部分I/O软件可以使用库函数实现, 放在操作系统内核之外, 运行时与应用程序链接.
虚拟设备软件: 用一类设备模拟另一类设备的仿真I/O软件(如用打印机模拟屏幕?).

*I/O缓冲(初学者可以跳过)

在内存中开辟一块缓冲区, 专门用于临时存放I/O操作的数据.
写: 将数据送至缓冲区, 写满或适当时刻系统清洗缓冲区, 将数据写到设备;
读: 将设备上的数据读到缓冲区, 根据要求将各个进程需要的数据读出并传送.
设置目的:
  • 解决CPU与设备之间速度不匹配的矛盾
  • 协调逻辑记录大小和物理记录大小不一致的问题
  • 减少I/O操作对CPU的中断次数, 提高CPU和设备的并行性(摊销)
  • 放宽对CPU中断响应时间的要求

单缓冲

单独一个缓冲区, 平平无奇.
notion image
概念: 单缓冲是最简单的缓冲技术. 它在内存中为I/O操作分配一个单独的缓冲区(一块内存区域). 数据从输入设备读入这个缓冲区, 或者从这个缓冲区写出到输出设备.
工作流程:
  • 输入:
      1. I/O设备将数据读入到指定的缓冲区.
      1. 当缓冲区填满后, 设备I/O操作暂停.
      1. CPU从缓冲区中读取数据并进行处理.
      1. CPU处理完缓冲区数据后, I/O设备才能继续填充下一个缓冲区(通常是重新使用同一个缓冲区).
  • 输出:
      1. CPU将要输出的数据写入缓冲区.
      1. 当缓冲区填满后, CPU暂停写入.
      1. I/O设备从缓冲区中读取数据并进行输出.
      1. I/O设备输出完缓冲区数据后, CPU才能继续填充下一个缓冲区.
特点:
  • 优点: 实现简单, 所需内存空间少.
  • 缺点: 效率不高. I/O设备和CPU之间仍然存在等待关系. 当CPU处理缓冲区数据时, I/O设备必须等待; 当I/O设备填充/清空缓冲区时, CPU也可能需要等待. 这种“串行”的工作方式限制了I/O吞吐量. 它至少会引入一个传输时间或处理时间(取决于哪个更快)的延迟.
  • 适用于: 对I/O速度要求不高的场景.

双缓冲

在主存中设置两个缓冲区: 一个缓冲区在被占用时可以写数据到另一个缓冲区.
notion image
概念: 双缓冲技术在内存中分配两个缓冲区. 当一个缓冲区正在进行I/O操作时(例如, 数据正在从设备写入), 另一个缓冲区可以同时被CPU访问(例如, CPU正在处理里面的数据).
工作流程:
  • 输入:
      1. I/O设备将数据读入缓冲区A.
      1. 当缓冲区A填满后, I/O设备立即切换到缓冲区B继续读入数据.
      1. 与此同时, CPU开始处理缓冲区A中的数据.
      1. 当缓冲区B填满且CPU处理完缓冲区A的数据后, CPU切换到缓冲区B处理, I/O设备切换回缓冲区A继续读入数据.
      1. 两个缓冲区交替使用, 形成流水线效应.
  • 输出:
      1. CPU将数据写入缓冲区A.
      1. 当缓冲区A填满后, CPU立即切换到缓冲区B继续写入数据.
      1. 与此同时, I/O设备开始从缓冲区A读取数据并输出.
      1. 当缓冲区B填满且I/O设备输出完缓冲区A的数据后, I/O设备切换到缓冲区B输出, CPU切换回缓冲区A继续写入数据.
特点:
  • 优点:
    • 显著提高I/O效率和系统吞吐量. I/O操作和CPU处理可以并行进行, 形成流水线, 减少了等待时间.
    • 提高了设备的利用率.
  • 缺点:
    • 需要两倍的内存空间来存放缓冲区.
    • 实现相对单缓冲复杂一点, 需要额外的逻辑来管理两个缓冲区的切换.
  • 适用于: 频繁的、大批量的I/O操作, 如文件读写、网络数据传输、图形显示(避免画面撕裂)等.

循环缓冲

分配一组缓冲区, 每个缓冲区都有指向下个缓冲区的指针, 构成循环缓冲, 能进一步调节设备和进程速度不匹配的问题.
概念: 循环缓冲是双缓冲的推广, 它使用多个(通常是三个或更多)缓冲区, 并将这些缓冲区组织成一个逻辑上的环形结构. 它允许I/O设备和CPU以更平滑、更连续的方式进行数据交换, 进一步提高并行性.
工作流程:
  1. 生产者-消费者模型: 循环缓冲通常用于实现生产者-消费者模型. I/O设备是“生产者”, 不断地将数据写入空闲的缓冲区. CPU是“消费者”, 不断地从已满的缓冲区中读取数据进行处理.
  1. 指针管理: 操作系统会维护两个或多个指针:
      • 一个“写入指针”/“头部指针”指向下一个可供I/O设备写入数据的空闲缓冲区.
      • 一个“读取指针”/“尾部指针”指向下一个可供CPU读取数据的已满缓冲区.
  1. 环形结构: 当读写指针到达缓冲区数组的末尾时, 它们会“环绕”回到数组的开头, 形成一个循环.
  1. 同步机制: 需要同步机制(如信号量、互斥锁)来确保生产者不会覆盖消费者尚未读取的数据, 以及消费者不会读取尚未填充的数据. 当所有缓冲区都满时, 生产者必须等待; 当所有缓冲区都空时, 消费者必须等待.
特点:
  • 优点:
    • 提供了更高的并行性和平滑性, 进一步减少了I/O等待.
    • 更有效地应对I/O速率波动, 因为有更多的缓冲区来吸收临时的数据峰值.
    • 特别适合连续的数据流处理.
  • 缺点:
    • 实现复杂, 需要更复杂的逻辑来管理多个缓冲区和同步.
    • 需要更多的内存空间(取决于缓冲区的数量).
  • 适用于: 高速数据流传输, 如实时音频/视频处理、网络通信协议栈、日志系统、驱动程序与操作系统内核之间的数据交换等.

设备独立性

💡
背景: 如果执行任务时需要指定具体物理设备:
优点: 设备分配简单, 常见于微型计算机OS;
缺点: 认死理, 如果指定设备出现故障, 则即便系统中有同类可替代设备也不能执行任务.
设备独立性: 用户通常不指定具体的物理设备, 而是指定逻辑设备, 使得用户进程和物理设备分离开来, 再通过其他途径建立逻辑设备和物理设备之间的映射. 设备管理中需要将逻辑设备名转换为物理设备名, 为此系统需要提供逻辑设备名和物理设备名的对应表以供转换使用.
优点:
  • 应用程序与具体物理设备无关, 系统增减或变更设备时不需要修改源程序;
  • 易于应对各种I/O设备故障, 提高系统的可靠性;
  • 增加设备分配的灵活性, 有利于更加有效地利用设备资源, 实现多道程序设计.

*独占型外围设备的分配(初学者可以跳过)

💡
独占型外围设备: 一次只能一个进程独占使用(互斥管理需求)
分配策略:
  1. 静态分配: 进程运行之前一次性申请所有需要的设备资源, 实现简单, 能防止死锁, 但会降低设备利用率;
  1. 动态分配: 随用随申请, 需要并发互斥管理, 可以提高设备利用率, 但是可能引发死锁.
设备分配的数据结构:
  • 设备类表: 每类设备对应于设备类表中的一个表项, 字段包括设备类, 总台数, 空闲台数, 设备表起始地址等;
  • 设备表: 每类设备都有各自的设备表, 用于登记这一类(逻辑)设备中的每一台物理设备, 字段包括物理设备名, 逻辑设备名, 占有设备的进程, 分配标志, 好/坏标志等.

*SPOOLing系统(初学者可以跳过)

💡
缓冲在内存中短期存储任务, 而SPOOLing系统在磁盘中持久存储, 调度慢速外设.
“假脱机技术”, 让慢速设备的运行模式类似于批处理.
SPOOLing系统(Simultaneous Peripheral Operations On-Line, 外部设备联机并行操作)是一种操作系统技术, 用于协调计算机与外部设备(主要是慢速设备, 如打印机、磁带驱动器等)之间的输入/输出操作, 以提高资源的使用效率.
是一种用共享型磁盘设备模拟独占性物理设备的技术, 也是一种速度匹配技术. 经典策略: 将高速磁盘设备伪装成慢速字符设备, 通过向磁盘而非慢速设备读写来缩短进程在内存中的驻留时间.
用共享设备代替独占设备, 用虚拟设备代替物理设备接收请求.
结构:
  • “井”: 用作缓冲的存储区域, 调节供求之间的矛盾, 消除人工干预带来的损失
  • “预输入程序”: 操作系统将一批作业从输入设备上预先输入到磁盘的输入缓冲区中暂时保存(“预输入”), 此后由作业调度程序调度作业执行, 作业使用数据时不必再启动输入设备, 只要从磁盘的输入缓冲区中读入
  • “缓输出程序”: 作业执行中不必直接启动输出设备, 只要将作业的输出数据暂时保存到磁盘的输出缓冲区, 当作业执行完毕后, 由操作系统组织信息成批输出
  • “井管理程序”
SPOOLing系统结构
SPOOLing系统结构
作业调度和进程调度的协同关系
作业调度和进程调度的协同关系
核心概念:
  • 缓冲处理: 数据不是直接发送到外设, 而是先存储在磁盘等中间介质中. 进程只经由井管理程序从输入井读入数据, 向输出井输出信息, 使得全部I/O都基于磁盘.
  • 异步操作: 输入/输出设备可以与CPU并行工作, 提高系统并发能力.
  • 排队机制: 多个作业的I/O请求被排队, 按照一定策略有序处理.
举例: 打印任务SPOOLing
  1. 用户提交打印任务.
  1. 任务被保存到SPOOL目录(一个磁盘区域).
  1. 打印机空闲时, 系统将任务一个个送去打印.
优点:
  • 提高CPU和外设的使用效率
  • 支持多任务同时进行
  • 避免I/O操作对程序执行的阻塞
注意事项:
  • SPOOLing适用于速度慢、资源共享的设备(如打印机)
  • 它与缓冲不同: 缓冲是短期内存存储, 而SPOOLing是长期/批量存储
常见应用场景:
  • 打印任务调度
  • 批处理系统的输入输出
  • 数据备份任务
总结: SPOOLing是一种通过磁盘中转来调度慢速外设I/O操作的系统机制, 使CPU与外设能够高效协同运行.

*磁盘(硬件内容, 可以略读或跳过)

一些名词

  • 卷: 存储介质的物理单位, 对应于一盘磁带、 一块软盘、一个光盘片、一个硬盘分区等;
  • 块: 存储介质上连续信息所组成的一个区域, 也叫做物理记录, 是主存储器和辅助存储器进行信息交换的物理单位, 每次总是交换整数块的信息. 不同类型存储介质的块的大小常常各不相同;
  • 顺序存取设备: 严格依赖信息的物理位置次序进行定位和读写, 例如磁带机;
  • 随机存取设备: 每个物理记录有确定的位置和唯一的地址, 存取任何一个物理块所需的时间几乎不依赖于此信息的位置.

对外接口

磁盘对外接口在逻辑层面上表现为操作系统可以识别和访问的抽象设备, 其主要形式包括:
  • 块设备(Block Device): 磁盘在操作系统中通常作为块设备出现, 数据以固定大小的块(如512字节或4KB)进行读写. 块设备支持随机访问, 允许操作系统直接寻址任意块. 所以可以将具有n个扇区的磁盘视为一个扇区数组, 从0~n-1编址. 磁盘保证对单个扇区的写入是原子的(要么完成更新, 要么完全没更新);
  • 文件系统接口: 在用户层面, 磁盘通过挂载文件系统呈现为目录结构, 用户通过文件路径进行访问. 常见文件系统有FAT32、NTFS、ext4等. 文件系统负责将逻辑文件映射到磁盘物理块;
  • 逻辑卷管理(LVM)接口: 操作系统可以通过逻辑卷管理器将多个物理磁盘抽象为逻辑卷, 实现磁盘的灵活分区、扩展与快照功能, 提升管理灵活性;
  • 虚拟磁盘接口: 在虚拟化环境中, 磁盘可能表现为虚拟磁盘(如VDI、VMDK文件), 由虚拟机管理程序映射到物理磁盘资源, 提供隔离的存储空间.
这些逻辑接口屏蔽了底层硬件细节, 为上层应用和操作系统提供统一、抽象且高效的磁盘访问方式.

基本内部结构

磁盘由以下几个主要部分组成:
  • 盘片(Platter): 圆形磁性材料的薄盘, 是数据存储的载体, 通常由多个盘片堆叠组成.
  • 磁头(Head): 用于读写数据, 通常每个盘面对应一个磁头.
  • 磁臂(Actuator Arm): 带动磁头在盘面上移动以定位到特定磁道.
  • 主轴(Spindle): 驱动盘片高速旋转, 常见转速有5400 RPM、7200 RPM、10000 RPM等.
  • 磁道(Track): 盘面上的同心圆区域, 用于组织数据. 在扇区线密度相同的情况下, 外圈周长更大, 包含的扇区更多. 考虑到移动磁头需要时间, 为了让磁头移动之后能继续访问下一个连续扇区(而非等待一周旋转), 磁道之间常常采取磁道偏斜设计, 即将相邻号的扇区错开一些, 使得磁头就位之后刚好续上下一个连续扇区;
    • 磁道偏斜示意
      磁道偏斜示意
      不同盘面上位于相同位置的磁道构成柱面
  • 扇区(Sector): 磁道上最小的数据单元, 常为512字节. 每个磁道分为多个固定扇区, 相邻扇区组合成簇.
  • 柱面(Cylinder): 多个盘片上相同编号的磁道集合, 形成柱状结构.
  • 磁道缓冲区(Track Buffer): 现代磁盘驱动器中自带的少量缓存(通常为8MB或16MB), 用于保存单条磁道上的所有扇区(方便响应对同一磁道上多个扇区内容的多次访问).

延迟计算

磁盘访问延迟通常由以下几个部分组成:

单磁道旋转延迟

这是磁头到达目标扇区所需等待的时间, 平均为旋转一圈所需时间的一半:
平均旋转延迟 = 0.5 ×(60 ÷ 转速)(单位: 秒)
例如, 对于7200 RPM磁盘:
平均旋转延迟 = 0.5 ×(60 ÷ 7200)≈ 4.17 毫秒

多磁道寻道时间

这是磁臂从当前位置移动到目标磁道所需的时间, 通常分为:
  • 最小寻道时间: 磁头已在目标磁道上, 为0.
  • 平均寻道时间: 通常由厂商提供, 或通过经验模型估算.
  • 最大寻道时间: 从最外层磁道到最内层磁道, 取决于磁盘物理结构.
经验模型:
T_seek = α + β × n
其中, n 为跨越的磁道数, α 和 β 为常数.

传输时间

这是将数据从磁盘传输到内存的时间, 计算公式如下:
传输时间 = 数据大小 ÷ 传输速率
例如, 传输4KB数据, 速率为100 MB/s, 则:
传输时间 = 4KB ÷ 100MB/s ≈ 0.04 毫秒

磁盘调度

如果随机响应I/O请求, 性能可能非常差.
磁盘调度算法用于决定多个I/O请求的服务顺序, 以优化平均响应时间或吞吐量.
较早的系统中磁盘调度交由操作系统全权负责, 而现代磁盘内部就具有复杂的内部调度程序, 可以精确控制所有硬件细节(如磁头的位置等), 故现代磁盘的调度方式往往是由操作系统选出几个它认为最好的请求, 将其全部发送到磁盘, 再让磁盘的内部调度程序决定具体调度方式.
现代调度系统还会进行I/O合并, 将多个连续单块的读取合并成一个多块请求.
有时候等待一段时间再将请求发送到磁盘, 会得到更好的结果(可能会有新的更合适的请求到达).
💡
notion image
SPOOLing系统伪脱机
SPOOLing系统伪脱机

移臂调度: 减少寻道

最短寻道时间优先(SSTF)

  • 选择距离当前磁头位置最近的请求进行处理.
  • 优点: 减少平均寻道时间.
  • 缺点: 可能导致远端请求长时间得不到响应(饥饿问题).

扫描算法(SCAN/LOOK)

  • SCAN(双向扫描): 磁头朝一个方向移动时处理沿途的请求, 到达最末端后反向.
  • C-SCAN(单向扫描): 只在一个方向上处理请求, 到达末端后快速返回起始端, 不处理请求.
  • LOOK/C-LOOK(电梯调度): 扫描的改进, 当前移动方向上前方没有访问请求时就回头. “C”决定是双向还是单向.
  • 优点: 相对公平, 减少饥饿现象.
  • SCAN适合负载均匀场景, C-SCAN适合高请求密度场景.

分步SCAN(FIFO和SCAN的混合策略)

💡
进程重复请求同一磁道会垄断整个设备, 造成磁头臂的”粘性”, 采用分步扫描可避免这类问题.
把磁盘请求队列均分成长度为N的多个子队列, 每一次用SCAN处理一个子队列
在处理一个队列时, 新请求必须添加到其他某个队列中
如果在扫描的最后剩下的请求数小于N, 则它们全部将在下一次扫描时处理
当N很大, 所有请求都在同一个队列中SCAN, 则分步SCAN退化为SCAN; 当N=1时, 退化为FIFO.

FSCAN

使用两个子队列, 开始扫描时所有请求都在一个队列中, 另一个队列为空. 扫描过程中所有新到的请求都被放入另一个队列中.
效果: I/O屏障, 对新请求的服务一定在完成所有老请求之后.

最短定位时间优先(SPTF)

  • 选择预计完成时间最短的请求处理, 考虑寻道时间和旋转延迟的综合.
  • 优点: 在高精度延迟控制场景下更优.
  • 缺点: 计算复杂度较高, 依赖较详细的硬件特性模型.
该调度策略理论最优, 但实践中实现较少, 多用于模拟或高端系统.

旋转调度: 减少旋转

循环排序

优化I/O请求排序, 在最少旋转圈数内完成对单一柱面的访问请求.
使用旋转位置测定硬件和多磁头同时读写技术来提高旋转调度的策略.

优化分布

在讲述磁盘结构的时候提到过, 通过优化扇区在磁盘上的排列方式来减少旋转延迟.
  • 交替排序: 对扇区间隔编号. 交叉因子n:1表示相邻编号扇区会间隔n-1个扇区位, 防止处理当前扇区数据时, 下个要访问扇区已经转过;
  • 将相邻扇区集中成簇读写(例如磁道缓冲区, 直接读一整条磁道);
  • 按柱面集中存储数据(改串行为并行);

*廉价冗余磁盘阵列 RAID(硬件内容, 可以略读或跳过)

对外接口

💡
对于上层的文件系统, RAID看起来像是一个很大的、(我们希望是)快速的、并且(希望是)可靠的磁盘. 就像单个磁盘一样, 它将自己展现为线性的块数组, 每个块都可以由文件系统(或其他客户端)读取或写入.
RAID(Redundant Array of Independent Disks)对外以单一逻辑磁盘的形式呈现, 操作系统将其视为一个标准的块设备, 进行统一的读写操作. RAID控制器或软件RAID系统负责将这些操作映射到多个物理磁盘. 其对外接口特性包括:
  • 逻辑块设备接口: RAID隐藏了底层多个磁盘的存在, 提供一致的逻辑块地址空间.
  • 透明性: 上层应用和文件系统无需了解数据如何分布在多个磁盘上, 读写操作由RAID系统自动调度.
  • 热插拔支持: 在支持热备份的RAID级别中, 对外接口可支持磁盘热插拔, 提高系统可用性.
  • 性能与冗余增强: 在不同RAID级别下, 对外表现为更高的吞吐量(如RAID 0)或更高的容错能力(如RAID 1/5).

基本内部结构

RAID的内部结构依赖于其级别, 但通常包含以下组件:
  • 多个物理磁盘: 通过硬件或软件组合而成.
  • 条带化(Striping): 将数据划分为多个条带(Stripe), 分布在不同磁盘上, 提高并行读写性能.
  • 冗余机制: 某些级别(如RAID 1、RAID 5)引入数据副本或奇偶校验位, 实现容错能力.
  • RAID控制器:
    • 硬件RAID控制器: 独立处理RAID逻辑, 减轻CPU负担, 提供更高性能.
    • 软件RAID: 由操作系统管理, 成本低, 适用于普通服务器或桌面环境.
  • 缓存与写策略: 部分RAID控制器带有缓存, 可配置写回或直写策略, 影响性能和可靠性.
当文件系统向RAID发出逻辑I/O请求时, RAID内部计算要访问的磁盘(或多个磁盘)以完成请求, 然后发出一个或多个物理I/O来执行此操作. 这些物理I/O的确切性质取决于RAID的级别.
RAID系统通常构建为单独的硬件盒, 并通过标准连接(例如SCSI或SATA)接入主机. 然而RAID内部相当复杂. 它包含一个微控制器, 运行固件指导RAID的操作. 它还包含易失性存储器DRAM, 用于在读取和写入时缓冲数据块. 在某些情况下, 还包含非易失性存储器, 用于安全地缓冲写入(可能对崩溃恢复有用). 它甚至可能包含专用的逻辑电路, 用于执行奇偶校验计算(在某些RAID级别中非常有用, 下面会提到). 在高层面上, RAID是一个非常专业的计算机系统: 它拥有自己的处理器, 内存和磁盘. 然而它不运行应用程序, 而是运行专门用于操作RAID的软件.

故障模型

RAID的设计目标之一是应对磁盘故障, 其常见故障模型包括:
  • 磁盘单点故障: 部分RAID级别可容忍一个或多个磁盘失效(如RAID 1、5), 系统仍能正常工作.
  • 双盘失效: 标准RAID 5无法容忍两个磁盘同时失效, 需使用RAID 6等更高级别.
  • 控制器或缓存故障: 硬件RAID控制器损坏可能导致整个阵列不可用, 应配备备份控制器或冗余设计.
  • 数据不一致或写入中断: 在断电或系统崩溃情况下, 未完成的写操作可能导致冗余信息不一致(写孔问题).

RAID 0: 条带化

💡
要性能不要安全, 无冗余设计.
notion image
  • 结构: 将数据按条带划分, 交错写入多个磁盘. 单个块的大小可以任意设置, 较大的块定位成本低但是并行性差, 较小的块反之, 需要权衡.
  • 优点:
    • 所有磁盘可以并行读写, 拥有极高的读写性能, 适用于高带宽应用.
    • 无冗余开销, 磁盘空间完全可用.
  • 缺点:
    • 无容错能力, 任一磁盘损坏都会导致整体数据丢失.
  • 适用场景: 对性能要求高且数据可重建的场合(如视频编辑、临时数据处理).
对N块盘的RAID 0:
指标
性能(相对单块盘)
读性能
(最好)N倍
写性能
(最好)N倍
空间利用率
100%
损坏容忍度
零容忍, 坏一个全寄

RAID 1: 镜像

💡
要安全不要性能, 直接存储两份相同数据.
可以与RAID 0组成RAID 01或RAID 10, 增加一些并行性来提高性能.
实际上是RAID 01, 先条带化再镜像
实际上是RAID 01, 先条带化再镜像
  • 结构: 每份数据在两个或多个磁盘上保持完全一致的副本.
  • 优点:
    • 具备完整的容错能力, 任何一个磁盘损坏数据仍然可用.
    • 读性能提升, 可从多个磁盘并发读取.
  • 缺点:
    • 存储效率低, 磁盘利用率为50%(双盘镜像).
  • 适用场景: 对数据安全性要求极高的系统, 如数据库、金融系统.
对N块盘的RAID 1:
指标
性能(相对单块盘)
读性能
(最好)N倍
写性能
和阵列中最差的磁盘一致(≤1倍)
空间利用率
50%
损坏容忍度
至少可以容忍坏一个, 至多可以容忍坏N/2个(镜像盘有一块可用即可)

RAID 10: 混合策略

先镜像再条带化
先镜像再条带化
将磁盘组成 RAID1后, 再组成RAID0, 这样写入的时候可以拥有RAID0的速度, 同时又可以拥有RAID1的数据安全性.
指标
性能(相对单块盘)
读性能
(最好)N倍
写性能
N/2倍
空间利用率
100% - 1/N
损坏容忍度
可以允许坏掉一块

RAID 4: 奇偶校验

💡
保守地提供冗余, 比RAID 0安全, 比RAID 1存储效率高.
notion image
  • 结构:
    • 数据条带化分布在多个磁盘上.
    • 单独设置一个磁盘存放所有奇偶校验信息.
  • 优点:
    • 支持容忍单个磁盘故障, 具有容错能力.
    • 相较RAID 1节省空间, 效率更高.
  • 缺点:
    • 奇偶校验磁盘成为性能瓶颈, 每次写操作都需更新该磁盘.
  • 适用场景: 对写入压力不高的中等容错场景.
对N块盘的RAID 4:
指标
性能(相对单块盘)
读性能
(最好)N-1倍
写性能
受校验盘限制
空间利用率
100% - 1/N
损坏容忍度
可以允许坏掉一块

RAID 5: 旋转奇偶校验

💡
将RAID 4中的奇偶校验条带打散, 在不降低安全性的前提下随机写入性能明显提高(不受到奇偶校验盘的瓶颈限制), 随机读取性能小幅提高, 没有小写入问题, 各盘均衡.
RAID 5基本上与RAID 4相同, 在少数情况下更好, 几乎完全取代了市场上的RAID 4.
notion image
  • 结构:
    • 类似RAID 4, 但奇偶校验信息分散在各个磁盘中, 按条带轮转分布.
  • 优点:
    • 容忍单盘故障.
    • 无奇偶校验磁盘瓶颈, 读写性能均衡.
    • 存储利用率较高(N块磁盘中有 N-1 块可用于数据).
  • 缺点:
    • 写操作涉及读取旧数据和校验信息, 写性能低于RAID 0.
    • 无法容忍双盘故障.
  • 适用场景: 适合读多写少的通用服务器、存储系统.
指标
性能(相对单块盘)
读性能
(最好)N倍
写性能
略弱于RAID0
空间利用率
100% - 1/N
损坏容忍度
可以允许坏掉一块, 支持热插拔一块, 但是不能同时拔出两块

总结

notion image
notion image

文件和目录API

文件

文件就是一个线性字节数组, 每个字节都可以读写.
文件的低级名称: inode号(每个文件都有一个inode号与其关联, 可能多个文件关联到同一inode).
文件系统不了解文件的结构(编码方式)/内容, 只负责保管和存取.
💡
*补充: 文件的逻辑/物理结构(初学者可以跳过)

文件的逻辑结构

又称为逻辑文件, 是独立于物理环境的, 用户概念中的抽象信息组织方式, 是用户能观察到并加以处理的数据集合.
文件的逻辑结构分为两种形式: 流式文件/记录式文件.
  • 流式文件: 文件内的数据不组成记录, 只是由一串字节组成的信息流序列, 常常按长度读取所需信息, 也可以插入特殊字符作为分界(如字节流)
  • 记录式文件: 一种有结构的文件, 是若干逻辑记录(文件中按信息在逻辑上的独立含义所划分的信息单位)所组成的记录流文件(如每个职工的工资信息是一个逻辑记录, 整个单位职工的工资信息便组成了该单位工资信息的记录式文件).
记录式文件
记录式文件

文件的物理结构

又称为物理文件, 指文件在物理存储空间中的存放方法和组织关系. 涉及块的划分、记录 的排列、索引的组织、信息的搜索等许多问题, 其优劣直接影响文件系统的性能.
物理结构分为顺序/连接/直接/索引文件几种.
  • 顺序文件: 将一个文件中逻辑上连续的信息存放到存储介质中在物理上相邻的块中, 形成顺序结构, 优点是存取记录速度较快, 缺点是建立之前要确定文件长度一遍分配空间, 修改/插入/增加文件记录有困难;
  • 连接文件(串联文件): 使用连接字来表示文件中各个物理块之间的先后次序, 第一块文件信息的物理地址由文件目录给出, 而每一块的连接字指出了文件的下一个物理块位置; 连接字内容为0时, 表示文件至本块结束, 即按链表结构存储, 所谓”连接字”即为链表指针(什么破翻译), 常用于输入井和输出井. 优点是易于对文件记录做增、删、改, 易于动态增长记录, 不必预先确知文件长度, 存储空间利用率高, 缺点是存放指针需额外的存储空间, 且待获得连接字后才能找到下一物理块的地址, 因而仅适用于顺序存取, 不能随机存取;
  • 直接文件(散列文件): 通过散列计算逻辑记录的键值建立与其物理存储地址之间的对应关系, 可能出现哈希冲突, 可通过拉链法、循环探查法、二次散列法、溢出区法等方式解决;
  • 索引文件(即使用inode): 为每个文件建立一张索引表(即inode), 每个条目包含一个记录的键(或逻辑记录号)及其存储地址(直接/间接指针). 索引表的地址(inode号)可由文件目录指出, 查阅索引表找到相应记录(即inode), 然后获得数据存储地址(即数据块指针). 优点: 是连接文件的一种扩展, 易于增删改的同时还能随机存取. 缺点是访问索引再访问数据块需要两次访存开销, 若将索引(inode)提前调入内存(变成活跃inode)则可以减少开销.

目录

💡
实现文件按名存取的关键数据结构, 维护inode号与文件名的映射
目录的低级名称: 也是inode号.
目录自身的内容: Pair<人类可读名, inode号>, 维护该目录下文件名→inode号的映射. 每个条目都指向一个文件或其他目录(目录和文件可以重名), 故用户可以构建任意的目录树, 根节点即为根目录/, 非叶节点为目录文件, 叶节点为文件或空的目录.
💡
树形父目录: 一个节点只能有一个父节点, 不能形成对称的文件共享, 要访问某必须经由他的属主.
有向无环图目录: 允许文件有多个父目录, 更复杂, 破坏了树特性但是可以实现对称共享. 需要为每个文件维护一个引用计数, 只有引用计数为0才可以将该文件彻底删除.
目录需要永久保存, 因此也组织称文件持久化存放在磁盘上, 称为目录文件.
目录树结构实例(Linux)
目录树结构实例(Linux)

文件系统API

创建文件

返回一个文件描述符fd(进程私有), 作为访问该文件的一个句柄(即引用/线索/指针, whatever).
💡
“句柄”到底是哪个神金想出来的名词?
使用open()打开的文件获得的fd0开始连续递增计数. 一般进程创建时会将0初始化为stdin, 1初始化为stdout, 2初始化为stderr. 也就是说首个获得的fd一般为3.

读写文件

💡
小工具: strace(Linux), dtruss(macOS), truss(Unix), 跟踪程序使用的系统调用.
改变读写位置:
读写行为隐式地改变文件指针位置, 而lseek()是显式地改变文件指针位置.
write()操作可能被缓冲(OS决定按批写入, 摊销)→ 可以fsync(int fd)调用强行立即写入(除了立即写入该文件本身可能还需要立即写入对该文件所在目录的修改).

文件重命名

可以用于实现某些原子更新行为, 例如:

文件元数据

储存元数据的结构如下:
notion image
每个文件通常将该类型信息保存在其inode中, 可以使用stat程序(使用statfstat系统调用)查看.

删除文件

创建目录

目录本身的信息由文件系统自动维护, 无法直接被更新, 只能通过在目录中新建/删除/修改文件和目录等内容间接更新.
空目录有两个条目: .(自己)和..(父目录).
参数mode指示rwx权限(如0777).

读取目录

目录条目的数据结构:
目录本身就相当于元素类型为dirent的列表.
目录本身只记录少量信息. 若想获取目录下各文件的详细信息(如使用ls -l), 则需要对该目录下的每个文件再各执行一遍stat().

删除目录

硬链接

硬链接: 将人类可读文件名关联到inode.
创建文件的流程: 新建一个inode结构来追踪几乎所有关于该文件的信息, 然后使用link()系统调用建立硬链接将人类可读的名称关联到该inode, 并将该链接放入目录. 多个人类可读名可以通过硬链接关联到同一inode(文件本体).
因此删除文件就要调用unlink(), 删除人类可读名称和inode号之间的硬链接. 当链接到某一inode的人类可读名称数量为0时即可释放该inode对应的数据块(可作他用, 不一定擦除, 留下恢复空间), 因为没有了链接/指针就说明不会再从上层被引用.
stat程序/系统调用同样可以查看一个inode的引用计数.
硬链接的局限:
  1. inode只在单一而非跨文件系统内唯一, 故不在同一文件系统内的文件和inode不能建立硬链接;
  1. 不能创建目录的硬链接, 因为可能导致目录树中出现环.

符号链接

又称软链接, 将人类可读文件名关联到链接指向文件的路径名, 是独立于文件和目录之外的第三种类型的文件系统成分.
💡
软链接文件的内容就是其指向的文件的路径名. 链接到的路径名越长则软链接文件越大.
相比硬链接, 软链接可以链接不同文件系统下的文件.
当软链接指向的文件路径被删除, 就会产生空指针/野指针等悬空引用问题.

创建和挂载文件系统

完整目录树的构建: 先分别制作不同的文件系统模块, 再将其挂载到目录树的相应位置.
mkfs工具: 使用指定的存储设备(如磁盘分区)创建指定类型的空文件系统.
mount程序/系统调用: 以现有目录作为目标挂载点, 将新的文件系统粘贴到目录树的指定节点上, 即可在统一的文件系统树中通过唯一路径访问该节点. 挂载之后挂载点的路径就是该文件系统的根目录路径.
运行mount程序即可查看系统当前挂载的内容和位置.
notion image

文件系统实现

💡
文件系统是纯软件, 不会使用专门的硬件支持, 具有很强灵活性.
核心: 按名存取.
理解某个具体文件系统的两个角度: 数据结构(数据和元数据的组织方式, 如数组/树结构)+ 访问方法(如何将进程发出的调用映射到文件系统的结构上, 即调用对应系统的哪些结构和行为).

极简文件系统 VSFS

数据区域

将磁盘分为个固定大小4KB的块的集合, 地址从. 大部分块用于存储用户数据, 少部分保留给文件系统自身, 用于记录元数据(如inode).
0~7块用于存放元数据, 其他为数据区域.
0~7块用于存放元数据, 其他为数据区域.

inode

💡
inode记录了诸如文件类型, 文件大小, 文件所有者, 访问权限, 访问和修改时间, 文件包含数据块的位置等元数据信息.
特别地, inode中不包含人类可读的高级文件名.
inode字段示例
inode字段示例
全名index node,(用inode号在inode数组中)索引(的)节点, 每个inode用inode号隐式引用, 给定一个inode号就可以根据inode表起始地址和inode大小计算目标inode的地址.
在元数据区域分配一些块, 用于存放inode数组. 该区域称为inode表. inode数组的大小决定了文件系统中文件的最大数量(要容纳更多文件需要扩大inode表).
notion image
💡
文件系统如何维护inode到数据块的映射?
  1. 直接指针: 在inode中维护若干个指针, 每个指向一个数据块. 缺陷: 能维护的指针数量有限 → 文件大小有上限, 对大文件支持不佳.
  1. 二级索引: 在inode中维护若干个直接指针和一个间接指针, 间接指针指向一个专门用于存放直接指针的数据块(可以形象理解为”直接指针库”). 所有的直接指针都指向一个数据块.
  1. 多级索引: 指针库中继续存放指向其他指针库的间接指针, 即可通过树结构构建指数级增长的数据块映射. 大部分文件很小, 所以多级索引的树结构为不平衡树.
    1. 一种多级索引结构
      一种多级索引结构
      notion image
💡
例题:
notion image
还有一种链表结构: 维护一个文件分配表, 维护某数据块的直接指针 → 下一个数据块的直接指针的映射关系, 从而可以顺序查找一个文件的所有数据块. 缺陷是随机访问效率较低.

位图

需要一个结构来记录inode表和数据块的分配情况(哪些空闲/哪些占用)→ 在元数据区域分配空间用于存放inode位图和数据块位图(bitmap), 用二进制序列的各位标记对应的inode/数据块是否被占用(空闲为0, 占用为1).
inode位图i, 数据位图d.
inode位图i, 数据位图d.
为文件分配数据块时, 以分配连续的数据块为佳(顺序存取效率高).

超级块

最后还需要在元数据区域加上一个超级块(super block), 用于存储描述该文件系统自身的信息, 例如文件系统中inode和数据块的个数, inode表的开始位置等, 还可能包含一些用于标识文件系统类型的幻数, 地位相当于该文件系统的使用说明/导航(不然上层不知道文件系统中各区域的划分).
最终组织形式
最终组织形式
文件系统挂载时将超级块调入内存, 其他部分留在磁盘中.

目录组织

目录只包含一个Pair<文件名, inode号>二元组的列表.
notion image
删除文件 → 可以将目录条目的inum设置为0(保留, 标记未用)以备重用.
文件系统通常将目录视为特殊类型的文件, 一个目录分配一个inode, 其中”类型”字段设置为”dir”. inode(使用直接或间接指针)指向的数据块中存储上述二元组列表.
除了简单列表之外还可以使用其他更高级的数据结构来存储目录条目(如B-树).
目录项, inode和数据块之间的关系
目录项, inode和数据块之间的关系

创建文件

  1. 为新文件分配inode和活动inode, 将活动inode加入主存活动inode表(存放活动inode的主存区域);
  1. 将inode号和文件名组成新目录项写入目录文件数据;
  1. 初始化新inode(如存取权限和链接计数等);
  1. 分配用户打开文件表(维护fd → 系统打开文件表项的映射)项和系统打开文件表(维护系统打开文件表项 → 活动inode表表项的映射)项, 并初始化(如文件读写偏移);
  1. 将各表项用指针链接起来, 将文件描述符fd返回给用户, fd即为该文件在用户打开文件表中的索引.
文件系统内部结构
文件系统内部结构

删除文件

  1. 将指定文件的目录项从所在目录中删去;
  1. 文件的链接用户-1, 若变为0则释放文件占用的存储空间(使用unlink(fileName)调用)
删除必须持有对文件的写权限.

打开文件

总过程: 根据完整路径名(顶层)找到对应的inode(底层).
  1. 遍历路径名,(在磁盘中)通过父目录的inode号索引到父目录的内容, 再从父目录的内容条目中找到子目录或目录中文件的inode号, 递归查找(起点: 根目录使用约定的周知inode号, Unix中一般为2, 找到目标文件的inode后将其读入内存(令其成为活动inode);
  1. 进行权限检查, 若检查通过则在进程私有的用户打开文件表中, 在全局的系统打开文件表中, 以及系统的全局主存活动inode表中各添加一个条目, 并用指针结构将它们链接起来. 最终将文件描述符fd返回给用户.
    1. 💡
      全局打开文件表中, 可以为不同的进程维护不同的记录, 如为多个进程独立维护文件读写偏移等.
      notion image
      不同用户打开文件表项可以通过指向同一个系统打开文件表项来实现文件共享(共享文件读写偏移), 也可以指向不同系统打开文件表项, 再让不同的系统打开文件表项指向同一个活动inode表项(分别拥有自己的文件读写偏移).

读取文件

  1. 打开文件;
  1. 检查read权限;
  1. 若权限合法, 则发出read()系统调用, 设置文件偏移量并开始读取, 从用户打开文件表索引到系统打开文件表, 再索引到活动inode表, 查阅活跃inode以获取每个块的位置, 从磁盘中读取块, 更新inode的相关字段, 文件偏移量实时更新;
  1. 结束访问后调用关闭过程.
notion image
notion image
💡
普遍顺序: 查inode → 读数据块 → 更新inode.
读取时不会访问分配结构(即位图), 因为已经检查到inode指向的数据块, 默认它已经存在.
调用open()打开文件时的I/O量与路径长度正相关(递归深度成正比).
对于读取大文件的情况, 可能需要遍历很多(间接)指针库, 开销会更大.

写入文件

用与读取类似的方式, 打开目标文件, 发出write()调用, 用完后关闭文件.
区别: write可能涉及新数据块的分配. 对已存在的文件进行写入操作首先要决定将哪个块分配给文件, 然后再将数据写入某个块, 同时需要更新inode位图和数据位图和inode等结构. 故每次写入文件至少会导致5个I/O: 读取数据位图, 更新数据位图(分配新块), 读取inode, 更新inode(增加指针), 将数据写入数据块.
可以通过前面提到的API中的whence来控制读写偏移指针的位置.
若要创建一个新文件, 首先要分配一个inode, 还要在直接包含该文件的目录数据中添加该文件的条目. 故需要进行以下I/O: 读取inode位图, 写入inode位图, 初始化inode, 读取目录inode, 更新目录inode, 写入目录数据(甚至还可能需要为目录分配更多的数据块).
notion image
单次操作的开销太大, 需要使用缓存和缓冲机制来摊销.

关闭文件

  1. 根据fd找到用户打开文件表项, 再由此找到系统打开文件表项, 再由此找到活动inode表项;
  1. 释放用户打开文件表项;
  1. 将对应系统打开文件表项中的引用数f_count-1, 若减到0则释放该系统打开文件表项;
  1. 将对应活动inode中的引用数i_count-1, 若减到0则将该inode中的内容写回磁盘的相应位置, 然后释放该活动inode.
💡
f_count和i_count分别反映了两种共享文件方式的计数.

文件共享

  1. 静态共享: 不同目录项对应的文件名共享inode(硬链接/符号链接);
  1. 动态共享: 多个运行时进程并发访问同一个文件的数据.

缓存和缓冲

读取和写入文件开销很大, 会导致频繁的I/O从而产生性能问题. 大多数文件系统会使用系统内存来缓存重要的(频繁更新的)块(或inode), 从而减少磁盘I/O的次数. 使用页替换策略决定哪些块留在内存中.
💡
将主存作为辅存的缓存, 访问时将数据块和inode调入, 然后与其他页一同由页替换策略决定去留.
同时维护一个”活动文件表(活动inode表)”, 将在主存区域中的文件(inode)作为表项填入表中, 建立用户进程与该文件索引的联系. 关闭文件时再从表中移除.
初次访问某文件时, 若在活动inode表中找不到目标inode, 则为其申请一个活动inode, 填入活动inode表, 随后由它来控制文件读写. 关闭时将对该活动inode修改的内容写回磁盘, 并将其释放.
早期的文件系统从内存中划分出固定比例的空间用于缓存块, 然而纯静态划分可能导致浪费. 相比之下现代系统更多采用动态划分方法, 即将虚拟内存页面和文件系统页面集成到统一页面缓存中, 即将两种页面同等对待, 拥有更强灵活性. 只要缓存命中就不需要磁盘I/O, 从而减少读取流量.
通过使用写回机制, 在数据页面被替换的时候再将修改写回磁盘, 可能可以减少写入的次数, 并且批处理和I/O调度可以摊销, 提高性能.
大多数现代文件系统都将写入在内存中缓冲5~30s, 但是会带来其他问题(若系统在缓冲时崩溃则会丢失所有更新). 所以这是性能和可靠性之间的折中. 某些对数据丢失敏感的程序会主动调用fsync()来强制立即完成更新, 甚至干脆直接越过文件系统, 使用原始的磁盘接口.

*主存映射文件(初学者可以跳过)

主存映射文件(Memory-Mapped File)是一种操作系统提供的机制, 它允许将磁盘上的文件内容直接“映射”到进程的虚拟地址空间中的一段连续区域. 一旦文件被映射到内存, 应用程序就可以像访问普通内存数组一样, 直接通过指针来读取和修改文件内容, 而不需要使用传统的 read()write()等I/O系统调用.
核心思想与工作原理:
  1. 虚拟地址空间映射:
    1. 当一个文件被主存映射时, 操作系统并不会立即将整个文件内容加载到物理内存中. 相反, 它会在进程的虚拟地址空间中分配一块区域, 并将这块区域与磁盘上的文件建立起一一对应的关系.
  1. 按需分页/按需加载(Demand Paging):
    1. 实际的数据加载是按需进行的. 当程序首次访问映射区域中的某个地址时, 如果对应的文件内容(通常是一个页大小的数据块)不在物理内存中, 就会触发缺页中断(Page Fault). 此时, 操作系统会负责将该页从磁盘加载到物理内存中, 并更新页表, 使虚拟地址能够正确地映射到物理地址.
  1. 内存访问代替I/O操作:
    1. 一旦数据被加载到物理内存, 后续对该区域的访问就变成了直接的内存访问, 就像访问普通的内存数组一样. 这比传统的I/O操作快得多, 因为避免了:
      • 系统调用开销: 传统I/O操作需要频繁地在用户态和内核态之间切换.
      • 数据拷贝: 传统I/O操作通常涉及数据从内核缓冲区到用户缓冲区, 以及从用户缓冲区到内核缓冲区的两次拷贝. 主存映射文件通过直接映射, 减少了甚至避免了这种不必要的拷贝.
  1. 数据同步:
    1. 对映射内存区域的修改会自动(或在特定情况下由操作系统控制)同步回磁盘上的原始文件. 这意味着你修改内存中的数据, 就是直接修改文件本身.
主要用途:
  • 高效文件I/O: 这是最常见的用途. 对于处理大文件(例如数据库文件、日志文件、大型图像文件等), 主存映射文件可以提供比传统 read()/write()更高的性能, 因为它避免了数据拷贝和频繁的系统调用.
  • 进程间通信(IPC)/ 共享内存: 多个进程可以将同一个文件映射到各自的虚拟地址空间中. 这样, 它们就可以通过访问这块共享的内存区域来高效地进行数据交换, 而无需复杂的消息传递或管道机制. 这是实现共享内存的一种重要方式.
  • 加载程序和动态链接库: 现代操作系统在加载可执行程序(.exe)和动态链接库(.dll/.so)时, 通常会使用主存映射技术. 程序代码和数据被映射到进程的虚拟地址空间, 只有当实际需要时才会被加载到物理内存, 这大大加快了程序启动速度并节省了内存.
  • 页面文件(Page File)的实现: 虚拟内存系统利用磁盘上的页面文件(也称为交换文件或交换区)作为物理内存的扩展. 这个页面文件在某种意义上就是一种主存映射文件, 操作系统将其映射到虚拟内存空间, 用于实现内存页的换入换出.
优点:
  • 性能提升: 显著减少了I/O操作的开销(系统调用、数据拷贝), 尤其适合大文件和随机访问.
  • 简化编程: 程序员可以像操作内存一样操作文件, 无需关心文件指针、缓冲区管理等细节, 代码更简洁.
  • 多进程共享: 提供了一种高效、直接的进程间共享内存的方式.
  • 节省内存: 对于部分文件访问, 可以只加载文件的一部分到内存, 避免一次性加载整个大文件. 操作系统可以利用其内存管理机制(如页面置换算法)自动管理哪些文件页应该驻留在物理内存中.
缺点和注意事项:
  • 对齐限制: 内存映射通常以内存页(例如4KB)为单位进行. 如果文件大小不是页大小的整数倍, 可能会导致一些内存浪费(但通常不是大问题).
  • 同步问题: 如果多个进程同时写入同一个共享映射文件, 需要适当的同步机制(如互斥锁、信号量)来避免数据竞争和损坏.
  • 错误处理: 当底层文件发生错误(如磁盘损坏)时, 内存访问可能会导致进程崩溃或异常.
  • 资源管理: 映射文件会占用进程的虚拟地址空间和文件句柄, 需要正确地解除映射和关闭文件.

快速文件系统FFS

💡
把磁盘当做磁盘, 让文件系统的实现具有”磁盘意识”, 令其结构和分配策略与磁盘特性相适应.

旧文件系统的局部性问题

旧Unix文件系统:
notion image
优点: 简单, 支持文件系统提供的基本抽象;
问题: 性能很差, 且随时间推移越来越差.
问题根源: Unix文件系统将磁盘当成RAM(随机存取)来处理, 数据遍布各处, 没有考虑到磁盘的定位成本(如寻道时间). 而且空闲空间没有得到精心管理, 最终会散布在磁盘的各处. 再分配给新文件时会将逻辑连续的文件在物理上存储在磁盘的各处.
notion image
💡
在磁盘上来回访问逻辑上连续的文件, 完全没有利用局部性, 大大地降低了性能.
磁盘只有在连续读取时才能获得峰值性能.
其他问题: 块太小, 虽然内部碎片少, 但是块定位开销更频繁.

FFS: 柱面组

将磁盘划分为若干个柱面组, 每个柱面组包含若干个块(所以柱面组也可以称作”块组”). 每个柱面组内都分配超级块+位图+数据区域的结构, 都可以分配文件和目录(即组就像一个子文件系统).
notion image
notion image
优点: 访问同一个组中的多个文件时不会出现长时间的磁盘寻道.
每个组都带有超级块, 以便在部分组故障时文件系统仍然能正常挂载.

文件和目录的分配策略

💡
咒语: 相关的东西放一起, 无关的东西分开放.
决定什么是相关的数据, 并将其放在同一个块组内; 不相关的东西放在不同的块组中.
放置推断方法:
  • 对目录: 找到被分配目录的数量较少(希望跨组平衡目录)且包含大量自由inode(希望随后能分配一堆文件)的块组.
  • 对文件: 首先确保将文件的inode与其数据块分配到相同的块组中(防止inode与数据块之间的长时间寻道), 并将同一目录下的所有文件与该目录放在同一个块组中(不同目录下的文件则可能相互远离).
  • 例外: 对大文件, 若按照一般文件分配, 则一个大文件很容易填满整个块组, 会影响其他相关文件进入该块组, 破坏文件存储的局部性, 不符合需要. 因此对于大文件, FFS将一定数量的数据块分配到第一个块组, 将下一个大块(间接指针指向的部分)放在另一个块组中, 递归进行, 从而跨组平衡大文件, 给每组都留下空间. 缺陷是跨块组访问同一文件的大块会损失性能, 但这是大文件和小文件之间的权衡. FFS采取的具体策略: 基于inode本身的结构, 12个直接指针指向的数据块与inode放在同一块组中, 其余每个间接块(指针库)及其指向的所有块(数据块/下一级指针库)都放在不同的组中. 这种策略下除了开头的12个块, 文件的每 个块都放在单独的组中.

其他设计

  • 引入子块(比标准块小的块)以支持小文件, 减少内部碎片. 小文件增大到一定程度后复制到一个完整的标准块, 并释放子块以备将来使用. 然而频繁复制效率低下, 所以常常通过缓冲来避免使用子块(除非必需否则不用);
  • 针对性能优化磁盘布局, 不用连续扇区分布, 而是间隔分布扇区, 用于适应连续请求与旋转之间的时间差(即下一个请求到达时还能赶上下一个连续扇区, 若连续分布扇区则会错过, 多转一圈). FFS会根据磁盘的性能参数决定布局时应该跳过多少块安排下一个扇区;
    • notion image
  • 现代磁盘优化: 将整个磁道读取到内部磁盘缓存(磁道缓冲区)中, 连续访问时不需要连续读取扇区, 而是直接读缓冲区即可;
  • FFS引入长文件名, 符号链接和原子rename操作.

文件系统检查程序FSCK

💡
“如何在出现断电或系统崩溃的情况下更新持久数据结构?” ——崩溃一致性问题
简而言之, 一致性类似于原子性, 希望磁盘上的结构在且仅在一致性状态之间原子地转移, 不存在中间状态. 然而, 磁盘一次只能处理一个写入任务, 多个更新只能串行进行, 而更新之间可能会发生崩溃或断电. 这就是我们要解决的崩溃一致性问题.
“不怕在写入开始之前或完成之后发生崩溃, 就怕在完成了部分步骤但还有剩余部分未完成时崩溃, 让文件系统处于中间态. ”
💡
可能的不一致:
  1. 分配了新inode, 但还没有更新位图和数据块: 不一致, 位图显示无效而inode显示已分配;
  1. 更新了位图, 但还没有分配新的inode和数据块: 不一致, 可能导致内存泄漏(错误分配的不会释放);
  1. 写入了新数据, 但还没有更新位图和inode: 没有影响, 只是写了点垃圾数据;
  1. 分配了新inode并写入了数据块, 但还没有更新位图: 不一致, 需要处理;
  1. 分配了新inode并更新了位图, 但还没有写入数据块: 元数据自洽, 但是按照inode指示会找到垃圾数据;
  1. 更新了位图并写入了数据块, 但还没有分配新inode: 不一致, 可能导致内存泄漏.
较老的文件系统采用文件系统检查程序fsck.
fsck:(在文件系统挂载完成之前)全盘扫描, 查找文件系统中元数据的不一致并修复(无法解决所有问题, 因为无法检查数据块的语义).
检查对象:
  1. 超级块: fsck首先检查超级块是否合理, 主要是进行健全性检查, 例如确保文件系统大小大于分配的块数. 通常, 这些健全性检查的目的是找到一个可疑的(冲突的)超级块. 在这种情况下, 系统(或管理员)可以决定使用超级块的备用副本;
  1. 空闲块: 接下来, fsck扫描inode、间接块、双重间接块等, 以了解当前在文件系统中分配的块, 生成正确版本的分配位图. 因此, 如果位图和inode之间存在不一致, 则最终信任inode内的信息并修正位图. 对所有inode执行相同类型的检查, 确保所有(看起来像)已分配的inode都在inode位图中有标记;
  1. inode状态: 检查每个inode是否存在损坏或其他问题. 例如, fsck 确保每个分配的inode的类型字段均有效(即常规文件、目录、符号链接等). 如果inode字段存在问题且不易修复, 则认为该inode可疑, 将其清除并更新inode位图;
  1. inode链接: fsck还会验证每个已分配的inode的链接数. fsck从根目录开始扫描整个目录树, 并为文件系统中的每个文件和目录构建各自的链接计数. 如果新计算的计数与inode中找到的计数不匹配, 则采取纠正措施, 通常是修复inode中的计数. 如果发现已分配但没有被任何目录引用的inode, 则将其移动到lost+found目录.
  1. 重复: fsck还检查重复指针, 即两个不同的inode引用同一个块的情况. 如果其中某个inode明显错误则可能会被清除. 或者, 可以复制指向的块, 从而根据需要让每个inode指向相同数据的不同副本.
  1. 坏块: 在扫描所有指针列表时, 还会检查坏块指针. 如果指针指向明显超出其有效范围的某个地址(例如指向大于其所在分区大小的块), 则认为该指针有问题. 在这种情况下, fsck不能做任何太聪明的事情, 只是从inode或间接块中删除该指针.
  1. 目录检查: fsck不了解用户文件的内容, 但是了解目录数据的格式(因为目录包含由文件系统本身创建的特定格式的元数据, 而非任意数据). 因此fsck对每个目录的内容执行额外的完整性检查, 确保“.” 和“..”条目在最前面, 且目录条目中引用的每个inode都已分配, 且整个层次结构中没有目录被引用超过一次.
需要充分的底层文件系统知识才能构建有效的fsck程序.
fsck程序的根本问题: 全盘扫描太慢了! 杀鸡用牛刀的策略性价比太低, 有效但太浪费.

预写日志

概述

从数据库管理系统中借鉴. 预写日志又称为日志.
基本思路: 写入结构之前先在另外一个周知的地址处登记, 描述将要做的写入事务(即为”预写”过程). 所有登记的题目组成”预写日志”. 如此一来如果在写入期间发生崩溃就可以通过查询日志来完整得知预期的写入行为, 并根据该信息来恢复, 不必扫描整个磁盘.
💡
预写日志在更新期间增加了一些工作量, 从而大大减少了恢复期间需要的工作量.
示例:
Linux ext2文件系统(无日志)的结构
Linux ext2文件系统(无日志)的结构
Linux ext3文件系统(有日志)的结构
Linux ext3文件系统(有日志)的结构
每个group中都包含该块组的位图, inode和数据块.

物理日志结构

在写入更新之前填写日志:
日志区域
日志区域
对每个事务写5个块: TxB(Transaction Begin, 事务开始), I[v2](要写入的新inode信息), B[v2](要写入的新位图信息), Db(要写入的新数据信息), TxE(Transaction End, 事务结束).
TxB中包含描述本次更新的元数据, 包括如I[v2], B[v2], Db等部分的目标地址和事务标识符(TID)等. TxE中同样包含TID.
这种将要更新的具体内容(I, B, Db)直接放在日志中的日志叫做物理日志.
还有一种不存储具体内容的逻辑日志, 更节省空间.
一旦事务安全地登记在日志中就可以开始写入/覆盖文件系统的结构了, 这个过程叫做”加检查点(checkpointing)”, 即遍历日志一项一项完成任务, 将I[v2], B[v2]Db写入对应的磁盘位置.
💡
什么破烂翻译...
实际写入时可能被调度!
实际写入时可能被调度!

元数据日志

对物理日志而言, 实现原子更新的代价沉重: 同样的数据在日志中和目标位置写了两次, 写入流量起码加了一倍. 在顺序写入时带宽只能达到峰值带宽的50%. 此外, 日志和写入目标位置之间相隔较远, 还会导致可观的寻道时间.
为了减少写入流量, 引入元数据日志: 不存储数据块Db, 其余部分与物理日志相同.
元数据日志
元数据日志
Db改为直接写入文件系统, 不再在日志条目中存储副本.
💡
Q: 没有了Db副本, 可能导致什么问题?
A: 崩溃时若尚未完成写入Db, 则恢复时只能恢复inode和位图, 然而此时对应数据块为垃圾数据 → 垃圾数据进入文件.
应对策略: 强制规定写入顺序, 先将Db写入目标地址, 再写入日志条目(TxB, I[v2], B[v2])并提交日志(写入TxE), 加检查点写入I[v2]B[v2].
💡
其实只要在提交日志(即写入TxE)之前完成写入Db即可, 可以于写入日志条目的任务并行调度.
这样一来, 只要日志被完整更新, 就可以认定数据已经成功写入, 保证不会引入垃圾数据. 如果日志没有完整更新, 则多一块”无主”的垃圾数据(没有元数据指定其归属)也无伤大雅.
💡
补充: 强制写入顺序的机制支持 → 禁用写缓冲/写入屏障(保证屏障前的事务先于屏障后的事务完成)
惯用法: 先写入被指对象, 再写入指针, 保证指针永远不会指向垃圾.
目录比较特殊, 其数据由文件系统维护, 被认为是元数据, 会放在元数据日志中(反正占空间小).
实际写入时可能被调度!
实际写入时可能被调度!

日志自身的原子写入

这样一旦日志写成功就能保证事务可以完成/恢复. 然而考察写入日志的行为本身, 却仍有问题: 如何保证日志的安全写入?
故障示例:
notion image
对单条记录而言, 为了提高效率, 写入5个部分的请求一并发出并接受I/O调度(而非先后串行发出), 可能以任意顺序执行. 如果像图示一样对数据块Db的写入调度到最后进行, 恰好在最后又发生崩溃或断电, 则对Db的登记就为垃圾数据, 然而文件系统无法检查Db的语义, 最终会将其判断为一个合法记录, 因为其余部分都是完好的. 所以最终后果是对磁盘写入垃圾数据. 如果将垃圾数据写入了关键区域(如超级块)那就坏事了.
解决方案: 分两步发出写入日志条目的事务.
首先同时发出将前4个部分写入日志的请求(日志写入):
notion image
当这4个部分写完之后再发出写入TxE块的请求, 完成日志条目更新(日志提交, commit):
notion image
这样就确保了日志的原子更新.
💡
日志原子更新的底层其实是磁盘的原子性保证——任何512字节(一个扇区大小)的写入都是原子的, 不会出现写入一半的情况. 因此为确保TxE的原子写入, 就令其大小恰好为512字节.
宏观上日志被实现为一个循环队列, 头部有一个存储了队首和队尾指针的日志超级块, 用于循环使用日志并防止日志被填满.
notion image
所以完成写入日志条目/加检查点的操作时也要实时维护日志超级块中的队列指针.

使用日志进行恢复

对不同崩溃节点:
  1. 如果在日志写入期间崩溃(导致日志都没写全), 就直接抛弃该更新;
  1. 事务已经完整登记到日志, 在根据日志写入更新期间发生崩溃, 则按顺序重放(replay)事务, 试着再写一遍. 这种策略称为重做日志(redo logging).
最坏的情况就是对未完成就被打断的事务重写一遍. 崩溃是意外事故, 无需担心恢复时的几次冗余写入影响性能.
以上使用日志的协议可能会带来大量的磁盘流量(需要维护日志的更新), 故一些文件系统(如Linux ext3)会通过缓冲成批写入日志来摊销, 代价是更新间隔变长会导致崩溃时丢失的更新变多.

块复用问题

当涉及删除操作时, 原本分配的数据块可能被释放并重用, 这样在元数据日志中可能会产生重用问题, 例如:
notion image
示例流程: 创建foo目录 → 删除foo目录 → 创建foobar文件(inode[1000]被释放并复用). 若在更新foobar期间发生崩溃, 则恢复时重新扫描日志, 创建foo目录的任务重放时会错误覆盖foobar文件的内容, 并且无法恢复(元数据日志中没有存放foobar的数据内容).
解决方案:
  1. 禁止复用: 永远不复用块, 直到该块从日志中绝迹;
  1. 撤销记录: 将删除行为作为一条”撤销记录”写入日志, 重放之前先扫描日志检查, 从而对有撤销记录的块特判, 不进行重放.

其他方案

  1. 软更新: 对文件系统的所有写入行为排序(例如先写指向的数据块再写指向它的inode, 保证inode永远不会指向垃圾), 但是构建顺序规则需要复杂的文件系统知识;
  1. 写时复制: 永远不会覆写文件或目录, 只选择磁盘上从未使用的位置存放更新, 完成大量更新后翻转文件系统的根结构以纳入指向方才更新的地址的指针;
  1. 反向指针: 在每个数据块中加上反向指针, 指向其所属的inode, 与inode中的正向指针形成双重验证, 获得惰性崩溃一致性;
  1. 乐观崩溃一致性: 尽可能多地向磁盘发出写入, 使用事务校验和等技术检测不一致, 可以将性能提高一个数量级, 但是需要特殊的磁盘接口才能实现.

日志结构文件系统LFS

概述

设计出发点:
  1. 内存大小不断增长, 可以缓存更多数据, 从而读取行为多数时候可以从缓存中直接获取数据, 磁盘流量中写入流量的占比日益增大, 因此文件系统的性能越来越依赖于写入性能;
  1. 随机I/O与顺序I/O的速率差距不断扩大, 因为寻道时间和旋转延迟下降较慢, 而传输带宽迅速增大, 因此若能以顺序方式访问磁盘则可以获得极大的性能优势;
  1. 当时的文件系统在许多常见工作负载(如创建新文件)下表现不佳, 需要多次写入多个结构, 而且写入之间存在很多短寻道和旋转延迟;
  1. 对RAID支持不好.
因此, 理想的文件系统应该专注于提升写入性能, 充分利用磁盘的顺序带宽, 在常见工作负载下表现良好, 对RAID支持良好.
LFS(Log-structured File System, 日志结构文件系统)的核心思想: 写入磁盘时先将(包括元数据在内的)所有更新在内存段中缓冲, 缓冲段满时发起一次向磁盘的长时顺序写入, 传输到磁盘的未使用部分. LFS永远不会覆写现有数据, 始终将数据段写入空闲位置. 段很大, 可以有效地使用磁盘, 令其性能接近于峰值带宽.
💡
顺序读取是不现实的(读取行为完全可能随机分布在磁盘的各处), 然而写入行为完全可以是顺序的(选一块顺序的地址写入即可).

顺序写入设计

将数据块及其inode一同放在一处, 避免跨区域写入导致寻道和旋转:
inode画得和数据块差不多大但实则小很多
inode画得和数据块差不多大但实则小很多
但是单单将数据块和inode放在一处不足以保证高效写入. 若连续写入先后间隔到达, 则可能因为时间差错过目标扇区而导致频繁的旋转延迟. 故若要获得良好性能, 必须使用写入缓冲, 攒够一大批连续写入事务之后大量(同时)向磁盘驱动器发出(或发出一次大写入).
LFS一次性写入的大块更新称为段(重名, 不是虚拟内存中的段). 只要段足够大就能有效摊销.
连续写入之后的磁盘布局:
notion image

查找inode

💡
老式文件系统中所有的inode以数组形式存放在固定位置, 只要得知目标inode在inode表中的偏移量即可;
FFS将磁盘切分为块组, 每个块组有自己的inode表, 故需要额外知道每个块组的大小和起始地址, 以及目标inode在其块组inode表中的偏移量.
LFS的inode则分散在整个磁盘上(紧随在每个数据块后面), 且因不会覆盖使用过的块故最新版本的inode位置会不断移动.
解决方案: inode映射, 在inode号和inode之间维护一个间接层imap: inode号 → inode地址(此处的imap可以理解为inode表, 用inode号索引到对应inode的地址). 每次写入inode时都会在imap中登记新增或更新.
imap本身也需要持久化到磁盘, 才能在断电后也忠实地维护每个inode的地址(不然断电就废了). 因此引入了一个问题: imap应该放在磁盘的何处?
  1. 若放在周知的固定地址: 固然简单有效, 但是在该地址与写入目标地址之间横跳会导致寻道开销;
  1. 实际采用的策略: 将imap追加到紧随最新一次写入之后的位置. 因此, 当写入新数据块时, LFS实际上将新数据块本身, 其inode和最新版本的完整imap一同写入磁盘.
    1. 引入imap后的磁盘布局
      引入imap后的磁盘布局
💡
有了imap不愁找不到inode了, 可是imap又到哪里去找呢? 这样递归下去建立索引层是没有尽头的, 一定需要一个周知的地址来存放线索的末端.
LFS在磁盘上划分一个固定的区域, 称作”检查点区域(ckeckpoint region, CR)”, 包含指向最新的imap的指针, 通过读取CR中的该指针即可找到imap, 进而找到inode, 最终找到数据块或元数据. 检查点区域仅定期更新, 不会导致频繁的寻道开销. 示例中CR放在0地址处(Q: 即超级块中?)
引入CR后的磁盘布局
引入CR后的磁盘布局

读取文件

流程: 检查CR找到imap → 将整个imap加载到内存(在此之后直接读内存中的imap即可), 查询imap找到inode → 根据inode中的直接指针/间接指针找到数据块并读取.
直到写任务出现才出现脏数据, 到时候再将(脏的)CR, imap, inode和数据块写回磁盘.

目录数据

LFS的目录结构与传统Unix系统基本相同, 目录的数据本质上是元数据(Pair<文件名, inode号>).
目录也采取顺序写入机制. 以下示例为在某个目录dir中创建文件foo:
notion image
先写了文件, 再写了目录, 最终追加上更新后的imap.
访问文件foo的流程: 查CR找到imap → 将整个imap加载到内存, 查询imap找到目录dir的inode → 根据目录inode中的条目获取foo的inode号 → 查询imap找到foo的inode → 根据foo的inode中的直接/间接指针找到目标数据块.
💡
递归更新问题:
对于任何不会重用块(即原地更新)的文件系统, 当更新inode时都会使inode在磁盘上的存储位置发生变化. 如果这一变化会导致目录数据的更新, 则会引发目录的父目录的更新, 一直递归到根目录.
LFS通过引入imap巧妙地解决了这一问题, 当inode的地址改变时只需要修改imap这一中间层, 不涉及对包含它的目录的修改, 因此不会引发递归更新.

垃圾收集

由于不会进行原地更新, 所以当任意新版本数据在新块上完成写入之后, 旧版本的数据都称为垃圾, 需要回收(否则很快就会将磁盘空间用尽).
示例1:
inode和数据块内容均被修改→A0处存放的版本变为垃圾.
inode和数据块内容均被修改→A0处存放的版本变为垃圾.
示例2:
D0没有修改, 追加了一个数据块D1并修改了inode→原来的inode变为垃圾, 仍然指向D0但是从imap中除名, 不会再被访问.
D0没有修改, 追加了一个数据块D1并修改了inode→原来的inode变为垃圾, 仍然指向D0但是从imap中除名, 不会再被访问.
LFS只保留文件的最新版本, 因此它必须在后台定期查找数据块/inode等结构的垃圾版本并及时清理, 使其再次空闲, 可以在后续写入时被重用.
问题: 若LFS清理程序在运行时简单释放单个垃圾块, 则会在内存中留下大量不连续碎片, 不利于为顺序写入分配连续大块, 会极大降低性能.
解决方案: LFS让清理程序按段(即先前大段写入使用的大小相同的内存块)工作, 为后续顺序写入清理出大块连续空间.
工作流程: 定期读入许多旧的(部分使用的)段, 判定该段中各块的死活, 将存活的块打包复制(妥善安置)到一个新的段, 然后将该段全部释放.
💡
Q: 如何判定块的死活?
A: LFS在每个段的头部维护一个”段摘要块(segment summary block)”, 记录该段中每个数据块的inode号及其在所属文件中的偏移量. 根据这些信息可以直接确定块的死活.
判定流程: 查段摘要块找到要判定的块的inode号 → 查imap找到该数据块的inode → 查inode检查指针字段, 检查指针是否指向该数据块(实际上是正向指针和反向指针对质). 如果一致则存活, 不一致则为垃圾.
notion image
在实际实现时LFS做了更多设计, 方便判定块的死活, 例如为文件inode在imap中的记录所属段的段摘要块中分别维护一个版本号. 当文件被截断或删除时版本号++, 并在imap新的所属段中登记. 要判定某个块是否存活时只需要检查所属段的段摘要块中的版本号是否与imap中的匹配即可, 匹配则存活, 否则为垃圾.
清理策略:
  1. 何时清理: 固定周期性清理/空闲时间清理/磁盘已满时清理/...;
  1. 清理哪些块: 一种可能方法(分离冷热段): 常发生覆盖的段(热段)少清理(因为刚清理并转移了活块就可能又产生新的垃圾), 不常发生覆盖的段(冷段)多清理(垃圾就是垃圾, 存活就是存活).

崩溃恢复和日志

💡
如果系统在LFS写入磁盘期间发生崩溃, 会导致什么后果?
正常工作时LFS会将所有写入任务缓冲一段时间后一次性顺序写入. LFS使用日志组织这些写入行为, 定期更新检查点区域CR, 在这些操作期间随时有可能发生崩溃.
为确保CR的原子更新, LFS在磁盘的两端维护了两个CR并交替写入. 更新CR时使用了一个保险的协议: 先写入一个带时间戳的CR头, 再写入CR的主体, 最后写出一个同样带有时间戳CR的末尾(可以类比预写日志中的TxB和TxE, 通过头尾封装来确保中间安全), 若在CR更新期间发生崩溃则头尾的时间戳会不一致, 从而暴露错误, 最终丢弃.
对于确保顺序写入的原子性, 由于LFS更新CR的间隔较长, 所以当系统崩溃重启后得到的CR很有可能是旧版本, 可以用于恢复数据(旧数据不会被覆盖), 但是在该版本到崩溃期间的更改将会丢失. 为了改进丢失问题, LFS借鉴数据库中的前滚技术, 根据当前的CR(大概率是旧版本), 找到(大概率是旧版本的)数据日志的末端, 从该处开始读取下一个段并查看其中是否包含有效更新. 若有则据其进行恢复.

虚拟文件系统(VFS)

💡
目标:
  • 同时支持多种文件系统
  • 多个文件系统应该与传统的单一文件系统没有区别, 对用户表现为一致的接口
  • 提供通过网络共享文件的支持, 访问远程节点应与访问本地节点别无二致
  • 若开发出新的文件系统可以以模块方式加入到现有的文件系统中
实现: 把实际文件系统的成分塞到主存中虚拟文件系统程序提供的一个统一的框架下.
Linux虚拟文件系统
Linux虚拟文件系统
分为三个层次:
  1. 应用层: 用户调用VFS的API, 可直接使用标准UNIX文件系统调用来操作文件, 无需考虑具体文件系统特性和物理存储介质;
  1. 虚拟层: 对所有具体文件系统的共同特性进行抽象, 构建一个与具体文件系统实现无关的虚拟层, 并在此层次上定义与用户的一致性接口, 即组成虚拟文件系统VFS(依赖倒置的中心);
  1. 实现层: 用各种文件系统作为基础来实现VFS规定的接口功能.
💡
VFS实质上是运行在主存中的虚拟化软件, 将多种类型具体文件系统抽象为一个统一的运行环境, 具有以下功能:
记录安装的文件系统类型, 建立设备与文件系统的联系, 实现面向文件的通用操作, 涉及特定文件系统的操作时将操作映射到具体文件系统中去.

VFS的数据结构组成

notion image
Linux文件系统的逻辑结构
Linux文件系统的逻辑结构
  1. 超级块: 代表一个文件系统(即VFS自身);
    1. 存放已挂载的文件系统信息. 如果是基于磁盘的文件系统, 该对象便对应于存放在磁盘上的文件系统控制块. 每个超级块对象都对应一个文件系统.
      当挂载一个具体的文件系统时, 内核调用alloc_super()函数为其分配一个VFS超级块, 并从磁盘读取具体文件系统超级块中的信息填充进来. VFS超级块在具体文件系统挂载时建立, 并在卸载时自动删除, 可见VFS超级块仅存在于主存中.
  1. inode节点: 代表文件;
    1. 存放通用的文件信息, 如果虚拟化的是基于磁盘的文件系统, 则该对象对应于存放在磁盘上的文件inode. 每个inode都有一个inode号, 唯一标识某个文件系统中的指定文件.
      对于UNIX类文件系统来说, 这些信息从磁盘inode直接读入VFS的inode对象中. 具体文件系统存放在磁盘上的inode称为静态inode, 它的内容被读入主存VFS的inode才能工作, 后者也称为活动inode.
  1. 目录项: 代表路径中一个组成部分;
    1. 存放目录项与对应文件进行链接的各种信息(文件名与inode号的二元组的列表). VFS把最近最常使用的dentry对象放在目录项Cache中, 加快文件路径名搜索过程, 以提高系统性能.
  1. 文件对象: 代表已经由进程打开的文件对象.
    1. 存放已打开文件与进程的交互信息, 这些信息(用户打开文件表, 系统打开文件表)仅当进程访问文件期间才存于主存中. 文件对象在执行open()系统调用时创建, 执行close()系统调用时撤销.
      每个文件都用一个32位数字来表示下一个读写的字节位置, 通常称它为文件偏移(offset), 每当打开一个文件时, 文件偏移被置0, 读写操作便从这里开始. 允许通过lseek()系统调用对文件位置作随机定位.
      Linux建立文件对象(file结构)来保存打开文件的读写偏移, 还把指向该文件inode的指针也放在其中, 称为系统打开文件表.
      操作系统之所以不直接使用dentry结构, 是因为多个进程能够打开同一个文件. 每一个file结构中记录了文件访问模式, 读写指针等信息, 实际上对应了一个进程的一次打开过程, 而dentry只能表示静态的存储关系. 简而言之, 在运行时可以有任意个对应同一文件对象的file结构, 而dentry只能与文件对象做静态对应(一一对应, 或者静态的软硬链接).
      VFS使用文件描述符fd描述打开的文件. 每个进程用一个files_struct结构来记录文件描述符的使用情况, 这个结构称为用户打开文件表. 进程PCB的task_struct结构的成员files中存有一个指针指向该用户打开文件表.

文件系统在VFS中的注册与注销

Linux支持多个物理磁盘, 每个磁盘可划分为一个或多个磁盘分区, 每个分区上可建立一个文件系统. VFS以链表形式管理已注册的具体文件系统, 一个安装好的Linux操作系统支持文件系统的种类是通过文件系统类型注册链表来描述的.
向系统注册文件系统类型有两种途径: 一是在编译操作系统内核时确定可支持哪些文件系统, 在文 件系统被引导时, 在VFS中进行注册; 二是将文件系统视为可装载模块, 通过insmod/rmmod命令在装入该文件系统模块时向VFS注册/注销.

文件系统在VFS中的挂载与卸载

  1. 文件系统挂载
    1. 明确文件系统类型名、所在物理设备名和安装点, 再用mount命令挂载.
  1. 文件系统挂载过程
    1. 寻找匹配的file_system_type, 查找安装点VFS inode, 分配一个VFS超级块, 利用read_super()函数读入磁盘中具体文件系统的超级块参数, 申请一个vfsmount数据结构.
3文件系统卸载过程
确认是否可卸载, 如果为“脏”则把修改后的VFS超级块写回磁盘, 主存VFS中删去vfsmount.
 
胶片里的南昌征服OS的第二座大山-并发
Loading...
目录
0%
JAY
JAY
Textbooks shouldn’t require a cryptologist to decode -- welcome to readable software engineering.
目录
0%