Flavors of IO

文件 I/O 的性能对数据库系统的性能有直接的影响,我们需要全面了解和掌握文件I/O。

整体架构

Linux 文件系统采用分层设计,对不同层进行抽象达到架构清晰、解耦的作用。

Linux Storage Stack

硬盘

HDD 和早期 SSD 绝大多数都是使用 SATA 接口,跑的是 AHCI(Advanced Host Controller Interface),它是由Intel联合多家公司研发的系统接口标准。AHCI 支持 NCQ(Native Command Queuing)功能和热插拔技术。NCQ 最大深度为32,即主机可以发最多32条命令给 HDD 或者 SSD 执行,跟之前硬盘只能一条命令一条命令执行相比,硬盘性能大幅提升。

这在 HDD 时代,或者 SSD 早期,AHCI 协议和 SATA 接口足够满足系统性能需求,因为整个系统性能瓶颈在硬盘端(低速,高延时),而不是在协议和接口端。然而,随着 SSD 技术的飞速发展,SSD 盘的性能飙升,底层闪存带宽越来越宽,介质访问延时越来越低,系统性能瓶颈已经由下转移到上面的接口和协议处了。AHCI 和 SATA已经不能满足高性能和低延时 SSD 的需求,因此 SSD 迫切需要自己更快、更高效的协议和接口。

在这样的背景下,NVMe 横空出世。2009年下半年,在带头大哥 Intel 领导下,美光、戴尔、三星、Marvell等巨头,一起制定了专门为 SSD 服务的 NVMe 协议,旨在让 SSD 从老旧的 SATA 和 AHCI 中解放出来。

何为NVMe?Non-Volatile Memory Express,非易失性存储器标准,是跑在 PCIe 接口上的协议标准。NVMe 的设计之初就有充分利用到 PCIe SSD 的低延时以及并行性,还有当代处理器、平台与应用的并行性。SSD的并行性可以充分被主机的硬件与软件充分利用,相比于现在的 AHCI 标准,NVMe 标准可以带来多方面的性能提升。NVMe为SSD而生,但不局限于以闪存为媒介的SSD,它同样可以应用在高性能和低延时的3D XPoint这类新型的介质上。

NVMe和AHCI相比,它的优势主要体现在以下几点:

低时延(Latency)

造成硬盘存储时延的三大因素:存储介质本身、控制器以及软件接口标准。

存储介质层面,闪存(Flash)比传统机械硬盘速度快的太多;

控制器方面,从 SATA SSD 发展成 PCIe SSD,原生 PCIe 主控与 CPU 直接相连,而不是传统方式,通过南桥控制器中转,再连接 CPU,因此基于 PCIe 的 SSD 时延更低;

软件接口方面,NVMe 缩短了 CPU 到 SSD 的指令路径,比如 NVMe 减少了对寄存器的访问次数;MSI-X 和中断管理的应用;并行&多线程优化,NVMe 减少了各个 CPU 核之间的锁同步操作。

所以基于 PCIe+NVMe 的SSD,具有非常低的延时。

高性能(Throughput & IOPS)

理论上,IOPS=队列深度/ IO延迟,故 IOPS 的性能,与队列深度有较大的关系(但 IOPS 并不与队列深度成正比,因为实际应用中,随着队列深度的增大,IO 延迟也会提高)。市面上性能不错的 SATA 接口 SSD,在队列深度上都可以达到32,然而这也是 AHCI 所能做到的极限。但目前高端的企业级 PCIe SSD,其队列深度可能要达到128,甚至是256才能够发挥出最高的 IOPS 性能。而 NVMe 标准下,最大的队列深度可达64K。此外,NVMe 的队列数量也从 AHCI 的1,提高到了64K。

PCIe 接口本身在性能上碾压 SATA,再加上 NVMe具有比AHCI 更深、更宽的命令队列,NVMe SSD 在性能上秒杀 SATA SSD 是水到渠成的事情。图是 NVMe SSD,SAS SSD和SATA SSD 性能对比图:

低功耗

NVMe加入了自动功耗状态切换和动态能耗管理功能

硬盘分类

硬盘的种类比较多,若是按照硬盘接口类型的不同来分,大致可以分为 IDE 硬盘、SATA 硬盘、SCSI 硬盘、移动硬盘、固态硬盘。 硬盘按照其工作形式的不同可以分为两种,一种是机械硬盘,另一种是固态硬盘。比较常见的机械硬盘按照其接口形式的不同可以分为 IDE 硬盘、SATA 硬盘、SCSI 硬盘三种。

IDE 硬盘

IDE(Integrated Drive Electronics)硬盘是指采用 IDE 接口的硬盘。如图,为 IDE 硬盘。IDE 是所有现存并行 ATA 接口规格的统称。这种硬盘相对来说价格低廉、兼容性强、工作稳定、容量大、噪音低,应用比较多。但是,这种硬盘采用并行数据传输方式,传输速度的不断提升使得信号干扰逐渐变强,不利于数据的传输。

SATA 硬盘

SATA(Serial Advande Technology Attachment)硬盘是指采用 SATA 接口的硬盘,如图,为 SATA 硬盘。SATA 接口采用串行数据传输方式,理论上传输速度比 IDE 接口要快很多,解决了IDE硬盘数据传输信号干扰限制传输速率的问题,并且采用该接口的硬盘支持热插拔,执行率也很高。

SCSI 硬盘

SCSI(Small Computer System Interface)硬盘就是采用SCSI接口的硬盘,采用这种接口的硬盘主要用于服务器,如图为SCIS硬盘。这种接口共有50针,外观和普通硬盘接口有些相似。SCSI硬盘和普通IDE硬盘相比有很多优点:接口速度快,并且由于主要用于服务器,因此硬盘本身的性能也比较高,硬盘转速快,缓存容量大,CPU占用率低,扩展性远优于IDE硬盘,并且同样支持热插拔。

固态硬盘

固态硬盘(Solid State Disk)用固态电子存储芯片列阵而制成的硬盘,如图,所示为固态硬盘,它主要由控制单元和存储单元(FLASH芯片)组成。固态硬盘的接口规范和定义、功能及使用方法上与普通硬盘的完全相同,在产品外形和尺寸上与普通硬盘几乎一致。固态硬盘的存储介质分为两种,一种是采用闪存(FLASH芯片)作为存储介质,另外一种是采用DRAM作为存储介质。

SSD 与 HDD 比较

  SSD HDD
启动时间 由于没有马达和转臂,所以几乎可以瞬间完成。
同时从休眠模式中唤醒也大约只需要几毫秒即可。
可能需要数秒以启动马达。而且当磁盘量非常大的时候,需要依次启动以防止瞬间电流过载。
随机访问时间 大约仅需0.1毫秒,因为无需寻道。 大约需要5–10毫秒。
读取潜伏期 通常很短,因为直接读取。 通常比较高,因为磁头需要额外的时间等待扇区的到来。
读取性能一致性 读取性能不因数据在SSD上的存储位置不同而不同。 读取性能与存放在磁盘的内圈还是外圈有关,也与文件的碎片程度有关。
碎片整理 SSD基本不需要进行碎片整理,因为读取连续的数据并不明显比读取分散的数据快。
并且碎片整理会额外增加NAND闪存的写入次数,从而降低其寿命。
HDD通常需要在文件碎片达到一定程度后进行整理,否则性能会有明显下降。特别是在含有大量文件的情况下更是如此。
噪音 SSD无任何噪音 HDD有明显的噪音,并且在读写频繁的时候噪音更大。
机械可靠性 无机械故障 随着时间的推移,机械故障概率会逐渐增加。
环境敏感性 对震动、磁场、碰撞不敏感 对震动、磁场、碰撞敏感
体积和重量 体积小、重量轻 性能越高,体积和重量越大
并行操作 多数控制器可以使用多个芯片进行并发读写 HDD虽然有多个磁头,但是由于共享同一个位置控制电机,所以不能并发读写。
写入寿命 基于闪存的SSD有写入寿命限制,且一旦损坏,整个SSD的数据都将丢失。 无写入寿命限制
数据安全问题 NAND闪存的存储块不能被直接覆盖重写,只能重新写入先前被擦除的块中。
如果一个软件加密程序对已经存在于SSD上的数据进行加密,那些原始的、看上去已经被覆盖掉的原始数据实际上并没有被覆盖,它们依然可以被读取,从而造成信息泄漏。
但是SSD自身基于硬件的加密装置没有这个问题。
此外,也不能简单的通过覆盖原文件的办法来清除原有的数据,除非该SSD有内建的安全删除机制,并且确实已经被启用。
HDD可以直接覆盖掉指定的扇区,因而不存在这个问题。
单位容量成本 贵。但是大约每两年下降一半。 便宜
最大存储容量 小。但是大约每两年可翻一倍。
读/写性能对称 低端SSD的读取速度远高于写入速度,但是高端产品的读写速度可以做到一致。 HDD的读取速度通常比写入速度快一些,但是差距并不很大。
TRIM与可用空白块 SSD的写入性能受可用空白块数量影响很大。
先前曾经写入过数据且现在未被使用的块,可以通过TRIM来回收,使其成为可用的空白块。
但是即使经过TRIM回收的块,其性能依然会出现下降。
HDD完全没有这些问题,其性能不会因为多次读写而出现下降,也不需要进行TRIM操作。
能耗 即使是高性能的SSD通常其能耗也只有HDD的1/2到1/3。 高性能HDD通常需要大约12-18瓦,而为笔记本设计的节能HDD的功耗通常在2-3瓦。

HDD(Hard Disk Drive)

硬盘驱动器是一种较旧的技术,最初由IBM在60多年前推出。它是硬盘驱动器的简称形式,并使用磁力进行数据存储。HDD具有高速旋转的旋转磁盘,同时其上方具有读/写头,其在旋转磁盘上读取和写入数据。HDD 的性能取决于磁盘的旋转速度。目前使用的 HDD 驱动器的通常转速范围在5,400 RPM(RPM)到7,200 RPM(RPM)之间。基于服务器的磁盘可以实现高达15,000 rpm(RPM)的旋转速度。

我们分别从硬盘的物理结构和逻辑结构分析。

物理结构

