日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

获取iOS任意线程调用堆栈(五)完整实现:BSBacktraceLogger

發(fā)布時間:2024/7/23 编程问答 37 豆豆
生活随笔 收集整理的這篇文章主要介紹了 获取iOS任意线程调用堆栈(五)完整实现:BSBacktraceLogger 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.

轉(zhuǎn)載自:https://toutiao.io/posts/aveig6/preview

BSBacktraceLogger 是一個輕量級的框架,可以獲取任意線程的調(diào)用棧,開源在我的?GitHub,建議下載下來結(jié)合本文閱讀。

我們知道?NSThread?有一個類方法?callstackSymbols?可以獲取調(diào)用棧,但是它輸出的是當(dāng)前線程的調(diào)用棧。在利用 Runloop 檢測卡頓時,子線程檢測到了主線程發(fā)生卡頓,需要通過主線程的調(diào)用棧來分析具體是哪個方法導(dǎo)致了阻塞,這時系統(tǒng)提供的方法就無能為力了。

最簡單、自然的想法就是利用?dispatch_async?或?performSelectorOnMainThread?等方法,回到主線程并獲取調(diào)用棧。不用說也能猜到這種想法并不可行,否則就沒有寫作本文的必要了。

這篇文章的重點不是介紹獲取調(diào)用棧的細(xì)節(jié),而是在實現(xiàn)過程中的遇到的諸多問題和嘗試過的解決方案。有的方案也許不能解決問題,但在思考的過程中能夠把知識點串聯(lián)起來,在我看來這才是本文最大的價值。

在介紹后續(xù)知識之前,有必要介紹一下調(diào)用棧的相關(guān)背景知識。

首先聊聊棧,它是每個線程獨享的一種數(shù)據(jù)結(jié)構(gòu)。借用維基百科上的一張圖片:

上圖表示了一個棧,它分為若干棧幀(frame),每個棧幀對應(yīng)一個函數(shù)調(diào)用,比如藍(lán)色的部分是?DrawSquare?函數(shù)的棧幀,它在執(zhí)行的過程中調(diào)用了?DrawLine?函數(shù),棧幀用綠色表示。

可以看到棧幀由三部分組成:函數(shù)參數(shù),返回地址,幀內(nèi)的變量。舉個例子,在調(diào)用?DrawLine?函數(shù)時首先把函數(shù)的參數(shù)入棧,這是第一部分;隨后將返回地址入棧,這表示當(dāng)前函數(shù)執(zhí)行完后回到哪里繼續(xù)執(zhí)行;在函數(shù)內(nèi)部定義的變量則屬于第三部分。

Stack Pointer(棧指針)表示當(dāng)前棧的頂部,由于大部分操作系統(tǒng)的棧向下生長,它其實是棧地址的最小值。根據(jù)之前的解釋,Frame Pointer 指向的地址中,存儲了上一次 Stack Pointer 的值,也就是返回地址。

在大多數(shù)操作系統(tǒng)中,每個棧幀還保存了上一個棧幀的 Frame Pointer,因此只要知道當(dāng)前棧幀的 Stack Pointer 和 Frame Pointer,就能知道上一個棧幀的 Stack Pointer 和 Frame Pointer,從而遞歸的獲取棧底的幀。

顯然當(dāng)一個函數(shù)調(diào)用結(jié)束時,它的棧幀就不存在了。

因此,調(diào)用棧其實是棧的一種抽象概念,它表示了方法之間的調(diào)用關(guān)系,一般來說從棧中可以解析出調(diào)用棧。

最初的想法很簡單,既然?callstackSymbols?只能獲取當(dāng)前線程的調(diào)用棧,那在目標(biāo)線程調(diào)用就可以了。比如?dispatch_async?到主隊列,或者?performSelector?系列,更不用說還可以用 Block 或者代理等方法。

我們以?UIViewController?的viewDidLoad?方法為例,推測它底層都發(fā)生了什么。

首先主線程也是線程,就得按照線程基本法來辦事。線程基本法說的是首先要把線程運行起來,然后(如果有必要,比如主線程)啟動 runloop 進(jìn)行保活。我們知道 runloop 的本質(zhì)就是一個死循環(huán),在循環(huán)中調(diào)用多個函數(shù),分別判斷 source0、source1、timer、dispatch_queue 等事件源有沒有要處理的內(nèi)容。

和 UI 相關(guān)的事件都是 source0,因此會執(zhí)行?__CFRunLoopDoSources0,最終一步步走到?viewDidLoad。當(dāng)事件處理完后 runloop 進(jìn)入休眠狀態(tài)。

假設(shè)我們使用?dispatch_async,它會喚醒 runloop 并處理事件,但此時__CFRunLoopDoSources0?已經(jīng)執(zhí)行完畢,不可能獲取到?viewDidLoad?的調(diào)用棧。

