Android Handler(一) 同步屏障泄露导致页面假死

系列文章


背景:

在项目中,页面A中有一个计时器,显示了会议持续时间:10:04。

在测试阶段,QA提了个bug,说页面卡死了,时间卡在了10:03秒,并且按返回键没有响应。但是会议的声音还在持续。

QA比较有经验,他在这个页面停留了30秒以上,都没有出现ANR的弹窗,进程也没有崩溃。最终,他杀掉了进程,重新进入会议后,一切又恢复了正常。

这是个很有意思的bug,因为最终分析下来,他不是ANR,而是一种假死现象。根本原因是Handler的同步屏障Barrier导致的。

一、页面实现逻辑

这个会议页面相对比较简单,使用SurfaceView展示视频流,AudioManager播放音频,底部有个TextView展示会议时间。

会议时间是用HandlersendEmptyMessageDelayed实现的,每次间隔1秒处理时间。

kotlin 复制代码
private val mHandler by lazy {
    Handler(Looper.getMainLooper()) {
        when (it.what) {
            MSG_REFRESH -> {
                refreshTime()
            }
        }
        false
    }
}
kotlin 复制代码
var duration = 0L
fun refreshTime() {
    WaveLog.i(TAG, "refreshTime")
    /// 处理时间加1秒的逻辑,并设置到textView上面
    duration = duration + 1000L
    /// ......
    /// 进行下一次的刷新
    mHandler.removeMessages(MSG_REFRESH)
    mHandler.sendEmptyMessageDelayed(MSG_REFRESH, 1000L)
}

二、分析问题

根据过往经验,这种页面卡死的问题,一般有两种情况:

  1. 页面ANR
  2. 页面A上面覆盖了透明Activity/View/Dialog,导致页面失焦

通过日志回捞,获取到了对应设备的日志。

1.ANR分析

起初方向是往ANR上分析的,但是在日志中,并没有找到ANR、进程重启(进程号变更)相关的日志。

这里甚至怀疑,页面确实ANR了,但是厂商魔改导致没有真正的崩溃,但是没有相关证据,无法继续下去。

2.页面失焦

之前遇到过,透明View覆盖导致返回失效的问题,所以也往这个方向排查。经过代码确认,此会议页面本身没有弹出透明Activity的业务,也没有导致失焦的业务。

所以这个可能性也被排除。

接下来只能继续分析日志,从日志里面查找蛛丝马迹。

在分析日志时,发现在问题发生后,时间刷新相关的日志refreshTime没有再输出,这个符合预期。

但是后面还有大量日志在输出,其中甚至有主线程的日志在输出,这更加说明,这不是ANR,因为主线程没有被阻塞。

其中,有业务在主线程检查登录态的逻辑,使用的协程调度器的MainScope实现的,这个checkLoginStatus的日志一直在输出。

kotlin 复制代码
/**
 * 启动登录态定时检测
 */
private fun checkLoginStatus() {
    Log.i(TAG, "checkLoginStatus")
    MainScope().launch {
        /// 检查登录态是否失效
        /// ......

        /// 每1秒检查一次
        kotlinx.coroutines.delay(1000L)
        checkLoginStatus()
    }
}

从日志中可以看到,在问题发生前,两个日志时每隔1秒交替输出的。问题发生后,只有checkLoginStatus在输出。

三、协程调度器

既然MainScope()的代码还能继续执行,那说明他有特殊之处。目前只有这条线索,所以需要分析这个协程作用域的实现原理。下面是他的源码:

MainScope指定了SupervisorJob() + Dispatchers.Main

其中SupervisorJob的作用是控制协程异常范围,因为协程的异常天然具有传播性。加上SupervisorJob,当某个子Job发生异常后,不会导致整个MainScope下所有的任务被取消。

Dispatchers.Main是协程提供的调度器,可以将作用域下的代码运行在Android主线程上。协程调度器属于协程拦截器,是协程上下文的一种。在不同的平台中有不同的实现,Dispatchers.Main是Android平台的一种特殊实现。

2.协程调度器Dispatchers.Main

Dispatchers.Main指向的disaptcher,是通过loadMainDispatcher()创建的。核心方法是tryCreateDispatcher()

tryCreateDispatcher调用了createDispatcher

createDispatcher是个接口

具体的实现类是AndroidDispatcherFactory,在这里我们找到了Dispathcers.Main可以在主线程运行的大概原因,他是将任务通过Handler放到了主线程运行。

进入Looper.asHandler方法,可以看到,他是通过反射的方式,创建了Handler

至于为什么直接new Handler,而是用反射,看到createAsync这个方法,应该可以明白了。他希望创建一个发送异步消息的Handler

这里使用反射创建了一个Handler,他可以发送异步消息,这样的消息不受同步屏障的影响。

接下来,需要分析一下,同步屏障、同步消息、异步消息的关系。

