Toutiao Stability Governance: Hidden Crash Hazards of Objective-C Object Assignment in ARC Environment

In the ARC environment, executing assignment code in multiple threads may generate wild pointers, resulting in EXC_BAD_ACCESS crashes.

The probability of such a crash is very low, and it is difficult to crash even if the corresponding code is executed in the development and grayscale stages, so it is easy to miss the official environment. Apps with hundreds of millions of users often become top problems, which affect indicators and are difficult to troubleshoot.

In the process of managing Crash, Toutiao has completely solved dozens of such crashes and found that they have certain commonalities. This article analyzes the process of the crash in detail, and summarizes the scenarios that are prone to problems, hoping to provide some ideas when you encounter such problems.

1. Principle

The assignment process of an Objective-C object includes four steps: creating a new value, retaining the old value, loading the new value, and releasing the old value. Compared with MRC, the compiler in the ARC environment will automatically insert the steps of retaining and releasing the old value:

NSObject *_instance;
void foo(void) {
    _instance = [[NSObject alloc] init];
}
复制代码

picture

picture

This is mentioned in the AutomaticReferenceCounting [1] document, which can also be analyzed through assembly code:

picture

picture

objc_release will reduce the reference count of the object, and the object will be destroyed when it is reduced to 0. If other threads are using this object at this time, the thread using the object is likely to crash.

2. Crash Scenario

In order to demonstrate that only one line of assignment code can cause a crash and clearly analyze the cause of the crash, I designed a demo that releases the object created by the A thread in the B thread to crash the C thread:

picture

Reproduction process:

picture

  1. Three threads A, B, and C enter the  foo function at the same time

  2. A thread first creates the initial value _instance

    Thread A executes until _instance = x0, creates a new value and assigns it to _instance; at this time, the reference count of _instance is 1;

  3. B. Thread C reads the initial value _instance created by thread A

    When threads B and C respectively execute x1 = _instance, they read the object created by thread A from _instance and save it in their respective contexts; the reference count of _instance is still 1;

  4. B thread releases _instance

    B 线程执行 objc_release(x1) 后会释放 _instance;_instance 引用计数变为 0,被销毁;

  5. C 线程访问 _instance

    C 线程执行到 objc_release(x1) 时访问 _instance;由于 _instance 已经被销毁,访问时会发生崩溃。

使用 lldb 的 thread continue 指令 [2] 来控制整个流程,它可以仅让一个线程执行,其它线程保持挂起。

  1. 3 个线程同时进入 foo 函数

    操作步骤:在 foo 函数里面打上断点,可以多次测试让 3 个线程同时进入断点。

    如图,线程 2 3 4 同时进入了 foo 函数:

picture

  1. 线程 2 执行到 _instance = x0,创建初始值并赋给 _instance

    操作步骤:在 Thread 2 中给汇编代码第 10 行打断点,执行 thread continue,使 Thread 2 执行完 _instance = x0。

    可以看到 Thread 2 创建的实例为 0x000000002813e400:

picture

  1. 线程 3、4 执行到 x1 = _instance,读取到线程 2 创建的 _instance

    操作步骤 1:删除所有断点,切换到 Thread 3 ,给第 9 行打断点,执行 thread continue

    操作步骤 2:删除断点,切换到 Thread 4,给第 9 行打断点,执行 thread continue

    线程 3、4 从 _instance 中读到了线程 2 创建的 _instance 0x000000002813e400:

picture

  1. 线程 3 执行完 objc_release,_instance 引用计数变为 0,被销毁

    操作步骤:删除断点,切换到 Thread 3,给第 12 行打断点,执行 thread continue。

    执行后打印 0x000000002813e400 出现随机值,说明 _instance 已经被销毁:

picture

  1. 线程 4 执行 objc_release,访问被销毁的 _instance,出现崩溃

    操作步骤:删除断点,切换到 Thread 4,给第 12 行打断点,执行 thread continue。

    由于 _instance 已经被销毁,再次访问它时发生 EXC_BAD_ACCESS 崩溃。

picture

3. 崩溃原因

如下图,为什么会发生 EXC_BAD_ACCESS 崩溃?

picture