硬盘的物理结构可分为外部结构和内部结构。

外部结构

硬盘的外部结构主要包括金属固定面板、控制电路板和接口三部分。

金属固定面板 硬盘外部会有一个金属的面板,用于保护整个硬盘。 金属面板和地板结合成一个密封的整体,保证硬盘盘体和机构的稳定运行。

控制电路板

控制电路板上的电子元器件大多采用贴片式元件焊接,这些电子元器件组成了功能不同的电子电路,这些电路包括主轴调速电路、磁头驱动与伺服定位电路、读写电路、控制与接口电路等。在电路板上有几个主要的芯片:主控芯片、BIOS芯片、缓存芯片、电机驱动芯片。

接口

在硬盘的顶端会有几个不同的硬盘接口,这些接口主要包括电源插座接口、数据接口和主、从跳线接口,其中电源插口与主机电源相联,为硬盘工作提供电力保证。中间的主、从盘跳线接口,用以设置主、从硬盘,即设置硬盘驱动器的访问顺序。

内部结构

硬盘内部主要包括磁头组件、磁头驱动组件、盘体、主轴组件、前置控制电路等。

磁头组件 磁头组件包括读写磁头、传动手臂、传动轴三部分组成。

磁头组件中最主要的部分是磁头,另外的两个部分可以看作是磁头的辅助装置。传动轴带动传动臂,使磁头到达指定的位置。 磁头是硬盘中对盘片进行读写工作的工具,是硬盘中最精密的部位之一。磁头是用线圈缠绕在磁芯上制成的,工作原理则是利用特殊材料的电阻值会随着磁场变化的原理来读写盘片上的数据。硬盘在工作时,磁头通过感应旋转的盘片上磁场的变化来读取数据;通过改变盘片上的磁场来写入数据。为避免磁头和盘片的磨损,在工作状态时,磁头悬浮在高速转动的盘片上方,间隙只有0.1~0.3um,而不是盘片直接接触,在电源关闭之后,磁头会自动回到在盘片上着陆区,此处盘片并不存储数据,是盘片的起始位置。

磁头驱动组件

磁头的移动是靠磁头驱动组件实现的,硬盘寻道时间的长短与磁头驱动组件关系非常密切。磁头的驱动机构由电磁线圈电机、磁头驱动小车、防震动装置构成,高精度的轻型磁头驱动机构能够对磁头进行正确的驱动和定位,并能在很短时间内精确定位系统指令指定的磁道,保证数据读写的可靠性。电磁线圈电机包含着一块永久磁铁,该磁铁的磁力很强,对于传动手臂的运动起着关键性的作用。防震装置是为了避免磁头将盘片刮伤等情况的发生而设计的。

盘片与主轴组件

盘片是硬盘存储数据的载体,盘片是在铝合金或玻璃基底上涂覆很薄的磁性材料、保护材料和润滑材料等多种不同作用的材料层加工而成,其中磁性材料的物理性能和磁层机构直接影响着数据的存储密度和所存储数据的稳定性。金属盘片具有很高的存储密度、高剩磁及高娇顽力;玻璃盘片比普通金属盘片在运行时具有更好的稳定性。 主轴组件包括主轴部件轴瓦和驱动电机等。随着硬盘容量的扩大和速度的提高,主轴电机的速度也在不断提升,有厂商开始采用精密机械工业的液态轴承机电技术,这种技术的应用有效地降低了硬盘工作噪音。

前置控制电路

前置放大电路控制磁头感应的信号、主轴电机调速、磁头驱动和伺服定位等,由于磁头读取的信号微弱,将放大电路密封在腔体内可减少外来信号的干扰,提高操作指令的准确性。

逻辑结构

新硬盘是不能直接使用的,必须对它进行分区进行格式化才能存储数据。经过格式化分区后,逻辑上每个盘片的每一面都会被分为磁道、扇区、柱面这几个虚拟的概念。如图所示为硬盘划分的逻辑结构图。另外,不同的硬盘中盘片数不同,一个盘片有两面,这两面都能存储数据,每一面都会对应一个磁头,习惯上将盘面数计为磁头数,用来计算硬盘容量。

扇区、磁道(或柱面)和磁头数构成了硬盘结构的基本参数,用这些参数计算硬盘的容量,其计算公式为:

存储容量 = 磁头数 * 磁道(柱面)数 * 每道扇区数 * 每扇区字节数

查看 ubuntu 磁盘信息

sudo fdisk -l /dev/mapper/ubuntu--vg-root

磁道

当磁盘旋转时,磁头若保持在一个位置上,则每个磁头都会在磁盘表面划出一个圆形轨迹,这些圆形轨迹就叫磁道。磁道上的磁道是一组记录密度不同的同心圆,如图。磁表面存储器是在不同形状(如盘状、带状等)的载体上,涂有磁性材料层,工作时,靠载磁体高速运动,由磁头在磁层上进行读写操作,信息被记录在磁层上,这些信息的轨迹就是磁道。这些磁道用肉眼是根本看不到的,因为他们仅是盘面上以特殊方式磁化了的一些磁化区,磁盘上的信息便是沿着这样的轨道存放的。相邻磁道之间并不是紧挨着的,这是因为磁化单元相隔太近时磁性会产生相互影响,同时也为磁头的读写带来困难,通常盘片的一面有成千上万个磁道。

扇区

分区格式化磁盘时,每个盘片的每一面都会划分很多同心圆的磁道,而且还会将每个同心圆进一步的分割为多个相等的圆弧,这些圆弧就是扇区。为什么要进行扇区的划分呢?因为,读取和写入数据的时候,磁盘会以扇区为单位进行读取和写入数据,即使电脑只需要某个扇区内的几个字节的文件,也必须一次把这几个字节的数据所在的扇区中的全部512字节的数据全部读入内存,然后再进行筛选所需数据,所以为了提高电脑的运行速度,就需要对硬盘进行扇区划分。另外,每个扇区的前后两端都会有一些特定的数据,这些数据用来构成扇区之间的界限标志,磁头通过这些界限标志来识别众多的扇区。

扇区是硬盘的最小操作单位,但扇区对于操作系统来说还是太小了,一般操作系统有自己的硬盘操作最小单位,在 linux 下一般为 4k

xiehui@xiehui-desktop:~$ sudo tune2fs -l  /dev/mapper/ubuntu--vg-root | grep "Block size"
Block size:               4096
xiehui@xiehui-desktop:~$ 
柱面

硬盘通常由一个或多个盘片构成,而且每个面都被划分为数目相等的磁道,并从外缘开始编号(即最边缘的磁道为0磁道,往里依次累加)。如此磁盘中具有相同编号的磁道会形成一个圆柱,此圆柱称为磁盘的柱面。磁盘的柱面数与一个盘面上的磁道数是相等的。由于每个盘面都有一个磁头,因此,盘面数等于总的磁头数。

访盘过程

即一次访盘请求(读/写)完成过程由三个动作组成:

  1. 寻道(时间):磁头移动定位到指定磁道
  2. 旋转延迟(时间):等待指定扇区从磁头下旋转经过
  3. 数据传输(时间):数据在磁盘与内存之间的实际传输

此在磁盘上读取扇区数据(一块数据)所需时间:

$T_{io} = t_{seek}+t{la}+ n * t_{wm}$

其中:

$t_{seek}$ 为寻道时间

$t_{la}$为旋转时间

$t_{wm}$ 为传输时间

总结

HDD 顺序读写的性能远高于随机读写,我们需要充分利用这一特性。

SSD(Solid State Drives)

SSD 用固态电子存储芯片列阵而制成的硬盘,如图,所示为固态硬盘,它主要由控制单元和存储单元(FLASH芯片)组成。固态硬盘的接口规范和定义、功能及使用方法上与普通硬盘的完全相同,在产品外形和尺寸上与普通硬盘几乎一致。固态硬盘的存储介质分为两种,一种是采用闪存(FLASH芯片)作为存储介质,另外一种是采用DRAM作为存储介质。

组成结构

SSD 主要由主控制器,存储单元,缓存(可选),以及跟主机接口(诸如SATA,SAS, PCIe等)组成。

主控制器

每个 SSD 都有一个控制器(controller)将存储单元连接到电脑,主控器可以通过若干个通道(channel)并行操作多块FLASH颗粒,类似 RAID0,大大提高底层的带宽。控制器是一个执行固件(firmware)代码的嵌入式处理器。主要功能如下:

存储单元

尽管有某些厂商推出了基于更高速的 DRAM 内存的产品,但 NAND 闪存依然最常见,占据着绝对主导地位。低端产品一般采用 MLC(multi-level cell) 甚至 TLC(Triple Level Cell) 闪存,其特点是容量大、速度慢、可靠性低、存取次数低、价格也低。高端产品一般采用 SLC(single-level cell) 闪存,其特点是技术成熟、容量小、速度快、可靠性高、存取次数高、价格也高。但是事实上,取决于不同产品的内部架构设计,速度和可靠性的差别也可以通过各种技术加以弥补甚至反转。

缓存

基于 NAND 闪存的 SSD 通常带有一个基于 DRAM 的缓存,其作用与普通的机械式硬盘类似,但是还会存储一些诸如 Wear leveling 数据之类的其他数据。把数据先缓存在 DRAM 中,然后集中写入,从而减少写入次数。特例之一是 SandForce 生产的控制器,它并不含有缓存,但是性能依旧很出色,由于其结构简单,故而可以生产体积更小的 SSD,并且掉电时数据更安全。

主机接口

主机接口与控制器紧密相关,但是通常与传统的机械式硬盘相差不大,主要有以下几种:

存储原理

SSD内部一般使用 NAND Flash 来作为存储介质,其逻辑结构如下:

SSD 中一般有多个 NAND Flash,每个 NAND Flash 包含多个 Block,每个Block 包含多个 Page。由于NAND 的特性,其存取都必须以 page 为单位,即每次读写至少是一个 page,通常地,每个 page 的大小为4k或者8k。另外,NAND还有一个特性是,其只能是读或写单个 page,但不能覆盖写如某个 page,必须先要清空里面的内容,再写入。由于清空内容的电压较高,必须是以 block 为单位。因此,没有空闲的 page 时,必须要找到没有有效内容的 block,先擦写,然后再选择空闲的 page 写入。