四、同步屏障概念

Handler中消息的分类:

  • 普通同步消息
  • 异步消息
  • 同步屏障消息

why:为什么需要同步屏障?

主线程不只会执行View渲染消息,也会注册监听,逻辑计算等。异步消息就是为了让View渲染消息更快执行。

同步屏障如何工作?

建立一道同步屏障,隔离同步消息,使得后面的异步消息先执行。

需要注意:同步屏障只能手动插入,手动移除,系统不会自动管理同步屏障。

下面的例子,消息执行顺序:1-->4-->5-->2-->3-->6.

过程:

  1. 执行同步消息1
  2. 插入同步屏障100,在这个图的上下文里,同步屏障100是为了执行异步消息5而插入的
  3. 执行异步消息4
  4. 执行异步消息5
  5. 执行异步消息5后,会移除同步屏障,此时MessageQueue恢复正常
  6. 执行同步消息2
  7. 执行同步消息3
  8. 执行同步消息6

ViewRootImpl的刷新过程:

1.Choreographer的VSync监听

我们都知道,View的渲染是由业务层调用requestLayout()等方法,层层调用,最终调用到了ViewRootImpl中,在这里,会调用scheduleTraversals(),我们从这个方法开始看。

上图第二步,是像Choregrapher发起VSync的监听申请,等下一帧VSync信号到达时,将在主线程,执行我们插入的mTraversalRunnable对象。

2.Choreographer的任务管理

postCallback最终调用到postCallbackDelayedInternal,他主要做了两件事情:

  1. action,即我们传递过来的mTraversalRunnable保存到待执行队列中
  2. 注册下一帧VSync信号的监听

3.Choreographer的任务执行

doFrame中,会执行我们之前存放的CALLBACK_TRAVERSAL类型的CallbackRecord

4.ViewRootImpl执行刷新操作

整体的流程图如下:

通过上面的流程,我们可以看到整个View刷新的整个大致过程,当然中间有很多细节都省略了。

解释这个很有必要,因为这个卡死问题跟这个有很大的关系。

补充:

FrameDisplayEventReceiverDisplayEventReceiver的子类,他的具体能力就是提供VSync信号的监听:

五、同步屏障的设计缺陷

最终定位,问题就出现在同步屏障上面,理想的情况,同步屏障会在绘制请求时插入,绘制真正执行前移除。

在Android Framework开发者看来,scheduleTraversals都是在主线程运行的,比如开发者可以调用的requestLayout(),前置就会有checkThread,非主线程直接抛异常。以及根植在Android开发者心中的大原则:所有的UI刷新操作都应该放在主线程。

所以在scheduleTraversals设计时,就没有考虑线程安全问题,因为都是在主线程,下面的防重入机制是很合理的。

但是在我遇到的这个卡死问题中,这里就出现了多线程的并发问题。

这里直接说明:View.invalidate()没有做主线程校验,会导致多线程并发问题,最终导致假死。

2.invalidate()的并发非安全

我们从View.invalidate看起,他最终调用到了ViewParent.invalidateChild

虽然注释里面强调了,需要在主线程调用。但是在View的整个invalidate的过程中,没有任何检查线程的操作。

ViewGroup.invalidateChild()中,由于默认开启硬件加速,所以直接调用onDescendantInvalidated

中间还有一些调用,最终结果是调用到了ViewRootImplonDescendantInvalidated

如下图,虽然有checkThread的代码,但是这个条件一直是false

一路追踪下来,我们可以得出结论,View.invalidate()方法是可以在子线程调用的,他是线程非安全的。

3.invalidate并发导致同步屏障异常

前面的过程,已经确认,invalidate可以在子线程被调用,意味着scheduleTraversals也可以在子线程被调用。

那么在并发情况下,下面的代码就会出现问题:

当在同一帧里,我们在多个线程中多次调用invalidate,由于mTraversalScheduled没有volatile修饰,并发不安全,会存在postSyncBarrier()被多次执行的情况。

此时,我们会想,虽然多次执行,插入了多个同步屏障,只要最后移除不就可以了。但问题是mTraversalBarrier是个int单值,不是数组,且也是并发不安全的。也就是会出现下面这种情况:

在同一帧请求里面,子线程postSyncBarrier并发不安全,插入了两个同步屏障100、101,但是绘制完成后,removeSyncBarrier只移除了101。

当本帧绘制结束后,同步屏障100永远的留在了消息队列MessageQueue中,导致后续的同步消息永远无法处理。

六、页面假死

思考一:假死的原因

到这里可以说,我遇到的问题,是页面假死,而不是ANR。因为主线程并没有被卡死,异步消息还是可以执行的。

在这里,再思考一个问题:前面说了,UI刷新的消息都是高优的,设置为了异步消息,那为什么页面还是假死了,时间戳没有更新了?

