抖音 Android 性能优化系列:新一代全能型性能分析工具 Rhea

发布于:2021-08-02 20:58:02


本文选自「抖音 Android 性能优化」系列文章。
- Block I/O: IO 阻塞



最终,上述两大类事件记录都汇集到内核态的同一缓冲中,PC 端上 Systrace 工具脚本是通过指定抓取 trace 的类别等参数,然后触发手机端的/system/bin/atrace 开启对应文件节点的信息,接着 atrace 会读取 ftrace 的缓存,生成只包含 ftrace 信息的 atrace_raw 信息,最终通过脚本转换成可视化 HTML 文件。大致流程如下:



因此,我们基于 Android atrace 的实现原理,我们同步参考了 * 的 profilo 用于在 APP 侧直接获取 atrace 的方案,实现了不依赖 PC 抓取 Trace 的方法。


我们通过 dlopen 获取 libcutils.so 对应句柄,通过对应 symbol 从中找到 atrace_enabled_tags 和 atrace_marker_fd 对应指针,从而设置 atrace_enabled_tags 用以打开 atrace 开关,具体实现如下:



??std::string?lib_name("libcutils.so");
??std::string?enabled_tags_sym("atrace_enabled_tags");
??std::string?marker_fd_sym("atrace_marker_fd");

??if?(sdk?????lib_name?=?"libutils.so";
????//?android::Tracer::sEnabledTags
????enabled_tags_sym?=?"_ZN7android6Tracer12sEnabledTagsE";
????//?android::Tracer::sTraceFD
????marker_fd_sym?=?"_ZN7android6Tracer8sTraceFDE";
??}

??if?(sdk?????handle?=?dlopen(lib_name.c_str(),?RTLD_LOCAL);
??}?else?{
????handle?=?dlopen(nullptr,?RTLD_GLOBAL);
??}
??//?safe?check?the?handle
??if?(handle?==?nullptr)?{
????ALOGE("atrace_handle?is?null");
????return?false;
??}

??atrace_enabled_tags_?=?reinterpret_cast?*>(
??????????dlsym(handle,?enabled_tags_sym.c_str()));
??if?(atrace_enabled_tags_?==?nullptr)?{
????ALOGE("atrace_enabled_tags?not?defined");
????goto?fail;
??}

??atrace_marker_fd_?=?reinterpret_cast(
??????dlsym(handle,?marker_fd_sym.c_str()));

接下来,我们通过 hook libcutils 动态库中的 write、write_chk 方法通过判定 atrace_marker_fd 来将对应 atrace 信息拦截下来转储到到本地或上传到云端分析。实现如下所示:


ssize_t?proxy_write_chk(int?fd,?const?void*?buf,?size_t?count,?size_t?buf_size)?{
??BYTEHOOK_STACK_SCOPE();
??if?(Atrace::Get().IsAtrace(fd,?count))?{
????Atrace::Get().LogTrace(buf,?count);
????return?count;
??}

??ATRACE_BEGIN_VALUE("__write_chk:",?FileInfo(fd,?count).c_str());

??size_t?ret?=?BYTEHOOK_CALL_PREV(proxy_write_chk,?fd,?buf,?count,?buf_size);

??ATRACE_END();

??return?ret;
}

二、提供更加全面 Trace 信息
1. ?锁耗时

Java 层的锁,无论是同步方法还是同步块,最终都会走到虚拟机的 MonitorEnter 和 MonitorExit,在 MonitorEnter 中实现了多种锁状态的切换,包括从无锁到轻锁,轻锁中的偏向和重入,出现竞争并超过自旋的次数之后升级成重锁分配 monitor 对象,其中 art 现在的自旋不是真的自旋,而是用 sched_yield 主动让出 CPU 等待下次调度。


而我们需要首先关注的就是出现锁竞争升级成重锁后的等待耗时信息,这个信息从 Android 6.x 开始会通过 ATrace 的方式输出到 trace_marker 中。


但是想要轻锁的信息还需要做一些额外的工作,因为是否输出轻锁的 ATrace 信息除了 ATRACE_ENABLE 条件之外,还有另外一个 systrace_lock_logging 的开关变量控制,这个变量是虚拟机中一个全局变量的成员,这个成员变量的值正常情况下是由虚拟机启动的时候确定,默认是 false,可以通过启动虚拟机的时候传递-verbose:sys-locks 参数来打开,但是作为普通应用我们没有办法通过这种方式来打开,所以需要用非常规手段在运行时动态打开:


    首先确认从 Android7.x 开始,这个结构的大小、成员顺序是否有发生变化;

    如果没有变化,则可以自己定义一个相同的结构,因为里面都是原始的 bool 类型变量,不会引入其他依赖;

    如果有变化,但是向前兼容,我们想要访问的成员位置没有变化,只是往后追加了成员,也同样可以自己定义相同的结构;

    通过 dlsym 找到虚拟机的全局符号?gLogVerbosity

    将其类型转换为预先定义的结构体类型;

    访问?systrace_lock_logging?成员并赋值为 true;

    轻锁的 ATrace 信息即可正常输出;


