磁盘:最容易被忽略的性能洼地

引言:从整个软件的性能来讲,资源类性能就像是撑起冰山一角的下面的冰层。构成这部分的,是传统部分的磁盘、CPU、内存和网络以及由于移动网络而显得特别重要的电池(耗电)。本文咱们将向您着重介绍磁盘部分。
本文选自《Android移动性能实战》。php

1 原理

  在没有SSD硬盘以前,你们都会以为咱们的HDD硬盘很好用,什么5400转、7200转,广告都是棒棒的。直到有一天,SSD出现了,发现启动Windows的时候,竟然能够秒开,这才幡然醒悟。所以,对于外行来讲,磁盘I/O性能老是最容易被忽略的,精力会更集中在CPU上。可是对于内行人来讲,你们都懂得,性能无非是CPU密集型和I/O密集型。磁盘I/O就是其中之一。那么到了移动时代,咱们的存储芯片性能究竟怎样呢?在讨论这个问题以前,咱们来看一个测试数据。
              图片描述
  如上图,咱们的顺序读/写的性能进步得很是快,不少新的机型,顺序读/写比起之前的性能,那是大幅度提高,跟SSD的差距已经缩小了不少。可是这里有个坏消息,随机读/写的性能依旧不好,见MOTO X、S七、iPhone 6S Plus。到这里,必须给你们介绍第一个概念:随机读/写。java

随机读/写

  随机写无处不在,举两个简单例子吧。第一个例子最简单,数据库的journal文件会致使随机写。当写操做在数据库的db文件和journal文件中来回发生时,则会引起随机写。以下表,将一条数据简单地插入到test.db,监控pwrite64的接口,能够看到表中有底纹的地方都是随机写。第二个例子,若是向设置了AUTOINCREMENT(自动建立主键字段的值)的数据库表中插入多条数据,那么每插入一条数据,都须要操做两张数据库表,这就意味着存在随机写。
           图片描述
           图片描述
  从上面的例子可知,随机读/ 写是相对顺序读/ 写而言的, 在读取或者写入的时候随机地产生offset。但为何随机读/ 写会如此之慢呢? 
  1. 随机读会失去预读(read-ahead)的优化效果。
  2. 随机写相对于顺序写除了产生大量的失效页面以外,更重要的是增长了触发“写入放大”效应的几率。
  那么“写入放大”又是什么呢?下面咱们来介绍第二个概念:“写入放大”效应。android

