iOS 穩定性問題治理:卡死崩潰監控原理及最佳實踐

不一樣於 Android 系統中的卡死(ANR)問題,目前業界對 iOS 系統中 App 發生的卡死崩潰問題並沒有成熟的解決方案,主要緣由是:git

  1. 一般 App 卡死時間超過 20s 以後會觸發操做系統的保護機制,發生崩潰,此時在用戶的設備中能找到操做系統生成的卡死崩潰日誌,可是由於 iOS 系統封閉生態的關係,App 層面沒有權限拿到卡死崩潰的日誌。github

  2. 通常而言用戶遇到卡死問題的時候並無耐心等待那麼久的時間,可能在卡住 5s 時就已經失去耐心,直接手動關閉應用或者直接將應用退到後臺,所以這兩種場景下系統也就不會生成卡死崩潰日誌。sql

因爲上面提到的兩個緣由,目前業界 iOS 生產環境中的卡死監控方案其實主要是基於卡頓監控,即當用戶在使用 App 的過程當中頁面響應時間超過必定的卡頓的閾值(通常是幾百 ms)以後斷定爲一次卡頓,而後抓取到當時現場的調用棧而且上報到後臺分析。這種方案的缺陷主要體如今:數據庫

  1. 沒有將比較輕微的卡頓問題和嚴重的卡死問題區分開,致使上報的問題數量太多,很難聚焦到重點。實際上這部分問題對用戶體驗的傷害實際上是遠遠大於卡頓的。小程序

  2. 由於一些使用低端機型的用戶更容易在短期內遇到頻繁的卡頓,可是調用棧抓取,日誌寫入和上報等監控手段都是性能有損的,這也是卡頓監控方案在生產環境中通常只能小流量而不能全量的緣由。後端

  3. 試想一次卡頓持續了 100ms,前 99ms 都卡在 A 方法的執行上,可是最後 1ms 恰好切換到了 B 方法在執行,這時候卡頓監控抓到的調用棧是 B 方法的調用棧,其實 B 方法並非形成卡頓的主要緣由,這樣也就形成了誤導。api

基於上述的痛點,字節跳動 APM 中臺團隊自研了一套專門用於定位生產環境中的卡死崩潰的解決方案,本文將詳細的介紹該方案的思路和具體實現,以及經過本方案上線後總結出來的一些典型問題和最佳實踐,指望對你們有所啓發。xcode

卡死崩潰背景介紹

什麼是 watchdog

若是某一天咱們的 App 在啓動時卡住大概 20s 而後崩潰以後,從設備中導出的系統崩潰日誌極可能是下面這種格式:瀏覽器

Exception Type:  EXC_CRASH (SIGKILL)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note:  EXC_CORPSE_NOTIFY
Termination Reason: Namespace ASSERTIOND, Code 0x8badf00d
Triggered by Thread:  0
複製代碼

下面就其中最重要的前 4 行信息逐一解釋:性能優化

  1. Exception Type

EXC_CRASH:Mach 層的異常類型,表示進程異常退出。

SIGKILL:BSD 層的信號,表示進程被系統終止,並且這個信號不能被阻塞、處理和忽略。這時能夠查看 Termination Reason 字段瞭解終止的緣由。

  1. Exception Codes

這個字段通常用不上,當崩潰報告包含一個未命名的異常類型時,這個異常類型將用這個字段表示,形式是十六進制數字。

  1. Exception Note

EXC_CORPSE_NOTIFYEXC_CRASH 定義在同一個文件中,意思是進程異常進入 CORPSE 狀態。

  1. Termination Reason

這裏主要關注 Code 0x8badf00d,能夠在蘋果的官方文檔中查看到 0x8badf00d 意味着 App ate bad food,表示進程由於 watchdog 超時而被操做系統結束進程。 經過上述已經信息能夠得出 watchdog 崩潰的定義:

在iOS平臺上,App若是在啓動、退出或者響應系統事件時由於耗時過長觸發系統保護機制,最終致使進程被強制結束的這種異常定義爲watchdog類型的崩潰。

