百度APP iOS端内存优化实践-内存管控方案
该技术方案在百度APP于22年Q1顺利上线,随着基础服务层和越来越多的业务线接入,尤其是OOM频发的页面接入后,在降低OOM率方面发挥了重大作用,效果非常明显。
实时监控APP内存:在APP运行阶段不断监控内存变化。重点关注两个问题,第一,选取合适的能反映APP内存的指标,第二、实时性必须满足要求,同时不能引入额外的性能问题。 页面内存预测:根据历史经验和线上数据,我们可以预测将要打开新页面后APP占用内存大小,结合当前内存我们可以实时计算出应用新开当前页面后自身占用的内存大小,举个例子,当前页面占用内存400M,通过线上历史经验数据我们知道新页面需要占用内存是300M,那么新开一个页面后,APP内存700M。 内存水位判断:根据对当前APP内存状态的监控,能够判断出用户内存所处的水位状态,如安全水位和危险水位,安全水位是指当前APP内存足够,可完全按照业务需求分配内存,危险水位是指目前APP很容易出现OOM,必须马上释放内存缓存。 频率控制:因为每隔3S做一次内存检测,当处于危险水位时,会通知APP各个模块做内存释放,但内存释放也是需要时间的,并且不一定会立马降低到安全水位,如果接下来还是每隔3S通知各模块做内存释放,其实是一种资源浪费,频繁的内存释放操作会给APP性能带来损耗,所以通过频率控制模块既能最大限度地释放内存,又实现APP性能最小损失。 危险水位报警:当APP的内存处于危险水位的状态时,会向基础服务层和业务两个层面发送报警通知,对于基础服务层来说,百度APP主要做了图片内存和NSURLCache内存自动回收,全局生效;对于业务层来说,主要针对内存大户且OOM率较高的页面做了内存释放操作,如小程序页面,收到内存报警时,会将缓存的处于非活跃状态的页面做清理操作,对于其他业务同样道理,清理业务自身的数据缓存和其他内存缓存。 主动降级:是指业务层在分配较大内存时,先判断当前APP所属的内存水位等级,若处于危险水位,业务做降级分配较小内存,若处于安全水位,做全量内存分配。目前百度APP的识图和数字人业务已接入此方案,对于百度识图场景,做多模态图片识别加载算法模型文件较大,处于危险水位时加载兜底模型,以业务能用为标准,其他场景类似。
内存报警机制是内存极其危险的时候才发出的,尤其是对于低端机而言非常致命,因为APP来不及释放内存到安全水位就已经OOM了。在实践开发过程中,对低端机(iPhone8以下)测试结果发现,当收到内存报警时,APP实际可使用内存(可用内存减去已用内存)没有超过100M,但是目前手百APP大于150M页面就有40个,当收到内存警告前后,随便打开上述40个大页面中的任何一个页面,APP根本没有来得及处理警报应用就会崩溃。相反,百度APP内存管控方案在制定危险水位时考虑到这种情况,适当预留了较大空间,让APP更从容地释放内存。 内存报警机制没有提供获取APP实时内存状态的功能,在实践中经常会遇到大块内存分配的场景,较为常见场景如在中低端机端智能场景中,加载大模型到内存时,因为不知道内存当前处于危险状态还是安全状态,分配较大内存会出现内存峰值瞬时上涨到高点,中低端机手机设备直接OOM,在整个过程中也根本没有收到过内存报警。内存管控方案弥补了这一不足,通过实时获取内存状态,不同机型不同设备设置不同危险水位级别,在分配较大内存时,先判断APP内存状态,若处于危险水位时,业务线开发可以走降级逻辑,降低对内存消耗,减少OOM风险。
百度APP实时监控内存采用如下方案:在子线程开启定时器,每隔3S去采样一次内存phys_footprint字段数据,以此作为衡量的内存的唯一指标,其他字段值一律不要获取,因为多增加一个变量会多增加CPU计算量。实践数据表明,第一、单次获取phys_footprint耗时小于1us,每隔3S获取phys_footprint没有引起CPU占比的涨幅,也就是说不会带来性能问题;第二、3S的采样周期实时性完全满足我们工程的要求,正常情况下,开启一个页面到页面可交互需要1.5S+,采样周期如果太长,会存在页面内存已经飙升但是还没来得及做管控,采样周期太短会浪费过多的CPU资源。
struct task_vm_info {
mach_vm_size_t virtual_size; /* virtual memory size (bytes) */
integer_t page_size;
mach_vm_size_t resident_size; /* resident memory size (bytes) */
/* 省略 */
mach_vm_size_t phys_footprint;
/* 省略 */
}
iOS开发演变的这几年历程中,受Android端内存指标影响,我们先后使用过各种内存指标,常见的如virtual_size( 虚拟内存)、resident_size(驻留内存)和phys_footprint,那究竟使用哪个指标是合理的?我们知道iOS使用的是低内存清理机制叫Jetsam,这个机制有点类似于Linux的“Out-of-Memory”杀手,当内存压力过大时,Jetsam会把一些优先级不高或者占用内存过大的进程杀掉。就是说内存处于危险状态时Jetsam决定kill哪个进程,因此Jetsam衡量内存水位指标绝对是众多内存指标中最为合理的一项,接下来我们看Jetsam机制源码。
static boolean_t
memorystatus_kill_hiwat_proc(uint32_t *errors, boolean_t *purged, uint64_t *memory_reclaimed)
{
next_p = memorystatus_get_first_proc_locked(&i, TRUE);
while (next_p) {
/* 省略 */
footprint_in_bytes = get_task_phys_footprint(p->task);
skip = (footprint_in_bytes <= memlimit_in_bytes);
if (skip) {
continue;
} else {
memorystatus_kill_proc(p, kMemorystatusKilledHiwat, jetsam_reason, &killed, &footprint_in_bytes);
/* 省略 */
}
}
首先通过memorystatus_get_first_proc_locked去优先级队列里面取出优先级最低的进程,如果内存超过阈值,将通过memorystatus_kill_proc杀掉这个进程,否则跳过取下一个进程。我们看到Jetsam是通过 get_task_phys_footprint方法获取内存水位来决定是不是需要kill该进程,因此使用phys_footprint作为APP内存指标是最合适的。
* Physical footprint: This is the sum of:
* + (internal - alternate_accounting)
* + (internal_compressed - alternate_accounting_compressed)
* + iokit_mapped
* + purgeable_nonvolatile
* + purgeable_nonvolatile_compressed
* + page_table
字段 | 具体含义 |
internal | 在iOS中表示的就是resident_size驻留内存 |
internal_compressed | iOS 上没有交换空间机制,取而代之使用Compressed memory,是在内存紧张时能够将最近使用过的内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用 |
iokit_mapped | io设备映射占用的内存,其实是不能使用purgeable memory的部分 |
alternate_accounting | iokit映射占用的dirty页 |
page_table | 虚拟地址映射表内存 |
purgeable_nonvolatile | 下面重点介绍 |
purgeable内存是iOS系统为开发者提供的一层cache机制,分为volatile、empty和non_volatile三种类型,volatile表示该内存资源是暂时不被使用的,系统将在内存吃紧的时候回收掉它,使用这种类型资源前要查询是否已经无效了(变成empty状态);empty表示该内存资源明确不用了需要立即释放;non_volatile表示该内存资源一直有用,不能被回收。volatile和empty状态的资源不计入进程自己的mem footprint,它算系统的cache内存,nonvolatile会算自己进程的内存,被虚拟内存系统回收时不会被换出到磁盘,所以phys_footprint在计算内存时,只计算了nonvolatile类型,对于volatile、empty没做计算。
注意,我们只通过push方式统计了页面内存,没有通过pop方式统计,有两个原因,第一、通过线上数据发现,pop方式时因页面已经打开,并且会创建单例导致内存统计存在很多badcase,push方式时页面从未创建也不会有单例,数据相对准确;第二、通过push方式已经可以覆盖所有页面了,pop方式不需要统计。
丨6.1 通过Jetsam日志获取
丨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宏定义可以获取进程的优先级列表以及每个进程的内存水位线。
typedef struct memorystatus_priority_entry {
pid_t pid;
int32_t priority;
uint64_t user_data;
int32_t limit;
uint32_t state;
} memorystatus_priority_entry_t;
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4
/* 省略 */
最后通过调用系统函数memorystatus_control的实现可获取memorystatus_priority_entry结构体值,其中limit字段代表水位线, 代码路径:bsd/kern/kern_memorystatus.c
/* 省略 */
switch (args->command) {
case MEMORYSTATUS_CMD_GET_PRIORITY_LIST:
error = memorystatus_cmd_get_priority_list(args->buffer, args->buffersize, ret);
break;
/* 省略 */
}
丨6.3 百度APP采用的技术方案
丨6.3.1 内存数据摸底
丨6.3.2 页面内存数据统计
丨6.3.3 主动触发OOM获取内存值
开启定时器任务每隔1S分配20M内存,示例代码如下所示:
int size = 20 * 1024 * 1024;
char *info = malloc(size);
memset(info, 1, size);
同时监控内存变化,在控制台输出,随着可用内存越来越少,触发Jetsam机制,直到发生OOM,从而得到OOM前内存阈值。
(int64_t)memoryUsage {
int64_t memoryUsageInByte = 0;
struct task_vm_info info;
mach_msg_type_number_t size = TASK_VM_INFO_COUNT;
kern_return_t kerr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &info, &size);
if (kerr == KERN_SUCCESS ) {
memoryUsageInByte = info.phys_footprint;
}
return memoryUsageInByte;
}
丨6.3.4 确定内存管控危险水位阈值
百度APP采用的这个技术方案不需要越狱手机,通过主动触发OOM获取的阈值体现了Jetsam机制,更具有可操作性;同时结合自身线上数据,针对手百场景定制化挖掘。
针对不同机型制定了相应的内存水位可以更加从容地释放内存。本技术方案结合Jetsam机制和百度APP线上内存数据,制定了iPhone各机型允许使用的内存水位线,给业务和框架更大的空间释放和清理内存。 实时内存监控和精细化页面内存预测,在实时内存监控的基础上,开发了页面级的内存度量方案,可以估算出用户在新开一个页面内存涨幅多少,在未来一段时间内存会不会达到危险水位。 内存管控方案提供主动和被动通知两种方式获取内存水位状态,实现了各业务层根据手机内存情况实时降级,时效性更强,跟之前服务端降全量降级方案相比,更加灵活,性能更好。
该方案上线后,随着Q2基础服务层和业务线接入,实现OOM降低一半的收益,并且业务层接入成本很低,后续会推动更多内存大户和OOM频发的页面接入。感谢各位阅读至此,如有问题请不吝指正。