[翻译] 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 来获取,使用超时时间等等。现在我应该可以写一个正规的版本了,下篇文章见。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容