企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
[TOC] ## 原理 IOCanary 将收集应用的文件中所有 I/O 信息并进行相关统计,再依据一定的算法规则进行检测,发现问题,将之上报到 Matrix 后台进行分析展示。流程图如下: ![](https://img.kancloud.cn/a4/2b/a42bea86c9e6e319afbe971836455080_1080x280.png) ### Hook 方案简介 IOCanary 采用 hook (ELF hook) 的方案收集 I/O 信息,代码无侵入,从而使得开发者可以**无感知接入**。方案主要通过 hook os posix 的四个关键的文件操作接口: ~~~ int open(const char *pathname, int flags, mode_t mode);//成功时返回值就是fd ssize_t read(int fd, void *buf, size_t size); ssize_t write(int fd, const void *buf, size_t size); int close(int fd); ~~~ 由上得知,通过 hook 这几个接口,可以拿到大部分关键操作信息。这里举 open 的例子介绍下原理。 简单起见,只结合**Android M**的代码以及大家最常用的 FileInputStream 分析。关键要找到 posix open 是在哪里被调用。由上往下我们列了大致的调用关系: ~~~ java : FileInputStream -> IoBridge.open -> Libcore.os.open -> BlockGuardOs.open -> Posix.open                             ↓ jni : libcore_io_Posix.cpp static jobject Posix_open(...) {    ...    int fd = throwIfMinusOne(env, "open", TEMP_FAILURE_RETRY(open(path.c_str(), flags, mode)));    ... } ~~~ 由上看到, android 框架的 FileInputStream ,最终是在 libcore\_io\_Posix.cpp 那里调到了posix的open接口。 最后我们再找它被编到哪个 so ,通过查阅源码对应的 NativeCode.mk ,可以找到: ~~~ LOCAL_MODULE := libjavacore ~~~ 因此,只要 hook libjavacore.so 的 open 符号就 ok 了。**找到 hook 目标 so 的目的是把 hook 的影响范围尽可能地降到最小**。同样, write,read,close 也是大同小异。不同的 Android 版本会有些坑需要填,这里不细述, 目前兼容到Android P。 由此, 通过ELF hook 便可以收集到应用在文件读写时的相关信息:**文件路径、fd、buffer 大小**等,并可以**统计耗时、操作次数**等。基于这些信息,就可以设定一些策略进行检测判断。 ## 监控问题 ### 1\. 主线程 I/O 我不止一次说过,有时候 I/O 的写入会突然放大,即使是几百 KB 的数据,还是尽量不要在主线程上操作。在线上也会经常发现一些 I/O 操作明明数据量不大,但是最后还是 ANR 了。 当然如果把所有的主线程 I/O 都收集上来,这个数据量会非常大,所以我会添加“连续读写时间超过 100 毫秒”这个条件。之所以使用连续读写时间,是因为发现有不少案例是打开了文件句柄,但不是一次读写完的。 在上报问题到后台时,为了能更好地定位解决问题,我通常还会把 CPU 使用率、其他线程的信息以及内存信息一并上报,辅助分析问题。 ### 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 呢?我们可以跟据文件保存所挂载的目录的 block size 来确认 Buffer 大小,数据库中的[pagesize](http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/database/sqlite/SQLiteGlobal.java#61)就是这样确定的。 ~~~ new StatFs("/data").getBlockSize() ~~~ 所以我们最终选择的判断条件为: * buffer size 小于 block size,这里一般为 4KB。 * read/write 的次数超过一定的阈值,例如 5 次,这主要是为了减少上报量。 buffer size 不应该小于 4KB,那它是不是越大越好呢?你可以通过下面的命令做一个简单的测试,读取测试应用的 iotest 文件,它的大小是 40M。其中 bs 就是 buffer size,bs 分别使用不同的值,然后观察耗时。 ~~~ // 每次测试之前需要手动释放缓存 echo 3 > /proc/sys/vm/drop_caches time dd if=/data/data/com.sample.io/files/iotest of=/dev/null bs=4096 ~~~ ![io_3_6](https://blog.yorek.xyz/assets/images/android/master/io_3_6.png) 通过上面的数据大致可以看出来,Buffer 的大小对文件读写的耗时有非常大的影响。耗时的减少主要得益于系统调用与内存拷贝的优化,Buffer 的大小一般我推荐使用 4KB 以上。 在实际应用中,ObjectOutputStream 和 ZipOutputStream 都是一个非常经典的例子,ObjectOutputStream 使用的 buffer size 非常小。而 ZipOutputStream 会稍微复杂一些,如果文件是 Stored 方式存储的,它会使用上层传入的 buffer size。如果文件是 Deflater 方式存储的,它会使用 DeflaterOutputStream 的 buffer size,这个大小默认是 512Byte。 **你可以看到,如果使用 BufferInputStream 或者 ByteArrayOutputStream 后整体性能会有非常明显的提升。** ![io_3_7](https://blog.yorek.xyz/assets/images/android/master/io_3_7.png) 正如我上一期所说的,准确评估磁盘真实的读写次数是比较难的。磁盘内部也会有很多的策略,例如预读。它可能发生超过你真正读的内容,预读在有大量顺序读取磁盘的时候,readahead 可以大幅提高性能。但是大量读取碎片小文件的时候,可能又会造成浪费。 你可以通过下面的这个文件查看预读的大小,一般是 128KB。 ~~~ /sys/block/[disk]/queue/read_ahead_kb ~~~ 一般来说,我们可以利用 /proc/sys/vm/block\_dump 或者[/proc/diskstats](https://www.kernel.org/doc/Documentation/iostats.txt)的信息统计真正的磁盘读写次数。 ~~~ /proc/diskstats 块设备名字|读请求次数|读请求扇区数|读请求耗时总和\.\.\.\. dm-0 23525 0 1901752 45366 0 0 0 0 0 33160 57393 dm-1 212077 0 6618604 430813 1123292 0 55006889 3373820 0 921023 3805823 ~~~ ### 3\. 重复读 微信之前在做模块化改造的时候,因为模块间彻底解耦了,很多模块会分别去读一些公共的配置文件。 有同学可能会说,重复读的时候数据都是从 Page Cache 中拿到,不会发生真正的磁盘操作。但是它依然需要消耗系统调用和内存拷贝的时间,而且 Page Cache 的内存也很有可能被替换或者释放。 你也可以用下面这个命令模拟 Page Cache 的释放。 ~~~ echo 3 > /proc/sys/vm/drop_caches ~~~ 如果频繁地读取某个文件,并且这个文件一直没有被写入更新,我们可以通过缓存来提升性能。不过为了减少上报量,我会增加以下几个条件: * 重复读取次数超过 3 次,并且读取的内容相同。 * 读取期间文件内容没有被更新,也就是没有发生过 write。 加一层内存 cache 是最直接有效的办法,比较典型的场景是配置文件等一些数据模块的加载,如果没有内存 cache,那么性能影响就比较大了。 ~~~ public String readConfig() { if (Cache != null) { return cache; } cache = read("configFile"); return cache; } ~~~ ### 4\. 资源泄漏 在崩溃分析中,我说过有部分的 OOM 是由于文件句柄泄漏导致。资源泄漏是指打开资源包括文件、Cursor 等没有及时 close,从而引起泄露。这属于非常低级的编码错误,但却非常普遍存在。 如何有效的监控资源泄漏?这里我利用了 Android 框架中的 StrictMode,StrictMode 利用[CloseGuard.java](http://androidxref.com/8.1.0_r33/xref/libcore/dalvik/src/main/java/dalvik/system/CloseGuard.java)类在很多系统代码已经预置了埋点。 到了这里,接下来还是查看源码寻找可以利用的 Hook 点。这个过程非常简单,CloseGuard 中的 REPORTER 对象就是一个可以利用的点。具体步骤如下: * 利用反射,把 CloseGuard 中的 ENABLED 值设为 true。 * 利用动态代理,把 REPORTER 替换成我们定义的 proxy。 虽然在 Android 源码中,StrictMode 已经预埋了很多的资源埋点。不过肯定还有埋点是没有的,比如 MediaPlayer、程序内部的一些资源模块。所以在程序中也写了一个 MyCloseGuard 类,对希望增加监控的资源,可以手动增加埋点代码。 ## I/O 与启动优化 通过 I/O 跟踪,可以拿到整个启动过程所有 I/O 操作的详细信息列表。我们需要更加的苛刻地检查每一处 I/O 调用,检查清楚是否每一处 I/O 调用都是必不可少的,特别是 write()。 当然主线程 I/O、读写 Buffer、重复读以及资源泄漏是首先需要解决的,特别是重复读,比如 cpuinfo、手机内存这些信息都应该缓存起来。 对于必不可少的 I/O 操作,我们需要思考是否有其他方式做进一步的优化。 * 对大文件使用 mmap 或者 NIO 方式。[MappedByteBuffer](https://developer.android.com/reference/java/nio/MappedByteBuffer)就是 Java NIO 中的 mmap 封装,正如上一期所说,对于大文件的频繁读写会有比较大的优化。 * 安装包不压缩。对启动过程需要的文件,我们可以指定在安装包中不压缩,这样也会加快启动速度,但带来的影响是安装包体积增大。事实上 Google Play 非常希望我们不要去压缩 library、resource、resource.arsc 这些文件,这样对启动的内存和速度都会有很大帮助。而且不压缩文件带来只是安装包体积的增大,对于用户来说,Download size 并没有增大。 * Buffer 复用。我们可以利用[Okio](https://github.com/square/okio)开源库,它内部的 ByteString 和 Buffer 通过重用等技巧,很大程度上减少 CPU 和内存的消耗。 * 存储结构和算法的优化。是否可以通过算法或者数据结构的优化,让我们可以尽量的少 I/O 甚至完全没有 I/O。比如一些配置文件从启动完全解析,改成读取时才解析对应的项;替换掉 XML、JSON 这些格式比较冗余、性能比较较差的数据结构,当然在接下来我还会对数据存储这一块做更多的展开。