在討論 runloop 相關的文章,以及分析 AFNetworking(2.x) 源碼的文章中,咱們常常會看到關於利用 runloop 進行線程保活的分析,但若是不求甚解的話,極有可能所以學會了一個錯誤的用法,本文就來分析一下其中常見的誤區。html
我提供了一個 Demo,能夠在個人 Github 上下載並運行一遍,文章中只提供了部分代碼。ios
首先咱們知道在舊版本的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
時有什麼區別,若是沒看出來也無妨,先記住這段代碼,咱們稍後分析。網絡
這種寫法的第一個問題就是存在內存泄漏。咱們構造如下用例,其實就是把 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 的介紹,它的啓動方式一共有三種:
這三種進入方式分別對應了三種方法,其中第一種就是咱們目前使用的:
接下來分別是對三種方式的介紹,文字比較囉嗦,這裏我簡單總結一下,有興趣的讀者能夠直接看原文。
查看 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 實現線程保活。要注意的地方主要有如下點:
因爲相關資料的匱乏以及我的水平有限,雖然竭力研究但仍不保證絕對的正確性,歡迎交流指正。
最後,文章開頭對 AFN 的分析留做一個簡單的思考題,爲何 AFN 中的用法不會有問題?