你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 深入研究 Runloop 與線程保活

深入研究 Runloop 與線程保活

編輯:IOS開發基礎

1441627490-runloop3600.jpg

授權轉載,作者:bestswifter

在討論 runloop 相關的文章,以及分析 AFNetworking(2.x) 源碼的文章中,我們經常會看到關於利用 runloop 進行線程保活的分析,但如果不求甚解的話,極有可能因此學會了一個錯誤的用法,本文就來分析一下其中常見的誤區。

我提供了一個 Demo,可以在我的 Github 上下載並運行一遍,文章中只提供了部分代碼。

Demo地址:https://github.com/bestswifter/MySampleCode/tree/master/RunloopAndThread

AFN 中的實現

首先我們知道在舊版本的AFN中使用了 NSURLConnection 來發起並處理網絡連接。AFN 的做法是把網絡請求的發起和解析都放在同一個子線程中進行,但由於子線程默認不開啟 runloop,它會向一個 C語言程序那樣在運行完所有代碼後退出線程。而網絡請求是異步的,這會導致獲取到請求數據時,線程已經退出,代理方法沒有機會執行。因此,AFN 的做法是使用一個 runloop 來保證線程不死,也就是下面這段被講爛了的代碼:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

當然,單獨看這一個方法意義不大,我們稍微結合一下上下文,看看這個方法在哪裡被調用:

+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

似乎這種寫法提供了一種思路:“如果需要在子線程中異步執行操作,可以利用 runloop 進行線程保活”。但准確的來說,AFN 的這種寫法並不能實現我們的需求,它只是在 AFN 這個特殊場景下可以工作。

不信你可以嘗試閱讀一下第二段代碼,看看它和平時使用 NSThread 時有什麼區別,如果沒看出來也無妨,先記住這段代碼,我們稍後分析。

NSThread 與內存洩漏

這種寫法的第一個問題就是存在內存洩漏。我們構造以下用例,其實就是把 AFN 的線程創建放在一個循環裡:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
    }
}
- (void)run {
    @autoreleasepool {
        NSLog(@"current thread = %@", [NSThread currentThread]);
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        if (!self.emptyPort) {
            self.emptyPort = [NSMachPort port];
        }
        [runLoop addPort:self.emptyPort forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

奇怪的事情出現了,盡管是在 ARC 環境下,內存依然不停的上漲。如果我們把 run 方法中和 runloop 相關的代碼刪除則不會出現上述問題,顯然,開啟 runloop 導致了內存洩漏,也就是 thread 對象無法釋放。

這裡的 emptyPort 用來維持 runloop 的運行,根據官方文檔的描述,如果 runloop 中沒有任何 modeItem,就不會啟動,而是立刻退出。之所以選擇作為屬性而不是臨時變量,是因為我發現每次調用 [NSMachPort port] 方法都會占用內存,原因暫時不清楚。

我們可以嘗試手動結束 runloop 並關閉線程:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
        [self performSelector:@selector(stopThread) onThread:thread withObject:nil waitUntilDone:YES];
    }
}
- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
    NSThread *thread = [NSThread currentThread];
    [thread cancel];
}

很遺憾,這依然沒有任何效果。而且不難猜測是我們沒有能正確的結束 runloop 的運行。

Runloop 的啟動與退出

考驗英文水平的時候到了,首先來看一段官方文檔對於如何啟動 runloop 的介紹,它的啟動方式一共有三種:

  • Unconditionally

  • With a set time limit

  • In a particular mode

這三種進入方式分別對應了三種方法,其中第一種就是我們目前使用的:

  • run

  • runUntilDate

  • runMode:beforeDate:

接下來分別是對三種方式的介紹,文字比較啰嗦,這裡我簡單總結一下,有興趣的讀者可以直接看原文。

  • 無條件進入是最簡單的做法,但也最不推薦。這會使線程進入死循環,從而不利於控制 runloop,結束 runloop 的唯一方式是 kill 它。

  • 如果我們設置了超時時間,那麼 runloop 會在處理完事件或超時後結束,此時我們可以選擇重新開啟 runloop。這種方式要優於前一種

  • 這是相對來說最優秀的方式,相比於第二種啟動方式,我們可以指定 runloop 以哪種模式運行。

查看 run 方法的文檔還可以知道,它的本質就是無限調用 runMode:beforeDate: 方法,同樣地,runUntilDate: 也會重復調用 runMode:beforeDate:,區別在於它超時後就不會再調用。

總結來說,runMode:beforeDate: 表示的是 runloop 的單次調用,另外兩者則是循環調用。