“写入放大”效应

  当数据第一次写入时,因为全部的颗粒都为已擦除状态,因此数据可以以页为最小单位直接写入进去。当有新的数据写入须要替换旧的数据时,主控制器将把新的数据写入到另外的空白闪存空间上(已擦除状态),而后更新逻辑LBA 地址来指向到新的物理FTL 地址。此时,旧的地址内容就变成了无效的数据,但主控制器并没执行擦除操做而是会标记对应的“页”为无效。当磁盘须要在上述无效区域进行再次写入的话,为了获得空闲空间,闪存必须先复制该“块”中全部的有效“页”到新的“块”里,并擦除旧“块”后,才能写入。(进一步学习,可参见:http://bbs.pceva.com.cn/forum.php?mod=viewthread&action=print able&tid=8277 。) 
  好比,如今写入一个4KB 的数据,最坏的状况就是,一个块里已经没有干净空间了, 可是刚好有一个“页”的无效数据能够擦除,因此主控就把全部的数据读出来,擦除块, 再加上这个4KB 新数据写回去。回顾整个过程,其实只想写4KB 的数据,结果形成了整个块(512KB)的写入操做。同时带来了本来只须要简单地写4KB 的操做变成了“闪存读取 (512KB)-> 缓存改(4KB)-> 闪存擦除(512KB)-> 闪存写入(512KB)”,这形成了延迟大大增长,速度慢是天然的。这就是所谓的“写入放大”(Write Amplification) 问题。
          【图4】
  下面咱们经过构造场景来验证写入放大效应的存在。
  场景 1:正常向 SD 卡写入 1MB 文件,统计文件写入的耗时。
  场景 2:先用 6KB 的小文件将 SD 卡写满,而后将写入的文件删除。这样就能够保证 SD 卡没有干净的数据块。这时再向 SD 卡写入 1MB 的文件,统计文件写入的耗时。
  下图是分别在三星 9100、三星 9006 以及三星 9300 上进行的测试数据,从测试数据看, 在 SD 卡没有干净数据块的状况下,文件的写入耗时是正常写入耗时的 1.9~6.5 倍,所以测 试结果能够很好地说明“写入放大”效应的存在。
          【图5】sql

  那么写入放大效应最容易是在何时出现呢?外因:手机长期使用,磁盘空间不足。内因:应用触发大量随机写。这时,磁盘I/O 的耗时会产生剧烈的波动,App 能作的只有一件事,即减小磁盘I/O 的操做量,特别是主线程的操做量。那么如何发现、定位、解决这些磁盘I/O 的性能问题呢?固然就要利用咱们的工具了。数据库

2 工具集

  工具集以下表。
   【图6】
  STRICTMODE 应该是入门级必备工具了,能够发现并定位磁盘I/O 问题中影响最大的主线程I/O。由下面代码可见,启用方法很是简单。缓存

public void onCreate() { 
   if (DEVELOPER_MODE) { 
   StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() 
   .detectDiskReads() 
   .detectDiskWrites() 
   .detectNetwork() 
   .penaltyLog() 
   .build()); 
   super.onCreate(); 
 } 
}

  原理也很是简单,主要是文件操做(BlockGuardOs.java)、数据库操做(SQLiteConnection. java)和SharePreferences 操做(SharedPreferencesImpl.java)的接口中插入检查的代码。咱们截取了一段Android 源码中文件操做的监控实现代码,以下,最后实际调用StrictMode 中的onWriteToDisk 方法,经过建立BlockGuardPolicyException 来打印I/O 调用的堆栈,帮助定位问题。
         【图7】
详细代码: 
http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java#91微信

Perfbox:I/OMonitor

  原理:I/OMonitor的功能能够归结为经过Hook Java层系统I/O的方法,收集区分进程和场景的I/O信息。网络

1. Hook java方法

  I/O Monitor Hook java方法借鉴了开源项目xposed,网上介绍xposed的文章不少,这里就用流程图来简要说明获取这次I/O操做信息的方法。
                      【图8】app

2. 区分进程和场景的I/O 信息收集

  区分进程和场景的I/O 信息收集有如下4个步骤。ide

(1)app_process 替换

  app_process 是Android 中Java 程序的入口,经过替换app_process 就能够控制入口, 在任何一个应用中运行咱们的代码。替换后的app_process 工做流程以下。
                      【图9】

(2) 将libfork.so添加到环境变量LD_PRELOAD中

  在UNIX中,LD_PRELOAD是一个能够影响程序的运行时连接的环境变量,让你能够定义在程序运行前优先加载的动态连接库。而这个功能就能够用来有选择性地载入不一样动态连接库中的相同函数。而在zygote进程启动前设置LD_PRELOAD环境变量,这样zygote的全部子进程都会继承这个环境变量。libfork.so实现了一个fork函数,当app_process经过fork函数来启动zygote进程时,会优先使用libfork.so中实现的fork函数,fork函数的流程以下。
                      【图10】

(3) 将XPlatform.jar 添加到环境变量CLASSPATH 中

  将XPlatform.jar 加入到CLASSPATH 中,是为了可让像common.jar 这种插件型jar 使用XPlatform.jar 中的类。手机QQ 中也存在相似事情,开发的同事把整个工程编译成了两个dex 文件,在手机QQ 启动后,把第二个dex 文件放入CLASSPATH 中(与XPlatform 实现方法不一样,但效果相同),这样主dex 能够直接import 并使用第二个dex 中的类。若是不加入CLASSPATH,须要借助DexClassLoader 类来使用另外一个jar 包中的类,这样使用起来很麻烦,而且会有很大的限制。