这就是我们恪守的主线程刷新UI的原则的问题了,为了保证主线程刷新UI,我们肯定会调用类似于post等方法保证。

在上述时间戳更新的实现中,我们使用的是handler.sendMessage实现的,这个方法默认发送的是同步消息。所以时间戳就无法被更新了。

也就是说,我们在主线程上,有个同步消息,这个同步消息的内容是想请求页面刷新-setText。但是由于同步屏障的存在,这个同步消息一直没被执行。即使系统在ViewRootImpl中使用了异步消息来保证优先级,但是巧妇难为无米之炊,ViewRootImpl始终没机会执行。

思考二:登录态定时检测的逻辑为什么还能正常执行?

前面提到,日志中,登录态定时检测的逻辑,也是在主线程执行的,为什么他还能继续执行呢?

原因也是同一个,因为他使用的是协程Dispatchers.Main调度器,根据前面的分析,这个调度器的任务都是主线程异步消息,所以还可以继续执行。

思考三:业务中存在的问题

通过排查,发现业务中,在屏幕旋转后,SDK回调了onSizeChanged(),上层开发同学判断有误,以为当前是主线程,直接调用了invalidate(),而且也没有crash。

在屏幕旋转过程中,onSizeChanged()被多次回调,且在子线程,最终导致了上述问题。

修改方法很简单,根据源码中的建议,在主线程中调用即可。

思考四:系统设计缺陷

大胆的批判一下Framework的设计缺陷,或者是几个优化建议:

  1. 所有的刷新操作都应该强制checkThread,invalidate()的漏洞应该补上。
  2. ViewRootImpl的scheduleTraversal,应当是线程安全的,至少mTraversalScheduled应该是volatile的。

七、补充知识点

1.如何设置异步消息

如果想要让指定的消息优先执行,需要两步:

1. 先发送一个同步屏障消息,告诉MessageQueue遇到同步屏障了。(此方法不对开发者开放)
2. 再发送一个异步消息,让MessageQueue可以取到该异步消息。

2.Handler处理同步屏障

MessageQueue中,获取下一个消息时

java 复制代码
MessageQueue.java
Message next() {
...
// 1.msg.target == null,很明显是一个同步屏障消息
// 2.遇到同步消息,就去获取下一个异步消息进行执行
if (msg != null && msg.target == null) {
    // Stalled by a barrier.  Find the next asynchronous message in the queue.
    do {
        prevMsg = msg;
        msg = msg.next;
    } while (msg != null && !msg.isAsynchronous());
}
...
}

3.同步屏障泄露的监控

所以,从编码规范上,更新UI应该全部放在主线程上,避免此问题。同时也可以使用监控机制解决,参考ANR系列(二)------ANR监听方案之SyncBarrier

4.模拟同步屏障

kotlin 复制代码
try {
    // 获取当前线程的 Looper 和 MessageQueue
    val looper = Looper.getMainLooper()
    val messageQueueField: Field = Looper::class.java.getDeclaredField("mQueue")
    messageQueueField.setAccessible(true)
    val messageQueue: Any = messageQueueField.get(looper)

    // 通过反射调用 postSyncBarrier 方法
    val messageQueueClass: Class<*> = messageQueue.javaClass
    val postSyncBarrierMethod: Method = messageQueueClass.getDeclaredMethod("postSyncBarrier")
    postSyncBarrierMethod.setAccessible(true)
    println("Barrier token: ${postSyncBarrierMethod.invoke(messageQueue)}")
} catch (e: Exception) {
    e.printStackTrace()
}

参考:

今日头条 ANR 优化实践系列 - Barrier 导致主线程假死

ANR系列(二)------ANR监听方案之SyncBarrier_anr系列(二)------anr监听方案之syncbarrier-CSDN博客

Handler机制中同步屏障原理及结合实际问题分析

相关推荐
锅拌饭3 小时前
Android Handler(二) 同步屏障泄露检测
android
手机不死我是天子4 小时前
《Android 核心组件深度系列 · 第 3 篇 BroadcastReceiver》
android·android studio
用户17345666963474 小时前
Android 日志库:高性能压缩加密日志系统
android
恋猫de小郭5 小时前
React 和 React Native 不再直接归属 Meta,React 基金会成立
android·前端·ios
bst@微胖子5 小时前
鸿蒙实现滴滴出行项目之侧边抽屉栏以及权限以及搜索定位功能
android·华为·harmonyos
zcz16071278215 小时前
Docker Compose 搭建 LNMP 环境并部署 WordPress 论坛
android·adb·docker
Pika15 小时前
深入浅出 Compose 测量机制
android·android jetpack·composer
木易 士心20 小时前
MPAndroidChart 用法解析和性能优化 - Kotlin & Java 双版本
android·java·kotlin
消失的旧时光-194320 小时前
Kotlin Flow 与“天然背压”(完整示例)
android·开发语言·kotlin