操作系统通常将硬盘理解为一连串 512B 大小的扇区[注意:操作系统对磁盘进行一次读或写的最小单位并不是扇区,而是文件系统的,一般为 512B/1KB/4KB 之一(也可能更大),其具体大小在格式化时设定],但是闪存的读写单位是 4/8/16KB 大小的,而且闪存的擦除(又叫编程)操作是按照 128 或 256 页大小的来操作的。更要命的是写入数据前必须要先擦除整个块,而不能直接覆盖。这完全不符合现有的、针对传统硬盘设计的文件系统的操作方式,很明显,我们需要更高级、专门针对 SSD 设计的文件系统来适应这种操作方式。但遗憾的是,目前还没有这样的文件系统。

为了兼容现有的文件系统,就出现了 FTL(闪存转换层),它位于文件系统和物理介质之间,把闪存的操作习惯虚拟成以传统硬盘的 512B 扇区进行操作。这样,操作系统就可以按照传统的扇区方式操作,而不用担心之前说的擦除/读/写问题。一切逻辑到物理的转换,全部由 FTL 层包了。

FTL 算法,本质上就是一种逻辑到物理的映射,因此,当文件系统发送指令说要写入或者更新一个特定的逻辑扇区时,FTL 实际上写入了另一个空闲物理页,并更新映射表,再把这个页上包含的旧数据标记为无效(更新后的数据已经写入新地址了,旧地址的数据自然就无效了)。

在 SSD 中,一般会维护一个 mapping table,维护逻辑地址到物理地址的映射。每次读写时,可以通过逻辑地址直接查表计算出物理地址,与传统的机械磁盘相比,省去了寻道时间和旋转时间。

读写特性

从NAND Flash的原理可以看出,其和HDD的主要区别为

因此,在顺序读测试中,由于定位数据只需要一次,定位之后,则是大批量的读取数据的过程,此时,HDD 和SSD 的性能差距主要体现在读取速度上,HDD 能到200M左右,而普通 SSD 是其两倍。

在随机读测试中,由于每次读都要先定位数据,然后再读取,HDD 的定位数据的耗费时间很多,一般是几毫秒到十几毫秒,远远高于 SSD 的定位数据时间(一般0.1ms左右),因此,随机读写测试主要体现在两者定位数据的速度上,此时,SSD 的性能是要远远好于 HDD 的。

对于SSD的写操作,针对不同的情况,有不同的处理流程,主要是受到 NAND Flash 的如下特性限制

SSD的写分为新写入和更新两种,处理流程不同。

新写入的数据的流程

假设新写入了一个page,其流程如下:

更新操作的流程

假设是更新了page G 中的某些字节,流程如下:

可以看出,如果在更新操作比较多的情况下,会产生较多的无效页,类似于磁盘碎片,此时,需要SSD的over-provisioning和garbage-collection。

磨损平衡

简单说来,磨损平衡(Wear leveling)是确保闪存的每个块被写入的次数相等的一种机制。

通常情况下,在 NAND 块里的数据更新频度是不同的:有些会经常更新,有些则不常更新。很明显,那些经常更新的数据所占用的块会被快速的磨损掉,而不常更新的数据占用的块磨损就小得多。为了解决这个问题,需要让每个块的编程(擦写)次数尽可能保持一致:这就是需要对每个页的读取/编程操作进行监测,在最乐观的情况下,这个技术会让全盘的颗粒物理磨损程度相同并同时报废。

磨损平衡算法分静态和动态。动态磨损算法是基本的磨损算法:只有用户在使用中更新的文件占用的物理页地址被磨损平衡了。而静态磨损算法是更高级的磨损算法:在动态磨损算法的基础上,增加了对于那些不常更新的文件占用的物理地址进行磨损平衡,这才算是真正的全盘磨损平衡。简单点说来,动态算法就是每次都挑最年轻的 NAND 块来用,老的 NAND 块尽量不用。静态算法就是把长期没有修改的老数据从一个年轻 NAND 块里面搬出来,重新找个最老的 NAND 块放着,这样年轻的 NAND 块就能再度进入经常使用区。概念很简单,但实现却非常的复杂,特别是静态。

尽管磨损均衡的目的是避免数据重复在某个空间写入,以保证各个存储区域内磨损程度基本一致,从而达到延长固态硬盘的目的。但是,它对固态硬盘的性能有不利影响。

以一个例子,说明损耗均衡控制的重要性:

假如一个 NAND Flash 总共有 4096个block,每个 block 的擦写次数最大为10000。其中有3个文件,每个文件占用50个block,平均10分钟更新1个文件,假设没有均衡控制,那么只会3 * 50 + 50共200个block,则这个SSD的寿命如下:

大约为278天。而如果是完美的损耗均衡控制,即4096个block都均衡地参与更新,则使用寿命如下:

大约5689天。因此,设计一个好的损耗均衡控制算法是非常有必要的,主流的方法主要有两种:

这里的 dynamic 和 static 是指的是数据的特性,如果数据频繁的更新,那么数据是 dynamic 的,如果数据写入后,不更新,那么是 static 的。

dynamic wear leveling 的原理是记录每个 block 的擦写次数,每次写入数据时,找到被擦除次数最小的空block。

static wear leveling 的原理分为两块:

以一个例子来说明,两种擦写算法的不同点:

假如 SSD 中有25%的数据是 dynamic 的,另外75%的数据是 static 的。对于 dynamic wear leveling 方法,每次要找的是擦除了数据的 block,而 static 的 block 里面是有数据的,因此,每次都只会在 dynamic 的 block 中,即最多会在25%的 block 中做均衡;对于 static 算法,每次找的 block 既可能是 dynamic 的,也可能是 static 的,因此,最多有可能在全部的 block 中做均衡。

相对而言,static 算法能使得 SSD 的寿命更长,但也有其缺点:

垃圾回收

由前面的磨损平衡机制知道,磨损平衡的执行需要有“空白块”来写入更新后的数据。当可以直接写入数据的“备用空白块”数量低于一个阀值后,SSD 主控制器就会把那些包含无效数据的块里的所有有效数据合并起来写到新的“空白块”中,然后擦除这个块以增加“备用空白块”的数量。这个操作就是 SSD 的垃圾回收。

有三种垃圾回收策略:

  1. 闲置垃圾回收:很明显在进行垃圾回收时候会消耗大量的主控处理能力和带宽造成处理用户请求的性能下降,SSD 主控制器可以设置在系统闲置时候做“预先”垃圾回收(提前做垃圾回收操作),保证一定数量的”备用空白块”,让 SSD 在运行时候能够保持较高的性能。闲置垃圾回收的缺点是会增加额外的”写入放大”,因为你刚刚垃圾回收的”有效数据”,也许马上就会被更新后的数据替代而变成”无效数据”,这样就造成之前的垃圾回收做无用功了。

  2. 被动垃圾回收:每个 SSD 都支持的技术,但是对主控制器的性能提出了很高的要求,适合在服务器里用到,SandForce 的主控就属这类。在垃圾回收操作消耗带宽和处理能力的同时处理用户操作数据,如果没有足够强劲的主控制器性能则会造成明显的速度下降。这就是为啥很多 SSD 在全盘写满一次后会出现性能下降的道理,因为要想继续写入数据就必须要边垃圾回收边做写入。

  3. 手动垃圾回收:用户自己手动选择合适的时机运行垃圾回收软件,执行垃圾回收操作。

可以想象,如果系统经常进行垃圾回收处理,频繁的将一些区块进行擦除操作,那么 SSD 的寿命反而也会进一步下降。由此把握这个垃圾回收的频繁程度,同时确保 SSD 中的闪存芯片拥有更高的使用寿命,这确实需要找到一个完美的平衡点。所以,SSD 必须要支持 Trim 技术,不然 GC 就显不出他的优势了。

Trim

Trim 是一个 ATA 指令,当操作系统删除文件或格式化的时候,由操作系统同时把这个文件地址发送给 SSD 的主控制器,让主控制器知道这个地址的数据无效了。

当你删除一个文件的时候,文件系统其实并不会真正去删除它,而只是把这个文件地址标记为“已删除”,可以被再次使用,这意味着这个文件占的地址已经是“无效”的了。这就会带来一个问题,硬盘并不知道操作系统把这个地址标记为“已删除”了,机械盘的话无所谓,因为可以直接在这个地址上重新覆盖写入,但是到了 SSD 上问题就来了。NAND 需要先擦除才能再次写入数据,要得到空闲的 NAND 空间,SSD 必须复制所有的有效页到新的空闲块里,并擦除旧块(垃圾回收)。如果没有 Trim 指令,意味着 SSD 主控制器不知道这个页是“无效”的,除非再次被操作系统要求覆盖上去。

Trim 只是条指令,让操作系统告诉 SSD 主控制器这个页已经“无效”了。Trim 会减少写入放大,因为主控制器不需要复制“无效”的页(没 Trim 就是“有效”的)到空白块里,这同时代表复制的“有效”页变少了,垃圾回收的效率和 SSD 性能也提升了。

Trim 能大量减少伪有效页的数量,它能大大提升垃圾回收的效率。

目前,支持 Trim 需要三个要素,缺一不可:

目前,RAID 阵列里的盘明确不支持 TRIM,不过 RAID 阵列支持 GC。

NCQ

NCQ(Native Command Queuing) 的意思是原生指令排序。使用 NCQ 技术可以对将要读取的文件进行内部排序,然后对文件的排序做最佳化线路读写,达到提升读写效率的目地。NCQ 最早是 SCSI 的标准之一,只是那时候不叫 NCQ,对这个标准稍作修改后,在 SATA 的应用上就叫做 NCQ 了,SAS 接口也支持 NCQ。SSD 虽然没有机械臂,但是 SSD 有多通道。开启 NCQ 后,SSD 主控制器会根据数据的请求和 NAND 内部数据的分布,充分利用主控制器通道的带宽达到提升性能的目地,所以 NCQ 对 SSD 也有帮助,理想状况下性能提升可达5-10倍。目前原生支持 SATA 的 SSD 都能支持 NCQ。当然,要开启NCQ,必须要使用 AHCI 模式。

