iOS runloop 詳解3 如何停止子執行緒的runloop
前言
多執行緒的價值無需贅述,對於App效能和使用者體驗都有著至關重要的意義,在iOS開發中,Apple提供了不同的技術支援多執行緒程式設計,除了跨平臺的pthread之外,還提供了NSThread、NSOperationQueue、GCD等多執行緒技術,從本篇Blog開始介紹這幾種多執行緒技術的細節。
NSThread
使用NSThead建立執行緒有很多方法:
- +detachNewThreadSelector:toTarget:withObject:類方法直接生成一個子執行緒
1
|
|
- 建立一個NSThread類例項,然後呼叫start方法。
1 2 |
|
- 呼叫NSObject的
+performSelectorInBackground:withObject:
方法生成子執行緒。
1
|
|
- 建立一個NSThread子類,然後呼叫子類例項的start方法,。
建立執行緒也是有開銷的,iOS下主要成本包括構造核心資料結構(大約1KB)、棧空間(子執行緒512KB、主執行緒1MB,不過可以使用方法-setStackSize:
自己設定,注意必須是4K的倍數,而且最小是16K),建立執行緒大約需要90毫秒的建立時間。
第二種和第四種方法建立的執行緒有個好處是擁有執行緒的物件,因此可以使用performSelector:onThread:withObject:waitUntilDone:
在該執行緒上執行方法,這是一種非常方便的執行緒間通訊的方法(相對於設定麻煩的NSPort用於通訊),所要執行的方法可以直接新增到目標執行緒的Runloop中執行。Apple建議使用這個介面執行的方法不要是耗時或者頻繁的操作,以免子執行緒的負載過重。
第三種方法其實與第一種方法是一樣的,都會直接生成一個子執行緒。
上面四種方法生成的子執行緒都是detached狀態,即主執行緒結束時這些執行緒都會被直接殺死;如果要生成joinable狀態的子執行緒,只能使用pthread介面啦。
如果需要,可以設定執行緒的優先順序(-setThreadPriority:
);如果要線上程中儲存一些狀態資訊,還可以使用到-threadDictionary
得到一個NSMutableDictionary,以key-value的方式儲存資訊用於執行緒內讀寫。
NSThread的入口方法
要寫一個有效的子執行緒入口方法需要注意很多問題,示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
- 必須建立一個NSAutoreleasePool,因為子執行緒不會自動建立。同時要注意這個pool因為是最外層pool,如果執行緒中要進行長時間的操作生成大量autoreleased的物件,則只有在該子執行緒退出時才會回收,因此如果執行緒中會大量建立autoreleased物件,那麼需要建立額外的NSAutoreleasePool,可以在NSRunloop每次迭代時建立和銷燬一個NSAutoreleasePool。
- 如果你的子執行緒會丟擲異常,最好在子執行緒中設定一個異常處理函式,因為如果子執行緒無法處理丟擲的異常,會導致程式直接Crash關閉。
- (可選)設定Run Loop,如果子執行緒只是做個一次性的操作,那麼無需設定Run Loop;如果子執行緒進入一個迴圈需要不斷處理一些事件,那麼設定一個Run Loop是最好的處理方式,如果需要Timer,那麼Run Loop就是必須的。
- 如果需要在子執行緒執行的時候讓子執行緒結束操作,子執行緒每次Run Loop迭代中檢查相應的標誌位來判斷是否還需要繼續執行,可以使用threadDictionary以及設定Input Source的方式來通知這個子執行緒。那麼什麼是Run Loop呢?這是涉及NSThread及執行緒相關的程式設計時無法迴避的一個問題。
Run Loop
Run Loop本身並不具備併發執行的功能,但是和多執行緒開發息息相關,而且概念令人迷惑,相關的介紹資料也很少,它的主要的特性如下:
- 每個執行緒都有一個Run Loop,主執行緒的Run Loop會在App執行時自動執行,子執行緒中需要手動執行。
- 每個Run Loop都會以一個模式mode來執行,可以使用NSRunLoop的
- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate
方法執行在某個特定模式mode。 - Run Loop的處理兩大類事件源:Timer Source和Input Source(包括performSelector***方法簇、Port或者自定義Input Source),每個事件源都會繫結在Run Loop的某個特定模式mode上,而且只有RunLoop在這個模式執行的時候才會觸發該Timer和Input Source。
- 如果沒有任何事件源新增到Run Loop上,Run Loop就會立刻exit。
Run Loop介面
要操作Run Loop,Foundation層和Core Foundation層都有對應的介面可以操作Run Loop。
Foundation層對應的是NSRunLoop:
Core Foundation層對應的是CFRunLoopRef:
兩組介面差不多,不過功能上還是有許多區別的,例如CF層可以新增自定義Input Source事件源(CFRunLoopSourceRef)和Run Loop觀察者Observer(CFRunLoopObserverRef),很多類似功能的介面特性也是不一樣的。
Run Loop執行
Run Loop如何執行呢?在上一節NSThread的入口函式中使用了一種NSRunLoop的使用場景,再看一例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
我們看到入口方法裡建立了一個NSTimer,並且以NSDefaultRunLoopMode模式加入到當前子執行緒的NSRunLoop中。進入迴圈後肯定會執行-doOtherTask
方式法一次,然後再以NSDefaultRunLoopMode模式執行NSRunLoop,如果一次Timer事件觸發處理後,這個Run
Loop會返回嗎?答案是不會,Why?
NSRunLoop的底層是由CFRunLoopRef實現的,你可以想象成一個迴圈或者類似Linux下select或者epoll,當沒有事件觸發時,你呼叫的Run Loop執行方法不會立刻返回,它會持續監聽其他事件源,如果需要Run Loop會讓子執行緒進入sleep等待狀態而不是空轉,只有當Timer Source或者Input Source事件發生時,子執行緒才會被喚醒,然後處理觸發的事件,然而由於Timer source比較特殊,Timer Source事件發生處理後,Run Loop執行方法-
(BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;
也不會返回;而其他非Timer事件的觸發處理會讓這個Run Loop退出並返回YES。當Run Loop執行在一個特定模式時,如果該模式下沒有事件源,執行Run Loop會立刻返回NO。
NSRunLoop的執行介面:
1 2 3 4 5 6 7 8 |
|
CFRunLoopRef的執行介面:
1 2 3 4 5 6 7 8 9 10 11 |
|
詳細講解下NSRunLoop的三個執行介面:
- (void)run;
無條件執行
不建議使用,因為這個介面會導致Run Loop永久性的執行在NSDefaultRunLoopMode模式,即使使用CFRunLoopStop(runloopRef);
也無法停止Run
Loop的執行,那麼這個子執行緒就無法停止,只能永久執行下去。
- (void)runUntilDate:(NSDate *)limitDate;
有一個超時時間限制
比上面的介面好點,有個超時時間,可以控制每次Run Loop的執行時間,也是執行在NSDefaultRunLoopMode模式。這個方法執行Run Loop一段時間會退出給你檢查執行條件的機會,如果需要可以再次執行Run Loop。注意CFRunLoopStop(runloopRef);
也無法停止Run
Loop的執行,因此最好自己設定一個合理的Run Loop執行時間。示例:
1 2 3 4 5 6 |
|
- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;
有一個超時時間限制,而且設定執行模式
這個介面在非Timer事件觸發、顯式的用CFRunLoopStop停止Run Loop、到達limitDate後會退出返回。如果僅是Timer事件觸發並不會讓Run Loop退出返回;如果是PerfromSelector***事件或者其他Input Source事件觸發處理後,Run Loop會退出返回YES。示例:
1 2 3 4 5 6 |
|
那麼如何知道一個Run Loop是因為什麼原因exit退出的呢?NSRunLoop中沒有介面可以知道,而需要通過Core Foundation的介面來執行CFRunLoopRef,NSRunLoop其實就是CFRunLoopRef的二次封裝。使用CFRunLoop的介面(C的介面)來執行Run Loop,有兩個介面:
void CFRunLoopRun(void);
執行在預設的kCFRunLoopDefaultMode模式下,直到使用CFRunLoopStop介面停止這個Run Loop,或者Run Loop的所有事件源都被刪除。
SInt32 CFRunLoopRunInMode(CFStringRef mode, CFTimeInterval seconds, Boolean returnAfterSourceHandled);
第一個引數是指RunLoop執行的模式(例如kCFRunLoopDefaultMode或者kCFRunLoopCommonModes),第二個引數是執行時間,第三個引數是是否在處理事件後讓Run Loop退出返回。 示例:
1 2 3 4 5 6 7 8 9 10 11 |
|
如果Run Loop退出返回後,返回值是SInt32型別(signed long),表明Run Loop返回的原因,目前有四種:
1 2 3 4 5 6 |
|
注意:Run Loop是可以巢狀呼叫的(就像NSAutoreleasePool),例如一個Run Loop執行過程中一個事件觸發後,那麼在觸發方法裡可以再運行當前子執行緒的Run Loop,然後由這個Run Loop等待其他事件觸發。不過這種巢狀Run Loop呼叫方式我用的比較少。
以上Run Loop執行方法參考本文最後的Sample Code自行嘗試。
Run Loop的執行模式Mode
iOS下Run Loop的主要執行模式mode有:
1) NSDefaultRunLoopMode: 預設的執行模式,除了NSConnection物件的事件。
2) NSRunLoopCommonModes: 是一組常用的模式集合,將一個input source關聯到這個模式集合上,等於將input source關聯到這個模式集合中的所有模式上。在iOS系統中NSRunLoopCommonMode包含NSDefaultRunLoopMode、NSTaskDeathCheckMode、UITrackingRunLoopMode,我有個timer要關聯到這些模式上,一個個註冊很麻煩,我可以用CFRunLoopAddCommonMode([[NSRunLoop
currentRunLoop] getCFRunLoop],(__bridge CFStringRef) NSEventTrackingRunLoopMode)
將NSEventTrackingRunLoopMode或者其他模式新增到這個NSRunLoopCommonModes模式中,然後只需要將Timer關聯到NSRunLoopCommonModes,即可以實現Run Loop執行在這個模式集合中任何一個模式時,這個Timer都可以被觸發。預設情況下NSRunLoopCommonModes包含了NSDefaultRunLoopMode和UITrackingRunLoopMode。注意:讓Run
Loop執行在NSRunLoopCommonModes下是沒有意義的,因為一個時刻Run Loop只能執行在一個特定模式下,而不可能是個模式集合。
3) UITrackingRunLoopMode: 用於跟蹤觸控事件觸發的模式(例如UIScrollView上下滾動),主執行緒當觸控事件觸發時會設定為這個模式,可以用來在控制元件事件觸發過程中設定Timer。
4) GSEventReceiveRunLoopMode: 用於接受系統事件,屬於內部的Run Loop模式。
5) 自定義Mode:可以設定自定義的執行模式Mode,你也可以用CFRunLoopAddCommonMode新增到NSRunLoopCommonModes中。
Run Loop執行時只能以一種固定的模式執行,只會監控這個模式下新增的Timer Source和Input Source,如果這個模式下沒有相應的事件源,Run Loop的執行也會立刻返回的。注意Run Loop不能在執行在NSRunLoopCommonModes模式,因為NSRunLoopCommonModes其實是個模式集合,而不是一個具體的模式,我可以在新增事件源的時候使用NSRunLoopCommonModes,只要Run Loop執行在NSRunLoopCommonModes中任何一個模式,這個事件源都可以被觸發。
Run Loop的事件源
歸根結底,Run Loop就是個處理事件的Loop,可以新增Timer和其他Input Source等各種事件源,如果事件源沒有發生時,Run Loop就可能讓執行緒進入asleep狀態,而事件源發生時就會喚醒休眠的(asleep)的子執行緒來處理事件。Run Loop的事件源事件源分兩類:Timer Source和Input Source(包括-performSelector:***API呼叫簇,Port Input Source、自定義Input Source)。
從上圖可以看出Run Loop就是處理事件的一個迴圈,不同的是Timer Source事件處理後不會使Run Loop結束,而Input Source事件處理後會讓Run Loop退出。因此你需要自己的一個Loop去不斷執行Run Loop來處理事件,就像本文開頭的示例那樣。
細分下Run Loop的事件源:
1) Timer Souce就是建立Timer新增到Run Loop中,沒啥好說的,Cocoa或者Core Foundation都有相應介面實現。需要注意的是scheduledTimerWith****
開頭生成的Timer會自動幫你以預設NSDefaultRunLoopMode模式載入到當前的Run
Loop中,而其他介面生成的Timer則需要你手動使用-addTimer:forMode
新增到Run
Loop中。需要額外注意的是Timer的觸發不會讓Run Loop返回。(Timer sources deliver events to their handler routines but do not cause the run loop to exit.) 具體實驗可以看下面的Sample Code。
2) Input Source中的-performSelector:***API呼叫簇方法,有以下這些介面:
1 2 3 4 5 6 7 8 9 10 11 |
|
這些API最後兩個是取消當前執行緒中呼叫,其他API是在主執行緒或者當前執行緒下的Run Loop中執行指定的@selector。
3) Port Input Source:概念上也比較簡單,可以用NSMachPort作為執行緒之間的通訊通道。例如在主執行緒建立子執行緒時傳入一個NSPort物件,這樣主執行緒就可以和這個子執行緒通訊啦,如果要實現雙向通訊,那麼子執行緒也需要回傳給主執行緒一個NSPort。
NSPort的子類除了NSMachPort,還可以使用NSMessagePort或者Core Foundation中的CFMessagePortRef。
注意:雖然有這麼棒的方式實現執行緒間通訊方式,但是估計是由於危及iOS的Sandbox沙盒環境,所以這些API都是私有介面,如果你用到NSPortMessage,XCode會提示'NSPortMessage'
for instance message is a forward declaration
。
4) 自定義Input Source:
向Run Loop新增自定義Input Source只能使用Core Foundation的介面:CFRunLoopSourceCreate
建立一個source,CFRunLoopAddSource
向Run
Loop中新增source,CFRunLoopRemoveSource
從Run
Loop中刪除source,CFRunLoopSourceSignal
通知source,CFRunLoopWakeUp
喚醒Run
Loop。
Apple官方文件提供了一個自定義Input Source使用模式。
主執行緒持有包含子執行緒的Run Loop和Source的context物件,還有一個用於儲存需要執行操作的