在系统启动过程当中,app_process 进程其实是zygote 进程的前身,因此XPlatform.jar 是在zygote 进程中运行的。
  在XPlatform 中主要Hook 了两个java 方法,来监控system_server 进程和应用进程的启·11· 动,并在这些进程中作一些初始化的操做。这里面用了一个fork的特性,父进程使用fork建立子进程,子进程会继承父进程的全部变量,因为zygote使用fork建立子进程,因此在zygote进程中进行Hook,在它建立的任何一个应用进程和system_server进程也是生效的。
  XPlatform工做流程图以下。
                      【图11】
  这样就实现了在应用进程启动时,控制在指定进程中运行I/O Monitor的功能。

(4) 区分场景的I/O信息收集

  为了实现分场景的I/O信息收集,咱们给I/O Monitor添加了一个开关,对应的就是Python控制脚本,这样即可以实现指定场景的I/O信息收集,使测试结果作到更精准。
                      【图12】
  这样咱们就实现了区分进程和场景的I/O 信息收集。
  在介绍了咱们的工具原理以后,来看一下采集的I/O 日志信息,包括文件路径、进程、线程、读/ 写文件的次数、大小和耗时以及调用的堆栈。
     【图13】
  XPlatform工做流程图中的数听说明:某个文件的一次对应CSV文件中的一行,每次调用系统的API(read或者write方法),读/写次数(readcount, writecount)就加1。读/写耗时(readtime, writetime)是计算open到close的时间。

SQLite性能分析/监控工具 SQL I/O Monitor

  咱们知道,数据库操做最终操做的是磁盘上的DB文件,DB文件和普通的文件本质上并没有差别,而I/O系统的性能一直是计算机的瓶颈,因此优化数据库最终落脚点每每在如何减小磁盘I/O上。
  不管是优化表结构、使用索引、增长缓存、调整page size等,最终的目的都是减小磁盘I/O,而这些都是咱们常规的优化数据库的手段。习惯从分析业务特性、尝试优化策略到验证测试结果的正向思惟,那么咱们为什么不能逆向一次?既然数据库优化的目的都是减小磁盘I/O,那咱们能不能直接从磁盘I/O数据出发,看会不会有意想不到的收获。

1.采集数据库I/O数据

  要想实现咱们的想法,第一步固然要采集数据库操做过程当中对应的磁盘I/O数据。因为以前经过Java Hook技术,获取到了Java层的I/O操做数据,虽然SQLite的I/O操做在libsqlite.so进行,属于Native层,但咱们会很天然地想到经过Native Hook采集SQLite的I/O数据。
Native Hook主要有如下实现方式。
  (1)修改环境变量LD_PRELOAD。
  (2)修改sys_call_table。
  (3)修改寄存器。
  (4)修改GOT表。
  (5)Inline Hook。
  下面主要介绍(1)、(4)、(5)三种实现方式。

