Android 性能监控框架 Matrix(8)I/O 监控及原理解析

5,780 阅读8分钟

使用

Matrix 中用于 I/O 监控的模块是 IOCanary,它是一个在开发、测试或者灰度阶段辅助发现 I/O 问题的工具,目前主要包括文件 I/O 监控和 Closeable Leak 监控两部分。

具体的问题类型有 4 种:

  1. 在主线程执行了 IO 操作
  2. 缓冲区太小
  3. 重复读同一文件
  4. 资源泄漏

IOCanary 采用 hook(ELF hook) 的方案收集 IO 信息,代码无侵入,从而使得开发者可以无感知接入。配置并启动 IOCanaryPlugin 即可:

IOCanaryPlugin ioCanaryPlugin = new IOCanaryPlugin(new IOConfig.Builder()
        .dynamicConfig(dynamicConfig)
        .build());
builder.plugin(ioCanaryPlugin);

与 IO 相关的配置选项有:

enum ExptEnum {
    // 监测在主线程执行 IO 操作的问题
    clicfg_matrix_io_file_io_main_thread_enable, 
    clicfg_matrix_io_main_thread_enable_threshold,  // 读写耗时
    // 监测缓冲区过小的问题
    clicfg_matrix_io_small_buffer_enable,
    clicfg_matrix_io_small_buffer_threshold, // 最小 buffer size
    clicfg_matrix_io_small_buffer_operator_times, // 读写次数
    // 监测重复读同一文件的问题
    clicfg_matrix_io_repeated_read_enable, 
    clicfg_matrix_io_repeated_read_threshold, // 重复读次数
    // 监测内存泄漏问题
    clicfg_matrix_io_closeable_leak_enable, 
}

出现资源泄漏(比如未关闭读写流)时,报告信息示例如下:

{
    "tag": "io",
    "type": 4,
    "process": "sample.tencent.matrix",
    "time": 1590410170122,
    "stack": "sample.tencent.matrix.io.TestIOActivity.leakSth(TestIOActivity.java:190)\nsample.tencent.matrix.io.TestIOActivity.onClick(TestIOActivity.java:103)\njava.lang.reflect.Method.invoke(Native Method)\nandroid.view.View$DeclaredOnClickListener.onClick(View.java:4461)\nandroid.view.View.performClick(View.java:5212)\nandroid.view.View$PerformClick.run(View.java:21214)\nandroid.app.ActivityThread.main(ActivityThread.java:5619)\njava.lang.reflect.Method.invoke(Native Method)\ncom.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:853)\ncom.android.internal.os.ZygoteInit.main(ZygoteInit.java:737)\n",
}

写入太多、缓冲区太小的报告示例如下:

{
    "tag": "io",
    "type": 2, // 问题类型
    "process": "sample.tencent.matrix",
    "time": 1590409786187,
    "path": "/sdcard/a_long.txt", // 文件路径
    "size": 40960000, // 文件大小
    "op": 80000, // 读写次数
    "buffer": 512, // 缓冲区大小
    "cost": 1453, // 耗时
    "opType": 2, // 1 读 2 写
    "opSize": 40960000, // 读写总内存
    "thread": "main",
    "stack":   "sample.tencent.matrix.io.TestIOActivity.writeLongSth(TestIOActivity.java:129)\nsample.tencent.matrix.io.TestIOActivity.onClick(TestIOActivity.java:99)\njava.lang.reflect.Method.invoke(Native Method)\nandroid.view.View$DeclaredOnClickListener.onClick(View.java:4461)\nandroid.view.View.performClick(View.java:5212)\nandroid.view.View$PerformClick.run(View.java:21214)\nandroid.app.ActivityThread.main(ActivityThread.java:5619)\njava.lang.reflect.Method.invoke(Native Method)\ncom.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:853)\ncom.android.internal.os.ZygoteInit.main(ZygoteInit.java:737)\n",
    "repeat": 0  // 重复读次数
}

需要注意的是,字段 repeat 在主线程 IO 事件中有不同的含义:"1" 表示单次读写耗时过长;"2" 表示连续读写耗时过长(大于配置指定值);"3" 表示前面两个问题都存在。

原理介绍

