字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

亦余心之所善兮,虽九死其犹未悔。这篇文章主要讲述字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路相关的知识,希望能为你提供帮助。
作者:字节跳动终端技术——白昆仑
前言卡死、卡顿作为目前ios App的重要性能指标,不仅影响着用户体验,更关系到用户留存、DAU等重要产品数据。本文主要介绍Heimdallr对卡死、卡顿异常的监控原理,并结合长时间的业务沉淀发现的问题进行不断迭代和优化,逐步实现全面、稳定、可靠的历程。
一、什么是卡死/卡顿?卡顿,顾名思义就是在使用过程中出现了一段时间的阻塞,使得用户在这一段时间内无法进行操作,屏幕上的内容也没有任何的变化。Heimdallr在监控指标上,根据阻塞时间的长短进行了3个等级的划分。

字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

1、流畅性与丢帧:动画、滑动列表不流畅,一般为十几至几十毫秒的级别
2、卡顿:短时间操作无反应,恢复后能继续使用,从几百毫秒至几秒
3、卡死:长时间无反应,直至被系统杀死,通过线上收集数据,最少为5s
可以看到,根据严重性由小至大可将卡顿问题划分为流畅性与丢帧、卡顿、卡死三个不同的等级。卡死的严重程度与Crash是相当的,甚至更为严重。因为卡死不仅仅造成了类似于崩溃的闪退,更使得用户被迫等待了相当长的一段时间,更加损害用户的体验。由于监控方案上的差异,本文主要面向的是后两者卡顿和卡死的监控。
二、卡死/卡顿的原因iOS开发中,由于UIKit是非线程安全的,因此一切与UI相关的操作都必须放在主线程执行,系统会每16ms(1/60帧)将UI的变化重新绘制,渲染至屏幕上。如果UI刷新的间隔能小于16ms,那么用户是不会感到卡顿的。但是如果在主线程进行了一些耗时的操作,阻碍了UI的刷新,那么就会产生卡顿,甚至是卡死。主线程对于任务的处理是基于Runloop机制,如下图所示。Runloop支持外部注册通知回调,提供了
1、RunloopEntry
2、RunloopBeforeTimers
3、RunloopBeforeSources
4、RunloopBeforeWaiting
5、RunloopAfterWaiting
6、RunloopExit
6个时机的事件回调,其流转关系如下图所示。Runloop在没有任务需要处理的时候就会进入至休眠状态,直至有信号将其唤醒,其又会去处理新的任务。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

在日常编码中,UIEvent事件、Timer事件、dispatch主线程任务都是在Runloop的循环机制的驱动下完成的。一旦我们在主线程中的任何一个环节进行了一个耗时的操作,或者因为锁的使用不当造成了与其它线程的死锁,主线程就会因为无法执行Core - Animation的回调而造成界面无法刷新。而用户的交互又依赖于UIEvent的传递和响应,该流程也必须在主线程中完成。所以说主线程的阻塞会导致UI和交互的双双阻塞,这也是导致卡死、卡顿的根本原因。
三、监控方案既然问题的根本在于主线程Runloop的阻塞,那么我们就要通过技术手段监测主线程Runloop的运行状态。为了能够实时获取主线程Runloop的状态,首先对主线程注册上面提到的几个事件回调,在触发事件回调时,利用signal机制将其运行状态传递给另一个正在监听的子线程(后面称之为监听线程)。监听线程对于信号的处理可以是多样的,它可以设置等待signal的超时时间,如果超过了设定的阈值,这说明主线程可能正在经历阻塞。通过监听线程,我们可以完整地了解到主线程Runloop循环的周期,目前处于哪个阶段,耗时了多久等等。根据这些必要的信息,就可以采取对应的策略进行异常的捕获和处理,后面会单独就卡顿、卡死分别进行说明。
目前大多数APM工具都是采用监听Runloop的方式进行卡顿的捕获,这也是性能、准确性表现最好的一种方案。由于RunloopBeforeTimers的和RunloopBeforeSources是紧邻的两个事件回调,Heimdallr为了降低Runloop频繁事件回调造成的性能损失,去除了对RunloopBeforeTimers的监听。
  1. 卡顿(ANR)
