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,具體字段值如下所示:
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機制源碼。
我們再次回到XNU源碼中,查看代碼bsd/kern/kern_memorystatus.c,重點查看函數 memorystatus_kill_hiwat_proc,這是jetsam核心代碼,用于kill高內存分配進程的關鍵函數,具體實現如下所示:
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內存指標是最合適的。
關于 phys_footprint 的定義,我們回到 XNU 源碼中,查看代碼 osfmk/kern/task.c ,有phys_footprint 的注釋定義。
* Physical footprint: This is the sum of:
* + (internal - alternate_accounting)
* + (internal_compressed - alternate_accounting_compressed)
* + iokit_mapped
* + purgeable_nonvolatile
* + purgeable_nonvolatile_compressed
* + page_table
phys_footprint = (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沒做計算。
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宏定義可以獲取進程的優先級列表以及每個進程的內存水位線。
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
int memorystatus_control(struct proc *p __unused, struct memorystatus_control_args *args, int *ret) {
/* 省略 */
switch (args->command) {
case MEMORYSTATUS_CMD_GET_PRIORITY_LIST:
error = memorystatus_cmd_get_priority_list(args->buffer, args->buffersize, ret);
break;
/* 省略 */
}
實踐證明這種方法是可行的,唯一缺點是需要獲取root權限,我們要獲取不同機型的內存閾值,需要將這些設備全部越獄。
丨 6.3 百度APP采用的技術方案
百度APP采用的方案是綜合百度APP自身的線上業務數據,采用主動觸發OOM獲取內存閾值方案,結合多方數據最后確定內存危險水位閾值。
丨 6.3.1 內存數據摸底
通過線上內存采樣打點,獲取了百度APP不同機型在使用過程中的內存值,然后經過服務端數據聚合,我們明確知道了百度APP在沒有發生OOM情況下不同機型的內存最大值,這份線上數據很重要,雖然不是內存閾值的,但是內存閾值肯定是高于該值的。
丨 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 確定內存管控危險水位閾值
經過前面三個步驟,我們獲取了不同機型的三個閾值,分別是內存數據摸底閾值、頁面內存閾值、主動觸發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