iOS-查看線程調用棧

展示線程調用棧


首先看一下函數(shù)調用棧, 該圖來自于 Wiki

image

上圖表示了一個棧,它分為若干棧幀(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 PointerFrame Pointer,就能知道上一個棧幀的 Stack PointerFrame Pointer,從而遞歸的獲取棧底的幀。

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

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

思路

剛看到這個需求的時候, 想起來 iOSThread.callstackSymbols可以獲取到調用堆棧信息, 但是實施的時候發(fā)現(xiàn)不行, 有兩個原因:

  1. 的確可以獲取到堆棧信息, 但是沒法得到預估時間.
image
  1. 我們程序啟動之后, 就會開啟一個RunLoop(一個循環(huán), 類似于如下代碼), 我們UI刷新, CADisplayLink,CATransition,CAAnimation, GCD中的Main Queue也會放入主循環(huán)中. 其他線程, 以及其他事件會添加到其他的 runloop 中, 每一個線程都維護了自己的runloop, runloop 的調用順序是source0、source1、timer、dispatch_queue, UI 相關都會放入source0(____CFRunLoopDoSources0__)中, 在一步步執(zhí)行到Viewdidload()方法, 事件處理完畢之后陷入休眠, 等待事件喚醒, 這時候如果在子線程想打印主線程的信息就無法抓取到了, 因為已經(jīng)執(zhí)行完其他方法(都已經(jīng)出棧了). performSelector也是發(fā)送了一個消息(事件), runloop 在執(zhí)行. 所以不可行
int main(int argc, char * argv[]) {
     //程序一直運行狀態(tài)
     while (AppIsRunning) {
          //睡眠狀態(tài),等待喚醒事件
          id whoWakesMe = SleepForWakingUp();
          //得到喚醒事件
          id event = GetEvent(whoWakesMe);
          //開始處理事件
          HandleEvent(event);
     }
     return 0;
}

主線程的棧信息:

graph TD
dyld[dyld, 存儲了系統(tǒng)中動態(tài)鏈接庫的位置, 快速查找] -->main.m(UIApplication/main.m)
main.m -->GraphicServices[GSEventRunModal/GraphicServices]
GraphicServices --> RunLoop[RunLoop]
RunLoop --> Event

RunLoop/包含CFRunLoopRunSpecific,__CFRunLoopRun,__CFRunLoopDoSouces0,CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION

image

實現(xiàn)思路

既然直接使用自帶的Thread.callstackSymbols方法, 那么就需要另辟蹊徑了, 回想一下我們開始看到的線程的調用棧, 我們每一個當前還未出棧的方法還未執(zhí)行, 都是保存在棧里面的, 我們可以通過獲取棧幀然后遞歸到棧底.

所以我們現(xiàn)在需要去拿到棧的指針(StackPointer)以及棧幀指針(FramePointer).

系統(tǒng)提供了 task_threads 方法,可以獲取到所有的線程,注意這里的線程是最底層的 mach 線程.

對于每一個線程,可以用 thread_get_state 方法獲取它的所有信息,信息填充在 _STRUCT_MCONTEXT 類型的參數(shù)中(這個方法中有兩個參數(shù)隨著 CPU 架構的不同而改變).

我們需要存儲線程的StackPointer以及 頂部的FramePointer, 通過遞歸獲取到整個調用棧.

根據(jù)棧幀的 Frame Pointer 獲取到這個函數(shù)調用的符號名

這樣我們的實現(xiàn)思路就是:

  1. 獲取線程的StackPointer 以及 FramePointer
  2. 找到FramePointer屬于哪一個鏡像文件(.m)
  3. 獲取鏡像文件的符號表
  4. 在符號表中找到函數(shù)調用地址對應的符號名
  5. return 到上一級調用函數(shù)的FramePointer, 重復第2步.
  6. 到達棧底, 退出.

NSThread

它是對pthread的面向對象表示.

  1. pthread是(POSIX 的簡寫,POSIX 表示 “可移植操作系統(tǒng)接口(Portable Operating System Interface)").
  2. NSThread的封裝方式.

不同的操作會設計自己的線程模型, 所以底層 API 是不相同的, 但是 POSIX提供的pthread就是相當于對底層進行了一次封裝, 讓不同平臺運行得到相同的效果.

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

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

我們來看一下NSThread里面用到 pthread 的地方

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

NSThread并沒有存儲pthread_t的信息.

只有在exit()方法再次使用了pthread_exit().

我們來研究一下performSelector的實現(xiàn), 它最終會調用

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

該方法只是一層封裝, 用于獲取線程RunLoop, 真正被調用是在RunLoop中的.

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

封裝成Perfomer作為一個Event放入 Runloop 中等待執(zhí)行.

了解到這里有什么用呢, 我們接著往下看(我們的目標是通過 NSThread 得到 pthread).

系統(tǒng)并沒有提供轉換的方法, 我們需要尋找其他的方式去實現(xiàn).

hookperformSelector的執(zhí)行, 然后在指定線程的代碼部分保存thread_t, 執(zhí)行代碼的時機不能太晚, 在打印的調用棧的時候會破壞目前的結構, 最好是在創(chuàng)建的時候就執(zhí)行, 從上方的代碼可以看見pthread_create(&thr, &attr, nsthreadLauncher, self) 會執(zhí)行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;
}

驚喜的發(fā)現(xiàn), 它會發(fā)送一個一個線程開始NSThreadDidStartNotification的通知, 然后監(jiān)聽這個通知去調用performSelector方法.

但是performSelector是基于runloop的, runloop 需要到main函數(shù)才有可能會開啟(還有一點, 啟動的線程一般在執(zhí)行完事務之后就會退出銷毀, 要做?;? 就需要傳入runloop(循環(huán))).

所以, 這個方向也無法解除, 既然無法轉換, 那么我們可以找到NSThreadpthread共有的唯一屬性嗎?

打印NSThread

<NSThread: 0x600002fc0cc0>{number = 1, name = main}

有一個地址, 一個線程序號, 線程名稱.

地址是堆區(qū)的, 線程序號不是很清楚, 最后只剩下名稱, 是否名稱是唯一的呢? 去嘗試一下pthread_getname_np(pthread_t, UnsafeMutablePointer<Int8>, Int)方法, 發(fā)現(xiàn)對應的名字是一樣的(根據(jù)bestswifter文中所說, NSThread的setName方法也是直接調用 pthread 接口).

這樣我們就可以通過NSThread 通過設置名字接口(記住是唯一的), 然后遍歷所有的線程找到名字一樣的線程在去獲得StackPointer, FramePointer等關鍵信息.

閱讀bestswifter文章的時候, 作者提到了一個坑

主線程修改名字之后是無法通過pthread_getname_np讀取到的, 所以我們在之前就將值讀取出來.

// 獲取主線程
let main_thread_t = mach_thread_self()

詳細代碼

參考文章

銘神-RunLoop
bestswifter-BSBacktraceLogger
GNUStep-base 的源碼

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,626評論 1 32
  • Runloop是iOS和OSX開發(fā)中非?;A的一個概念,從概念開始學習。 RunLoop的概念 -般說,一個線程一...
    小貓仔閱讀 1,106評論 0 1
  • 轉載:http://www.cocoachina.com/ios/20150601/11970.html RunL...
    Gatling閱讀 1,556評論 0 13
  • 轉自bireme,原地址:https://blog.ibireme.com/2015/05/18/runloop/...
    乜_啊_閱讀 1,680評論 0 5
  • 女兒今年上幼兒園大班了,開始家庭作業(yè)也多起來了,難度也大了。今天幼兒園讓做的作業(yè)是蒙氏數(shù)學(5)8-9的減法運算,...
    從xin出發(fā)閱讀 394評論 0 0

友情鏈接更多精彩內容