前言
在很早之前就有過實現一套自己的iOS監控體系,但首先是instrument
足夠的優秀,幾乎所有監控相關的操作都有對應的工具。二來,也是筆者沒(lan)時(de)間(zuo),專案大多也整合了第三方的統計SDK,所以遲遲沒有去實現。這段時間,因為程式碼設計上存在的缺陷,導致專案在iphone5s以下的裝置執行時會出現比較明顯的卡頓現象。雖然instrument
足夠優秀,但筆者更希望在程式執行期間能及時獲取卡頓資訊,因此開始動手自己的卡頓檢測方案。
獲取棧上下文
任何監控體系在監控到目標事件發生時,獲取執行緒的呼叫棧上下文是必須的,問題在於如何掛起當前執行緒並且獲取執行緒資訊。好在網上有大神分享了足夠多的資料供筆者查閱,讓筆者可以站在巨人的肩膀上來完成這部分業務。
demo中獲取呼叫棧程式碼重寫自BSBacktraceLogger,在使用之前建議能結合下方的參考資料和原始碼一起閱覽,知其然知其所以然。棧是一種後進先出(LIFO)的資料結構,對於一個執行緒來說,其呼叫棧的結構如下:
呼叫棧上每一個單位被稱作棧幀(stack frame),每一個棧幀由函式引數、返回地址以及棧幀中的變數組成,其中Frame Pointer
指向記憶體儲存了上一棧幀的地址資訊。換句話說,只要能獲取到棧頂的Frame Pointer
就能遞迴遍歷整個棧上的幀,遍歷棧幀的核心程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#define MAX_FRAME_NUMBER 30 #define FAILED_UINT_PTR_ADDRESS 0 NSString * _lxd_backtraceOfThread(thread_t thread) { uintptr_t backtraceBuffer[MAX_FRAME_NUMBER]; int idx = 0; ...... LXDStackFrameEntry frame = { 0 }; const uintptr_t framePtr = lxd_mach_framePointer(&machineContext); if (framePtr == FAILED_UINT_PTR_ADDRESS || lxd_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) { return @"failed to get frame pointer"; } for (; idx |
從棧幀中我們只能獲取到呼叫函式的地址資訊,為了輸出上下文資料,我們還需要根據地址進行符號化,即找到地址所在的記憶體映象,然後定位該映象中的符號表,最後從符號表中匹配地址對應的符號輸出。
符號化過程中包括不限於以下的資料結構:
1 2 3 4 5 6 |
typedef struct dl_info { const char *dli_fname; void *dli_fbase; const char *dli_sname; void *dli_saddr; } Dl_info; |
Dl_info
儲存了包括路徑名、映象起始地址、符號地址和符號名等資訊
1 2 3 4 5 6 7 8 |
struct symtab_command { uint32_t cmd; uint32_t cmdsize; uint32_t symoff; uint32_t nsyms; uint32_t stroff; uint32_t strsize; }; |
提供了符號表的偏移量,以及元素個數,還有字串表的偏移和其長度。更多堆疊的資料可以參考文末最後三個連結學習。符號化的核心函式lxd_dladdr
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
bool lxd_dladdr(const uintptr_t address, Dl_info * const info) { info->dli_fname = NULL; info->dli_fbase = NULL; info->dli_sname = NULL; info->dli_saddr = NULL; const uint32_t idx = lxd_imageIndexContainingAddress(address); if (idx == UINT_MAX) { return false; } const struct mach_header * header = _dyld_get_image_header(idx); const uintptr_t imageVMAddressSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx); const uintptr_t addressWithSlide = address - imageVMAddressSlide; const uintptr_t segmentBase = lxd_segmentBaseOfImageIndex(idx) + imageVMAddressSlide; if (segmentBase == FAILED_UINT_PTR_ADDRESS) { return false; } info->dli_fbase = (void *)header; info->dli_fname = _dyld_get_image_name(idx); const LXD_NLIST * bestMatch = NULL; uintptr_t bestDistance = ULONG_MAX; uintptr_t cmdPtr = lxd_firstCmdAfterHeader(header); if (cmdPtr == FAILED_UINT_PTR_ADDRESS) { return false; } for (uint32_t iCmd = 0; iCmd ncmds; iCmd++) { const struct load_command * loadCmd = (struct load_command *)cmdPtr; if (loadCmd->cmd == LC_SYMTAB) { const struct symtab_command * symtabCmd = (struct symtab_command *)cmdPtr; const LXD_NLIST * symbolTable = (LXD_NLIST *)(segmentBase + symtabCmd->symoff); const uintptr_t stringTable = segmentBase + symtabCmd->stroff; for (uint32_t iSym = 0; iSym nsyms; iSym++) { if (symbolTable[iSym].n_value == FAILED_UINT_PTR_ADDRESS) { continue; } uintptr_t symbolBase = symbolTable[iSym].n_value; uintptr_t currentDistance = addressWithSlide - symbolBase; if ( (addressWithSlide >= symbolBase && currentDistance dli_saddr = (void *)(bestMatch->n_value + imageVMAddressSlide); info->dli_sname = (char *)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx); if (*info->dli_sname == '_') { info->dli_sname++; } if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) { info->dli_sname = NULL; } break; } } cmdPtr += loadCmd->cmdsize; } return true; } |
整個符號化過程可以用下面的圖表示
關於RunLoop
RunLoop
是一個重複接收著埠訊號和事件源的死迴圈,它不斷的喚醒沉睡,主執行緒的RunLoop
在應用跑起來的時候就自動啟動,RunLoop
的執行流程由下圖表示:
在CFRunLoop.c中,可以看到RunLoop
的執行程式碼大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
{ /// 1. 通知Observers,即將進入RunLoop /// 此處有Observer會建立AutoreleasePool: _objc_autoreleasePoolPush(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry); do { /// 2. 通知 Observers: 即將觸發 Timer 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers); /// 3. 通知 Observers: 即將觸發 Source (非基於port的,Source0) 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources); __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block); /// 4. 觸發 Source0 (非基於port的) 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0); __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block); /// 6. 通知Observers,即將進入休眠 /// 此處有Observer釋放並新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting); /// 7. sleep to wait msg. mach_msg() -> mach_msg_trap(); /// 8. 通知Observers,執行緒被喚醒 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting); /// 9. 如果是被Timer喚醒的,回撥Timer __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer); /// 9. 如果是被dispatch喚醒的,執行所有呼叫 dispatch_async 等方法放入main queue 的 block __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block); /// 9. 如果如果Runloop是被 Source1 (基於port的) 的事件喚醒了,處理這個事件 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1); } while (...); /// 10. 通知Observers,即將退出RunLoop /// 此處有Observer釋放AutoreleasePool: _objc_autoreleasePoolPop(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit); } |
通過原始碼不難發現RunLoop
處理事件的時間主要出在兩個階段:
kCFRunLoopBeforeSources
和kCFRunLoopBeforeWaiting
之間kCFRunLoopAfterWaiting
之後
監控RunLoop狀態檢測超時
通過RunLoop
的原始碼我們已經知道了主執行緒處理事件的時間,那麼如何檢測應用是否發生了卡頓呢?為了找到合理的處理方案,筆者先監聽RunLoop
的狀態並且輸出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
static void lxdRunLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void * info) { SHAREDMONITOR.currentActivity = activity; dispatch_semaphore_signal(SHAREDMONITOR.semphore); switch (activity) { case kCFRunLoopEntry: NSLog(@"runloop entry"); break; case kCFRunLoopExit: NSLog(@"runloop exit"); break; case kCFRunLoopAfterWaiting: NSLog(@"runloop after waiting"); break; case kCFRunLoopBeforeTimers: NSLog(@"runloop before timers"); break; case kCFRunLoopBeforeSources: NSLog(@"runloop before sources"); break; case kCFRunLoopBeforeWaiting: NSLog(@"runloop before waiting"); break; default: break; } }; |
執行之後輸出的結果是滾動引發的Sources
事件總是被快速的執行完成,然後進入到kCFRunLoopBeforeWaiting
狀態下。假如在滾動過程中發生了卡頓現象,那麼RunLoop
必然會保持kCFRunLoopAfterWaiting
或者kCFRunLoopBeforeSources
這兩個狀態之一。
為了實現卡頓的檢測,首先需要註冊RunLoop
的監聽回撥,儲存RunLoop
狀態;其次,通過建立子執行緒迴圈監聽主執行緒RunLoop
的狀態來檢測是否存在停留卡頓現象: 收到Sources相關的事件時,將超時闕值時間內分割成多個時間片段,重複去獲取當前RunLoop的狀態。如果多次處在處理事件的狀態下,那麼可以視作發生了卡頓現象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#define SHAREDMONITOR [LXDAppFluecyMonitor sharedMonitor] @interface LXDAppFluecyMonitor : NSObject @property (nonatomic, assign) int timeOut; @property (nonatomic, assign) BOOL isMonitoring; @property (nonatomic, assign) CFRunLoopActivity currentActivity; + (instancetype)sharedMonitor; - (void)startMonitoring; - (void)stopMonitoring; @end - (void)startMonitoring { dispatch_async(lxd_fluecy_monitor_queue(), ^{ while (SHAREDMONITOR.isMonitoring) { long waitTime = dispatch_semaphore_wait(self.semphore, dispatch_time(DISPATCH_TIME_NOW, lxd_wait_interval)); if (waitTime != LXD_SEMPHORE_SUCCESS) { if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) { if (++SHAREDMONITOR.timeOut |
標記位檢測執行緒超時
與UI卡頓不同的事,事件處理往往是處在kCFRunLoopBeforeWaiting
的狀態下收到了Sources
事件源,最開始筆者嘗試同樣以多個時間片段查詢的方式處理。但是由於主執行緒的RunLoop
在閒置時基本處於Before Waiting
狀態,這就導致了即便沒有發生任何卡頓,這種檢測方式也總能認定主執行緒處在卡頓狀態。
就在這時候寒神(南梔傾寒)推薦給我一套Swift
的卡頓檢測第三方ANREye,這套卡頓監控方案大致思路為:建立一個子執行緒進行迴圈檢測,每次檢測時設定標記位為YES
,然後派發任務到主執行緒中將標記位設定為NO
。接著子執行緒沉睡超時闕值時長,判斷標誌位是否成功設定成NO
。如果沒有說明主執行緒發生了卡頓,無法處理派發任務:
事後發現在特定情況下,這種檢測方式會出錯:當主執行緒被async
大量的執行任務時,每個任務執行時間小於卡頓時間闕值,即對操作無影響。這時候由於設定標誌位的async
任務位置過於靠後,導致子執行緒沉睡後未能成功設定,造成卡頓誤報的現象。(ps:當然,實測結果是基本不可能發生這種現象)這套方案解決了上面監聽RunLoop
的缺陷。結合這套方案,當主執行緒處在Before Waiting
狀態的時候,通過派發任務到主執行緒來設定標記位的方式處理常態下的卡頓檢測:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
dispatch_async(lxd_event_monitor_queue(), ^{ while (SHAREDMONITOR.isMonitoring) { if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) { __block BOOL timeOut = YES; dispatch_async(dispatch_get_main_queue(), ^{ timeOut = NO; dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore); }); [NSThread sleepForTimeInterval: lxd_time_out_interval]; if (timeOut) { [LXDBacktraceLogger lxd_logMain]; } dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER); } } }); |
尾言
多數開發者對於RunLoop
可能並沒有進行實際的應用開發過,或者說即便了解RunLoop
也只是處在理論的認知上。當然,也包括呼叫堆疊追溯的技術。本文旨在通過自身實現的卡頓監控程式碼來讓更多開發者去了解這些深層次的運用與實踐。
此外,上面兩種檢測方案可以兼併使用,甚至只使用後者進行主執行緒的卡頓檢測也是可以的,本文demo已經上傳:LXDAppFluecyMonitor
參考資料
深入瞭解RunLoop
移動端監控體系之技術原理
趣探 Mach-O:FishHook 解析
iOS中執行緒Call Stack的捕獲和解析1-2