performSelector?系列方法的底層也依賴于 runloop,因此它只是像當(dāng)前的 runloop 提交了一個任務(wù),但是依然要等待現(xiàn)有任務(wù)完成以后才能執(zhí)行,所以拿不到實時的調(diào)用棧。

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

要想不依賴于?viewDidLoad?完成,并在主線程執(zhí)行代碼,只能從操作系統(tǒng)層面入手。我嘗試了使用信號(Signal)來實現(xiàn),

信號其實是一種軟中斷,也是由系統(tǒng)的中斷處理程序負(fù)責(zé)處理。在處理信號時,操作系統(tǒng)會保存正在執(zhí)行的上下文,比如寄存器的值,當(dāng)前指令等,然后處理信號,處理完成后再恢復(fù)執(zhí)行上下文。

因此從理論上來說,信號可以強制讓目標(biāo)線程停下,處理信號再恢復(fù)。一般情況下發(fā)送信號是針對整個進(jìn)程的,任何線程都可以接受并處理,也可以用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ā)出的信號似乎無法被上述方法正確處理,查閱各種資料無果后放棄此思路。但至今任然覺得這是可行的,如果有人知道還望指正。

回憶之前對棧的介紹,只要知道 StackPointer 和 FramePointer 就可以完全確定一個棧的信息,那有沒有辦法拿到所有線程的 StackPointer 和 FramePointer 呢?

答案是肯定的,首先系統(tǒng)提供了?task_threads?方法,可以獲取到所有的線程,注意這里的線程是最底層的 mach 線程,它和 NSThread 的關(guān)系稍后會詳細(xì)闡述。

對于每一個線程,可以用?thread_get_state?方法獲取它的所有信息,信息填充在_STRUCT_MCONTEXT?類型的參數(shù)中。這個方法中有兩個參數(shù)隨著 CPU 架構(gòu)的不同而改變,因此我定義了?BS_THREAD_STATE_COUNT?和?BS_THREAD_STATE?這兩個宏用于屏蔽不同 CPU 之間的區(qū)別。

在?_STRUCT_MCONTEXT?類型的結(jié)構(gòu)體中,存儲了當(dāng)前線程的 Stack Pointer 和最頂部棧幀的 Frame Pointer,從而獲取到了整個線程的調(diào)用棧。

在項目中,調(diào)用棧存儲在?backtraceBuffer?數(shù)組中,其中每一個指針對應(yīng)了一個棧幀,每個棧幀又對應(yīng)一個函數(shù)調(diào)用,并且每個函數(shù)都有自己的符號名。

接下來的任務(wù)就是根據(jù)棧幀的 Frame Pointer 獲取到這個函數(shù)調(diào)用的符號名。

