運用 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);
}
流程圖示:

(左邊的“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)的。)
一次觸摸的整個過程的圖示

三、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:啊左~