iOS 线上野指针探测实践与展望

1,922 阅读10分钟

为啥要做线上探测

iOS的常规崩溃数量已经不多,剩余的崩溃往往是不能稳定复现或缺乏有效信息。经过线上统计后我发现目前剩余的无法定位和解决的崩溃有60%+都是由于野指针引起。各种各样的堆栈千奇百怪,比较典型的堆栈如下:

image-20220114135115950

当然还有其他类型的堆栈,但是这些堆栈都有一个特征:都是在release或者retain时发生了崩溃。

如果你的APP是首次监控的野指针崩溃,那么建议你首先进行线下的模拟和复现,Xcode提供了很多优秀的工具:Address SanitizerSCribbleZombie等。

image-20220114140406395

但是在实践过程中很难依靠上述工具复现问题,这跟APP的性质有很大的关系。如果APP是一个聚合平台,整合了非常多的业务,并且这些业务是由各自独立的团队开发,有各自的入口和触发逻辑,甚至有独立的灰度策略,那我们可能连测试入口都无法找到。面对这种情况,只能依赖线上手段去收集和排查问题。

做了一些技术调研

参考的文章我列举到了参考文献中,感兴趣的可以阅读。我整理了下大体的思路可以分为2类:

  • hook free

    free时,并不释放内存,保留内存,判断是否为objc对象,如果是objc对象则将对象setclass为自定义类,借助消息转发得到堆栈和类信息。

  • hook dealloc

    dealloc时判断是否需要开启野指针探测,如果不需要则直接释放,否则将对象修改isa后保留并加入到内存池中,再次调用对象时会触发消息转发拦截到堆栈及对象类名信息。

我查看了很多业界的方案,针对OC的方案基本上大同小异,思路都是保存对象,等再次调用时触发消息转发拦截到堆栈及类型信息。但是如何控制内存增长、到底对哪些类做监控等实际问题都没有做太多的介绍。上述列举的方案在debug阶段或者灰度阶段使用尚可,如果真的在线上使用,可能造成不小的性能问题。因此野指针探测如果上线,核心问题是如何在保证有效覆盖率的前提下控制性能损耗。

探索与实践

在刚开始时,我尝试使用通过从文章中获取到的技术方案直接落实到项目中,并且监控了全部的OC类。调试时我发现APP光启动阶段就已经耗费了很长时间。这是由于由于监控的类型过多,有很多非常频繁释放的类型也被我们监控和处理,导致性能下滑非常严重。因此在这里我做了多次优化,主要手段如下:

  • 屏蔽一些无关紧要的类型
  • 在dealloc中,尽量不要做耗性能操作
  • 用线程池将大量任务分发到子线程

如何屏蔽一些无关紧要的类型?

首先来回答第一个问题,如何屏蔽一些无关紧要的类型监控。在开发阶段我发现很多底层xpc类以及很多不曾见过的类型都被我们监控,这显然有些扯淡。因此为了优化监控的类型范围,我做了2次改进。首先说下第一次改进:

基于动态库的优化方案

众所周知,我们APP的主执行文件依赖了很多的系统库,这些系统库是我们所使用到的明确声明链接到程序上的。但是这些动态库也会依赖其他的系统库,在这里我将这些系统库称为:二级动态库。显然这些系统库的类不是我们所关心的。

image-20220120110422679

因此需要在运行阶段排除来自二级系统库的这些类。

那如何确定一个类来自哪个库呢?

我最先想到的是dladdrdladdr可以在运行阶段告诉我们这个地址的详细信息,其中就包括镜像文件信息。那我们就能直接知道这个类是否需要监控。但是,如果你这样做的话就会发现一个非常明显的问题,那就是:这个函数运行简直~太!慢!了!! ,根本无法支撑大量且频繁的调用。因此我的优化方案是,根据先获取类名地址,根据类名地址的区间判断在哪个库中。

image-20220120112457131

具体实现为:在启动时读取所有的image以及每个imageTEXT段地址范围,然后存储到unordermap中。当然这里只是列举了大体的思路,具体实现时还需要处理对段迁移方案的适配。经过上述优化后,实际效率大幅提升。

当然,这并不是一个很完美的方案,因为在开发阶段我发现我为野指针开辟的缓存池很快就被耗尽,启动阶段,30MB的缓存池竟然6~11秒就耗尽,这个消耗速度有点太快,按我个人理解,在常规使用下,一个对象能在缓存池中存在30秒才算及格。为此,我将每个对象的类型及每个对象的大小写入文件,查看后发现尽管我们做了动态库的过滤,但是依旧有很多我们没有见过的类型也被纳入到了监控范围。这个很好理解,UIKitCorelibobjc等我们常见的动态库中依旧有很多大量的我们没用过的类型。因此我们需要转换思路,采用更精细的监控:只监控我们用到的系统类。

基于Bind信息的优化方案

监控我们用到的系统类的难点在:如何获取到项目中用到的所有系统类? 这一步可以参考我的另一篇文章:从野指针探测到对iOS 15 bind 的探索 (文章比较长,耐着性子读一下应该会有所收获),在这里不再重复。

dealloc中千万不要做的事情