预留空间

预留空间(Over-provisioning)是指用户不可操作的容量,为实际物理闪存容量减去用户可用容量。这块区域一般被用来做优化,包括磨损均衡,GC和坏块映射。

第一层为固定的7.37%,这个数字是如何得出的哪?我们知道机械硬盘和 SSD 的厂商容量是这样算的,1GB 是1,000,000,000字节(10的9 次方),但是闪存的实际容量是每 GB=1,073,741,824,(2的30次方) ,两者相差7.37%。所以说假设1块 128GB 的 SSD,用户得到的容量是 128,000,000,000 字节,多出来的那个 7.37% 就被主控固件用做OP了。

第二层来自制造商的设置,通常为 0%,7%,28% 等,打个比方,对于 128G 颗粒的 SandForce 主控 SSD,市场上会有 120G 和 100G 两种型号卖,这个取决于厂商的固件设置,这个容量不包括之前的第一层 7.37% 。

第三层是用户在日常使用中可以分配的预留空间,用户可以在分区的时候,不分到完全的 SSD 容量来达到这个目的。不过需要注意的是,需要先做安全擦除(Secure Erase),以保证此空间确实没有被使用过。

预留空间虽然让 SSD 的可用容量小了,但是带来了减少写入放大、提高耐久性、提高性能的效果。根据经验,预留空间在 20%-35% 之间是最佳平衡点。

以一个例子,说明 SSD 的预留空间和 GC:

如上图所示,假设系统中就两个 block,最终还剩下两个无效的 page,此时,要写入一个新 page,根据 NAND 原理,必须要先对两个无效的 page 擦除才能用于写入。此时,就需要用到 SSD 提供的额外空间,才能用 GC 方法整理出可用空间。

GC 的整理流程如上图所示

有空闲 page 之后,就可以按照正常的流程来写入了。

SSD 的 GC 会带来两个问题:

如果频繁的在某些 block 上做 GC,会使得这些元件比其他部分更快到达擦写次数限制,因此,需要某个算法,能使得原件的擦写次数比较平均,这样才能延长 SSD 的寿命,这就需要损耗均衡控制了。

写入放大

写入放大(Write amplification),因为闪存必须先擦除(也叫编程)才能写入,在执行这些操作的时候,移动或覆盖用户数据和元数据(metadata)不止一次。这些额外的操作,不但增加了写入数据量,减少了SSD的使用寿命,而且还吃光了闪存的带宽,间接地影响了随机写入性能。这种效应就叫写入放大(Write amplification)。一个主控的好坏主要体现在写入放大上。

比如我要写入一个 4KB 的数据,最坏的情况是,一个块里已经没有干净空间了,但是有无效数据可以擦除,所以主控就把所有的数据读到缓存,擦除块,从缓存里更新整个块的数据,再把新数据写回去。这个操作带来的写入放大就是:我实际写4K的数据,造成了整个块(1024KB)的写入操作,那就是256倍放大。同时带来了原本只需要简单的写4KB的操作变成闪存读取(1024KB),缓存改(4KB),闪存擦(1024KB),闪存写(1024KB),造成了延迟大大增加,速度急剧下降也就是自然的事了。所以,写入放大是影响 SSD 随机写入性能和寿命的关键因素。

用100%随机4KB来写入 SSD,对于目前的大多数 SSD 主控而言,在最糟糕的情况下,写入放大的实际值可能会达到或超过20倍。当然,用户也可以设置一定的预留空间来减少写入放大,假设你有个 128G 的 SSD,你只分了 64G 的区使用,那么最坏情况下的写入放大就能减少约3倍。

许多因素影响 SSD 的写入放大。下面列出了主要因素,以及它们如何影响写入放大。

  1. 垃圾回收虽然增加了写入放大(被动垃圾回收不影响,闲置垃圾回收影响),但是速度有提升。
  2. 预留空间可以减少写入放大,预留空间越大,写入放大越低。
  3. 开启 TRIM 指令后可以减少写入放大
  4. 用户使用中没有用到的空间越大,写入放大越低(需要有 Trim 支持)。
  5. 持续写入可以减少写入放大。理论上来说,持续写入的写入放大为1,但是某些因素还是会影响这个数值。
  6. 随机写入将会大大提升写入放大,因为会写入很多非连续的 LBA。
  7. 磨损平衡机制直接提高了写入放大

SSD 读写速度

从软件开发人员作为 SSD 的用户角度来讲,首先需要了解的是 SSD 和普通 HDD 的性能对比,如下:

顺序读和顺序写

HDD 的顺序读速度差不多为最慢的 SSD的一半,顺序写稍微好点,但也比大部分慢一倍左右的速度。

随机读和随机写

可以看出,HDD的随机读的性能是普通SSD的几十分之一,随机写性能更差。

因此,SSD的随机读和写性能要远远好于HDD。

总结

使用 SSD 时,我们需要考虑如下情况:

Linux测试磁盘I/O性能

我们常用dd命令测试 Linux 磁盘 I/O 情况,dd只是测试顺序读写性能。对于随机读写性能测试,可采用FIO工具。

安装

下载并安装

wget http://brick.kernel.dk/snaps/fio-2.2.5.tar.gz
yum install libaio-devel gcc  -y
tar -zxvf fio-2.2.5.tar.gz
cd fio-2.2.5
make
make install

FIO参数

随机读:

fio -filename=/tmp/test_randread -direct=1 -iodepth 1 -thread -rw=randread -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=60 -group_reporting -name=mytest

####################
说明:
filename=/dev/sdb1       测试文件名称,通常选择需要测试的盘的data目录。
direct=1                 测试过程绕过机器自带的buffer。使测试结果更真实。
rw=randwrite             测试随机写的I/O
rw=randrw                测试随机写和读的I/O
bs=16k                   单次io的块文件大小为16k
bsrange=512-2048         同上,提定数据块的大小范围
size=5g    本次的测试文件大小为5g,以每次4k的io进行测试。
numjobs=30               本次的测试线程为30.
runtime=1000             测试时间为1000秒,如果不写则一直将5g文件分4k每次写完为止。
ioengine=psync           io引擎使用pync方式
rwmixwrite=30            在混合读写的模式下,写占30%
group_reporting          关于显示结果的,汇总每个进程的信息。
 
此外
lockmem=1g               只使用1g内存进行测试。
zero_buffers             用0初始化系统buffer。

常用测试命令

随机读

fio -filename=/tmp/test_randread -direct=1 -iodepth 1 -thread -rw=randread -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=600 -group_reporting -name=mytest

随机写

fio -filename=/tmp/test_randread -direct=1 -iodepth 1 -thread -rw=randread -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=600 -group_reporting -name=mytest

顺序写

fio -filename=/data/test_randread -direct=1 -iodepth 1 -thread -rw=write -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=600 -group_reporting -name=mytest

顺序读

fio -filename=/tmp/test_randread -direct=1 -iodepth 1 -thread -rw=read -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=60 -group_reporting -name=mytest

混合随机读写

fio -filename=/tmp/test_randread -direct=1 -iodepth 1 -thread -rw=randrw -rwmixread=70 -ioengine=psync -bs=16k -size=30G -numjobs=10 -runtime=600 -group_reporting -name=mytest -ioscheduler=noop

IOPS 和 吞吐量

为何随机是关注IOPS,顺序关注吞吐量?

因为随机读写的话,每次IO操作的寻址时间和旋转延时都不能忽略不计,而这两个时间的存在也就限制了IOPS的大小;而顺序读写可以忽略不计寻址时间和旋转延时,主要花费在数据传输的时间上。

分层详解

程序处理文件的目的就是把数据写到磁盘或者从磁盘把数据读取到内存中, 我们先用程序将数据写入到磁盘中:

#include  <stdio.h>
#include  <stdlib.h>
int main(int argc,char *argv[])
{
    FILE *fp1, *fp2;       //流指针
    char buf[1024];        //缓冲区
    int n;                 //存放fread和fwrite函数的返回值
    if(argc <=2)        
    {
       printf("请输入正确的参数\n!");  
    }
    if ((fp1 = fopen(*(argv+1), "rb")) == NULL)
    {
         printf("读源文件%s发生错误\n",*(argv+1));
         return 1;    
    }
    if ((fp2 = fopen(*(argv+2), "wb")) == NULL)
    {
         printf("打开目标文件%s失败\n",*(argv+2));
         return 2;    
    }
    while ((n = fread(buf, sizeof(char), 1024, fp1)) > 0)
    {
        if (fwrite(buf, sizeof(char), n, fp2) == -1)
        {
            printf("写入文件发生错误\n");
            return 3;  
        }
    }
    printf("从源文件%s读数据写入目标文件%s中完成\n",*(argv+1),*(argv+2)); //输出对应的提示
    if(n == -1)
    {
         printf("读文件发生错误\n");
         return 4;
    }
    fclose(fp1);
    fclose(fp2);
    return 0;
}

文件先写入应用程序 buffer;调用fwrite后,把数据从 buffer 拷贝到了 CLib buffer,即C库标准 IO buffer。fwrite返回后,数据还在 CLib buffer,如果这时候进程 crash 掉,这些数据会丢失。没有写到磁盘介质上。当调用fclose的时候,fclose调用会把这些数据刷新到磁盘介质上。除了fclose方法外,还有一个主动刷新操作fflush函数,不过fflush函数只是把数据从 CLib buffer 拷贝到 page cache 中,并没有刷新到磁盘上,从 page cache 刷新到磁盘上可以通过调用fsync函数完成。

当进程 crash 时如果数据还处在应用 cache 或 CLib cache 时候,数据会丢失。如果数据在 page cache,进程crash 掉,即使数据还没有到硬盘。数据也不会丢失。当内核 crash 掉后,只要数据没有到达 disk cache,数据都会丢失。

Linux File I/O

Linux 给我们提供各种风格的 IO 接口来使用:

标准 I/O(Standard I/O)

缓存 I/O

大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。缓存 I/O 有以下这些优点:

标准IO通过系统调用 read()write() 执行 IO 操作。当应用程序尝试读取某块数据的时候,如果这块数据已经存放在了 Page Cache 中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。当然,如果数据在应用程序读取之前并未被存放在页缓存中,则会触发*Page Fault **然后将数据从磁盘读到 Page Cache 中去。对于写操作来说,应用程序也会将数据先写到 Page Cache 中去,数据是否被立即写到磁盘上去取决于应用程序所采用的写操作机制:如果用户采用的是同步写机制( synchronous writes ), 那么数据会立即被写回到磁盘上,应用程序会一直等到数据被写完为止;如果用户采用的是延迟写机制( deferred writes ),那么应用程序就完全不需要等到数据全部被写回到磁盘,数据只要被写到 Page Cache 中去就可以了。在延迟写机制的情况下,操作系统会定期地将放在 Page Cache 中的数据刷到磁盘上。与异步写机制( asynchronous writes )不同的是,延迟写机制在数据完全写到磁盘上的时候不会通知应用程序,而异步写机制在数据完全写到磁盘上的时候是会返回给应用程序的。所以延迟写机制本身是存在数据丢失的风险的,而异步写机制则不会有这方面的担心。

缓存 I/O 的缺点

在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

对于某些特殊的应用程序来说,避开操作系统内核缓冲区而直接在应用程序地址空间和磁盘之间传输数据会比使用操作系统内核缓冲区获取更好的性能。

直接 I/O

凡是通过直接 I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库系统,它们更倾向于选择他们自己的缓存机制,因为数据库系统往往比操作系统更了解数据库中存放的数据,数据库系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。

要在块设备中执行直接 I/O,进程必须在打开文件的时候设置对文件的访问模式为 O_DIRECT,这样就等于告诉操作系统进程在接下来使用 read() 或者 write() 系统调用去读写文件的时候使用的是直接 I/O 方式,所传输的数据均不经过操作系统内核缓存空间。使用直接 I/O 读写数据必须要注意缓冲区对齐( buffer alignment )以及缓冲区的大小的问题,即对应 read() 以及 write() 系统调用的第二个和第三个参数。这里边说的对齐指的是文件系统块大小的对齐,缓冲区的大小也必须是该块大小的整数倍。

直接 I/O 的优点

直接 I/O 最主要的优点就是通过减少操作系统内核缓冲区和应用程序地址空间的数据拷贝次数,降低了对文件读取和写入时所带来的 CPU 的使用以及内存带宽的占用。这对于某些特殊的应用程序,比如自缓存应用程序来说,不失为一种好的选择。如果要传输的数据量很大,使用直接 I/O 的方式进行数据传输,而不需要操作系统内核地址空间拷贝数据操作的参与,这将会大大提高性能。O_DIRECT 特别适用于数据库系统这类应用。

直接 I/O 潜在可能存在的问题

直接 I/O 并不一定总能提供令人满意的性能上的飞跃。设置直接 I/O 的开销非常大,而直接 I/O 又不能提供缓存 I/O 的优势。缓存 I/O 的读操作可以从高速缓冲存储器中获取数据,而直接 I/O 的读数据操作会造成磁盘的同步读,这会带来性能上的差异 , 并且导致进程需要较长的时间才能执行完;对于写数据操作来说,使用直接 I/O 需要 write() 系统调用同步执行,否则应用程序将会不知道什么时候才能够再次使用它的 I/O 缓冲区。与直接 I/O 读操作类似的是,直接 I/O 写操作也会导致应用程序关闭缓慢。所以,应用程序使用直接 I/O 进行数据传输的时候通常会和使用异步 I/O 结合使用。

引用Linus的话:” The thing that has always disturbed me about O_DIRECT is that the whole interface is just stupid, and was probably designed by a deranged monkey on some serious mind-controlling substances.”—Linus(O_DIRECT 就是傻逼设计)

实例

  1. 打开文件时,添加O_DIRECT参数,需要定义_GNU_SOURCE,否则找不到O_DIRECT宏定义
#define _GNU_SOURCE
  
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
 
int fd = open("test.out", O_RDWR | O_CREAT | O_DIRECT, 0644);
  1. 读写操作的传输数据大小和缓冲区地址都需要按照一定的规则对齐,对于不同的文件系统和内核版本,需要的对齐边界不同,也没有统一的接口可以获取到该边界值。

    • 对于 kernel 2.4 版本:传输大小和缓冲区地址均需要按照访问文件系统的逻辑块大小对齐,比如文件系统的块大小是4K,buffer 地址需要按照4K对齐,需要读写4K倍数的数据

    • 对于 kernel 2.6 版本:传输大小和缓冲区地址按照目标存储设备的扇区大小(一般512)对齐

    可使用memalign (malloc.h)来分配指定地址对齐的资源接口:void *memalign(size_t boundary, size_t size);

#define _GNU_SOURCE
 
#include <sys/types.h>
#include <fcntl.h>
#include <malloc.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
 
int main(void) {
        char hello_str[] = "Hello World!";
        void *write_buffer;
        void *read_buffer;
        int fd; 
        int ret = 0;
     
        fd = open("test.out", O_RDWR | O_CREAT | O_DIRECT, 0644);
        if (fd < 0) {
                printf("Failed to open file\n");
                return fd; 
        }   
     
        /* allocate a 1024 bytes buffer */
        write_buffer = memalign(512, 512 * 2); // align by 512
        if (!write_buffer) {
                printf("Failed to alloc write buffer\n");
                ret = -ENOMEM;
                goto bad_write_buffer;
        }   
 
        memcpy(write_buffer, hello_str, sizeof(hello_str));
 
        ret = write(fd, write_buffer, 512 * 2); 
        if (ret < 0) {
                printf("Failed to write file\n"); 
                goto bad_write;
        }   
 
        lseek(fd, 0, SEEK_SET); // read  previous write data
     
        read_buffer = memalign(512, 512 * 2);
        if (!read_buffer) {
                printf("Failed to alloc read buffer\n");
                ret = -ENOMEM;
                goto bad_read_buffer;
        }
 
        ret = read(fd, read_buffer, 512 * 2);
        if (ret <0) {
                printf("Failed to read file\n");
                goto bad_read;
        }
 
        printf("read from file : %s\n", read_buffer);
 
bad_read:
        free(read_buffer);
bad_read_buffer:
bad_write:
        free(write_buffer);
bad_write_buffer:
        close(fd);
        return ret;
}

O_DIRECT的详细描述,可以看linux open系统调用的文档

AIO

Asynchronous I/O 帮助用户程序提高 CPU 和 IO 设备的利用率和提高程序性能,特别是在高负载的IO操作下。比如各种代理服务器,数据库,流服务器等等。

很多人会将 AIO 理解成磁盘 IO 的异步方案,会将 AIO 狭隘化为类 epoll 接口在磁盘 IO 的特殊化,其实 AIO 应该是横架于整个内核的接口,它把所有的 IO 包括(本地设备,网络,管道等)以统一的异步接口提供给用户程序,每个子系统都针对接口实现自己的异步方案,而同步IO(Synchronous IO)只是在内核内部的”AIO+Blocking”.

Linux Native Aio

由操作系统内核提供的AIO,头文件为<linux/aio_abi.h>。Native Aio 是真正的 AIO,完全非阻塞异步的,而不是用阻塞IO和线程池模拟。主要的几个系统调用为io_submit/io_setup/io_getevents

GCC AIO

gcc 遵循 posix 标准实现了AIO。头文件为 <aio.h>,支持 FreeBSD/Linux。是通过阻塞 IO+线程池来实现的。主要的几个函数是aio_read/aio_write/aio_return

Libeio

libev 的作者开发的 AIO 实现,与 gcc aio 类似也是使用阻塞IO+线程池实现的。优点与缺点参见上面。它与gcc aio 的不同之处,代码更简洁,所以 bug 少更安全稳定。但这是一个第三方库,你的代码需要依赖 libeio。

Vectored IO

Vectored IO(也称为Scatter / Gather)是一种可以在单次系统调用中对多个缓冲区输入输出的方法,可以把多个缓冲区的数据写到单个数据流,也可以把单个数据流读到多个缓冲区中。其命名的原因在于数据会被分散到指定缓冲区向量,或者从指定缓冲区向量中聚集数据。这种输入输出方法也称为向量 I/O(vector I/O)。与之不同,标准读写系统调用(read,write)可以称为线性I/O(linear I/O)。

与线性 I/O 相比,分散/聚集 I/O 有如下几个优势:

  1. 编码模式更自然
    • 如果数据本身是分段的(比如预定义的结构体的变量),向量 I/O 提供了直观的数据处理方式。
  2. 效率更高
    • 单个向量 I/O 操作可以取代多个线性 I/O 操作。
  3. 性能更好
    • 除了减少了发起的系统调用次数,通过内部优化,向量 I/O 可以比线性 I/O 提供更好的性能。
  4. 支持原子性
    • 和多个线性 I/O 操作不同,一个进程可以执行单个向量 I/O 操作,避免了和其他进程交叉操作的风险。

Linux实现了 POSIX 1003.1-2001 中定义的一组实现 Scatter / Gather I/O 机制的系统调用。该实现满足了上面所述的所有特性。

readv() 函数从文件描述符 fd 中读取 count 个段 (segment) (一个段即一个 iovec 结构体)到参数 iov 所指定的缓冲区中:

#include <sys/uio.h>

ssize_t readv (int fd,const struct iovec *iov,int count)

write() 函数从参数 iov 指定的缓冲区中读取 count 个段的数据,并写入 fd 中:

#include <sys/uio.h>
ssize_t writev(int fd,const struct iovec *iov,int count)

除了同时操作多个缓冲区外,readv() 函数和 writev() 函数的功能分别和 read(),write() 的功能一致。

每个 iovec 结构体描述一个独立的,物理不连续的缓冲区,我们称其为段(segment):

#include <sys/uio.h>

struct iovec {
   void      *iov_base;/* pointer to start of buffer */
   size_t   iov_len;/* size of buffer in bytes */
};

一组段的集合称为向量(vector)。每个段描述了内存中所要读写的缓冲区的地址和长度。readv() 函数在处理下个缓冲区之前,会填满当前缓冲区的 iov_len 个字节。write() 函数在处理下个缓冲区之前,会把当前缓冲区所有 iov_len 个字节数据输出,这两个函数都会顺序处理向量中的段,从 iov[0] 开始,接着是 iov[1],一直到 iov[count - 1] 。