就像 “把大象關(guān)進(jìn)冰箱需要幾步” 一樣,獲取 Frame Pointer 對應(yīng)的符號名也可以分為以下幾步:

  • 根據(jù) Frame Pointer 找到函數(shù)調(diào)用的地址
  • 找到 Frame Pointer 屬于哪個鏡像文件
  • 找到鏡像文件的符號表
  • 在符號表中找到函數(shù)調(diào)用地址對應(yīng)的符號名
  • 這實際上都是 C 語言編程問題,我沒有相關(guān)經(jīng)驗,不過好在有前人的研究成果可以借鑒。感興趣的讀者可以直接閱讀源碼。

    根據(jù)上述分析,我們可以獲取到所有線程以及他們的調(diào)用堆棧,但如果想單獨獲取某個線程的堆棧呢?問題在于,如何建立 NSThread 線程和內(nèi)核線程之間的聯(lián)系。

    再次 Google 無果后,我找到了?GNUStep-base 的源碼,下載了 1.24.9 版本,其中包含了 Foundation 庫的源碼,我不能確保現(xiàn)在的 NSThread 完全采用這里的實現(xiàn),但至少可以從?NSThread.m?類中挖掘出很多有用信息。

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

  • pthread 是什么
  • NSThread 如何封裝 pthread
  • pthread 中的字母 p 是 POSIX 的簡寫,POSIX 表示 “可移植操作系統(tǒng)接口(Portable Operating System Interface)”。

    每個操作系統(tǒng)都有自己的線程模型,不同操作系統(tǒng)提供的,操作線程的 API 也不一樣,這就給跨平臺的線程管理帶來了問題,而 POSIX 的目的就是提供抽象的 pthread 以及相關(guān) API,這些 API 在不同操作系統(tǒng)中有不同的實現(xiàn),但是完成的功能一致。

    Unix 系統(tǒng)提供的?thread_get_state?和?task_threads?等方法,操作的都是內(nèi)核線程,每個內(nèi)核線程由?thread_t?類型的 id 來唯一標(biāo)識,pthread 的唯一標(biāo)識是pthread_t?類型。

    內(nèi)核線程和 pthread 的轉(zhuǎn)換(也即是?thread_t?和?pthread_t?互轉(zhuǎn))很容易,因為 pthread 誕生的目的就是為了抽象內(nèi)核線程。

    說 NSThread 封裝了 pthread 并不是很準(zhǔn)確,NSThread 內(nèi)部只有很少的地方用到了 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?標(biāo)識。

    另一處用到 pthread 的地方就是 NSThread 在退出時,調(diào)用了?pthread_exit()。除此以外就很少感受到 pthread 的存在感了,因此個人認(rèn)為 “NSThread 是對 pthread 的封裝” 這種說法并不準(zhǔn)確。

    實際上所有的?performSelector系列最終都會走到下面這個全能函數(shù):

    - (void) performSelector: (SEL)aSelectoronThread: (NSThread*)aThreadwithObject: (id)anObjectwaitUntilDone: (BOOL)aFlagmodes: (NSArray*)anArray; 而它僅僅是一個封裝,根據(jù)線程獲取到 runloop,真正調(diào)用的還是 NSRunloop 的方法: - (void) performSelector: (SEL)aSelectortarget: (id)targetargument: (id)argumentorder: (NSUInteger)ordermodes: (NSArray*)modes{} 這些信息將組成一個?Performer?對象放進(jìn) runloop 等待執(zhí)行。

    由于系統(tǒng)沒有提供相應(yīng)的轉(zhuǎn)換方法,而且 NSThread 沒有保留線程的pthread_t,所以常規(guī)手段無法滿足需求。

    一種思路是利用?performSelector?方法在指定線程執(zhí)行代碼并記錄?thread_t,執(zhí)行代碼的時機(jī)不能太晚,如果在打印調(diào)用棧時才執(zhí)行就會破壞調(diào)用棧。最好的方法是在線程創(chuàng)建時執(zhí)行,上文提到了利用?pthread_create?方法創(chuàng)建線程,它的回調(diào)函數(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)聽這個通知并調(diào)用?performSelector?方法。

    一般 NSThread 使用?initWithTarget:Selector:object?方法創(chuàng)建。在 main 方法中 selector 會被執(zhí)行,main 方法執(zhí)行結(jié)束后線程就會退出。如果想做線程保活,需要在傳入的 selector 中開啟 runloop,詳見我的這篇文章:?深入研究 Runloop 與線程保活。

    可見,這種方案并不現(xiàn)實,因為之前已經(jīng)解釋過,performSelector?依賴于 runloop 開啟,而 runloop 直到?main?方法才有可能開啟。

    回顧問題發(fā)現(xiàn),我們需要的是一個聯(lián)系 NSThread 對象和內(nèi)核 thread 的紐帶,也就是說要找到 NSThread 對象的某個唯一值,而且內(nèi)核 thread 也具有這個唯一值。

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

    <NSThread: 0x144d095e0>{number = 1, name = main} 地址分配在堆上,沒有使用意義,序列號的計算沒有看懂,因此只剩下 name。幸運的是 pthread 也提供了一個方法?pthread_getname_np?來獲取線程的名字,兩者是一致的,感興趣的讀者可以自行閱讀?setName?方法的實現(xiàn),它調(diào)用的就是 pthread 提供的接口。

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

    于是解決方案就很簡單了,對于 NSThread 參數(shù),把它的名字改為某個隨機(jī)數(shù)(我選擇了時間戳),然后遍歷 pthread 并檢查有沒有匹配的名字。查找完成后把參數(shù)的名字恢復(fù)即可。

    本來以為問題已經(jīng)圓滿解決,不料還有一個坑,主線程設(shè)置 name 后無法用pthread_getname_np?讀取到。

    好在我們還可以迂回解決問題: 事先獲得主線程的?thread_t,然后進(jìn)行比對。

    上述方案要求我們在主線程中執(zhí)行代碼從而獲得?thread_t,顯然最好的方案是在 load 方法里:

    static mach_port_t main_thread_id; + (void)load {main_thread_id = mach_thread_self(); }

    以上就是 BSBacktraceLogger 的全部分析,它只有一個類,400行代碼,因此還算是比較簡單。然而 NSThread、NSRunloop 以及 GCD 的源碼著實值得反復(fù)研究、閱讀。

    完成一個技術(shù)項目往往最大的收獲不是最后的結(jié)果,而是實現(xiàn)過程中的思考。這些走過的彎路加深了對知識體系的理解。

    總結(jié)

    以上是生活随笔為你收集整理的获取iOS任意线程调用堆栈(五)完整实现:BSBacktraceLogger的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。