本文在 2024 年初最新一期『抖音客户端基础技术大揭秘』技术沙龙活动中已做过专题分享,本次将内容重新整理文章进行分享。公众号后台回复技术沙龙可查看沙龙回放及 PPT~
抖音作为一个超大型的应用,我们在 ANR 问题治理上面临着很大的挑战。首先对于存量问题的优化,由于缺少有效的归因手段,一些长期的疑难问题一直难以突破解决,例如长期位于 Top 1 的 nativePollonce 问题。同时我们在防劣化上也面临很大的压力,版本快速迭代引入的新增劣化,以及线上变更导致的激增劣化,都需要投入大量的人力去排查定位,无法在第一时间快速修复止损。
ANR 原理简介
既然我们要建设的是 ANR 归因平台,首先需要了解下什么是 ANR ?它是 Android 系统定义的一种“应用程序无响应”的异常问题,目的是为了监控发现应用程序是否存在交互响应慢或卡死的问题。从用户的视角来看,发生 ANR 时设备上会出现提示应用无响应的弹窗,甚至在一些机型上可能就直接闪退了。所以 ANR 与其他崩溃问题一样,是一种会对用户体验造成严重打断的异常问题。
接下来我们从系统的设计原理来看下为什么会发生 ANR ?以一种常见的广播超时引起的 ANR 为例,首先系统 AMS 服务会通过 IPC 方式将一个有序广播发送给应用进程,并在同时启动一个超时监控。应用进程在 Binder 线程接收到广播之后,会将其封装成一个消息 Message 加入到主线程的消息队列里等待执行。正常情况下,广播消息在应用内都会得到及时响应,然后通知系统 AMS 服务取消超时监控。但是在一些异常的情况下,如果在系统设置的超时到来之前,目标消息还没有调度执行完成的话,系统就会判定响应超时并触发 ANR。
归因方案现状
当前业界针对 ANR 问题的归因手段有哪些?第一种是传统归因方案:基于系统生成的 ANR Trace 和 ANR Info 来定位问题原因。这里的 ANR Trace 是在问题发生时系统通知应用自身 dump 采集的各个线程的堆栈以及状态信息,而 ANR Info 中则包括了 ANR 原因、系统以及应用进程的 CPU 使用率 和 IO 负载等信息。对于像下图中这类由于当前消息严重耗时或卡死引起的 ANR,这种系统原生的方案可以帮助我们快速的定位到问题堆栈。
但是它也存在一个明显的问题,从前面的原理分析可以知道,广播等系统组件消息在加入到主线程之后,是按照它在消息队列中的先后顺序来执行的,所以有可能是之前的历史消息存在严重耗时从而引起的问题。这种情况下在 ANR 实际发生时系统抓取的堆栈很有可能就已经错过了问题的现场,基于这样的数据进行归因得到的结果也是不准确的。
第二种是慢消息归因方案:通过监控主线程消息的执行情况,并结合耗时消息的采样抓栈来定位问题原因。这个方案解决了前面传统归因方案中存在的问题,提供了一种更细粒度的监控和归因能力,可以同时发现当前和历史的耗时消息,以及其中可能存在的耗时问题堆栈。
但这个方案也同样存在的一些不足之处, 因为对于像抖音这样的大型应用来说,ANR 通常是由于各种复杂的综合性因素导致的,包括子线程 / 子进程 CPU 抢占、应用 / 系统内存不足等也都会对主线程的执行效率造成影响,间接导致主线程整体都变慢了。在这种情况下,主线程的慢消息或堆栈可能并不是问题的根本原因,同样以此得到的归因结果也是不全面的。
所以总结一下目前的现状,现有的 ANR 归因方案存在以下几个痛点问题:首先是归因不准确,归因结果难以消费,不能真正解决问题。其次是归因能力少,对于复杂问题难以定位根本原因。最后是归因效率低,人工排查周期长。
接下来重点介绍下 ANR 归因平台的建设思路,平台归因体系主要围绕以下三个方向进行建设:
单点问题归因:首先需要对单个 ANR 问题实现精准的归因,这也是我们整个归因体系建设的重点和基础。
聚合问题归因:其次是线上大数据的聚合问题归因,帮助我们聚焦 Top 重点问题。
劣化问题归因:最后线上灰度以及全量版本劣化问题的自动归因,提升新增 / 激增问题的解决效率,目前正在建设中,本次分享就不展开介绍了。
单点归因思路
单点 ANR 问题的归因可以分为三个步骤,首先需要从原理出发明确 ANR 问题区间;接下来对问题进行粗归因,也就是一种定性的分析,比如说是主线程阻塞卡死、还是 CPU 抢占或是内存异常导致的问题;最后就是进一步进行细归因,也就是需要定位到具体的问题代码,能实际指导我们消费并解决问题。
问题区间
首先从 ANR 问题的原理出发,来分析一下如何大致确定 ANR 问题区间。我们同样还是以上面的广播消息超时引起的 ANR 为例,问题产生的时间顺序为:从系统 AMS 服务发送有序广播并启动超时监控开始,到应用进程将该广播消息加入到主线程消息队列,并按照队列中的先后顺序等待调度执行。当系统进程的超时时间结束前,对应的广播消息还没有执行完成并通知系统,系统就会判定响应超时并触发 ANR。这里我们可以以 ANR 实际发生时间为结束点,往前回溯对应的超时时间(这里不同的 ANR 原因会对应不同的超时时间设置),也就是后续需要诊断分析 ANR 问题区间范围。
粗归因
在明确 ANR 问题的时间范围后,我们需要从技术角度来拆解下,如何进行粗归因的定性分析。从上面的原理分析可以推导出,引起 ANR 的根本原因就是系统组件消息(包括 input 事件)在应用侧没有得到及时的执行。而我们知道这些关键目标消息都是在主线程中进行消费处理的,所以这里的关键点就是 ANR 区间内之前的这些消息为什么执行耗时 ?从系统的角度来看,所有代码逻辑的执行可以分为 On-CPU 和 Off-CPU 两种情况:
On-CPU:对应 Running 状态,即当前任务正在占用 CPU 资源进行计算处理。已知 计算耗时 = 计算量 / 计算速度,这里计算速度会受到 CPU 硬件本身的限制,比如 CPU 核心频率以及当前运行在大核或小核上。另一个跟应用自身关系比较紧密的就是计算量,例如主线程在执行 CPU 密集型的操作,比如 JSON 序列化 / 反序列化,或是在处理大量的高频业务消息。
Off-CPU:包括 Runnable 和 Sleep 两种状态。Runnable 代表任务所需的资源已就绪,正在 CPU 运行队列上等待调度执行,这里除了受到系统本身的调度策略的影响之外,也跟当前同样已就绪并等待调度的任务数量有关。如果子线程或子进程有很多 CPU 耗时任务在等待执行的话,因为总的计算资源是有限的,互相之间频繁的抢占也会影响主线程的执行效率。Sleep 代表任务在阻塞等待资源,比如等待 Lock、IO、同步 Binder 以及内存 Block GC 等。通常情况下我们应该避免在主线程发生这类 Block 阻塞问题,同时这也是比较常见的一类解决卡顿或 ANR 的优化手段。
下面我们来看下第一种主线程异常消息直接引起的 ANR,它可能是由于当前消息严重耗时或卡死导致的,也可能是由于之前的一个或多个历史耗时消息引起的 ANR。
第二种就是后台任务 CPU 资源抢占引起的ANR,它会间接影响主线程的执行效率。从下图中可以看到在子线程 CPU 负载变高之后,主线程的整体性能开始下降变慢,这种情况下就会更容易发生 ANR。最典型的就是在冷启动的场景下,通过降级或打散 CPU 耗时的后台任务,我们已经验证可以有效的降低 ANR 率以及缩短启动首刷耗时。
最后一种内存等资源异常问题,例如虚拟机 Java 内存不足时,GC 线程就会开始变得活跃并进行频繁 GC,这同样也会抢占主线程 CPU 资源或 Block GC 等待,从而导致 ANR 问题的发生。对于抖音这样的视频类大型应用,线上内存问题导致的卡顿或 ANR 问题的占比较高,目前也在专项治理优化中。
基于以上的演绎推理,总结一下我们的归因思路主要包括以下几类:第一,主线程本身的异常问题,例如存在严重的阻塞等待或者 CPU 繁忙等问题。第二,后台任务抢占 CPU 资源导致的异常问题。最后,内存 / IO 等系统资源不足导致的异常问题,目前正在探索中,本次分享就不展开介绍了。
细归因
主线程消息异常
首先来看主线程消息异常的归因思路:我们需要先对主线程消息进行监控,这里包括三种情况,已经执行完成和正在执行中的消息以及消息队列中待执行的消息。对于已经或正在执行的消息,我们主要关注它们的耗时情况,通过分析系统源码我们可以知道,主线程消息队列里处理的消息一共包括三种:Java 消息、Native 消息和 Idle Handler。而对于待执行的消息,我们主要关注其中的数量,从而判断是否存在大量消息堆积的异常问题。
对于主线程异常消息的问题类型,第一类就是耗时消息,即在问题区间内存在一个或多个耗时大于阈值的慢消息。另一种是高频消息,也就是存在出现次数以及累计耗时超过一定阈值的高密度消息。这里通过对业务消息的 target、callback 和 what 等信息进行聚合分析,有时也可以协助定位到导致问题的业务方。
但是仅仅找到异常消息并不能直接帮助我们解决问题,还需要对消息的耗时原因做进一步的归因分析,找到其中引起消息耗时的问题函数。接下来介绍一下线上 Trace 数据采集方案,这里我们采取类似 Matrix 方案,通过 ASM 字节码工具,在编译时对应用内的业务代码进行插桩,也就是在函数的入口和出口插入一行统计代码,来记录当前方法的运行耗时。为了降低采集时的性能损耗,将方法 begin / end 状态标识、当前方法 ID 以及时间戳的 Diff 相对值,合并使用一个 64 位的变量来记录。在 ANR 等异常问题发生时,再将 Ring Buffer 中记录的数据进行上报,在后端数据链路处理生成对应的 Trace 堆栈,并提供给后续的诊断算法进行自动化分析,以及 Perfetto 人工可视化分析的需求。
但对于抖音这样的大型应用来说,插桩方案也会有一些弊端,当插桩的函数过多时,会对包体积以及性能产生负面的影响。为了尽可能降低监控工具对线上用户体验的影响,我们提出了精准插桩的方案!因为结合对于线上问题的分析诉求来看,我们重点关注的是上层执行的业务函数,比如页面生命周期、业务消息等入口方法,以及底层那些可能耗时的业务函数。所以我们基于静态代码分析的基础能力,分析提取出带有耗时特征的函数来进行插桩,例如下面表格中带有锁关键字的函数、存在 Native / IO 等调用的函数以及特别复杂的大方法等。在这个精准插桩的优化策略之下,大幅减少了约 90% 的插桩数量!
在大幅精简插桩数量之后,我们在消费线上数据时又面临到一个问题,就是仅有插桩的堆栈信息太少,有时难以帮助我们实际定位到发生问题的代码。为了解决这一痛点问题,我们又设计了插桩和抓栈数据拟合的优化方案,其原理就是通过对耗时超过一定阈值的慢函数进行抓栈上报,然后在服务端再将插桩与抓栈数据根据时间点进行拟合,补齐其中缺失的业务和系统堆栈信息。如下图中所示,当插桩堆栈中连续两个节点能在抓栈数据中找到最小间距的数据并对齐时,抓栈数据中对应层之间的数据将会被补全到到插桩数据中,生成新的拟合堆栈数据,图中的 D 就是被补全的数据。
在获取到线上问题发生时的详细 Trace 数据后,我们需要进一步找到其中引起耗时的问题函数,常见的问题函数类型包括以下两种:
慢函数:是指函数的执行耗时超过一定的阈值;并且从实际可消费性的角度来看,我们预期是要能找到更靠近叶子节点的业务慢函数。所以需要根据实际调用堆栈的情况,进一步剔除底层的基础库或工具类方法,以及存在相同调用链路的亲缘父子节点,来找到最合适的慢函数问题。
高频函数:对应就是单个虽然并不耗时,但是由于次数很多,累计执行耗时超过阈值的函数;根据我们以往的经验来看,对于高频函数的优化通常也能带来不错的收益。
当然仅仅知道函数慢也是不够的!我们结合一个线上实际的示例来看,通过 Trace 可以发现标记红框的这里有一个业务函数执行比较耗时,但是这里为什么会耗时呢?我们要如何进行优化呢?目前仅有的堆栈信息并不能满足我们的归因需求。之后通过分析业务代码并补齐了缺失的“关键”信息之后,我们就可以明确知道这里耗时的原因是由于锁竞争导致的,并且我们还进一步补充了当前持有锁的线程以及堆栈等重要信息。
为了更好的对慢函数的耗时进行归因,除了前面采集的 Trace 堆栈数据之外,我们还需要补充一些关键的上下文信息,这里就统称为精细化数据。比如上面提到的锁,还有函数的 CPU-Time、Binder 调用的名称、IO 读写的文件路径和大小、绘制渲染相关的 RenderNode,以及内存 Block GC 等相关信息。
所以回顾总结一下主线程消息异常的归因流程,首先需要明确当前 ANR 的问题区间,然后找到其中的异常消息(耗时消息或高频消息),进一步下钻找到其中引起耗时的问题函数(慢函数或高频函数),最后再结合精细化数据对其耗时原因进行归因。
后台任务异常
接下来再看下后台任务 CPU 异常的归因思路:首先我们需要明确是否存在后台任务对主线程 CPU 资源产生抢占的问题,这里可以结合主线程的非自愿上下文切换以及调度状态的信息,来观测主线程是否有较多的时间都花费在等待系统调度上。如果存在明显的异常情况,再结合系统和应用的 CPU 使用率信息,可以进一步先定位到是应用的子线程 / 子进程,或是关键系统进程(如 dex2oat 进程等),还是其他应用进程造成的 CPU 资源抢占。
对于应用自身造成的 CPU 抢占问题,我们需要进一步定位到具体的问题代码。所以我们在之前的 Trace 采集方案的基础上,进行了重大的升级改造,扩展支持了全线程的 Trace 数据采集。
由于后台任务我们重点关注的是 CPU-Time 耗时,所以在采集函数的 Wall-Time 执行耗时之外,同时也支持函数粒度的 CPU-Time 耗时采集,并在后端进行处理关联。这里出于性能损耗上的考虑,我们会进一步精简控制同时需要采集 CPU 时间的插桩函数数量,例如仅对系统的生命周期方法,以及子线程的 Runnable、Callable 以及二方 / 三方的任务框架入口方法,以及少量的关键特征方法才开启,并且会设置最小的采样间隔时间。
对于 CPU-Time 的获取一般有两种方式:一种是通过定期读取 proc 文件系统下的文件来解析获取,这种方式如果想要精确到方法级别需要相当高的读取频率,这种高频率读取文件并解析的性能损耗很高,不适合在线上方法级别的 CPU-Time 采集;另一种则是通过 Android 提供的 方法或者 Native 层的 方法获取当前线程的 CPU-Time,这种方式比较适合采集方法级别的 CPU 耗时,在方法开始和结束时分别调用前述方法再计算差值即可,因此我们线上采集选择的也是这个方案。
同样回顾总结一下后台任务异常的归因流程,在 ANR 的问题区间内,首先需要明确是否存在对主线程执行效率产生明显影响的 CPU 资源抢占,如果是应用自身的问题,先找到应用内 CPU 负载较高的线程或进程,进一步定位到对应异常阶段里的后台任务代码,最后再结合精细化数据对其 CPU 耗时原因进行归因。
聚合归因思路
基于以上对 ANR 单点问题进行诊断分析后产出的归因结论,我们可以进一步结合线上大数据进行聚合归因,从而帮助我们更好的聚焦到 Top 重点问题的优化上。
归因标签
首先是对归因标签的聚合分析,主要包括以下几类:
粗归因标签:针对 ANR 问题定性的归因分类标签,包括主线程阻塞、高频消息、CPU 抢占、堆内存不足等。
细归因标签:针对细归因定位到的问题代码的精细化归因标签,包括主线程锁、IO、Binder 或者 Block GC 阻塞耗时等。
业务归因特征:发生 ANR 时用户所在场景页面等业务维度的特征标签,有时也可以辅助快速定位到问题相关的业务方。
如下图所示,通过对以上不同归因标签的多维聚合分析,可以帮助我们对线上 ANR 问题的特征分布有一个全局的了解和认知,同时也能指导我们在归因能力上下一步需要重点攻坚的方向。
异常问题
其次是对细归因产出的异常问题进行聚合分析, 目前主要包括主线程异常函数、后台任务以及内存这三个维度。聚合后的问题列表支持渗透率、耗时均值、PCT 50 / 90 耗时以及场景等维度的统计数据,可以帮助我们识别出线上整体占比较高或耗时特别严重的这类问题。
在进入异常函数的归因详情页之后,可以查看当前问题函数在线上大数据聚合后的火焰图,其中 Caller 堆栈代表上层不同业务方的调用次数分布情况,而 Callee 堆栈则是所有子函数的耗时分布情况。
最后,基于以上的归因标签、异常问题以及业务归因信息,平台会产出一个对 ANR 问题最终的归因结论以及对应的综合置信度评分。
接下来再介绍一下平台目前的落地效果:首先这个案例是一个启动阶段的 ANR 问题,我们从主线程 Trace 中可以分析定位到主线程的耗时函数,并且通过细归因标签的结果,可以明确知道是一个锁耗时的问题。进一步结合锁的详情信息进行下钻分析,通过当前子线程持有锁的聚合堆栈,发现是由于某个后台任务的执行时机变更提前了,从而与主线程某个任务产生了锁竞争冲突,导致主线程长时间的阻塞等待引起了 ANR。
第二个案例是一个主线程高频消息问题,从定位到的问题函数可以发现其调用的非常高频,平均在一次 ANR 里会出现了上千次!通过进一步分析发现是由于某个业务的逻辑 Bug,导致在特定场景下会发送大量的重复消息,导致主线程消息队列堵塞引起的 ANR 劣化。
第三个案例是一个子线程高频任务问题,通过定位到的后台任务可以发现其在多个子线程的出现次数都非常高频,并且累计的 CPU 耗时也比较高。进一步分析发现也是某个业务的 Bug 问题,在特定场景下会向子线程发送大量的重复任务,并且由于这些异步任务内部还会给主线程 Handler 发送或删除消息,所以除了会抢占 CPU 资源之外,还会间接导致主线程消息队列在遍历取消息时会发生高频的锁竞争耗时,两个因素叠加之下引起的 ANR。
最后总结下平台过去一年的阶段性成果,总共累计发现了有效问题 88 个,修复并优化其中 56 个,同时协助抖音 / 抖极的大盘 ANR 率分别下降了 -13.06% 和 -8.70% ,并取得了不错的业务收益。
抖音 ANR 自动归因平台未来的规划主要包括以下三个方面:
归因体系:持续打磨监控能力和归因算法,包括探索完善 Java / Native 内存、绘制渲染以及 Native Trace 等方向上的精细化归因能力。
防劣化体系:持续优化线上劣化归因和消费流程,提升线上自动归因准确率,以及劣化问题的消费解决效率。
专家系统:沉淀专家经验,并尝试结合大模型等新技术,通过对技术特征和业务特征进行精细化聚合分析,进一步提升问题发现和解决效率。