質(zhì)量監(jiān)控-保護(hù)你的crash

原文地址

如何去衡量一款應(yīng)用的質(zhì)量好壞?為了回答這一問題,APM這一目的性極強(qiáng)的工具向開發(fā)順應(yīng)而生。最早的APM開發(fā)只關(guān)注于crashcpu這類的硬性指標(biāo)。而隨著移動(dòng)開發(fā)市場(chǎng)的成熟,越來越多的數(shù)據(jù)指標(biāo)也被加入到了APM的采集范疇中,包括感官體驗(yàn)相關(guān)的數(shù)據(jù)和使用習(xí)慣等。

然而,無論APM最終如何發(fā)展,其最核心的采集指標(biāo)一定是crash數(shù)據(jù)。一套完善的crash監(jiān)控方案可以快速的發(fā)現(xiàn)并協(xié)助完成問題定位,從而能夠及時(shí)止損,避免更多的損失。而反過來說,如果crash不能及時(shí)被發(fā)現(xiàn),又或者因?yàn)椴杉溨谐霈F(xiàn)異常導(dǎo)致了數(shù)據(jù)丟失,對(duì)于開發(fā)者和公司來說,這都會(huì)是一個(gè)噩夢(mèng)。

crash采集

細(xì)分之下,crash分別存在mach exceptionsignal以及NSException三種類型,每一種類型表示不同分層上的crash,也擁有各自的捕獲方式。

  • mach exception

    mach異常由處理器陷阱引發(fā),在異常發(fā)生后會(huì)被異常處理程序轉(zhuǎn)換成Mach消息,接著依次投遞到threadtaskhost端口。如果沒有一個(gè)端口處理這個(gè)異常并返回KERN_SUCCESS,那么應(yīng)用將被終止。每個(gè)端口擁有一個(gè)異常端口數(shù)組,系統(tǒng)暴露了后綴為_set_exception_ports的多個(gè)API讓我們注冊(cè)對(duì)應(yīng)的異常處理到端口中。

    mach異常即便注冊(cè)了對(duì)應(yīng)的處理,也不會(huì)導(dǎo)致影響原有的投遞流程。此外,即便不去注冊(cè)mach異常的處理,最終經(jīng)過一系列的處理,mach異常會(huì)被轉(zhuǎn)換成對(duì)應(yīng)的UNIX信號(hào),一種mach異常對(duì)應(yīng)了一個(gè)或者多個(gè)信號(hào)類型。因此在捕獲crash要提防二次采集的可能。

  • NSException

    NSException發(fā)生在CoreFoundation以及更高抽象層,在CoreFoundation層操作發(fā)生異常時(shí),會(huì)通過__cxa_throw函數(shù)拋出異常。在通過NSSetUncaughtExceptionHandler注冊(cè)NSException的捕獲函數(shù)之后,崩潰發(fā)生時(shí)會(huì)調(diào)用這個(gè)捕獲函數(shù)。但如果沒有任何函數(shù)去捕獲這個(gè)異常 如果在捕獲函數(shù)中沒有進(jìn)行操作終止應(yīng)用,最終異常會(huì)通過abort()來拋出一個(gè)SIGABRT信號(hào)。

    由于NSException的抽象層次足夠高,相比較其他的crash類型,NSException是可以被人為的阻止crash的。比如@try-catch機(jī)制能夠捕獲塊中發(fā)生的異常,避免應(yīng)用被殺死。但由于try-catch的開銷和回報(bào)不成正比,往往不會(huì)使用這種機(jī)制。其二是crash防護(hù),這一手段通過hook掉上層接口來規(guī)避crash風(fēng)險(xiǎn),但是只建議用于線上防護(hù),而且hook未必不會(huì)導(dǎo)致其他的問題。

  • signal

    signa會(huì)導(dǎo)致crash,這是多數(shù)iOS開發(fā)者對(duì)于信號(hào)的印象。傳遞crash信息其實(shí)只是信號(hào)的一部分功能,信號(hào)是一套基于POSIX標(biāo)準(zhǔn)開發(fā)的通信機(jī)制,具體可以閱讀Signal-wikipedia。在signal.h中聲明了32種異常信號(hào),下面列出一部分的信號(hào)異常對(duì):

    信號(hào) 異常
    SIGILL 執(zhí)行了非法指令,一般是可執(zhí)行文件出現(xiàn)了錯(cuò)誤
    SIGTRAP 斷點(diǎn)指令或者其他trap指令產(chǎn)生
    SIGABRT 調(diào)用abort產(chǎn)生
    SIGBUS 非法地址。比如錯(cuò)誤的內(nèi)存類型訪問、內(nèi)存地址對(duì)齊等
    SIGSEGV 非法地址。訪問未分配內(nèi)存、寫入沒有寫權(quán)限的內(nèi)存等
    SIGFPE 致命的算術(shù)運(yùn)算。比如數(shù)值溢出、NaN數(shù)值等

    雖然存在三種crash,但由于mach exception會(huì)在BSD層被轉(zhuǎn)換成UNIX信號(hào)NSException在未被捕獲的情況下會(huì)調(diào)用abort拋出信號(hào),因此即便是我們只注冊(cè)了signal的處理,只要注冊(cè)的signal足夠多,理論上也是能捕獲到全部的crash

