获取iOS任意线程调用堆栈(五)完整实现:BSBacktraceLogger
轉載自:https://toutiao.io/posts/aveig6/preview
BSBacktraceLogger 是一個輕量級的框架,可以獲取任意線程的調用棧,開源在我的?GitHub,建議下載下來結合本文閱讀。
我們知道?NSThread?有一個類方法?callstackSymbols?可以獲取調用棧,但是它輸出的是當前線程的調用棧。在利用 Runloop 檢測卡頓時,子線程檢測到了主線程發(fā)生卡頓,需要通過主線程的調用棧來分析具體是哪個方法導致了阻塞,這時系統(tǒng)提供的方法就無能為力了。
最簡單、自然的想法就是利用?dispatch_async?或?performSelectorOnMainThread?等方法,回到主線程并獲取調用棧。不用說也能猜到這種想法并不可行,否則就沒有寫作本文的必要了。
這篇文章的重點不是介紹獲取調用棧的細節(jié),而是在實現(xiàn)過程中的遇到的諸多問題和嘗試過的解決方案。有的方案也許不能解決問題,但在思考的過程中能夠把知識點串聯(lián)起來,在我看來這才是本文最大的價值。
在介紹后續(xù)知識之前,有必要介紹一下調用棧的相關背景知識。
調用棧
首先聊聊棧,它是每個線程獨享的一種數(shù)據(jù)結構。借用維基百科上的一張圖片:
上圖表示了一個棧,它分為若干棧幀(frame),每個棧幀對應一個函數(shù)調用,比如藍色的部分是?DrawSquare?函數(shù)的棧幀,它在執(zhí)行的過程中調用了?DrawLine?函數(shù),棧幀用綠色表示。
可以看到棧幀由三部分組成:函數(shù)參數(shù),返回地址,幀內的變量。舉個例子,在調用?DrawLine?函數(shù)時首先把函數(shù)的參數(shù)入棧,這是第一部分;隨后將返回地址入棧,這表示當前函數(shù)執(zhí)行完后回到哪里繼續(xù)執(zhí)行;在函數(shù)內部定義的變量則屬于第三部分。
Stack Pointer(棧指針)表示當前棧的頂部,由于大部分操作系統(tǒng)的棧向下生長,它其實是棧地址的最小值。根據(jù)之前的解釋,Frame Pointer 指向的地址中,存儲了上一次 Stack Pointer 的值,也就是返回地址。
在大多數(shù)操作系統(tǒng)中,每個棧幀還保存了上一個棧幀的 Frame Pointer,因此只要知道當前棧幀的 Stack Pointer 和 Frame Pointer,就能知道上一個棧幀的 Stack Pointer 和 Frame Pointer,從而遞歸的獲取棧底的幀。
顯然當一個函數(shù)調用結束時,它的棧幀就不存在了。
因此,調用棧其實是棧的一種抽象概念,它表示了方法之間的調用關系,一般來說從棧中可以解析出調用棧。
失敗的傳統(tǒng)方法
最初的想法很簡單,既然?callstackSymbols?只能獲取當前線程的調用棧,那在目標線程調用就可以了。比如?dispatch_async?到主隊列,或者?performSelector?系列,更不用說還可以用 Block 或者代理等方法。
我們以?UIViewController?的viewDidLoad?方法為例,推測它底層都發(fā)生了什么。
首先主線程也是線程,就得按照線程基本法來辦事。線程基本法說的是首先要把線程運行起來,然后(如果有必要,比如主線程)啟動 runloop 進行保活。我們知道 runloop 的本質就是一個死循環(huán),在循環(huán)中調用多個函數(shù),分別判斷 source0、source1、timer、dispatch_queue 等事件源有沒有要處理的內容。
和 UI 相關的事件都是 source0,因此會執(zhí)行?__CFRunLoopDoSources0,最終一步步走到?viewDidLoad。當事件處理完后 runloop 進入休眠狀態(tài)。
假設我們使用?dispatch_async,它會喚醒 runloop 并處理事件,但此時__CFRunLoopDoSources0?已經執(zhí)行完畢,不可能獲取到?viewDidLoad?的調用棧。
performSelector?系列方法的底層也依賴于 runloop,因此它只是像當前的 runloop 提交了一個任務,但是依然要等待現(xiàn)有任務完成以后才能執(zhí)行,所以拿不到實時的調用棧。
總而言之,一切涉及到 runloop,或者需要等待?viewDidLoad?執(zhí)行完的方案都不可能成功。
信號
要想不依賴于?viewDidLoad?完成,并在主線程執(zhí)行代碼,只能從操作系統(tǒng)層面入手。我嘗試了使用信號(Signal)來實現(xiàn),
信號其實是一種軟中斷,也是由系統(tǒng)的中斷處理程序負責處理。在處理信號時,操作系統(tǒng)會保存正在執(zhí)行的上下文,比如寄存器的值,當前指令等,然后處理信號,處理完成后再恢復執(zhí)行上下文。
因此從理論上來說,信號可以強制讓目標線程停下,處理信號再恢復。一般情況下發(fā)送信號是針對整個進程的,任何線程都可以接受并處理,也可以用pthread_kill()?向指定線程發(fā)送某個信號。
信號的處理可以用?signal?或者?sigaction?來實現(xiàn),前者比較簡單,后者功能更加強大。
比如我們運行程序后按下?Ctrl + C?實際上就是發(fā)出了?SIGINT?信號,以下代碼可以在按下?Ctrl + C?時做一些輸出并避免程序退出:
void sig_handler(int signum) { printf("Received signal %d\n", signum); }void main() { signal(SIGINT, sig_handler); } 遺憾的是,使用pthread_kill()?發(fā)出的信號似乎無法被上述方法正確處理,查閱各種資料無果后放棄此思路。但至今任然覺得這是可行的,如果有人知道還望指正。Mach_thread
回憶之前對棧的介紹,只要知道 StackPointer 和 FramePointer 就可以完全確定一個棧的信息,那有沒有辦法拿到所有線程的 StackPointer 和 FramePointer 呢?
答案是肯定的,首先系統(tǒng)提供了?task_threads?方法,可以獲取到所有的線程,注意這里的線程是最底層的 mach 線程,它和 NSThread 的關系稍后會詳細闡述。
對于每一個線程,可以用?thread_get_state?方法獲取它的所有信息,信息填充在_STRUCT_MCONTEXT?類型的參數(shù)中。這個方法中有兩個參數(shù)隨著 CPU 架構的不同而改變,因此我定義了?BS_THREAD_STATE_COUNT?和?BS_THREAD_STATE?這兩個宏用于屏蔽不同 CPU 之間的區(qū)別。
在?_STRUCT_MCONTEXT?類型的結構體中,存儲了當前線程的 Stack Pointer 和最頂部棧幀的 Frame Pointer,從而獲取到了整個線程的調用棧。
在項目中,調用棧存儲在?backtraceBuffer?數(shù)組中,其中每一個指針對應了一個棧幀,每個棧幀又對應一個函數(shù)調用,并且每個函數(shù)都有自己的符號名。
接下來的任務就是根據(jù)棧幀的 Frame Pointer 獲取到這個函數(shù)調用的符號名。
符號解析
就像 “把大象關進冰箱需要幾步” 一樣,獲取 Frame Pointer 對應的符號名也可以分為以下幾步:
這實際上都是 C 語言編程問題,我沒有相關經驗,不過好在有前人的研究成果可以借鑒。感興趣的讀者可以直接閱讀源碼。
揭秘 NSThread
根據(jù)上述分析,我們可以獲取到所有線程以及他們的調用堆棧,但如果想單獨獲取某個線程的堆棧呢?問題在于,如何建立 NSThread 線程和內核線程之間的聯(lián)系。
再次 Google 無果后,我找到了?GNUStep-base 的源碼,下載了 1.24.9 版本,其中包含了 Foundation 庫的源碼,我不能確保現(xiàn)在的 NSThread 完全采用這里的實現(xiàn),但至少可以從?NSThread.m?類中挖掘出很多有用信息。
NSThread 的封裝層級
很多文章都提到了 NSThread 是 pthread 的封裝,這就涉及兩個問題:
pthread 中的字母 p 是 POSIX 的簡寫,POSIX 表示 “可移植操作系統(tǒng)接口(Portable Operating System Interface)”。
每個操作系統(tǒng)都有自己的線程模型,不同操作系統(tǒng)提供的,操作線程的 API 也不一樣,這就給跨平臺的線程管理帶來了問題,而 POSIX 的目的就是提供抽象的 pthread 以及相關 API,這些 API 在不同操作系統(tǒng)中有不同的實現(xiàn),但是完成的功能一致。
Unix 系統(tǒng)提供的?thread_get_state?和?task_threads?等方法,操作的都是內核線程,每個內核線程由?thread_t?類型的 id 來唯一標識,pthread 的唯一標識是pthread_t?類型。
內核線程和 pthread 的轉換(也即是?thread_t?和?pthread_t?互轉)很容易,因為 pthread 誕生的目的就是為了抽象內核線程。
說 NSThread 封裝了 pthread 并不是很準確,NSThread 內部只有很少的地方用到了 pthread。NSThread 的?start?方法簡化版實現(xiàn)如下:
- (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系列最終都會走到下面這個全能函數(shù):
- (void) performSelector: (SEL)aSelectoronThread: (NSThread*)aThreadwithObject: (id)anObjectwaitUntilDone: (BOOL)aFlagmodes: (NSArray*)anArray; 而它僅僅是一個封裝,根據(jù)線程獲取到 runloop,真正調用的還是 NSRunloop 的方法: - (void) performSelector: (SEL)aSelectortarget: (id)targetargument: (id)argumentorder: (NSUInteger)ordermodes: (NSArray*)modes{} 這些信息將組成一個?Performer?對象放進 runloop 等待執(zhí)行。NSThread 轉內核 thread
由于系統(tǒng)沒有提供相應的轉換方法,而且 NSThread 沒有保留線程的pthread_t,所以常規(guī)手段無法滿足需求。
一種思路是利用?performSelector?方法在指定線程執(zhí)行代碼并記錄?thread_t,執(zhí)行代碼的時機不能太晚,如果在打印調用棧時才執(zhí)行就會破壞調用棧。最好的方法是在線程創(chuàng)建時執(zhí)行,上文提到了利用?pthread_create?方法創(chuàng)建線程,它的回調函數(shù)?nsthreadLauncher?實現(xiàn)如下:
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; } 很神奇的發(fā)現(xiàn)系統(tǒng)居然會發(fā)送一個通知,通知名不對外提供,但是可以通過監(jiān)聽所有通知名的方法得知它的名字:?@"_NSThreadDidStartNotification",于是我們可以監(jiān)聽這個通知并調用?performSelector?方法。一般 NSThread 使用?initWithTarget:Selector:object?方法創(chuàng)建。在 main 方法中 selector 會被執(zhí)行,main 方法執(zhí)行結束后線程就會退出。如果想做線程保活,需要在傳入的 selector 中開啟 runloop,詳見我的這篇文章:?深入研究 Runloop 與線程保活。
可見,這種方案并不現(xiàn)實,因為之前已經解釋過,performSelector?依賴于 runloop 開啟,而 runloop 直到?main?方法才有可能開啟。
回顧問題發(fā)現(xiàn),我們需要的是一個聯(lián)系 NSThread 對象和內核 thread 的紐帶,也就是說要找到 NSThread 對象的某個唯一值,而且內核 thread 也具有這個唯一值。
觀察一下 NSThread,它的唯一值只有對象地址,對象序列號(Sequence Number) 和線程名稱:
<NSThread: 0x144d095e0>{number = 1, name = main} 地址分配在堆上,沒有使用意義,序列號的計算沒有看懂,因此只剩下 name。幸運的是 pthread 也提供了一個方法?pthread_getname_np?來獲取線程的名字,兩者是一致的,感興趣的讀者可以自行閱讀?setName?方法的實現(xiàn),它調用的就是 pthread 提供的接口。這里的?np?表示 not POSIX,也就是說它并不能跨平臺使用。
于是解決方案就很簡單了,對于 NSThread 參數(shù),把它的名字改為某個隨機數(shù)(我選擇了時間戳),然后遍歷 pthread 并檢查有沒有匹配的名字。查找完成后把參數(shù)的名字恢復即可。
主線程轉內核 thread
本來以為問題已經圓滿解決,不料還有一個坑,主線程設置 name 后無法用pthread_getname_np?讀取到。
好在我們還可以迂回解決問題: 事先獲得主線程的?thread_t,然后進行比對。
上述方案要求我們在主線程中執(zhí)行代碼從而獲得?thread_t,顯然最好的方案是在 load 方法里:
static mach_port_t main_thread_id; + (void)load {main_thread_id = mach_thread_self(); }總結
以上就是 BSBacktraceLogger 的全部分析,它只有一個類,400行代碼,因此還算是比較簡單。然而 NSThread、NSRunloop 以及 GCD 的源碼著實值得反復研究、閱讀。
完成一個技術項目往往最大的收獲不是最后的結果,而是實現(xiàn)過程中的思考。這些走過的彎路加深了對知識體系的理解。
總結
以上是生活随笔為你收集整理的获取iOS任意线程调用堆栈(五)完整实现:BSBacktraceLogger的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: java中使用lua脚本
- 下一篇: [记录] --- linux上项目