iOS 底層探索之Runloop

淡定的笨鳥發表於2019-09-07

本篇是探索底層Runloop,目的是能夠深入理解Runloop是幹什麼用的?什麼時候用?怎麼用?

1、什麼是runloop?

runloop是一個迴圈,它在持續不斷的跑圈,iOS應用程式剛開啟時,就建立了一個主執行緒,並預設建立了Runloop保持主執行緒的持續執行。

我們到官方文件搜尋一下Runloop,如圖所示

image.png
發現找不到Runloop,再嘗試搜尋thread,發現線上程介紹裡面竟然出現了Runloop字樣,如圖
image.png

如此可見,Runloop和執行緒之間有著不清不楚的關係。

再來看一下CFRunloop原始碼中的CFRunLoopRun函式

void CFRunLoopRun(void) {	/* DOES CALLOUT */
    int32_t result;
    do {
        result = CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
        CHECK_FOR_FORK();
    } while (kCFRunLoopRunStopped != result && kCFRunLoopRunFinished != result);
}
複製程式碼

我們可以看到,Runloop本質是一個do...while迴圈。 結合官方文件提供的執行迴圈結構看一下,Runloop是如何執行的。

image.png

從上圖中我們可以看出,Runloop就是依附線上程上的迴圈,通過輸入源(Input sources)和定時源(Timer sources)接收事件,然後交給執行緒去處理事件。

所以什麼是Runloop? Runloop就是一個迴圈,為了執行緒而生,它的本質是一個do...while迴圈

2、Runloop的作用

  • 1、保持程式持續的執行 一般情況下,執行緒在執行完任務就會退出,如果我們不希望執行緒退出,還想讓它執行更多的任務,就需要用到Runloop了。
  • 2、接收並處理App中的各種事件 Runloop在迴圈時,通過輸入源(input source)和定時源(timer source)接收App事件(觸控事件、UI重新整理時間、定時器、performSelector)。
  • 3、提升效能 線上程不工作時休眠,節省CPU資源。

以上就是Runloop的作用,這裡只是概括一下,後面會有具體到用法的Runloop應用。

3、Runloop和執行緒的關係

接下來我們結合Runloop原始碼看看它和執行緒之間的關係,找到_CFRunLoopGet0這個函式,它的作用是獲取runloop物件

//CFRunloop.c
//這個類是獲取Runloop物件的
CF_EXPORT CFRunLoopRef _CFRunLoopGet0(pthread_t t) {
    if (!__CFRunLoops) {//CFRunloops是存放runloop和執行緒對應關係的字典
        //建立存放runloop和執行緒的字典
        CFMutableDictionaryRef dict = CFDictionaryCreateMutable(kCFAllocatorSystemDefault, 0, NULL, &kCFTypeDictionaryValueCallBacks);
        //獲取主執行緒runloop
        CFRunLoopRef mainLoop = __CFRunLoopCreate(pthread_main_thread_np());
        //將主執行緒和主執行緒對應的Runloop物件mainLoop新增到字典中
        CFDictionarySetValue(dict, pthreadPointer(pthread_main_thread_np()), mainLoop);
        if (!OSAtomicCompareAndSwapPtrBarrier(NULL, dict, (void * volatile *)&__CFRunLoops)) {
            CFRelease(dict);
        }
        CFRelease(mainLoop);
        __CFSpinLock(&loopsLock);
    }
    //獲取子執行緒對應的runloop
    CFRunLoopRef loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
    __CFSpinUnlock(&loopsLock);
    if (!loop) {
        //依據執行緒t建立Runloop物件
        CFRunLoopRef newLoop = __CFRunLoopCreate(t);
        __CFSpinLock(&loopsLock);
        
        loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
        if (!loop) {
            //繫結執行緒和runloop到字典中
            CFDictionarySetValue(__CFRunLoops, pthreadPointer(t), newLoop);
            loop = newLoop;
        }
    }
}
複製程式碼

上述程式碼中有一段CFDictionarySetValue(dict, pthreadPointer(pthread_main_thread_np()), mainLoop);,意思是以執行緒為key,runloop為value,將runloop儲存到一個全域性的字典中。 至此我們得出了第一個結論:執行緒和Runloop是一一對應的關係。

再看這段獲取主執行緒runloop的程式碼CFRunLoopRef mainLoop = __CFRunLoopCreate(pthread_main_thread_np());,意思是建立一個主執行緒的Runloop。 至此得出第二個結論:Runloop是以執行緒為引數建立的,並儲存在全域性的字典裡。

