面试常问的 RunLoop,到底在Loop什么?

14 阅读12分钟

大家好,我是嘉豪。

这两天我又把 RunLoop 重新翻了一遍。这个话题在 iOS 里其实一点都不新,甚至已经算老朋友了,但有意思的是:很多同学平时天天在和它打交道,却未必真的知道它在干什么。

比如下面这些场景,你大概率都见过:

  • 为什么 NSTimer 在滑动 ScrollView 的时候会“失灵”?
  • 为什么 performSelector:afterDelay: 有时候不执行?
  • 为什么子线程里的定时任务就是不回调?
  • 为什么主线程明明没有代码在跑,却也不会退出?

这些问题看起来东一榔头西一棒子,实际上背后都能收敛到同一个东西:RunLoop

所以这篇文章,我不打算只聊“RunLoop 是什么”,而是想带大家把这件事真正串起来:它和线程是什么关系、内部都有哪些角色、每一轮循环在做什么,以及它到底怎么影响我们平时的业务开发。RunLoop 本质上是线程基础设施的一部分,是一个事件处理循环:有事就处理,没事就让线程休眠;主线程的 RunLoop 会在应用启动过程中由系统自动建立并运行,子线程则通常需要你自己决定是否显式启动。

前言:为什么 RunLoop 值得理解?

我一直觉得,RunLoop 这个东西最容易被误解的地方,在于它听起来太“底层”了,于是很多人会下意识觉得:业务开发也用不上。

但真相往往比较朴素,甚至有点滑稽:你不是用不上 RunLoop,而是你天天在被 RunLoop 影响。

主线程为什么能不断响应点击、手势、定时器、刷新 UI?因为它背后一直有一个 RunLoop 在接收事件、分发事件、决定什么时候睡眠、什么时候醒来。Apple 官方对它的定义也很直白:RunLoop 是线程关联的基础设施之一,用来调度任务并协调传入事件的接收。

所以理解 RunLoop,不只是为了背面试题,而是为了在遇到卡顿、定时器异常、线程保活、异步回调这些问题时,不至于两眼一黑,开始对着代码做法事。

RunLoop 到底是什么?

先别急着上源码,我们先用最朴素的方式理解它。

如果让我们自己写一个“线程不退出,但能不断处理事件”的模型,伪代码大概会长这样:

function loop() {
  while (!stopped) {
    const event = getNextEvent();
    if (event) {
      handle(event);
    } else {
      sleep();
    }
  }
}

RunLoop 的本质,和这个思路几乎一模一样。它是一个“事件循环”模型:线程进入循环后,反复执行“接收消息 -> 处理消息 -> 没消息就休眠 -> 被唤醒后继续处理”这一套流程。Apple 官方文档也明确说明,RunLoop 是一个 event processing loop;而从经典的 CFRunLoop 源码解析视角来看,它也完全可以理解成线程内部长期运行的事件循环。

所以从结果上看,RunLoop 解决的是两个核心问题:

  1. 让线程在有事做的时候保持工作。
  2. 让线程在没事做的时候别空转耗 CPU。

这个设计非常重要。否则主线程如果一直死循环轮询事件,手机发热和掉电会快得像开了涡轮;如果线程处理完一个任务就退出,那 App 也根本不可能持续响应事件。宇宙不会允许这种离谱工程存在太久。

RunLoop 和线程是什么关系?

这一点其实是 RunLoop 最关键的前置知识。

可以先记住一句话:RunLoop 和线程是一一对应理解的。

Apple 文档里给出的说法是:每个线程都有关联的 RunLoop 对象,主线程的 RunLoop 会由应用框架自动配置并运行,而二级线程是否运行 RunLoop,则取决于你自己。只有在你真的需要它的时候,才需要显式启动。

这句话翻译成人话就是:

  • 主线程一定有 RunLoop,而且系统已经帮你跑起来了。
  • 子线程就算能拿到 RunLoop,也不代表它已经在跑。
  • 如果子线程要长期存活、处理 Timer、接收 Selector、接收 Port/Source 事件,那你得自己把它跑起来。

还有一个很容易踩坑的点:RunLoop 里必须至少有一个输入源(source)或者 timer,否则一启动就会立刻退出。 Apple 官方文档对此写得很直白。

所以,很多同学在子线程里写个 Timer,结果发现根本不回调,本质原因通常不是 Timer 坏了,而是线程的 RunLoop 根本没跑,或者刚跑起来就退出了。

RunLoop 里到底有什么?

从概念上讲,一个 RunLoop 主要围绕四类东西运转:

  • Mode
  • Source
  • Timer
  • Observer

