阅读 2365

[译]奔跑吧!RunLoop!

翻译自 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,进行轮询,使用超时等等。现在我可以编写一个像样的版本了,让我们在下一篇文章中找到答案。

关注下面的标签,发现更多相似文章
评论