许多操作系统支持两部分的文件名,它们之间用 . 分隔开,比如文件名 prog.c。原点后面的文件称为 文件扩展名(file extension) ,文件扩展名通常表示文件的一些信息。一些常用的文件扩展名以及含义如下图所示
扩展名含义bak备份文件cc 源程序文件gif符合图形交换格式的图像文件hlp帮助文件htmlWWW 超文本标记语言文档jpg符合 JPEG 编码标准的静态图片mp3符合 MP3 音频编码格式的音乐文件mpg符合 MPEG 编码标准的电影o目标文件(编译器输出格式,尚未链接)pdfpdf 格式的文件psPostScript 文件tex为 TEX 格式化程序准备的输入文件txt文本文件zip压缩文件
在 UNIX 系统中,文件扩展名只是一种约定,操作系统并不强制采用。
文件结构
文件的构造有多种方式。下图列出了常用的三种构造方式
三种不同的文件。 a) 字节序列 。b) 记录序列。c) 树
上图中的 a 是一种无结构的字节序列,操作系统不关心序列的内容是什么,操作系统能看到的就是字节(bytes)。其文件内容的任何含义只在用户程序中进行解释。UNIX 和 Windows 都采用这种办法。
图 b 表示在文件结构上的第一部改进。在这个模型中,文件是具有固定长度记录的序列,每个记录都有其内部结构。 把文件作为记录序列的核心思想是:读操作返回一个记录,而写操作重写或者追加一个记录。第三种文件结构如上图 c 所示。在这种组织结构中,文件由一颗记录树构成,记录树的长度不一定相同,每个记录树都在记录中的固定位置包含一个key 字段。这棵树按 key 进行排序,从而可以对特定的 key 进行快速查找。
文件类型
很多操作系统支持多种文件类型。例如,UNIX(同样包括 OS X)和 Windows 都具有常规的文件和目录。除此之外,UNIX 还具有字符特殊文件(character special file) 和 块特殊文件(block special file)。常规文件(Regular files) 是包含有用户信息的文件。用户一般使用的文件大都是常规文件,常规文件一般包括 可执行文件、文本文件、图像文件,从常规文件读取数据或将数据写入时,内核会根据文件系统的规则执行操作,是写入可能被延迟,记录日志或者接受其他操作。
文件访问
早期的操作系统只有一种访问方式:序列访问(sequential access)。在这些系统中,进程可以按照顺序读取所有的字节或文件中的记录,但是不能跳过并乱序执行它们。顺序访问文件是可以返回到起点的,需要时可以多次读取该文件。当存储介质是磁带而不是磁盘时,顺序访问文件很方便。
在使用磁盘来存储文件时,可以不按照顺序读取文件中的字节或者记录,或者按照关键字而不是位置来访问记录。这种能够以任意次序进行读取的称为随机访问文件(random access file)。许多应用程序都需要这种方式。
随机访问文件对许多应用程序来说都必不可少,例如,数据库系统。如果乘客打电话预定某航班机票,订票程序必须能够直接访问航班记录,而不必先读取其他航班的成千上万条记录。
有两种方法可以指示从何处开始读取文件。第一种方法是直接使用 read 从头开始读取。另一种是用一个特殊的 seek 操作设置当前位置,在 seek 操作后,从这个当前位置顺序地开始读文件。UNIX 和 Windows 使用的是后面一种方式。
文件属性
文件包括文件名和数据。除此之外,所有的操作系统还会保存其他与文件相关的信息,如文件创建的日期和时间、文件大小。我们可以称这些为文件的属性(attributes)。有些人也喜欢把它们称作 元数据(metadata)。文件的属性在不同的系统中差别很大。文件的属性只有两种状态:设置(set) 和 清除(clear)。
文件操作
使用文件的目的是用来存储信息并方便以后的检索。对于存储和检索,不同的系统提供了不同的操作。以下是与文件有关的最常用的一些系统调用:
- Create,创建不包含任何数据的文件。调用的目的是表示文件即将建立,并对文件设置一些属性。
- Delete,当文件不再需要,必须删除它以释放内存空间。为此总会有一个系统调用来删除文件。
- Open,在使用文件之前,必须先打开文件。这个调用的目的是允许系统将属性和磁盘地址列表保存到主存中,用来以后的快速访问。
- Close,当所有进程完成时,属性和磁盘地址不再需要,因此应关闭文件以释放表空间。很多系统限制进程打开文件的个数,以此达到鼓励用户关闭不再使用的文件。磁盘以块为单位写入,关闭文件时会强制写入最后一块,即使这个块空间内部还不满。
- Read,数据从文件中读取。通常情况下,读取的数据来自文件的当前位置。调用者必须指定需要读取多少数据,并且提供存放这些数据的缓冲区。
- Write,向文件写数据,写操作一般也是从文件的当前位置开始进行。如果当前位置是文件的末尾,则会直接追加进行写入。如果当前位置在文件中,则现有数据被覆盖,并且永远消失。
- append,使用 append 只能向文件末尾添加数据。
- seek,对于随机访问的文件,要指定从何处开始获取数据。通常的方法是用 seek 系统调用把当前位置指针指向文件中的特定位置。seek 调用结束后,就可以从指定位置开始读写数据了。
- get attributes,进程运行时通常需要读取文件属性。
- set attributes,用户可以自己设置一些文件属性,甚至是在文件创建之后,实现该功能的是 set attributes 系统调用。
- rename,用户可以自己更改已有文件的名字,rename 系统调用用于这一目的。
目录
文件系统通常提供目录(directories) 或者 文件夹(folders) 用于记录文件的位置,在很多系统中目录本身也是文件,下面我们会讨论关于文件,他们的组织形式、属性和可以对文件进行的操作。
一级目录系统
目录系统最简单的形式是有一个能够包含所有文件的目录。这种目录被称为根目录(root directory),由于根目录的唯一性,所以其名称并不重要。在最早期的个人计算机中,这种系统很常见,部分原因是因为只有一个用户。下面是一个单层目录系统的例子
含有四个文件的单层目录系统
该目录中有四个文件。这种设计的优点在于简单,并且能够快速定位文件,毕竟只有一个地方可以检索。这种目录组织形式现在一般用于简单的嵌入式设备(如数码相机和某些便携式音乐播放器)上使用。
层次目录系统
对于简单的应用而言,一般都用单层目录方式,但是这种组织形式并不适合于现代计算机,因为现代计算机含有成千上万个文件和文件夹。如果都放在根目录下,查找起来会非常困难。为了解决这一问题,出现了层次目录系统(Hierarchical Directory Systems),也称为目录树。通过这种方式,可以用很多目录把文件进行分组。进而,如果多个用户共享同一个文件服务器,比如公司的网络系统,每个用户可以为自己的目录树拥有自己的私人根目录。这种方式的组织结构如下
根目录含有目录 A、B 和 C ,分别属于不同的用户,其中两个用户个字创建了子目录。用户可以创建任意数量的子目录,现代文件系统都是按照这种方式组织的。
路径名
当目录树组织文件系统时,需要有某种方法指明文件名。常用的方法有两种,第一种方式是每个文件都会用一个绝对路径名(absolute path name),它由根目录到文件的路径组成。
另外一种指定文件名的方法是 相对路径名(relative path name)。它常常和 工作目录(working directory) (也称作 当前目录(current directory))一起使用。用户可以指定一个目录作为当前工作目录。例如,如果当前目录是 /usr/ast,那么绝对路径 /usr/ast/mailbox可以直接使用 mailbox 来引用。
目录操作
不同文件中管理目录的系统调用的差别比管理文件的系统调用差别大。为了了解这些系统调用有哪些以及它们怎样工作,下面给出一个例子(取自 UNIX)。
- Create,创建目录,除了目录项 . 和 .. 外,目录内容为空。
- Delete,删除目录,只有空目录可以删除。只包含 . 和 .. 的目录被认为是空目录,这两个目录项通常不能删除
- opendir,目录内容可被读取。例如,未列出目录中的全部文件,程序必须先打开该目录,然后读其中全部文件的文件名。与打开和读文件相同,在读目录前,必须先打开文件。
- closedir,读目录结束后,应该关闭目录用于释放内部表空间。
- readdir,系统调用 readdir 返回打开目录的下一个目录项。以前也采用 read 系统调用来读取目录,但是这种方法有一个缺点:程序员必须了解和处理目录的内部结构。相反,不论采用哪一种目录结构,readdir 总是以标准格式返回一个目录项。
- rename,在很多方面目录和文件都相似。文件可以更换名称,目录也可以。
- link,链接技术允许在多个目录中出现同一个文件。这个系统调用指定一个存在的文件和一个路径名,并建立从该文件到路径所指名字的链接。这样,可以在多个目录中出现同一个文件。有时也被称为硬链接(hard link)。
- unlink,删除目录项。如果被解除链接的文件只出现在一个目录中,则将它从文件中删除。如果它出现在多个目录中,则只删除指定路径名的链接,依然保留其他路径名的链接。在 UNIX 中,用于删除文件的系统调用就是 unlink。
文件系统的实现
文件系统布局
文件系统存储在磁盘中。大部分的磁盘能够划分出一到多个分区,叫做磁盘分区(disk partitioning) 或者是磁盘分片(disk slicing)。每个分区都有独立的文件系统,每块分区的文件系统可以不同。磁盘的 0 号分区称为 主引导记录(Master Boot Record, MBR),用来引导(boot) 计算机。在 MBR 的结尾是分区表(partition table)。每个分区表给出每个分区由开始到结束的地址。
当计算机开始引 boot 时,BIOS 读入并执行 MBR。
引导块
MBR 做的第一件事就是确定活动分区,读入它的第一个块,称为引导块(boot block) 并执行。引导块中的程序将加载分区中的操作系统。为了一致性,每个分区都会从引导块开始,即使引导块不包含操作系统。引导块占据文件系统的前 4096 个字节,从磁盘上的字节偏移量 0 开始。引导块可用于启动操作系统。
除了从引导块开始之外,磁盘分区的布局是随着文件系统的不同而变化的。通常文件系统会包含一些属性,如下
文件系统布局
超级块
紧跟在引导块后面的是 超级块(Superblock),超级块 的大小为 4096 字节,从磁盘上的字节偏移 4096 开始。超级块包含文件系统的所有关键参数
- 文件系统的大小
- 文件系统中的数据块数
- 指示文件系统状态的标志
- 分配组大小
在计算机启动或者文件系统首次使用时,超级块会被读入内存。
空闲空间块
接着是文件系统中空闲块的信息,例如,可以用位图或者指针列表的形式给出。
BitMap 位图或者 Bit vector 位向量
位图或位向量是一系列位或位的集合,其中每个位对应一个磁盘块,该位可以采用两个值:0和1,0表示已分配该块,而1表示一个空闲块。下图中的磁盘上给定的磁盘块实例(分配了绿色块)可以用16位的位图表示为:0000111000000110。
使用链表进行管理
在这种方法中,空闲磁盘块链接在一起,即一个空闲块包含指向下一个空闲块的指针。第一个磁盘块的块号存储在磁盘上的单独位置,也缓存在内存中。
碎片
这里不得不提一个叫做碎片(fragment)的概念,也称为片段。一般零散的单个数据通常称为片段。 磁盘块可以进一步分为固定大小的分配单元,片段只是在驱动器上彼此不相邻的文件片段。
inode
然后在后面是一个 inode(index node),也称作索引节点。它是一个数组的结构,每个文件有一个 inode,inode 非常重要,它说明了文件的方方面面。每个索引节点都存储对象数据的属性和磁盘块位置
有一种简单的方法可以找到它们 ls -lai 命令。让我们看一下根文件系统:
inode 节点主要包括了以下信息
- 模式/权限(保护)
- 所有者 ID
- 组 ID
- 文件大小
- 文件的硬链接数
- 上次访问时间
- 最后修改时间
- inode 上次修改时间
文件分为两部分,索引节点和块。一旦创建后,每种类型的块数是固定的。你不能增加分区上 inode 的数量,也不能增加磁盘块的数量。
紧跟在 inode 后面的是根目录,它存放的是文件系统目录树的根部。最后,磁盘的其他部分存放了其他所有的目录和文件。
文件的实现
最重要的问题是记录各个文件分别用到了哪些磁盘块。不同的系统采用了不同的方法。下面我们会探讨一下这些方式。分配背后的主要思想是有效利用文件空间和快速访问文件 ,主要有三种分配方案
- 连续分配
- 链表分配
- 索引分配
连续分配
最简单的分配方案是把每个文件作为一连串连续数据块存储在磁盘上。因此,在具有 1KB 块的磁盘上,将为 50 KB 文件分配 50 个连续块。
使用连续空间存储文件
上面展示了 40 个连续的内存块。从最左侧的 0 块开始。初始状态下,还没有装载文件,因此磁盘是空的。接着,从磁盘开始处(块 0 )处开始写入占用 4 块长度的内存 A 。然后是一个占用 6 块长度的内存 B,会直接在 A 的末尾开始写。
注意每个文件都会在新的文件块开始写,所以如果文件 A 只占用了 3 又 1/2 个块,那么最后一个块的部分内存会被浪费。在上面这幅图中,总共展示了 7 个文件,每个文件都会从上个文件的末尾块开始写新的文件块。
连续的磁盘空间分配有两个优点。
第一,连续文件存储实现起来比较简单,只需要记住两个数字就可以:一个是第一个块的文件地址和文件的块数量。给定第一个块的编号,可以通过简单的加法找到任何其他块的编号。
第二点是读取性能比较强,可以通过一次操作从文件中读取整个文件。只需要一次寻找第一个块。后面就不再需要寻道时间和旋转延迟,所以数据会以全带宽进入磁盘。
因此,连续的空间分配具有实现简单、高性能的特点。
不幸的是,连续空间分配也有很明显的不足。随着时间的推移,磁盘会变得很零碎。下图解释了这种现象
这里有两个文件 D 和 F 被删除了。当删除一个文件时,此文件所占用的块也随之释放,就会在磁盘空间中留下一些空闲块。磁盘并不会在这个位置挤压掉空闲块,因为这会复制空闲块之后的所有文件,可能会有上百万的块,这个量级就太大了。
链表分配
第二种存储文件的方式是为每个文件构造磁盘块链表,每个文件都是磁盘块的链接列表,就像下面所示
以磁盘块的链表形式存储文件
每个块的第一个字作为指向下一块的指针,块的其他部分存放数据。如果上面这张图你看的不是很清楚的话,可以看看整个的链表分配方案
与连续分配方案不同,这一方法可以充分利用每个磁盘块。除了最后一个磁盘块外,不会因为磁盘碎片而浪费存储空间。同样,在目录项中,只要存储了第一个文件块,那么其他文件块也能够被找到。
另一方面,在链表的分配方案中,尽管顺序读取非常方便,但是随机访问却很困难(这也是数组和链表数据结构的一大区别)。
还有一个问题是,由于指针会占用一些字节,每个磁盘块实际存储数据的字节数并不再是 2 的整数次幂。虽然这个问题并不会很严重,但是这种方式降低了程序运行效率。许多程序都是以长度为 2 的整数次幂来读写磁盘,由于每个块的前几个字节被指针所使用,所以要读出一个完成的块大小信息,就需要当前块的信息和下一块的信息拼凑而成,因此就引发了查找和拼接的开销。
使用内存表进行链表分配
由于连续分配和链表分配都有其不可忽视的缺点。所以提出了使用内存中的表来解决分配问题。取出每个磁盘块的指针字,把它们放在内存的一个表中,就可以解决上述链表的两个不足之处。下面是一个例子
上图表示了链表形成的磁盘块的内容。这两个图中都有两个文件,文件 A 依次使用了磁盘块地址 4、7、 2、 10、 12,文件 B 使用了6、3、11 和 14。也就是说,文件 A 从地址 4 处开始,顺着链表走就能找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找到文件 B 的全部磁盘块。你会发现,这两个链表都以不属于有效磁盘编号的特殊标记(-1)结束。内存中的这种表格称为 文件分配表(File Application Table,FAT)。
目录的实现
文件只有打开后才能够被读取。在文件打开后,操作系统会使用用户提供的路径名来定位磁盘中的目录。目录项提供了查找文件磁盘块所需要的信息。根据系统的不同,提供的信息也不同,可能提供的信息是整个文件的磁盘地址,或者是第一个块的数量(两个链表方案)或 inode的数量。不过不管用那种情况,目录系统的主要功能就是 将文件的 ASCII 码的名称映射到定位数据所需的信息上。
共享文件
当多个用户在同一个项目中工作时,他们通常需要共享文件。如果这个共享文件同时出现在多个用户目录下,那么他们协同工作起来就很方便。下面的这张图我们在上面提到过,但是有一个更改的地方,就是 C 的一个文件也出现在了 B 的目录下。
如果按照如上图的这种组织方式而言,那么 B 的目录与该共享文件的联系称为 链接(link)。那么文件系统现在就是一个 有向无环图(Directed Acyclic Graph, 简称 DAG),而不是一棵树了。
日志结构文件系统
技术的改变会给当前的文件系统带来压力。这种情况下,CPU 会变得越来越快,磁盘会变得越来越大并且越来越便宜(但不会越来越快)。内存容量也是以指数级增长。但是磁盘的寻道时间(除了固态盘,因为固态盘没有寻道时间)并没有获得提高。
为此,Berkeley 设计了一种全新的文件系统,试图缓解这个问题,这个文件系统就是 日志结构文件系统(Log-structured File System, LFS)。旨在解决以下问题。
不断增长的系统内存
顺序 I/O 性能胜过随机 I/O 性能
现有低效率的文件系统
文件系统不支持 RAID(虚拟化)
另一方面,当时的文件系统不论是 UNIX 还是 FFS,都有大量的随机读写(在 FFS 中创建一个新文件至少需要5次随机写),因此成为整个系统的性能瓶颈。同时因为 Page cache的存在,作者认为随机读不是主要问题:随着越来越大的内存,大部分的读操作都能被 cache,因此 LFS 主要要解决的是减少对硬盘的随机写操作。
在这种设计中,inode 甚至具有与 UNIX 中相同的结构,但是现在它们分散在整个日志中,而不是位于磁盘上的固定位置。所以,inode 很定位。为了能够找到 inode ,维护了一个由 inode 索引的 inode map(inode 映射)。表项 i 指向磁盘中的第 i 个 inode 。这个映射保存在磁盘中,但是也保存在缓存中,因此,使用最频繁的部分大部分时间都在内存中。
到目前为止,所有写入最初都缓存在内存中,并且追加在日志末尾,所有缓存的写入都定期在单个段中写入磁盘。所以,现在打开文件也就意味着用映射定位文件的索引节点。一旦 inode 被定位后,磁盘块的地址就能够被找到。所有这些块本身都将位于日志中某处的分段中。
真实情况下的磁盘容量是有限的,所以最终日志会占满整个磁盘空间,这种情况下就会出现没有新的磁盘块被写入到日志中。幸运的是,许多现有段可能具有不再需要的块。例如,如果一个文件被覆盖了,那么它的 inode 将被指向新的块,但是旧的磁盘块仍在先前写入的段中占据着空间。
为了处理这个问题,LFS 有一个清理(clean)线程,它会循环扫描日志并对日志进行压缩。首先,通过查看日志中第一部分的信息来查看其中存在哪些索引节点和文件。它会检查当前 inode 的映射来查看 inode 否在在当前块中,是否仍在被使用。如果不是,该信息将被丢弃。如果仍然在使用,那么 inode 和块就会进入内存等待写回到下一个段中。然后原来的段被标记为空闲,以便日志可以用来存放新的数据。用这种方法,清理线程遍历日志,从后面移走旧的段,然后将有效的数据放入内存等待写到下一个段中。由此一来整个磁盘会形成一个大的环形缓冲区,写线程将新的段写在前面,而清理线程则清理后面的段。
日志文件系统
虽然日志结构系统的设计很优雅,但是由于它们和现有的文件系统不相匹配,因此还没有广泛使用。不过,从日志文件结构系统衍生出来一种新的日志系统,叫做日志文件系统,它会记录系统下一步将要做什么的日志。微软的 NTFS 文件系统、Linux 的 ext3 就使用了此日志。 OS X 将日志系统作为可供选项。为了看清它是如何工作的,我们下面讨论一个例子,比如 移除文件 ,这个操作在 UNIX 中需要三个步骤完成:
- 在目录中删除文件
- 释放 inode 到空闲 inode 池
- 将所有磁盘块归还给空闲磁盘池。
虚拟文件系统
UNIX 操作系统使用一种 虚拟文件系统(Virtual File System, VFS) 来尝试将多种文件系统构成一个有序的结构。关键的思想是抽象出所有文件系统都共有的部分,并将这部分代码放在一层,这一层再调用具体文件系统来管理数据。下面是一个 VFS 的系统结构
还是那句经典的话,在计算机世界中,任何解决不了的问题都可以加个代理来解决。所有和文件相关的系统调用在最初的处理上都指向虚拟文件系统。这些来自用户进程的调用,都是标准的 POSIX 系统调用,比如 open、read、write 和 seek 等。VFS 对用户进程有一个 上层 接口,这个接口就是著名的 POSIX 接口。
文件系统的管理和优化
能够使文件系统工作是一回事,能够使文件系统高效、稳定的工作是另一回事,下面我们就来探讨一下文件系统的管理和优化。
磁盘空间管理
文件通常存在磁盘中,所以如何管理磁盘空间是一个操作系统的设计者需要考虑的问题。在文件上进行存有两种策略:分配 n 个字节的连续磁盘空间;或者把文件拆分成多个并不一定连续的块。在存储管理系统中,主要有分段管理和 分页管理 两种方式。
正如我们所看到的,按连续字节序列存储文件有一个明显的问题,当文件扩大时,有可能需要在磁盘上移动文件。内存中分段也有同样的问题。不同的是,相对于把文件从磁盘的一个位置移动到另一个位置,内存中段的移动操作要快很多。因此,几乎所有的文件系统都把文件分割成固定大小的块来存储。
块大小
一旦把文件分为固定大小的块来存储,就会出现问题,块的大小是多少?按照磁盘组织方式,扇区、磁道和柱面显然都可以作为分配单位。在分页系统中,分页大小也是主要因素。
拥有大的块尺寸意味着每个文件,甚至 1 字节文件,都要占用一个柱面空间,也就是说小文件浪费了大量的磁盘空间。另一方面,小块意味着大部分文件将会跨越多个块,因此需要多次搜索和旋转延迟才能读取它们,从而降低了性能。因此,如果分配的块太大会浪费空间;分配的块太小会浪费时间。
记录空闲块
一旦指定了块大小,下一个问题就是怎样跟踪空闲块。有两种方法被广泛采用,如下图所示
第一种方法是采用磁盘块链表,链表的每个块中包含极可能多的空闲磁盘块号。对于 1 KB 的块和 32 位的磁盘块号,空闲表中每个块包含有 255 个空闲的块号。考虑 1 TB 的硬盘,拥有大概十亿个磁盘块。为了存储全部地址块号,如果每块可以保存 255 个块号,则需要将近 400 万个块。通常,空闲块用于保存空闲列表,因此存储基本上是空闲的。
另一种空闲空间管理的技术是位图(bitmap),n 个块的磁盘需要 n 位位图。在位图中,空闲块用 1 表示,已分配的块用 0 表示。对于 1 TB 硬盘的例子,需要 10 亿位表示,即需要大约 130 000 个 1 KB 块存储。很明显,和 32 位链表模型相比,位图需要的空间更少,因为每个块使用 1 位。只有当磁盘快满的时候,链表需要的块才会比位图少。
磁盘配额
为了防止一些用户占用太多的磁盘空间,多用户操作通常提供一种磁盘配额(enforcing disk quotas)的机制。系统管理员为每个用户分配最大的文件和块分配,并且操作系统确保用户不会超过其配额。我们下面会谈到这一机制。
在用户打开一个文件时,操作系统会找到文件属性和磁盘地址,并把它们送入内存中的打开文件表。其中一个属性告诉文件所有者是谁。任何有关文件的增加都会记到所有者的配额中。
配额表中记录了每个用户的配额
第二张表包含了每个用户当前打开文件的配额记录,即使是其他人打开该文件也一样。如上图所示,该表的内容是从被打开文件的所有者的磁盘配额文件中提取出来的。当所有文件关闭时,该记录被写回配额文件。
当在打开文件表中建立一新表项时,会产生一个指向所有者配额记录的指针。每次向文件中添加一个块时,文件所有者所用数据块的总数也随之增加,并会同时增加硬限制和软限制的检查。可以超出软限制,但硬限制不可以超出。当已达到硬限制时,再往文件中添加内容将引发错误。同样,对文件数目也存在类似的检查。
文件系统备份
做文件备份很耗费时间而且也很浪费空间,这会引起下面几个问题。首先,是要备份整个文件还是仅备份一部分呢?一般来说,只是备份特定目录及其下的全部文件,而不是备份整个文件系统。
其次,对上次未修改过的文件再进行备份是一种浪费,因而产生了一种增量转储(incremental dumps) 的思想。最简单的增量转储的形式就是周期性的做全面的备份,而每天只对增量转储完成后发生变化的文件做单个备份。
稍微好一点的方式是只备份最近一次转储以来更改过的文件。当然,这种做法极大的缩减了转储时间,但恢复起来却更复杂,因为最近的全面转储先要全部恢复,随后按逆序进行增量转储。为了方便恢复,人们往往使用更复杂的转储模式。
第三,既然待转储的往往是海量数据,那么在将其写入磁带之前对文件进行压缩就很有必要。但是,如果在备份过程中出现了文件损坏的情况,就会导致破坏压缩算法,从而使整个磁带无法读取。所以在备份前是否进行文件压缩需慎重考虑。
第四,对正在使用的文件系统做备份是很难的。如果在转储过程中要添加,删除和修改文件和目录,则转储结果可能不一致。因此,因为转储过程中需要花费数个小时的时间,所以有必要在晚上将系统脱机进行备份,然而这种方式的接受程度并不高。所以,人们修改了转储算法,记下文件系统的瞬时快照,即复制关键的数据结构,然后需要把将来对文件和目录所做的修改复制到块中,而不是到处更新他们。
磁盘转储到备份磁盘上有两种方案:物理转储和逻辑转储。物理转储(physical dump) 是从磁盘的 0 块开始,依次将所有磁盘块按照顺序写入到输出磁盘,并在复制最后一个磁盘时停止。这种程序的万无一失性是其他程序所不具备的。
第二个需要考虑的是坏块的转储。制造大型磁盘而没有瑕疵是不可能的,所以也会存在一些坏块(bad blocks)。有时进行低级格式化后,坏块会被检测出来并进行标记,这种情况的解决办法是用磁盘末尾的一些空闲块所替换。
然而,一些块在格式化后会变坏,在这种情况下操作系统可以检测到它们。通常情况下,它可以通过创建一个由所有坏块组成的文件来解决问题,确保它们不会出现在空闲池中并且永远不会被分配。那么此文件是完全不可读的。如果磁盘控制器将所有的坏块重新映射,物理转储还是能够正常工作的。
Windows 系统有分页文件(paging files) 和 休眠文件(hibernation files) 。它们在文件还原时不发挥作用,同时也不应该在第一时间进行备份。
文件系统的一致性
影响可靠性的一个因素是文件系统的一致性。许多文件系统读取磁盘块、修改磁盘块、再把它们写回磁盘。如果系统在所有块写入之前崩溃,文件系统就会处于一种不一致(inconsistent)的状态。如果某些尚未写回的块是索引节点块,目录块或包含空闲列表的块,则此问题是很严重的。
为了处理文件系统一致性问题,大部分计算机都会有应用程序来检查文件系统的一致性。例如,UNIX 有 fsck;Windows 有 sfc,每当引导系统时(尤其是在崩溃后),都可以运行该程序。
可以进行两种一致性检查:块的一致性检查和文件的一致性检查。为了检查块的一致性,应用程序会建立两张表,每个包含一个计数器的块,最初设置为 0 。第一个表中的计数器跟踪该块在文件中出现的次数,第二张表中的计数器记录每个块在空闲列表、空闲位图中出现的频率。
文件系统性能
访问磁盘的效率要比内存满的多,是时候又祭出这张图了
从内存读一个 32 位字大概是 10ns,从硬盘上读的速率大概是 100MB/S,对每个 32 位字来说,效率会慢了四倍,另外,还要加上 5 - 10 ms 的寻道时间等其他损耗,如果只访问一个字,内存要比磁盘快百万数量级。所以磁盘优化是很有必要的,下面我们会讨论几种优化方式
高速缓存
最常用的减少磁盘访问次数的技术是使用 块高速缓存(block cache) 或者 缓冲区高速缓存(buffer cache)。高速缓存指的是一系列的块,它们在逻辑上属于磁盘,但实际上基于性能的考虑被保存在内存中。
管理高速缓存有不同的算法,常用的算法是:检查全部的读请求,查看在高速缓存中是否有所需要的块。如果存在,可执行读操作而无须访问磁盘。如果检查块不再高速缓存中,那么首先把它读入高速缓存,再复制到所需的地方。之后,对同一个块的请求都通过高速缓存来完成。
高速缓存的操作如下图所示
由于在高速缓存中有许多块,所以需要某种方法快速确定所需的块是否存在。常用方法是将设备和磁盘地址进行散列操作,然后,在散列表中查找结果。具有相同散列值的块在一个链表中连接在一起(这个数据结构是不是很像 HashMap?),这样就可以沿着冲突链查找其他块。
如果高速缓存已满,此时需要调入新的块,则要把原来的某一块调出高速缓存,如果要调出的块在上次调入后已经被修改过,则需要把它写回磁盘。
块提前读
第二个明显提高文件系统的性能是,在需要用到块之前,试图提前将其写入高速缓存,从而提高命中率。许多文件都是顺序读取。如果请求文件系统在某个文件中生成块 k,文件系统执行相关操作并且在完成之后,会检查高速缓存,以便确定块 k + 1 是否已经在高速缓存。如果不在,文件系统会为 k + 1 安排一个预读取,因为文件希望在用到该块的时候能够直接从高速缓存中读取。
当然,块提前读取策略只适用于实际顺序读取的文件。对随机访问的文件,提前读丝毫不起作用。甚至还会造成阻碍。
减少磁盘臂运动
高速缓存和块提前读并不是提高文件系统性能的唯一方法。另一种重要的技术是把有可能顺序访问的块放在一起,当然最好是在同一个柱面上,从而减少磁盘臂的移动次数。当写一个输出文件时,文件系统就必须按照要求一次一次地分配磁盘块。如果用位图来记录空闲块,并且整个位图在内存中,那么选择与前一块最近的空闲块是很容易的。如果用空闲表,并且链表的一部分存在磁盘上,要分配紧邻的空闲块就会困难很多。
磁盘碎片整理
在初始安装操作系统后,文件就会被不断的创建和清除,于是磁盘会产生很多的碎片,在创建一个文件时,它使用的块会散布在整个磁盘上,降低性能。删除文件后,回收磁盘块,可能会造成空穴。
磁盘性能可以通过如下方式恢复:移动文件使它们相互挨着,并把所有的至少是大部分的空闲空间放在一个或多个大的连续区域内。Windows 有一个程序 defrag 就是做这个事儿的。Windows 用户会经常使用它,SSD 除外。
磁盘碎片整理程序会在让文件系统上很好地运行。Linux 文件系统(特别是 ext2 和 ext3)由于其选择磁盘块的方式,在磁盘碎片整理上一般不会像 Windows 一样困难,因此很少需要手动的磁盘碎片整理。而且,固态硬盘并不受磁盘碎片的影响,事实上,在固态硬盘上做磁盘碎片整理反倒是多此一举,不仅没有提高性能,反而磨损了固态硬盘。所以碎片整理只会缩短固态硬盘的寿命。
下面我们来探讨一下 I/O 流程问题。
I/O 设备
什么是 I/O 设备?I/O 设备又叫做输入/输出设备,它是人类用来和计算机进行通信的外部硬件。输入/输出设备能够向计算机发送数据(输出)并从计算机接收数据(输入)。
I/O 设备(I/O devices)可以分成两种:块设备(block devices) 和 字符设备(character devices)。
块设备
块设备是一个能存储固定大小块信息的设备,它支持以固定大小的块,扇区或群集读取和(可选)写入数据。每个块都有自己的物理地址。通常块的大小在 512 - 65536 之间。所有传输的信息都会以连续的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 硬盘、蓝光光盘、USB 盘
与字符设备相比,块设备通常需要较少的引脚。
块设备的缺点
基于给定固态存储器的块设备比基于相同类型的存储器的字节寻址要慢一些,因为必须在块的开头开始读取或写入。所以,要读取该块的任何部分,必须寻找到该块的开始,读取整个块,如果不使用该块,则将其丢弃。要写入块的一部分,必须寻找到块的开始,将整个块读入内存,修改数据,再次寻找到块的开头处,然后将整个块写回设备。
字符设备
另一类 I/O 设备是字符设备。字符设备以字符为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有 打印机、网络设备、鼠标、以及大多数与磁盘不同的设备。
设备控制器
设备控制器是处理 CPU 传入和传出信号的系统。设备通过插头和插座连接到计算机,并且插座连接到设备控制器。设备控制器从连接的设备处接收数据,并将其存储在控制器内部的一些特殊目的寄存器(special purpose registers) 也就是本地缓冲区中。
每个设备控制器都会有一个应用程序与之对应,设备控制器通过应用程序的接口通过中断与操作系统进行通信。设备控制器是硬件,而设备驱动程序是软件。
内存映射 I/O
每个控制器都会有几个寄存器用来和 CPU 进行通信。通过写入这些寄存器,操作系统可以命令设备发送数据,接收数据、开启或者关闭设备等。通过从这些寄存器中读取信息,操作系统能够知道设备的状态,是否准备接受一个新命令等。
为了控制寄存器,许多设备都会有数据缓冲区(data buffer),来供系统进行读写。
那么问题来了,CPU 如何与设备寄存器和设备数据缓冲区进行通信呢?存在两个可选的方式。第一种方法是,每个控制寄存器都被分配一个 I/O 端口(I/O port)号,这是一个 8 位或 16 位的整数。所有 I/O 端口的集合形成了受保护的 I/O 端口空间,以便普通用户程序无法访问它(只有操作系统可以访问)。使用特殊的 I/O 指令像是
IN REG,PORT
CPU 可以读取控制寄存器 PORT 的内容并将结果放在 CPU 寄存器 REG 中。类似的,使用
OUT PORT,REG
CPU 可以将 REG 的内容写到控制寄存器中。大多数早期计算机,包括几乎所有大型主机,如 IBM 360 及其所有后续机型,都是以这种方式工作的。
第二个方法是 PDP-11 引入的,它将所有控制寄存器映射到内存空间中。
直接内存访问
无论一个 CPU 是否具有内存映射 I/O,它都需要寻址设备控制器以便与它们交换数据。CPU 可以从 I/O 控制器每次请求一个字节的数据,但是这么做会浪费 CPU 时间,所以经常会用到一种称为直接内存访问(Direct Memory Access) 的方案。为了简化,我们假设 CPU 通过单一的系统总线访问所有的设备和内存,该总线连接 CPU 、内存和 I/O 设备,如下图所示
DMA 传送操作
现代操作系统实际更为复杂,但是原理是相同的。如果硬件有DMA 控制器,那么操作系统只能使用 DMA。有时这个控制器会集成到磁盘控制器和其他控制器中,但这种设计需要在每个设备上都装有一个分离的 DMA 控制器。单个的 DMA 控制器可用于向多个设备传输,这种传输往往同时进行。
DMA 工作原理
首先 CPU 通过设置 DMA 控制器的寄存器对它进行编程,所以 DMA 控制器知道将什么数据传送到什么地方。DMA 控制器还要向磁盘控制器发出一个命令,通知它从磁盘读数据到其内部的缓冲区并检验校验和。当有效数据位于磁盘控制器的缓冲区中时,DMA 就可以开始了。
DMA 控制器通过在总线上发出一个读请求到磁盘控制器而发起 DMA 传送,这是第二步。这个读请求就像其他读请求一样,磁盘控制器并不知道或者并不关心它是来自 CPU 还是来自 DMA 控制器。通常情况下,要写的内存地址在总线的地址线上,所以当磁盘控制器去匹配下一个字时,它知道将该字写到什么地方。写到内存就是另外一个总线循环了,这是第三步。当写操作完成时,磁盘控制器在总线上发出一个应答信号到 DMA 控制器,这是第四步。
然后,DMA 控制器会增加内存地址并减少字节数量。如果字节数量仍然大于 0 ,就会循环步骤 2 - 步骤 4 ,直到字节计数变为 0 。此时,DMA 控制器会打断 CPU 并告诉它传输已经完成了。
重温中断
在一台个人计算机体系结构中,中断结构会如下所示
中断是怎样发生的
当一个 I/O 设备完成它的工作后,它就会产生一个中断(默认操作系统已经开启中断),它通过在总线上声明已分配的信号来实现此目的。主板上的中断控制器芯片会检测到这个信号,然后执行中断操作。
精确中断和不精确中断
使机器处于良好状态的中断称为精确中断(precise interrupt)。这样的中断具有四个属性:
- PC (程序计数器)保存在一个已知的地方
- PC 所指向的指令之前所有的指令已经完全执行
- PC 所指向的指令之后所有的指令都没有执行
- PC 所指向的指令的执行状态是已知的
不满足以上要求的中断称为 不精确中断(imprecise interrupt),不精确中断让人很头疼。上图描述了不精确中断的现象。指令的执行时序和完成度具有不确定性,而且恢复起来也非常麻烦。
IO 软件原理
I/O 软件目标
设备独立性
I/O 软件设计一个很重要的目标就是设备独立性(device independence)。这意味着我们能够编写访问任何设备的应用程序,而不用事先指定特定的设备。
错误处理
除了设备独立性外,I/O 软件实现的第二个重要的目标就是错误处理(error handling)。通常情况下来说,错误应该交给硬件层面去处理。如果设备控制器发现了读错误的话,它会尽可能的去修复这个错误。如果设备控制器处理不了这个问题,那么设备驱动程序应该进行处理,设备驱动程序会再次尝试读取操作,很多错误都是偶然性的,如果设备驱动程序无法处理这个错误,才会把错误向上抛到硬件层面(上层)进行处理,很多时候,上层并不需要知道下层是如何解决错误的。
同步和异步传输
I/O 软件实现的第三个目标就是 同步(synchronous) 和 异步(asynchronous,即中断驱动)传输。这里先说一下同步和异步是怎么回事吧。
同步传输中数据通常以块或帧的形式发送。发送方和接收方在数据传输之前应该具有同步时钟。而在异步传输中,数据通常以字节或者字符的形式发送,异步传输则不需要同步时钟,但是会在传输之前向数据添加奇偶校验位。大部分物理IO(physical I/O) 是异步的。物理 I/O 中的 CPU 是很聪明的,CPU 传输完成后会转而做其他事情,它和中断心灵相通,等到中断发生后,CPU 才会回到传输这件事情上来。
缓冲
I/O 软件的最后一个问题是缓冲(buffering)。通常情况下,从一个设备发出的数据不会直接到达最后的设备。其间会经过一系列的校验、检查、缓冲等操作才能到达。
共享和独占
I/O 软件引起的最后一个问题就是共享设备和独占设备的问题。有些 I/O 设备能够被许多用户共同使用。一些设备比如磁盘,让多个用户使用一般不会产生什么问题,但是某些设备必须具有独占性,即只允许单个用户使用完成后才能让其他用户使用。
一共有三种控制 I/O 设备的方法
- 使用程序控制 I/O
- 使用中断驱动 I/O
- 使用 DMA 驱动 I/O
I/O 层次结构
I/O 软件通常组织成四个层次,它们的大致结构如下图所示
下面我们具体的来探讨一下上面的层次结构
中断处理程序
在计算机系统中,中断就像女人的脾气一样无时无刻都在产生,中断的出现往往是让人很不爽的。中断处理程序又被称为中断服务程序 或者是 ISR(Interrupt Service Routines),它是最靠近硬件的一层。中断处理程序由硬件中断、软件中断或者是软件异常启动产生的中断,用于实现设备驱动程序或受保护的操作模式(例如系统调用)之间的转换。
中断处理程序负责处理中断发生时的所有操作,操作完成后阻塞,然后启动中断驱动程序来解决阻塞。通常会有三种通知方式,依赖于不同的具体实现
- 信号量实现中:在信号量上使用 up 进行通知;
- 管程实现:对管程中的条件变量执行 signal 操作
- 还有一些情况是发送一些消息
设备驱动程序
每个连接到计算机的 I/O 设备都需要有某些特定设备的代码对其进行控制。这些提供 I/O 设备到设备控制器转换的过程的代码称为 设备驱动程序(Device driver)。
设备控制器的主要功能有下面这些
接收和识别命令:设备控制器可以接受来自 CPU 的指令,并进行识别。设备控制器内部也会有寄存器,用来存放指令和参数
进行数据交换:CPU、控制器和设备之间会进行数据的交换,CPU 通过总线把指令发送给控制器,或从控制器中并行地读出数据;控制器将数据写入指定设备。
地址识别:每个硬件设备都有自己的地址,设备控制器能够识别这些不同的地址,来达到控制硬件的目的,此外,为使 CPU 能向寄存器中写入或者读取数据,这些寄存器都应具有唯一的地址。
差错检测:设备控制器还具有对设备传递过来的数据进行检测的功能。
在这种情况下,设备控制器会阻塞,直到中断来解除阻塞状态。还有一种情况是操作是可以无延迟的完成,所以驱动程序不需要阻塞。在第一种情况下,操作系统可能被中断唤醒;第二种情况下操作系统不会被休眠。
设备驱动程序必须是可重入的,因为设备驱动程序会阻塞和唤醒然后再次阻塞。驱动程序不允许进行系统调用,但是它们通常需要与内核的其余部分进行交互。
与设备无关的 I/O 软件
I/O 软件有两种,一种是我们上面介绍过的基于特定设备的,还有一种是设备无关性的,设备无关性也就是不需要特定的设备。设备驱动程序与设备无关的软件之间的界限取决于具体的系统。下面显示的功能由设备无关的软件实现
与设备无关的软件的基本功能是对所有设备执行公共的 I/O 功能,并且向用户层软件提供一个统一的接口。
缓冲
无论是对于块设备还是字符设备来说,缓冲都是一个非常重要的考量标准。缓冲技术应用广泛,但它也有缺点。如果数据被缓冲次数太多,会影响性能。
错误处理
在 I/O 中,出错是一种再正常不过的情况了。当出错发生时,操作系统必须尽可能处理这些错误。有一些错误是只有特定的设备才能处理,有一些是由框架进行处理,这些错误和特定的设备无关。
I/O 错误的一类是程序员编程错误,比如还没有打开文件前就读流,或者不关闭流导致内存溢出等等。这类问题由程序员处理;另外一类是实际的 I/O 错误,例如向一个磁盘坏块写入数据,无论怎么写都写入不了。这类问题由驱动程序处理,驱动程序处理不了交给硬件处理,这个我们上面也说过。
设备驱动程序统一接口
我们在操作系统概述中说到,操作系统一个非常重要的功能就是屏蔽了硬件和软件的差异性,为硬件和软件提供了统一的标准,这个标准还体现在为设备驱动程序提供统一的接口,因为不同的硬件和厂商编写的设备驱动程序不同,所以如果为每个驱动程序都单独提供接口的话,这样没法搞,所以必须统一。
分配和释放
一些设备例如打印机,它只能由一个进程来使用,这就需要操作系统根据实际情况判断是否能够对设备的请求进行检查,判断是否能够接受其他请求,一种比较简单直接的方式是在特殊文件上执行 open操作。如果设备不可用,那么直接 open 会导致失败。还有一种方式是不直接导致失败,而是让其阻塞,等到另外一个进程释放资源后,在进行 open 打开操作。这种方式就把选择权交给了用户,由用户判断是否应该等待。
设备无关的块
不同的磁盘会具有不同的扇区大小,但是软件不会关心扇区大小,只管存储就是了。一些字符设备可以一次一个字节的交付数据,而其他的设备则以较大的单位交付数据,这些差异也可以隐藏起来。
用户空间的 I/O 软件
虽然大部分 I/O 软件都在内核结构中,但是还有一些在用户空间实现的 I/O 软件,凡事没有绝对。一些 I/O 软件和库过程在用户空间存在,然后以提供系统调用的方式实现。
盘
盘可以说是硬件里面比较简单的构造了,同时也是最重要的。下面我们从盘谈起,聊聊它的物理构造
盘硬件
盘会有很多种类型。其中最简单的构造就是磁盘(magnetic hard disks), 也被称为 hard disk,HDD等。磁盘通常与安装在磁臂上的磁头配对,磁头可将数据读取或者将数据写入磁盘,因此磁盘的读写速度都同样快。在磁盘中,数据是随机访问的,这也就说明可以通过任意的顺序来存储和检索单个数据块,所以你可以在任意位置放置磁盘来让磁头读取,磁盘是一种非易失性的设备,即使断电也能永久保留。
磁盘
为了组织和检索数据,会将磁盘组织成特定的结构,这些特定的结构就是磁道、扇区和柱面
磁盘被组织成柱面形式,每个盘用轴相连,每一个柱面包含若干磁道,每个磁道由若干扇区组成。软盘上大约每个磁道有 8 - 32 个扇区,硬盘上每条磁道上扇区的数量可达几百个,磁头大约是 1 - 16 个。
对于磁盘驱动程序来说,一个非常重要的特性就是控制器是否能够同时控制两个或者多个驱动器进行磁道寻址,这就是重叠寻道(overlapped seek)。对于控制器来说,它能够控制一个磁盘驱动程序完成寻道操作,同时让其他驱动程序等待寻道结束。控制器也可以在一个驱动程序上进行读写草哦做,与此同时让另外的驱动器进行寻道操作,但是软盘控制器不能在两个驱动器上进行读写操作。
RAID
RAID 称为 磁盘冗余阵列,简称 磁盘阵列。利用虚拟化技术把多个硬盘结合在一起,成为一个或多个磁盘阵列组,目的是提升性能或数据冗余。
RAID 有不同的级别
- RAID 0 - 无容错的条带化磁盘阵列
- RAID 1 - 镜像和双工
- RAID 2 - 内存式纠错码
- RAID 3 - 比特交错奇偶校验
- RAID 4 - 块交错奇偶校验
- RAID 5 - 块交错分布式奇偶校验
- RAID 6 - P + Q冗余
磁盘格式化
磁盘由一堆铝的、合金或玻璃的盘片组成,磁盘刚被创建出来后,没有任何信息。磁盘在使用前必须经过低级格式化(low-levvel format),下面是一个扇区的格式
前导码相当于是标示扇区的开始位置,通常以位模式开始,前导码还包括柱面号、扇区号等一些其他信息。紧随前导码后面的是数据区,数据部分的大小由低级格式化程序来确定。大部分磁盘使用 512 字节的扇区。数据区后面是 ECC,ECC 的全称是 error correction code ,数据纠错码,它与普通的错误检测不同,ECC 还可以用于恢复读错误。ECC 阶段的大小由不同的磁盘制造商实现。ECC 大小的设计标准取决于设计者愿意牺牲多少磁盘空间来提高可靠性,以及程序可以处理的 ECC 的复杂程度。通常情况下 ECC 是 16 位,除此之外,硬盘一般具有一定数量的备用扇区,用于替换制造缺陷的扇区。
磁盘臂调度算法
下面我们来探讨一下关于影响磁盘读写的算法,一般情况下,影响磁盘快读写的时间由下面几个因素决定
- 寻道时间 - 寻道时间指的就是将磁盘臂移动到需要读取磁盘块上的时间
- 旋转延迟 - 等待合适的扇区旋转到磁头下所需的时间
- 实际数据的读取或者写入时间
这三种时间参数也是磁盘寻道的过程。一般情况下,寻道时间对总时间的影响最大,所以,有效的降低寻道时间能够提高磁盘的读取速度。
如果磁盘驱动程序每次接收一个请求并按照接收顺序完成请求,这种处理方式也就是 先来先服务(First-Come, First-served, FCFS) ,这种方式很难优化寻道时间。因为每次都会按照顺序处理,不管顺序如何,有可能这次读完后需要等待一个磁盘旋转一周才能继续读取,而其他柱面能够马上进行读取,这种情况下每次请求也会排队。
通常情况下,磁盘在进行寻道时,其他进程会产生其他的磁盘请求。磁盘驱动程序会维护一张表,表中会记录着柱面号当作索引,每个柱面未完成的请求会形成链表,链表头存放在表的相应表项中。
一种对先来先服务的算法改良的方案是使用 最短路径优先(SSF) 算法,下面描述了这个算法。
假如我们在对磁道 6 号进行寻址时,同时发生了对 11 , 2 , 4, 14, 8, 15, 3 的请求,如果采用先来先服务的原则,如下图所示
我们可以计算一下磁盘臂所跨越的磁盘数量为 5 + 9 + 2 + 10 + 6 + 7 + 12 = 51,相当于是跨越了 51 次盘面,如果使用最短路径优先,我们来计算一下跨越的盘面
跨越的磁盘数量为 4 + 1 + 1 + 4 + 3 + 3 + 1 = 17 ,相比 51 足足省了两倍的时间。
但是,最短路径优先的算法也不是完美无缺的,这种算法照样存在问题,那就是优先级 问题,
这里有一个原型可以参考就是我们日常生活中的电梯,电梯使用一种电梯算法(elevator algorithm) 来进行调度,从而满足协调效率和公平性这两个相互冲突的目标。电梯一般会保持向一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
电梯算法需要维护一个二进制位,也就是当前的方向位:UP(向上)或者是 DOWN(向下)。当一个请求处理完成后,磁盘或电梯的驱动程序会检查该位,如果此位是 UP 位,磁盘臂或者电梯仓移到下一个更高跌未完成的请求。如果高位没有未完成的请求,则取相反方向。当方向位是 DOWN时,同时存在一个低位的请求,磁盘臂会转向该点。如果不存在的话,那么它只是停止并等待。
我们举个例子来描述一下电梯算法,比如各个柱面得到服务的顺序是 4,7,10,14,9,6,3,1 ,那么它的流程图如下
所以电梯算法需要跨越的盘面数量是 3 + 3 + 4 + 5 + 3 + 3 + 1 = 22
电梯算法通常情况下不如 SSF 算法。
错误处理
一般坏块有两种处理办法,一种是在控制器中进行处理;一种是在操作系统层面进行处理。
这两种方法经常替换使用,比如一个具有 30 个数据扇区和两个备用扇区的磁盘,其中扇区 4 是有瑕疵的。
控制器能做的事情就是将备用扇区之一重新映射。
还有一种处理方式是将所有的扇区都向上移动一个扇区
上面这这两种情况下控制器都必须知道哪个扇区,可以通过内部的表来跟踪这一信息,或者通过重写前导码来给出重新映射的扇区号。如果是重写前导码,那么涉及移动的方式必须重写后面所有的前导码,但是最终会提供良好的性能。
稳定存储器
磁盘经常会出现错误,导致好的扇区会变成坏扇区,驱动程序也有可能挂掉。RAID 可以对扇区出错或者是驱动器崩溃提出保护,然而 RAID 却不能对坏数据中的写错误提供保护,也不能对写操作期间的崩溃提供保护,这样就会破坏原始数据。
我们期望磁盘能够准确无误的工作,但是事实情况是不可能的,但是我们能够知道的是,一个磁盘子系统具有如下特性:当一个写命令发给它时,磁盘要么正确地写数据,要么什么也不做,让现有的数据完整无误的保留。这样的系统称为 稳定存储器(stable storage)。 稳定存储器的目标就是不惜一切代价保证磁盘的一致性。
稳定存储器使用两个一对相同的磁盘,对应的块一同工作形成一个无差别的块。稳定存储器为了实现这个目的,定义了下面三种操作:
- 稳定写(stable write)
- 稳定读(stable read)
- 崩溃恢复(crash recovery)
时钟
时钟(Clocks) 也被称为定时器(timers),时钟/定时器对任何程序系统来说都是必不可少的。时钟负责维护时间、防止一个进程长期占用 CPU 时间等其他功能。时钟软件(clock software) 也是一种设备驱动的方式。下面我们就来对时钟进行介绍,一般都是先讨论硬件再介绍软件,采用由下到上的方式,也是告诉你,底层是最重要的。
时钟硬件
在计算机中有两种类型的时钟,这些时钟与现实生活中使用的时钟完全不一样。
- 比较简单的一种时钟被连接到 110 V 或 220 V 的电源线上,这样每个电压周期会产生一个中断,大概是 50 - 60 HZ。这些时钟过去一直占据支配地位。
- 另外的一种时钟由晶体振荡器、计数器和寄存器组成,示意图如下所示
这种时钟称为可编程时钟 ,可编程时钟有两种模式,一种是 一键式(one-shot mode),当时钟启动时,会把存储器中的值复制到计数器中,然后,每次晶体的振荡器的脉冲都会使计数器 -1。当计数器变为 0 时,会产生一个中断,并停止工作,直到软件再一次显示启动。还有一种模式时 方波(square-wave mode) 模式,在这种模式下,当计数器变为 0 并产生中断后,存储寄存器的值会自动复制到计数器中,这种周期性的中断称为一个时钟周期。
时钟软件
时钟硬件所做的工作只是根据已知的时间间隔产生中断,而其他的工作都是由时钟软件来完成,一般操作系统的不同,时钟软件的具体实现也不同,但是一般都会包括以下这几点
- 维护一天的时间
- 阻止进程运行的时间超过其指定时间
- 统计 CPU 的使用情况
- 处理用户进程的警告系统调用
- 为系统各个部分提供看门狗定时器
- 完成概要剖析,监视和信息收集
软定时器
时钟软件也被称为可编程时钟,可以设置它以程序需要的任何速率引发中断。时钟软件触发的中断是一种硬中断,但是某些应用程序对于硬中断来说是不可接受的。
这时候就需要一种软定时器(soft timer) 避免了中断,无论何时当内核因为某种原因呢在运行时,它返回用户态之前都会检查时钟来了解软定时器是否到期。如果软定时器到期,则执行被调度的事件也无需切换到内核态,因为本身已经处于内核态中。这种方式避免了频繁的内核态和用户态之前的切换,提高了程序运行效率。
软定时器因为不同的原因切换进入内核态的速率不同,原因主要有
- 系统调用
- TLB 未命中
- 缺页异常
- I/O 中断
- CPU 变得空闲
死锁问题也是操作系统非常重要的一类问题
资源
大部分的死锁都和资源有关,在进程对设备、文件具有独占性(排他性)时会产生死锁。我们把这类需要排他性使用的对象称为资源(resource)。资源主要分为 可抢占资源和不可抢占资源
可抢占资源和不可抢占资源
资源主要有可抢占资源和不可抢占资源。可抢占资源(preemptable resource) 可以从拥有它的进程中抢占而不会造成其他影响,内存就是一种可抢占性资源,任何进程都能够抢先获得内存的使用权。
不可抢占资源(nonpreemtable resource) 指的是除非引起错误或者异常,否则进程无法抢占指定资源,这种不可抢占的资源比如有光盘,在进程执行调度的过程中,其他进程是不能得到该资源的。
死锁
如果要对死锁进行一个定义的话,下面的定义比较贴切
如果一组进程中的每个进程都在等待一个事件,而这个事件只能由该组中的另一个进程触发,这种情况会导致死锁。
资源死锁的条件
针对我们上面的描述,资源死锁可能出现的情况主要有
- 互斥条件:每个资源都被分配给了一个进程或者资源是可用的
- 保持和等待条件:已经获取资源的进程被认为能够获取新的资源
- 不可抢占条件:分配给一个进程的资源不能强制的从其他进程抢占资源,它只能由占有它的进程显示释放
- 循环等待:死锁发生时,系统中一定有两个或者两个以上的进程组成一个循环,循环中的每个进程都在等待下一个进程释放的资源。
发生死锁时,上面的情况必须同时会发生。如果其中任意一个条件不会成立,死锁就不会发生。可以通过破坏其中任意一个条件来破坏死锁,下面这些破坏条件就是我们探讨的重点
死锁模型
Holt 在 1972 年提出对死锁进行建模,建模的标准如下:
- 圆形表示进程
- 方形表示资源
从资源节点到进程节点表示资源已经被进程占用,如下图所示
在上图中表示当前资源 R 正在被 A 进程所占用
由进程节点到资源节点的有向图表示当前进程正在请求资源,并且该进程已经被阻塞,处于等待这个资源的状态
在上图中,表示的含义是进程 B 正在请求资源 S 。Holt 认为,死锁的描述应该如下
这是一个死锁的过程,进程 C 等待资源 T 的释放,资源 T 却已经被进程 D 占用,进程 D 等待请求占用资源 U ,资源 U 却已经被线程 C 占用,从而形成环。
有四种处理死锁的策略:
- 忽略死锁带来的影响(惊呆了)
- 检测死锁并回复死锁,死锁发生时对其进行检测,一旦发生死锁后,采取行动解决问题
- 通过仔细分配资源来避免死锁
- 通过破坏死锁产生的四个条件之一来避免死锁
下面我们分别介绍一下这四种方法
鸵鸟算法
最简单的解决办法就是使用鸵鸟算法(ostrich algorithm),把头埋在沙子里,假装问题根本没有发生。每个人看待这个问题的反应都不同。数学家认为死锁是不可接受的,必须通过有效的策略来防止死锁的产生。工程师想要知道问题发生的频次,系统因为其他原因崩溃的次数和死锁带来的严重后果。如果死锁发生的频次很低,而经常会由于硬件故障、编译器错误等其他操作系统问题导致系统崩溃,那么大多数工程师不会修复死锁。
死锁检测和恢复
第二种技术是死锁的检测和恢复。这种解决方式不会尝试去阻止死锁的出现。相反,这种解决方案会希望死锁尽可能的出现,在监测到死锁出现后,对其进行恢复。下面我们就来探讨一下死锁的检测和恢复的几种方式
每种类型一个资源的死锁检测方式
每种资源类型都有一个资源是什么意思?我们经常提到的打印机就是这样的,资源只有打印机,但是设备都不会超过一个。
可以通过构造一张资源分配表来检测这种错误,比如我们上面提到的
如果这张图包含了一个或一个以上的环,那么死锁就存在,处于这个环中任意一个进程都是死锁的进程。
每种类型多个资源的死锁检测方式
如果有多种相同的资源存在,就需要采用另一种方法来检测死锁。可以通过构造一个矩阵来检测从 P1 -> Pn 这 n 个进程中的死锁。
现在我们提供一种基于矩阵的算法来检测从 P1 到 Pn 这 n 个进程中的死锁。假设资源类型为 m,E1 代表资源类型1,E2 表示资源类型 2 ,Ei 代表资源类型 i (1 <= i <= m)。E 表示的是 现有资源向量(existing resource vector),代表每种已存在的资源总数。
现在我们就需要构造两个数组:C 表示的是当前分配矩阵(current allocation matrix) ,R 表示的是 请求矩阵(request matrix)。Ci 表示的是 Pi 持有每一种类型资源的资源数。所以,Cij 表示 Pi 持有资源 j 的数量。Rij 表示 Pi 所需要获得的资源 j 的数量
一般来说,已分配资源 j 的数量加起来再和所有可供使用的资源数相加 = 该类资源的总数。
死锁的检测就是基于向量的比较。每个进程起初都是没有被标记过的,算法会开始对进程做标记,进程被标记后说明进程被执行了,不会进入死锁,当算法结束时,任何没有被标记过的进程都会被判定为死锁进程。
上面我们探讨了两种检测死锁的方式,那么现在你知道怎么检测后,你何时去做死锁检测呢?一般来说,有两个考量标准:
- 每当有资源请求时就去检测,这种方式会占用昂贵的 CPU 时间。
- 每隔 k 分钟检测一次,或者当 CPU 使用率降低到某个标准下去检测。考虑到 CPU 效率的原因,如果死锁进程达到一定数量,就没有多少进程可以运行,所以 CPU 会经常空闲。
从死锁中恢复
上面我们探讨了如何检测进程死锁,我们最终的目的肯定是想让程序能够正常的运行下去,所以针对检测出来的死锁,我们要对其进行恢复,下面我们会探讨几种死锁的恢复方式
通过抢占进行恢复
在某些情况下,可能会临时将某个资源从它的持有者转移到另一个进程。比如在不通知原进程的情况下,将某个资源从进程中强制取走给其他进程使用,使用完后又送回。这种恢复方式一般比较困难而且有些简单粗暴,并不可取。
通过回滚进行恢复
如果系统设计者和机器操作员知道有可能发生死锁,那么就可以定期检查流程。进程的检测点意味着进程的状态可以被写入到文件以便后面进行恢复。检测点不仅包含存储映像(memory image),还包含资源状态(resource state)。一种更有效的解决方式是不要覆盖原有的检测点,而是每出现一个检测点都要把它写入到文件中,这样当进程执行时,就会有一系列的检查点文件被累积起来。
为了进行恢复,要从上一个较早的检查点上开始,这样所需要资源的进程会回滚到上一个时间点,在这个时间点上,死锁进程还没有获取所需要的资源,可以在此时对其进行资源分配。
杀死进程恢复
最简单有效的解决方案是直接杀死一个死锁进程。但是杀死一个进程可能照样行不通,这时候就需要杀死别的资源进行恢复。
另外一种方式是选择一个环外的进程作为牺牲品来释放进程资源。
死锁避免
我们上面讨论的是如何检测出现死锁和如何恢复死锁,下面我们探讨几种规避死锁的方式
单个资源的银行家算法
银行家算法是 Dijkstra 在 1965 年提出的一种调度算法,它本身是一种死锁的调度算法。它的模型是基于一个城镇中的银行家,银行家向城镇中的客户承诺了一定数量的贷款额度。算法要做的就是判断请求是否会进入一种不安全的状态。如果是,就拒绝请求,如果请求后系统是安全的,就接受该请求。
类似的,还有多个资源的银行家算法,读者可以自行了解。
破坏死锁
死锁本质上是无法避免的,因为它需要获得未知的资源和请求,但是死锁是满足四个条件后才出现的,它们分别是
- 互斥
- 保持和等待
- 不可抢占
- 循环等待
我们分别对这四个条件进行讨论,按理说破坏其中的任意一个条件就能够破坏死锁
破坏互斥条件
我们首先考虑的就是破坏互斥使用条件。如果资源不被一个进程独占,那么死锁肯定不会产生。如果两个打印机同时使用一个资源会造成混乱,打印机的解决方式是使用 假脱机打印机(spooling printer) ,这项技术可以允许多个进程同时产生输出,在这种模型中,实际请求打印机的唯一进程是打印机守护进程,也称为后台进程。后台进程不会请求其他资源。我们可以消除打印机的死锁。
后台进程通常被编写为能够输出完整的文件后才能打印,假如两个进程都占用了假脱机空间的一半,而这两个进程都没有完成全部的输出,就会导致死锁。
因此,尽量做到尽可能少的进程可以请求资源。
破坏保持等待的条件
第二种方式是如果我们能阻止持有资源的进程请求其他资源,我们就能够消除死锁。一种实现方式是让所有的进程开始执行前请求全部的资源。如果所需的资源可用,进程会完成资源的分配并运行到结束。如果有任何一个资源处于频繁分配的情况,那么没有分配到资源的进程就会等待。
很多进程无法在执行完成前就知道到底需要多少资源,如果知道的话,就可以使用银行家算法;还有一个问题是这样无法合理有效利用资源。
还有一种方式是进程在请求其他资源时,先释放所占用的资源,然后再尝试一次获取全部的资源。
破坏不可抢占条件
破坏不可抢占条件也是可以的。可以通过虚拟化的方式来避免这种情况。
破坏循环等待条件
现在就剩最后一个条件了,循环等待条件可以通过多种方法来破坏。一种方式是制定一个标准,一个进程在任何时候只能使用一种资源。如果需要另外一种资源,必须释放当前资源。对于需要将大文件从磁带复制到打印机的过程,此限制是不可接受的。
另一种方式是将所有的资源统一编号,如下图所示
进程可以在任何时间提出请求,但是所有的请求都必须按照资源的顺序提出。如果按照此分配规则的话,那么资源分配之间不会出现环。
尽管通过这种方式来消除死锁,但是编号的顺序不可能让每个进程都会接受。
其他问题
下面我们来探讨一下其他问题,包括 通信死锁、活锁是什么、饥饿问题和两阶段加锁
两阶段加锁
虽然很多情况下死锁的避免和预防都能处理,但是效果并不好。随着时间的推移,提出了很多优秀的算法用来处理死锁。例如在数据库系统中,一个经常发生的操作是请求锁住一些记录,然后更新所有锁定的记录。当同时有多个进程运行时,就会有死锁的风险。
一种解决方式是使用 两阶段提交(two-phase locking)。顾名思义分为两个阶段,一阶段是进程尝试一次锁定它需要的所有记录。如果成功后,才会开始第二阶段,第二阶段是执行更新并释放锁。第一阶段并不做真正有意义的工作。
如果在第一阶段某个进程所需要的记录已经被加锁,那么该进程会释放所有锁定的记录并重新开始第一阶段。从某种意义上来说,这种方法类似于预先请求所有必需的资源或者是在进行一些不可逆的操作之前请求所有的资源。
不过在一般的应用场景中,两阶段加锁的策略并不通用。如果一个进程缺少资源就会半途中断并重新开始的方式是不可接受的。
通信死锁
我们上面一直讨论的是资源死锁,资源死锁是一种死锁类型,但并不是唯一类型,还有通信死锁,也就是两个或多个进程在发送消息时出现的死锁。进程 A 给进程 B 发了一条消息,然后进程 A 阻塞直到进程 B 返回响应。假设请求消息丢失了,那么进程 A 在一直等着回复,进程 B 也会阻塞等待请求消息到来,这时候就产生死锁。
尽管会产生死锁,但是这并不是一个资源死锁,因为 A 并没有占据 B 的资源。事实上,通信死锁并没有完全可见的资源。根据死锁的定义来说:每个进程因为等待其他进程引起的事件而产生阻塞,这就是一种死锁。相较于最常见的通信死锁,我们把上面这种情况称为通信死锁(communication deadlock)。
通信死锁不能通过调度的方式来避免,但是可以使用通信中一个非常重要的概念来避免:超时(timeout)。在通信过程中,只要一个信息被发出后,发送者就会启动一个定时器,定时器会记录消息的超时时间,如果超时时间到了但是消息还没有返回,就会认为消息已经丢失并重新发送,通过这种方式,可以避免通信死锁。
但是并非所有网络通信发生的死锁都是通信死锁,也存在资源死锁,下面就是一个典型的资源死锁。
当一个数据包从主机进入路由器时,会被放入一个缓冲区,然后再传输到另外一个路由器,再到另一个,以此类推直到目的地。缓冲区都是资源并且数量有限。如下图所示,每个路由器都有 10 个缓冲区(实际上有很多)。
假如路由器 A 的所有数据需要发送到 B ,B 的所有数据包需要发送到 D,然后 D 的所有数据包需要发送到 A 。没有数据包可以移动,因为在另一端没有缓冲区可用,这就是一个典型的资源死锁。
活锁
某些情况下,当进程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。
现在假想有一对并行的进程用到了两个资源。它们分别尝试获取另一个锁失败后,两个进程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有进程阻塞,但是进程仍然不会向下执行,这种状况我们称之为 活锁(livelock)。
饥饿
与死锁和活锁的一个非常相似的问题是 饥饿(starvvation)。想象一下你什么时候会饿?一段时间不吃东西是不是会饿?对于进程来讲,最重要的就是资源,如果一段时间没有获得资源,那么进程会产生饥饿,这些进程会永远得不到服务。
我们假设打印机的分配方案是每次都会分配给最小文件的进程,那么要打印大文件的进程会永远得不到服务,导致进程饥饿,进程会无限制的推后,虽然它没有阻塞。