IOCanary 将收集应用的所有文件 I/O 信息并进行相关统计,再依据一定的算法规则进行检测,发现问题后再上报到 Matrix 后台进行分析展示。流程图如下:

IOCanary 基于 xHook 收集 IO 信息,主要 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);

以 open 为例,追根溯源,可以发现 open 函数最终是 libjavacore.so 执行的,因此 hook libjavacore.so 即可,找到 hook 目标 so 的目的是把 hook 的影响范围尽可能地降到最小。不同的 Android 版本可能会有些不同,目前兼容到 Android P。

另外,不同于其它 IO 事件,对于资源泄漏监控,Android 本身就支持了该功能,这是基于工具类 dalvik.system.CloseGuard 来实现的,因此在 Java 层通过反射 hook 相关 API 即可实现资源泄漏监控。

hook 介绍

想要了解 hook 技术,首先需要了解动态链接,了解动态链接之前,又需要从静态链接说起。

静态链接可以让开发者们相对独立地开发自己的程序模块,最后再链接到一起,但静态链接也存在浪费内存和磁盘更新、更新困难等问题。比如 program1 和 program2 都依赖 Lib.o 模块,那么,最终链接到可执行文件中的 Lib.o 模块将会有两份,极大地浪费了内存空间。同时,一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。

因此,要解决空间浪费和更新困难这两个问题,最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。也就是说,要在程序运行时进行链接,这就是动态链接的基本思想。

虽然动态链接带来了很多优化,但也带来了一个新的问题:共享对象在装载时,如何确定它在进程虚拟地址空间中的位置?

解决思路是把指令中那些需要修改的部分分离出来,和数据部分放在一起。

对于模块内部的数据访问、函数调用,因为它们之间的相对位置是固定的,因此这些指令不需要重定位。

对于模块外部的数据访问、函数调用,基本思想就是把地址相关的部分放到数据段里面,建立一个指向这些变量的指针数组,这个数据也被称为全局偏移表(Global Offset Table,GOT)。链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针指向的地址正确。

但 GOT 也带来了新的问题——性能损失,动态链接比静态链接慢的主要原因就是动态链接对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址。

对于这个问题,在一个程序运行过程中,可能很多函数直到程序执行完毕都不会被用到,比如一些错误处理函数等,如果一开始就把所有函数都链接好实际上是一种浪费,所以 ELF 采用了延迟绑定的方法,基本思想是当函数第一次被用到时才由动态链接器来进行绑定(符号查找、重定位等)。延迟绑定对应的就是 PLT(Procedure Linkage Table) 段。也就是说,ELF 在 GOT 之上又增加了一层间接跳转。

因此,所谓 hook 技术,实际上就是修改 PLT/GOT 表中的内容。

源码解析

IOCanary 的源码结构是很清晰的,流程大致如下:

  1. hook 目标 so 文件的 open、read、write、close 函数
  2. 在执行文件 IO 时记录 IO 耗时、操作次数、缓冲区大小等信息,使用结构体 IOInfo 保存
  3. 在 IO 执行完毕,调用 close 方法时,将 IOInfo 插入到一个队列
  4. 后台线程循环从队列获取 IOInfo,并交给 Detector 检查
  5. 如果 Detector 认为有问题,则上报

hook

IOCanary 的 hook 目标 so 文件包括 libopenjdkjvm.so、libjavacore.so、libopenjdk.so,每个 so 文件的 open 和 close 函数都会被 hook,如果是 libjavacore.so,read 和 write 函数也会被 hook。源码如下所示,

const static char* TARGET_MODULES[] = {
    "libopenjdkjvm.so",
    "libjavacore.so",
    "libopenjdk.so"
};
const static size_t TARGET_MODULE_COUNT = sizeof(TARGET_MODULES) / sizeof(char*);

