iOS RunLoop分析

Runloop

Runloop是iOS系統(tǒng)中的事件循環(huán),它保證了我們的程序不會在main函數(shù)執(zhí)行完后就被退出,(線程保活),可以粗糙地理解成一個while(true)的循環(huán),但它的實現(xiàn)并沒有那么簡單。實際上它是一個NSRunLoop的對象,在對象內(nèi)部維護了一個事件循環(huán),當沒有事件要處理時,Runloop將線程控制器交給系統(tǒng),即從用戶態(tài)->內(nèi)核態(tài),當被喚醒時又從內(nèi)核態(tài)->用戶態(tài),實現(xiàn)了在休眠時不占用CPU資源。

基礎(chǔ)概念

正如前面的引言提到的,一般程序運行完畢后就會自動退出,比如當我們在Xcode中新建一個macOS的CommandLine項目,當main函數(shù)return后程序即運行完畢并退出。
然而,我們的APP顯然不能這樣,所以我們要讓APP可以隨時響應(yīng)而不退出。這樣的機制通常使用事件循環(huán)(Event Loop)來實現(xiàn),在iOS中即為Runloop。
與Runtime不同的是,Runloop是一個可實際獲取的對象,對應(yīng)Foundation框架的NSRunloop類與Core Foundation框架的CFRunloop,NSRunloop是基于CFRunloop的上層封裝。

Runloop核心

前面我們提到,Runloop可以簡單地概括成一個while(true)的循環(huán),但實際上這樣的實現(xiàn)會使CPU進行大量無謂的空轉(zhuǎn)。所以,Runloop機制的核心就是保證線程在有events需要處理時能喚醒,在沒有events時能進行休眠。
而實現(xiàn)真正的休眠,是靠沒有events時從用戶態(tài)->內(nèi)核態(tài)實現(xiàn)的,當有事件時,系統(tǒng)內(nèi)核通過mach_msg()或者mach port方法將事件發(fā)送給對應(yīng)的Runloop,Runloop收到事件后從休眠狀態(tài)切換到喚醒狀態(tài),并從內(nèi)核態(tài)->用戶態(tài)。

如何喚醒Runloop

Source

Source是Runloop中一個重要的概念,它代表了在上文中提到的events。
在Runloop中,Source分為兩類

  • Source0:該類Source是App的內(nèi)部事件,不具有獨立喚醒Runloop的能力。一個Source0需要被處理時,他需要被CFRunLoopSourceSignal()函數(shù)標記為待處理,并調(diào)用CFRunLoopWakeUp函數(shù)來喚醒Runloop,CFRunLoopWakeUp函數(shù)內(nèi)部通過一個_wakeUpPort成員變量來喚醒Runloop,推測該變量是一個mach port,Runloop只有通過mach port與mach_msg()才可以喚醒。喚醒后通過調(diào)用__CFRunLoopDoSources0函數(shù)來處理Source0事件,并在之后將該事件標記為已處理。
  • Source1:該類Source是由硬件事件生成的Source,如觸摸、搖晃、旋轉(zhuǎn)等。此類Source可喚醒Runloop。

Timer

使用NSTimer API注冊執(zhí)行的任務(wù),就屬于這一類

Observer

某個Observer可以監(jiān)聽runloop的狀態(tài)變化,并作出反應(yīng)

Runloop與線程的關(guān)系

  • Runloop與線程是一一對應(yīng)的,且子線程的Runloop無法獲取到其他子線程的Runloop,一一對應(yīng)的關(guān)系以key-value存儲在一個全局字典里。在CFRunloop中,只有CFRunloopGetMainCFRunloopGetCurrent兩個函數(shù)可以獲取到Runloop
  • 主線程會自動創(chuàng)建Runloop以響應(yīng)事件,但子線程并不會自動創(chuàng)建Runloop。由于NSTimer對象需要加入到Runloop中的mode,所以在子線程中調(diào)用performSel afterdelay系列方法并不會被調(diào)用,因為這些方法都會注冊一個NSTimer到Runloop中,而子線程默認情況下是沒有Runloop的。
  • Runloop會在線程銷毀時銷毀

CFRunLoopMode

mode是管理著Runloop與source/timer/observer之間的橋梁,在一開始會注冊五個mode

  • kCFRunLoopDefaultMode: App的默認 Mode,通常主線程是在這個 Mode 下運行的。
  • UITrackingRunLoopMode: 界面跟蹤 Mode,用于 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響。默認NSTimer是被加入到default mode中的,所以當滑動時Runloop切換到tracking mode,這時default mode中的Timer回調(diào)不會被調(diào)用,所以NSTimer的精度沒有CADisplayLinker高。
  • UIInitializationRunLoopMode: 在剛啟動 App 時第進入的第一個 Mode,啟動完成后就不再使用。
  • GSEventReceiveRunLoopMode: 接受系統(tǒng)事件的內(nèi)部 Mode,通常用不到。
  • kCFRunLoopCommonModes: 這是一個占位的 Mode,沒有實際作用。
  • 如果需要將事件加入到多個mode中,則將它注冊到commonMode中,該mode實際上是多個mode的集合。
  • 出于將source/timer/observer分隔開的目的,RunLoop一次只能運行在一個mode下,當運行時在RunLoop的currentMode屬性中會標記當前運行的mode。而當要切換mode時,RunLoop必須先退出,并選中一個mode重新進入,達到切換mode的目的。在切換mode時,被加入到commonModes中的事件會被拷貝一次到運行的mode中。

源碼驗證

__CFRunLoop

RunLoop在Core Foundation中對應(yīng)的類是CFRunLoopRef,其對應(yīng)的結(jié)構(gòu)是__CFRunLoop

-w795

可以看到結(jié)構(gòu)體中包含了上文提到的mode,對應(yīng)CFRunLoopModeRef,其結(jié)構(gòu)如下圖

-w778

在這里我們看到了上文提到的source,observer,timer,這是能喚醒RunLoop的三種類型,當然能獨立喚醒RunLoop的只有sources1. Mode與source,observer,timer的關(guān)系如下圖


-w455

一個 RunLoop 包含若干個 Mode,每個 Mode 又包含若干個 Source/Timer/Observer。每次調(diào)用 RunLoop 的主函數(shù)時,只能指定其中一個 Mode,這個Mode被稱作 CurrentMode。如果需要切換 Mode,只能退出 Loop,再重新指定一個 Mode 進入。這樣做主要是為了分隔開不同組的 Source/Timer/Observer,讓其互不影響。

_CFRunloopGet0()

在Core Foundation中,提供了兩個接口來獲取Runloop,分別是CFRunloopGetMainCFRunloopGetCurrent,先來看看他們的源碼實現(xiàn)。

-w769

可以看到,兩個函數(shù)實際上都是調(diào)用了_CFRunLoopGet0()方法,方法的參數(shù)是線程pthread_t。在CFRunLoopGetCurrent()中,如果當前線程的Runloop已存在,那么會在_CFGetTSD()函數(shù)中找到并返回。

接下來繼續(xù)看看_CFRunLoopGet0()函數(shù),顯然這是獲取RunLoop的關(guān)鍵函數(shù)。先看一下第一部分。

-w741

這里注意到__CFRunLoops變量,它是一個CFMutableDictionaryRef類型的字典,key為線程,value為CFRunLoopRef

-w499

在第一次進入時,_CFRunLoopGet0()函數(shù)先創(chuàng)建了CFMutableDictionaryRef類型的字典變量,顯然這個就是全局RunLoop表了。這里也印證了前文提到的線程與RunLoop一一對應(yīng)的結(jié)論。
接著我們可以看到,這里調(diào)用__CFRunLoopCreate()函數(shù)創(chuàng)建了主線程的RunLoop,所以RunLoop是直到被獲取時才會被創(chuàng)建,如果不獲取便不會被創(chuàng)建。RunLoop創(chuàng)建后以key為線程,value為CFRunLoopRef存儲在全局RunLoop表中。

接著看看第二部分,如果不是第一次進入則是走到這個流程的代碼。

-w803

首先定義了一個loop變量,從全局RunLoops表__CFRunLoops中查找線程對應(yīng)的RunLoop,如果找到了則返回該CFRunLoopRef。
如果在__CFRunLoops中沒有查找到該線程對應(yīng)的RunLoop,則調(diào)用__CFRunLoopCreate()函數(shù)創(chuàng)建RunLoop,并添加到__CFRunLoops中。

__CFRunLoopSource

image

在mode中,Source對應(yīng)__CFRunLoopSource結(jié)構(gòu)體。在上文我們知道Source是分為Source0和Source1的,而他們其實都是__CFRunLoopSource,在結(jié)構(gòu)體中,以CFRunLoopSourceContext來區(qū)分不同的Source,其中的version0version1分別對應(yīng)source0source1

Source0與Source1

上圖即為CFRunLoopSourceContextCFRunLoopSourceContext1的結(jié)構(gòu)定義,可以看到CFRunLoopSourceContext1與CFRunLoopSourceContext0一個明顯的區(qū)別就是CFRunLoopSourceContext1具有一個mach_port_t類型的變量。從這里就可以知道為什么Source0不可以獨立喚醒RunLoop而Source1可以,在前文中我們提到只有mach portmach_msg()可以獨立喚醒RunLoop。

__CFRunLoopCreate(_CFThreadRef t)

該函數(shù)被用來創(chuàng)建RunLoop,在_CFRunloopGet0()函數(shù)中若獲取不到線程對應(yīng)的RunLoop則調(diào)用該函數(shù)來創(chuàng)建一個新RunLoop??梢钥吹饺?yún)⑹且粋€_CFThreadRef類型的變量,代表著線程,因為線程與RunLoop是一一對應(yīng)的。