(1)修改环境变量LD_PRELOAD

  这种方式实现最简单,重写系统函数open、read、write和close,将so库放进环境变量LD_PRELOAD中,这样程序在调用系统函数时,会先去环境变量里面找,这样就会调用重写的系统函数。能够参考看雪论坛的文章“Android使用LD_PRELOAD进行Hook”(http://bbs.pediy.com/showthread.php?t=185693)。
可是这种Hook针对整个系统生效,即系统全部I/O操做都被Hook,形成Hook的数据量巨大,系统动不动就卡死。

(4)修改GOT 表

  引用外部函数的时候,在编译时会将外部函数的地址以Stub 的形式存放在.GOT 表中,加载时linker 再进行重定位,即将真实的外部函数写到此stub 中。Hook 的思路就是替换.GOT 表中的外部函数地址。而libsqlite.so 中的I/O 操做是调用libc.so 中的系统函数进行,因此修改GOT 表的Hook 方案是可行的。
  然而现实总不是一路顺风的,当咱们的方案实现后,发现只能记录到libsqlite.so 中的open 和close 函数调用,而因为sqlite 的内部机制而致使的read/write 调用咱们没法记录到。

(5)Inline Hook

  在前两种方案无果后,只能尝试Inline Hook。Inline Hook 能够Hook so 库的内部函数, 咱们首先想到的是Hook libsqlite.so 内部I/O 接口posixOpen、seekandread、seekandwrite 以及robust_close。可是在成功的路上老是充满波折,sqlite 内部居然将大部分的关键函数定义为static 函数,如posixOpen。在C 语言中,static 函数是不导出符号的,而Inline Hook 就是要在符号表中找到对应的函数位置。这样一来,经过Hook sqlite 内部函数的路子又行不通了。

static int posixOpen(const char *zFile, int flags, int mode){ 
return open(zFile, flags, mode); 
}

  既然这样不行,那咱们只能更暴力地Hook libc.so 中的open、read、write 和close 方法。由于无论sqlite 里面怎么改,最终仍是会调用系统函数,惟一很差的是这样录到了该进程全部的IO 数据。这种方法在本身编译的libsqlite.so 里面证明是可行的。
  正当我满怀欣喜地去调用手机自带的libsqlite.so 库时,读/ 写数据再一次没有被记录到, 我当时的心里几乎是崩溃的。为何我本身编译的libsqlite.so 库能够,用手机上的就不行呢?没办法,只能再去看以下面的源码,最后在seekAndRead 里面发现,sqlite 定义了不少宏开关,能够决定调用系统函数pread、pread64 以及read 来进行读文件。莫非我本身编的so 和手机里面的so 的编译方式不同?

static int seekAndRead(unixFile *id, sqlite3_int64 offset, void *pBuf, int cnt){ 
int got; 
int prior = 0;#if (!defined(USE_PREAD) && !defined(USE_PREAD64)) i64 newOffset;#endif TIMER_START; 
do{#if defined(USE_PREAD) got = osPread(id->h, pBuf, cnt, offset); 
SimulateIOError( got = -1 );#elif defined(USE_PREAD64) got = osPread64(id->h, pBuf, cnt, offset); 
SimulateIOError( got = -1 );#else newOffset = lseek(id->h, offset, SEEK_SET); 
SimulateIOError( newOffset-- );

  笔者又Hook 了pread和pread64,这一次终于记录到了完整的I/O数据,原来手机里面的libsqlite.so调用系统的pread64和pwrite64函数来进行I/O操做,同时经过Inline Hook获取到了数据库读/写磁盘时page的类型,sqlite的page类型有表叶子页、表内部页、索引叶子页、索引内部页以及溢出页,采集的数据库日志信息以下。
      【图14】
  费尽了千辛万苦,终于拿到了数据库读/写磁盘的信息,可是这些信息有什么用呢?咱们能想到能够有如下用途。

  • 经过I/O数据的量直观地验证数据库优化效果。

  • 经过偏移量找出随机读/写进行优化。

可是咱们又面临另一个问题,由于获取的磁盘信息是基于DB 文件的,而应用层操做数据库是基于表的,同时又缺少堆栈,很难定位问题。基于此,咱们又想到了另一个解决方法,就是Hook 应用代码的数据库操做,经过堆栈把二者对应起来,这样就能够把应用代码联系起来,更方便分析问题。

2. Hook 应用层SQL 操做

  Hook 应用代码其实就是Hook SQLiteDatabase 里面的数据库增删改查操做,应用代码SQL 语句以下,Java 层Hook 基于Xposed 的方案实现。
         【图15】

  最终能够经过堆栈和磁盘信息对应起来。

【图16】

  获取到了这么多数据,咱们在以后的推送中将向你们介绍一些数据库相关的案例,看其如何应用。

  本文选自《Android移动性能实战》,点此连接可在博文视点官网查看此书。
                    图片描述
  想及时得到更多精彩文章,可在微信中搜索“博文视点”或者扫描下方二维码并关注。
                       图片描述