采集沖突

由于crash的捕獲機(jī)制只會(huì)保存最后一個(gè)注冊(cè)的handle,因此如果項(xiàng)目中殘留或者存在另外的第三方框架采集crash信息時(shí),經(jīng)常性的會(huì)存在沖突。解決沖突的做法是在注冊(cè)自己的handle之前保存已注冊(cè)的處理函數(shù),便于發(fā)生崩潰后能將crash信息連續(xù)的傳遞下去。

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, &registered_action);
}

一般來說,一個(gè)經(jīng)驗(yàn)豐富的開發(fā)者在注冊(cè)crash回調(diào)時(shí)都會(huì)主動(dòng)的去保存其他函數(shù),避免因?yàn)闆_突導(dǎo)致別人的數(shù)據(jù)丟失。但是即便按照這樣的方式來注冊(cè)你的回調(diào),也不代表我們的處理函數(shù)是安全的。最重要的原因在于完成回調(diào)的注冊(cè)之后,我們無法保證后續(xù)會(huì)不會(huì)有其他人繼續(xù)注冊(cè),如果有就會(huì)存在被替換掉的風(fēng)險(xiǎn)

解決方案

按照正常方式的做法,能保證先于我們注冊(cè)的crash回調(diào)不會(huì)被我們攔截導(dǎo)致失敗,但如果在我們后方存在另外的注冊(cè),我們需要一個(gè)有效的機(jī)制來保護(hù)我們的采集數(shù)據(jù)。解決問題的收益是不變的,所以解決方案理當(dāng)盡可能的低開銷和低風(fēng)險(xiǎn)。

如何去判斷我們的handle是否安全?這要求我們對(duì)已注冊(cè)的handle進(jìn)行檢測(cè)。首先檢測(cè)時(shí)機(jī)要選擇在哪?由于crash是可能發(fā)生在應(yīng)用啟動(dòng)階段的,因此crash采集一般也是發(fā)生在didLaunch這個(gè)時(shí)間,下圖是我繪制的應(yīng)用啟動(dòng)到完全啟動(dòng)的幾個(gè)重要階段:

applicationActive這個(gè)階段基本上是能保證crash相關(guān)的注冊(cè)都完成的,因此沖突檢測(cè)可以放到這個(gè)階段進(jìn)行。

周期性檢測(cè)

利用已有的周期性機(jī)制或者使用定時(shí)器來進(jìn)行handle沖突檢測(cè)。可以分別使用通知定時(shí)器兩個(gè)機(jī)制來完成周期性檢測(cè)方案

  • 監(jiān)聽?wèi)?yīng)用狀態(tài)

    監(jiān)聽UIApplicationDidBecomeActiveNotification在應(yīng)用進(jìn)入活躍狀態(tài)時(shí)做檢測(cè):

      - (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);
              }
          }
      }
    
  • 定時(shí)器檢測(cè)

    創(chuàng)建定時(shí)器來進(jìn)行周期性的檢測(cè),相比通知的機(jī)制,可以控制檢測(cè)間隔:

      - (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注冊(cè)函數(shù)

通過hook調(diào)用注冊(cè)handle的對(duì)應(yīng)函數(shù),建立一個(gè)回調(diào)數(shù)組來保存非exception_handle的所有回調(diào),后續(xù)處理完我們的采集,再逐個(gè)調(diào)起。由于捕獲函數(shù)都是基于C接口的,因此我們需要fishhook來提供相應(yīng)的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);
    }
}

風(fēng)險(xiǎn)

在周期性檢測(cè)的方案下,假設(shè)存在handle注冊(cè)鏈(依次從左到右):

previous <- exception_handle <- other

在檢測(cè)時(shí)發(fā)現(xiàn)當(dāng)前回調(diào)是other,于是重新注冊(cè)我們的回調(diào),保存other。但是假如other也保存了我們的回調(diào),這樣可能會(huì)導(dǎo)致崩潰發(fā)生的時(shí)候,調(diào)用順序變成一個(gè)死循環(huán)。