std::string?lib_name("libart.so");
//?art::gLogVerbosity
std::string?log_verbosity_sym("_ZN3art13gLogVerbosityE");

void?*handle?=?nullptr;
handle?=?npth_dlopen_full(lib_name.c_str());
if?(handle?==?nullptr)?{
??ALOGE("libart?handle?is?null");
??return?false;
}

log_verbosity_?=?reinterpret_cast(
????npth_dlsym(handle,?log_verbosity_sym.c_str()));
if?(log_verbosity_?==?nullptr)?{
??ALOGE("gLogVerbosity?not?defined");
??npth_dlclose(handle);
??return?false;
}

npth_dlclose(handle);

2. ?IO 耗时

在做抖音在启动路径上性能优化时,我们统计了冷启动的耗时,其中占比最长的是进程处于 D 状态(不可中断睡眠态,Uninterruptible Sleep ,通常我们用 PS 查看进程状态显示 D,因此俗称 D 状态)的时间,这部分耗时占比占总启动耗时的 40%左右,进程为什么会被置于 D 状态呢?处于 uninterruptible sleep 状态的进程通常是在等待 IO,比如磁盘 IO,其他外设 IO,正是因为得不到 IO 的响应,进程才进入了 uninterruptible sleep 状态,所以要想使进程从 uninterruptible sleep 状态恢复,就得使进程等待的 IO 恢复。类似如下:



但我们在使用 Systrace 进行优化时仅能得到如上内核态的调用状态,却无法得知具体的 IO 操作是什么。因此,我们专门设计了一套获取 IO 耗时信息的方案,其包括用户空间和内核空间两部分。


一是在用户空间,为了采集到需要的 I/O 耗时信息,我们通过 Hook I/O 操作时标准的关键函数族,包括 open,write,read,fsync,fdatasync 等,插入对应的 trace 埋点用于统计对应的 IO 耗时。以 fsync 为例:


int?proxy_fsync(int?fd)?{
??BYTEHOOK_STACK_SCOPE();
??ATRACE_BEGIN_VALUE("fsync:",?FileInfo(fd).c_str());

??int?ret?=?BYTEHOOK_CALL_PREV(proxy_fsync,?fd);

??ATRACE_END();
??return?ret;
}


二是在内核空间,除了可由 systrace 或 atrace 直接支持启用的功能之外,ftrace 还提供了其他功能,并且包含一些对调试性能问题至关重要的高级功能(这些功能需要 root 访问权限,通常可能也需要新的内核)。因此,我们基于此添加了显示定制 IO 信息等功能。在线下模式,我们开启了/sys/kernel/debug/tracing/events/android_fs 节点下 ftrace 信息,用于收集 IO 相关的信息,


这时候,我们追本溯源,先找到 Systrace 之母,Google Android 和 Chrome 团队的所有开源项目?Catapult?。正是 Catapult 生成了 Systrace 及其解析器的工具,在 Catapult 中,采用 javascript 实现了一个跨*台的 trace 解析工具,我们在此基础上开发了 Rhea 工具脚本将转换成 systrace 可显示化的格式,用于快速诊断发现 IO 性能瓶颈。


例如,我们线上监控发现我们某个 View 方法调用 setText 方法会导致 ANR,线下通过 Systrace 抓取 Trace 如下:



此时,看到主线程处于 D 状态,却束手无策,而通过我们的 Rhea 工具,获取 Trace 如下:
方法来统计对应 binder 调用耗时。


if?(TraceProvider::Get().isEnableBinder())?{
??//?static?jboolean?android_os_BinderProxy_transact(JNIEnv*?env,?jobject?obj,jint?code,?jobject?dataObj,?jobject?replyObj,?jint?flags)
??bytehook_stub_t?stub?=?bytehook_hook_single(
??????"libbinder.so",
??????NULL,
??????"_ZN7android14IPCThreadState8transactEijRKNS_6ParcelEPS1_j",
??????reinterpret_cast(proxy_transact),
??????NULL,
??????NULL);
??stubs.push_back(stub);
}

之后,统计对应 binder 耗时,如果耗时超过指定阈值,则将对应堆栈打印出来用于辅助分析 Sleep 耗时问题。


static?void?log_binder(int64_t?start,?int64_t?end,?int64_t?flags)?{
??JNIEnv?*env?=?context.env;
??env->CallStaticVoidMethod(context.javaRef,?context.logBinder,?start,?end,?flags);
}

status_t?proxy_transact(void?*pIPCThreadState,?int32_t?handle,?uint32_t?code,
??????????????????const?void?*data,?void?*reply,?uint32_t?flags)?{
??//?todo:?add?more?informations
??nsecs_t?start?=?systemTime();
??status_t?status?=?BYTEHOOK_CALL_PREV(proxy_transact,?pIPCThreadState,?handle,?code,?data,?reply,
??????????????????????????????flags);
??nsecs_t?end?=?systemTime();
??nsecs_t?cost_us?=?ns2us(end?-?start);
??if?(is_main_thread()?&&?cost_us?>?10000)?{
????log_binder(ns2us(start),?ns2us(end),?flags);
????nsecs_t?end_?=?systemTime();
??}

??return?status;
}

trace 效果如图所示:



4. ?支持后续增加更多数据源

当然,仅仅支持上述这些信息不可能完全覆盖我们性能优化过程中未来还可能遇到的其他问题,因此,我们支持了动态配置的功能,后续仅需要在现有框架下,简单添加对应配置项及其功能即可快速方便收集到我们所需要的信息。


enum?TraceConfigKey?{
??kIO?=?0,
??kBinder,
??kThinLock,
??kStopTraceUnhook,
??kLockStack,

??kKeyEnd,
};

5. ?不限层级插桩获取函数耗时

限制插桩的层级固然可以提升运行时性能,但是限制层级后面临两个问题:


函数调用数据采集不全面;

难以定位深层的耗时调用;


因此在用户态,为了获取 App 更多的 Trace 信息,便于性能优化。我们采用不限制层级的插桩方案。开发了在编译阶段不限制层级插桩的插件,通过静态代码插桩方式,在 App 调用方法的起始和结束位置分别插入 ?Trace.beginSection?和?Trace.endSection?。效果如下:



三、优化降低性能损耗

1. ?插桩性能优化

在插桩阶段, 我们做了如下优化:


支持自定义插桩作用域, 减少 Trace 对于其他无关模块的运行损耗;

针对 Trace 数据出现不闭合的问题, 对 catch 代码块进行全插桩;

针对高频调用函数, 可以选择性的添加到黑名单中, 提升运行时性能;

为支持生产环境使用,我们采用在 proguard 后进行插桩,由于函数内联等优化, 相较于混淆前插桩插桩数量可以减少 2.6%。对于线上模式,直接插入方法 ID,收集 Trace 后需在主机端或服务端对方法 id 重新映射成方法名,但又考虑到线下用户的易用性,在线下模式打包阶段直接插入方法名;

在编译阶段通过分析字节码信息,过滤掉不耗时函数的插桩。


2. ?优化 App 侧启停 Trace 性能

由于 App 侧抓取 Trace 的实现要依赖于 hook,我们参考了 * Profilo 的实现,但其实现存在动态库过大、启停 Trace 耗时问题,因此我们进一步优化了 App 本地获取 atrace 依赖的动态库大小和性能。如下所示:



3. ?优化 Trace 写入性能

由于在 App 方法中插入大量 Trace 信息,在开启 atrace 后,所有线程会将所有的 trace 都写入到 trace_marker 文件,会带来 IO 损耗剧增,会掩盖真实性能问题,原因是所有线程都在短时间向 trace_marker 文件进行写入操作,同时竞争内核态 pos 锁,导致获取到的 trace 文件无法真实反映性能问题,如下图所示:



因此,我们将原本直接写入内核态文件的 Trace 在用户态进行拦截,缓存起来,再以异步 IO 的方式转储。既避免了大量用户态与内核态切换带来的上下文损耗,又避免了直接 IO 带来的 IO 损耗。效果如下所示:



四、可视化

由于我们将用户态 atrace 和内核态 ftrace 分别存储在对应空间下的 ringbuffer 中,原生的 systrace 只能分别进行可视化,因此我们开发了统一整合 trace 的脚本工具,将多个 trace 信息将成为单个的 html 文件,当浏览 trace 信息时,可在 Chrome(chrome://tracing 访问)中可视化显示。


未来规划

目前,Rhea 对 Native 的支持还不够全;性能优化还不够极致,特别在用于分析卡顿问题时需要定位几毫秒甚至更细粒度耗时的情况下,性能损耗仍然会有些偏大,在一定程度上会带偏优化方向;目前 Trace 工具更多的还是在线下使用,由于插桩过多影响了包大小,使得我们线上部分只能对小规模的用户群体定向打开,没法全量上线定位线上大规模用户的性能问题。未来我们会重点解决如上问题,将 Trace 工具打造到极致。


小结

目前新一代 Trace 分析工具 Rhea 其主要优势如下:


1、使用灵活,不依赖 PC 抓取脚本,同时支持线上线下多种模式和配置开关;


2、支持采集和追踪包括不限层级 ATrace 函数耗时插桩、等锁信息、I/O 信息以及 Binder 耗时等在内的多种信息;


3、兼容性高,支持 API 16~30 全机型的 trace 抓取;


4、零侵入代码,通过 gradle 完成插件全部配置,无任何代码直接调用。


加入我们

我们是负责抖音客户端基础技术能力研发和前沿技术探索的客户端团队,我们专注于性能、架构、稳定性、研发工具、编译构建等方向的深耕,保障超大规模团队的研发效率和工程质量,将 6 亿人使用的抖音打造成极致用户体验的产品。


如果你对技术充满热情,欢迎加入抖音基础技术团队,让我们共建亿级全球化 App。目前我们在上海、北京、杭州、深圳均有招聘需求,内推可以联系邮箱:?tech@bytedance.com?;邮件标题:?姓名 - 工作年限 - 抖音 - 基础技术 - Android / iOS?





欢迎关注「?字节跳动技术团队?」


简历投递联系邮箱「?tech@bytedance.com?」


?点击阅读原文,快来加入我们吧!

相关推荐

最新更新

猜你喜欢