目前只有很少的数据库使用 Vectored IO,毕竟它们要处理很多文件,关注延时,按块访问和缓存,而分析型或列式数据库比较适合使用 Vectored IO,比如: Apache Arrow

Memory Mapping

Linux 中内存区域( memory region )是可以跟一个普通的文件或者块设备文件的某一个部分关联起来的,若进程要访问内存页中某个字节的数据,操作系统就会将访问该内存区域的操作转换为相应的访问文件的某个字节的操作。Linux 中提供了系统调用 mmap() 来实现这种文件访问方式。与标准的访问文件的方式相比,内存映射方式可以减少标准访问文件方式中 read() 系统调用所带来的数据拷贝操作,即减少数据在用户地址空间和操作系统内核地址空间之间的拷贝操作。映射通常适用于较大范围,对于相同长度的数据来讲,映射所带来的开销远远低于 CPU 拷贝所带来的开销。当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。数据库引擎中大量采用 mmap 的方式。

用户调用mmap将文件映射到内存时,内核进行一系列的参数检查,然后创建对应的vma,然后给该vma绑定vma_ops。当用户访问到mmap对应的内存时,CPU会触发page fault,在page fault回调中,将申请pagecache中的匿名页,读取文件到其物理内存中,然后将pagecache中所属的物理页与用户进程的vma进行映射。

其整个内核逻辑流程可以用下图来表示:

Page Cache

引入Cache 层的目的是为了提高 Linux 对磁盘访问的性能。Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 Cache 中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。Cache 层也正是磁盘 IOPS 为什么能突破200的主要原因之一。

在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。Page Cache 主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有read/write操作的时候。Buffer Cache 则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。

磁盘 Cache 有两大功能:预读和回写。预读其实就是利用了局部性原理,具体过程是:对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(通常是三个页面),这时的预读称为同步预读。对于第二次读请求,如果所读页面不在 Cache 中,即不在前次预读的页中,则表明文件访问不是顺序访问,系统继续采用同步预读;如果所读页面在 Cache 中,则表明前次预读命中,操作系统把预读页的大小扩大一倍,此时预读过程是异步的,应用程序可以不等预读完成即可返回,只要后台慢慢读页面即可,这时的预读称为异步预读。任何接下来的读请求都会处于两种情况之一:第一种情况是所请求的页面处于预读的页面中,这时继续进行异步预读;第二种情况是所请求的页面处于预读页面之外,这时系统就要进行同步预读。

回写是通过暂时将数据存在 Cache 里,然后统一异步写到磁盘中。通过这种异步的数据I/O模式解决了程序中的计算速度和数据存储速度不匹配的鸿沟,减少了访问底层存储介质的次数,使存储系统的性能大大提高。Linux 2.6.32内核之前,采用 pdflush 机制来将脏页真正写到磁盘中,什么时候开始回写呢?下面两种情况下,脏页会被写回到磁盘:

  1. 在空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
  2. 当脏页在内存中驻留超过一定的阈值时,内核必须将超时的脏页写会磁盘,以确保脏页不会无限期地驻留在内存中。

回写开始后,pdflush 会持续写数据,直到满足以下两个条件:

  1. 已经有指定的最小数目的页被写回到磁盘。
  2. 空闲内存页已经回升,超过了阈值。

Linux 2.6.32 内核之后,放弃了原有的 pdflush 机制,改成了 bdi_writeback 机制。bdi_writeback 机制主要解决了原有 fdflush 机制存在的一个问题:在多磁盘的系统中,pdflush 管理了所有磁盘的 Cache,从而导致一定程度的 I/O 瓶颈。bdi_writeback 机制为每个磁盘都创建了一个线程,专门负责这个磁盘的 Page Cache 的刷新工作,从而实现了每个磁盘的数据刷新在线程级的分离,提高了 I/O 性能。

回写机制存在的问题是回写不及时引发数据丢失(可由sync|fsync解决),回写期间读 I/O 性能很差。

详细的分析可以参见:Linux内核文件Cache 机制Linux内核延迟写机制

Page Cache 优化

fadvise

在典型的 I/O 密集型的数据库服务器如 MySQL 中,会涉及到大量的文件读写,通常这些文件都是通过 buffer io 来使用的,以便充分利用到 Linux的 Page Cache。

Buffer I/O 的特点是读的时候,先检查页缓存里面是否有需要的数据,如果没有就从设备读取,返回给用户的同时,加到缓存一份;写的时候,直接写到缓存去,再由后台的进程定期刷到磁盘去。这样的机制看起来非常的好,在实践中也效果很好。

但是如果你的 I/O 非常密集,就会出现问题。首先由于 pagesize 是4K,内存的利用效率比较低。其次缓存的淘汰算法很简单,由操作系统自主进行,用户不大好参与。当你的写很多,超过系统内存的某个上限的时候,后台的进程(swapd)要出来回收页面,而且一旦回收的速度小于写入的速度,就会出现不可预期的行为。 这里面最大的问题是:当你使用的内存包括缓存,没超过操作系统规定的上限的时候,操作系统选择不作为,让用户充分使用缓存,从它的角度来看这样效率最高。但是正是由于这种策略在实践中会导致问题。

比如说MySQL服务器,我们可以把数据直接走 direct IO ,但是它的日志是走 bufferio 的。因为走 directio 需要对写入文件的偏移和大小都要扇区对全,这对日志系统来讲太麻烦了。由于 MySQL 是基于事务的,会涉及到大量的日志动作,频繁的写入,然后 fsync 日志一旦写入磁盘,buffer page 就没用了,但是一直会在内存呆着,直到达到内存上限,引起操作系统突然大量回收页面,出现 IO 柱塞或者内存交换等负面问题。

那么我们知道了困境在哪里,我们可以主动避免这个现象的发生。有二种方法:

  1. 日志也走 direct io ,需要规模的修改 MySQL 代码,如 percona 就这么做了,提供相应的 patch。
  2. 日志还是走 buffer io, 但是定期清除无用 Page Cache.

第一张方法不是我们要讨论的,我们重点讨论第二种如何做:

我们在程序里知道文件的句柄,是不是就可以很轻松的用:

int posix_fadvise(int fd, off_t offset, off_t len, int advice);
POSIX_FADV_DONTNEED
The specified data will not be accessed in the near future.

来解决问题呢? 比如写类似 posix_fadvise(fd, 0, len_of_file, POSIX_FADV_DONTNEED);这样的代码来清掉文件所属的缓存。但是你会发现内存根本就没下来, 为什么相关的内存没有被释放出来:页面还脏是最关键的因素。

但是我们如何保证页面全部不脏呢?fdatasync或者fsync都是选择, 或者 Linux 下新系统调用sync_file_range都是可用的,这几个都是使用WB_SYNC_ALL模式强制要求回写完毕才返回的。 如这样做:

`fdatasync(fd);`
mlock/文件预热

使用 mlock 可以将进程使用的部分或者全部的地址空间锁定在物理内存中,防止其被交换到 swap 空间。对于高吞吐量的分布式消息队列来说,追求的是消息读写低延迟,那么肯定希望尽可能地多使用物理内存,提高数据读写访问的操作效率。

文件预热的目的主要有两点:

  1. 由于仅分配内存并进行mlock系统调用后并不会为程序完全锁定这些内存,因为其中的分页可能是写时复制的。因此,就有必要对每个内存页面中写入一个假的值。比如:RocketMQ 是在创建并分配MappedFile 的过程中,预先写入一些随机值至mmap映射出的内存空间里。
  2. 调用mmap进行内存映射后,OS 只是建立虚拟内存地址至物理地址的映射表,而实际并没有加载任何文件至内存中。程序要访问数据时OS会检查该部分的分页是否已经在内存中,如果不在,则发出一次缺页中断。这里,可以想象下1G的 CommitLog 需要发生多少次缺页中断,才能使得对应的数据才能完全加载至物理内存中。RocketMQ 的做法是,在做mmap内存映射的同时进行madvise系统调用,目的是使 OS 做一次内存映射后对应的文件数据尽可能多的预加载至内存中,从而达到内存预热的效果。

虚拟文件系统(VFS )

VFS(Virtual File System)虚拟文件系统是一种软件机制,更确切的说扮演着文件系统管理者的角色,与它相关的数据结构只存在于物理内存当中。它的作用是:屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。正是因为有了这个层次,Linux 中允许众多不同的文件系统共存并且对文件的操作可以跨文件系统而执行。

VFS 中包含着向物理文件系统转换的一系列数据结构,如 VFS 超级块、VFS 的 inode、各种操作函数的转换入口等。Linux 中 VFS 依靠四个主要的数据结构来描述其结构信息,分别为超级块、索引结点、目录项和文件对象。

  1. 超级块(Super Block):超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。VFS超级块存在于内存中,它在文件系统安装时建立,并且在文件系统卸载时自动删除。同时需要注意的是对于每个具体的文件系统来说,也有各自的超级块,它们存放于磁盘。
  2. 索引结点(inode):索引结点对象存储了文件的相关元数据信息,例如:文件大小、设备标识符、用户标识符、用户组标识符等等。inode 分为两种:一种是 VFS 的 inode,一种是具体文件系统的 inode。前者在内存中,后者在磁盘中。所以每次其实是将磁盘中的 inode调进填充内存中的 inode,这样才是算使用了磁盘文件 inode。当创建一个文件的时候,就给文件分配了一个 inode。一个 inode 只对应一个实际文件,一个文件也会只有一个 inode。
  3. 目录项(Dentry):引入目录项对象的概念主要是出于方便查找文件的目的。不同于前面的两个对象,目录项对象没有对应的磁盘数据结构,只存在于内存中。一个路径的各个组成部分,不管是目录还是普通的文件,都是一个目录项对象。如,在路径/home/source/test.java中,目录 /, home, source和文件 test.java 都对应一个目录项对象。VFS 在查找的时候,根据一层一层的目录项找到对应的每个目录项的Inode,那么沿着目录项进行操作就可以找到最终的文件。
  4. 文件对象(File):文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。一个文件对应的文件对象可能不是惟一的,但是其对应的索引节点和目录项对象肯定是惟一的。

