iOS性能優化實踐:頭條抖音如何實現OOM崩潰率下降50%+

轉載:字節跳動

iOS OOM 崩潰在生產環境中的歸因一直是困擾業界已久的疑難問題,字節跳動旗下的頭條、抖音等產品也面臨同樣的問題。

在字節跳動性能與穩定性保障團隊的研發實踐中,我們自研了一款基于內存快照技術并且可應用于生產環境中的 OOM 歸因方案——線上 Memory Graph。基于此方案,3 個月內頭條抖音 OOM 崩潰率下降 50%+。

本文主要分享下該解決方案的技術背景,技術原理以及使用方式,旨在為這個疑難問題提供一種新的解決思路。

OOM 崩潰背景介紹

OOM

OOM 其實是Out Of Memory的簡稱,指的是在 iOS 設備上當前應用因為內存占用過高而被操作系統強制終止,在用戶側的感知就是 App 一瞬間的閃退,與普通的 Crash 沒有明顯差異。但是當我們在調試階段遇到這種崩潰的時候,從設備設置->隱私->分析與改進中是找不到普通類型的崩潰日志,只能夠找到Jetsam開頭的日志,這種形式的日志其實就是 OOM 崩潰之后系統生成的一種專門反映內存異常問題的日志。那么下一個問題就來了,什么是Jetsam

Jetsam

Jetsam是 iOS 操作系統為了控制內存資源過度使用而采用的一種資源管控機制。不同于MacOSLinuxWindows等桌面操作系統,出于性能方面的考慮,iOS 系統并沒有設計內存交換空間的機制,所以在 iOS 中,如果設備整體內存緊張的話,系統只能將一些優先級不高或占用內存過大的進程直接終止掉。

[圖片上傳失敗...(image-aeea8d-1603760345328)]

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: justify; color: rgb(136, 136, 136); font-size: 14px;">Jetsam 日志解讀
</figcaption>

上圖是截取一份Jetsam日志中最關鍵的一部分。關鍵信息解讀:

  • pageSize:指的是當前設備物理內存頁的大小,當前設備是iPhoneXs Max,大小是 16KB,蘋果 A7 芯片之前的設備物理內存頁大小則是 4KB。
  • states:當前應用的運行狀態,對于Heimdallr-Example這個應用而言是正在前臺運行的狀態,這類崩潰我們稱之為FOOM(Foreground Out Of Memory);與此相對應的也有應用程序在后臺發生的 OOM 崩潰,這類崩潰我們稱之為BOOM(Background Out Of Memory)。
  • rpages:是resident pages的縮寫,表明進程當前占用的內存頁數量,Heimdallr-Example 這個應用占用的內存頁數量是 92800,基于 pageSize 和 rpages 可以計算出應用崩潰時占用的內存大小:16384 * 92800 / 1024 /1024 = 1.4GB。
  • reason:表明進程被終止的的原因,Heimdallr-Example這個應用被終止的原因是超過了操作系統允許的單個進程物理內存占用的上限。

Jetsam機制清理策略可以總結為下面兩點:

  1. 單個 App 物理內存占用超過上限2. 整個設備物理內存占用收到壓力按照下面優先級完成清理:

  2. 后臺應用>前臺應用

  3. 內存占用高的應用>內存占用低的應用

  4. 用戶應用>系統應用

Jetsam的代碼在開源的XNU代碼中可以找到,這里篇幅原因就不具體展開了,具體的源碼解析可以參考本文最后第 2 和第 3 篇參考文獻。

為什么要監控 OOM 崩潰

前面我們已經了解到,OOM 分為FOOMBOOM兩種類型,顯然前者因為用戶的感知更明顯,所以對用戶的體驗的傷害更大,下文中提到的 OOM 崩潰僅指的是FOOM。那么針對 OOM 崩潰問題有必要建立線上的監控手段嗎?

答案是有而且非常有必要的!原因如下:

  1. 重度用戶也就是使用時間更長的用戶更容易發生FOOM,對這部分用戶體驗的傷害導致用戶流失的話對業務損失更大。
  2. 頭條,抖音等多個產品線上數據均顯示FOOM量級比普通崩潰還要多,因為過去缺乏有效的監控和治理手段導致問題被長期忽視。
  3. 內存占用過高即使沒導致FOOM也可能會導致其他應用BOOM的概率變大,一旦用戶發現從微信切換到我們 App 使用,再切回微信沒有停留在之前微信的聊天頁面而是重新啟動的話,對用戶來說,體驗是非常糟糕的。

