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