你的 App 在 iOS 13 上被卡死了嗎?

自從58同城iOS客戶端9.0.0版本上線以來,陸續接到反饋說App有時啓動會超時,無法響應,然後被系統殺死,只有重啓手機才能恢復。

得知存在App無法啓動的問題後,我們馬上展開了調查。通過對觸發此問題的設備進行測試,發現此問題所影響的不僅僅是58同城App的啓動,另有如京東、大衆點評、騰訊視頻等其他App也無法正常打開。

圖1 App啓動崩潰截屏

而且經過進一步測試,發現當此問題觸發時,在任意App中進行剪貼板的相關操作都會突然導致App卡死無法操作。

圖2 App卡死截屏

如何找到崩潰堆棧?

 

雖然我們總結出了這種卡死App問題的各種表現,但是如果沒有清晰的崩潰棧信息,就沒有線索去解決這個問題。於是我們開始去Bugly上查找有可能相關聯的崩潰信息,但是並沒有收穫。

爲什麼Bugly上收集不到崩潰信息?

之後我們拿到發生崩潰的iPhone設備,連接到電腦並通過”Xcode-Devices and Simulators-View Devices Logs”導出了設備的崩潰日誌去排查原因。它是在主線程(main-thread)中發生的崩潰,異常類型(Exception Type)爲一個終止程序的信號(SIGKILL)類型,Code爲0x8badf00d。如下所示:

圖3崩潰信息描述

那Bugly爲什麼收集不到這種崩潰?

(1)信號類型

首先,信號是Unix、類Unix以及其他POSIX兼容的操作系統中進程間通訊的一種有限制的方式。它是一種異步的通知機制,用來提醒進程一個事件已經發生。當一個信號發送給一個進程,操作系統中斷了進程正常的控制流程,此時,任何非原子操作都將被中斷。如果進程定義了信號的處理函數,那麼它將被執行,否則就執行默認的處理函數。因此在應用的Crash引起的程序異常退出都會有signal。它的種類有多種,常見的有SIGSEGV,SIGILL,SIGABRT,SIGBUS,SIGKILL等等。

信號類型

信號解釋

SIGSEGV

無效的內存地址引用信號,試圖訪問未分配給自己的內存, 或試圖往沒有寫權限的內存地址寫數據。

SIGILL

執行了非法指令,通常是因爲可執行文件本身出現錯誤, 或者試圖執行數據段. 堆棧溢出時也有可能產生這個信號。

SIGABRT

通常由於異常引起的中斷信號,異常發生時系統會調用abort()函數發出該信號。

SIGBUS

非法地址, 包括內存地址對齊(alignment)出錯。與SIGSEGV的區別在於後者是由於對合法存儲地址的非法訪問觸發的(如訪問不屬於自己存儲空間或只讀存儲空間)。

SIGKILL

用來立即結束程序的運行,該信號不能被阻塞、處理和忽略。

表 1 信號類型解釋

其中本次發生崩潰的信號是終止程序的SIGKILL,它是不能被阻塞、處理和忽略。因此在應用中不能捕獲此類崩潰,第三方工具中是無法收集到。

(2)Code異常編碼

異常編碼也是分析崩潰原因的重要依據之一,該日誌中Code碼0x8badf00d,即“ate bad food”,表示在應用程序啓動、終止或響應系統事件花費的時間過長,應用程序已被系統終止,發生了監視程序超時。它是蘋果設計的“看門狗”(watchdog)機制,若超出了該場景所規定的運行時間,“看門狗”就會強制終結這個應用的進程。

觸發0x8badf00d的場景除了主線程被卡死的情況,還有以下幾種情況:

  • 在iOS11.0到iOS11.2以前系統手機在前臺收到推送後進入後臺被殺死或可能會在前臺殺死。

  • 開啓任務後做了大量耗時操作無法任務結束。

  • 系統掛起beginBackgroundTask方法回調中沒有關閉後臺任務或添加兩次或兩次以上的回調無法一對一關閉後臺任務。

  • 開啓任務後在到期事件處理的回調中開啓子線程進行大量耗時操作等等。

