京东金融App崩溃治理实践
作者:吴欣宇京东科技业务中台-移动研发团队原创
一、前言
京东金融App经过高质量的持续修复,崩溃率远低于行业平均水平两个数量级,长期稳定在0.007%水平。
用户崩溃率数据来源于APM性能监控系统。
一、崩溃的定义
1、崩溃发生的原因
崩溃是CPU对发生异常的一种显式反应,CPU的异常处理是基于中断来完成的。中断是CPU暂停正在执行的程序,保留现场后转去执行相应的处理程序,处理完该事件后再返回断点继续执行被“打断”的程序。
比如在Intel架构中,中断处理的入口由操作系统内核中的中断分配表定义(interrupt dispatch table, IDT),IDT中有255个中断向量,其中前20个定义为异常(exception)的处理入口,即中断包含异常。 而在ARM架构中,中断处理的入口则是在异常向量(exception vector)中,8个异常向量里边有3个是中断相关的,即异常包含中断。
在“执行”阶段产生的“数据中止”异常:若处理器数据访问指令的地址不存在,或该地址不允许当前指令访问时,产生数据中止异常。 在“取指”阶段产生的”预取中止“异常:若处理器预取指令的地址不存在,或该地址不允许当前指令访问,存储器会向处理器发出中止信号,但当预取的指令被执行时,才会产生指令预取中止异常。
2、在iOS系统中崩溃是如何发生的
二、常见崩溃的几种类型
-(void)sceneAnalysis {
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"Sync Task Result");
});
NSLog(@"Do Other Tasks");
}
程序会卡死在函数体的第一行,错误信息如下:Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)
以上示例代码是最典型的主线程死锁,主队列中添加了一个同步任务「NSLog(@"Sync Task Result")」,所以主线程会暂停当前代码转去执行block代码块,并等待dispatch_sync函数返回后继续执行。但主队列(main queu)是串行队列遵循先入先出的原则,当前主线程正在执行sceneAnalysis函数,dispatch_sync需要等待sceneAnalysis函数执行完毕。sceneAnalysis和dispatch_sync互相等待,这样就造成了死锁。
如果App在执行某个任务(启动、终止或响应系统事件)时花费时间较长,操作系统会终止当前进程。看门狗机制触发的崩溃最明显的标识就是错误码:0x8badf00d 。通常崩溃日志会如下图所示:
当App的启动时间超过最大允许值(一般为20s),在iOS系统中就会触发看门狗机制立即终止进程。值得注意的是:由看门狗触发的崩溃并不会收集在自己开发的错误监控里,可以在崩溃设备中获取崩溃日志。苹果在使用模拟器时关闭了看门狗机制,并且在Debug模式下也不会触发看门狗机制。所以在开发中对App启动流程务必做到简洁、按需加载。
三、金融App的崩溃治理实战案例
1、多线程导致的野指针问题
在金融APP中使用长连接技术更新大盘指数信息。如下图例:
功能灰度时一直持续关注,期间并未发现崩溃问题,但随着新版本开放更新及活跃用户量的持续增长,APM性能监控平台开始发现偶发崩溃。从崩溃日志上看,崩溃出现在MQTTClient开源库里。通过沟通发现其他业务部门使用MQTTClient开源库的场景也出现过同样问题。
由于属线上崩溃,在APM性能监控平台上通过崩溃次数和堆栈信息评估风险确定这是非必现偶发问题,于是研发团队开始着手对问题进行定位并查找原因。通过在APM性能监控平台对崩溃进行持续跟踪,收集数据样本后,分析出App崩溃前用户的共通操作 —— 应用切换前后台。这是非常可疑的操作路径信息。
京东金融App中长链接使用开源的MQTT协议,研发团队在项目开源社区中查询相关issues和解决方案,社区中虽有相似问题,但由于该库超过2年未进行更新维护,未能找到解决方案。
于是,我们将注意力重新转回业务使用场景和MQTTClient源码当中。源码中引起崩溃的函数如下,此处为MQTT发送消息的NSStream流对象回调时机。
- (void)stream:(NSStream *)sender handleEvent:(NSStreamEvent)eventCode
在实际业务场景中,MQTTClient采用前台运行时连接,退入后台主动断开的方式进行工作。于是研发团队想要通过前后台切换复现此问题,通过代码高频模拟App进入前后台场景,终于在debug模式下复现了此问题。
崩溃发生在MQTT的内部线程,金融App在切换后台时断开连接,MQTTCFSockeEncoder对象在外部线程(对象创建的线程)被释放,于MQTTCFSockeEncoder对象的释放与stream处理现队列不统一,当前线程未能同步状态,依然访问该地址造成“野指针”崩溃。
修复方案:通过对self对象进行“保留”操作,使对象所属的堆内存引用计数增加,防止在执行回调函数过程中,堆内存被系统回收。之后再次通过高频调用对场景进行模拟,未再复现。问题因此得以解决。
- (void)stream:(NSStream *)sender handleEvent:(NSStreamEvent)eventCode {
MQTTCFSocketDecoder *strongDecoder = self;
(void)strongDecoder;
//其他代码。。。
}
解决问题后,在团队内部通过托管更新方式,为金融app及其他业务团队进行统一更新。同时在社区中提交了PR。未来在长连接的使用过程中,研发团队还会持续对发现的问题进行关注。
开发中还一个有关于野指针比较明显的例子就是通知中心NSNotificationCenter。注册通知时,通知中心会保存接收对象的内存地址,但是不会对接受对象引用计数+1(unsafe_unretained)。当对象被释放后,原有的内存地址可能已被重用。通知中心发送通知时仍然会将消息发给保存的内存地址,但是保存的内存地址已不再是原来的对象,接收到消息不能处理,程序发生crash错误。
基于野指针造成的崩溃经常发生,苹果在iOS9就已经优化了通知中心的使用。iOS9以后的版本,在对象释放时会自动移除所有通知。前提是对象能正常释放。同理还有在tabview的delegate和dataSource,在iOS9以前使用unsafe_unretained修饰,在iOS9及以后修改成使用weak修饰防野指针情况(使用weak修饰的指针在对象被释放后会自动置为nil)。如果App支持的最低版本是8.0,那么还需要额外关注delegate的野指针问题。
2、多线程共享资源导致的过度释放
开发中遇到的另一个错误也是由多线程引起的,是比较罕见的set方法赋值错误。App使用开源的lottie框架替代GIF图做复杂的氛围动画,以便降低内存消耗。获取本地兜底lottie文件时,因为是耗时操作,所以开辟子线程从本地获取和解压,完成后回归主线程渲染。 另外在App启动时会发送网络请求拉取最新的lottie文件,如果网络请求顺利且快速,界面会优先展示最新的lottie文件。
在查看崩溃统计日志和业务代码后很快就怀疑是多线程引起的错误。读取本地lottie文件为1号线程,网络请求是7号线程,在获取文件路径后1号线程和7号线程都会调用handleCacheFilePath函数获取lottie文件。handleCacheFilePath函数代码如下:
-(void)handleCacheFilePath:(NSString *)filePath {
if (!filePath) {
return;;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *zipData = [NSData dataWithContentsOfFile:filePath];
/*省略zip解压等其他操作 ... */
manager.lottieData = zipData;
dispatch_async(dispatch_get_main_queue(), ^{
//回主线程
});
});
}
在解压缩后将lottie的json数据赋值给self.lottieData,等待在接来下的界面展示lottie动画。实际运行过程中,在某些情况下会发生崩溃,因为在App业务比较复杂,触发场景及其苛刻,所以将lottie获取和展示模块移到新建Domo中进行复现。
崩溃日志解析后内容如下:
我们知道给Object-C中给变量赋值就是调用setter方法,那为什么会过度释放呢,看看OC的底层源码是如何处理的 (源码链接:https://opensource.apple.com/source/objc4/objc4-723/runtime/objc-accessors.mm.auto.html)。通过汇编代码发现set方法调用了OC runtime的objc_setProperty_nonatomic函数,而苹果的部分run time源码是开源的,直接查看源码。
void objc_setProperty_nonatomic(id self, SEL _cmd, id newValue, ptrdiff_t offset)
{
reallySetProperty(self, _cmd, newValue, offset, false, false, false);
}
在objc_setProperty_nonatomic中实际调用了reallySetProperty函数。
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
if (offset == 0) {
object_setClass(self, newValue);
return;
}
id oldValue;
id *slot = (id*) ((char*)self + offset);//计算偏移量获取指针地址
if (copy) {
newValue = [newValue copyWithZone:nil];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:nil];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);//retain新值newValue
}
if (!atomic) {//非原子属性
oldValue = *slot;//第一步
*slot = newValue;//第二步
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
}
objc_release(oldValue);//释放旧值 引用计数-1
}
问题的关键就出在reallySetProperty函数第20~31行,如果是非原子(nonatomic)操作,直接将*slot赋值给oldValue对象,然后将新值付给*slot,最后将oldValue释放引用计数减一。如果是原子操作(atomic修饰),在变量读取之前会先添加自旋锁,spinlock自旋锁在被当前线程获取后,另外一个线程获取自旋锁时获取不到,只能原地等待。
因为lottieData是个共享变量,且使用非原子nonatomic修饰,1号线程赋值会进入到非原子条件中,假设当1号线程执行完oldValue = *slot赋值,时间片耗尽。此时CPU调度开始执行7号线程,7号线程也会执行相同的操作给oldValue赋值。赋值完成后执行objc_release(oldValue)指令,对oldValue指向的内存空间做了release操作。此时oldValue所指向的内存空间已经被释放。7号线程执行完毕后CPU在在转去执行1号线程,当1号线程再次执行objc_release(oldValue)方法时,会就发生崩溃。原因是对一个已经释放的内存再次做release操作。这也对应了崩溃堆栈中overrelease_error错误。
解决方法:深入了解了原理之后问题的解决就水到渠成了。lottieData是一个manager持有的共享变量,可以改成使用atomic修饰防止多线程竞争。因为atomic修饰的变量set和get方法都会添加自旋锁,如果是读取比较频繁的场景自旋锁是比较耗费CPU资源的。所幸我们结合业务分析lottieData的使用场景并不算多,并不会造成CPU资源的过度浪费。 另外还可以修改代码逻辑将lottieData声明成临时变量,使用临时变量作为函数返回值来解决多线程竞争问题。
3、方法调用异常
对于崩溃的千变万化来说,方法调用异常是最容易解决的问题之一了。在App中有疏忽导致的方法未实现,也有因为内存释放导致对象方法不存在,我们不讨论解决办法,因为开发者早已熟知并解决过很多次。在这主要探索一下“unrecognized selector sent to instance”异常抛出的过程。
我们都知道,在OC中调用对象的方法本质就是向对象发消息。在编译阶段方法调用会被转换成objc_msgSend函数。第一个必须参数是消息接收者,第二个必须参数是方法名,后面是传递的参数。
objc_msgSend(id self, SEL op, ... )
下面是消息发送的步骤:
(1) 检测selecter是否需要忽略,如某Mac OX系统存在垃圾回收机制,则会忽略retain/release函数。
(2) 查看响应对象是否为nil。向nil对象发送消息会被runtime系统忽略。
(3) 从cache中通过IMP函数指针查找方法实现,若存在则执行该方法。
(4) cache中找不到则从Class的方法列表中查找,并递归查找父类的方法列表。
(5) 如果都找不到则进入动态方法决议和消息转发流程。
通过objc_msgSend函数向一个对象发送消息,如果该对象经过多个流程仍无法处理,则会抛出异常。在crash之前OC的运行时系统会先经过以下两个步骤:
DynamicMethod Resolution(动态方法决议)
以对象方法举例,系统会调用resolveInstanceMethod:为对象动态添加方法,如果返回值为yes则会再次查找实例方法,如果返回值为No则会进入消息转发流程。
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(handleOpenPage:)) {
IMP imp = class_getMethodImplementation([self class], @selector(openNewPage:));
class_addMethod([self class], sel, imp, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
上面的例子为实例对象的handleOpenPage:方法动态添加了实现(openNewPage:)。其中“v@:” 表示返回值和参数。每个字符含义可以查看Type Encodings。(Type Encodings链接:https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html)
Message Forwarding(消息转发)
消息转发会调用forwardingTargetForSelector 方法获取新的target 作为 receiver 重新执行selector。如果是对象方法需要复写- (id)forwardingTargetForSelector:(SEL)aSelector方法。如果是类方法,则复写+ (id)forwardingTargetForSelector:(SEL)aSelector方法。
- (id)forwardingTargetForSelector:(SEL)aSelector {
if(aSelector == @selector(handleOpenPage:)){
return _otherObject;
}
return [super forwardingTargetForSelector:aSelector];
}
如果返回的对象不合法(为nil或者跟旧receiver一样),则进入forwardInvocation流程。可以在程序中复写这个方法来定义转发逻辑。anInvocation参数是runtime系统调用methodSignatureForSelector:方法获取方法签名时生成的对象。重写forwardInvocation:的同时也要重写methodSignatureForSelector:方法,否则会抛异常。
- (void)forwardInvocation:(NSInvocation *)anInvocation {
if ([_otherObject respondsToSelector:[anInvocation selector]]) {
[anInvocation invokeWithTarget:_otherObject];
} else {
[super forwardInvocation:anInvocation];
}
消息转发整个流程如图所示:
当一个对象没有实现响应的方法,runtime系统将通过forwardInvocation消息通知该对象。每个对象都从NSObject类中继承了forwardInvocation:方法,NSObject中的方法实现只是简单地调用了doesNotRecognizeSelector:。通过实现我们自己的forwardInvocation:方法,可以在该方法实现中将消息转发给其它对象。如果在这些流程中都不处理,则会抛出异常导致崩溃。
以上是金融App在崩溃治理过程中的典型案例和源码、原理分析。在上面案例治理过程中研发团队沉淀了非常有用的问题查找经验。
四、实战经验沉淀
1、用户的操作路径是复现问题的最佳提示,崩溃日志中的堆栈信息能追溯用户进入了哪些页面,处于什么状态,前台运行还是在后台。再配合APM性能监控平台就能够更清晰的分析出App当前所处的状态。根据启动Id可以看到崩溃之前App的网络状态,发送了哪些网络请求。这些都能帮助开发人员更快的复现问题。
2、崩溃发生在App本身的业务代码中往往是容易解决的,如果发生在使用的第三方开源库当中,可以优先去开源社区寻找相同的问题,一些经常维护的开源库经过了诸多App真实场景测试,会有类似的问题和解决方案。如果是研发团队攻克的,也可以在开源社区提交相关pull request。并将解决的经验分享给遇到相同问题的研发人员。
3、任何崩溃都有其发生的特定条件,当实在无法复现时应该从另一个角度去寻找解决办法,阅读源码是一个非常非常好的了解问题本质的方式,苹果的系统是闭环生态,但部分源码是开源的,可以在线阅读或者下载相关源代码深入理解。
4、崩溃日志是解决崩溃问题的第一手资料,崩溃日志中包含了App崩溃时的堆栈信息和崩溃原因。在开发中崩溃日志的阅读是非常重要的。所以在下面部分有必要简要简绍一下崩溃日志相关内容。
五、崩溃日志分析
1、崩溃日志内容
崩溃发生以后,我们第一个想到的就是崩溃在哪一行代码、堆栈是什么、运行中有哪些线程,这些信息都包含在崩溃报告中。以WWDC中的Demo举例,ChocolateChip运行在模拟器上。崩溃日志的顶部包含了一些摘要信息,包括App名称、版本号、操作系统以及崩溃的日期和时间。
下面部分是崩溃的原因,错误发生在主线程,崩溃类型是SIGILL即CPU正在执行不存在或无效的指令。Fatal error中显示崩溃具体原因是强制拆包一个可选值为nil的变量。
再下面部分就是崩溃的堆栈信息,可以查当前崩溃的线程,崩溃时堆栈信息等。
崩溃的原始堆栈信息如下图所示:
崩溃的原始堆栈信息是不方便直接定位崩溃问题的,还需要将原始堆栈信息进行符号化()。将内存地址转化为方法名、文件名和行号的过程叫做符号化。崩溃日志符号化有3个必须要素。
(1) 崩溃日志,崩溃日志可以在Xcode的Window选项中打开Organizer窗口,从Crashes面板中获取,也可以从App提交的后台下载。具备完备监控能力的App可以通过App端采集上报给服务器端存储,然后从服务器端下载崩溃日志。
(2) 符号表。dSYM(debugging SYMbols)又称为调试符号表。每一个通过Xcode编译上传的应用都会自动归档,在Xcode的Window选项中打开Organizer窗口,在Archives菜单中会显示已编译应用文件。选中文件通过show in finder→显示包内容就能看到应用的dsym文件。
(3)/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash路径可以获取Xcode自带的符号化工具。
将以上三个文件拷贝到同一个文件中,查看三个文件的UUID一致,然后使用终端进入当前目录,执行符号化命令./symbolicatecrash-vxxxx.crash xxxx.app.dSYM。完成后打开xxxx.crash文件,便能够看到已经符号化的堆栈,可以清楚的看到方法名、文件名和行号等信息。符号化的信息如下图所示:
当然,在日志的最下面还会有一些低级信息,包括崩溃线程的寄存器状态,还有加载到进程中的二进制数据镜像,这是App的可执行文件数据。xcode通过符号化查找符号、文件和行号信息,并显示在堆栈当中。
可执行文件镜像:
以上就是一个崩溃日志的所有内容,在这些内容中应该关注下面这些有用信息。
首先,从崩溃类型开始,例子中异常类型为EXC_BAD_INSTRUCTION异常,CPU在执行一个非法指令。崩溃信息中说明崩溃原因是强制拆包了一个可选对象。
其次,崩溃在主线程,堆栈中包含了崩溃时正在运行的函数堆栈。堆栈中看到了 fatalErrorMessage函数 这是一个系统函数,代码中的 一个函数调用了它。
从堆栈信息中(RecipeImage.swift:26)能够看到,调用发生在RecipeImage.swift文件的第26行。代码中有一个Recipe类,其image函数被调用并且该函数由于某些错误又调用了 fatalErrorMessage函数。在获取image时,代码中对可选路径进行了强制拆包,导致崩溃。见下图:
2、崩溃日志在哪查看
解读了崩溃日志的内容,那么在哪里可以查看崩溃日志
(1) 使用AppleID登录Xcode,在菜单栏中的organizer查看crash项。
(2) 如果能拿到崩溃的机器,可以直接获取设备中日志信息,并筛选出和App有关的日志信息。
(3) App监控平台,通过App端收集崩溃信息,在后台进行分类和解析。更方便的帮助开发定位问题。
六、崩溃专项治理攻略
1、成立崩溃专项,定一个阶段性目标。
在京东金融App崩溃专项治理之前,App的崩溃率是不稳定的,会随着版本发布和业务的迭代上下浮动。线上统计的崩溃问题种类繁多。有简单的数组越界崩溃、插入nil值崩溃,也有内存问题导致野指针、多线程异常。针对这些情况,团队成立崩溃治理专项小组,根据崩溃次数、紧急程度进行排序,循环解决崩溃列表前十问题。
2、崩溃模块定位和分发。
当前App已不局限于某一项业务,已经是多个业务功能的集合体。在整个App进行组件化拆分之后,各业务功能代码都以.a,.framwork等形式集成在App中。当一个崩溃发生后难以查找崩溃代码存在于哪个业务模块当中,给崩溃的分发和解决造成很大困难。基于这个问题,崩溃专项小组通过linkmap进行文件名称匹配,或者通过grep命令查找二进制中包含崩溃代码的.a和.framework文件。查找成功通过便捷形式发送各业务方及时处理。
3、建立App监控体系。
在以往的崩溃解决流程中,都是通过研发主动去苹果开发者后台或者第三方崩溃监控后台(bugly、友盟等)去查看当前崩溃趋势等,这种方式需要研发自驱并且是有延迟的,比如后台更改配置突然出现的大量崩溃很难及时响应。金融App通过APM性能监控系统对崩溃进行趋势监控,设定崩溃阈值,在多长时间之内崩溃数指定次数就会主动触发报警,一定时间崩溃率大于阈值也会触发报警,并以邮件、内部沟通工具等方式通知负责人及时处理。同时,每周自动发送性能周报来评估性能体系。
4、持续性关注一直存在的问题、扼制新出现问题。
日常开发中一定有一些问题是持续的,难以复现的。这类问题在日活平稳时崩溃量不大,但持续贯穿多个版本,平时可能会淹没在其他的崩溃当中,但一到618、双十一等日活大增时,崩溃量就会上升。这也是发现问题的一个特殊时期。另外,在解决原有问题的同时,上线后对新增业务重点监控。以防灰度中未出现的问题在更多用户使用时崩溃爆发。
5、编码规范。
良好的编码规范有助于减少编码错误。程序中复杂的算法和逻辑只程序的很少部分,大部分的崩溃通过代码review都能够避免。常规的字典数组插入空值、方法找不到等在开发、测试、灰度过后基本上已全部消灭。
七、总结
本篇着重讲了崩溃相关基本知识,包括崩溃的发生、崩溃的典型场景、如何解读崩溃日志。同时结合金融App开发过程中遇到的实际崩溃例子,从问题发生原因,如何定位崩溃位置,以及怎么复现,如何修复等几个方面详细进行分析。开发中可以根据崩溃的类型快和崩溃日志速定位问题,为典型崩溃提供一种解决思路。App性能和用户体验是一个长期优化过程,崩溃不会随着优化而停止,持续关注和优化才能让当今已经代码量爆炸的App稳步前行。
在本篇的第六部分,提到APM性能监控系统,APM性能监控系统是京东科技移动团队和运维团队一起倾力打造的性能监控平台,从启动耗时,网络请求波动,webView打开耗时,用户轨迹、原生页面监控、崩溃卡顿、自定义监控等功能应有尽有,达到了从启动到连接服务器再到退出的全链路监控。目前京东科技内多个App已接入APM性能监控系统,给各App及业务团队提供了更高的质量保障。
往期好文推荐: