iOS Runloop 面试深度解析
一句话定义:Runloop 是一个有事干就干、没事就睡觉的事件驱动循环,它让线程不退出,同时做到 CPU 零空转。
目录
-
Runloop 是什么
-
Runloop 与线程的关系
-
核心数据结构
-
Mode 机制
-
Source / Timer / Observer
-
完整运行流程
-
休眠原理(亮点)
-
Runloop 与 AutoreleasePool
-
Runloop 与 NSTimer
-
Runloop 与 GCD
-
Runloop 与事件响应
-
实战应用场景
-
高频面试题精解
1. Runloop 是什么
本质:有条件的 do-while
Objective-C
// 伪代码还原 Runloop 内核
int retVal = 0;
do {
// 1. 通知 Observer:即将处理 Timer/Source
// 2. 处理到来的事件(Touch / Timer / Source)
// 3. 没有事件 → 调用 mach_msg 陷入内核态睡眠
// 4. 被唤醒 → 处理唤醒原因
retVal = XCFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle);
} while (retVal != kCFRunLoopRunStopped && retVal != kCFRunLoopRunFinished);
与普通 while(1) 的本质区别
| 普通死循环 | Runloop | |
| CPU 占用 | 100%(忙等) | 接近 0%(睡眠) |
| 唤醒方式 | 无,一直跑 | mach_msg 内核事件唤醒 |
| 适用场景 | 不适合长时间运行 | 线程保活、事件驱动 |
深度:Runloop 的"睡眠"不是
sleep(),而是通过mach_msg_trap陷入内核态,CPU 真正分配给其他进程,这是它的核心价值所在。
2. Runloop 与线程的关系
一一对应,懒加载
Objective-C
线程 ←------对应------→ Runloop(存储在全局 CFMutableDictionaryRef 中,key=线程,value=Runloop)
-
主线程:App 启动时由系统自动创建并运行 Runloop(
UIApplicationMain内部调用) -
子线程:默认没有 Runloop,第一次调用
[NSRunLoop currentRunLoop]时才懒加载创建 -
子线程 Runloop 不会自动运行:需要手动调用
run/runUntilDate:/runMode:beforeDate:
为什么主线程不会退出?
因为主线程的 Runloop 在 UIApplicationMain 里被启动,内部是一个永不退出的循环,App 的生命周期就等于这个循环的生命周期。
3. 核心数据结构
Runloop 相关结构全部定义在 CoreFoundation 框架(开源)中:
Objective-C
CFRunLoop
├── pthread_t thread // 对应线程
├── CFMutableSetRef modes // 所有注册的 Mode
├── CFRunLoopModeRef currentMode // 当前运行的 Mode(只能跑一个)
└── CFMutableSetRef commonModes // 标记为 Common 的 Mode 集合
│
└── CFRunLoopMode
├── CFMutableSetRef sources0 // Source0 集合
├── CFMutableSetRef sources1 // Source1 集合
├── CFMutableArrayRef timers // Timer 集合
└── CFMutableArrayRef observers // Observer 集合
关键:Runloop 在同一时刻只能运行在一个 Mode 下,切换 Mode 需要先退出当前 Mode,再重新进入新 Mode。这个设计是 NSTimer 在滚动时失效的根本原因。
4. Mode 机制
系统内置 Mode
| Mode 名称 | 常量 | 触发场景 |
| Default | kCFRunLoopDefaultMode / NSDefaultRunLoopMode |
默认,App 空闲时 |
| Tracking | UITrackingRunLoopMode |
ScrollView 滚动时 |
| Initialization | _kCFRunLoopInitializationMode |
App 启动初始化 |
| Common | kCFRunLoopCommonModes / NSRunLoopCommonModes |
伪 Mode,代表一组 Mode |
| EventReceiver | GSEventReceiveRunLoopMode |
接收系统事件 |
CommonModes 不是一个真正的 Mode
kCFRunLoopCommonModes 是一个标记集合,不是实际运行的 Mode。
当你把 Source/Timer 加入 CommonModes 时,实际上是把它加入到所有标记了 Common 的 Mode(Default + Tracking)中。
Objective-C
// 错误:Timer 在滚动时暂停
[NSTimer scheduledTimerWithTimeInterval:1.0 ...]; // 默认加入 Default Mode
// 正确:滚动时也触发
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
5. Source / Timer / Observer
Source0 ------ 非内核驱动的输入源
-
不基于 mach port,不能主动唤醒 Runloop
-
需要先调用
CFRunLoopSourceSignal标记为待处理,再调用CFRunLoopWakeUp手动唤醒 -
典型场景:
-
performSelector:onThread: -
UIKit 的触摸事件分发(
IOKit的原始事件经 Source1 接收后,转交给 Source0 分发) -
CFSocket回调
Source1 ------ 基于 mach port 的输入源
-
基于 mach port,可以主动唤醒 Runloop(内核直接发消息)
-
典型场景:
-
硬件事件(触摸、锁屏等)原始接收
-
线程间通信(
performSelector:onThread:的底层唤醒部分) -
各类系统 Port 事件
亮点:触摸事件的完整链路是
IOKit → SpringBoard(Source1 唤醒 App) → Source0 → UIApplication → UIWindow → hitTest → 响应链。
Timer
-
NSTimer/CADisplayLink本质都是CFRunLoopTimer -
时间精度受 Runloop 迭代耗时影响,不精确
-
必须添加到 Runloop 某个 Mode 才能触发
Observer
监听 Runloop 的状态变化,共 6 种状态:
Objective-C
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = 1 << 0, // 即将进入 Loop
kCFRunLoopBeforeTimers = 1 << 1, // 即将处理 Timer
kCFRunLoopBeforeSources = 1 << 2, // 即将处理 Source
kCFRunLoopBeforeWaiting = 1 << 5, // 即将休眠
kCFRunLoopAfterWaiting = 1 << 6, // 从休眠中被唤醒
kCFRunLoopExit = 1 << 7, // 即将退出 Loop
kCFRunLoopAllActivities = 0x0FFFFFFFU
};
系统注册了哪些 Observer?
-
kCFRunLoopEntry:初始化 AutoreleasePool(push) -
kCFRunLoopBeforeWaiting:释放旧的 AutoreleasePool(pop)并创建新的(push) -
kCFRunLoopExit:释放 AutoreleasePool(pop) -
kCFRunLoopBeforeWaiting:Core Animation 提交待渲染的图层树(CA::Transaction::commit)
6. 完整运行流程
Objective-C
进入 Runloop
│
▼
① 通知 Observer: kCFRunLoopEntry
│
▼
② 通知 Observer: kCFRunLoopBeforeTimers
│
▼
③ 通知 Observer: kCFRunLoopBeforeSources
│
▼
④ 处理 Source0(非端口)事件
│
▼
⑤ 如果有 Source1 就绪 → 跳到第 ⑨ 步处理
│
▼
⑥ 通知 Observer: kCFRunLoopBeforeWaiting
│
▼
⑦ 调用 mach_msg → 线程进入内核态休眠 💤
│
等待唤醒(Timer 到期 / Source1 / 外部调用 CFRunLoopWakeUp / 超时)
│
▼
⑧ 通知 Observer: kCFRunLoopAfterWaiting
│
▼
⑨ 处理唤醒原因:
├── Timer 到期 → 触发 Timer 回调
├── Source1 就绪 → 处理 Source1
└── 外部唤醒 → 处理其他来源
│
▼
⑩ 通知 Observer: kCFRunLoopExit(如果要退出)
│
▼
循环回 ②,或退出
7. 休眠原理(亮点)
这是面试中拉开差距的关键点。
mach_msg_trap:从用户态到内核态
Objective-C
// CFRunLoop 源码简化
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);
// 内部调用:
mach_msg(msg, MACH_RCV_MSG, 0, msg->msgh_size, port, timeout, MACH_PORT_NULL);
-
调用
mach_msg系统调用,CPU 从用户态切换到内核态 -
内核将线程挂起,放入等待队列
-
当指定 port 收到消息(Timer 触发 / 系统事件 / 主动唤醒),内核将线程从等待队列取出
-
CPU 切回用户态,Runloop 继续处理
整个过程中线程真正休眠,CPU 不做任何工作,这是 Runloop 能让主线程同时保活且不耗电的根本机制。
与 sleep() 的区别
sleep() 是用户态计时的忙等待变种;mach_msg 是内核级挂起,CPU 直接调度到其他线程/进程,唤醒精度更高,功耗更低。
8. Runloop 与 AutoreleasePool
主线程 Runloop 注册了两个与 AutoreleasePool 相关的 Observer:
Objective-C
Observer 优先级最高(priority = 2147483647)
kCFRunLoopEntry → _objc_autoreleasePoolPush() // 创建自动释放池
kCFRunLoopBeforeWaiting → _objc_autoreleasePoolPop() // 销毁旧池
+ _objc_autoreleasePoolPush() // 创建新池
kCFRunLoopExit → _objc_autoreleasePoolPop() // 最终销毁
实际意义:
-
每次 Runloop 即将休眠时,当前循环内所有
autorelease对象被释放 -
这就是为什么局部
autorelease对象的生命周期能安全地撑过一次完整事件处理 -
子线程没有自动的 AutoreleasePool,如果子线程中大量创建 OC 对象,需要手动创建
@autoreleasepool {}
9. Runloop 与 NSTimer
NSTimer 不精确的原因
-
Timer 依赖 Runloop,Runloop 每次循环耗时不固定
-
当前 Mode 下有耗时任务时,Timer 回调会延迟
经典问题:Timer 在 ScrollView 滚动时失效
Objective-C
// 原因:scheduledTimerWithTimeInterval 默认加入 NSDefaultRunLoopMode
// 滚动时 Runloop 切换到 UITrackingRunLoopMode,Default Mode 中的 Timer 暂停
// 解法:
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; // 同时加入 Default + Tracking
CADisplayLink
-
本质也是
CFRunLoopTimer,但绑定屏幕刷新(60/120Hz) -
同样受 Mode 影响,滚动时需要加入 CommonModes
子线程 Timer
Objective-C
// 子线程使用 Timer 必须手动跑 Runloop
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] run]; // 必须启动,否则 Timer 不触发
});
10. Runloop 与 GCD
GCD 和 Runloop 是独立的两套机制,但在主线程上有交集:
Objective-C
GCD dispatch_async(main_queue, block)
→ libdispatch 向主线程的 mach port 发送消息
→ 唤醒主线程 Runloop(Source0 处理,但由 Source1 唤醒)
→ Runloop 在下一次循环处理这个 block
所以
dispatch_async(main_queue)的 block 是在 Runloop 的一次循环中执行的,而不是"立即"执行。
Runloop 被 GCD 唤醒,处理的是 Source0(已标记为待处理),触发唤醒的是 libdispatch 的 port(Source1 范畴)。
11. Runloop 与事件响应
触摸事件完整链路
Objective-C
硬件触摸
→ IOKit.framework 封装为 IOHIDEvent
→ SpringBoard(系统进程)通过 mach port 转发给 App
→ App 主线程 Source1(mach port)被唤醒
→ Source1 回调将 IOHIDEvent 包装为 UIEvent,标记 Source0 为待处理
→ Source0 回调调用 UIApplication.sendEvent:
→ UIWindow → hitTest:withEvent: → 响应链
面试亮点:触摸事件经过了两次 Source 转换(Source1 接收 → Source0 分发),这个细节能体现对 Runloop 原理的深度理解。
performSelector 与 Runloop
Objective-C
// 延迟执行,依赖 Runloop(子线程中若无 Runloop 则不执行)
[self performSelector:@selector(foo) withObject:nil afterDelay:1.0];
// 跨线程执行,Runloop 不存在时无效
[self performSelector:@selector(foo) onThread:thread withObject:nil waitUntilDone:NO];
12. 实战应用场景
场景一:常驻线程(保活线程)
Objective-C
// AFNetworking 经典实现
+ (void)networkRequestThreadEntryPoint:(id)object {
@autoreleasepool {
[[NSThread currentThread] setName:@"com.alamofire.networking"];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
// 添加一个永不触发的 Source,防止 Runloop 因无事件而退出
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run]; // 启动
}
}
为什么需要加 Port? Runloop 退出的条件之一是:当前 Mode 下没有任何 Source/Timer/Observer。加一个空 Port(Source1)让 Runloop 认为有待处理事件,从而不退出。
场景二:TableView 滚动时延迟加载图片
Objective-C
// 图片加载放到 Default Mode,滚动时不占资源
[imageView performSelector:@selector(setImage:)
withObject:image
afterDelay:0
inModes:@[NSDefaultRunLoopMode]];
场景三:卡顿检测
通过 Observer 监控主线程 Runloop 的状态时长:
Objective-C
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
kCFRunLoopBeforeSources | kCFRunLoopAfterWaiting,
YES, 0,
^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
// 记录状态开始时间
// 在子线程定时检查:如果某状态持续超过阈值(如 16ms),判定为卡顿
}
);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
原理:卡顿 = 主线程 Runloop 某次循环耗时过长(即在 BeforeSources 或 AfterWaiting 状态停留太久),子线程定时采样 Runloop 状态,触发卡顿上报。
场景四:界面流畅优化
Core Animation 的渲染提交时机在 kCFRunLoopBeforeWaiting Observer 回调中(CA::Transaction::commit)。因此:
-
同一个 Runloop 循环内的多次 UI 修改会被合并成一次渲染提交,不会每次修改都触发重绘
-
手动调用
setNeedsDisplay/setNeedsLayout只是标记,真正的渲染在 Runloop 即将休眠时批量处理
13. 高频面试题精解
Q1:Runloop 和线程的关系?
-
一一对应,保存在全局字典中
-
主线程 Runloop 由系统自动创建并启动
-
子线程 Runloop 懒加载,首次调用
currentRunLoop时创建,但不会自动启动 -
线程销毁时 Runloop 也销毁
Q2:为什么说 Runloop 和 AutoreleasePool 有关系?
主线程 Runloop 注册了 Observer:在每次循环开始时 push 一个新池,即将休眠时 pop 旧池再 push 新池,退出时 pop 最后的池。因此主线程上普通 autorelease 对象最晚在当次 Runloop 循环结束(休眠前)被释放,而不需要等到整个 App 退出。
Q3:NSTimer 为什么在滚动时停止,如何解决?
原因:scheduledTimerWithTimeInterval: 默认将 Timer 加入 NSDefaultRunLoopMode,ScrollView 滚动时 Runloop 切换到 UITrackingRunLoopMode,Default Mode 下的 Timer 暂停。
解决:将 Timer 加入 NSRunLoopCommonModes(等价于同时加入 Default + Tracking)。
Objective-C
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
Q4:如何实现一个常驻子线程?
Objective-C
NSThread *thread = [[NSThread alloc] initWithBlock:^{
// 添加 Port 防止 Runloop 因空 Mode 退出
[[NSRunLoop currentRunLoop] addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] run];
}];
[thread start];
关键点:必须在子线程内部启动 Runloop,且至少有一个 Source/Timer/Observer,否则 Runloop 立刻退出。
Q5:Source0 和 Source1 有什么区别?
| Source0 | Source1 | |
| 驱动方式 | 非端口,需手动唤醒 | 基于 mach port,内核主动唤醒 |
| 典型场景 | 触摸事件分发、performSelector | 系统事件接收、线程间端口通信 |
| 唤醒 Runloop | 不能(需先 Signal + WakeUp) | 能(内核直接发消息) |
Q6:Runloop 的休眠和 sleep 有什么本质区别?
sleep() 是用户态的等待,CPU 仍然调度到当前线程检查时间;Runloop 通过 mach_msg_trap 陷入内核态,线程被挂起,CPU 完全分配给其他任务,直到指定 port 有消息时才被内核唤醒。后者是真正意义上的零 CPU 占用休眠。
Q7:performSelector:afterDelay: 在子线程不执行是为什么?
performSelector:afterDelay: 内部使用 NSTimer,NSTimer 需要添加到 Runloop 才能触发。子线程默认没有 Runloop,Timer 无法注册,因此方法永远不会执行。解决方法:在子线程手动启动 Runloop。
Q8:GCD 的 dispatch_async 到主队列与 Runloop 有什么关系?
dispatch_async(main_queue, block) 并不直接执行 block,而是向主线程的 dispatch_main_queue port 发送消息,唤醒主线程 Runloop,Runloop 在下一次循环中处理这个 block。因此,如果主线程 Runloop 被长时间阻塞,主队列的任务也会延迟。
Q9:界面更新(setNeedsLayout / setNeedsDisplay)的时机?
调用这些方法只是标记需要更新,并不立刻重绘。真正的渲染提交发生在 Runloop 即将休眠时,由 Core Animation 注册的 kCFRunLoopBeforeWaiting Observer 回调触发(CA::Transaction::commit)。这就是为什么同一次循环内的多次 UI 修改不会导致多次重绘。
Q10:如何用 Runloop 实现卡顿监控?
核心思路:子线程信号量定时采样 + 主线程 Runloop Observer 状态记录。
-
主线程注册 Observer 监听
BeforeSources和AfterWaiting状态,记录进入时间戳 -
子线程每隔 16ms(一帧)通过信号量检查主线程 Runloop 状态
-
若主线程在
BeforeSources或AfterWaiting状态停留超过阈值(如 50ms),判定卡顿 -
通过
PLCrashReporter等采集当前主线程调用栈上报
总结:一张图串联全部知识点
Objective-C
App 启动
└── UIApplicationMain → 启动主线程 Runloop
主线程 Runloop(Default / Tracking / Common)
│
├── Source1(mach port)← 接收硬件事件 / 系统消息 / GCD 主队列
│ │ 转交
│ ▼
├── Source0 ← 处理 UIEvent 事件分发 / performSelector
│
├── Timer ← NSTimer / CADisplayLink(注意 Mode!)
│
├── Observer
│ ├── Entry/Exit → AutoreleasePool push/pop
│ ├── BeforeWaiting → AutoreleasePool pop+push
│ │ → CATransaction commit(UI 提交渲染)
│ └── BeforeSources/AfterWaiting → 卡顿监控采样点
│
└── mach_msg 休眠 💤(真正零 CPU,内核级挂起)
参考:CFRunLoop 开源实现(swift.org/open-source/corelibs-foundation)、Apple 官方 Threading Programming Guide