帶你打造一套 APM 監控系統(一)

APM 是 Application Performance Monitoring 的縮寫,監視和管理軟件應用程序的性能和可用性。應用性能管理對一個應用的持續穩定運行至關重要。所以這篇文章就從一個 iOS App 的性能管理的緯度談談如何精確監控以及數據如何上報等技術點

App 的性能問題是影響用戶體驗的重要因素之一。性能問題主要包含:Crash、網絡請求錯誤或者超時、UI 響應速度慢、主線程卡頓、CPU 和內存使用率高、耗電量大等等。大多數的問題原因在於開發者錯誤地使用了線程鎖、系統函數、編程規範問題、數據結構等等。解決問題的關鍵在於儘早的發現和定位問題。

本篇文章着重總結了 APM 的原因以及如何收集數據。APM 數據收集後結合數據上報機制,按照一定策略上傳數據到服務端。服務端消費這些信息併產出報告。請結合姊妹篇, 總結了如何打造一款靈活可配置、功能強大的數據上報組件。

一、卡頓監控

卡頓問題,就是在主線程上無法響應用戶交互的問題。影響着用戶的直接體驗,所以針對 App 的卡頓監控是 APM 裏面重要的一環。

FPS(frame per second)每秒鐘的幀刷新次數,iPhone 手機以 60 爲最佳,iPad 某些型號是 120,也是作爲卡頓監控的一項參考參數,爲什麼說是參考參數?因爲它不準確。先說說怎麼獲取到 FPS。CADisplayLink 是一個系統定時器,會以幀刷新頻率一樣的速率來刷新視圖。 [CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]。至於爲什麼不准我們來看看下面的示例代碼

_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)];
[_displayLink setPaused:YES];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

代碼所示,CADisplayLink 對象是被添加到指定的 RunLoop 的某個 Mode 下。所以還是 CPU 層面的操作,卡頓的體驗是整個圖像渲染的結果:CPU + GPU。請繼續往下看

1. 屏幕繪製原理

老式 CRT 顯示器原理
講講老式的 CRT 顯示器的原理。 CRT 電子槍按照上面方式,從上到下一行行掃描,掃面完成後顯示器就呈現一幀畫面,隨後電子槍回到初始位置繼續下一次掃描。爲了把顯示器的顯示過程和系統的視頻控制器進行同步,顯示器(或者其他硬件)會用硬件時鐘產生一系列的定時信號。當電子槍換到新的一行,準備進行掃描時,顯示器會發出一個水平同步信號(horizonal synchronization),簡稱 HSync;當一幀畫面繪製完成後,電子槍恢復到原位,準備畫下一幀前,顯示器會發出一個垂直同步信號(Vertical synchronization),簡稱 VSync。顯示器通常以固定的頻率進行刷新,這個固定的刷新頻率就是 VSync 信號產生的頻率。雖然現在的顯示器基本都是液晶顯示屏,但是原理保持不變。

顯示器和 CPU、GPU 關係
通常,屏幕上一張畫面的顯示是由 CPU、GPU 和顯示器是按照上圖的方式協同工作的。CPU 根據工程師寫的代碼計算好需要現實的內容(比如視圖創建、佈局計算、圖片解碼、文本繪製等),然後把計算結果提交到 GPU,GPU 負責圖層合成、紋理渲染,隨後 GPU 將渲染結果提交到幀緩衝區。隨後視頻控制器會按照 VSync 信號逐行讀取幀緩衝區的數據,經過數模轉換傳遞給顯示器顯示。

在幀緩衝區只有一個的情況下,幀緩衝區的讀取和刷新都存在效率問題,爲了解決效率問題,顯示系統會引入2個緩衝區,即雙緩衝機制。在這種情況下,GPU 會預先渲染好一幀放入幀緩衝區,讓視頻控制器來讀取,當下一幀渲染好後,GPU 直接把視頻控制器的指針指向第二個緩衝區。提升了效率。

目前來看,雙緩衝區提高了效率,但是帶來了新的問題:當視頻控制器還未讀取完成時,即屏幕內容顯示了部分,GPU 將新渲染好的一幀提交到另一個幀緩衝區並把視頻控制器的指針指向新的幀緩衝區,視頻控制器就會把新的一幀數據的下半段顯示到屏幕上,造成畫面撕裂的情況。