Apple 文档里把 RunLoop Mode 描述为:一组要监听的 input sources、timers,以及要通知的 observers 的集合。每次 RunLoop 运行时,只会在某个特定 mode 下处理对应的事件;不属于当前 mode 的 source/timer,不会在这一轮被处理。

1. Mode:不是模式切换开关,而是“事件分组”

很多人第一次看 Mode,会觉得这名字有点抽象。其实你可以把它理解成:

RunLoop 当前这一轮,只看哪一组事件。

这就像你开了一个筛子。默认状态下,线程处理一部分事件;当用户开始拖拽 ScrollView 时,RunLoop 可以切到另一个 mode,只处理和拖拽更相关的输入,暂时忽略别的一些东西。Apple 也明确说明了,mode 的作用是根据 source 来过滤事件,而不是根据事件类型本身来过滤。

常见的几个模式可以先记住:

  • NSDefaultRunLoopMode / kCFRunLoopDefaultMode:默认模式,大多数情况下主线程都在这个模式下运行。
  • UITrackingRunLoopMode:控件跟踪时使用的模式,比如滑动列表时。Apple 当前文档对它的描述很直接:这是 tracking 发生时使用的模式,可用于让某些 timer 在 tracking 期间继续触发。
  • NSRunLoopCommonModes / .common:这是一个“伪 mode”,表示一组 common modes。把对象加到这里后,RunLoop 会在所有 common modes 下都监控它。

2. Source:事件从哪来

Apple 官方主要把 input source 分成两类:

  • Port-Based Source:基于端口,通常由内核自动发信号。
  • Custom Input Source:自定义 source,需要你自己定义事件传递机制,并在另一个线程手动 signal。

如果你平时看的是 CFRunLoop 源码分析文章,那还会经常见到 Source0Source1 这套说法。可以先粗暴理解成:

  • Source0:更偏“手动触发”的 source;
  • Source1:更偏“基于 port 被唤醒”的 source。

这两套说法并不冲突,只是一个更偏官方抽象分类,一个更偏底层实现语境。

3. Timer:线程给自己定闹钟

Timer 属于时间源。Apple 文档里强调了两件很重要的事:

  • Timer 不是实时机制,它不是“时间一到,立刻绝对执行”;
  • Timer 也受 RunLoop mode 影响,如果它不在当前被监控的 mode 里,就不会触发;如果 RunLoop 根本没在跑,那它永远不会触发。

这也是为什么你不能把 NSTimer 当成一把精确到毫秒的手术刀。它更像一个“尽量按时提醒你”的闹钟,而不是原子钟。

4. Observer:旁观者,但很重要

Observer 不产生事件,它负责观察 RunLoop 当前走到了哪一步。Apple 文档列出的典型观察时机包括:

  • 即将进入 RunLoop
  • 即将处理 Timer
  • 即将处理 Input Source
  • 即将进入休眠
  • 刚从休眠中唤醒
  • 即将退出 RunLoop

这个东西很关键,因为系统里很多“顺便做一下”的工作,恰恰就是挂在这些观察点上的。

RunLoop 一次循环到底会发生什么?

Apple 官方文档把一次 RunLoop 的执行顺序列得很清楚,大体可以压缩成下面这条主线:先通知 observer -> 处理 timer/source -> 没事就休眠 -> 被 timer、source、超时或显式唤醒后再继续处理。

为了更好理解,我把它翻译成一个更贴近开发直觉的版本:

1. 进入 loop
2. 通知 observer:我要处理 timer 了
3. 通知 observer:我要处理 source 了
4. 处理非 port 的 source
5. 如果没有可立即处理的事,就准备休眠
6. 线程休眠,等待被 timer / source / wakeup 唤醒
7. 被唤醒后,处理对应事件
8. 决定是继续下一轮,还是退出 loop

如果你看过一些调用栈或者源码解析文章,会发现 RunLoop 的底层核心休眠/唤醒机制和 mach port 消息密切相关;这也是为什么它能做到“没事就睡,有事马上醒”。

为什么滑动列表时,NSTimer 会不执行?

这个问题几乎是 RunLoop 的必考题了。

原因并不神秘:你创建出来的 Timer,大概率默认被加在了 DefaultMode 里;而当你拖拽 ScrollView 时,主线程 RunLoop 会进入 tracking 相关的 mode,这时候默认 mode 下的 timer 就不会被处理。 Apple 文档明确说明,timer 和 source 都和特定 mode 绑定;不在当前 mode 里的对象,要等 RunLoop 以后切回支持它的 mode 才会触发。

所以解决思路也就顺理成章了:把 Timer 加到 common modes。

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
                                        repeats:YES
                                          block:^(NSTimer * _Nonnull timer) {
    NSLog(@"tick");
}];

