搜公众号
推荐 原创 视频 Java开发 开发工具 Python开发 Kotlin开发 Ruby开发 .NET开发 服务器运维 开放平台 架构师 大数据 云计算 人工智能 开发语言 其它开发 iOS开发 前端开发 JavaScript开发 Android开发 PHP开发 数据库
Lambda在线 > 腾讯课堂Coding学院 > 经典随机Crash之一:线程安全

经典随机Crash之一:线程安全

腾讯课堂Coding学院 2018-05-14
举报




@腾讯学院Coding学院


背景


Android QQ在2016下半年连着好几个版本二灰Crash率都很高,如果说有新需求,一灰的Crash率高,还能找点理由,可是开发童鞋解过一灰的Crash单后,为啥二灰还有这么高的Crash率,我们还有覆盖全SNG、不少外BG明星产品的终端稳定性测试工具每天都在跑,更何况大多Top Crash都发生在用户使用很普通、很频繁的场景,实在令人匪夷所思。


那段时间抄送各老板的运营邮件Crash率数据天天标红,项目组人心惶惶,发个版本感觉要烧高香。


在这样的背景下,look临危受命,负责研究外网Top Crash,尽可能找到一些共性问题,在研究过程中,得到开发的大多反馈是:

1、场景就在这里,但就是复现不了。

2、这里有个线程安全问题,那我加个同步;这里有个空指针,那我就加个判空,一时间look也陷入深深的困扰。


代码是开发写的,开发都复现不了,我更复现不了啊。会Crash的代码在那,开发就改了,完全是头痛医头脚痛医脚的做法,作为一个测试,我还能做啥呢?


当时的心情真的是


经典随机Crash之一:线程安全



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


线程问题的现状



经典随机Crash之一:线程安全


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


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


经典随机Crash之一:线程安全


经典随机Crash之一:线程安全

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


经典随机Crash之一:线程安全


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


经典随机Crash之一:线程安全经典随机Crash之一:线程安全


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


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


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

经典随机Crash之一:线程安全

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


问题的剖析


问题的分析方法无非就是从日志、代码逻辑、原理上着手了,look觉得写得不错,推荐给大家。

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


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种情况,look用图给您展示两种:


 

经典随机Crash之一:线程安全经典随机Crash之一:线程安全



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


那为啥我们复现不了呢?

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

问题可能的解决方案


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

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

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


2. 在执行语句上暂停

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


3. 模拟线程并发

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

线程的并发方案


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


经典随机Crash之一:线程安全

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

经典随机Crash之一:线程安全


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

经典随机Crash之一:线程安全

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


线程池的并发方案


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

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

经典随机Crash之一:线程安全


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

经典随机Crash之一:线程安全


经典随机Crash之一:线程安全

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

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

 手Q的线程池基于线程池进一步做了封装,做了很多非常深入、实用的改造,更加强大,这里向大家推荐一下手Q线程池组件,有兴趣可以联系组件作者maxwellwli,也感谢maxwell对look在线程池原理上的指导。


效果


最终,我们将IllegalStateException Crash的占比成功大幅度降低。

look诚惶诚恐,冠上“经典”二字,是为了博人眼球,文章若有纰漏,欢迎大家指教,道高一尺魔高一丈,在降Crash率上,依旧任重而道远。


—————END—————


扫码下方二维码,

就可以看到更多腾讯大牛分享的实战干货





业界最顶尖的技术大咖/最权威的实战分享/最前沿的行业资讯/尽在腾讯课堂Coding学院

长按二维码关注Coding学院






版权声明:本站内容全部来自于腾讯微信公众号,属第三方自助推荐收录。《经典随机Crash之一:线程安全》的版权归原作者「腾讯课堂Coding学院」所有,文章言论观点不代表Lambda在线的观点, Lambda在线不承担任何法律责任。如需删除可联系QQ:516101458

文章来源: 阅读原文

相关阅读

举报