iOS雲音樂APM性能監控實踐

語言: CN / TW / HK

本文作者:xxq

背景

客户端 APM 監控是發現和解決產品質量問題的重要手段,通常用於排查線上崩潰等問題,隨着業務迭代,單純的崩潰監控不能滿足要求,特別是對於雲音樂這樣業務場景很複雜的產品,滑動不流暢、設備發熱、UI 卡死、無故閃退等異常問題對用户體驗傷害都很大,因此我們自研了一套能力更完善的 APM 監控系統並在雲音樂上取得了不錯的效果,本文是關於客户端監控部分的具體實現方案以及實施效果的一些總結。

行業調研

互聯網大廠基本都有自研的 APM,其中有些甚至已經開源,市面已有方案中有大廠將自己積累多年的 APM 監控能力商業化(字節、阿里、手Q),也有許多優秀的開源項目或詳細方案介紹(matrixWedjatSentry),這些 APM 項目中不乏質量較高的開源項目比如 matrix 的內存監控,也有原理和思路比較全面比如 Wedjat 以及一些技術分享文章。

但對於雲音樂這樣比較複雜且獨立的大型項目來講,亟需一款技術可控且符合自身業務特點的 APM,因此我們不僅吸納了市面上優秀方案的實踐經驗,同時結合業務場景做了深度的優化與改進,我們的方案主要有如下特點:

  • 場景豐富全面:覆蓋了 OOM、ANR、Jank 卡頓、CPU 發熱、UI 假死等場景;
  • 異常精細管控:設計了一套異常問題分級標準,對不同級別的問題採用不同的監控和治理策略;
  • 堆棧精準高效:
  • 通過聚合型堆棧結構提升問題堆棧的準確率;
  • 通過過濾無用堆棧減少干擾信息;
  • 上報堆棧的線程名以便於過濾特定問題堆棧;
  • 調試能力豐富:調試工具可以有效提升問題排查效率
  • 監控台實時展現CPU/GPU/FPS等信息;
  • 支持各類異常場景的模擬;
  • 支持本地符號化堆棧信息;
  • 支持函數耗時統計。

方案介紹

一、堆棧

目標

一款 APM 項目的核心目標是幫助業務提前發現和快速定位性能問題,在大家熟知的崩潰監控中崩潰堆棧是其最為核心的信息,在大部分場景能直接定位到出現崩潰問題的代碼行,在本文提到的各類異常監控中亦是如此,本項目中絕大部分異常 Issue 都會將堆棧作為其核心信息上報,因此堆棧是 APM 項目中最基礎也是最重要的模塊。 但與此同時性能性能異常的堆棧和崩潰型堆棧也存在很大區別,崩潰堆棧是在問題發生時抓取全線程堆棧,而性能異常的監控很多時候不能準確抓取到當時的調用棧,需要利用統計學手段去問題場景最有可能的堆棧,所以我們設計了一套聚合型堆棧方案,本文也先從這裏開始闡述。

堆棧聚合

Apple 的 ips 堆棧

堆棧格式參考自蘋果ips文件,它將多組堆棧聚合到一起展示,通過縮進來表示堆棧的深度,這樣即節省了堆棧的存儲空間,也便於直觀展示多組堆棧信息,還能根據堆棧的命中次數提取出命中率最高的關鍵堆棧,這對 Issue 的聚合有很大的幫助。

image.png

雲音樂的聚合型堆棧

存儲結構:這種聚合型堆棧實現方法比較簡單,通過二叉樹存儲堆棧數據,打印結果時只需遍歷二叉樹,其中二叉樹生成的算法如下:

  1. 傳入堆棧數組以及當前遍歷的深度,如果深度已經超過數組大小,則退出遞歸;否則執行 > 步驟2
  2. 從棧底開始匹配當前二叉樹節點,如果相同,則跳轉至 步驟3;不相同則跳轉至 步驟> 4
  3. 移動到下一個深度並交給 right節點處理,right為nil時創建節點,遞歸跳轉至 > 步驟1
  4. 不移動深度並交給 left處理,left為nil時創建節點,遞歸跳轉至 步驟1