爲了解決這個問題,GPU 通常有一個機制叫垂直同步信號(V-Sync),當開啓垂直同步信號後,GPU 會等到視頻控制器發送 V-Sync 信號後,才進行新的一幀的渲染和幀緩衝區的更新。這樣的幾個機制解決了畫面撕裂的情況,也增加了畫面流暢度。但需要更多的計算資源

IPC喚醒 RunLoop

答疑

可能有些人會看到「當開啓垂直同步信號後,GPU 會等到視頻控制器發送 V-Sync 信號後,才進行新的一幀的渲染和幀緩衝區的更新」這裏會想,GPU 收到 V-Sync 才進行新的一幀渲染和幀緩衝區的更新,那是不是雙緩衝區就失去意義了?

設想一個顯示器顯示第一幀圖像和第二幀圖像的過程。首先在雙緩衝區的情況下,GPU 首先渲染好一幀圖像存入到幀緩衝區,然後讓視頻控制器的指針直接直接這個緩衝區,顯示第一幀圖像。第一幀圖像的內容顯示完成後,視頻控制器發送 V-Sync 信號,GPU 收到 V-Sync 信號後渲染第二幀圖像並將視頻控制器的指針指向第二個幀緩衝區。

看上去第二幀圖像是在等第一幀顯示後的視頻控制器發送 V-Sync 信號。是嗎?真是這樣的嗎? 😭 想啥呢,當然不是。 🐷 不然雙緩衝區就沒有存在的意義了

揭祕。請看下圖

多緩衝區顯示原理

當第一次 V-Sync 信號到來時,先渲染好一幀圖像放到幀緩衝區,但是不展示,當收到第二個 V-Sync 信號後讀取第一次渲染好的結果(視頻控制器的指針指向第一個幀緩衝區),並同時渲染新的一幀圖像並將結果存入第二個幀緩衝區,等收到第三個 V-Sync 信號後,讀取第二個幀緩衝區的內容(視頻控制器的指針指向第二個幀緩衝區),並開始第三幀圖像的渲染並送入第一個幀緩衝區,依次不斷循環往復。

請查看資料,需要梯子:Multiple buffering

2. 卡頓產生的原因

卡頓原因

VSync 信號到來後,系統圖形服務會通過 CADisplayLink 等機制通知 App,App 主線程開始在 CPU 中計算顯示內容(視圖創建、佈局計算、圖片解碼、文本繪製等)。然後將計算的內容提交到 GPU,GPU 經過圖層的變換、合成、渲染,隨後 GPU 把渲染結果提交到幀緩衝區,等待下一次 VSync 信號到來再顯示之前渲染好的結果。在垂直同步機制的情況下,如果在一個 VSync 時間週期內,CPU 或者 GPU 沒有完成內容的提交,就會造成該幀的丟棄,等待下一次機會再顯示,這時候屏幕上還是之前渲染的圖像,所以這就是 CPU、GPU 層面界面卡頓的原因。

目前 iOS 設備有雙緩存機制,也有三緩衝機制,Android 現在主流是三緩衝機制,在早期是單緩衝機制。
iOS 三緩衝機制例子

CPU 和 GPU 資源消耗原因很多,比如對象的頻繁創建、屬性調整、文件讀取、視圖層級的調整、佈局的計算(AutoLayout 視圖個數多了就是線性方程求解難度變大)、圖片解碼(大圖的讀取優化)、圖像繪製、文本渲染、數據庫讀取(多讀還是多寫樂觀鎖、悲觀鎖的場景)、鎖的使用(舉例:自旋鎖使用不當會浪費 CPU)等方面。開發者根據自身經驗尋找最優解(這裏不是本文重點)。

3. APM 如何監控卡頓並上報

CADisplayLink 肯定不用了,這個 FPS 僅作爲參考。一般來講,卡頓的監測有2種方案:監聽 RunLoop 狀態回調、子線程 ping 主線程

3.1 RunLoop 狀態監聽的方式

RunLoop 負責監聽輸入源進行調度處理。比如網絡、輸入設備、週期性或者延遲事件、異步回調等。RunLoop 會接收2種類型的輸入源:一種是來自另一個線程或者來自不同應用的異步消息(source0事件)、另一種是來自預定或者重複間隔的事件。

RunLoop 狀態如下圖

RunLoop

第一步:通知 Observers,RunLoop 要開始進入 loop,緊接着進入 loop