详细的分析可以参见:inux虚拟文件系统

Ext 4 文件系统

全称 Linux extended file system, extfs,即 Linux 扩展文件系统,Ext2 就代表第二代文件扩展系统,Ext3/Ext4 以此类推,它们都是 Ext2 的升级版,只不过为了快速恢复文件系统,减少一致性检查的时间,增加了日志功能,所以 Ext2 被称为索引式文件系统,而 Ext3/Ext4 被称为日志式文件系统

Linux支持很多文件系统,包括网络文件系统(NFS)、Windows的Fat文件系统。

查看Linux支持的文件系统:ls -l /lib/modules/$(uname -r)/kernel/fs

xiehui@xiehui-desktop:~/apps$ ls -l /lib/modules/$(uname -r)/kernel/fs
总用量 236
drwxr-xr-x 2 root root  4096 4月  24 09:59 9p
drwxr-xr-x 2 root root  4096 4月  24 09:59 adfs
drwxr-xr-x 2 root root  4096 4月  24 09:59 affs
drwxr-xr-x 2 root root  4096 4月  24 09:59 afs
drwxr-xr-x 2 root root  4096 4月  24 09:59 aufs
drwxr-xr-x 2 root root  4096 4月  24 09:59 autofs
drwxr-xr-x 2 root root  4096 4月  24 09:59 befs
drwxr-xr-x 2 root root  4096 4月  24 09:59 bfs
........

查看Linux支持的文件系统(已载入到内存中):cat /proc/filesystems

xiehui@xiehui-desktop:~/apps$ cat /proc/filesystems
nodev	sysfs
nodev	rootfs
nodev	ramfs
nodev	pipefs
nodev	hugetlbfs
nodev	devpts
	ext3
	ext2
	ext4
...

查看当前机器使用的文件系统类型df -T

xiehui@xiehui-desktop:~/apps$ df -T
文件系统                    类型         1K-块     已用      可用 已用% 挂载点
udev                        devtmpfs   3939508        0   3939508    0% /dev
tmpfs                       tmpfs       792544     2112    790432    1% /run
/dev/mapper/ubuntu--vg-root ext4     243559804 77716804 153401196   34% /

主要特性

兼容性(Compatibility)

Ext4 兼容 Ext3,升级只需运行一些命令即可,不需要变动磁盘格式,升级中不会影响已有的数据。

更大的文件系统和文件大小(Bigger File System and File Sizes )

File System Max FS Size Max File Size block addressing bits
Ext3 16TB 2TB 32
Ext4 1EB 16TB 48

​ Tips:

拓展子目录数量(Sub directory scalability )

在一个目录中 :

拓展块大小(Extents)

Ext3 为每个文件维护一个 block 表,用于保存这个文件在磁盘上的块号,因为一个 block 只有 4kb 的大小,所以对于一个大文件来说的话,需要维护的 block 表占用的空间就比较可观了,删除和截断等操作的效率也就比较低。

Ext4 使用 extents 代替 block。 extents 由多个连续的 block 组成。能够有效的减少需要维护的 block 表的长度,进而提高在文件上操作的效率

多块分配(Multiblock allocation)

当需要将新数据写入磁盘上时,需要块分配器决定将数据写入哪一个空闲块中。

但是 Ext3 写入的时候,每次只分配一个 block(4kb), 也就是说如果要写入 100 Mb 的数据时会调用块分配器 25600 词,效率很低,分配器也无法作优化。

Ext4 使用多块分配器,根据需要,一次调用分配多个块(一个 extents)

延迟分配(Delayed allocation)

传统的文件系统尽可能早的分配磁盘 blocks,当进程调用 write() 时,文件系统立即为其分配 block,即使数据并没有立即写入磁盘(在缓存中临时存放)。这种方式的缺点是当进程持续向文件写入数据,文件增长时需要分配另外的 block 来存放新增的数据,块分配器无法对分配方式作优化。

而延迟分配策略解决了这个问题,当进程调用 write() 时它并不立即分配 blocks,直到数据从缓存写入磁盘时进行分配。写入磁盘时,数据基本就不再增长了,此时使用多块分配器为该文件分配多个 extents

快速文件系统检测(Fast fsck)

文件系统检测是一项非常慢的操作,特别是检查文件系统中所有的 inode 节点。

Ext4 跳过未使用的 inode 节点来加快检测速度,根据已使用的 inode 节点的数量不同,性能会提升 2 到 20 倍。

日志校验(Journal checksumming)

使用校验和来判断一个日志块是否已失效。

Ext3 使用两阶段(执行 + commit/rollback)提交来保证正确性。

Ext4 使用一阶段提交 + 日志校验来保证正确性,性能提升大约 20%。

禁用日志模式(“No Journaling” mode )

日志确保了磁盘上内容变动时文件系统的完整性,但是却带来了少量的额外开销(日志记录)。

通过禁用日志特性可以获得少量的性能提升

在线磁盘整理(Online defragmentation )

这个特性正在开发中,会包含到之后的版本中。

通过使用延迟分配、extents 和 多块分配能够有效减少磁盘碎片,但是文件内容变动(可以需要另外的 block 来存放数据,这个 block 可能会离原来的地方比较远,从而引发一次额外的寻道)也会带来很多碎片,磁盘碎片整理可以将文件尽可能的重分配到连续的 block 中,从而减少磁盘碎片,提高访问效率。

Inode 相关特性(Inode-related features)

  1. 更大的 inodes:Ext3 支持配置 inode 大小,默认为 128 bytes,Ext4 默认为 256 bytes。增加了一些额外的域(比如纳秒级的 timestamps 或 inode 版本),剩余的空间用来保存拓展属性。这种方式可以使访问这些属性的速度更快,从而提高应用程序的性能。
  2. 当创建目录时,直接为其创建几个保留的 inode 节点,当在这个目录中创建新文件时,就可以直接使用这些保留的 inode 节点,从而提高文件创建和删除的效率。
  3. Ext3 的时间属性是秒级的,Ext4 的时间属性是纳秒级的。

磁盘预分配(Persistent preallocation )

这个特性允许应用程序预先分配磁盘空间,应用通知文件系统预先分配空间,文件系统预先分配需要的块和数据结构,直到应用程序向该空间写数据前,该空间中是没有数据的。

屏障默认开启(Barriers on by default)

这个选项改善了文件系统的完整性,但损失了一些性能。

文件系统在写入数据之前必须先将事务信息记录到日志,然后根据顺序写入,但是这种方式效率比较低。现代的驱动有很大的内部缓存并且为了得到更好的性能会进行操作重排序,所以在写入数据之前,文件系统必须先显式的指示磁盘加载所有的日志。

内核的 阻塞 I/O 子系统使用屏障来实现,即在加载日志时进行阻塞,其他数据 I/O 操作就无法再进行了。

通用块层

通用块层的主要工作是:接收上层发出的磁盘请求,并最终发出 I/O 请求。该层隐藏了底层硬件块设备的特性,为块设备提供了一个通用的抽象视图。

对于 VFS 和具体的文件系统来说,块(Block)是基本的数据传输单元,当内核访问文件的数据时,它首先从磁盘上读取一个块。但是对于磁盘来说,扇区是最小的可寻址单元,块设备无法对比它还小的单元进行寻址和操作。由于扇区是磁盘的最小可寻址单元,所以块不能比扇区还小,只能整数倍于扇区大小,即一个块对应磁盘上的一个或多个扇区。一般来说,块大小是2的整数倍,而且由于 Page Cache 层的最小单元是页(Page),所以块大小不能超过一页的长度。

大多情况下,数据的传输通过 DMA 方式。旧的磁盘控制器,仅仅支持简单的 DMA 操作:每次数据传输,只能传输磁盘上相邻的扇区,即数据在内存中也是连续的。这是因为如果传输非连续的扇区,会导致磁盘花费更多的时间在寻址操作上。而现在的磁盘控制器支持“分散/聚合”DMA操作,这种模式下,数据传输可以在多个非连续的内存区域中进行。为了利用“分散/聚合”DMA操作,块设备驱动必须能处理被称为段(segments)的数据单元。一个段就是一个内存页面或一个页面的部分,它包含磁盘上相邻扇区的数据。

通用块层是粘合所有上层和底层的部分,一个页的磁盘数据布局如下图所示:

详细的分析可以参见:Linux通用块设备层

I/O调度层

I/O调度层的功能是管理块设备的请求队列。即接收通用块层发出的I/O请求,缓存请求并试图合并相邻的请求。并根据设置好的调度算法,回调驱动层提供的请求处理函数,以处理具体的 I/O 请求。

如果简单地以内核产生请求的次序直接将请求发给块设备的话,那么块设备性能肯定让人难以接受,因为磁盘寻址是整个计算机中最慢的操作之一。为了优化寻址操作,内核不会一旦接收到 I/O 请求后,就按照请求的次序发起块 I/O 请求。为此 Linux 实现了几种 I/O 调度算法,算法基本思想就是通过合并和排序 I/O 请求队列中的请求,以此大大降低所需的磁盘寻道时间,从而提高整体I/O性能。

常见的 I/O 调度算法包括 Noop 调度算法(No Operation)、CFQ(完全公正排队I/O调度算法)、DeadLine(截止时间调度算法)、AS 预测调度算法等。

前文中计算出的 IOPS 是理论上的随机读写的最大 IOPS,在随机读写中,每次 I/O 操作的寻址和旋转延时都不能忽略不计,有了这两个时间的存在也就限制了 IOPS 的大小。现在如果我们考虑在读取一个很大的存储连续分布在磁盘的文件,因为文件的存储的分布是连续的,磁头在完成一个读 I/O 操作之后,不需要重新寻址,也不需要旋转延时,在这种情况下我们能到一个很大的 IOPS 值。这时由于不再考虑寻址和旋转延时,则性能瓶颈仅是数据传输时延,假设数据传输时延为0.4ms,那么IOPS=1000 / 0.4 = 2500 IOPS。

