I/O基本知识
整个I/O操作由应用程序、文件系统和磁盘共同完成,应用程序将I/O命令发送到文件系统,文件系统在合适的时机把I/O操作发送给磁盘。整个流程的瓶颈在于磁盘I/O,但有时文件系统为了降低磁盘对应用程序的影响,会采用各种方式进行优化,因为文件系统的性能变得十分重要。
1、文件系统
文件系统,简单来说既是存储和组织数据的方式。对于Android来说普遍采用的文件系统是Linux的常用的ext4文件系统,华为在EMUI5.0以后就使用F2FS取代ext4,谷歌也在最新的旗舰手机Pixel3使用了F2FS文件系统。F2FS文件系统在小文件随机读写方面比ext4更快,不足之处在于可靠性方面出现过一点问题,随着谷歌和华为的投入和使用,未来F2FS将成为Android的主流文件系统。
应用程序调用read()方法,系统会通过中断从用户空间进入内核处理流程,然后经过VFS(Virtual File System,虚拟文件系统)、具体文件系统、页缓存Page Cache。下面是Linux一个通用I/O架构模型
- 虚拟文件系统(VFS)。它主要用于实现屏蔽具体的文件系统,为应用程序的操作提供一个统一的接口。
- 文件系统(File System)。文件系统需要考虑具体文件元数据如何组织、目录和索引结构如何设计,怎么分配和清理数据。
- 页缓存(Page Cache)。Page Cache就像我们经常使用的数据缓存,是文件系统对数据的缓存,目的是提升内存命中率。通过/proc/meminfo文件可以查看缓存的内存占用情况,当手机内存不足时,系统会回收他们的内存,这样整体I/O的性能就会有所降低。
2、磁盘
磁盘指的就是系统的存储设备。当应用程序要read()的数据没有在页缓存中,这时候就需要真正想磁盘发起I/O请求,这个过程要先经过内核的通用模块层、I/O调度层、设备驱动层,最后才会交给具体的硬件设备处理。
- 通用块层。系统中能够随机访问固定大小数据块(block)的设备成为设备,CD、磁盘和SSD这些都输设备。通用块层主要作用是接收上层发出的磁盘请求,并最终发出I/O,他的作用类似VFS,提供通用接口,屏蔽下层实现。
- I/O调度层。为了降低真正的磁盘I/O,我们不能接收到I/O操作后就立即执行,而是交由I/O调度层,根据不同的算法,对请求进行合并和排序。
- 块设备驱动层。 块设备驱动层根据具体的物理设备,选择对应的驱动程序操控硬件设备完成最终的I/O请求。
Android I/O
闪存是手机常用的存储设备,Android手机前几年通常使用eMMC标准,近年来通常会使用性能更好的UFS2.0/2.1标准。
1、文件为什么会损坏:
一个文件的格式或者内容,如果没有按照应用程序写入时的结果都属于文件损坏。
- 应用程序。大部分I/O方法都是非原子操作,文件的跨进程或多线程写入、使用一个已经关闭的文件描述符fd来操作文件,它们都有可能导致文件内容修改和删除。
- 文件系统。文件系统把数据写入到页缓存,在合适的时机写入到磁盘,内核崩溃和断电就有可能导致写入到磁盘异常和没有写入。
- 磁盘。磁盘属于电子设备,内容在传输过程中存在错误的可能,同时闪存的寿命也可能导致文件错误。
2、I/O为什么会突然变慢:
- 内存不足,当手机内存不足时,系统会回收Page Cache和Buffer Cache的内存,大部分的写操作会直接落盘,导致性能低下。
- 写入放大,闪存重复写入需要先擦除操作,这个擦除操作是以block块为基本单元的,一个page的写入操作会引起整个块数据迁移。低端机或者使用很久的设备,由于磁盘碎片多、剩余空间少,非常容易出现写入放大的现象。
- 低端机的CPU和闪存性能相对较差,在高负载的情况下容易出现瓶颈。
系统为了缓解磁盘碎片问题,可以引入fstrim/TRIM机制,在锁屏、充电等一些时机会触发磁盘碎片整理。
I/O的性能评估
1、I/O性能指标
最核心的指标为吞吐量和IOPS。吞吐量是指单位时间的数据读取或写入峰值,IOPS指的是每秒可以读写的次数。
2、I/O测量
- 使用proc
- 使用strace,可以跟踪I/O相关的系统调用次数和耗时
- 使用vmstat
I/O的三种方式
1、标准I/O
应用程序平时用到的read/write操作都属于标准I/O,也就是缓存I/O(Buffered I/O)。其特点为:
- 对于读操作来说,当应用程序读取到某块数据时,如果这块数据已经存放在页缓存中,那么这块数据之间返回,不需要实际的物理操作。
-
对于写操作来书,应用程序也会将数据写到页缓存中去,数据是否被立即写到磁盘上取决于应用程序所采用的写操作机制。默认系统采用延迟写机制,应用程序只需写入页缓存,系统负责定期写入到磁盘。
Page Cache中被修改的内存称为“脏页”,内核通过flush线程定期将数据写入磁盘,具体写入的条件我们可以通过/proc/sys/vm文件或sysctl -a | grep vm命令得到。如果某些数据非常重要,不允许出现丢失的风险,这个时候可以采用同步写机制,在应用程序中使用sync、fsync、msync等系统调用时,内核都会将相应的数据写回到磁盘。
2、直接I/O
直接I/O访问文件方式减少了一次数据拷贝和一些系统调用时间,很大程度上降低了CPU的使用率以及内存的占用。但是,直接I/O有时候也会对性能产生不良影响:
- 对于读操作来说,读数据操作会造成磁盘同步读,导致进程需要较长时间才能执行完;
- 对于写操作来说,使用直接I/O也需要同步执行,也会导致程序等待。
3、mmap
它是通过吧文件映射到进程的地址空间,带来的好处有:
- 减少系统调用。只需一次mmap()系统调用,后续所有调用像操作内存一样,而不会出现大量的read/write系统调用。
- 减少数据拷贝。普通的read()操作需要经过两次拷贝,而mmap只需要从磁盘拷贝一次,并且由于做过内存映射,也不需要再拷贝回用户空间。
-
可靠性高。mmap把数据写入缓存,跟缓存I/O的延迟写机制是一样的,可以依靠内核线程定期写回磁盘。但是在内核崩溃或断电的时候,同样可能引起内容丢失,也可以使用msync来强制同步写。
mmap同样也存在缺点:
- 虚拟内存增大。mmap会导致虚拟内存增大,mmap一个大文件,有可能出现虚拟内存不足而导致OOM。
-
磁盘延迟。mmap通过缺页中断向磁盘发起真正的磁盘I/O,所以如果当前的问题在于磁盘I/O的高延迟,那么用mmap()消除小小的系统调用开销是杯水车薪的。类重排技术,就是将Dex中类按照启动顺序重新排列,主要为了减少缺页中断造成的磁盘I/O延迟。
mmap比较适合对同一块区域频繁读写的情况,用户日志、数据上报都满足这种场景,另外跨进程通讯的时候也是不错的选择。Android跨进程通讯的Binder机制,其内部实现就是采用的mmap实现。
利用mmap,Binder在跨进程通讯只需要一次数据拷贝,比传统的Socket、管道等跨进程通讯方式会少一次数据拷贝。
多线程阻塞I/O和NIO
1、多线程阻塞I/O
文件读写受到I/O性能瓶颈的影响,在到达一定速度后整体性能就会受到明显影响,过多的线程反而导致应用整体性能明显下降。
2、NIO
非阻塞NIO将I/O以事件的方式通知,的确可以减少线程切换的开销。其缺点是导致应用程序实现变得更复杂。其实NIO最大作用不是减少读取文件的耗时,而是最大化提升应用整体的CPU利用率。在CPU繁忙地时候,我们可以将线程等待磁盘I/O的时间来做部分CPU操作。
I/O跟踪
1、Java Hook
出于稳定性的考虑,采用Java Hook的方案,通过动态代理的方式,在所有I/O相关方法前后加入插装代码,统计I/O操作相关信息。这个方法存在下列缺点:
- 性能极差。
- 无法监控Native代码。
- 兼容性差。
2、Native Hook
Profilo使用PLT Hook方案,它的性能比GOT Hook要稍好些,不过GOT Hook的兼容性更好一些。
3、监控内容
线上监控
通过Native Hook方案可以采集到所有I/O相关的信息,对于I/O的线上监控,我们需要进一步抽象出规则,明确哪些情况可以定义为不良情况,需要上报到后台,进而推动开发去解决。
1、主线程I/O
有时候I/O的写入会突然放大,所以尽量不要在主线程上操作,线上也经常发现一些I/O操作明明数据量不大,但是最后还是出现ANR。如果将主线程的所有I/O都收集起来,数据量会非常大,所以可以加上“连续读写时间超过100毫秒”这样的条件,之所以连续读写,是因为不少情况这是打开了文件句柄,但不是一次读写完的。
2、读写Buffer过小
文件系统读写是以Block为单位的,对于磁盘是以Page为单位读写,如果我们的Buffer太小会导致对此无用的系统调用和内存拷贝,导致read/write次数增多,从而影响性能。Buffer的大小对文件的读写的耗时有非常大的影响,耗时减小主要得益于系统图调用于内存拷贝的优化,Buffer的大小一般推荐使用4KB以上。
3、重复读
如果频繁的读取某个文件,并且这个文件一直没有被写入跟新,我们可以通过缓存来提升性能,不过未来减少上报量,通常增加几个条件:
- 重复读取次数超过3次,并且读取的内容相同。
- 读取期间文件内容没有被更新,也没有发生过write
4、资源泄漏
资源泄漏是指打开资源包括文件、cursor等没有及时关闭,从而引起的泄漏。利用Android 框架中的StrictMode实现监控资源泄漏,StrictMode利用CloseGuard.java类在很多系统代码已经预制了埋点,我们可以增加更多的埋点,据图步骤如下:
- 利用反射,吧CloseGuard中的ENABLED的值为true。
- 利用动态代理,把REPORTER替换成我们定义的proxy。
I/O启动优化
- 对大文件使用mmap或者NIO方式。
- 安装包不压缩。对启动过程需要的文件,指定不压缩,这样会加快启动速度,但会带来安装包体积增大的问题。
- Buffer复用。重用技巧,如Okio很大程度上减少CPU和内存的消耗。
- 存储结构和算法优化。从存储结构和算法优化方面减少甚至去除I/O操作。