if (currentMode->_observerMask & kCFRunLoopEntry )
    // 通知 Observers: RunLoop 即將進入 loop
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
// 進入loop
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);

第二步:開啓 do while 循環保活線程,通知 Observers,RunLoop 觸發 Timer 回調、Source0 回調,接着執行被加入的 block

 if (rlm->_observerMask & kCFRunLoopBeforeTimers)
    //  通知 Observers: RunLoop 即將觸發 Timer 回調
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);
if (rlm->_observerMask & kCFRunLoopBeforeSources)
    //  通知 Observers: RunLoop 即將觸發 Source 回調
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);
// 執行被加入的block
__CFRunLoopDoBlocks(rl, rlm);

第三步:RunLoop 在觸發 Source0 回調後,如果 Source1 是 ready 狀態,就會跳轉到 handle_msg 去處理消息。

//  如果有 Source1 (基於port) 處於 ready 狀態,直接處理這個 Source1 然後跳轉去處理消息
if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
    msg = (mach_msg_header_t *)msg_buffer;
    
    if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {
        goto handle_msg;
    }
#elif DEPLOYMENT_TARGET_WINDOWS
    if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) {
        goto handle_msg;
    }
#endif
}

第四步:回調觸發後,通知 Observers 即將進入休眠狀態

Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
// 通知 Observers: RunLoop 的線程即將進入休眠(sleep)
if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
	__CFRunLoopSetSleeping(rl);

第五步:進入休眠後,會等待 mach_port 消息,以便再次喚醒。只有以下4種情況纔可以被再次喚醒。

  • 基於 port 的 source 事件
  • Timer 時間到
  • RunLoop 超時
  • 被調用者喚醒
do {
    if (kCFUseCollectableAllocator) {
        // objc_clear_stack(0);
        // <rdar://problem/16393959>
        memset(msg_buffer, 0, sizeof(msg_buffer));
    }
    msg = (mach_msg_header_t *)msg_buffer;
    
    __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);
    
    if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
        // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer.
        while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue));
        if (rlm->_timerFired) {
            // Leave livePort as the queue port, and service timers below
            rlm->_timerFired = false;
            break;
        } else {
            if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg);
        }
    } else {
        // Go ahead and leave the inner loop.
        break;
    }
} while (1);

第六步:喚醒時通知 Observer,RunLoop 的線程剛剛被喚醒了

// 通知 Observers: RunLoop 的線程剛剛被喚醒了
if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);
    // 處理消息
    handle_msg:;
    __CFRunLoopSetIgnoreWakeUps(rl);

第七步:RunLoop 喚醒後,處理喚醒時收到的消息

  • 如果是 Timer 時間到,則觸發 Timer 的回調
  • 如果是 dispatch,則執行 block
  • 如果是 source1 事件,則處理這個事件
#if USE_MK_TIMER_TOO
        // 如果一個 Timer 到時間了,觸發這個Timer的回調
        else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) {
            CFRUNLOOP_WAKEUP_FOR_TIMER();
            // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled.
            // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754
            if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
                // Re-arm the next timer
                __CFArmNextTimerInMode(rlm, rl);
            }
        }
#endif
        //  如果有dispatch到main_queue的block,執行block
        else if (livePort == dispatchPort) {
            CFRUNLOOP_WAKEUP_FOR_DISPATCH();
            __CFRunLoopModeUnlock(rlm);
            __CFRunLoopUnlock(rl);
            _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL);
#if DEPLOYMENT_TARGET_WINDOWS
            void *msg = 0;
#endif
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
            _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL);
            __CFRunLoopLock(rl);
            __CFRunLoopModeLock(rlm);
            sourceHandledThisLoop = true;
            didDispatchPortLastTime = true;
        }
        // 如果一個 Source1 (基於port) 發出事件了,處理這個事件
        else {
            CFRUNLOOP_WAKEUP_FOR_SOURCE();
            
            // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again.
            voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release);

            CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort);
            if (rls) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
		mach_msg_header_t *reply = NULL;
		sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop;
		if (NULL != reply) {
		    (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL);
		    CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply);
		}
#elif DEPLOYMENT_TARGET_WINDOWS
                sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop;
#endif

第八步:根據當前 RunLoop 狀態判斷是否需要進入下一個 loop。當被外部強制停止或者 loop 超時,就不繼續下一個 loop,否則進入下一個 loop

