Linux-存储栈的架构设计-全-

Linux 存储栈的架构设计(全)

原文:annas-archive.org/md5/e11aef0df23eeb9bfc961a4fab894ca8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

开源操作系统(如 Linux)的主要优势在于任何人都可以深入挖掘并揭示它们的内部工作原理。尽管软件开发领域取得了天文般的进步,但 Linux 内核仍然是最复杂的代码之一。开发者、程序员以及潜在的内核黑客们不断深入内核代码,并推动新功能的实现。像我这样的爱好者和技术爱好者也在尝试理解并解开其中的谜团。

作为其中的一名爱好者,我花了相当多的时间探索 Linux 存储堆栈的复杂性。从简单的硬盘驱动器到复杂的网络存储系统,Linux 是世界上许多最复杂存储技术的核心。过去几年中,我有机会参与 Linux 和多个存储技术的工作,这激发了我对这一领域的兴趣。本书便是这一探索的成果。我尝试揭开 Linux 存储堆栈的各个层次,展示它们是如何协同工作的。我的目标是与那些与我一样对这一话题充满兴趣的人分享我所学到的知识。

本书提供了 Linux 存储堆栈的深入概述,并且具有强烈的概念性内容。它涵盖了堆栈中的所有主要组件,并详细分析了存储子系统及其架构、虚拟文件系统层、不同的文件系统及其实现差异、块层、多队列和设备映射框架、调度以及物理层。它还涵盖了与存储性能分析、调优和故障排除相关的各种主题。

我相信任何希望扩展对 Linux 及其存储领域理解的人都会发现本书既有信息量又实用。

本书适合人群

本书的主要目标读者是 Linux 系统和存储管理员、工程师、Linux 专业人士、整个 Linux 社区以及任何希望扩大对 Linux 了解的人。在任何环境中工作时,深入理解你所使用的技术至关重要。我相信本书将帮助你更好地理解 Linux 内部工作,提供必要的知识,并增强你对这一主题的兴趣。

本书内容

第一章从哪里开始——虚拟文件系统,介绍了 Linux 内核中的虚拟文件系统VFS)。本章将解释 VFS 在 I/O 堆栈中的关键作用,并提供对 VFS 的深刻概念理解,因为它是 Linux 中 I/O 请求的起点。

第二章解释 VFS 中的数据结构,介绍了内核中 VFS 使用的各种数据结构。本章将解释内核如何使用结构体,如 inode、目录项和文件对象来存储文件元数据、目录和打开的文件。此外,还将介绍超级块结构如何使内核记录文件系统特性,最后将讲解内核中的页面缓存机制。

第三章探索 VFS 下的实际文件系统,介绍了 Linux 中的文件系统概念。本章将解释 Linux 中最流行的块存储文件系统——扩展文件系统。此外,还将详细讨论诸如日志记录和写时复制等重要的文件系统概念。本章还将探讨文件和块 I/O 之间的差异,并深入研究网络文件系统。最后,将介绍用户空间文件系统的概念。

第四章理解块层、块设备和数据结构,介绍了内核中的块层。本章将解释块设备的概念及其与字符设备的区别,并涵盖块层中的主要数据结构。

第五章理解块层、多队列和设备映射,介绍了内核中的设备映射框架和多队列块 I/O 排队机制。本章将解释多队列框架如何提高现代存储设备的 I/O 操作性能。此外,还将讲解设备映射在实现如 LVM 等功能时所起的基础性作用。

第六章理解块层中的 I/O 处理与调度,讨论了内核中不同的 I/O 处理机制和 I/O 调度程序。本章将讨论合并、合并写入和插入等操作在块层中的处理方式。还将详细解释 Linux 内核支持的不同 I/O 调度程序,以及它们在操作逻辑上的差异。

第七章SCSI 子系统,重点介绍了 Linux 中的 SCSI 子系统及其多层架构。本章将解释多层 SCSI 架构、SCSI 设备寻址机制以及 SCSI 层中的主要数据结构。

第八章物理介质布局的说明,讨论了当前可用的不同存储介质及其架构差异。传统的机械硬盘、固态硬盘和新的 NVMe 接口进行了比较。

第九章分析物理存储性能,涵盖了存储子系统的性能分析和特性。本章将介绍可用于评估物理存储性能的不同指标。接下来,将讨论可用于衡量存储性能的不同工具和机制。

第十章分析文件系统和块层,重点介绍了用于分析块层和文件系统性能的技术。本章将解释不同类型的文件系统 I/O 以及可能影响应用程序 I/O 请求的因素。接下来,将介绍不同的工具和跟踪机制,例如伯克利数据包过滤器编译器集,用于识别每个层次的潜在瓶颈。

第十一章调整 I/O 堆栈,讨论了一些针对应用程序需求调整底层存储层的推荐实践。讨论了可用于在每个层次调整性能的不同选项。

为了最大化本书的学习效果

本书的主要目标是理解 Linux 内核及其主要子系统的内部工作原理。因此,为了最大限度地从本书中获益,您需要对操作系统概念(特别是 Linux)有一定的了解。最重要的是,要以耐心、好奇心和学习的态度来接触这些话题。

书中涉及的软件/硬件 操作系统要求
Linux

安装特定软件包的命令包含在每章的技术要求部分。

下载示例代码文件

我们有来自我们丰富书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的截图和图表的彩色图片。您可以在这里下载:packt.link/Uz9ge

使用的约定

本书中使用了许多文本约定。

文中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。示例:“如果我们查看 /dev 中的 sd* 设备,注意到文件类型显示为 b,表示块设备。”

代码块如下所示:

struct block_device {
        sector_t                bd_start_sect;
        sector_t                bd_nr_sectors;
        struct disk_stats __percpu *bd_stats;
        unsigned long           bd_stamp;
        bool                    bd_read_only;
        dev_t                   bd_dev;
        atomic_t                bd_openers;
        struct inode *          bd_inode;
[……..]

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

struct block_device {
        sector_t                bd_start_sect;
        sector_t                bd_nr_sectors;
        struct disk_stats __percpu *bd_stats;
        unsigned long           bd_stamp;
        bool                    bd_read_only;
        dev_t                   bd_dev;
        atomic_t                bd_openers;
        struct inode *          bd_inode;
[……..]

任何命令行输入或输出均如下所示:

[root@linuxbox ~]# find / -inum 67118958 -exec ls -l {} \;
-rw-r--r-- 1 root root 220 Jun 15 22:30 /etc/hosts
[root@linuxbox ~]#

粗体:表示新术语、重要词汇或屏幕上显示的文字。例如,菜单或对话框中的文字通常为粗体。例如:“在目录的情况下,inode 中的type字段是一个目录。”

小贴士或重要提示

以这样的形式呈现。

联系我们

我们随时欢迎读者的反馈。

customercare@packtpub.com 并在邮件主题中注明书名。

勘误:尽管我们已尽力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,我们将非常感谢你报告给我们。请访问 www.packtpub.com/support/errata 并填写表格。

copyright@packt.com 并附上相关资料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣写书或为书籍做贡献,请访问 authors.packtpub.com

分享你的想法

阅读完《Linux 存储栈的架构与设计》后,我们非常希望听到你的想法!请点击此处直接前往亚马逊的本书评论页面并分享你的反馈。

你的评论对我们以及技术社区都非常重要,能帮助我们确保提供高质量的内容。

下载本书的免费 PDF 副本

感谢购买本书!

你是否喜欢随时随地阅读,但又无法随身携带纸质书籍?

你的电子书购买与所选设备不兼容吗?

别担心,现在每本 Packt 书籍,你都可以免费获得该书的无 DRM PDF 版本。

随时随地,任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用中。

优惠不止这些,你还可以每天收到独家折扣、新闻简报以及精彩的免费内容。

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问下面的链接

packt.link/free-ebook/9781837639960

  1. 提交你的购买凭证

  2. 就这样!我们会直接将你的免费 PDF 和其他福利发送到你的邮箱。

第一部分:深入虚拟文件系统

本部分详细介绍了虚拟文件系统VFS)层及其下面的实际文件系统。你将了解 VFS,它的主要数据结构,扩展文件系统家族,以及与 Linux 中不同文件系统相关的主要概念。

本部分包含以下章节:

  • 第一章一切从这里开始 – 虚拟文件系统

  • 第二章解释 VFS 中的数据结构

  • 第三章探索 VFS 下的实际文件系统

第一章:一切从哪里开始——虚拟文件系统

即使在软件开发取得了惊人的进展的今天,Linux 内核仍然是最复杂的代码之一。开发者、程序员和未来的内核黑客们不断试图深入内核代码并推动新特性,而爱好者和技术爱好者则尝试理解并解开这些谜团。

自然地,关于 Linux 及其内部工作原理,已经写了很多,从一般的管理到内核编程。在过去的几十年里,已经出版了数百本书,涵盖了许多重要的操作系统主题,如进程创建、线程、内存管理、虚拟化、文件系统实现和 CPU 调度。本书(感谢你选择它!)将专注于 Linux 中的存储堆栈及其多层次的组织。

我们将从介绍 Linux 内核中的虚拟文件系统以及它在允许最终用户程序访问文件系统数据方面的关键作用开始。由于本书的目的是覆盖整个存储堆栈,从上到下,深入理解虚拟文件系统非常重要,因为它是内核中 I/O 请求的起点。我们将介绍用户空间和内核空间的概念,了解系统调用,并看看 Linux 中的一切皆文件哲学是如何与虚拟文件系统相关联的。

本章我们将涵盖以下主要主题:

  • 理解现代数据中心中的存储

  • 定义系统调用

  • 解释虚拟文件系统的需求

  • 描述虚拟文件系统

  • 解释一切皆文件哲学

技术要求

在深入之前,我认为有必要在这里指出,某些技术主题对于初学者来说可能比其他主题更具挑战性。由于这里的目标是理解 Linux 内核及其主要子系统的内部工作原理,因此,拥有一定的操作系统基础知识,尤其是 Linux 的基础知识,将非常有帮助。最重要的是,要以耐心、好奇心和愿意学习的态度来接触这些话题。

本章中呈现的命令和示例与特定的发行版无关,可以在任何 Linux 操作系统上运行,例如 Debian、Ubuntu、Red Hat 和 Fedora。文中提到了一些内核源代码的参考。如果你想下载内核源代码,可以从www.kernel.org下载。本章相关的操作系统软件包可以按如下方式安装:

  • 对于 Ubuntu/Debian 系统:

    • sudo apt install strace

    • sudo apt install bcc

  • 对于 Fedora/CentOS/Red Hat 系统:

    • sudo yum install strace

    • sudo yum install bcc-tools

理解现代数据中心中的存储

在没有数据的情况下进行理论推测是一项严重的错误。人们往往会开始扭曲事实以适应理论,而不是让理论适应事实。——亚瑟·柯南·道尔爵士

计算、存储和网络是任何基础设施的基本构件。您的应用程序表现如何,通常取决于这三层的综合性能。现代数据中心中运行的工作负载从流媒体服务到机器学习应用都有。随着云计算平台的迅猛崛起和普及,所有这些基本构件现在都被从终端用户那里抽象出来。随着应用程序变得资源需求更大,向应用程序添加更多硬件资源已成为新常态。故障排除性能问题通常被跳过,转而将应用程序迁移到更好的硬件平台。

在计算、存储和网络这三个基础构件中,存储通常被认为是大多数场景中的瓶颈。对于像数据库这样的应用程序,底层存储的性能至关重要。在托管关键任务和时间敏感应用(例如在线事务处理OLTP))的基础设施中,存储性能经常受到关注。即便是微小的 I/O 请求延迟,也可能影响整个应用程序的响应。

测量存储性能的最常见指标是延迟。存储设备的响应时间通常以毫秒为单位进行测量。而与此相比,您的处理器或内存的测量通常以纳秒为单位,您就会发现存储层的性能如何影响系统的整体工作。这就导致了应用程序需求与底层存储实际能够提供的性能之间的不一致。在过去的几年里,现代存储硬盘的进展大多集中在大小方面——容量领域。然而,存储硬件的性能提升并没有按相同的速度发展。与计算功能相比,存储性能相形见绌。因此,它常被称为数据中心的三条腿狗

在讨论存储介质的选择时,需要指出的是,无论硬件多么强大,它总会有其功能上的限制。应用程序和操作系统根据硬件进行调优同样重要。对您的应用程序、操作系统和文件系统参数进行精细调优,可以显著提升整体性能。为了充分利用底层硬件的潜力,I/O 层次结构的所有层次都需要高效运作。

在 Linux 中与存储交互

Linux 内核明确区分了用户空间和内核空间进程。所有硬件资源,如 CPU、内存和存储,都位于内核空间。任何想要访问内核空间资源的用户空间应用程序,都必须生成一个 系统调用,如 图 1.1 所示:

图 1.1 – 用户空间与内核空间之间的交互

图 1.1 – 用户空间与内核空间之间的交互

用户空间 指的是所有位于内核之外的应用程序和进程。内核空间包括设备驱动程序等程序,这些程序可以不受限制地访问底层硬件。用户空间可以视为一种沙箱机制,用来限制最终用户程序修改关键的内核功能。

用户空间与内核空间的概念深深植根于现代处理器的设计中。传统的 x86 CPU 使用保护域的概念,称为 ,来共享和限制对硬件资源的访问。处理器提供四个环或模式,编号从 0 到 3。现代处理器设计只在其中两个模式下运行,即环 0 和环 3。用户空间应用程序运行在环 3 中,具有有限的内核资源访问权限。内核则占据环 0。内核代码在这里执行,并与底层硬件资源进行交互。

当进程需要读取或写入文件时,它们需要与物理磁盘上方的文件系统结构进行交互。每种文件系统使用不同的方法来组织磁盘上的数据。进程的请求不会直接到达文件系统或物理磁盘。为了让物理磁盘服务于进程的 I/O 请求,必须经过内核中整个存储层级。该层级中的第一层称为 虚拟文件系统。以下的图 图 1.2 突出了虚拟文件系统的主要组件:

图 1.2 – 内核中的虚拟文件系统 (VFS) 层

图 1.2 – 内核中的虚拟文件系统 (VFS) 层

Linux 中的存储栈由多个紧密相连的层组成,这些层共同确保通过统一的接口抽象了对物理存储介质的访问。在接下来的内容中,我们将基于这一结构进行扩展,增加更多的层次。我们将尽力深入探讨每一层,了解它们如何协同工作。

本章将专注于虚拟文件系统及其各种特性。在接下来的章节中,我们将解释并揭示 Linux 中更常用的文件系统的一些底层工作原理。然而,考虑到这里将多次提到 文件系统 这一词汇,我认为有必要简要地对不同的文件系统类型进行分类,以避免任何混淆:

  • 块级文件系统:块级或基于磁盘的文件系统是存储用户数据的最常见方式。作为普通操作系统用户,这些就是用户大多数交互的文件系统。像扩展文件系统版本 2/3/4Ext 2/3/4)、扩展文件系统XFS)、Btrfs、FAT 和 NTFS 等文件系统都被归类为基于磁盘或块级文件系统。这些文件系统以为单位。块大小是文件系统的一个属性,只有在创建设备上的文件系统时才能设置。块大小表示文件系统在读写数据时使用的大小。我们可以将其视为文件系统中存储分配和检索的逻辑单位。一个可以按块访问的设备因此被称为块设备。任何连接到计算机的存储设备,无论是硬盘还是外部 USB,都可以归类为块设备。传统上,块级文件系统是挂载在单个主机上的,不允许多个主机之间共享。

  • 集群文件系统:集群文件系统也是块级文件系统,使用基于块的访问方法来读写数据。不同之处在于,它们允许单个文件系统被多个主机同时挂载和使用。集群文件系统基于共享存储的概念,这意味着多个主机可以同时访问同一个块设备。Linux 中常用的集群文件系统包括 Red Hat 的全球文件系统 2GFS2)和Oracle 集群文件系统OCFS)。

  • 网络文件系统(NFS):NFS 是一种允许远程文件共享的协议。与常规的块级文件系统不同,NFS 基于多个主机之间共享数据的概念。NFS 的工作模式是客户端和服务器的概念。后端存储由 NFS 服务器提供。挂载 NFS 文件系统的主机系统称为客户端。客户端和服务器之间的连接是通过常规以太网实现的。所有 NFS 客户端共享 NFS 服务器上的单一文件副本。NFS 的性能不如块级文件系统,但它仍然广泛应用于企业环境,主要用于存储长期备份和共享常用数据。

  • /proc (procfs)/sys (sysfs)属于这一类别。这些目录包含虚拟或临时文件,其中包含有关不同内核子系统的信息。这些伪文件系统也是虚拟文件系统的一部分,正如我们将在万物皆文件章节中看到的那样。

现在我们对用户空间、内核空间和不同类型的文件系统有了基本的了解,接下来我们将解释应用程序如何通过系统调用在内核空间中请求资源。

理解系统调用

在查看说明应用程序与虚拟文件系统交互的图示时,你可能注意到了用户空间程序与虚拟文件系统之间的中介层;该层被称为系统调用接口。为了从内核请求某些服务,用户空间程序调用系统调用接口。这些系统调用为最终用户应用程序提供了访问内核空间资源(如处理器、内存和存储)的方式。系统调用接口有三个主要目的:

  • 确保安全性:系统调用防止用户空间的应用程序直接修改内核空间的资源。

  • 抽象性:应用程序不需要关注底层硬件规格

  • 可移植性:用户程序可以在所有实现相同接口集的内核上正确运行

关于系统调用和应用程序编程接口API)之间的区别,常常存在一些混淆。API 是程序使用的一组编程接口,这些接口定义了两个组件之间的通信方式。API 实现于用户空间,描述了如何获取特定的服务。而系统调用是一个更底层的机制,通过中断向内核发出显式请求。系统调用接口由 Linux 的标准 C 库提供。

如果调用进程生成的系统调用成功,将返回一个文件描述符。open ()系统调用会返回一个文件描述符给调用进程。一旦文件被打开,程序使用文件描述符对文件进行操作。所有的读取、写入以及其他操作都通过文件描述符来执行。

每个进程始终至少有三个打开的文件——标准输入、标准输出和标准错误,分别由文件描述符 0、1 和 2 表示。接下来的文件将分配文件描述符值为 3。如果我们通过ls进行文件列表并运行一个简单的straceopen系统调用将返回一个值为 3 的文件描述符,代表文件——在此案例中为/etc/hosts。之后,这个文件描述符值 3 将被fstatclose调用用于执行进一步操作:

strace ls /etc/hosts
root@linuxbox:~# strace ls /etc/hosts
execve("/bin/ls", ["ls", "/etc/hosts"], 0x7ffdee289b48 /* 22 vars */) = 0
brk(NULL)                               = 0x562b97fc6000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=140454, ...}) = 0
mmap(NULL, 140454, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fbaa2519000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3

[由于篇幅原因,省略其余代码。]

在 x86 系统上,大约有 330 个系统调用。对于其他架构,这个数字可能不同。每个系统调用都有一个唯一的整数编号。你可以通过ausyscall命令列出系统上可用的系统调用,这将列出系统调用及其对应的整数值:

ausyscall –dump
root@linuxbox:~# ausyscall --dump
Using x86_64 syscall table:
0       read
1       write
2       open
3       close
4       stat
5       fstat
6       lstat
7       poll
8       lseek
9       mmap
10      mprotect

[由于篇幅原因,省略其余代码。]

root@linuxbox:~# ausyscall --dump|wc -l
334
root@linuxbox:~#

以下表格列出了一些常见的系统调用:

系统调用 描述
open (), close () 打开和关闭文件
create () 创建文件
chroot () 更改root目录
mount (), umount () 挂载和卸载文件系统
lseek () 更改文件中的指针位置
read (), write () 读写文件
stat (), fstat () 获取文件状态
statfs (), fstatfs () 获取文件系统统计信息
execve () 执行路径名所指的程序
access () 检查调用进程是否能访问文件路径名
mmap () 在调用进程的虚拟地址空间中创建一个新的映射

表 1.1 – 一些常见的系统调用

那么,系统调用在与文件系统交互中扮演什么角色呢?正如我们将在后续章节中看到的,当用户空间进程生成系统调用来访问内核空间中的资源时,它首先与虚拟文件系统交互。该系统调用首先由内核中的相应系统调用处理程序处理,在验证请求的操作后,处理程序会调用 VFS 层中的适当函数。VFS 层将请求传递给适当的文件系统驱动模块,该模块执行文件上的实际操作。

我们需要理解这里的原因——为什么进程会与虚拟文件系统交互,而不是与磁盘上的实际文件系统交互?在接下来的章节中,我们将尝试弄清楚这个问题。

总结一下,Linux 中的系统调用接口实现了通用的方法,用户空间的应用程序可以通过这些方法访问内核空间中的资源。

解释虚拟文件系统的需求

标准文件系统是一组数据结构,决定了用户数据在磁盘上的组织方式。最终用户能够通过常规的文件访问方法与这个标准文件系统交互,并执行常见任务。每个操作系统(无论是 Linux 还是非 Linux)至少提供一个这样的文件系统,显然,它们每个都宣称自己比其他的更好、更快、更安全。绝大多数现代 Linux 发行版将 XFS 或 Ext4 作为默认文件系统。这些文件系统具有多个特性,并被认为在日常使用中稳定可靠。

然而,Linux 对文件系统的支持不仅仅限于这两种。使用 Linux 的一个重要好处是它支持多种文件系统,所有这些文件系统都可以被视为 Ext4 和 XFS 的完美替代方案。因此,Linux 可以与其他操作系统和谐共存。一些常用的文件系统包括 Ext4 的旧版本,如 Ext2 和 Ext3,Btrfs、ReiserFS、OpenZFS、FAT 和 NTFS。在使用多个分区时,用户可以从长长的文件系统列表中进行选择,根据需求在每个磁盘分区上创建不同的文件系统。

物理硬盘上最小的可寻址单位是扇区。对于文件系统来说,最小的可写单位称为块(block)。可以视为一组连续的扇区。文件系统的所有操作都是以块为单位进行的。不同文件系统在如何寻址和组织这些块上并没有统一的方法。每个文件系统可能会使用不同的数据结构来分配和存储这些块上的数据。每个存储分区上可能存在不同的文件系统,这会使管理变得复杂。考虑到 Linux 支持的文件系统种类繁多,试想一下,如果应用程序需要理解每种文件系统的具体细节,会是多么麻烦。为了与文件系统兼容,应用程序需要为每种文件系统实现独特的访问方法,这几乎使应用程序的设计变得不切实际。

抽象接口在 Linux 内核中起着至关重要的作用。在 Linux 中,无论使用哪种文件系统,最终用户或应用程序都可以通过统一的访问方法与文件系统进行交互。所有这些都是通过虚拟文件系统层(VFS)实现的,它通过一个包容性接口隐藏了文件系统的实现。

描述 VFS

为了确保应用程序在处理不同文件系统时不会遇到上述障碍,Linux 内核在最终用户应用程序和存储数据的文件系统之间实现了一层抽象。这一层被称为mkfs.vfs命令!因此,一些人更喜欢使用虚拟 文件系统切换这一术语。

想象一下纳尼亚传奇中的神奇衣橱。衣橱实际上是通往纳尼亚魔法世界的门户。一旦你穿过衣橱,你就可以探索新世界并与其中的居民互动。衣橱帮助你进入魔法世界。同样地,VFS 为不同的文件系统提供了一个通道。

VFS 定义了一个通用接口,使得多个文件系统能够在 Linux 中共存。值得再次强调的是,VFS 并非指标准的基于块的文件系统。我们在谈论的是一个抽象层,它提供了最终用户应用程序与实际块文件系统之间的联系。通过 VFS 中的标准化,应用程序可以执行读写操作,而无需担心底层的文件系统。

图 1.3所示,VFS 位于用户空间程序与实际文件系统之间:

图 1.3 – VFS 作为用户空间程序与文件系统之间的桥梁

图 1.3 – VFS 作为用户空间程序与文件系统之间的桥梁

为了使 VFS 能够为双方提供服务,以下条件必须成立:

  • 所有最终用户应用程序需要根据 VFS 提供的标准接口来定义它们的文件系统操作

  • 每个文件系统需要提供 VFS 提供的通用接口的实现。

我们已经解释过,用户空间中的应用程序在需要访问内核空间中的资源时,需要生成系统调用。通过 VFS 提供的抽象,像read()write()这样的系统调用可以正常工作,而不管使用的是哪种文件系统。这些系统调用能够跨文件系统边界工作。我们不需要特别的机制将数据移到不同或非本地文件系统。例如,我们可以轻松地将数据从 Ext4 文件系统移动到 XFS,反之亦然。从高层次来说,当进程发出read()write()系统调用以读取或写入文件时,VFS 会搜索要使用的文件系统驱动程序,并将这些系统调用转发给该驱动程序。

通过 VFS 实现通用文件系统接口

VFS 的主要目标是以最小的开销在内核中表示多种文件系统。当进程请求对文件进行读写操作时,内核将其替换为该文件所在文件系统的特定函数。为了实现这一点,每个文件系统必须根据 VFS 进行适配。

为了更好地理解,我们来看看以下示例。

以 Linux 中的cpcopy)命令为例。假设我们要将一个文件从 Ext4 文件系统复制到 XFS 文件系统。那么,这个复制操作是如何完成的?cp命令是如何与这两个文件系统进行交互的?请参见图 1.4

图 1.4 – VFS 确保不同文件系统之间的互操作性

图 1.4 – VFS 确保不同文件系统之间的互操作性

首先,cp命令并不关心所使用的文件系统。我们已经将 VFS 定义为实现抽象的层。因此,cp命令无需关心文件系统的细节。它将通过标准的系统调用接口与 VFS 层进行交互。具体来说,它将发出open()read()系统调用来打开和读取要复制的文件。一个已打开的文件在内核中由文件数据结构表示(正如我们将在下一章第二章中学习的那样,解释 VFS 中的数据结构)。

cp生成这些通用系统调用时,内核将通过指针将这些调用重定向到文件所在文件系统的适当函数。为了将文件复制到 XFS 文件系统,write()系统调用会传递给 VFS。然后,这个调用会再次被重定向到实现该功能的 XFS 文件系统的特定函数。通过向 VFS 发出的系统调用,cp进程可以使用 Ext4 的read()方法和 XFS 的write()方法执行复制操作。就像一个开关,VFS 将在它们指定的文件系统实现之间切换通用的文件访问方法。

内核中的读、写或任何其他功能没有默认定义——这也是虚拟一词的含义。对于这些操作的解释取决于底层文件系统。就像利用 VFS 提供的抽象的用户程序一样,文件系统也能从这种方法中受益。文件系统不需要重新实现文件的常见访问方法。

这还挺酷的,对吧?但是如果我们想从 Ext4 复制一些东西到一个非原生文件系统呢?像 Ext4、XFS 和 Btrfs 这样的文件系统是专门为 Linux 设计的。如果其中一个文件系统是 FAT 或 NTFS,情况会怎样呢?

不得不承认,VFS 的设计偏向于源自 Linux 系列的文件系统。对于最终用户来说,文件和目录之间有明确的区别。在 Linux 的哲学中,一切都是文件,包括目录。原生 Linux 文件系统,如 Ext4 和 XFS,都是在考虑到这些细微差别的情况下设计的。由于实现上的差异,非原生文件系统如 FAT 和 NTFS 并不支持所有 VFS 操作。Linux 中的 VFS 使用 inode、超级块和目录项等结构来表示文件系统的通用视图。非原生 Linux 文件系统并不使用这些结构。那么,Linux 是如何适配这些文件系统的呢?以 FAT 文件系统为例,FAT 文件系统来源于不同的世界,并未使用这些结构来表示文件和目录。它不会将目录视为文件。那么,VFS 是如何与 FAT 文件系统交互的呢?

内核中与文件系统相关的所有操作都与 VFS 数据结构紧密集成。为了支持 Linux 上的非原生文件系统,内核动态构建相应的数据结构。例如,为了满足 FAT 等文件系统的通用文件模型,将在内存中动态创建对应于目录的文件。这些文件虚拟的,只存在于内存中。这是一个需要理解的重要概念。在原生文件系统中,像 inode 和超级块这样的结构不仅存在于内存中,还存储在物理介质上。相反,非 Linux 文件系统只需要在内存中执行这些结构的实现。

查看源代码

如果我们查看内核源代码,可以看到 VFS 提供的不同函数都在 fs 目录下。所有以 .c 结尾的源文件都包含了不同 VFS 方法的实现。子目录包含特定文件系统的实现,如图 1.5所示:

图 1.5 – 内核 5.19.9 的源代码

图 1.5 – 内核 5.19.9 的源代码

你会注意到诸如 open.cread_write.c 这样的源文件,这些文件是在用户空间进程生成 open ()read ()write () 系统调用时被调用的。这些文件包含了大量的代码,鉴于我们在这里不会创建任何新代码,这仅仅是一次探索练习。不过,这些文件中确实有几段重要的代码,突显了我们之前所讲解的内容。让我们快速看一下 readwrite 函数。

SYSCALL_DEFINE3 宏是定义系统调用的标准方法,接收系统调用名称作为参数之一。

对于 write 系统调用,其定义如下。请注意,参数之一是文件描述符:

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
{
        return ksys_write(fd, buf, count);
}

类似地,这是 read 系统调用的定义:

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
        return ksys_read(fd, buf, count);
}

两者都调用了 ksys_write ()ksys_read () 函数。我们来看一下这两个函数的代码:

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
        struct fd f = fdget_pos(fd);
        ssize_t ret = -EBADF;
******* Skipped *******
                ret = vfs_read(f.file, buf, count, ppos);
******* Skipped *******
        return ret;
}
ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
{
        struct fd f = fdget_pos(fd);
        ssize_t ret = -EBADF;
******* Skipped *******
                ret = vfs_write(f.file, buf, count, ppos);
                ******* Skipped *******
        return ret;
}

vfs_read ()vfs_write () 函数的存在表明我们正在过渡到 VFS。这些函数查找底层文件系统的 file_operations 结构,并调用适当的 read ()write () 方法:

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
******* Skipped *******
        if (file->f_op->read)
                ret = file->f_op->read(file, buf, count, pos);
        else if (file->f_op->read_iter)
                ret = new_sync_read(file, buf, count, pos);
******* Skipped *******
return ret;
}
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
******* Skipped *******
        if (file->f_op->write)
                ret = file->f_op->write(file, buf, count, pos);
        else if (file->f_op->write_iter)
                ret = new_sync_write(file, buf, count, pos);
 ******* Skipped *******
       return ret;
}

每个文件系统定义了支持操作的 file_operations 结构体。内核源代码中有多个 file_operations 结构体的定义,每个文件系统都有独特的定义。该结构体中定义的操作描述了读写函数如何执行:

root@linuxbox:/linux-5.19.9/fs# grep -R "struct file_operations" * | wc -l
453
root@linuxbox:/linux-5.19.9/fs# grep -R "struct file_operations" *
9p/vfs_dir.c:const struct file_operations v9fs_dir_operations = {
9p/vfs_dir.c:const struct file_operations v9fs_dir_operations_dotl = {
9p/v9fs_vfs.h:extern const struct file_operations v9fs_file_operations;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_file_operations_dotl;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_dir_operations;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_dir_operations_dotl;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_cached_file_operations;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_cached_file_operations_dotl;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_mmap_file_operations;
9p/v9fs_vfs.h:extern const struct file_operations v9fs_mmap_file_operations_dotl;

[其余代码因简洁性省略。]

如你所见,file_operations 结构体适用于各种文件类型,包括常规文件、目录、设备文件和网络套接字。一般来说,任何可以使用标准文件 I/O 操作打开和操作的文件类型,都可以通过此结构体来处理。

跟踪 VFS 函数

Linux 提供了相当多的跟踪机制,可以让我们窥见其底层工作原理。其中之一是 funccount。顾名思义,funccount 用于计算函数调用的次数:

root@linuxbox:~# funccount --help
usage: funccount [-h] [-p PID] [-i INTERVAL] [-d DURATION] [-T] [-r] [-D]
                 [-c CPU]
                 pattern
Count functions, tracepoints, and USDT probes

为了测试和验证我们之前的理解,我们将在后台运行一个简单的复制进程,并使用 funccount 程序跟踪由于执行 cp 命令而调用的 VFS 函数。由于我们只需要计算 cp 进程的 VFS 调用次数,因此需要使用 -p 标志来指定进程 ID。vfs_* 参数将跟踪该进程的所有 VFS 函数。你会看到 vfs_read ()vfs_write () 函数是由 cp 进程调用的。COUNT 列显示该函数被调用的次数:

funccount -p process_ID 'vfs_*'
[root@linuxbox ~]# nohup cp myfile /tmp/myfile &
[1] 1228433
[root@linuxbox ~]# nohup: ignoring input and appending output to 'nohup.out'
[root@linuxbox ~]#
[root@linuxbox ~]# funccount -p 1228433 "vfs_*"
Tracing 66 functions for "b'vfs_*'"... Hit Ctrl-C to end.
^C
FUNC                                    COUNT
b'vfs_read'                             28015
b'vfs_write'                            28510
Detaching...
[root@linuxbox ~]#

我们再运行一次,看看在执行简单的复制操作时使用了哪些系统调用。正如预期的那样,在执行 cp 时最常用的系统调用是 readwrite

funccount 't:syscalls:sys_enter_*' -p process_ID
[root@linuxbox ~]# nohup cp myfile /tmp/myfile &
[1] 1228433
[root@linuxbox ~]# nohup: ignoring input and appending output to 'nohup.out'
[root@linuxbox ~]#
[root@linuxbox ~]# /usr/share/bcc/tools/funccount -p 1228433 "vfs_*"
Tracing 66 functions for "b'vfs_*'"... Hit Ctrl-C to end.
^C
FUNC                                    COUNT
b'vfs_read'                             28015
b'vfs_write'                            28510
Detaching...
[root@linuxbox ~]#

让我们总结一下本节内容。Linux 支持多种文件系统,内核中的 VFS 层确保了这一目标能够轻松实现。VFS 为终端用户进程与不同的文件系统交互提供了标准化的方法。这一标准化是通过实现通用文件模式来实现的。VFS 定义了多个虚拟函数来执行常见的文件操作。由于这种方法,应用程序可以普遍地执行常规文件操作。当一个进程发出系统调用时,VFS 会将这些调用重定向到相应的文件系统函数。

解释“一切皆文件”哲学

在 Linux 中,以下所有内容都被视为文件:

  • 目录

  • 磁盘驱动器及其分区

  • 套接字

  • 管道

  • 光盘

一切皆文件这一说法意味着,Linux 中的所有上述实体都通过文件描述符表示,并通过 VFS 进行了抽象。你也可以说一切都有文件描述符,不过我们不在此展开这个辩论。

特征化 Linux 系统架构的一切皆文件理念也得益于 VFS 的实现。之前我们定义了伪文件系统,它们是动态生成内容的文件系统。这些文件系统也被称为 VFS,并在实现这一理念中发挥了重要作用。

你可以通过procfs伪文件系统获取当前已注册到内核的文件系统列表。在查看这个列表时,注意第一列中有些文件系统会标记为nodevnodev表示这是一个伪文件系统,不依赖于块设备。像 Ext2、3、4 这样的文件系统是基于块设备创建的,因此它们在第一列中没有nodev标记:

cat /proc/filesystems
[root@linuxbox ~]# cat /proc/filesystems
nodev   sysfs
nodev   tmpfs
nodev   bdev
nodev   proc
nodev   cgroup
nodev   cgroup2
nodev   cpuset
nodev   devtmpfs
nodev   configfs
nodev   debugfs
nodev   tracefs
nodev   securityfs
nodev   sockfs
nodev   bpf
nodev   pipefs
nodev   ramfs

[其余代码省略以简化内容。]

你也可以使用mount命令来查看当前系统中已挂载的伪文件系统:

mount | grep -v sd | grep -ivE ":/|mapper"
[root@linuxbox ~]# mount | grep -v sd | grep -ivE ":/|mapper"
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
devtmpfs on /dev type devtmpfs (rw,nosuid,size=1993552k,nr_inodes=498388,mode=755)
securityfs on /sys/kernel/security type securityfs (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
tmpfs on /run type tmpfs (rw,nosuid,nodev,mode=755)
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
pstore on /sys/fs/pstore type pstore (rw,nosuid,nodev,noexec,relatime)
efivarfs on /sys/firmware/efi/efivars type efivarfs (rw,nosuid,nodev,noexec,relatime)

[其余代码省略以简化内容。]

让我们来浏览一下/proc目录。你会看到一长串编号的目录;这些数字代表了系统中当前运行的所有进程的 ID:

[root@linuxbox ~]# ls /proc/
1    1116   1228072  1235  1534  196  216  30   54   6  631  668  810      ioports     scsi
10   1121   1228220  1243  1535  197  217  32   55   600  632  670  9      irq         self
1038  1125  1228371  1264  1536  198  218  345  56   602  
633  673  905      kallsyms     slabinfo
1039  1127  1228376  13    1537  199  219  347  570  603  
634  675  91       kcore      softirqs
1040  1197  1228378  14    1538  2    22   348  573  605  635  677  947      keys       stat
1041  12    1228379  1442  16    20   220  37   574  607  
636  679  acpi     key-users  swaps
1042  1205  1228385  1443  1604  200  221  38   576  609  
637  681  buddyinfo  kmsg       sys
1043  1213  1228386  1444  1611  201  222  39   577  610  638  684  bus      kpagecgroup  sysrq-

[其余代码省略以简化内容。]

procfs文件系统让我们得以窥见内核的运行状态。/proc中的内容是在我们查看时生成的。这些信息并不是持久保存在你的磁盘上的,所有这一切都发生在内存中。正如你从ls命令中看到的那样,/proc在磁盘上的大小为零字节:

[root@linuxbox ~]# ls -ld /proc/
dr-xr-xr-x 292 root root 0 Sep 20 00:41 /proc/
[root@linuxbox ~]#

/proc提供了一个实时视图,展示了系统中正在运行的进程。考虑一下/proc/cpuinfo文件。这个文件显示了与你系统相关的处理器信息。如果我们检查这个文件,它会显示为empty

[root@linuxbox ~]# ls -l /proc/cpuinfo
-r--r--r-- 1 root root 0 Nov  5 02:02 /proc/cpuinfo
[root@linuxbox ~]#
[root@linuxbox ~]# file /proc/cpuinfo
/proc/cpuinfo: empty
[root@linuxbox ~]#

然而,当通过cat查看文件内容时,会显示大量信息:

[root@linuxbox ~]# cat /proc/cpuinfo
processor       : 0
vendor_id       : GenuineIntel
cpu family      : 6
model           : 79
model name      : Intel(R) Xeon(R) CPU E5-2683 v4 @ 2.10GHz
stepping        : 1
microcode       : 0xb00003e
cpu MHz         : 2099.998
cache size      : 40960 KB
physical id     : 0
siblings        : 1
core id         : 0
cpu cores       : 1
apicid          : 0
initial apicid  : 0
fpu             : yes
fpu_exception   : yes
cpuid level     : 20
wp              : yes

[其余代码省略以简化内容。]

Linux 将所有实体(如进程、目录、网络套接字和存储设备)抽象为 VFS(虚拟文件系统)。通过 VFS,我们可以从内核中获取信息。大多数 Linux 发行版提供多种工具来监控存储、计算和内存资源的消耗。所有这些工具通过procfs中的数据收集各种指标的统计信息。例如,mpstat命令提供关于系统中所有处理器的统计信息,它从/proc/stat文件中获取数据,然后以人类可读的格式呈现这些数据,便于理解:

[root@linuxbox ~]# cat /proc/stat
cpu  5441359 345061 4902126 1576734730 46546 1375926 942018 0 0 0
cpu0 1276258 81287 1176897 394542528 13159 255659 280236 0 0 0
cpu1 1455759 126524 1299970 394192241 13392 314865 178446 0 0 0
cpu2 1445048 126489 1319450 394145153 12496 318550 186289 0 0 0
cpu3 1264293 10760 1105807 393854806 7498 486850 297045 0 0 0

[为了简洁,省略了其余的代码。]

如果我们在mpstat命令上使用strace工具,它将显示在后台,mpstat使用/proc/stat文件来显示处理器统计信息:

strace mpstat 2>&1 |grep "/proc/stat"
[root@linuxbox ~]# strace mpstat 2>&1 |grep "/proc/stat"
openat(AT_FDCWD, "/proc/stat", O_RDONLY) = 3
[root@linuxbox ~]#

类似地,常用命令如toppsfree会从/proc/meminfo文件中收集与内存相关的信息:

[root@linuxbox ~]# strace free -h 2>&1 |grep meminfo
openat(AT_FDCWD, "/proc/meminfo", O_RDONLY) = 3
[root@linuxbox ~]#

类似于/proc,另一个常用的伪文件系统是/syssysfs 文件系统主要包含关于系统硬件设备的信息。例如,要查找系统中磁盘驱动器的信息,如其型号,可以执行以下命令:

cat /sys/block/sda/device/model
[root@linuxbox ~]# cat /sys/block/sda/device/model
SAMSUNG MZMTE512
[root@linuxbox ~]#

即使是键盘上的 LED 灯也有一个对应的文件在/sys中:

[root@linuxbox ~]# ls /sys/class/leds
ath9k-phy0  input4::capslock  input4::numlock  input4::scrolllock
[root@linuxbox ~]#

一切皆文件的哲学是 Linux 内核的一个定义性特征。它意味着系统中的一切,包括常规文本文件、目录和设备,都可以通过内核中的 VFS 层进行抽象。因此,所有这些实体都可以通过 VFS 层表示为类似文件的对象。Linux 中有几个伪文件系统,包含有关不同内核子系统的信息。这些伪文件系统的内容仅存在于内存中,并动态生成。

总结

Linux 存储栈是一个复杂的设计,由多个层次组成,所有这些层次都协同工作。像其他硬件资源一样,存储位于内核空间。当用户空间程序想要访问这些资源时,它必须调用系统调用。Linux 中的系统调用接口允许用户空间程序访问内核空间中的资源。当用户空间程序想要访问磁盘上的内容时,它首先与 VFS 子系统交互。VFS 提供文件系统相关接口的抽象,并负责在内核中容纳多个文件系统。通过其通用文件系统接口,VFS 拦截来自用户空间程序的通用系统调用(如read()write()),并将它们重定向到文件系统层中的适当接口。由于这种方式,用户空间程序无需关心底层使用的文件系统,它们可以统一地执行文件系统操作。

本章作为对主要 Linux 内核子系统虚拟文件系统(VFS)的介绍,讲解了其在 Linux 内核中的主要功能。VFS 通过数据结构,如索引节点(inode)、超级块(superblock)和目录项(directory entries),为所有文件系统提供了一个统一的接口。在下一章,我们将深入探讨这些数据结构,并解释它们如何帮助 VFS 管理多个文件系统。

第二章:解释 VFS 中的数据结构

在本书的第一章中,我们详细介绍了虚拟文件系统VFS),它最常见的功能、为何它是必要的以及它如何在实现 Linux 中的一切皆文件概念中发挥关键作用。我们还解释了 Linux 中的系统调用接口,以及用户空间应用程序如何使用通用系统调用与 VFS 进行交互。VFS 位于用户空间程序和实际文件系统之间,实施了一个通用的文件模型,使得应用程序能够使用统一的访问方式来执行操作,无论使用的是哪种文件系统。

在讨论不同的文件系统时,我们提到过 VFS 使用诸如 inode、超级块和目录项等结构来表示文件系统的通用视图。这些结构至关重要,因为它们确保了文件的元数据与实际数据之间的清晰区分。

本章将介绍内核 VFS 中的不同数据结构。你将了解内核如何使用诸如 inode 和目录项等结构来存储文件的元数据。你还将学习内核如何通过超级块结构记录文件系统的特性。最后,我们将解释 VFS 中的缓存机制。

我们将涵盖以下主要内容:

  • inode

  • 超级块

  • 目录项

  • 文件对象

  • 页面缓存

技术要求

拥有一定的 Linux 操作系统概念的理解会非常有帮助。这包括对文件系统、进程和内存管理的知识。本书不会创建任何新代码,但如果你想更深入地探索 Linux 内核,理解 C 编程概念对于理解 VFS 数据结构至关重要。作为一般规则,你应该养成查阅官方内核文档的习惯,因为它能提供有关内核内部工作机制的深入信息。

本章中介绍的命令和示例与发行版无关,可以在任何 Linux 操作系统上运行,例如 Debian、Ubuntu、Red Hat、Fedora 等等。文中有一些涉及内核源代码的参考。如果你想下载内核源代码,可以从www.kernel.org下载。本章和本书中提到的代码段来自内核 5.19.9

VFS 中的数据结构

VFS 使用多种数据结构来实现所有文件系统的通用抽象方法,并为用户空间程序提供文件系统接口。这些结构确保了文件系统设计和操作的某种共通性。要记住的一个重要点是,VFS 定义的所有方法并不一定适用于所有文件系统。是的,文件系统应遵循 VFS 中定义的结构,并在此基础上建立以确保它们之间的共通性。但是,在某些情况下,这些结构中可能有许多方法和字段对于特定文件系统是不适用的。在这种情况下,文件系统会根据其设计坚持使用相关字段,并且放弃多余的信息。因为我们将要解释常见的 VFS 数据结构,所以有必要查看内核中的相关代码片段以便澄清一些问题。尽管如此,我已尽力以一种通用的方式呈现材料,以便大多数概念即使没有开发对代码的理解也能被理解。

古希腊人相信四大元素构成了一切:土、水、空气和火。同样,以下结构构成了 VFS 的大部分内容:

  • 索引节点

  • 目录条目

  • 文件对象

  • 超级块

Inodes – 索引文件和目录

在磁盘上存储数据时,Linux 遵循一个严格的规则:封装之外的所有信息必须与封装内部的内容分开存放。换句话说,描述文件的数据与文件中实际数据是隔离的。保存这些元数据的结构称为索引节点,简称inode。inode 结构包含 Linux 中文件和目录的元数据。文件或目录的名称仅是指向 inode 的指针,并且每个文件或目录恰好有一个 inode。

把“魔法地图”作为一个类比来考虑(哈利·波特,有人认为?)。地图显示了学校每个人的位置。每个人在地图上由一个点表示,当你点击这个点时,它会显示关于这个人的信息,如他们的姓名、位置和状态。把“魔法地图”想象成文件系统,把点代表的人想象成显示元数据的 inode。

但是,文件的元数据包括什么呢?当你通过ls命令简单列出文件时,你会看到许多信息,例如文件权限、所有权、时间戳等。所有这些细节构成了文件的元数据,因为它们描述了文件的某些属性,而不是其实际内容。

通过简单的ls命令可以检查一些文件元数据。尽管显示文件元数据的略微更好的命令是stat,因为它提供了关于文件属性的更多信息。例如,它显示访问、修改、更改时间戳,文件所在设备,驱动器上为文件保留的块数,以及文件的索引节点号。

如果要获取关于文件元数据的详细信息,如 /etc/hosts,我们可以使用以下 stat 命令:

stat /etc/hosts

注意 /etc/hosts 的索引节点号(67118958)在 stat 命令的输出中:

[root@linuxbox ~]# stat /etc/hosts
  File: /etc/hosts
  Size: 220             Blocks: 8          IO Block: 4096   regular file
Device: fd00h/64768d    Inode: 67118958    Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2022-11-20 04:00:38.054988422 -0500
Modify: 2022-06-15 22:30:32.755324938 -0400
Change: 2022-06-15 22:30:32.755324938 -0400
Birth: 2022-06-15 22:30:32.755324938 -0400
[root@linuxbox ~]#

文件的索引节点号(inode number)作为文件的唯一标识符。例如,find 命令提供 inum 参数来通过索引节点号搜索文件:

find / -inum 67118958 -exec ls -l {} \;

如果我们通过 stat 命令检索到索引节点号,可以检索到相应的文件:

[root@linuxbox ~]# find / -inum 67118958 -exec ls -l {} \;
-rw-r--r-- 1 root root 220 Jun 15 22:30 /etc/hosts
[root@linuxbox ~]#

索引节点号仅在文件系统边界内唯一。如果系统上的目录(如 /home/tmp)位于不同的磁盘分区和文件系统上,则同一索引节点号可能分配给每个文件系统中的不同文件:

[root@linuxbox ~]# ls -li /home/pokemon/pikachu 
134460858 -rw-r--r-- 1 root root 1472 Oct 11 05:10 /home/pokemon/pikachu
[root@linuxbox ~]# 
[root@linuxbox ~]# ls -li /tmp/bulbasaur
134460858 -rw-r--r-- 1 root root 259 Nov 20 04:36 /tmp/bulbasaur
[root@linuxbox ~]#

索引节点号在文件系统边界内的唯一性与 链接 的概念相关联。由于同一索引节点号可能由不同的文件系统使用,硬链接不跨文件系统边界。

在内核中定义索引节点

在内核源代码中,索引节点的定义位于 linux/fs.h。此定义中有无数个字段。请注意,此 struct inode 的定义是通用且包罗万象的。索引节点是特定于文件系统的属性。文件系统不必在其索引节点定义中定义所有这些字段。索引节点结构的定义相当长,因此我们将限制到一些基本字段:

struct inode {
        umode_t                 i_mode;
        unsigned short          i_opflags;
        kuid_t                  i_uid;
        kgid_t                  i_gid;
        unsigned int            i_flags;
        const struct inode_operations   *i_op;
        struct super_block      *i_sb;
[………………………...]

以下是一些定义:

  • i_mapping:这是一个指针,指向保存索引节点数据块映射的地址空间结构。当创建索引节点或从磁盘读取时,文件系统会初始化此字段。例如,当进程向文件写入数据时,内核使用此字段将适当的内存页面映射到文件的数据块中。(数据块在下一节中有解释。)

  • i_uidi_gid:分别是用户和组的所有者。

  • i_flags:这定义了特定于文件系统的标志。

  • i_acl:这是用于文件系统访问控制列表的字段。

  • i_op:这指向索引节点操作结构,定义了可以对索引节点执行的所有操作,如创建、读取、写入和修改文件属性。

  • i_sb:这指向包含索引节点所在的底层文件系统的超级块结构。(有一个单独的主题来解释超级块结构。)

  • i_rdev:此字段存储某些特殊文件的设备号。例如,内核为系统中的硬盘和其他设备创建特殊文件。创建特殊文件时,内核为其分配唯一的设备号,创建设备的索引节点,并将此字段设置为指向设备的标识符。

  • i_atimei_mtimei_ctime:分别是访问时间、修改时间和更改时间戳。

  • i_bytes:文件中的字节数。

  • i_blkbits:此字段存储表示 inode 所属文件系统块大小所需的位数。

  • i_blocks:此字段存储 inode 所表示的文件使用的磁盘块的总数。

  • i_fop:这是指向与 inode 关联的文件操作结构的指针。例如,当进程打开文件时,内核使用这个字段获取该文件的文件操作结构的指针。然后它可以使用文件操作结构中定义的函数来对文件进行操作,比如读取或写入。

  • i_count:用于跟踪指向 inode 的活动引用次数。每当一个新进程访问一个文件时,这个计数器就会为该文件递增。如果这个字段的值为零,意味着没有更多的引用指向该 inode,此时可以安全地释放它。

  • i_nlink:此字段引用指向该 inode 的硬链接数量。

  • i_io_list:这是一个用于跟踪有待处理 I/O 请求的 inode 的列表。当内核将 I/O 请求添加到某个 inode 的队列时,该 inode 会被添加到这个列表中。当 I/O 请求完成后,inode 会从该列表中移除。

在 inode 结构的定义中大约有 50 个字段,因此我们这里只是略微触及了表面。但这应该可以让我们了解到,inode 定义的内容远不止文件的表层信息。如果你感到困惑,不用担心,我们会详细解释 inode。对于 inode 结构,有两种操作类型,它们分别由file_operationsinode_operations结构定义。稍后我们会在文件对象 – 代表已打开文件部分中稍微介绍一下file_operations结构。

定义 inode 操作

inode 操作结构包含一组函数指针,定义了文件系统如何与 inode 交互。每个文件系统都有自己的 inode 操作结构,在文件系统挂载时会注册到虚拟文件系统(VFS)。

inode_operations结构由i_op指针引用。还记得我们在第一章中解释的一切皆文件概念吗?一切皆文件,虽然是不同类型的文件,因此每个文件都会分配一个 inode。磁盘驱动器、磁盘分区、常规文本文件、文档、管道和套接字都有一个 inode。每个目录也有一个 inode。但所有这些文件的性质不同,并且在你的系统中代表不同的实体。例如,适用于目录的 inode 操作与常规文本文件不同。inode_operations结构提供了每种类型文件需要实现的所有函数,用于管理 inode 数据。

每个 inode 都与inode_operations结构的一个实例相关联,该结构提供了一组可以对 inode 执行的操作。该结构包含指向不同函数的指针,这些函数用于操作 inode:

struct inode_operations {
        struct dentry * (*lookup) (struct inode *,struct dentry *, unsigned int);
        const char * (*get_link) (struct dentry *, struct inode *, struct delayed_call *);
        int (*permission) (struct user_namespace *, struct inode *, int);
        struct posix_acl * (*get_acl)(struct inode *, int, bool);
        int (*readlink) (struct dentry *, char __user *,int);
        int (*create) (struct user_namespace *, struct inode *,struct dentry *, umode_t, bool);
        int (*link) (struct dentry *,struct inode *,struct dentry *);
        int (*unlink) (struct inode *,struct dentry *);
        int (*symlink) (struct user_namespace *, struct inode *,struct dentry *,
[……………………………………….]

一些可以通过该结构执行的重要操作在这里描述:

  • lookup:用于在目录中查找 inode 条目。它接受一个目录 inode 和文件名作为参数,返回一个指向与文件名对应的 inode 的指针。

  • create:当创建新文件或目录时调用此函数,它负责使用适当的元数据(如所有权和权限)初始化 inode。这用于响应open ()系统调用时构建 inode 对象。

  • get_link:用于操作符号链接。符号链接指向另一个 inode。

  • permission:当文件需要访问时,VFS 调用此函数来检查文件的访问权限。

  • link:这是响应link ()系统调用时调用的,它创建一个新的硬链接。它增加了 inode 的链接计数,并更新了其元数据。

  • symlink:这是响应symlink ()系统调用时调用的,它创建一个新的软链接。

  • unlink:这是响应unlink ()系统调用时调用的,用于删除文件链接。它减少 inode 的链接计数,并在链接计数为零时从磁盘中删除 inode。

  • mkdirrmdir:这些是响应mkdir ()rmdir ()系统调用时分别用于创建和删除目录的函数。

通过 inode 跟踪文件在磁盘上的数据。

由于系统中的每个文件都会有一些元数据,它将始终与一个 inode 相关联。由于每个 inode 都存储了一些信息,文件系统需要为它们预留一些空间,通常只有几个字节。例如,Ext4文件系统默认为单个 inode 使用 256 字节。文件系统维护一个inode 表来跟踪已用和空闲的 inode。

inode 结构中存在的字段提供了关于文件的以下两类信息:

  • 文件属性:有关文件所有权、权限、时间戳、链接和使用的块数的详细信息。

  • 数据块:指向磁盘上的数据块,实际文件内容存储的位置。

除了文件权限、所有权和时间戳外,inode 提供的另一个重要信息是实际数据在磁盘上的位置。文件的大小决定了它可能跨越多个磁盘块。inode 结构使用指针来追踪这些信息。为什么需要这样做呢?因为没有保证文件中的数据会以顺序或连续的方式存储和访问。inode 使用的指针通常为 4 字节大小,可以分为直接指针和间接指针。对于较小的文件,inode 包含指向文件数据块的直接指针。每个直接指针指向存储文件数据的磁盘地址。

使用直接指针来引用磁盘地址注定会有一个主要的局限性。问题是:多少个直接指针足够?文件大小可以从几个字节到几太字节不等。在结构中使用 15 个直接指针意味着,对于 4 KB 的块大小,我们最多只能指向 60 KB 的数据。当然,这在任何维度上都行不通,因为即使是小的文本文件也通常大于 60 KB。这在图 2.1中得到了展示:

图 2.1 – 使用直接指针时的限制:对于 4 KB 的块大小,仅能寻址 60 KB 的数据

图 2.1 – 使用直接指针时的限制:对于 4 KB 的块大小,仅能寻址 60 KB 的数据

为了解决这个问题,使用了间接指针。一个 inode 结构包含 12 个直接指针和 3 个间接指针。与直接指针不同,间接指针是指向指针块的指针。当所有直接指针用尽时,文件系统使用数据块来存储额外的指针。inode 中的倒霉的第 13 个或者单间接指针指向这个数据块。这个块中的指针指向实际包含文件数据的数据块。当文件大小无法通过单间接指针寻址时,就会使用双间接指针。双间接指针指向一个包含指向间接块的指针的块,每个间接块包含指向磁盘地址的指针。类似地,当文件的大小超出双间接指针的限制时——是的,你猜对了——三重间接指针被使用。

到这个时候,你可能已经头晕眼花,觉得根本没有意义(指针)。不用说,这整个层次结构相当复杂。一些现代文件系统利用了一种叫做范围(extents)的概念来存储更大的文件。我们将在讨论块文件系统时详细讲解这个概念,具体内容会在第三章探索 VFS 下的实际文件系统中介绍。

现在,让我们简化这一点,并为自己指明正确的方向。我们将利用一些基础数学来解释间接指针如何帮助存储更大的文件。我们将考虑 4 KB 的块大小,因为这是大多数文件系统的默认设置:

  • 一个 inode 中的总指针数量 = 15

  • 直接指针的数量 = 12

  • 间接指针的数量 = 1

  • 双重间接指针的数量 = 1

  • 三重间接指针的数量 = 1

  • 每个指针的大小(直接或间接)= 4 字节

  • 每个块的指针数量 = (块大小)/(指针大小)= (4 KB / 4)= 1,024 个指针

  • 使用直接指针可以引用的最大文件大小 = 12 x 4 KB = 48 KB

  • 使用 12 个直接指针和 1 个间接指针可以引用的最大文件大小 = [(12 x 4 KB) + (1,024 x 4 KB)] ≈ 4 MB

  • 使用 12 个直接指针、1 个间接指针和 1 个双级间接指针可以引用的最大文件大小 = [(12 x 4 KB) + (1,024 x 4 KB) + (1,024 x 1,024 x 4 KB)] ≈ 4 GB

  • 使用 12 个直接指针、1 个单级间接指针、1 个双级间接指针和 1 个三级间接指针可以引用的最大文件大小 = (12 x 4 KB)+ (1,024 x 4 KB)+ (1,024 x 1,024 x 4 KB)+ (1 x 1,024 x 1,024 x 1,024 x 4 KB)≈ 4 TB

以下图示展示了如何通过使用间接指针来帮助处理更大的文件:

图 2.2 – inode 结构的可视化表示

图 2.2 – inode 结构的可视化表示

图 2.2所示,文件系统可能会为较小的文件使用单级间接块,然后对于较大的文件切换到双级间接块。使用间接 inode 指针有多个优点。首先,它消除了为了容纳大文件而需要连续存储分配的需求,从而使文件系统能够有效地处理这些文件。其次,它有助于高效的空间利用,因为可以根据需要为文件分配块,而不是预先保留大量空间。每个 inode 通常有 12 个直接块指针,1 个单级间接块指针,1 个双级间接块指针和 1 个三级间接块指针。

文件系统会耗尽 inode 吗?

在管理存储时,保持空间可用是一个主要关注点。磁盘空间耗尽是常见的情况。每个文件和目录都分配了一个 inode,但如果所有的 inode 都已分配了怎么办?这不太可能发生,因为文件系统通常有数百万个可用的 inode。但是,是的,文件系统确实有可能耗尽 inode。如果发生这种情况,磁盘上可用的空间将无济于事,因为文件系统将无法创建任何新文件。文件系统创建后,inode 的数量无法扩展,因此备份将是唯一的选择。你可以使用 df 命令检查已挂载文件系统的 inode 使用情况:

df -Thi

Figure 2**.3图示了这种情况。挂载在/ford上的文件系统有接近 40%的剩余空间,但由于已经耗尽了 630 万个索引节点,无法创建任何新文件。

图 2.3 – 文件系统可能耗尽索引节点

图 2.3 – 文件系统可能耗尽索引节点

让我们用几个关键的索引节点指针来总结我们的讨论:

  • 除了元数据外,索引节点还保存文件在物理磁盘上存储位置的信息。为了追踪文件的物理位置,索引节点使用多个直接和间接指针。

  • 索引节点存储在磁盘上的文件系统结构中的索引节点表中。当需要打开一个文件时,相应的索引节点被加载到内存中。

  • 对于仅在内存中生成其内容的文件系统(如procfssysfs),它们的索引节点仅存在于内存中。

  • 虽然索引节点存储了关于文件的大量元数据,但它们不存储文件的名称。因此,索引节点结构中不包括文件内容和文件名。

  • 索引节点使用 15 个指针来追踪文件在磁盘上的数据块。前 12 个指针是直接指针,只能寻址最大 48 KB 的文件大小。剩余的三个指针提供单一、双重和三重间接。通过使用这些间接指针,可以寻址大文件。

  • 如果文件系统的索引节点用完,就无法在其上创建新文件。这种情况非常罕见,因为文件系统通常拥有数量庞大的索引节点,数量可达数百万。

目录条目 – 将索引节点映射到文件名

目录充当用户文件的目录或容器。适用于目录的操作与常规文件不同。有不同的命令用于处理目录。文件始终位于一个目录中,并且要访问该文件,需要以目录的绝对或相对路径指定。但是像 Linux 中的大多数事物一样,目录也被视为文件。那么这一切是如何工作的呢?

本地 Linux 文件系统将目录视为文件并像文件一样存储它们。像所有常规文件一样,目录也分配了一个索引节点。目录索引节点和文件之间有一个区别。在目录的情况下,索引节点中的类型字段是目录。请记住,从我们关于索引节点的讨论中得知,索引节点包含了关于文件的大量元数据,但它不包含文件名。文件名存在于目录中。可以将目录视为包含表格的特殊文件。该表格包含文件名及其相应的索引节点号。

当尝试访问文件时,进程必须遍历分层目录结构。该结构中的每个级别定义了绝对或相对路径,可以是/etc/ssh/sshd_config绝对路径,也可以是ssh/sshd_config相对路径。相对路径名中没有前导的/

完全限定的将名称映射到数字,注意这听起来有点像名称解析的概念。在描述 inode 时,我们用了 DNS 的类比,这里也将继续使用这个类比。就像常规的 DNS 记录将网站名称映射到 IP 地址,目录将所有文件名映射到对应的 inode 编号。文件名和 inode 之间的这种组合被称为 链接。链接的概念将在本章末尾解释。

如果你喜欢用流行文化的参考,想象一下 星际大战 中的星图。为了前往特定的星球,角色们会查阅星图来找到正确的位置。将星图想象为一个目录,每个星球则是一个文件或子目录。星图列出了每个星球的确切位置和坐标,就像目录列出了每个文件的 inode 编号和位置。

这种映射在执行查找操作时非常有用。查找路径名是一个以目录为中心的操作,因为文件总是存在于目录内。对于查找路径名,VFS 使用目录条目,也就是 dentry objectsdentry objects 负责在内存中表示目录。当遍历路径时,每个组件都被视为一个 dentry object。以 /etc/hosts 文件为例,/etc 目录和 hosts 文件都被视为 dentry objects,并映射到内存中。这有助于缓存 lookup 操作的结果,从而加快查找路径名时的整体性能。

考虑以下示例:在 / 分区中有一个 /cars 目录,里面有三个文件:McLarenPorscheLamborghini。以下步骤提供了当进程想要访问 /cars 目录中的 McLaren 文件时发生的简化版事件:

  • VFS 会将路径重构为 dentry object

  • 对路径名中的每个组件都会创建一个 dentry object。VFS 将跟踪每个目录条目以进行路径解析。对于查找 /cars/McLaren,将为 /carsMcLaren 分别创建独立的 dentry objects

  • 由于我们的进程已经指定了绝对路径,VFS 将从路径名中的第一个组件开始,即 /,然后继续处理子对象。

  • VFS 将检查 inode 上的相关权限,查看调用进程是否具有所需的权限。

  • VFS 还会计算 dentry objects 的哈希值,并将其与哈希表中的值进行比较。

  • / 目录包含文件和子目录与其对应的 inode 编号的映射。一旦 /cars 的 inode 被检索到,内核就可以使用块指针查看该目录的磁盘上的内容。

  • /cars目录将包含三个文件(McLarenPorscheLamborghini)与它们的 inode 号之间的映射。从这里,我们可以使用McLaren的 inode,它将指向包含文件数据的磁盘数据块。

重要的是要知道,通过dentry 对象表示目录仅存在于内存中。它们不会被存储在磁盘上。这些对象是由 VFS 动态创建的:

图 2.4 – 目录与 inode 之间的交换

图 2.4 – 目录与 inode 之间的交换

目光敏锐的读者可能会想,我们是如何知道/目录的 inode 的?大多数文件系统从2开始分配 inode 值。inode 号0未被使用。inode 号1用于跟踪物理磁盘上的坏块和损坏的块。因此,文件系统中的 inode 分配从2开始,文件系统的根目录总是被分配 inode 号2

Dentry 缓存

在性能方面,路径名遍历和目录查找可能是昂贵的操作,尤其是在需要解析多个递归路径时。一旦路径被解析,且一个进程再次需要访问同一路径,VFS 必须重新执行整个操作,这是不必要的。此外,还依赖于底层存储介质:它能多快地检索所需信息。这会拖慢操作速度。

我们再次使用我们的比喻——DNS。当一个 DNS 客户端执行相同的 DNS 查询时,客户端的本地 DNS 服务器会缓存查询结果。这样做是为了确保对于任何相同的请求,DNS 服务器不必遍历整个 DNS 服务器层级。类似地,为了加速路径名查找过程,内核使用目录项缓存。经常访问的路径名会被保存在内存中,以加速查找过程。这可以节省大量不必要的 I/O 请求,避免对底层文件系统造成负担。dentry 缓存在文件名查找操作中起着关键作用。

目录将文件名映射到 inode。你可能会问,如果 dentry 对象创建了目录的内存表示,并且查找操作的结果被缓存,这是否意味着对应的 inode 也被缓存?答案是肯定的。没有必要只缓存一个而不缓存另一个。如果目录项被缓存,相应的 inode 也会被缓存。dentry 对象将对应的 inode 固定在内存中,并且它们会一直驻留在内存中,直到 dentry 对象被释放。

Dentry 对象在include/linux/dcache.h文件中定义:

struct dentry {
        unsigned int d_flags;           /* protected by d_lock */
        seqcount_spinlock_t d_seq;      /* per dentry seqlock */
        struct hlist_bl_node d_hash;    /* lookup hash list */
        struct dentry *d_parent;        /* parent directory */
        struct qstr d_name;
        struct inode *d_inode;          /* Where the name belongs to – NU
[…………..]

这里描述了一些常用的术语:

  • d_name:该字段包含指向struct qstr对象的指针,表示文件或目录的名称。qstr对象是内核用于表示字符串或字符序列的结构体。

  • d_parent:此字段包含指向与目录项相关联的文件或目录的父目录的指针。

  • d_inode:此字段是指向文件或目录的struct inode对象的指针。

  • d_lock:此字段包含一个自旋锁,用于保护对struct dentry对象的访问。dentryinode对象通常被多个进程共享,这些进程打开相同的文件或目录。d_lock字段保护这些对象,避免并发修改,防止导致文件系统数据不一致或损坏。

  • d_op:此字段包含指向struct dentry_operations结构的指针,该结构包含一组函数指针,用于定义可以对dentry对象执行的操作。

  • d_sb:这是指向struct super_block结构的指针,该结构定义了目录项所属的文件系统的超级块。

缓存通过哈希表在内存中表示。哈希表结构中的每个条目指向具有相同哈希值的目录缓存条目列表。当一个进程尝试访问文件或目录时,内核会使用文件或目录名称作为键,在 dentry 缓存中查找对应的目录项。如果在缓存中找到该条目,它将返回给调用进程。如果未找到该条目,内核必须访问磁盘并执行 I/O 操作,从文件系统中读取目录项。

Dentry 状态

Dentry 对象通常处于以下三种状态之一:

  • 已使用:已使用的 dentry 表示一个当前被 VFS 使用的 dentry 对象,并表明与之关联的 inode 结构有效。这意味着一个进程正在积极使用这个条目。

  • 未使用:未使用的条目也与有效的 inode 关联,但它没有被 VFS 使用。如果再次执行与此条目相关的路径查找操作,可以使用这个缓存的条目来完成该操作。如果需要回收内存,则可以处理该条目。

  • 负值:负值状态有点特殊,它表示一个查找操作失败的情况。例如,如果要访问的文件已经被删除,或者路径名根本不存在,通常会返回没有此文件或目录的消息给调用进程。由于此查找失败,VFS 将创建一个负值 dentry。过多的查找失败可能会创建不必要的负值 dentry,从而对性能产生不利影响。

Dentry 操作

可以对 dentry 对象执行的各种文件系统相关操作由dentry_operations结构定义:

struct dentry_operations {
        int (*d_revalidate)(struct dentry *, unsigned int);
        int (*d_weak_revalidate)(struct dentry *, unsigned int);
        int (*d_hash)(const struct dentry *, struct qstr *);
        int (*d_compare)(const struct dentry *, unsigned int, const char *, const struct qstr *);
        int (*d_delete)(const struct dentry *);
        int (*d_init)(struct dentry *);
        void (*d_release)(struct dentry *);
        void (*d_prune)(struct dentry *);
        [……………………….]

下面描述了一些重要的操作:

  • d_revalidate:目录项缓存中的 dentry 对象可能会与磁盘上的数据不同步,尤其在网络文件系统中常见。内核依赖网络来获取磁盘结构的信息。在这种情况下,VFS 使用 d_revalidate 操作来重新验证一个 dentry。

  • d_weak_revalidate:当路径查找操作结束时,dentry 不是通过父目录查找得到的,VFS 会调用 d_weak_revalidate 操作。

  • d_hash:用于计算 dentry 的哈希值。它以一个 dentry 作为输入,返回一个哈希值,用于在目录缓存中查找该 dentry。

  • d_compare:用于比较两个 dentry 的文件名。它接受两个 dentry 作为参数,如果它们指向同一个文件或目录,返回 true;如果它们不同,则返回 false

  • d_init:在初始化一个 dentry 对象 时调用。

  • d_release:当一个 dentry 需要被释放时调用。它释放 dentry 占用的内存以及任何相关资源,例如缓存的数据。

  • d_iput:当一个 dentry 对象失去其 inode 时调用。这个操作在 d_release 之前被调用。

  • d_dname:用于为伪文件系统(如 procfssysfs)生成路径名。

让我们总结一下关于目录项的讨论:

  • Linux 将目录视为文件。目录也会分配一个 inode。文件的名称存储在目录中。

  • 文件和目录的 inode 之间的区别在于它们对应磁盘块的内容。目录的磁盘数据包含文件名及其 inode 编号的映射。

  • 目录在内存中通过 dentry 对象表示。dentry 对象由 VFS 在内存中创建,并不会存储在物理磁盘上。

  • 为了优化查找操作,使用了目录项缓存(dentry cache)。dentry 缓存将最近访问过的路径名及其 inode 保存在内存中。

文件对象 – 代表已打开的文件

类似于 dentry 对象,readwrite。这些操作的背后思想是确保用户空间程序不必关心文件系统及其数据结构。

当应用程序发起系统调用访问文件时(如 open()),会在内存中创建一个文件对象。同样,当应用程序不再需要访问该文件并决定使用 close() 关闭文件时,文件对象将被丢弃。需要注意的是,VFS 可以为一个特定的文件创建多个文件对象,因为对特定文件的访问不限于单个进程;一个文件可以被多个进程同时打开。因此,文件对象是由每个进程私有使用的。

以下是 inode 和文件对象在内核中使用方式的一些不同之处:

  • 文件对象和 inode 一起使用,当进程需要访问文件时。

  • 要访问文件的 inode,进程需要一个指向文件 inode 的文件对象。文件对象属于单个进程,而 inode 可以被多个进程使用。

  • 每当文件被打开时,都会创建一个文件对象。当另一个进程想要访问相同的文件时,会创建一个新的文件对象,这个文件对象是该进程私有的。因此,我们可以说每个打开的文件都会有一个文件对象。但每个文件始终只有一个 inode。

  • 当进程关闭文件时,相应的文件对象将被销毁,但其 inode 可能仍然保留在缓存中。

文件对象和用于访问文件的另一个类似实体之间可能会有些混淆,进程通过open()系统调用通常会返回一个文件描述符,进程用它来访问文件。从某种程度上讲,文件描述符也能说明进程与文件之间的关系。那么,它们有什么区别呢?从字面上看,文件对象提供的是打开文件描述。文件对象将包含与文件描述符相关的所有数据。文件描述符是用户空间对内核对象的引用。文件对象将包含诸如表示当前文件位置的文件指针以及文件是如何打开的信息:

图 2.5 – 文件对象是由 open()函数创建的结果

图 2.5 – 文件对象是由 open()函数创建的结果

文件对象的定义出现在include/linux/fs.h中。该结构体存储了进程与已打开文件之间关系的信息。f_inode指针指向文件的 inode:

struct file {
        union {
                struct llist_node       fu_llist;
                struct rcu_head         fu_rcuhead;
        } f_u;
        struct path             f_path;
        struct inode            *f_inode;       /* cached value */
        const struct file_operations    *f_op;
[……..]

这里描述了一些重要字段:

  • f_path:此字段表示与打开文件关联的文件的目录路径。当文件被打开时,VFS 会创建一个新的struct file对象,并将其f_path字段初始化为指向文件的目录路径。

  • f_inode:这是一个指向表示与struct file对象关联的文件的struct inode对象的指针。

  • f_op:这是一个指向struct file_operations对象的指针,该对象包含用于文件操作的一组函数指针。

  • f_lock:此字段用于确保不同线程之间对同一文件对象的访问同步。

定义文件操作

与其他结构体一样,适用于文件对象的文件系统方法在file_operations表中定义。f_op指针指向file_operations表。VFS 为所有文件系统实现了一个通用接口,连接到底层文件系统的实际机制:

struct file_operations {
        struct module *owner;
        loff_t (*llseek) (struct file *, loff_t, int);
        ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
        ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
        ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
        ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
        int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *, unsigned int flags);
        int (*iterate) (struct file *, struct dir_context *);
        int (*iterate_shared) (struct file *, struct dir_context *);
        __poll_t (*poll) (struct file *, struct poll_table_struct *);
        long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
        long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
        int (*mmap) (struct file *, struct vm_area_struct *);
[………………]

这里定义的操作看起来与我们在第一章中描述的通用系统调用非常相似:

  • llseek:当 VFS 需要移动文件位置索引时会调用此函数。

  • read:这是由read()及相关系统调用调用的函数。

  • open:当需要打开文件(inode)时调用此操作。

  • write:此操作由write()及相关系统调用触发。

  • release:当一个打开的文件正在被关闭时调用此操作。

  • map:当进程通过mmap()系统调用希望将文件映射到内存时,会调用此操作。

这些是通用操作,并不是所有操作都可以应用于单一文件。归根结底,由各个文件系统来选择适用哪些操作。如果某个特定方法不适用于某个文件系统,则可以简单地设置为NULL

进程会耗尽文件描述符吗?

内核会强制限制每次可以打开的最大进程数。这些限制可以在用户、组或全局系统级别应用。如果所有的文件描述符都已分配,进程将无法打开更多的文件。许多大型应用程序需要的文件描述符数量远超默认的限制。在这种情况下,单个用户的限制可以在/etc/security/limits.conf文件中进行设置。对于系统级设置,可以使用sysctl命令:

图 2.6 – 打开的文件过多会破坏应用程序

图 2.6 – 打开的文件过多会破坏应用程序

在我们到达打开文件的限制之前,让我们总结一下我们的讨论:

  • 文件对象是一个内存中的打开文件表示,且没有任何对应的磁盘映像。

  • 文件对象是在进程通过open()系统调用响应时创建的。

  • 文件对象是进程私有的。由于多个进程可以访问同一个文件,VFS 会为每个文件创建多个文件对象。

超级块 – 描述文件系统元数据

如果你曾通过在块设备上运行mkfs创建文件系统,可能会在输出中看到超级块一词。超级块是 Linux 用户较为熟悉的结构之一。你可能注意到,VFS 中使用的结构彼此相似。目录项(Dentry)和文件对象分别存储目录和打开文件的内存表示。两个结构都没有磁盘映像,仅存在于内存中。类似地,超级块结构与 inode 有很多相似之处。inode 存储文件的元数据,而超级块存储文件系统的元数据。

以一个图书馆目录系统为例,该系统记录了书籍的标题、作者以及它们在书架上的位置。如果目录系统丢失或损坏,可能很难在图书馆中找到并取回特定的书籍。同样,如果内核中的超级块结构被破坏或损坏,可能会导致数据丢失或文件系统错误。

就像每个文件都有一个分配给它的 inode 编号一样,每个文件系统也有一个对应的超级块结构。与 inode 类似,超级块也有其磁盘映像。对于那些动态生成内容的文件系统,如 procfssysfs,它们的超级块结构仅存储在内存中。当文件系统需要挂载时,超级块是第一个被读取的结构。类似地,当文件系统被挂载后,关于已挂载文件系统的信息会存储在超级块中。

文件系统的超级块包含有关文件系统的复杂信息,如总块数、已用块数、未用块数、空闲块数、文件系统状态和类型、inode 等等。随着文件系统的变化,存储在超级块中的信息会被更新。由于在挂载文件系统时会读取超级块,因此我们需要思考,如果存储在超级块中的信息被擦除或损坏会发生什么。简而言之,文件系统没有超级块是无法挂载的。鉴于其关键性质,超级块的多个副本会保存在不同的磁盘位置。若主超级块损坏,文件系统可以通过任何一个备份超级块进行挂载。

超级块结构在 include/linux/fs.h 中定义。s_list 包含指向已挂载超级块的指针,s_dev 用于标识设备。超级块操作在 super_operations 表中定义,由 s_op 指针指向:

struct super_block {
        struct list_head        s_list;         /* Keep this first */
        dev_t                   s_dev;          /* search index; _not_ kdev_t */
        unsigned char           s_blocksize_bits;
        unsigned long           s_blocksize;
        loff_t                  s_maxbytes;     /* Max file size */
        struct file_system_type *s_type;
        const struct super_operations   *s_op;
        const struct dquot_operations   *dq_op;
        const struct quotactl_ops       *s_qcop;
        const struct export_operations *s_export_op;
[…………………………] 

超级块结构中的一些重要字段如下所述:

  • s_list:该字段用于维护当前挂载的所有文件系统的列表。

  • s_dev:该字段指定与文件系统根目录 inode 对应的设备编号。用于识别文件系统所在的设备。

  • s_type:该字段指向用于解释文件系统中存储数据的特定文件系统的定义。例如,如果该字段指向 XFS 文件系统,内核便知道需要使用 XFS 特定的函数与文件系统进行交互。

  • s_root:该字段由内核在挂载文件系统时用于定位根目录。一旦根目录被识别,便可以遍历目录树,访问文件系统中的其他文件和目录。

  • s_magic:该字段用于标识特定设备或分区上的文件系统类型。

同样,由于字段数量众多,因此不可能逐一解释所有字段。有些字段是简单的整数,而另一些则有更为复杂的数据结构和函数指针。

内核中的超级块操作

与所有 VFS 结构一样,include/linux/fs.h 中的所有超级块操作对于文件系统来说并不是强制的。内核将文件系统超级块的副本保存在内存中。当文件系统发生变化时,超级块中的信息会在内存中更新。因此,内存中的超级块副本被标记为 ,因为内核需要将更新后的信息写入磁盘上的超级块:

struct super_operations {
        struct inode *(*alloc_inode)(struct super_block *sb);
        void (*destroy_inode)(struct inode *);
        void (*free_inode)(struct inode *);
        void (*dirty_inode) (struct inode *, int flags);
        int (*write_inode) (struct inode *, struct writeback_control *wbc);
        int (*drop_inode) (struct inode *);
        void (*evict_inode) (struct inode *);
        void (*put_super) (struct super_block *);
        int (*sync_fs)(struct super_block *sb, int wait);
        int (*freeze_super) (struct super_block *);
[………………………..]

一些重要的方法定义如下:

  • alloc_inode:调用此方法以初始化并分配 struct inode 的内存。

  • destroy_inode:此方法由 destroy_inode() 调用,用于释放分配给 struct inode 的资源。

  • dirty_inode:VFS 调用此方法将 inode 标记为

  • write_inode:当 VFS 需要将 inode 写入磁盘时调用此方法。

  • delete_inode:当 VFS 想要删除 inode 时调用此方法。

  • sync_fs:当 VFS 正在写出所有与超级块相关的 数据时调用此方法。

  • statfs:当 VFS 需要获取文件系统统计信息(例如文件系统的大小、可用空间和 inode 数量)时调用此方法。

  • umount_begin:当 VFS 卸载文件系统时调用此方法。

让我们总结一下:

  • 超级块结构记录了所有文件系统特性。

  • 超级块结构在挂载和卸载文件系统时被读取。文件系统会在多个磁盘位置保存超级块的副本。

链接

在我们讨论目录条目时,提到了链接操作。链接有两种类型:符号链接(或软链接)和硬链接,正如大多数用户所知道的那样。符号(软)链接表现为快捷方式,尽管存在一些微妙的差异。软链接指向包含数据的路径,而硬链接指向数据本身。

再往回一点,inode 中不包含文件的名称。文件名包含在目录中。这意味着在目录列表中可以有多个文件名,它们都指向相同的 inode。硬链接正是利用了这一逻辑。硬链接指向文件的 inode。这意味着链接和文件是不可区分的,因为它们都指向相同的 inode。过一段时间后,你甚至可能无法分辨哪个是原始文件:

图 2.7 – 无法判断哪个是原始文件,因为它们有相同的 inode

图 2.7 – 无法判断哪个是原始文件,因为它们有相同的 inode。

相反,符号链接与原始文件的 inode 编号不同。注意这个符号链接指向原始文件,并在文件的权限部分显示出l

图 2.8 – 对于软链接,文件权限中的第一个字符是“l”

图 2.8 – 对于软链接,文件权限中的第一个字符是“l”。

对多个文件使用相同的 inode 编号会导致一些限制。由于 inode 编号仅在单一文件系统内唯一,因此硬链接不能跨文件系统边界存在。它们只能存在于同一文件系统中。硬链接仅能用于常规文件,而不能用于目录。这是为了防止破坏文件系统结构。硬链接到目录可能会创建一个无限循环结构。

总结四个结构

我们在这里讨论的一些概念,等到在第三章中讨论块文件系统时,可能会变得更加清晰,探索 VFS 下的实际文件系统。不过我们已经对 VFS 如何构建抽象网络有所了解。

如在第一章中讨论的,VFS 的设计偏向于源自 Linux 系列的文件系统。大多数非本地文件系统并不使用 inode、超级块、文件和目录对象的概念。为了为它们实现通用的文件模型,VFS 在内存中创建了这些结构。因此,像 inode 和超级块这样的对象,在本地文件系统中可能既存在于磁盘上也存在于内存中,而在非本地文件系统中可能仅存在于内存中。由于非本地文件系统设计的不同,它们可能不支持一些 Linux 中常见的文件系统操作,如符号链接。

以下表格提供了主要 VFS 结构的简要总结:

结构 描述 存储位置 磁盘/内存
Inode 包含除文件名以外的所有文件元数据 存储在磁盘和内存中
Dentry 表示目录项与文件之间的关系 仅在内存中
文件对象 存储进程与打开文件之间关系的信息 仅在内存中
超级块 保存文件系统特征和元数据 存储在磁盘和内存中

表 2.1 – 总结主要 VFS 数据结构

下图展示了进程如何打开存储在磁盘上的文件:

图 2.9 – 常见 VFS 结构之间的关系

图 2.9 – 常见 VFS 结构之间的关系

请记住,超级块结构是在文件系统挂载过程中创建和初始化的,它包含指向根目录项的指针,而根目录项又包含指向表示文件系统根目录的 inode 的指针。

当进程调用 open() 系统调用打开文件时,VFS 创建一个 struct file 对象来表示进程地址空间中的文件,并初始化其 f_path 字段,指向文件的目录路径。struct dentry 对象包含指向 struct inode 对象的指针,后者表示文件的磁盘 inode。

struct inode 对象与 struct super_block 对象相关联,后者代表了磁盘上的文件系统。struct super_block 对象包含指向文件系统特定功能的指针,这些功能在 struct super_operations 结构中定义,并由 VFS 用于与文件系统交互。

页面缓存

Linux 中用于定义不同概念和术语的命名法有些奇怪。用于创建文件的系统调用被称为 creat。Unix 的创始人 Ken Thompson 曾开玩笑地说,creat 中缺失的 e 是他在 Unix 设计中最大的遗憾。在解释 VFS 结构的一些操作时,使用了 dirty 这个词。Linux 中为何以及如何使用这个术语,没人知道。这里的 dirty 指的是那些已经被修改但尚未 flush 到磁盘的内存页面。

缓存是硬件和软件应用程序中常用的提高性能的做法。在硬件方面,CPU、内存子系统和物理磁盘的速度与性能是相互关联的。CPU 的速度远远快于内存子系统,而内存子系统又比物理磁盘更快。这种速度差异可能导致在等待内存或磁盘响应时浪费 CPU 周期。

为了解决这个问题,CPU 中增加了缓存层,用来存储从主内存中频繁访问的数据。这样,只要所需的数据在缓存中可用,CPU 就能够以其自然速度运行。同样,软件应用程序也使用缓存,将频繁访问的数据或指令存储在更快、更易访问的位置,以提高性能。

Linux 的设计以性能为导向,而页面缓存在确保这一点上发挥了至关重要的作用。页面缓存的主要目的是通过确保数据保存在内存中(前提是有足够的可用空间),从而减少频繁访问底层物理磁盘的时间,以提高 readwrite 操作的延迟。所有这些都有助于提升性能,因为磁盘访问速度远低于内存。

操作系统与硬件在更低层次进行交互,并使用不同的单位来管理和利用可用资源。例如,文件系统将磁盘空间划分为块,这是比单个字节或比特更高级的抽象。这是因为在字节或比特级别管理数据可能非常复杂且耗时。页面是内核中内存的基本单位,默认大小为 4 KB。这一点非常重要,因为所有的 I/O 操作都是以一定数量的页面为单位进行对齐的。下图总结了如何通过页面缓存从磁盘读取和写入数据:

图 2.10 – 通过页面缓存提高 I/O 性能

图 2.10 – 通过页面缓存提高 I/O 性能

图 2.10 重点展示了页面缓存如何通过在内存中缓存频繁访问的文件数据,从而减少磁盘访问并提高系统性能,进而改善读写性能。

读取

以下几点简要总结了当用户空间中的进程请求从磁盘读取数据时会发生的情况:

  1. 内核首先检查所需的数据是否已经存在于缓存中。如果数据在缓存中找到了,内核可以避免执行任何磁盘操作,直接将请求的数据提供给进程。这种情况被称为缓存命中

  2. 如果请求的数据未能在缓存中找到,内核必须去底层磁盘进行操作。这被称为读取操作,从磁盘获取请求的数据,保存到缓存中,然后将其交给调用进程。

  3. 如果之后有更多请求访问此页面,它们可以从页面缓存中获取。

  4. 在请求的数据已在缓存中找到但已标记为的情况下,内核会先将其写回磁盘,然后再执行之前提到的过程。

类似地,当进程需要将数据写入磁盘时,会发生以下情况:

  1. 内核更新映射到文件的页面缓存,并将数据标记为。尚未写入磁盘的页面被称为脏页面

  2. 内核不会立即将所有脏数据写入磁盘。根据内核刷写线程的配置,脏数据会被刷新到磁盘。

写入请求完成后,内核会向调用进程发送确认消息。然而,它不会告知进程对应的脏页面何时会被实际写入磁盘。值得注意的是,这种异步处理使得写入操作比读取操作要快得多,因为内核避免了访问底层物理磁盘的操作。这也引出了一个问题:如果我的 I/O 请求来自内存,在突发性断电的情况下,我的数据会怎么样? 内存中的脏页面在一定时间后会被刷新到物理磁盘,这个过程称为sysctl,可以用来控制页面缓存的这一行为。

尽管页面缓存存在一定的风险,但毫无疑问它能提升性能。页面缓存的大小并非固定,而是动态变化的。页面缓存可以使用系统中可用的内存资源。然而,当系统中可用的空闲内存低于某个阈值时,刷新调度程序会启动并开始将页面缓存中的数据卸载到磁盘。

总结

Linux 文件系统的支持灵活性直接来源于 VFS 实现的一组抽象接口。在本章中,我们学习了 VFS 中的主要数据结构,以及它们如何协同工作。VFS 使用多个数据结构来实现不同原生和非原生文件系统的通用抽象方法。四个最常见的结构是 inode、目录项、文件对象和超级块。这些结构确保了不同文件系统在设计和操作上的一致性。由于 VFS 定义的方法是通用的,因此文件系统不必实现所有方法,尽管文件系统应该遵循 VFS 中定义的结构,并在其基础上构建,以确保保持通用接口。

除了文件系统抽象,VFS 还提供了许多缓存,以提高文件系统操作的性能,如目录项缓存和 inode 缓存。我们还解释了内核中页缓存的机制,并展示了它如何加速用户空间程序发出的读写请求。在 第三章 中,我们将探索 VFS 层下的实际文件系统。我们将介绍一些流行的 Linux 文件系统,主要是扩展文件系统以及它如何在磁盘上组织用户数据。我们还将解释与 Linux 中不同文件系统相关的一些常见概念,如日志记录、写时复制和用户空间中的文件系统。

第三章:探索 VFS 下的实际文件系统

“不是所有的根都埋在地下,有些根在树顶。” — Jinvirle

内核的 I/O 栈可以分为三个主要部分:虚拟文件系统VFS)、块层物理层。Linux 支持的不同类型的文件系统可以视为 VFS 层的尾端。前两章使我们对 VFS 的角色、VFS 使用的主要结构以及它如何帮助终端用户进程通过通用文件模型与不同的文件系统交互有了一个不错的理解。这意味着,我们现在将能够以通常接受的方式使用 文件系统 这个词。终于。

第二章 中,我们定义并解释了 VFS 使用的一些重要数据结构,这些数据结构为不同的文件系统定义了一个通用框架。为了使某个特定的文件系统能够被内核支持,它应该在这个框架定义的边界内运行。但是,并非所有由 VFS 定义的方法都必须被文件系统使用。文件系统应遵循 VFS 中定义的结构并在此基础上进行扩展,以确保它们之间的通用性,但由于每个文件系统在组织数据方面的方式不同,这些结构中可能会有许多方法和字段对于某个特定文件系统并不适用。在这种情况下,文件系统根据其设计定义相关字段,省略不必要的信息。

如我们所见,VFS 位于用户空间程序与实际文件系统之间,并实现了一个通用文件模型,以便应用程序可以使用统一的访问方法来执行操作,而不管底层使用的是哪种文件系统。现在,我们将重点关注这个三明治的一个方面,即包含用户数据的文件系统。

本章将向你介绍一些在 Linux 中使用的更常见和流行的文件系统。我们将详细讨论扩展文件系统的工作原理,因为它是最常用的。我们还将介绍一些网络文件系统,并探讨与文件系统相关的一些重要概念,如日志记录、用户空间中的文件系统和写时复制CoW)机制。

我们将覆盖以下主要主题:

  • 日志记录的概念

  • CoW 机制

  • 扩展文件系统家族

  • 网络文件系统

  • 用户空间中的文件系统

技术要求

本章完全聚焦于文件系统及相关概念。如果你有 Linux 存储管理经验,但尚未深入了解文件系统的内部工作原理,那么本章将成为一个宝贵的练习。了解文件系统概念的前置知识将有助于你更好地理解本章所涉及的内容。

本章中展示的命令和示例与发行版无关,可以在任何 Linux 操作系统上运行,如 Debian、Ubuntu、Red Hat、Fedora 等等。文中有一些关于内核源代码的参考。如果你想下载内核源代码,可以从www.kernel.org下载。本书中提到的代码片段来自内核 5.19.9

Linux 文件系统画廊

如前所述,使用 Linux 的一个主要好处是支持的文件系统种类繁多。内核对其中一些文件系统提供开箱即用的支持,如 XFS、Btrfs 和扩展文件系统 2、3 和 4 版本。这些被认为是本地文件系统,因为它们在设计时就考虑到了 Linux 的原则和理念。另一方面,像 NTFS 和 FAT 这样的文件系统可以被视为非本地文件系统。这是因为,尽管 Linux 内核能够理解这些文件系统,但它们通常需要额外的配置支持,因为它们不符合本地文件系统采用的约定。我们将重点讨论本地文件系统,并解释与它们相关的关键概念。

尽管每个文件系统都声称比其他文件系统更好、更快、更可靠和更安全,但需要注意的是,没有任何一个文件系统能够适用于所有类型的应用程序。每个文件系统都有其优点和局限性。从功能的角度来看,文件系统可以分类如下:

图 3.1 – Linux 文件系统画廊

图 3.1 – Linux 文件系统画廊

图 3.1 展示了一些支持的文件系统及其各自的类别。由于 Linux 支持大量的文件系统,覆盖所有文件系统将占用我们太多空间(这是一个文件系统的双关语!)。尽管实现细节有所不同,但文件系统通常会利用一些常见的技术来进行内部操作。一些核心概念,如日志记录,在文件系统中更为常见。类似地,一些文件系统使用了流行的 CoW 技术,因此它们不需要日志记录。

让我们来解释文件系统中的日志记录概念。

文件系统的日志记录 – 日志记录的概念

文件系统使用复杂的结构来组织物理磁盘上的数据。在系统崩溃或突然故障的情况下,文件系统无法以优雅的方式完成其操作,这可能会损坏其组织结构。当系统下次启动时,用户需要对文件系统进行一致性或完整性检查,以检测和修复那些损坏的结构。

第二章 中解释 VFS 数据结构时,我们讨论了 Linux 遵循的一个基本原则——将元数据与实际数据分离。文件的元数据在一个独立的结构中定义,称为 inode。我们还看到,目录作为特殊文件来处理,并包含文件名到其 inode 编号的映射。记住这一点,假设我们正在创建一个简单的文件并向其中添加一些文本。为了实现这一点,内核需要执行以下操作:

  1. 为要创建的文件创建并初始化一个新的 inode。一个 inode 在文件系统中应该是唯一的。

  2. 更新文件所在目录的时间戳。

  3. 更新目录的 inode。这是必需的,以便更新文件名到 inode 的映射关系。

即使是像文本文件创建这样简单的操作,内核也需要执行多个 I/O 操作以更新多个结构。假设在执行这些操作时,由于硬件或电力故障导致系统突然关闭。此时,创建新文件所需的所有操作都没有成功完成,这将导致文件系统结构不完整。如果文件的 inode 已初始化但未链接到包含该文件的目录,则该 inode 将被视为孤立的。一旦系统重新上线,文件系统将进行一致性检查,删除任何未链接到任何目录的 inode。系统崩溃后,文件系统本身可能保持完整,但个别文件可能会受到影响。在最坏的情况下,文件系统本身也可能会永久损坏。

为了提高文件系统在发生中断和系统崩溃时的可靠性,文件系统引入了日志功能。第一个支持此功能的文件系统是 IBM 的 JFS,即 日志文件系统。近年来,日志记录已成为文件系统设计中的重要组成部分。

文件系统日志功能的概念起源于数据库系统的设计。在大多数数据库中,日志记录保证了数据的一致性和完整性,以防事务因外部事件(如硬件故障)而失败。数据库日志会通过记录操作来跟踪未提交的更改。当系统重新上线时,数据库将使用日志进行恢复。文件系统的日志功能也遵循相同的方式。

任何需要在文件系统上执行的更改,首先会顺序地写入日志。这些更改或修改被称为事务。一旦事务被写入日志,它会被写入磁盘上的相应位置。如果发生系统崩溃,文件系统会回放日志,查看是否有任何事务未完成。当事务已写入磁盘上的位置后,它就会从日志中删除。

根据日志记录的方式,首先会将元数据或实际数据(或两者)写入日志。一旦数据被写入文件系统,事务就会从日志中删除:

图 3.2 – 文件系统中的日志记录

图 3.2 – 文件系统中的日志记录

需要注意的是,默认情况下,文件系统日志也存储在同一文件系统中,尽管它被存储在一个隔离的区域。有些文件系统还允许将日志存储在独立的磁盘上。日志的大小通常只有几兆字节。

那个令人迫切想知道的问题——日志记录不会对性能产生负面影响吗?

日志记录的整个意义在于提高文件系统的可靠性,并在系统崩溃和硬件故障的情况下保护其结构。在启用日志记录的文件系统中,数据首先写入日志,然后再写入其指定的磁盘位置。显而易见,我们在到达目的地时增加了额外的步骤,因为我们需要将相同的数据写两次。这肯定会适得其反,破坏文件系统的性能吧?

这是一个看似答案显而易见,但实际上并非如此的问题。使用日志记录并不一定会导致文件系统性能下降。事实上,在大多数情况下,情况恰恰相反。某些工作负载下,两者之间的差异可能微不足道,但在大多数场景中,尤其是在元数据密集型的工作负载下,文件系统日志记录实际上可以提高性能。性能提升的程度可能有所不同。

考虑一个没有日志记录的文件系统。每次修改文件时,采取的自然行动是直接在磁盘上执行相关的修改。对于元数据密集型操作,这可能会对性能产生负面影响。例如,文件内容的修改还需要相应地更新文件的时间戳。这意味着每次处理和修改文件时,文件系统不仅需要更新实际的文件数据,还需要更新元数据。启用日志记录后,对物理磁盘的查找次数较少,因为数据仅在事务已提交到日志或日志已满时才会写入磁盘。另一个好处是日志中使用了顺序写入。在使用日志时,随机写操作会转化为顺序写操作。

在大多数情况下,性能的提升是通过取消元数据操作来实现的。当需要快速更新元数据时,比如递归地对目录及其内容进行操作,使用日志记录可以通过减少频繁的磁盘访问并在原子操作中执行多个更新来提高性能。

当然,文件系统如何实现日志记录在其中也起着重要作用。不同的文件系统在日志记录方面提供了不同的处理方式。例如,一些文件系统只记录文件的元数据,而另一些则在日志中同时记录元数据和实际数据。一些文件系统还提供灵活的处理方式,允许最终用户自行决定日志记录模式。

总结来说,日志记录是现代文件系统的重要组成部分,因为它确保即使在系统崩溃的情况下,文件系统仍然保持结构的完整性。

CoW 文件系统的奇特情况

fork()系统调用。fork()系统调用通过复制调用进程来创建一个新进程。当使用fork()系统调用创建新进程时,父子进程之间会共享内存页面。只要页面被共享,它们就不能被修改。当父进程或子进程尝试修改页面时,内核会复制该页面并将其标记为可写。

在 Linux 中,长期存在的大多数文件系统在核心设计原则上采用了非常传统的方法。在过去的几年里,扩展文件系统的两个主要变化是使用日志记录和扩展(extents)。尽管已经采取了一些措施来扩展文件系统以适应现代应用,但一些关键领域如错误检测、快照和去重等却被忽略了。这些功能是当今企业存储环境中的需求。

使用 CoW(写时复制)方法的文件系统与其他文件系统有一个显著的不同。当在 Ext4 或 XFS 文件系统上覆盖数据时,新数据会写到现有数据上方。这意味着原始数据会被销毁。而使用 CoW 方法的文件系统则将旧数据复制到磁盘的其他位置,新的数据会写入这个新位置。因此,才有了写时复制这一术语。由于旧数据或其快照仍然存在,文件系统上的空间利用率会比用户预期的要高得多。这常常让新手用户感到困惑,可能需要一段时间才能适应。一些 Linux 用户对此有一种相当幽默的看法:写时复制吃掉了我的数据。如图 3.3所示,使用 CoW 方法的文件系统会将新数据写入新的块:

图 3.3 – 文件系统中的 CoW 方法

图 3.3 – 文件系统中的 CoW 方法

作为类比,我们可以将其与电影中的时间旅行概念进行粗略比较。当有人回到过去并对过去做出更改时,会创建一条平行时间线。这会产生与原始时间线不同的副本。CoW 文件系统的操作方式类似。当请求修改文件时,系统不会直接修改原始数据,而是创建数据的一个单独副本。原始数据保持不变,而修改后的版本则存储在另一个位置。

由于在此过程中保留了原始数据,这为我们开辟了一些有趣的方向。正因如此,在系统崩溃的情况下,文件系统恢复变得更加简化。数据的先前状态被保存在磁盘上的另一个位置。因此,如果发生故障,文件系统可以轻松恢复到先前的状态。这使得维护任何日志文件的需求变得多余。它还允许在文件系统级别实现快照。只有被修改的数据块才会被复制到新位置。当需要通过特定的快照恢复文件系统时,数据可以轻松地重建。

表 3.1 突出了日志文件系统和基于 CoW 文件系统之间的一些主要区别。请注意,这些功能的实现和可用性可能会根据文件系统类型的不同而有所变化:

日志文件系统 写时复制(CoW)
写入处理 在应用更改之前,修改内容会先记录在日志中 创建数据的单独副本并进行修改
原始数据 原始数据会被覆盖 原始数据保持不变
数据一致性 通过记录元数据更改并在需要时重放日志来确保一致性 通过从不修改原始数据来确保一致性
性能 取决于日志模式的类型,通常只有最小的开销 由于写入速度更快,性能有所提升
空间利用率 日志大小通常为 MB,因此不需要额外的空间 由于需要为数据创建单独的副本,因此需要更多的空间
恢复时间 恢复时间较快,因为日志可以立即重放 恢复时间较慢,因为需要使用最近的副本重建数据
功能 不支持如压缩或去重等功能 内建支持压缩和去重功能

表 3.1 – CoW 与日志文件系统之间的区别

使用基于 CoW(写时复制)方法来组织数据的文件系统包括Zettabyte 文件系统ZFS)、B 树文件系统Btrfs)和 Bcachefs。ZFS 最初在 Solaris 上使用,并因其强大的功能迅速获得了广泛的应用。尽管由于许可问题未能纳入内核,但它已经通过ZFS on Linux项目移植到了 Linux 上。Bcachefs 文件系统是从内核的块缓存代码开发而来的,并且正迅速获得流行。它可能会成为未来内核发布的一部分。Btrfs,也被亲切地称为 ButterFS,直接受 ZFS 启发。不幸的是,由于早期版本中的一些 bug,它在 Linux 社区的采用进程放缓。然而,它一直在积极开发,并且已经成为 Linux 内核的一部分超过十年。

尽管存在一些问题,Btrfs 仍然是内核中最先进的文件系统,因其丰富的功能集。如前所述,Btrfs 受到了 ZFS 的深刻影响,并力图提供几乎相同的功能。像 ZFS 一样,Btrfs 不仅仅是一个简单的磁盘文件系统,它还提供了逻辑卷管理器和软件独立磁盘冗余阵列RAID)的功能。它的一些功能包括快照、校验和、加密、去重和压缩,这些功能通常在常规块文件系统中无法使用。所有这些特点极大简化了存储管理。

总结来说,像 Btrfs 和 ZFS 这样的文件系统的 CoW 方法确保现有数据永远不会被覆盖。因此,即使在系统突然崩溃的情况下,现有数据也不会处于不一致的状态。

扩展文件系统

扩展文件系统,简称Ext,自 Linux 内核诞生以来一直是其可信赖的助手,几乎与 Linux 内核本身一样古老。它最早在内核版本 0.96c 中被引入。多年来,扩展文件系统经历了一些重大变化,导致了多个版本的文件系统。这些版本简要介绍如下:

  • 第一个扩展文件系统:第一个运行 Linux 的文件系统是 Minix,支持的最大文件系统大小为 64 MB。扩展文件系统的设计旨在克服 Minix 中的不足,通常被认为是 Minix 文件系统的扩展。扩展文件系统支持最大 2 GB 的文件系统大小。它也是第一个使用 VFS(虚拟文件系统)的文件系统。第一个 Ext 文件系统每个文件只能有一个时间戳,而与今天的三个时间戳相比,它有些简陋。

  • 第二扩展文件系统:在第一个扩展文件系统发布约一年后,第二版 Ext2 文件系统发布了。Ext2 文件系统解决了前一个版本的限制,如分区大小、碎片化、文件名长度、时间戳和最大文件大小等。它还引入了多个新特性,包括文件系统块的概念。Ext2 的设计灵感来自 BSD 的 Berkeley Fast File System。Ext2 文件系统支持更大的文件系统大小,最多可达到几 TB。

  • 第三扩展文件系统:Ext2 文件系统得到了广泛应用,但在系统崩溃时仍然存在碎片化和文件系统损坏的巨大问题。第三扩展文件系统 Ext3 在设计时考虑到了这一点。该版本引入的最重要特性是日志功能。通过日志功能,Ext3 文件系统可以跟踪未提交的更改。这在系统因硬件或电力故障崩溃时,减少了数据丢失的风险。

  • 第四扩展文件系统:Ext4 是扩展文件系统家族中目前最新的版本。Ext4 文件系统在性能、碎片化和可扩展性方面相较于 Ext2 和 Ext3 提供了若干改进,同时保持了与 Ext2 和 Ext3 的向后兼容性。在 Linux 发行版中,Ext4 可能是最常部署的文件系统。

我们将主要关注最新版本的扩展文件系统 Ext4 的设计和结构。

块 —— 文件系统的通用语言

在最低层次上,硬盘是以扇区为单位进行寻址的。扇区是磁盘驱动器的物理属性,通常大小为 512 字节。尽管如今,使用 4 KB 扇区大小的驱动器并不罕见。扇区大小是我们无法更改的,因为它是由驱动器制造商决定的。由于扇区是驱动器上最小的可寻址单位,任何对物理驱动器执行的操作,都会大于或等于扇区大小。

文件系统是建立在物理驱动器之上的,并且不以扇区为单位来访问驱动器。所有文件系统(扩展文件系统系列也不例外)都是以块为单位来访问物理驱动器。块是物理扇区的集合,是文件系统的基本单位。Ext4 文件系统在进行所有操作时,都是以块为单位。在 x86 系统上,文件系统的块大小默认设置为 4 KB。虽然可以设置为更小或更大的值,但块大小应始终满足以下两个约束条件:

  • 块大小应始终是磁盘扇区大小的二次幂倍数。

  • 块大小应始终小于或等于内存页大小。

文件系统的最大块大小是架构的页面大小。在大多数基于 x86 的系统上,内核的默认页面大小为 4 KB。因此,文件系统块大小不能超过 4 KB。VFS 缓存的页面大小也为 4 KB。块大小限制不仅限于扩展文件系统。页面大小在内核编译时定义,对于 x86_64 系统为 4 KB。如下所示,对于 Ext4 的mkfs程序,如果指定的块大小大于页面大小,将会发出警告。即使使用大于页面大小的块大小创建文件系统,也无法挂载:

[root@linuxbox ~]# getconf PAGE_SIZE
4096
[root@linuxbox ~]# mkfs.ext4 /dev/sdb -b 8192
Warning: blocksize 8192 not usable on most systems.
mke2fs 1.44.6 (5-Mar-2019)
mkfs.ext4: 8192-byte blocks too big for system (max 4096)
Proceed anyway? (y,N) y
Warning: 8192-byte blocks too big for system (max 4096), forced to continue
[....]
[root@linuxbox ~]# mount /dev/sdb /mnt
mount: /mnt: wrong fs type, bad option, bad superblock on /dev/sdb, missing codepage or helper program, or other error.
[root@linuxbox ~]#
[root@linuxbox ~]# dmesg |grep bad
[ 5436.033828] EXT4-fs (sdb): bad block size 8192
[ 5512.534352] EXT4-fs (sdb): bad block size 8192
[root@linuxbox ~]#

一旦文件系统创建完成,块大小就无法更改。默认情况下,Ext4 文件系统将可用存储空间分割为 4 KB 的逻辑块。选择块大小对文件系统的空间利用效率和性能有重大影响。块大小决定了文件的最小磁盘大小,即使实际大小小于块大小。假设我们的文件系统使用 4 KB 的块大小,我们在其上保存一个包含 10 字节的简单文本文件。这个 10 字节的文件,在物理磁盘上存储时将占用 4 KB 的空间。一个块只能容纳一个文件。这意味着对于一个 10 字节的文件,块中剩余的空间(4 KB - 10 字节)将被浪费。如下所示,一个包含字符串"hello"的简单文本文件将占用整个文件系统块:

robocop@linuxbox:~$ echo "hello" > file.txt
robocop@linuxbox:~$ stat file.txt
  File: file.txt
  Size: 6               Blocks: 8          IO Block: 4096   regular file
Device: 803h/2051d      Inode: 2622288     Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/   robocop)   Gid: ( 1000/   robocop)
Access: 2022-11-10 12:55:55.406596713 +0500
Modify: 2022-11-10 13:01:12.962761327 +0500
Change: 2022-11-10 13:01:12.962761327 +0500
 Birth: -
robocop@linuxbox:~$

stat命令给出了一个块计数为8,但这有点误导,因为实际上这是扇区计数。这是因为stat系统调用假设每个块分配了 512 字节的磁盘空间。这里的块计数表示在磁盘上实际分配了4096字节(8 x 512)。文件大小只有6字节,但它占据了一个完整的块。如下所示,当我们在文件中添加另一行文本时,文件大小从6增加到19字节,但使用的扇区和块的数量保持不变:

robocop@linuxbox:~$ echo "another line" >> file.txt
robocop@linuxbox:~$ stat file.txt
  File: file.txt
  Size: 19              Blocks: 8          IO Block: 4096   regular file
Device: 803h/2051d      Inode: 2622288     Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/   robocop)   Gid: ( 1000/   robocop)
Access: 2022-11-10 12:55:55.406596713 +0500
Modify: 2022-11-10 13:01:59.772249416 +0500
Change: 2022-11-10 13:01:59.772249416 +0500
 Birth: -
robocop@linuxbox:~$

有没有更有效的数据组织方法?

由于一个小文本文件占据一个完整的块,可以看出文件系统块大小的影响。在大块大小的文件系统上有很多小文件可能导致磁盘空间的浪费,并且文件系统很快可能用尽块。我们来看一个更清晰理解的可视化表示。

假设我们有四个不同大小的文件如下:

  • 文件 A -> 5 KB

  • 文件 B -> 1 KB

  • 文件 C -> 7 KB

  • 文件 D -> 2 KB

按照分配一个完整块(4 KB)给单个文件的方法,文件将存储在磁盘上如下:

图 3.4 - 即使是最小的文件也占据一个完整的块

图 3.4 - 即使是最小的文件也占据一个完整的块

正如图 3.4所示,我们在第 2 和第 3 块中浪费了 3 KB 的空间,在第 5 和第 6 块中分别浪费了 1 KB 和 2 KB 的空间。显然,太多的小文件会浪费块空间!

让我们尝试一种替代方法,并尝试以更紧凑的格式存储文件,以避免浪费空间:

图 3.5 – 存储文件的另一种方法

图 3.5 – 存储文件的另一种方法

不难看出,第二种方法更紧凑且高效。现在,我们能够仅用四个块存储相同的四个文件,而不是第一个方法中的六个块。我们甚至能够节省 1 KB 的文件系统空间。显然,为单个文件分配一个完整的文件系统块似乎是一种低效的空间管理方法,但实际上,这是必要的权宜之计。

从第一眼看,第二种方法似乎好得多,但你看出设计缺陷了吗?如果文件系统采用这种方法,可能会遇到重大的问题。如果文件系统设计为在单一块中容纳多个文件,它们需要设计一种机制来跟踪单个块内每个文件的边界。这会大大增加设计的复杂性。此外,这还会导致严重的碎片化,从而降低文件系统的性能。如果文件的大小增加,新增的数据将不得不调整到一个单独的块中。文件将存储在随机块中,没有顺序访问。所有这些都会导致文件系统性能差,并使这种紧凑方法的任何优势都变得毫无意义。因此,每个文件都占据一个完整的块,即使它的大小小于文件系统块的大小。

Ext4 文件系统的结构布局

Ext4 中的各个块被组织成另一种单元,称为块组。块组是一个连续块的集合。关于块组的组织,有两种情况。对于第一个块组,第一个 1,024 字节不使用。这些字节保留用于安装启动扇区。第一个块组的布局如下:

图 3.6 – 块组 0 的布局

图 3.6 – 块组 0 的布局

如果文件系统是以 1 KB 的块大小创建的,则超级块将保存在下一个块中。对于所有其他块组,布局变为如下所示:

图 3.7 – 块组 1 及之后的布局

图 3.7 – 块组 1 及之后的布局

让我们讨论一下 Ext4 块组的组成部分。

超级块

第二章所述,fs/ext4/ext4.h,如下面所示,它包含定义文件系统不同属性的多个字段:

struct ext4_super_block {
  __le32  s_inodes_count;         /* Inodes count */
        __le32  s_blocks_count_lo;      /* Blocks count */
        __le32  s_r_blocks_count_lo;    /* Reserved blocks count */
        __le32  s_free_blocks_count_lo; /* Free blocks count */
    __le32  s_free_inodes_count;    /* Free inodes count */
        __le32  s_first_data_block;     /* First Data Block */
        __le32  s_log_block_size;       /* Block size */
        __le32  s_log_cluster_size;     /* Allocation cluster size */
/*20*/  __le32  s_blocks_per_group;     /* # Blocks per group */
        __le32  s_clusters_per_group;   /* # Clusters per group */
        __le32  s_inodes_per_group;     /* # Inodes per group */
        __le32  s_mtime;                /* Mount time */
/*30*/  __le32  s_wtime;                /* Write time */
        __le16  s_mnt_count;            /* Mount count */
[……]

__le32 数据类型表示数据采用小端序(little-endian)表示。正如其在内核源代码中的定义所示,Ext4 超级块定义了多个属性来描述文件系统。它包含的信息包括文件系统中的块总数和块组总数、已用和未用块的总数、块大小、已用和未用的 inode 总数、文件系统状态等。超级块中包含的信息至关重要,因为它是挂载文件系统时第一个被读取的内容。鉴于其关键性质,超级块的多个副本会保存在不同位置。

超级块定义中的大多数字段都容易理解。这里解释了一些有趣的字段:

  • 块大小计算:Ext4 文件系统的块大小是通过此 32 位值计算的。块大小计算方法如下:

Ext4 块大小 = 2 ^ (10 + s_log_block_size)

s_log_block_size 为零时,Ext4 文件系统的最小块大小为 1 KB。Ext4 文件系统支持最大 64 KB 的块大小。

  • s_log_cluster_size

  • s_mnt_count 字段表示自上次一致性检查以来,文件系统已挂载的次数。s_max_mnt_count 字段对挂载次数施加了硬性限制,超过该限制将强制执行一致性检查。文件系统状态保存在 s_state 中,可能的状态有以下几种:

    • 已干净卸载

    • 检测到错误

    • 孤儿 正在恢复中

如果 s_state 中的文件系统状态不是干净的,将自动强制执行检查。上次一致性检查的日期保存在 s_lastcheck 中。如果自上次检查以来,s_checkinterval 字段指定的时间已过去,文件系统将强制执行一致性检查。

  • 超级块中的 s_magic 字段包含此魔数。对于 Ext4,其值为 0xEF53s_rev_levels_minor_rev_level 字段用于区分文件系统版本。

  • 0(根用户)。Ext4 文件系统为超级用户或根用户保留了 5% 的文件系统块。这是为了确保即使非根用户进程无法写入文件系统,根用户进程仍然可以继续运行。

  • 11,属于 Ext4 文件系统中的 lost+found 目录。

  • sdasdb)通常会发生变化,从而导致混淆和不正确的挂载点。UUID 是文件系统的唯一标识符,可以在 /etc/fstab 中用于挂载文件系统。

  • s_feature_compat 字段包含一个 32 位的兼容特性位掩码。文件系统可以自由支持此字段中定义的特性。另一方面,如果 s_feature_incompat 中定义的任何特性内核无法理解,文件系统挂载操作将无法成功。

数据块和 inode 位图

Ext4 文件系统使用极少的空间来组织一些内部结构。文件系统中大部分空间用于存储用户数据。Ext4 文件系统将用户数据存储在数据块中。正如我们在 第二章 中所学到的,每个文件的元数据存储在一个单独的结构中,称为索引节点。索引节点也存储在磁盘上,尽管是在一个保留空间中。每个文件系统中的索引节点是唯一的。每个文件系统都使用某种技术来跟踪已分配和可用的索引节点。同样,必须有一种方法来跟踪已分配和空闲的块的数量。

Ext4 使用位图作为分配结构。位图是一个由位组成的序列。单独的位图用于跟踪索引节点和数据块的数量。数据块位图用于跟踪块组内数据块的使用情况。类似地,索引节点位图用于跟踪索引节点表中的条目。位值为 0 表示块或索引节点可用;位值为 1 表示块或索引节点已被占用。

索引节点和数据块的位图各占一个块。由于一个字节由 8 位组成,这意味着,对于默认的 4 KB 块大小,块位图每个块组可以表示最多 8 x 4 KB = 32,768 个块。这可以通过 mkfs 的输出或 tune2fs 程序进行验证。

索引节点表

除了索引节点位图外,块组还包含一个索引节点表。索引节点表跨越一系列连续的块。Ext4 索引节点的定义存在于 fs/ext4/ext4.h 文件中:

struct ext4_inode {
        __le16  i_mode;         /* File mode */
        __le16  i_uid;          /* Low 16 bits of Owner Uid */
        __le32  i_size_lo;      /* Size in bytes */
        __le32  i_atime;        /* Access time */
        __le32  i_ctime;        /* Inode Change time */
        __le32  i_mtime;        /* Modification time */
        __le32  i_dtime;        /* Deletion Time */
        __le16  i_gid;          /* Low 16 bits of Group Id */
        __le16  i_links_count;  /* Links count */
        __le32  i_blocks_lo;    /* Blocks count */
        __le32  i_flags;        /* File flags */
[…………..]

Ext4 索引节点的大小为 256 字节。以下是一些特别关注的字段:

  • i_uidi_gid 字段用作用户和组标识符。

  • i_atimei_ctimei_mtime。它们分别描述了最后访问时间、索引节点变更时间和数据修改时间。文件删除时间保存在 i_dtime 中。这 4 个字段是 32 位带符号整数,表示自 Unix 纪元时间(1970 年 1 月 1 日 00:00:00 UTC)以来经过的秒数。为了进行亚秒级别的时间计算,使用 i_atime_extrai_mtime_extrai_ctime_extra 字段。

  • i_links_count 字段。这是一个 16 位值,意味着 Ext4 允许每个文件最多有 65K 个硬链接。

  • i_block,它是一个长度为 EXT4_N_BLOCKS 的数组。EXT4_N_BLOCKS 的值为 15。正如在 第二章 中讨论的,索引节点结构使用指针进行块地址映射。首先,12 个指针直接指向块地址,称为 直接指针。接下来的三个指针是间接指针。间接指针 指向一个包含指针的块。第 13、第 14 和第 15 个指针分别提供单级、双级和三级间接指向。

块组描述符

分组描述符存储在超级块之后的文件系统布局中。每个块组都有一个与之关联的分组描述符,因此块组的数量与分组描述符的数量相同。理解块组描述符是描述文件系统中每个块组的内容这一点非常重要。这意味着它们包含了有关本地块组以及文件系统中所有其他块组的信息。分组描述符结构定义在fs/ext4/ext4.h中:

struct ext4_group_desc
{
        __le32  bg_block_bitmap_lo;     /* Blocks bitmap block */
        __le32  bg_inode_bitmap_lo;     /* Inodes bitmap block */
        __le32  bg_inode_table_lo;      /* Inodes table block */
        __le16  bg_free_blocks_count_lo;/* Free blocks count */
        __le16  bg_free_inodes_count_lo;/* Free inodes count */
        __le16  bg_used_dirs_count_lo;  /* Directories count */
        __le16  bg_flags;               /* EXT4_BG_flags (INODE_UNINIT, etc) */
[…………]

下面是一些重要字段的进一步描述:

  • bg_block_bitmap_lobg_inode_bitmap_lobg_inode_table_lo,而最重要的位则存储在 bg_block_bitmap_hibg_inode_bitmap_hibg_inode_table_hi 中。

  • bg_free_blocks_count_lobg_free_blocks_count_hibg_free_inodes_count_lobg_free_inodes_count_hibg_used_dirs_count_lobg_used_dirs_count_hi

由于每个块组描述符包含关于本地和非本地块组的信息,因此它包含文件系统中每个块组的描述符。因此,可以从任何单一块组中确定以下信息:

  • 空闲块和 inode 的数量

  • 文件系统中 inode 表的位置

  • 块和 inode 位图的位置

保留的 GDT 块

Ext4 文件系统最有用的功能之一是其动态扩展功能。Ext4 文件系统的大小可以在不中断的情况下动态增加。保留的组描述符表GDT)块在文件系统创建时就被预留出来。这是为了帮助文件系统扩展过程。增加文件系统的大小涉及到增加物理磁盘空间并在新增加的磁盘空间中创建文件系统块。这也意味着,为了容纳新增的空间,将需要更多的块组和组描述符。当要扩展一个 Ext4 文件系统时,这些预留的 GDT 块就会被使用。

日志记录模式

像大多数文件系统一样,Ext4 也实现了日志记录的概念,以防止系统崩溃时数据损坏和不一致。默认的日志大小通常只有几兆字节。Ext4 中的日志记录使用内核中的通用日志记录层,称为jbd2进程。这是负责更新 Ext4 日志的内核线程。

Ext4 在日志记录方面提供了极大的灵活性。Ext4 文件系统支持三种日志记录模式。根据需求,如果需要,日志记录模式可以更改。默认情况下,在创建文件系统时启用日志记录。如果需要,可以稍后禁用它。不同的日志记录模式在这里进行了解释:

  • Ordered(有序模式):在有序模式下,只有元数据被日志记录。实际数据直接写入磁盘。操作的顺序严格按照规定执行。首先,元数据被写入日志;其次,实际数据被写入磁盘;最后,元数据被写入磁盘。如果发生崩溃,文件系统结构会被保留。然而,在崩溃时正在写入的数据可能会丢失。

  • Writeback(回写模式):回写模式也只记录元数据的日志。不同之处在于,实际数据和元数据可以按任何顺序写入。这种方法比有序模式稍微有风险,但性能要好得多。

  • Journal(日志模式):在日志模式下,数据和元数据都首先被写入日志,然后才提交到磁盘。这提供了最高级别的安全性和一致性,但可能会影响性能,因为所有写操作必须执行两次。

默认的日志模式是ordered(有序模式)。如果你想更改日志模式,你需要卸载文件系统,并在相应的fstab条目中添加所需的模式。例如,要将日志模式更改为writeback(回写模式),在/etc/fstab文件中相应的文件系统条目中添加data=writeback。完成后,你可以通过以下方式验证日志模式:

[root@linuxbox ~]# mount |grep sdc
/dev/sdc on /mnt type ext4 (rw,relatime,data=writeback)
[root@linuxbox ~]#

你还可以使用debugfs中的logdump命令显示文件系统日志的信息。例如,你可以通过如下方式查看sdc设备的日志:

[root@linuxbox ~]#  debugfs -R 'logdump -S' /dev/sdc
debugfs 1.44.6 (5-Mar-2019)
Journal features:         journal_64bit journal_checksum_v3
Journal size:             32M
Journal length:           8192
Journal sequence:         0x00000005
Journal start:            1
Journal checksum type:    crc32c
Journal checksum:         0xb78622f2
Journal starts at block 1, transaction 5
Found expected sequence 5, type 1 (descriptor block) at block 1
Found expected sequence 5, type 2 (commit block) at block 13
Found expected sequence 6, type 1 (descriptor block) at block 14
[…............]

文件系统扩展

我们已经介绍了使用间接指针来处理大文件的问题。通过使用间接指针,一个 inode 可以跟踪包含文件内容的数据块。对于大文件,这种方法变得有些低效。文件占用的块数越多,跟踪每个块所需的指针数量就越多。这就创建了一个复杂的映射方案,并增加了每个文件的元数据使用量。因此,对于大文件的一些操作执行起来会相对较慢。

Ext4 通过使用扩展来解决这个问题,并减少跟踪数据块所需的元数据。扩展是一个指针加上块的长度——基本上是一组连续的物理块。在使用扩展时,我们只需要知道这个连续范围的第一个和最后一个块的地址。例如,假设我们使用 4 MB 的扩展大小。要存储一个 100 MB 的文件,我们可以分配 25 个连续的块。由于这些块是连续的,我们只需要记住第一个和最后一个块的地址。假设块大小为 4 KB,在使用指针时,我们需要创建一个间接映射来存储一个 100 MB 的文件,这需要 25,600 个块。

块分配策略

在文件系统性能方面,碎片化是一个隐形的杀手。Ext4 文件系统使用多种技术来提高整体性能并减少碎片化。Ext4 中的块分配策略确保相关信息存在于同一文件系统块组内。

当要创建并保存新文件时,文件系统需要为该文件初始化一个 inode。然后,Ext4 会为该文件选择一个合适的块组。Ext4 的设计确保最大程度地做以下操作:

  • 在包含文件父目录的块组中分配 inode

  • 将文件分配到包含文件 inode 的块组

一旦文件已保存在磁盘上,过了一段时间,用户可能想向该文件添加新数据。Ext4 将开始从最近分配给该文件的块开始,寻找空闲块。

在写入数据到 Ext3 文件系统时,块分配器每次只分配一个 4KB 的块。假设块大小为 4KB,对于一个单独的 100MB 文件,块分配器需要调用 25600 次。同样,当文件被扩展并从块组中分配新的块时,这些块可能是随机顺序的。这样的随机分配可能导致过度的磁盘寻道。该方法扩展性差,且会导致碎片化和性能问题。Ext4 文件系统通过使用多块分配器显著改进了这一点。当创建新文件时,Ext4 中的多块分配器会在一次调用中分配多个块。这减少了开销并提高了性能。如果文件使用了这些块,数据将写入一个单一的多块范围。如果文件没有使用额外分配的块,这些块将被释放。

Ext4 文件系统使用延迟分配,并不会在写入操作时立即分配块。这是因为内核大量使用页面缓存。所有操作首先在内核的页面缓存中执行,然后在一段时间后刷新到磁盘。通过使用延迟分配,只有在数据实际写入磁盘时才会分配块。这非常有用,因为文件系统可以为保存文件分配连续的块。

Ext4 尽量将文件的数据块保存在与其 inode 相同的块组中。同样,目录中的所有 inode 也被放置在与目录相同的块组中。

检查 mkfs 操作的结果

让我们总结一下关于 Ext4 的讨论,看看当我们使用mkfs创建 Ext4 文件系统时会发生什么。以下命令在一个只有 1GB 的磁盘上运行:

[root@linuxbox ~]# mkfs.ext4 -v /dev/sdb
mke2fs 1.44.6 (5-Mar-2019)
fs_types for mke2fs.conf resolution: 'ext4'
Discarding device blocks: done
Filesystem label=
OS type: Linux
Block size=4096 (log=2)
Fragment size=4096 (log=2)
Stride=0 blocks, Stripe width=0 blocks
65536 inodes, 262144 blocks
13107 blocks (5.00%) reserved for the super user
First data block=0
Maximum filesystem blocks=268435456
8 block groups
32768 blocks per group, 32768 fragments per group
8192 inodes per group
Filesystem UUID: ebcfa024-f87b-4c52-b3e1-25f1d4d31fec
Superblock backups stored on blocks:
        32768, 98304, 163840, 229376
Allocating group tables: done
Writing inode tables: done
Creating journal (8192 blocks): done
Writing superblocks and filesystem accounting information: done

让我们检查一下输出结果。

mkfs.ext4的手册页所述,丢弃设备块功能对固态硬盘特别有用。默认情况下,mkfs命令会发出TRIM命令,通知底层驱动器擦除未使用的块。

该文件系统由 262,144 个 4 KB 的块组成。文件系统中的总 inode 数量为 65,536。UUID 可以在fstab中用于挂载文件系统。

当底层存储是 RAID 卷时,会使用跨步(stride)和条带宽度(stripe width)。

默认情况下,Ext4 文件系统会为超级用户保留 5%的空间。

我们可以看到文件系统将 262,144 个块划分为 8 个块组。每个块组共有 32,768 个块。每个块有 8,192 个 inode。这与前面提到的 inode 总数一致——即 8 x 8,192 = 65,536。

超级块结构的副本存储在多个块上。文件系统将始终使用主超级块进行挂载。但如果主超级块由于某种原因损坏,可以使用存储在不同块位置的备份来挂载文件系统。文件系统日志占用 8,192 个块,这使得日志的大小为 8,192 x 4 KB = 32 MB。

扩展文件系统是最古老的 Linux 特定软件项目之一。多年来,它在可靠性、可扩展性和性能方面经历了几次增强。大多数与 Ext4 相关的概念,如日志记录、使用扩展区和延迟分配,也适用于 XFS,尽管 XFS 使用不同的技术来实现这些功能。与所有基于块的文件系统一样,Ext4 将可用磁盘空间划分为固定大小的块。作为本地文件系统,它广泛使用在 VFS 中定义的结构,并根据自己的设计实现它们。由于其经过验证的稳定性,它是 Linux 发行版中最常用的文件系统。

网络文件系统

计算机网络和网络协议的发展使得远程文件共享成为可能。这催生了分布式计算和客户端-服务器架构的概念,这些可以称为分布式文件系统。其思想是将数据存储在一个或多个服务器上的中央位置。多个客户端通过不同的程序和协议请求访问这些数据。包括文件传输协议FTP)和安全文件传输协议SFTP)等协议。使用这些程序使得在两台计算机之间传输数据成为可能。

与任何传统文件系统相比,使用分布式方法的文件系统在运行时需要一些额外的元素。我们已经看到进程通过通用系统调用层发出读写请求。在传统文件系统的情况下,发出请求的进程和提供该请求的存储都属于同一系统。在分布式系统中,可能会有一个专门的客户端应用程序用于访问文件系统。响应如read ()等通用系统调用时,客户端会向服务器发送消息,请求访问特定资源的读取权限。

遵循这种方法的最古老的文件系统之一是 网络文件系统,简称 NFS。NFS 协议由 Sun Microsystems 于 1984 年创建。NFS 是一个分布式文件系统,允许访问存储在远程位置的文件。NFS 第 4 版是该协议的最新版本。由于客户端和服务器之间的通信是通过网络进行的,因此客户端的任何请求都会经过 开放系统互联OSI)模型中的所有层。

NFS 架构

从架构的角度来看,NFS 有三个主要组成部分:

  • 远程过程调用RPC):为了允许进程相互发送和接收消息,内核提供了不同的进程间通信IPC)机制。NFS 使用 RPC 作为 NFS 客户端与服务器之间的通信方式。RPC 是 IPC 机制的扩展。如其名称所示,在 RPC 中,客户端调用的过程不需要与客户端处于同一地址空间,它可以在远程地址空间中。RPC 服务在会话层实现。

  • 外部数据表示XDR):NFS 使用 XDR 作为 OSI 模型中表示层的二进制数据编码标准。使用 XDR 可以确保所有参与者在通信时使用相同的语言。采用标准化的数据传输方法是必要的,因为数据表示在不同系统之间可能有所不同。例如,NFS 的参与者可能在架构上有所不同,并且具有不同的字节序。例如,如果数据从一个使用大端架构的系统传输到一个使用小端架构的系统,字节将以相反的顺序接收。XDR 使用规范化的数据表示方法。当 NFS 客户端需要在 NFS 服务器上写入数据时,它将把相关数据的本地表示转换为其等效的 XDR 编码。同样,当服务器接收到这个 XDR 编码的数据时,它将解码并转换回本地表示。

  • NFS 程序:所有的 NFS 操作都在 OSI 模型的应用层执行。此层定义的程序指定了可以在 NFS 服务器上执行的不同任务。这些程序包括文件操作、目录操作和文件系统操作。

图 3.8 描述了使用 NFS 时 I/O 请求的流向:

图 3.8 – NFS 中 I/O 请求的流向

图 3.8 – NFS 中 I/O 请求的流向

在版本 2 中,NFS 使用 UDP 作为底层传输协议,因此,NFS v2 和 v3 是无状态的。这种方法的一个优点是,由于使用 UDP 时的开销较低,性能稍微更好。自版本 4 以来,默认协议已更改为 TCP。使用 TCP 挂载 NFS 共享是一个更可靠的选项。NFS 版本 4 是有状态的,这意味着 NFS 客户端和服务器都会维护关于打开文件和文件锁定的信息。在服务器崩溃的情况下,客户端和服务器都会努力恢复故障之前的状态。NFS 版本 4 还引入了复合请求格式。通过使用复合请求格式,NFS 客户端可以将多个操作合并为单个请求。复合过程充当包装器,将一个或多个操作合并为单个 RPC 请求。

与任何常规文件系统一样,NFS 也需要挂载以建立客户端和服务器之间的逻辑连接。此挂载操作与本地文件系统有所不同。在挂载 NFS 文件系统时,我们不需要创建文件系统,因为文件系统已经存在于远程侧。mount命令将包括要挂载的远程目录的名称。在 NFS 术语中,这称为导出。NFS 服务器会保留可以导出的文件系统列表以及允许访问这些导出的主机列表。

NFS 服务器使用特殊结构来唯一标识文件。这种结构称为文件句柄。此句柄利用 inode 号码、文件系统标识符和生成号码。生成号码在此识别过程中起着关键作用。假设文件 A 的 inode 号为 100,并被用户删除。然后创建了一个新文件 B,并分配了最近释放的 inode 号 100。当尝试使用其文件句柄访问文件时,这可能会导致混淆,因为现在文件 B 使用了之前分配给文件 A 的 inode 号。因此,文件句柄结构还使用生成号码。每当服务器重用 inode 时,生成号码都会递增。

将 NFS 与常规块文件系统进行比较

网络文件系统也被称为文件级存储。因此,对 NFS 执行的 I/O 操作被称为文件级 I/O 操作。与块文件系统不同,文件级 I/O 在请求操作时并不指定文件的块地址。跟踪文件在磁盘上的确切位置是 NFS 服务器的工作。当 NFS 服务器接收到来自 NFS 客户端的请求时,它会将请求转换为块级请求并执行相应操作。这确实引入了额外的开销,也是 NFS 性能远逊色于常规块文件系统的主要原因之一。在块文件系统和存储的情况下,应用程序可以自由决定如何访问或修改文件系统块。而对于 NFS,文件系统结构的管理完全由 NFS 服务器负责。

我们可以看到 NFS 和块文件系统之间的区别:

图 3.9 —— NFS 与块文件系统的对比

图 3.9 —— NFS 与块文件系统的对比

总结一下,NFS 是最流行的远程文件共享协议之一。它具有分布式特性,并遵循客户端-服务器架构。从 NFS 客户端发出的请求最终会经过整个网络栈到达 NFS 服务器。为了在客户端和服务器之间标准化数据表示,NFS 在 OSI 模型的表示层使用 XDR 进行数据编码。尽管与常规块存储相比其性能较差,但它仍在大多数企业基础设施中得到应用,主要用于备份和归档。

FUSE —— 一种独特的创建文件系统的方法

我们已经讨论过内核是如何将系统分为两个部分的:用户空间和内核空间。所有特权系统资源都驻留在内核空间。包括文件系统在内的内核代码也存在于内核空间中的一个单独内存区域。普通的用户空间应用程序无法访问这些代码。用户空间和内核空间程序之间的区分限制了任何常规进程修改内核代码。

尽管这种方法对内核设计至关重要,但它在开发过程中确实会带来一些问题。以任何文件系统为例。由于所有的文件系统代码都存在于内核空间,一旦文件系统代码出现 bug,调试和故障排除会变得极为困难,因为存在这种隔离。任何对文件系统的操作也必须由 root 用户来执行。

用户空间文件系统FUSE)框架的设计旨在解决这些限制。通过使用 FUSE 接口,可以创建文件系统,而无需修改内核代码。因此,这种文件系统的代码只存在于用户空间。文件系统上的实际数据和元数据由用户空间进程管理。这是非常灵活的,因为它允许非特权用户挂载文件系统。值得注意的是,基于 FUSE 的文件系统是可以叠加的,这意味着它们可以部署在现有文件系统之上,例如 Ext4 和 XFS。使用这种方法的最广泛使用的 FUSE 解决方案之一是 GlusterFS。GlusterFS 作为一个用户空间文件系统运行,并可以叠加在任何现有的基于块的文件系统之上,如 Ext4 或 XFS。

FUSE 提供的功能是通过使用一个内核模块(fuse.ko)和一个使用 libfuse 库的用户空间守护进程来实现的。FUSE 内核模块负责将文件系统注册到 VFS。用户空间守护进程与内核之间的交互是通过一个字符设备 /dev/fuse 来实现的。这个设备在用户空间守护进程和内核模块之间充当桥梁角色。用户空间守护进程将从该设备读取并写入请求:

图 3.10 – FUSE 方法

图 3.10 – FUSE 方法

当用户空间中的进程对 FUSE 文件系统执行任何操作时,相关的系统调用会发送到 VFS 层。VFS 检查该操作是否对应于 FUSE 基于的文件系统,如果是,它将把请求转发到 FUSE 内核模块。FUSE 驱动程序将创建一个请求结构并将其放入 /dev/fuse 中的 FUSE 队列。内核模块与 libfuse 库之间的通信是通过一个特殊的文件描述符来实现的。用户空间守护进程将打开 /dev/fuse 设备以处理结果。如果 FUSE 文件系统叠加在现有文件系统之上,那么请求将再次被路由到内核空间,以便传递给底层的文件系统。

FUSE 文件系统不像传统文件系统那样强大,但它们提供了极大的灵活性。它们易于部署,并且可以由非特权用户挂载。由于文件系统代码位于用户空间,因此更容易进行故障排除和修改。即使代码中存在 bug,也不会影响内核的功能。

摘要

在前两章介绍了 VFS 的工作原理后,本章向您介绍了常见文件系统及其概念。Linux 内核能够支持约 50 种文件系统,涵盖每一种是一项不可能的任务。我们专注于 Linux 中的本地文件系统,因为内核可以直接支持它们。我们解释了一些在一组文件系统中常见的特性,例如日志记录、CoW 机制和 FUSE。本章的重点是扩展文件系统的工作和内部设计。扩展文件系统自内核版本 0.96 以来就存在,并且是计算平台上部署最广泛的文件系统。我们还介绍了网络文件系统的体系结构,并解释了文件与块存储之间的区别。最后,我们讨论了 FUSE,它为用户空间程序向 Linux 内核导出文件系统提供了接口。

通过这一章,我们已经完成了对 VFS 和内核文件系统层的探索。这标志着本书第一部分的结束。我希望迄今为止这是一次良好的学习旅程,并希望它能继续保持下去。本书的第二部分将从第四章开始,重点介绍内核中的块层,它为文件系统提供了上游接口。

第二部分:穿越块层

本部分介绍了块层在 Linux 内核中的作用。块层是内核存储栈的关键部分,因为用户空间应用程序使用块层中实现的接口来访问可用的存储设备。本部分将解释块层及其主要组件,如设备映射器框架、块设备、块层数据结构、多队列框架和不同的 I/O 调度器。

本部分包含以下章节:

  • 第四章理解块层、块设备和数据结构

  • 第五章理解块层、多队列和设备映射器

  • 第六章理解块层中的 I/O 处理和调度

第四章:理解块层、块设备和数据结构

本书的前三章主要围绕内核 I/O 层次结构的第一个组成部分——VFS 层展开。我们解释了 VFS 的功能和目的,以及它如何充当通用系统调用接口与文件系统之间的中介层,并介绍了其主要数据结构。此外,我们还讨论了可以在 VFS 层下找到的文件系统,并介绍了一些与之相关的基本概念。

我们现在将重点转向内核存储层次结构中的第二个主要部分:块层。块层处理块设备,并负责处理在块设备上执行的 I/O 操作。所有的用户空间程序都使用块层接口来寻址和访问底层存储设备。在过去的十多年里,物理存储介质发生了显著变化,从较慢的机械硬盘转向了更快的闪存驱动器。因此,内核中的块层也经历了大量修改。由于存储硬件的性能至关重要,内核代码进行了多项优化,以便磁盘驱动器能够充分发挥其潜力。本章中,我们将介绍块层,定义块设备,然后深入探讨块层中的主要数据结构。

以下是接下来内容的总结:

  • 解释块层的作用

  • 定义块设备

  • 块设备的定义特征

  • 块设备的表示

  • 查看块层中的主要数据结构

  • I/O 请求在块层的传递过程

技术要求

Linux 内核的块层是一个稍显复杂的话题。理解前三章中介绍的内容将有助于你理解块层与各种文件系统之间的交互。具备 C 编程语言的经验将帮助你理解本章中展示的代码。此外,任何 Linux 系统的实践经验都将增强你对本文中讨论的概念的理解。

如果你想下载内核源码,可以从www.kernel.org下载。本章和本书中引用的代码片段来自内核 5.19.9

解释块层的作用

块层负责实现内核接口,使文件系统能够与存储设备交互。在访问物理存储的过程中,应用程序使用块设备,所有对这些设备数据的访问请求都由块层管理。内核还包含一个位于块层之上的映射层。该层提供了一种灵活而强大的方式,将一个块设备映射到另一个设备,从而支持创建快照、加密数据和创建跨多个物理设备的逻辑卷等操作。块层中实现的接口对于管理 Linux 中的物理存储至关重要。块设备的设备文件位于/dev目录下。

像 VFS 一样,抽象是块层的核心功能。VFS 层允许应用程序以通用的方式进行文件交互请求,而无需担心底层文件系统。同样,块层也允许应用程序以统一的方式访问存储设备。应用程序无需关心后端存储介质的选择。

为了突出块层的主要功能,让我们在描述 VFS 时所定义的存储层次结构基础上进行扩展。下图概述了块层的主要组件:

图 4.1 – 从 VFS 到块层的 I/O 层次结构

图 4.1 – 从 VFS 到块层的 I/O 层次结构

让我们简要了解这些功能:

  • 块层提供了一个向上接口供文件系统使用,使其能够以统一的方式访问各种存储设备。同样,它也为驱动程序和存储设备提供下行接口,通过提供一个所有应用程序都可以访问的单一入口点。

  • 正如我们将在本章中看到的,块层包含了一些复杂的结构,以通用的方式提供其服务。其中最重要的结构可能是bio结构。文件系统层创建一个 bio 结构来表示 I/O 请求,并将其传递到块层。bio 结构负责将所有 I/O 请求传输到驱动程序。映射层则负责提供一个基础设施,将物理块设备映射到逻辑设备。映射层可以通过使用内核中的设备映射框架来实现这一点。设备映射器为内核中的几种技术奠定了基础,包括卷管理、多路径、精简配置、加密和软件 RAID。其中最著名的技术是逻辑卷管理LVM)。设备映射器将每个逻辑卷创建为一个映射设备。LVM 为存储管理员提供了极大的灵活性,并简化了存储管理。

  • blk-mq 框架已经成为块层的重要组成部分,因为它通过为每个 CPU 核心隔离请求队列解决了性能限制。这个框架负责将块 I/O 请求引导到多个派发队列。我们将在第五章中详细介绍 blk-mq 框架。

  • 块层还包括几个用于处理 I/O 请求的调度程序。这些调度程序是可插拔的,并且可以为单个块设备设置。非多队列调度程序已被弃用,并且在现代内核中不再受支持。正如我们在第六章中看到的,这些调度程序利用多种技术来做出关于 I/O 调度的智能决策。

  • 此外,块层还实现了如错误处理和收集块设备 I/O 统计信息等功能。

块层的核心是块设备。除了像磁带驱动器这样的流式数据设备外,大多数存储设备,如机械硬盘和固态硬盘(闪存卡),都被认为是基于块的设备。让我们来看看块设备的定义特征以及它们在 Linux 中的表现。

定义块设备

内核与外部设备交换数据有两种主要方式。一种方法是与设备逐个字符地交换数据。通过这种方法寻址的设备被称为字符设备。字符设备通过一串连续的数据进行寻址。程序可以通过它们来执行逐字符的输入和输出操作。由于缺少随机访问方法,内核管理字符设备相对简单。键盘、基于文本的控制台和串行端口等设备都是字符设备的例子。

当数据量较少时,通过逐个字符进行通信是可以接受的,例如使用串行端口或键盘时。键盘一次只能接受一个字符,因此使用字符接口是合理的。但当传输大量数据时,这种方式就变得不可行。当写入物理磁盘时,我们希望它们能够一次处理多个字符,并允许数据随机访问。内核以固定大小的块(称为块)来寻址物理驱动器。除了传统磁盘外,光盘驱动器和闪存驱动器等设备也使用这种方法。这些设备被称为块设备。与字符设备相比,块设备更复杂,管理起来需要更多的注意。内核必须在块设备的寻址和组织上做出重要决策,因为这些决策不仅会影响块设备本身,还会影响整个系统的性能。

块设备可以存在于内存中。这可以通过创建一个 ramdisk 来实现。ramdisk 的一个显著应用场景是在 Linux 系统的启动序列中。初始 ramdiskinitrd)负责在内存中加载一个临时根文件系统,以帮助启动过程。可以在 ramdisk 上创建文件系统并像常规文件系统一样挂载。由于 RAM 的速度非常快,ramdisk 的速度也非常快。但由于 RAM 的易失性,写入 ramdisk 的任何数据仅在设备开机时保留。

尽管 ramdisk 也是基于块的,但它们很少被使用。正如你在本书中将看到的,块设备通常被视为具有文件系统层的持久数据存储介质。

所有对块设备的操作都由内核以固定大小的 N 字节块进行,这些块被称为 ,是处理块设备时的交换货币。N 的实际值在内核 I/O 层次结构中的不同层次中有所不同,因为不同的层次使用不同大小的块来处理块设备。因此,术语“块”根据其在堆栈中的位置有不同的定义:

  • 用户空间应用程序:当应用程序通过标准系统调用与内核空间交互时,在此上下文中指的是通过系统调用读取和写入的数据量。根据应用程序的不同,这个大小可以有所不同。

  • 页面缓存:内核广泛使用 VFS 页面缓存来提高读写操作的性能。在这里,数据传输的基本单元是 页面,其大小为 4 KB。

  • 基于磁盘的文件系统:如在第三章中所述,块表示文件系统进行 I/O 操作时固定数量的字节。尽管文件系统允许更大的块大小,通常最大可达 64 KB,但由于页面大小的原因,文件系统的块大小通常在 512 字节到 4 KB 之间。

  • 物理存储:在物理磁盘上,最小的可寻址单元被称为扇区,通常为 512 字节。该扇区通常会进一步分类为逻辑扇区或物理扇区。

我们在第三章中讨论了文件系统块。不要混淆;文件系统块的大小不是块 I/O 的基本单元。块 I/O 的基本单元是扇区。块层中的数据结构在内核代码中定义了一个 sector_t 类型的变量,表示一个偏移量或大小,它是 512 的倍数。sector_t 变量被定义为一个无符号整数类型,足够大以表示块设备可以寻址的最大扇区数。它在块层中被广泛使用,在诸如 “bio” 等结构中表示磁盘地址和偏移量。

总结一下,按块组织和寻址的设备称为块设备。它们允许随机访问,并且相比字符设备提供更优的性能。为了充分利用块设备,内核必须做出关于其寻址和组织的明智决策。

让我们简要回顾一下定义块设备的一些关键特性。

块设备的定义特征

如前所述,块设备允许使用更为高级的方式来处理 I/O 请求。块设备的一些定义特征如下:

  • 随机访问:块设备允许随机访问。这意味着设备可以从一个位置跳跃到另一个位置。

  • 块大小:块设备以固定大小的块进行寻址和数据传输。

  • 可堆叠性:块设备可以通过设备映射框架进行堆叠。这扩展了物理磁盘的基本功能,并允许扩展逻辑卷。

  • 缓冲 I/O:块设备使用缓冲 I/O,这意味着数据在写入设备之前会先写入内存中的缓冲区。对块设备的读写操作广泛使用页面缓存。读取块设备中的数据时,会将其加载并保留在内存中一段时间。同样,任何要写入块设备的数据都会先写入缓存。

  • 文件系统/分区:块设备可以被分割成较小的逻辑单元,并在其上创建独立的文件系统。

  • 请求队列:块设备实现了请求队列的概念,负责管理提交到块设备的 I/O 请求。

让我们看看块设备在 Linux 中是如何表示的。

查看块设备的表示方式

在讨论 VFS 时,我们看到抽象是内核 I/O 堆栈的核心。块层也不例外。无论物理设备的型号和制造方式如何,内核都应该能够统一地与存储设备进行交互。为了为所有设备实现标准接口,操作应该独立于底层存储设备的属性。

如在 第一章 中解释的那样,几乎所有的东西都以文件的形式表示,包括硬件设备。块设备是一个特殊的文件,之所以如此命名,是因为内核通过固定数量的字节与其交互。根据设备的性质,表示它们的文件会在系统中的特定位置创建和存储。系统中的块设备位于 /dev 目录下。表示磁盘驱动器的文件名以 sd 开头,后面跟着一个字母表示发现顺序。第一个驱动器命名为 sda,依此类推。类似地,sda 驱动器上的第一个分区表示为 sda1。如果我们查看 /dev 中的 sd* 设备,会注意到文件类型是 b,表示块设备。你也可以使用 lsblk 命令列出块设备,如下图所示:

图 4.2 – 主次设备号

图 4.2 – 主次设备号

在修改时间戳之前,注意到两个数字之间用逗号分隔。内核将块设备表示为一对数字。这些数字被称为设备的主设备号和次设备号。主设备号 标识与设备关联的驱动程序,而 次设备号 用于区分不同的设备。

在前面的图中,所有三个设备——sdasda1sda2——使用相同的驱动程序,因此具有相同的主设备号 8。次设备号——012——用于标识每个设备的驱动程序实例。

图 4.3 – 设备主次设备号

图 4.3 – 设备主次设备号

/dev 目录下的设备文件与相应的设备驱动程序连接,以建立与实际硬件的通信链接。当程序与块设备文件交互时,内核通过主设备号识别该设备的适当驱动程序,并发送请求。由于一个驱动程序可能负责处理多个设备,因此必须有一种方法,使内核能够区分使用相同主设备号的设备。为此,使用次设备号。

现在我们将探讨在块层中使用的主要数据结构。

查看块层中的数据结构

处理块设备相对复杂,因为内核必须实现诸如队列管理、调度和随机数据访问等功能。块设备的速度远高于字符设备,这使得块设备对性能极为敏感,内核必须做出智能决策,以发挥其最大性能。因此,有必要将这两种设备区分开来处理。正因如此,内核有一个专门的子系统来管理块设备。所有这些使得块层成为 Linux 内核中最复杂的代码部分。

在本书中,我们提到了一些内核代码的相关部分,以便你能熟悉某些概念的实现。如果你有兴趣从事内核开发,这可能是一个很好的起点。然而,如果你更关注理论理解,代码的使用可能会让人稍感困惑。但了解某些事物在内核中如何表示是非常必要的。具体来说,关于块层(block layer),无法讨论所有构成其复杂设计的结构。然而,我们必须强调一些更为重要的构造,它们能帮助我们理解内核中块设备的表示和组织。

用于处理块设备的一些主要数据结构如下:

  • register_blkdev

  • block_device

  • gendisk

  • buffer_head

  • bio

  • bio_vec

  • request

  • request_queue

让我们逐一看一下它们。

register_blkdev 函数(块设备注册)

为了使块设备可供使用,它们必须首先在内核中注册。注册过程由 register_blkdev() 函数执行,该函数定义在 include/linux/blkdev.h 中:

int __register_blkdev(unsigned int major, const char *name,
                void (*probe)(dev_t devt))

register_blkdev 函数由块设备驱动程序用于注册自己,它是一个宏,指向 __register_blkdev__register_blkdev 函数执行实际的注册过程。设置一个单独的内部函数是为了在修改内核数据结构之前提供额外的错误处理和验证。

注册函数执行以下任务:

  • 它从内核的动态主设备号分配池请求一个主设备号。主设备号在系统中唯一标识块设备驱动程序。

  • 一旦成功获取主设备号,函数将创建一个 block_device 结构体,代表块设备驱动程序。该结构体包含诸如主设备号、驱动程序名称和指向各种驱动操作的函数指针等信息。

总结来说,register_blkdev 函数作为一个友好的接口,使得块设备驱动程序能够启动与内核块层的注册过程。它处理获取主设备号、创建 block_device 结构体以及与块层建立必要连接的步骤。

block_device 结构体(表示块设备)

块设备在内核中通过 include/linux/blk_types.h 中的 block_device 结构体定义:

struct block_device {
        sector_t                bd_start_sect;
        sector_t                bd_nr_sectors;
        struct disk_stats __percpu *bd_stats;
        unsigned long           bd_stamp;
        bool                    bd_read_only;
        dev_t                   bd_dev;
        atomic_t                bd_openers;
        struct inode *          bd_inode;
[……..]

block_device 结构体实例在设备文件打开时创建。块设备可以是整个磁盘或单个分区,block_device 结构体可以表示这两者。当使用分区时,个别分区通过 bd_partno 字段进行标识。由于对块设备的访问是通过 VFS 层完成的,相应的设备文件也会被分配一个 inode 编号。块设备的 inode 是虚拟的,存储在 bdev 虚拟文件系统中。块设备的 inode 还包含其主设备号和次设备号的信息。

block_device 结构体还提供了有关设备的信息,如名称、大小和块大小。它还包含指向 gendisk 结构体的指针,该结构体表示磁盘,并包含一个 request_queues 结构体列表,用于处理 I/O 请求。

gendisk 结构体(表示物理磁盘)

block_device 结构体定义中的一个重要字段是 bd_disk 指针,它指向 gendisk 结构体。gendisk 结构体定义在 include/linux/blkdev.h 中,表示关于磁盘的信息,并用于在内核中实现物理硬盘的概念。

gendisk 结构体表示磁盘的属性及用于访问磁盘的方法。它用于向内核注册一个块设备及其相关的 I/O 操作,使内核能够与设备进行通信:

struct gendisk {
        int major;
        int first_minor;
        int minors;
        char disk_name[DISK_NAME_LEN];
        unsigned short events;
        unsigned short event_flags;
[……]

gendisk 可以被看作是前述块设备接口与文件系统接口以及硬件接口之间的一个桥梁。在 gendisk 中,会有一个 block_device 结构体来表示整个物理磁盘。同样,也会有单独的 block_device 结构体来描述 gendisk 中的各个分区。需要注意的是,gendisk 是由块设备驱动分配和控制的,并通过 register_blkdev 函数向内核注册。一旦注册,块设备驱动就可以使用 gendisk 结构体对设备进行 I/O 操作。

让我们来看看这个结构体的一些重要字段:

  • major:该字段指定与 gendisk 结构体关联的主设备号。如前所述,主设备号由内核用于标识负责处理块设备的驱动程序。

  • first_minor:该字段表示分配给给定块设备的最小次设备号。可以将其视为一个偏移量,后续设备分区的次设备号会从这个偏移量开始分配。

  • minors:该字段指定与 gendisk 结构体关联的次设备号总数。

  • fops:该字段指向与 gendisk 结构体关联的文件操作结构体。这些文件操作由内核用于处理对设备的读、写及其他文件操作。

  • private_data:该字段由驱动程序用于存储与gendisk结构相关的任何私有数据,例如任何特定于驱动程序的信息。

  • queue:该字段指向与gendisk结构相关联的请求队列。请求队列负责管理发送到设备的 I/O 请求。因此,这是一个非常重要的字段,它使内核能够将特定的 I/O 队列与每个块设备关联起来。通过为每个块设备设置独立的 I/O 队列,内核可以独立管理多个块设备,并更高效地处理它们的 I/O 操作。这使得内核能够优化性能,应用适当的调度策略,并防止 I/O 瓶颈。

  • disk_name:该字段是一个字符串,指定设备的名称。该名称由内核用来识别设备,通常会在系统日志中显示。

让我们继续查看下一个结构。

buffer_head结构(表示内存中的块)

块设备的一个显著特点是它广泛使用页面缓存。对块设备的读写操作是在缓存中进行的。当应用程序首次从块中读取时,数据块会从物理磁盘加载到内存中。同样,当程序想要写入数据时,写操作首先会在缓存中进行。稍后会将数据写入物理磁盘。

从磁盘读取或要写入磁盘的块存储在一个缓冲区中。这个缓冲区由buffer_head结构表示,它在内核的include/linux/buffer_head.h中定义。我们可以说,这个缓冲区是一个内存中表示的单独块:

struct buffer_head {
        unsigned long b_state;
        struct buffer_head *b_this_page;
        struct page *b_page;
        sector_t b_blocknr;
        size_t b_size;
[………...]

buffer_head结构中的字段包含了唯一标识块设备中特定块所需的信息。buffer_head结构中的字段描述如下:

  • b_data:该字段指向与buffer_head关联的数据缓冲区的起始位置。缓冲区的大小由文件系统的块大小决定。

  • b_size:该字段指定缓冲区的大小(以字节为单位)。

  • b_page:这是一个指向内存中存储该块的页面的指针。该字段通常与b_datab_size等字段一起使用,以操作缓冲区数据。

  • b_blocknr:该字段指定文件系统中文件缓冲区的逻辑块号。文件系统中的每个块都分配一个唯一的编号,称为逻辑块号。这个编号表示块在文件系统中的顺序,从 0 开始为第一个块。

  • b_state:该字段是一个位域,表示缓冲区的状态。它可以具有多个值。例如,值为BH_Uptodate表示缓冲区包含最新数据,而BH_Dirty表示缓冲区包含脏数据(已修改的数据),需要写入磁盘。

  • b_count:该字段跟踪buffer_head的使用者数量。

  • b_page:该字段指向页面缓存中包含与buffer_head结构相关数据的页面。

  • b_assoc_map:该字段用于一些文件系统追踪当前与buffer_head关联的块。

  • b_private:该字段是指向与buffer_head关联的私有数据的指针。文件系统可以使用它来存储与缓冲区相关的信息。

  • b_bdev:该字段是指向缓冲区所属的块设备的指针。

  • b_end_io:该字段是一个函数指针,指定对缓冲区进行 I/O 操作的完成函数,并用于执行任何必要的清理操作。

默认情况下,由于文件系统的块大小等于页面大小,因此内存中的单个页面可以容纳一个块。如果块大小小于页面大小,则页面可以容纳多个块。

buffer_head维持内存中页面与其对应的磁盘版本之间的映射。尽管它仍然保存重要信息,但在 2.6 版本之前,它是内核中的一个更加核心的组成部分。当时,除了维持页面与磁盘块的映射外,它还充当块层所有 I/O 操作的容器。将buffer_head作为 I/O 容器的使用导致了大量内存的占用。在处理大量 I/O 请求时,内核必须将其拆分为更小的请求,每个请求都与一个buffer_head结构相关联。

bio结构(表示活动块 I/O)

由于buffer_head结构的限制,bio结构被创建来表示正在进行的块 I/O 操作。自内核 2.5 版本以来,bio结构一直是块层 I/O 的基本单元。当应用程序发出 I/O 请求时,底层文件系统将其转换为一个或多个bio结构,并将这些结构传递到块层。块层随后使用这些bio结构向底层块设备发出 I/O 请求。bio结构在include/linux/blk_types.h中定义:

struct bio {
        struct bio              *bi_next;
        struct block_device     *bi_bdev;
        unsigned int            bi_opf;
……
        unsigned short          bi_max_vecs;
        atomic_t                __bi_cnt;
        struct bio_vec          *bi_io_vec;
[……….]

以下是一些特别有趣的字段:

  • bi_next:这是指向列表中下一个bio结构的指针,用于链接表示单个 I/O 操作的多个bio结构。理解这一点非常重要,因为一个 I/O 操作可能需要拆分成多个bio结构。

  • bi_vcnt:该字段指定用于描述 I/O 操作的bio_vec结构的数量。向量中的每个bio_vec结构描述一个在块设备和用户空间程序之间传输的连续内存块。

  • bi_io_vec:这是一个指向bio_vec结构数组的指针,该数组描述了与 I/O 操作关联的数据缓冲区的位置和长度。这为执行scatter-gather I/O 奠定了基础——即,数据可以分布在多个不连续的内存位置。

  • bi_vcnt:此字段指定与 I/O 操作关联的数据缓冲区的数量。每个数据缓冲区由一个bio_vec结构表示,包含指向内存缓冲区的指针和缓冲区的长度。

  • bi_end_io:这是一个指向函数的指针,当 I/O 操作完成时会调用该函数。该函数负责清理与 I/O 操作相关的任何资源,并唤醒任何等待操作完成的进程。

  • bi_private:这是一个指向与 I/O 操作关联的任何私有数据的指针。

  • bi_opf:这是一个位掩码,用于指定与 I/O 操作关联的任何附加选项或标志。这可能包括诸如强制同步 I/O禁用 写缓存等选项。

当用户空间应用程序发起 I/O 请求时,bio 结构跟踪块层中的所有活动 I/O 事务。一旦 bio 结构构建完成,它会通过submit_bio函数交给块 I/O 层。submit_bio()函数用于将 I/O 请求提交到块设备。一旦 I/O 被提交到块设备,它会被加入到请求队列中。submit_bio()函数不会等待 I/O 完成。

可以说,bio 结构充当了文件系统与块设备层之间的桥梁,使文件系统能够对块设备执行 I/O 操作。

bio_vec结构(表示向量 I/O)

bio_vec结构定义了块层中的向量或散布-聚集 I/O 操作。

bio_vec结构定义在include/linux/bvec.h中:

struct bio_vec {
        struct page     *bv_page;
        unsigned int    bv_len;
        unsigned int    bv_offset;
};

各字段描述如下:

  • bv_page:此字段保存指向包含要传输数据的页结构(struct page)的引用。如我们在第二章中所解释,页面是固定大小的内存块。

  • bv_offset:此字段保存页面内数据传输起始位置的偏移量。

  • bv_len:此字段保存要传输数据的长度。

bio_vec结构用于表示一个散布-聚集 I/O 操作。块层可能会构建一个包含多个bio_vec结构的单一 bio,每个结构表示内存中不同的物理页面和该页面内的不同偏移量。

请求和请求队列(表示挂起的 I/O 请求)

当 I/O 请求提交到块层时,块层会创建一个request结构来表示该请求。

requestrequest_queue结构分别定义在include/linux/blk-mq.hinclude/linux/blkdev.h中:

struct request {
        struct request_queue *q;
        struct blk_mq_ctx *mq_ctx;
        struct blk_mq_hw_ctx *mq_hctx;
[……..]

这里解释了一些主要字段:

  • struct request_queue *q:每个 I/O 请求都会添加到块设备的请求队列中。此处的q字段指向该请求队列。

  • struct blk_mq_ctx *mq_ctxblk_mq_ctx *mq_ctx字段指向软件暂存队列;此结构是按每个 CPU 核心分配的。每个 CPU 都有一个blk_mq_ctx,它用于跟踪该 CPU 上处理的请求状态。

  • struct blk_mq_hw_ctx *mq_hctx:此字段表示与请求队列关联的硬件上下文。它用于跟踪请求所属的硬件队列。

  • struct list_head queuelist:这是一个等待处理的请求的链表。当一个请求提交给块层时,它会被添加到此列表中。

  • struct request *rq_next:这是指向队列中下一个请求的指针,用于在请求队列内链接请求。

  • sector_t sector:此字段指定 I/O 操作的起始扇区号。

  • struct bio *bio:此字段指向一个包含 I/O 操作信息的bio结构,例如它的类型(读取或写入)。

  • struct bio *biotail:此字段指向队列中的最后一个bio结构。当一个新的 bio 被添加到队列时,它将被链接到 biotail 指向的列表末尾。

request_queue结构表示与块设备关联的请求队列。请求队列负责管理所有提交给块设备的 I/O 请求:

struct request_queue {
        struct request          *last_merge;
        struct elevator_queue   *elevator;
        struct percpu_ref       q_usage_counter;
[………..]

让我们来看一下其中一些重要的字段:

  • struct request *last_merge:此字段由 I/O 调度器使用,用来跟踪与另一个请求合并的最后一个请求。

  • struct elevator_queue *elevator:此字段指向请求队列的 I/O 调度器。I/O 调度器决定请求的服务顺序。

  • struct percpu_ref q_usage_counter:此字段表示请求队列的使用计数器。内核使用每个 CPU 的计数器来跟踪每个 CPU 上资源的引用计数。

  • struct rq_qos *rq_qos:此字段指向一个请求队列,该队列提供质量服务协议,用于块设备。它们用于根据不同的标准(例如请求的优先级)来优先处理 I/O 请求。

  • const struct blk_mq_ops *mq_ops:此结构包含函数指针,定义了多队列 I/O 调度器请求队列的行为。

  • struct gendisk *disk:此字段指向与请求队列关联的gendisk结构。gendisk表示一个通用的磁盘设备。

哇!这些字段太多了。让我们总结一下每个结构的作用,看看它们是如何协同工作的。

I/O 请求在块层的旅程

以下表格简明地概述了上一节中介绍的结构:

结构 表示的内容 描述
gendisk 物理磁盘 用于表示物理设备的整体,包含诸如磁盘容量和几何结构等信息
block_device 块设备 表示设备的特定实例,包含诸如主次设备号、分区信息和处理 I/O 请求的队列等信息
buffer_head 内存中的数据块 用于跟踪从块设备读取或写入到内存的数据
request I/O 请求 这包括诸如 I/O 操作类型和起始块号等信息
request_queue I/O 请求队列 该队列包含有关当前状态的信息,例如等待处理的请求数量
bio 块 I/O 这是一个更高层次的 I/O 请求,可以包含多个请求结构
bio_vec 内存缓冲区的散布-聚集列表 作为 bio 结构的一部分,用于描述一个单独的数据缓冲区

表 4.1 – 主要块层结构总结

让我们来看看这些结构之间的关系,当一个进程发出 I/O 请求时:

  1. 当应用程序在其地址空间的缓冲区中写入数据时,块层创建一个 buffer_head 结构来表示这些数据。

  2. 块层构建一个 bio 结构来表示块 I/O 请求,并将 buffer_head 结构映射到 bio_vec 结构。对于每个 bio,块层创建一个或多个 bio_vec 结构来表示读取或写入的数据。

  3. 然后,bio 结构通过 request 结构添加到目标块设备的 request_queue 结构中。

  4. 该设备的设备驱动程序通过 register_blkdev 注册后,将从 bio 结构队列中取出,并安排进行处理。

  5. 然后,bio 根据设备的块大小被拆分为一个或多个 request 结构。

  6. 每个 request 对象随后被添加到相应设备驱动程序的 request_queue 结构中进行处理。

  7. 在处理请求后,设备驱动程序将数据写入物理存储。

  8. 一旦 I/O 请求完成,设备驱动程序会通知块层。

  9. 然后,块层更新缓冲区缓存和相关的数据结构。它将请求结构标记为已完成,并通知任何等待的进程,I/O 操作已经完成。

  10. 相应的 buffer_head 结构被更新,以反映块设备上数据的当前状态。

块层通过其复杂的设计,使用一些复杂的结构来与块设备进行交互。我们介绍了一些主要结构,以帮助你理解系统内部如何运作。每个结构定义了大量的字段;我们尝试突出一些要点以帮助理解。

需要注意的是,旧版内核中的请求队列是单线程的,无法充分利用现代硬件的能力。Linux 内核在版本3.13中增加了多队列支持。实现多队列支持的框架被称为blk-mq。我们将在下一章中详细讨论多队列框架。

摘要

本书的第一部分,包括第一章第二章第三章,主要讲解了 VFS 和文件系统。第二部分,包括第四章第五章第六章,则全都关于块层。本章介绍了块层在 Linux 内核中的角色。/dev目录。与字符设备相比,操作块设备要复杂得多,因为字符设备只能按顺序工作。字符设备只有一个当前的位置。管理块设备对内核来说是一个更复杂的任务,因为块设备必须能够移动到任何位置,以提供对数据的随机访问。因此,性能在操作块设备时是一个主要的关注点。Linux 内核在块层提供了一个复杂的结构生态系统,用于操作块设备。

在下一章中,我们将基于我们的理解,看到 I/O 请求如何在块层中得到处理。我们还将讲解设备映射器和内核中的多队列框架。

第五章:理解块层、多队列和设备映射器

“我感到需要……需要速度。”——《壮志凌云》中的 Maverick

第四章介绍了内核中块层的作用。我们了解了块设备的构成,并探索了块层中的主要数据结构。本章将在此基础上继续加深对块层的理解。

本章将介绍两个主要概念:多队列块 I/O 机制和设备映射框架。近年来,为了应对性能问题,内核的块层经历了重大变化。多队列框架的引入是这一方向的重要里程碑,正如在第四章中讨论的那样。性能是处理块设备时的关键考虑因素,内核已经实施了多种改进来优化磁盘驱动器性能。在第四章中,我们查看了块层中的请求和响应队列结构,它们处理块设备的 I/O 请求。在本章中,我们将首先介绍单请求队列模型、其性能限制以及在使用现代高性能存储设备(如 NVMe 和 SSD)时块层面临的挑战。我们还将解释单请求队列模型如何影响多核系统的性能。

本章的第二个主要主题将是内核中的映射框架,称为设备映射器。内核中的设备映射器框架与块层协同工作,负责将物理块设备映射到逻辑块设备。正如我们将看到的,设备映射器框架为实现各种技术(如逻辑卷管理、RAID、加密和薄配置)奠定了基础。最后,我们还将简要讨论块层中的缓存机制。

我们将讨论以下主要主题:

  • 单请求队列的问题

  • 多队列块 I/O 机制

  • 设备映射器框架

  • 块层中的多级缓存

技术要求

除了我们之前讨论的 Linux 操作系统概念外,本章讨论的主题需要对现代处理器和存储技术有基本了解。任何在 Linux 存储管理方面的实践经验,都将大大增强你对某些方面的理解。

本章中展示的命令和示例是与发行版无关的,可以在任何 Linux 操作系统上运行,如 Debian、Ubuntu、Red Hat、Fedora 等。文中有许多与内核源代码相关的引用。如果你想下载内核源代码,可以从www.kernel.org下载。本章和本书中提到的代码片段来自内核5.19.9

看看单请求队列的问题

操作系统必须处理块设备,以确保它们能够充分发挥潜力。应用程序可能需要对块设备的任意位置执行 I/O 操作,这需要寻址多个磁盘位置,并可能延长操作的时间。当使用旋转机械硬盘时,持续的随机访问不仅会降低性能,还可能产生显著的噪音。尽管如今仍在使用,像 串行先进技术附件 (SATA) 这样的接口曾是机械硬盘的首选协议。内核块层的原始设计是为机械硬盘作为首选介质的时代所设计的。这些传统硬盘只能处理几百次 IOPs。两件事改变了这一点:多核处理器的崛起和硬盘技术的进步。随着这些变化,存储堆栈中的瓶颈从物理硬件转移到了内核中的软件层。

在传统设计中,内核的块层以以下方式处理 I/O 请求:

  • 块层维护了一个单一的请求队列,采用链表结构来处理 I/O 请求。新请求会被插入到队列的尾部。在将这些请求交给驱动程序之前,块层会对其进行合并和聚合等技术处理(我们将在下一章中解释)。

  • 在某些情况下,I/O 请求必须绕过请求队列,直接进入设备驱动程序。这意味着所有在请求队列中完成的处理都会由驱动程序执行。通常,这会导致性能负面影响。

即使使用了现代固态硬盘,这种设计仍然存在重大局限性。这种方法进一步导致了三重问题:

  • 包含 I/O 请求的请求队列无法扩展以满足现代处理器的需求。在多核系统中,单一请求队列必须在多个核心之间共享。因此,为了访问请求队列,使用了锁机制。这个全局锁用于同步对块层请求队列的共享访问。为了实现不同的 I/O 处理技术,CPU 核心需要获取请求队列的锁。这意味着,如果另一个核心需要操作请求队列,它必须等待相当长的时间。所有 CPU 核心都处于对请求队列锁的争用状态。很容易看出,这种设计使得请求队列成为多核系统中的单点争用。

  • 单一请求队列还会引入缓存一致性问题。每个 CPU 核心都有自己的 L1/L2 缓存,可能包含共享数据的副本。当一个 CPU 核心在获取请求队列的全局锁后修改一些数据,并在其缓存中更新这些数据时,其他核心的缓存中可能仍然包含这些数据的过时副本。因此,一个核心所做的修改可能不会及时传播到其他核心的缓存中。这会导致不同核心之间对于共享数据的视图不一致。当某个核心释放请求队列的全局锁时,其所有权会转移到已经在等待该锁的另一个核心。尽管存在几种缓存一致性协议,确保缓存保持共享数据的一致视图,但关键问题是,单队列设计本身并没有提供机制来同步不同 CPU 核心的缓存。这增加了确保缓存一致性所需的总体工作负载。

  • 这种在核心之间频繁切换请求队列锁的做法导致了中断次数的增加。

总的来说,使用多个核心意味着多个执行线程会同时竞争同一个共享锁。系统中 CPU/核心的数量越高,请求队列的锁竞争就越激烈。由于获取锁时的旋转和竞争,浪费了大量的 CPU 周期。在多插槽系统中,这大大减少了 IOPs 的数量。

图 5**.1 突出了使用单队列模型的局限性:

图 5.1 – 单请求队列模型

图 5.1 – 单请求队列模型

图 5**.1 中可以清楚地看出,无论 CPU 核心数和底层物理存储的类型如何,单队列块层的设计都无法扩展以匹配它们的性能需求。

在过去十年左右,企业存储环境已经转向固态硬盘和非易失性存储器。这些设备没有机械部件,能够并行处理 I/O 请求。这些设备的设计确保了在进行随机访问时不会出现性能惩罚。随着闪存驱动器成为首选的持久存储介质,过去在块层中用于处理 HDD 的传统技术已经过时。为了充分利用 SSD 的增强能力,块层的设计也需要相应成熟。

在接下来的章节中,我们将看到块层如何发展以应对这一挑战。

理解多队列块 I/O 框架

Linux 中的存储层次结构与 Linux 中的网络堆栈有些相似。两者都是多层次的,并严格定义了堆栈中每一层的角色。设备驱动程序和物理接口的参与决定了整体性能。与块层的行为类似,当一个网络包准备好传输时,它被放入一个单一队列中。这种方法使用了好几年,直到网络硬件发展到支持多个队列。因此,对于支持多个队列的设备,这种方法变得过时。

这个问题与后来内核中的块层面临的问题非常相似。Linux 内核中的网络堆栈比存储堆栈更早解决了这个问题。因此,内核的存储堆栈借鉴了这一点,最终创建了一个新的框架,称为 多队列块 I/O 排队机制,简称 blk-mq

多队列框架通过为每个 CPU 核心隔离请求队列,解决了块层的局限性。图 5.2 展示了这种方法如何修复单队列框架设计中的三个局限:

图 5.2 – 多队列框架

图 5.2 – 多队列框架

通过使用这种方法,CPU 核心可以专注于执行其线程,而无需担心其他核心上的线程。这种方法解决了共享全局锁所带来的局限性,并且最小化了中断的使用以及对缓存一致性的需求。

blk-mq 框架实现了以下两级队列设计,用于处理 I/O 请求:

  • bio 结构。一个块设备将有多个软件 I/O 提交队列,通常每个 CPU 核心一个队列,每个队列会有一个锁。一个拥有 M 个插槽和 N 个核心的系统,可以至少有 M 个队列,最多有 N 个队列。每个核心在其队列中提交 I/O 请求,并且不会与其他核心交互。这些队列最终会汇聚成一个设备驱动程序的单一队列。I/O 调度程序可以在暂存队列中的请求上进行操作,以重新排序或合并它们。然而,这种重新排序并不重要,因为 SSD 和 NVMe 驱动并不在乎 I/O 请求是随机的还是顺序的。这个调度仅发生在同一队列中的请求之间,因此不需要锁机制。

  • blk-mq 会将请求直接发送到硬件队列。

多队列 API 利用标签来指示哪个请求已经完成。每个请求都有一个标签,这是一个从零到分派队列大小之间的整数值。块层生成一个标签,随后该标签被设备驱动程序使用,从而消除了重复标识符的需要。一旦驱动程序完成请求的处理,标签将被返回到块层,以示操作完成。以下部分突出了在多队列块层实现中起着至关重要作用的一些主要数据结构。

查看数据结构

以下是实现多队列块层所必需的一些主要数据结构:

  • 多队列框架使用的第一个相关数据结构是blk_mq_register_dev结构,它包含了在将新块设备注册到块层时所需的所有必要信息。它包含多个字段,提供有关驱动程序能力和要求的详细信息。

  • blk_mq_ops数据结构作为多队列块层访问设备驱动程序特定例程的参考。该结构充当驱动程序与blk-mq层之间通信的接口,使得驱动程序能够无缝地集成到多队列处理框架中。

  • 软件暂存队列由blk_mq_ctx结构表示。该结构是按每个 CPU 核心分配的。

  • 硬件分派队列的对应结构由blk_mq_hw_ctx结构定义。它表示与请求队列关联的硬件上下文。

  • 将软件暂存队列映射到硬件分派队列的任务由blk_mq_queue_map结构执行。

  • 请求通过blk_mq_submit_bio函数创建并发送到块设备。

以下图展示了这些功能是如何相互连接的:

图 5.3 – 多队列框架中主要结构的相互作用

图 5.3 – 多队列框架中主要结构的相互作用

总结来说,多队列接口解决了块层在处理具有多个队列的现代存储设备时所面临的限制。历史上,不管底层物理存储介质的能力如何,块层都保持一个单一的请求队列来处理 I/O 请求。在具有多个核心的系统中,这很快就成为了一个主要瓶颈。由于请求队列是通过全局锁在所有 CPU 核心之间共享的,每个 CPU 核心都花费了相当多的时间等待其他核心释放锁。为了克服这一挑战,开发了一个新的框架,来满足现代处理器和存储设备的需求。多队列框架通过为每个 CPU 核心隔离请求队列来解决块层的限制。该框架采用了双队列设计,包括软件分阶段队列和硬件调度队列。

到此为止,我们已经分析了块层中的多队列框架。现在,我们将转向探讨设备映射器框架。

查看设备映射器框架

默认情况下,管理物理块设备是比较僵化的,应用程序只能通过少数几种方式使用这些设备。在处理块设备时,必须做出关于磁盘分区和空间管理的明智决策,以确保可用资源的最佳使用。在过去,薄配置、快照、卷管理和加密等功能仅限于企业存储阵列。然而,随着时间的推移,这些功能已成为任何本地存储基础设施的关键组成部分。在操作物理驱动器时,操作系统的上层通常需要具备实施和维持这些功能的能力。Linux 内核提供了设备映射器框架,用于实现这些概念。设备映射器由内核用于将物理块设备映射到更高级别的虚拟块设备。设备映射器框架的主要目标是为物理设备创建一个高级抽象层。设备映射器提供了一种机制,可以修改传输中的 bio 结构并将其映射到块设备。使用设备映射器框架为实现诸如逻辑卷管理等功能奠定了基础。

设备映射器提供了一种通用方法,用于在物理设备之上创建虚拟块设备层,并实现如条带化、镜像、快照和多路径等功能。像 Linux 中的大多数功能一样,设备映射器框架的功能被划分为内核空间和用户空间。与策略相关的工作,如定义物理到逻辑的映射,位于用户空间,而实现这些策略以建立映射的功能则位于内核空间。

设备映射器的应用程序接口是 ioctl 系统调用。此系统调用调整特殊文件的底层设备参数。采用设备映射器框架的逻辑设备通过 dmsetup 命令和 libdevmapper 库进行管理,后者实现了相应的用户接口,如下图所示:

图 5.4 – 设备映射器框架的主要组件

图 5.4 – 设备映射器框架的主要组件

如果我们在 dmsetup 命令上运行 strace,我们会看到它利用了 libdevmapper 库和 ioctl 接口:

root@linuxbox:~# strace dmsetup ls
execve("/sbin/dmsetup", ["dmsetup", "ls"], 0x7fffbd282c58 /* 22 vars */) = 0
[..................]
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdevmapper.so.1.02.1", O_RDONLY|O_CLOEXEC) = 3
[...............…]
stat("/dev/mapper/control", {st_mode=S_IFCHR|0600, st_rdev=makedev(10, 236), ...}) = 0
openat(AT_FDCWD, "/dev/mapper/control", O_RDWR) = 3
openat(AT_FDCWD, "/proc/devices", O_RDONLY) = 4
[...............…]
ioctl(3, DM_VERSION, {version=4.0.0, data_size=16384, flags=DM_EXISTS_FLAG} => {version=4.41.0, data_size=16384, flags=DM_EXISTS_FLAG}) = 0
ioctl(3, DM_LIST_DEVICES, {version=4.0.0, data_size=16384, data_start=312, flags=DM_EXISTS_FLAG} => {version=4.41.0, data_size=528, data_start=312, flags=DM_EXISTS_FLAG, ...}) = 0
[..................]

建立映射设备的应用程序,如 LVM,通过 libdevmapper 库与设备映射器框架进行通信。libdevmapper 库使用 ioctl 命令将数据传输到 /dev/mapper/control 设备。/dev/mapper/control 设备是一个专用设备,作为设备映射器框架的控制机制。

图 5.4 中,我们可以看到,内核空间中的设备映射器框架实现了一个模块化的存储管理架构。设备映射器框架的功能包括以下三个主要组件:

  • 映射设备

  • 映射表

  • 目标设备

我们简要看看它们各自的角色。

查看映射设备

一个块设备,例如整个磁盘或单个分区,可以被 映射 到另一个设备。映射设备是设备映射器驱动程序提供的逻辑设备,通常存在于 /dev/mapper 目录中。LVM 中的逻辑卷就是映射设备的例子。映射设备在 drivers/md/dm-core.h 中定义。如果我们查看这个定义,会遇到一个熟悉的结构:

struct mapped_device {
[……..]
struct gendisk *disk;
[………..]

第四章 所述,gendisk 结构代表内核中的物理硬盘概念。

查看映射表

映射设备由映射表定义。该映射表表示从映射设备到目标设备的映射。映射设备由一个表定义,描述设备的每个逻辑扇区范围如何映射,使用设备映射器框架支持的设备表映射。drivers/md/dm-core.h 中定义的映射表包含指向映射设备的指针:

struct dm_table {
        struct mapped_device *md;
[……………..]

该结构允许在设备映射器堆栈中创建、修改和删除映射。可以通过运行 dmsetup 命令查看映射表的详细信息。

查看目标设备

如前所述,设备映射框架通过定义物理块设备上的映射来创建虚拟块设备。逻辑设备是通过“目标”创建的,可以将其视为模块化插件。可以使用这些目标创建不同的映射类型,如线性、镜像、快照等。数据通过这些映射从虚拟块设备传递到物理块设备。目标设备结构定义在include/linux/device-mapper.h中。用于映射的单位是扇区:

struct dm_target {
        struct dm_table *table;
        sector_t begin;
        sector_t len;
[………….]

设备映射器可能有点令人困惑,所以让我们通过说明我们之前解释的构建模块的一个简单用例来帮助理解。我们将使用线性目标,它为逻辑卷管理奠定了基础。如前所述,我们将使用dmsetup命令,因为它实现了设备映射器的用户空间功能。我们将创建一个名为dm_disk的线性映射目标。如果你打算运行以下命令,请确保在空白磁盘上运行它们。这里,我使用了两块磁盘,sdcsdd(你可以使用任何空的磁盘进行练习!)。注意,在按下dmsetup create命令后,它会提示你输入数据。sdcsdd磁盘通过它们的主次设备号进行引用。你可以使用lsblk命令来查找磁盘的主次设备号。sdc的主次设备号是 8 和 32,表示为8:32。同样,sdd的组合是8:48。其余的输入字段将在稍后解释。一旦你输入了所需的数据,使用Ctrl + D退出。以下示例将创建一个 5 GiB 的线性目标:

[root@linuxbox ~]# dmsetup create dm_disk
dm_disk: 0 2048000 linear 8:32 0
dm_disk: 2048000 8192000 linear 8:48 1024
[root@linuxbox ~]#
[root@linuxbox ~]# fdisk -l /dev/mapper/dm_disk
Disk /dev/mapper/dm_disk: 4.9 GiB, 5242880000 bytes, 10240000 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
[root@linuxbox ~]#

这是我们所做的:

  1. 我们通过使用两块物理磁盘 sdcsdd的特定部分或范围,创建了一个名为dm_disk的逻辑设备。

  2. 我们输入的第一行数据dm_disk: 0 2048000 linear 8:32 0意味着dm_disk的前2048000 个扇区(0-2047999)将使用来自/dev/sdc的扇区,从扇区 0 开始。因此,sdc的前2048000 个扇区(0-2047999)将被dm_disk使用。

  3. 第二行dm_disk: 2048000 8192000 linear 8:48 1024表示dm_disk的下一个8192000 个扇区(从扇区号 2047999开始)将从sdd中分配。这些来自sdd8192000个扇区将从扇区号1024开始分配。如果磁盘上没有数据,我们可以在这里使用任何扇区号。如果已有数据,则应从未使用的范围分配扇区。

  4. dm_disk中的总扇区数将是8192000 + 2048000 = 10240000

  5. 以 512 字节为扇区大小,dm_disk的大小将是(8192000 x 512) + (2048000 x 512) ≈ 5 GiB

dm_disk0-2047999扇区号映射自sdc,而2048000-10239999扇区号映射自sdd。我们讨论的这个例子是一个简单的例子,但应该能显而易见,我们可以将逻辑设备映射到任意数量的硬盘,并实现不同的概念。

下图总结了我们之前解释的内容:

图 5.5 – 设备映射框架中的线性目标映射

图 5.5 – 设备映射框架中的线性目标映射

设备映射框架支持多种目标类型。以下是其中一些目标的解释:

  • 线性:如我们之前所看到的,线性映射目标可以将一段连续的块范围映射到另一个块设备。这是逻辑卷管理的基本构建块。

  • RAID:RAID 目标用于实现软件 RAID 的概念。它支持不同类型的 RAID。

  • 加密:加密目标用于在块设备上加密数据。

  • (raid 0) 跨多个底层磁盘。

  • 多路径:多路径映射目标用于存储环境中,主机有多个路径通向存储设备的情况。它允许将多路径设备进行映射。

  • 精简:精简目标用于精简配置——即创建大于底层物理设备大小的设备。物理空间仅在写入时分配。

如前所述,线性映射目标通常在 LVM 中实现。大多数 Linux 发行版默认使用 LVM 进行空间分配和分区。对于普通用户来说,LVM 可能是 Linux 中最著名的功能之一。通过之前提到的例子,可以很容易理解它如何应用于 LVM 或任何其他目标。

如大多数人所知,LVM 分为三个基本实体:

  • 物理卷:物理卷位于最底层。底层的物理磁盘或分区即为物理卷。

  • 卷组:卷组将物理卷中可用的空间划分为一系列块,称为物理区。物理区表示一段连续的块范围。它是 LVM 可以单独管理的最小磁盘空间单位。默认情况下,使用 4 MB 的区块大小。

  • 逻辑卷:在卷组中可用的空间中,可以创建逻辑卷。逻辑卷通常被划分为更小的数据块,每个数据块称为逻辑区。由于 LVM 使用线性目标映射,物理区和逻辑区之间存在直接的对应关系。因此,逻辑卷可以视为 LVM 建立的映射,它将逻辑区与物理区关联起来。可以通过下图进行可视化:

图 5.6 – LVM 架构

图 5.6 – LVM 架构

正如我们所知道的,逻辑卷可以像任何常规的块设备一样使用,文件系统可以在其上创建。一个跨多个物理磁盘的单一逻辑卷类似于RAID-0。物理和逻辑扩展之间使用何种映射方式由目标决定。由于 LVM 基于线性目标,因此物理扩展和逻辑扩展之间存在一对一的映射关系。假设我们使用dm-raid目标并配置RAID-1在多个块设备之间进行镜像。在这种情况下,多个物理扩展将映射到单个逻辑扩展。

让我们通过将一些关键事实牢记在心来总结一下关于设备映射框架的讨论。设备映射框架在内核中扮演着至关重要的角色,负责实现存储层次结构中的多个关键概念。内核使用设备映射框架将物理块设备映射到更高级别的虚拟块设备。设备映射框架的功能被划分为用户空间和内核空间。用户空间接口由libdevmapper库和dmsetup工具组成。内核部分由三个主要组件构成:映射设备、映射表和目标设备。设备映射框架为 Linux 中的几个重要技术提供了基础,例如 LVM。LVM 在物理磁盘和分区之上提供了一个薄的抽象层。这层抽象使得存储管理员可以根据空间需求轻松调整文件系统的大小,从而提供了高度的灵活性。在结束本章之前,让我们简要了解一下块层使用的缓存机制。

查看块层中的多级缓存机制

物理存储的性能通常比处理器和内存慢几个数量级。Linux 内核深知这一限制,因此,它利用可用内存作为缓存,在将所有数据写入底层磁盘之前,先在内存中执行所有操作。这种缓存机制是内核的默认行为,并在提高块设备性能方面起着核心作用。这也有助于提高系统的整体性能。

尽管固态硬盘和 NVMe 驱动器现在在大多数存储基础设施中已经非常普及,但传统的旋转硬盘仍然被用于容量需求较大且性能不是主要关注点的场景。当我们谈论硬盘性能时,随机工作负载是旋转机械硬盘的致命弱点。相比之下,闪存驱动器的性能并不受这种限制,但它们的价格远高于机械硬盘。理想情况下,能够同时获得两种介质的优势将是非常理想的。大多数存储环境都是混合型的,努力高效利用两种类型的硬盘。最常见的技术之一是将热数据或频繁使用的数据放置在最快的物理介质上,而将冷数据迁移到较慢的机械硬盘上。大多数企业级存储阵列都提供内置的存储分层功能,实现这种缓存功能。

Linux 内核也能够实现这种缓存解决方案。内核提供了几种选项,可以将旋转机械硬盘提供的容量与 SSD 提供的访问速度结合起来。正如我们之前所看到的,设备映射框架提供了多种目标,可以在块设备上添加额外的功能。其中一个目标是 dm-cache 目标。dm-cache 目标可以通过将部分数据迁移到更快的驱动器(如 SSD)来提高机械硬盘的性能。这种方法有点与内核的默认缓存机制相悖,但在某些情况下它可能非常有用。

大多数缓存机制提供以下操作模式:

  • 回写(Write-back):此模式缓存新写入的数据,但不会立即将其写入目标设备。

  • 直写(Write-through):在此模式下,数据写入目标设备的同时,仍然保留在缓存中,以便后续读取。

  • 写绕(Write-around):此模式实现只读缓存。写入设备的数据直接写入较慢的机械硬盘,而不会写入快速的 SSD。

  • 直通(Pass-through):要启用直通模式,缓存需要保持清洁。读取操作将绕过缓存,从源设备直接提供。写入操作会转发到源设备并使缓存块无效

dm-cache 目标支持前述所有模式,除了写绕(write-around)模式。所需的功能通过以下三个设备实现:

  • 源设备(Origin device):这将始终是较慢的主存储设备。

  • 缓存设备(Cache device):这是一个高性能驱动器,通常是 SSD。

  • 元数据设备(Metadata device):尽管这是可选的,并且这些信息也可以保存在快速缓存设备上,但该设备用于跟踪所有元数据,例如哪些磁盘块在缓存中,哪些块是脏的,等等。

另一个类似的缓存解决方案是 dm-writecache,它也是一个设备映射器目标。顾名思义,dm-writecache 的主要功能是写回缓存。它只缓存写操作,不执行任何读取或写直通缓存。之所以不缓存读取操作,是因为读取的数据应该已经在页缓存中。写操作会缓存到更快的存储设备上,然后在后台迁移到较慢的磁盘。

另一个获得广泛关注的解决方案是bcachebcache解决方案支持之前定义的所有四种缓存模式。bcache采用了更为复杂的方法,并默认将所有顺序操作发送到机械硬盘。由于 SSD 在随机操作中表现优异,因此在 SSD 上缓存大规模顺序操作通常不会带来太多好处。因此,bcache 会检测顺序操作并跳过它们。bcache的开发者将其与 L2 bcache 项目进行了对比,这也推动了 Bcachefs 文件系统的发展。

总结

本章是我们探索内核块层的第二章。我们详细讨论的两个主要主题是多队列和设备映射框架。在本章开始时,我们探讨了块层中的传统单请求队列模型、其局限性以及在现代存储驱动器和多核系统上运行时对性能的负面影响。接下来,我们介绍了内核中的多队列框架。我们描述了多队列框架如何解决单请求模型的局限性,并提升现代存储驱动器的性能,这些驱动器能够支持多个硬件队列。

我们还获得了机会,了解了内核中的设备映射框架。设备映射框架是内核的重要组成部分,负责实现多项技术,如多路径、逻辑卷、加密和 RAID。其中最著名的是逻辑卷管理。我们了解了设备映射器如何通过映射技术实现这些强大的功能。

在下一章中,我们将在探索内核中不同的 I/O 调度器后,总结关于块层的讨论。

第六章:理解块层中的 I/O 处理和调度

“关键不是优先安排你的日程,而是安排你的优先事项。” – 史蒂芬·柯维

第四章第五章 聚焦于内核中块层的作用。我们能够看到构成块设备的内容、块层中的主要数据结构、多队列块 I/O 框架和设备映射器。本章将重点介绍块层的另一个重要功能——调度。

调度是任何系统中至关重要的组件,因为调度器做出的决策对整体系统性能的影响至关重大。块层中的 I/O 调度也不例外。I/O 调度器在决定 I/O 请求传递给下层的方式和时机方面具有重要作用。因此,仔细分析应用程序的 I/O 模式变得至关重要,因为某些请求需要优先处理。

本章将介绍块层中可用的不同 I/O 调度器及其工作方式。每个调度器使用不同的技术集将 I/O 请求分发到下层。正如我们反复提到的,在处理块设备时,性能是一个关键问题。块层经历了几次改进,以便从磁盘驱动器中提取最大性能。这包括开发调度器来处理现代和高性能存储设备。

我们将首先介绍不同调度器用来更高效处理 I/O 请求的常见技术。尽管这些技术是为传统的旋转硬盘开发的,但它们对于现代闪存驱动器仍然被认为是有用的。这些技术的主要目标是减少机械硬盘的磁盘寻道操作,因为这些操作会对性能产生不利影响。大多数调度器默认使用这些方法,无论底层存储硬件是什么。

本章讨论的主要话题将是内核中可用的不同 I/O 调度类型。早期的磁盘调度器是为使用单队列机制访问的设备开发的,它们已经过时,因为无法扩展以满足现代硬盘的性能需求。在过去几年中,四个多队列 I/O 调度器已被集成到内核中。这些调度器能够将 I/O 请求映射到多个队列。

在本章中,我们将讨论以下主要内容:

  • 理解块层中的 I/O 处理技术

  • 解释 Linux 中的 I/O 调度器:

    • MQ-deadline 调度器 – 保证开始服务时间

    • 预算公平排队 – 提供按比例分配的磁盘共享

    • Kyber – 优先考虑吞吐量

    • None – 最小化调度开销

  • 讨论调度难题

技术要求

要理解本章中提出的概念,了解一些磁盘 I/O 基础知识会非常有帮助。了解不同类型的存储介质,以及磁盘寻址时间和旋转延迟等概念,有助于理解本章中介绍的内容。

本章中提供的命令和示例是与发行版无关的,可以在任何 Linux 操作系统上运行,例如 Debian、Ubuntu、Red Hat 和 Fedora。本书中有不少涉及内核源代码的内容。如果你想下载内核源代码,可以从www.kernel.org下载。本章和本书中提到的代码段来自5.19.9内核。

理解块层中的 I/O 处理技术

在探索第四章第五章的块层时,我们经常提到块设备的性能敏感性,以及块层如何做出有根据且智能的决策,以发挥其最大潜力。到目前为止,我们还没有真正讨论任何能够帮助提升块设备性能的技术。

回到旋转硬盘的时代,存储驱动器的性能是 I/O 栈中的一个主要瓶颈。机械硬盘在执行顺序 I/O 操作时表现得相当不错。然而,对于随机工作负载,它们的性能会急剧下降。这是可以理解的,因为机械硬盘必须通过旋转并将读写头定位到特定位置来寻址请求的磁盘位置。随机寻址的次数越多,性能惩罚就越大。在块设备上创建的文件系统尝试实施一些优化磁盘性能的做法,但完全避免随机操作几乎是不可能的。

鉴于机械硬盘的巨大寻址时间,在将 I/O 请求交给底层存储之前,必须应用某种优化来减少寻址。简单地将请求交给底层物理存储显得有些原始。这时,I/O 调度程序就显得尤为重要。块层中的 I/O 调度程序采用一些常见方法,以确保尽量减少随机访问操作所带来的开销。这些技术解决了旋转硬盘的一些性能问题,尽管在使用闪存硬盘时,它们的效果可能不明显,因为闪存硬盘不受随机操作的影响。

大多数调度程序结合以下几种技术来优化磁盘性能:

  • 排序

  • 合并

  • 合并

  • 插件

让我们更详细地讨论这些。

排序

假设有四个 I/O 请求 A、B、C 和 D,分别对应扇区 2、3、1 和 4,按照该顺序接收,如图 6.1所示。如果请求按此顺序传递给底层旋转硬盘,它们将按照以下顺序完成:

图 6.1 – 磁盘寻址

图 6.1 – 磁盘寻址

这意味着,在按顺序完成请求 A 和 B 之后,对于请求 C,磁头将必须回到扇区 1。完成请求 C 后,它还需执行另一个寻址操作并前往扇区 D。从这种方法所带来的低效是显而易见的。如果请求按接收的顺序直接交给磁盘,磁盘性能将大打折扣。

对于旋转硬盘,随机访问操作会大大降低性能,因为磁盘必须执行多个寻址操作。如果传入请求仅仅按照先进先出的队列顺序插入,队列中的每个请求都需要单独处理,随机寻址所带来的开销将增加。因此,大多数调度器保持请求队列有序,并尽量以排序的方式插入新传入的请求。请求队列按扇区逐个排序。这确保了对相邻扇区的请求能够顺序执行。

合并

合并作为排序机制的补充,进一步减少了随机访问。合并可以通过两种方式进行:前向合并和回合并。如果两个请求针对的是连续的扇区,它们就可以合并。如果一个 I/O 请求进入调度器并与已入队的请求相邻,它就符合前向或回合并的条件。如果传入请求与现有请求合并,则称为回合并。回合并的概念如图 6.2所示:

图 6.2 – 回合并

图 6.2 – 回合并

同理,当新生成的请求与现有请求合并时,称之为前向合并,如图 6.3所示:

图 6.3 – 前向合并

图 6.3 – 前向合并

这个思路很简单——避免不断地访问随机位置。这对旋转机械硬盘尤其有效。默认情况下,大多数块层调度器会尝试将传入请求与现有请求合并。

合并

合并操作包括前向合并和回合并。合并发生在一个新的 I/O 请求填补了两个现有请求之间的空隙,如下图所示:

图 6.4 – 合并

图 6.4 – 合并

合并被用来减少小而频繁的 I/O 操作的开销,尤其是在旋转硬盘驱动器中。通过合并多个请求,磁盘可以执行顺序读写,从而加快 I/O 操作速度并减少磁头的移动。

插入

内核使用plugging概念来停止队列中请求的处理。我们在这里讨论的是提升性能,那么为何将请求挂起有助于提升性能呢?正如我们所学,合并对硬盘性能有非常积极的作用。然而,为了让较小的 I/O 请求合并成一个较大的统一请求,队列中必须存在相邻扇区的现有请求。因此,为了执行合并,内核首先需要通过一些请求来填充请求队列,从而增加合并的概率。将队列插入(plugging)有助于批量处理请求,为合并和排序操作的机会做好准备。

插入(plugging)是一种确保队列中有足够请求以进行潜在合并操作的技术。它涉及等待更多的请求填满请求队列,并帮助调节请求向设备队列的调度速率。插入的目的是控制请求向设备队列的调度速率。当块设备队列中没有待处理的请求或仅有非常少的请求时,传入的请求不会立即调度到设备驱动程序,这样设备就处于插入(plugged)状态。以下图示说明了这一概念:

图 6.5 – 插入(Plugging)

图 6.5 – 插入(Plugging)

插入(plugging)是在进程级别而非设备级别执行的。当进程执行 I/O 操作时,内核会启动一个插入序列。在进程提交其 I/O 请求到队列之后,请求会被转发到块层(block layer),然后调度到设备驱动程序。一旦进程完成提交 I/O 请求,设备就被认为是“拔出”(unplugged)状态。如果在插入序列中应用程序被阻塞,调度器将继续处理已经在队列中的请求。

在讨论了 I/O 调度器中最常用的 I/O 调度技术之后,接下来让我们深入探讨 Linux 中最广泛使用的 I/O 调度器的决策过程背后的推理和原理。

解释 Linux I/O 调度器

磁盘调度器是一个有趣的话题。它们作为块层和低级设备驱动程序之间的桥梁。发出的对块设备的请求会被 I/O 调度器修改后交给设备驱动程序。调度器的工作是对 I/O 请求进行合并、排序和插入等操作,并将存储资源分配给排队的 I/O 请求。Linux 中磁盘调度器的一个显著优势是它们的即插即用功能,允许它们实时切换。此外,根据所使用存储硬件的特性,可以为系统中的每个块设备分配不同的调度器。磁盘调度器的选择并不是经常被关注的事情,除非你试图从系统中挤出最大性能。I/O 调度器负责决定 I/O 请求交给设备驱动程序的顺序。这个顺序是基于以下任务的优先级来决定的:

  • 降低磁盘寻道

  • 确保 I/O 请求的公平性

  • 最大化磁盘吞吐量

  • 降低对时间敏感任务的延迟

在这些目标之间取得平衡是一项艰巨的任务。不同的调度器利用多个队列来实现这些目标。合并和排序等操作会在请求队列中执行。调度器还会根据其内部算法在这些队列中执行额外的处理。一旦请求准备就绪,它们会被交给设备驱动程序管理的调度队列。

早期块层设计中的主要性能优化针对的是硬盘驱动器。这一点对于磁盘调度算法尤其适用。我们到目前为止讨论的大部分 I/O 处理技术,在底层存储介质由旋转机械驱动器组成时最为有效。正如我们将在第七章中看到的,SSD 和 NVMe 驱动器是不同性质的硬件,并不受阻碍机械驱动器的限制影响。

调度器控制底层磁盘的行为,因此在决定应用程序性能时起着至关重要的作用。就像物理存储的不同特性一样,每个应用程序的构建方式也不同。了解所调优环境的工作负载类型至关重要。没有任何一个调度器能够被认为是足够适合匹配所有应用程序不同 I/O 特性的。选择调度器时,必须问以下问题:

  • 主机系统类型是什么——是桌面、笔记本、虚拟机还是服务器?

  • 将运行什么样的工作负载?是什么类型的应用程序?数据库、多用户桌面界面、游戏还是视频?

  • 托管的应用程序是处理器密集型还是 I/O 密集型?

  • 后端存储介质类型是什么?HDD、SSD 还是 NVMe?

  • 存储是本地的还是来自大型企业存储区域网络的?

实时应用生成的 I/O 请求应在特定的截止时间内完成。例如,在通过多媒体播放器播放视频时,必须保证视频帧能够及时读取,以便视频能够流畅播放。另一方面,交互式应用必须等待任务完成后才能进行下一个任务。例如,在文档编辑器中输入文字时,最终用户期望编辑器在按下键时立即响应。并且,文本必须按输入的顺序显示出来。

对于单个系统,调度器的选择可能不太重要,默认设置可能足够。对于运行企业工作负载的服务器,性能要求就更为严格,调度器如何处理 I/O 请求可能决定了应用程序的整体性能。正如我们在本书中反复提到的,磁盘 I/O 比处理器和内存子系统慢得多。因此,任何关于选择磁盘调度器的决策都应该经过深思熟虑,并伴随着性能基准测试。

磁盘调度不应与 CPU 调度混淆。处理任何请求时,都需要 I/O 和 CPU 时间。简单来说,一个进程向 CPU 请求时间,获得时间后它就可以运行(如果时间被分配)。进程可以向磁盘发出读写请求。然后,磁盘调度程序的工作就是对这些请求进行排序,并将它们引导到底层磁盘。

Linux 中的 I/O 调度器也称为电梯。电梯算法,也叫SCAN,将传统机械硬盘的操作与电梯或升降机进行比较。当电梯上下移动时,它会保持一个方向并在途中停下让人上下。在磁盘调度中,驱动的读写头从磁盘的一端开始,向另一端移动,同时服务沿途的请求。继续这个类比,机械硬盘需要在不同的磁盘位置(“楼层”)读取(“接载”)和写入(“卸载”)请求(“人”)。

内核中可用的不同类型的 I/O 调度器适用于特定的使用场景,有些比其他的更合适。正如我们在第五章中学到的,单队列框架不能满足现代存储设备的性能要求。驱动技术和多核系统的进步促使了多队列块 I/O 框架的发展。即使实现了这个框架,内核在处理现代驱动时仍然缺少一个重要的成分——一个能够与多队列设备配合使用的 I/O 调度器。为单队列框架设计、用于单队列设备的调度器,在现代驱动上并不能发挥最佳性能。

图 6.6 突出了适用于单队列和多队列框架的各种 I/O 调度器类型:

图 6.6 – 不同的 I/O 调度选项

图 6.6 – 不同的 I/O 调度选项

单队列 I/O 调度器已被弃用,并且自版本 5.0 起不再包含在内核中。尽管你可以禁用这些并恢复为单队列调度器,但最新的内核版本默认使用多队列调度器,因此我们将重点关注作为内核一部分的多队列调度器。这个类别中有四个主要的调度器。这些调度器将 I/O 请求映射到多个队列,这些队列由分布在多个 CPU 核心上的内核线程处理:

  • MQ-截止日期

  • 预算公平 排队 (BFQ)

  • Kyber

让我们来看一下这些调度器的操作逻辑。

MQ-截止日期调度器 – 保证启动服务时间

截止日期调度器顾名思义,为 I/O 请求服务施加一个截止日期。由于其以延迟为导向的设计,它通常用于延迟敏感的工作负载。由于其高性能,它也被采用于多队列设备。它在多队列设备上的实现被称为mq-deadline

截止日期调度器的主要目标是确保每个请求都有指定的启动服务时间。这是通过对所有 I/O 操作强制实施一个截止日期来实现的,帮助防止请求被忽视。截止日期调度器利用以下队列:

  • 排序:该队列中的读写操作按其访问的扇区号进行排序。

  • 截止日期:截止日期队列是一个标准的先进先出FIFO)队列,包含按截止日期排序的请求。为了防止请求饥饿,截止日期调度器为读写请求使用单独的截止日期队列实例,并为每个 I/O 请求分配一个过期时间。

截止日期调度器将每个 I/O 请求放入排序队列和截止日期队列中。在决定服务哪个请求之前,截止日期调度器会从中选择一个队列来选择读请求或写请求。如果读写队列中都有请求,优先选择读队列。这是因为写请求可能会导致读操作饥饿。这使得截止日期调度器在读密集型工作负载中非常有效。

截止日期调度器的操作逻辑如以下图所示:

图 6.7 – MQ-截止日期 I/O 调度器

图 6.7 – MQ-截止日期 I/O 调度器

要服务的 I/O 请求决定如下:

  1. 假设调度器已经决定处理读请求。它将检查截止时间队列中的第一个请求。如果该请求的计时器已到期,它将被交给调度队列,并插入到调度队列的尾部。调度器接着会将注意力转向排序队列,并选择一个请求批次(默认 16 个请求),这些请求会紧随选中的请求后面。这样做是为了增加顺序操作。可以想象为电梯在前往最终目的地的途中在不同楼层停靠并放人。每批请求的数量是一个可调参数,可以进行更改。

  2. 也可能发生在截止时间队列中没有任何请求的截止时间已经过期。在这种情况下,调度器会检查排序队列中最后一个被处理的请求,并选择序列中的下一个请求。调度器随后会选择一个包含 16 个请求的批次,这些请求紧跟在被选择的请求后面。

  3. 在处理完每个请求批次后,截止时间调度器会检查写操作截止时间队列中的请求是否已被饿死太久,然后决定是否开始新的读或写操作批次。

下图解释了这个过程。如果收到一个针对磁盘第 19 扇区的读请求,它会被分配一个截止时间,并插入到读操作的截止时间队列的尾端。根据扇区号,该请求也会被放入排序的扇区队列,位于第 11 扇区请求之后。截止时间调度器的操作流程,关于请求如何处理,在图 6.8中展示:

图 6.8 – MQ-deadline I/O 调度器中的请求处理

图 6.8 – MQ-deadline I/O 调度器中的请求处理

以下是 block/mq-deadline 中 mq-deadline 的代码片段,它控制了前述图示中的某些行为。读请求的截止时间(HZ/2)为 500 毫秒,而写请求的截止时间则为 5 秒(5*HZ)。这确保了读请求具有更高的优先级。术语 HZ 代表每秒钟生成的时钟滴答数。writes_starved 的定义表示读请求可以饿死写请求。写请求仅在经过两轮读请求后才能得到服务。fifo_batch 设置了可以批量处理的请求数量:

[……….]
static const int read_expire = HZ / 2;
static const int write_expire = 5 * HZ;
static const int writes_starved = 2;
static const int fifo_batch = 16;
[……….]

总结来说,截止时间调度器通过为每个传入请求实现开始服务时间,努力减少 I/O 延迟。每个新请求都会分配一个截止时间计时器。当请求的截止时间到达时,调度器会强制处理该请求,以防止请求饿死。

预算公平排队 – 提供按比例的磁盘共享

预算公平排队BFQ)调度器在磁盘调度器的世界中是一个相对较新的成员,但它已经获得了相当大的普及。它的设计灵感来源于完全公平排队CFQ)调度器。它提供了相当不错的响应时间,并且被认为特别适用于较慢的设备。凭借其丰富而全面的调度技术,BFQ 通常被认为是最完整的磁盘调度器之一,尽管其复杂的设计也使其成为所有调度器中最复杂的一个。

BFQ 是一个按比例分配的磁盘调度器。BFQ 的主要目标是公平地对待所有 I/O 请求。为了实现这种公平性,它采用了一些复杂的技术。在内部,BFQ 使用最坏情况公平加权公平排队+(B-WF2Q+)算法来辅助调度决策。

BFQ 调度器为系统中的每个进程保证一个与磁盘资源成比例的份额。它将 I/O 请求收集到以下两个队列中:

  • 每个进程队列:BFQ 调度器为每个进程分配一个队列。每个进程队列包含同步 I/O 请求。

  • 每设备队列:所有异步 I/O 请求都被收集到每个设备的队列中。这个队列是进程共享的。

每当创建一个新队列时,它会被分配一个变量预算。与大多数调度器通过分配时间片不同,BFQ 的预算是通过每个进程下次调度访问磁盘资源时允许转移的扇区数来实现的。这个预算的数值最终决定了每个进程的磁盘吞吐量份额。因此,预算的计算是复杂的,并且基于多个因素。主要的因素包括 I/O 权重和进程的近期 I/O 活动。根据这些观察,调度器为进程分配一个与其 I/O 活动成比例的预算。进程的 I/O 权重有一个默认值,但可以更改。预算的分配方式确保单个进程无法独占所有存储资源的带宽。图 6.9展示了 BFQ 调度器使用的不同队列:

图 6.9 – BFQ I/O 调度器

图 6.9 – BFQ I/O 调度器

在服务 I/O 请求时,一些影响调度决策的因素如下所述:

  • BFQ 调度器通过 C-LOOK 算法选择要服务的队列。它从所选队列中取出第一个请求并将其交给驱动程序。队列的预算会根据请求的大小减少。这个过程在本讨论的最后部分会做更详细的说明。BFQ 每次只服务一个队列。

  • BFQ 调度器优先安排具有较小 I/O 预算的进程。通常,这些是具有一小部分随机 I/O 请求的进程。相比之下,具有大量顺序 I/O 请求的 I/O 密集型进程分配了较大预算。在选择要为其提供服务的进程队列时,BFQ 调度器选择具有最低 I/O 预算的队列,为磁盘资源提供独占访问。这种方法实现了两个目标。首先,具有较小预算的进程能够及时得到服务,而无需过度等待。其次,具有较大预算的 I/O 密集型进程获得了比例更大的磁盘资源份额,促进顺序 I/O 操作,从而提升磁盘性能。BFQ 调度器利用稍微非正统的方法来增加磁盘吞吐量,通过检查同步 I/O 请求进行磁盘空闲。当应用程序生成同步 I/O 请求时,它进入阻塞状态并等待操作完成。这些通常是读取请求,因为写入操作是异步的,可以直接在缓存中执行。如果进程队列中的最后一个请求是同步的,则进程进入等待状态。此请求不会立即发送到磁盘,因为 BFQ 调度器允许进程生成另一个请求。在此时间段内,驱动器保持空闲状态。通常情况下,进程会生成另一个请求,因为它等待当前同步请求完成后才会发出新请求。新请求通常与上一个请求相邻,这提高了顺序操作的机会。有时,这种方法可能适得其反,并且可能并不总是对性能产生积极影响。

  • 如果两个进程在磁盘上相邻区域上操作,合并它们的请求是有意义的,以便增加顺序操作。在这种情况下,BFQ 合并两个进程的队列,以实现请求的整合。传入的请求与正在服务的进程的下一个请求进行比较,如果两个请求接近,则合并两个进程的请求队列。

如果执行读取请求的应用程序在仍然有剩余预算的情况下耗尽其队列,磁盘将短暂空闲,以使该进程有机会发出另一个 I/O 请求。

调度器在以下事件之一发生前一直为队列提供服务:

  • 队列预算用尽。

  • 所有队列请求都已完成。

  • 在等待来自进程的新请求时,空闲计时器超时。

  • 在为队列提供服务时花费了过多时间。

在查看block/bfq-iosched.c中找到的 BFQ 代码时,您将发现一个被称为费用因子的显著概念,用于异步请求:

static const int bfq_async_charge_factor = 3;

如前所述,当从队列中选择一个请求进行处理时,队列的预算会减少该请求的大小——也就是请求中的扇区数。这对于同步请求是成立的,但对于异步请求来说,这个成本要高得多。这也是读取操作优先于写入操作的一种方式。对于异步请求,队列会按请求中的扇区数进行收费,并乘以bfq_async_charge_factor的值,即 3。根据内核文档,当前的收费因子值是通过一项调优过程确定的,该过程涉及了各种硬件和软件配置。

总结来说,BFQ 调度器通过分配一定比例的 I/O 吞吐量给每个进程,采用公平排队的方法。它使用每个进程的队列来处理同步请求,并使用每个设备的队列来处理异步请求。它为每个进程分配一个预算。这个预算是根据 I/O 优先级以及进程上次调度时传输的扇区数来计算的。尽管 BFQ 调度器比较复杂,并且相较于其他调度器会带来稍微更大的开销,但它被广泛使用,因为它提高了系统的响应时间,并减少了对时间敏感应用的延迟。

Kyber – 优先考虑吞吐量

Kyber 调度器也是磁盘调度领域中相对较新的一个成员。尽管 BFQ 调度器比 Kyber 调度器更早,但两者都在内核版本 4.12 中正式成为一部分。Kyber 调度器特别为现代高性能存储设备设计。

从历史上看,磁盘调度器的最终目标是减少机械硬盘的寻道时间,从而降低随机访问操作所带来的开销。因此,不同的磁盘调度器使用了复杂且精密的技术来实现这一共同目标。每个调度器以不同的方式优先考虑某些性能方面,这在处理 I/O 请求时会引入额外的开销。随着现代硬盘,如 SSD 和 NVMe,已经不再受随机访问操作的限制,一些调度器所使用的复杂技术可能不再适用于这些设备。例如,BFQ 调度器每个请求的开销稍微较高,因此它不被认为适合具有高吞吐量硬盘的系统。这就是 Kyber 调度器发挥作用的地方。

Kyber 调度器没有复杂的内部调度算法。它旨在用于包含高性能存储设备的环境。它使用非常简单的方法并实施一些基本策略来管理 I/O 请求。Kyber 调度器将底层设备划分为多个域。其理念是为不同类型的 I/O 请求维护队列。通过检查 block/kyber-iosched.c 中的代码,我们可以观察到以下请求类型:

[…….]
static const char *Kyber_domain_names[] = {
        [KYBER_READ] = "READ",
        [KYBER_WRITE] = "WRITE",
        [KYBER_DISCARD] = "DISCARD",
        [KYBER_OTHER] = "OTHER",
};
[…….]

Kyber 调度器将请求分为以下几类——读取、写入、丢弃和其他请求。Kyber 调度器为这些类型的请求维护队列。丢弃请求用于如 SSD 这样的设备。设备上的文件系统可以发出此请求,以丢弃文件系统未使用的块。对于前面提到的请求类型,调度器会对设备队列中相应操作的数量进行限制:

[…...]
static const unsigned int Kyber_depth[] = {
        [KYBER_READ] = 256,
        [KYBER_WRITE] = 128,
        [KYBER_DISCARD] = 64,
        [KYBER_OTHER] = 16,
};
[…...]

Kyber 调度方法的关键在于限制调度队列的大小。这直接与在请求队列中等待 I/O 请求所花费的时间相关。调度器只将有限数量的操作发送到调度队列,确保调度队列不会过于拥挤,从而实现调度队列中请求的快速处理。因此,请求队列中的 I/O 操作不必等待太长时间即可得到服务。这种方法减少了延迟。以下图示说明了 Kyber 调度器的逻辑:

图 6.10 – Kyber I/O 调度器

图 6.10 – Kyber I/O 调度器

为了确定允许进入调度队列的请求数量,Kyber 调度器采用了一种简单但有效的方法。它计算每个请求的完成时间,并根据这一反馈调整调度队列中的请求数量。此外,读取和同步写入的目标延迟是可调参数,可以进行修改。根据这些值,调度器将限制请求以满足这些目标延迟。

Kyber 调度器优先处理读取队列中的请求,而不是写入队列中的请求,除非写入请求已经等待过长时间,意味着目标延迟已被突破。

Kyber 调度器在现代存储设备方面是一款性能强大的工具。它专为高速存储设备(如 SSD 和 NVMe)量身定制,并优先考虑低延迟 I/O 操作。该调度器通过仔细检查 I/O 请求动态调整自身,并使得可以为同步写入和读取操作设定目标延迟。因此,它调节 I/O 请求,以满足指定的目标。

无 – 最小调度开销

I/O 请求的调度是一个多方面的问题。调度程序必须处理多个方面,例如重新排序队列中的请求、为每个进程分配一定比例的磁盘资源、控制每个请求的执行时间,并确保单个请求不会垄断可用的存储资源。每个调度程序都假设主机本身无法优化请求。因此,它会介入并应用复杂的技术,试图最大限度地利用可用的存储资源。调度技术越复杂,处理开销就越大。在优化请求时,调度程序通常会对底层设备做出一些假设。除非堆栈的低层能够更好地了解可用存储资源,并能自行做出调度决策,否则这种方法通常有效,例如:

  • 在高端存储环境中,如存储区域网络,存储阵列通常包括它们自己的调度逻辑,因为它们对底层设备的细微差别有更深的了解。因此,I/O 请求的调度通常发生在较低层次。当使用 RAID 控制器时,主机系统无法完全了解底层磁盘。即使调度程序对 I/O 请求进行了一些优化,可能也没有太大区别,因为主机系统缺乏足够的可见性,无法准确地重新排序请求以降低寻道时间。在这种情况下,直接将请求发送到 RAID 控制器是有意义的。

  • 大多数调度程序优化针对的是较慢的机械硬盘。如果环境中使用的是 SSD 和 NVMe 驱动器,那么这些调度优化带来的处理开销可能显得过于冗余。

在这种情况下,一种独特但有效的解决方案是使用 none 调度程序。none 调度程序是一个多队列的 no-op I/O 调度程序。对于单队列设备,相同的功能是通过 no-op 调度程序实现的。

none 调度程序是所有调度程序中最简单的,它不执行任何调度优化。每个传入的 I/O 请求都会被追加到一个 FIFO 队列中,并委托给块设备处理。当已经确定主机不应根据包含的扇区号重新排列请求时,这一策略非常有用。none 调度程序有一个单一的请求队列,包含读写 I/O 请求。由于其原始的方法,尽管 none I/O 调度程序对系统的开销最小,但它不确保任何特定的服务质量。none 调度程序也不对请求进行重新排序,它只进行请求合并以减少寻道时间并提高吞吐量。与所有其他调度程序不同,none 调度程序没有优化的可调参数或设置。请求合并操作是其复杂性的全部。由于这个原因,none 调度程序每个 I/O 请求所需的 CPU 指令最少。none 调度程序的操作基于这样的假设:底层设备,如 RAID 控制器或存储控制器,将优化 I/O 性能。

none 调度程序的简单操作逻辑如图 6.11所示:

图 6.11 – none I/O 调度程序

图 6.11 – none I/O 调度程序

尽管每个环境都有许多变量,但根据操作模式,none 调度程序似乎是企业存储区域网络的首选调度程序,因为它不会对底层物理设备做出任何假设,并且不会实施任何可能与低层 I/O 控制器逻辑冲突的调度决策。

鉴于可供选择的选项繁多,确定哪个调度程序最适合你的需求可能是一个挑战。在接下来的部分中,我们将概述本章中介绍的调度程序的常见使用场景。

讨论调度难题

我们已经讨论并解释了不同 I/O 调度策略的工作方式,但选择调度程序时应始终伴随通过实际应用工作负载收集的基准测试结果。如前所述,大多数时候,默认设置可能已经足够好。只有当你尝试达到最佳效率时,才会尝试调整默认设置。

这些调度程序的可插拔特性意味着我们可以动态地更改块设备的 I/O 调度程序。有两种方法可以做到这一点。可以通过sysfs检查特定磁盘设备的当前活动调度程序。在以下示例中,活动调度程序设置为mq-deadline

[root@linuxbox ~]# cat /sys/block/sda/queue/scheduler
[mq-deadline] none bfq kyber
[root@linuxbox ~]#

要更改活动调度程序,将所需调度程序的名称写入调度程序文件。例如,要为sda设置 BFQ 调度程序,可以使用以下命令:

echo bfq > /sys/block/sda/queue/scheduler

上述方法只会暂时设置调度器,并在重启后恢复默认设置。要使此更改永久生效,请编辑 /etc/default/grub 文件,并将 elevator=bfq 参数添加到 GRUB_CMDLINE_LINUX_DEFAULT 行中。然后,重新生成 GRUB 配置并重启系统。

单纯改变调度器并不会带来两倍的性能提升。通常,性能提升在 10% 到 20% 之间。

尽管每个环境不同,调度器的性能可能会根据多个变量而有所变化,但作为基准,以下是我们在本章中讨论的调度器的一些使用场景:

使用场景 推荐的 I/O 调度器
桌面 GUI、交互式应用程序和软实时应用程序,如音频和视频播放器 BFQ,因为它能保证良好的系统响应性和低延迟,适用于时间敏感型应用
传统机械驱动 BFQ 或 MQ-deadline – 都被认为适用于较慢的驱动。Kyber/none 偏向于支持更快的磁盘。
高性能 SSD 和 NVMe 驱动作为本地存储 最好使用 none,但在某些情况下 Kyber 也可能是一个不错的替代方案
企业存储阵列 None,因为大多数存储阵列内置了逻辑来更高效地调度 I/O
虚拟化环境 MQ-deadline 是一个不错的选择。如果虚拟机管理程序层已经自行调度 I/O,那么使用 none 调度器可能会有帮助。

表 6.1 – I/O 调度器的典型使用场景

请注意,这些并非严格的使用场景,因为通常多个条件可能会重叠。应用类型、工作负载、主机系统和存储介质等因素在选择调度器之前都需要考虑。通常,deadline 调度器被认为是一个多用途的选择,因为它的 CPU 开销较小。BFQ 在桌面环境中表现良好,而 none 和 Kyber 更适合高端存储设备。

总结

本章概述了 I/O 调度,这是块层的一个关键功能。当一个读写请求穿过虚拟文件系统的所有层时,最终会到达块层。本章探讨了各种 I/O 调度器及其特性,包括优点和缺点。块层包含多个适用于特定使用场景的 I/O 调度器。I/O 调度器的选择在决定如何处理下层的 I/O 请求中起着至关重要的作用。为了做出更具性能导向的决策,大多数调度器使用一些常见技术,以帮助提高整体磁盘性能。本章讨论的技术包括合并、聚合、排序和插入。

我们还解释了内核中不同的调度选项。内核为单队列和多队列设备提供了不同的 I/O 调度程序。自内核版本 5.0 起,单队列调度器已经被弃用。多队列调度选项包括多队列 Deadline 调度器、BFQ、Kyber 和 None 调度器。每个调度器适用于特定的使用场景,没有一个适用于所有情况的单一推荐方案。MQ-deadline 调度器具有良好的通用性能。BFQ 调度器更倾向于交互式应用,而 Kyber 和 None 则面向高端存储设备。选择调度器时,了解环境的详细信息至关重要,其中包括工作负载类型、应用程序、主机系统和后端物理介质等细节。

本章总结了本书第二部分的内容,我们深入探讨了块层。下一章,我们将看到当前可用的不同类型存储介质,并解释它们之间的差异。

第三部分:深入物理层

本部分将介绍 Linux 内核中 SCSI 子系统的架构和主要组件。你还将了解当前可用的不同类型物理存储介质及其实现差异。

本部分包含以下章节:

  • 第七章SCSI 子系统

  • 第八章物理介质布局示意

第七章:SCSI 子系统

在本书中,我们逐步从存储栈的高层走向低层。我们从 VFS 开始,探讨了主要的 VFS 结构和文件系统,接着研究了块层中的结构和调度技术。VFS 和块层代表了 I/O 层次结构中软件部分的一个重要组成部分。随着我们逐步进入物理层,事情变得稍微更通用,因为用于访问物理硬盘的底层标准在大多数系统中是相同的。

本书的第三部分包含了两章,专门用于建立对物理层面的理解。在本章中,我们将主要关注一个已经存在一段时间的子系统,它是最常用的标准和协议,用于访问物理设备——小型计算机系统接口SCSI)。

SCSI 协议的发展旨在促进计算机与外设之间无缝的数据传输,包括磁盘驱动器、光盘驱动器、打印机、扫描仪以及其他各种资源,从而确保高效可靠的通信。由于我们这里主要关注存储,我们将仅讨论它在磁盘驱动器方面的作用。任何传递给 SCSI 的读写请求都会被转换成等效的 SCSI 命令。需要理解的是,SCSI 不处理数据块的传输安排或它们在磁盘上的物理布局,这些属于 I/O 层次结构中的上层职责。

在我们深入了解 SCSI 之前,首先需要对 Linux 中的设备模型有一个基本的了解。内核中的 kobject 结构提供了一系列构造体,使硬件设备与相应的设备驱动程序之间能够顺利地进行通信和交互。掌握了设备模型的一些基本知识后,我们将尝试解释 SCSI 子系统的主要组成部分。

在本章中,我们将讨论以下主要主题:

  • 设备驱动程序模型

  • SCSI 子系统

技术要求

由于 SCSI 是一种用于与外设通信的协议,例如硬盘,了解这些设备的基本工作原理有助于理解 SCSI 子系统。

本章中展示的命令和示例不依赖于特定的发行版,可以在任何 Linux 操作系统上运行,例如 Debian、Ubuntu、Red Hat 或 Fedora。本书中有不少引用了内核源代码。如果你想下载内核源代码,可以从 www.kernel.org 下载。

设备驱动程序模型

内核中有不同的子系统,例如 系统调用接口虚拟文件系统(VFS)进程和内存管理、以及 网络栈。在本书中,我们严格聚焦于 Linux I/O 层级结构中的各个结构和实体。然而,实际上,读取和写入存储设备的数据过程必须经过这些子系统中的大部分。正如我们所见,抽象层次是 I/O 栈的核心,但这种抽象方式不仅限于存储设备。对于内核来说,磁盘只是其必须管理的多个硬件中的一个。如果有一个专门的子系统来管理不同类型的设备,那么代码将会变得臃肿。当然,不同类型的设备通常会有不同的处理方式,因为它们的角色可能截然不同,但对于最终用户来说,应该有一个关于系统结构的通用抽象视图。

为了实现这种统一,Linux 设备模型提取了设备操作的共性属性,将其抽象化,并在内核中实现这些共性属性,为新加入的设备提供统一的接口。这使得驱动开发过程更加简便顺畅,因为开发者只需要熟悉接口即可。

设备模型的主要目标是维护准确反映系统状态和配置的内部数据结构。这包括设备的存在、它们的关联总线和驱动程序,以及系统中总线、设备和驱动的整体层级和结构等重要信息。为了跟踪这些信息,设备模型利用以下实体将它们映射到物理对应物:

  • 总线:系统中有多个组件,例如 CPU、内存以及输入输出设备。这些设备之间的通信依赖于一个通道,这个通道就是总线。总线是用来传输数据的通道。你可以把它想象成一个线性通道,用于传递交通信号,类似于一条道路。为了方便设备模型的抽象,所有设备都应该连接到总线上。设备模型中的总线是基于物理总线的抽象。

  • 设备:这是指连接到总线的物理设备。在设备模型中,设备抽象了系统中的所有硬件设备,并描述了它们的属性、所连接的总线以及其他信息。

  • 设备驱动:驱动程序是与设备关联的软件实体。设备模型通过驱动程序来抽象硬件设备的驱动,包括设备初始化和电源管理相关的接口实现。

  • Class: 类的概念有些有趣。类代表具有类似功能或属性的设备集合。例如,SCSI 和 Advanced Technology Attachment (ATA) 驱动程序属于同一磁盘类别。类用于根据功能而不是连接性或操作机制对设备进行分类。这与面向对象编程中类的概念有些相似。

设备模型提供一种通用机制来表示和操作系统中的每个设备。正如我们在本书的 第一章 中解释的那样,内核通过 VFS 提供了一个窗口来导出有关各种内核子系统的信息。用户空间中设备模型的表示可以通过 Sysfs VFS 查看。Sysfs 文件系统被挂载在 /sys 目录下,如下截图所示:

图 7.1 – Sysfs 的内容

图 7.1 – Sysfs 的内容

目录包含以下信息:

  • block: 这包含系统中所有可用的块设备,包括磁盘和分区

  • bus: 这表示连接物理设备的各种总线类型,例如 PCI、IDE 和 USB

  • class: 这表示系统中可用的驱动程序类别,如网络、声音和 USB

  • devices: 这表示系统中连接设备的分层结构

  • firmware: 这包含从系统固件检索的信息,特别是 ACPI

  • fs: 这提供有关已挂载文件系统的详细信息

  • kernel: 这提供内核状态信息,包括已登录用户和热插拔事件

  • module: 这显示当前加载的模块列表

  • power: 这包含有关电源管理子系统的信息。

描述模型中内核数据结构与 Sysfs VFS 中的子目录之间存在关联。设备模型中有多个结构允许设备驱动程序与相应的硬件设备之间进行通信。我们不打算探索这些结构,但只需知道,Linux 设备模型的基本结构是 kobject。将 kobject 想象为将设备模型和 Sysfs 接口粘合在一起的胶水。模型较高级别中的结构如 图 7**.2 所示:

  • struct bus_type

  • struct device

  • struct device_driver

这里是 图 7**.2:

图 7.2 – 设备模型组件

图 7.2 – 设备模型组件

总结来说,Linux 设备模型通过一套标准的数据结构和接口对硬件设备进行分类和抽象。通过查看 Sysfs 文件系统的内容,可以在用户空间中看到这个模型。Sysfs 中的实体与实际的物理实现紧密相关。接下来,让我们更详细地探讨 SCSI 子系统的架构。

解释 SCSI 子系统

当提到SCSI(发音为 SKUZ-ee)时,人们可能指代几个不同的意思:

  • 连接外设与计算机的硬件总线

  • 一组用于通过不同总线与设备通信的命令集

长时间以来,SCSI 是计算机中 I/O 总线的主要技术。SCSI 定义了一个接口和一个数据协议,用于将不同类型的设备连接到计算机上。作为一种媒介,SCSI 定义了一个用于数据传输的总线。作为协议,它定义了设备如何通过 SCSI 总线进行相互通信。

最初,外设设备的连接是通过并行 SCSI 总线实现的。多年来,SCSI 并行总线逐渐被淘汰,取而代之的是串行接口。其中最常见的接口包括串行附加 SCSISAS)和SCSI 通过光纤通道。串行接口提供了更高的数据传输速率和可靠性。还有一种通过 TCP/IP 实现的 SCSI 协议,称为互联网 SCSIiSCSI)。

我们将在这里聚焦于 Linux 方面,讨论 SCSI 子系统在 I/O 层次结构中的组织方式。SCSI 标准为各种设备定义了命令集,不仅仅是硬盘。SCSI 命令可以通过几乎任何类型的传输机制发送。这使得 SCSI 成为通过 SATA、SAS 或光纤通道协议访问存储设备的事实标准。

SCSI 架构

SCSI 子系统采用三层架构。顶部的层代表内核的最高接口,用于最终用户应用程序。中间层为 SCSI 堆栈的上下层提供一些公共服务。最底层是底层,它包含与底层物理设备交互的实际驱动程序。每个涉及 SCSI 子系统的操作都在三个层级中各使用一个驱动程序。图 7.3 强调了 SCSI 子系统的多层设计:

图 7.3 – SCSI 架构

图 7.3 – SCSI 架构

以下小节中将更详细描述这三层。

上层

上层包含最接近用户空间应用程序的特定设备类型驱动程序。这些上层驱动程序提供用户空间和内核空间之间的接口。最常用的上层驱动程序包括以下几种:

  • sd:磁盘驱动程序

  • sr:CD-ROM 驱动程序

  • sg:通用的 SCSI 驱动程序

在查看这些驱动程序名称之后,设备名称通常会以驱动程序的前缀缩写表示,如 sda,这也就不足为奇了。上层接受来自存储栈更高层的请求,如 VFS,并通过中间层和下层将它们转化为相应的 SCSI 请求。在 SCSI 命令完成后,上层驱动程序会通知更高层。通用的 SCSI 驱动程序 sg 允许直接向 SCSI 设备发送 SCSI 命令,绕过文件系统层。

上层 SCSI 磁盘驱动程序实现于 /linux/drivers/scsi/sd.c。上层 SCSI 磁盘驱动程序通过调用 register_blkdev 注册为块设备,进行自我初始化,并通过 scsi_register_driver 函数提供一组功能来表示所有 SCSI 设备。

中间层

中间层 是所有 SCSI 操作的共同部分,包含了 SCSI 支持的核心。中间层通过定义内部接口并向上下层驱动程序提供公共服务,将上层和下层连接起来。它负责管理 SCSI 命令队列,确保高效的错误处理,并促进电源管理功能。没有中间层提供的功能,上层和下层驱动程序无法正常工作。

通用的中间层 SCSI 驱动程序实现于 linux/drivers/scsi/scsi.c。中间层抽象了低层驱动程序的实现,并将上层的命令转化为等效的 SCSI 请求。中间层还实现了命令排队功能。当接收到上层的请求时,中间层会将请求排队以便处理。一旦请求处理完成,它会接收来自下层的响应并通知上层。如果请求超时,中间层负责执行错误处理或重新发送请求。

有几个重要的函数,通过它们,中间层作为上层和下层之间的桥梁,sd_probesd_init。在驱动程序初始化期间以及每当新的 SCSI 设备连接到系统时,sd_probe 函数在确定设备是否受 SCSI 磁盘驱动程序管理方面发挥着关键作用。如果设备在管理范围内,sd_probe 会生成一个新的 scsi_disk 结构体来作为该设备的代表实体。当来自存储栈更高层的读取或写入请求(如文件系统)被接收时,sd_init_command 函数将该请求转换为相应的 SCSI 读写命令。

下层

lpfc 是 Emulex HBA 的设备驱动程序。低层驱动程序位于 linux/drivers/scsi/ 目录中。

现在,让我们更深入地探讨 SCSI 子系统如何在客户端-服务器模型中运行。

客户端和服务器模型

SCSI 子系统接收来自存储栈上层的请求,要求从存储设备发送或检索数据块。当应用程序发起读取或写入请求时,SCSI 层通过将该请求转换为等效的 SCSI 命令来处理它。SCSI 子系统不处理数据块如何在存储设备上组织和存放;这是 I/O 栈上层的工作。SCSI 将数据块发送到目标设备,这个目标设备可以是一个单独的磁盘,或一个独立冗余磁盘阵列RAID)控制器。

当操作系统侧的 SCSI 层开始对存储设备执行操作,并且存储设备反过来响应并执行该操作时,这一事件流程可以被归类为客户端-服务器交换模型。在 SCSI 术语中,双方被称为发起者目标。发起请求的主机操作系统被称为 SCSI 发起者。接收并处理此请求的目标存储设备被称为 SCSI 目标。

SCSI 发起者驻留在主机上,代表 I/O 栈中的上层(如应用程序和文件系统)生成请求。SCSI 目标等待发起者的命令,并执行请求的数据传输。必须有一个底层的传输机制来确保发起者的 SCSI 命令能够传送到目标。这是通过 SCSI 传输层来实现的。有多种传输协议可用,例如用于直接附加磁盘的串行附加 SCSISAS),以及用于 SCSI 目标的光纤通道或 iSCSI,这些 SCSI 目标属于存储区域网络SAN)。SCSI 发起者和目标之间的关系如图 7.4所示:

图 7.4 – SCSI 发起者和目标

图 7.4 – SCSI 发起者和目标

接下来让我们来讨论地址方案。

设备地址

Linux 使用四部分层次化的地址方案来标识 SCSI 设备。这四个数字的组合唯一标识了系统中 SCSI 设备的位置。如果你在命令行中运行 lsscsisg_map -x,你会看到每个 SCSI 设备都会用一组四个数字来表示:

[root@linuxbox ~]# lsscsi
[0:0:0:0]    disk    ATA      SAMSUNG MZMTE512 400Q  /dev/sda
[4:0:0:0]    disk    ATA      ST9320320AS      SD57  /dev/sdb
[6:0:0:0]    disk    Generic- Multi-Card       1.00  /dev/sdc
[root@linuxbox ~]#

这种四元地址方案被称为主机、总线、目标和 LUNHBTL),其字段解释如下:

  • 主机:主机表示一个能够发送和接收 SCSI 命令的控制器。SCSI 主机 ID 是 HBA 的 ID,也被称为 SCSI 控制器或 SCSI 适配器。该标识符表示分配给内部系统总线上的适配器卡的任意编号。内核根据适配器发现顺序按升序分配这个编号。例如,第一个适配器将被分配为零,第二个适配器为一,依此类推。

  • 总线:这是在 SCSI 控制器中使用的总线或通道。一个控制器可以有多个 SCSI 总线。此标识符由内核分配,反映了 SCSI 控制器的硬件和固件架构的一部分。通常,SCSI 控制器只有一个总线。高端设备,如 RAID 控制器,可以拥有多个总线。

  • 目标:每个总线可以连接多个设备或目标。目标是总线内的目的设备。这个标识符也由内核分配,按给定 SCSI 控制器内目标发现的顺序进行分配。

  • 逻辑单元号(LUN):这是主机操作系统看到的 SCSI 目标中的逻辑设备。LUN 是能够接收主机发送的 SCSI 命令的实体,意味着一个磁盘驱动器。每个 LUN 在内核的块层中都有一个独占的请求队列。LUN 标识符由存储设备分配,使其成为 SCSI 寻址方案中唯一一个不是由内核分配的部分。

下图说明了这一寻址机制以及从主机到 SCSI 硬盘的路径。请注意,与 SCSI 控制器或 HBA 相关联的是一个相对的 SCSI 目标索引。第一次在 host0 上发现的 SCSI 存储目标被分配为 SCSI 目标(相对索引)0,然后是 1、2,以此类推:

图 7.5 – SCSI 寻址

图 7.5 – SCSI 寻址

如果你查看 /sys/class/scsi_host/,你会看到主机 0 到 6 对应于 SCSI 控制器:

图 7.6 – Sysfs 中的 SCSI 主机

图 7.6 – Sysfs 中的 SCSI 主机

类似地,targetX:Y:Z 格式的结构存在于 /sys/bus/scsi/devices/ 中,并附加到 SCSI 总线:

图 7.7 – Sysfs 中的 SCSI 目标

图 7.7 – Sysfs 中的 SCSI 目标

LUN 可以通过之前讨论的四级层次寻址方案进行标识:

图 7.8 – Sysfs 中的 SCSI LUN

图 7.8 – Sysfs 中的 SCSI LUN

现在我们将探索一些与 SCSI 子系统相关的内核中的主要数据结构。

主要数据结构

上述概念通过三个主要数据结构在内核中实现——Scsi_Hostscsi_targetscsi_device。当然,这些并不是内核中唯一与 SCSI 相关的结构。除了这三种结构外,还有几个辅助结构,例如scsi_host_templatescsi_transport_template。顾名思义,这些结构用于表示 SCSI 适配器和传输类型的一些共通特性。例如,scsi_host_template为相同型号的主机适配器提供共通内容,包括请求队列深度、SCSI 命令处理回调函数以及错误处理恢复函数。SCSI 设备包括硬盘、SSD、光驱等,所有这些设备都具有一些共通功能。这些共通功能被提取为内核中的模板。

这三种主要结构如下:

  • Scsi_Host:这是对应于控制器或 HBA 的数据结构,位于 SCSI 总线下。它包含关于 HBA 的信息,例如其唯一标识符、最大传输大小、支持的特性和主机特定的数据。系统中可以存在多个 SCSI 主机结构,每个结构代表一个独立的主机适配器。SCSI_Host结构作为管理 SCSI 通信的顶级结构。

  • scsi_target:该结构对应于附加到特定主机适配器的目标设备。它包含关于目标的信息,例如其 SCSI ID、LUN以及其他一些标志和参数。SCSI 目标结构与特定的 SCSI 主机结构相关联,并用于管理与该目标设备相关的通信和命令。目标设备可以是物理设备或虚拟设备。

  • scsi_device:该结构表示 SCSI 目标设备中的一个 LUN。它表示目标内的特定设备或分区。当操作系统扫描连接到主机适配器的逻辑设备时,它会为上层 SCSI 驱动程序创建一个scsi_device结构,以便与设备进行通信。它包含设备的 SCSI ID、LUN 和队列深度等信息。它与 SCSI 目标结构和 SCSI 主机结构都相关联,并用于管理该特定设备的通信和 I/O 操作。

这个分层方案允许内核高效地管理 SCSI 设备及其通信。命令和数据传输可以被定向到特定的 SCSI 设备或逻辑单元,并且可以在层次结构的每一层进行错误处理和状态跟踪。这些结构的相互作用在图 7.9中得到了突出展示:

图 7.9 – 主要的 SCSI 结构

图 7.9 – 主要的 SCSI 结构

请注意,Linux 内核中 SCSI 设备的实际实现要复杂得多,涉及到额外的数据结构和接口。然而,这个简化的图示展示了 SCSI 主机、SCSI 目标和 SCSI 设备结构之间的基本连接。

与 SCSI 设备通信

图 7.10所示,和 SCSI 设备通信有三种不同的方式:

图 7.10 – 与 SCSI 设备通信

图 7.10 – 与 SCSI 设备通信

如下所述:

  • 基于文件系统:最常见的方法是通过文件系统提供的接口访问 SCSI 设备。这是大多数常规用户空间应用与 SCSI 设备交互的方式。

  • Linux 中的dd命令。使用原始访问方法不需要文件系统进行地址映射。

  • 在 Linux 中可用的sg3_utils包提供了一组工具,可以通过主机操作系统提供的 SCSI 直通接口将 SCSI 命令发送到设备。

SCSI 层与块层之间的交互

SCSI 层和块层协同工作,以促进 SCSI 设备与文件系统之间的交互。SCSI 层作为块层与特定 SCSI 主机适配器设备驱动程序之间的中间层。

以下是 SCSI 层与块层交互的概述:

  1. 当文件系统发送 I/O 请求(如读写操作)时,块层将其转换为 SCSI 命令。块层构建一个与请求操作对应的 SCSI 命令描述块CDB),并将其传递给 SCSI 层。

  2. SCSI 中间层从块层接收 SCSI 命令并执行必要的处理,包括命令排队、错误处理和数据传输。

  3. SCSI 中间层将 SCSI 命令转发给与特定 SCSI 主机适配器相关的适当底层 SCSI 设备驱动程序。设备驱动程序直接与硬件交互,并通过 SCSI 总线将 SCSI 命令发送到目标设备。

  4. 一旦目标设备执行了 SCSI 命令,底层的 SCSI 设备驱动程序接收到命令完成状态,并将此信息传递回 SCSI 中间层,随后再传递给块层。

  5. 块层接收来自 SCSI 中间层的命令完成状态,并使用此信息处理任何错误,更新 I/O 请求状态,并通知文件系统关于 I/O 请求的完成或失败。

请注意,这是块层和 SCSI 层之间交互的概述视图。块层为高层(如文件系统)提供标准化接口,而 SCSI 层负责将块级 I/O 请求转换为等效的 SCSI 命令,并通过特定于 SCSI 主机适配器的底层设备驱动程序管理与 SCSI 设备的通信。

概要

本章重点讨论了两个主要主题:Linux 中的设备模型和 SCSI 子系统。我们首先简要概述了 Linux 中的设备模型,以及内核如何通过Sysfs虚拟文件系统在用户空间提供其视图。接着我们探讨了 SCSI 子系统,并解释了其三级架构。

如本章所述,SCSI 定义了一个接口和一个数据协议,用于将不同类型的设备连接到系统。作为一种媒介,它定义了一个用于数据传输的总线;作为协议,它定义了设备如何通过 SCSI 总线相互通信。当用户空间中的应用程序发起写入请求以存储数据时,SCSI 子系统将此写入请求转换为 SCSI 命令,将请求的数据写入指定的磁盘位置。它充当 I/O 栈中高层和物理存储之间的中介。SCSI 不负责传输过程中数据块的组装或它们在磁盘上的物理放置。在 SCSI 术语中,发起请求的一方被称为发起者,而目标方则称为目标。SCSI 协议的目标可以是单个物理驱动器、HBA 或 RAID 控制器。SCSI 协议的主要职责是确保写入任务的成功完成,并将其状态报告给高层。

在下一章,我们将讨论当今世界中可用的不同物理存储选项,如机械硬盘、固态硬盘(SSD)和 NVMe 硬盘。我们将描述它们在设计上的差异,并比较它们之间的异同。

第八章:说明物理介质的布局

“如果我问人们他们想要什么,他们会说更快的马。” — 亨利·福特

在本书的前七章中,我们探讨了 Linux 内核中存储层次结构的组织方式、不同层次的组织方式、不同的抽象方法以及物理存储设备的表示方式。我们现在已经完成了对存储堆栈中软件部分的解释,这意味着是时候看看实际硬件,了解其中的奥秘了。我认为最好的方式是通过了解不同类型的存储介质,这样我们不仅能理解它们的工作原理,还能看到为什么 Linux 内核使用不同的调度器和技术来处理不同类型的硬盘。简而言之,了解磁盘驱动器的内部结构将使本书中之前呈现的信息更具相关性。

硬件介质的选择在过去几年中发生了变化,因为市场上现在有大量的选择,不仅适用于企业存储,也适用于个人使用。这些不同的存储选项适用于特定的环境和工作负载类型。例如,在某些场景中,人们寻求容量优先的解决方案,而在其他场景中,最大化性能是最终目标。无论如何,每种场景都有相应的解决方案。对于企业环境,供应商提供可以实现混合解决方案的存储阵列,包含这些选项的组合。

大多数协议和系统在设计时都考虑到了旋转硬盘。Linux 中的存储堆栈也不例外。在讨论块层调度时,我们看到合并和合并等技术是针对机械硬盘的,以便增加顺序操作的数量。由于本章完全涉及不同类型硬盘的物理布局和结构,我们将详细了解为什么旋转硬盘比其他硬盘更慢。

我们将从介绍今天最传统、最古老的存储形式——旋转硬盘开始。我们将讨论其物理结构、设计和工作原理。之后,我们将转向固态硬盘,看看它们与机械硬盘有何不同。我们将讨论它们的内部结构和布局,并解释它们的工作原理。我们还将简要讨论硬盘耐久性的概念,并了解机械硬盘和固态硬盘在这方面的区别。最后,我们将讨论非易失性内存快车道NVMe)接口,它彻底改变了固态硬盘的性能。

我们将讨论以下主要话题:

  • 理解机械硬盘

  • 解释固态硬盘的架构

  • 理解硬盘耐久性

  • 使用 NVMe 重新定义固态硬盘

技术要求

本章节介绍的内容与操作系统无关。因此,没有与 Linux 特定绑定的命令或概念。不过,如果你对今天可用的不同类型的存储介质选项有一些基本知识,会有所帮助。

了解机械硬盘

机械硬盘,也称为硬盘、磁盘、旋转盘或旋转盘,是现代计算机系统中唯一的机械组件。在本书中,我们通常通过称它们为更慢或遗留驱动器来描述它们。事实上,尽管近年来机械硬盘的使用有所下降,但它们仍然广泛存在于今天的企业环境中,扮演了稍有不同角色。由于性能敏感的应用程序有更好的存储选项可用,因此硬盘主要用于冷数据存储。由于更高的容量和较低的成本,机械硬盘仍然是任何环境中不可或缺的一部分。

让我们简要描述机械硬盘的主要组成部分:

  • 盘片:硬盘由多个薄圆盘组成,称为盘片。所有硬盘上的数据都记录在这些盘片上。为了最大化容量,数据可以从盘片表面的顶部和底部进行读写。盘片表面从两端磁化。这些盘片的总数和它们的存储容量决定了硬盘的总容量。

  • 主轴:驱动盘片旋转依靠驱动主轴电机的动力,该电机设计用于保持恒定的转速。硬盘盘片以每分钟数千转(rpm)的速度旋转,标准主轴转速为 5,400 rpm、7,200 rpm、10,000 rpm 和 15,000 rpm,因为所有盘片都连接到单个主轴电机上。因此,它们同时旋转并以相同的速度旋转。

  • 读写(R/W)头:作为新手,我一直以为数据是以书面形式或书写形式刻在硬盘上的。事实上,数据是通过移动介质上磁信号的模式来表达的。每个盘片上有两个读写头,分别位于顶部和底部。在写数据时,读写头修改盘片表面的磁方向,而在读取数据模式下,读头检测盘片表面的磁方向。值得注意的是,读写头永远不会触碰盘片表面。

  • 臂式臂:臂式臂组件负责安装读写头。臂式臂在将读写头精确定位到需要读取或写入数据的特定位置方面起着至关重要的作用。

  • 控制器:磁盘控制器是一个重要组件,负责管理之前提到的各个组件的运行,并与主机系统进行交互。它执行来自主机的指令,管理读写头,并控制驱动臂。

现在我们已经熟悉了机械硬盘的主要组件,让我们来看看机械硬盘的几何结构。

查看物理布局和寻址

硬盘的几何描述了数据在盘片上的组织方式。这种组织方式是基于将盘片表面分成同心圆环,称为磁道。柱面是一个垂直部分,穿过所有盘片上的相应磁道,用于指代磁盘上的特定位置。柱面由每个盘片上的相同磁道号组成。每个磁道进一步划分为更小的单元,称为扇区。扇区是硬盘上最小的可寻址单元。我们在第三章中讨论过块大小的概念。块是一组扇区,是文件系统的属性。扇区是硬盘的物理属性,其结构由硬盘制造商在初次格式化时创建。最初,最常见的扇区大小是 512 字节。然而,一些现代硬盘也使用 4KB 的扇区。以下是机械硬盘物理结构的示意图:

图 8.1 – 机械硬盘结构

图 8.1 – 机械硬盘结构

有几种技术可以用来寻址硬盘上的物理位置。一种被称为柱面、磁头和扇区CHS)。硬盘的物理几何通常通过 CHS 表示。CHS 的组合可以用来标识磁盘上的任何位置。为了在硬盘上定位一个地址,主机操作系统必须知道磁盘的 CHS 几何信息。

CHS 现在已被逻辑块寻址LBA)所取代。LBA 是另一种磁盘寻址方式,它简化了操作系统侧的地址管理。LBA 使用线性寻址方案来访问物理数据块。使用 LBA 时,不再通过 CHS 寻址扇区,而是为每个扇区分配一个唯一的逻辑编号。通过这种方式,硬盘被简化为一个单一的大设备,只需从 0 开始计数现有的扇区。然后,磁盘控制器的任务是将 LBA 地址转换为 CHS 地址。主机操作系统只需要知道磁盘驱动器的大小,以逻辑块地址的数量表示即可。

查看坏道

坏扇区或坏块是硬盘上无法再写入或读取的区域,可能是因为它们已损坏或受到损坏。在这种情况下,硬盘控制器会将逻辑扇区重映射到另一个物理扇区。这一过程可以透明地进行,操作系统不会察觉到。

坏扇区有两种类型——硬坏扇区和软坏扇区。硬坏扇区是由于物理损坏(例如,物理冲击或制造缺陷)导致的。硬错误通常是无法修复的,这样的扇区无法再用于存储数据。软坏扇区是硬盘上由主操作系统识别为有问题的区域。如果该扇区的纠错码ECC)与写入该位置的信息不匹配,操作系统可以识别该扇区为有问题。如果应用程序尝试从某个扇区读取数据并发现 ECC 与该扇区的内容不匹配,这可能表示该扇区存在软坏扇区错误。这些错误可以通过多种方法进行修正和解决。

查看硬盘性能

在强调机械硬盘的性能限制时,我们经常使用寻道寻道时间这个术语。硬盘的寻道时间是指将读写头移动到磁盘表面正确轨道所花费的时间。如前所述,机械硬盘的随机访问操作非常昂贵。当访问随机轨道上的数据时,寻道时间会增加,因为读写头需要不断移动。硬盘的寻道时间越低,I/O 请求的服务速度越快。

一旦读写头定位到正确的轨道,接下来的任务是将所需的扇区定位到头部下方。为此,磁盘盘片会旋转,以将请求的扇区定位到读写头下方。完成这一任务的总时间被称为旋转延迟。这个操作依赖于主轴电机的速度。电机的转速越高,旋转延迟就越低。同样,如果请求的是同一轨道上的相邻扇区,旋转延迟会较低。要在随机扇区上读写数据时,旋转延迟会更高。

驱动头需要在旋转磁盘的特定区域进行对准才能读取或写入数据,这导致在数据能够访问之前会出现延迟。要启动程序或加载文件,驱动器可能需要从多个位置读取数据,这可能导致多次延迟,因为每次磁盘盘片都需要旋转到正确的位置。

理解机械硬盘的延迟问题

自从固态硬盘问世以来,显而易见,机械硬盘无法与 CPU 的运行速度相匹配。机械硬盘的响应时间以毫秒为单位,而 CPU 则以纳秒为单位。设计中存在的机械部件也限制了性能。这并不是说没有努力改善硬盘的物理结构。例如,硬盘配备了小型磁盘缓存以提升性能。多年来,主轴电机的转速从几百转每分钟提升到高达 15,000 转每分钟。还设计了更小的盘片表面以提高性能。所有这些因素都有助于显著提高机械硬盘的性能。然而,尽管如此,即便是最快的旋转硬盘,与 CPU 相比依然太慢。大部分时间都花费在机械部件的运动上。

由于机械部件速度的限制,硬盘性能在与一些现代存储选项相比时显得力不从心。硬盘的性能深受应用程序读写模式的影响。对于顺序操作,性能显著更好。然而,对于随机访问操作,硬盘的性能会退化,因为这些操作涉及频繁移动读写头和盘片的连续旋转。尽管存在这些缺点,机械硬盘仍然被视为企业环境中重要的组成部分。机械硬盘每千兆字节的成本较低,使其在容量为首要考量的情况下,仍然是一个非常好的选择。

现在我们已经对机械硬盘有了基本的了解,让我们来探讨一下固态硬盘,并看看它们与传统旋转介质有何不同。

解释固态硬盘的架构

企业存储性能随着固态硬盘(SSDs)的引入发生了巨大的飞跃。SSD 之所以被称为固态硬盘,是因为它们基于半导体材料。与旋转硬盘不同,SSD 没有任何机械部件,使用非易失性内存芯片来存储数据。由于没有活动部件,SSD 的速度远远快于机械硬盘也就不足为奇了。它们提供了对传统硬盘的显著升级,并逐渐取代了机械硬盘,成为首选存储介质。

SSD 使用闪存芯片来实现数据的永久存储。在这方面有两个选择,NAND 和 NOR 闪存。大多数 SSD 使用 NAND 闪存芯片,因为它们提供更快的写入和擦除速度。冒着过多探讨电子学的风险(这是我大学时最不喜欢的科目),NAND 闪存由浮栅晶体管组成,电子存储在浮栅中。当浮栅中包含电荷时,它被读取为零。这意味着数据存储在单元中,这与我们通常的想法相反(你可以理解为什么我不喜欢电子学了)。

SSD 的主要组件如下所示:

图 8.2 – SSD 的架构

图 8.2 – SSD 的架构

SSD 控制器执行各种功能,并将 NAND 闪存中的原始存储呈现给主机。

查看物理布局和寻址

SSD 中的每个 NAND 闪存内存包含以下组件:

  • :浮栅晶体管是 SSD 的关键组件,负责电荷在单元中的传导、保持和释放,利用存储在浮栅中的电子。

  • 单元:单元是存储的基本单位,可以包含单个数据,其电荷表示位的值。正如我们将要看到的,单元可以存储单一电平或多个电平的电荷。

  • 字节:一个字节包含八个单元。

  • :在 SSD 中,页类似于硬盘上的扇区,表示可以被写入和读取的最小单位。通常,页面大小为 4 KB,尽管它也可以大于此值。

  • :块是 SSD 中一组页的集合。SSD 中的擦除操作是按块进行的,这意味着块中的所有页面必须一起擦除。

在内部,SSD 中的位被存储在单元中,这些单元然后被组织成页。页面被分组成块,块又封装在一个平面中,如图 8.3所示。一个芯片通常由多个平面组成:

图 8.3 – SSD 中的芯片布局

图 8.3 – SSD 中的芯片布局

SLCMLC这两个术语描述了每个单元存储的位数。除了 SLC 和 MLC 之外,还有SLC NAND,此时闪存控制器只需知道该位是 0 还是 1。对于MLC NAND,单元可以有四个值——00011011。同样,对于TLC NAND,单元可以有8个值,而 QLC 可以有16个值。下图显示了不同类型 SSD 之间的关系:

图 8.4 – SLC、MLC、TLC 和 QLC 闪存的比较

图 8.4 – SLC、MLC、TLC 和 QLC 闪存的比较

类似于机械硬盘,固态硬盘(SSD)也使用逻辑块地址(LBA)来寻址物理位置,但这个过程涉及到一些额外的组件。NAND 闪存使用闪存转换层FTL)将逻辑块地址映射到物理页面。FTL 隐藏了 NAND 闪存内部的复杂性,只向主机暴露逻辑块地址的数组。这样做是故意模拟机械硬盘,因为操作系统端的大部分技术栈都是为机械硬盘优化的。

查看读写操作

与硬盘不同,硬盘的扇区是所有操作的基本单位,而 SSD 使用不同的单位来执行不同的操作。SSD 在页面级别读取和写入数据,而所有的擦除操作都在块级别进行。机械硬盘中的扇区可以反复重写,而 SSD 中的页面则无法被覆盖,正如我们在后续部分将看到的那样,这背后有其原因。

SSD 中扇区的等价物是页面。无法单独读取单个单元。读操作与设备的原生页面大小对齐。例如,假设页面大小为 4 KB,如果你想读取 2 KB 的数据,闪存控制器将获取整个 4 KB 页面。同样,写入操作也遵循相同的规律。写入操作按页面对齐,并按页面大小发生。假设页面大小为 4 KB,写入 6 KB 的数据将使用两个 4 KB 的页面。

擦除、垃圾回收和可用空间的假象

当应用程序向 NAND 闪存写入数据时,闪存必须为新数据分配一个新的空白页面。擦除 NAND 闪存需要高电压,如果在页面级别进行擦除,可能会对相邻单元造成负面影响,限制其寿命。因此,SSD 在块级别擦除数据,以减少这一问题,尽管这增加了擦除操作的复杂性。擦除操作是决定 NAND 闪存寿命的关键因素。编程和擦除周期P/E)这个术语反映了 SSD 的寿命,基于 NAND 闪存可以承受的 P/E 周期数。当一个块被写入并擦除时,这算作一个周期。这很重要,因为块可以写入有限次数,超出这个次数后,它们就无法再写入新数据了。

那么,SSD 是如何擦除数据的呢?假设我们向 SSD 写入一些数据。写入操作,也称为编程操作,发生在页面级别。一段时间后,我们意识到之前写入的数据需要用一些新内容更新。这里有两种情况:

  • 足够的空闲页面是以块为单位提供的。

  • 有足够的空闲页面,但所有块包含自由页面、已使用页面和陈旧页面的混合,或者包含已使用页面和陈旧页面的混合。

假设有空闲页面可用。闪存控制器将把更新后的数据写入任何空闲的空页面,而旧页面将被标记为过时。这些被标记为过时的页面是一个块的一部分。很可能,块中其他页面包含的是正在使用的数据。当块中的某个页面需要更新时,闪存控制器会在内存中读取整个块的内容(其中包括该页面),并计算该页面的更新值。然后,它会对该块执行擦除操作。该块擦除操作会擦除整个块的内容,包括除了要更新的页面之外的所有页面。接着,闪存控制器会写回该块的先前内容和该页面的更新值。这个过程叫做写放大。写放大是指存储设备执行的写操作次数多于主机设备执行的操作次数。

由于所有擦除操作都在块级别进行,那么我们如何回收被过时页面占用的空间呢?控制器肯定不会等到一个块中的所有页面都变为过时状态后才擦除它们吧?如果真是这样,驱动器很快就会用尽空闲页面。显然,这种方法可能带来一些危险后果。这就引出了第二个问题——当没有足够的空闲页面来容纳新的写入,或者所有块都包含已用和过时页面的组合时,SSD 如何应对这种情况?为了回收过时页面,擦除操作需要在块级别进行,但我们应该将当前正在使用的所有页面放在哪里呢?下面的图示突出展示了这一特定场景:

图 8.5 – 我们将如何写入 incoming 数据?

图 8.5 – 我们将如何写入 incoming 数据?

这就是过度配置幻象的来源。闪存驱动器中有比终端用户可见的更多空间。这些未分配的空间被 SSD 控制器保留,用于像磨损均衡和垃圾回收这样的操作。额外的空间在一些情况下非常有用,例如需要释放过时的块时。清理过时块的过程称为垃圾回收。通常,SSD 的实际容量可以比宣传的多 20-40%。闪存驱动器供应商在各类 SSD 中普遍使用这种技巧,从个人系统使用的 SSD 到企业存储阵列中使用的 SSD 都有。这部分额外的空间有助于提升 SSD 的耐用性和写入性能。

观察磨损均衡情况

鉴于 P/E 循环的次数有限,均衡操作的目的是通过确保数据在页面之间均匀分布来延长 SSD 的使用寿命。当某个特定单元中的数据需要修改时,均衡过程会通知 FTL 将 LBA 重新映射到新块。均衡操作会将旧数据标记为过时。如前所述,当前的块不需要擦除。所有这些决策都是为了延长单元的使用寿命。例如,如果主机应用程序频繁更新单个单元中的值,而闪存控制器不断修改同一块,则该单元的绝缘材料将更快地磨损。

查看坏块管理

由于每个单元只能经历有限次数的 P/E 循环,因此必须跟踪那些已经成为缺陷、无法再被编程或擦除的单元。从这个时刻起,该单元被视为坏块。控制器维护一个所有坏块的表格。如果块中的页面包含有效数据,则将该块中的现有数据复制到新的块中,并更新坏块表。

查看 SSD 性能

SSD 中使用 NAND 闪存使得其非常快速。尽管性能仍然远不及主存储器的速度,但它比旋转硬盘快了多个数量级。没有任何机械部件的存在确保 SSD 不受限制机械硬盘性能的因素影响。随机访问操作是机械硬盘的阿基琉斯之踵,使用 SSD 时,这不再是问题。

理解 SSD 的瓶颈

电子设备的问题在于它们通常被设计为只能使用一定的时间。这对于 SSD 也是如此;它们有一定的使用寿命。为了简化物理和电子学方面的内容,SSD 中的写入过程是存储电子,而擦除过程则是排放浮栅晶体管中的电压。这个事件的顺序被称为 P/E 循环。每个 NAND 单元都包含帮助保持电压的绝缘材料。每当一个单元经历一次 P/E 循环时,绝缘材料就会遭受一些损伤。这种损伤是有限的,但随着时间的推移,它会逐渐积累,最终,绝缘材料将失去其功能。这可能导致电压泄漏,进而引起电压状态之间的变化。之后,该单元将被认为是有缺陷的,无法再用于存储。如果有太多的单元达到其极限,硬盘将无法正常工作。这就是为什么机械硬盘比 SSD 具有更强的耐用性的原因。

你会经常看到 SSD 的规格表中包含它可以承受的 P/E 循环次数。但这并不意味着 SSD 不能用于长期存储。尽管其使用寿命是有限的,但这个限制通常是相当长的,可以继续使用多年。市面上有许多工具可以检查 SSD 的健康状况和磨损水平。表 8.1展示了机械硬盘和 SSD 的一些常见操作,显示了它们的基本单位:

驱动器 操作 单位
机械硬盘 读取 扇区
写入 扇区
更新 扇区
擦除 扇区
坏块管理 扇区
SSD 读取
写入
更新
擦除
坏块管理

表 8.1 – SSD 和 HDD 的操作单位

在性能方面,与传统的机械硬盘相比,SSD 提供了巨大的优势。它们具有更低的延迟,这推动了应用程序向新的性能门槛发展。如今,SSD 不仅在企业环境中常见,在个人系统中也已普及。它们没有任何机械组件,大多数驱动器使用 NAND 闪存来持久存储数据。它们的内部结构和策略比旋转硬盘更加复杂。虽然它们比机械硬盘贵,但在几乎所有其他方面都优于机械硬盘。

在深入了解 NVMe 驱动器的世界之前,让我们简要讨论一下驱动器的耐用性问题。

理解驱动器耐用性

通常,机械硬盘和固态硬盘(SSD)都有一个耐用性评分。驱动器的耐用性定义了多个方面,如其最大性能、工作负载限制以及平均故障时间MTBF)。由于它们本质上的差异,机械硬盘和 SSD 的耐用性测量方式也不同。

两种驱动器的耐用性评分表达方式不同。如前所述,NAND 闪存中的单元可以经历有限次数的 P/E 循环。一旦达到这个限制,单元将变得有缺陷。SSD 的耐用性评分是根据 NAND 的 P/E 循环次数来确定的。需要注意的是,NAND 单元仅在写(编程)和擦除操作时才会磨损。对于读取操作,这种开销可以忽略不计。衡量 SSD 耐用性的指标如下:

  • 每日写入驱动次数(DWPD):DWPD 评分显示了在驱动器的整个生命周期中,每天可以覆盖整个 SSD 的次数

  • 写入的字节数(TBW):TBW 评分表示在整个生命周期内,驱动器可以写入多少数据,超出此范围后可能需要更换

如果你有一个 100 GB 的 SSD,保修期为三年,并且 DWPD 评分为 1,这意味着你可以每天将 100 GB 的数据写入该驱动器,持续三年,这意味着你的 TBW 评分将为 109 TBW:

100 GB x 365 天 x 3 年 ≈ 109 TB

机械硬盘不同,因为它们不受 P/E 循环的影响。机械硬盘的磁性盘片表面支持数据的覆盖写入。如果要写入的物理位置已经有数据,现有数据可以直接被新数据覆盖。然而,虽然 SSD 的评级仅受写操作影响,与读取操作无关,机械硬盘则受到读写操作的双重影响。因此,机械硬盘的评级是以读取和/或写入的字节数来表示的。这个工作负载限制没有官方术语,但根据 SSD 的术语,非正式地可以称之为 每天写入/读取次数 (DWRPD)。读写操作对硬盘耐用性的影响见于 表 8.2

硬盘类型 工作负载评级 操作 对耐用性的影响
机械硬盘 DWPD 读取 降低
写入 降低
SSD DWRPD 读取 影响微乎其微
写入 降低

表 8.2 – 硬盘耐用性

表 8.2 总结了读取和写入操作对两种硬盘的影响。实际的保修值会因存储供应商的不同而有所差异。机械硬盘的工作负载限制也以每年可读取/写入的 TB 数据量来表示。在检查耐用性时,请记住,DWPD 和 TBW 等术语仅仅是数字。理解保修期是确定实际耐用性的关键。选择硬盘时,最好同时考虑保修期和 DWPD。

现在,让我们来探讨一下 NVMe 接口如何彻底改变传统的 SSD。

通过 NVMe 重塑 SSD

有几种传输协议用于访问机械硬盘和固态硬盘(SSD)。像 SATA、SCSI 和 SAS 等协议最初是为机械硬盘设计的,因此这些协议更侧重于利用旋转硬盘的潜力。随着 SSD 的出现,这些协议也开始用于这些类型的硬盘。大多数 SSD,尤其是在早期,使用 SATA 和 SAS 接口,就像任何其他机械硬盘一样。它们可以轻松地安装到现有的机械硬盘插槽中,并通过 SATA 或 SAS 控制器与系统连接。尽管使用 SSD 时性能有了显著提升,但最初为机械硬盘编写的接口、协议和命令集仍然被用于 SSD,这被认为是一种额外的负担,许多人认为这在某种程度上限制了闪存硬盘的潜力发挥。

NVMe 接口是专门为 NAND 闪存等技术设计的。人们常常误认为 NVMe 是一种新型硬盘,但从技术上讲,NVMe 并不是。NVMe 是一种用于 SSD 的存储访问和传输协议。它充当一个通信接口,直接通过 PCIe 接口进行操作。标准 SSD 是带有 SATA 或 SAS 接口的硬盘。这些硬盘通过传统的 SCSI 协议由主机操作系统访问。NVMe SSD 使用 M2 物理形态,并使用专门为这类硬盘开发的 NVMe 逻辑接口。NVMe 硬盘只能通过 PCIe 接口进行访问。在主机操作系统中,使用独立的驱动程序和协议来访问 NVMe 硬盘。简而言之,记住以下几点:

每个 NVMe 硬盘都是 SSD,但并非每个 SSD 都是 NVMe 硬盘。

NVMe 跳过了传统 SSD 所经过的路径,直接通过 PCIe 接口连接到 CPU,利用主板上的 PCIe 插槽。存储与 CPU 之间的信号路径越小,性能越好。此外,PCIe 为存储设备提供四条通道,使得数据交换速度是 SATA 连接的四倍,而 SATA 只有一条通道。当与 NVMe SSD 配合使用时,性能会呈指数级增长。

性能提升不仅仅是因为强大的硬件。软件堆栈也需要优化,以充分利用硬件。有时,一个硬件组件的表现仅能达到控制它的软件的水平。例如,SATA 和 SAS 接口分别支持 32 个和 256 个命令的单个队列。相比之下,NVMe 拥有 64,000 个队列,每个队列有 64,000 个命令。这是一个惊人的差距。NVMe 接口有一套专门的命令集,这与所有旧的 SATA 和 SCSI 协议完全不同。旧的协议是专门为机械硬盘设计的,软件负担较重。而 NVMe 只包含少数几个命令,确保处理 I/O 请求时占用非常少的 CPU 周期。

由于 NVMe 定义了通信接口和存储如何呈现给系统的方法,这使得在软件堆栈中使用单一驱动程序来控制设备成为可能。对于传统协议,每个厂商都需要为每个设备开发一个驱动程序,以支持所需的功能。图 8.6 表示了存储堆栈的简要层次结构,同时突出了使用 NVMe 和 SCSI 协议时的开销差异:

图 8.6 – NVMe 与 SCSI 堆栈对比

图 8.6 – NVMe 与 SCSI 堆栈对比

在性能方面,NVMe 驱动器轻松提供市场上所有 SSD 中最快的传输速度。NVMe SSD 的读写性能远远优于任何标准 SSD。由于其卓越的性能,NVMe SSD 的价格明显高于标准 SSD,这并不意外,因为 NVMe 接口和协议旨在充分利用 SSD 的能力。

摘要

在大部分时间都集中在软件方面之后,本章专注于实际的物理硬件。因此,本章中呈现的几乎所有信息都可以视为与平台无关。硬件能力是相同的;关键在于驱动硬件的软件,如何使其发挥最大的潜力。

我们讨论了市场上最常见的三种存储选项——旋转硬盘、SSD 和 NVMe 驱动器。旋转机械硬盘是市场上最古老的存储介质之一。过去几十年,它经历了一些变化,虽然在某种程度上提升了性能,但由于其由多个机械组件组成,所以它的性能有一个硬性限制。毕竟,旋转硬盘的主轴电机只能以一定的速度旋转磁盘表面。由于硬盘性能的局限性,SSD 应运而生。SSD 没有任何机械零件,仅由电子组件组成。它们使用 NAND 闪存进行数据的永久存储,这使得它们相比旋转硬盘速度极快。由于写入和擦除过程涉及电压对单元的应用和释放,SSD 能够承受有限次数的编程和擦除周期,这在一定程度上限制了它们的使用寿命。

之前,SSD 受限于为机械硬盘设计的协议和接口。然而,随着 NVMe 的出现,这一限制得以突破。NVMe 是专为 NAND 闪存开发的,作为一种存储访问和传输协议,专门用于 SSD。与传统的 SSD 不同,NVMe 直接通过 PCIe 接口操作,这使得它的速度大大提高。

我们现在已经完成了本书的第三部分。希望你觉得其中的信息有用。在第四部分,我们将讨论和探索一些故障排除和分析存储性能的工具和技术。

第四部分:分析与故障排除存储性能

本部分将重点介绍可以用来评估和衡量存储性能的不同标准。我们将介绍评估性能的不同指标,并讨论在存储堆栈的每一层中,可以使用的不同工具和技术来调查性能。我们还将介绍一些推荐的实践,帮助提高存储性能。

本部分包含以下章节:

  • 第九章分析物理存储性能

  • 第十章分析文件系统和块层

  • 第十一章调优 I/O 栈

第九章:分析物理存储性能

“当你排除了不可能的事情后,剩下的,无论多么不可能,必须就是事实。”— 亚瑟·柯南·道尔

现在我们已经理解了 Linux 存储环境的细节,可以将这些理解应用于实际操作。我总是喜欢将 I/O 堆栈与网络中的 OSI 模型进行比较,每一层都有其专门的功能,并使用不同的数据单元进行通信。在前八章中,我们已经加深了对存储堆栈分层结构及其概念模型的理解。如果你还在继续跟随,你可能已经理解了即使是最基本的应用请求也需要经过多个层级才能被底层磁盘处理。

作为好人,我们在与他人合作时,可能过于挑剔,喜欢吹毛求疵。这将引领我们进入下一个阶段——我们如何评估和衡量存储性能?计算资源与存储资源之间总会有显著的性能差距,因为磁盘的速度比处理器和内存慢几个数量级。这使得性能分析成为一个非常广泛且复杂的领域。你如何判断多少才算过多,多少才算过慢?一组数值可能对某个环境非常合适,而在另一个环境中却会引发警报。根据工作负载,这些变量在每个环境中都会有所不同。

在 Linux 中,有许多工具和跟踪机制可以帮助识别系统性能的潜在瓶颈。我们将重点关注存储子系统,利用这些工具来了解幕后发生的情况。一些工具在大多数 Linux 发行版中默认提供,可以作为一个很好的起点。

以下是我们将在本章中讨论的内容概述:

  • 我们如何评估性能?

  • 了解存储拓扑

  • 分析物理存储

  • 使用磁盘 I/O 分析工具

技术要求

本章更注重实践操作,要求读者有一定的 Linux 命令行经验。大多数读者可能已经熟悉本章讨论的一些工具和技术。具备基本的系统管理技能会有所帮助,因为这些工具涉及资源监控和分析。最好拥有所需的权限(root 或 sudo)来运行这些工具。根据你选择的 Linux 发行版,你需要安装相关的包。在 Debian/Ubuntu 上安装 iostatiotop,使用以下命令:

apt install sysstat iotop

在 Fedora/Red Hat 上安装 iostatiotop,使用以下命令:

yum install sysstat iotop

要安装 Performance Co-Pilot,你可以参考他们官方文档中的安装说明,网址如下:pcp.readthedocs.io/en/latest/HowTos/installation/index.html

这些命令在所有 Linux 发行版中的使用方法相同。

我们如何评估性能?

我们可以通过不同的视角来评估系统的性能。一种常见的方法是将整体系统性能与处理器的速度等同。如果我们回到单处理器系统普遍存在的简单时代,并与现代的多插槽、多核心系统进行比较,我们会发现处理器的性能已经以一种简单来说是史诗般的速度增长。如果我们将处理器性能的提升与磁盘性能的提升做比较,处理器无疑是遥不可及的赢家。

存储设备的响应时间通常以毫秒为单位进行测量。对于处理器和内存,这个值通常是纳秒级别的。这导致了应用程序需求与底层存储实际能够提供的性能之间的矛盾。存储子系统的性能进展速度并没有跟上处理器的脚步。因此,将系统性能等同于处理器性能的观点逐渐消失。就像链条的强度取决于最弱的一环,整个系统的性能也依赖于最慢的组件。

大多数工具和实用程序通常只关注磁盘性能,并未深入分析更高层次的性能。正如我们在这个过程中发现的那样,当应用程序向存储设备发送 I/O 请求时,幕后发生了大量的操作。考虑到这一点,我们将把性能分析分为以下两部分:

  • 物理存储的分析

  • 对 I/O 栈中更高层次的分析,如文件系统和块层

在这两种情况下,我们将解释相关的度量标准以及它们如何影响性能。文件系统和块层的分析将在第十章中进行讨论。我们还将看到如何通过 Linux 发行版中的可用工具检查这些度量标准。

理解存储拓扑

大多数企业环境通常包含以下几种类型的存储。

  • 直接附加存储(DAS):这是最常见的存储类型,直接连接到系统,例如你笔记本电脑中的硬盘。由于数据中心环境需要在每一层都有一定级别的冗余,企业服务器中的直接附加存储由多个磁盘组成,这些磁盘被组合成 RAID 配置,以提高性能和数据保护。

  • 光纤通道存储区域网络:这是一种块级存储协议,利用光纤通道技术,使服务器能够访问存储设备。与传统的 DAS 相比,它提供了极高的性能和低延迟,并用于运行关键任务应用。由于需要专用硬件,如光纤通道适配器、光纤通道交换机和存储阵列,因此其成本远高于其他选项。

  • iSCSI SAN:这也是一种块级存储协议,可以利用现有的网络基础设施,使主机能够访问存储设备。iSCSI SAN 通过 TCP/IP 网络传输 SCSI 数据包,实现源端与目标块存储之间的通信。由于它不依赖于专用网络(如 FC SAN),因此性能低于 FC SAN。然而,由于不需要专用适配器或交换机,iSCSI SAN 的实施要容易且成本较低。

  • 网络附加存储(NAS):NAS 是一种文件级存储协议。像 iSCSI SAN 一样,NAS 阵列也依赖现有的网络基础设施,不需要额外的硬件。然而,由于存储是通过文件级机制访问的,其性能较低。尽管如此,NAS 阵列是最具成本效益的选择,通常用于存储长期备份。

这些技术的简化比较见图 9.1。为了专注于访问每种存储类型的差异,已省略了高层中的附加细节:

图 9.1 – 不同的存储拓扑结构

图 9.1 – 不同的存储拓扑结构

我们在讨论中不会包括光纤交换机或任何 SAN 阵列。然而,请记住,访问不同类型存储技术涉及许多组件。每一层都需要仔细检查,因此在诊断存储环境时,您应该始终心中有一张拓扑图。

分析物理存储

性能定义了磁盘驱动器在访问、检索或保存数据时的表现。有很多指标可以帮助定义磁盘子系统的性能。对于那些在评估和购买高端存储阵列时与存储供应商合作过的人来说,IOPS(每秒输入输出操作数)是一个非常熟悉的术语。供应商通常会频繁提到这个缩写,并将存储系统的 IOPS 作为其主要卖点之一。

每秒输入输出操作数IOPS)可能是一个完全没有用的数字,除非它与存储系统的其他能力结合使用,比如响应时间、读写比、吞吐量和块大小。IOPS 通常被称为英雄数字,除非与其他指标结合,否则它很少能提供系统能力的任何洞见。当你购买一辆汽车时,你需要了解诸如加速、燃油经济性以及它如何应对弯道等详细信息。你很少会考虑它的最高速度。同样,你也需要了解存储系统的所有能力。

聚焦于物理磁盘,我们首先定义基于时间的性能指标,因为它们是解释时间如何以及在哪里花费的指标。每当你在分析性能时听到延迟延时这两个词时,通常是失去的时间的一个标志。这是本可以用于处理某些事情的时间,但却花费在等待某些事情发生上。

了解磁盘服务时间

首先让我们了解在分析物理磁盘时需要关注的时间相关指标。一旦我们对这些指标有了概念性的理解,我们将使用具体工具来寻找潜在的瓶颈。下图展示了用于衡量磁盘性能的最常见的时间中心指标:

图 9.2 – 磁盘服务时间

图 9.2 – 磁盘服务时间

需要强调的是,上述指标并没有考虑到通过内核 I/O 层级(如文件系统、块层和调度)所花费的时间。我们将分别探讨这些内容。目前,我们只关注物理层。

图 9.2 中使用的术语在这里解释:

  • I/O 等待:I/O 请求可以在队列中等待,也可以被积极处理。I/O 请求在被调度处理之前会先被插入到磁盘的队列中。在队列中等待的时间量被量化为 I/O 等待时间。

  • I/O 服务时间:I/O 服务时间是指磁盘控制器在积极处理 I/O 请求期间的时间。换句话说,它是 I/O 请求未在队列中等待的时间。服务时间包括以下内容:

    • 磁盘寻道时间是指将磁盘读写头通过径向运动移动到指定磁道所花费的时间。

    • 一旦读写头放置在正确的磁道上,盘片表面将旋转,将准确的扇区(从中读取或写入数据的区域)对齐到读写头的位置。在此处花费的时间被称为旋转延迟

    • 一旦读写磁头定位到正确的扇区,就会执行实际的 I/O 操作。这就是传输时间。传输时间是指将数据从磁盘传输到主机系统或从主机系统传输到磁盘所需的时间。

  • 响应时间:响应时间或延迟是服务时间和等待时间的总和,可以看作是 I/O 请求的往返时间。它以毫秒为单位,是与存储设备工作时最重要的术语,因为它表示从发出 I/O 请求到实际完成所需的全部时间,如图 9.3所示:

图 9.3 – 磁盘延迟

图 9.3 – 磁盘延迟

图 9.4所示,存储供应商通常会提到以下寻道时间规格:

  • 全程寻道:这是读写磁头从磁盘最内圈移动到最外圈轨道所需的时间。

  • 平均寻道时间:这是读写磁头从一个随机轨道移动到另一个轨道所需的平均时间。

  • 轨道到轨道:这是读写磁头在两个相邻轨道之间移动所需的时间。

磁盘寻道时间规格见图 9.4

图 9.4 – 磁盘寻道时间规格

图 9.4 – 磁盘寻道时间规格

传输速率可以分为内部和外部传输速率:

  • 内部传输速率:这是数据从磁盘的盘片表面传输到其内部缓存或缓冲区的速度。

  • 外部传输速率:一旦数据被提取到缓冲区,它将通过磁盘支持的接口或协议传输到主机总线适配器控制器。如图 9.5所示,从缓冲区到主机总线适配器的数据传输速度决定了外部传输速率:

图 9.5 – 磁盘传输速率

图 9.5 – 磁盘传输速率

正如我们在第八章中解释的那样,与机械硬盘不同,SSD 不使用任何机械组件。因此,诸如旋转延迟和寻道时间等概念不适用于它们。响应时间涵盖了所有与时间相关的方面,它是检查性能相关问题时最常用的术语。

磁盘访问模式

机械硬盘最受 I/O 访问模式的影响。应用程序生成的 I/O 模式可以是顺序操作和随机操作的组合:

  • 顺序 I/O:顺序 I/O 操作指的是从连续或相邻磁盘位置读取或写入数据的 I/O 请求。对于机械硬盘,这会显著提高性能,因为这只需要读写磁头移动极小的距离,从而减少了磁盘寻道时间。

  • 随机 I/O:随机 I/O 请求会在磁盘的非连续位置上执行,正如你所猜测的那样,这会导致更长的磁盘寻道时间,从而对磁盘性能产生负面影响。

再次强调,随机 I/O 操作影响旋转机械硬盘,但不会像机械硬盘那样影响 SSD。尽管如此,由于在磁盘上读取相邻字节对控制器的要求要小得多,因此在 SSD 上进行顺序操作比随机操作更快。然而,与机械硬盘相比,这种差异要小得多。

确定读取/写入比例和 I/O 大小

单独的 IOPS 并不能完全反映磁盘的性能,应该谨慎对待。重要的是要查看 I/O 请求的大小以及读取和写入操作的比例。例如,复杂的存储系统是为特定的读写比例和 I/O 大小设计的,例如70/30 读写32 KB块大小。

不同的应用程序对底层驱动器有不同的需求和期望。了解存储设备上将执行的 I/O 操作类型的百分比非常重要。例如,在线事务处理应用程序通常由70/30 读写比例组成。另一方面,日志应用程序可能总是忙于写入,并可能需要较少的读取操作。

应用程序的 I/O 请求大小也会有所不同,这取决于应用程序的类型。在某些情况下,传输较大的数据块是一种更有效的方法。处理此类请求所需的时间比单个较小的请求要长。另一方面,考虑相同的数据量,许多较小请求的综合处理和响应时间可能会大于单个较大请求的时间。

磁盘缓存

现代硬盘通常配备有内置的磁盘缓存缓冲区。磁盘缓冲区是磁盘驱动器中嵌入的内存,它充当主机总线适配器HBA)与磁盘盘片或用于存储的闪存之间的缓冲区。

下表突出了缓存对不同类型 I/O 模式的影响:

I/O 类型 读取 写入
随机 由于模式无法预测,因此很难缓存和预取。 缓存非常有效,因为随机写入需要大量的磁盘寻道时间。
顺序 缓存非常有效,因为数据可以轻松预取。 缓存有效且可以迅速刷新,因为数据会被写入连续的位置。

表 9.1 – 缓存对读/写的影响

使用缓存可以加速从硬盘存储和访问数据的过程。企业级存储阵列通常为此目的提供大量的缓存。

IOPS 和吞吐量

与延迟一起,IOPS 和吞吐量定义了物理存储的基本特性:

  • IOPS:IOPS 表示在特定时间段内可以执行的 I/O 操作的速率。IOPS 的测量值可以告诉你存储系统当前每秒能处理的操作数。

  • 吞吐量:吞吐量指的是从硬盘驱动器传输到或从硬盘驱动器传输的数据量——换句话说,就是你一次能吃掉多少披萨。这也叫带宽。由于吞吐量衡量的是实际的数据传输量,因此它以每秒 MB 或 GB 为单位。

这里有几点重要的内容需要记住:

  • IOPS 指标应始终与延迟、读写比例和 I/O 请求大小相关联。如果单独使用 IOPS,它的价值不大。

  • 在处理大量数据时,带宽统计可能比 IOPS 更为相关。

队列深度

队列深度决定了每次可以并发处理的 I/O 请求数量。一般来说,这个值不需要修改。在大规模 SAN 环境中,主机通过光纤通道 HBA 连接到存储阵列时,队列深度变得尤为重要。在这种情况下,硬盘、HBA 和存储阵列端口都有独立的队列深度值。

如果发出的 I/O 请求数量超过了支持的队列深度,任何新的请求将不会被存储设备处理。相反,存储设备会返回“队列已满”的消息给主机。一旦队列中有空位,主机需要重新发送失败的 I/O 请求。队列深度的设置会影响机械硬盘和固态硬盘。使用 SATA 和 SAS 接口的机械硬盘和 SSD 只支持一个队列,且每个队列分别支持 32 个和 256 个命令。相反,NVMe 硬盘支持 64,000 个队列,每个队列有 64,000 个命令。

在大多数情况下,队列深度的默认设置可能已经足够。存储环境中的每个组件都有自己的队列深度设置。例如,RAID 控制器也有自己的队列深度,且这个队列深度可能大于单个硬盘的组合队列深度。

确定硬盘忙碌程度

有几个概念决定了硬盘的实际使用程度,具体如下所述:

  • 利用率:磁盘利用率是一个非常常见的指标,你会看到各种工具报告这个数据。利用率意味着在给定的时间间隔内磁盘被积极使用。这个值以时间的百分比表示。例如,70%的利用率意味着,如果内核检查磁盘 100 次,70 次磁盘在执行某些 I/O 请求时是忙碌的。类似地,一个 100%利用率的磁盘意味着它不断地处理 I/O 请求。同样,完全利用的磁盘可能成为瓶颈,也可能不会。这个值需要与其他一些指标相关联,如关联的延迟和队列深度。可能是,尽管 I/O 请求持续不断,但它们相当小且是顺序的,因此磁盘能够及时处理它们。类似地,RAID 阵列能够并行处理请求,因此一个 100%利用的磁盘也许不会成为问题。

  • 饱和度:饱和度意味着发给磁盘的请求数量可能超过它实际能够处理的能力。这意味着我们正试图超过磁盘的额定容量。当发生饱和时,应用程序必须等待才能从磁盘读取或写入数据。饱和将导致响应时间增加,影响系统的整体性能。

I/O 等待

很容易理解的是,I/O 等待通常是检查性能问题时最容易被误解的指标。尽管它的名称中有I/O,但 I/O 等待时间实际上是一个 CPU 指标,它并不表示 CPU 性能的问题。明白了吗?

I/O 等待时间是 CPU 空闲的时间百分比,在这些空闲时间内系统有待处理的磁盘 I/O 请求。之所以难以理解,是因为即使系统 I/O 等待百分比较高,也可能是健康的系统;同样,即使系统 I/O 等待百分比较低,也可能是性能较慢的系统。高 I/O 等待意味着 CPU 在等待磁盘请求完成时处于空闲状态。我们可以通过几个例子来解释这一点:

  • 例如,如果一个进程发送了一些 I/O 请求,而底层磁盘无法立即满足该请求,那么 CPU 就处于等待状态,因为它在等待请求的完成。在这里,等待意味着 CPU 周期被浪费,且底层磁盘可能响应 I/O 请求较慢。

  • 然后,有一个相反的情况。假设进程 A 极度依赖 CPU,持续让 CPU 忙碌。另一个在系统上运行的进程 B 是 I/O 密集型的,占用了磁盘。即使磁盘响应进程 B 的请求很慢,且成为系统瓶颈的来源,I/O 等待值在这种情况下也会非常低。为什么?因为 CPU 并没有空闲,它一直在为进程 A 提供服务。因此,尽管 I/O 等待值较低,但存储可能仍然存在潜在的瓶颈。

高 I/O 等待值可能是以下任何一个或多个因素导致的:

  • 物理存储中的瓶颈

  • 大量的 I/O 请求排队

  • 磁盘接近饱和或已完全饱和

  • 处于不可中断睡眠状态的进程,称为 D 状态(当存储通过 网络文件系统 (NFS) 访问时,这种情况相当常见)

  • 在 NFS 的情况下,网络速度慢

  • 高交换活动

我认为我们已经涵盖了在分析存储设备时需要注意的许多事项。再次强调,如果你的存储环境包含传统 SAN 环境中的所有组件,那么你还需要关注更多内容,例如 光纤通道 (FC) 交换机以及存储阵列中的潜在瓶颈。要排查 FC 交换机的问题,你需要对 FC 协议有基本的了解。

让我们看看如何使用现有工具识别这些红旗。

使用磁盘 I/O 分析工具

我们现在已经对诊断底层存储问题时需要关注的内容有了基本了解。大多数时候,问题行为首先是在应用层报告的,通常需要检查多个层次才能最终识别问题。问题情况可能也会是间歇性的,这使得检测起来更加困难。幸运的是,Linux 有一系列强大的工具可以帮助识别这些问题行为。我们将逐一查看它们,并重点介绍在故障排除过程中需要关注的有价值的内容。

使用 top 建立基准

top 是故障排除性能问题时最常用的命令之一。它之所以有效,是因为它能够快速展示系统的当前状态,并可能给出潜在问题的线索。尽管大多数人使用它来进行 CPU 和内存分析,但有一个特定的字段可以表明底层存储存在问题。如下所示,top 命令可以快速提供当前系统状态的总结视图:

top - 19:11:56 up 96 days, 12:38,  0 users,  load average: 9.44, 6.71, 3.75
Tasks: 498 total,   14 running, 484 sleeping,   0 stopped,   0 zombie
%Cpu(s): 20.6%us,  7.9%sy,  0.0%ni, 13.4%id, 57.1%wa,  0.1%hi,  0.9%si,  0.0%st
KiB Mem : 19791910+total, 10557456 free, 80016952 used, 10734470+buff/cache
KiB Swap:  8388604 total,  5058092 free,  3330512 used. 11555254+avail Mem

正如我们之前讨论的,高 I/O 等待是存储层瓶颈的一个迹象。wa 字段是等待平均值,表示 CPU 因磁盘原因而等待的时间比例。高等待平均值意味着磁盘响应不及时。尽管这里没有讨论,负载平均值也可能因为等待平均值的增加而上升。这是因为负载平均值包括了磁盘等待活动。

top 工具有几个选项可以提供有关 CPU 和内存消耗的洞察,但我们不会在这里重点关注这些。因为我们主要关注的是存储问题,所以需要特别留意 wa 列中的高值以及负载平均值。

iotop 工具

iotop命令是一个类似于top的工具,用于监控与磁盘相关的活动。top命令默认按 CPU 使用率对输出进行排序。同样,iotop命令按每个进程读取和写入的数据量对进程进行排序。它显示了突出显示系统中磁盘带宽消耗的列。此外,它还显示了线程/进程在交换和等待 I/O 操作过程中所占用的时间比例。每个进程的 I/O 优先级,包括类别和级别,也会显示。

最好使用-o标志运行iotop,这样它会显示当前向磁盘写入的进程:

Total DISK READ :       231.10 K/s | Total DISK WRITE :     556.40 K/s
Actual DISK READ:       233.13 K/s | Actual DISK WRITE:     593.72 K/s
  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
23744 be/2 root        0.00 B/s  519.08 K/s  0.00 %  3.03 %  mysql
10395 be/4 root        231.10 K/s   37.32 K/s  0.00 % 1.58 % java

iotop命令显示了进程从磁盘读取或写入的数据量。检查支持的磁盘读写速度,并将其与占用带宽最多的进程的吞吐量进行比较。这也有助于识别应用程序的异常磁盘活动,并确定是否有进程正在向底层磁盘读取或写入异常的数据量。

有时,iotop命令可能会提示内核中未启用延迟计数。这可以通过以下方式修复:

sysctl kernel.task_delayacct = 1

iostat 工具

iostat命令是最受欢迎的磁盘分析工具,因为它显示了各种有助于分析性能问题的信息。之前解释的多数指标,如磁盘饱和、利用率和 I/O 等待,都可以通过iostat进行分析。

iostat的磁盘统计输出的第一行是自系统最近启动以来的总结,显示了系统运行期间的平均值。随后的行则按秒统计显示,根据命令行指定的时间间隔计算,如下图所示:

图 9.6 – iostat 命令

图 9.6 – iostat 命令

需要注意的是:

  • 第一行,avg-cpu,显示了在每个状态下执行时 CPU 利用率的百分比。

  • r/sw/s数字提供了每秒发往设备的读写请求数。

  • avgqu-sz表示处于队列状态或正在被服务的操作的计数。await值对应于请求被放入队列到完成之间的平均时间。r_awaitw_await列显示了读写操作的平均等待时间。如果这里的值持续较高,设备可能接近饱和。

  • %util列显示了磁盘在服务至少一个 I/O 请求期间的繁忙时间。若底层存储是基于 RAID 的卷,利用率值可能会产生误导。

一般假设,当设备的使用率接近 100%时,它变得更为饱和。这适用于表示单一磁盘的存储设备。然而,SAN 阵列或 RAID 卷由多个驱动器组成,可以同时处理多个请求。内核无法直接看到 I/O 设备的设计方式,这使得在某些情况下,这个数字是值得怀疑的。

性能协同驾驶员

sysstat包。PCP 工具还包括一个图形用户界面应用程序,可以根据生成的指标创建图表,并能够保存历史数据供以后查看。以下是几个可以协助存储分析的工具:

  • pcp atop:此命令提供的信息类似于iotopatop命令。该命令列出了执行 I/O 的进程以及它们使用的磁盘带宽。像iotoptop一样,pcp atop是快速了解系统变化的好工具。

  • pcp iostatpcp iostat命令报告实时磁盘 I/O 统计信息,类似于我们之前看到的iostat命令。如图 9.7所示,输出中的列与iostat类似:

图 9.7 – pcp iostat

图 9.7 – pcp iostat

在排查磁盘性能或资源相关问题时,vmstat可以提供有价值的信息,因为它有助于识别磁盘 I/O 拥堵、过度分页或交换活动。

vmstat 命令

vmstat命令,源自“虚拟内存统计”,是几乎所有 Linux 发行版中都包含的原生监控工具。如图 9.8所示,它报告有关进程、内存、分页、磁盘和处理器活动的信息:

图 9.8 – vmstat 命令

图 9.8 – vmstat 命令

输出中的b列显示的是等待资源(如磁盘 I/O)时被阻塞的进程数。对排查 I/O 问题最有用的附加信息如下:

  • si:该字段表示每秒从磁盘交换空间交换到系统内存的内存量(以千字节为单位)。si字段的值越高,表示交换活动越频繁,表明系统经常从交换空间检索数据。

  • so:该字段表示每秒从系统内存交换到磁盘交换空间的内存量(以千字节为单位)。so字段的值越高,表示交换活动越频繁,这可能发生在系统内存紧张,需要释放物理内存时。

  • bi:该字段专门指从磁盘到内存的数据传输速率。bi字段的值越高,表示磁盘读取活动越频繁。

  • bo:此项反映了系统内存写入到磁盘的输出活动或数据量。bo 字段的值越高,表示磁盘写入活动增加,说明数据经常从内存写入磁盘。

  • wa:此字段表示 CPU 空闲时,系统等待 I/O 操作完成的时间百分比。wa 字段的值越高,表示系统经历了 I/O 瓶颈或延迟,CPU 经常等待 I/O 操作完成。

压力停滞指数

压力停滞指数PSI)是 Linux 中故障排除工具集的一个相对较新的功能,提供了一种新的方式来获取内存、CPU 和磁盘 I/O 的利用率指标。当 CPU、内存或 I/O 设备发生竞争时,可能会出现延迟峰值,导致工作负载的等待时间增加。PSI 功能可以识别这种情况,并实时打印出这些信息的总结视图。

PSI 值通过 /proc pseudo 文件系统访问。原始的全局 PSI 值出现在 /proc/pressure 目录下的 cpuiomemory 文件中。我们来看一下 io 文件:

[root@linuxbox ~]# cat /proc/pressure/io
some avg10=51.30 avg60=41.28 avg300=23.33 total=84845633
full avg10=48.28 avg60=39.22 avg300=22.78 total=75033948

avg 字段表示最近 10 秒、60 秒和 300 秒内,进程因磁盘 I/O 而被饿死的百分比时间。以 some 为前缀的行表示由于资源不足,某些任务被延迟的时间部分。以 full 为前缀的行表示所有任务因资源限制而被延迟的时间百分比,表明无效时间的程度。这与 top 命令中的负载平均值有些相似。这里的输出显示,1060300 秒间隔的平均值较高,表明进程正在被暂停。

总结来说,Linux 提供了许多工具来分析系统性能。我们在这里介绍的工具不仅用于存储分析,还用于建立系统的整体视图,包括处理器和内存子系统。每个工具提供了广泛的选项,可以用于分析特定方面。我们强调了使用这些工具时需要关注的主要指标,但由于每个环境中都有不同的变量,因此没有固定的故障排除方法。

总结

故障排除性能问题是一个复杂的过程,因为它可能需要很长时间才能诊断和分析。在环境中的三个主要组件——存储、计算和内存——中,存储是最慢的。它们的性能总是会存在不匹配的情况,任何磁盘性能的下降都可能影响系统的整体运行。

鉴于此目标,我们将本章分为两部分。在第一部分,我们解释了在排查任何问题之前,您应该了解的最重要的指标。我们讨论了与存储设备相关的时间相关指标、CPU 等待平均值、磁盘饱和度和磁盘利用率,以及从物理磁盘读取或写入时的不同访问模式。

在第二部分,我们看到了解析第一部分中突出的指标的不同方式。Linux 中有许多可用的机制,可以帮助识别整体系统性能中的潜在瓶颈。我们使用了topPSIiostatiotopvmstat等工具来分析磁盘性能。

在下一章,我们将继续分析存储堆栈,重点关注更高层次的部分,如块层和文件系统。为此,我们将利用 Linux 中提供的不同跟踪机制。

第十章:分析文件系统和块层

对存储设备的读写访问通常会经过几个中间层,如文件系统和块层。还有页面缓存,在数据被延迟写入底层存储之前,所请求的数据会被保留在其中。到目前为止,我们已经尝试理解可能影响磁盘性能的不同因素,并检查与物理磁盘相关的重要指标,但是,正如福尔摩斯所说:“完全合理的分析,但我希望你能 深入一点。”

应用程序通常与文件系统进行交互,而不是与物理存储进行交互。文件系统的工作就是将应用程序的请求转换并发送到下层进行进一步处理。请求将在块层进行进一步处理,最终被调度并发送到存储设备。这个层次结构中的每个阶段都会增加处理开销。因此,检查文件系统和块层的行为对于进行任何性能分析至关重要。

本章将重点介绍可以用于调查文件系统和块层的技术。在这一阶段,我想认为前六章帮助我们建立了对这些层的相当理解(我当然希望如此)。熟悉相关的分析方法应该不成问题。

这里是我们将要涵盖的内容摘要:

  • 调查文件系统和块层

  • 不同类型的文件系统 I/O

  • 什么导致文件系统延迟?

  • 确定目标层

  • 寻找合适的工具

技术要求

本章将重点介绍如何使用 rootsudo 来运行这些工具。

本章相关的操作系统软件包可以通过以下方式安装:

  • 对于 Ubuntu/Debian:

    • sudo apt install strace

    • sudo apt install bpfcc-tools

  • 对于 Fedora/CentOS/基于 Red Hat 的系统:

    • sudo yum install strace

    • sudo yum install bcc-tools

调查文件系统和块层

鉴于存储比系统中的其他组件更慢,性能问题通常与 I/O 相关也就不足为奇了。然而,简单地将性能问题归类为基于 I/O 的问题是一种过度简化。

文件系统是应用程序的第一个接触点,并且被认为夹在应用程序和物理存储之间。传统上,在进行任何性能分析时,物理存储总是受到关注的中心。大多数工具关注的是物理驱动器的利用率、吞吐量和延迟,而忽略了 I/O 请求的其他方面。对存储的检查通常以物理磁盘为起点并结束,从而忽略了对文件系统的分析。

类似地,块层发生的事件在性能分析中也往往容易被忽视。我们在第九章中讨论的工具通常提供特定时间间隔内的平均值,这可能会导致误导。例如,假设一个应用程序在 10 秒的时间间隔内生成了以下数量的 I/O 请求:

秒数 请求数 秒数 请求数
1 10 6 20
2 15 7 5
3 500 8 15
4 20 9 8
5 5 10 2

表 10.1 – I/O 请求的平均统计数据

如果我每 10 秒收集一次 I/O 统计信息,那么每秒发出的平均 I/O 请求数将是 60——也就是总请求数除以时间间隔。平均值可能被认为是正常的,但它完全忽略了大约在三秒钟时发出的 I/O 请求爆发。提供磁盘级统计的工具无法提供每个 I/O 请求的详细信息。

传统方法一直是从文件系统的底层收集信息——也就是物理磁盘。然而,这是一个多方面的问题,涉及到分析以下几个层次:

  • 系统和库调用:应用程序使用通用系统调用接口从内核空间请求资源。当应用程序调用内核提供的函数时,执行时间将在内核空间内度过。这个函数被称为系统调用。相反,库调用是在用户空间中执行的。当应用程序希望使用编程库中定义的函数(例如 GNU C 库)时,它会发送一个请求,称为库调用。为了准确评估性能,必须衡量在内核空间和用户空间中花费的时间。通过追踪这些调用,可以获得有关应用程序行为的宝贵洞察,并识别可能导致进程卡住的潜在问题,例如资源争用或锁定。

  • VFS:正如本书中一直所解释的,VFS 充当用户和底层文件系统之间的接口。它将应用程序的文件操作与特定文件系统解耦,通过通用系统调用隐藏了实现细节。VFS 还包括页缓存、inode 和 dentry 缓存,以加速磁盘访问。分析 VFS 对于一般工作负载特征化很有帮助,可以识别应用程序随时间变化的操作模式,并找出应用程序如何使用不同类型的可用缓存。

  • 文件系统:每个文件系统在磁盘上组织数据时都使用不同的方法。正如我们在第九章中解释的那样,重要的是要表征文件系统将管理的工作负载类型 - 例如,应用程序的访问模式、同步和异步操作、读写请求的比例、缓存命中和失误比率以及 I/O 请求的大小。在内部,文件系统执行诸如预读、预取、锁定和日志记录等操作,这些操作可能以某种方式影响整体 I/O 性能。

  • 块层:当 I/O 请求进入块层时,它可以映射到另一个设备,如 LVM、软件独立磁盘冗余阵列RAID)或多路径设备。通常在这些逻辑设备上创建文件系统。在这种情况下,对于任何文件系统 I/O,这些技术的相应任务需要资源,可能是 I/O 争用的来源,如 RAID 条带化或多路径 I/O 驱动程序。

  • 调度程序:磁盘调度程序的选择也会影响应用程序的 I/O 性能。调度程序可以使用合并和排序等技术,这可以改变请求最终落在磁盘上的顺序。正如我们在第六章中所学到的,Linux 内核提供了不同类型的磁盘调度程序。一些 I/O 调度程序适合高端存储设备,而另一些则适用于较慢的驱动器。由于每个环境都不同,在决定适当的磁盘调度程序之前,需要考虑多种因素。

  • 物理存储:在任何故障排除场景中,物理层通常是关注的焦点。我们在第九章中讨论了分析不同物理磁盘指标的部分。

虽然这里没有详细涵盖,但重要的是要知道,可以绕过文件系统,直接将数据写入物理存储设备。这被称为原始访问,通过这种方法访问的设备称为原始设备。一些应用程序,如数据库,能够写入原始设备。采用这种方法的主要原因是,任何抽象层,比如文件系统或卷管理器,都会增加处理开销。文件系统利用缓冲区缓存读写操作,延迟将它们提交到磁盘,直到稍后。在没有文件系统的情况下,像数据库这样的大型应用程序能够绕过文件系统缓存,从而管理自己的缓存。这种方法可以更精细地控制设备 I/O,并可能有助于测试存储设备的原始速度,因为它绕过了任何额外的处理开销。

图 10**.1 强调了影响应用程序 I/O 性能的一些因素:

图 10.1 – 影响应用程序 I/O 性能的因素

图 10.1 – 影响应用程序 I/O 性能的因素

总结来说,I/O 栈中的不同层级可以通过多种方式影响应用程序的 I/O 性能。因此,在排查性能问题时,第一步是将问题分解成更小的部分;通过去除尽可能多的层级来简化问题。

不同类型的文件系统 I/O

文件系统可以发出许多不同类型的 I/O 请求。为了便于理解,我们将由进程发出的 I/O 请求称为逻辑 I/O,而实际在磁盘上执行的操作则称为物理 I/O。正如你可能猜到的那样,这两者并不相等。逻辑 I/O 指的是在逻辑层面上读取或写入数据,也就是说在文件系统或应用程序层面。相反,物理 I/O 涉及在存储设备和内存之间传输数据。在这个阶段,数据在硬件层面进行移动,并由磁盘控制器等硬件设备进行管理。

磁盘 I/O 可能会膨胀或收缩。一次逻辑 I/O 请求可能会导致多次物理磁盘操作。相反,来自进程的逻辑请求可能不需要任何物理 I/O 操作。

为了详细说明这个概念,下面我们来看一些导致这两种请求不成比例的因素:

  • 缓存:Linux 内核大量利用可用内存来缓存数据。如果数据是从磁盘加载的,它会保存在缓存中,以便对同一数据的后续访问能够快速响应。如果应用程序的读取请求是从缓存中提供的,它将不会导致物理操作。

  • 写回:由于文件系统写操作默认会被缓存,这也导致了物理操作和逻辑操作数量的差异。写回缓存机制将延迟并合并写操作,然后最终将其刷新到磁盘。

  • 预取:大多数文件系统都有预取机制,在从磁盘读取一个数据块时,它们可以预先将相邻的顺序数据块加载到缓存中。文件系统预测应用程序将需要的数据,并在应用程序实际请求之前将其读取到内存中。预取操作使得顺序读取非常快速。如果数据已经被预取到缓存中,文件系统可以避免未来访问物理存储,从而减少物理操作的次数。

  • 日志记录:根据文件系统采用的日志记录技术不同,写操作的数量可能会翻倍。最初,它们会被写入文件系统的日志中,然后再刷新到磁盘。

  • 元数据:每当访问或修改文件时,文件系统需要更新其时间戳。同样,在写入新数据时,文件系统也需要更新其内部元数据,例如已使用和空闲块的数量。所有这些更改都需要在磁盘上执行物理操作。

  • RAID:这一点常常被忽视,但底层存储的 RAID 配置类型对是否需要额外写入有着重要影响。例如,像数据条带化到多个磁盘、写入奇偶校验信息、创建镜像副本以及重建数据等操作都会产生额外的写入。

  • 调度:I/O 调度器通常采用合并和重排序等技术,以最小化磁盘寻道时间并提高磁盘性能。因此,多个请求可以在调度层合并为一个请求。

  • 数据减少:如果进行任何压缩或去重操作,磁盘上执行的物理 I/O 请求数量将低于应用程序发起的逻辑请求数量。

什么导致了文件系统延迟?

如我们在第九章中讨论的那样,延迟是任何性能测量和分析中最重要的指标。从文件系统的角度来看,延迟是指从逻辑请求发起到物理磁盘上完成该请求所花费的时间。

由于物理存储瓶颈导致的延迟是影响整体文件系统响应时间的一个因素。然而,正如我们在上一节中讨论的那样,由于文件系统不仅仅是将 I/O 请求交给物理磁盘处理,延迟可以通过多种方式表现出来,例如以下几种:

  • 资源争用:如果多个进程同时写入同一个文件,可能会影响文件系统性能。文件锁定对于大型应用程序(如数据库)来说可能是一个重大性能问题。锁定的目的是串行化文件访问。Linux 中的文件系统使用通用 VFS 方法进行锁定。

  • 缓存未命中:将数据缓存到内存中的目的是避免频繁访问磁盘。如果应用程序配置为避免使用页缓存,则可能会经历一些延迟。

  • 块大小:大多数存储系统设计时会使用特定的块大小,如 8K、32K 或 64K。如果发出的 I/O 请求较大,它们首先需要被拆分成合适的大小,这会涉及额外的处理。

  • 元数据更新:文件系统的元数据更新可能是延迟的主要来源。更新文件系统元数据涉及执行多个磁盘操作,包括定位适当的磁盘位置、写入更新的数据,然后将磁盘缓存与磁盘同步。根据更新的元数据的大小和位置,这一过程可能会消耗相当长的时间,尤其是当文件系统被频繁使用,且磁盘正忙于其他操作时。这可能导致请求积压,并整体降低文件系统的性能。

  • 逻辑 I/O 的拆分:如前节所述,逻辑 I/O 操作可能需要拆分为多个物理 I/O 操作。这可能增加文件系统的延迟,因为每个物理 I/O 操作都需要额外的磁盘访问时间,从而导致额外的处理开销。

  • 数据对齐:文件系统分区必须正确对齐物理磁盘几何结构。分区对齐不正确会导致性能下降,特别是在 RAID 卷方面。

鉴于可能影响应用程序 I/O 性能的因素众多,毫不奇怪,大多数人不愿意探索这个方向,而只是专注于磁盘级的统计数据,因为这些数据更易理解。到目前为止,我们仅讨论了一些可能影响 I/O 请求生命周期的常见问题。故障排除是一项复杂的技能,确定一个良好的起点可能是一个困难的决策。令人困惑的是,有大量的工具可以用于性能分析。尽管我们这里只关注存储方面的内容,但要一一涵盖所有能够在某种程度上帮助我们实现目标的工具,几乎是不可能的。

确定目标层

下表总结了性能分析的不同目标层,并展示了每种方法的优缺点:

优点 缺点
应用程序 应用程序日志、特定工具或调试技术可以确定问题的范围,帮助后续步骤。 调试技术不常见,并且因应用程序而异。
系统调用接口 跟踪进程生成的调用很容易。 难以过滤,因为同一功能有多个系统调用。
VFS 所有文件系统使用通用调用。 需要隔离目标文件系统,因为追踪可能包含所有文件系统的数据,包括伪文件系统。
文件系统 文件系统是应用程序的第一个接触点,这使它们成为分析的理想对象。 可用的特定于文件系统的追踪机制非常少。
块层 提供多种追踪机制,可以用于识别请求的处理方式。 一些组件(如调度器)没有太多可调参数。
磁盘 这更容易分析,因为它不需要对更高层次有深入的理解。 这无法清晰地描绘应用程序的行为。

表 10.2 – 比较分析每一层的优缺点

一般共识(并且确实有一定道理)是,调查每一层次的工作量太大!有专门的性能分析工程师的企业习惯性地检查每一个细节,并识别系统中的潜在瓶颈。然而,近年来更常见的方法是增加更多的计算能力,特别是对于基于云的工作负载。当应用程序变得资源密集时,增加更多硬件资源成为了新常态。故障排除性能问题往往被忽视,而更倾向于将应用程序迁移到更好的硬件平台。

寻找合适的工具

试图深入挖掘应用程序的行为可能是一个艰巨的任务。I/O 堆栈中的抽象层在这方面并没有让我们的工作变得更容易。要分析 I/O 层次结构中的每一层,你必须对每一层所使用的概念有一定的掌握。当你把应用程序包括在内时,这项工作变得更加困难。虽然 Linux 中的跟踪机制有助于理解应用程序产生的模式,但并不是每个人都能对应用程序的设计和实现细节有相同程度的可视化。

如果你正在运行一个关键应用程序,例如一个在线事务处理OLTP)数据库,每天处理数百万次事务,那么了解 CPU 周期的浪费位置可能会有所帮助。例如,事务与几个服务级别协议(SLA)相关,并且必须在几秒钟内完成。如果一个事务需要在 10 秒内完成,而仅花费 1 秒钟处理文件系统和磁盘 I/O,那么显然你的存储并不是瓶颈,因为总时间的 10% 仅用于 I/O 堆栈。如果应用程序在文件系统级别被阻塞了 5 秒钟,那么显然需要进行调整。

让我们来看一下可用的工具选项来分析 I/O 堆栈。请注意,这绝不是工具的完整列表。BCC 本身包含了大量此类工具。以下介绍的工具仅根据个人经验挑选出来。

跟踪应用程序调用

strace 命令有助于识别程序所花费时间的内核函数。例如,以下命令提供了一个总结报告,并显示每个系统调用的频率和所花费的时间。-c 开关显示计数。这里,myapp 只是一个简单的用户空间程序:

图 10.2 – 使用 strace 跟踪系统调用

图 10.2 – 使用 strace 跟踪系统调用

这个命令对于找出某些类型的进程性能瓶颈非常有用。要过滤输出并只显示特定系统调用的统计信息,请使用-``e标志:

图 10.3 – 过滤特定的调用

图 10.3 – 过滤特定的调用

让我们再深入一些,看看能否从实际的跟踪输出中获取一些有用信息。你还可以打印每个系统调用的时间戳以及花费的时间。跟踪输出可以使用-``o标志保存到文件中:

strace -T -ttt -o output.txt ./myapp

只关注应用程序 I/O 部分的子集,注意第一个写调用中等号后的数字。我们可以看到,写调用能够将所有数据缓冲到一个单独的写函数调用中。应用程序在 156 微秒内写入了 319,488 字节:

图 10.4 – 分析 strace 输出

图 10.4 – 分析 strace 输出

strace 命令也可以附加到正在运行的进程上。strace 输出相当庞大,你通常需要费力地浏览大量信息才能得到有用的结果。这就是为什么了解应用程序最常生成的调用非常重要的原因。对于 I/O 分析,专注于常见的系统调用,如openreadwrite。这有助于从应用程序的角度理解应用程序的 I/O 模式。尽管strace不会告诉你操作系统在之后如何处理 I/O 请求,但它告诉你应用程序生成了什么。

总结一下,进行快速分析时,执行以下操作:

  • 生成应用程序生成的系统调用的摘要。

  • 检查每个调用的执行时间。

  • 隔离你想获取信息的调用。对于 I/O 分析,关注读写调用。

跟踪 VFS 调用

在你开始调查的最初阶段,分析 VFS 对于一般的工作负载特征化是非常有益的。它也有助于识别应用程序如何高效地利用 VFS 中可用的不同类型缓存。BCC 程序包含一些工具,如vfsstatvfscount,可以帮助理解 VFS 中的事件。

vfsstat工具显示一些常见 VFS 调用的统计摘要,如readwriteopencreatefsync

图 10.5 – vfsstat 输出

图 10.5 – vfsstat 输出

除了 READWRITE 调用外,留意 OPEN 列。它显示了每秒打开的文件数量。打开文件数量的突然增加可能会显著增加 I/O 请求的数量,尤其是对于元数据操作。

单独运行这些工具可能不会提供太多见解。一个好的使用方法是将它们与一些磁盘分析工具结合使用,如iostat。这将使你能够比较逻辑 I/O 请求与物理 I/O 请求。

vfsstat 的一个限制是它没有在文件系统层面上区分 I/O 活动。另一个程序 fsrwstat 跟踪读取和写入功能,并按不同文件系统进行分类。下图展示了不同文件系统的读取和写入调用的数量:

图 10.6 – fsrwstat 输出

图 10.6 – fsrwstat 输出

继续分析 vfsstat 的输出,如果发现大量文件被打开,考虑使用 filetop。该工具显示系统中访问频率最高的文件,并显示它们的读取和写入活动:

图 10.7 – filetop 输出

图 10.7 – filetop 输出

发往 VFS 的请求构成了逻辑 I/O 请求。分析 VFS 时,执行以下操作:

  • 尝试了解系统的一般工作负载

  • 检查常见 VFS 调用的频率

  • 将获得的数字与物理层面的请求进行比较

分析缓存使用情况

VFS 包含多个缓存,以加速对常用对象的访问。在 Linux 中,默认行为是将所有写操作完成后先存入缓存,稍后再将已写入的数据刷新到磁盘。同样,内核也会尝试从缓存中提供读取操作,并显示页面缓存的命中与未命中统计信息。

cachestat 工具可用于显示页面缓存命中与未命中比率的统计信息:

图 10.8 – 使用 cachestat

图 10.8 – 使用 cachestat

从上图可以看出,缓存命中率非常高,有时接近 100%。这表明内核能够从内存中满足应用程序的 I/O 请求。缓存命中率越高,应用程序的性能提升越好。

同样,cachetop 工具提供按进程划分的缓存命中与未命中统计数据。输出结果通过类似于 top 命令的交互式界面显示:

图 10.9 – 使用 cachetop

图 10.9 – 使用 cachetop

使用这些工具分析缓存使用情况时,执行以下操作:

  • 查看命中与未命中比率,以了解有多少请求是从内存中服务的

  • 如果比率较低,可能需要调整应用程序或操作系统参数

分析文件系统

尽管没有很多工具可以跟踪文件系统级操作,但 BCC 提供了一些优秀的脚本来观察文件系统。两个脚本 ext4slowerxfsslower 用于分析两个最常用文件系统 Ext4 和 XFS 上的慢操作。

ext4slowerxfsslower 两个工具的输出是相同的。默认情况下,这两个工具会打印出完成时间超过 10 毫秒的操作,但你可以通过传递持续时间值作为参数来更改该设置。两个工具也可以附加到特定进程:

图 10.10 – 跟踪慢速的 Ext4 操作

图 10.10 – 跟踪慢速的 Ext4 操作

T 列显示操作类型,可能为 R(读取)、W(写入)和 O(打开)。BYTES 列显示 I/O 的字节数,OFF_KB 列显示 I/O 的文件偏移量(以 KB 为单位)。最重要的值来自 LAT(ms) 列,它显示 I/O 请求的持续时间,从 VFS 向文件系统发出请求到完成的时间。这是一个相当准确的度量,反映了应用程序在执行文件系统 I/O 时所承受的延迟。

该工具集中的另两个工具是 xfsdistext4dist。这两个工具显示相同的信息,只是针对不同的文件系统——即 XFS 和 Ext4。它们总结了执行常见文件系统操作时所花费的时间,并提供了经验延迟的分布情况,形式为直方图。这些工具可以附加到特定的进程上:

图 10.11 – 使用 xfsdist

图 10.11 – 使用 xfsdist

使用文件系统专用工具时,请记住以下几点:

  • ext4dist/xfsdist 工具可以帮助建立基线——也就是说,区分工作负载是以读操作为主还是写操作为主。

  • 两个 ext4slower/xfsslower 脚本在确定进程执行文件系统 I/O 时实际经历的延迟方面非常有效。运行这些脚本时,请检查延迟列以确定应用程序所承受的延迟量。

分析块 I/O

正如我们在 第九章 中看到的那样,标准磁盘分析工具如 iostat 提供了每秒读取和写入的字节数、磁盘利用率以及与特定设备相关的请求队列等信息。这些指标是按时间段平均得出的,无法提供每次 I/O 的详细信息。无法提取某个特定间隔内发生的事件。

与 VFS 和文件系统类似,BCC 还包括一些工具,可以帮助分析块层中发生的事件。其中一个工具是 biotop,它类似于磁盘的 top 命令。默认情况下,biotop 工具跟踪块设备上的 I/O 操作,并每秒显示每个进程活动的汇总信息。汇总信息按吞吐量排序,以 KB 为单位,表示每个进程对磁盘的消耗。汇总中显示的进程 ID 和名称表示 I/O 操作最初创建的时间,这有助于识别负责的进程:

图 10.12 – 使用 biotop

图 10.12 – 使用 biotop

另一个用于分析块层的 BCC 工具是 biolatency。顾名思义,biolatency 跟踪块设备 I/O,并打印出显示 I/O 延迟分布的直方图:

图 10.13 – 使用 biolatency

图 10.13 – 使用 biolatency

从前面的输出可以看出,大部分 I/O 请求的完成时间为 128-255 微秒。根据工作负载的不同,这些数字可能会高得多。

来自 BCC 的biosnoop工具可以跟踪块设备 I/O 并打印详细信息,包括发起请求的进程:

图 10.14 – 使用 biosnoop

图 10.14 – 使用 biosnoop

biosnoop的输出包括从请求发出到设备的时间,直到完成的延迟。biosnoop的输出可用于识别导致磁盘过度写入的进程。

我想提到的最后一个工具是bitesize,它用于描述块设备 I/O 大小的分布:

图 10.15 – 使用 bitesize

图 10.15 – 使用 bitesize

如前面的输出所示,javaMyApp进程(一个简单的基于 Java 的应用程序)生成介于 16-32 KB 之间的请求,而mysql则使用 4-8 KB 范围。

在分析块层时,请记住以下内容:

  • 要获取系统中磁盘活动的顶端视图,请使用biotop

  • 要跟踪应用程序的 I/O 大小,使用bitesize。如果应用程序的工作负载是顺序的,那么使用更大的块大小可能会带来更好的性能。

  • 要观察块设备的延迟,请使用biolatency。该工具将总结块 I/O 请求的时间范围。如果看到较高的值,可能需要进一步挖掘。

  • 要进一步检查,请使用biosnoop。要找出从创建 I/O 请求到发出请求给设备的时间,请使用-Q标志与biosnoop

工具总结

以下表格总结了可用于分析不同层次事件的工具:

分析工具
应用程序 特定应用程序工具
系统调用接口 stracesyscount(BCC)
VFS vfsstatvfscountfunccount
缓存 slabtopcachestatcachetopdcstatdcsnoop
文件系统 ext4slowerxfsslowerext4distxfsdistfiletopfileslowerstackcountfunccountnfsslowernfsdist
块层 biolatencybiosnoopbiotopbitesizeblktrace
磁盘 iostatiotopsystemtapvmstatPCP

表 10.3 – 工具总结

请注意,工具不仅限于表格中提到的那些。BCC 工具集本身就包含了其他多个可用于性能分析的工具。此外,还可以向每个工具传递多个参数,以获得更有意义的输出。考虑到层次结构中涉及的多个层次,诊断 I/O 性能问题是一项复杂的任务,就像其他任何故障排除场景一样,这将需要多个团队的参与。

总结

在本章中,我们恢复了性能分析,并将其扩展到 I/O 栈中的更高层次。大多数时候,分析更高层次的工作会被跳过,焦点仅仅放在物理层。然而,对于时间敏感型应用程序,我们需要拓宽我们的分析视野,寻找可能的延迟源,进而优化应用响应时间。

我们在本章开始时解释了当应用程序从文件系统读取或写入数据时,可能观察到的不同延迟来源。文件系统操作超出了由应用程序发起的 I/O 请求。除了应用程序的 I/O 请求,文件系统还可能在执行诸如元数据更新、日志记录或将现有缓存数据刷新到磁盘等任务上花费时间。所有这些都导致额外的操作,从而引发额外的 I/O 操作。在第九章中讨论的工具主要集中在磁盘上,未能提供关于 VFS 和块层中发生事件的可视化。BCC 提供了一套丰富的脚本,可以追踪内核中的事件,并为我们提供对单个 I/O 请求的洞察。

在下一章,我们将进一步分析,并学习可以在 I/O 层级的不同层次应用的各种优化方法,从而提高性能。

第十一章:调优 I/O 堆栈

好的,我们已经到了旅程的终点。仅仅因为你正在阅读最后一章的介绍,并不意味着你已经读完了整本书,但我还是要冒险这么说。如果你真的跟随我们走到这里,那么我希望这段旅程对你来说是值得的,并且让你渴望了解更多。

回到正题,前两章主要集中在 I/O 堆栈的性能分析。第九章 重点介绍了最常见的磁盘指标以及帮助我们识别物理磁盘性能瓶颈的工具。在任何性能分析中,物理磁盘往往比任何其他层次受到更多的关注,这有时可能会产生误导。因此,在第十章中,我们探讨了如何检查 I/O 堆栈中的更高层次,如文件系统和块层。

这将引导我们进入下一个逻辑步骤:一旦我们识别出环境中的瓶颈,接下来可以采取哪些步骤来缓解这些限制?设定具体的目标对于调优结果至关重要,因为最终,性能调优是不同选择之间的权衡。例如,调优系统以降低延迟可能会减少其整体吞吐量。首先应确定一个性能基线,任何调整或改动都应分小范围进行。本章将探讨可以应用的不同调整方法,以改善 I/O 性能。

接下来是大纲:

  • 内存使用如何影响 I/O

  • 调优内存子系统

  • 调优文件系统

  • 选择合适的调度器

技术要求

本章中展示的内容是基于前面章节讨论的概念。如果你已经跟随阅读并熟悉了磁盘 I/O 层次中每一层的功能,那么你会发现本章内容更容易理解。如果你对 Linux 中的内存管理有先前了解,那将是一个巨大的优势。

本章中展示的命令和示例与发行版无关,可以在任何 Linux 操作系统上运行,如 Debian、Ubuntu、Red Hat 或 Fedora。文中提到了一些内核源代码的参考。如果你想下载内核源代码,可以从www.kernel.org下载。

内存使用如何影响 I/O

正如我们所见,VFS 作为我们 I/O 请求的入口点,并包含不同类型的缓存,其中最重要的是页面缓存。页面缓存的目的是提高 I/O 性能,并最小化由于交换和文件系统操作产生的 I/O 成本,从而避免不必要的访问底层物理磁盘。虽然我们在这些页面中没有详细探讨,但了解内核如何管理其内存管理子系统是很重要的。内存管理子系统也被称为虚拟内存管理器VMM)。虚拟内存管理器的一些职责包括:

  • 管理所有用户空间和内核空间应用程序的物理内存分配

  • 虚拟内存和需求分页的实现

  • 将文件映射到进程的地址空间

  • 在内存不足的情况下,释放内存,要么通过修剪缓存,要么通过换出缓存

正如人们常说的,最好的 I/O 就是避免 I/O。内核遵循这一方法,充分分配空闲内存,用于填充不同类型的缓存。空闲内存越多,缓存机制越有效。这一方法在一般使用场景中表现良好,尤其是在应用程序执行小规模请求且有一定数量的页面缓存可用的情况下:

图 11.1 – 页面缓存可以加速 I/O 性能

图 11.1 – 页面缓存可以加速 I/O 性能

相反,如果内存资源稀缺,不仅缓存会定期被修剪,数据可能还会被换出到磁盘,这最终会影响性能。内核遵循时间局部性原理,意味着最近访问的数据块更可能会再次被访问。这对大多数情况来说通常是有利的。从磁盘的随机位置读取数据可能需要几毫秒,而如果该数据已经缓存到内存中,访问它只需几纳秒。因此,任何能够直接从页面缓存中提供的数据请求都能最小化 I/O 操作的成本。

调优内存子系统

有点奇怪的是,Linux 如何处理内存实际上可能会对磁盘性能内存产生重大影响。正如之前所解释的,内核的默认行为在大多数情况下效果良好。然而,正如人们所说,凡事过犹不及。频繁缓存可能会导致一些问题场景:

  • 当内核在页面缓存中积累了大量数据,并最终开始将数据刷新到磁盘时,由于频繁的写入操作,磁盘将会长时间处于忙碌状态。这可能会对整体 I/O 性能产生不利影响,并增加磁盘响应时间。

  • 内核并不理解页面缓存中数据的重要性。因此,它不会区分重要不重要的 I/O。内核会选择它认为合适的数据块,并安排进行读写操作。例如,如果一个应用程序同时执行后台和前台 I/O 操作,通常前台操作的优先级应该更高。然而,属于后台任务的 I/O 可能会压倒前台任务。

内核提供的缓存通常能够使应用程序在读取和写入数据时获得更好的性能,但页面缓存所使用的算法并不是为特定应用程序设计的;它们是为了通用用途而设计的。在大多数情况下,这种默认行为效果很好,但在某些情况下,这可能适得其反。对于一些自缓存应用程序,例如数据库管理系统,这种方法可能不会提供最佳的结果。像数据库这样的应用程序更好地理解数据在内部的组织方式。因此,这些系统更倾向于使用自己的缓存机制,以提高读写性能。

使用直接 I/O

如果数据直接在应用程序级别进行缓存,那么将数据从磁盘移动到页面缓存,再返回到应用程序缓存将构成显著的开销,导致更多的 CPU 和内存使用。在这种情况下,可能希望完全绕过内核的页面缓存,将缓存的责任交给应用程序。这就是直接 I/O

使用直接 I/O 时,所有文件的读写操作直接从应用程序传输到存储设备,绕过了内核的页面缓存。O_DIRECT 标志用于系统调用,例如open()O_DIRECT 标志仅仅是一个状态标志(表示为 DIR),它由应用程序在打开或创建文件时传递,以便绕过内核的页面缓存:

图 11.2 – 执行 I/O 的不同方式

图 11.2 – 执行 I/O 的不同方式

对于常规应用程序来说,直接 I/O 并不合理,因为它可能导致性能下降。然而,对于自缓存应用程序,它可以提供显著的性能提升。推荐的方法是通过应用程序检查直接 I/O 的状态。然而,如果你想通过命令行进行检查,可以使用lsof命令查看文件是通过哪些标志打开的。

图 11.3 – 检查直接 I/O

图 11.3 – 检查直接 I/O

对于通过O_DIRECT标志由应用程序打开的文件,输出的FILE-FLAG列将包括DIR标志。

直接 I/O 带来的性能提升源于避免了将数据从磁盘复制到页面缓存的 CPU 开销,并避免了双重缓存,一次是在应用程序中,一次是在文件系统中。

控制写回频率

如前所述,缓存有其优势,因为它加速了对文件的访问。一旦大多数空闲内存被缓存占用,内核就必须做出决定,如何释放内存以处理即将到来的 I/O 操作。使用最近最少使用LRU)方法,内核做了两件事——它从页面缓存中驱逐旧数据,甚至将部分数据卸载到交换区,以腾出空间处理新的请求。

归根结底,一切都取决于具体情况。默认的处理方法已经足够好,这正是内核处理如何为即将到来的数据腾出空间的方式。然而,考虑到以下几种场景:

  • 如果当前缓存中的数据将来不会再次被访问怎么办?对于大多数备份操作来说,这种情况是成立的。备份操作会从磁盘读取和写入大量数据,这些数据将被内核缓存。然而,缓存中的这些数据在不久的将来不太可能被再次访问。不过,内核仍然会将这些数据保留在缓存中,并可能驱逐更旧的页面,这些页面更有可能再次被访问。

  • 将数据交换到磁盘会产生大量的磁盘 I/O,这对性能是不利的。

  • 当大量数据被缓存时,系统崩溃可能会导致大量数据丢失。如果数据具有敏感性,这可能是一个重要问题。

无法禁用页面缓存。即使有这种选项,也不建议这样做。不过,确实有一些参数可以调整,以控制其行为。如这里所示,有几个参数可以通过sysctl接口进行控制:

[root@linuxbox ~]# sysctl -a | grep dirty
vm.dirty_background_ratio = 10
vm.dirty_background_bytes = 0
vm.dirty_ratio = 20
vm.dirty_bytes = 0
vm.dirty_writeback_centisecs = 500
vm.dirty_expire_centisecs = 3000

让我们详细看看这些情况:

  • vm.dirty_background_ratio:当缓存中脏页的百分比超过某个阈值时,写回刷新线程会启动,将脏页刷新到磁盘。在此阈值之前,页面不会被写入磁盘。一旦刷新开始,它会在后台进行,不会打扰前台进程。

  • vm.dirty_ratio:这是指系统内存利用率的阈值,超过该阈值后,写入过程会被阻塞,脏页将被写入磁盘。

对于大内存系统,大量的 GB 数据可能会从页面缓存刷新到磁盘,这将导致显著的延迟,不仅会影响磁盘的性能,还会影响整体系统的性能。在这种情况下,降低这些值可能会有所帮助,因为数据将定期刷新到磁盘,避免了写入风暴。

你可以使用sysctl检查这些参数的当前值——例如,如果这些值如下:

vm.dirty_background_ratio=10
vm.dirty_ratio=20

vm.dirty_ratio 看作上限。使用前面提到的这些值意味着,当缓存中脏页的百分比达到 10% 时,后台线程会被触发,将它们写入磁盘。然而,当缓存中脏页的总数超过 20% 时,所有写入都会被阻塞,直到部分脏页被写入磁盘。这两个参数有以下两个对应参数:

  • vm.dirty_background_bytes:这是触发后台刷写线程启动磁盘写入的脏内存数量,以字节为单位。它是 vm.dirty_background_ratio 的对应参数,也只能配置其中一个。该值可以以百分比或确切的字节数定义。

  • vm.dirty_bytes:这是脏内存的数量,以字节为单位,当该值达到一定程度时,写入过程会被阻塞,直到脏页被写入磁盘。它控制与 vm.dirty_ratio 相同的可调参数,二者只能设置一个。

  • vm.dirty_expire_centisecs:这表示某个数据在缓存中可以存在多久,直到需要写入。这个可调参数指定了脏数据被认为适合由刷写线程回写的年龄。时间单位是百分之一秒。

总结来说,内核的页面缓存默认行为在大多数情况下表现良好,通常不需要做任何调整。然而,对于像大规模数据库这样的智能应用程序,频繁缓存数据可能会成为一个障碍。幸运的是,存在一些解决方法。此类应用程序可以配置为使用直接 I/O,这样可以绕过页面缓存。内核还提供了几个参数,可以用来调整页面缓存的行为。然而,值得注意的是,改变这些值可能会导致 I/O 流量增加。因此,在进行更改之前,应进行特定工作负载的测试。

调优文件系统

在我们专注于调优可能影响 I/O 性能的不同组件时,我们会尽量避免讨论硬件方面的内容。鉴于硬件的进步,升级内存、计算、网络和存储设备肯定会带来一定的性能提升。然而,大多数时候,这些提升的幅度是有限的。你需要一个设计良好且配置得当的软件堆栈来充分利用这些硬件。由于我们并不专注于某种特定类型的应用程序,因此我们会尝试提供一些通用的调整方法,用于微调 I/O。再次强调,下面提到的参数或之前讨论的参数需要进行充分的测试,并且在不同的环境中可能不会得到相同的结果。

回到我们讨论的主题,文件系统负责在磁盘上组织数据,并且是应用程序执行 I/O 操作的接触点。这使得它们成为调优和故障排除过程的理想候选者。有些应用程序明确指出应使用哪种文件系统来获得最佳性能。由于 Linux 支持多种不同类型的文件系统,这些文件系统使用不同的技术存储用户数据,因此一些挂载选项可能在文件系统之间并不通用。

块大小

文件系统以块为单位处理物理存储。是物理扇区的组合,是文件系统的基本 I/O 单元。文件系统中的每个文件至少占用一个块,即使文件为空。默认情况下,大多数文件系统使用 4 KB 的块大小。如果应用程序主要在文件系统中创建大量小文件,通常是几字节或不到几 KB,那么最好使用比默认的 4 KB 小的块大小。

如果应用程序使用与块大小相同的读写大小,或使用块大小的倍数,文件系统的性能会更好。文件系统的块大小只能在创建时指定,之后无法更改。因此,在创建文件系统之前需要确定块大小。

文件系统 I/O 对齐

I/O 对齐的概念通常被忽视,但它对文件系统的性能有巨大的影响。尤其对于今天复杂的企业存储系统来说更为明显,这些系统由具有不同页面大小的闪存驱动器组成,并且上面可能有某种形式的 RAID 配置。

文件系统的 I/O 对齐涉及数据在文件系统中的分布和组织方式。这是一个方面。如果底层物理存储由条带化 RAID 配置组成,为了获得最佳性能,数据应该与底层存储几何结构对齐。例如,对于一个每磁盘 64K 条带大小和 10 个数据承载磁盘的 RAID 设备,应该如下创建文件系统:

  • 对于 XFS,应如下所示:
mkfs.xfs -f -d su=64k,sw=10  /dev/sdX

XFS 提供了两组可调参数。在你设置的规格单位的基础上,可以使用以下内容:

  • Sunit:条带单元,以 512 字节为单位的块

  • swidth:条带宽度,以 512 字节为单位的块

或者,你可以使用以下内容:

  • su:每磁盘条带单元,如果带有 k 后缀,则以 KB 为单位

  • sw:条带宽度,按数据磁盘数量计算

  • 对于 Ext4,命令应如下所示:

mkfs.ext4 -E stride=16,stripe-width=160 /dev/sdX

Ext4 还提供了几个可调参数,可按以下方式使用:

  • stride:每个数据承载磁盘上每个条带中的文件系统块数

  • stripe-width:文件系统块中的总条带宽度,等于(步幅)x(数据承载磁盘数量)

LVM I/O 对齐

在 RAID 设备上创建的每一层抽象都必须对齐到Stripe Width的倍数,并加上任何所需的初始对齐偏移量。这可以确保文件系统中单个块的读写请求不会跨越 RAID 条带边界,从而避免在磁盘级别读取和写入多个条带,进而影响性能。

在物理卷内分配的第一个物理区段应该对齐到 RAID Stripe Width的倍数。如果物理卷是直接在原始磁盘上创建的,则它也应当根据需要偏移任何初始对齐偏移量。要检查物理区段的起始位置,可以使用以下命令:

pvs -o +pe_start

这里,pe_start指的是第一个物理区段。

逻辑卷在可能的情况下总是分配一个连续的物理区段范围。如果不存在连续范围,可能会分配非连续范围。由于非连续的区段范围可能会影响性能,因此在创建逻辑卷时有一个选项(--contiguous),可以防止分配非连续的区段。

日志记录

第三章所述,日志记录的概念确保了在由于外部事件导致文件系统的 I/O 操作失败时,数据的一致性和完整性。任何需要对文件系统进行的更改都会先写入日志。一旦数据被写入日志,它将被写入磁盘上的相应位置。如果发生系统崩溃,文件系统会回放日志,检查是否有任何操作处于不完整状态。这减少了文件系统在硬件故障时损坏的可能性。

显然,日志记录方法会增加额外的开销,可能会影响文件系统性能。然而,考虑到日志写入的顺序性,文件系统性能不会受到影响。因此,建议保持启用文件系统日志以确保数据完整性。

然而,建议根据需要更改文件系统日志的模式。大多数文件系统没有多种日志记录模式,但 Ext4 在这方面提供了很大的灵活性。Ext4 提供了三种日志记录模式。其中,写回模式的性能明显优于有序模式和数据模式。写回模式仅记录元数据,并且在将数据和元数据写入磁盘时不遵循任何顺序。而有序模式则遵循严格的顺序,首先写入实际数据,然后再写入元数据。数据模式提供最低的性能,因为它必须将数据和元数据都写入日志,导致操作次数翻倍。

另一个可以用来改进日志记录的做法是使用外部日志。文件系统日志的默认位置是与数据存储在同一块设备上。如果 I/O 工作负载是以元数据为主,而且在开始任何关联数据写入之前,必须确保同步的元数据写入日志成功完成,这可能导致 I/O 争用并影响性能。在这种情况下,使用外部设备进行文件系统日志记录可能是一个不错的选择。日志的大小通常非常小,所需存储空间也很少。外部日志最好放置在具有电池备份写回缓存的快速物理介质上。

屏障

如前所述,大多数文件系统使用日志记录来跟踪尚未写入磁盘的更改。写屏障是一种内核机制,确保文件系统元数据在持久存储上按正确的顺序和准确地写入,即使存储设备的写缓存因失电而丢失。写屏障通过强制存储设备在某些间隔刷新其缓存来强制保证日志提交的磁盘顺序。这使得不稳定的写缓存可以安全使用,但可能会带来一些性能损失。如果存储设备的缓存有电池备份,禁用文件系统屏障可能会提高一些性能。

时间戳

内核记录关于文件创建时间(ctime)、最后修改时间(mtime)以及最后访问时间(atime)的信息。如果一个应用程序频繁修改大量文件,那么每次都会需要更新它们相应的时间戳。执行这些修改还需要进行 I/O 操作,而当这些操作过多时,会带来一定的成本。

为了缓解这一问题,文件系统有一个特殊的挂载选项叫做 noatime。当文件系统以 noatime 选项挂载时,从文件系统读取数据将不会更新文件的 atime 信息。noatime 设置很重要,因为它消除了系统为仅读取的文件执行写操作的要求。这可以显著提高性能,因为写操作可能是非常昂贵的。

预读

文件系统中的预读功能可以通过主动获取预计很快需要的数据并将其存储在页缓存中来提高文件访问性能,这比从磁盘中读取数据更快。较高的预读值表示系统将在当前读取位置之前更远的地方预取数据。这对于顺序工作负载尤其有效。

丢弃未使用的块

正如我们在第八章中解释的那样,在 SSD 中,写操作可以在页面级别进行,但擦除操作总是影响整个块。因此,只要能使用空闲页面,写入 SSD 的数据非常快。然而,一旦需要覆盖先前写入的页面,写入速度会显著变慢,影响性能。trim命令告诉 SSD 丢弃那些不再需要并且可以删除的块。文件系统是 I/O 堆栈中唯一知道哪些 SSD 部分应该被修剪的组件。大多数文件系统提供了实现这一功能的挂载参数。

总结来说,文件系统在一定程度上将逻辑地址映射到物理地址。当应用程序写入数据时,文件系统决定如何适当地分配写入操作,以便更好地利用底层的物理存储。这使得文件系统在性能调优方面非常重要。大多数文件系统的更改无法即时完成;它们要么在文件系统创建时执行,要么需要卸载并重新挂载文件系统。因此,关于选择文件系统参数的决定应提前做出,因为事后更改可能会带来干扰。

选择合适的调度程序

I/O 调度程序的唯一目的是优化磁盘访问请求。调度程序使用一些常见技术,例如合并磁盘上相邻的 I/O 请求。其目的是避免频繁访问物理存储。将磁盘上相近的请求聚集在一起,可以减少硬盘寻道操作的频率,从而提高磁盘操作的总体响应时间。I/O 调度程序旨在通过将访问请求重新排列成顺序来优化吞吐量。然而,这种策略可能导致某些 I/O 请求等待较长时间,从而在某些情况下造成延迟问题。I/O 调度程序努力在最大化吞吐量和在所有进程之间公平分配 I/O 请求之间找到平衡。像其他所有事物一样,Linux 提供了多种 I/O 调度程序,每个调度程序都有其自身的优点:

使用场景 推荐的 I/O 调度程序
桌面 GUI、交互式应用程序和软实时应用程序,如音频和视频播放器 预算公平队列 (BFQ),因为它保证了良好的系统响应性和低延迟,适用于时间敏感型应用程序
传统机械硬盘 BFQ 或多队列 (MQ)-Deadline – 这两者都被认为适用于较慢的硬盘。Kyber/none 偏向于支持更快的磁盘。
高性能 SSD 和 NVMe 驱动器作为本地存储 最佳选择是没有,但在某些情况下 Kyber 也可能是一个不错的替代方案
企业存储阵列 无,因为大多数存储阵列都具有内置逻辑,可以更高效地调度 I/O
虚拟化环境 MQ-Deadline 是一个不错的选择。如果虚拟机管理程序(hypervisor)层执行自身的 I/O 调度,那么使用 none 调度器可能会带来好处,

表 11.1 – I/O 调度器的一些使用案例

好的一点是,I/O 调度器可以在运行时动态更换。还可以为系统上的每个存储设备使用不同的 I/O 调度器。选择或微调 I/O 调度器的一个良好起点是确定系统的用途或角色。普遍认为,没有单一的 I/O 调度器能够满足系统所有多样化的 I/O 需求。

总结

在花费了两章时间尝试诊断和分析 I/O 堆栈不同层次的性能后,本章重点关注 I/O 堆栈的性能调优方面。在本书中,我们已经熟悉了 I/O 堆栈的多层次层级,并建立了对可能影响整体 I/O 性能的各个组件的理解。

本章开始时,我们简要介绍了内存子系统的功能及其如何影响系统的 I/O 性能。由于所有写操作默认首先在页缓存中执行,因此页缓存的配置行为在很大程度上会影响应用程序的 I/O 性能。我们还解释了直接 I/O 的概念,并定义了一些可以用于调整写回缓存的不同参数。

我们还探讨了文件系统的不同调优选项。文件系统提供了不同的挂载选项,这些选项可以改变以减少一些 I/O 开销。此外,文件系统的块大小、其几何结构和基于底层 RAID 配置的 I/O 对齐方式也可能影响性能。最后,我们解释了 Linux 中不同调度策略的使用案例。

我想这就结束了!我真诚地希望本书带领你深入探索了构成 Linux 内核存储堆栈的复杂层次。从 第一章 的 VFS 介绍开始,我们试图探索存储架构的复杂领域。每一章都深入探讨了 Linux 存储堆栈的细节,涉及了 VFS 数据结构、文件系统、块层的作用、多队列和设备映射框架、I/O 调度、SCSI 子系统、物理存储硬件及其性能调优与分析等主题。我们的目标是优先考虑概念方面的内容,分析磁盘 I/O 活动的流向,这也是我们没有过多深入一般存储管理任务的原因。

当我们结束这次探索时,我希望你已经全面理解了 Linux 存储堆栈、其主要组件及其相互作用,并且现在具备了做出明智决策、分析、故障排除和优化 Linux 环境中存储性能的必要知识和技能。

posted @ 2025-07-04 15:40  绝不原创的飞龙  阅读(23)  评论(0)    收藏  举报