獲取任意執行緒呼叫棧的那些事

bestswifter發表於2019-03-04

BSBacktraceLogger 是一個輕量級的框架,可以獲取任意執行緒的呼叫棧,開源在我的 GitHub,建議下載下來結合本文閱讀。

我們知道 NSThread 有一個類方法 callstackSymbols 可以獲取呼叫棧,但是它輸出的是當前執行緒的呼叫棧。在利用 Runloop 檢測卡頓時,子執行緒檢測到了主執行緒發生卡頓,需要通過主執行緒的呼叫棧來分析具體是哪個方法導致了阻塞,這時系統提供的方法就無能為力了。

最簡單、自然的想法就是利用 dispatch_asyncperformSelectorOnMainThread 等方法,回到主執行緒並獲取呼叫棧。不用說也能猜到這種想法並不可行,否則就沒有寫作本文的必要了。

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

在介紹後續知識之前,有必要介紹一下呼叫棧的相關背景知識。

呼叫棧

首先聊聊棧,它是每個執行緒獨享的一種資料結構。借用維基百科上的一張圖片:

呼叫棧示意圖

上圖表示了一個棧,它分為若干棧幀(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、source1、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的捕獲和解析(二)

相關文章