RunLoop 淺析

hsy0發表於2019-01-17

RunLoop 淺析

一個小應用

首先我們需要編寫一個應用,這個小應用的要求很簡單:它需要執行一些比較耗時的操作,在執行耗時操作的同時還需要可以繼續響應使用者的操作。

那麼首先想到的就是使用兩個執行緒,一個 Main 一個 Worker,在 Main 中響應使用者的操作,而將實際的耗時任務放到 Worker 中。

首先看看在不使用 RunLoop 時的程式碼是如何實現的:

//
//  main.m
//  Downloader
//
//  Created by mconintet on 11/23/15.
//  Copyright © 2015 mconintet. All rights reserved.
//

#import <Foundation/Foundation.h>

// 『訊息佇列(messages queue)』這個名詞想必是家喻戶曉了
// 這裡 commands 就相當於一個訊息佇列的作用
// 主執行緒在收到了使用者的 command 之後並不是
// 立即處理它們,轉而將其新增到這個 queue 中,
// 然後 Worker 會逐個的處理這個命令
static NSMutableArray* commands;

// NSMutableArray 並不是 thread-safety,所以
// 需要 @synchronized 來保證資料完整性
void pushCommand(NSString* cmd)
{
    @synchronized(commands)
    {
        [commands addObject:cmd];
    }
}

NSString* popCommand()
{
    @synchronized(commands)
    {
        NSString* ret = [commands lastObject];
        [commands removeLastObject];
        return ret;
    }
}

@interface Worker : NSThread

@end

@implementation Worker

- (void)main
{
	// 如你所見,在 Worker 中我們
	// 採用了『輪詢』的方式,就是不斷的
	// 詢問訊息佇列,是不是有新訊息來了
    while (1) {
        NSString* last = popCommand();
        // 如果通過不斷的輪詢得到新的命令
        // 那麼就處理那個命令
        while (last) {
            NSLog(@"[Worker] executing command: %@", last);
            sleep(2); // 模擬耗時的計算所需的時間
            NSLog(@"[Worker] executed command: %@", last);
            last = popCommand();
        }
    }
}

@end

int main(int argc, const char* argv[])
{
    @autoreleasepool
    {
        commands = [[NSMutableArray alloc] init];

        Worker* worker = [[Worker alloc] init];
        [worker start];

        int c = 0;
        do {
            c = getchar();
            // 忽略輸入的換行
            // 這樣 Log 內容更加清晰
            if (c == '\n')
                continue;

            NSString* cmd = [NSString stringWithCharacters:(const unichar*)&c length:1];
            pushCommand(cmd);
            // 在主執行緒中 Log 這條資訊,
            // 以此來表示主執行緒可以繼續響應
            NSLog(@"[Main] added new command: %@", cmd);
        } while (c != 'q');
    }
    return 0;
}
複製程式碼

執行下這個程式,然後切換到 Debug navigator,會看到這樣的結果:

RunLoop 淺析

Worker 讓 CPU 幾乎滿了 ?,看來 Worker 輪詢訊息佇列的方式有很大的效能問題。回看 Worker 中這樣的程式碼:

while (1) {
    NSString* last = popCommand();
    while (last) {
        NSLog(@"executint command: %@", last);
        sleep(2); // 模擬耗時的計算所需的時間
        NSLog(@"executed command: %@", last);
        last = popCommand();
    }
}
複製程式碼

上面程式碼作用就是採用輪詢的方式不斷的向訊息佇列詢問是否有新訊息到達。這樣的模式會有一個嚴重的問題:如果在很長一段時間內使用者並沒有輸入新的 command,子執行緒還是會不斷的輪詢,就是因為這些不斷的輪詢導致 CPU 資源被佔滿。

Worker 不斷輪詢訊息佇列的模式已經被我們證明是具有效能問題的了,那麼是不是可以換一種思路?如果可以讓 Main 和 Worker 的協作變為這樣:

  1. Main 不斷地接收到使用者輸入,將輸入放到訊息佇列中,然後通知 Worker 說『Wake up,你有新的任務需要處理』
  2. Worker 開始處理訊息佇列中任務,任務處理完成之後,自動進入休眠,不再繼續佔用 CPU 資源,直到接收到下一次 Main 的通知

