本文选自《抖音性能优化》系列文章。
《抖音性能优化》系列文章是抖音基础技术seo录制技术达人打造的技术干货内容,与大家分享性能优化方法论、方法论、工具与实践抖音优化,与技术同窗交流成长。
用户交互响应耗时作为用户在日常生活中感知最深的性能指标,在日常开发中具有重要意义。 为了打造极致的交互响应体验,抖音基础技术团队一直致力于极致性能的探索,包括如何打造极致的耗时检测工具。
概述
俗话说,工欲善其事,必先利其器。 要想做好性能优化,首先要能够发现性能问题。 这就需要可靠的工具来帮助我们做性能分析。 市面上主流的性能分析工具有:、、CPU。 相信做性能优化的同学应该对这些工具都非常熟悉。 抖音最早也是作为主要的分析工具,在前期的优化中也起到了比较大的作用。 随着抖音的性能优化已经来到深水区,我们需要发现和解决更细粒度、多维度的性能问题。 我们会关注几毫秒的耗时,关注一些低端机用户在线遇到的锁阻塞。 和 IO 等待问题。 然而,目前市面上这些主流的性能分析工具,由于使用上的局限性,性能损失较大,已经不能满足抖音性能优化的需求。 为了更进一步,我们需要开发更灵活、更精细、更多样化的信息和工具,以协助我们进行高效的优化工作。
在此背景下,抖音基础技术团队开发了Rhea([ˈriːə] Rhea,意为时间女神)(),通过静态代码插桩技术自动添加分析APP运行时的耗时性能分析工具,意味着做一个功能全面、追求效率、受大家喜爱的女神,这也符合我们工具的核心设计原则。 Rhea 的获取不仅要有低的性能损失,还要能够在App端直接抓取,不需要PC端的工具。 在跟踪更多常规功能的同时,还必须能够跟踪系统调用,例如:锁信息、I/O耗时、IPC等。 最后,还提供了转换脚本工具,可以将原始文件生成可视化报告,方便用户分析性能问题。
优势比较
Rhea 以其非侵入性、高性能、信息完备等优势,目前被多款字节应用使用。 , IO, lock, , CPU调度等耗时信息,其部分作用如下:
与其他性能检测工具相比,其具体优势如下:
目前只能监控特定的系统信息,应用层的监控耗时长,需要人工管理; 性能与采样率密切相关,采样太频繁性能开销巨大,采样太低则难以准确找到问题函数; 虽然几乎没有性能损失,但是每次都需要自定义刷入ROM,使用成本非常高,而且这些工具只支持离线分析应用。 这些工具在APP性能优化方面都不够完善,而Rhea是高手。 它突出了每个工具的优点并弥补了相关的缺点。
架构演进之路第一阶段:基于补充功能的耗时
是性能调试和优化的常用工具。 可以收集进程活动信息,如函数调用耗时、锁等; 它还可以收集内核信息,如CPU调度、IO活动、调用信息等; 显示在浏览器中,方便工程师进行性能调试、优化滞后等工作。 因此,抖音前期的性能优化是作为主要工具的首选,其大致流程如下:
1.功能修改
该工具只能监控特定系统调用的耗时,不支持应用代码的耗时分析,因此在使用上有一定的局限性。 最初,开发人员需要手动添加 . 采集数据一步步完成耗时的方法定位,也使得使用成本极高。
为了提高易用性,我们开发了 Rhea 1.0 修改功能,增加了自动插桩机制:通过字节码插桩,插入 . ,有效控制引入监控带来的性能损失。
class Tracer{
method_stack = list()
max_size = 6
methodIn(method_id, method_name){
if(method_stack.size()<=max_size){
method_stack.push(method_id)
Trace.beginSection(method_name)
}
}
methodOut(){
if(method_stack.size>0){
method_stack.pop()
Trace.end()
}
}
}
method1(){
Tracer.methodIn(1,method1)
...
Tracer.methodOut()
}
输出数据如下,指定层级的所有方法都可以正常显示在输出html中:
2.方法没问题
在使用修改版的时候,我们经常会遇到以下问题:
分析发现,主要原因是方法在运行时执行过程中被中断,例如:方法执行过程中发生异常后,被其调用者方法捕获,异常方法的.o方法没有被调用。 如图:执行test方法中的方法时,arr[2]的数组越界,导致方法。 它们是成对调用的,最终导致测试外层的所有方法调用都无法正常关闭。 (注:本总结中提到的存根方法,即相关方法,都是通过字节码检测自动插入的)
解决方法是在外层捕获所有异常的位置插入一个额外的存根方法,重现异常调用链下存根方法不配对的问题。 如下所示:
三、遗留问题
随着Rhea 1.0功能的深入使用,在带来极大便利的同时,功能本身的不足也逐渐暴露出来。 在收集数据的过程中,自身的性能损失会导致在一些实际的性能优化过程中出现偏差。 经过我们严格的测试,其性能损失约为11.5%,如下图:
在实际使用过程中发现,开启后,极端情况下对应的耗时比例会超过40%。 一方面是APP锁造成的耗时。 比如抖音启动路径优化过程中,开启Rhea 1.0功能后,发现SP调用有明显的耗时锁。 经过一系列的排查,发现锁耗时是开启功能导致的。 另一方面是IO造成的耗时。 比如我们在一个性能优化的过程中看到了很多的操作,统计这些操作的相对比例,至少占了总耗时的60%。 我们花了很长时间才添加了很多额外的 IO 信息。 最后的原因是开启后,所有线程都会写入同一个文件,所有线程会在内核态同步竞争文件pos锁。 工具本身的性能问题误导了我们的排查方向,同时也暴露出在排查这个IO Wait问题时,由于IO信息不完整导致排查效率低下的问题。
原理决定了当我们在应用层插入更多的函数检测来定位应用层的耗时问题时,会造成非常严重的性能问题抖音优化,所以我们离线使用这个工具来减少的性能损失。 但是层级的限制使得超出既定层级的函数调用数据缺失,在分析更深层调用层级的耗时函数时,无法定位到准确的耗时点。
由于在采集数据的过程中需要依赖PC,因此无法满足一些需要脱离PC采集数据的场景需求。 比如我们产品运营同学经常测试抖音在线下场景下的性能,比如地铁、餐厅、咖啡店等,这些实际使用场景下的性能数据是无法支撑的。
我们在对低端机进行耗时的优化时,发现一些早期的低端机如三星、oppo等都无法支持其数据采集。
针对以上问题,我们对工具进行了深入的探索和优化。 至此,该工具的开发进入了第二阶段。
第二阶段:高性能全场景爬虫工具 1、功能升级
为了弥补第一阶段功能的不足,进一步提升性能,满足更多的使用场景,我们找到了新的解决方案:在Java层,通过记录第一个位置和最后一个位置的时间戳等信息方法的所在线程,在指定一个耗时阈值的函数后,过滤掉多条异步日志数据到文件。 数据采集完成后,将输出文件转换成指定格式后,可以通过SDK提供的工具转换成易于查看的Html格式,从而达到同样的可视化效果。
2、实现原理
Rhea 2.0是如何采集数据并生成视觉效果一致的html的? SDK中工具的--from-file命令可以将原来的. 将数据格式化成html格式,分析.net的内部格式。 数据:
经过多次尝试,总结出SDK工具能够解析的.必须满足如下格式:
格式规范:
可见,采集到的数据至少要包含以上内容。
下面是对应的格式:
depth,methodID,inTime,outTime,threadName,threadID
与Rhea 1.0相比,Rhea 2.0的性能损失明显减少,性能损失也从11.5%下降到3%。 效果如下:
3. 最佳实践
相比之下,它提供了更丰富的线下功能,包括:解决真实用户点对点的耗时问题反馈功能,解决产品、运营、QA同学外出查看场景的问题反馈功能。
总之,无论身在何处,性能反馈触手可及! 图中展示了完整的操作流程。
真实案例:抖音灰度版在线用户反映卡顿,使用功能包远程分析排查卡顿问题! 以下是用户合作返回的真实冻结数据,经过分析可以找到耗时调用点:
四、存在的问题
由于现阶段只收集了Java方法层的数据,在抖音启动IO耗时的优化工作中,无法提供哪些函数进行了IO操作,IO操作读/写了哪些文件,带来困难到优化工作。 难度较大。 另外,在一些复杂的场景下,只记录函数的执行时间,但由于多线程同步或系统IO等锁导致执行时间延长,无法准确定位。
针对以上问题,我们意识到一套优秀的工具需要集成更多的系统事件,于是工具进入第三阶段打磨。
第三阶段:动态综合刀具规划
Rhea 1.0和2.0在抖音前期的性能优化工作中取得了显着的成绩,但随着优化工作的深入,也暴露出了很多局限性和不便之处。
一方面,使用常规工具进行性能优化有很多局限性。 一是信息少。 默认只包含系统预设的耗时信息,不足以支持常规的耗时分析。 您需要手动调用 . 和 。 App端获取更多功能耗时信息的方法,为避免影响在线包大小,使用后需要手动移除。 二是本身性能损耗较大,尤其是当应用通过存根插入进行大量业务代码管理时,极端情况下性能损耗会超过50%。 三是完全依赖PC端工具进行抓取,不够灵活。 尤其是在需要能够稳定复现性能问题的场景下,对于只能在特定区域或者特定用户群中复现的问题,依赖研发或者测试走查,甚至是一些概率问题,无法直接高效获取有效信息用户反映 也未能获取到相应的信息,导致优化效率低下。
另一方面,与通过简单定制的耗时获取功能相比,虽然有显着的性能提升和更高的灵活性,但数据仅包含基本的耗时信息。 在一些复杂的场景下(比如耗时导致的锁),数据还是有限的。
以上工具均不能完全满足抖音开机、首次更新、低端机等核心场景的性能优化工作。 我们需要重新设计和规划更强大的动态集成工具来辅助性能分析。
该工具应该是非常灵活的,它可以不依赖于PC端的抓包脚本,并且可以支持在线和离线,并且可以在应用程序想要抓取数据时运行。 Rhea作为一个平台工具,还需要支持动态扩展,支持多个场景的配置和动态切换,可以收集任何需要的信息。 这个工具抓取的信息应该是全面的,它可以收集和跟踪各种信息,包括插桩、等锁信息、I/O信息、耗时等。 以统一的格式支持可视化、输出和排版,最终以兼容的结果展示和使用前端,尽量不改变用户习惯。 性能损失要低,以免偏离性能优化的方向。
因此,我们重新设计了新一代的分析工具:
整体上,App集成了Rhea SDK在打包时无限层级插入函数和耗时存根方法,并在运行时插入IO,,Lock等相关信息,支持动态配置,统一格式为,支持系统级的获取,以及App插入的信息,无需依赖PC即可获取,最终提供可视化展示。 具体实现如下:
1.不依赖PC爬取
为了实现不依赖PC的爬虫,我们有必要先了解一下实现机制。 首先,包含的数据源包括:
其中seo优化,用户空间的数据包括应用层的自定义、系统层的gfx渲染、系统层的锁相关信息等,最终通过调用SDK提供。 或者记录到同一个文件点/sys////中。 该节点允许用户层写入字符串,并会记录写入操作的时间戳。 当用户在上层调用不同的函数时,写入不同的调用信息,比如函数入口和出口分开写,这样就可以记录跟踪函数的运行时间。 在处理用户层的多个类别时,只需激活不同的TAG,如果选中,则激活并记录渲染事件。
内核空间中的数据主要是一些辅助分析数据freq, 等,常用的CPU调度等信息包括:
App可以通过直接读取/sys///cpu节点下的相关信息获取这些信息,而其他部分标识线程状态的信息只能通过或者adb获取,这些信息是不可获取的由一个统一的节点控制。 需要激活相应的事件节点来记录不同的事件。 内核在运行时,会根据节点的使能状态,将事件记录到缓冲区中。 例如激活线程调度状态信息记录,需要像下面这样激活相关节点:
events/sched/sched_switch/enable
events/sched/sched_wakeup/enable
激活后,可以得到线程调度状态的相关信息,例如:
最终,上述两类事件记录在内核态被收集到同一个缓冲区中。 PC端的工具脚本指定要抓取的类别等参数,然后在手机端触发//bin/打开对应文件节点的信息,然后就会读取到的缓存,生成纯信息信息,最后通过脚本转换成可视化的HTML文件。 大致流程如下:
因此,基于实现原理,我们同时参考了APP端直接采集的方案,实现了不依赖PC采集的方法。
我们获取.so对应的句柄,找到并对应对应的指针,设置为打开开关。 具体实现如下:
std::string lib_name("libcutils.so");
std::string enabled_tags_sym("atrace_enabled_tags");
std::string marker_fd_sym("atrace_marker_fd");
if (sdk < 18) {
lib_name = "libutils.so";
// android::Tracer::sEnabledTags
enabled_tags_sym = "_ZN7android6Tracer12sEnabledTagsE";
// android::Tracer::sTraceFD
marker_fd_sym = "_ZN7android6Tracer8sTraceFDE";
}
if (sdk < 21) {
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()));
接下来我们截取相应的信息dump到本地或者通过动态库中的hook方法上传到云端进行分析。 实现看起来像这样:
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;
}
2.提供更全面的信息 1.耗时锁
Java层的锁,不管是同步方法还是同步块,最终都会去到虚拟机的sum,其中实现了多种锁状态的切换,包括从无锁到轻锁,偏向和 in lock,发生竞争,自旋次数超过后,升级为 。 当前的艺术自旋并不是真正的自旋,而是主动让出CPU等待下一次调度。
首先要注意的是锁竞争升级为重锁后的耗时等待信息。 从 6.x 开始,这些信息将以一种方式输出到服务器。
但是如果想要 lock信息就需要做一些额外的工作,因为除了之外,还有一个g 变量来控制是否输出 lock信息。 该变量是虚拟机中全局变量的成员。 成员变量的值通常在虚拟机启动时确定。 默认情况下,在启动虚拟机的时候可以通过传递-:sys-参数来开启,但是作为一个普通的应用,我们是没有办法通过这种方式开启的,所以需要通过非常规的方式在运行时动态开启:
首先确认这个结构体的size和 从.x之后有没有变化; 如果没有变化,可以自己定义相同的结构体,因为里面都是原来的bool类型变量,不会引入其他依赖; 如果有,but ,我们要访问的成员的位置没有变,只是后来加入的成员,同样的结构也可以自己定义; 通过找到虚拟机的全局符号; 将其类型转换为预定义的结构体类型; 访问 g 成员并将其分配给 true; 光锁信息可以正常输出;
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态(不可中断的休眠状态,,一般我们用PS查看进程状态和它显示为D,所以俗称D态),这部分耗时占总启动时间的40%左右,为什么进程会被置于D态呢? 处于状态的进程通常是在等待IO,比如磁盘IO和其他外设IO。 正是因为没有收到IO响应,进程才进入状态。 因此,为了从状态中恢复进程,必须让进程等待。 IO恢复。 类似于以下内容:
但是当我们用它来优化的时候,我们只能得到上面内核态的调用状态,而无法知道具体的IO操作是什么。 因此,我们专门设计了一套获取IO耗时信息的方案,包括用户空间和内核空间两部分。
一种是在用户空间,为了收集需要的I/O耗时信息,我们在Hook I/O操作时使用标准的key函数族,包括open、read等,插入相应的埋入用于统计对应IO耗时的点数。 例如:
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;
}
第二个是在内核空间中,它提供了超出可以启用或直接支持的功能的附加功能,并包含一些对调试性能问题至关重要的高级功能(这些功能需要 root 访问权限,并且通常还可能需要新内核)。 因此,在此基础上,我们添加了显示自定义 IO 信息等功能。 在离线模式下,我们开启了节点下的/sys/////信息来收集IO相关的信息。
这个时候,我们追根溯源,先找到老妈,以及团队的所有开源项目。 它是生成其解析器的工具。 在 中,我们实现了一个跨平台的解析工具。 在此基础上,我们开发了Rhea工具脚本,将其转化为可显示的格式,用于快速诊断和发现IO性能。 瓶颈。
比如我们在线监控发现调用我们其中一个View方法的某个方法会出现ANR,离线抓包如下:
此时看到主线程处于D状态,无奈,又通过我们的Rhea工具,获取如下:
由于读取对应字体导致的IO耗时,此时我们很容易定位到问题所在。
3.耗时
在抖音启动性能优化的过程中,我们通常会遇到耗时问题。 这部分耗时通常占总耗时的30%左右。 在这种睡眠状态下,进程通常在等待锁。 或者通话很费时间。 通常在离线状态下,我们可以通过打开//节点获取到,但是在线上由于权限问题我们很难获取到这部分信息。 因此,我们使用Hook.so对应的方法来统计对应的调用时间。
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);
}
之后统计相应的耗时。 如果耗时超过了指定的阈值,则会打印相应的栈,辅助分析耗时问题。
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;
}
效果如图:
4.支持后续添加更多数据源
当然,仅支持上述信息并不能完全覆盖我们在性能优化过程中未来可能遇到的其他问题。 因此,我们支持动态配置的功能。 以后我们只需要简单的添加相应的配置项,其功能就可以快速方便的收集到我们需要的信息。
enum TraceConfigKey {
kIO = 0,
kBinder,
kThinLock,
kStopTraceUnhook,
kLockStack,
kKeyEnd,
};
5.无限级别的插桩获取函数耗时
限制stub插入的层级固然可以提高运行时性能,但是限制层级后会出现两个问题:
因此,在用户态,为了获取App的更多信息,便于性能优化。 我们采用不限制水平的仪器方案。 开发了一个在编译阶段不限制检测级别的插件。 通过静态代码检测,. 和 。 分别插入在App调用方法的开头和结尾。 效果如下:
三、优化和降低性能损失 1、优化插桩性能
在打桩阶段,我们做了以下优化:
2.优化App端启停性能
由于app端抓包的实现依赖于,所以我们参考.的实现。 但其实现存在动态库大、启动和关闭耗时等问题。 因此,我们进一步优化了App本地获取所依赖的动态库的大小和性能。 如下:
3.优化写入性能
因为在App方法中插入了很多信息,打开后,所有线程都会往文件中写入所有内容,这样会造成IO损失急剧增加,会掩盖真正的性能问题。 原因是所有线程都在短时间内写入文件。 写操作,同时竞争内核态pos锁,导致获取的文件不能真实反映性能问题,如下图:
因此,我们将原本在用户态直接写入内核态文件的文件拦截下来,缓存起来,然后在异步IO中转储。 既避免了大量用户态和内核态切换带来的上下文丢失,又避免了直接IO带来的IO丢失。 效果如下:
4.可视化
由于我们将用户态和内核态存放在对应的空间,原生的只能单独可视化,所以我们开发了统一集成的脚本工具,将多种信息转换成一个html文件。 浏览信息时,可以在(://)中可视化。
将来的计划
目前Rhea的支持还不完善; 性能优化不够完善,尤其是用来分析卡顿问题,需要定位几毫秒甚至更细粒度的时候,性能损失还是会有些大,一定程度上会偏向优化上面; 目前,更多的工具还是在线下使用。 由于太多影响包大小,我们的上线部分只能对小规模用户群体开放,不能全线上线定位大规模用户的线上性能问题。 未来,我们将着力解决上述问题,将工具做到极致。
概括
目前新一代分析工具Rhea的主要优势如下:
1、使用灵活,不依赖PC抓包脚本,支持多种在线离线模式和配置切换;
2. 支持无限级函数耗时检测、等待锁信息、I/O信息、耗时等多种信息的采集和跟踪;
3、兼容性高seo优化,支持API 16~30全机型爬取;
4.零侵入代码,通过完成插件的所有配置,没有直接调用任何代码。
加入我们
我们是负责抖音客户端基础技术能力研发和前沿技术探索的客户端团队。 我们专注于性能、架构、稳定性、研发工具、编译构建等方面的开发,保障超大规模团队的研发效率和工程质量。 ,将拥有6亿人使用的抖音打造成具有极致用户体验的产品。
If you are , to join the team, let us a - app. At , we have in , , , and . For , you can the : ; : Name- -- - / iOS.
to pay to " Team"
" "