OOM 線上監控

Jetsam 強殺代碼截圖

翻閱XNU源碼的時候我們可以看到在Jetsam機制終止進程的時候最終是通過發送SIGKILL異常信號來完成的。

define SIGKILL 9 kill (cannot be caught or ignored)

從系統庫 signal.h 文件中我們可以找到SIGKILL這個異常信號的解釋,它不可以在當前進程被忽略或者被捕獲,我們之前監聽異常信號的常規 Crash 捕獲方案肯定也就不適用了。那我們應該如何監控 OOM 崩潰呢?

正面監控這條路行不通,2015 年的時候Facebook提出了另外一種思路,簡而言之就是排除法。具體流程可以參考下面這張流程圖:

排除法判定OOM崩潰的流程

我們在每次 App 啟動的時候判斷上一次啟動進程終止的原因,那么已知的原因有:

  • App 更新了版本
  • App 發生了崩潰
  • 用戶手動退出
  • 操作系統更新了版本
  • App 切換到后臺之后進程終止

如果上一次啟動進程終止的原因不是上述任何一個已知原因的話,就判定上次啟動發生了一次FOOM崩潰。

曾經Facebook旗下的Fabric也是這樣實現的。但是通過我們的測試和驗證,上述這種方式至少將以下幾種場景誤判:

  • WatchDog 崩潰
  • 后臺啟動
  • XCTest/UITest 等自動化測試框架驅動
  • 應用 exit 主動退出

在字節跳動 OOM 崩潰監控上線之前,我們已經排除了上面已知的所有誤判場景。需要說明的是,因為排除法畢竟沒有直接的監控來的那么精準,或多或少總有一些 bad case,但是我們會保證盡量的準確。

自研線上 Memory Graph,OOM 崩潰率下降 50%+

OOM 生產環境歸因

目前在 iOS 端排查內存問題的工具主要包括 Xcode 提供的 Memory Graph 和 Instruments 相關的工具集,它們能夠提供相對完備的內存信息,但是應用場景僅限于開發環境,無法在生產環境使用。由于內存問題往往發生在一些極端的使用場景,線下開發測試一般無法覆蓋對應的問題,Xcode 提供的工具無法分析處理大多數偶現的疑難問題。

對此,各大公司都提出了自己的線上解決方案,并開源了例如MLeaksFinderOOMDetectorFBRetainCycleDetector等優秀的解決方案。

在字節跳動內部的使用過程中,我們發現現有工具各有側重,無法完全滿足我們的需求。主要的問題集中在以下兩點:

  • 基于 Objective-C 對象引用關系找循環引用的方案,適用范圍比較小,只能處理部分循環引用問題,而內存問題通常是復雜的,類似于內存堆積,Root Leak,C/C++層問題都無法解決。
  • 基于分配堆棧信息聚類的方案需要常駐運行,對內存、CPU 等資源存在較大消耗,無法針對有內存問題的用戶進行監控,只能廣撒網,用戶體驗影響較大。同時,通過某些比較通用的堆棧分配的內存無法定位出實際的內存使用場景,對于循環引用等常見泄漏也無法分析。

為了解決頭條,抖音等各產品日益嚴峻的內存問題,我們自行研發了一款基于內存快照技術的線上方案,我們稱之為——線上 Memory Graph。上線后接入了集團內幾乎所有的產品,幫助各產品修復了多年的歷史問題,OOM 率降低一個數量級,3 個月之內抖音最新版本 OOM 率下降了 50%,頭條下降了 60%。線上突發 OOM 問題定位效率大大提升,徹底告別了線上 OOM 問題歸因“兩眼一抹黑”的時代。

線上 Memory Graph 核心的原理是掃描進程中所有 Dirty 內存,通過內存節點中保存的其他內存節點的地址值建立起內存節點之間的引用關系的有向圖,用于內存問題的分析定位,整個過程不使用任何私有 API。這套方案具備的能力如下:

  1. 完整還原用戶當時的內存狀態。
  2. 量化線上用戶的大內存占用和內存泄漏,可以精確的回答 App 內存到底大在哪里這個問題。
  3. 通過內存節點符號和引用關系圖回答內存節點為什么存活這個問題。
  4. 嚴格控制性能損耗,只有當內存占用超過異常閾值的時候才會觸發分析。沒有運行時開銷,只有采集時開銷,對 99.9%正常使用的用戶幾乎沒有任何影響。
  5. 支持主要的編程語言,包括 OC,C/C++,Swift,Rust 等。