為了完成這個模式,我們可以採用 RunLoop。

RunLoop

在使用 RunLoop 之前,先了解下它。具體的在 Run Loops,扼要的說:

  1. 每個執行緒都有一個與之相關的 RunLoop
  2. 與執行緒相關聯的 RunLoop 需要手動的執行,以此讓其開始處理任務。主執行緒已經為你自動的啟動了與其關聯的 RunLoop(注意命令列程式的主執行緒並沒有這個自動開啟的動作)
  3. RunLoop 需要以特定的 mode 去執行。『common mode』實際上是一組 modes,有相關的 API 可以向其中新增 mode
  4. RunLoop 的目的就是監控 timers 和 run loop sources。每一個 run loop source 需要註冊到特定的 run loop 的特定 mode 上,並且只有當 run loop 執行在相應的 mode 上時,mode 中的 run loop source 才有機會在其準備好時被 run loop 所觸發
  5. RunLoop 在其每一次的迴圈中,都會經歷幾個不同的場景,比如檢查 timers、檢查其他的 event sources。如果有需要被觸發的 source,那麼會觸發與那個 source 相關的 callback
  6. 除了使用 run loop source 之外,還可以建立 run loop observers 來追蹤 run loop 的處理進度

如果要更加深入的瞭解 RunLoop 推薦閱讀 深入理解RunLoop

使用 RunLoop 來改寫程式

下面的程式碼使用 RunLoop 來改寫上面的程式:

//
//  main.m
//  Downloader
//
//  Created by mconintet on 11/23/15.
//  Copyright © 2015 mconintet. All rights reserved.
//

#import <Foundation/Foundation.h>

static NSMutableArray* commands;

void pushCommand(NSString* cmd)
{
    @synchronized(commands)
    {
        [commands addObject:cmd];
    }
}

NSString* popCommand()
{
    @synchronized(commands)
    {
        NSString* ret = [commands lastObject];
        [commands removeLastObject];
        return ret;
    }
}

// run loop source 相關的回撥函式
// 在外部程式碼標記了 run loop 中的某個 run loop source
// 是 ready-to-be-fired 時,那麼在未來的某一時刻 run loop
// 發現該 run loop source 需要被觸發,那麼就會呼叫到這個與其
// 相關的回撥
void RunLoopSourcePerformRoutine(void* info)
{
    // 如果該方法被呼叫,那麼說明其相關的 run loop source
    // 已經準備好。在這個程式中就是 Main 通知了 Worker 『任務來了』
    NSString* last = popCommand();
    while (last) {
        NSLog(@"[Worker] executing command: %@", last);
        sleep(2); // 模擬耗時的計算所需的時間
        NSLog(@"[Worker] executed command: %@", last);
        last = popCommand();
    }
}

// Main 除了需要標記相關的 run loop source 是 ready-to-be-fired 之外,
// 還需要呼叫 CFRunLoopWakeUp 來喚醒指定的 RunLoop
// RunLoop 是不能手動建立的,所以必須註冊這個回撥來向 Main 暴露 Worker
// 的 RunLoop,這樣在 Main 中才知道要喚醒誰
static CFRunLoopRef workerRunLoop = nil;
// 這也是一個 run loop source 相關的回撥,它發生在 run loop source 被新增到
// run loop 時,通過註冊這個回撥來獲取 Worker 的 run loop
void RunLoopSourceScheduleRoutine(void* info, CFRunLoopRef rl, CFStringRef mode)
{
    workerRunLoop = rl;
}

@interface Worker : NSThread
@property (nonatomic, assign) CFRunLoopSourceRef rlSource;
@end

@implementation Worker

- (instancetype)initWithRunLoopSource:(CFRunLoopSourceRef)rlSource
{
    if ((self = [super init])) {
        _rlSource = rlSource;
    }
    return self;
}

- (void)main
{
    NSLog(@"[Worker] is running...");
    // 往 RunLoop 中新增 run loop source
    // 我們的 Main 會通過 rls 和 Worker 協調工作
    CFRunLoopAddSource(CFRunLoopGetCurrent(), _rlSource, kCFRunLoopDefaultMode);
    // 執行緒需要手動執行 RunLoop
    CFRunLoopRun();
    NSLog(@"[Worker] is stopping...");
}

