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!