再看後半段程式碼

//獲取子執行緒對應的runloop
    CFRunLoopRef loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
    __CFSpinUnlock(&loopsLock);
    if (!loop) {
        //依據執行緒t建立Runloop物件
        CFRunLoopRef newLoop = __CFRunLoopCreate(t);
        __CFSpinLock(&loopsLock);
        
        loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
        if (!loop) {
            //繫結執行緒和runloop到字典中
            CFDictionarySetValue(__CFRunLoops, pthreadPointer(t), newLoop);
            loop = newLoop;
        }
    }
複製程式碼

這段程式碼的意思是先從__CFRunLoops字典中獲取Runloop物件,若沒有,則以執行緒為引數建立一個,並儲存到__CFRunLoops字典裡,至此我們知道了第三個結論:主執行緒的Runloop由系統自動建立,子執行緒的Runloop需要在子執行緒裡手動獲取Runloop時建立。

綜上我們知道了Runloop和執行緒的關係:
1、執行緒和Runloop是一一對應的關係。
2、Runloop是以執行緒為引數建立的,並儲存到全域性的字典裡。
3、主執行緒的Runloop由系統自動建立,子執行緒的Runloop需要在子執行緒裡手動獲取Runloop時建立。
4、Runloop在第一次獲取時建立,線上程銷燬時隨之銷燬。

4、Runloop的五個物件

  1. __CFRunLoop * CFRunLoopRef;
  2. __CFRunLoopSource * CFRunLoopSourceRef;
  3. __CFRunLoopObserver * CFRunLoopObserverRef;
  4. __CFRunLoopTimer * CFRunLoopTimerRef;
  5. CFRunloopModeRef(為什麼這個這麼寫呢,因為Runloop並沒有暴露RunloopMode這個物件)

下面逐一講一下Runloop這幾個物件的含義和它們之間的關係,如圖

iOS 底層探索之Runloop
上圖就是Runloop物件、Mode、Source、Observer、Timer之間的關係。 一個Runloop包含若干個CFRunloopModeRef(執行模式),一個CFRunloopModeRef又包含若干個CFRunLoopSourceRef(輸入源)/CFRunLoopTimerRef(定時源)/CFRunLoopObserverRef(觀察源),但是Runloop同一時間只能指定一個CFRunloopModeRef(執行模式),如果要切換CFRunloopModeRef,需要先退出Runloop,再指定一個CFRunloopModeRef(執行模式)進入。

為什麼是這樣的結構? 答:這樣做主要是為了分離Source/Timer/Observer,讓其互不影響。

4.1、CFRunLoopRef(Runloop物件)

CFRunLoopRef 是 Core Foundation 框架下 RunLoop 物件類,可通過如下方式獲取

// 獲得當前執行緒的 RunLoop 物件
CFRunLoopGetCurrent(); 
// 獲得主執行緒的 RunLoop 物件
CFRunLoopGetMain(); 
複製程式碼

也可以使用Foundation框架中的NSRunloop獲取封裝過的Runloop,NSRunloop是對CFRunLoopRef的封裝

// 獲得當前執行緒的 RunLoop 物件
[NSRunLoop currentRunLoop]; 
// 獲得主執行緒的 RunLoop 物件
[NSRunLoop mainRunLoop]; 
複製程式碼
4.2、CFRunLoopSourceRef(輸入源)

先看一下原始碼

struct __CFRunLoopSource {
    CFRuntimeBase _base;
    uint32_t _bits;
    pthread_mutex_t _lock;
    CFIndex _order;            /* immutable */
    CFMutableBagRef _runLoops;
    union {
        //對應Source0
        CFRunLoopSourceContext version0;    /* immutable, except invalidation */
        //對應Source1
        CFRunLoopSourceContext1 version1;    /* immutable, except invalidation */
    } _context;
};
複製程式碼

從原始碼看出Source分為2個版本

  • 1、Source0:這種版本的Source不能主動觸發事件,得是使用者或開發者手動觸發,比如觸控事件和performSelector等App內部事件(UIEvent),需要先進行CFRunLoopSourceSignal標記,再通過CFRunLoopWakeUp喚醒Runloop處理事件。
  • 2、Source1:基於Port,用於通過核心和執行緒之間通訊的,這種Source可以主動喚醒Runloop執行緒,一會兒用例子看一下。

