抖音 ANR 自動歸因平臺建設實踐
抖音作為一個超大型的應用,我們在 ANR 問題治理上面臨著很大的挑戰(zhàn)。首先對于存量問題的優(yōu)化,由于缺少有效的歸因手段,一些長期的疑難問題一直難以突破解決,例如長期位于 Top 1 的 nativePollOnce 問題。同時我們在防劣化上也面臨很大的壓力,版本快速迭代引入的新增劣化,以及線上變更導致的激增劣化,都需要投入大量的人力去排查定位,無法在第一時間快速修復止損。
ANR 原理簡介
既然我們要建設的是 ANR 歸因平臺,首先需要了解下什么是 ANR ?它是 Android 系統(tǒng)定義的一種“應用程序無響應”的異常問題,目的是為了監(jiān)控發(fā)現(xiàn)應用程序是否存在交互響應慢或卡死的問題。從用戶的視角來看,發(fā)生 ANR 時設備上會出現(xiàn)提示應用無響應的彈窗,甚至在一些機型上可能就直接閃退了。所以 ANR 與其他崩潰問題一樣,是一種會對用戶體驗造成嚴重打斷的異常問題。
接下來我們從系統(tǒng)的設計原理來看下為什么會發(fā)生 ANR ?以一種常見的廣播超時引起的 ANR 為例,首先系統(tǒng) AMS 服務會通過 IPC 方式將一個有序廣播發(fā)送給應用進程,并在同時啟動一個超時監(jiān)控。應用進程在 Binder 線程接收到廣播之后,會將其封裝成一個消息 Message 加入到主線程的消息隊列里等待執(zhí)行。正常情況下,廣播消息在應用內都會得到及時響應,然后通知系統(tǒng) AMS 服務取消超時監(jiān)控。但是在一些異常的情況下,如果在系統(tǒng)設置的超時到來之前,目標消息還沒有調度執(zhí)行完成的話,系統(tǒng)就會判定響應超時并觸發(fā) ANR。
歸因方案現(xiàn)狀
當前業(yè)界針對 ANR 問題的歸因手段有哪些?第一種是傳統(tǒng)歸因方案:基于系統(tǒng)生成的 ANR Trace 和 ANR Info 來定位問題原因。這里的 ANR Trace 是在問題發(fā)生時系統(tǒng)通知應用自身 dump 采集的各個線程的堆棧以及狀態(tài)信息,而 ANR Info 中則包括了 ANR 原因、系統(tǒng)以及應用進程的 CPU 使用率 和 IO 負載等信息。對于像下圖中這類由于當前消息嚴重耗時或卡死引起的 ANR,這種系統(tǒng)原生的方案可以幫助我們快速的定位到問題堆棧。
但是它也存在一個明顯的問題,從前面的原理分析可以知道,廣播等系統(tǒng)組件消息在加入到主線程之后,是按照它在消息隊列中的先后順序來執(zhí)行的,所以有可能是之前的歷史消息存在嚴重耗時從而引起的問題。這種情況下在 ANR 實際發(fā)生時系統(tǒng)抓取的堆棧很有可能就已經(jīng)錯過了問題的現(xiàn)場,基于這樣的數(shù)據(jù)進行歸因得到的結果也是不準確的。
第二種是慢消息歸因方案:通過監(jiān)控主線程消息的執(zhí)行情況,并結合耗時消息的采樣抓棧來定位問題原因。這個方案解決了前面?zhèn)鹘y(tǒng)歸因方案中存在的問題,提供了一種更細粒度的監(jiān)控和歸因能力,可以同時發(fā)現(xiàn)當前和歷史的耗時消息,以及其中可能存在的耗時問題堆棧。
但這個方案也同樣存在的一些不足之處, 因為對于像抖音這樣的大型應用來說,ANR 通常是由于各種復雜的綜合性因素導致的,包括子線程 / 子進程 CPU 搶占、應用 / 系統(tǒng)內存不足等也都會對主線程的執(zhí)行效率造成影響,間接導致主線程整體都變慢了。在這種情況下,主線程的慢消息或堆??赡懿⒉皇菃栴}的根本原因,同樣以此得到的歸因結果也是不全面的。
所以總結一下目前的現(xiàn)狀,現(xiàn)有的 ANR 歸因方案存在以下幾個痛點問題:首先是歸因不準確,歸因結果難以消費,不能真正解決問題。其次是歸因能力少,對于復雜問題難以定位根本原因。最后是歸因效率低,人工排查周期長。
建設思路
接下來重點介紹下 ANR 歸因平臺的建設思路,平臺歸因體系主要圍繞以下三個方向進行建設:
- 單點問題歸因:首先需要對單個 ANR 問題實現(xiàn)精準的歸因,這也是我們整個歸因體系建設的重點和基礎。
- 聚合問題歸因:其次是線上大數(shù)據(jù)的聚合問題歸因,幫助我們聚焦 Top 重點問題。
- 劣化問題歸因:最后線上灰度以及全量版本劣化問題的自動歸因,提升新增 / 激增問題的解決效率,目前正在建設中,本次分享就不展開介紹了。
單點歸因思路
單點 ANR 問題的歸因可以分為三個步驟,首先需要從原理出發(fā)明確 ANR 問題區(qū)間;接下來對問題進行粗歸因,也就是一種定性的分析,比如說是主線程阻塞卡死、還是 CPU 搶占或是內存異常導致的問題;最后就是進一步進行細歸因,也就是需要定位到具體的問題代碼,能實際指導我們消費并解決問題。
問題區(qū)間
首先從 ANR 問題的原理出發(fā),來分析一下如何大致確定 ANR 問題區(qū)間。我們同樣還是以上面的廣播消息超時引起的 ANR 為例,問題產(chǎn)生的時間順序為:從系統(tǒng) AMS 服務發(fā)送有序廣播并啟動超時監(jiān)控開始,到應用進程將該廣播消息加入到主線程消息隊列,并按照隊列中的先后順序等待調度執(zhí)行。當系統(tǒng)進程的超時時間結束前,對應的廣播消息還沒有執(zhí)行完成并通知系統(tǒng),系統(tǒng)就會判定響應超時并觸發(fā) ANR。這里我們可以以 ANR 實際發(fā)生時間為結束點,往前回溯對應的超時時間(這里不同的 ANR 原因會對應不同的超時時間設置),也就是后續(xù)需要診斷分析 ANR 問題區(qū)間范圍。
粗歸因
在明確 ANR 問題的時間范圍后,我們需要從技術角度來拆解下,如何進行粗歸因的定性分析。從上面的原理分析可以推導出,引起 ANR 的根本原因就是系統(tǒng)組件消息(包括 input 事件)在應用側沒有得到及時的執(zhí)行。而我們知道這些關鍵目標消息都是在主線程中進行消費處理的,所以這里的關鍵點就是 ANR 區(qū)間內之前的這些消息為什么執(zhí)行耗時 ?從系統(tǒng)的角度來看,所有代碼邏輯的執(zhí)行可以分為 On-CPU 和 Off-CPU 兩種情況:
- On-CPU:對應 Running 狀態(tài),即當前任務正在占用 CPU 資源進行計算處理。已知 計算耗時 = 計算量 / 計算速度,這里計算速度會受到 CPU 硬件本身的限制,比如 CPU 核心頻率以及當前運行在大核或小核上。另一個跟應用自身關系比較緊密的就是計算量,例如主線程在執(zhí)行 CPU 密集型的操作,比如 JSON 序列化 / 反序列化,或是在處理大量的高頻業(yè)務消息。
- Off-CPU:包括 Runnable 和 Sleep 兩種狀態(tài)。Runnable 代表任務所需的資源已就緒,正在 CPU 運行隊列上等待調度執(zhí)行,這里除了受到系統(tǒng)本身的調度策略的影響之外,也跟當前同樣已就緒并等待調度的任務數(shù)量有關。如果子線程或子進程有很多 CPU 耗時任務在等待執(zhí)行的話,因為總的計算資源是有限的,互相之間頻繁的搶占也會影響主線程的執(zhí)行效率。Sleep 代表任務在阻塞等待資源,比如等待 Lock、IO、同步 Binder 以及內存 Block GC 等。通常情況下我們應該避免在主線程發(fā)生這類 Block 阻塞問題,同時這也是比較常見的一類解決卡頓或 ANR 的優(yōu)化手段。
下面我們來看下第一種主線程異常消息直接引起的 ANR,它可能是由于當前消息嚴重耗時或卡死導致的,也可能是由于之前的一個或多個歷史耗時消息引起的 ANR。
第二種就是后臺任務 CPU 資源搶占引起的ANR,它會間接影響主線程的執(zhí)行效率。從下圖中可以看到在子線程 CPU 負載變高之后,主線程的整體性能開始下降變慢,這種情況下就會更容易發(fā)生 ANR。最典型的就是在冷啟動的場景下,通過降級或打散 CPU 耗時的后臺任務,我們已經(jīng)驗證可以有效的降低 ANR 率以及縮短啟動首刷耗時。
最后一種內存等資源異常問題,例如虛擬機 Java 內存不足時,GC 線程就會開始變得活躍并進行頻繁 GC,這同樣也會搶占主線程 CPU 資源或 Block GC 等待,從而導致 ANR 問題的發(fā)生。對于抖音這樣的視頻類大型應用,線上內存問題導致的卡頓或 ANR 問題的占比較高,目前也在專項治理優(yōu)化中。
基于以上的演繹推理,總結一下我們的歸因思路主要包括以下幾類:第一,主線程本身的異常問題,例如存在嚴重的阻塞等待或者 CPU 繁忙等問題。第二,后臺任務搶占 CPU 資源導致的異常問題。最后,內存 / IO 等系統(tǒng)資源不足導致的異常問題,目前正在探索中,本次分享就不展開介紹了。
細歸因
主線程消息異常
首先來看主線程消息異常的歸因思路:我們需要先對主線程消息進行監(jiān)控,這里包括三種情況,已經(jīng)執(zhí)行完成和正在執(zhí)行中的消息以及消息隊列中待執(zhí)行的消息。對于已經(jīng)或正在執(zhí)行的消息,我們主要關注它們的耗時情況,通過分析系統(tǒng)源碼我們可以知道,主線程消息隊列里處理的消息一共包括三種:Java 消息、Native 消息和 Idle Handler。而對于待執(zhí)行的消息,我們主要關注其中的數(shù)量,從而判斷是否存在大量消息堆積的異常問題。
對于主線程異常消息的問題類型,第一類就是耗時消息,即在問題區(qū)間內存在一個或多個耗時大于閾值的慢消息。另一種是高頻消息,也就是存在出現(xiàn)次數(shù)以及累計耗時超過一定閾值的高密度消息。這里通過對業(yè)務消息的 target、callback 和 what 等信息進行聚合分析,有時也可以協(xié)助定位到導致問題的業(yè)務方。
但是僅僅找到異常消息并不能直接幫助我們解決問題,還需要對消息的耗時原因做進一步的歸因分析,找到其中引起消息耗時的問題函數(shù)。接下來介紹一下線上 Trace 數(shù)據(jù)采集方案,這里我們采取類似 Matrix 方案,通過 ASM 字節(jié)碼工具,在編譯時對應用內的業(yè)務代碼進行插樁,也就是在函數(shù)的入口和出口插入一行統(tǒng)計代碼,來記錄當前方法的運行耗時。為了降低采集時的性能損耗,將方法 begin / end 狀態(tài)標識、當前方法 ID 以及時間戳的 Diff 相對值,合并使用一個 64 位的變量來記錄。在 ANR 等異常問題發(fā)生時,再將 Ring Buffer 中記錄的數(shù)據(jù)進行上報,在后端數(shù)據(jù)鏈路處理生成對應的 Trace 堆棧,并提供給后續(xù)的診斷算法進行自動化分析,以及 Perfetto 人工可視化分析的需求。
但對于抖音這樣的大型應用來說,插樁方案也會有一些弊端,當插樁的函數(shù)過多時,會對包體積以及性能產(chǎn)生負面的影響。為了盡可能降低監(jiān)控工具對線上用戶體驗的影響,我們提出了精準插樁的方案!因為結合對于線上問題的分析訴求來看,我們重點關注的是上層執(zhí)行的業(yè)務函數(shù),比如頁面生命周期、業(yè)務消息等入口方法,以及底層那些可能耗時的業(yè)務函數(shù)。所以我們基于靜態(tài)代碼分析的基礎能力,分析提取出帶有耗時特征的函數(shù)來進行插樁,例如下面表格中帶有鎖關鍵字的函數(shù)、存在 Native / IO 等調用的函數(shù)以及特別復雜的大方法等。在這個精準插樁的優(yōu)化策略之下,大幅減少了約 90% 的插樁數(shù)量!
在大幅精簡插樁數(shù)量之后,我們在消費線上數(shù)據(jù)時又面臨到一個問題,就是僅有插樁的堆棧信息太少,有時難以幫助我們實際定位到發(fā)生問題的代碼。為了解決這一痛點問題,我們又設計了插樁和抓棧數(shù)據(jù)擬合的優(yōu)化方案,其原理就是通過對耗時超過一定閾值的慢函數(shù)進行抓棧上報,然后在服務端再將插樁與抓棧數(shù)據(jù)根據(jù)時間點進行擬合,補齊其中缺失的業(yè)務和系統(tǒng)堆棧信息。如下圖中所示,當插樁堆棧中連續(xù)兩個節(jié)點能在抓棧數(shù)據(jù)中找到最小間距的數(shù)據(jù)并對齊時,抓棧數(shù)據(jù)中對應層之間的數(shù)據(jù)將會被補全到到插樁數(shù)據(jù)中,生成新的擬合堆棧數(shù)據(jù),圖中的 D 就是被補全的數(shù)據(jù)。
在獲取到線上問題發(fā)生時的詳細 Trace 數(shù)據(jù)后,我們需要進一步找到其中引起耗時的問題函數(shù),常見的問題函數(shù)類型包括以下兩種:
- 慢函數(shù):是指函數(shù)的執(zhí)行耗時超過一定的閾值;并且從實際可消費性的角度來看,我們預期是要能找到更靠近葉子節(jié)點的業(yè)務慢函數(shù)。所以需要根據(jù)實際調用堆棧的情況,進一步剔除底層的基礎庫或工具類方法,以及存在相同調用鏈路的親緣父子節(jié)點,來找到最合適的慢函數(shù)問題。
- 高頻函數(shù):對應就是單個雖然并不耗時,但是由于次數(shù)很多,累計執(zhí)行耗時超過閾值的函數(shù);根據(jù)我們以往的經(jīng)驗來看,對于高頻函數(shù)的優(yōu)化通常也能帶來不錯的收益。
當然僅僅知道函數(shù)慢也是不夠的!我們結合一個線上實際的示例來看,通過 Trace 可以發(fā)現(xiàn)標記紅框的這里有一個業(yè)務函數(shù)執(zhí)行比較耗時,但是這里為什么會耗時呢?我們要如何進行優(yōu)化呢?目前僅有的堆棧信息并不能滿足我們的歸因需求。之后通過分析業(yè)務代碼并補齊了缺失的“關鍵”信息之后,我們就可以明確知道這里耗時的原因是由于鎖競爭導致的,并且我們還進一步補充了當前持有鎖的線程以及堆棧等重要信息。
為了更好的對慢函數(shù)的耗時進行歸因,除了前面采集的 Trace 堆棧數(shù)據(jù)之外,我們還需要補充一些關鍵的上下文信息,這里就統(tǒng)稱為精細化數(shù)據(jù)。比如上面提到的鎖,還有函數(shù)的 CPU-Time、Binder 調用的名稱、IO 讀寫的文件路徑和大小、繪制渲染相關的 RenderNode,以及內存 Block GC 等相關信息。
所以回顧總結一下主線程消息異常的歸因流程,首先需要明確當前 ANR 的問題區(qū)間,然后找到其中的異常消息(耗時消息或高頻消息),進一步下鉆找到其中引起耗時的問題函數(shù)(慢函數(shù)或高頻函數(shù)),最后再結合精細化數(shù)據(jù)對其耗時原因進行歸因。
后臺任務異常
接下來再看下后臺任務 CPU 異常的歸因思路:首先我們需要明確是否存在后臺任務對主線程 CPU 資源產(chǎn)生搶占的問題,這里可以結合主線程的非自愿上下文切換以及調度狀態(tài)的信息,來觀測主線程是否有較多的時間都花費在等待系統(tǒng)調度上。如果存在明顯的異常情況,再結合系統(tǒng)和應用的 CPU 使用率信息,可以進一步先定位到是應用的子線程 / 子進程,或是關鍵系統(tǒng)進程(如 dex2oat 進程等),還是其他應用進程造成的 CPU 資源搶占。
對于應用自身造成的 CPU 搶占問題,我們需要進一步定位到具體的問題代碼。所以我們在之前的 Trace 采集方案的基礎上,進行了重大的升級改造,擴展支持了全線程的 Trace 數(shù)據(jù)采集。
單線程 Trace | 多線程 Trace | 說明 | |
Flag 狀態(tài)位 | 2 bit | 2 bit | 代表函數(shù)開始/結束:3(二進制0b11)= catch, // 預留 2(二進制0b10)= throw,// 預留 1(二進制0b01)= begin, 0(二進制0b00)= end |
Method ID | 20 bit | 20 bit | 代表插樁的方法 ID,最大支持 1048575 個函數(shù) |
Thread ID | - | 15 bit | 代表當前線程的 TID |
Timestamp | 42 bit | 27 bit | 代表當前函數(shù)執(zhí)行時與基準時間的相對時間,多線程模式下最大支持 134,217,727 ms = 約 1.5 天 |
由于后臺任務我們重點關注的是 CPU-Time 耗時,所以在采集函數(shù)的 Wall-Time 執(zhí)行耗時之外,同時也支持函數(shù)粒度的 CPU-Time 耗時采集,并在后端進行處理關聯(lián)。這里出于性能損耗上的考慮,我們會進一步精簡控制同時需要采集 CPU 時間的插樁函數(shù)數(shù)量,例如僅對系統(tǒng)的生命周期方法,以及子線程的 Runnable、Callable 以及二方 / 三方的任務框架入口方法,以及少量的關鍵特征方法才開啟,并且會設置最小的采樣間隔時間。
對于
CPU-Time 的獲取一般有兩種方式:一種是通過定期讀取 proc
文件系統(tǒng)下的文件來解析獲取,這種方式如果想要精確到方法級別需要相當高的讀取頻率,這種高頻率讀取文件并解析的性能損耗很高,不適合在線上方法級別的
CPU-Time 采集;另一種則是通過 Android 提供的 SystemClock.currentThreadTimeMillis()
方法或者 Native 層的 clock_gettime(CLOCK_THREAD_CPUTIME_ID)
方法獲取當前線程的 CPU-Time,這種方式比較適合采集方法級別的 CPU 耗時,在方法開始和結束時分別調用前述方法再計算差值即可,因此我們線上采集選擇的也是這個方案。
同樣回顧總結一下后臺任務異常的歸因流程,在 ANR 的問題區(qū)間內,首先需要明確是否存在對主線程執(zhí)行效率產(chǎn)生明顯影響的 CPU 資源搶占,如果是應用自身的問題,先找到應用內 CPU 負載較高的線程或進程,進一步定位到對應異常階段里的后臺任務代碼,最后再結合精細化數(shù)據(jù)對其 CPU 耗時原因進行歸因。
聚合歸因思路
基于以上對 ANR 單點問題進行診斷分析后產(chǎn)出的歸因結論,我們可以進一步結合線上大數(shù)據(jù)進行聚合歸因,從而幫助我們更好的聚焦到 Top 重點問題的優(yōu)化上。
歸因標簽
首先是對歸因標簽的聚合分析,主要包括以下幾類:
- 粗歸因標簽:針對 ANR 問題定性的歸因分類標簽,包括主線程阻塞、高頻消息、CPU 搶占、堆內存不足等。
- 細歸因標簽:針對細歸因定位到的問題代碼的精細化歸因標簽,包括主線程鎖、IO、Binder 或者 Block GC 阻塞耗時等。
- 業(yè)務歸因特征:發(fā)生 ANR 時用戶所在場景頁面等業(yè)務維度的特征標簽,有時也可以輔助快速定位到問題相關的業(yè)務方。
如下圖所示,通過對以上不同歸因標簽的多維聚合分析,可以幫助我們對線上 ANR 問題的特征分布有一個全局的了解和認知,同時也能指導我們在歸因能力上下一步需要重點攻堅的方向。
異常問題
其次是對細歸因產(chǎn)出的異常問題進行聚合分析, 目前主要包括主線程異常函數(shù)、后臺任務以及內存這三個維度。聚合后的問題列表支持滲透率、耗時均值、PCT 50 / 90 耗時以及場景等維度的統(tǒng)計數(shù)據(jù),可以幫助我們識別出線上整體占比較高或耗時特別嚴重的這類問題。
在進入異常函數(shù)的歸因詳情頁之后,可以查看當前問題函數(shù)在線上大數(shù)據(jù)聚合后的火焰圖,其中 Caller 堆棧代表上層不同業(yè)務方的調用次數(shù)分布情況,而 Callee 堆棧則是所有子函數(shù)的耗時分布情況。
最后,基于以上的歸因標簽、異常問題以及業(yè)務歸因信息,平臺會產(chǎn)出一個對 ANR 問題最終的歸因結論以及對應的綜合置信度評分。
落地效果
接下來再介紹一下平臺目前的落地效果:首先這個案例是一個啟動階段的 ANR 問題,我們從主線程 Trace 中可以分析定位到主線程的耗時函數(shù),并且通過細歸因標簽的結果,可以明確知道是一個鎖耗時的問題。進一步結合鎖的詳情信息進行下鉆分析,通過當前子線程持有鎖的聚合堆棧,發(fā)現(xiàn)是由于某個后臺任務的執(zhí)行時機變更提前了,從而與主線程某個任務產(chǎn)生了鎖競爭沖突,導致主線程長時間的阻塞等待引起了 ANR。
第二個案例是一個主線程高頻消息問題,從定位到的問題函數(shù)可以發(fā)現(xiàn)其調用的非常高頻,平均在一次 ANR 里會出現(xiàn)了上千次!通過進一步分析發(fā)現(xiàn)是由于某個業(yè)務的邏輯 Bug,導致在特定場景下會發(fā)送大量的重復消息,導致主線程消息隊列堵塞引起的 ANR 劣化。
第三個案例是一個子線程高頻任務問題,通過定位到的后臺任務可以發(fā)現(xiàn)其在多個子線程的出現(xiàn)次數(shù)都非常高頻,并且累計的 CPU 耗時也比較高。進一步分析發(fā)現(xiàn)也是某個業(yè)務的 Bug 問題,在特定場景下會向子線程發(fā)送大量的重復任務,并且由于這些異步任務內部還會給主線程 Handler 發(fā)送或刪除消息,所以除了會搶占 CPU 資源之外,還會間接導致主線程消息隊列在遍歷取消息時會發(fā)生高頻的鎖競爭耗時,兩個因素疊加之下引起的 ANR。
最后總結下平臺過去一年的階段性成果,總共累計發(fā)現(xiàn)了有效問題 88 個,修復并優(yōu)化其中 56 個,同時協(xié)助抖音 / 抖極的大盤 ANR 率分別下降了 -13.06% 和 -8.70% ,并取得了不錯的業(yè)務收益。
總結展望
抖音 ANR 自動歸因平臺未來的規(guī)劃主要包括以下三個方面:
- 歸因體系:持續(xù)打磨監(jiān)控能力和歸因算法,包括探索完善 Java / Native 內存、繪制渲染以及 Native Trace 等方向上的精細化歸因能力。
- 防劣化體系:持續(xù)優(yōu)化線上劣化歸因和消費流程,提升線上自動歸因準確率,以及劣化問題的消費解決效率。
- 專家系統(tǒng):沉淀專家經(jīng)驗,并嘗試結合大模型等新技術,通過對技術特征和業(yè)務特征進行精細化聚合分析,進一步提升問題發(fā)現(xiàn)和解決效率。