引言
在上篇文章《钉钉 ANR 治理最佳实践 | 定位 ANR 不再雾里看花》中介绍了因为 ANR Trace 刻舟求剑的问题,导致 ANR 监控平台中排名第一的往往都是 nativePollOnce。也说明基于 ANR Trace 里的堆栈进行聚合并不能定位到 App 的头部 ANR 问题。
本文将重点介绍 ANRCanary 的 ANR 归因算法和 ANR 归因聚合上报的能力,帮助研发人员更快的分析和定位头部 ANR 问题。
1. 术语表
2. 其他 ANR 原因
在 App 的运行环境中,主线程并非独立存在的,因此导致 ANR 的原因也不一定都是长耗时主线程任务。接下来聊聊钉钉遇到的其他原因导致 ANR 的两种情况。
2.1 线程死锁检测
线程死锁是导致 ANR 的原因之一。两个子线程发生死锁会产生连锁反应,可能会让主线程进入阻塞状态,从而导致 ANR 。
背景知识
如上图所示,线程死锁的原因,通常是两个或多个线程在锁操作的过程中,出现了循环等待的情况而导致的。
所以关键点是:拿到线程的持有锁和等待锁信息,再配合有向无环图算法,检测是否存在循环依赖,就可以做到死锁检测。
获取线程锁信息
先来看 VMStack:VMStack 源码[1]
/** * @hide */ public final class VMStack { ...... /** * @hide */ @SystemApi(client = MODULE_LIBRARIES) native public static @Nullable AnnotatedStackTraceElement[] getAnnotatedThreadStackTrace(Thread t); ...... }
系统隐藏类 VMStack#getAnnotatedThreadStackTrace()
接口详细定义如上图所示,基于该接口可以获取线程的 AnnotatedStackTraceElement[]
。
接下来,再看看 AnnotatedStackTraceElement: AnnotatedStackTraceElement 源码[2]
* * A class encapsulating a StackTraceElement and lock state. This adds * critical thread state to the standard stack trace information, which * can be used to detect deadlocks at the Java level. * * @hide */ @SystemApi(client = MODULE_LIBRARIES) public final class AnnotatedStackTraceElement { private StackTraceElement stackTraceElement; private Object[] heldLocks; private Object blockedOn; }
隐藏类 AnnotatedStackTraceElement
接口详细定义如上图所示,系统定义这个类的最初目的也是为了做死锁检测。
其中:成员变量 heldLocks
为线程持有锁对象数组,成员变量 blockedOn
为线程等待锁对象。
通过反射手段,可以获取这些锁信息,然后基于这些锁信息就可以进行死锁检测。
死锁检测完整流程
线程死锁检测的整个过程详细描述如下:
- 死锁检测模块获取到所有线程对象之后,以线程对象为参数,通过反射机制调用
VMStack#getAnnotatedThreadStackTrace()
接口。会得到线程的AnnotatedStackTraceElement
数组。 - 死锁检测模块在拿到所有线程的
AnnotatedStackTraceElement
数组之后,死锁检测模块将其封装成 Node 集合,Node 里包含锁之间的依赖关系:持有锁对象依赖等待锁对象。 - 死锁检测模块将 Node 集合给到有向无环图模块进行环路检测。
- 有向无环图模块会返回环路检测结果。死锁检测模块如果发现存在环路,则判断为存在死锁。
案例分享
子进程线程死锁导致主进程 ANR
{ "case1":{ "threadName":"thread-1", "threadStackList":[ "com.alibaba.dingtalk.android.o.a(Unknown Source:???)", "- waiting on <90707987> (a com.alibaba.dingtalk.android.o)", "com.alibaba.dingtalk.android.q.a(SourceFile:???)", "- locked <106576464> (a com.alibaba.dingtalk.android.v)", "com.alibaba.dingtalk.android.v.a(SourceFile:???)", "- locked <106576464> (a com.alibaba.dingtalk.android.v)", "com.alibaba.dingtalk.android.xxx.hta(SourceFile:???)", "com.alibaba.dingtalk.mp.service.psc$b$b.run(SourceFile:???)", "android.os.Handler.handleCallback(Handler.java:900)", "android.os.Handler.dispatchMessage(Handler.java:103)", "android.os.Looper.loop(Looper.java:219)", "android.os.HandlerThread.run(HandlerThread.java:67)" ] }, "case2":{ "name":"thread-2", "threadStackList":[ "com.alibaba.dingtalk.android.r.a(SourceFile:???)", "- waiting on <106576464> (a com.alibaba.dingtalk.android.v)", "com.alibaba.dingtalk.android.r.a(SourceFile:???)", "com.alibaba.dingtalk.android.o.a(SourceFile:???)", "- locked <90707987> (a com.alibaba.dingtalk.android.o)", "com.alibaba.dingtalk.android.r.b(SourceFile:???)", "com.alibaba.dingtalk.android.o$h.b(SourceFile:???)", "com.alibaba.dingtalk.android.r0$b.b(SourceFile:???)", "com.alibaba.dingtalk.android.d0$d.run(SourceFile:???)", "android.os.Handler.handleCallback(Handler.java:900)", "android.os.Handler.dispatchMessage(Handler.java:103)", "android.os.Looper.loop(Looper.java:219)", "android.os.HandlerThread.run(HandlerThread.java:67)" ] } }
ANRCanary 收集到死锁信息示例如上:
- 该案例属于非常经典的案例,我们从线上监控到主进程发生 ANR,从 ANR Trace来看,都是卡在跨进程通信。
- 由于子进程没有发生 ANR,所以缺乏子进程的 Trace 信息,无法定位到跨进程通信耗时的根本原因。
- 但是从 ANRCanary 的死锁监控日志中,发现子进程线程死锁的上报记录。
- 如上所示,两个线程进入循环等待的状态:
- 线程 thread-1 持有锁 ID:106576464, 等待着锁 ID:90707987
- 线程 thread-2 持有锁 ID:90707987,等待着锁 ID:106576464
- 在解决子进程线程死锁的问题之后,主进程的 ANR 问题也得到解决。
2.2 Barrier 消息泄露
Barrier 消息泄露是导致 nativePollOnce ANR 的原因之一,同时一旦发生 Barrier 消息泄露,用户会连续 ANR ,非常容易引起客诉。
Android 的 Barrier 消息机制
- Android 的 Barrier 消息是消息队列中的一类特殊消息,并不能被执行,是为了让主线程优先执行 UI 刷新类消息(也称为异步消息)而存在的。
- Barrier 消息像一道栅栏,将消息队列里的普通消息先拦住,等最后一个 UI 刷新类消息(也称为异步消息)执行完以后,撤掉栅栏,普通消息(包括会导致 ANR 的消息)才得以继续执行。
- 如上图所示,在 Barrier 消息的作用下,消息队列中的任务执行顺序变为:1,4,5,2,3,6 。
ANRCanary 的消息泄露检测机制具体实现如下:
- 独立子线程定时触发,当主线程中消息队列的第一个消息为 Barrier 消息,且该 Barrier 消息阻塞超过 10 秒,认定为疑似 Barrier 泄露,启动校验机制。
- 校验机制会往主线程依次分别发送三个异步消息,三个同步消息,共 6 个消息。
- 其中异步消息会对一个校验值 +1, 同步消息会将校验值赋 0 。
- 如果前面的 Barrier 消息没有发生泄露,则异步消息和同步消息会依次执行,校验值最终为 0 。
- 如果 Barrier 消息发生了泄露,则只有异步消息会执行,校验值最终会变为 3 。
- 当校验值变为 3 ,则可以认定为发生了 Barrier 泄露,检测机制会执行该 Barrier 消息的移除,自动修复该异常。
3. 聚合签名
如果要建一份基于 ANR 归因的大盘报表,需要从用户每次 ANR 信息中提取出一个 KEY 字符串,称之为聚合签名,对于聚合签名的规则要求基本如下:
- 不同的 ANR 原因,聚合签名不相同
- 不同用户,相同的 ANR 原因,聚合签名相同
- 不同 App 版本,相同的 ANR 原因,聚合签名相同
- 聚合签名数量不能无限扩张
下面以一个最复杂的 Huge 类型的 Android Message 任务为例,说明一下聚合签名的组成部分。
huge|Choreographer$FrameHandler|Choreographer$FrameDisplayEventReceiver|0|andorid.widget.ListView.makeAn
一个 Android Message 任务的聚合签名由三部分组成:
- 归因类型:导致 ANR 的主要原因类型。
- 消息信息:具体包含:Handler 类信息,Runnable 类信息,what 信息。
- 关键函数信息:依据提取出来的关键函数信息可以进一步拆分,将同一个函数导致的 ANR 问题,聚合在一起。
4. ANR 归因计算
ANRCanary 信息的生成时机是在用户发生 ANR 时,这时拿到的第一手资料包括:历史任务,当前 Running 任务,Pending 消息列表相关的各种信息。
ANR 归因计算的目标就是基于这第一手资料,将导致 ANR 的原因确定下来。
5. 关键函数提取
一个主线程任务(比如 Activity 启动等)执行涉及的代码可能会非常多,不同用户导致消息执行耗时的原因可能也不尽相同,需要基于堆栈比较,得出最耗时函数,称之为关键函数。
5.1 样例说明
为了方便说明,先将问题简化为假设所有的堆栈深度为 10 ,且堆栈之间的采样间隔是一样的。
- 样例3:
- 假设一个任务里面有4份堆栈,4 份堆栈的相同深度为5,中间两个堆栈相同的深度为 8 。
- 左边函数更耗时,右边的函数更深,此时应该如何取舍呢?
5.2 归一化权值计算
- 将耗时(duration)和栈深度(deep)两个数据,分别作为 X 轴和 Y 轴。
- 以样例 3 为例,最大耗时值为 4 (因为是 4 份堆栈),最大深度为 10 。
- 则左边的函数,耗时为 4 ,归一化之后为 1.0 ;深度为 5 ,归一化之后为 0.5 。
- 则右边的函数,耗时为 2 ,归一化之后为 0.5 ;深度为 8 ,归一化之后为 0.8 。
- 分别计算左边函数和右边函数到原点的距离,由此得出左边函数比右边函数权值更大,因此应该取左边的函数。
5.3 无关键函数
聚合签名中没有关键函数的情况,主要有两种:
- 如果该 Huge 任务,只有一个或没有堆栈,无法进行堆栈比较,自然就没有关键函数。
- 如果关键函数就是消息执行的根函数,比如:Handler#handleMessage 或 Runnable#run ,也应该当做没有关键函数处理。
6. ANR 归因监控平台
基于聚合签名进行聚合计数并排序,可以得出 App 中头部 ANR 问题的排名。由于该排名方式和Crash SDK 的 ANR Trace 堆栈聚合的排名方式不相同,因此排名第一的将不再是 nativePollOnce。