位元組跳動技術團隊
參考:/ Github /
不同於 Android 系統中的卡死(ANR)問題,目前業界對 iOS 系統中 App 發生的卡死崩潰問題並無成熟的解決方案,主要原因是:
-
通常 App 卡死時間超過 20s 之後會觸發作業系統的保護機制,發生崩潰,此時在使用者的裝置中能找到作業系統生成的卡死崩潰日誌,但是因為 iOS 系統封閉生態的關係,App 層面沒有許可權拿到卡死崩潰的日誌。
-
一般而言使用者遇到卡死問題的時候並沒有耐心等待那麼久的時間,可能在卡住 5s 時就已經失去耐心,直接手動關閉應用或者直接將應用退到後臺,因此這兩種場景下系統也就不會生成卡死崩潰日誌。
由於上面提到的兩個原因,目前業界 iOS 生產環境中的卡死監控方案其實主要是基於卡頓監控,即當使用者在使用 App 的過程中頁面響應時間超過一定的卡頓的閾值(一般是幾百 ms)之後判定為一次卡頓,然後抓取到當時現場的呼叫棧並且上報到後臺分析。這種方案的缺陷主要體現在:
-
沒有將比較輕微的卡頓問題和嚴重的卡死問題區分開,導致上報的問題數量太多,很難聚焦到重點。實際上這部分問題對使用者體驗的傷害其實是遠遠大於卡頓的。
-
因為一些使用低端機型的使用者更容易在短時間內遇到頻繁的卡頓,但是呼叫棧抓取,日誌寫入和上報等監控手段都是效能有損的,這也是卡頓監控方案在生產環境中一般只能小流量而不能全量的原因。
-
試想一次卡頓持續了 100ms,前 99ms 都卡在 A 方法的執行上,但是最後 1ms 剛好切換到了 B 方法在執行,這時候卡頓監控抓到的呼叫棧是 B 方法的呼叫棧,其實 B 方法並不是造成卡頓的主要原因,這樣也就造成了誤導。
基於上述的痛點,位元組跳動 APM 中臺團隊自研了一套專門用於定位生產環境中的卡死崩潰的解決方案,本文將詳細的介紹該方案的思路和具體實現,以及通過本方案上線後總結出來的一些典型問題和最佳實踐,期望對大家有所啟發。
卡死崩潰背景介紹
什麼是 watchdog
如果某一天我們的 App 在啟動時卡住大概 20s 然後崩潰之後,從裝置中匯出的系統崩潰日誌很可能是下面這種格式:
Exception Type: EXC_CRASH (SIGKILL)Exception Codes: 0x0000000000000000, 0x0000000000000000Exception Note: EXC_CORPSE_NOTIFYTermination Reason: Namespace ASSERTIOND, Code 0x8badf00dTriggered by Thread: 0
複製程式碼
下面就其中最重要的前 4 行資訊逐一解釋:
- Exception Type
EXC_CRASH
:Mach 層的異常型別,表示程式異常退出。
SIGKILL
:BSD 層的訊號,表示程式被系統終止,而且這個訊號不能被阻塞、處理和忽略。這時可以檢視 Termination Reason
欄位瞭解終止的原因。
- Exception Codes
這個欄位一般用不上,當崩潰報告包含一個未命名的異常型別時,這個異常型別將用這個欄位表示,形式是十六進位制數字。
- Exception Note
EXC_CORPSE_NOTIFY
和 EXC_CRASH
定義在同一個檔案中,意思是程式異常進入 CORPSE 狀態。
- Termination Reason
這裡主要關注 Code 0x8badf00d
,可以在蘋果的官方文件中檢視到 0x8badf00d
意味著 App ate bad food
,表示程式因為 watchdog
超時而被作業系統結束程式。通過上述已經資訊可以得出 watchdog
崩潰的定義:
在iOS平臺上,App如果在啟動、退出或者響應系統事件時因為耗時過長觸發系統保護機制,最終導致程式被強制結束的這種異常定義為watchdog型別的崩潰。
所謂的 watchdog
崩潰也就是本文所說的卡死崩潰。
為什麼要監控卡死崩潰
大家都知道在客戶端研發中,因為會阻斷使用者的正常使用,閃退已經是最嚴重的 bug,會直接影響留存,收入等各項最核心的業務指標。之前大家重點關注的都是諸如 unrecognized selector
、EXC_BAD_ACCESS
等可以在 App 程式內被捕獲的崩潰(下文中稱之為普通崩潰),但是對於 SIGKILL
這類因為程式外的指令強制退出導致的異常,原有的監控原理是覆蓋不到的,也導致此類崩潰在生產環境中被長期忽視。除此之外,還有如下理由:
-
因為卡死崩潰最常見發生於 App 啟動階段,使用者在開屏頁面卡住 20s 後什麼都做不了緊接著 App 就閃退了。這種體驗對使用者的傷害比普通的崩潰更加嚴重。
-
在卡死監控上線之初,今日頭條 App 每天卡死崩潰發生的量級大概是普通崩潰的 3 倍,可見如果不做任何治理的話,這類問題的發生量級是非常大的。
-
OOM 崩潰也是由
SIGKILL
異常訊號最終觸發的,目前 OOM 崩潰主流的監控原理還是排除法。不過傳統方案在做排除法的時候漏掉了一類量級非常大的其他型別的崩潰就是這裡的卡死崩潰。如果能準確的監控到卡死崩潰,也同樣能大大提高 OOM 崩潰監控的準確性。關於 OOM 崩潰的具體監控原理和優化思路可以參考:iOS 效能優化實踐:頭條抖音如何實現 OOM 崩潰率下降 50%+
因此,基於以上資訊我們可以得出結論:卡死崩潰的監控和治理是非常有必要的。經過近 2 年的監控和治理,目前今日頭條 App 卡死崩潰每天發生的量級大致和普通崩潰持平。
卡死崩潰監控原理
卡頓監控原理
其實從使用者體驗出發的話,卡死的定義就是長時間卡住並且最終也沒有恢復的那部分卡頓,那麼下面我們就先回顧一下卡頓監控的原理。我們知道在 iOS 系統中,主執行緒絕大部分計算或者繪製任務都是以 runloop
為單位週期性被執行的。單次 runloop
迴圈如果時長超過 16ms,就會導致 UI 體驗的卡頓。那如何檢測單次 runloop
的耗時呢?
通過上圖可以看到,如果我們註冊一個 runloop
生命週期事件的觀察者,那麼在 afterWaiting=>beforeTimers,beforeTimers=>beforeSources
以及beforeSources=>beforeWaiting
這三個階段都有可能發生耗時操作。所以對於卡頓問題的監控原理大概分為下面幾步:
-
註冊
runloop
生命週期事件的觀察者。 -
在
runloop
生命週期回撥之間檢測耗時,一旦檢測到除休眠階段之外的其他任意一個階段耗時超過我們預先設定的卡頓閾值,則觸發卡頓判定並且記錄當時的呼叫棧。 -
在合適的時機上報到後端平臺分析。
整體流程如下圖所示:
如何判定一次卡頓為一次卡死
其實通過上面的一些總結我們不難發現,長時間的卡頓最終無論是觸發了系統的卡死崩潰,還是使用者忍受不了主動結束程式或者退後臺,他們的共同特徵就是發生了長期時間卡頓且最終沒有恢復,阻斷了使用者的正常使用流程。
基於這個理論的指導,我們就可以通過下面這個流程來判定某次卡頓到底是不是卡死:
-
某次長時間的卡頓被檢測到之後,記錄當時所有執行緒的呼叫棧,存到資料庫中作為卡死崩潰的懷疑物件。
-
假如在當前
runloop
的迴圈中進入到了下一個活躍狀態,那麼該卡頓不是一次卡死,就從資料庫中刪除該條日誌。本次使用週期內,下次長時間的卡頓觸發時再重新寫入一條日誌作為懷疑物件,依此類推。 -
在下次啟動時檢測上一次啟動有沒有卡死的日誌(使用者一次使用週期最多隻會發生一次卡死),如果有,說明使用者上一次使用期間最終遇到了一次長時間的卡頓,且最終 runloop 也沒能進入下一個活躍狀態,則標記為一次卡死崩潰上報。
通過這套流程分析下來,我們不僅可以檢測到系統的卡死崩潰,也可以檢測到使用者忍受不了長時間卡頓最終殺掉應用或者退後臺之後被系統殺死等行為,這些場景雖然並沒有實際觸發系統的卡死崩潰,但是嚴重程度其實是等同的。也就是說本文提到的卡死崩潰監控能力是系統卡死崩潰的超集。
卡死時間的閾值如何確定
系統的卡死崩潰日誌格式擷取部分如下:
Exception Type: EXC_CRASH (SIGKILL)Exception Codes: 0x0000000000000000, 0x0000000000000000Exception Note: EXC_CORPSE_NOTIFYTerminationReason: Namespace ASSERTIOND, Code 0x8badf00dTriggered by Thread: 0Termination Description: SPRINGBOARD, scene-create watchdog transgression: application<com.ss.iphone.article.News>:2135 exhausted real (wall clock) time allowance of 19.83 seconds
複製程式碼
可以看到 iOS 系統的保護機制只有在 App 卡死時間超過一個異常閾值之後才會觸發,那麼這個卡死時間的閾值就是一個非常關鍵的引數。
遺憾的是,目前沒有官方的文件或者 api,可以直接拿到系統判定卡死崩潰的閾值。這裡 exhausted real (wall clock) time allowance of 19.83 seconds
其中的 19.83 並不是一個固定的數字,在不同的使用階段,不同系統版本的實現裡都可能有差異,在一些系統的崩潰日誌中也遇到過 10s 的 case。
基於以上資訊,為了覆蓋到大部分使用者可以感知到的場景,遮蔽不同系統版本實現的差異,我們認為系統觸發卡死崩潰的時間閾值為 10s,實際上有相當一部分使用者在遇到 App 長時間卡頓的時候會習慣性的手動結束程式重啟而不是一直等待,因此這個閾值不宜過長。為了給觸發卡死判定之後的抓棧,日誌寫入等操作預留足夠的時間,所以最終本方案的卡死時間閾值確定為 8s。發生 8s 卡死的概率比發生幾百 ms 卡頓的概率要低的多,因此該卡死監控方案並沒有太大的效能損耗,也就可以在生產環境中對全量使用者開放。
如何檢測到使用者一次卡死的時間
在卡死發生之後,實際上我們也會關注一次卡死最終到底卡住了多久,卡死時間越長,對使用者使用體驗的傷害也就越大,更應該被高優解決。
在觸發卡死閾值之後我們可以再以一個時間間隔比較短的定時器(目前策略預設 1s,線上可調整),每隔 1s 就檢測當前 runloop
有沒有進入到下一個活躍狀態,如果沒有,則當前的卡死時間就累加 1s,用這種方式即使最終發生了閃退也可以逼近實際的卡死時間,誤差不超過 1s,最終的卡死時間也會寫入到日誌中一起上報。
但是這種方案在上線後遇到了一些卡死時長特別長的 case,這種問題多發生在 App 切後臺的場景。因為在後臺情況下,App 的程式會被掛起(suspend)後,就可能被判定為持續很久的卡死狀態。而我們在計算卡死時間的時候,採用的是現實世界的時間差,也就是說當前 App 在後臺被掛起 10s 後又恢復時,我們會認為 App 卡死了 10s,輕易的超過了我們設定的卡死閾值,但其實 App 並沒有真正卡死,而是作業系統的排程行為。這種誤報常常是不符合我們的預期的。誤報的場景如下圖所示:
如何解決主執行緒呼叫棧可能有誤報的問題
為了解決上面的問題,我們採用多段等待的方式來降低執行緒排程、掛起導致的程式執行時間與現實時間不匹配的問題,以下圖為例。在 8s 的卡死閾值前,採用間隔等待的方式,每隔 1s 進行一次等待。等待超時後對當前卡死的時間進行累加 1s。如果在此過程中,App 被掛起,無論被掛起多久,再恢復時最多會造成 1s 的誤差,這與之前的方案相比極大的增加了穩定性和準確性。
另外,待卡死時間超過了設定的卡死閾值後,會對全執行緒進行抓棧。但是僅憑這一時刻的執行緒呼叫棧並不保證能夠準確定位問題。因為此時主執行緒執行的可能是一個非耗時任務,真正耗時的任務已經結束;或者在後續會發生一個更加耗時的任務,這個任務才是造成卡死的關鍵。因此,為了增加卡死呼叫棧的置信度,在超過卡死閾值後,每隔 1s 進行一次間隔等待的同時,對當前主執行緒的堆疊進行抓取。為了避免卡死時間過長造成的執行緒呼叫棧數量膨脹,最多會保留距離 App 異常退出前的最近 10 次主執行緒呼叫棧。經過多次間隔等待,我們可以獲取在 App 異常退出前主執行緒隨著時間變化的一組函式呼叫棧。通過這組函式呼叫棧,我們可以定位到主執行緒真正卡死的原因,並結合卡死時間超過閾值時獲取的全執行緒呼叫棧進一步定位卡死原因。
最終的監控效果如下:
因為圖片大小的限制,這裡僅僅截了卡死崩潰之前最後一次的主執行緒呼叫棧,實際使用的時候可以檢視崩潰之前一段時間內每一秒的呼叫棧,如果發現每一次主執行緒的呼叫棧都沒有變化,那就能確認這個卡死問題不是誤報,例如這裡就是一次異常的跨程式通訊導致的卡死。
卡死崩潰常見問題歸類及最佳實踐
多執行緒死鎖
問題描述
比較常見的就是在 dispatch_once
中子執行緒同步訪問主執行緒,最終造成死鎖的問題。如上圖所示,這個死鎖的復現步驟是:
-
子執行緒先進入
dispatch_once
的 block 中並加鎖。 -
然後主執行緒再進入
dispatch_once
並等待子執行緒解鎖。 -
子執行緒初始化時觸發了
CTTelephonyNetworkInfo
物件初始化丟擲了一個通知卻要求主執行緒同步響應,這就造成了主執行緒和子執行緒因為互相等待而死鎖,最終觸發了卡死崩潰。
這裡的其實是踩到了 CTTelephonyNetworkInfo
一個潛在的坑。如果這裡替換成一段 dispatch_sync
到 dispatch_get_main_queue()
的程式碼,效果還是等同的,同樣有卡死崩潰的風險。
最佳實踐
-
dispatch_once
中不要有同步到主執行緒執行的方法。 -
CTTelephonyNetworkInfo
最好在+load
方法或者main
方法之前的其他時機提前初始化一個共享的例項,避免踩到子執行緒懶載入時候要求主執行緒同步響應的坑。
主執行緒執行程式碼與子執行緒耗時操作存在鎖競爭
問題描述
一個比較典型的問題是卡死在-[YYDiskCache containsObjectForKey:]
,YYDiskCache
內部針對磁碟多執行緒讀寫操作,通過一個訊號量鎖保證互斥。通過分析卡死堆疊可以發現是子執行緒佔用鎖資源進行耗時的寫操作或清理操作引發主執行緒卡死,問題發生時一般可以發現如下的子執行緒呼叫棧:
最佳實踐
-
有可能存在鎖競爭的程式碼儘量不在主執行緒同步執行。
-
如果主執行緒與子執行緒不可避免的存在競爭時,加鎖的粒度要儘量小,操作要儘量輕。
磁碟 IO 過於密集
問題描述
此類問題,表現形式可能多種多樣,但是歸根結底都是因為磁碟 IO 過於密集最終導致主執行緒磁碟 IO 耗時過長。典型 case:
-
主執行緒壓縮/解壓縮。
-
主執行緒同步寫入資料庫,或者與子執行緒可能的耗時操作(例如
sqlite
的vaccum
或者checkpoint
等)複用同一個序列佇列同步寫入。 -
主執行緒磁碟 IO 比較輕量,但是子執行緒 IO 過於密集,常發生於一些低端裝置。
最佳實踐
-
資料庫讀寫,檔案壓縮/解壓縮等磁碟 IO 行為不放在主執行緒執行。
-
如果存在主執行緒將任務同步到序列佇列中執行的場景,確保這些任務不與子執行緒可能存在的耗時操作複用同一個序列佇列。
-
對於一些啟動階段非必要同步載入並且有比較密集磁碟 IO 行為的 SDK,如各種支付分享等第三方 SDK 都可以延遲,錯開載入。
系統 api 底層實現存在跨程式通訊
問題描述
因為跨程式通訊需要與其他程式同步,一旦其他程式發生異常或者掛起,很有可能造成當前 App 卡死。典型 case:
-
UIPasteBoard
,特別是OpenUDID
。因為OpenUDID
這個庫為了跨 App 可以訪問到相同的 UDID,通過建立剪下板和讀取剪下板的方式來實現的跨 App 通訊,外部每次呼叫OpenUDID
來獲取一次 UDID,OpenUDID 內部都會迴圈 100 次,從剪下板獲取 UDID,並通過排序獲得出現頻率最高的那個 UDID,也就是這個流程可能最終會導致訪問剪下板卡死。 -
NSUserDefaults
底層實現中存在直接或者間接的跨程式通訊,在主執行緒同步呼叫容易發生卡死。 -
[[UIApplication sharedApplication] openURL]
介面,內部實現也存在同步的跨程式通訊。
最佳實踐
-
廢棄
OpenUDID
這個第三方庫,一些依賴了UIPaseteBoard
的第三方 SDK 推動維護者下掉對 UIPasteBoard 的依賴並更新版本;或者將這些 SDK 的初始化統一放在非主執行緒,不過經驗來看子執行緒初始化可能有 5%的卡死轉化為閃退,因此最好加一個開關逐步放量觀察。 -
對於 kv 類儲存需求,如果重度的使用可以考慮
MMKV
,如果輕度的使用可以參考firebase
的實現自己重寫一個更輕量的 UserDefaults 類。 -
iOS10 及以上的系統版本使用
[[UIApplication sharedApplication] openURL:options:completionHandler:]
這個介面替換,此介面可以非同步調起,不會造成卡死。
Objective-C Runtime Lock 死鎖
問題描述
此類問題雖然出現概率不大,但是在一些複雜場景下也是時有發生。主執行緒的呼叫棧一般都會卡死在一個看似很普通的 OC 方法呼叫,非常隱晦,因此想要發現這類問題,卡死監控模組本身就不能用 OC 語言實現,而應該改為 C/C++。此問題一般多發於_dyld_register_func_for_add_image
回撥方法中同步呼叫 OC 方法(先持有 dyld lock 後持有 OC runtime lock),以及 OC 方法同步呼叫 objc_copyClassNamesForImage
方法(先持有 OC runtime lock 後持有 dyld lock)。典型 case:
- dyld lock、 selector lock 和 OC runtime lock 三個鎖互相等待造成死鎖的問題。三個鎖互相等待的場景如下圖所示:
- 在某次迭代的過程中 APM SDK 內部判定裝置是否越獄的實現改為依賴
fork
方法能否呼叫成功,但是fork
方法會呼叫_objc_atfork_prepare
,這個函式會獲取 objc 相關的 lock,之後會呼叫dyld_initializer
,內部又會獲取 dyld lock,如果此時我們的某個執行緒已經持有了 dyld lock,在等待 OC runtime lock,就會引發死鎖。
最佳實踐
-
慎用
_dyld_register_func_for_add_image
和objc_copyClassNamesForImage
這兩個方法,特別是與 OC 方法同步呼叫的場景。 -
越獄檢測,不依賴
fork
方法的呼叫。