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

在测试阶段,QA提了个bug,说页面卡死了,时间卡在了10:03秒,并且按返回键没有响应。但是会议的声音还在持续。
QA比较有经验,他在这个页面停留了30秒以上,都没有出现ANR的弹窗,进程也没有崩溃。最终,他杀掉了进程,重新进入会议后,一切又恢复了正常。
这是个很有意思的bug,因为最终分析下来,他不是ANR,而是一种假死现象。根本原因是Handler
的同步屏障Barrier
导致的。
一、页面实现逻辑
这个会议页面相对比较简单,使用SurfaceView
展示视频流,AudioManager
播放音频,底部有个TextView
展示会议时间。
会议时间是用Handler
的sendEmptyMessageDelayed
实现的,每次间隔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)
}
二、分析问题
根据过往经验,这种页面卡死的问题,一般有两种情况:
- 页面ANR
- 页面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
- 插入同步屏障100,在这个图的上下文里,同步屏障100是为了执行异步消息5而插入的
- 执行异步消息4
- 执行异步消息5
- 执行异步消息5后,会移除同步屏障,此时
MessageQueue
恢复正常 - 执行同步消息2
- 执行同步消息3
- 执行同步消息6

ViewRootImpl的刷新过程:
1.Choreographer的VSync监听
我们都知道,View
的渲染是由业务层调用requestLayout()
等方法,层层调用,最终调用到了ViewRootImpl
中,在这里,会调用scheduleTraversals()
,我们从这个方法开始看。



上图第二步,是像Choregrapher
发起VSync的监听申请,等下一帧VSync信号到达时,将在主线程,执行我们插入的mTraversalRunnable
对象。
2.Choreographer的任务管理
postCallback
最终调用到postCallbackDelayedInternal
,他主要做了两件事情:
- 将
action
,即我们传递过来的mTraversalRunnable
保存到待执行队列中 - 注册下一帧VSync信号的监听



3.Choreographer的任务执行

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


4.ViewRootImpl执行刷新操作

整体的流程图如下:

通过上面的流程,我们可以看到整个View
刷新的整个大致过程,当然中间有很多细节都省略了。
解释这个很有必要,因为这个卡死问题跟这个有很大的关系。
补充:
FrameDisplayEventReceiver
是DisplayEventReceiver
的子类,他的具体能力就是提供VSync信号的监听:

五、同步屏障的设计缺陷
最终定位,问题就出现在同步屏障上面,理想的情况,同步屏障会在绘制请求时插入,绘制真正执行前移除。
在Android Framework开发者看来,scheduleTraversals
都是在主线程运行的,比如开发者可以调用的requestLayout()
,前置就会有checkThread
,非主线程直接抛异常。以及根植在Android开发者心中的大原则:所有的UI刷新操作都应该放在主线程。


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

但是在我遇到的这个卡死问题中,这里就出现了多线程的并发问题。
这里直接说明:View.invalidate()
没有做主线程校验,会导致多线程并发问题,最终导致假死。
2.invalidate()的并发非安全
我们从View.invalidate
看起,他最终调用到了ViewParent.invalidateChild
。
虽然注释里面强调了,需要在主线程调用。但是在View
的整个invalidate
的过程中,没有任何检查线程的操作。

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

中间还有一些调用,最终结果是调用到了ViewRootImpl
的onDescendantInvalidated
。
如下图,虽然有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的设计缺陷,或者是几个优化建议:
- 所有的刷新操作都应该强制checkThread,invalidate()的漏洞应该补上。
- 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博客