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

为啥要做线上探测

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用户,总的来说还是能收集到一些问题的。例如下面的问题,根据捕捉到的信息排查后发现,是多线程使用不当引起过度释放。

监控结果

抽象总结起来就是:

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对象安全气垫为何会失效

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容