JNIEXPORT jboolean JNICALL
Java_com_tencent_matrix_iocanary_core_IOCanaryJniBridge_doHook(JNIEnv *env, jclass type) {

    for (int i = 0; i < TARGET_MODULE_COUNT; ++i) {
        const char* so_name = TARGET_MODULES[i];

        void* soinfo = xhook_elf_open(so_name);

        // 将目标函数替换为自己的实现
        xhook_hook_symbol(soinfo, "open", (void*)ProxyOpen, (void**)&original_open);
        xhook_hook_symbol(soinfo, "open64", (void*)ProxyOpen64, (void**)&original_open64);

        bool is_libjavacore = (strstr(so_name, "libjavacore.so") != nullptr);
        if (is_libjavacore) {
            xhook_hook_symbol(soinfo, "read", (void*)ProxyRead, (void**)&original_read);
            xhook_hook_symbol(soinfo, "__read_chk", (void*)ProxyReadChk, (void**)&original_read_chk);
            xhook_hook_symbol(soinfo, "write", (void*)ProxyWrite, (void**)&original_write);
            xhook_hook_symbol(soinfo, "__write_chk", (void*)ProxyWriteChk, (void**)&original_write_chk);
        }

        xhook_hook_symbol(soinfo, "close", (void*)ProxyClose, (void**)&original_close);

        xhook_elf_close(soinfo);
    }
}

统计 IO 操作

为了分析是否出现主线程 IO、缓冲区过小、重复读同一文件等问题,首先需要对每一次的 IO 操作进行统计,记录 IO 耗时、操作次数、缓冲区大小等信息。

这些信息最终都会由 Collector 保存,为此,在执行 open 操作时,需要创建一个 IOInfo,并保存到 map 里面,key 为文件句柄:

int ProxyOpen(const char *pathname, int flags, mode_t mode) {
    int ret = original_open(pathname, flags, mode);
    if (ret != -1) {
        DoProxyOpenLogic(pathname, flags, mode, ret);
    }
    return ret;
}

static void DoProxyOpenLogic(const char *pathname, int flags, mode_t mode, int ret) {
    ... // 通过 Java 层的 IOCanaryJniBridge 获取 JavaContext
    iocanary::IOCanary::Get().OnOpen(pathname, flags, mode, ret, java_context);
}

void IOCanary::OnOpen(...) {
    collector_.OnOpen(pathname, flags, mode, open_ret, java_context);
}

void IOInfoCollector::OnOpen(...) {
    std::shared_ptr<IOInfo> info = std::make_shared<IOInfo>(pathname, java_context);
    info_map_.insert(std::make_pair(open_ret, info));
}

接着,在执行 read/write 操作时,更新 IOInfo 的信息:

void IOInfoCollector::OnWrite(...) {
    CountRWInfo(fd, FileOpType::kWrite, size, write_cost);
}

void IOInfoCollector::CountRWInfo(int fd, const FileOpType &fileOpType, long op_size, long rw_cost) {
    info_map_[fd]->op_cnt_ ++;
    info_map_[fd]->op_size_ += op_size;
    info_map_[fd]->rw_cost_us_ += rw_cost;
    ...
}

最后,在执行 close 操作时,将 IOInfo 插入到队列中:

void IOCanary::OnClose(int fd, int close_ret) {
    std::shared_ptr<IOInfo> info = collector_.OnClose(fd, close_ret);
    OfferFileIOInfo(info);
}

void IOCanary::OfferFileIOInfo(std::shared_ptr<IOInfo> file_io_info) {
    std::unique_lock<std::mutex> lock(queue_mutex_);
    queue_.push_back(file_io_info); // 将数据保存到队列中
    queue_cv_.notify_one(); // 唤醒后台线程,队列有新的数据了
    lock.unlock();
}

检测 IO 事件

后台线程被唤醒后,首先会从队列中获取一个 IOInfo:

int IOCanary::TakeFileIOInfo(std::shared_ptr<IOInfo> &file_io_info) {
    std::unique_lock<std::mutex> lock(queue_mutex_);

    while (queue_.empty()) {
        queue_cv_.wait(lock);
    }

    file_io_info = queue_.front();
    queue_.pop_front();
    return 0;
}

接着,将 IOInfo 传给所有已注册的 Detector,Detector 返回 Issue 后再回调上层 Java 接口,上报问题:

void IOCanary::Detect() {
    std::vector<Issue> published_issues;
    std::shared_ptr<IOInfo> file_io_info;
    while (true) {
        published_issues.clear();

        int ret = TakeFileIOInfo(file_io_info);
        for (auto detector : detectors_) {
            detector->Detect(env_, *file_io_info, published_issues); // 检查该 IO 事件是否存在问题
        }

        if (issued_callback_ && !published_issues.empty()) { // 如果存在问题
            issued_callback_(published_issues); // 回调上层 Java 接口并上报
        }
    }
}

