iOS开发 -卡死崩溃监控原理及最佳实践

字节跳动技术团队git

参考:/ Github /github

不一样于 Android 系统中的卡死(ANR)问题,目前业界对 iOS 系统中 App 发生的卡死崩溃问题并没有成熟的解决方案,主要缘由是:sql

  1. 一般 App 卡死时间超过 20s 以后会触发操做系统的保护机制,发生崩溃,此时在用户的设备中能找到操做系统生成的卡死崩溃日志,可是由于 iOS 系统封闭生态的关系,App 层面没有权限拿到卡死崩溃的日志。数据库

  2. 通常而言用户遇到卡死问题的时候并无耐心等待那么久的时间,可能在卡住 5s 时就已经失去耐心,直接手动关闭应用或者直接将应用退到后台,所以这两种场景下系统也就不会生成卡死崩溃日志。后端

因为上面提到的两个缘由,目前业界 iOS 生产环境中的卡死监控方案其实主要是基于卡顿监控,即当用户在使用 App 的过程当中页面响应时间超过必定的卡顿的阈值(通常是几百 ms)以后断定为一次卡顿,而后抓取到当时现场的调用栈而且上报到后台分析。这种方案的缺陷主要体如今:api

  1. 没有将比较轻微的卡顿问题和严重的卡死问题区分开,致使上报的问题数量太多,很难聚焦到重点。实际上这部分问题对用户体验的伤害实际上是远远大于卡顿的。性能优化

  2. 由于一些使用低端机型的用户更容易在短期内遇到频繁的卡顿,可是调用栈抓取,日志写入和上报等监控手段都是性能有损的,这也是卡顿监控方案在生产环境中通常只能小流量而不能全量的缘由。markdown

  3. 试想一次卡顿持续了 100ms,前 99ms 都卡在 A 方法的执行上,可是最后 1ms 恰好切换到了 B 方法在执行,这时候卡顿监控抓到的调用栈是 B 方法的调用栈,其实 B 方法并非形成卡顿的主要缘由,这样也就形成了误导。多线程

基于上述的痛点,字节跳动 APM 中台团队自研了一套专门用于定位生产环境中的卡死崩溃的解决方案,本文将详细的介绍该方案的思路和具体实现,以及经过本方案上线后总结出来的一些典型问题和最佳实践,指望对你们有所启发。app

卡死崩溃背景介绍

什么是 watchdog

若是某一天咱们的 App 在启动时卡住大概 20s 而后崩溃以后,从设备中导出的系统崩溃日志极可能是下面这种格式:

Exception Type:  EXC_CRASH (SIGKILL)Exception Codes: 0x0000000000000000, 0x0000000000000000Exception Note:  EXC_CORPSE_NOTIFYTermination Reason: Namespace ASSERTIOND, Code 0x8badf00dTriggered by Thread:  0
复制代码

下面就其中最重要的前 4 行信息逐一解释:

  1. Exception Type

EXC_CRASH:Mach 层的异常类型,表示进程异常退出。

SIGKILL:BSD 层的信号,表示进程被系统终止,并且这个信号不能被阻塞、处理和忽略。这时能够查看 Termination Reason 字段了解终止的缘由。

  1. Exception Codes

这个字段通常用不上,当崩溃报告包含一个未命名的异常类型时,这个异常类型将用这个字段表示,形式是十六进制数字。

  1. Exception Note

EXC_CORPSE_NOTIFYEXC_CRASH 定义在同一个文件中,意思是进程异常进入 CORPSE 状态。

  1. Termination Reason

这里主要关注 Code 0x8badf00d,能够在苹果的官方文档中查看到 0x8badf00d 意味着 App ate bad food,表示进程由于 watchdog 超时而被操做系统结束进程。经过上述已经信息能够得出 watchdog 崩溃的定义:

在iOS平台上,App若是在启动、退出或者响应系统事件时由于耗时过长触发系统保护机制,最终致使进程被强制结束的这种异常定义为watchdog类型的崩溃。

所谓的 watchdog 崩溃也就是本文所说的卡死崩溃。

为何要监控卡死崩溃

