授權轉載,作者: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