線上 Memory Graph 采集及上報流程示意圖

內存快照采集

線上 Memory Graph 采集內存快照主要是為了獲取當前運行狀態下所有內存對象以及對象之間的引用關系,用于后續的問題分析。主要需要獲取的信息如下:

  • 所有內存的節點,以及其符號信息(如OC/Swift/C++ 實例類名,或者是某種有特殊用途的 VM 節點的 tag 等)。
  • 節點之間的引用關系,以及符號信息(偏移,或者實例變量名),OC/Swift成員變量還需要記錄引用類型。

由于采集的過程發生在程序正常運行的過程中,為了保證不會因為采集內存快照導致程序運行異常,整個采集過程需要在一個相對靜止的運行環境下完成。因此,整個快照采集的過程大致分為以下幾個步驟:

  1. 掛起所有非采集線程。
  2. 獲取所有的內存節點,內存對象引用關系以及相應的輔助信息。
  3. 寫入文件。
  4. 恢復線程狀態。

下面會分別介紹整個采集過程中一些實現細節上的考量以及收集信息的取舍。

內存節點的獲取

程序的內存都是由虛擬內存組成的,每一塊單獨的虛擬內存被稱之為VM Region,通過 mach 內核的vm_region_recurse/vm_region_recurse64函數我們可以遍歷進程內所有VM Region,并通過vm_region_submap_info_64結構體獲取以下信息:

  • 虛擬地址空間中的地址和大小。
  • Dirty 和 Swapped 內存頁數,表示該VM Region的真實物理內存使用。
  • 是否可交換,Text 段、共享 mmap 等只讀或隨時可以被交換出去的內存,無需關注。
  • user_tag,用戶標簽,用于提供該VM Region的用途的更準確信息。

大多數 VM Region 作為一個單獨的內存節點,僅記錄起始地址和 Dirty、Swapped 內存作為大小,以及與其他節點之間的引用關系;而 libmalloc 維護的堆內存所在的 VM Region 則由于往往包含大多數業務邏輯中的 Objective-C 對象、C/C++對象、buffer 等,可以獲取更詳細的引用信息,因此需要單獨處理其內部節點、引用關系。

在 iOS 系統中為了避免所有的內存分配都使用系統調用產生性能問題,相關的庫負責一次申請大塊內存,再在其之上進行二次分配并進行管理,提供給小塊需要動態分配的內存對象使用,稱之為堆內存。程序中使用到絕大多數的動態內存都通過堆進行管理,在 iOS 操作系統上,主要的業務邏輯分配的內存都通過libmalloc進行管理,部分系統庫為了性能也會使用自己的單獨的堆管理,例如WebKit內核使用bmallocCFNetwork也使用自己獨立的堆,在這里我們只關注libmalloc內部的內存管理狀態,而不關心其它可能的堆(即這部分特殊內存會以VM Region的粒度存在,不分析其內部的節點引用關系)。

我們可以通過malloc_get_all_zones獲取libmalloc內部所有的zone,并遍歷每個zone中管理的內存節點,獲取 libmalloc 管理的存活的所有內存節點的指針和大小。

符號化

獲取所有內存節點之后,我們需要為每個節點找到更加詳細的類型名稱,用于后續的分析。其中,對于 VM Region 內存節點,我們可以通過 user_tag 賦予它有意義的符號信息;而堆內存對象包含 raw buffer,Objective-C/Swift、C++等對象。對于 Objective-C/Swift、C++這部分,我們通過內存中的一些運行時信息,嘗試符號化獲取更加詳細的信息。

Objective/Swift 對象的符號化相對比較簡單,很多三方庫都有類似實現,Swift在內存布局上兼容了Objective-C,也有isa指針,objc相關方法可以作用于兩種語言的對象上。只要保證 isa 指針合法,對象實例大小滿足條件即可認為正確。

C++對象根據是否包含虛表可以分成兩類。對于不包含虛表的對象,因為缺乏運行時數據,無法進行處理。

