關(guān)于線上檢測主線程卡頓的問題

大家好,一年多沒有更新文章了,最大的原因我想是不知道該分享些什么,這次是在一個巧合下發(fā)現(xiàn)網(wǎng)上經(jīng)常被人討論的APP在線上狀態(tài)如何檢測到主線程的卡頓情況,我也稍微了解了一下,前段時間就在一個博主的文章里看到一篇有部分講解這個問題的,據(jù)說美團(tuán)用的也是這種方案,具體不得而知,然后我發(fā)現(xiàn)網(wǎng)上關(guān)于這種問題的實現(xiàn)方案都十分類似,如果屏幕前的你還沒有意識過這個問題,那就請聽我往下分析這個網(wǎng)上常用的檢測方案:

利用runloop的檢測方案

關(guān)于runloop是什么我就不多說了,因為網(wǎng)上有很多關(guān)于這個的文章,最推薦的還是YYKit的作者博客上那篇。
我要拿出來注意的是 runloop 的狀態(tài):

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

網(wǎng)上熱議的是利用 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 這兩個狀態(tài)之間的耗時進(jìn)行判斷是否有太多事件處理導(dǎo)致出現(xiàn)了卡頓,下面直接上代碼:

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    PingConfig *object = (__bridge PingConfig*)info;
    
    // 記錄狀態(tài)值
    object->activity = activity;

    // 發(fā)送信號
    dispatch_semaphore_t semaphore = object->semaphore;
    dispatch_semaphore_signal(semaphore);
}

上面這些是監(jiān)聽runloop的狀態(tài)而寫的回調(diào)函數(shù)

- (void)registerObserver
{
    PingConfig *config = [PingConfig new];
    // 創(chuàng)建信號
    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;

    // 在子線程監(jiān)控時長
    dispatch_async(dispatch_get_global_queue(0, 0), ^{

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

            if (st != 0)
            {

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

                }
                
 
            }
            timeoutCount = 0;
        }
    });
}

現(xiàn)在我解讀一下這段代碼:

  1. PingConfig 只是我隨便寫的一個用來存儲runloop的狀態(tài)和信號量的自定義類,其中的結(jié)構(gòu)如下:
@interface PingConfig : NSObject
{
    @public
    CFRunLoopActivity activity;
    dispatch_semaphore_t semaphore;
}
@end

恩,只有這么多足矣。

  1. APP啟動時我可以進(jìn)入 registerObserver 方法,其中首先我創(chuàng)建一個記錄信息的類PingConfig實例,然后創(chuàng)建一個信號,并且保存在這個PingConfig實例中(其實只是為了方便拿到)。
  2. 接下來我創(chuàng)建了一個觀察者監(jiān)測主線程的 runloop,它會在主線程runloop狀態(tài)切換時進(jìn)行回調(diào)。
  3. 開啟一個子線程,并且在里面進(jìn)行一個 while 循環(huán),在 循環(huán)的開始處 wait 一個信號量,并且設(shè)置超時為 50毫秒,失敗后會返回一個非0數(shù),成功將會返回0,這時候線程會阻塞住等待一個信號的發(fā)出。
  4. 如果runloop狀態(tài)正常切換,那么就會進(jìn)入回調(diào)函數(shù),在回調(diào)函數(shù)中我們發(fā)出一個信號,并且記錄當(dāng)前狀態(tài)到PingConfig實例中,下面的判斷語句中發(fā)現(xiàn)為0,timeoutCount自動置為0,一切正常。
  5. 當(dāng)主線程出現(xiàn)卡頓,while循環(huán)中的信號量再次等待,但是回調(diào)函數(shù)沒有觸發(fā),從而導(dǎo)致等待超時,返回一個非0數(shù),進(jìn)入判斷句后,我們再次判斷狀態(tài)是否處于 kCFRunLoopBeforeSources 或 kCFRunLoopAfterWaiting,如果成立,timeoutCount+1。
  6. 持續(xù)五次runloop不切換狀態(tài),說明runloop正在處理某個棘手的事件無法休息且不更新狀態(tài),這樣while循環(huán)中的信號量超時會一直發(fā)生,超過五次后我們將斷定主線程的卡頓并上傳堆棧信息。

經(jīng)過測試,的確可以檢測到主線程的卡頓現(xiàn)象,不得不佩服大佬們的方案。
但是在一次測試中,發(fā)現(xiàn)當(dāng)主線程卡在界面尚未完全顯示前,這個方案就檢測不出來卡頓了,比如我將下面的代碼放在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);

上面是一段有問題的代碼,將導(dǎo)致主線程的持續(xù)堵塞,如果我們在這段代碼放在B控制器的ViewDidLoad方法中(ViewWillAppear同樣),這樣運行后,當(dāng)你希望push到B控制器時,項目將在上一個界面完全卡住,并且無法用上面的方案檢測到,而且CPU及內(nèi)存都顯示正常:

QQ20170930-153549@2x.png

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

我的檢測方案

先上代碼:

    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. 開啟一個異步隊列,并且創(chuàng)建一個定時器,時間我設(shè)置的是0.25秒,具體時間隨你自己,這個時間是用來檢測卡死的持續(xù)時間。
  2. 在定時器外面我也同樣創(chuàng)建了一個用來同步的信號量,這個不解釋了,不會的就去看一下信號量的使用方式。進(jìn)入定時器的回調(diào)后,我設(shè)置了一個靜態(tài)變量來記錄主隊列是否執(zhí)行完成。
  3. 我們判斷當(dāng)前runloop的狀態(tài)是否為kCFRunLoopBeforeWaiting,所以這個方案是用來彌補(bǔ)前面那個方案,如果主線程此時沒有阻塞住,我們在這里向main Queue拋一個block,看它是否能夠成功執(zhí)行,如果成功執(zhí)行,說明主線程沒有阻塞住,如果已經(jīng)被阻塞住,那我拋過去的block是肯定不會被執(zhí)行的。
  4. 下面的代碼就是一些輔助操作,當(dāng)信號量超過50毫秒,拋給主線程的block沒有執(zhí)行,那么說明此時就有一些阻塞了,返回一個非0數(shù),并設(shè)置 ex為NO,從而在下一次定時器回調(diào)到來時進(jìn)行上報。

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

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

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,781評論 25 709
  • 從哪說起呢? 單純講多線程編程真的不知道從哪下嘴。。 不如我直接引用一個最簡單的問題,以這個作為切入點好了 在ma...
    Mr_Baymax閱讀 2,903評論 1 17
  • Object C中創(chuàng)建線程的方法是什么?如果在主線程中執(zhí)行代碼,方法是什么?如果想延時執(zhí)行代碼、方法又是什么? 1...
    AlanGe閱讀 1,908評論 0 17
  • 本文將從以下幾個部分來介紹多線程。 第一部分介紹多線程的基本原理。 第二部分介紹Run loop。 第三部分介紹多...
    曲年閱讀 1,339評論 2 14
  • 文/愛學(xué)習(xí)的飛哥 ‖飛哥有話說,專注于探求大學(xué)生學(xué)習(xí)、讀書、生活那些事。 ‖本文系作者原創(chuàng)文章,未經(jīng)許可,不得轉(zhuǎn)載...
    愛學(xué)習(xí)的飛哥閱讀 704評論 1 28

友情鏈接更多精彩內(nèi)容