[譯]奔跑吧!RunLoop!

金西西發表於2018-02-27

翻譯自 Run, RunLoop, Run!

儘管在開發者間很少討論,但它是所有 app 中最重要的幾個元件之一:Run Loops。Run Loops 就像是 app 跳動的心臟,它是讓你的程式碼真正執行起來的東西。

Run Loop 的基本原理實際上很簡單。在 iOS 和 OSX 中,CFRunLoop 實現了供所有高層通訊和分發 API 使用的的核心機制。

Run Loop 到底是什麼?

簡單來說,Run Loop 是一種通訊機制,用來完成非同步或執行緒間通訊。可以把它看作一個郵箱——等待訊息並將它們傳送給接收者們。

Run Loop 要做兩件事:

  • 等待某些事件發生(例如來了一個訊息)
  • 將訊息傳送給對應的接受者

在其他平臺中(Win32),這種機制被叫做“訊息泵(Message Pump)”。

Run Loop 是區分互動式應用和命令列工具的關鍵。命令列工具接受引數並啟動,執行具體命令,最終退出。互動式應用則等待使用者輸入,做相應的反應,然後接著等待。事實上,這個基本機制在長時間執行的程式中也很常見。比如在伺服器中的 while(1) {select();} 就是一個很好(雖然老)的 Run Loop 例子。

Run loop 的工作就是等待某些事情發生。這些事情可以是由使用者或者系統觸發的外部事件(例如網路請求),或者是內部應用訊息,例如執行緒間通知、非同步程式碼執行、計時器等等。當收到一個事件(或者說訊息)時,Run loop 找到一個相應的監聽者並將訊息傳遞給它。

實現一個基礎的 Run loop 很容易。下面是一個簡單的虛擬碼版本:

func postMessage(runloop, message){
    runloop.queue.pushBack(message)
    runloop.signal()
}

func run(runloop){
    do {
        runloop.wait()
        message = runloop.queue.popFront()
        dispatch(message)
    } while (true)
}
複製程式碼

用這種簡單的機制,每個執行緒都會 run() 自己的 Run loop,然後使用 postMessage() 來和其他執行緒非同步交換訊息。我的同事 Cyril Mottier 告訴我 Android 版本的實現 並不比這個複雜多少。

iOS 和 OS X 呢

在 Apple 系統裡面,這由 CFRunLoop 實現,一個稍微高階變形(CFRunLoop.c 有 3909 行,Looper.java 有 309 行)。除了早期初始化和你自己生成的執行緒,所有你寫的程式碼都會在某個時刻被 CFRunLoop 呼叫。(據我所知,為 GCD 自動建立的執行緒不需要 CFRunLoop,但是肯定有一個訊息系統來允許複用。)CFRunLoop 最重要的特性是 CFRunLoopModesCFRunLoop 與一個“Run Loop Sources”系統一同工作。註冊在 Run Loop 上的 Sources 有一個或多種模式(modes),而 Run loop 本身就是在給定模式下執行的。當一個事件到達 source 時,只會交給有和 source 的模式匹配的 Run loop 來處理。

此外,CFRunLoop 是可重入,不論是通過自己的程式碼或者框架內部程式碼。因為每個執行緒都只有一個 CFRunLoop,當一個元件想在一個特定模式下執行 Run Loop ,可以通過呼叫 CFRunLoopRunInMode() 實現。所有沒有被註冊為這個模式的 Run Loop source 都將被停止處理。通常該元件最終還會返回之前的模式。

CFRunLoop定義了一個偽模式:“公共模式”(kCFRunLoopCommonModes),實際上是一組“常規”的 Run Loop。主 Run Loop 開始是工作在 kCFRunLoopCommonModes。另一方面,UIKit 定義了一個特殊的 Run Loop 模式叫做 UITrackingRunLoopMode。它在“當控制跟蹤發生時”使用這個模式,例如觸控的時候。這非常重要,因為這保證了 TableView 的流暢滾動。當主執行緒的 Run Loop 在 UITrackingRunLoopMode 時,大部分後臺事件,例如網路回撥,都沒有被分發。這樣,沒有了額外處理,滾動就不會卡頓(現在再卡頓的話,就是你的錯了)。

揭祕 CFRunLoop

如果你用堆疊跟蹤除錯過 iOS 和 OS X 程式碼,很可能你會在棧跟蹤中注意到一個全大寫的方法 CFRUNLOOP_IS_CALLING_OUT。當 CFRunLoop 呼叫程式程式碼時,它就喜歡這麼幹。這裡列出了 6 個定義在 CFRunLoop.c 的函式:

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();
複製程式碼

你猜的沒錯,這些函式除了用於跟蹤除錯外沒其他作用。 CFRunLoop 確保了所有的應用程式碼會通過上面其中一個函式呼叫。讓我們一個一個地看一下。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(
  CFRunLoopObserverCallBack func,
  CFRunLoopObserverRef observer,
  CFRunLoopActivity activity,
  void *info);
複製程式碼

觀察者(Observers) 有一些特別。CFRunLoopObserver API 允許你觀察 CFRunLoop 的行為和它是否活躍(在處理事件或是正要去休眠等)。觀察者在除錯時非常有用,尤其是當你想了解 CFRunLoop 的特性的話。事實上,在一些特定的用途上它很有用,例如:CoreAnimation 通過觀察者調出(callout)來執行,這是有意義的,因為這樣保證了所有 UI 程式碼都已經被執行,並且一次執行完所有的動畫。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(void (^block)(void));
複製程式碼

閉包(Blocks)CFRunLoopPerformBlock() API 的 另一面,當你想在“下一個迴圈”執行程式碼時它非常有用。