卡顿监控的特点在于主线程的阻塞是暂时的、能够恢复的,因此我们要获取卡顿持续的时间,用来评估卡顿问题的严重性。我们预先设定一个卡顿时间的阈值T,当主线程阻塞的时间超过该阈值,则会触发全线程的抓栈,获取卡顿场景的堆栈信息。此后监听线程继续等待主线程直至主线程恢复,并计算卡顿的总时间,整合之前获取的堆栈信息,上报卡顿异常。需要说明的是,如果在抓栈之后主线程无法恢复,那么该异常不是卡顿,应交由卡死模块处理。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

  1. 卡死(WatchDog)
与卡顿不同,卡死的阻塞是更长的,而且是无法恢复的。iOS系统会对App的主线程进行类似的监控,一旦发现了阻塞的情况,持续时间大于当前系统内允许的阈值(不同iOS版本和机型不同),就会强制杀死当前App进程,这个操作是没有任何通知的。因此我们需要做的就是在系统发现卡死并强杀之前,获取堆栈,并尽可能的评估出卡死持续的时间。
预先设定一个卡死的阈值T(默认是8s),这个阈值可以是相对保守的,并不是说超过了这个阈值就一定会被判定为卡死。在超过卡死阈值T的时候,获取全线程的堆栈,并保存至本地文件中。之后每隔一段时间(采样间隔,默认是1s),会进行一次采样。采样的目的不是为了获取新的堆栈,而是为了更新卡死持续的时间,将该信息保存至本地文件中。因此,采样的间隔越小逼近真实卡死时间按越精确。直至到某一个时间节点,系统把App杀死。当App下一次启动时,卡死模块会根据上一次启动中保留的本地文件信息,还原出卡死的堆栈、持续时间等信息,并上报卡死异常。
需要说明的是,很多人认为卡死一定是因为死锁、死循环这样的场景,导致程序永远也无法完成导致的。其实不然,在很多场景下,一个或多个耗时的操作,只要其耗时超过了系统的允许阈值,都会触发卡死。当应用启动过程中,没有在限定时间内完成初始化工作也会被系统杀死。所以,某些卡死可能是多个场景的不合理一起导致的,这也给卡死的问题定位提出了更高的要求。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

四、问题与优化理想是丰满的,现实是骨感的。看似”无懈可击“的监控方案,在线上却暴露出不同程度的问题。
  1. 卡顿监控优化
在卡顿监控中,我们认为超过了卡顿阈值时获取的堆栈一定是一个卡顿的场景,其实不然。在一些时候,获取的堆栈可能是他人的”背锅侠“。我们来看下面这个case。导致主线程卡顿的是4这个耗时操作,但是当我们设定阈值超时时,获取的堆栈却是没有任何性能问题的5。因此如果使用这种方式来进行卡顿的监控,一定会存在误报。而根据概率来讲,虽然上报的5是一个误报,但就线上的上报量来讲,4的数量一定是要大于5的。因此上报量级大的堆栈才应该是真正的耗时操作,是需要我们专注去解决的,而那些量级较小的堆栈则可能是误报。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

那么是否能够通过一些技术手段,在控制性能开销的情况下,对卡顿场景捕捉的更加准确呢?一个比较好的思路就是采样策略。如下图,我们在原有的”常规模式“的基础上增加了”采样模式“。需要额外定义采样间隔、采样阈值。我们把卡顿阈值的等待过程,划分为以采样间隔为单位的粒度更细的时间节点。在每个时间节点进行主线程采样,对主线程进行堆栈的提取。由于仅对主线程进行堆栈提取,所以耗时较全线程抓栈要小很多。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

