快影iOS端如何实现OOM率下降80%+
导读
线上OOM是快影iOS端稳定性治理的重点和难点。我们实现了一套分析复现线上OOM头部问题的工具,OOM率下降超过80%。本文主要分享快影iOS端系统化治理OOM过程的基本思路和关键案例,希望和大家进行广泛而深入的讨论。
文 / Hannibal
编辑 / 乔
本文共6813字,预计阅读时间25分钟。
背景
快影APP作为一款专业的音视频制作软件,在使用过程可能会占用大量内存。当内存超过系统限制时会触发OOM(Out Of Memory),表现为APP闪退。
随着快影业务的快速迭代,iOS端线上OOM问题日益严峻。从用户反馈看,OOM是Top1问题。从大盘数据看,OOM率是Crash率的5~6倍。这严重影响了用户体验和产品口碑,需要重点治理。然而,治理iOS端的线上OOM并非易事。难点主要有两方面,一是无法直接捕获线上OOM,二是很难对线上OOM进行归因(尽管业界提出了一些优秀的解决方案,但仍存在一些局限性,详见后文分析)。
定义问题
当我们在聊iOS端“线上OOM”的时候,我们在聊什么?
iOS端OOM是指Jetsam机制在回收内存过程中按优先级强制杀死部分进程的现象。这一过程会在系统“隐私”数据中生成Jetsam日志。
对于线上OOM,我们无法拿到用户的Jetsam日志,也没办法捕获Jetsam强杀进程的UNIX信号SIGKILL。为了能够发现线上OOM,业界主流做法都是采用Facebook提出的排除法来间接识别OOM。也就是说,线上OOM其实是通过排除法来定义的。
排除法的工作原理很简单,在APP重启时先排除可以直接发现的中断(包括Crash/用户手动退出/程序主动退出),再排除可以间接识别的中断(包括Watchdog/APP版本升级/系统升级/系统重启引起的系统强杀),最后把剩余的中断判定为OOM。排除法还会记录中断前的APP前后台状态,用于区分前台OOM和后台OOM。由于应用级APP在后台的存活优先级很低,业界包括本文所讨论的OOM均是指前台OOM。
假设排除法的实现是正确无误的,那么线上OOM的定义是:APP在前台发生了未捕获的SIGKILL。如下图所示,SIGKILL本身是无法捕获的,排除法只是间接捕获了一部分SIGKILL,剩下的情况也就是未捕获的SIGKILL。这里可以分为以下四类情况:
-
Jetsam OOM;通过task_terminate_internal -> proc_exit -> SIGKILL来终止进程;
-
EXC_RESOURCE;当应用超过系统资源的消耗限制时会触发此类Mach异常,在满足fatal condition时也会通过SIGKILL来终止进程;
-
EXC_CRASH(SIGKLL);比如过热保护(0xc00010ff);
-
其它情况;比如外挂强杀(kill -9);
分析问题
如何分析线上OOM?
首先调研业界方案。一种是基于内存分配堆栈进行聚类,另一种是基于vm_region引用关系图谱进行聚类。两种方案的本质都是采集APP存活内存信息进行分析——发现内存“对象”大小、数量甚至引用关系的异常。尽管业界方案很优秀,但也存在一些局限性:
-
只能分析APP内存(Footprint)异常;无法分析系统可用内存(Free)的异常,比如音视频业务严重依赖的媒体服务进程也可能占用大量的内存;
-
无法分析EXC_RESOURCE和EXC_CRASH(SIGKLL);
那是否有更简单直接的方案来分析线上OOM(未捕获的SIGKILL)呢?
如图所示,快影编辑器业务架构有两个特点:
-
可还原的编辑上下文Context;具体包括:State,描述编辑器状态的数据;Draft,描述用户作品的数据;Asset,Draft引用的素材概况(为保护用户隐私,我们仅获取Asset的meta信息)。
-
可追踪的用户操作Action;用户在UI层的操作都会转换成Action事件来驱动业务逻辑,我们可以在Action分发前统一监听。
综上两点,快影编辑上下文可以抽象为:Context(n) = Context(n-1) ⊕ Action。比如Context(32)触发线上OOM,那么拿到Context(31) 和Action,在相同系统下⊕就能复现问题。
复现单点问题只是第一步,那如何分析出一类问题呢?以Jetsam OOM为例,因为最后一个Action可能只是触发OOM的临门一脚。我们可以在Action处理前记录内存相关的Qos,最终根据这一组Qos数据(可以形象理解为按内存曲线特征)来聚合分类。
至此,快影提出了一种基于 采集编辑上下文进行分析和复现线上OOM 的思路。整体方案如下。
整体方案
如上图所示,按数据维度可以分三个阶段,但方案设计的目标始终围绕以下两个问题展开:
-
如何复现单点问题
-
如何分析一类问题
1.如何复现单点问题
考虑到数据完备性,我们补充完善了UI层和系统层的Action。比如编辑器时间轴的滚动Action,打开相册Action,APP前后台切换Action以及3秒内无用户操作的PingAction等。
那如何保证数据正确性呢?比如,我们非常依赖“最后一次Action”的准确记录,所以采用了mmap的方式来存储Action以及对应的QoS,不必担心Crash导致数据丢失。
为了保证复现成功率,我们需要尽可能还原编辑上下文。还原开关/AB实验环境、还原作品及其引用的素材Asset。我们维护了一个本地素材库AssetsLibrary,通过哈希原始素材的meta信息得到一个近似素材。另外一方面,假设我们能分析出一类问题,就意味会有足够多的Context和Action。因此越是头部的问题,复现概率越大。
2.如何分析一类问题
有了大量线上OOM的编辑上下文数据,我们可以使用中后台能力来分析数据。
当前主要的分析手段是聚合分类,除了按设备维度聚合,还有支持以下三类维度:
-
Action维度,按最后一次Action/Action栈,按是否包含指定Action等;
-
Draft维度,按Draft复杂度或Draft特征;
-
QoS维度,按QoS的特征;
关于QoS维度,不同的SIGKILL异常,需要关注的QoS也不同。比如,Jetsam OOM不仅要关注APP内存(Footprint),也需要关注系统可用内存(Free),还可能要关注磁盘剩余空间(DiskFree)。EXC_RESOURCE则需要关注最近的CPU使用率和Wakeup数。随着我们对“未捕获的SIGKILL”的认知不断提高,相关的QoS也在不断完善。
举个例子,我们聚合出一类“Footprint曲线整体递增”的线上OOM。使用ActionTracker打开,显示这一类问题有16个。选定一个会话,可以看到三部分信息:
-
QoS部分:默认展示了Memory指标,包括APP内存和系统可用内存;
-
Action部分:展示用户操作名称以及操作详情,支持按关键词筛选或过滤;
-
Context部分:展示会话信息和草稿信息(包括草稿二维码和摘要信息);
解决优化
方案上线2个月,分析出超过20类线上OOM问题,其中头部问题均在线下成功复现。
线下复现后,我们发现快影的线上OOM的头部问题可以概述为四类,包括 APP内存超限、系统可用内存不足、捕获Crash过程异常 以及 生成标记过程异常 。下面将举例说明。
1、APP内存超限
APP内存超限是指Footprint超出系统设定的阈值。Jetsam reason通常为per-process-limit。
聚合Footprint曲线特征,可以发现4类APP内存超限问题,包括 内存泄漏、内存堆积、资源过大 以及 内存占用高 的问题。
如图1所示,通过二次聚合Action确认了一类“导入贴纸”弹框内存泄漏;
如图2所示,用户在清理磁盘缓存后,打开作品Draft,在“时间轴”进行快速滚动和缩放。由于作品中包含几百个镜头片段,上述Action会创建几千上万个“生成缩略图”的任务造成了内存堆积;
如图3所示,用户使用裁剪Action后,由于程序异常产生了4个16380*3628分辨率的bitmap;
如图4所示,用户作品Draft中存在时长几十几百分钟的长视频,由于音频解码算法空间复杂度过高,导致内存占用高;
以上案例主要是内存超限问题中的bug,比较容易解决。对于结构性问题的治理,往往比较复杂。比如快影的内存容灾方案、解码帧缓存重构方案均涉及架构和算法数据结构的优化。
2、系统可用内存不足
系统可用内存严重不足时也可能会强杀前台APP。Jetsam reason通常为vm-pageshortage。
神秘的内存“转移”
通过聚合Action栈,我们发现1G内存设备中,存在大量先“打开视频特效弹窗”,然后“在弹窗滚动”的Action引发的OOM。其内存表现如下图所示:Footprint稳定在140M左右,但系统可用内存Free从360M逐渐下降到80M,最终因系统可用不足触发OOM。
在线下复现过程,我们发现特效弹窗中展示的特效示意图均为webp格式的动图,平均帧数约为30。当我们上下左右快速滚动搜寻特效时,会有大量的图片帧需要解码,大量并发解码任务会造成内存堆积。然而,这种画布模式分配的内存CG raster data并没有计算在APP内存Footprint之中。这也是为什么这个问题在测试阶段被忽略的原因——在此之前,我们往往只关心APP内存。
优化这类内存堆积问题并不困难,经典的思路是通过限制解码并发数来降低峰值(还可以使用任务取消机制或拓扑排序来提升用户体验)。最有趣的问题是,APP分配CG raster data类型的内存,是如何不被计算在APP内存Footprint中的?
借助Allocations,我们发现这些CG raster data是通过CGDataProviderCreateWithCopyOfData这个方法进行分配的。如下图所示,方法内部先使用mmap分配出了bitmap所需的内存大小,然后将解码后的数据写入其中。
////////mmap函数签名
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
////////mmap实现
/*
* Mapping blank space is trivial. Use positive fds as the alias
* value for memory tracking.
*/
if (fd != -1) {
/*
* Use "fd" to pass (some) Mach VM allocation flags,
* (see the VM_FLAGS_* definitions).
*/
alloc_flags = fd & (VM_FLAGS_ALIAS_MASK | VM_FLAGS_SUPERPAGE_MASK | VM_FLAGS_PURGABLE);
if (alloc_flags != fd) {
/* reject if there are any extra flags */
return EINVAL;
}
}
////////vm_statistics.h
#define VM_SET_FLAGS_ALIAS(flags, alias) \
(flags) = (((flags) & ~VM_FLAGS_ALIAS_MASK) | \
(((alias) & ~VM_FLAGS_ALIAS_MASK) << 24))
/* private raster data (i.e. layers, some images, QGL allocator) */
#define VM_MEMORY_COREGRAPHICS_DATA 54
(上下滑动,查看代码)
结合mmap函数签名,fd通过x4寄存器传入了一个神秘数字905969664(即0x36000000)。 这个数字是怎么得来的呢? 阅读XNU内核源码可以发现,这个数是VM_SET_FLAGS_ALIAS宏左移24位得到的VM Alias。 真正含义是VM_MEMORY_COREGRAPHICS_DATA。 从vm_statistics.h可以看到该类型正是private raster data (i.e. layers, some images, QGL allocator)。
可见,解码内存并非随意分配的,而是系统通过定义好的标记在特殊地址进行分配的结果。这些被标记的情况在内存分配时不会统计到App内存Footprint中。值得一提的是,另外一种基于文件的MAP_SHARED类型的mmap也有同样的效果。
这个案例启发我们,APP分配的内容是可以被“转移”出去的。那么,能否用来在“APP内存超限”问题上发挥用途呢?答案是肯定的,我们在后续内存优化中就实践了这一思路。
隐藏的系统进程
通过聚合最后一个Action,我们发现2G内存以下的设备中,Top1的OOM Action是时间轴“滚动””Action。其线上内存表现如图所示。
经过线下复现分析,该问题集中出现在“多片段+转场”的Draft中。通过Activity分析,mediaserverd进程内存波动巨大,峰值可能超过800M。如下图所示,mediaserverd关联了内核驱动服务,是一个与应用层“通信”的中间进程。iOS设备上全部的音视频编解码最终都将通过这个进程与底层通信,倘若应用层不加限制的使用这个daemon进程,导致其持有大量内存的同时还在不断请求分配内存。最终就会造成系统可用内存不足,触发Jetsam OOM。由于daemon进程的高优先级,系统往往会强杀前台APP。
回到问题本身,为什么mediaserverd会吃掉这么多内存呢?考虑到预览的流畅度,我们会进行prepare预解码。添加转场后会把转场前后视频分成两路,因此正常prepare逻辑会创建2+2=4个解码器,以4K视频为例,每个解码器会造成mediaserverd增长200-250M内存。
解决的思路是在流畅度与内存峰值之间寻找折中。在prepareNextDecode的时候,只创建解码器但不进行预解码,预解码时机避开转场过程从而避免mediaserverd引入的内存堆积问题。
这个案例提示我们,在iOS系统上处理音视频需要格外关注mediaserverd分配的内存,稍有不慎可能会导致系统可用不足,从而触发OOM。
3、捕获Crash过程异常
聚合最后一个Action,发现有14.2%的线上OOM发生在“字幕拖拽”Action之后。
通过工具,我们在线下成功复现这类OOM。令人感到不解的是,这个问题在调试环境表现为Crash,非调试环境却表现为卡死,且无法attach调试。
经过分析,Crash的直接原因是寻址到空指针触发了EXC_BAD_ACCESS类型的Mach异常。那么,为什么这个Crash没有在线下测试阶段被发现呢?这是因为产生空指针的条件需要用户作品满足一定的特征,这些作品中字幕相对时间的计算出现了浮点数精度问题,当字幕拖拽跨越片段时会触发误删,从而产生空指针。那为什么非调试环境下又表现为卡死呢?原来KSCrash监控库在写崩溃日志kscrashreport_writeStandardReport这一步,将非OC对象的地址误当作OC对象进行解析时,再次触发EXC_BAD_ACCESS。此时Mach异常处理线程已经崩溃,无法处理第二次崩溃,而内核会一直等待第一次崩溃处理的消息回复。整个APP处于“僵死”状态,最终发生Watchdog超时被系统强杀。
那么问题来了,为什么这种卡死会被判定为OOM?首先,OOM排除法依赖Crash标记,然而该标记是在CrashReport生成后通过kscm_innerHandleSignal回调生成。既然Mach异常的CrashReport处理过程又发生了Mach异常,自然是无法生成标记了。其次,在Crash处理过程中,我们已经挂起了主线程和卡死监控线程,也无法识别为卡死。
值得一提的是,Bugly监控库在2.5.71版本以前,捕获到SIGABRT信号时,处理线程会发生死循环(如下图所示,控制台不停地打印异常信号日志)。由于Bugly监控库放在最后注册,第一个拿到了异常处理的控制权。这种情况下,OOM排除法所依赖的Crash监控库(比如,Matrix依赖的是KSCrash)就没有机会处理异常。Crash就误判成了OOM。
那么,如何解决呢?
首先,尽量保证排除法所依赖的Crash监控第一个拿到异常处理控制权,比如放在最后注册或只保留这一个Crash监控。
其次,前置生成Crash标记的时机。如下图所示,我们可以在Mach/Signal/C++/OC异常处理线程挂起其他线程之后,新增一个回调来记录Crash标记。线上数据显示,该修改可以多发现50%+的Crash。当然,这样做会产生一类缺少崩溃日志的Crash。这就需要重点修复监控库的bug,把业务Crash暴露出来。
通过这个案例,我们认识到工程上要正确标记Crash并不是一件容易的事情,这要求监控库的实现方和调用方都要清楚其内部工作原理。
4、生成标记过程异常
通过聚合Action栈,我们发现线上存在大量由“导出作品”再“切换APP到后台”的这一操作导致的OOM案例。而且,这些案例中“导出作品”的过程无一例外都出现了导出空间不足的错误。结合SQL查表,我们统计了这类OOM的占比约8.9%。
这是一个特征很明确的头部OOM问题,我们在线下很快就复现了——制造存储空间紧张的设备上下文,在相同软件版本中导出作品,触发空间不足报错,切后台一段时间再切回前台时,发现APP已在后台被杀。那么,为什么在后台被杀还会判为前台OOM呢?调试发现,在切后台过程中,由于设备剩余空间严重不足,排除法依赖的前后台标记写入必现失败。在下次启动时,排除法认为APP是在前台被强杀的,所以判定为OOM。
有意思的是,这是一个版本迭代引入的新问题。我们清理了问题的直接原因后,不禁要问:为什么过去的版本不存在这个问题?分析发现,老版本在导出过程中,如果发生错误会立即删除中间文件,因此很难出现前文所述的“设备剩余空间严重不足”。那是不是新版本中修改了这块逻辑?答案是否定的,新版本同样会删除中间文件——删除接口正确返回,文件也在APP沙盒目录中消失。但奇怪的是系统剩余空间并没有恢复。
如下图所示,调试过程中,我们发现中间文件一直是open状态,这种情况下APFS不会立刻释放空间,除非文件关闭或者进程退出。最终,我们定位到新版本的底层代码有一个严重的外存泄漏:FFmpegMuxer对象在析构函数中没有关闭中间文件。这会导致用户在APP生命周期内每一次导出作品,中间文件都无法删除。
再回顾下这个案例,因为外存泄漏导致存储空间不断变小,再因为存储空间严重不足,导致排除法依赖的前后台标记写入失败,最后发生误判。暂且不讨论外存泄漏问题(外存泄漏对用户是有害的,考虑到文章主题这里不作具体讨论),我们非常好奇的是:因为写标记失败引入的误判到底有多少?
既然我们通过mmap的方式可以记录APP前后台切换的Action(哪怕磁盘空间严重不足),我们放弃了Matrix使用Archive存储标记的方式,统一改为mmap方式。线上数据显示,因为Archive存储标记失败引入的OOM误判率高达37.1%。
从这个案例中可以发现,排除法在工程上的正确实现需要小心谨慎。失之毫厘,谬以千里。
验收
为了验收性能优化效果以及副作用,我们做了大量的AB实验。这里从用户反馈和OOM率两个维度聊一聊验收情况。
如下图所示,我们统计了12周(2021年11月~2022年1月)的数据。从图中可以看出,OOM用户反馈数有了明显下降,最新版本OOM率下降80%+,其中OOM优化上线以来OOM率下降50%+,OOM去误判上线后OOM率再下降60%+。
一些思考
治理线上问题的一般思路是定义问题、分析问题、解决优化、验收以及防劣化。快影治理线上OOM也是依据这个思路进行实践的。
首先是定义问题。一些复杂问题的定义并非一成不变。比如iOS端的线上OOM,我们最初的定义是“APP在前台发生未捕获的SIGKILL”。通过深入分析头部问题,我们发现更准确的定义是“APP在前台发生未捕获的SIGKILL和排除法误判”。
线上问题往往是分布式的,分析的基础是问题上下文数据,分析的目标是找到头部问题。快影充分利用自身架构的特点来采集和还原问题的上下文,实现了一套分析复现线上OOM头部问题的工具。
解决优化需要对具体问题进行溯本追源,做到“透过现象看本质”。举例来说,我们通过一个“字幕拖拽”的线上OOM案例,先是解决业务层的浮点数bug,再是修复KSCrash的二次崩溃。不仅如此,我们意识到排除法存在误判是更深层次的问题——假设OOM率是不可靠的,不能准确反映APP的内存表现;那么内存相关的AB实验就会不置信,业务迭代引入的OOM劣化也可能无法归因;这严重影响我们的技术决策。为此,我们修改了Matrix和KSCrash的源码,极大减少了排除法的误判。
为了确认问题解决或优化的业务价值,我们需要充分验收。比如快影OOM优化,有可能会造成APP流畅度、视频画质和启动时长等Qos的劣化,最终影响产品的留存转化率等QoE。通常情况下,重大的修改都做了AB实验进行全面评估,有些情况还会开启holdout做长期评估。
防劣化是一个值得商榷的命题,因为单一指标的劣化有时是一种业务需要。快影会根据当前业务的复杂度并结合实际情况动态评估一个“合理的”OOM率,再使用OOM率收敛周期来衡量防劣化工作的效果。在未来彻底解决OOM误判的情况下,我们还可以分设备内存定制OOM率红线,甚至对高端机(如内存4G以上的设备)采用“清零”策略。至于具体的防劣化措施,快影分成三个阶段来做——测试灰度阶段,我们通过线下自动化测试和灰度监控来提前发现新增问题。在方案设计阶段,我们依赖技术评审和CheckList来避免引入新问题。最后是意识阶段,主要通过知识分享和性能优化培训来提升团队的防劣化风险意识。
”
欢迎加入
快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。
在这里你可以获得:
-
提升架构设计能力和代码质量
-
通过大数据解决用户痛点的能力
-
持续优化业务架构、挑战高效研发效能
-
和行业大牛并肩作战
我们期待你的加入!请发简历到:
[email protected]
”
- 快手 iOS 启动优化实践
- 一次逆向分析 Android 内存错误之旅
- 快影iOS端如何实现OOM率下降80%
- 打造你的专属动态化引擎
- Xcode13自适应瀑布流Layout在iOS15上对crash的定位及修复
- Swift代码优化指南 | 如何最大化实现性能提升?
- 快手 Android 内存分配器优化探索 (二)
- 快手 Android 内存分配器优化探索 (一)
- 快手开源 Android 平台弹幕引擎——AkDanmaku
- 一个JavaScriptCore框架中对象与Timer引发的死锁问题内幕
- 卡死 App 的神秘字符串,究竟是何方神圣(下)
- 卡死 App 的神秘字符串,究竟是何方神圣(上)
- 浅析快手iOS启动优化方式——动态库懒加载