運用 Runloop 對主線程耗時的一次分析

運用 Runloop 對主線程耗時的一次分析

(小編在深圳小廠碼代碼,最近公司各種職位熱招,需要內(nèi)推的可以私聊~)

一、Runloop 簡述:

1、作用:

1.保持程序持續(xù)運行:例如程序一啟動就會開一個主線程,主線程一開起來就會跑一個主線程對應的RunLoop,RunLoop 保證主線程不會被銷毀,也就保證了程序的持續(xù)運行;

例如子線程的?;?。

2.處理 App 中的各種事件

比如:觸摸事件,定時器事件,Selector 事件等;

3.節(jié)省 CPU 資源,優(yōu)化程序性能:程序運行起來時,當什么操作都沒有做的時候,RunLoop 就通知系統(tǒng),現(xiàn)在沒有事情做,然后進行休息待命狀態(tài),這時系統(tǒng)就會將其資源釋放出來去做其他的事情。當有事情做,也就是一有響應的時候 RunLoop 就會喚醒線程去做事情;

主要的作用是:不停跑 Runloop 處理各種事件,休眠 or 喚醒工作 。

2、大體的流程:

 int32_t __CFRunLoopRun()
{
    // 通知即將進入runloop
    __CFRunLoopDoObservers(KCFRunLoopEntry);
    
    do
    {
        // 通知將要處理timer和source
        __CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
        __CFRunLoopDoObservers(kCFRunLoopBeforeSources);
        
        // 處理非延遲的主線程調(diào)用
        __CFRunLoopDoBlocks();
        // 處理Source0事件
        __CFRunLoopDoSource0();
        
        if (sourceHandledThisLoop) {
            __CFRunLoopDoBlocks();
             }
        /// 如果有 Source1 (基于port) 處于 ready 狀態(tài),直接處理這個 Source1 然后跳轉(zhuǎn)去處理消息。
        if (__Source0DidDispatchPortLastTime) {
            Boolean hasMsg = __CFRunLoopServiceMachPort();
            if (hasMsg) goto handle_1msg;
        }
            
        /// 通知 Observers: RunLoop 的線程即將進入休眠(sleep)。
        if (!sourceHandledThisLoop) {
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
        }
            
        // GCD dispatch main queue
        CheckIfExistMessagesInMainDispatchQueue();
        
        // 即將進入休眠
        __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
        
        // 等待內(nèi)核mach_msg事件
        mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
        
        // 等待。。。
        
        // 從等待中醒來
        __CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
        
        // 處理因timer的喚醒
       if (wakeUpPort == timerPort)
            __CFRunLoopDoTimers();
        
        // 處理異步方法喚醒,如dispatch_async
        else if (wakeUpPort == mainDispatchQueuePort)
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
            
        // 處理Source1
        else
            __CFRunLoopDoSource1();
        
        // 再次確保是否有同步的方法需要調(diào)用
        __CFRunLoopDoBlocks();
        
    } while (!stop && !timeout);
    
    // 通知即將退出runloop
    __CFRunLoopDoObservers(CFRunLoopExit);
}

流程圖示:

圖片2.png

(左邊的“source0 (port) ”應改為"source1 (port)",因為 source0 無法主動喚醒。)

【Runloop 流程介紹:https://blog.ibireme.com/2015/05/18/runloop/

3、接受源

RunLoop 接收輸入事件來自兩種不同的來源:輸入源(input source)和定時源(timer source)

輸入源有兩種 Source0 和 Source1:

? Source0:非基于端口 port,例如觸摸,滾動,selector 選擇器等用戶觸發(fā)的事件;(只包含了一個回調(diào)函數(shù),它并不能主動喚醒觸發(fā)事件,需要手動觸發(fā)。)

? Source1:基于端口 port,一些系統(tǒng)事件; (包含了一個 mach_port 和一個回調(diào)函數(shù),被用于通過內(nèi)核和其他線程相互發(fā)送消息。能主動喚醒 RunLoop 的線程)

4、Runloop 的觀察者 CFRunLoopObserverRef

/* Run Loop Observer Activities */
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
};

二、Runloop 參與的開發(fā)角色

1、NSTimer 和 CADisplayLink

NSTimer:

也即 CFRunLoopTimerRef:是基于時間的觸發(fā)器。其包含一個時間長度和一個回調(diào)(函數(shù)指針)。當其加入到 RunLoop 時,RunLoop 會注冊對應的時間點,當時間點到時,RunLoop 會被喚醒以執(zhí)行那個回調(diào)。

NSTimer 定時器不精準的原因

A timer is not a real-time mechanism; it fires only when one of the run loop modes to which the timer has been added is running and able to check if the timer’s firing time has passed. If a timer’s firing time occurs while the run loop is in a mode that is not monitoring the timer or during a long callout, the timer does not fire until the next time the run loop checks the timer. Therefore, the actual time at which the timer fires potentially can be a significant period of time after the scheduled firing time.

計時器不是實時機制。僅當添加了計時器的運行循環(huán)模式之一正在運行并且能夠檢查計時器的觸發(fā)時間是否經(jīng)過時,它才會觸發(fā)。如果在運行循環(huán)處于不監(jiān)視計時器的模式下或長時間調(diào)用期間,計時器的觸發(fā)時間發(fā)生,則直到下一次運行循環(huán)檢查計時器時,計時器才會啟動。因此,計時器可能實際觸發(fā)的時間可能是在計劃的觸發(fā)時間之后的相當長的一段時間

CADisplayLink:

幀數(shù)定時器 為什么能準確?

跟屏幕刷新同步的。和 NSTimer 并不一樣,其內(nèi)部實際是操作了一個 Source,通過 Source 1 mach_port 直接接受 VSync 信號驅(qū)動的。

因為是跟著屏幕幀數(shù)去定時的,且當屏幕正常繪制的情況下,理想狀態(tài)下,60 fps 比較精確的。

如果對 NSRunloop 而言,想讓 CADisplayLink 在正常的 fps 工作下,至少每秒循環(huán)60次,也就是至多17ms。

by:在 iOS 15 ProMotion 設備上,CADisplayLink 不再由 VSync信號驅(qū)動,而是由一個 UIKit 內(nèi)部的 Source0 信號驅(qū)動。

CoreAnimation 的事務提交不再由完全由 RunLoop 驅(qū)動,而是涉及了多個信號源。

2、一次觸摸屏幕 Runloop 的參與過程

source0 和 source 1 在觸摸屏幕的場景中的角色:

我們觸摸屏幕,先摸到硬件(屏幕),屏幕表面的事件會先包裝成 Event,Event 先告訴 source1(mach_port), source1 喚醒 RunLoop。

應用程序把事件放入隊列,也就是將事件 Event 分發(fā)給 source0, 然后由 source0 來處理。。UIApplication 對象是第一個對象接收到事件,然后決定怎樣處理它。一個 touch event 通常都被分發(fā)到 main window對象,然后依次分發(fā)到發(fā)生觸碰的 view,直到找到能接收這個事件的類。

也即是:首先是由 Source1接收 IOHIDEvent,之后在回調(diào) __IOHIDEventSystemClientQueueCallback() 內(nèi)觸發(fā)的 Source0,Source0 再觸發(fā)的 _UIApplicationHandleEventQueue()。所以 UIButton 事件看到是在 Source0 內(nèi)的。)

一次觸摸的整個過程的圖示

圖片3.png

三、Runloop 的運用

主題:運用以上 Runloop 內(nèi)容,對 dispatch_async 隊列在主線程中的一次分析過程。

dispatch_async(dispatch_get_main_queue(), block) 在 iOS 開發(fā)中常見的 GCD 函數(shù)。

主要作用:異步切換到主線程執(zhí)行 block

1. 一般會在什么情況下使用?

子線程切換到主線程渲染 UI。

2. 如果在主線程中使用,會發(fā)生什么?

打印不同函數(shù):

NSLog(@"dispatch_async block 外 1"); dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"dispatch_async block 內(nèi)"); }); NSLog(@"dispatch_async block 外 2"); **2022-08-31 17:31:20.376227+0800 iOSTest[64011:1594427] dispatch_async block 外 1****2022-08-31 17:31:20.376306+0800 iOSTest[64011:1594427] dispatch_async block 外 2****2022-08-31 17:31:20.382031+0800 iOSTest[64011:1594427] dispatch_async block 內(nèi)

比較得出 :會有所延遲