获取了主线程堆栈后,通过提取顶层第一个自身调用来进行堆栈的聚合。如果某一个相同堆栈持续的时间超过了设定的采样阈值,例如图中的4,重复了3次,那么就会判定该场景一定是一个卡顿场景。那么此时就会进行全线程抓栈,而后面的卡顿阈值触发时则不再抓栈。\\
结合主线程采样,我们可以更加精准的以函数级别监控卡顿场景,但是也需要付出采样带来的额外性能开销。为了将采样的开销降至最低,避免线上对低端设备造成二次性能劣化,卡顿监控支持采样功能的退火策略。当某一个卡顿场景被多次捕获时,为了避免再次将其捕获,造成不必要的性能浪费,会逐步增加采样间隔,直至将”采样模式“退化成”常规模式“。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

在Slardar平台配置并开启采样功能后,可以通过sample_flag来过滤通过采样超时获取的卡顿异常。通过此方式获取的堆栈,大概率为卡顿场景,可以更加有针对性的去分析和解决。
  1. 卡死监控优化
相比卡顿,卡死的误报大多发生在后台(目前Heimdallr提供后台卡死过滤,如果对后台卡死不关心的业务方可以自行打开)。因为后台场景的限制,当前App的线程优先级更低,而且随时存在被系统挂起的可能,这给我们进行卡死时间的判定带来了很多问题。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

上面的Case描述的是一个卡死的误报场景,因为在后台的原因线程的优先级较低,因此1、2、3任务执行的时间要比前台更久,更加容易超过我们的卡死阈值。而后,因为iOS系统的策略问题,后台应用被挂起(suspend),直至某一个时间点因为内存紧张,将整个应用杀死。但请注意,这个流程属于App正常的生命周期范畴,并不是WatchDog。而按照我们之前的策略,这将会被判定为卡死。由于我们无法监听到suspend事件,所以这种场景目前还无法排除误报。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

还有一种误触发卡死的case是,suspend发生在8s阈值前,在长时间的挂起后,应用被resume,此时8s的超时被触发。但是实际上,我们的App只有在8s中的很少一部分时间在running,大部分时间都是被挂起,所以不应该触发卡死判定。归根结底是卡死计时的准确性问题。
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

为了解决上面的问题,对计时策略进行了改进。相比于直接进行8s的等待,我们将时间细分为8个1s。如果在这段时间内App被挂起,等到恢复时也不会直接超过8s的阈值,而仅仅会造成最多1s的误差。
此外,上面也提到过,卡死有的时候可能是多个耗时场景累计导致的。为了能够跟踪主线程的变化,在抓栈之后的采样阶段,对主线程进行堆栈采样,并将其一起上报。结合采样中获取的主线程堆栈,我们可以得到一个主线程堆栈变化的时间线,能够更加准确的帮助定位问题所在。(时间线功能在Heimdallr 0.7.15之后支持)
字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路

文章图片

最后,我们发现部分卡死场景是由于OC Runtime Lock导致的(大概率是dyldOC Runtime Lock造成的死锁)。一旦发生这种类型的卡死,其它所有线程的OC代码都会因此而阻塞,当然也包括监听线程,卡死监控此时就无法捕获这个异常。为了能够覆盖所有场景,我们把卡死、卡顿模块的所有逻辑进行了C/C++重构,解除了对OC调用的依赖,并且性能相比与OC实现进一步得到提升。
结语HeimdallrANRWatchDog模块经过一段时间的迭代与优化,达到了一个全面、稳定、可靠的状态。这期间的一些优化思路借鉴了一些开源的APM框架,并结合使用方的实际需求进行不断改进。感谢所有使用方的反馈,帮助我们不断完善我们的功能与体验。后续我们会继续针对Watchdog场景增加防卡死功能,帮助接入方能够在无侵入式的情况下,解决通用场景的卡死问题。
【字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路】

    推荐阅读