基于RunLoop進行線程保活的簡單分析

線程與RunLoop

線程一般一次只能執行一個任務,執行完成后線程就會退出;如果需要一個執行任務后不退出的永駐線程,可以利用RunLoop實現;
利用RunLoop實現線程保活(常駐線程),我們需要明確線程與RunLoop的關系:

  • 線程和 RunLoop 之間是一一對應的,其關系是保存在一個全局的Dictionary里(key是線程地址, value是RunLoop對象);
  • 線程剛創建時并沒有RunLoop,如果不主動獲取,那它一直都不會有(主線程的RunLoop在程序啟動時系統就已經獲取,無需再主動獲取);RunLoop的創建是發生在第一次獲取時,RunLoop的銷毀是發生在線程結束時;
  • 線程添加了RunLoop,并運行起來;實際上是添加了一個do,while循環,這樣這個線程的程序一直卡在這個do,while循環上,這樣相當于線程的任務一直沒有執行完,所以線程一直不會退出;

AFNetworking2.x中的實現

基于RunLoop的線程保活,早期的AFN就有經典的實現:

+ (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;
}

_networkRequestThread就是創建的常駐線程,這個線程里獲取了RunLoop并運行了;所以這個線程不會被退出、銷毀,除非RunLoop停止;這樣就實現了線程保活功能;

AFNetworking2.x線程保活的作用

  • AFNetworking2.x網絡請求是基于NSURLConnection實現的;NSURLConnection是被設計成異步發送的,調用了-start方法后,NSURLConnection 會新建一些線程用底層的CFSocket去發送和接收請求,在發送和接收的一些事件發生后通知原來線程的RunLoop去回調事件。也就是說NSURLConnection的代理回調,也是通過RunLoop觸發的;
  • 平常我們自己使用NSURLConnection實現網絡請求時,URLConnection的創建與回調一般都是在主線程,主線程本來一直存在所有回調沒有問題;
  • AFN作為網絡層框架,在NSURLConnection回調回來之后,對Response 做了一些諸如序列化、錯誤處理的操作的,這些操作都放在子線程去做,處理后接著回到主線程,再通過AFN自己的代理回調給用戶;
    AFN的接收NSURLConnection回調的這個線程,正常情況下在執行[connection start]發送網絡請求后就立即退出了,后續的回調就調用不了;而線程保活就能確保該線程不退出,回調成功;

AFNetworking3.x不再需要線程保活

AFNetworking3.x是基于NSUrlSession實現的,NSUrlSession參考了AFN2.x的優點,自己維護了一個線程池,做Request線程的調度與管理;因此AFN3.x無需常駐線程,只是用的時候CFRunLoopRun();開啟RunLoop,結束的時候CFRunLoopStop(CFRunLoopGetCurrent());停止RunLoop即可;

線程保活代碼實現細節

參考AFN的實現,似乎我們只要依葫蘆畫瓢也能這樣實現線程保活;但其中很多細節需要探究,接下來一步步分析:

為了監聽線程的生命周期,先創建NSThread的子類;

@interface KeepThread : NSThread

@end

@implementation KeepThread

- (void)dealloc {
    NSLog(@"%s",__func__);
}

@end

然后依照AFN代碼,創建線程;(這里使用block的方式代替了target的方式,因為target會對self強引用不利于分析內存問題);在開啟RunLoop前后分別打印,以便查看代碼執行狀態;

- (IBAction)start:(id)sender {
    self.thread = [[KeepThread alloc] initWithBlock:^{
        NSLog(@"%@,start", [NSThread currentThread]);
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
        
        NSLog(@"%@,end", [NSThread currentThread]);
    }];
    [self.thread start];
}

然后點擊vc的start按鈕執行代碼,結果是只打印了start,未輸出end;

<KeepThread: 0x6000022695c0>{number = 3, name = (null)},start

這是因為開啟RunLoop并運行后,代碼一直在[runloop run]這句代碼循環,不會往下執行;block里的代碼沒有執行完,那么線程就不會退出、銷毀;這樣就達到了線程保活的作用,我們也可以從其他方面驗證該線程一直存在著:

  • 退出當前vc,vc銷毀;但是可以發現,KeepThread對象self.thread并未調用-dealloc方法,線程并不會銷毀;
  • 添加一個點擊事件,通過performSelector:onThread:在線程中執行代碼:
- (void)dosomething {
    NSLog(@"%s",__func__);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self performSelector:@selector(dosomething) onThread:self.thread withObject:nil waitUntilDone:NO];
}

每點擊一次,會發現都能正常執行dosomething方法;這也說明線程一直存活,能被喚醒;

不過,以上代碼,一個會令人疑惑的地方是[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];;runLoop中添加了NSMachPort,但是NSMachPort對象并沒有用到;
NSMachPort的確沒有其他實際用處,只是因為一個RunLoop如果沒有任何要處理的事件時,就會退出;為了保證RunLoop不會一執行就退出就需要加上這段代碼;
如果注釋掉這句代碼,那么就會輸出以下結果,線程正常退出了;

<KeepThread: 0x600003cabfc0>{number = 3, name = (null)},start
<KeepThread: 0x600003cabfc0>{number = 3, name = (null)},end

而且這個也不是一定只能添加port事件,添加timer事件也能實現同樣效果;只是port事件簡單點;

[runLoop addTimer:timer forMode:NSDefaultRunLoopMode]
可控制的常駐線程

以上代碼雖然實現了線程保活,但是并沒有實現手動退出RunLoop,銷毀線程的功能;而且經過上面的分析,這種方式的線程保活還存在內存泄漏的風險(因為thread釋放不了,AFN的使用場景不同本身設計的就是永不釋放同App生命周期一致);接下來我們就來嘗試實現一個可控制的線程,即可以隨時讓保活的線程"死"去;
原理上講,只要保證該線程的RunLoop停止,那么線程就能正常退出;接下來我們就添加一個按鈕,當點擊按鈕時調用代碼主動停止RunLoop:

- (IBAction)stop:(id)sender {
    [self performSelector:@selector(stopThread) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
}

令人意外的是,當點擊停止后,沒有任何輸出,線程還是沒有退出;

這個其實可以從RunLoop的run方法官方文檔中找到答案:

If no input sources or timers are attached to the run loop, this method exits immediately; otherwise, it runs the receiver in the NSDefaultRunLoopMode by repeatedly invoking runMode:beforeDate:. In other words, this method effectively begins an infinite loop that processes data from the run loop’s input sources and timers.
Manually removing all known input sources and timers from the run loop is not a guarantee that the run loop will exit. macOS can install and remove additional input sources as needed to process requests targeted at the receiver’s thread. Those sources could therefore prevent the run loop from exiting.
If you want the run loop to terminate, you shouldn't use this method. Instead, use one of the other run methods and also check other arbitrary conditions of your own, in a loop. A simple example would be:

NSRunLoop *theRL = [NSRunLoop currentRunLoop];
while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

大概意思是,run方法其實就是開啟了一個無限循環,循環里調用runMode:beforeDate:運行RunLoop;因此我們調用CFRunLoopStop(CFRunLoopGetCurrent());只能退出exit一個RunLoop,但是并不能終止terminate外部的while循環;
也就是說以上代碼其實就類似下面這段代碼:

- (void)threadRun {
    @autoreleasepool {
        NSLog(@"%@,start", [NSThread currentThread]);
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        while (YES) {
            [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }

        NSLog(@"%@,end", [NSThread currentThread]);
    }
}

因此通過run方法開啟的RunLoop無法終止;如果想終止,就需要使用 runMode:beforeDate:.方式,并使用一個BOOL變量控制while循環以此控制RunLoop;

可控制的線程保活的最終代碼如下:

- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
    self.isStop = YES;
}

- (IBAction)start:(id)sender {
    __weak typeof (self) weakSelf = self;
    self.thread = [[KeepThread alloc] initWithBlock:^{
        NSLog(@"%@,start", [NSThread currentThread]);

        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        while (!weakSelf.isStop) {
            [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
        
        NSLog(@"%@,end", [NSThread currentThread]);
    }];
    [self.thread start];
}

參考:
AFNetworking3.0后為什么不再需要常駐線程?
深入研究 Runloop 與線程保活
深入理解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

推薦閱讀更多精彩內容