image.png

打印堆棧則是通過 DFS 後續遍歷二叉樹,再格式化輸出每一棧幀的信息即可,需要根據樹深度來輸出正確的縮進,同時將堆棧的命中次數/佔比打印在前面,後文有聚合型堆棧的展示效果,此處不贅述。

壓縮原理:函數調用棧有一個特點,棧底的調用變化遠遠小於棧頂,這很好理解,一個調用樹肯定是越往樹枝末端分叉越多,這也使得從棧底向上聚合時能壓縮大量的存儲空間,粗略統計相比不用聚合型堆棧的數據,可以節省50%以上的存儲空間。

下圖中演示了3組堆棧聚合的過程,其中堆棧數據通過二叉樹來管理。

image.png

關鍵堆棧

每次傳入堆棧更新/構建二叉樹時,將當前節點的計數+1,表示當前節點匹配的次數,次數最高的權重也就最高,權重最高的為關鍵堆棧。

因此獲取關鍵堆棧的過程也是搜索權重最大的二叉樹路徑,實現比較簡單此處不再贅述。

無效堆棧

為什麼要過濾?

在實際上報的堆棧裏,我們發現大量堆棧如下,都是一些純系統調用。

image.png

image.png

image.png

這類堆棧對我們排查問題幾乎沒有什麼幫助,因此我們默認剔除這類堆棧,最大程度減少干擾。

