1. 程式人生 > >iOS runloop 詳解3 如何停止子執行緒的runloop

iOS runloop 詳解3 如何停止子執行緒的runloop

前言

多執行緒的價值無需贅述,對於App效能和使用者體驗都有著至關重要的意義,在iOS開發中,Apple提供了不同的技術支援多執行緒程式設計,除了跨平臺的pthread之外,還提供了NSThread、NSOperationQueue、GCD等多執行緒技術,從本篇Blog開始介紹這幾種多執行緒技術的細節。

NSThread

使用NSThead建立執行緒有很多方法:

  • +detachNewThreadSelector:toTarget:withObject:類方法直接生成一個子執行緒
1
[NSThread detachNewThreadSelector:@selector(threadRoutine:
) toTarget:self withObject:nil];
  • 建立一個NSThread類例項,然後呼叫start方法。
1
2
NSThread* aThread = [[NSThread alloc] initWithTarget:self selector:@selector(threadRoutine:) object:nil];
[aThread start];
  • 呼叫NSObject的+performSelectorInBackground:withObject:方法生成子執行緒。
1
[myObj performSelectorInBackground:
@selector(threadRoutine:) withObject:nil];
  • 建立一個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
- (void)threadRoutine
{
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
  BOOL moreWorkToDo = YES;
    BOOL exitNow = NO;
    NSRunLoop* runLoop = [NSRunLoop currentRunLoop];
    NSMutableDictionary* threadDict = [[NSThread currentThread] threadDictionary];
    [threadDict setValue:[NSNumber numberWithBool:exitNow] forKey:@"ThreadShouldExitNow"];
  //新增事件源
    [self myInstallCustomInputSource];
    while (moreWorkToDo && !exitNow)
    {
        //執行執行緒真正的工作方法,如果完成了可以設定moreWorkToDo為False
        [runLoop runUntilDate:[NSDate date]];
        exitNow = [[threadDict valueForKey:@"ThreadShouldExitNow"] boolValue];
    }
    [pool release];
}
  • 必須建立一個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
- (void)main
{
    @autoreleasepool {
        NSLog(@"starting thread.......");
        NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(doTimerTask) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
        [timer release];
        while (! self.isCancelled) {
            [self doOtherTask];
            BOOL ret = [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
            NSLog(@"after runloop counting.........: %d", ret);
        }
        NSLog(@"finishing thread.........");
    }
}
- (void)doTimerTask
{
    NSLog(@"do timer task");
}
- (void)doOtherTask
{
    NSLog(@"do other task");
}

我們看到入口方法裡建立了一個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
//執行 NSRunLoop,執行模式為預設的NSDefaultRunLoopMode模式,沒有超時限制
- (void)run;
//執行 NSRunLoop: 引數為執行模式、時間期限,返回值為YES表示是處理事件後返回的,NO表示是超時或者停止執行導致返回的
- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;
//執行 NSRunLoop: 引數為運時間期限,執行模式為預設的NSDefaultRunLoopMode模式 
-(void)runUntilDate:(NSDate *)limitDate;

CFRunLoopRef的執行介面:

1
2
3
4
5
6
7
8
9
10
11
//執行 CFRunLoopRef
void CFRunLoopRun();
//執行 CFRunLoopRef: 引數為執行模式、時間和是否在處理Input Source後退出標誌,返回值是exit原因
SInt32 CFRunLoopRunInMode (mode, seconds, returnAfterSourceHandled);
//停止執行 CFRunLoopRef
void CFRunLoopStop( CFRunLoopRef rl );
//喚醒 CFRunLoopRef
void CFRunLoopWakeUp ( CFRunLoopRef rl );

詳細講解下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
while (!Done)
{
    [[NSRunLoop currentRunLoop] runUntilDate:[NSDate
                dateWithTimeIntervalSinceNow:10]];
    NSLog(@"exiting runloop.........:");
}
  • - (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
while (!Done)
{
    BOOL ret = [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                        beforeDate:[NSDate distantFuture]];
    NSLog(@"exiting runloop.........: %d", ret);
}

那麼如何知道一個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
while (!self.isCancelled)
{
    [self doOtherTask];
    SInt32 result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 2, YES);
    if (result == kCFRunLoopRunStopped)
    {
        [self cancel];
    }
    NSLog(@"exit run loop.........: %ld", result);
}

如果Run Loop退出返回後,返回值是SInt32型別(signed long),表明Run Loop返回的原因,目前有四種:

1
2
3
4
5
6
enum {
    kCFRunLoopRunFinished = 1, //Run Loop結束,沒有Timer或者其他Input Source
    kCFRunLoopRunStopped = 2, //Run Loop被停止,使用CFRunLoopStop停止Run Loop
    kCFRunLoopRunTimedOut = 3, //Run Loop超時
    kCFRunLoopRunHandledSource = 4 ////Run Loop處理完事件,注意Timer事件的觸發是不會讓Run Loop退出返回的,即使CFRunLoopRunInMode的第三個引數是YES也不行
};

注意: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
performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:
performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:
performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:
cancelPreviousPerformRequestsWithTarget:
cancelPreviousPerformRequestsWithTarget:selector:object:

這些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物件,還有一個用於儲存需要執行操作的