你们都知道在客户端研发中,由于会阻断用户的正常使用,闪退已是最严重的 bug,会直接影响留存,收入等各项最核心的业务指标。以前你们重点关注的都是诸如 unrecognized selectorEXC_BAD_ACCESS 等能够在 App 进程内被捕获的崩溃(下文中称之为普通崩溃),可是对于 SIGKILL 这类由于进程外的指令强制退出致使的异常,原有的监控原理是覆盖不到的,也致使此类崩溃在生产环境中被长期忽视。除此以外,还有以下理由:

  1. 由于卡死崩溃最多见发生于 App 启动阶段,用户在开屏页面卡住 20s 后什么都作不了紧接着 App 就闪退了。这种体验对用户的伤害比普通的崩溃更加严重。

  2. 在卡死监控上线之初,今日头条 App 天天卡死崩溃发生的量级大概是普通崩溃的 3 倍,可见若是不作任何治理的话,这类问题的发生量级是很是大的。

  3. OOM 崩溃也是由 SIGKILL 异常信号最终触发的,目前 OOM 崩溃主流的监控原理仍是排除法。不过传统方案在作排除法的时候漏掉了一类量级很是大的其余类型的崩溃就是这里的卡死崩溃。若是能准确的监控到卡死崩溃,也一样能大大提升 OOM 崩溃监控的准确性。关于 OOM 崩溃的具体监控原理和优化思路能够参考:iOS 性能优化实践:头条抖音如何实现 OOM 崩溃率降低 50%+

所以,基于以上信息咱们能够得出结论:卡死崩溃的监控和治理是很是有必要的。通过近 2 年的监控和治理,目前今日头条 App 卡死崩溃天天发生的量级大体和普通崩溃持平。

卡死崩溃监控原理

卡顿监控原理

其实从用户体验出发的话,卡死的定义就是长时间卡住而且最终也没有恢复的那部分卡顿,那么下面咱们就先回顾一下卡顿监控的原理。咱们知道在 iOS 系统中,主线程绝大部分计算或者绘制任务都是以 runloop 为单位周期性被执行的。单次 runloop 循环若是时长超过 16ms,就会致使 UI 体验的卡顿。那如何检测单次 runloop 的耗时呢?

经过上图能够看到,若是咱们注册一个 runloop 生命周期事件的观察者,那么在 afterWaiting=>beforeTimers,beforeTimers=>beforeSources 以及beforeSources=>beforeWaiting 这三个阶段都有可能发生耗时操做。因此对于卡顿问题的监控原理大概分为下面几步:

  1. 注册 runloop 生命周期事件的观察者。

  2. runloop 生命周期回调之间检测耗时,一旦检测到除休眠阶段以外的其余任意一个阶段耗时超过咱们预先设定的卡顿阈值,则触发卡顿断定而且记录当时的调用栈。

  3. 在合适的时机上报到后端平台分析。

总体流程以下图所示:

如何断定一次卡顿为一次卡死

其实经过上面的一些总结咱们不难发现,长时间的卡顿最终不管是触发了系统的卡死崩溃,仍是用户忍受不了主动结束进程或者退后台,他们的共同特征就是发生了长期时间卡顿且最终没有恢复,阻断了用户的正常使用流程。

基于这个理论的指导,咱们就能够经过下面这个流程来断定某次卡顿究竟是不是卡死:

  1. 某次长时间的卡顿被检测到以后,记录当时全部线程的调用栈,存到数据库中做为卡死崩溃的怀疑对象。

  2. 假如在当前 runloop 的循环中进入到了下一个活跃状态,那么该卡顿不是一次卡死,就从数据库中删除该条日志。本次使用周期内,下次长时间的卡顿触发时再从新写入一条日志做为怀疑对象,依此类推。

  3. 在下次启动时检测上一次启动有没有卡死的日志(用户一次使用周期最多只会发生一次卡死),若是有,说明用户上一次使用期间最终遇到了一次长时间的卡顿,且最终 runloop 也没能进入下一个活跃状态,则标记为一次卡死崩溃上报。

经过这套流程分析下来,咱们不只能够检测到系统的卡死崩溃,也能够检测到用户忍受不了长时间卡顿最终杀掉应用或者退后台以后被系统杀死等行为,这些场景虽然并无实际触发系统的卡死崩溃,可是严重程度实际上是等同的。也就是说本文提到的卡死崩溃监控能力是系统卡死崩溃的超集。

卡死时间的阈值如何肯定

系统的卡死崩溃日志格式截取部分以下:

Exception Type:  EXC_CRASH (SIGKILL)Exception Codes: 0x0000000000000000, 0x0000000000000000Exception Note:  EXC_CORPSE_NOTIFYTerminationReason: Namespace ASSERTIOND, Code 0x8badf00dTriggered by Thread:  0Termination Description: SPRINGBOARD, scene-create watchdog transgression: application<com.ss.iphone.article.News>:2135 exhausted real (wall clock) time allowance of 19.83 seconds
复制代码

能够看到 iOS 系统的保护机制只有在 App 卡死时间超过一个异常阈值以后才会触发,那么这个卡死时间的阈值就是一个很是关键的参数。

遗憾的是,目前没有官方的文档或者 api,能够直接拿到系统断定卡死崩溃的阈值。这里 exhausted real (wall clock) time allowance of 19.83 seconds 其中的 19.83 并非一个固定的数字,在不一样的使用阶段,不一样系统版本的实现里均可能有差别,在一些系统的崩溃日志中也遇到过 10s 的 case。

基于以上信息,为了覆盖到大部分用户能够感知到的场景,屏蔽不一样系统版本实现的差别,咱们认为系统触发卡死崩溃的时间阈值为 10s,实际上有至关一部分用户在遇到 App 长时间卡顿的时候会习惯性的手动结束进程重启而不是一直等待,所以这个阈值不宜过长。为了给触发卡死断定以后的抓栈,日志写入等操做预留足够的时间,因此最终本方案的卡死时间阈值肯定为 8s。发生 8s 卡死的几率比发生几百 ms 卡顿的几率要低的多,所以该卡死监控方案并无太大的性能损耗,也就能够在生产环境中对全量用户开放。

如何检测到用户一次卡死的时间

在卡死发生以后,实际上咱们也会关注一次卡死最终到底卡住了多久,卡死时间越长,对用户使用体验的伤害也就越大,更应该被高优解决。

在触发卡死阈值以后咱们能够再以一个时间间隔比较短的定时器(目前策略默认 1s,线上可调整),每隔 1s 就检测当前 runloop 有没有进入到下一个活跃状态,若是没有,则当前的卡死时间就累加 1s,用这种方式即便最终发生了闪退也能够逼近实际的卡死时间,偏差不超过 1s,最终的卡死时间也会写入到日志中一块儿上报。

可是这种方案在上线后遇到了一些卡死时长特别长的 case,这种问题多发生在 App 切后台的场景。由于在后台状况下,App 的进程会被挂起(suspend)后,就可能被断定为持续好久的卡死状态。而咱们在计算卡死时间的时候,采用的是现实世界的时间差,也就是说当前 App 在后台被挂起 10s 后又恢复时,咱们会认为 App 卡死了 10s,轻易的超过了咱们设定的卡死阈值,但其实 App 并无真正卡死,而是操做系统的调度行为。这种误报经常是不符合咱们的预期的。误报的场景以下图所示:

如何解决主线程调用栈可能有误报的问题

为了解决上面的问题,咱们采用多段等待的方式来下降线程调度、挂起致使的程序运行时间与现实时间不匹配的问题,如下图为例。在 8s 的卡死阈值前,采用间隔等待的方式,每隔 1s 进行一次等待。等待超时后对当前卡死的时间进行累加 1s。若是在此过程当中,App 被挂起,不管被挂起多久,再恢复时最多会形成 1s 的偏差,这与以前的方案相比极大的增长了稳定性和准确性。

另外,待卡死时间超过了设定的卡死阈值后,会对全线程进行抓栈。可是仅凭这一时刻的线程调用栈并不保证可以准肯定位问题。由于此时主线程执行的多是一个非耗时任务,真正耗时的任务已经结束;或者在后续会发生一个更加耗时的任务,这个任务才是形成卡死的关键。所以,为了增长卡死调用栈的置信度,在超过卡死阈值后,每隔 1s 进行一次间隔等待的同时,对当前主线程的堆栈进行抓取。为了不卡死时间过长形成的线程调用栈数量膨胀,最多会保留距离 App 异常退出前的最近 10 次主线程调用栈。通过屡次间隔等待,咱们能够获取在 App 异常退出前主线程随着时间变化的一组函数调用栈。经过这组函数调用栈,咱们能够定位到主线程真正卡死的缘由,并结合卡死时间超过阈值时获取的全线程调用栈进一步定位卡死缘由。

最终的监控效果以下:

由于图片大小的限制,这里仅仅截了卡死崩溃以前最后一次的主线程调用栈,实际使用的时候能够查看崩溃以前一段时间内每一秒的调用栈,若是发现每一次主线程的调用栈都没有变化,那就能确认这个卡死问题不是误报,例如这里就是一次异常的跨进程通讯致使的卡死。

卡死崩溃常见问题归类及最佳实践

多线程死锁

问题描述

比较常见的就是在 dispatch_once 中子线程同步访问主线程,最终形成死锁的问题。如上图所示,这个死锁的复现步骤是:

  1. 子线程先进入 dispatch_once 的 block 中并加锁。

  2. 而后主线程再进入 dispatch_once 并等待子线程解锁。

  3. 子线程初始化时触发了 CTTelephonyNetworkInfo 对象初始化抛出了一个通知却要求主线程同步响应,这就形成了主线程和子线程由于互相等待而死锁,最终触发了卡死崩溃。

这里的实际上是踩到了 CTTelephonyNetworkInfo 一个潜在的坑。若是这里替换成一段 dispatch_syncdispatch_get_main_queue()的代码,效果仍是等同的,一样有卡死崩溃的风险。

最佳实践

  1. dispatch_once 中不要有同步到主线程执行的方法。

  2. CTTelephonyNetworkInfo 最好在 +load方法或者 main 方法以前的其余时机提早初始化一个共享的实例,避免踩到子线程懒加载时候要求主线程同步响应的坑。

