保護你的crash

原文連接html

如何去衡量一款應用的質量好壞?爲了回答這一問題,APM這一目的性極強的工具向開發順應而生。最先的APM開發只關注於crashcpu這類的硬性指標。而隨着移動開發市場的成熟,愈來愈多的數據指標也被加入到了APM的採集範疇中,包括感官體驗相關的數據和使用習慣等。node

然而,不管APM最終如何發展,其最核心的採集指標必定是crash數據。一套完善的crash監控方案能夠快速的發現並協助完成問題定位,從而可以及時止損,避免更多的損失。而反過來講,若是crash不能及時被發現,又或者由於採集鏈中出現異常致使了數據丟失,對於開發者和公司來講,這都會是一個噩夢。linux

crash採集

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

  • mach exceptionc++

    mach異常由處理器陷阱引起,在異常發生後會被異常處理程序轉換成Mach消息,接着依次投遞到threadtaskhost端口。若是沒有一個端口處理這個異常並返回KERN_SUCCESS,那麼應用將被終止。每一個端口擁有一個異常端口數組,系統暴露了後綴爲_set_exception_ports的多個API讓咱們註冊對應的異常處理到端口中。git

    mach異常即使註冊了對應的處理,也不會致使影響原有的投遞流程。此外,即使不去註冊mach異常的處理,最終通過一系列的處理,mach異常會被轉換成對應的UNIX信號,一種mach異常對應了一個或者多個信號類型。所以在捕獲crash要提防二次採集的可能。github

  • NSExceptionapi

    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, &registered_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_handleprevious和更早以前的註冊可以被順利調起。

另外,hook還存在一個風險是假如第三方一樣作了hook掉註冊函數的處理,而且作了篩選處理,最終致使的結果是沒辦法完成任何一個註冊。兩害相較取其輕,我的的建議是使用週期性檢測方案。

最簡單的方式

上述的兩套方案都存在風險點,並且這些風險點對於應用來講都算是致命的。那麼有沒有幾乎沒有風險又能解決問題的辦法呢?答案是確定的,那就是不要用有潛在風險的第三方,或者和第三方開發者商量提供一個無需crash採集的版本。

在應用發生崩潰的時候,此時的崩潰所在線程是極不穩定的,不穩定性包括幾點:

  • 內存不穩定

    若是是內存相關錯誤引起的crash,好比內存過載、野指針等,此時線程的內存是危險狀態。若是這時候在handle中再次分配內存,極有可能致使二次crash

  • 死鎖

    大多數底層的的核心API會涉及到加鎖處理,這一狀況在signal錯誤中出現的較多。而做爲上層調用方的咱們是不自知的,此時錯誤的操做可能致使線程陷入死鎖狀態

理論上當咱們攔截了一個signal的時候,此時的應用會陷入內核並中止工做,應用頁面卡死,這時候咱們可執行時長是無限的。若是處理鏈過長,耗時過多或者陷入某種循環,會形成一種應用卡死而非崩潰的錯覺,而通過我廠大量的統計,應用卡死要比應用崩潰更讓人難以接受。此外,過多的處理鏈會增長回調流程上的風險點。若是鏈條上的某個點發生了二次崩潰,會致使後續的處理都沒法執行。所以,不用第三方或者讓第三方去除crash採集,是一種可行且高效的手段。

其餘

文中提到過一次如今比較流行的crash防禦手段,這裏仍是想說兩句。在開發中,crash防禦會形成依賴心理,下降對風險的敏感。而在線上,這種方案可能屏蔽了大量的低級錯誤,也是讓我不能容忍的,固然循環引用的防禦屬於例外。最後安利一波寒神的XXShield,除了容器類的防crash都值得學習,尤爲是正確的method swizzling姿式。

參考

Foundation

iOS異常捕獲

libc++ api spec

Linux信號處理機制

淺談Mach Exceptions

漫談iOS Crash收集框架

源碼剖析signal和sigaction的區別

iOS Crash捕獲及堆棧符號化思路剖析

關注個人公衆號獲取更新信息
相關文章
相關標籤/搜索