關於線上檢測主執行緒卡頓的問題

JungHsu發表於2017-10-23

大家好,第一次在掘金這個平臺寫東西。如有錯誤,希望指出。
最近發現網上經常被人討論的APP線上上狀態如何檢測到主執行緒的卡頓情況,我也稍微瞭解了一下,前段時間就在一個博主的文章裡看到一篇有部分講解這個問題的,據說美團用的也是這種方案,具體不得而知,然後我發現網上關於這種問題的實現方案都十分類似,如果螢幕前的你還沒有意識過這個問題,那就請聽我往下分析這個網上常用的檢測方案:

利用runloop的檢測方案

關於runloop是什麼我就不多說了,因為網上有很多關於這個的文章,最推薦的還是YYKit的作者部落格上那篇。
我要拿出來注意的是 runloop 的狀態:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即將進入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即將處理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即將處理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即將進入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 剛從休眠中喚醒
    kCFRunLoopExit          = (1UL << 7), // 即將退出Loop
};複製程式碼

網上熱議的是利用 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 這兩個狀態之間的耗時進行判斷是否有太多事件處理導致出現了卡頓,下面直接上程式碼:

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    PingConfig *object = (__bridge PingConfig*)info;

    // 記錄狀態值
    object->activity = activity;

    // 傳送訊號
    dispatch_semaphore_t semaphore = object->semaphore;
    dispatch_semaphore_signal(semaphore);
}複製程式碼

上面這些是監聽runloop的狀態而寫的回撥函式

- (void)registerObserver
{
    PingConfig *config = [PingConfig new];
    // 建立訊號
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    config->semaphore = semaphore;

    CFRunLoopObserverContext context = {0,(__bridge void*)config,NULL,NULL};
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            0,
                                                            &runLoopObserverCallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);

    __block uint8_t timeoutCount = 0;

    // 在子執行緒監控時長
    dispatch_async(dispatch_get_global_queue(0, 0), ^{


        while (YES)
        {
            // 假定連續5次超時50ms認為卡頓(當然也包含了單次超時250ms)
            long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));

            if (st != 0)
            {

//                NSLog(@"迴圈中--%ld",config->activity);
                if (config->activity==kCFRunLoopBeforeSources || config->activity==kCFRunLoopAfterWaiting)
                {
                    if (++timeoutCount < 5){
                        continue;
                    }else{
                        NSLog(@"卡頓了");
                    }

                }


            }
            timeoutCount = 0;
        }
    });
}複製程式碼

現在我解讀一下這段程式碼:

  1. PingConfig 只是我隨便寫的一個用來儲存runloop的狀態和訊號量的自定義類,其中的結構如下:
    @interface PingConfig : NSObject
    {
     @public
     CFRunLoopActivity activity;
     dispatch_semaphore_t semaphore;
    }
    @end複製程式碼
    恩,只有這麼多足矣。
  2. APP啟動時我可以進入 registerObserver 方法,其中首先我建立一個記錄資訊的類PingConfig例項,然後建立一個訊號,並且儲存在這個PingConfig例項中(其實只是為了方便拿到)。
  3. 接下來我建立了一個觀察者監測主執行緒的 runloop,它會在主執行緒runloop狀態切換時進行回撥。
  4. 開啟一個子執行緒,並且在裡面進行一個 while 迴圈,在 迴圈的開始處 wait 一個訊號量,並且設定超時為 50毫秒,失敗後會返回一個非0數,成功將會返回0,這時候執行緒會阻塞住等待一個訊號的發出。
  5. 如果runloop狀態正常切換,那麼就會進入回撥函式,在回撥函式中我們發出一個訊號,並且記錄當前狀態到PingConfig例項中,下面的判斷語句中發現為0,timeoutCount自動置為0,一切正常。
  6. 當主執行緒出現卡頓,while迴圈中的訊號量再次等待,但是回撥函式沒有觸發,從而導致等待超時,返回一個非0數,進入判斷句後,我們再次判斷狀態是否處於 kCFRunLoopBeforeSources 或 kCFRunLoopAfterWaiting,如果成立,timeoutCount+1。
  7. 持續五次runloop不切換狀態,說明runloop正在處理某個棘手的事件無法休息且不更新狀態,這樣while迴圈中的訊號量超時會一直髮生,超過五次後我們將斷定主執行緒的卡頓並上傳堆疊資訊。