因此以上的場景均無法應用攔截,處理,不能上報到第三方崩潰收集工具中。

藉助隱私數據查詢崩潰日誌

既然第三方崩潰收集工具拿不到日誌,那麼我們之前是通過將iPhone設備連接到電腦中,通過”Xcode-Devices and Simulators-View Devices Logs”來導出當前設備發生的崩潰日誌。這種方式可以收集到所有類型的崩潰。但是不可能人人都具備Xcode工具,也不可能時時刻刻都帶電腦。而我們發現蘋果會將當前設備所發生的所有事件都記錄到系統日誌中,包括崩潰日誌,CPU Usage日誌。

在系統日誌中崩潰日誌名稱的格式爲“進程名+日期+時間.ips.synced”或“進程名+日期+時間.ips”,如:“58tongcheng-2019-12-04-113614.ips”。該日誌在iOS10.2以及以上系統的設備上可以進入“設置-隱私-診斷與用量”中獲取,iOS10.2以上系統的設備上可以進入“設置-隱私-分析-分析數據”中獲取。因此,用戶可以直接通過iPhone設備選擇一個崩潰日誌後,通過Airdrops或其他三方app發送到電腦或崩潰自動解析工具進行解析。

圖4系統隱私數據

點對點分析崩潰日誌

在獲取到日誌之後如何進行解析呢?針對指定的日誌進行日誌解析,絕大多數iOS開發者都會想到使用符號表進行解析。但是原始的dSYM文件可能存在沒有保存或者丟失的情況。因此58同城對日誌解析進行了相應的擴展,擴大了日誌的解析的適用範圍。除了使用原始的dSYM符號表文件進行日誌解析外,58的點對點日誌解析工具還支持,針對bugly生成的符號表symbol文件的解析,甚至在沒有任何符號表的情況下,也可以根據二進制數據進行日誌解析。

基於dSYM符號表

衆所周知,崩潰日誌符號化所需要的符號表通常指dSYM文件,dSYM文件是用來記錄調試信息的文件,其數據存儲格式爲DWARF格式。其數據來源爲應用二進制文件的DEBUG段,記錄的信息主要包括:文件路徑信息、行號信息、變量與地址的映射、函數與地址的映射等。正是因爲其存在地址與符號的映射關係,符號表纔可以被用於解析崩潰日誌。在得到崩潰日誌和相應的dSYM文件後,可藉助symbolicatecrash工具實現日誌符號化。如果沒有symbolicatecrash工具,那麼dwarfdump命令也可以逐條實現地址符號化。

在業務開發過程中,本地調試狀態下打包是默認不生成dSYM文件的,但是這並不意味着調試信息和符號信息丟失了。當我們本地Xcode打出來的包發生偶現崩潰時,可以通過Xcode提供的dsymutil工具將dSYM文件從應用程序的二進制文件中剝離。剝離出的dSYM文件即可藉助相應symbolicatecrash實現地址符號化。

基於bugly符號表

 

在使用bugly進行崩潰統計時,我們需要將符號表上傳到bugly的後臺。這個符號表並不是原始的dSYM文件,而是bugly從dSYM文件中提取的文本文件。其數據格式如下圖所示:

圖5 Symbol文件

bugly的符號表是bugly從dSYM文件中提取的函數地址與符號的映關係,其格式爲:起始指令地址 + 結束指令地址 + 代碼所在函數名 + 代碼所在文件及行號。舉例說明,假如我們拿到的崩潰偏移地址爲B,通過文本掃描後發現函數F的L行代碼的起始指令地址爲A,結束指令地址C,地址滿足A <= B <= C的原則,因此可以確定崩潰發生在F函數的L行。由於bugly的符號表只保留了函數地址符號映射,不包含文件路徑、變量地址符號映射等信息,因此bugly的符號表相比於dSYM文件更輕量,更適合保存和傳輸。

無符號情況處理