[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

common 本质上不是一个真正的独立 mode,而是一个“公共模式集合”。把 timer 加进去以后,它就能在多种 common mode 下都被监控,自然也就不会在滚动时轻易“哑火”了。

RunLoop 在系统里都干了些什么?

如果只把 RunLoop 理解成“Timer 的家”,那就太小看它了。结合 Apple 文档和经典的 CFRunLoop 源码解析,RunLoop 至少和下面这些机制高度相关。

1. 自动释放池的维护

经典分析里提到,主线程 RunLoop 上挂了和 autorelease pool 相关的 observer:进入 loop 时创建池,准备休眠时销毁旧池并重建,退出 loop 时再做一次销毁。也就是说,我们很多主线程回调,其实天然就被 autorelease pool 包着。

2. 事件响应

触摸、手势、各种输入事件之所以能不断进入 App,被分发到 UIWindowUIViewUIGestureRecognizer,背后同样离不开主线程 RunLoop 对事件源的处理。经典解析中也展示了系统事件如何通过 Source1 进入应用内部分发链路。

3. 界面刷新与提交

很多 setNeedsLayoutsetNeedsDisplay 并不会让 UI 立刻重绘,而是先标记“需要更新”,再等到 RunLoop 的某个合适时机统一提交。经典分析中把这部分和 BeforeWaiting / Exit 这些阶段关联了起来。

4. performSelector 系列方法

Apple 官方文档明确说了:performSelector:onThread: 这一类调用,目标线程必须有一个 active run loop;performSelector:withObject:afterDelay: 也是在当前线程的下一次 run loop cycle 中调度执行。

所以它们“有时不执行”的根本原因,经常不是 selector 本身有问题,而是:

  • 当前线程没有 RunLoop
  • 目标线程的 RunLoop 没启动
  • 或者当前 mode 不对

什么时候你需要手动启动子线程 RunLoop?

Apple 给出的建议其实很实用:只有当子线程需要更强交互性时,才需要显式运行 RunLoop。 比如下面这些场景:

  • 线程间通过 port 或自定义 input source 通信
  • 在线程里使用 timer
  • 使用 performSelector...
  • 想让这个线程长期存活,周期性处理任务

如果你的线程只是做一个明确的、一次性的耗时任务,比如图片解码、文件处理、纯计算,那干完退出往往更合适,没必要强行塞一个 RunLoop 进去。别什么都开火车,线程也会累。

一个很常见的“子线程保活”写法大概是这样:

- (void)threadMain {
    @autoreleasepool {
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

这个写法的核心不是 NSMachPort 本身有多神秘,而是:先往 RunLoop 里塞一个 source,避免它因为空空如也而直接退出,然后再让 RunLoop 跑起来。 Apple 官方文档也明确说明,secondary thread 的 RunLoop 在启动前必须至少附着一个 input source 或 timer,否则会立刻结束。

几个常见误区

误区一:线程创建出来,就等于 RunLoop 在工作

不是。主线程是系统自动托管的,子线程通常需要你自己决定是否获取、配置并运行 RunLoop。

误区二:NSTimer 不回调,就是 Timer 不准

也不一定。它可能只是:

  • 当前 RunLoop 没跑;
  • 当前 mode 不匹配;
  • 当前正在执行长任务,错过了触发时机。Apple 官方文档明确说明 timer 不是实时机制,而且如果错过一个或多个计划时间点,也不会把所有错过的触发一股脑补回来。

误区三:NSRunLoopCFRunLoop 完全一样,随便跨线程改

这也不对。Apple 文档提到,Core Foundation 那套 API 通常是线程安全的;但 NSRunLoop 本身并不像底层 CFRunLoopRef 那么天然线程安全,最好只在拥有它的线程里修改它。

小结

到这里,RunLoop 的主线其实就已经很清楚了。

它不是某个冷门 API,也不只是 NSTimer 的背景板。它本质上是线程背后的事件循环机制,负责把 source、timer、observer 和 mode 组织起来,让线程做到:

  • 有事件就处理;
  • 没事件就休眠;
  • 在合适的时机完成事件分发、定时任务、界面提交等工作。

很多平时看起来零碎的问题,比如 Timer 在滚动时失效、子线程任务不回调、performSelector 不执行、UI 为什么不是立刻刷新,本质上都能用 RunLoop 这套模型解释清楚。

所以 RunLoop 这东西,真的不是为了面试八股才学。它更像是一把钥匙:平时你可能把它丢在抽屉里,但一旦遇到线程、事件、时序、刷新相关的问题,它就会突然变得非常好用。