經過測試,的確可以檢測到主執行緒的卡頓現象,不得不佩服大佬們的方案。
但是在一次測試中,發現當主執行緒卡在介面尚未完全顯示前,這個方案就檢測不出來卡頓了,比如我將下面的程式碼放在B控制器中:

    dispatch_semaphore_t t = dispatch_semaphore_create(0);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"----");
        dispatch_semaphore_signal(t);
    });
    dispatch_semaphore_wait(t, DISPATCH_TIME_FOREVER);複製程式碼

上面是一段有問題的程式碼,將導致主執行緒的持續堵塞,如果我們在這段程式碼放在B控制器的ViewDidLoad方法中(ViewWillAppear同樣),這樣執行後,當你希望push到B控制器時,專案將在上一個介面完全卡住,並且無法用上面的方案檢測到,而且CPU及記憶體都顯示正常:

QQ20170930-153549@2x.png
QQ20170930-153549@2x.png

具體原因我想了一下,由於runloop在處理完source0或者source1後,比如介面的跳轉也是執行了方法,具體有沒有用到source0這不重要,但是後面會緊接著進入準備睡眠(kCFRunLoopBeforeWaiting)的狀態,然而此時執行緒的阻塞導致runloop的狀態也被卡住無法切換,這樣也就導致在那段檢測程式碼中無法進入條件,從而檢測不出來。
但是話說回來,APP在靜止狀態(保持休眠)和剛剛那種卡死狀態都會使runloop維持在 kCFRunLoopBeforeWaiting狀態,這樣我們就無法在那段程式碼中增加判斷來修復,因為無法知道到底是真的靜止沒有操作還是被阻塞住,我也沒找到執行緒的阻塞狀態屬性,如果你發現這個屬性,那麼就可以使用那個屬性來判斷。但是我也得說下在沒找到那個屬性時我的檢測方案:

我的檢測方案

先上程式碼:

    dispatch_queue_t serialQueue = dispatch_queue_create("serial", DISPATCH_QUEUE_SERIAL);
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, serialQueue);
    dispatch_source_set_timer(self.timer, DISPATCH_TIME_NOW, 0.25 * NSEC_PER_SEC, 0);

    __block int8_t chokeCount = 0;
    dispatch_semaphore_t t2 = dispatch_semaphore_create(0);
    dispatch_source_set_event_handler(self.timer, ^{
        if (config->activity == kCFRunLoopBeforeWaiting) {
            static BOOL ex = YES;
            if (ex == NO) {
                chokeCount ++;
                if (chokeCount > 40) {
                    NSLog(@"差不多卡死了");
                    dispatch_suspend(self.timer);
                    return ;
                }
                NSLog(@"卡頓了");
                return ;
            }
            dispatch_async(dispatch_get_main_queue(), ^{
                ex = YES;
                dispatch_semaphore_signal(t2);
            });
            BOOL su = dispatch_semaphore_wait(t2, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
            if (su != 0) {
                ex = NO;
            };
        }
    });
    dispatch_resume(self.timer);複製程式碼

解釋一下我的方案:

  1. 開啟一個非同步佇列,並且建立一個定時器,時間我設定的是0.25秒,具體時間隨你自己,這個時間是用來檢測卡死的持續時間。
  2. 在定時器外面我也同樣建立了一個用來同步的訊號量,這個不解釋了,不會的就去看一下訊號量的使用方式。進入定時器的回撥後,我設定了一個靜態變數來記錄主佇列是否執行完成。
  3. 我們判斷當前runloop的狀態是否為kCFRunLoopBeforeWaiting,所以這個方案是用來彌補前面那個方案,如果主執行緒此時沒有阻塞住,我們在這裡向main Queue拋一個block,看它是否能夠成功執行,如果成功執行,說明主執行緒沒有阻塞住,如果已經被阻塞住,那我拋過去的block是肯定不會被執行的。
  4. 下面的程式碼就是一些輔助操作,當訊號量超過50毫秒,拋給主執行緒的block沒有執行,那麼說明此時就有一些阻塞了,返回一個非0數,並設定 ex為NO,從而在下一次定時器回撥到來時進行上報。

我寫的這段解決方案中的示例程式碼只是用來演示,具體是原理可以大家盡情在此基礎上優化,目前在我的專案中可以正常檢測到之前那種阻塞造成的APP卡死現象,如果你發現有更好的檢測方案,希望能告訴我,謝謝!

相關文章