if (sourceHandledThisLoop && stopAfterHandle) {
    // 進入loop時參數說處理完事件就返回
    retVal = kCFRunLoopRunHandledSource;
    } else if (timeout_context->termTSR < mach_absolute_time()) {
        // 超出傳入參數標記的超時時間了
        retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(rl)) {
        __CFRunLoopUnsetStopped(rl);
    // 被外部調用者強制停止了
    retVal = kCFRunLoopRunStopped;
} else if (rlm->_stopped) {
    rlm->_stopped = false;
    retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) {
    // source/timer一個都沒有
    retVal = kCFRunLoopRunFinished;
}

完整且帶有註釋的 RunLoop 代碼見此處。 Source1 是 RunLoop 用來處理 Mach port 傳來的系統事件的,Source0 是用來處理用戶事件的。收到 Source1 的系統事件後本質還是調用 Source0 事件的處理函數。

RunLoop 狀態
RunLoop 6個狀態

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry ,           // 進入 loop
    kCFRunLoopBeforeTimers ,    // 觸發 Timer 回調
    kCFRunLoopBeforeSources ,   // 觸發 Source0 回調
    kCFRunLoopBeforeWaiting ,   // 等待 mach_port 消息
    kCFRunLoopAfterWaiting ),   // 接收 mach_port 消息
    kCFRunLoopExit ,            // 退出 loop
    kCFRunLoopAllActivities     // loop 所有狀態改變
}

RunLoop 在進入睡眠前的方法執行時間過長而導致無法進入睡眠,或者線程喚醒後接收消息時間過長而無法進入下一步,都會阻塞線程。如果是主線程,則表現爲卡頓。

一旦發現進入睡眠前的 KCFRunLoopBeforeSources 狀態,或者喚醒後 KCFRunLoopAfterWaiting,在設置的時間閾值內沒有變化,則可判斷爲卡頓,此時 dump 堆棧信息,還原案發現場,進而解決卡頓問題。

開啓一個子線程,不斷進行循環監測是否卡頓了。在 n 次都超過卡頓閾值後則認爲卡頓了。卡頓之後進行堆棧 dump 並上報(具有一定的機制,數據處理在下一 part 講)。

WatchDog 在不同狀態下具有不同的值。

  • 啓動(Launch):20s
  • 恢復(Resume):10s
  • 掛起(Suspend):10s
  • 退出(Quit):6s
  • 後臺(Background):3min(在 iOS7 之前可以申請 10min;之後改爲 3min;可連續申請,最多到 10min)

卡頓閾值的設置的依據是 WatchDog 的機制。APM 系統裏面的閾值需要小於 WatchDog 的值,所以取值範圍在 [1, 6] 之間,業界通常選擇3秒。

通過 long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout) 方法判斷是否阻塞主線程,Returns zero on success, or non-zero if the timeout occurred. 返回非0則代表超時阻塞了主線程。

RunLoop-ANR

可能很多人納悶 RunLoop 狀態那麼多,爲什麼選擇 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?因爲大部分卡頓都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之間。比如 Source0 類型的 App 內部事件等

Runloop 檢測卡頓流程圖如下:

RunLoop ANR

關鍵代碼如下:

// 設置Runloop observer的運行環境
CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
// 創建Runloop observer對象
_observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                    kCFRunLoopAllActivities,
                                    YES,
                                    0,
                                    &runLoopObserverCallBack,
                                    &context);
// 將新建的observer加入到當前thread的runloop
CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
// 創建信號
_semaphore = dispatch_semaphore_create(0);

__weak __typeof(self) weakSelf = self;
// 在子線程監控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    __strong __typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    while (YES) {
        if (strongSelf.isCancel) {
            return;
        }
        // N次卡頓超過閾值T記錄爲一次卡頓
        long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC));
        if (semaphoreWait != 0) {
            if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) {
                if (++strongSelf.countTime < strongSelf.standstillCount){
                    continue;
                }
                // 堆棧信息 dump 並結合數據上報機制,按照一定策略上傳數據到服務器。堆棧 dump 會在下面講解。數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
            }
        }
        strongSelf.countTime = 0;
    }
});

3.2 子線程 ping 主線程監聽的方式