方案的整体思路是hook dealloc,因此我们不可避免地要在dealloc阶段注入我们的代码,这里有几个小的注意点:

  • 不要调用任何OC代码
  • 不要使用objc_setAssociatedObject
  • 不要直接上来一顿操作,先判断下isTaggedPointer

第一点很容易理解,因为你调用了任何OC代码都可能导致在dealloc中继续引起额外的对象释放,而这些对象释放有可能又被你纳入到监控范围。

第二点可能很多同学想象不到,不使用objc_setAssociatedObject是因为objc_setAssociatedObject有不小的内存消耗(约96B)有在大量暴力使用时才能发现。

第三点是可能很多方案没有提到的,TaggedPointer我们没有必要做更进一步的监控浪费缓存池。关于TaggedPointer的介绍可以参考字节APM的文章:Tagged Pointer对象安全气垫为何会失效

多线程与内存优化

多线程处理

开发阶段,由于各项性能指标都不理想,因此将批量释放对象以及对象入池包装为Task加入到任务队列中,多线程处理Task。我已经忘记了当时这么处理是因为哪块性能问题了😭,印象中好像是不这么处理掉帧明显,最近好奇把对象入池同步处理也没发现有明显掉帧现象,比较尴尬😓。

image-20220120130415835

内存优化

内存上的优化主要在捕捉堆栈上。野指针探测实践就会发现,单单知道类名以及野指针发生时的堆栈是不够的。作为开发者,我还想知道这个对象到底是在什么时候释放的。因此野指针探测我加了记录释放堆栈的功能,当然这个功能不会全量开放,仅针对配置的指定类型进行记录。这里有2个比较有意思的问题:

  • 堆栈的大小是否能优化?
  • 抓取堆栈时能否用memcpy替代vm_read_overwrite

所谓的堆栈,在符号化之前其实就是一堆UInt64的数据,假设我们的堆栈一共有10行信息,那么实际上就是10个UInt64数据,共640字节。但是实际上iOS中一个指针UInt64根本用不上全部的64bit信息。因此在这里可以做个优化,用堆栈距离(UInt64)&_mh_execute_header的偏移来替代堆栈,这样既可用32bit信息来表示64bit信息。记录堆栈所消耗的内存减少接近一半。

另外,还有个有趣的问题。大家看到的很多关于抓取堆栈的代码中,保存栈帧的函数都是vm_read_overwrite,为什么不用memcpy或者像这样直接用指针去操作呢?vm_read_overwrite非常慢,在dealloc中即使是灰度少量地使用,也绝对会卡爆你。那在dealloc中我们到底能不能用memcpy呢?

从这个问题我发现我对内存机制不了解,感觉内存这块很有趣。

简单来说vm_read_overwrite是安全读取地址的,具有探测机制,即使是个非法地址也能保证程序正常运行。但是memcpy则是简单直接但是不安全。至于为什么大多数抓取堆栈都是使用vm_read_overwrite则是看中了vm_read_overwrite的安全能力,因为跨线程回溯堆栈并且没有挂起所有线程,可能会造成读取非法地址的情况。因此只要我们能保证当前线程堆栈不会被破坏就可以用memcpy替代vm_read_overwrite,显然我们这里是在当前dealloc线程同步回溯,不会出现问题,因此可以用memcpy优化调用。

线上效果

说了那么多,这东西到底敢不敢上线使用?使用后到底能不能发现问题?目前代码已经上线一段时间了,线上放量30w用户,总的来说还是能收集到一些问题的。例如下面的问题,根据捕捉到的信息排查后发现,是多线程使用不当引起过度释放。

image-20220120141113467

抽象总结起来就是:

self.dic = [NSDictionary new];
for (int i = 0 ; i < 3000 ; i++) { 
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
         self.dic = @{@"name":@(i)};
     });
 }

不足与改进

一个技术方案不能只说优点,还应该给大家展示下相应的缺点及不足。总的来说从现阶段来说我感觉有3点不太满意:

  • 内存、内存、内存!目前实际消耗的内存要比我们记录的消耗要偏大。这是由于还有些我们调用的函数内部会消耗一些内存,还没有被发现,这会导致极端情况下实际内存在一直增长而内存缓存迟迟得不到释放。典型的例子就是objc_setAssociatedObject,这个函数内部维护了一个unordermap
  • 缺少相应的控制平台。目前灰度都是服务端写死进行控制,想要灵活控制非常不方便。我的想法是可以根据机型、系统、版本等按百分比进行控制灰度,并且可以灵活配置针对设备进行分布式探测,例如总共8000个类,按设备占比分布到8种设备上,这样每种设备只承担了1000个监控任务,压力极大减少。这一步正在规划中,要放假了,年后再说~
  • 缺少通用符号化平台。目前我们上报的堆栈还没有被符号化,需要本地进行符号化,这对开发者有一定的要求,使用不便。规划、放假、年后说~

参考文献

1、大白健康系统--iOS APP运行时Crash自动修复系统

2、JJException

3、iOS 野指针定位:野指针嗅探器

4、iOS野指针定位总结

5、浅谈 iOS 中的 Crash 捕获与防护

6、xiejunyi'Blog

7、Tagged Pointer对象安全气垫为何会失效