@end

// 告訴 Worker 任務來了
// 把 Worker 拎起來幹事
void notifyWorker(CFRunLoopSourceRef rlSource)
{
    if (workerRunLoop) {
        CFRunLoopSourceSignal(rlSource);
        CFRunLoopWakeUp(workerRunLoop);
    }
}

int main(int argc, const char* argv[])
{
    @autoreleasepool
    {
        NSLog(@"[Main] is running...");

        commands = [[NSMutableArray alloc] init];

        // run loop source 的上下文
        // 就是一些 run loop source 相關的選項以及回撥
        // 另外我們這的第一個引數是 0,必須是 0
        // 這樣建立的 run loop source 就被新增在
        // run loop 中的 _sources0,作為使用者建立的
        // 非自動觸發的
        CFRunLoopSourceContext context = {
            0, NULL, NULL, NULL, NULL, NULL, NULL,
            RunLoopSourceScheduleRoutine,
            NULL,
            RunLoopSourcePerformRoutine
        };

        CFRunLoopSourceRef runLoopSource = CFRunLoopSourceCreate(NULL, 0, &context);

        Worker* worker = [[Worker alloc] initWithRunLoopSource:runLoopSource];
        [worker start];

        int c = 0;
        do {
            c = getchar();
            if (c == '\n')
                continue;

            NSString* cmd = [NSString stringWithCharacters:(const unichar*)&c length:1];
            pushCommand(cmd);
            NSLog(@"[Main] added new command: %@", cmd);

            notifyWorker(runLoopSource);
        } while (c != 'q');

        NSLog(@"[Main] is stopping...");
    }
    return 0;
}
複製程式碼

可以執行一下看下效能如何:

RunLoop 淺析

可以看到,在沒有新的使用者輸入到達,且訊息佇列中沒有需要處理的任務時,整個應用程式沒有持續的霸佔 CPU 資源,這就歸功於 RunLoop。

最後簡單概括下為什麼 RunLoop 有這麼『神奇』的功能吧。

首先 RunLoop 內部核心也是一個 loop 迴圈(和它的名字呼應),然後這個迴圈中做了一些有意思的事情:

  1. 首先每一次的迴圈中,都會檢查被新增到其中的 timers 和 run loop sources,如果它們之中有符合條件的,那麼自然是需要觸發相關的回撥操作
  2. 如果沒有 timers 或者 run loop sources 或者 run loop 被手動的停止了 那麼 run loop 會退出內部的迴圈
  3. 如果被新增到內部的 timers 和 run loop sources 都沒有準備好被觸發,那麼 run loop 就會進行一個系統呼叫,使執行緒進入休眠
  4. 進入休眠了就不會佔用 CPU 資源,那麼喚醒的工作就需要其外部的程式碼進行,比如上面程式碼中 Main 中的 notifyWorker

這都是嘛

有這麼幾個名詞真是非常的饒人:RunLoopRunLoop SourceRunLoop ModeCommonMode ...

『這些都是嘛?』這就是我剛見到它們的感覺,如果你也有這樣的感覺,那麼再次推薦你先看下 深入理解RunLoop,我也是看了其中內容,然後下載了 RunLoop 的原始碼,自己動手分析分析,接下來將是我分析的備忘。

首先是看下 RunLoop 的結構:

struct __CFRunLoop {
    CFMutableSetRef _commonModes;
    CFMutableSetRef _commonModeItems;
    CFRunLoopModeRef _currentMode;
    CFMutableSetRef _modes;
}
複製程式碼

於是看到,與 RunLoop 有直接關係的是 RunLoop Mode。那麼看看 RunLoop Mode 的結構:

struct __CFRunLoopMode {
	CFStringRef _name;
	CFMutableSetRef _sources0;
	CFMutableSetRef _sources1;
	CFMutableArrayRef _observers;
	CFMutableArrayRef _timers;
}
複製程式碼

發現與 RunLoop Mode 有關的是 RunLoop sourcetimer 以及 observer

於是就有了這個圖:

