深入研究 Runloop 與執行緒保活

bestswifter發表於2016-07-25

在討論 runloop 相關的文章,以及分析 AFNetworking(2.x) 原始碼的文章中,我們經常會看到關於利用 runloop 進行執行緒保活的分析,但如果不求甚解的話,極有可能因此學會了一個錯誤的用法,本文就來分析一下其中常見的誤區。

我提供了一個 Demo,可以在我的 Github 上下載並執行一遍,文章中只提供了部分程式碼。

AFN 中的實現

首先我們知道在舊版本的AFN 中使用了 NSURLConnection 來發起並處理網路連線。AFN 的做法是把網路請求的發起和解析都放在同一個子執行緒中進行,但由於子執行緒預設不開啟 runloop,它會向一個 C語言程式那樣在執行完所有程式碼後退出執行緒。而網路請求是非同步的,這會導致獲取到請求資料時,執行緒已經退出,代理方法沒有機會執行。因此,AFN 的做法是使用一個 runloop 來保證執行緒不死,也就是下面這段被講爛了的程式碼:

當然,單獨看這一個方法意義不大,我們稍微結合一下上下文,看看這個方法在哪裡被呼叫:

似乎這種寫法提供了一種思路:“如果需要在子執行緒中非同步執行操作,可以利用 runloop 進行執行緒保活”。但準確的來說,AFN 的這種寫法並不能實現我們的需求,它只是在 AFN 這個特殊場景下可以工作。

不信你可以嘗試閱讀一下第二段程式碼,看看它和平時使用 NSThread 時有什麼區別,如果沒看出來也無妨,先記住這段程式碼,我們稍後分析。

NSThread 與記憶體洩漏

這種寫法的第一個問題就是存在記憶體洩漏。我們構造以下用例,其實就是把 AFN 的執行緒建立放在一個迴圈裡:

奇怪的事情出現了,儘管是在 ARC 環境下,記憶體依然不停的上漲。如果我們把 run 方法中和 runloop 相關的程式碼刪除則不會出現上述問題,顯然,開啟 runloop 導致了記憶體洩漏,也就是 thread 物件無法釋放。

這裡的 emptyPort 用來維持 runloop 的執行,根據官方文件的描述,如果 runloop 中沒有任何 modeItem,就不會啟動,而是立刻退出。之所以選擇作為屬性而不是臨時變數,是因為我發現每次呼叫 [NSMachPort port] 方法都會佔用記憶體,原因暫時不清楚。

我們可以嘗試手動結束 runloop 並關閉執行緒:

很遺憾,這依然沒有任何效果。而且不難猜測是我們沒有能正確的結束 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() 方法結束。而文件則推薦了另一種方法:

我嘗試了文件提供的方法,確實不會導致記憶體洩漏,但不方便驗證 runloop 是否真的開啟,然後又被終止。所以我實際採用的是第一種方案:

驗證

採用上述方案後,確實可以觀察到不會再出現記憶體洩漏問題,但這並不是終點。因為我們還需要驗證 runloop 確實在啟動後被關閉。

為了證明 runloop 確實啟動,我設計瞭如下方法:

我們知道 performSelector:withObject:afterDelay 依賴於執行緒的 runloop,因為它本質上是由一個定時器負責定期加入到 runloop 中執行。所以如果這個方法可以成功執行,說明當前執行緒的 runloop 已經開啟,否則則說明沒有啟動。

為了證明 runloop 可以被終止,我建立了一個按鈕,在點選按鈕時執行以下方法:

成功的觀察到點選按鈕後,控制檯不再有日誌輸出,因此證明 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

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

深入研究 Runloop 與執行緒保活 深入研究 Runloop 與執行緒保活

相關文章