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机制中同步屏障原理及结合实际问题分析

相关推荐
踏雪羽翼13 小时前
android TextView实现文字字符不同方向显示
android·自定义view·textview方向·文字方向·textview文字显示方向·文字旋转·textview文字旋转
lxysbly13 小时前
安卓玩MRP冒泡游戏:模拟器下载与使用方法
android·游戏
夏沫琅琊15 小时前
Android 各类日志全面解析(含特点、分析方法、实战案例)
android
程序员JerrySUN16 小时前
OP-TEE + YOLOv8:从“加密权重”到“内存中解密并推理”的完整实战记录
android·java·开发语言·redis·yolo·架构
TeleostNaCl17 小时前
Android | 启用 TextView 跑马灯效果的方法
android·经验分享·android runtime
TheNextByte118 小时前
Android USB文件传输无法使用?5种解决方法
android
quanyechacsdn19 小时前
Android Studio创建库文件用jitpack构建后使用implementation方式引用
android·ide·kotlin·android studio·implementation·android 库文件·使用jitpack
程序员陆业聪20 小时前
聊聊2026年Android开发会是什么样
android
编程大师哥20 小时前
Android分层
android
极客小云1 天前
【深入理解 Android 中的 build.gradle 文件】
android·安卓·安全架构·安全性测试