RunLoop面試題分析

博客連接RunLoop面試題分析面試

更新於2019-07-29 完善AFNetworking常駐線程的做用bash

重拾RunLoop原理中RunLoop的源碼進行了分析,本該作一個總結方便之後查看,可是RunLoop中的知識點相對來講比較多,總結的東西就比較多。在面試中,又常常愛問一些RunLoop的知識點,接着就以我以前能回憶起來的面試題來對RunLoop作一個總結。網絡

RunLoop的做用

RunLoop就是一種循環,它將運行的程序包裹起來。當沒有事件時,RunLoop會進入休眠狀態,有事件發生時,RunLoop會去找對應的Handler處理事件。RunLoop可讓線程在須要作事的時候忙起來,不須要的話就讓線程休眠,一種讓線程能隨時處理事件但並不退出的機制。多線程

RunLoop與線程之間的關係

  • RunLoop和線程之間是一一對應的,它們之間的關係保存在一個全局字典以及線程私有數據中;
  • 在線程建立的時候,是沒有對應的RunLoop,它的建立是在第一次獲取的時候,它的銷燬則發生在線程銷燬的時候;

NSTimer在列表滑動時失效的緣由和解決方法

NSTimer默認運行在RunLoop的kCFRunLoopDefaultMode下,在列表滑動的時候,RunLoop會切換UITrackingRunLoopMode,由於RunLoop只能運行在一種模式下,因此NSTimer不會執行回調。併發

使用[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];將timer添加到CommonModes中。它可讓timer運行在全部被標記爲Common屬性的Mode中,kCFRunLoopDefaultModeUITrackingRunLoopMode默認都已經被標爲」Common」屬性的。async

NSTimer不精準的理由

NSTimer定時器的觸發是基於RunLoop運行的,因此使用NSTimer以前必須註冊到RunLoop,可是RunLoop爲了節省資源並不會在很是準確的時間點調用定時器。當RunLoop在處理比較複雜的任務時,可能會錯過一個時間點,那麼定時器只能等到下一個時間點執行,並不會延後執行。ide

NSTimer不精準的解決方法

  • 使用GCDTimer做爲定時器,它是基於硬件時間的,相對來講更精準一點;
  • 開闢子線程中進行NSTimer的操做,再在主線程中修改UI界面顯示操做結果,這麼作相對於將NSTimer添加在主線程RunLoop來講是會提升精確度,可是若是說子線程的RunLoop也比較繁忙的話,那同樣會帶來偏差;

RunLoop的mode和Common mode

Mode是RunLoop的運行模式,每一個RunLoop須要運行在一個特定的Mode中。若是須要切換Mode,只能退出Loop,再從新指定一個Mode進入。不一樣組的source0/source1/observer/timer能夠相互隔離,互不影響,從而提升執行效率。函數

經常使用的RunLoop的Modeoop

  • kCFRunLoopDefaultMode:App的默認Mode,一般主線程是在這個Mode下運行;
  • UITrackingRunLoopMode:界面跟蹤Mode,用於滾動視圖追蹤觸摸滑動,保證界面滑動時不受其餘 Mode影響;
  • kCFRunLoopCommonModes:這是一個佔位用的Mode,並非一種真正的Mode;

Common mode並非一個具體的Mode,它只是用來將Mode標記爲Common屬性。當source0/source1/observer/timer被添加到Common mode下的時候,意味着他們能夠運行在全部被標記爲Common屬性的Mode中。post

kCFRunLoopDefaultModeUITrackingRunLoopMode默認就被標記了Common屬性。

RunLoop響應用戶操做

以按鈕點擊觸發事件爲例,點擊屏幕的時候,首先系統內部捕獲到這個點擊事件,這是在Source1中處理的,Source1會包裝成事件丟到事件隊列中,交給Source0處理。

RunLoop與UI刷新

當UI須要更新的時候,好比改變了frame、更新了UIView/CALayer的層次時,或者手動調用了setNeedsLayout/setNeedsDisplay方法後,這個UIView/CALayer就被標記爲待處理,並被提交到一個全局的容器去。

蘋果註冊了一個Observer監聽BeforeWaiting(即將進入休眠) 和 Exit(即將退出Loop) 事件,回調去執行一個很長的函數:CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*)() 這個函數裏會遍歷全部待處理的UIView/CAlayer以執行實際的繪製和調整,並更新界面。

RunLoop與UI刷新

RunLoop與AutoreleasePool

在程序啓動以後,主線程會建立一個Runloop,也會建立兩個Observer,回調工做都是在_wrapRunLoopWithAutoreleasePoolHandler函數中。

第一個Observer監聽的是Entry(即將進入Loop),回調是在_objc_autoreleasePoolPush()中建立自動釋放池的,優先級是最高的,保證建立釋放池是在全部回調以前。

第二個Observer監聽有兩個事件:BeforeWaiting(進入休眠)時調用_objc_autoreleasePoolPop_objc_autoreleasePoolPush釋放舊的釋放池以及建立新的釋放池;Exit(退出Loop)調用_objc_autoreleasePoolPop來釋放自動釋放池。這個優先級是最低的,保證釋放池發生在全部回調以後調用。

實現線程保活/控制線程生命週期

  1. 首先須要知道線程通常執行完任務後,就會被銷燬;
  2. 爲何說使用了Runloop就能夠實現線程保活。添加runloop並運行起來,其實是添加了一個循環,這樣這個線程的程序一直卡在這個循環上,這樣至關於線程的任務一直沒有執行完,因此線程一直不會銷燬;
  3. 如何銷燬這個線程? 中止runloop,可使用CFRunLoopStop函數。

在AFNetworking2.x中即是利用runloop實現線程保活的。代碼以下:

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

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
複製代碼

接着本身控制線程的生命週期

@interface TestThreadViewController ()

@property (nonatomic, strong) TestThread *thread;

@end

@implementation TestThreadViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.view.backgroundColor = UIColor.whiteColor;
    
    __weak __typeof(self) weakSelf = self;
    
    // 這裏有個問題要注意: 線程內部會對target形成一個強引用
    // 使用Block形式的API
    self.thread = [[TestThread alloc] initWithBlock:^{
        NSLog(@"%s -----beigin-----", __func__);
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        // 添加一個Source1
        [runLoop addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
        // 調用runloop run, 則runloop就不能被中止,其內部是在不斷調用runMode:beforeDate:
        // 不能調用[runLoop run];
        
        while (weakSelf) {
            [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
        
        NSLog(@"%s -----end-----", __func__);
    }];
    
    [self.thread start];
    
    UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
    button.frame = CGRectMake(100, 100, 30, 20);
    button.backgroundColor = UIColor.redColor;
    [self.view addSubview:button];
    [button addTarget:self action:@selector(_didButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
}

- (void)dealloc {
    NSLog(@"%@ dealloc", self.class);
    
    // 下面的代碼就是說在子線程中執行threadStop方法
    // 這裏要注意waitUntilDone的用法
    // 若是傳入NO,該方法會直接返回執行後續的代碼,這樣會出現問題
    // 若是傳入YES,表明必須等到子線程執行完threadStop方法才能執行後面的代碼
    [self performSelector:@selector(threadStop)
                 onThread:self.thread
               withObject:nil
            waitUntilDone:YES];
}

- (void)_didButtonPressed:(id)sender {
    NSLog(@"開始銷燬頁面");
    [self dismissViewControllerAnimated:YES completion:nil];
}

#pragma mark - 線程保活

- (void)threadPerformingTasks {
    NSLog(@"%@ %s", [NSThread currentThread], __func__);
}

- (void)threadStop {
    CFRunLoopStop(CFRunLoopGetCurrent());
    NSLog(@"%s", __func__);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"觸發點擊事件");
    [self performSelector:@selector(threadPerformingTasks)
                 onThread:self.thread
               withObject:nil
            waitUntilDone:NO];
}

@end
複製代碼

執行結果:

RunLoop控制線程生命週期

RunLoop與NSURLConnection

NSURLConnection目前來講應該不多會被問到了,其底層會使用CFSocket去發送和接收請求,在發送和接收的一些事件發生後通知原來線程的Runloop去回調事件。

這道題能夠說是在RunLoop的範疇,可是它更像是AFNetworking2.x爲何使用線程保活的解答,只是內部使用RunLoop技術去解決問題。

首先須要在子線程去開始鏈接,請求發送後,所在的子線程須要保活以保證正常接收到 NSURLConnectionDelegate回調方法。若是每來一個請求就開一條線程,而且保活線程,這樣開銷太大了。因此只須要保活一條固定的線程,在這個線程裏發起請求、接收回調。

這裏要注意一點:雖然使用了一條固定的線程,可是網絡請求並非單線程的,網絡請求會放在一個併發隊列裏,是多線程的。請求返回後通知常駐線程,再經過常駐線程通知主線程。

涉及到RunLoop的相關API

如下代碼的輸出結果

- (void)viewDidLoad {
    [super viewDidLoad];
    NSInteger number = 1;
    NSLog(@"%zd", number);
    
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_async(queue, ^{
        [self performSelector:@selector(printString) withObject:nil afterDelay:0];
    });
    
    number = 3;
    NSLog(@"%zd", number);
}

- (void)printString {
    NSLog(@"sdasdas");
}
複製代碼

結果:1 3

NSObject的performSelecter:afterDelay:或者performSelector:onThread:這樣相似的方法被調用時,都依賴於RunLoop。子線程默認不啓用RunLoop的,因此不會執行Selector中傳入的方法。除非說API內部啓用了RunLoop。好比調用performSelector:onThread:withObject:waitUntilDone:方法,waitUntilDone:傳YES的時候,內部會本身啓動RunLoop。

解決方法:

dispatch_async(queue, ^{
    [self performSelector:@selector(printString) withObject:nil afterDelay:0];
    
    NSRunLoop *runloop = [NSRunLoop currentRunLoop];
    [runloop run];
});
複製代碼

RunLoop的運行過程/內部實現

將下面這張圖深深地刻在本身的骨子裏

RunLoop_run

RunLoop的休眠實現

當RunLoop一旦休眠意味着CPU不會分配任何資源,那線程也進入休眠。RunLoop休眠內部是調用了mach_msg()函數。操做系統中有內核層面的API和應用層面的API。mach_msg()能夠理解爲是應用層面的API,告訴內核休眠該線程休眠。一旦接受到系統事件,也會轉化成內核API,告訴內核須要喚醒該線程,那麼又能夠執行應用層API了。因此RunLoop的休眠能夠當作是用戶狀態到內核狀態的切換,而喚醒RunLoop就是內核狀態到用戶狀態的切換。

相關文章
相關標籤/搜索