跳转至

09 | I/O优化(上):开发工程师必备的I/O优化知识

极客时间——Android开发高手课

本栏目内容源于Android开发高手课,外加Sample的个人练习小结。本栏目内的内容将会持续混合着博主个人的收集到的知识点。若本栏目内容令人不适,请移步原始课程。

“双十一”在天猫看到一款固态硬盘有上面的这些介绍,这些数字分别代表了什么意思?

在专栏前面卡顿和启动优化里,我也经常提到 I/O 优化。可能很多同学觉得 I/O 优化不就是不在主线程读写大文件吗,真的只有这么简单吗?那你是否考虑过,从应用程序调用 read() 方法,内核和硬件会做什么样的处理,整个流程可能会出现什么问题?今天请你带着这些疑问,我们一起来看看 I/O 优化需要的知识。

I/O 的基本知识

在工作中,我发现很多工程师对 I/O 的认识其实比较模糊,认为 I/O 就是应用程序执行 read()、write() 这样的一些操作,并不清楚这些操作背后的整个流程是怎样的。

io_1_1

我画了一张简图,你可以看到整个文件 I/O 操作由应用程序、文件系统和磁盘共同完成。首先应用程序将 I/O 命令发送给文件系统,然后文件系统会在合适的时机把 I/O 操作发给磁盘。

这就好比 CPU、内存、磁盘三个小伙伴一起完成接力跑,最终跑完的时间很大程度上取决于最慢的小伙伴。我们知道,CPU 和内存相比磁盘是高速设备,整个流程的瓶颈在于磁盘 I/O 的性能。所以很多时候,文件系统性能比磁盘性能更加重要,为了降低磁盘对应用程序的影响,文件系统需要通过各种各样的手段进行优化。那么接下来,我们首先来看文件系统。

1. 文件系统

文件系统,简单来说就是存储和组织数据的方式。比如在 iOS 10.3 系统以后,苹果使用 APFS(Apple File System)替代之前旧的文件系统 HFS+。对于 Android 来说,现在普遍使用的是 Linux 常用的 ext4 文件系统。

关于文件系统还需要多说两句,华为在 EMUI 5.0 以后就使用 F2FS 取代 ext4,Google 也在最新的旗舰手机 Pixel 3 使用了 F2FS 文件系统。Flash-Friendly File System 是三星是专门为 NAND 闪存芯片开发的文件系统,也做了大量针对闪存的优化。根据华为的测试数据,F2FS 文件系统在小文件的随机读写方面比 ext4 更快,例如随机写可以优化 60%,不足之处在于可靠性方面出现过一些问题。我想说的是,随着 Google、华为的投入和规模化使用,F2FS 系统应该是未来 Android 的主流文件系统。

还是回到文件系统的 I/O。应用程序调用 read() 方法,系统会通过中断从用户空间进入内核处理流程,然后经过 VFS(Virtual File System,虚拟文件系统)、具体文件系统、页缓存 Page Cache。下面是 Linux 一个通用的 I/O 架构模型。

io_1_2

  • 虚拟文件系统(VFS)。它主要用于实现屏蔽具体的文件系统,为应用程序的操作提供一个统一的接口。这样保证就算厂商把文件系统从 ext4 切换到 F2FS,应用程序也不用做任何修改。
  • 文件系统(File System)。ext4、F2FS 都是具体文件系统实现,文件元数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这些都是设计一个文件系统必须要考虑的。每个文件系统都有适合自己的应用场景,我们不能说 F2FS 就一定比 ext4 要好。F2FS 在连续读取大文件上并没有优势,而且会占用更大的空间。只是对一般应用程序来说,随机 I/O 会更加频繁,特别是在启动的场景。你可以在 /proc/filesystems 看到系统可以识别的所有文件系统的列表。
  • 页缓存(Page Cache)。在启动优化中我已经讲过 Page Cache 这个概念了,在读文件的时候会,先看它是不是已经在 Page Cache 中,如果命中就不会去读取磁盘。在 Linux 2.4.10 之前还有一个单独的 Buffer Cache,后来它也合并到 Page Cache 中的 Buffer Page 了。

具体来说,Page Cache 就像是我们经常使用的数据缓存,是文件系统对数据的缓存,目的是提升内存命中率。Buffer Cache 就像我们经常使用的 BufferInputStream,是磁盘对数据的缓存,目的是合并部分文件系统的 I/O 请求、降低磁盘 I/O 的次数。需要注意的是,它们既会用在读请求中,也会用到写请求中