ldr x17, [x2, #0x20] 指令认为寄存器 x2 中存放的是地址,将该地址和 0x20 相加获得一个新地址,再从新地址中读取 8 字节存放到 x17 中。

本例中可以分析出寄存器 x2 存放的是 Class 的地址,x2+0x20 是 Class 的成员变量 bits 的地址,这个地址是 0x00000007374040e0。从这个地址中读值时操作系统发现它是非法内存地址,从而产生 EXC_BAD_ACCESS 异常并报出这个错误地址。

附:Class 的结构体及成员变量的偏移

picture

为什么 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 中

picture

picture

崩溃原因:_instance 作为参数传递到 bar 函数,在函数开始执行时会保留参数 objc_reatin(_instance),结束执行时会释放参数objc_release(_instance)。若保留参数时 _instance 已被其它线程销毁,就会导致崩溃在 objc_reatin 中。

4.2 崩溃在 objc_msgSend 中

picture

picture

崩溃原因:第 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 中

picture

picture

崩溃原因:若对象使用非 new/alloc/copy/mutableCopy 开头的接口创建,并且不满足 Autorelease elision [3] 策略,会被添加到自动释放池中。本例创建的 _instance 被添加到子线程的自动释放池中,子线程任务执行完成后会对池中的对象 pop,依次调用 objc_release 进行释放,若次此时 _instance 已在其它线程中销毁,就会发生崩溃。

4.4  EXC_BREAKPOINT 崩溃

除了上面提到的 EXC_BAD_ACCESS 异常,这类问题也能导致其它类型的异常,这里举一个 EXC_BREAKPOINT 异常的例子。

picture

picture

崩溃原因:-[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

picture

这段代码定义了全局变量 geckoSettingDict,并在在一个懒加载方法中对它初始化。最初这段代码正常运行在于 A 业务中,后面被 B 业务拷贝走,B 业务存在多线程调用的场景,在 geckoSettingDict 未初始化时,多个线程可以同时进入 if (geckoSettingDict == nil) 对 geckoSettingDict 赋值,导致 geckoSettingDict 被提前销毁产生崩溃。

由于使用了 dictionaryWithContestOfFile: 接口初始化,geckoSettingDict 会被添加到自动释放池中,导致崩溃发生在 objc_autoreleasePoolPop 堆栈里,很难追查。这个问题困扰头条半年之久,最终借助字节内部 APM 提供的线上工具定位到原因:

picture

修复办法是使用 dispatch_once 保证 geckoSettingDict 只赋值一次:

picture

典型案例 2

picture

picture

在图片监控的组中件, queue 被设计为全局变量,在 startImageMonitor: 中对它初始化,这是启动监控功能的方法,调用一次就可以了。但使用方在某次改动中,无意间在另一个线程中多调用了一次 startImageMonitor: 方法,使 queue 被同时赋值了两次,导致它提前销毁。

另一线程在使用 dispatch_async(queue,^{}) 接口时,由于 queue 已经被销毁,在 dispatch_async 堆栈中发生崩溃:

picture

picture

崩溃在 ldr x3, [x16, #0x58] 是因为 x16 存储的是 dispatch_async 的参数 queue,queue 被销毁后,queue + 0x58 可能是一个非法内存地址,从该非法地址读值会导致异常。

修复办法是业务方调整了调用逻辑,图片监控组件中也优化了代码,使用 dispatch_once 保证 queue 只能赋值一次。

场景小结

这类问题常见于开发者设计了全局变量,并在对外暴露的接口中对全局变量进行赋值,开发者预期变量只会初始化一次,但实际接口被调用的环境不可控。

修复建议:使用 dispatch_once,保证全局变量只被赋值一次。

5.2 场景二 对属性赋值

典型案例 1

picture

picture

某类设计了属性 extraParam 用于保存透传参数,并在 updateExtraParams: 方法中更新该属性。最初 updateExtraParams: 也在多线程中被调用,但没有造成很大影响,某次需求增大了它被同时调用的概率,引发了大面积的崩溃。

典型案例 2

picture

picture

A 业务设计了单例类 Configure 并提供了对外的属性 autoResolutionParams。B 业务对 Configure 的属性 autoResolutionParams 重新赋值使它被销毁,导致其它正在使用 autoResolutionParams 的线程崩溃。

场景小结

这类问题常见于类向外部提供了接口来更新成员变量,但接口被调用的环境不可控。

单例的属性更容易被外界访问,更容易在多线程下出现赋值,因此这类问题也最多。

修复建议:涉及多线程修改的属性,使用 atomic 修饰。

5.3 场景三 属性懒加载

典型案例 1

picture

某类在懒加载方法中对 _interceptUrls 赋值,在 addADparamsToRequest 方法中调用 self.interceptUrls 触发懒加载。由于业务环境复杂,addADparamsToRequest 在主线程、网络回调线程、通知线程等多个场景中被调用,多线程下同时对 _interceptUrls 赋值导致它被提前销毁,产生崩溃。

修复办法是将 _interceptUrls 的初始化放在 init 方法中,保证它只被赋值一次。

picture

典型案例 2

picture

某类在懒加载方法中对 _userCache 赋值,在 cacheUserInfo:removeCachedUserInfo:等 4 个方法中都调用了 self.userCache 触发懒加载,这 4 个方法可能同时被多个线程调用,很容易出现多线程环境下对 _userCache 赋值,导致它提前销毁。解决办法是将 _userCache 初始化放在 init 中,保证它只会被赋值一次。

场景小结

这是类场景比上述场景都更加隐蔽,在设计懒加载方法时要考虑触发懒加载的方法是否会在多线程环境中被调用。

修复建议:如果懒加载属性会被多线程访问到,就不要使用懒加载,直接在 init 方法中初始化,保证赋值的代码只会被一个线程访问。

6. 总结

产生这类崩溃的原因虽然简单,但是在大型 App 中很难避免。随着业务方增多、触发赋值代码的接口增多,调用环境会更复杂;而且也存在相似代码 copy ,从无问题环境 copy 到有问题环境,很容易出现多线程环境下同时给对象赋值,导致旧值被过度释放。

在分析此类崩溃堆栈时,往往很难注意到是赋值时 ARC 添加的 objc_release 指令使旧值被过度释放导致的,并且线下也基本无法复现,因此这类野指针问题也容易成为悬案。熟悉原理和常见场景有助于排查问题,更有助于在开发阶段就设计稳健的代码。

7. 答疑

  1. EXC_BAD_ACCESS 是否都是这种问题导致的?
  • 不是,访问非法内存地址就会报 EXC_BAD_ACCESS 错误。
  • 但根据经验来看,非多线程导致的问题在开发和测试环境中比较容易复现,在上线前基本都会被修复,上线后才爆发出来的野指针问题 80% 都是这个原因。
  1. 如何分析此类崩溃?
  • 有业务代码堆栈的崩溃,可以通过反汇编推断出具体崩溃的对象;在工程中检索对该对象赋值的代码是否存在多线程调用,如果存在就基本可以确认崩溃原因是多线程赋值导致。
  • 纯系统堆栈的崩溃,如发生在 objc_autoreleasePoolPop 堆栈的崩溃。通过反汇编只能推断出是某个对象被 over-release 了,无法推断出具体是哪个对象。字节内部的同学可以使用 APM 提供的 Zombie、GWPASan、Coredump 等线上工具 [5]进行排查;如果没有线上工具,需要找到与该崩溃同一版本/时间段上涨的其它野指针崩溃,它们有可能是同一个原因导致的,从有业务代码堆栈的崩溃入手去排查。

8. 加入我们

我们是字节跳动产品研发和工程架构部-头条-客户端基础技术-iOS 团队,在性能优化、基础组件、业务架构、研发体系、安全合规、线下质量基础设施、线上问题定位归因平台等方向深耕,负责保障和提升今日头条、西瓜视频和番茄小说的产品质量与开发效率,聚焦于此的同时向外延伸。

如果你对技术充满热情,喜欢追求极致,渴望用自己的代码改变数亿用户的体验,欢迎加入我们。目前我们在北京、深圳、广州均有招聘需求,简历投递邮箱:[email protected];邮件标题:姓名-工作年限-产品研发和工程架构部-头条-客户端基础技术-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 - Nuggets ( juejin.cn/post/713534… )

[4] ARM-pointer certification ( www.jianshu.com/p/62bf046b7… )

[5] How ByteDance systematically manages iOS stability issues ( juejin.cn/post/703441… )

Upcoming Events

Focusing on Toutiao’s experience in iOS stability management, we will carry out more technology sharing in the future, you can add the assistant below to reply [iOS], and get the event registration information as soon as possible⬇️

picture

Guess you like

Origin juejin.im/post/7179845582922448933