如何去衡量一款應用的質量好壞?為了回答這一問題,APM
這一目的性極強的工具向開發順應而生。最早的APM
開發只關注于crash
、cpu
這類的硬性指標。而隨著移動開發市場的成熟,越來越多的數據指標也被加入到了APM
的采集范疇中,包括感官體驗相關的數據和使用習慣等。
然而,無論APM
最終如何發展,其最核心的采集指標一定是crash
數據。一套完善的crash
監控方案可以快速的發現并協助完成問題定位,從而能夠及時止損,避免更多的損失。而反過來說,如果crash
不能及時被發現,又或者因為采集鏈中出現異常導致了數據丟失,對于開發者和公司來說,這都會是一個噩夢。
crash采集
細分之下,crash
分別存在mach exception
、signal
以及NSException
三種類型,每一種類型表示不同分層上的crash
,也擁有各自的捕獲方式。
-
mach exception
mach異常
由處理器陷阱引發,在異常發生后會被異常處理程序轉換成Mach消息
,接著依次投遞到thread
、task
和host
端口。如果沒有一個端口處理這個異常并返回KERN_SUCCESS
,那么應用將被終止。每個端口擁有一個異常端口數組
,系統暴露了后綴為_set_exception_ports
的多個API
讓我們注冊對應的異常處理到端口中。mach異常
即便注冊了對應的處理,也不會導致影響原有的投遞流程。此外,即便不去注冊mach異常
的處理,最終經過一系列的處理,mach異常
會被轉換成對應的UNIX信號
,一種mach異常
對應了一個或者多個信號類型。因此在捕獲crash
要提防二次采集的可能。 -
NSException
NSException
發生在CoreFoundation
以及更高抽象層,在CoreFoundation
層操作發生異常時,會通過__cxa_throw
函數拋出異常。在通過NSSetUncaughtExceptionHandler
注冊NSException
的捕獲函數之后,崩潰發生時會調用這個捕獲函數。但如果沒有任何函數去捕獲這個異常如果在捕獲函數中沒有進行操作終止應用,最終異常會通過abort()
來拋出一個SIGABRT
信號。由于
NSException
的抽象層次足夠高,相比較其他的crash
類型,NSException
是可以被人為的阻止crash
的。比如@try-catch
機制能夠捕獲塊中發生的異常,避免應用被殺死。但由于try-catch
的開銷和回報不成正比,往往不會使用這種機制。其二是crash防護
,這一手段通過hook
掉上層接口來規避crash
風險,但是只建議用于線上防護,而且hook
未必不會導致其他的問題。 -
signal
signa
會導致crash
,這是多數iOS
開發者對于信號的印象。傳遞crash
信息其實只是信號的一部分功能,信號是一套基于POSIX標準
開發的通信機制,具體可以閱讀Signal-wikipedia。在signal.h
中聲明了32
種異常信號,下面列出一部分的信號異常對:信號 異常 SIGILL 執行了非法指令,一般是可執行文件出現了錯誤 SIGTRAP 斷點指令或者其他trap指令產生 SIGABRT 調用abort產生 SIGBUS 非法地址。比如錯誤的內存類型訪問、內存地址對齊等 SIGSEGV 非法地址。訪問未分配內存、寫入沒有寫權限的內存等 SIGFPE 致命的算術運算。比如數值溢出、NaN數值等 雖然存在三種
crash
,但由于mach exception
會在BSD
層被轉換成UNIX信號
,NSException
在未被捕獲的情況下會調用abort
拋出信號,因此即便是我們只注冊了signal
的處理,只要注冊的signal
足夠多,理論上也是能捕獲到全部的crash
。
采集沖突
由于crash
的捕獲機制只會保存最后一個注冊的handle
,因此如果項目中殘留或者存在另外的第三方框架采集crash
信息時,經常性的會存在沖突。解決沖突的做法是在注冊自己的handle
之前保存已注冊的處理函數,便于發生崩潰后能將crash
信息連續的傳遞下去。
struct sigaction my_action;
static struct sigaction registered_action;
static NSUncaughtExceptionHandler *previousHandle;
void signal_handler(int signal) {
......
}
void exception_handler(NSException *exception) {
......
}
void registerCrashHandle() {
previousHandle = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&exception_handler);
myAction.sa_handler = &signal_handler;
sigemptyset(&my_action.sa_mask);
sigaction(SIGABRT, &my_action, ®istered_action);
}
一般來說,一個經驗豐富的開發者在注冊crash
回調時都會主動的去保存其他函數,避免因為沖突導致別人的數據丟失。但是即便按照這樣的方式來注冊你的回調,也不代表我們的處理函數是安全的。最重要的原因在于完成回調的注冊之后,我們無法保證后續會不會有其他人繼續注冊,如果有就會存在被替換掉的風險
解決方案
按照正常方式的做法,能保證先于我們注冊的crash
回調不會被我們攔截導致失敗,但如果在我們后方存在另外的注冊,我們需要一個有效的機制來保護我們的采集數據。解決問題的收益是不變的,所以解決方案理當盡可能的低開銷和低風險。
如何去判斷我們的handle
是否安全?這要求我們對已注冊的handle
進行檢測。首先檢測時機要選擇在哪?由于crash
是可能發生在應用啟動階段的,因此crash
采集一般也是發生在didLaunch
這個時間,下圖是我繪制的應用啟動到完全啟動的幾個重要階段:
applicationActive
這個階段基本上是能保證crash
相關的注冊都完成的,因此沖突檢測可以放到這個階段進行。
周期性檢測
利用已有的周期性機制或者使用定時器來進行handle
沖突檢測。可以分別使用通知
和定時器
兩個機制來完成周期性檢測方案
-
監聽應用狀態
監聽
UIApplicationDidBecomeActiveNotification
在應用進入活躍狀態時做檢測:- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... [[NSNotificationCenter defaultCenter] addObserver: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) name: UIApplicationDidBecomeActiveNotification object: nil]; ...... } static struct sigaction existActions[32]; static int fatal_signals[] = { SIGILL, SIGBUS, SIGABRT, SIGPIPE, }; - (void)checkRegisterCrashHandler { struct sigaction oldAction; for (int idx = 0; idx < sizeof(fatal_signals) / sizeof(int); idx++) { sigaction(fatal_signals[idx], NULL, &oldAction); if (oldAction.sa_handler != &signal_handler) { existActions[fatal_signals[idx]] = oldAction; struct sigaction myAction; myAction.sa_handler = &signal_handler; sigemptyset(&myAction.sa_mask); sigaction(SIGABRT, &myAction, NULL); } } }
-
定時器檢測
創建定時器來進行周期性的檢測,相比通知的機制,可以控制檢測間隔:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... NSTimer *timer = [[NSTimer alloc] initWithFireDate: [NSDate date] interval: 30 target: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) userInfo: nil repeats: YES]; [[NSRunLoop currentRunLoop] addTimer: timer forMode: NSRunLoopCommonModes]; [timer fire]; ...... }
hook注冊函數
通過hook
調用注冊handle
的對應函數,建立一個回調數組來保存非exception_handle
的所有回調,后續處理完我們的采集,再逐個調起。由于捕獲函數都是基于C
接口的,因此我們需要fishhook來提供相應的hook
功能。
struct SignalHandler {
void (*signal_handler)(int);
struct SignalHandler *next;
}
struct SignalHandler *previousHandlers[32];
void append(struct SignalHandler *handlers, struct SignalHandler *node) {
......
}
static int (*origin_sigaction)(int, const struct sigaction *__restrict, struct sigaction * __restrict) = NULL;
int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
if (new_action.sa_handler != signal_handler) {
append(previousHandlers[signal], new_action);
return origin_sigaction(signal, NULL, old_action);
} else {
return origin_sigaction(signal, new_action, old_action);
}
}
風險
在周期性檢測的方案下,假設存在handle
注冊鏈(依次從左到右):
previous
<- exception_handle
<- other
在檢測時發現當前回調是other
,于是重新注冊我們的回調,保存other
。但是假如other
也保存了我們的回調,這樣可能會導致崩潰發生的時候,調用順序變成一個死循環。
hook
方案則是因為在調用origin_sigaction
時會傳入old_action
,可能導致另外的注冊者保存了我們的exception_handle
,并在最后處理的時候出現同樣的循環調用問題。對于hook
方案來說,解決方法要簡單很多,只需要在非我們的注冊調用origin_sigaction
時不傳入old_action
就能保證其他注冊者無法獲取到我們的回調:
int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
if (new_action.sa_handler != signal_handler) {
append(previousHandlers[signal], new_action);
return origin_sigaction(signal, NULL, NULL);
} else {
return origin_sigaction(signal, new_action, old_action);
}
}
而使用周期性監測,就需要考慮是否放棄other
的回調,最終只保證exception_handle
和previous
和更早之前的注冊能夠被順利調起。
另外,hook
還存在一個風險是假如第三方同樣做了hook
掉注冊函數的處理,并且做了篩選處理,最終導致的結果是沒辦法完成任何一個注冊。兩害相較取其輕,個人的建議是使用周期性檢測方案。
最簡單的方式
上述的兩套方案都存在風險點,而且這些風險點對于應用來說都算是致命的。那么有沒有幾乎沒有風險又能解決問題的辦法呢?答案是肯定的,那就是不要用有潛在風險的第三方,或者和第三方開發者商量提供一個無需crash
采集的版本。
在應用發生崩潰的時候,此時的崩潰所在線程
是極不穩定的,不穩定性包括幾點:
-
內存不穩定
如果是內存相關錯誤引發的
crash
,比如內存過載、野指針等,此時線程的內存是危險狀態。如果這時候在handle
中再次分配內存,極有可能導致二次crash
-
死鎖
大多數底層的的核心
API
會涉及到加鎖處理,這一情況在signal
錯誤中出現的較多。而作為上層調用方的我們是不自知的,此時錯誤的操作可能導致線程陷入死鎖狀態
理論上當我們攔截了一個signal
的時候,此時的應用會陷入內核并停止工作,應用頁面卡死,這時候我們可執行時長是無限的。如果處理鏈過長,耗時過多或者陷入某種循環,會造成一種應用卡死而非崩潰的錯覺,而經過我廠大量的統計,應用卡死
要比應用崩潰
更讓人難以接受。此外,過多的處理鏈會增加回調流程上的風險點。如果鏈條上的某個點發生了二次崩潰,會導致后續的處理都無法執行。因此,不用第三方或者讓第三方去除crash
采集,是一種可行且高效的手段。
其他
文中提到過一次現在比較流行的crash防護
手段,這里還是想說兩句。在開發中,crash防護
會造成依賴心理,降低對風險的敏感。而在線上,這種方案可能屏蔽了大量的低級錯誤,也是讓我不能容忍的,當然循環引用的防護屬于例外。最后安利一波寒神的XXShield,除了容器類的防crash
都值得學習,尤其是正確的method swizzling
姿勢。