質量監控-保護你的crash

原文地址

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

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

crash采集

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

  • mach exception

    mach異常由處理器陷阱引發,在異常發生后會被異常處理程序轉換成Mach消息,接著依次投遞到threadtaskhost端口。如果沒有一個端口處理這個異常并返回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, &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捕獲及堆棧符號化思路剖析

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容