[翻译] Run, RunLoop, Run!

注:这篇文章翻译自 http://bou.io/RunRunLoopRun.html ,仅供学习参考,谢绝转载,已获得作者 Nicolas Bouilleaud 授权。

iOS 中有一个话题很少被开发者们提起,尽管它是所有 app 中最重要的组成元素之一,它就是 Runloop。Runloop 就像是 app 的心脏,你的代码因为有它才运行起来。

Runloop 的基本原则实际上很简单,在 iOS 和 OS X 上,CFRunloop 实现了被所有高层消息和调度 API 所使用的核心机制。

Runloop 到底是什么?

简单来说,runloop 是一个消息发送机制,用于异步的或者线程内的通信。它可以被看做一个信箱,等待消息并把消息发送出去。

Runloop 主要干两件事:

  • 等待事件的发生(例如:消息到达),
  • 发送消息给它的接收者。

在其他平台上,这个机制被称作“Message Pump”。

Runloop 把可交互的 app 和命令行程序区分开来。命令行程序带着参数启动,执行它们的命令,然后退出。可交互的 app 等待用户的输入,反应,然后继续等待。事实上,这个基本的机制在长时间运行的进程中也能找到。在服务器中的,一个 while(1){select();} 就可以看做 runloop。

Runloop 的工作是等待事情发生。这些事情可以是外部的事件,由用户或系统产生(例如网路请求)或者内部的 app 消息,例如线程内的通知,代码的异步执行,定时器...... 一旦一个事件(或者说消息)被接收,runloop 就会找到相应的监听者并把消息发送给它。

一个基本的 runloop 实际上很容易实现。下面是简单的伪代码:

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() 它自己的 runloop,和其他线程的 runloop 通过 postMessage() 方法交换消息。我的同事 Cyril Mottier 向我指出 Android 的实现 不像那样复杂。

iOS 和 OS X 中又如何呢?

在苹果的系统中,这是 CFRunloop 的工作,是一个更高级的变体 。你写的所有代码都是在某个时刻被 CFRunloop 调用的,除了提前的初始化,或者你自己创建线程。(据我所知,GCD 队列自动创建的线程不需要 CFRunloop,但是也必然需要一个消息系统来方便重用。)

CFRunloop 最重要的特点是 CFRunLoopModes。CFRunloop 和一系统的“Run Loop Sources”一起工作。Sources 被注册到 runloop 的一个或多个 mode 中,runloop 被要求在一个指定的 mode 下运行。当一个事件到达 sources 时,当且仅当 source 的 mode 和 runloop 的当前 mode 相同时,事件才会被 runloop 处理。

另外,CFRunloop 可以从应用代码中重新进入,要么从你自己的代码中,要么从 framework 中。因为一个线程只有一个 CFRunloop,当一个元素想要在一个特定的 mode 下运行时,它需要调用 CFRunLoopRunInMode() 。所有没有注册进这个 mode 的 sources 会被停止服务。通常来说,那个元素最终会把控制权交给之前的 mode。

CFRunloop 定义了一个虚拟的 mode 称作 “common modes”(KCFRunloopCommonModes),它实际上是包含了 app 用到的一系列“常用”的 mode。比如,main runloop 在 kCFRunLoopCommonModes 下运行。

另一方面,UIKit 定义了一个特殊的 runloop mode,叫做 UITrackingRunLoopMode 。当对 controls 的追踪发生时,例如触摸事件,就会用到这个 mode。这很重要,因为这就是 tableview 流畅滚动的原因。当主线程的 runloop 在 UITrackingRunLoopMode 下运行时,大多数的后台事件,例如网络请求,就不会被发送了。就像这样,没有其他的工作在进行,滑动也没有延迟。(至少这时候应该是你的问题了。)

简单理解 CFRunloop

如果你曾经调试过 iOS 程序的堆栈信息,你应该已经发现,在堆栈信息的里面,所有的消息都以 CFRUNLOOP_IS_CALLING_OUT 开头。当 CFRunloop 调出程序代码时,它喜欢让它们显示出来。在 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);