hook方案則是因?yàn)樵谡{(diào)用origin_sigaction時(shí)會(huì)傳入old_action,可能導(dǎo)致另外的注冊(cè)者保存了我們的exception_handle,并在最后處理的時(shí)候出現(xiàn)同樣的循環(huán)調(diào)用問題。對(duì)于hook方案來說,解決方法要簡單很多,只需要在非我們的注冊(cè)調(diào)用origin_sigaction時(shí)不傳入old_action就能保證其他注冊(cè)者無法獲取到我們的回調(diào):

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);
    }
}

而使用周期性監(jiān)測(cè),就需要考慮是否放棄other的回調(diào),最終只保證exception_handleprevious和更早之前的注冊(cè)能夠被順利調(diào)起。

另外,hook還存在一個(gè)風(fēng)險(xiǎn)是假如第三方同樣做了hook掉注冊(cè)函數(shù)的處理,并且做了篩選處理,最終導(dǎo)致的結(jié)果是沒辦法完成任何一個(gè)注冊(cè)。兩害相較取其輕,個(gè)人的建議是使用周期性檢測(cè)方案。

最簡單的方式

上述的兩套方案都存在風(fēng)險(xiǎn)點(diǎn),而且這些風(fēng)險(xiǎn)點(diǎn)對(duì)于應(yīng)用來說都算是致命的。那么有沒有幾乎沒有風(fēng)險(xiǎn)又能解決問題的辦法呢?答案是肯定的,那就是不要用有潛在風(fēng)險(xiǎn)的第三方,或者和第三方開發(fā)者商量提供一個(gè)無需crash采集的版本。

在應(yīng)用發(fā)生崩潰的時(shí)候,此時(shí)的崩潰所在線程是極不穩(wěn)定的,不穩(wěn)定性包括幾點(diǎn):

  • 內(nèi)存不穩(wěn)定

    如果是內(nèi)存相關(guān)錯(cuò)誤引發(fā)的crash,比如內(nèi)存過載、野指針等,此時(shí)線程的內(nèi)存是危險(xiǎn)狀態(tài)。如果這時(shí)候在handle中再次分配內(nèi)存,極有可能導(dǎo)致二次crash

  • 死鎖

    大多數(shù)底層的的核心API會(huì)涉及到加鎖處理,這一情況在signal錯(cuò)誤中出現(xiàn)的較多。而作為上層調(diào)用方的我們是不自知的,此時(shí)錯(cuò)誤的操作可能導(dǎo)致線程陷入死鎖狀態(tài)

理論上當(dāng)我們攔截了一個(gè)signal的時(shí)候,此時(shí)的應(yīng)用會(huì)陷入內(nèi)核并停止工作,應(yīng)用頁面卡死,這時(shí)候我們可執(zhí)行時(shí)長是無限的。如果處理鏈過長,耗時(shí)過多或者陷入某種循環(huán),會(huì)造成一種應(yīng)用卡死而非崩潰的錯(cuò)覺,而經(jīng)過我廠大量的統(tǒng)計(jì),應(yīng)用卡死要比應(yīng)用崩潰更讓人難以接受。此外,過多的處理鏈會(huì)增加回調(diào)流程上的風(fēng)險(xiǎn)點(diǎn)。如果鏈條上的某個(gè)點(diǎn)發(fā)生了二次崩潰,會(huì)導(dǎo)致后續(xù)的處理都無法執(zhí)行。因此,不用第三方或者讓第三方去除crash采集,是一種可行且高效的手段。

其他

文中提到過一次現(xiàn)在比較流行的crash防護(hù)手段,這里還是想說兩句。在開發(fā)中,crash防護(hù)會(huì)造成依賴心理,降低對(duì)風(fēng)險(xiǎn)的敏感。而在線上,這種方案可能屏蔽了大量的低級(jí)錯(cuò)誤,也是讓我不能容忍的,當(dāng)然循環(huán)引用的防護(hù)屬于例外。最后安利一波寒神的XXShield,除了容器類的防crash都值得學(xué)習(xí),尤其是正確的method swizzling姿勢(shì)。

參考

Foundation

iOS異常捕獲

libc++ api spec

Linux信號(hào)處理機(jī)制

淺談Mach Exceptions

漫談iOS Crash收集框架

源碼剖析signal和sigaction的區(qū)別

iOS Crash捕獲及堆棧符號(hào)化思路剖析

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容