所謂的 watchdog 崩潰也就是本文所說的卡死崩潰。

爲何要監控卡死崩潰

你們都知道在客戶端研發中,由於會阻斷用戶的正常使用,閃退已是最嚴重的 bug,會直接影響留存,收入等各項最核心的業務指標。以前你們重點關注的都是諸如 unrecognized selectorEXC_BAD_ACCESS 等能夠在 App 進程內被捕獲的崩潰(下文中稱之爲普通崩潰),可是對於 SIGKILL 這類由於進程外的指令強制退出致使的異常,原有的監控原理是覆蓋不到的,也致使此類崩潰在生產環境中被長期忽視。除此以外,還有以下理由:

  1. 由於卡死崩潰最多見發生於 App 啓動階段,用戶在開屏頁面卡住 20s 後什麼都作不了緊接着 App 就閃退了。這種體驗對用戶的傷害比普通的崩潰更加嚴重。

  2. 在卡死監控上線之初,今日頭條 App 天天卡死崩潰發生的量級大概是普通崩潰的 3 倍,可見若是不作任何治理的話,這類問題的發生量級是很是大的。

  3. OOM 崩潰也是由 SIGKILL 異常信號最終觸發的,目前 OOM 崩潰主流的監控原理仍是排除法。不過傳統方案在作排除法的時候漏掉了一類量級很是大的其餘類型的崩潰就是這裏的卡死崩潰。若是能準確的監控到卡死崩潰,也一樣能大大提升 OOM 崩潰監控的準確性。關於 OOM 崩潰的具體監控原理和優化思路能夠參考:iOS 性能優化實踐:頭條抖音如何實現 OOM 崩潰率降低 50%+

所以,基於以上信息咱們能夠得出結論:卡死崩潰的監控和治理是很是有必要的。通過近 2 年的監控和治理,目前今日頭條 App 卡死崩潰天天發生的量級大體和普通崩潰持平。

卡死崩潰監控原理

卡頓監控原理

其實從用戶體驗出發的話,卡死的定義就是長時間卡住而且最終也沒有恢復的那部分卡頓,那麼下面咱們就先回顧一下卡頓監控的原理。 咱們知道在 iOS 系統中,主線程絕大部分計算或者繪製任務都是以 runloop 爲單位週期性被執行的。單次 runloop 循環若是時長超過 16ms,就會致使 UI 體驗的卡頓。那如何檢測單次 runloop 的耗時呢?

經過上圖能夠看到,若是咱們註冊一個 runloop 生命週期事件的觀察者,那麼在 afterWaiting=>beforeTimers,beforeTimers=>beforeSources 以及 beforeSources=>beforeWaiting 這三個階段都有可能發生耗時操做。因此對於卡頓問題的監控原理大概分爲下面幾步:

  1. 註冊 runloop 生命週期事件的觀察者。

  2. runloop 生命週期回調之間檢測耗時,一旦檢測到除休眠階段以外的其餘任意一個階段耗時超過咱們預先設定的卡頓閾值,則觸發卡頓斷定而且記錄當時的調用棧。

  3. 在合適的時機上報到後端平臺分析。

總體流程以下圖所示:

如何斷定一次卡頓爲一次卡死

其實經過上面的一些總結咱們不難發現,長時間的卡頓最終不管是觸發了系統的卡死崩潰,仍是用戶忍受不了主動結束進程或者退後臺,他們的共同特徵就是發生了長期時間卡頓且最終沒有恢復,阻斷了用戶的正常使用流程。