主线程执行代码与子线程耗时操做存在锁竞争

问题描述

一个比较典型的问题是卡死在-[YYDiskCache containsObjectForKey:]YYDiskCache 内部针对磁盘多线程读写操做,经过一个信号量锁保证互斥。经过分析卡死堆栈能够发现是子线程占用锁资源进行耗时的写操做或清理操做引起主线程卡死,问题发生时通常能够发现以下的子线程调用栈:

最佳实践

  1. 有可能存在锁竞争的代码尽可能不在主线程同步执行。

  2. 若是主线程与子线程不可避免的存在竞争时,加锁的粒度要尽可能小,操做要尽可能轻。

磁盘 IO 过于密集

问题描述

此类问题,表现形式可能多种多样,可是归根结底都是由于磁盘 IO 过于密集最终致使主线程磁盘 IO 耗时过长。典型 case:

  1. 主线程压缩/解压缩。

  2. 主线程同步写入数据库,或者与子线程可能的耗时操做(例如 sqlitevaccum 或者checkpoint 等)复用同一个串行队列同步写入。

  3. 主线程磁盘 IO 比较轻量,可是子线程 IO 过于密集,常发生于一些低端设备。

最佳实践

  1. 数据库读写,文件压缩/解压缩等磁盘 IO 行为不放在主线程执行。

  2. 若是存在主线程将任务同步到串行队列中执行的场景,确保这些任务不与子线程可能存在的耗时操做复用同一个串行队列。

  3. 对于一些启动阶段非必要同步加载而且有比较密集磁盘 IO 行为的 SDK,如各类支付分享等第三方 SDK 均可以延迟,错开加载。

系统 api 底层实现存在跨进程通讯

问题描述

由于跨进程通讯须要与其余进程同步,一旦其余进程发生异常或者挂起,颇有可能形成当前 App 卡死。典型 case:

  1. UIPasteBoard,特别是 OpenUDID。由于 OpenUDID 这个库为了跨 App 能够访问到相同的 UDID,经过建立剪切板和读取剪切板的方式来实现的跨 App 通讯,外部每次调用 OpenUDID 来获取一次 UDID,OpenUDID 内部都会循环 100 次,从剪切板获取 UDID,并经过排序得到出现频率最高的那个 UDID,也就是这个流程可能最终会致使访问剪切板卡死。

  2. NSUserDefaults 底层实现中存在直接或者间接的跨进程通讯,在主线程同步调用容易发生卡死。

  3. [[UIApplication sharedApplication] openURL]接口,内部实现也存在同步的跨进程通讯。

最佳实践

  1. 废弃 OpenUDID 这个第三方库,一些依赖了 UIPaseteBoard 的第三方 SDK 推进维护者下掉对 UIPasteBoard 的依赖并更新版本;或者将这些 SDK 的初始化统一放在非主线程,不过经验来看子线程初始化可能有 5%的卡死转化为闪退,所以最好加一个开关逐步放量观察。

  2. 对于 kv 类存储需求,若是重度的使用能够考虑 MMKV,若是轻度的使用能够参考 firebase 的实现本身重写一个更轻量的 UserDefaults 类。

  3. iOS10 及以上的系统版本使用[[UIApplication sharedApplication] openURL:options:completionHandler:]这个接口替换,此接口能够异步调起,不会形成卡死。

Objective-C Runtime Lock 死锁

问题描述

此类问题虽然出现几率不大,可是在一些复杂场景下也是时有发生。主线程的调用栈通常都会卡死在一个看似很普通的 OC 方法调用,很是隐晦,所以想要发现这类问题,卡死监控模块自己就不能用 OC 语言实现,而应该改成 C/C++。此问题通常多发于_dyld_register_func_for_add_image 回调方法中同步调用 OC 方法(先持有 dyld lock 后持有 OC runtime lock),以及 OC 方法同步调用 objc_copyClassNamesForImage 方法(先持有 OC runtime lock 后持有 dyld lock)。典型 case:

  1. dyld lock、 selector lock 和 OC runtime lock 三个锁互相等待形成死锁的问题。三个锁互相等待的场景以下图所示:

  1. 在某次迭代的过程当中 APM SDK 内部断定设备是否越狱的实现改成依赖 fork 方法可否调用成功,可是 fork 方法会调用 _objc_atfork_prepare,这个函数会获取 objc 相关的 lock,以后会调用 dyld_initializer,内部又会获取 dyld lock,若是此时咱们的某个线程已经持有了 dyld lock,在等待 OC runtime lock,就会引起死锁。

最佳实践

  1. 慎用_dyld_register_func_for_add_imageobjc_copyClassNamesForImage 这两个方法,特别是与 OC 方法同步调用的场景。

  2. 越狱检测,不依赖 fork 方法的调用。

相关文章
相关标签/搜索