開啓一個子線程,創建一個初始值爲0的信號量、一個初始值爲 YES 的布爾值類型標誌位。將設置標誌位爲 NO 的任務派發到主線程中去,子線程休眠閾值時間,時間到後判斷標誌位是否被主線程成功(值爲 NO),如果沒成功則認爲豬線程發生了卡頓情況,此時 dump 堆棧信息並結合數據上報機制,按照一定策略上傳數據到服務器。數據上報會在 打造功能強大、靈活可配置的數據上報組件

while (self.isCancelled == NO) {
        @autoreleasepool {
            __block BOOL isMainThreadNoRespond = YES;
            
            dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
            
            dispatch_async(dispatch_get_main_queue(), ^{
                isMainThreadNoRespond = NO;
                dispatch_semaphore_signal(semaphore);
            });
            
            [NSThread sleepForTimeInterval:self.threshold];
            
            if (isMainThreadNoRespond) {
                if (self.handlerBlock) {
                    self.handlerBlock(); // 外部在 block 內部 dump 堆棧(下面會講),數據上報
                }
            }
            
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        }
    }

4. 堆棧 dump

方法堆棧的獲取是一個麻煩事。理一下思路。[NSThread callStackSymbols] 可以獲取當前線程的調用棧。但是當監控到卡頓發生,需要拿到主線程的堆棧信息就無能爲力了。從任何線程回到主線程這條路走不通。先做個知識回顧。

在計算機科學中,調用堆棧是一種棧類型的數據結構,用於存儲有關計算機程序的線程信息。這種棧也叫做執行堆棧、程序堆棧、控制堆棧、運行時堆棧、機器堆棧等。調用堆棧用於跟蹤每個活動的子例程在完成執行後應該返回控制的點。

維基百科搜索到 “Call Stack” 的一張圖和例子,如下
調用棧
上圖表示爲一個棧。分爲若干個棧幀(Frame),每個棧幀對應一個函數調用。下面藍色部分表示 DrawSquare 函數,它在執行的過程中調用了 DrawLine 函數,用綠色部分表示。

可以看到棧幀由三部分組成:函數參數、返回地址、局部變量。比如在 DrawSquare 內部調用了 DrawLine 函數:第一先把 DrawLine 函數需要的參數入棧;第二把返回地址(控制信息。舉例:函數 A 內調用函數 B,調用函數B 的下一行代碼的地址就是返回地址)入棧;第三函數內部的局部變量也在該棧中存儲。

棧指針 Stack Pointer 表示當前棧的頂部,大多部分操作系統都是棧向下生長,所以棧指針是最小值。幀指針 Frame Pointer 指向的地址中,存儲了上一次 Stack Pointer 的值,也就是返回地址。

大多數操作系統中,每個棧幀還保存了上一個棧幀的幀指針。因此知道當前棧幀的 Stack Pointer 和 Frame Pointer 就可以不斷回溯,遞歸獲取棧底的幀。

接下來的步驟就是拿到所有線程的 Stack Pointer 和 Frame Pointer。然後不斷回溯,還原案發現場。

5. Mach Task 知識

Mach task:

App 在運行的時候,會對應一個 Mach Task,而 Task 下可能有多條線程同時執行任務。《OS X and iOS Kernel Programming》 中描述 Mach Task 爲:任務(Task)是一種容器對象,虛擬內存空間和其他資源都是通過這個容器對象管理的,這些資源包括設備和其他句柄。簡單概括爲:Mack task 是一個機器無關的 thread 的執行環境抽象。

作用: task 可以理解爲一個進程,包含它的線程列表。

結構體:task_threads,將 target_task 任務下的所有線程保存在 act_list 數組中,數組個數爲 act_listCnt

kern_return_t task_threads
(
  task_t traget_task,
  thread_act_array_t *act_list,                     //線程指針列表
  mach_msg_type_number_t *act_listCnt  //線程個數
)

thread_info:

kern_return_t thread_info
(
  thread_act_t target_act,
  thread_flavor_t flavor,
  thread_info_t thread_info_out,
  mach_msg_type_number_t *thread_info_outCnt
);

如何獲取線程的堆棧數據:

系統方法 kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt); 可以獲取到所有的線程,不過這種方法獲取到的線程信息是最底層的 mach 線程

對於每個線程,可以用 kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt); 方法獲取它的所有信息,信息填充在 _STRUCT_MCONTEXT 類型的參數中,這個方法中有2個參數隨着 CPU 架構不同而不同。所以需要定義宏屏蔽不同 CPU 之間的區別。