對于對于包含虛表的對象,在調研 mach-o 和 C++的 ABI 文檔后,可以通過 std::type_info 和以下幾個 section 的信息獲取對應的類型信息。

  • type_name string - 類名對應的常量字符串,存儲在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。
C++實例以及 vtable 的引用關系示意圖

在 iOS 系統內,還有一類特殊的對象,即CoreFoundation。除了我們熟知的CFStringCFDictionary外等,很多很多系統庫也使用 CF 對象,比如CGImageCVObject等。從它們的 isa 指針獲取的Objective-C類型被統一成__NSCFType。由于 CoreFoundation 類型支持實時的注冊、注銷類型,為了細化這部分的類型,我們通過逆向拿到 CoreFoundation 維護的類型 slot 數組的位置并讀取其數據,保證能夠安全的獲取準確的類型。

CoreFoundation 類型獲取

引用關系的構建

整個內存快照的核心在于重新構建內存節點之間的引用關系。在虛擬內存中,如果一個內存節點引用了其它內存節點,則對應的內存地址中會存儲指向對方的指針值。基于這個事實我們設計了以下方案:

  1. 遍歷一個內存節點中所有可能存儲了指針的范圍獲取其存儲的值 A。
  2. 搜索所有獲得的節點,判斷 A 是不是某一個內存節點中任何一個字節的地址,如果是,則認為是一個引用關系。
  3. 對所有內存節點重復以上操作。

對于一些特定的內存區域,為了獲取更詳細的信息用于排查問題,我們對棧內存以及 Objective-C/Swift 的堆內存進行了一些額外的處理。

其中,棧內存也以VM Region的形式存在,棧上保存了臨時變量和 TLS 等數據,獲取相應的引用信息可以幫助排查諸如 autoreleasepool 造成的內存問題。由于棧并不會使用整個棧內存,為了獲取 Stack 的引用關系,我們根據寄存器以及棧內存獲取當前的棧可用范圍,排除未使用的棧內存造成的無效引用。

棧使用范圍

而對于Objective-C/Swift對象,由于運行時包含額外的信息,我們可以獲得Ivar的強弱引用關系以及Ivar的名字,帶上這些信息有助于我們分析問題。通過獲得Ivar的偏移,如果找到的引用關系的偏移和Ivar的偏移一致,則認為這個引用關系就是這個Ivar,可以將Ivar相關的信息附加上去。

數據上報策略

我們在 App 內存到達設定值后采集 App 當時的內存節點和引用關系,然后上傳至遠端進行分析,可以精準的反映 App 當時的內存狀態,從而定位問題,總的流程如下:

線上 Memory Graph 整體工作流程

整個線上 Memory Graph 模塊工作的完整流程如上圖所示,主要包括:

  1. 后臺線程定時檢測內存占用,超過設定的危險閾值后觸發內存分析。
  2. 內存分析后數據持久化,等待下次上報。
  3. 原始文件壓縮打包。
  4. 檢查后端上報許可,因為單個文件很大,后端可能會做一些限流的策略。
  5. 上報到后端分析,如果成功后清除文件,失敗后會重試,最多三次之后清除,防止占用用戶太多的磁盤空間。

后臺分析

這是字節監控平臺 Memory Graph 單點詳情頁的一個 case:

線上 Memory Graph 詳情頁概覽

我們可以看到這個用戶的內存占用已經將近 900MB,我們分析時候的思路一般是:

  1. 從對象數量和對象內存占用這兩個角度嘗試找到類列表中最有嫌疑的那個類。
  2. 從對象列表中隨機選中某個實例,向它的父節點回溯引用關系,找到你認為最有嫌疑的一條引用路徑。
  3. 點擊引用路徑模塊右上角的Add Tag來判斷當前選中的引用路徑在同類對象中出現過多少次。
  4. 確認有問題的引用路徑之后再判斷究竟是哪個業務模塊發生的問題。
當前引用路徑在同類型對象中出現頻率統計

通過上圖中引用路徑的分析我們發現,所有的圖片最終都被TTImagePickController這個類持有,最終排查到是圖片選擇器模塊一次性把用戶相冊中的所有圖片都加載到內存里,極端情況下會發生這個問題。

整體性能和穩定性

采集側優化策略

由于整個內存空間一般包含的內存節點從幾十萬到幾千萬不等,同時程序的運行狀態瞬息萬變,采集過程有著很大的性能和穩定性的壓力。

