Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)

目录

I/O 的基本知识

1. 文件系统

2. 磁盘

Android I/O

1. Android 闪存

2. 两个疑问

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

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

不同的场景使用不同的I/O方式

I/O 的三种方式

1. 标准 I/O

2. 直接 I/O

3. mmap

多线程阻塞 I/O 和 NIO

1. 多线程阻塞 I/O

2. NIO

小文件系统

常见不良现象

1. 主线程 I/O

2. 读写 Buffer 过小

3. 重复读

4. 资源泄漏

IO优化的一些思考


I/O 的基本知识

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

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第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 的主流文件系统。

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

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第2张图片

  • 虚拟文件系统(VFS)。它主要用于实现屏蔽具体的文件系统,为应用程序的操作提供一个统一的接口。这样保证就算厂商把文件系统从 ext4 切换到 F2FS,应用程序也不用做任何修改。
  • 文件系统(File System)。ext4、F2FS 都是具体文件系统实现,文件元数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这些都是设计一个文件系统必须要考虑的。每个文件系统都有适合自己的应用场景,我们不能说 F2FS 就一定比 ext4 要好。F2FS 在连续读取大文件上并没有优势,而且会占用更大的空间。只是对一般应用程序来说,随机 I/O 会更加频繁,特别是在启动的场景。你可以在 /proc/filesystems 看到系统可以识别的所有文件系统的列表。
  • 页缓存(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 调度层、设备驱动层,最后才会交给具体的硬件设备处理。

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第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

1. Android 闪存

手机使用闪存作为存储设备,也就是我们常说的 ROM。考虑到体积和功耗,我们肯定不能直接把 PC 的 SSD 方案用在手机上面。Android 手机前几年通常使用 eMMC 标准,近年来通常会采用性能更好的 UFS 2.0/2.1 标准,而苹果依然坚持独立自主的道路,在 2015 年就在 iPhone 6s 上就引入了 MacBook 上备受好评的 NVMe 协议。

2. 两个疑问

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

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

明确了文件损坏的定义后,我们再思考为什么会损坏,可以从应用程序、文件系统和磁盘三个角度来审视这个问题。

  • 应用程序。大部分的 I/O 方法都不是原子操作,文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符 fd 来操作文件,它们都有可能导致数据被覆盖或者删除。事实上,大部分的文件损坏都是因为应用程序代码设计考虑不当导致的,并不是文件系统或者磁盘的问题。
  • 文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏,不过文件系统也做了很多的保护措施。例如 system 分区保证只读不可写,增加异常检查和恢复机制,ext4 的 fsck、f2fs 的 fsck.f2fs 和 checkpoint 机制等。

在文件系统这一层,更多是因为断电而导致的写入丢失。为了提升 I/O 性能,文件系统把数据写入到 Page Cache 中,然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过 fsync、msync 这些接口强制写入磁盘。

  • 磁盘。手机上使用的闪存是电子式的存储设备,所以在资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用 ECC、多级编码等多种方式增加数据的可靠性,一般来说出现这种情况的可能性也比较小。

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

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

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第4张图片

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

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

I/O 有时候为什么会突然变慢,可能有下面几个原因。

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

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

不同的场景使用不同的I/O方式

I/O 的三种方式

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第5张图片

从上图中可以看到标准 I/O、mmap、直接 I/O 这三种 I/O 方式在流程上的差异,接下来我详细讲一下不同 I/O 方式的关键点以及在实际应用中需要注意的地方。

1. 标准 I/O

我们应用程序平时用到 read/write 操作都属于标准 I/O,也就是缓存 I/O(Buffered I/O)。它的关键特性有:

  • 对于读操作来说,当应用程序读取某块数据的时候,如果这块数据已经存放在页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。
  • 对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用写操作的机制。默认系统采用的是延迟写机制,应用程序只需要将数据写到页缓存中去就可以了,完全不需要等数据全部被写回到磁盘,系统会负责定期地将放在页缓存中的数据刷到磁盘上。

从中可以看出来,缓存 I/O 可以很大程度减少真正读写磁盘的次数,从而提升性能,但延迟写机制可能会导致数据丢失。在实际应用中,如果某些数据我们觉得非常重要,是完全不允许有丢失风险的,这个时候我们应该采用同步写机制。在应用程序中使用 sync、fsync、msync 等系统调用时,内核都会立刻将相应的数据写回到磁盘。

2. 直接 I/O

很多数据库自己已经做了数据和索引的缓存管理,对页缓存的依赖反而没那么强烈。它们希望可以绕开页缓存机制,这样可以减少一次数据拷贝,这些数据也不会污染页缓存。

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第6张图片

从图中你可以看到,直接 I/O 访问文件方式减少了一次数据拷贝和一些系统调用的耗时,很大程度降低了 CPU 的使用率以及内存的占用。不过,直接 I/O 有时候也会对性能产生负面影响。

  • 对于读操作来说,读数据操作会造成磁盘的同步读,导致进程需要较长的时间才能执行完。
  • 对于写操作来说,使用直接 I/O 也需要同步执行,也会导致应用程序等待。

Android 并没有提供 Java 的 DirectByteBuffer,直接 I/O 需要在 open() 文件的时候需要指定 O_DIRECT 参数,更多的资料可以参考《Linux 中直接 I/O 机制的介绍》。在使用直接 I/O 之前,一定要对应用程序有一个很清醒的认识,只有在确定缓冲 I/O 的开销非常巨大的情况以后,才可以考虑使用直接 I/O。

3. mmap

Android 系统启动加载 Dex 的时候,不会把整个文件一次性读到内存中,而是采用 mmap 的方式。它是通过把文件映射到进程的地址空间,而网上很多文章都说 mmap 完全绕开了页缓存机制,其实这并不正确。我们最终映射的物理内存依然在页缓存中,它可以带来的好处有:

  • 减少系统调用。我们只需要一次 mmap() 系统调用,后续所有的调用像操作内存一样,而不会出现大量的 read/write 系统调用。
  • 减少数据拷贝。普通的 read() 调用,数据需要经过两次拷贝;而 mmap 只需要从磁盘拷贝一次就可以了,并且由于做过内存映射,也不需要再拷贝回用户空间。
  • 可靠性高。mmap 把数据写入页缓存后,跟缓存 I/O 的延迟写机制一样,可以依靠内核线程定期写回磁盘。但是需要提的是,mmap 在内核崩溃、突然断电的情况下也一样有可能引起内容丢失,当然我们也可以使用 msync 来强制同步写。

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第7张图片

从上图看来,我们使用 mmap 仅仅只需要一次数据拷贝。看起来 mmap 的确可以秒杀普通的文件读写,那我们为什么不全都使用 mmap 呢?

事实上,它也存在一些缺点:

  • 虚拟内存增大。mmap 会导致虚拟内存增大,我们的 APK、Dex、so 都是通过 mmap 读取。而目前大部分的应用还没支持 64 位,除去内核使用的地址空间,一般我们可以使用的虚拟内存空间只有 3GB 左右。如果 mmap 一个 1GB 的文件,应用很容易会出现虚拟内存不足所导致的 OOM。
  • 磁盘延迟。mmap 通过缺页中断向磁盘发起真正的磁盘 I/O,所以如果我们当前的问题是在于磁盘 I/O 的高延迟,那么用 mmap() 消除小小的系统调用开销是杯水车薪的。

mmap 比较适合于对同一块区域频繁读写的情况,推荐也使用线程来操作。用户日志、数据上报都满足这种场景,另外需要跨进程同步的时候,mmap 也是一个不错的选择。Android 跨进程通信有自己独有的 Binder 机制,它内部也是使用 mmap 实现。

多线程阻塞 I/O 和 NIO

上面提到,由于写入放大的现象,特别是在低端机中,有时候 I/O 操作可能会非常慢。所以 I/O 操作应该尽量放到子线程中,不过很多同学可能都有这样一个疑问:如果同时读 10 个文件,我们应该用单线程还是 10 个线程并发读?

1. 多线程阻塞 I/O

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第8张图片

通过实验发现,多线程在 I/O 操作上收益并没有那么大,总时间从 3.6 秒减少到 1.1 秒。因为 CPU 的性能相比磁盘来说就是火箭,I/O 操作主要瓶颈在于磁盘带宽,30 条线程并不会有 30 倍的收益。而线程数太多甚至会导致耗时更长,表格中我们就发现 30 个线程所需要的时间比 10 个线程更长。但是在 CPU 繁忙的时候,更多的线程会让我们更有机会抢到时间片,这个时候多线程会比单线程有更大的收益。

总的来说文件读写受到 I/O 性能瓶颈的影响,在到达一定速度后整体性能就会受到明显的影响,过多的线程反而会导致应用整体性能的明显下降。

可以再来看看这两个案例。

案例一:
CPU: 0.3% user, 3.1% kernel, 60.2% iowait, 36% idle\.\.\.
案例二:
CPU: 60.3% user, 20.1% kernel, 14.2% iowait, 4.6% idle\.\.\.

案例一:当系统空闲(36% idle)时,如果没有其他线程需要调度,这个时候才会出现 I/O 等待(60.2% iowait)。

案例二:如果我们的系统繁忙起来,这个时候 CPU 不会“无所事事”,它会去看有没有其他线程需要调度,这个时候 I/O 等待会降低(14.2% iowait)。但是太多的线程阻塞会导致线程切换频繁,增大系统上下文切换的开销。

简单来说,iowait 高,I/O 一定有问题。但 iowait 低,I/O 不一定没有问题。这个时候我们还要看 CPU 的 idle 比例。从下图我们可以看到同步 I/O 的工作模式:

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第9张图片

对应用程序来说,磁盘 I/O 阻塞线程的总时间会更加合理,它并不关心 CPU 是否真的在等待,还是去执行其他工作了。在实际开发工作中,大部分时候都是读一些比较小的文件,使用单独的 I/O 线程还是专门新开一个线程,其实差别不大。

2. NIO

多线程阻塞式 I/O 会增加系统开销,那我们是否可以使用异步 I/O 呢?当我们线程遇到 I/O 操作的时候,不再以阻塞的方式等待 I/O 操作的完成,而是将 I/O 请求发送给系统后,继续往下执行。这个过程你可以参考下面的图。

Android性能优化--IO 优化( IO基本知识:应用程序、文件系统和磁盘,三种IO方式及适用场景,多线程阻塞IO和NIO)_第10张图片

非阻塞的 NIO 将 I/O 以事件的方式通知,的确可以减少线程切换的开销。Chrome 网络库是一个使用 NIO 提升性能很好的例子,特别是在系统非常繁忙的时候。但是 NIO 的缺点也非常明显,应用程序的实现会变得更复杂,有的时候异步改造并不容易。

其实使用 NIO 的最大作用不是减少读取文件的耗时,而是最大化提升应用整体的 CPU 利用率。在 CPU 繁忙的时候,我们可以将线程等待磁盘 I/O 的时间来做部分 CPU 操作。非常推荐 Square 的Okio,它支持同步和异步 I/O,也做了比较多优化,你可以尝试使用。

小文件系统

对于文件系统来说,目录查找的性能是非常重要的。比如微信朋友圈图片可能有几万张,如果我们每张图片都是一个单独的文件,那目录下就会有几万个小文件,你想想这对 I/O 的性能会造成什么影响?

文件的读取需要先找到存储的位置,在文件系统上面我们使用 inode 来存储目录。读取一个文件的耗时可以拆分成下面两个部分。

文件读取的时间 = 找到文件的 inode 的时间 + 根据 inode 读取文件数据的时间

如果我们需要频繁读写几万个小文件,查找 inode 的时间会变得非常可观。这个时间跟文件系统的实现有关。

  • 对于 FAT32 系统来说,FAT32 系统是历史久远的产物,在一些低端机的外置 SD 卡会使用这个系统。当目录文件数比较多的时候,需要线性去查找,一个 exist() 都非常容易出现 ANR。
  • 对于 ext4 系统来说,ext4 系统使用目录 Hash 索引的方式查找,目录查找时间会大大缩短。但是如果需要频繁操作大量的小文件,查找和打开文件的耗时也不能忽视。

大量的小文件合并为大文件后,我们还可以将能连续访问的小文件合并存储,将原本小文件间的随机访问变为了顺序访问,可以大大提高性能。同时合并存储能够有效减少小文件存储时所产生的磁盘碎片问题,提高磁盘的利用率。

业界中 Google 的 GFS、淘宝开源的TFS、Facebook 的 Haystack 都是专门为海量小文件的存储和检索设计的文件系统。微信也开发了一套叫 SFS 的小文件管理系统,主要用在朋友圈图片的管理,用于解决当时外置 SD 卡使用 FAT32 的性能问题。

常见不良现象

1. 主线程 I/O

我们知道,有时候 I/O 的写入会突然放大,即使是几百 KB 的数据,还是尽量不要在主线程上操作。在线上也会经常发现一些 I/O 操作明明数据量不大,但是最后还是 ANR 了。

2. 读写 Buffer 过小

我们知道,对于文件系统是以 block 为单位读写,对于磁盘是以 page 为单位读写,看起来即使我们在应用程序上面使用很小的 Buffer,在底层应该差别不大。那是不是这样呢?

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

虽然后面两次系统调用的时间的确会少一些,但是也会有一定的耗时。如果我们的 Buffer 太小,会导致多次无用的系统调用和内存拷贝,导致 read/write 的次数增多,从而影响了性能。那应该选用多大的 Buffer 呢?Buffer 的大小一般推荐使用 4KB 以上。

3. 重复读

如果频繁地读取某个文件,并且这个文件一直没有被写入更新,我们可以通过缓存来提升性能。加一层内存 cache 是最直接有效的办法,比较典型的场景是配置文件等一些数据模块的加载,如果没有内存 cache,那么性能影响就比较大了。

伪代码:

public String readConfig() {
  if (Cache != null) {
     return cache; 
  }
  cache = read("configFile");
  return cache;
}

4. 资源泄漏

资源泄漏是指打开资源包括文件、Cursor 等没有及时 close,从而引起泄露。这属于非常低级的编码错误,但却非常普遍存在。

IO优化的一些思考

  • 对大文件使用 mmap 或者 NIO 方式。MappedByteBuffer就是 Java NIO 中的 mmap 封装,对于大文件的频繁读写会有比较大的优化。
  • 安装包不压缩。对启动过程需要的文件,我们可以指定在安装包中不压缩,这样会加快启动速度,但带来的影响是安装包体积增大。事实上 Google Play 非常希望我们不要去压缩 library、resource、resource.arsc 这些文件,这样对启动的内存和速度都会有很大帮助。而且不压缩文件带来只是安装包体积的增大,对于用户来说,Download size 并没有增大。
  • Buffer 复用。我们可以利用Okio开源库,它内部的 ByteString 和 Buffer 通过重用等技巧,很大程度上减少 CPU 和内存的消耗。
  • 存储结构和算法的优化。是否可以通过算法或者数据结构的优化,让我们可以尽量的少 I/O 甚至完全没有 I/O。比如一些配置文件从启动完全解析,改成读取时才解析对应的项;替换掉 XML、JSON 这些格式比较冗余、性能比较差的数据结构。

参考:

磁盘I/O那些事

选eMMC、UFS还是NVMe? 手机ROM存储传输协议解析

聊聊 Linux IO

采用NAND Flash设计存储设备的挑战在哪里?

《Linux 中直接 I/O 机制的介绍》

你可能感兴趣的:(Android性能优化,Android,安卓,性能优化,IO优化,安卓进阶高级开发)