獲取任意線程調用棧的那些事

BSBacktraceLogger 是一個輕量級的框架,能夠獲取任意線程的調用棧,開源在個人 GitHub,建議下載下來結合本文閱讀。php

咱們知道 NSThread 有一個類方法 callstackSymbols 能夠獲取調用棧,可是它輸出的是當前線程的調用棧。在利用 Runloop 檢測卡頓時,子線程檢測到了主線程發生卡頓,須要經過主線程的調用棧來分析具體是哪一個方法致使了阻塞,這時系統提供的方法就無能爲力了。git

最簡單、天然的想法就是利用 dispatch_asyncperformSelectorOnMainThread 等方法,回到主線程並獲取調用棧。不用說也能猜到這種想法並不可行,不然就沒有寫做本文的必要了。github

這篇文章的重點不是介紹獲取調用棧的細節,而是在實現過程當中的遇到的諸多問題和嘗試過的解決方案。有的方案也許不能解決問題,但在思考的過程當中可以把知識點串聯起來,在我看來這纔是本文最大的價值。編程

在介紹後續知識以前,有必要介紹一下調用棧的相關背景知識。swift

調用棧

首先聊聊棧,它是每一個線程獨享的一種數據結構。借用維基百科上的一張圖片:數組

調用棧示意圖

上圖表示了一個棧,它分爲若干棧幀(frame),每一個棧幀對應一個函數調用,好比藍色的部分是 DrawSquare 函數的棧幀,它在執行的過程當中調用了 DrawLine 函數,棧幀用綠色表示。微信

能夠看到棧幀由三部分組成:函數參數,返回地址,幀內的變量。舉個例子,在調用 DrawLine 函數時首先把函數的參數入棧,這是第一部分;隨後將返回地址入棧,這表示當前函數執行完後回到哪裏繼續執行;在函數內部定義的變量則屬於第三部分。數據結構

Stack Pointer(棧指針)表示當前棧的頂部,因爲大部分操做系統的棧向下生長,它實際上是棧地址的最小值。根據以前的解釋,Frame Pointer 指向的地址中,存儲了上一次 Stack Pointer 的值,也就是返回地址。架構

在大多數操做系統中,每一個棧幀還保存了上一個棧幀的 Frame Pointer,所以只要知道當前棧幀的 Stack Pointer 和 Frame Pointer,就能知道上一個棧幀的 Stack Pointer 和 Frame Pointer,從而遞歸的獲取棧底的幀。框架

顯然當一個函數調用結束時,它的棧幀就不存在了。

所以,調用棧實際上是棧的一種抽象概念,它表示了方法之間的調用關係,通常來講從棧中能夠解析出調用棧。

失敗的傳統方法

最初的想法很簡單,既然 callstackSymbols 只能獲取當前線程的調用棧,那在目標線程調用就能夠了。好比 dispatch_async 到主隊列,或者 performSelector 系列,更不用說還能夠用 Block 或者代理等方法。

咱們以 UIViewControllerviewDidLoad 方法爲例,推測它底層都發生了什麼。

首先主線程也是線程,就得按照線程基本法來辦事。線程基本法說的是首先要把線程運行起來,而後(若是有必要,好比主線程)啓動 runloop 進行保活。咱們知道 runloop 的本質就是一個死循環,在循環中調用多個函數,分別判斷 source0、source一、timer、dispatch_queue 等事件源有沒有要處理的內容。

和 UI 相關的事件都是 source0,所以會執行 __CFRunLoopDoSources0,最終一步步走到 viewDidLoad。當事件處理完後 runloop 進入休眠狀態。

假設咱們使用 dispatch_async,它會喚醒 runloop 並處理事件,但此時 __CFRunLoopDoSources0 已經執行完畢,不可能獲取到 viewDidLoad 的調用棧。

performSelector 系列方法的底層也依賴於 runloop,所以它只是像當前的 runloop 提交了一個任務,可是依然要等待現有任務完成之後才能執行,因此拿不到實時的調用棧。

總而言之,一切涉及到 runloop,或者須要等待 viewDidLoad 執行完的方案都不可能成功。

信號

要想不依賴於 viewDidLoad 完成,並在主線程執行代碼,只能從操做系統層面入手。我嘗試了使用信號(Signal)來實現,

信號實際上是一種軟中斷,也是由系統的中斷處理程序負責處理。在處理信號時,操做系統會保存正在執行的上下文,好比寄存器的值,當前指令等,而後處理信號,處理完成後再恢復執行上下文。

所以從理論上來講,信號能夠強制讓目標線程停下,處理信號再恢復。通常狀況下發送信號是針對整個進程的,任何線程均可以接受並處理,也能夠用 pthread_kill() 向指定線程發送某個信號。