Observer 有点特殊。CFRunLoopObserber API 让你能够观察 CFRunloop 的行为并且收到它活动的通知,例如当它在处理事件,当它进入休眠等等。这对调试来说起了很大的作用,你通常在你的 app 中不需要它,但是当你想实验 CFRunloop 的特性时它就很有帮助了。[2014-10-2 更细:事实上,它在其他的地方也有作用,例如 CoreAnimation 通过 Observser 的调出运行。它能够保证所有的 UI 代码已经开始运行,它会一次性的执行所有动画。]

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(
        void (^block)(void));

BlockCFRunLoopPerformBlock()API 的反面,当你想在下个循环里执行代码时很有用。

static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(
    void *msg);

Main Dispatch Queue 当然就是 CFRunloop 和 GCD 沟通的标志。很显然,至少在主线程中,GCD 和 CFRunloop 是手把手工作的。尽管 GCD 可以创建一个没有 CFRunloop 的线程,当有一个时,它会把自己塞进去。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(
    CFRunLoopTimerCallBack func,
    CFRunLoopTimerRef timer,
    void *info);

Timer 相对来说就很明了了。在 iOS 和 OS X 中,高层的 timer,例如 NSTimer 或者 performSelector:afterDelay: 是用 CFRunloop 的 timer 实现的。从 iOS 7 和 Mavericks 开始,timer 开始的时间有一个容忍度,这个特性也是 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 Sources,另一方面来说,使用 math_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]

代码在 CFRunloop 的这里,就在这代码的上面几行,苹果工程师注释了来自 Hamlet soliloquy 和这相关的引言:

/* In that sleep of death what nightmares may come ... */

CFRunloop.c 的一瞥

在你 app 运行的任何时候,CFRunloop 的核心就是 __CFRunLoopRun() 方法,被公共 API 方法 CFRunLoopRun()CFRunLoopRunInMode(mode, seconds, returnAfterSourceHandled) 调用。

__CFRunLoopRun() 会因为四种原因退出:

  • kCFRunLoopRunTimedOut:在超时后,如果规定了间隔的话,
  • kCFRunLoopRunFinished:当它变为空的后,例如,所有的 Source 都被移除了。
  • kCFRunLoopRunHandledSource:当一个事件被处理后,并且携带着 returnAfterSourceHandled 标志。
  • kCFRunLoopRunStopped:被手动用 CFRunLoopStop() 停止。

直到其中的一个原因发生,它会持续等待和发送事件。这里有一个单程,示例着处理上面所讨论的事件类型。

  1. 调用 “block”。(CFRunLoopPerformBlock() API)
  2. 检查 Version 0 Sources,如果必要的话调用它们的 “perform” 方法。
  3. Poll and internal dispatch queues and mach_ports, and (这句不知道怎么翻译,感觉有笔误)
  4. 如果没有事件在等待就休眠。如果有事件就把它唤醒。其实在代码里面更复杂,因为在 Win32 的兼容代码里加了很多 #ifdef #elif,并且在代码中部有一个 goto。这里的主要想法是,mach_msg() 可以被配置来等待多个队列和 port。CFRunloop 通过这个来等同时待 timer,GCD 调度,手动唤醒,或者 Version 1 Sources。
  5. 被唤醒,并且尝试搞清楚原因:
    1. 手动唤醒。仅仅是继续运行这个 loop,可能有一个 block 或者 Version 0 Source 等待服务。
    2. 一个或多个 timer 发动了。调用它们的方法。
    3. GCD 需要工作。通过一个特殊的 “4CF” dispatch_queue API 来调用它。
    4. 内核给一个 Version 1 Source 发了一个信号。找到并且给他服务。
  6. 再次调用 “block”。
  7. 检查退出条件。(Finished, Stopped, TimedOut, HandledSource)
  8. 全部重新开始。

吁。是不是很简单。正如你所知道的,CoreFoundation 是用 C 实现的,看起来不怎么现代。在读这个的时候,我的第一反应是 “哇,这需要重构”。另一方面,这代码是经过测验的,所以我并不期望它会很快用 Swift 重写。

有一个代码模式我最近几年一直在用,特别是在测试的时候。它就是“运行 runloop 直到条件变为 true”,这是任何异步单元测试的基础。从以前到现在,我可能已经写了很多这样的代码,直接用 NSRunloop 或者 CFRunloop 来获取,使用超时时间等等。现在我应该可以写一个正规的版本了,下篇文章见。

你可能感兴趣的:([翻译] Run, RunLoop, Run!)