以 small_buffer_detector 为例,如果 IOInfo 的 buffer_size_ 字段大于选项给定的值就上报问题:

void FileIOSmallBufferDetector::Detect(...) {
    if (file_io_info.op_cnt_ > env.kSmallBufferOpTimesThreshold // 连续读写次数
            && (file_io_info.op_size_ / file_io_info.op_cnt_) < env.GetSmallBufferThreshold() // buffer size
            && file_io_info.max_continual_rw_cost_time_μs_ >= env.kPossibleNegativeThreshold) /* 连续读写耗时 */ {
        PublishIssue(Issue(kType, file_io_info), issues);
    }
}

资源泄漏监控

Android framework 已实现了资源泄漏监控的功能,它是基于工具类 dalvik.system.CloseGuard 来实现的。以 FileInputStream 为例,在 GC 准备回收 FileInputStream 时,会调用 guard.warnIfOpen 来检测是否关闭了 IO 流:

public class FileInputStream extends InputStream {

    private final CloseGuard guard = CloseGuard.get();

    public FileInputStream(File file) {
        ...
        guard.open("close");
    }

    public void close() {
        guard.close();
    }

    protected void finalize() throws IOException {
        if (guard != null) {
            guard.warnIfOpen();
        }
    }
}

CloseGuard 的部分源码如下:

final class CloseGuard {
    public void warnIfOpen() {
        REPORTER.report(message, allocationSite);
    }
}

可以看到,执行 warnIfOpen 时如果未关闭 IO 流,就调用 REPORTER 的 report 方法。

因此,利用反射把 REPORTER 换成自己的就行了:

public final class CloseGuardHooker {

    private boolean tryHook() {
        Class<?> closeGuardCls = Class.forName("dalvik.system.CloseGuard");
        Class<?> closeGuardReporterCls = Class.forName("dalvik.system.CloseGuard$Reporter");
        Method methodGetReporter = closeGuardCls.getDeclaredMethod("getReporter");
        Method methodSetReporter = closeGuardCls.getDeclaredMethod("setReporter", closeGuardReporterCls);
        Method methodSetEnabled = closeGuardCls.getDeclaredMethod("setEnabled", boolean.class);

        sOriginalReporter = methodGetReporter.invoke(null);

        methodSetEnabled.invoke(null, true);

        ClassLoader classLoader = closeGuardReporterCls.getClassLoader();
        methodSetReporter.invoke(null, Proxy.newProxyInstance(classLoader,
            new Class<?>[]{closeGuardReporterCls},
            new IOCloseLeakDetector(issueListener, sOriginalReporter)));
    }
}

framework 很多代码都用了 CloseGuard ,因此,诸如文件资源没 close、Cursor 没有 close 等问题都能通过它来检测。

总结

IOCanary 是一个在开发、测试或者灰度阶段辅助发现 I/O 问题的工具,目前主要包括文件 I/O 监控和 Closeable Leak 监控两部分。具体的问题类型有 4 种:

  1. 在主线程执行了 IO 操作
  2. 缓冲区太小
  3. 重复读同一文件
  4. 资源泄漏

基于 xHook,IOCanary 将收集应用的所有文件 I/O 信息并进行相关统计,再依据一定的算法规则进行检测,发现问题后再上报到 Matrix 后台进行分析展示。

流程如下:

  1. hook 目标 so 文件的 open、read、write、close 函数
  2. 在执行文件 IO 时记录 IO 耗时、操作次数、缓冲区大小等信息,使用结构体 IOInfo 保存
  3. 在 IO 执行完毕,调用 close 方法时,将 IOInfo 插入到一个队列
  4. 后台线程循环从队列获取 IOInfo,并交给 Detector 检查
  5. 如果 Detector 认为有问题,则上报

不同于其它 IO 事件,对于资源泄漏监控,Android 本身就支持了该功能,这是基于工具类 dalvik.system.CloseGuard 来实现的,因此在 Java 层通过反射 hook CloseGuard 即可实现资源泄漏监控。因为 Android 框架层很多代码都用了 CloseGuard ,因此,诸如文件资源没 close、Cursor 没有 close 等问题都能通过它来检测。