打印幾次不同函數(shù)時間 -> 具體大概多少時間?
2022-08-31 17:32:48.462829+0800 iOSTest[64074:1596740] 相差時間(block 中):7.905006毫秒

現(xiàn)象:一次主線程的異步 dispatch_async 只有幾毫秒?

那么,如果加上其他操作例如:
[**self** addTableView]; **// 如果以上該操作中有復雜的算法,耗時會更明顯** **2022-08-31 17:33:34.714659+0800 iOSTest[64105:1598089] 相差時間(block 中):13.237000毫秒**

也就是在比較復雜的業(yè)務場景中,DispatchQueue 大概可以延遲到 10幾毫秒。

10幾毫秒的概念:

在一些秒開的性能優(yōu)化中,就是一個比較明顯的指標消耗數(shù)據(jù)。

例如首開優(yōu)化,抖音大概100-200毫秒。

小結(jié):在主線程中執(zhí)行 dispatch_async(dispatch_get_main_queue(), block),block 在開始執(zhí)行時,會比非 dispatch 操作有一定的延遲。

(當遇到問題的時候,解決前提,最好是分析這個問題存在的原因。)

這里的問題是:在主線程的 dispatch_async 會出現(xiàn)<u>多余的耗時操作</u>。

3. 這個耗時操作存在的原因?

首先分析出現(xiàn)的原因:

這里注冊了 Runloop 的觀察者。

輸出日志:

2022-08-31 17:43:12.063394+0800 iOSTest[64402:1605714] ------ 通知即將進入 runloop
2022-08-31 17:43:12.063475+0800 iOSTest[64402:1605714] ------- 通知將要處理 timer
2022-08-31 17:43:12.063519+0800 iOSTest[64402:1605714] ------- 通知將要處理 source**
2022-08-31 17:43:12.071835+0800 iOSTest[64402:1605714] 相差時間1:0.000954毫秒****
2022-08-31 17:43:12.072033+0800 iOSTest[64402:1605714] 相差時間2:0.182986毫秒**
2022-08-31 17:43:12.079609+0800 iOSTest[64402:1605714] ------- 通知將要處理 timer
2022-08-31 17:43:12.079693+0800 iOSTest[64402:1605714] ------- 通知將要處理 source**
2022-08-31 17:43:12.080144+0800 iOSTest[64402:1605714] 相差時間(block 中):8.311033毫秒**
2022-08-31 17:43:12.080369+0800 iOSTest[64402:1605714] ------- 通知將要處理 timer
2022-08-31 17:43:12.080558+0800 iOSTest[64402:1605714] ------- 通知將要處理 source

可以發(fā)現(xiàn)這里的延遲,處在兩組 timer 和 source 的中間。

而在源碼中,一次的 Runloop 中有兩次調(diào)用 dispatch_async(dispatch_get_main_queue) 的機會。

// 1.休眠前**CheckIfExistMessagesInMainDispatchQueue()    
// 2.被喚醒后
else if (wakeUpPort == mainDispatchQueuePort) {  
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()  
}

小結(jié):延遲的主要原因可能有兩個:

? handle_msg 后,只處理了 Source1,然后等再下一次 Runloop,才執(zhí)行:

CheckIfExistMessagesInMainDispatchQueue()  //* 最長是慢了一個 Runloop;

在一次 Runloop 已執(zhí)行完,進入睡眠,才喚醒后觸發(fā) DispatchQueue;

4. 一次 Runloop 消耗的時間?

在保證幀數(shù)據(jù)畫面,實時刷新,不卡頓。一個 runloop 16.7 毫秒這樣。(CADisplayLink 在正常的 fps 工作下,就是一秒走 60 次,因為由 sourece 去驅(qū)動)

所以:一次在主線程的 dispatch_async,延遲最多 10幾毫秒。

小結(jié):(分析問題出現(xiàn)的原因和現(xiàn)象。)

主線程,避免使用 dispatch_async,調(diào)用可導致延遲最多一個 runloop,大概幾毫秒到10幾毫秒的時間。

5、如何解決 DispatchQueue 在主線程的耗時?

1. 方案:

判斷是否在線程,是的話,直接執(zhí)行 block。

否則使用:

dispatch_async(dispatch_get_main_queue(), block);

2. 如何處理:

把方案寫進代碼:

 if ([NSThread isMainThread]) {     
 block(); 
 } else {
      dispatch_async(dispatch_get_main_queue(), block);
 }