_STRUCT_MCONTEXT 結構體中,存儲了當前線程的 Stack Pointer 和最頂部棧幀的 Frame pointer,進而回溯整個線程調用堆棧。

但是上述方法拿到的是內核線程,我們需要的信息是 NSThread,所以需要將內核線程轉換爲 NSThread。

pthread 的 p 是 POSIX 的縮寫,表示「可移植操作系統接口」(Portable Operating System Interface)。設計初衷是每個系統都有自己獨特的線程模型,且不同系統對於線程操作的 API 都不一樣。所以 POSIX 的目的就是提供抽象的 pthread 以及相關 API。這些 API 在不同的操作系統中有不同的實現,但是完成的功能一致。

Unix 系統提供的 task_threadsthread_get_state 操作的都是內核系統,每個內核線程由 thread_t 類型的 id 唯一標識。pthread 的唯一標識是 pthread_t 類型。其中內核線程和 pthread 的轉換(即 thread_t 和 pthread_t)很容易,因爲 pthread 設計初衷就是「抽象內核線程」。

memorystatus_action_neededpthread_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 其實就是字符串 @"_NSThreadDidStartNotification"。

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

爲了 NSThread 和內核線程對應起來,只能通過 name 一一對應。 pthread 的 API pthread_getname_np 也可獲取內核線程名字。np 代表 not POSIX,所以不能跨平臺使用。

思路概括爲:將 NSThread 的原始名字存儲起來,再將名字改爲某個隨機數(時間戳),然後遍歷內核線程 pthread 的名字,名字匹配則 NSThread 和內核線程對應了起來。找到後將線程的名字還原成原本的名字。對於主線程,由於不能使用 pthread_getname_np,所以在當前代碼的 load 方法中獲取到 thread_t,然後匹配名字。

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

二、 App 啓動時間監控

1. App 啓動時間的監控

應用啓動時間是影響用戶體驗的重要因素之一,所以我們需要量化去衡量一個 App 的啓動速度到底有多快。啓動分爲冷啓動和熱啓動。

App 啓動時間

冷啓動:App 尚未運行,必須加載並構建整個應用。完成應用的初始化。冷啓動存在較大優化空間。冷啓動時間從 application: didFinishLaunchingWithOptions: 方法開始計算,App 一般在這裏進行各種 SDK 和 App 的基礎初始化工作。

熱啓動:應用已經在後臺運行(常見場景:比如用戶使用 App 過程中點擊 Home 鍵,再打開 App),由於某些事件將應用喚醒到前臺,App 會在 applicationWillEnterForeground: 方法接受應用進入前臺的事件

思路比較簡單。如下

  • 在監控類的 load 方法中先拿到當前的時間值
  • 監聽 App 啓動完成後的通知 UIApplicationDidFinishLaunchingNotification
  • 收到通知後拿到當前的時間
  • 步驟1和3的時間差就是 App 啓動時間。

mach_absolute_time 是一個 CPU/總線依賴函數,返回一個 CPU 時鐘週期數。系統休眠時不會增加。是一個納秒級別的數字。獲取前後2個納秒後需要轉換到秒。需要基於系統時間的基準,通過 mach_timebase_info 獲得。

mach_timebase_info_data_t g_cmmStartupMonitorTimebaseInfoData = 0;
mach_timebase_info(&g_cmmStartupMonitorTimebaseInfoData);
uint64_t timelapse = mach_absolute_time() - g_cmmLoadTime;
double timeSpan = (timelapse * g_cmmStartupMonitorTimebaseInfoData.numer) / (g_cmmStartupMonitorTimebaseInfoData.denom * 1e9);

2. 線上監控啓動時間就好,但是在開發階段需要對啓動時間做優化。

要優化啓動時間,就先得知道在啓動階段到底做了什麼事情,針對現狀作出方案。

pre-main 階段定義爲 App 開始啓動到系統調用 main 函數這個階段;main 階段定義爲 main 函數入口到主 UI 框架的 viewDidAppear。

