百度 APP iOS 端内存优化实践 - 内存管控方案
01 背景
随着业务的发展,百度 APP 有很多大内存业务场景如直播、短视频、小程序、百度识图等,通过线上页面统计数据得知超过 150M 页面有 40 个,耗内存最多的页面有 400M。单个页面不会有内存或者稳定性问题,但是当用户浏览了很多页面之后,累加起来内存已经很高了,再加上我们为了追求秒开,经常采用的思路是以空间换取时间,从而导致 APP 处于一个内存高水位状态,在这种情况下如果打开一个大内存页面,中低端机极大概率会出现 OOM 类型的崩溃。
内存管控方案应运而生,该方案重点解决的问题是在内存水位很高的情况下,保证 APP 稳定性又兼顾用户体验,延长 APP 使用时长同时避免 OOM。
该技术方案在百度 APP 于 22 年 Q1 顺利上线,随着基础服务层和越来越多的业务线接入,尤其是 OOM 频发的页面接入后,在降低 OOM 率方面发挥了重大作用,效果非常明显。
02 技术方案综述
内存管控整体方案架构图如下所示:
实时监控 APP 内存:在 APP 运行阶段不断监控内存变化。重点关注两个问题,第一,选取合适的能反映 APP 内存的指标,第二、实时性必须满足要求,同时不能引入额外的性能问题。
页面内存预测:根据历史经验和线上数据,我们可以预测将要打开新页面后 APP 占用内存大小,结合当前内存我们可以实时计算出应用新开当前页面后自身占用的内存大小,举个例子,当前页面占用内存 400M,通过线上历史经验数据我们知道新页面需要占用内存是 300M,那么新开一个页面后,APP 内存 700M。
内存水位判断:根据对当前 APP 内存状态的监控,能够判断出用户内存所处的水位状态,如安全水位和危险水位,安全水位是指当前 APP 内存足够,可完全按照业务需求分配内存,危险水位是指目前 APP 很容易出现 OOM,必须马上释放内存缓存。
频率控制:因为每隔 3S 做一次内存检测,当处于危险水位时,会通知 APP 各个模块做内存释放,但内存释放也是需要时间的,并且不一定会立马降低到安全水位,如果接下来还是每隔 3S 通知各模块做内存释放,其实是一种资源浪费,频繁的内存释放操作会给 APP 性能带来损耗,所以通过频率控制模块既能最大限度地释放内存,又实现 APP 性能最小损失。
危险水位报警:当 APP 的内存处于危险水位的状态时,会向基础服务层和业务两个层面发送报警通知,对于基础服务层来说,百度 APP 主要做了图片内存和 NSURLCache 内存自动回收,全局生效;对于业务层来说,主要针对内存大户且 OOM 率较高的页面做了内存释放操作,如小程序页面,收到内存报警时,会将缓存的处于非活跃状态的页面做清理操作,对于其他业务同样道理,清理业务自身的数据缓存和其他内存缓存。
主动降级:是指业务层在分配较大内存时,先判断当前 APP 所属的内存水位等级,若处于危险水位,业务做降级分配较小内存,若处于安全水位,做全量内存分配。目前百度 APP 的识图和数字人业务已接入此方案,对于百度识图场景,做多模态图片识别加载算法模型文件较大,处于危险水位时加载兜底模型,以业务能用为标准,其他场景类似。
03 与内存报警的区别
目前 iOS 系统中存在类似的方案,专业名称为内存报警机制,当设备可用内存下降到到危险状态时,Mach 系统的 pageout 守护程序会查询进程列表及其驻留页面数,向驻留页面数最高的进程发送 NOTE_VM_PRESSURE ,被选中的进程会响应这个压力通知,本质上就是 APP 收到系统的 didReceiveMemoryWarning 内存警告,APP 释放部分内存达到降低手机内存负载的目标。有人会问 iOS 系统提供了内存报警通知,为什么我们还会做貌似类似的事情,这是因为我们对系统的内存报警机制做了如下两点补充:
内存报警机制是内存极其危险的时候才发出的,尤其是对于低端机而言非常致命,因为 APP 来不及释放内存到安全水位就已经 OOM 了。在实践开发过程中,对低端机(iPhone8 以下)测试结果发现,当收到内存报警时,APP 实际可使用内存(可用内存减去已用内存)没有超过 100M,但是目前手百 APP 大于 150M 页面就有 40 个,当收到内存警告前后,随便打开上述 40 个大页面中的任何一个页面,APP 根本没有来得及处理警报应用就会崩溃。相反,百度 APP 内存管控方案在制定危险水位时考虑到这种情况,适当预留了较大空间,让 APP 更从容地释放内存。
内存报警机制没有提供获取 APP 实时内存状态的功能,在实践中经常会遇到大块内存分配的场景,较为常见场景如在中低端机端智能场景中,加载大模型到内存时,因为不知道内存当前处于危险状态还是安全状态,分配较大内存会出现内存峰值瞬时上涨到高点,中低端机手机设备直接 OOM,在整个过程中也根本没有收到过内存报警。内存管控方案弥补了这一不足,通过实时获取内存状态,不同机型不同设备设置不同危险水位级别,在分配较大内存时,先判断 APP 内存状态,若处于危险水位时,业务线开发可以走降级逻辑,降低对内存消耗,减少 OOM 风险。
04 实时监控 APP 内存
百度 APP 实时监控内存采用如下方案:在子线程开启定时器,每隔 3S 去采样一次内存 phys_footprint 字段数据,以此作为衡量的内存的唯一指标,其他字段值一律不要获取,因为多增加一个变量会多增加 CPU 计算量。实践数据表明,第一、单次获取 phys_footprint 耗时小于 1us,每隔 3S 获取 phys_footprint 没有引起 CPU 占比的涨幅,也就是说不会带来性能问题;第二、3S 的采样周期实时性完全满足我们工程的要求,正常情况下,开启一个页面到页面可交互需要 1.5S+,采样周期如果太长,会存在页面内存已经飙升但是还没来得及做管控,采样周期太短会浪费过多的 CPU 资源。
为什么我们选用 phys_footprint 作为内存衡量指标,而不用其他字段,需要重点解释一下。iOS 端所有的内存相关指标都集中在 task_vm_info 结构体中,下载 XNU 最新开源代码(https://opensource.apple.com/source/xnu/),代码路径:osfmk/mach/task_info.h,具体字段值如下所示:
iOS 开发演变的这几年历程中,受 Android 端内存指标影响,我们先后使用过各种内存指标,常见的如 virtual_size( 虚拟内存)、resident_size(驻留内存)和 phys_footprint,那究竟使用哪个指标是合理的?我们知道 iOS 使用的是低内存清理机制叫 Jetsam,这个机制有点类似于 Linux 的“Out-of-Memory”杀手,当内存压力过大时,Jetsam 会把一些优先级不高或者占用内存过大的进程杀掉。就是说内存处于危险状态时 Jetsam 决定 kill 哪个进程,因此 Jetsam 衡量内存水位指标绝对是众多内存指标中最为合理的一项,接下来我们看 Jetsam 机制源码。
我们再次回到 XNU 源码中,查看代码 bsd/kern/kern_memorystatus.c,重点查看函数 memorystatus_kill_hiwat_proc,这是 jetsam 核心代码,用于 kill 高内存分配进程的关键函数,具体实现如下所示:
首先通过 memorystatus_get_first_proc_locked 去优先级队列里面取出优先级最低的进程,如果内存超过阈值,将通过 memorystatus_kill_proc 杀掉这个进程,否则跳过取下一个进程。我们看到 Jetsam 是通过 get_task_phys_footprint 方法获取内存水位来决定是不是需要 kill 该进程,因此使用 phys_footprint 作为 APP 内存指标是最合适的。
关于 phys_footprint 的定义,我们回到 XNU 源码中,查看代码 osfmk/kern/task.c ,有 phys_footprint 的注释定义。
phys_footprint = (internal - alternate_accounting) + (internal_compressed - alternate_accounting_compressed) + iokit_mapped + purgeable_nonvolatile + purgeable_nonvolatile_compressed + page_table 。
purgeable 内存是 iOS 系统为开发者提供的一层 cache 机制,分为 volatile、empty 和 non_volatile 三种类型,volatile 表示该内存资源是暂时不被使用的,系统将在内存吃紧的时候回收掉它,使用这种类型资源前要查询是否已经无效了(变成 empty 状态);empty 表示该内存资源明确不用了需要立即释放;non_volatile 表示该内存资源一直有用,不能被回收。volatile 和 empty 状态的资源不计入进程自己的 mem footprint,它算系统的 cache 内存,nonvolatile 会算自己进程的内存,被虚拟内存系统回收时不会被换出到磁盘,所以 phys_footprint 在计算内存时,只计算了 nonvolatile 类型,对于 volatile、empty 没做计算。
05 页面内存预测
为了能够更精准的对页面的内存进行分析和预测,我们在实时内存监控的基础上,开发了页面内存预测方案。具体来说,在前面通过定时器我们知道了每隔 3S 手机 APP 内存状态,本方案通过经验数据直接预测未来一段时间内存的涨幅,让业务线可以更加从容的释放内存。我们知道当新打开一个页面时存在内存飙升的情景,这个时候 3S 的采样周期未到,内存已经上涨很多,内存管控方案还未生效 APP 极有可能已经 OOM 了。我们的方案是通过页面内存计算,在打开新页面前一刻, 就知道接下来页面内存可能会涨到多少,如果进入危险水位,实时释放内存以降低 OOM 率,通过这种精细化处理进一步提前降低内存峰值。
页面内存计算方案如下所示,首先,当前页面是 P1 页面,当有页面跳转发生,将要通过 push 操作进入到 P2 页面时,记录当前百度 APP 内存 phys_footprint 值为 M1,当从 P2 页面同样发生跳转到其他页面时,记录百度 APP 内存 phys_footprint 值为 M2,那么 M2-M1 为 P2 页面内存。
注意,我们只通过 push 方式统计了页面内存,没有通过 pop 方式统计,有两个原因,第一、通过线上数据发现,pop 方式时因页面已经打开,并且会创建单例导致内存统计存在很多 badcase,push 方式时页面从未创建也不会有单例,数据相对准确;第二、通过 push 方式已经可以覆盖所有页面了,pop 方式不需要统计。
06 制定内存水位
关于内存水位的制定直接决定了本方案实际收益的大小,水位阈值制定太小会导致频繁的内存管控影响业务效果,水位阈值制定的太大,与实际的 Jetsam 水位线偏离过大,导致内存管控无法生效,可能会出现 APP 已经 OOM 了,管控方案还没生效,水位线的制定非常关键。
关于危险水位线的制定,必须结合 Jetsam 原理,目前苹果官方没有公开 Jetsam 水位的文档,业界有如下方法解决方案。
丨 6.1 通过 Jetsam 日志获取
具体来说从手机"设置->隐私->分析与改进->分析数据"这条操作路径中,可以拿到 JetsamEvent 开头的日志。这些日志中就可以获取一些关于 App 的内存信息,查找崩溃原因时需要关注 per-process-limit 部分的 rpages,其中 rpages 代表进程占用的内存页数量。pageSize 代表当前设备物理内存页的大小,在 JetsamEvent 开头的系统日志里可以找到 pageSize 的值,那么 pageSize * rpage 的值代表目前该进程 OOM 时使用的内存大小,可作为进程可用内存的上限。
实际操作过程中,发现此方法可操作性不强,因为同一台手机不同的 JetsamEvent 日志 rpages 值变化太大,用 iphone12 的测试结果显示,从 400 到 800 都有,pageSize 是固定值 16384Byte,按照最高值计算当前 App 的内存限制值:pageSize * rpages / 1024 /1024 =16384 * 800 / 1024 / 1024 = 12.5M,按这个结果 iphone12 最大的内存阈值是 12.5M,置信度明显有问题。
丨 6.2 通过 XNU 源码获取内存水位阈值
首先必须越狱手机获取 root 权限,通过 XNU 源码中的数据结构、宏定义和函数获取 OOM 阈值,参考 XNU 最新开源代码(https://opensource.apple.com/source/xnu/),代码路径:bsd/sys/kern_memorystatus.h,关键数据结构 memorystatus_priority_entry,定义如下,其中 pid 代表进程标识,priority 代表 JetSam 中的优先级,limit 就是我们要找的水位线上线。同时,在文件 kern_memorystatus.h 有如下跟进程优先级相关的宏命令,其中通过 MEMORYSTATUS_CMD_GET_PRIORITY_LIST 宏定义可以获取进程的优先级列表以及每个进程的内存水位线。
最后通过调用系统函数 memorystatus_control 的实现可获取 memorystatus_priority_entry 结构体值,其中 limit 字段代表水位线, 代码路径:bsd/kern/kern_memorystatus.c
实践证明这种方法是可行的,唯一缺点是需要获取 root 权限,我们要获取不同机型的内存阈值,需要将这些设备全部越狱。
丨 6.3 百度 APP 采用的技术方案
百度 APP 采用的方案是综合百度 APP 自身的线上业务数据,采用主动触发 OOM 获取内存阈值方案,结合多方数据最后确定内存危险水位阈值。
丨 6.3.1 内存数据摸底
通过线上内存采样打点,获取了百度 APP 不同机型在使用过程中的内存值,然后经过服务端数据聚合,我们明确知道了百度 APP 在没有发生 OOM 情况下不同机型的内存最大值,这份线上数据很重要,虽然不是内存阈值的,但是内存阈值肯定是高于该值的。
丨 6.3.2 页面内存数据统计
技术方案在第五节做过详细介绍,这儿不再赘述,通过服务端对页面内存数据挖掘后,我们明确知道了不同机型新开一个页面时最大的内存涨幅。
丨 6.3.3 主动触发 OOM 获取内存值
开启定时器任务每隔 1S 分配 20M 内存,示例代码如下所示:
同时监控内存变化,在控制台输出,随着可用内存越来越少,触发 Jetsam 机制,直到发生 OOM,从而得到 OOM 前内存阈值。
丨 6.3.4 确定内存管控危险水位阈值
经过前面三个步骤,我们获取了不同机型的三个阈值,分别是内存数据摸底阈值、页面内存阈值、主动触发 OOM 获取的阈值,为了让业务更从容地释放内存, 内存管控阈值为主动触发 OOM 获取的阈值减去页面内存阈值,如果该值小于内存数据摸底阈值,那么内存数据摸底阈值就是该机型内存管控阈值。
百度 APP 采用的这个技术方案不需要越狱手机,通过主动触发 OOM 获取的阈值体现了 Jetsam 机制,更具有可操作性;同时结合自身线上数据,针对手百场景定制化挖掘。
07 总结
最后,总结百度 APP 内存管控方案具有如下特点:
针对不同机型制定了相应的内存水位可以更加从容地释放内存。本技术方案结合 Jetsam 机制和百度 APP 线上内存数据,制定了 iPhone 各机型允许使用的内存水位线,给业务和框架更大的空间释放和清理内存。
实时内存监控和精细化页面内存预测,在实时内存监控的基础上,开发了页面级的内存度量方案,可以估算出用户在新开一个页面内存涨幅多少,在未来一段时间内存会不会达到危险水位。
内存管控方案提供主动和被动通知两种方式获取内存水位状态,实现了各业务层根据手机内存情况实时降级,时效性更强,跟之前服务端降全量降级方案相比,更加灵活,性能更好。
该方案上线后,随着 Q2 基础服务层和业务线接入,实现 OOM 降低一半的收益,并且业务层接入成本很低,后续会推动更多内存大户和 OOM 频发的页面接入。感谢各位阅读至此,如有问题请不吝指正。
———— END————
参考资料:
[1] OOM 探究:XNU 内存状态管理:https://www.jianshu.com/p/4458700a8ba8
[2]XNU 源码:https://opensource.apple.com/source/xnu/
[3]《深入解析 Mac OS X & iOS 操作系统》
[4]iOS Out-Of-Memory 原理阐述及方案调研:https://juejin.cn/post/6844903749836603400#heading-7
推荐阅读:
评论