__CFRunLoopCreate

在該函數(shù)中進行了對__CFRunLoop結(jié)構(gòu)的分配內(nèi)存與初始化,可以看到實際上是調(diào)用了_CFRuntimeCreateInstance()函數(shù)創(chuàng)建了CFRunLoopRef類型的實例,從該函數(shù)的名字以及代碼可以看到,其實RunLoop的創(chuàng)建是利用Runtime的動態(tài)創(chuàng)建類的特性來創(chuàng)建的。

CFRunLoopWakeUp

CFRunLoopWakeUp

該函數(shù)在CFRunLoop中用來喚醒RunLoop,可以看到在TARGET_OS_MAC下,函數(shù)的關(guān)鍵調(diào)用是__CFSendTrivialMachMessage()函數(shù),該函數(shù)使用了CFRunLoop中的_wakeUpPort屬性。

__CFSendTrivialMachMessage

可以看到在__CFSendTrivialMachMessage函數(shù)內(nèi)部,的確是使用了mach_msg的方式來給mach port發(fā)送信息,以達到喚醒RunLoop的目的。

Runloop應(yīng)用之事件響應(yīng)

從用戶觸摸屏幕,到我們的app響應(yīng)這個觸摸,中間其實需要經(jīng)過多步的處理,并且涉及到的是硬件->軟件的通信。之前關(guān)于Runloop的Source中提到Source1是一類由硬件生成的事件,那么以觸摸事件為例子,看看Runloop是怎么處理事件響應(yīng)的。

事件響應(yīng)鏈

首先我們來梳理一下事件響應(yīng)鏈。

  1. 用戶觸發(fā)事件
  2. 系統(tǒng)將事件轉(zhuǎn)交到對應(yīng) APP 的事件隊列
  3. APP 從消息隊列頭取出事件
  4. 交由 Main Window 進行消息分發(fā)
  5. 找到合適的 Responder 進行處理,如果沒找到,則會沿著 Responder chain 返回到 APP 層,丟棄不響應(yīng)該事件。

從上面的五步,我們可以看到其實1和2是獨立于app外的,需要涉及到硬件,從第3步開始事件才被發(fā)送到app內(nèi)進行處理。

用戶觸發(fā)事件

這一步始于用戶點觸屏幕,此時系統(tǒng)的IOKit.framework會生成一個IOHIDEvent事件,該事件會被Spring board接收。

事件轉(zhuǎn)發(fā)到app

IOHIDEvent被接收后,將會通過mach port轉(zhuǎn)發(fā)給對應(yīng)的app進程。而這時,app中一個名為com.apple.uikit.eventfetch-thread的線程中已經(jīng)注冊了一個Source1,其回調(diào)是__IOHIDEventSystemClientQueueCallback()函數(shù)。

消息隊列

IOHIDEvent被mach port轉(zhuǎn)發(fā)到對應(yīng)app進程后,就喚醒了com.apple.uikit.eventfetch-thread線程的Runloop,并調(diào)用了Source1對應(yīng)的__IOHIDEventSystemClientQueueCallback()回調(diào)。該回調(diào)從消息隊列中取出event,并將main thread__handleEventQueue對應(yīng)的Source0設(shè)置為待處理狀態(tài),同時喚醒main thread的Runloop。

消息分發(fā)

此時就開始調(diào)用__eventQueueSourceCallback函數(shù)進行消息分發(fā),_UIApplicationHandleEventQueue會把IOHIDEvent封裝成UIEvent并分發(fā)出去,開始hitTest等函數(shù)的調(diào)用,并且TouchBegan/end/move/cancel等函數(shù)都是在該回調(diào)中調(diào)用的。

In short

用戶觸發(fā)事件, IOKit.framework 生成一個 IOHIDEvent 事件并由 SpringBoard接收,SpringBoard 會利用 mach port,產(chǎn)生 source1,來喚醒目標 APP 的 com.apple.uikit.eventfetch-thread 的 RunLoop。Eventfetch thread 會將 main runloop 中 __handleEventQueue 所對應(yīng)的 source0 設(shè)置為 signalled = Yes 狀態(tài),同時喚醒 main RunLoop。mainRunLoop 則調(diào)用 __eventQueueSourceCallback進行事件隊列處理。

Runloop與autorelease pool

Runloop會注冊幾個監(jiān)聽自己狀態(tài)變化的回調(diào),當Runloop進入一次事件循環(huán)時,會調(diào)用AutoreleasePoolPage::push()方法創(chuàng)建一個新的自動釋放池并傳入哨兵對象,而在即將退出或者休眠時則會將自動釋放池中的對象pop出。

寫在最后

運行的主要函數(shù)CFRunLoopRun()有點長...留待下次分析

如有錯漏,歡迎指出


Tino Wu
more at tinowu.top

?著作權(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)容

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