引言

在上篇文章《钉钉 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 。
  • 如果最后一个异步消息丢失了(没有进队列或进了队列被误删),栅栏没有撤掉,就发生了 Barrier 消息泄露,那普通消息将会永远被拦住得不到执行,主线程将永远只会执行异步消息,从而导致 ANR 。
  • Barrier 机制是系统内部机制,通常不会有问题,但是可能有一些错误的业务场景会导致这类问题,比如子线程操作 UI,引发线程安全问题,会概率性导致 Barrier 消息泄露。

Barrier 消息泄露检测机制

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.makeAndAddView

一个 Android Message 任务的聚合签名由三部分组成:
  • 归因类型:导致 ANR 的主要原因类型。
  • 消息信息:具体包含:Handler 类信息,Runnable 类信息,what 信息。
  • 关键函数信息:依据提取出来的关键函数信息可以进一步拆分,将同一个函数导致的 ANR 问题,聚合在一起。

4. ANR 归因计算

ANRCanary 信息的生成时机是在用户发生 ANR 时,这时拿到的第一手资料包括:历史任务,当前 Running 任务,Pending 消息列表相关的各种信息。
ANR 归因计算的目标就是基于这第一手资料,将导致 ANR 的原因确定下来。

5. 关键函数提取

一个主线程任务(比如 Activity 启动等)执行涉及的代码可能会非常多,不同用户导致消息执行耗时的原因可能也不尽相同,需要基于堆栈比较,得出最耗时函数,称之为关键函数。

5.1 样例说明

为了方便说明,先将问题简化为假设所有的堆栈深度为 10 ,且堆栈之间的采样间隔是一样的。
  • 样例1:
    • 假设一个任务里面有 5 份堆栈,前两个堆栈相同的深度是 8,后三个堆栈相同的深度也是 8 。
    • 相同的是深度,后三个堆栈的耗时更长。
    • 则最耗时的函数为后三个堆栈里深度为 8 的那个函数。
  • 样例2:
    • 假设一个任务里面有 4 份堆栈,前两个堆栈相同的深度是 5,后两个堆栈相同的深度是 8 。
    • 相同的是耗时,后两个堆栈的深度更深。
    • 越深的函数,和业务代码的关联度更高,则取后两个堆栈里深度为 8 的那个函数。
  • 样例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。

7. 后续

有了 ANR 归因监控平台之后,就可以从头部问题开始治理钉钉的 ANR 问题。
接下来下篇文章介绍 ANRCanary 在钉钉的 ANR 治理实战中遇到的各种案例 Case,看看实际效果如何。

参考资料

[1]
VMStack 源码: https://cs.android.com/android/platform/superproject/+/master:libcore/libart/src/main/java/dalvik/system/VMStack.java;l=35?q=VMStack&sq=&ss=android%2Fplatform%2Fsuperproject
[2]
AnnotatedStackTraceElement 源码: https://cs.android.com/android/platform/superproject/+/master:libcore/libart/src/main/java/dalvik/system/AnnotatedStackTraceElement.java
继续阅读
阅读原文