先看一個觸控事件觸發Source0的例子,建立個工程,上面放一個按鈕,在點選事件的回撥中打斷點,如圖

image.png
在左側欄目中是觸發的方法,如圖
image.png
我們可以看到,使用者的觸控事件,果然觸發的是Source0。

下面是Source0的使用例子

要建立一個Source0輸入源,需要執行六步走, 1、建立Context上下文 2、建立Source0輸入源物件 3、獲取Runloop 4、繫結Runloop、Source0和mode 5、標記執行訊號CFRunloopSourceSignal 6、喚醒CFRunLoopWakeUp

- (void)source0Demo{
    //1、建立Context上下文
    CFRunLoopSourceContext context = {
        0,
        NULL,
        NULL,
        NULL,
        NULL,
        NULL,
        NULL,
        schedule,
        cancel,
        perform,
    };
    /**
     2、建立CFRunLoopSourceRef
     引數一:傳遞NULL或kCFAllocatorDefault以使用當前預設分配器。
     引數二:優先順序索引,指示處理執行迴圈源的順序。這裡我傳0為了的就是自主回撥
     引數三:為執行輸入源儲存上下文資訊的結構
     */
    CFRunLoopSourceRef source0 = CFRunLoopSourceCreate(CFAllocatorGetDefault(), 0, &context);
    //3、獲取Runloop
    CFRunLoopRef rlp = CFRunLoopGetCurrent();
    //4、繫結Source、Runloop、Mode,此時我們的source就進入待緒狀態
    CFRunLoopAddSource(rlp, source0, kCFRunLoopDefaultMode);
    //5、 一個執行訊號
    CFRunLoopSourceSignal(source0);
    //6、 喚醒 run loop 防止沉睡狀態
    CFRunLoopWakeUp(rlp);
    // 取消 移除
//    CFRunLoopRemoveSource(rlp, source0, kCFRunLoopDefaultMode);
    CFRelease(rlp);
}

void schedule(void *info, CFRunLoopRef rl, CFRunLoopMode mode){
    NSLog(@"準備處理事件");
}

void perform(void *info){
    NSLog(@"少年,執行事件吧");
}

void cancel(void *info, CFRunLoopRef rl, CFRunLoopMode mode){
    NSLog(@"移除輸入源,事件停止執行");
}
複製程式碼

從程式碼中可以看出,若要讓Runloop執行Source0的事件,需要先發出一個執行訊號CFRunLoopSourceSignal,再呼叫CFRunLoopWakeUp喚醒Runloop執行任務。

控制檯列印效果如下

image.png

下面是Source1使用Port進行執行緒間通訊的例子

建立

@interface ViewController ()<NSPortDelegate>
@property (nonatomic, strong) NSPort* subThreadPort;//主執行緒Port
@property (nonatomic, strong) NSPort* mainThreadPort;//子執行緒Port
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self portCommunicateTest];
}
- (void)portCommunicateTest{
    self.mainThreadPort = [NSPort port];
    self.mainThreadPort.delegate = self;
    // port - source1 -- runloop
    //port是操作Source1的,所以同樣依賴於runloop
    [[NSRunLoop currentRunLoop] addPort:self.mainThreadPort forMode:NSDefaultRunLoopMode];

    //建立子執行緒
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //例項化子執行緒對應的Port
        self.subThreadPort = [NSPort port];
        self.subThreadPort.delegate = self;
        
        [[NSRunLoop currentRunLoop] addPort:self.subThreadPort forMode:NSDefaultRunLoopMode];
        [[NSRunLoop currentRunLoop] run];
    });
}

//NSPort的代理方法,執行緒間通訊的回撥
- (void)handlePortMessage:(id)message {
   NSLog(@"當前執行緒是 == %@", [NSThread currentThread]); // 3 1
    NSLog(@"傳來的訊息內容 = %@", [[NSString alloc] initWithData:[message valueForKey:@"components"][0] encoding:NSUTF8StringEncoding]);
    sleep(1);
    if (![[NSThread currentThread] isMainThread]) {
        //像子執行緒的Port傳送訊息
        NSMutableArray* components = [NSMutableArray array];
        NSData* data = [@"world" dataUsingEncoding:NSUTF8StringEncoding];
        [components addObject:data];

        [self.mainThreadPort sendBeforeDate:[NSDate date] components:components from:self.subThreadPort reserved:0];
    }
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //注意,component必須以NSData的形式傳遞
    NSMutableArray* components = [NSMutableArray array];
    NSData* data = [@"Hello" dataUsingEncoding:NSUTF8StringEncoding];
    [components addObject:data];
    
    [self.subThreadPort sendBeforeDate:[NSDate date] components:components from:self.mainThreadPort reserved:0];
}
複製程式碼

