原文: https://bestswifter.com/callstack/
BSBacktraceLogger 是一個輕量級的框架,可以獲取任意線程的調用棧,開源在我的 GitHub,建議下載下來結合本文閱讀。
我們知道 NSThread
有一個類方法 callstackSymbols
可以獲取調用棧,但是它輸出的是當前線程的調用棧。在利用
Runloop 檢測卡頓時,子線程檢測到了主線程發生卡頓,需要通過主線程的調用棧來分析具體是哪個方法導致了阻塞,這時系統提供的方法就無能爲力了。
最簡單、自然的想法就是利用 dispatch_async
或performSelectorOnMainThread
等方法,回到主線程並獲取調用棧。不用說也能猜到這種想法並不可行,否則就沒有寫作本文的必要了。
這篇文章的重點不是介紹獲取調用棧的細節,而是在實現過程中的遇到的諸多問題和嘗試過的解決方案。有的方案也許不能解決問題,但在思考的過程中能夠把知識點串聯起來,在我看來這纔是本文最大的價值。
在介紹後續知識之前,有必要介紹一下調用棧的相關背景知識。
調用棧
首先聊聊棧,它是每個線程獨享的一種數據結構。借用維基百科上的一張圖片:
上圖表示了一個棧,它分爲若干棧幀(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 或者代理等方法。
我們以 UIViewController
的viewDidLoad
方法爲例,推測它底層都發生了什麼。
首先主線程也是線程,就得按照線程基本法來辦事。線程基本法說的是首先要把線程運行起來,然後(如果有必要,比如主線程)啓動
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_COUNT
和 BS_THREAD_STATE
這兩個宏用於屏蔽不同
CPU 之間的區別。
在 _STRUCT_MCONTEXT
類型的結構體中,存儲了當前線程的
Stack Pointer 和最頂部棧幀的 Frame Pointer,從而獲取到了整個線程的調用棧。
在項目中,調用棧存儲在 backtraceBuffer
數組中,其中每一個指針對應了一個棧幀,每個棧幀又對應一個函數調用,並且每個函數都有自己的符號名。
接下來的任務就是根據棧幀的 Frame Pointer 獲取到這個函數調用的符號名。
符號解析
就像 “把大象關進冰箱需要幾步” 一樣,獲取 Frame Pointer 對應的符號名也可以分爲以下幾步:
-
根據 Frame Pointer 找到函數調用的地址
-
找到 Frame Pointer 屬於哪個鏡像文件
-
找到鏡像文件的符號表
-
在符號表中找到函數調用地址對應的符號名
這實際上都是 C 語言編程問題,我沒有相關經驗,不過好在有前人的研究成果可以借鑑。感興趣的讀者可以直接閱讀源碼。
揭祕 NSThread
根據上述分析,我們可以獲取到所有線程以及他們的調用堆棧,但如果想單獨獲取某個線程的堆棧呢?問題在於,如何建立
NSThread 線程和內核線程之間的聯繫。
再次 Google 無果後,我找到了 GNUStep-base 的源碼,下載了
1.24.9 版本,其中包含了 Foundation 庫的源碼,我不能確保現在的 NSThread 完全採用這裏的實現,但至少可以從 NSThread.m
類中挖掘出很多有用信息。
NSThread 的封裝層級
很多文章都提到了 NSThread 是 pthread 的封裝,這就涉及兩個問題:
-
pthread 是什麼
-
NSThread 如何封裝 pthread
pthread 中的字母 p 是 POSIX 的簡寫,POSIX 表示 “可移植操作系統接口(Portable
Operating System Interface)”。
每個操作系統都有自己的線程模型,不同操作系統提供的,操作線程的 API 也不一樣,這就給跨平臺的線程管理帶來了問題,而
POSIX 的目的就是提供抽象的 pthread 以及相關 API,這些 API 在不同操作系統中有不同的實現,但是完成的功能一致。
Unix 系統提供的 thread_get_state
和 task_threads
等方法,操作的都是內核線程,每個內核線程由 thread_t
類型的
id 來唯一標識,pthread 的唯一標識是 pthread_t
類型。
內核線程和 pthread 的轉換(也即是 thread_t
和 pthread_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 的源碼着實值得反覆研究、閱讀。
完成一個技術項目往往最大的收穫不是最後的結果,而是實現過程中的思考。這些走過的彎路加深了對知識體系的理解。