App 啓動過程:

  • 解析 Info.plist:加載相關信息例如閃屏;沙盒建立、權限檢查;
  • Mach-O 加載:如果是胖二進制文件,尋找合適當前 CPU 架構的部分;加載所有依賴的 Mach-O 文件(遞歸調用 Mach-O 加載的方法);定義內部、外部指針引用,例如字符串、函數等;加載分類中的方法;c++ 靜態對象加載、調用 Objc 的 +load() 函數;執行聲明爲 _attribute((constructor)) 的 c 函數;
  • 程序執行:調用 main();調用 UIApplicationMain();調用 applicationWillFinishLaunching();

Pre-Main 階段
Pre-Main 階段

Main 階段
Main 階段

2.1 加載 Dylib

每個動態庫的加載,dyld 需要

  • 分析所依賴的動態庫
  • 找到動態庫的 Mach-O 文件
  • 打開文件
  • 驗證文件
  • 在系統核心註冊文件簽名
  • 對動態庫的每一個 segment 調用 mmap()

優化:

  • 減少非系統庫的依賴
  • 使用靜態庫而不是動態庫
  • 合併非系統動態庫爲一個動態庫

2.2 Rebase && Binding

優化:

  • 減少 Objc 類數量,減少 selector 數量,把未使用的類和函數都可以刪掉
  • 減少 c++ 虛函數數量
  • 轉而使用 Swift struct(本質就是減少符號的數量)

2.3 Initializers

優化:

  • 使用 +initialize 代替 +load
  • 不要使用過 attribute*((constructor)) 將方法顯示標記爲初始化器,而是讓初始化方法調用時才執行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用時才初始化,推遲了一部分工作耗時也儘量不要使用 c++ 的靜態對象

2.4 pre-main 階段影響因素

  • 動態庫加載越多,啓動越慢。
  • ObjC 類越多,函數越多,啓動越慢。
  • 可執行文件越大啓動越慢。
  • C 的 constructor 函數越多,啓動越慢。
  • C++ 靜態對象越多,啓動越慢。
  • ObjC 的 +load 越多,啓動越慢。

優化手段:

  • 減少依賴不必要的庫,不管是動態庫還是靜態庫;如果可以的話,把動態庫改造成靜態庫;如果必須依賴動態庫,則把多個非系統的動態庫合併成一個動態庫
  • 檢查下 framework應當設爲optional和required,如果該framework在當前App支持的所有iOS系統版本都存在,那麼就設爲required,否則就設爲optional,因爲optional會有些額外的檢查
  • 合併或者刪減一些OC類和函數。關於清理項目中沒用到的類,使用工具AppCode代碼檢查功能,查到當前項目中沒有用到的類(也可以用根據linkmap文件來分析,但是準確度不算很高)
    有一個叫做FUI的開源項目能很好的分析出不再使用的類,準確率非常高,唯一的問題是它處理不了動態庫和靜態庫裏提供的類,也處理不了C++的類模板
  • 刪減一些無用的靜態變量
  • 刪減沒有被調用到或者已經廢棄的方法
  • 將不必須在 +load 方法中做的事情延遲到 +initialize中,儘量不要用 C++ 虛函數(創建虛函數表有開銷)
  • 類和方法名不要太長:iOS每個類和方法名都在 __cstring 段裏都存了相應的字符串值,所以類和方法名的長短也是對可執行文件大小是有影響的
    因還是 Object-c 的動態特性,因爲需要通過類/方法名反射找到這個類/方法進行調用,Object-c 對象模型會把類/方法名字符串都保存下來;
  • 用 dispatch_once() 代替所有的 attribute((constructor)) 函數、C++ 靜態對象初始化、ObjC 的 +load 函數;
  • 在設計師可接受的範圍內壓縮圖片的大小,會有意外收穫。
    壓縮圖片爲什麼能加快啓動速度呢?因爲啓動的時候大大小小的圖片加載個十來二十個是很正常的,
    圖片小了,IO操作量就小了,啓動當然就會快了,比較靠譜的壓縮算法是 TinyPNG。

2.5 main 階段優化

  • 減少啓動初始化的流程。能懶加載就懶加載,能放後臺初始化就放後臺初始化,能延遲初始化的就延遲初始化,不要卡主線程的啓動時間,已經下線的業務代碼直接刪除
  • 優化代碼邏輯。去除一些非必要的邏輯和代碼,減小每個流程所消耗的時間
  • 啓動階段使用多線程來進行初始化,把 CPU 性能發揮最大
  • 使用純代碼而不是 xib 或者 storyboard 來描述 UI,尤其是主 UI 框架,比如 TabBarController。因爲 xib 和 storyboard 還是需要解析成代碼來渲染頁面,多了一步。

