深刻研究 Runloop 與線程保活

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

我提供了一個 Demo,能夠在個人 Github 上下載並運行一遍,文章中只提供了部分代碼。ios

AFN 中的實現

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

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];

        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}複製代碼

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

+ (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 這個特殊場景下能夠工做。swift

不信你能夠嘗試閱讀一下第二段代碼,看看它和平時使用 NSThread 時有什麼區別,若是沒看出來也無妨,先記住這段代碼,咱們稍後分析。網絡

NSThread 與內存泄漏

這種寫法的第一個問題就是存在內存泄漏。咱們構造如下用例,其實就是把 AFN 的線程建立放在一個循環裏:app

- (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] 方法都會佔用內存,緣由暫時不清楚。oop

咱們能夠嘗試手動結束 runloop 並關閉線程:ui

- (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 的介紹,它的啓動方式一共有三種:

  1. Unconditionally
  2. With a set time limit
  3. In a particular mode

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

  1. run
  2. runUntilDate
  3. runMode:beforeDate:

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

  • 無條件進入是最簡單的作法,但也最不推薦。這會使線程進入死循環,從而不利於控制 runloop,結束 runloop 的惟一方式是 kill 它。
  • 若是咱們設置了超時時間,那麼 runloop 會在處理完事件或超時後結束,此時咱們能夠選擇從新開啓 runloop。這種方式要因爲前一種
  • 這是相對來講最優秀的方式,相比於第二種啓動方式,咱們能夠指定 runloop 以哪一種模式運行。

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

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

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

  1. 設置超時時間
  2. 手動結束

若是你使用方法二或三來啓動 runloop,那麼在啓動的時候就能夠設置超時時間。然而考慮到目標是:「利用 runloop 進行線程保活」,因此咱們但願對線程和它的 runloop 有最精確的控制,好比在完成任務後馬上結束,而不是依賴於超時機制。

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

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

這裏的解釋很是容易產生誤會,若是在閱讀時沒有注意到 exitterminate 的微小差別就很容易掉進坑裏,由於在 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 實現線程保活。要注意的地方主要有如下點:

  1. 瞭解 runloop 實現線程保活的原理,注意添加的那個空 port
  2. 瞭解 runloop 致使的線程對象內存泄漏問題
  3. 瞭解 runloop 的幾種啓動方式以及彼此之間的關聯
  4. 瞭解 runloop 的釋放方式和原理

因爲相關資料的匱乏以及我的水平有限,雖然竭力研究但仍不保證絕對的正確性,歡迎交流指正。

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

參考資料

  1. Run Loops 官方文檔
  2. Runloop not being stopped by CFRunLoopStop?
  3. 深刻理解 RunLoop
相關文章
相關標籤/搜索