58同城在業務開發階段提供給測試同學的測試包都是通過Jenkins服務打包。隨着業務的發展,58同城APP的體積越來越龐大,這就導致測試同學從Jenkins服務器上下載APP的時間較長。爲了能夠儘可能的減小下載體積,58同城將APP的符號表在打包期間從應用程序中剝離出來形成dSYM文件,保存在打包服務器中。因此測試同學下載的Jenkins包是不包含符號表信息的。由於剝離出來的dSYM文件較大,爲了節省服務器空間,dSYM在保留2天后會自動清除。假設有這樣一個場景:測試同學下載了一個測試包,在測試到第三天時發生了不可穩定復現的崩潰,那麼此時我們進行日誌解析是沒有任何符號表的。

爲了解決這種場景的問題,58同城開發了基於Mach-O文件解析的無符號表日誌解析工具。通過遍歷二進制文件中所有類的方法列表,確定崩潰堆棧的指令地址位於哪個函數的指令區間範圍,從而確定崩潰發生時正在調用的函數,進而實現崩潰日誌的符號化。目前此工具已經成爲58質量保證必不可缺的工具之一。相關代碼已經通過58技術委員會審覈,近期將對外開源。

分析崩潰堆棧

因此,通過點對點崩潰分析的方式將崩潰日誌進行解析,我們獲取了具體各個不同線程的堆棧信息,開始定位問題。

該崩潰主要現象是主線程卡死,我們先從主線程的堆棧開始分析。

主線程調用棧分析

圖6 崩潰主線程堆棧信息

日誌中,應用被殺死之前主線程停留在+[WIMOpenUDID valueWithError:]中獲取系統剪貼板UIPasteboard對象的操作中。但是通常情況下,在主線程中獲取一個對象不會把主線程卡死,於是我們便查看了這個方法的實現以嘗試定位問題。如下:

圖7 OpenUDID部分源碼

這段代碼是從開源代碼庫OpenUDID中直接私有化出來的一份代碼,並維持了原有OpenUDID的邏輯。它在主線程中通過for循環100次來嘗試獲取標識從org.OpenUDID.slot.0到 org.OpenUDID.slot.99的UIPasteboard對象,並從每個獲取到的剪貼板對象中獲取OpenUDID的值,並保存起來。按照OpenUDID的邏輯,從100個剪貼板裏取出的OpenUDID值可能會有不同,但是它最終回取出現次數最多的一個OpenUDID值作爲最終的OpenUDID結果。

如此頻繁地調用UIPasteboard的接口去獲取對象會阻塞主線程嗎?驗證一下。

驗證主線程調用UIPasteboard的影響

首先寫一個循環來測試UIPasteboard的API的耗時情況

圖8 主線程UIPasteboard測試代碼

循環創建100個UIPasteboard對象,併爲向每個UIPasteboard對象都存入100個字符串。並打印對每個UIPasteboard對象的操作時間,執行上述代碼後,結果如下:

圖9 主線程UIPasteboard測試代碼執行輸出結果

從打印結果可以看出,獲取並對UIPasteboard進行操作的確是一個比較耗時的操作。按此結果100次循環需要50秒,這樣的話App肯定是啓動不了的。

但這是測試Demo的極端耗時操作,而OpenUDID對於剪貼板的頻繁操作僅僅是嘗試獲取剪貼板對象,這個操作的耗時不至於卡死App,所以此時單看主線程的堆棧信息不能說明什麼問題。需要再看其他線程堆棧。

子線程調用棧分析

圖10崩潰子線程堆棧信息一

圖11 崩潰子線程堆棧信息二

通過子線程堆棧信息的分析,我們發現在崩潰日誌中除了主線程以外,還有另外兩個子線程也停留在獲取系統剪貼板UIPasteboard對象的操作中。其中有一個線程停留在+[WBWMDA_OpenUDID valueWithError:]方法中,查看他的實現後發現這是應該另一個私有化的OpenUDID代碼,同樣維持了OpenUDID原有的邏輯:循環100次嘗試獲取名稱從org.OpenUDID.slot.0到org.OpenUDID.slot.99的UIPasteboard對象,從中獲取openudid的值。由於它是在子線程中被調用的,就導致了子線程頻繁獲取UIPasteboard對象的情況。