一個堆棧是由一組調用幀組成,每個調用幀由 image addr offset 或與之等價的信息構成,我們只需判斷 image 是不是 app 自己即可知道當次調用是否來自我們應用自身的代碼。需要注意的是APP自身引入的動態庫也要納入內部調用,因此判斷 image 是否來自 app 自身時,文件路徑要去掉 *.app/*這部分的匹配。

判斷 main函數地址

上面的三個圖中,第一個圖裏有 main函數,不論何時抓取主線程幾乎必定有這個調用,因為 APP 是由它啟動的。但是 main 函數的 image 就是應用自身,如何單獨排除掉這個特殊情況?可以通過 main 函數地址進行判斷,首先獲取到 main 函數地址,然後判斷調用幀的 addr是否來自main函數。

main函數地址存在 mach-o 文件信息 LC_MAIN CMD 中

c++ // 獲取 main 函數地址 struct uuid_command * cmd = (struct uuid_command *)macho_search_command(image, LC_MAIN); if (cmd != NULL) { struct entry_point_command * entry_pt = (struct entry_point_command *)cmd; Dl_info info = {0}; dladdr((const void *)header, &info); main_func_addr = (void *)(info.dli_saddr + entry_pt->entryoff); }

需要注意的是,獲取到的函數地址與frame的 addr會存在一個固定差值,判斷時需要處理一下。

二、監控

目標

有了新的堆棧能力後,接下來我們需要針對不同的異常場景設計相應的監控方案,一般比較常見的性能異常場景和歸因如下:

| 場景 | 歸因 | | ---------------- | ------------------------------------------------------------------------ | | 設備發熱、耗電快 | CPU 長時間高佔用、頻繁磁盤IO | | 卡頓 | 主線程執行或同步等待耗時任務,比如磁盤IO、文件加解密計算、圖片提前解壓等 | | 界面不響應 | 主隊列不響應任務,比如主線程死鎖、死循環佔用等 | | 異常閃退 | 內存佔用過高OOM、界面卡死、磁盤空間不足、CPU持續過高等 |

我們需要利用設備的系統信息對不同的場景實施與之相應的監控方案,其中系統信息與異常場景之間可以簡單按照下面的映射進行關聯:

  • CPU => 設備發熱問題
  • Runloop 耗時 => 卡頓問題
  • main queue => 界面不響應
  • 內存佔用 => OOM

實際中會稍微複雜一些,接下來本文會圍繞一些典型場景講述其監控原理。

CPU 高消耗

原理

窗口統計機制

CPU過高的佔用會帶來設備發熱、耗電快、後台進程被系統強殺等問題,嚴重影響用户體驗,但正常使用下,比如滾動列表視圖,通常會由於頻繁I/O以及UI高頻刷新,而致使CPU很容易達到100%佔用率,但短時間的CPU高佔用並不能衡量APP的健康度,甚至很多時候是正常現象,我們更關注的那些長時間佔用 CPU 的問題線程,像 Xcode 自帶的耗電監控也是類似的邏輯,因此我們使用窗口掃描機制策略來發現這類異常問題。

Apple Xcode自帶的耗電監控異常日誌

image.png

實踐中我們發現大部分CPU異常場景會集中在單個線程,因此監控更側重線程維度的表達,異常Issue與線程一對一的關係,同時將線程名稱一併上報。

此外CPU異常最關鍵的信息是堆棧,關於堆棧的格式、抓取策略、關鍵幀提取等內容,前面已經詳細闡述,總的來説方案有如下幾個關鍵點:

  1. 通過窗口掃描機制,聚焦長時間佔用 CPU 的異常情況
  2. 將異常問題根據平均CPU佔用率劃分 info/warn/error 三種級別
  3. 一個 Issue 對應一個線程,Issue 中包含線程名信息
  4. 默認情況下,過濾完全沒有APP內部調用的堆棧數據

窗口掃描機制

固定的統計窗口內CPU超過限制的次數超過一定次數時,抓取當前線程堆棧,當抓取線程堆棧數量超過設定閾值時,將採集到的堆棧聚合、排序並上報。

image.png

解釋説明:

  • CPU usage 範圍是0~1000,即 usage 為 100表示佔用率為 10%
  • 圖中窗口為 5/8,即窗口8次中有5次超限(超過80閾值),抓取堆棧
  • 窗口1中只有120、100、100,共計3次超限
  • 窗口2中有120、100、100、100,共計4次超限
  • 窗口3中有120、100、100、100、100,共計5次超限,滿足5/8窗口,抓取堆棧
  • ...

效果

通過CPU監控定位了一處後台線程高佔用從而導致雲音樂後台聽歌被強殺的線上問題。

某個線程CPU高佔用上報量突增,解決後上報量降低到個位數

image.png

上報堆棧顯示主線程某個動畫模塊持續高CPU佔用

image.png

Jank 卡頓

原理

後台線程監控

業內關於卡頓監控的方案基本大同小異,通過一個單獨的線程不斷輪訓檢測 Main Runloop 的耗時情況,超時則認為發生卡頓,我們定義超時時間為3幀即 50ms。同時我們還控制了堆棧抓取的頻次以及頁面採集頻次,因為卡頓事件實在是太多了😹。

image.png

示例代碼

```objc // 監控線程 dispatch_async(self.monitorQueue, ^{ //子線程開啟一個持續的loop用來進行監控 while (YES) { NSTimeInterval tsBeforeWaiting = GetTimestamp(); long semaphoreWait = dispatch_semaphore_wait(self.dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, s_jank_monitor_runloop_timeout * NSEC_PER_MSEC)); CFRunLoopActivity runloopActivity = atomic_load_explicit(&self->_runLoopActivity, memory_order_acquire); NSTimeInterval currentTime = GetTimestamp(); NSTimeInterval tsInterval = currentTime - tsBeforeWaiting; if (semaphoreWait != 0) { // 信號量超時,認為發生卡頓 ... } } }

...

// 主線程runloop回調

static void RunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void info) { APMJankRunloopMonitor jankMonitor = (__bridge APMJankRunloopMonitor *)info; atomic_store_explicit(&jankMonitor->_runLoopActivity, activity, memory_order_release); dispatch_semaphore_t semaphore = jankMonitor.dispatchSemaphore; dispatch_semaphore_signal(semaphore); } ```

頻控

每個頁面每日只統計1次,除此之外,為了避免過於密集地抓取堆棧以及擴大堆棧採集的時間跨度,並不是每次卡頓事件發生時都抓取堆棧,約定在第1、3、5、10、15、20...5n次卡頓時抓取主線程堆棧,當抓取到的堆棧數量超過一個閾值時上報數據。

效果

從上線後效果來看,聚合的準確度還不錯,通過幾個頭部卡頓 Issue 可以看到,頁面卡頓的典型場景集中在磁盤IO方面,與實際的結果是相符的。

主線程操作 FMDB

image.png

主線程 md5 計算

image.png

主線程下載文件

image.png

ANR 卡死

原理

ping機制

ANR 是指UI線程無響應的情況,此時UI線程由於某種原因被阻塞,不執行任何新提交的主線程隊列任務,基於這個特點,監控原理則是通過定時向 main_queue中發送任務修改 ack值,每次輪訓檢測 ack的值是否發生修改來判斷主線程是否發生了ANR

檢測流程示意

image.png

示意代碼

```objc // ack: recv success if (atomic_load_explicit(&s_ack, memory_order_acquire)) { // ack成功,值被修改 // 狀態恢復,ANR結束/未發生 // ... // ANR 計數清零 atomic_store_explicit(&s_anr_count, 0u, memory_order_release); } else { // 無應答,ANR 計數+1 unsigned long anr_count = atomic_fetch_add_explicit(&s_anr_count, 1u, memory_order_acq_rel); anr_count ++; // 發生 ANR 事件 // ... }

// ack: send atomic_store_explicit(&s_ack, false, memory_order_release); dispatch_async(dispatch_get_main_queue(), ^{ // ack: recv atomic_store_explicit(&s_ack, true, memory_order_release); }); ```

每次發生 ANR 時抓取堆棧,抓取規則如下

  1. ANR 的第 4、8、16 秒時,抓取全線程堆棧並聚合
  2. ANR 的第 2、3、4、5、6...n 秒時,抓取主線程堆棧並聚合

實時將抓取到的堆棧數據存儲到本地,如果程序從 ANR 狀態恢復執行,則刪除本地 ANR 數據;

每次啟動時檢查本地是否存在 ANR 數據,如果有數據則上報 ANR 異常,上報後刪除這份數據。

效果

常見的ANR場景有死鎖(CPU佔用低)、死循環(CPU佔用高)、大任務等,下面展示了幾種典型的ANR異常堆棧。

死鎖問題

image.png

h5 頁面死鎖

image.png

IO 操作超時

image.png

內存異常

原理

內存異常主要包含OOM大內存對象巨量小內存對象三類異常,其中 OOM 屬於崩潰型異常,而後兩者屬於運行時異常內存分配,比如某個對象創建了是百萬次,或者一次申請了10M大小的內存對象。

方案原理在一定程度參考了 matrix 的方案,通過系統的 malloc_logger 回調時抓取內存申請的堆棧,根據內存大小維度聚合內存對象,記錄內存的申請數量、內存大小以及堆棧等信息,在上報時dump出堆棧數據並上報,堆棧格式和前面一樣都是聚合型堆棧。

需要注意的是,Dump 內存信息是比較耗性能的任務,監控只在APP內存佔用超過500M時觸發 dump,同時在 >500M 的前提下,每次內存增長300M會再次觸發 dump 任務,下圖展示了內存波動與 dump 時機的場景。

image.png

效果

目前OOM監控已在線上啟用3個月以上,沒有對用户體驗產生明顯劣化,我們甚至嘗試過在 main 函數前就啟動 OOM 監控,幫助業務側定位到一個極難排查的啟動 OOM 問題。

程序剛啟動便發生嚴重的 OOM,系統的 ips 以及 xcode instrument 等官方工具,對這個場景幾乎都束手無策。

image.png

下圖展示了某個 240 字節的內存對象申請了6535次,共佔用485Mb內存大小

image.png

後記

限於篇幅有很多能力沒有展開講述,APM 上線半年以來,幫助雲音樂發現和定位不少線上問題,如今面對客訴反饋時也不再兩眼一抹黑,大大提高了問題的解決效率,APM 在未來還會圍繞下面幾個方向持續完善,它也將持續為雲音樂線上質量保駕護航。

關於 APM 未來的規劃

  • 鏈路自動化:異常 Issue 自動指派
  • 場景精細化:網絡大圖內存異常監控
  • 更全面的工具:監控日誌定向回撈、採樣數據可視化展現

本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!