信號的處理能夠用 signal 或者 sigaction 來實現,前者比較簡單,後者功能更增強大。

好比咱們運行程序後按下 Ctrl + C 實際上就是發出了 SIGINT 信號,如下代碼能夠在按下 Ctrl + C 時作一些輸出並避免程序退出:

void sig_handler(int signum) {
    printf("Received signal %d\n", signum);
}

void main() {
    signal(SIGINT, sig_handler);
}複製代碼

遺憾的是,使用pthread_kill() 發出的信號彷佛沒法被上述方法正確處理,查閱各類資料無果後放棄此思路。但至今任然以爲這是可行的,若是有人知道還望指正。

Mach_thread

回憶以前對棧的介紹,只要知道 StackPointer 和 FramePointer 就能夠徹底肯定一個棧的信息,那有沒有辦法拿到全部線程的 StackPointer 和 FramePointer 呢?

答案是確定的,首先系統提供了 task_threads 方法,能夠獲取到全部的線程,注意這裏的線程是最底層的 mach 線程,它和 NSThread 的關係稍後會詳細闡述。

對於每個線程,能夠用 thread_get_state 方法獲取它的全部信息,信息填充在 _STRUCT_MCONTEXT 類型的參數中。這個方法中有兩個參數隨着 CPU 架構的不一樣而改變,所以我定義了 BS_THREAD_STATE_COUNTBS_THREAD_STATE 這兩個宏用於屏蔽不一樣 CPU 之間的區別。

_STRUCT_MCONTEXT 類型的結構體中,存儲了當前線程的 Stack Pointer 和最頂部棧幀的 Frame Pointer,從而獲取到了整個線程的調用棧。

在項目中,調用棧存儲在 backtraceBuffer 數組中,其中每個指針對應了一個棧幀,每一個棧幀又對應一個函數調用,而且每一個函數都有本身的符號名。

接下來的任務就是根據棧幀的 Frame Pointer 獲取到這個函數調用的符號名。

符號解析

就像 「把大象關進冰箱須要幾步」 同樣,獲取 Frame Pointer 對應的符號名也能夠分爲如下幾步:

  1. 根據 Frame Pointer 找到函數調用的地址
  2. 找到 Frame Pointer 屬於哪一個鏡像文件
  3. 找到鏡像文件的符號表
  4. 在符號表中找到函數調用地址對應的符號名

這實際上都是 C 語言編程問題,我沒有相關經驗,不過好在有前人的研究成果能夠借鑑。感興趣的讀者能夠直接閱讀源碼。

揭祕 NSThread

根據上述分析,咱們能夠獲取到全部線程以及他們的調用堆棧,但若是想單獨獲取某個線程的堆棧呢?問題在於,如何創建 NSThread 線程和內核線程之間的聯繫。

再次 Google 無果後,我找到了 GNUStep-base 的源碼,下載了 1.24.9 版本,其中包含了 Foundation 庫的源碼,我不能確保如今的 NSThread 徹底採用這裏的實現,但至少能夠從 NSThread.m 類中挖掘出不少有用信息。

NSThread 的封裝層級

不少文章都提到了 NSThread 是 pthread 的封裝,這就涉及兩個問題:

  1. pthread 是什麼
  2. NSThread 如何封裝 pthread

pthread 中的字母 p 是 POSIX 的簡寫,POSIX 表示 「可移植操做系統接口(Portable Operating System Interface)」。

每一個操做系統都有本身的線程模型,不一樣操做系統提供的,操做線程的 API 也不同,這就給跨平臺的線程管理帶來了問題,而 POSIX 的目的就是提供抽象的 pthread 以及相關 API,這些 API 在不一樣操做系統中有不一樣的實現,可是完成的功能一致。

Unix 系統提供的 thread_get_statetask_threads 等方法,操做的都是內核線程,每一個內核線程由 thread_t 類型的 id 來惟一標識,pthread 的惟一標識是 pthread_t 類型。

內核線程和 pthread 的轉換(也便是 thread_tpthread_t 互轉)很容易,由於 pthread 誕生的目的就是爲了抽象內核線程。

說 NSThread 封裝了 pthread 並非很準確,NSThread 內部只有不多的地方用到了 pthread。NSThread 的 start 方法簡化版實現以下:

- (void) start {
  pthread_attr_t    attr;
  pthread_t        thr;
  errno = 0;
  pthread_attr_init(&attr);
  if (pthread_create(&thr, &attr, nsthreadLauncher, self)) {
      // Error Handling
  }
}複製代碼

甚至於 NSThread 都沒有存儲新建 pthread 的 pthread_t 標識。