在许多的开源框架如 Kafka、HBase中,都通过追加写的方式来尽可能的将随机 I/O 转换为顺序 I/O,以此来降低寻址时间和旋转延时,从而最大限度的提高 IOPS。

详细的分析可以参见:Linux内核IO调度层

块设备驱动层

驱动层中的驱动程序对应具体的物理块设备。它从上层中取出I/O请求,并根据该I/O请求中指定的信息,通过向具体块设备的设备控制器发送命令的方式,来操纵设备传输数据。

系统调用

open

open 负责在内核生成与文件相对应的struct file元数据结构,并且与文件系统中该文件的struct inode进行关联,装载对应文件系统的操作回调函数,然后返回一个int fd给用户进程。后续用户对该文件的相关操作,会涉及到其相关的struct filestruct inodeinode->i_opinode->i_fopinode->i_mapping->a_ops等。

文件操作对应的偏移存储于struct file中,每个open的文件单独维护一份,同一个文件的读写操作共享同一个偏移。

其整个内核逻辑流程可以用下图来表示:

write

write的写逻辑路径有好几条,最常使用的就是利用pagecache延迟写的这条路径,所以主要分析这个。在write调用的调用、返回之间,其负责分配新的pagecache,将数据写入pagecache,同时根据系统参数,判断pagecache中的脏数据占比来确定是否要触发回写逻辑。其详细的代码分析可以参考:《Linux内核写文件过程》《Linux内核延迟写机制》

其整个内核逻辑流程可以用下图来表示:

read

read的读逻辑中包含预期readahead的逻辑,其可以通过与fadvise的配合达到文件预取的效果。这部分的代码分析可以参考:《Linux内核读文件过程》

其整个内核逻辑流程可以用下图来表示:

fsync/fdatasync

fsyncfdatasync主要逻辑流程基本相同。其通过触发对应文件的pagecache脏页回写,并且阻塞等待到回写逻辑完成,以达到同步数据的目的。

其整个内核逻辑流程可以用下图来表示:

mmap

用户调用mmap将文件映射到内存时,内核进行一系列的参数检查,然后创建对应的vma,然后给该vma绑定vma_ops。当用户访问到mmap对应的内存时,CPU会触发page fault,在page fault回调中,将申请pagecache中的匿名页,读取文件到其物理内存中,然后将pagecache中所属的物理页与用户进程的vma进行映射。

其整个内核逻辑流程可以用下图来表示,其中page fault部分比较简略,可以参考Linux Page Fault(缺页异常)

munmap

msync

msync的实际实现与其手册中的描述有很大不同,其调用时,flag=MS_SYNC等同于对mmap对应的文件调用fsyncflag=MS_ASYNC/MS_INVALIDATE其实什么都不执行。

madvise

fadvise

编程实践

File IO

java.io 包下的 FileReaderFileWriter 之类 API ,性能太差基本不考虑使用。

FileChannel

FileChannel 采用了 ByteBuffer 内存缓冲区,让我们可以非常精准的控制写盘的大小,这是普通 IO 无法实现的。我们在写入时注意控制 ByteBuffer 的大小,FileChannel 只有在一次写入 4kb 的整数倍时,才能发挥出实际的性能,主要取决你机器的磁盘结构,并且受到操作系统,文件系统,CPU 的影响,需要对使用的机器进行测试获取到最佳写入大小。

FIleChannel writeread 方法均是线程安全的,FileChannel 内部通过一把 private final Object positionLock = new Object(); 锁来控制并发。

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class TestFileChannel {
	public static void main(String[] args) throws Exception {
		FileInputStream inputStream = new FileInputStream("/home/xiehui/test.in");
		FileChannel in = inputStream.getChannel();
		FileOutputStream outputStream = new FileOutputStream("/home/xiehui/test.out");
		FileChannel out = outputStream.getChannel();
		ByteBuffer buffer = ByteBuffer.allocate(4096);
		/**
		 * long position = 1024L;
		 * 指定 position 读取 4kb 的数据
		 * fileChannel.read(buffer,position);
		 * 从当前文件指针的位置读取 4kb 的数据
		 * fileChannel.read(buffer);
		 */
		while ((in.read(buffer)) != -1) {
			buffer.flip();
			/**
			 * byte[] data = new byte[4096];
			 * long position = 1024L;
			 * 指定 position 写入 4kb 的数据
			 * fileChannel.write(ByteBuffer.wrap(data), position);
			 * 从当前文件指针的位置写入 4kb 的数据
			 * fileChannel.write(ByteBuffer.wrap(data));
			 */
			out.write(buffer);
			buffer.clear();
		}
		out.force(true);//将文件数据和元数据强制写到磁盘上
		inputStream.close();
		outputStream.close();
	}
}

MMAP

从代码层面上看,从硬盘上将文件读入内存,都要经过文件系统进行数据拷贝,并且数据拷贝操作是由文件系统和硬件驱动实现的,理论上来说,拷贝数据的效率是一样的。但是通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高,原因是:

read()是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝;

map()也是系统调用,但没有进行数据拷贝,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝

所以,理论上采用内存映射的读写效率要比传统的read/write性能高。

优缺点

  1. MMAP 使用虚拟内存,因此分配(map)的内存大小不受JVM的-Xmx参数限制,但是也是有大小限制的。MMAP 使用时必须实现指定好内存映射的大小,并且一次 map 的大小限制在 1.5G 左右,重复 map 又会带来虚拟内存的回收、重新分配的问题,对于文件不确定大小的情形不太友好。

  2. 如果当文件超出1.5G限制时,可以通过 position 参数重新 map 文件后面的内容;
  3. MMAP 使用的是虚拟内存,和 PageCache 一样是由操作系统来控制刷盘的,虽然可以通过 force() 来手动控制,但这个时间把握不好,在小内存场景下会很令人头疼。
  4. MMAP 的回收问题,当 MappedByteBuffer 不再需要时,可以手动释放占用的虚拟内存,注意 JDK 不同版本的区别。

MMAP 在实际使用中并没有表现出比 FileChannel 优异的性能。建议优先使用 FileChannel

import java.io.File;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.lang.reflect.Field;
import sun.misc.Unsafe;

public class TestMMAP {
	private static int count = 1024 * 1024 * 1024; // 1G
	public static final Unsafe UNSAFE;
	    static {
		try {
		    Field field = Unsafe.class.getDeclaredField("theUnsafe");
		    field.setAccessible(true);
		    UNSAFE = (Unsafe) field.get(null);
		} catch (Exception e) {
		    throw new RuntimeException(e);
		}
	}
	public static void main(String[] args) throws Exception {
		File f = new File("/home/xiehui/largeFile.txt");
		RandomAccessFile rf = new RandomAccessFile(f, "rw");
		// Mapping a file into memory
		MappedByteBuffer out = rf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, count);

		// Writing into Memory Mapped File
		for (int i = 0; i < count; i++) {
			out.put((byte) 'A');
		}
		System.out.println("Writing to Memory Mapped File is completed");
		// reading from memory file in Java
		for (int i = 0; i < 10; i++) {
			System.out.print((char) out.get(i));
		}
		System.out.println("");
		System.out.println("Reading from Memory Mapped File is completed");
		rf.close();
		UNSAFE.invokeCleaner(out); // >= jdk 9 采用这个方法 unmap
		//clean(out); // < jdk 9 采用这个方法
	}

	public static void clean(MappedByteBuffer mappedByteBuffer) {
		ByteBuffer buffer = mappedByteBuffer;
		if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
			return;
		invoke(invoke(viewed(buffer), "cleaner"), "clean");
	}

	/**
	 * 在MappedByteBuffer释放后再对它进行读操作的话就会引发 jvm crash,在并发情况下很容易发生
     * 正在释放时另一个线程正开始读取,于是 crash 就发生了。所以为了系统稳定性释放前一般需要检查是否还有线程在读或写
	 * @param target
	 * @param methodName
	 * @param args
	 * @return
	 */
	private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
		return AccessController.doPrivileged(new PrivilegedAction<Object>() {
			public Object run() {
				try {
					Method method = method(target, methodName, args);
					method.setAccessible(true);
					return method.invoke(target);
				} catch (Exception e) {
					throw new IllegalStateException(e);
				}
			}
		});
	}

	private static Method method(Object target, String methodName, Class<?>[] args) throws NoSuchMethodException {
		try {
			return target.getClass().getMethod(methodName, args);
		} catch (NoSuchMethodException e) {
			return target.getClass().getDeclaredMethod(methodName, args);
		}
	}

	private static ByteBuffer viewed(ByteBuffer buffer) {
		String methodName = "viewedBuffer";
		Method[] methods = buffer.getClass().getMethods();
		for (int i = 0; i < methods.length; i++) {
			if (methods[i].getName().equals("attachment")) {
				methodName = "attachment";
				break;
			}
		}
		ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
		if (viewedBuffer == null)
			return buffer;
		else
			return viewed(viewedBuffer);
       }
}

可以参考 kafka 的工具类MappedByteBuffers的实现方式。

Direct IO

Java 中常用的文件操作接口为:FileChannel,并且没有直接操作 Direct IO 的接口。这也就意味着 Java 无法绕开 PageCache 直接对存储设备进行读写,但对于使用 Java 语言来编写的数据库,消息队列等产品而言,的确存在绕开 PageCache 的需求:

PageCache 可能会好心办坏事,采用 Direct IO + 自定义内存管理机制会使得产品更加的可控,高性能。

Direct IO 的限制

在 Java 中使用 Direct IO 最终需要调用到 c 语言的 pwrite 接口,并设置 O_DIRECT flag,使用 O_DIRECT 存在不少限制:

如果想在 Java 中使用 Direct IO 可以参考项目 https://github.com/lexburner/kdio

总结

本文对文件 IO 的知识进行了全面的梳理和整理,为后续在实现存储类项目时提供参考。

参考资料

从SATA、SAS到NVMe SSD

SSD背后的秘密:SSD基本工作原理

SSD(固态硬盘)简介

NVMe SSD 性能影响因素

如何提高Linux下块设备IO的整体性能?

磁盘I/O那些事

posix_fadvise清除缓存的误解和改进措施

解析 Linux 中的 VFS 文件系统机制

Java文件IO操作之DirectIO