基於這個理論的指導,咱們就能夠經過下面這個流程來斷定某次卡頓究竟是不是卡死:

  1. 某次長時間的卡頓被檢測到以後,記錄當時全部線程的調用棧,存到數據庫中做爲卡死崩潰的懷疑對象。

  2. 假如在當前 runloop 的循環中進入到了下一個活躍狀態,那麼該卡頓不是一次卡死,就從數據庫中刪除該條日誌。本次使用週期內,下次長時間的卡頓觸發時再從新寫入一條日誌做爲懷疑對象,依此類推。

  3. 在下次啓動時檢測上一次啓動有沒有卡死的日誌(用戶一次使用週期最多隻會發生一次卡死),若是有,說明用戶上一次使用期間最終遇到了一次長時間的卡頓,且最終 runloop 也沒能進入下一個活躍狀態,則標記爲一次卡死崩潰上報。

經過這套流程分析下來,咱們不只能夠檢測到系統的卡死崩潰,也能夠檢測到用戶忍受不了長時間卡頓最終殺掉應用或者退後臺以後被系統殺死等行爲,這些場景雖然並無實際觸發系統的卡死崩潰,可是嚴重程度實際上是等同的。也就是說本文提到的卡死崩潰監控能力是系統卡死崩潰的超集。

卡死時間的閾值如何肯定

系統的卡死崩潰日誌格式截取部分以下:

Exception Type:  EXC_CRASH (SIGKILL)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note:  EXC_CORPSE_NOTIFYTermination
Reason: Namespace ASSERTIOND, Code 0x8badf00d
Triggered by Thread:  0
Termination 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 中子線程同步訪問主線程,最終形成死鎖的問題。 如上圖所示,這個死鎖的復現步驟是:

  1. 子線程先進入 dispatch_once 的 block 中並加鎖。
  2. 而後主線程再進入 dispatch_once 並等待子線程解鎖。
  3. 子線程初始化時觸發了 CTTelephonyNetworkInfo 對象初始化拋出了一個通知卻要求主線程同步響應,這就形成了主線程和子線程由於互相等待而死鎖,最終觸發了卡死崩潰。

這裏的實際上是踩到了 CTTelephonyNetworkInfo 一個潛在的坑。若是這裏替換成一段 dispatch_syncdispatch_get_main_queue()的代碼,效果仍是等同的,一樣有卡死崩潰的風險。

最佳實踐

  1. dispatch_once 中不要有同步到主線程執行的方法。

  2. CTTelephonyNetworkInfo 最好在 +load 方法或者 main 方法以前的其餘時機提早初始化一個共享的實例,避免踩到子線程懶加載時候要求主線程同步響應的坑。

主線程執行代碼與子線程耗時操做存在鎖競爭

問題描述

一個比較典型的問題是卡死在-[YYDiskCache containsObjectForKey:]YYDiskCache 內部針對磁盤多線程讀寫操做,經過一個信號量鎖保證互斥。經過分析卡死堆棧能夠發現是子線程佔用鎖資源進行耗時的寫操做或清理操做引起主線程卡死,問題發生時通常能夠發現以下的子線程調用棧:

最佳實踐

  1. 有可能存在鎖競爭的代碼儘可能不在主線程同步執行。

  2. 若是主線程與子線程不可避免的存在競爭時,加鎖的粒度要儘可能小,操做要儘可能輕。

磁盤 IO 過於密集

問題描述

此類問題,表現形式可能多種多樣,可是歸根結底都是由於磁盤 IO 過於密集最終致使主線程磁盤 IO 耗時過長。 典型 case:

  1. 主線程壓縮/解壓縮。

  2. 主線程同步寫入數據庫,或者與子線程可能的耗時操做(例如 sqlitevaccum 或者 checkpoint 等)複用同一個串行隊列同步寫入。

  3. 主線程磁盤 IO 比較輕量,可是子線程 IO 過於密集,常發生於一些低端設備。

最佳實踐

  1. 數據庫讀寫,文件壓縮/解壓縮等磁盤 IO 行爲不放在主線程執行。

  2. 若是存在主線程將任務同步到串行隊列中執行的場景,確保這些任務不與子線程可能存在的耗時操做複用同一個串行隊列。

  3. 對於一些啓動階段非必要同步加載而且有比較密集磁盤 IO 行爲的 SDK,如各類支付分享等第三方 SDK 均可以延遲,錯開加載。