相比於 runloop 的啟動,它的退出就比較簡單了,只有兩種方法:

  • 設置超時時間

  • 手動結束

如果你使用方法二或三來啟動 runloop,那麼在啟動的時候就可以設置超時時間。然而考慮到目標是:“利用 runloop 進行線程保活”,所以我們希望對線程和它的 runloop 有最精確的控制,比如在完成任務後立刻結束,而不是依賴於超時機制。

好在根據文檔的描述,我們還可以使用 CFRunLoopStop() 方法來手動結束一個 runloop。注意文檔中在介紹利用 CFRunLoopStop() 手動退出時有下面這句話:

The difference is that you can use this technique on run loops you started unconditionally.

這裡的解釋非常容易產生誤會,如果在閱讀時沒有注意到 exit 和 terminate 的微小差異就很容易掉進坑裡,因為在 run 方法的文檔中還有這句話:

If you want the run loop to terminate, you shouldn't use this method

總的來說,如果你還想從 runloop 裡面退出來,就不能用 run 方法。根據實踐結果和文檔,另外兩種啟動方法也無法手動退出。

正確的做法

難道子線程中開啟了 runloop 就無法結束並釋放了麼?這顯然是一個不合理的結論,經過一番查找,終於在這篇文章裡找到了答案,它給出了使用 CFRunLoopStop() 無效的原因:

CFRunLoopStop() 方法只會結束當前的 runMode:beforeDate: 調用,而不會結束後續的調用。

這也就是為什麼 Runloop 的文檔中說 CFRunLoopStop() 可以 exit(退出) 一個 runloop,而在 run 等方法的文檔中又說這樣會導致 runloop 無法 terminate(終結)。

文章中給出的方案是使用 CFRunLoopRun() 啟動 runloop,這樣就可以通過 CFRunLoopStop() 方法結束。而文檔則推薦了另一種方法:

BOOL shouldKeepRunning = YES; // global
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

我嘗試了文檔提供的方法,確實不會導致內存洩漏,但不方便驗證 runloop 是否真的開啟,然後又被終止。所以我實際采用的是第一種方案:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
        [self performSelector:@selector(stopThread) onThread:thread withObject:nil waitUntilDone:YES];
    }
}
- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
    NSThread *thread = [NSThread currentThread];
    [thread cancel];
}
- (void)run {
    @autoreleasepool {
        NSLog(@"current thread = %@", [NSThread currentThread]);
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        if (!self.emptyPort) {
            self.emptyPort = [NSMachPort port];
        }
        [runLoop addPort:self.emptyPort forMode:NSDefaultRunLoopMode];
        [runLoop runMode:NSRunLoopCommonModes beforeDate:[NSDate distantFuture]];
    }
}

驗證

采用上述方案後,確實可以觀察到不會再出現內存洩漏問題,但這並不是終點。因為我們還需要驗證 runloop 確實在啟動後被關閉。

為了證明 runloop 確實啟動,我設計了如下方法:

- (void)printSomething {
    NSLog(@"current thread = %@", [NSThread currentThread]);
    [self performSelector:@selector(printSomething) withObject:nil afterDelay:1];
}

我們知道 performSelector:withObject:afterDelay 依賴於線程的 runloop,因為它本質上是由一個定時器負責定期加入到 runloop 中執行。所以如果這個方法可以成功執行,說明當前線程的 runloop 已經開啟,否則則說明沒有啟動。

為了證明 runloop 可以被終止,我創建了一個按鈕,在點擊按鈕時執行以下方法:

- (void)stopButtonDidClicked:(id)sender {
    [self performSelector:@selector(stopRunloop) onThread:self.thread withObject:nil waitUntilDone:YES];
}
- (void)stopRunloop {
    CFRunLoopStop(CFRunLoopGetCurrent());
}

成功的觀察到點擊按鈕後,控制台不再有日志輸出,因此證明 runloop 確實已經停止。

總結

啰嗦了這麼多,其實是為了研究如何利用 runloop 實現線程保活。要注意的地方主要有以下點:

  • 了解 runloop 實現線程保活的原理,注意添加的那個空 port

  • 了解 runloop 導致的線程對象內存洩漏問題

  • 了解 runloop 的幾種啟動方式以及彼此之間的關聯

  • 了解 runloop 的釋放方式和原理

由於相關資料的匮乏以及個人水平有限,雖然竭力研究但仍不保證絕對的正確性,歡迎交流指正。

最後,文章開頭對 AFN 的分析留作一個簡單的思考題,為什麼 AFN 中的用法不會有問題?

參考資料

Run Loops 官方文檔

Runloop not being stopped by CFRunLoopStop?

深入理解 RunLoop

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved