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

本文作者:鲁可——腾讯SNG专项测试组 测试工程师

背景

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

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率上,依旧任重而道远。

NewMonkey系腾讯内部研发的测试工具,外部app有兴趣请点击这里申请使用



更多精彩内容欢迎关注腾讯 Bugly的微信公众账号:

腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的情况以及解决方案。智能合并功能帮助开发同学把每天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同学定位到出问题的代码行,实时上报可以在发布后快速的了解应用的质量情况,适配最新的 iOS, Android 官方操作系统,鹅厂的工程师都在使用,快来加入我们吧!

时间: 2024-12-26 19:00:22

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

【腾讯bugly干货分享】如何定位Obj-C野指针随机Crash(三):加点黑科技让Crash自报家门

本文主要介绍如何利用OC Runtime的特性,让OC野指针对象主动抛出自己的信息,秒杀某些全系统栈Crash. 陈其锋,腾讯SNG即通产品部音视频技术中心软件工程师,主要负责iOS平台音视频功能开发,热衷于移动开发,以及各类APP体验. (注:本文由于涉及一些技术比较猥琐,可能会引起处女座同学的不适,如果有任何疑问欢迎一起讨论.另外,本文只讨论Arm 32位情况) 为什么错误地址是0x55555561? 我们在前文里曾经介绍过在内存释放后填充0x55使野指针出现后数据不能访问,从而使野指针变成

【腾讯Bugly干货分享】TRIM:提升磁盘性能,缓解Android卡顿

Bugly 技术干货系列内容主要涉及移动开发方向,是由 Bugly 邀请腾讯内部各位技术大咖,通过日常工作经验的总结以及感悟撰写而成,内容均属原创,转载请标明出处.在业内,Android 手机一直有着“越用越慢”的口碑.根据第三方的调研数据显示,有77%的 Android 手机用户承认自己曾遭遇过手机变慢的影响.他们不明白为什么购买之初“如丝般顺滑”的 Android 手机,在使用不到一年之后都会“卡顿”得让人抓狂!根据我们初步的测试数据,手机长期所使用产生的磁盘碎片可以使得磁盘的写入效率下降为

【腾讯Bugly干货分享】深入源码探索 ReactNative 通信机制

Bugly 技术干货系列内容主要涉及移动开发方向,是由 Bugly 邀请腾讯内部各位技术大咖,通过日常工作经验的总结以及感悟撰写而成,内容均属原创,转载请标明出处. 本文从源码角度剖析 RNA 中 Java <> Js 的通信机制(基于最新的 RNA Release 20). 对于传统 Java<>Js 通信而言,Js 调用 Java 通不外乎 Jsbridge.onprompt.log 及 addjavascriptinterface 四种方式,在 Java 调用 Js 只有 l

【腾讯Bugly干货分享】浅谈Android自定义锁屏页的发车姿势

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/57875330c9da73584b025873 一.为什么需要自定义锁屏页 锁屏作为一种黑白屏时代就存在的手机功能,至今仍发挥着巨大作用,特别是触屏时代的到来,锁屏的功用被发挥到了极致.多少人曾经在无聊的时候每隔几分钟划开锁屏再关上,孜孜不倦,其酸爽程度不亚于捏气泡膜.确实,一款漂亮的锁屏能为手机增色不少,但锁屏存在的核心目的主要是三个:保护自己手机的隐私,防止误操作,在不关闭

【腾讯Bugly干货分享】从0到1打造直播 App

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/5811d42e7fd6ec467453bf58 作者:李智文 概要 分享内容: 互联网内容载体变迁历程,文字--图片/声音--视频--VR/AR----..从直播1.0秀场时代(YY),2.0游戏直播(斗鱼.虎牙.熊猫)到如今全民直播3.0泛生活娱乐时代(映客.花椒),国外直播app(Meerkat .Periscope),随着VA/AR/MR提出的沉浸式视听体验,直播4.0时

【腾讯Bugly干货分享】移动App入侵与逆向破解技术-iOS篇

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/577e0acc896e9ebb6865f321 如果您有耐心看完这篇文章,您将懂得如何着手进行app的分析.追踪.注入等实用的破解技术,另外,通过"入侵",将帮助您理解如何规避常见的安全漏洞,文章大纲: 简单介绍ios二进制文件结构与入侵的原理 介绍入侵常用的工具和方法,包括pc端和手机端 讲解黑客技术中的静态分析和动态分析法 通过一个简单的实例,来介绍如何综合运用砸

【腾讯Bugly干货分享】美团大众点评 Hybrid 化建设

本文来自于腾讯Bugly公众号(weixinBugly),未经作者同意,请勿转载,原文地址:http://mp.weixin.qq.com/s/rNGD6SotKoO8frmxIU8-xw 本期 T 沙龙探讨了移动端热更新相关的话题.由于沙龙时间的限制,本期我们选取了美团的 Hybrid 化建设.去哪儿的跨平台 ListView 性能优化.微博 Android 端热更新踩过的坑话题.还期待热更新.热修复哪些话题?欢迎留言给我们.也欢迎报名参加 T 沙龙分享自己开发中的心得. Hybrid 是移动

【腾讯Bugly干货分享】微信读书iOS性能优化

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/578c93ca9644bd524bfcabe8 "8小时内拼工作,8小时外拼成长"这是大家共同的理想.除了每天忙于工作外,我们都希望能更多地区吸收领域内的新知识与新技能,从而走向人生巅峰. Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师.每周都会举行嘉宾分享,话题讨论等活动. 上一期我们邀请了腾讯SNG工程师&qu

【腾讯bugly干货分享】Android自绘动画实现与优化实战——以Tencent OS录音机波形动

前言 本文为腾讯bugly的原创内容,非经过本文作者同意禁止转载,原文地址为:http://bugly.qq.com/bbs/forum.php?mod=viewthread&tid=1180 我们所熟知的,Android 的图形绘制主要是基于 View 这个类实现. 每个 View 的绘制都需要经过 onMeasure.onLayout.onDraw 三步曲,分别对应到测量大小.布局.绘制. Android 系统为了简化线程开发,降低应用开发的难度,将这三个过程都放在应用的主线程(UI 线程)