三、 CPU 使用率監控

1. CPU 架構

CPU(Central Processing Unit)中央處理器,市場上主流的架構有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。區別在於不同的 CPU 設計理念和方法

早期 CPU 全部是 CISC 架構,設計目的是用最少的機器語言指令來完成所需的計算任務。比如對於乘法運算,在 CISC 架構的 CPU 上。一條指令 MUL ADDRA, ADDRB 就可以將內存 ADDRA 和內存 ADDRB 中的數香乘,並將結果存儲在 ADDRA 中。做的事情就是:將 ADDRA、ADDRB 中的數據讀入到寄存器,相乘的結果寫入到內存的操作依賴於 CPU 設計,所以 CISC 架構會增加 CPU 的複雜性和對 CPU 工藝的要求。

RISC 架構要求軟件來指定各個操作步驟。比如上面的乘法,指令實現爲 MOVE A, ADDRA; MOVE B, ADDRB; MUL A, B; STR ADDRA, A;。這種架構可以降低 CPU 的複雜性以及允許在同樣的工藝水平下生產出功能更加強大的 CPU,但是對於編譯器的設計要求更高。

目前市場是大部分的 iPhone 都是基於 arm64 架構的。且 arm 架構能耗低。

2. 獲取線程信息

講完了區別來講下如何做 CPU 使用率的監控

  • 開啓定時器,按照設定的週期不斷執行下面的邏輯
  • 獲取當前任務 task。從當前 task 中獲取所有的線程信息(線程個數、線程數組)
  • 遍歷所有的線程信息,判斷是否有線程的 CPU 使用率超過設置的閾值
  • 假如有線程使用率超過閾值,則 dump 堆棧
  • 組裝數據,上報數據

線程信息結構體

struct thread_basic_info {
	time_value_t    user_time;      /* user run time(用戶運行時長) */
	time_value_t    system_time;    /* system run time(系統運行時長) */ 
	integer_t       cpu_usage;      /* scaled cpu usage percentage(CPU使用率,上限1000) */
	policy_t        policy;         /* scheduling policy in effect(有效調度策略) */
	integer_t       run_state;      /* run state (運行狀態,見下) */
	integer_t       flags;          /* various flags (各種各樣的標記) */
	integer_t       suspend_count;  /* suspend count for thread(線程掛起次數) */
	integer_t       sleep_time;     /* number of seconds that thread
	                                 *  has been sleeping(休眠時間) */
};

代碼在講堆棧還原的時候講過,忘記的看一下上面的分析

thread_act_array_t threads;
mach_msg_type_number_t threadCount = 0;
const task_t thisTask = mach_task_self();
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
if (kr != KERN_SUCCESS) {
    return ;
}
for (int i = 0; i < threadCount; i++) {
    thread_info_data_t threadInfo;
    thread_basic_info_t threadBaseInfo;
    mach_msg_type_number_t threadInfoCount;
    
    kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
    
    if (kr == KERN_SUCCESS) {
        
        threadBaseInfo = (thread_basic_info_t)threadInfo;
        // todo:條件判斷,看不明白
        if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
            integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
            if (cpuUsage > CPUMONITORRATE) {
                
                NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary];
                NSData *CPUPayloadData = [NSData data];
                
                NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread];
                // 1. 組裝卡頓的 Meta 信息
                CPUMetaDictionary[@"MONITOR_TYPE"] = CMMonitorCPUType;
            
                // 2. 組裝卡頓的 Payload 信息(一個JSON對象,對象的 Key 爲約定好的 STACK_TRACE, value 爲 base64 後的堆棧信息)
                NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding];
                NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0];
                NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)};
                
                NSError *error;
                // NSJSONWritingOptions 參數一定要傳0,因爲服務端需要根據 \n 處理邏輯,傳遞 0 則生成的 json 串不帶 \n
                NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error];
                if (error) {
                    CMMLog(@"%@", error);
                    return;
                }
                CPUPayloadData = [parsedData copy];
                
                // 3. 數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
                [[PrismClient sharedInstance] sendWithType:CMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; 
            }
        }
    }
}

文章較長,拆分爲。Github 上完整文章閱讀體驗更佳,請點擊訪問 Github

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章