+---------------------------------------------------------+
|                                                         |
|                        RunLoop                          |
|                                                         |
|  +----------------------+    +----------------------+   |
|  |                      |    |                      |   |
|  |     RunLoopMode      |    |     RunLoopMode      |   |
|  |                      |    |                      |   |
|  |  +----------------+  |    |  +----------------+  |   |
|  |  | RunLoopSources |  |    |  | RunLoopSources |  |   |
|  |  +----------------+  |    |  +----------------+  |   |
|  |                      |    |                      |   |
|  |    +-----------+     |    |    +-----------+     |   |
|  |    | Observers |     |    |    | Observers |     |   |
|  |    +-----------+     |    |    +-----------+     |   |
|  |                      |    |                      |   |
|  |      +--------+      |    |      +--------+      |   |
|  |      | Timers |      |    |      | Timers |      |   |
|  |      +--------+      |    |      +--------+      |   |
|  |                      |    |                      |   |
|  +----------------------+    +----------------------+   |
|                                                         |
+---------------------------------------------------------+
複製程式碼

然後看看 Common Mode 是幹什麼的,首先看看這個函式:

void CFRunLoopAddCommonMode(CFRunLoopRef rl, CFStringRef modeName);
複製程式碼

就是往 RunLoop 中新增 Common Mode,而 Common Mode 在 RunLoop 中以 Set 的結構去存放(見上面 RunLoop 資料結構中的 CFMutableSetRef _commonModes;),也就是 RunLoop 中可以有多個 Common Mode,而且注意到新增時是以 Mode Name 去代表具體的 Mode 的。

然後再看下這個函式:

void CFRunLoopAddSource(
	CFRunLoopRef rl, 
	CFRunLoopSourceRef rls, 
	CFStringRef modeName
);
複製程式碼

這裡就不放函式體了,有興趣的可以下載原始碼去看,大概的意思就是:

如果 CFRunLoopAddSource 被呼叫時,形參 modeName 的實參值為 kCFRunLoopCommonModes 時,就會將 rls 新增到 RunLoop 中的 _commonModeItems 中。上面我知道了 _commonModes 其實是一個 Set,裡面存放的是 Mode Names,於是下一步 RunLoop 就會迭代 _commonModes 這個 Set 中的元素。對於迭代時的元素,很明顯都是 Mode Name,然後通過 __CFRunLoopFindMode 方法,根據 Mode Name 找出儲存在 RunLopp 中的 _modes 中的 Mode,然後將 rls 新增到那些 Mode 中。

如果覺得很亂的話,只要知道為什麼這麼幹就行了:

RunLoop 中是有多個 Mode 的,而 RunLoop 需要以指定的 Mode 去執行,並且一旦執行就無法切換到其他 Mode 中。那麼當你將一個 rls(run loop source) 新增到 RunLoop 的某一個 Mode 之後,一旦 RunLoop 不是執行在 rls 被新增到的 Mode 上,那麼 rls 將無法被檢測並觸發到,為了解決這個問題,可以將 rls 新增到 RunLoop 中的所有 Modes 中就行了,這樣無論 RunLoop 工作在哪一個 Mode 上 rls 都有機會被檢測和觸發。

這是關於上面描述的一個具體例子:

應用場景舉例:主執行緒的 RunLoop 裡有兩個預置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。這兩個 Mode 都已經被標記為"Common"屬性。DefaultMode 是 App 平時所處的狀態,TrackingRunLoopMode 是追蹤 ScrollView 滑動時的狀態。當你建立一個 Timer 並加到 DefaultMode 時,Timer 會得到重複回撥,但此時滑動一個TableView時,RunLoop 會將 mode 切換為 TrackingRunLoopMode,這時 Timer 就不會被回撥,並且也不會影響到滑動操作。

那麼怎麼將 rls 新增到 RunLoop 所有的 Modes 中呢?於是提供了這樣的方法:

CFRunLoopAddSource(
	CFRunLoopRef rl, 
	CFRunLoopSourceRef rls, 
	CFStringRef kCFRunLoopCommonModes // 注意到 kCFRunLoopCommonModes 了嗎
); 
複製程式碼

暫時就這麼多,enjoy!