引言:从整个软件的性能来说,资源类性能就像是撑起冰山一角的下面的冰层。构成这部分的,是传统部分的磁盘、CPU、内存和网络以及因为移动网络而显得特别重要的电池(耗电)。本文我们将向您着重介绍磁盘部分。
本文选自《Android移动性能实战》。
1 原理
在没有SSD硬盘之前,大家都会觉得我们的HDD硬盘很好用,什么5400转、7200转,广告都是棒棒的。直到有一天,SSD出现了,发现启动Windows的时候,居然可以秒开,这才幡然醒悟。因此,对于外行来说,磁盘I/O性能总是最容易被忽略的,精力会更集中在CPU上。但是对于内行人来说,大家都懂得,性能无非是CPU密集型和I/O密集型。磁盘I/O就是其中之一。那么到了移动时代,我们的存储芯片性能究竟怎样呢?在讨论这个问题之前,我们来看一个测试数据。
如上图,我们的顺序读/写的性能进步得非常快,很多新的机型,顺序读/写比起以前的性能,那是大幅度提升,跟SSD的差距已经缩小了很多。但是这里有个坏消息,随机读/写的性能依旧很差,见MOTO X、S7、iPhone 6S Plus。到这里,必须给大家介绍第一个概念:随机读/写。
随机读/写
随机写无处不在,举两个简单例子吧。第一个例子最简单,数据库的journal文件会导致随机写。当写操作在数据库的db文件和journal文件中来回发生时,则会引发随机写。如下表,将一条数据简单地插入到test.db,监控pwrite64的接口,可以看到表中有底纹的地方都是随机写。第二个例子,如果向设置了AUTOINCREMENT(自动创建主键字段的值)的数据库表中插入多条数据,那么每插入一条数据,都需要操作两张数据库表,这就意味着存在随机写。
从上面的例子可知,随机读/ 写是相对顺序读/ 写而言的, 在读取或者写入的时候随机地产生offset。但为什么随机读/ 写会如此之慢呢?
1. 随机读会失去预读(read-ahead)的优化效果。
2. 随机写相对于顺序写除了产生大量的失效页面之外,更重要的是增加了触发“写入放大”效应的概率。
那么“写入放大”又是什么呢?下面我们来介绍第二个概念:“写入放大”效应。
“写入放大”效应
当数据第一次写入时,由于所有的颗粒都为已擦除状态,所以数据能够以页为最小单位直接写入进去。当有新的数据写入需要替换旧的数据时,主控制器将把新的数据写入到另外的空白闪存空间上(已擦除状态),然后更新逻辑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)
问题。
下面我们通过构造场景来验证写入放大效应的存在。
场景 1:正常向 SD 卡写入 1MB 文件,统计文件写入的耗时。
场景 2:先用 6KB 的小文件将 SD 卡写满,然后将写入的文件删除。这样就可以保证 SD 卡没有干净的数据块。这时再向 SD 卡写入 1MB 的文件,统计文件写入的耗时。
下图是分别在三星 9100、三星 9006 以及三星 9300 上进行的测试数据,从测试数据看, 在 SD 卡没有干净数据块的情况下,文件的写入耗时是正常写入耗时的 1.9~6.5 倍,因此测 试结果可以很好地说明“写入放大”效应的存在。
那么写入放大效应最容易是在什么时候出现呢?外因:手机长期使用,磁盘空间不足。内因:应用触发大量随机写。这时,磁盘I/O 的耗时会产生剧烈的波动,App 能做的只有一件事,即减少磁盘I/O 的操作量,特别是主线程的操作量。那么如何发现、定位、解决这些磁盘I/O 的性能问题呢?当然就要利用我们的工具了。
2 工具集
工具集如下表。
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 调用的堆栈,帮助定位问题。
详细代码:
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操作信息的方法。
2. 区分进程和场景的I/O 信息收集
区分进程和场景的I/O 信息收集有以下4个步骤。
(1)app_process 替换
app_process 是Android 中Java 程序的入口,通过替换app_process 就可以控制入口, 在任何一个应用中运行我们的代码。替换后的app_process 工作流程如下。
(2) 将libfork.so添加到环境变量LD_PRELOAD中
在UNIX中,LD_PRELOAD是一个可以影响程序的运行时链接的环境变量,让你可以定义在程序运行前优先加载的动态链接库。而这个功能就可以用来有选择性地载入不同动态链接库中的相同函数。而在zygote进程启动前设置LD_PRELOAD环境变量,这样zygote的所有子进程都会继承这个环境变量。libfork.so实现了一个fork函数,当app_process通过fork函数来启动zygote进程时,会优先使用libfork.so中实现的fork函数,fork函数的流程如下。
(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工作流程图如下。
这样就实现了在应用进程启动时,控制在指定进程中运行I/O Monitor的功能。
(4) 区分场景的I/O信息收集
为了实现分场景的I/O信息收集,我们给I/O Monitor添加了一个开关,对应的就是Python控制脚本,这样便可以实现指定场景的I/O信息收集,使测试结果做到更精准。
这样我们就实现了区分进程和场景的I/O 信息收集。
在介绍了我们的工具原理之后,来看一下采集的I/O 日志信息,包括文件路径、进程、线程、读/ 写文件的次数、大小和耗时以及调用的堆栈。
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类型有表叶子页、表内部页、索引叶子页、索引内部页以及溢出页,采集的数据库日志信息如下。
费尽了千辛万苦,终于拿到了数据库读/写磁盘的信息,但是这些信息有什么用呢?我们能想到可以有以下用途。
- 通过I/O数据的量直观地验证数据库优化效果。
- 通过偏移量找出随机读/写进行优化。
但是我们又面临另外一个问题,因为获取的磁盘信息是基于DB 文件的,而应用层操作数据库是基于表的,同时又缺乏堆栈,很难定位问题。基于此,我们又想到了另外一个解决方法,就是Hook 应用代码的数据库操作,通过堆栈把两者对应起来,这样就可以把应用代码联系起来,更方便分析问题。
2. Hook 应用层SQL 操作
Hook 应用代码其实就是Hook SQLiteDatabase 里面的数据库增删改查操作,应用代码SQL 语句如下,Java 层Hook 基于Xposed 的方案实现。
最终可以通过堆栈和磁盘信息对应起来。
获取到了这么多数据,我们在之后的推送中将向大家介绍一些数据库相关的案例,看其如何应用。
本文选自《Android移动性能实战》,点此链接可在博文视点官网查看此书。
想及时获得更多精彩文章,可在微信中搜索“博文视点”或者扫描下方二维码并关注。