子線程反覆調用UIPasteboard的接口會使App卡主嗎?接下來,我們再驗證一下子線程操作UIPasteboard對象的情況:

驗證子線程調用UIPasteboard的影響

圖12 子線程UIPasteboard測試代碼

如上圖所示,將之前100次的UIPasteboard操作放在子線程中,執行後App成功啓動,並得到如下輸出:

圖13 子線程UIPasteboard測試代碼執行輸出結果

從結果中可以看出,將上述複雜的UIPasteboard操作放入子線程的確可以使App啓動,且對UIPasteboard的操作耗時與在主線程中是一致的。也並未阻塞App啓動,反而很順利地進入了測試Demo的首頁。

由此可以看出單單在子線程反覆調用剪貼板的邏輯並不會使App卡主。那麼主線程與子線程同時調用UIPasteboard會有什麼影響呢?繼續測試

主線程與子線程同步頻繁調用UIPasteboard接口測試

圖14 主線程與子線程混合UIPasteboard測試代碼

同時開啓主線程和子線程來執行多次UIPasteboard操作,其中主線程執行50次,子線程執行50次。在最開始的測試中,在主線程進行100次UIPasteboard操作耗時一共50秒,現在我們將其中的一般轉移到了子線程那麼耗時應該減少一半。是這樣嗎?執行代碼驗證一下,輸出如下:

圖15 主線程與子線程混合UIPasteboard測試代碼執行輸出結果

這個結果在我們的意料之外。儘管將50次的UIPasteboard對象的操作放在了子線程,主線程僅執行了50次,但是單次執行時間又原來的0.5秒左右提高到了接近1秒。顯然系統對於剪貼板的操作是做了線程同步限制。總時間是不變的。

儘管我們知道了剪貼板是同步操作的,依然並未復現卡死的情況。那麼多線程併發到底會不會有可能觸發剪貼板的卡死呢?繼續驗證。

多線程併發頻繁調用UIPasteboard接口測試

圖16 多線程併發UIPasteboard測試代碼

創建1000個子線程併發任務,每個併發任務中獲取100次UIPasteboard對象。同時在主線程調用1次UIPasteboard的存取操作。執行後輸出如下:

圖17 多線程併發UIPasteboard測試代碼執行輸出結果

從輸出內容可以看出,子線程在執行了150次左右便不再執行下去了,通過上面的操作,成功將App卡死無法執行下去了。並且此時嘗試打開京東、騰訊視頻等其他App發現此時已經無法打開了,而且在所有App中使用剪貼板都會使App卡主。

所以多線程併發使用UIPasteboard相關接口的確會導致App卡死現象,並且會影響其他程序。

測試到這裏,我們瞭解到了系統對於UIPasteboard不但做了線程同步的限制,而且做了進程同步限制。在高併發使用UIPasteboard接口的情況下,很容易使UIPasteboard出現卡主的問題,並且影響整個系統的App。

那麼觸發100次UIPasteboard的OpenUDID對App會帶來多大的風險?

 

OpenUDID的影響

 

  • App的影響範圍

通過上述調研,OpenUDID會在第一次獲取OpenUDID值的時候訪問100次UIPasteboard的API。如果啓動時使用了OpenUDID等於間接使用了UIPasteboard,由於UIPasteboard是進程間同步的,當系統UIPasteboard被卡死時,App便無法啓動了。

另外,大家在使用OpenUDID的時候經常會把它私有化,尤其是在做SDK時,僅僅改個類名,然後便使用原有邏輯也是常有的事。出於用戶體驗優化的需要,很多開發者會將部分邏輯比如初始化等放在子線程執行。如果放到子線程的這部分邏輯首先訪問了私有OpenUDID代碼去獲取OpenUDID,就發生了子線程連續訪問UIPasteboard的情況。通常一個App會接入多個SDK,如果每個SDK都有一個OpenUDID,並且各自創建子線程訪問那麼就會發生併發訪問剪貼板的情況。如下圖所示:

圖18 OpenUDID多線程併發頻繁使用剪貼板

基於上述測試的結果,可以猜測:線程開的越多,則越有可能觸發剪貼板被卡死的情況。

爲了瞭解App無法啓動的情況,我們在啓動時添加了啓動異常計數的埋點策略,當啓動失敗次數達到3次時就進行埋點並上報。同時爲了優化用戶體驗,啓動失敗次數達到3次時則進入啓動修復頁面,提示用戶去重啓設備。通過對該策略的埋點數據分析,每天大約會有萬分之二的用戶會觸發連續三次啓動失敗的問題。雖然App啓動失敗還有許多其他的原因,但剪貼板卡死這個問題的影響應該還是比較大的。

  • OpenUDID的使用現狀調研

爲了進一步擴大對OpenUDID剪貼板問題的影響範圍的瞭解,對經常用到的SDK使用OpenUDID以及修復的情況進行了調研(由於SDK存在版本差異,實際情況可能與結果有些偏差):

SDK

OpenUDID類名

是否修改了

啓動註冊是否調用

滴滴打車

DIOpenUDID

訊飛

IFlyOpenUDID

微博

WBSDKOpenUDID

是(只修改了私有剪貼板標識,還是會訪問100次剪貼板)

否(但初始時會觸發一次剪貼板的調用)

支付寶

UTDIDOpenUDID

是(僅在App首次啓動時會觸發100次訪問剪貼板的邏輯)

微信

WXOMTAOpenUDID

是(不會觸發100次訪問剪貼板邏輯)

否(但初始化時會觸發一次剪貼板的調用)

頭條廣告

BUOpenUDID

是(不會觸發100次訪問剪貼板邏輯)

百度地圖

--

--

--

ZBar

--

--

--

聽雲

--

--

--

表 2 常用SDK OpenUDID使用情況

從以上的調研結果中看出,目前OpenUDID使用還是非常廣泛的,並且大多數情況下均保留了OpenUDID反覆使用UIPasteboard接口的邏輯。

爲了降低觸發UIPasteboard卡死的概率,可以拋棄剪貼板保存OpenUDID的邏輯,將OpenUDID的值保存在鑰匙串中。最初OpenUDID是利用系統自定義剪貼板,可以在不同App之前共享數據的特性來保證OpenUDID的值始終不變,但隨着iOS系統對此特性的封鎖,利用剪貼板保存OpenUDID反而會帶來問題。

 

總結

 

對此次App卡死問題調研,是基於一個個猜想而推動的,儘管它擁有非常低的概率復現,但我們還是循着蛛絲馬跡找到了他們之間可能存在的關聯,這裏從隱私數據獲取的崩潰日誌以及基於點對點的崩潰分析技術起到了關鍵的作用,然後通過一步步驗證,最終得出結論:

  1. 首先這個問題很可能是一個由系統UIPasteboard接口與OpenUDID開源代碼共同影響而引起的系統性問題。

  2. 因爲UIPasteboard的相關接口是進程間同步的,一旦UIPasteboard在某一極端情況下被卡死,所有App在主線程調用UIPasteboard接口的操作都會被卡死。所以應當避免在App生命週期函數中調用UIPasteboard接口的操作。

  3. OpenUDID最初的目的是在不同App中共享私有剪貼板來確保其UDID值唯一不變,但是隨着iOS系統對這一特性的封鎖,這段代碼已經失去了之前的意義。爲了降低系統剪貼板卡死的概率,建議修改OpenUDID關於剪貼板相關的這部分邏輯。

此次問題其實也是爲我們敲響了一次警鐘,對於第三方代碼的使用,充分掌握其原理以及潛在影響是十分必要的,尤其是使用缺乏維護的代碼時。隨着其運行環境的不斷迭代,其功能的穩定性也會受到影響,若放任不管則會便會在未來某一天帶來意想不到的問題。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章