另外一處用到 pthread 的地方就是 NSThread 在退出時,調用了 pthread_exit()。除此之外就不多感覺到 pthread 的存在感了,所以我的認爲 「NSThread 是對 pthread 的封裝」 這種說法並不許確。

PerformSelectorOn

實際上全部的 performSelector系列最終都會走到下面這個全能函數:

- (void) performSelector: (SEL)aSelector
                onThread: (NSThread*)aThread
              withObject: (id)anObject
           waitUntilDone: (BOOL)aFlag
                   modes: (NSArray*)anArray;複製代碼

而它僅僅是一個封裝,根據線程獲取到 runloop,真正調用的仍是 NSRunloop 的方法:

- (void) performSelector: (SEL)aSelector
          target: (id)target
        argument: (id)argument
           order: (NSUInteger)order
           modes: (NSArray*)modes{}複製代碼

這些信息將組成一個 Performer 對象放進 runloop 等待執行。

NSThread 轉內核 thread

因爲系統沒有提供相應的轉換方法,並且 NSThread 沒有保留線程的 pthread_t,因此常規手段沒法知足需求。

一種思路是利用 performSelector 方法在指定線程執行代碼並記錄 thread_t,執行代碼的時機不能太晚,若是在打印調用棧時才執行就會破壞調用棧。最好的方法是在線程建立時執行,上文提到了利用 pthread_create 方法建立線程,它的回調函數 nsthreadLauncher 實現以下:

static void *nsthreadLauncher(void* thread)
{
    NSThread *t = (NSThread*)thread;
    [nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil];
    [t _setName: [t name]];
    [t main];
    [NSThread exit];
    return NULL;
}複製代碼

很神奇的發現系統竟然會發送一個通知,通知名不對外提供,可是能夠經過監聽全部通知名的方法得知它的名字: @"_NSThreadDidStartNotification",因而咱們能夠監聽這個通知並調用 performSelector 方法。

通常 NSThread 使用 initWithTarget:Selector:object 方法建立。在 main 方法中 selector 會被執行,main 方法執行結束後線程就會退出。若是想作線程保活,須要在傳入的 selector 中開啓 runloop,詳見個人這篇文章: 深刻研究 Runloop 與線程保活

可見,這種方案並不現實,由於以前已經解釋過,performSelector 依賴於 runloop 開啓,而 runloop 直到 main 方法纔有可能開啓。

回顧問題發現,咱們須要的是一個聯繫 NSThread 對象和內核 thread 的紐帶,也就是說要找到 NSThread 對象的某個惟一值,並且內核 thread 也具備這個惟一值。

觀察一下 NSThread,它的惟一值只有對象地址,對象序列號(Sequence Number) 和線程名稱:

<NSThread: 0x144d095e0>{number = 1, name = main}複製代碼

地址分配在堆上,沒有使用意義,序列號的計算沒有看懂,所以只剩下 name。幸運的是 pthread 也提供了一個方法 pthread_getname_np 來獲取線程的名字,二者是一致的,感興趣的讀者能夠自行閱讀 setName 方法的實現,它調用的就是 pthread 提供的接口。

這裏的 np 表示 not POSIX,也就是說它並不能跨平臺使用。

因而解決方案就很簡單了,對於 NSThread 參數,把它的名字改成某個隨機數(我選擇了時間戳),而後遍歷 pthread 並檢查有沒有匹配的名字。查找完成後把參數的名字恢復便可。

主線程轉內核 thread

原本覺得問題已經圓滿解決,不料還有一個坑,主線程設置 name 後沒法用 pthread_getname_np 讀取到。

好在咱們還能夠迂迴解決問題: 事先得到主線程的 thread_t,而後進行比對。

上述方案要求咱們在主線程中執行代碼從而得到 thread_t,顯然最好的方案是在 load 方法裏:

static mach_port_t main_thread_id;
+ (void)load {
    main_thread_id = mach_thread_self();
}複製代碼

總結

以上就是 BSBacktraceLogger 的所有分析,它只有一個類,400行代碼,所以還算是比較簡單。然而 NSThread、NSRunloop 以及 GCD 的源碼着實值得反覆研究、閱讀。

完成一個技術項目每每最大的收穫不是最後的結果,而是實現過程當中的思考。這些走過的彎路加深了對知識體系的理解。

關注與訂閱

搜索 「iOSZhaZha」 關注微信公衆號,第一時間得到更新。

參考資料

  1. Call Stack
  2. KSCrash
  3. 深刻理解RunLoop
  4. iOS中線程Call Stack的捕獲和解析(一)iOS中線程Call Stack的捕獲和解析(二)
相關文章
相關標籤/搜索