【腾讯Bugly干货分享】经典随机Crash之一:线程安全

发表于2017-04-13
评论0 2.7k浏览

背景

Android QQ 在2016下半年连着好几个版本二灰 Crash 率都很高,如果说有新需求,一灰的 Crash 率高,还能找点理由,可是开发童鞋解过一灰的 Crash 单后,为啥二灰还有这么高的 Crash 率,我们还有覆盖全 SNG、不少外 BG 明星产品的终端稳定性测试工具 NewMonkey 随身版(NewMonkey系腾讯内部研发的测试工具,外部app有兴趣请点击这里填问卷调查申请使用)每天都在跑,更何况大多 Top Crash 都发生在用户使用很普通、很频繁的场景,实在令人匪夷所思,那段时间抄送各老板的运营邮件 Crash 率数据天天标红,项目组人心惶惶,发个版本感觉要烧高香,当时作为 Android NewMonkey 核心成员的我更是压力山大,在这样的背景下,我临危受命,负责研究外网 Top Crash,尽可能找到一些共性问题,在研究过程中,得到开发的大多反馈是:

  1. 问开发:这个 Top Crash 能找到复现场景吗?答:场景就在这里,但就是复现不了

  2. 这里有个线程安全问题,那我加个同步;这里有个空指针,那我就加个判空

一时间我也陷入深深的困扰:

  1. 代码是开发写的,开发都复现不了,我更复现不了啊

  2. 会 Crash 的代码在那,开发就改了,完全是头痛医头脚痛医脚的做法,作为一个测试,我还能做啥呢?

当时的心情真的是如图所示:

然而作为一名专项测试,如果只是看到这些表象,是远远不够的,也感谢老大一直对我的激励:“一切你复现不了的 Crash,那都是你没有找到问题的根源。”我当时给自己的目标是“一定要复现,有条件要上,没有条件创造条件也要上。”

线程安全问题的现状

《Bugly2016移动应用质量大数据报告》提到:

空指针异常在Java代码中最为常见,不出所料,NullPointerException依然是最常见的Java异常,该异常影响面广但容易修复,开发者想快速降低崩溃率可以优先解决此类异常。相较于2015年,IllegalStateException从5%提升至10%,OutOfMemoryError从3%提升至6%。

——数据源自腾讯Bugly

IllegalStateException主要是由线程引起的,本篇就线程安全类问题与您一探究竟,我将向您展示研究过程中的乐趣以及最终取得的效果,另外解密我申请的两个线程领域的专利。

我们先来看一种具有代表性的Crash,这里以一次灰度的Top 1 Crash为例子,至于这个Crash的引入原因,开发童鞋为了修改性能bug,将方法放到了线程中执行,我省去中间几百行代码,抽取出代码梗概。

类中声明了一个成员变量mTask

getDrawable会被多次调用,ThreadManager是Android QQ线程管理组件,用ThreadManager提交了一个Runnable任务,run()里调用decodeBigImage做解码,new一个AsyncTask对象,然后execute

首先说明同一个AsyncTask实例不能execute多次,否则就会报:

java.lang.IllegalStateException: Cannot execute task: the task is already running
  • 1
  • 1

Top Crash中正是在decodeBigImage方法中mTask.execute那一行报的这个错,开发童鞋的解法,那就很自然了,虽然不知道怎么Crash的,先将decodeBigImage加了同步,反正不会Crash了,况且当时紧急情况下,也容不得多想。

请您静思几秒,想想上面的代码不加同步可能会有什么问题,这个Top Crash开发、测试同学一度觉得十分诡异,实在想不出哪里会有问题,mTask怎么会执行多次呢?代码里每次都有new对象啊,然后用新建出来的对象execute,怎么会有问题呢?

问题的剖析

问题的分析,分析的一些方法无非就是从日志、代码逻辑、原理上着手了。

如果您当初像我一样,没啥思路,不妨先做一道笔试题吧:

i=0,两个线程分别执行i++,可能的结果有1、2

解释:i++不是原子操作,每次要先把i从内存读取到寄存器,然后++,然后再把寄存器中的值写回到内存中,这需要至少3步。