我們在前面的基礎上還進行了一些性能優化:

  • 寫出采集數據使用mmap映射,并自定義二進制格式保證順序讀寫。
  • 提前對內存節點進行排序,建立邊引用關系時使用二分查找。通過位運算對一些非法內存地址進行提前快速剪枝。

對于穩定性部分,我們著重考慮了下面幾點:

  • 死鎖

由于無法保證 Objective-C 運行時鎖的狀態,我們將需要通過運行時 api 獲取的信息在掛起線程前提前緩存。同時,為了保證libmalloc鎖的狀態安全,在掛起線程后我們對 libmalloc 的鎖狀態進行了判斷,如果已經鎖住則恢復線程重新嘗試掛起,避免堆死鎖。

  • 非法內存訪問

在掛起所有其他線程后,為了減少采集本身分配的內存對采集的影響,我們使用了一個單獨的malloc_zone管理采集模塊的內存使用。

性能損耗

因為在數據采集的時候需要掛起所有線程,會導致用戶感知到卡頓,所以字節模塊還是有一定性能損耗的,經過我們測試,在iPhone8 Plus設備上,App 占用 1G 內存時,采集用時 1.5-2 秒,采集時額外內存消耗 10-20MB,生成的文件 zip 后大小在 5-20MB。

為了嚴格控制性能損耗,線上 Memory Graph 模塊會應用以下策略,避免太頻繁的觸發打擾用戶正常使用,避免自身內存和磁盤等資源過多的占用:

性能損耗控制策略

穩定性

該方案已經在字節全系產品線上穩定運行了 6 個月以上,穩定性和成功率得到了驗證,目前單次采集成功率可以達到 99.5%,剩下的失敗基本都是由于內存緊張提前 OOM,考慮到大多數應用只有不到千分之一的用戶會觸發采集,這種情況屬于極低概率事件。

試用路徑

目前,線上 Memory Graph 已搭載在字節跳動火山引擎旗下應用性能管理平臺(APMInsight)上賦能給外部開發者使用。

APMInsight 的相關技術經過今日頭條、抖音、西瓜視頻等眾多應用的打磨,已沉淀出一套完整的解決方案,能夠定位移動端、瀏覽器、小程序等多端問題,除了支持崩潰、錯誤、卡頓、網絡等基礎問題的分析,還提供關聯到應用啟動、頁面瀏覽、內存優化的眾多功能。目前 Demo 已開放大部分能力,歡迎各位注冊賬號試用:https://www.volcengine.cn/product/apminsight

加入我們

本技術方案由字節跳動 APM 中臺和抖音基礎技術團隊深度合作聯合打造,歡迎對我們兩個團隊感興趣的同學加入:

APM 中臺

字節跳動 APM 中臺目前致力于提升整個集團內全系產品的性能和穩定性表現,技術棧覆蓋 iOS/Android/Flutter/Web/Hybrid/PC/游戲/小程序等,工作內容包括但不限于線上監控,線上運維,深度優化,線下防劣化等。長期期望為業界輸出更多更有建設性的問題發現和深度優化手段。

歡迎各位有識之士加入我們,一起為了“更快,更穩,更省,更有品質”的極致目標攜手前行。我們在北京,深圳兩地均有招聘需求,簡歷投遞郵箱:** tech@bytedance.com ;郵件標題:姓名 - 工作年限 - APM 中臺 - 技術棧方向(如 iOS/Android/Web/后端)**。

抖音基礎技術

我們是負責抖音客戶端基礎能力研發和新技術探索的團隊。我們在工程/業務架構,研發工具,編譯系統等方向深耕,支撐業務快速迭代的同時,保證超大規模團隊的研發效能和工程質量。在性能/穩定性等方面不斷探索,努力為全球數億用戶提供最極致的基礎體驗。

如果你對技術充滿熱情,歡迎加入抖音基礎技術團隊,讓我們共建億級全球化 App。目前我們在上海、北京、杭州、深圳均有招聘需求,內推可以聯系郵箱:** tech@bytedance.com** ;郵件標題: **姓名 - 工作年限 - 抖音 - 基礎技術 - iOS/Android **。

參考文獻

[1] https://zhuanlan.zhihu.com/p/49829766

[2] http://satanwoo.github.io/2017/10/18/abort/

[3] https://jinxuebin.cn/2019/07/OOM底層原理探究/

[4] https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。