static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(void *msg);
複製程式碼

Main Dispatch Queue 標籤是 CFRunLoop 對 GCD 的處理。顯然,至少在主執行緒上,GCD 和 CFRunLoop 是協同工作的。即使 GCD 可以(並且會)創造沒有 CFRunLoop 的執行緒,但當這裡有一個 CFRunLoop 時,它會插入進去。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(
  CFRunLoopTimerCallBack func,
  CFRunLoopTimerRef timer,
  void *info);
複製程式碼

定時器(Timer) 相對容易從字面理解。在 iOS 和 OSX 中,高層 “定時器” 例如 NSTimer 或者 performSelector:afterDelay: 是通過 CFRunLoop 定時器實現的。從 iOS 7 和 Mavericks 開始,定時器的觸發時間點有了一個容錯區間的概念,這個特性當然也是 CFRunLoop 處理的。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(
  void (*perform)(void *),
  void *info);
複製程式碼

CFRunLoopSources “Version 0”和“Version 1”是兩個非常不同的東西,雖然它們有一個通用的 API。

Version 0 sources 只是一個應用內訊息處理機制,必須由應用程式碼手動處理。在給 Version 0 Source 傳送訊號後(通過 CFRunLoopSourceSignal()),CFRunloop 必須被喚醒(通過 CFRunLoopWakeUp())後才能處理這個 source。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(
  void *(*perform)(void *msg, CFIndex size, CFAllocatorRef allocator, void *info),
  mach_msg_header_t *msg,
  CFIndex size,
  mach_msg_header_t **reply,
  void (*perform)(void *),
  void *info);
複製程式碼

另一方面,Version 1 Sourcesmach_port 來處理核心事件。這實際上是 CFRunLoop 的核心:大多數時候,當你的 app 就站在那,什麼也不做的時候,它會被阻塞在這個 mach_msg(…,MACH_RCV_MSG,…) 呼叫中。如果你用活動監視器(Activity Monitor)觀察任意一個 app,很可能你會看到這個:

2718 CFRunLoopRunSpecific  (in CoreFoundation) + 296  [0x7fff98bb7cb8]
  2718 __CFRunLoopRun  (in CoreFoundation) + 1371  [0x7fff98bb845b]
    2718 __CFRunLoopServiceMachPort  (in CoreFoundation) + 212  [0x7fff98bb8f94]
      2718 mach_msg  (in libsystem_kernel.dylib) + 55  [0x7fff99cf469f]
        2718 mach_msg_trap  (in libsystem_kernel.dylib) + 10  [0x7fff99cf552e]
複製程式碼

就在 CGRunLoop.c 的這裡。在上面幾行,你能看到 Apple 工程師引用了哈姆雷特的獨白:

/* In that sleep of death what nightmares may come ... */
複製程式碼

悄悄看一眼 CFRunLoop.c

每當你的 app 執行時,CFRunLoop 的核心是 __CFRunLoopRun() 函式,通過公有 API CFRunLoopRun()CFRunLoopRunInMode(mode, seconds, returnAfterSourceHandled) 來呼叫。

__CFRunLoopRun() 會因為四個原因退出:

  • kCFRunLoopRunTimedOut: 超時時,如果指定了間隔
  • kCFRunLoopRunFinished: 當它 “空”的時候,例如所有的資源都被刪除了
  • kCFRunLoopRunHandledSource: 如果有 returnAfterSourceHandled 標誌,在事件被髮送之後
  • kCFRunLoopRunStopped: 通過 CFRunLoopStop() 手動停止

在以上四個原因之一出現之前,它會一直等待和分發事件。下面是一個包含我們前文討論的各種型別事件的處理過程的例子。

  1. 呼叫閉包們(blocks, CFRunLoopPerformBlock() API)。
  2. 檢查 Version 0 Sources, 並在必要時呼叫它們的 “perform” 函式。
  3. 輪訓並內部排程佇列和 mach_port,然後
  4. 如果沒有東西需要處理,就去休眠。有什麼事情的話核心會喚醒我們。實際上這一塊的程式碼要更加複雜,因為(a)為了 Win32 相容性增加了很多 #ifdef #elif 程式碼(b)程式碼中間有 goto。主要的思路是,可以將 mach_msg() 配置為在多個佇列和埠上等待。CFRunLoop 可以同時等待定時器、GCD 分發、手動喚醒或是去處理 Version 1 Sources。
  5. 喚醒,並嘗試找出喚醒的原因:
  6. 一個手動喚醒:繼續執行這個迴圈,也許有一個閉包或者是 Version 0 Sources 在等待被處理。
  7. 一個或多個定時器被觸發:呼叫定時器對應的方法。
  8. GCD 需要工作:通過特定的 “4CF” dispatch_queue API 來呼叫它。
  9. 核心發出了一個 Version 1 Source。找到並處理它。
  10. 再次呼叫閉包們。
  11. 檢查退出條件。(結束、中斷、超時、處理了 Source)(Finished, Stopped, TimedOut, HandledSource)
  12. 從頭再來。

很簡單吧?CoreFoundation 是由 C 實現的,看起來不怎麼現代。我看到程式碼的第一反應是“這需要重構”。但另一方面這些程式碼久經沙場,所以我不期待最近它會被用 Swift 重寫。

有一種程式碼模式,我最近幾年一直在使用,特別是在測試中。它就是“執行 run loop,直到這個條件變為真”,這是任何型別的非同步單元測試的基礎。隨著時間的推移,我可能已經編寫了很多這樣的變體,直接使用 NSRunLoopCFRunLoop,進行輪詢,使用超時等等。現在我可以編寫一個像樣的版本了,讓我們在下一篇文章中找到答案。

相關文章