系統 api 底層實現存在跨進程通訊

問題描述

由於跨進程通訊須要與其餘進程同步,一旦其餘進程發生異常或者掛起,頗有可能形成當前 App 卡死。 典型 case:

  1. UIPasteBoard,特別是 OpenUDID。由於 OpenUDID 這個庫爲了跨 App 能夠訪問到相同的 UDID,經過建立剪切板和讀取剪切板的方式來實現的跨 App 通訊,外部每次調用 OpenUDID 來獲取一次 UDID,OpenUDID 內部都會循環 100 次,從剪切板獲取 UDID,並經過排序得到出現頻率最高的那個 UDID,也就是這個流程可能最終會致使訪問剪切板卡死。
  2. NSUserDefaults 底層實現中存在直接或者間接的跨進程通訊,在主線程同步調用容易發生卡死。
  3. [[UIApplication sharedApplication] openURL]接口,內部實現也存在同步的跨進程通訊。

最佳實踐

  1. 廢棄 OpenUDID 這個第三方庫,一些依賴了 UIPaseteBoard 的第三方 SDK 推進維護者下掉對 UIPasteBoard 的依賴並更新版本;或者將這些 SDK 的初始化統一放在非主線程,不過經驗來看子線程初始化可能有 5%的卡死轉化爲閃退,所以最好加一個開關逐步放量觀察。

  2. 對於 kv 類存儲需求,若是重度的使用能夠考慮 MMKV,若是輕度的使用能夠參考 firebase 的實現本身重寫一個更輕量的 UserDefaults 類。

  3. 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:

  1. dyld lock、 selector lock 和 OC runtime lock 三個鎖互相等待形成死鎖的問題。三個鎖互相等待的場景以下圖所示:

  1. 在某次迭代的過程當中 APM SDK 內部斷定設備是否越獄的實現改成依賴 fork 方法可否調用成功,可是 fork 方法會調用 _objc_atfork_prepare,這個函數會獲取 objc 相關的 lock,以後會調用 dyld_initializer,內部又會獲取 dyld lock,若是此時咱們的某個線程已經持有了 dyld lock,在等待 OC runtime lock,就會引起死鎖。

最佳實踐

  1. 慎用_dyld_register_func_for_add_imageobjc_copyClassNamesForImage 這兩個方法,特別是與 OC 方法同步調用的場景。
  2. 越獄檢測,不依賴 fork 方法的調用。

試用路徑

目前,字節 APM 中臺自研的卡死監控功能已對外開發,搭載於字節跳動火山引擎旗下的應用性能監控平臺上,以供外部開發者及企業使用。

應用性能監控平臺所集成的相關技術,經今日頭條、抖音、西瓜視頻等衆多 APP 的打磨,已沉澱出一套完整的解決方案,可以定位移動端、瀏覽器、小程序等多端問題,除了支持崩潰、錯誤、卡頓、網絡等基礎問題的分析,還提供關聯到應用啓動、頁面瀏覽、內存優化的衆多能力,目前 Demo 已開放,歡迎你們試用。

值得注意的是,火山引擎近期針對中小企業及我的開發者推出了增加賦能計劃——「火種計劃」。符合條件的企業/開發者僅需於官網註冊並提交相應申請,便可無償使用應用性能監控這一平臺,有須要的同窗抓緊申請吧~

詳情可點擊傳送門:zjsms.com/ed8ktbb/

加入咱們

本技術方案由字節跳動 APM 中臺設計研發,歡迎對咱們團隊感興趣的同窗加入。

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

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

參考文獻

[1] developer.apple.com/documentati…

[2] geek-is-stupid.github.io/2018-10-15-…

[3] honchwong.github.io/2018/05/05/…

[4] zhuanlan.zhihu.com/p/37652140

[5] openfibers.github.io/blog/2016/1…


歡迎關注「 字節跳動技術團隊

簡歷投遞聯繫郵箱「 tech@bytedance.com

相關文章
相關標籤/搜索