ARC 环境下在多线程中履行赋值代码或许会发生野指针,导致 EXC_BAD_ACCESS 溃散。
这种溃散发生的概率很低,在开发和灰度阶段即使履行到相应代码也很难溃散,因此简单遗漏到正式环境。在上亿级用户的 App 往往会成为 Top 问题,对指标造成影响,而且很难排查。
今天头条在管理 Crash 的进程中彻底解决了数十个此类溃散,发现其具有必定共性。本文详细剖析溃散发生的进程,以及总结了简单呈现问题的场景,期望在咱们遇到此类问题时能供给一些思路。
1. 原理
Objective-C 目标的赋值进程包括创立新值、保存旧值、加载新值、开释旧值四步。比较 MRC,ARC 环境中编译器会自动插入保存与开释旧值的过程:
NSObject*_instance;
voidfoo(void){
_instance=[[NSObjectalloc]init];
}
这点在 AutomaticReferenceCounting [1] 文档中有提到,经过汇编代码也能够剖析:
objc_release 会减小目标的引证计数,减小到 0 时目标就会被毁掉,假设这时有其它线程正在运用这个目标,那么运用目标的线程就很或许发生溃散。
2. 溃散场景
为了演示仅一行赋值代码就能造成溃散,以及明晰地剖析溃散的原因,我规划了一个 Demo,在 B 线程中开释 A 线程创立的目标使 C 线程溃散:
复现进程:
-
A、B、C 三个线程一起进入
foo
函数 -
A 线程先创立初始值 _instance
A 线程履行到 _instance = x0, 创立了新值并赋给 _instance;此刻 _instance 引证计数为 1;
-
B、C 线程读取到 A 线程创立的初始值 _instance
B、C 线程别离履行到 x1 = _instance 时,从 _instance 中读到线程 A 创立的目标,保存到各自的上下文中;_instance 引证计数仍为 1;
-
B 线程开释 _instance
B 线程履行
objc_release(x1)
后会开释 _instance;_instance 引证计数变为 0,被毁掉; -
C 线程拜访 _instance
C 线程履行到
objc_release(x1)
时拜访 _instance;因为 _instance 现已被毁掉,拜访时会发生溃散。
运用 lldb 的 thread continue 指令 [2] 来控制整个流程,它能够仅让一个线程履行,其它线程保持挂起。
-
3 个线程一起进入
foo
函数操作过程:在
foo
函数里面打上断点,能够多次测验让 3 个线程一起进入断点。如图,线程 2 3 4 一起进入了
foo
函数:
-
线程 2 履行到 _instance = x0,创立初始值并赋给 _instance
操作过程:在 Thread 2 中给汇编代码第 10 行打断点,履行 thread continue,使 Thread 2 履行完 _instance = x0。
能够看到 Thread 2 创立的实例为 0x000000002813e400:
-
线程 3、4 履行到 x1 = _instance,读取到线程 2 创立的 _instance
操作过程 1:删去一切断点,切换到 Thread 3 ,给第 9 行打断点,履行 thread continue
操作过程 2:删去断点,切换到 Thread 4,给第 9 行打断点,履行 thread continue
线程 3、4 从 _instance 中读到了线程 2 创立的 _instance 0x000000002813e400:
-
线程 3 履行完 objc_release,_instance 引证计数变为 0,被毁掉
操作过程:删去断点,切换到 Thread 3,给第 12 行打断点,履行 thread continue。
履行后打印 0x000000002813e400 呈现随机值,阐明 _instance 现已被毁掉:
-
线程 4 履行 objc_release,拜访被毁掉的 _instance,呈现溃散
操作过程:删去断点,切换到 Thread 4,给第 12 行打断点,履行 thread continue。
因为 _instance 现已被毁掉,再次拜访它时发生 EXC_BAD_ACCESS 溃散。
3. 溃散原因
如下图,为什么会发生 EXC_BAD_ACCESS 溃散?
ldr x17, [x2, #0x20]
指令以为寄存器 x2 中寄存的是地址,将该地址和 0x20 相加获得一个新地址,再从新地址中读取 8 字节寄存到 x17 中。
本例中能够剖分出寄存器 x2 寄存的是 Class 的地址,x2+0x20 是 Class 的成员变量 bits 的地址,这个地址是0x00000007374040e0
。从这个地址中读值时操作体系发现它是不合法内存地址,从而发生 EXC_BAD_ACCESS 反常并报出这个错误地址。
附:Class 的结构体及成员变量的偏移
为什么 Class->bits 的地址会是0x00000007374040e0
,这个不合法地址是怎样来的?
_instance 目标被毁掉后,内存被体系随机改写,经过溃散截图中 lldb 打印的日志可知:
- 目标的 ISA 方位寄存的随机值是 0x000010d7374040c0
- Class = ISA & ISA_MASK = 0x00000007374040c0
- Class->bits = 0x00000007374040c0 + 0x20 =
0x00000007374040e0
ISA 是随机值,那么 Class、Class->bits 也都是随机值,很简单是一个不合法的内存地址,拜访不合法内存地址就会发生 EXC_BAD_ACCESS 反常。
在履行 objc_release 函数之前 _instance 就现已毁掉了,为什么履行到ldr x17, [x2, #0x20]
这一行指令时才发生溃散,之前没有溃散?
EXC_BAD_ACCESS 反常发生在拜访不合法内存地址时。在ldr x17, [x2, #0x20]
之前仅有ldr x16, [x0]
中运用方括号[]
拜访了 x0 中存储的地址。此刻 x0 中存储的是 _instance 的地址,_instance 毁掉后目标的内存被体系随机改写,而 x0 中的地址是之前就存进来的合法地址,拜访合法地址不会呈现反常。
4. 更多溃散场景
上述溃散发生在 objc_release 仓库中,但实践或许发生在任意仓库,这与 _instance 运用的场景有关。下面构造了一些常见的溃散仓库,感兴趣的读者能够参照复现。
4.1 溃散在 objc_retain 中
溃散原因:_instance 作为参数传递到 bar 函数,在函数开端履行时会保存参数objc_reatin(_instance)
,结束履行时会开释参数objc_release(_instance)
。若保存参数时 _instance 已被其它线程毁掉,就会导致溃散在 objc_reatin 中。
4.2 溃散在 objc_msgSend 中
溃散原因:第 7 行代码向 _instance 发送了isEqual:
消息,在履行到溃散指令ldr x11,[x16, #0x10]
时,寄存器 x16 寄存的是 _instance 的 Class,[x16, #0x10]
指令想要读取 Class->cache,进而从 cache 中寻找缓存的办法。_instance 毁掉后 ISA、Class、Class->cache 会成为随机值,假如 Class->cache 是不合法地址,在履行[x16, #0x10]
时就会溃散。
4.3 溃散在 objc_autoreleasePoolPop 中
溃散原因:若目标运用非new/alloc/copy/mutableCopy
开始的接口创立,而且不满足 Autorelease elision [3] 战略,会被添加到自动开释池中。本例创立的 _instance 被添加到子线程的自动开释池中,子线程使命履行完成后会对池中的目标 pop,依次调用 objc_release 进行开释,若次此刻 _instance 已在其它线程中毁掉,就会发生溃散。
4.4 EXC_BREAKPOINT 溃散
除了上面提到的 EXC_BAD_ACCESS 反常,这类问题也能导致其它类型的反常,这里举一个 EXC_BREAKPOINT 反常的例子。
溃散原因:-[NSString stringWithFormat:@"%@",_instance]
会调用 objc_opt_respondsToSelector 函数并将 _instance 作为参数传入。在 objc_opt_respondsToSelector 函数发生溃散前,x16 存储的是参数 _instance 的 Class。
指针认证 [4] 相关的指令会使 x16 寄存器与 x17 寄存器持平,然后用xpacd x17
对 x17 寄存器中高位清零,再比较 x16 与 x17,不持平则履行 brk 指令触发 EXC_BREAKPOINT 反常。xpacd
对合法指针清零不会改动指针的值,不会履行 brk 指令发生反常。当参数被毁掉后,x16 或许被改写为不合法指针并赋给 x17,xpacd x17
对不合法指针高位清零会改动 x17,使 x17 不等于 x16,导致 EXC_BREAKPOINT 反常。
5. 典型事务场景
事务中有三种常见导致溃散的场景,本文从每个场景中选择了两个典型事例。
5.1 场景一 对全局变量赋值
典型事例 1
这段代码定义了全局变量 geckoSettingDict,并在在一个懒加载办法中对它初始化。开始这段代码正常运转在于 A 事务中,后边被 B 事务拷贝走,B 事务存在多线程调用的场景,在 geckoSettingDict 未初始化时,多个线程能够一起进入if (geckoSettingDict == nil)
对 geckoSettingDict 赋值,导致 geckoSettingDict 被提早毁掉发生溃散。
因为运用了dictionaryWithContestOfFile:
接口初始化,geckoSettingDict 会被添加到自动开释池中,导致溃散发生在 objc_autoreleasePoolPop 仓库里,很难追查。这个问题困扰头条半年之久,终究借助字节内部 APM 供给的线上东西定位到原因:
修正办法是运用 dispatch_once 确保 geckoSettingDict 只赋值一次:
典型事例 2
在图片监控的组中件, queue 被规划为全局变量,在startImageMonitor:
中对它初始化,这是启动监控功用的办法,调用一次就能够了。但运用方在某次改动中,无意间在另一个线程中多调用了一次startImageMonitor:
办法,使 queue 被一起赋值了两次,导致它提早毁掉。
另一线程在运用dispatch_async(queue,^{})
接口时,因为 queue 现已被毁掉,在 dispatch_async 仓库中发生溃散:
溃散在 ldr x3, [x16, #0x58] 是因为 x16 存储的是 dispatch_async 的参数 queue,queue 被毁掉后,queue + 0x58 或许是一个不合法内存地址,从该不合法地址读值会导致反常。
修正办法是事务方调整了调用逻辑,图片监控组件中也优化了代码,运用 dispatch_once 确保 queue 只能赋值一次。
场景小结
这类问题常见于开发者规划了全局变量,并在对外露出的接口中对全局变量进行赋值,开发者预期变量只会初始化一次,但实践接口被调用的环境不可控。
修正主张:运用 dispatch_once,确保全局变量只被赋值一次。
5.2 场景二 对特点赋值
典型事例 1
某类规划了特点 extraParam 用于保存透传参数,并在updateExtraParams:
办法中更新该特点。开始updateExtraParams:
也在多线程中被调用,但没有造成很大影响,某次需求增大了它被一起调用的概率,引发了大面积的溃散。
典型事例 2
A 事务规划了单例类 Configure 并供给了对外的特点 autoResolutionParams。B 事务对 Configure 的特点 autoResolutionParams 重新赋值使它被毁掉,导致其它正在运用 autoResolutionParams 的线程溃散。
场景小结
这类问题常见于类向外部供给了接口来更新成员变量,但接口被调用的环境不可控。
单例的特点更简单被外界拜访,更简单在多线程下呈现赋值,因此这类问题也最多。
修正主张:触及多线程修改的特点,运用 atomic 润饰。
5.3 场景三 特点懒加载
典型事例 1
某类在懒加载办法中对 _interceptUrls 赋值,在 addADparamsToRequest 办法中调用self.interceptUrls
触发懒加载。因为事务环境复杂,addADparamsToRequest 在主线程、网络回调线程、告诉线程等多个场景中被调用,多线程下一起对 _interceptUrls 赋值导致它被提早毁掉,发生溃散。
修正办法是将 _interceptUrls 的初始化放在 init 办法中,确保它只被赋值一次。
典型事例 2
某类在懒加载办法中对 _userCache 赋值,在cacheUserInfo:
、removeCachedUserInfo:
等 4 个办法中都调用了self.userCache
触发懒加载,这 4 个办法或许一起被多个线程调用,很简单呈现多线程环境下对 _userCache 赋值,导致它提早毁掉。解决办法是将 _userCache 初始化放在 init 中,确保它只会被赋值一次。
场景小结
这是类场景比上述场景都愈加隐蔽,在规划懒加载办法时要考虑触发懒加载的办法是否会在多线程环境中被调用。
修正主张:假如懒加载特点会被多线程拜访到,就不要运用懒加载,直接在 init 办法中初始化,确保赋值的代码只会被一个线程拜访。
6. 总结
发生这类溃散的原因尽管简单,但是在大型 App 中很难避免。跟着事务方增多、触发赋值代码的接口增多,调用环境会更复杂;而且也存在类似代码 copy ,从无问题环境 copy 到有问题环境,很简单呈现多线程环境下一起给目标赋值,导致旧值被过度开释。
在剖析此类溃散仓库时,往往很难注意到是赋值时 ARC 添加的 objc_release 指令使旧值被过度开释导致的,而且线下也基本无法复现,因此这类野指针问题也简单成为悬案。了解原理和常见场景有助于排查问题,更有助于在开发阶段就规划稳健的代码。
7. 答疑
- EXC_BAD_ACCESS 是否都是这种问题导致的?
- 不是,拜访不合法内存地址就会报 EXC_BAD_ACCESS 错误。
- 但根据经验来看,非多线程导致的问题在开发和测验环境中比较简单复现,在上线前基本都会被修正,上线后才迸发出来的野指针问题 80% 都是这个原因。
- 怎么剖析此类溃散?
- 有事务代码仓库的溃散,能够经过反汇编推断出详细溃散的目标;在工程中检索对该目标赋值的代码是否存在多线程调用,假如存在就基本能够确认溃散原因是多线程赋值导致。
- 纯体系仓库的溃散,如发生在 objc_autoreleasePoolPop 仓库的溃散。经过反汇编只能推断出是某个目标被 over-release 了,无法推断出详细是哪个目标。字节内部的同学能够运用 APM 供给的 Zombie、GWPASan、Coredump 等线上东西 [5]进行排查;假如没有线上东西,需求找到与该溃散同一版别/时间段上涨的其它野指针溃散,它们有或许是同一个原因导致的,从有事务代码仓库的溃散入手去排查。
8. 参加咱们
咱们是字节跳动产品研制和工程架构部-头条-客户端根底技能-iOS 团队,在功能优化、根底组件、事务架构、研制体系、安全合规、线下质量根底设施、线上问题定位归因渠道等方向深耕,负责保证和提升今天头条、西瓜视频和番茄小说的产品质量与开发功率,聚焦于此的一起向外延伸。
假如你对技能充满热情,喜欢追求极致,渴望用自己的代码改动数亿用户的体验,欢迎参加咱们。目前咱们在北京、深圳、广州均有招聘需求,简历投递邮箱:chenjun.jonas@bytedance.com;邮件标题:姓名-作业年限-产品研制和工程架构部-头条-客户端根底技能-iOS/Android。
9. 参考文献
[1] Objective-C Automatic Reference Counting (ARC) — Clang 16.0.0git documentation (clang.llvm.org/docs/Automa…)
[2] LLDB Tutorial (opensource.apple.com/source/lldb…)
[3] WWDC22: Improve app size and runtime performance – (/post/713534…)
[4] ARM-指针认证 (www.jianshu.com/p/62bf046b7…)
[5] 字节跳动怎么体系性管理 iOS 稳定性问题 (/post/703441…)
活动预告
围绕头条在 iOS 稳定性管理的经验,未来咱们将展开更多技能共享,你可添加下方小帮手回复【iOS】,第一时间获知活动报名信息 ⬇️