通过 /proc/meminfo 文件可以查看缓存的内存占用情况,当手机内存不足的时候,系统会回收它们的内存,这样整体 I/O 的性能就会有所降低。

MemTotal:    2866492 kB
MemFree:      72192 kB
Buffers:      62708 kB      // Buffer Cache
Cached:      652904 kB      // Page Cache

2. 磁盘

磁盘指的是系统的存储设备,就像小时候我们常听的 CD 或者电脑使用的机械硬盘,当然还有现在比较流行的 SSD 固态硬盘。

正如我上面所说,如果发现应用程序要 read() 的数据没有在页缓存中,这时候就需要真正向磁盘发起 I/O 请求。这个过程要先经过内核的通用块层、I/O 调度层、设备驱动层,最后才会交给具体的硬件设备处理。

io_1_3

  • 通用块层。系统中能够随机访问固定大小数据块(block)的设备称为块设备,CD、硬盘和 SSD 这些都属于块设备。通用块层主要作用是接收上层发出的磁盘请求,并最终发出 I/O 请求。它跟 VFS 的作用类似,让上层不需要关心底层硬件设备的具体实现。
  • I/O 调度层。磁盘 I/O 那么慢,为了降低真正的磁盘 I/O,我们不能接收到磁盘请求就立刻交给驱动层处理。所以我们增加了 I/O 调度层,它会根据设置的调度算法对请求合并和排序。这里比较关键的参数有两个,一个是队列长度,一个是具体的调度算法。我们可以通过下面的文件可以查看对应块设备的队列长度和使用的调度算法。
    /sys/block/[disk]/queue/nr_requests      // 队列长度,一般是 128/sys/block/[disk]/queue/scheduler        // 调度算法
    
  • 块设备驱动层。块设备驱动层根据具体的物理设备,选择对应的驱动程序通过操控硬件设备完成最终的 I/O 请求。例如光盘是靠激光在表面烧录存储、闪存是靠电子擦写存储数据。

Android I/O

前面讲了 Linux I/O 相关的一些知识,现在我们再来讲讲 Android I/O 相关的一些知识。

1. Android 闪存

我们先来简单讲讲手机使用的存储设备,手机使用闪存作为存储设备,也就是我们常说的 ROM。

虑到体积和功耗,我们肯定不能直接把 PC 的 SSD 方案用在手机上面。Android 手机前几年通常使用 eMMC 标准,近年来通常会采用性能更好的 UFS 2.0/2.1 标准,之前沸沸扬扬的某厂商“闪存门”事件就是因为使用 eMMC 闪存替换了宣传中的 UFS 闪存。而苹果依然坚持独立自主的道路,在 2015 年就在 iPhone 6s 上就引入了 MacBook 上备受好评的 NVMe 协议。

最近几年移动硬件的发展非常妖孽,手机存储也朝着体积更小、功耗更低、速度更快、容量更大的方向狂奔。iPhone XS 的容量已经达到 512GB,连续读取速度可以超过 1GB/s,已经比很多的 SSD 固态硬盘还要快,同时也大大缩小了和内存的速度差距。不过这些都是厂商提供的一些测试数据,特别是对于随机读写的性能相比内存还是差了很多。

io_1_4

上面的数字好像有点抽象,直白地说闪存的性能会影响我们打开微信、游戏加载以及连续自拍的速度。当然闪存性能不仅仅由硬件决定,它跟采用的标准、文件系统的实现也有很大的关系。

2. 两个疑问

看到这里可能有些同学会问,知道文件读写的流程、文件系统和磁盘这些基础知识,对我们实际开发有什么作用呢?下面我举两个简单的例子,可能你平时也思考过,不过如果不熟悉 I/O 的内部机制,你肯定是一知半解。

疑问一:文件为什么会损坏?

先说两个客观数据,微信聊天记录使用的 SQLite 数据库大概有几万分之一的损坏率,系统 SharedPreference 如果频繁跨进程读写也会有万分之一的损坏率。

在回答文件为什么会损坏前,首先需要先明确一下什么是文件损坏。一个文件的格式或者内容,如果没有按照应用程序写入时的结果都属于文件损坏。它不只是文件格式错误,文件内容丢失可能才是最常出现的,SharedPreference 跨进程读写就非常容易出现数据丢失的情况。

再来探讨文件为什么会损坏,我们可以从应用程序、文件系统和磁盘三个角度来审视这个问题。

  • 应用程序。大部分的 I/O 方法都不是原子操作,文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符 fd 来操作文件,它们都有可能导致数据被覆盖或者删除。事实上,大部分的文件损坏都是因为应用程序代码设计考虑不当导致的,并不是文件系统或者磁盘的问题。
  • 文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏,不过文件系统也做了很多的保护措施。例如 system 分区保证只读不可写,增加异常检查和恢复机制,ext4 的 fsck、f2fs 的 fsck.f2fs 和 checkpoint 机制等。
    在文件系统这一层,更多是因为断电而导致的写入丢失。为了提升 I/O 性能,文件系统把数据写入到 Page Cache 中,然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过 fsync、msync 这些接口强制写入磁盘,在下一其我会详细介绍直接 I/O 和缓存 I/O。
  • 磁盘。手机上使用的闪存是电子式的存储设备,所以在资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用 ECC、多级编码等多种方式增加数据的可靠性,一般来说出现这种情况的可能性也比较小。

闪存寿命也可能会导致数据错误,由于闪存的内部结构和特征,导致它写过的地址必须擦除才能再次写入,而每个块擦除又有次数限制,次数限制是根据采用的存储颗粒,从十万次到几千都有(SLC>MLC>TLC)。

下图是闪存(Flash Memory)的结构图,其中比较重要的是 FTL(Flash Translation Layer),它负责物理地址的分配和管理。它需要考虑到每个块的擦除寿命,将擦除次数均衡到所有块上去。当某个块空间不够的时候,它还要通过垃圾回收算法将数据迁移。FTL 决定了闪存的使用寿命、性能和可靠性,是闪存技术中最为重要的核心技术之一。

io_1_5

对于手机来说,假设我们的存储大小是 128GB,即使闪存的最大擦除次数只有 1000 次,那也可以写入 128TB,但一般来说比较难达到。

疑问二:I/O 有时候为什么会突然很慢?

手机厂商的数据通常都是出厂数据,我们在使用 Android 手机的时候也会发现,刚买的时候“如丝般顺滑”的手机,在使用一年之后就会变得卡顿无比。

这是为什么呢?在一些低端机上面,我发现大量跟 I/O 相关的卡顿。I/O 有时候为什么会突然变慢,可能有下面几个原因。

  • 内存不足。当手机内存不足的时候,系统会回收 Page Cache 和 Buffer Cache 的内存,大部分的写操作会直接落盘,导致性能低下。
  • 写入放大。上面我说到闪存重复写入需要先进行擦除操作,但这个擦除操作的基本单元是 block 块,一个 page 页的写入操作将会引起整个块数据的迁移,这就是典型的写入放大现象。低端机或者使用比较久的设备,由于磁盘碎片多、剩余空间少,非常容易出现写入放大的现象。具体来说,闪存读操作最快,在 20us 左右。写操作慢于读操作,在 200us 左右。而擦除操作非常耗时,在 1ms 左右的数量级。当出现写入放大时,因为涉及移动数据,这个时间会更长。
  • 由于低端机的 CPU 和闪存的性能相对也较差,在高负载的情况下容易出现瓶颈。例如 eMMC 闪存不支持读写并发,当出现写入放大现象时,读操作也会受影响。

系统为了缓解磁盘碎片问题,可以引入 fstrim/TRIM 机制,在锁屏、充电等一些时机会触发磁盘碎片整理。

I/O 的性能评估

正如下图你所看到的,整个 I/O 的流程涉及的链路非常长。我们在应用程序中通过打点,发现一个文件读取需要 300ms。但是下面每一层可能都有自己的策略和调度算法,因此很难真正的得到每一层的耗时。

io_1_6

在前面的启动优化内容中,我讲过 Facebook 和支付宝采用编译单独 ROM 的方法来评估 I/O 性能。这是一个比较复杂但是有效的做法,我们可以通过定制源码,选择打开感兴趣的日志来追踪 I/O 的性能。

1. I/O 性能指标

I/O 性能评估中最为核心的指标是吞吐量和 IOPS。今天文章开头所说的,“连续读取不超过 550MB/s,连续写入不超过 520MB/s”,就指的是 I/O 吞吐量。

还有一个比较重要的指标是 IOPS,它指的是每秒可以读写的次数。对于随机读写频繁的应用,例如大量的小文件存储,IOPS 是关键的衡量指标。

2. I/O测量

如果不采用定制源码的方式,还有哪些方法可以用来测量 I/O 的性能呢?

第一种方法:使用 proc。

总的来说,I/O 性能会跟很多因素有关,是读还是写、是否是连续、I/O 大小等。另外一个对 I/O 性能影响比较大的因素是负载,I/O 性能会随着负载的增加而降低,我们可以通过 I/O 的等待时间和次数来衡量。

