深入研究 Runloop 與線程保活

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,619評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,155評論 3 425
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,635評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,539評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,255評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,646評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,655評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,838評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,399評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,146評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,338評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,893評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,565評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,983評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,257評論 1 292
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,059評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,296評論 2 376

推薦閱讀更多精彩內容