在討論 runloop 相關的文章,以及分析 AFNetworking(2.x) 原始碼的文章中,我們經常會看到關於利用 runloop 進行執行緒保活的分析,但如果不求甚解的話,極有可能因此學會了一個錯誤的用法,本文就來分析一下其中常見的誤區。
我提供了一個 Demo,可以在我的 Github 上下載並執行一遍,文章中只提供了部分程式碼。
AFN 中的實現
首先我們知道在舊版本的AFN 中使用了 NSURLConnection 來發起並處理網路連線。AFN 的做法是把網路請求的發起和解析都放在同一個子執行緒中進行,但由於子執行緒預設不開啟 runloop,它會向一個 C語言程式那樣在執行完所有程式碼後退出執行緒。而網路請求是非同步的,這會導致獲取到請求資料時,執行緒已經退出,代理方法沒有機會執行。因此,AFN 的做法是使用一個 runloop 來保證執行緒不死,也就是下面這段被講爛了的程式碼:
1 2 3 4 5 6 7 8 9 |
+ (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { [[NSThread currentThread] setName:@"AFNetworking"]; NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } |
當然,單獨看這一個方法意義不大,我們稍微結合一下上下文,看看這個方法在哪裡被呼叫:
1 2 3 4 5 6 7 8 9 10 |
+ (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 的執行緒建立放在一個迴圈裡:
1 2 |
- (void)memoryTest { for (int i = 0; i |
奇怪的事情出現了,儘管是在 ARC 環境下,記憶體依然不停的上漲。如果我們把 run
方法中和 runloop 相關的程式碼刪除則不會出現上述問題,顯然,開啟 runloop 導致了記憶體洩漏,也就是 thread
物件無法釋放。
這裡的 emptyPort 用來維持 runloop 的執行,根據官方文件的描述,如果 runloop 中沒有任何 modeItem,就不會啟動,而是立刻退出。之所以選擇作為屬性而不是臨時變數,是因為我發現每次呼叫 [NSMachPort port] 方法都會佔用記憶體,原因暫時不清楚。
我們可以嘗試手動結束 runloop 並關閉執行緒:
1 2 |
- (void)memoryTest { for (int i = 0; i |
很遺憾,這依然沒有任何效果。而且不難猜測是我們沒有能正確的結束 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()
方法結束。而文件則推薦了另一種方法:
1 2 3 |
BOOL shouldKeepRunning = YES; // global NSRunLoop *theRL = [NSRunLoop currentRunLoop]; while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]); |
我嘗試了文件提供的方法,確實不會導致記憶體洩漏,但不方便驗證 runloop 是否真的開啟,然後又被終止。所以我實際採用的是第一種方案:
1 2 |
- (void)memoryTest { for (int i = 0; i |
驗證
採用上述方案後,確實可以觀察到不會再出現記憶體洩漏問題,但這並不是終點。因為我們還需要驗證 runloop 確實在啟動後被關閉。
為了證明 runloop 確實啟動,我設計瞭如下方法:
1 2 3 4 |
- (void)printSomething { NSLog(@"current thread = %@", [NSThread currentThread]); [self performSelector:@selector(printSomething) withObject:nil afterDelay:1]; } |
我們知道 performSelector:withObject:afterDelay
依賴於執行緒的 runloop,因為它本質上是由一個定時器負責定期加入到 runloop 中執行。所以如果這個方法可以成功執行,說明當前執行緒的 runloop 已經開啟,否則則說明沒有啟動。
為了證明 runloop 可以被終止,我建立了一個按鈕,在點選按鈕時執行以下方法:
1 2 3 4 5 6 7 |
- (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 中的用法不會有問題?
參考資料
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式