proc/self/sched:
  se.statistics.iowait_count:IO 等待的次数
  se.statistics.iowait_sum:  IO 等待的时间(单位都是ms)

如果是 root 的机器,我们可以开启内核的 I/O 监控,将所有 block 读写 dump 到日志文件中,这样可以通过 dmesg 命令来查看。

echo 1 > /proc/sys/vm/block_dump
dmesg -c grep pid

.sample.io.test(7540): READ block 29262592 on dm-1 (256 sectors)
.sample.io.test(7540): READ block 29262848 on dm-1 (256 sectors)

第二种方法:使用 strace。

Linux 提供了 iostat、iotop 等一些相关的命令,不过大部分 Anroid 设备都不支持。我们可以通过 strace 来跟踪 I/O 相关的系统调用次数和耗时。

strace -ttT -f -p [pid]

read(53, "*****************"\.\.\., 1024) = 1024       <0.000447>
read(53, "*****************"\.\.\., 1024) = 1024       <0.000084>
read(53, "*****************"\.\.\., 1024) = 1024       <0.000059>

通过上面的日志,你可以看到应用程序在读取文件操作符为 53 的文件,每次读取 1024 个字节。第一次读取花了 447us,后面两次都使用了 100us 不到。这跟启动优化提到的“数据重排”是一个原因,文件系统每次读取以 block 为单位,而 block 的大小一般是 4KB,后面两次的读取是从页缓存得到。

我们也可以通过 strace 统计一段时间内所有系统调用的耗时概况。不过 strace 本身也会消耗不少资源,对执行时间也会产生影响。

strace -c -f -p [pid]

% time     seconds  usecs/call     calls    errors  syscall
------ ----------- ----------- --------- --------- ----------------
 97.56    0.041002          21      1987             read
  1.44    0.000605          55        11             write

从上面的信息你可以看到,读占了 97.56% 的时间,一共调用了 1987 次,耗时 0.04s,平均每次系统调用 21us。同样的道理,我们也可以计算应用程序某个任务 I/O 耗时的百分比。假设一个任务执行了 10s,I/O 花了 9s,那么 I/O 耗时百分比就是 90%。这种情况下,I/O 就是我们任务很大的瓶颈,需要去做进一步的优化。

第三种方法:使用 vmstat。

vmstat 的各个字段说明可以参考《vmstat 监视内存使用情况》,其中 Memory 中的 buff 和 cache,I/O 中的 bi 和 bo,System 中的 cs,以及 CPU 中的 sy 和 wa,这些字段的数值都与 I/O 行为有关。

io_1_7

我们可以配合dd 命令来配合测试,观察 vmstat 的输出数据变化。不过需要注意的是 Android 里面的 dd 命令似乎并不支持 conv 和 flag 参数

总结

在性能优化的过程中,我们关注最多的是 CPU 和内存,I/O 也是性能优化中比较重要的一部分。

今天我们学习 I/O 处理的整个流程,它包括应用程序、文件系统和磁盘三个部分。不过 I/O 这个话题真的很大,在课后需要花更多时间学习课后练习中的一些参考资料。

LPDDR5、UFS 3.0 很快就要在 2019 年面世,有些同学会想,随着硬件越来越牛,我们根本就不需要去做优化了。但是一方面考虑到成本的问题,在嵌入式、IoT 等一些场景的设备硬件不会太好;另一方面,我们对应用体验的要求也越来越高,沉浸体验(VR)、人工智能(AI)等新功能对硬件的要求也越来越高。所以,应用优化是永恒的,只是在不同的场景下有不同的要求。

课后作业

关于I/O的参考资料。

  1. 磁盘 I/O 那些事
    主要介绍文件系统。“ 写的时候可以采用追加写,来尽可能的将随机I/O变成顺序I/O,以此来降低寻址时间和旋转延时,从而最大限度的提高IOPS”
  2. Linux 内核的文件 Cache 管理机制介绍
    Buffer Cache、Page Cache
  3. The Linux Kernel/Storage
  4. 选eMMC、UFS还是NVMe? 手机ROM存储传输协议解析
  5. 聊聊 Linux IO
    从几个实际问题出发,推荐
  6. 采用 NAND Flash 设计存储设备的挑战在哪里? 这里介绍NAND Flash的一些原理

“实践出真知”,你也可以尝试使用 strace 和 block_dump 来观察自己应用的 I/O 情况,不过有些实验会要求有 root 的机器。

评论