點選螢幕後,列印結果如下

image.png
在回撥訊息處打斷點,發現如圖所示
image.png
看到左側顯示的輸入源是Source1,至此使用NSPort進行執行緒間通訊的例子執行完畢,真可愛,執行緒還能這麼玩。

4.3、CFRunloopModeRef

Runloop有五種執行模式

    1. UIInitializationRunLoopMode :在App剛啟動進入的執行模式,啟動完成後會切換到kCFRunLoopDefaultMode,從此不再使用。
    1. kCFRunLoopDefaultMode: 預設執行模式,在主執行緒執行在這個模式下。
    1. UITrackingRunLoopMode:介面跟蹤模式,當見面滾動時,會切換到這個執行模式下,保證不收其他模式的影響。
    1. GSEventReceiveRunLoopMode: 接受系統事件的內部執行模式。
    1. kCFRunLoopCommonModes: 佔位模式,通常用來標記kCFRunLoopDefaultModeUITrackingRunLoopMode,如果NSTimer加入這個模式,將不受執行模式切換的影響。
4.4、CFRunloopTimerRef

CFRunloopTimerRef是一個時間觸發器,它包含一個時間長度和一個回撥(函式回撥),在加入Runloop時,Runloop會註冊一個時間點,經過時間長度後,Runloop會被喚醒執行回撥。Timer的底層就是一個CFRunloopTimerRef,它受Mode切換的影響。如果把Timer加入到kCFRunLoopCommonModes就不會受切換影響了,像下面這樣

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0f repeats:YES block:^(NSTimer * _Nonnull timer) {
    NSLog(@"log NSTimer runloop");
}];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
複製程式碼
4.5、CFRunloopObserverRef

CFRunloopObserverRef是一個觀察者,用來監控Runloop的狀態的,它分為以下狀態

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即將進入Loop 1
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即將處理 Timer 2
    kCFRunLoopBeforeSources = (1UL << 2), // 即將處理 Source 4
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即將進入休眠 32
    kCFRunLoopAfterWaiting  = (1UL << 6), // 剛從休眠中喚醒 64
    kCFRunLoopExit          = (1UL << 7), // 即將退出Loop 128
};
複製程式碼

舉個例子

- (void)obseverDemo{
    //1、建立觀察者上下文
    CFRunLoopObserverContext context = {
        0,
        ((__bridge void *)self),
        NULL,
        NULL,
        NULL
    };
    //2、獲取當前Runloop物件
    CFRunLoopRef rlp = CFRunLoopGetCurrent();
    //3、建立觀察者CFRunLoopObserverRef
    CFRunLoopObserverRef observerRef = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, runLoopObserverCallBack, &context);
    //4、將觀察者observer和runloop物件、Mode關聯起來
    CFRunLoopAddObserver(rlp, observerRef, kCFRunLoopDefaultMode);
}

void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    switch (activity) {
    case kCFRunLoopEntry:
        NSLog(@"RunLoop進入");
        break;
    case kCFRunLoopBeforeTimers:
        NSLog(@"RunLoop要處理Timers了");
        break;
    case kCFRunLoopBeforeSources:
        NSLog(@"RunLoop要處理Sources了");
        break;
    case kCFRunLoopBeforeWaiting:
        NSLog(@"RunLoop要休息了");
        break;
    case kCFRunLoopAfterWaiting:
        NSLog(@"RunLoop醒來了");
        break;
    case kCFRunLoopExit:
        NSLog(@"RunLoop退出了");
        break;
        
    default:
        break;
    }
}
複製程式碼

在我們滾動檢視時,控制檯列印如下

image.png

由此可見,官方說的對☺,確實可以通過CFRunloopObserverRef監聽Runloop的狀態。

Runloop的應用有很多,之前在專案中應用的比較深的是將對CPU壓力比較大的UI任務拆分成多個小任務,通過監聽Runloop的Observer空閒時機,在空閒時強制其執行小任務,高效利用系統資源提升效能。 RunLoopWorkDistribution瞭解一下(?)

相關文章