【碎片八股文 #006】RunLoop 为什么能让主线程不退出?
一、面试题原文
面试官: 为什么 iOS 的主线程不会像普通线程一样执行完就退出?RunLoop 在其中起什么作用?
候选人: RunLoop 是个循环吧……一直在运行所以不会退出。
面试官心里想: 能说出事件循环、休眠唤醒、Source/Timer/Observer 就算过关了。
二、常见误答
很多人只知道"RunLoop 是个循环",但说不清楚:
- RunLoop 的"循环"具体循环什么?
- 为什么没事件时不会耗电?
- RunLoop 和线程是什么关系?
这些都是面试官会追问的点。
三、正确理解
什么是 RunLoop?
RunLoop 是一个事件循环机制,让线程能够:
- 有事做事(处理事件)
- 没事休眠(节省 CPU)
- 随时被唤醒(响应新事件)
核心作用:
- 保持线程不退出
- 监听并处理各种事件
- 节省系统资源
为什么需要 RunLoop?
普通线程的生命周期:
// 普通线程:执行完就退出
- (void)normalThread {
NSThread *thread = [[NSThread alloc] initWithBlock:^{
NSLog(@"线程开始");
// 执行一些任务
NSLog(@"线程结束");
}];
[thread start];
// 线程执行完 block 后自动退出
}
主线程的特殊性:
主线程需要一直运行,处理用户交互、界面刷新等事件。如果执行完 main() 就退出,应用就结束了。
RunLoop 让主线程进入循环状态:
int main(int argc, char * argv[]) {
@autoreleasepool {
// 这里会启动主线程的 RunLoop
return UIApplicationMain(argc, argv, nil,
NSStringFromClass([AppDelegate class]));
// UIApplicationMain 内部会启动 RunLoop,永不返回
}
}
四、RunLoop 的核心原理
1. RunLoop 和线程的关系
一对一绑定:
- 每个线程都可以有一个 RunLoop
- RunLoop 和线程一一对应
- 主线程的 RunLoop 自动创建并启动
- 子线程的 RunLoop 需要手动获取和启动
// 获取当前线程的 RunLoop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
// 获取主线程的 RunLoop
NSRunLoop *mainRunLoop = [NSRunLoop mainRunLoop];
线程保活:
// 子线程默认会退出
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"开始");
// 执行完就退出
});
// 用 RunLoop 保活子线程
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"开始");
// 启动 RunLoop
[[NSRunLoop currentRunLoop] run]; // 线程不会退出
NSLog(@"永远不会执行"); // 这行代码到不了
});
2. RunLoop 的输入源
RunLoop 通过 输入源 接收事件,主要分为三类:
| 类型 | 说明 | 示例 |
|---|---|---|
| Source0 | 非基于 Port 的事件 | 触摸事件、performSelector |
| Source1 | 基于 Port 的进程间通信 | Mach Port、网络唤醒 |
| Timer | 定时器事件 | NSTimer、CADisplayLink |
| Observer | RunLoop 状态观察 | 监听 RunLoop 各个阶段 |
3. RunLoop 的运行循环
1. 通知 Observers:即将进入 Loop
↓
2. 通知 Observers:即将处理 Timers
↓
3. 通知 Observers:即将处理 Sources
↓
4. 处理 Source0(非 Port 事件)
↓
5. 如果有 Source1(Port 事件),跳到步骤 9
↓
6. 通知 Observers:即将休眠
↓
7. 休眠(等待被唤醒)
↓
8. 被唤醒(收到事件)
↓
9. 通知 Observers:被唤醒
↓
10. 处理唤醒事件
- 处理 Timer
- 处理 Source1
- 处理 GCD Main Queue
↓
11. 通知 Observers:即将退出 Loop
↓
12. 回到步骤 1(继续循环)
关键点: 第 7 步的"休眠"是真正的休眠,不占用 CPU。
五、图解核心概念
RunLoop 的休眠唤醒机制
┌─────────────────────────────────────────────────┐
│ RunLoop 循环 │
│ │
│ ┌──────────────┐ │
│ │ 处理事件 │ │
│ └──────┬───────┘ │
│ │ │
│ │ 没有事件了 │
│ ▼ │
│ ┌──────────────┐ mach_msg_trap() │
│ │ 进入休眠 │◄─────────────────────┐ │
│ │ (不占 CPU) │ │ │
│ └──────┬───────┘ │ │
│ │ │ │
│ │ 有新事件 │ │
│ ▼ │ │
│ ┌──────────────┐ 内核唤醒 │ │
│ │ 被唤醒 │──────────────────────┘ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 处理事件 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────┘
唤醒来源:
- 触摸屏幕(Source0)
- 网络数据到达(Source1)
- Timer 触发
- GCD 任务到达主队列
RunLoop 的 Mode 机制
RunLoop 运行在不同的 Mode(模式) 下:
┌─────────────────────────────────────────────┐
│ RunLoop Mode │
│ │
│ ┌──────────────────┐ │
│ │ NSDefaultRunLoopMode (默认模式) │
│ │ - 普通事件处理 │
│ │ - NSTimer │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ UITrackingRunLoopMode (滚动模式) │
│ │ - 滚动 ScrollView 时 │
│ │ - 拖动界面时 │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ NSRunLoopCommonModes (组合模式) │
│ │ - 包含 Default + Tracking │
│ │ - Timer 加到这个 Mode 就不会被滚动影响 │
│ └──────────────────┘ │
└─────────────────────────────────────────────┘
常见问题: Timer 在滚动时不触发
// 问题代码:Timer 加到默认模式
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:@selector(update)
userInfo:nil
repeats:YES];
// 滚动时 RunLoop 切换到 Tracking 模式,Timer 不会触发
// 解决方案:把 Timer 加到 Common 模式
[[NSRunLoop currentRunLoop] addTimer:timer
forMode:NSRunLoopCommonModes];
六、延伸提问
1. RunLoop 的底层实现是什么?
RunLoop 基于 mach_msg_trap() 系统调用实现休眠和唤醒。
// 简化版 RunLoop 伪代码
void CFRunLoopRun() {
while (true) {
// 处理事件
handleEvents();
// 如果没有事件,进入休眠
if (noEventsToProcess) {
mach_msg_trap(); // 内核级休眠,不占 CPU
}
// 被内核唤醒后继续循环
}
}
mach_msg_trap() 的作用:
- 让线程进入内核态休眠
- 等待 Mach Port 消息唤醒
- 被唤醒后返回用户态继续执行
2. AutoreleasePool 和 RunLoop 的关系
AutoreleasePool 的创建和释放由 RunLoop Observer 控制:
RunLoop 启动时:
- 创建 AutoreleasePool(最外层 pool)
RunLoop 即将休眠时:
- 释放旧 pool
- 创建新 pool
RunLoop 退出时:
- 释放 pool
这就是为什么主线程不需要手动创建 @autoreleasepool。
3. 子线程如何正确使用 RunLoop?
// 错误示例:Run 后无法停止
NSThread *thread = [[NSThread alloc] initWithBlock:^{
[[NSRunLoop currentRunLoop] run]; // 永远不会返回
}];
[thread start];
// 正确示例:使用 runMode:beforeDate: 可控制
NSThread *thread = [[NSThread alloc] initWithBlock:^{
while (!self.shouldStop) {
// 运行一小段时间,可随时退出
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate distantFuture]];
}
}];
[thread start];
4. performSelector 在 RunLoop 中的执行时机
// 立即执行(不需要 RunLoop)
[self performSelector:@selector(test)];
// 下一个 RunLoop 循环执行
[self performSelector:@selector(test)
withObject:nil
afterDelay:0]; // 需要 RunLoop
// 在指定线程的 RunLoop 中执行
[self performSelector:@selector(test)
onThread:otherThread
withObject:nil
waitUntilDone:NO]; // 目标线程必须有 RunLoop
关键点: afterDelay 和 onThread 的方法都依赖 RunLoop。
七、记忆口诀
"RunLoop 循环不停转,有事处理没事眠;Source 触发 Timer 到,Observer 观察全过程;主线程常驻靠它管,子线程保活也能玩。"
八、碎片笔记
核心关键词: RunLoop、事件循环、休眠唤醒、Source/Timer/Observer、Mode、mach_msg_trap
重点记忆:
- RunLoop 通过 mach_msg_trap 实现休眠,不占 CPU
- 主线程的 RunLoop 自动创建并启动,子线程需要手动
- RunLoop 运行在不同 Mode 下,Mode 之间互不影响
- Timer 要加到 NSRunLoopCommonModes 才不会被滚动影响
实际应用:
- 子线程保活:手动启动 RunLoop
- 避免 Timer 在滚动时失效:用 Common Mode
- performSelector:afterDelay: 需要目标线程有 RunLoop
- AutoreleasePool 的创建释放由 RunLoop 控制
性能优化:
- 不要在主线程 RunLoop 中做耗时操作
- 长时间运行的任务放到子线程 + RunLoop
- 监听 RunLoop 状态可以做卡顿检测
今天的碎片,帮你面试少挂一次。
下一篇预告: 【碎片八股文 #007】Handler 消息机制底层是怎么实现的?