大家好,我是嘉豪。
这两天我又把 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 解决的是两个核心问题:
- 让线程在有事做的时候保持工作。
- 让线程在没事做的时候别空转耗 CPU。
这个设计非常重要。否则主线程如果一直死循环轮询事件,手机发热和掉电会快得像开了涡轮;如果线程处理完一个任务就退出,那 App 也根本不可能持续响应事件。宇宙不会允许这种离谱工程存在太久。
RunLoop 和线程是什么关系?
这一点其实是 RunLoop 最关键的前置知识。
可以先记住一句话:RunLoop 和线程是一一对应理解的。
Apple 文档里给出的说法是:每个线程都有关联的 RunLoop 对象,主线程的 RunLoop 会由应用框架自动配置并运行,而二级线程是否运行 RunLoop,则取决于你自己。只有在你真的需要它的时候,才需要显式启动。
这句话翻译成人话就是:
- 主线程一定有 RunLoop,而且系统已经帮你跑起来了。
- 子线程就算能拿到 RunLoop,也不代表它已经在跑。
- 如果子线程要长期存活、处理 Timer、接收 Selector、接收 Port/Source 事件,那你得自己把它跑起来。
还有一个很容易踩坑的点:RunLoop 里必须至少有一个输入源(source)或者 timer,否则一启动就会立刻退出。 Apple 官方文档对此写得很直白。
所以,很多同学在子线程里写个 Timer,结果发现根本不回调,本质原因通常不是 Timer 坏了,而是线程的 RunLoop 根本没跑,或者刚跑起来就退出了。
RunLoop 里到底有什么?
从概念上讲,一个 RunLoop 主要围绕四类东西运转:
ModeSourceTimerObserver
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 源码分析文章,那还会经常见到 Source0 和 Source1 这套说法。可以先粗暴理解成:
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,被分发到 UIWindow、UIView、UIGestureRecognizer,背后同样离不开主线程 RunLoop 对事件源的处理。经典解析中也展示了系统事件如何通过 Source1 进入应用内部分发链路。
3. 界面刷新与提交
很多 setNeedsLayout、setNeedsDisplay 并不会让 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 不是实时机制,而且如果错过一个或多个计划时间点,也不会把所有错过的触发一股脑补回来。
误区三:NSRunLoop 和 CFRunLoop 完全一样,随便跨线程改
这也不对。Apple 文档提到,Core Foundation 那套 API 通常是线程安全的;但 NSRunLoop 本身并不像底层 CFRunLoopRef 那么天然线程安全,最好只在拥有它的线程里修改它。
小结
到这里,RunLoop 的主线其实就已经很清楚了。
它不是某个冷门 API,也不只是 NSTimer 的背景板。它本质上是线程背后的事件循环机制,负责把 source、timer、observer 和 mode 组织起来,让线程做到:
- 有事件就处理;
- 没事件就休眠;
- 在合适的时机完成事件分发、定时任务、界面提交等工作。
很多平时看起来零碎的问题,比如 Timer 在滚动时失效、子线程任务不回调、performSelector 不执行、UI 为什么不是立刻刷新,本质上都能用 RunLoop 这套模型解释清楚。
所以 RunLoop 这东西,真的不是为了面试八股才学。它更像是一把钥匙:平时你可能把它丢在抽屉里,但一旦遇到线程、事件、时序、刷新相关的问题,它就会突然变得非常好用。