關于主隊列和主線程的問題:

1、主線程上的隊列一定是主隊列嗎?

2、主隊列一定在主線程執(zhí)行嗎?

3. 為什么一定要在主隊列?

如果庫(如MapKit / VektorKit)依賴于檢查主隊列上的執(zhí)行,則從在主線程上執(zhí)行的非主隊列調(diào)用 API 將導致問題。

【以下填充文章部分?!?/p>

判斷是否當前主隊列:

 #ifndef dispatch_main_async_safe  (比較嚴謹?shù)?,防止重復定義 dispatch_main_async_safe) 
 #define dispatch_main_async_safe(block)\      if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {\
block();\      
} else {\ 
dispatch_async(dispatch_get_main_queue(), block);\     
}  #endif

小結(jié):

在主隊列調(diào)度的任務肯定在主線程執(zhí)行,而在主線程執(zhí)行的任務不一定是由主隊列調(diào)度的。

所以,做主線程判斷,可以判斷是否在主線程中。

那么,按以上結(jié)論,問題耗時的原因已分析,解決方案已提供。

疑問:在主線程中 DispatchQueue:

4. 一定會產(chǎn)生耗時操作?

2022-09-04 14:36:50.974062+0800 iOSTest[29279:3664874] ------- 線程即將進入休眠(sleep)
2022-09-04 14:36:51.034567+0800 iOSTest[29279:3664874] ------- 從等待中醒來****
2022-09-04 14:36:51.035105+0800 iOSTest[29279:3664874] ------- 通知將要處理 timer
2022-09-04 14:36:51.035225+0800 iOSTest[29279:3664874] ------- 通知將要處理 source
2022-09-04 14:36:51.036229+0800 iOSTest[29279:3664874] buttonClick start****2022-09-04 14:36:51.036720+0800 iOSTest[29279:3664874] 相差時間1:0.001073毫秒
2022-09-04 14:36:51.037145+0800 iOSTest[29279:3664874] 相差時間2:0.419974毫秒****
2022-09-04 14:36:51.037670+0800 iOSTest[29279:3664874] 相差時間(block 中):0.931025毫秒
2022-09-04 14:36:51.037862+0800 iOSTest[29279:3664874] ------- 通知將要處理 timer
2022-09-04 14:36:51.038030+0800 iOSTest[29279:3664874] ------- 通知將要處理 source
2022-09-04 14:36:51.038202+0800 iOSTest[29279:3664874] ------- 線程即將進入休眠(sleep)

參見 UI 更新,一次觸摸,Runloop 參與的角色。

所以,這是喚醒過來的,是在同一 Runloop 中處理的,還是在原來的事件里面。

但是因為是異步的,會立即返回,會先執(zhí)行下一行的代碼,后面再執(zhí)行 block。

但基本上述無過多的耗時操作。

歸納總結(jié):

在主線程中執(zhí)行 dispatch_async(dispatch_get_main_queue(), block),如果在非同一個事件(例如點擊觸摸事件)中,可能導致執(zhí)行 block 在主線程 的 下一次 Runloop 中執(zhí)行,耗時從幾毫秒到十幾毫秒不等。

規(guī)避耗時操作代碼如下:

#ifndef dispatch_main_async_safe  
  #define dispatch_main_async_safe(block)\     
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {\         
block();\      
} else {\          
dispatch_async(dispatch_get_main_queue(), block);\      
}  #endif

四、拓展

兩點優(yōu)化:

1、看一下項目里的代碼,是否有在主線程做異步執(zhí)行的切換?省個幾毫秒。

2、過一遍 Runloop 源碼,創(chuàng)建 Runloop 觀察者,看不同時期調(diào)用。

留意一下平時開發(fā)哪些流程可以根據(jù) Runloop 不同時期做優(yōu)化和拆分。

一些思考:

1、source0 為什么無法主動喚醒?

2、dispatch_async 改為 dispatch_sync?

3、子線程的 Runloop 怎么運行?和主線程的 Runloop 有啥區(qū)別?

4、在低端機中使用多線程對優(yōu)化收益?參考: GCD queue 對主線程的搶占



(轉(zhuǎn)載請標明原文出處,謝謝支持 ~ - ~)
? by:啊左~

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

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