ARC环境下多线程执行的赋值代码可能会产生野指针,导致EXC_BAD_ACCESS崩溃。这种崩溃的概率很低,即使在开发和灰度阶段执行相应的代码也很难崩溃,所以很容易错过官方环境。亿级用户的应用往往成为头号问题,影响指标,排查难度大。在管理Crash的过程中,今日头条彻底解决了几十个这样的crash,发现它们有一定的共性。本文详细分析了崩溃的过程,总结了容易出现问题的场景,希望能在遇到此类问题时提供一些思路。一、原理一个Objective-C对象的赋值过程包括四个步骤:创建新值、保留旧值、加载新值和释放旧值。相比MRC,ARC环境下编译器会自动插入保留和释放旧值的步骤:NSObject*_instance;voidfoo(void){_instance=[[NSObjectalloc]init];}这个在AutomaticReferenceCounting[1]document中提到汇编代码也可以分析:objc_release会减少对象的引用计数,减为0时对象会被销毁。如果此时其他线程正在使用这个对象,那么使用该对象的线程很可能会发生崩溃。2、崩溃场景为了演示只有一行赋值代码会导致崩溃,并且清楚分析崩溃原因,我设计了一个demo,在线程B中释放线程A创建的对象,让线程C崩溃:重现过程:A、B、C三个线程同时进入foo函数。线程A先创建初始值_instanceA线程执行到_instance=x0,创建新值赋给_instance;此时_instance的引用计数为1;B当线程C读取到线程A创建的初始值_instanceB,线程C分别执行到x1=_instance时,从_instance中读取线程A创建的对象,保存在各自的上下文中;_instance的引用计数还是1;B线程释放_instanceB线程执行objc_release(x1)后会释放_instance;_instance引用计数变为0并被销毁;C线程访问_instanceC线程执行到objc_release(x1)时访问_instance;由于_instance已经被销毁,访问时会发生crash。使用lldb的threadcontinue指令[2]来控制整个过程,只允许一个线程执行,而其他线程保持挂起状态。3个线程同时进入foo函数操作步骤:在foo函数中设置断点,可以多次测试让3个线程同时进入断点。如图,线程2、3、4同时进入了函数foo:线程2执行到_instance=x0,创建一个初始值赋给_instance操作步骤:在第10行设置断点Thread2中的汇编代码,执行threadcontinue,使Thread2执行完_instance=x0。可以看到Thread2创建的instance是0x000000002813e400:Thread3和4执行到x1=_instance,读取Thread2创建的_instance。第一步:删除所有断点,切换到Thread3,中断第9行点,执行线程继续操作步骤2:删除断点,切换到线程4,在第9行断点,执行线程继续线程3、4从_instance0x000000002813e400读取线程2创建的_instance:线程3执行objc_release后,引用计数of_instance变为0,它被销毁。操作步骤:删除断点,切换到线程3,在第12行设置断点,执行threadcontinue。执行后打印0x000000002813e400,出现一个随机值,说明_instance已经被销毁:线程4执行objc_release,访问被销毁的_instance,发生crash操作步骤:删除断点,切换到线程4,在第12行设置断点,执行线程继续。由于_instance已经被销毁,再次访问时会出现EXC_BAD_ACCESS崩溃。3、崩溃原因如下图所示。为什么会出现EXC_BAD_ACCESS崩溃?ldrx17,[x2,#0x20]该指令考虑寄存器x2中存储的地址,将地址与0x20相加得到新地址,然后从新地址读取8个字节存储到x17中。在这个例子中,可以分析出寄存器x2存放的是Class的地址,x2+0x20是Class的成员变量位的地址,这个地址是0x00000007374040e0。当从这个地址读取一个值时,操作系统发现它是一个非法的内存地址,从而产生EXC_BAD_ACCESS异常并报告这个错误的地址。附:Class->bits的Class结构体和成员变量的地址为什么是0x00000007374040e0,这个非法地址是怎么来的?_instance对象销毁后,内存会被系统随机改写。根据lldb在崩溃截图中打印的日志:对象的ISA位置存储的随机值是0x000010d7374040c0Class=ISA&ISA_MASK=0x00000007374040c0Class->bits=0x00000007374040c0+0x207e7000=00所以ISA是一个随机值Class和Class->bits也是随机值,很容易成为非法内存地址。访问非法内存地址将产生EXC_BAD_ACCESS异常。_instance在objc_release函数执行之前就已经销毁了,为什么在执行ldrx17,[x2,#0x20]这行的时候就崩溃了,而之前没有崩溃呢?EXC_BAD_ACCESS异常发生在访问非法内存地址时。在ldrx17,[x2,#0x20]之前,只有ldrx16,[x0]使用方括号[]来访问存储在x0中的地址。此时_instance的地址存放在x0中,_instance销毁后对象的内存被系统随机改写,x0中的地址为之前存放的合法地址,访问时不会出现异常法定地址。4.更多的崩溃场景上面的崩溃发生在objc_release栈中,但实际上它可能发生在任何栈中,这与_instance使用的场景有关。下面构建了一些常见的crashstack,感兴趣的读者可以参考转载。4.1崩溃objc_retain中崩溃的原因:_instance作为参数传递给bar函数。函数开始执行时,参数objc_reatin(_instance)会被保留,函数结束时参数objc_release(_instance)会被释放。如果保留参数时_instance已经被其他线程销毁,会导致objc_reatin崩溃。4.2Crashobjc_msgSend中crash的原因:第7行的代码向_instance发送了一个isEqual:消息,crash指令执行到ldrx11,[x16,#0x10]时,寄存器x16存储是_instance的Class,[x16,#0x10]指令要读取Class->cache,然后从缓存中找到缓存方法。_instance销毁后,ISA、Class、Class->cache会变成随机值。如果Class->cache是??非法地址,执行[x16,#0x10]时会崩溃。4.3Crashobjc_autoreleasePoolPopcrash原因:如果一个对象是使用非以new/alloc/copy/mutableCopy开头的接口创建的,不满足Autoreleaseelision[3]策略,它会被添加到autoreleasepool中。本例中创建的_instance被添加到子线程的自动释放池中。子线程任务执行完成后,弹出池中的对象,依次调用objc_release释放。如果此时_instance已经在其他线程中被销毁,就会发生crash。4.4EXC_BREAKPOINTcrash除了上面提到的EXC_BAD_ACCESS异常,这类问题还会引起其他类型的异常。这是EXC_BREAKPOINT异常的示例。崩溃原因:-[NSStringstringWithFormat:@"%@",_instance]会调用objc_opt_respondsToSelector函数,将_instance作为参数传入。在objc_opt_respondsToSelector函数崩溃之前,x16存储了参数_instance的Class。指针认证相关的指令[4]会让x16寄存器等于x17寄存器,然后使用xpacdx17清空x17寄存器的高位,然后比较x16和x17,不相等则执行brk指令触发EXC_BREAKPOINT异常。xpacd清除合法指针不会改变指针的值,也不会执行brk指令产生异常。当参数被销毁时,x16可能被改写为非法指针赋值给x17,xpacdx17会改变x17,使x17不等于x16,从而导致EXC_BREAKPOINT异常。五、典型业务场景导致业务崩溃的常见场景有3种,本文分别从每种场景中选取2个典型案例。5.1场景1全局变量赋值典型案例1这段代码定义了全局变量geckoSettingDict,并以懒加载的方式对其进行了初始化。最初这段代码在A业务中正常运行,后来被B业务复制过来。B业务中有一个多线程的调用场景。当geckoSettingDict未初始化时,多个线程可以同时进入。if(geckoSettingDict==nil)给geckoSettingDict赋值导致geckoSettingDict被提前销毁导致崩溃。由于dictionaryWithContestOfFile:接口初始化,将geckoSettingDict加入autoreleasepool,导致objc_autoreleasePoolPop栈崩溃,难以追查。这个问题困扰了头条半年,最终借助字节内部APM提供的在线工具定位原因:解决方法是使用dispatch_once保证geckoSettingDict只赋值一次:典型案例2是在图像监控组中,将队列设计为一个全局变量,在startImageMonitor:中初始化,这是启动监控功能的方法,调用一次即可。但是,用户在某个变化过程中无意中在另一个线程中再次调用了startImageMonitor:方法,导致队列同时被赋值两次,导致队列提前销毁。当另一个线程使用dispatch_async(queue,^{})接口时,因为队列已经被销毁,dispatch_async栈发生crash:crash在ldrx3,[x16,#0x58]因为x16存储了参数dispatch_async的queue,queue销毁后,queue+0x58可能是非法内存地址,从这个非法地址读取一个值会引发异常。fix是业务方调整调用逻辑,优化图片监控组件中的代码,使用dispatch_once保证队列只能分配一次。场景总结开发者设计全局变量,在暴露的接口中给全局变量赋值时,这类问题很常见。开发者期望变量只被初始化一次,但实际调用接口的环境是不可控的。修复建议:使用dispatch_once保证全局变量只被赋值一次。5.2场景2属性赋值典型案例1某类设计了属性extraParam来存储透明参数,并在updateExtraParams:方法中更新该属性。最初updateExtraParams:也是在多线程中调用的,但影响不大。某个需求增加了同时被调用的概率,导致大规模崩溃。典型案例2A,设计了单例类Configure,提供了外部属性autoResolutionParams。业务B重新赋值Configure的属性autoResolutionParams将其销毁,导致其他正在使用autoResolutionParams的线程崩溃。场景总结此类问题常见于类对外提供接口更新成员变量,但接口调用环境不可控的情况。单例的属性更容易被外界访问,多线程下更容易赋值,所以这类问题最多。修复建议:对涉及多线程修改的属性使用原子修改。5.3场景三属性懒加载典型案例1某类在懒加载方法中给_interceptUrls赋值,在addADparamsToRequest方法中调用self.interceptUrls触发懒加载。由于业务环境复杂,addADparamsToRequest在主线程、网络回调线程、通知线程等多种场景下被调用。多线程下同时给_interceptUrls赋值导致它被提前销毁,导致崩溃。修复方法是将_interceptUrls的初始化放在init方法中,保证只赋值一次。典型案例2某类在懒加载方法中给_userCache赋值,在cacheUserInfo:,removeCachedUserInfo:四个方法中调用self.userCache触发懒加载。这四个方法可能会被多个线程同时调用,在多线程环境下很容易给_userCache赋值,导致它被提前销毁。解决方法是将_userCache初始化放在init中,保证只会被赋值一次。场景总结这是一个比上述场景更隐蔽的类场景。在设计懒加载方法时,需要考虑在多线程环境下是否会调用触发懒加载的方法。修复建议:如果懒加载属性会被多线程访问,不要使用懒加载,直接在init方法中初始化,保证分配的代码只会被一个线程访问。6.总结这种crash的原因很简单,但是在大型app中很难避免。随着业务方的增多,触发赋值码的接口越来越多,调用环境会越来越复杂;而且也有类似的代码拷贝,从无问题的环境拷贝到有问题的环境,在多线程环境下很容易同时给对象赋值,导致oldValue被过度释放。在分析这种crashstack的时候,往往很难注意到ARC添加的objc_release指令导致旧值被过度释放,离线基本无法重现,所以这种野指针问题也很容易出现成为悬案。熟悉原理和常见场景有助于排查问题,有助于在开发阶段设计健壮的代码。7.Q&AEXC_BAD_ACCESS都是这种问题引起的吗?不行,访问非法内存地址会报EXC_BAD_ACCESS错误。但是根据经验,非多线程引起的问题在开发测试环境中更容易重现,上线前基本都会修好。上线后爆发的野指针问题,80%都是因为这个原因。如何分析此类崩溃?如果业务代码栈发生崩溃,可以通过反汇编推导出具体的崩溃对象;在项目中,查看赋值对象的代码中是否存在多线程调用。如果有,则基本可以确定是多线程赋值导致崩溃的原因。纯系统栈崩溃,比如objc_autoreleasePoolPop栈崩溃。通过反汇编,只能推断出某个对象被过度释放了,而无法推断出是哪个对象。Byte的同学可以使用APM提供的Zombie、GWPASan、Coredump等在线工具[5]排查问题;如果没有在线工具,则需要查找与该崩溃在同一版本/时间段内出现的其他野指针崩溃。他们有可能是同一个原因引起的,从业务代码栈的崩溃入手排查。8、加入我们我们是字节跳动产品研发与工程架构部-今日头条-客户端基础技术-iOS团队,在性能优化、基础组件、业务架构、研发体系、安全合规、线下质量基础设施、线下负责保障和提升今日头条、西瓜视频、番茄小说的产品质量和开发效率,以此为重点,向外延伸。如果你热爱技术,喜欢追求极致,渴望用自己的代码改变亿万用户的体验,欢迎加入我们。目前我们在北京、深圳、广州都有招聘需求。将简历发送至邮箱:chenjun.jonas@bytedance.com;邮箱标题:姓名-工作年限-产品研发及工程架构部-今日头条-客户端基础技术-iOS/Android.九。参考资料[1]Objective-C自动引用计数(ARC)—Clang16.0.0git文档(https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics)[2]LLDB教程(https://opensource.apple.com/source/lldb/lldb-310.2.36/www/tutorial.html)[3]WWDC22:提升app体积和运行时性能——掘金(https://juejin.cn/post/7135344206939160612#heading-5)[4]ARM指针认证(https://www.jianshu.com/p/62bf046b7701)[5]字节跳动如何系统化管理iOS稳定性问题(https://juejin.cn/post/7034418275728097288)