可能出现的情况:

Case1:

thread1 读到0,寄存器加1,写回内存1

thread2 读到0,寄存器加1,写回内存1

结果:1

Case2:

thread1 读到0,寄存器加1,写回内存1

thread2 读到1,寄存器加1,写回内存2

结果:2

到这里,您或许有点思路了,因为我们潜意识把decodeBigImage()看成了原子操作,然而真实情况并非如此。

如果是两个线程同时并发,一共有4种情况,我用图给您展示两种:

两个线程在并发的情况下,用排列组合的知识,很容易算出发生Crash的概率是50%,那这个概率还是蛮高的,如果更多数量线程并发,Crash概率更高,那也就不难理解这个Crash是Top 1 Crash了。

问:那为啥我们复现不了?

答:因为我省掉的几百行代码中,随时有if else分支有可能return掉,并且cpu瞬息万变,我们手工很难构造出线程并发的条件。

如果到这里,对临界资源访问的方法加了同步,这个Crash就算解决了,那下次碰到这类问题,都要等出了问题后,再加同步吗?那这个代价有点太高了,况且Crash 我还没复现出来呢。

问题可能的解决方案

1.监控临界资源的变更记录

既然问题发生在一个类成员变量有多处对它修改,出现了覆盖写的情况,那监控变量值变更记录,似乎是个有效的监控手段,但请教了专业做静态代码扫描codedog的同学,行业内貌似没有成熟的解决方案,动态执行时做这个变量值监控似乎难度不小。

那么多变量哪些该监控?怎么判断出值变更有问题的?怎么避免误报?这些都有不小的难度。

2. 在执行语句上暂停

既然是给mTask赋值时出现的问题,一个线程执行后,那我们在这条语句上暂停,像调试一样,等其他线程来覆盖第一个线程的赋值结果,那这个Crash就能完整重现了,可这个方案依旧有不小的难度,那么多赋值语句,哪些需要暂停?怎么动态在语句执行时暂停?怎么释放?要解决好这些问题,难度依旧不小。

3. 模拟线程并发

既然这类线程安全的问题是在多线程并发时出现问题的概率大,避免发生Crash就加同步,避免线程并发访问临界资源,如果要在事发前发现这类问题,那我们就应该反其道而行之,增大线程并发的概率。由于有hook技术,对方法执行前后能做手脚,似乎有切入点。

考虑到方案3已是我们能想到最容易实现的一个方案了,最终我们采用了方案3,但依然有不少问题要解决。

一般线程执行情况是这样的:

3.1、哪些线程需要并发?

因为有些线程八竿子打不着,没有竞争关系,根本就没必要让它们并发。

我们这里先把范围局限在同一个方法启动的多个线程对同一个资源有竞争关系,归为同一类线程;如果是在不同方法里开启多个线程对同一个资源并发访问,这种情况更加复杂,静态分析做检查,都有很高的误报率,动态分析更加难做,暂时不在我们考虑范围内。

3.2、怎么区分不同类别的线程?

应用程序中启动线程的地方不相同,则认为是不同类型的线程,我们用调用堆栈区分不同类型的线程。

3.3、假设同时想让n个线程并发,怎么让它们在执行前都停住,然后让它们同时执行?

找到线程真正执行的地方,在执行前加一种计数器锁,如果计数值达到n后,再释放锁,加计数器锁后效果:

3.4、如果线程请求数达不到n,又如何让已加锁的线程同时执行?

加一个倒计时锁,如果等待超过设定时间,则自行释放锁,计数器、倒计时锁同时作用的效果如图所示:

这个方案可能带来的影响:

  1. 性能上,毫无疑问,由于我们暂停了线程的执行,肯定是有影响的

  2. 兼容性上,由于采用的都是通用的hook技术,并发SDK已集成到NewMonkey随身版中,已稳定运行了好几个月,稳定性得到了保证。

线程的并发方案

Java里新建线程主要有两种方式:通过实现 Runnable 接口;通过继承 Thread 类本身;实现 Runnable 接口也要被Thread封装了然后再去执行,总之两种方式,启动最终都是靠Thread.start(),执行都是靠Thread.run(),这就好办了,线程的并发方案分两步,如下图所示:

Hook start获取调用堆栈,将同一调用堆栈的tid聚在一类。

通过上面处理,我们能对拥有同一key值、不同tid的线程加同一个锁

到此第一个专利水到渠成:一种模拟线程并发的方法

中间踩过一些坑,分享给大家:

问题1、为啥不hook Runnable?

答:因为Runnable是接口,只能hook类

问题2、为啥不hook start来获取调用堆栈时就模拟并发?

答:1、线程真正执行时是在run里 2、start是个同步方法,在这里加锁也没法模拟并发

问题3、为啥不hook run来获取调用堆栈、并且模拟并发?

答:因为被开发者调用的是start(),能拿到app的调用堆栈,以此区分不同类型的thread,hook run获取到的都是系统堆栈,无法做线程特征区分。

线程池的并发方案

自己写了个Thread的demo,发现并发凑效了,本以为到此就大功告成了,可以模拟出Top Crash了,结果发现并非如此,像手Q这么大的项目是不太允许随便通过new Thread方式新建线程的,Runnable任务大多通过线程池调度来执行。

由于线程池的原理比线程复杂,我觉得线程池核心思想是最大程度复用了存活的线程,限于篇幅,这里我对线程池不再多做赘述,给大家推荐几篇不错的文章:

聊聊并发(三)——JAVA线程池的分析和使用 
http://oa5504rxk.bkt.clouddn.com/utest_week33/www.infoq.com/cn/articles/java-threadPool

Java线程池架构原理和源码解析(ThreadPoolExecutor) 
http://oa5504rxk.bkt.clouddn.com/utest_week33/blog.csdn.net/xieyuooo/article/details/8718741

我画了一个线程池流程图,以帮助理解下面的hook并发方案

总之,结论就是:

  1. 开发者通过ThreadPoolExecutor.execute(runnable) 提交Runnable任务

  2. runnable任务执行前会执行ThreadPoolExecutor.beforeExecute(Runnable)

在此不得不感叹老外设计接口时的缜密和深思熟虑。

我们的线程池模拟并发方案仍然分两步:

Hook execute获取调用堆栈,将同一调用堆栈的runnable的hashcode聚在一类。

通过上面处理,我们能对拥有同一key值、不同hash的Runnable加同一个锁

您可能一咋眼看上去跟上面那个方案好像很接近,其实有着本质上的区别,正好也可以回答为啥上面hook Thread start run不能解决线程池并发的问题

答:原因有两点:

  1. 我通过hook Thread拿不到线程池启动线程的调用堆栈,因为线程池至始至终就没有把Thread暴露给开发者

  2. 线程池里的Worker(Worker是对Thread的再封装)与Runnable不再具有一一绑定的关系,Worker以领任务的方式去执行Runnable,同一堆栈特征的Runnable究竟由哪个Worker执行的,跟设定线程数量、采用何种缓冲队列、每个Runnable执行耗时、这些Worker的状态都有关系,只能通过Runnable自身加调用堆栈去区分。

第二个专利因此也水到渠成:一种模拟通过线程池调度的线程并发方案

那这个方案能否替换线程并发那个方案呢?不能,由于Thread和Runnable一一绑定,可以将线程并发方案中的线程tid换成Runnable实例的hashcode,但是hook Thread还是必须要做的。

手Q的线程池基于线程池进一步做了封装,做了很多非常深入、实用的改造,更加强大。

效果

最终,我们将IllegalStateException Crash的占比由Android QQ 6.5.0的8%下降至6.6.0的1%

我诚惶诚恐,冠上“经典”二字,是为了博人眼球,文章若有纰漏,欢迎大家指教,两个专利的适用范围我想了下,也不仅仅适用于Android终端,前端、客户端、后台,所有平台应该都适用,大家可以按照自己平台去实现。

道高一尺魔高一丈,在降Crash率上,依旧任重而道远。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引