RunLoop
一句话:RunLoop 是 iOS 线程的事件泵(Event Loop),通过 mach_msg 让线程“有事忙事、没事休眠”以节省 CPU。
一、为什么要学 RunLoop
| 不理解 RunLoop | 理解了 RunLoop |
|---|---|
| NSTimer 滑动时卡住,不知道原因 | 知道是 Mode 不对,加 CommonModes 解决 |
| 子线程 performSelector 不触发,一头雾水 | 知道子线程 RunLoop 默认没启动 |
| 卡顿监控觉得是黑魔法 | 知道是 Observer 监听状态切换 |
| autoreleasepool 什么时候释放对象?猜 | 知道 RunLoop 每轮循环结束自动 pop |
二、核心概念
2.1 RunLoop 是什么
[应用主线程]
↓
while (app.isRunning) { ← 核心就是一个 while 循环
有事件? → 处理 ← 处理点击、滑动、Timer、网络回调...
没事件? → mach_msg 休眠 ← 内核态等待,不耗 CPU
}
RunLoop 是一个对象,管理这个 while 循环里的所有逻辑。它决定什么时候处理什么事件,什么时候该休眠。
2.2 RunLoop 与线程 —— 一一对应
线程 A ──owns──→ RunLoop A
线程 B ──owns──→ RunLoop B
每个线程有且仅有一个 RunLoop,通过 TLS(线程局部存储)绑定。
-
主线程:UIApplicationMain 自动创建并跑起来,你从来没管过它但它一直在
-
子线程:懒加载,你不调
[NSRunLoop currentRunLoop]就不创建;你不手动run就不启动 -
不能手动 new:只能通过
CFRunLoopGetMain()/CFRunLoopGetCurrent()获取
2.3 Mode —— RunLoop 的运行模式
RunLoop 同一时刻只能在一个 Mode 下运行。这是理解 Timer 问题的关键。
[ RunLoop ]
├── Mode: Default
├── Mode: Tracking (滑动时)
├── Mode: Initialization (启动时,用完即弃)
├── Mode: GSEventReceive (系统图形事件)
└── CommonModes (⚠️ 不是真实 Mode,是一个标记集合)
Mode 切换的本质:退出当前循环 → 换 Mode → 重新进入循环。
| Mode | 何时运行 |
|---|---|
DefaultMode | 日常状态 |
TrackingMode | UIScrollView 滑动中 |
CommonModes | 一个标签集合,默认 = DefaultMode + TrackingMode |
为什么要切换 Mode:滑动时需要更高的刷新率处理触摸事件。如果还处理 Timer 等杂事,滑动手势就掉帧了。所以切换到 TrackingMode 后,只注册在这个 Mode 下的 source/timer 才会被处理。
2.4 五个关键类型
| 类型 | 角色 | 一句话 |
|---|---|---|
CFRunLoopRef | 循环本身 | 管理多个 Mode |
CFRunLoopModeRef | 运行模式 | 包含 source/timer/observer 的容器 |
CFRunLoopSourceRef | 输入源 | source0(手动触发)/ source1(mach_port 唤醒) |
CFRunLoopTimerRef | 定时器 | NSTimer 的底层实现 |
CFRunLoopObserverRef | 观察者 | 监听 RunLoop 生命周期,卡顿监控的基础 |
Source0 vs Source1 —— 面试高频:
-
source0:App 内部事件(触摸、gesture、performSelector 等),需要手动
CFRunLoopSourceSignal()+CFRunLoopWakeUp()才能唤醒休眠的 RunLoop -
source1:基于 mach_port 的内核消息(系统事件、GCD 主线程回调等),内核自动唤醒线程
三、RunLoop 的完整生命周期
CFRunLoopRun()
│
├─ ① Observer: Entry ───── 即将进入循环
│
├─ ② Observer: BeforeTimers ── 准备处理 Timer
├─ ③ Observer: BeforeSources ── 准备处理 Source
├─ ④ 执行 Block
├─ ⑤ 处理 Source0
│
├─ ⑥ 有 Source1?── 有 → 跳到 ⑨
│ │
│ 无
│ ↓
├─ ⑦ Observer: BeforeWaiting ── 马上休眠
│
├─ ⑧ mach_msg 休眠 ── 🔋 内核态,线程休眠不耗 CPU
│ │
│ └── 被以下事件唤醒:
│ · Source1 (mach_port 消息)
│ · Timer 到期
│ · CFRunLoopWakeUp() 手动唤醒
│ · 超时
│
├─ ⑨ Observer: AfterWaiting ── 醒过来了
│
├─ ⑩ 处理唤醒源
│ · Timer → 执行 Timer 回调
│ · Source1 → 分发到 Source0 执行
│ · 手动唤醒 → 执行 Block
│
├─ ⑪ Observer: Exit ── 准备退出
│
└─ ⑫ 回到 ②(除非被要求退出)
休眠机制的“省电”原理
用户态(处理事件)──→ 没事干了 ──→ mach_msg() 系统调用 ──→ 内核态(休眠)
↓ 事件来了
用户态(醒来干活)──← mach_msg 返回 ──← 内核唤醒线程
关键点:内核态休眠时,线程不占用 CPU 时间片。
这就是为什么 iOS App 可以在前台一直跑却不耗电——大多数时间都在休眠。
四、RunLoop 与 autoreleasepool 的关系
RunLoop 一轮循环:
objc_autoreleasePoolPush() ← 打哨兵
├── 处理事件 (Timer、Source...)
└── objc_autoreleasePoolPop() ← 释放本轮产生的临时对象
循环回到 Push...
含义:主线程 RunLoop 每跑完一轮,就会释放那一轮创建的临时 autorelease 对象。这也是为什么 autorelease 的对象不会立即释放,而是等到 RunLoop 休眠前。
五、实践场景
场景 1:子线程保活(常驻线程)
问题:子线程执行完任务就销毁了,需要一个一直活着的子线程反复接收任务。
难点:RunLoop 没有 source/timer 就立刻退出。所以需要先加个 Port,再 run。
@interface PermanentThread : NSObject
@property (nonatomic, strong) NSThread *thread;
- (void)executeTask:(void(^)(void))task;
@end
@implementation PermanentThread
- (instancetype)init {
if (self = [super init]) {
self.thread = [[NSThread alloc] initWithBlock:^ {
@autoreleasepool {
// 关键①:添加 port,让 RunLoop 有事件源可处理
[[NSRunLoop currentRunLoop] addPort:[NSPort port]
forMode:NSDefaultRunLoopMode];
// 关键②:启动 RunLoop
while (YES) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate distantFuture]];
}
}
}];
[self.thread start];
}
return self;
}
- (void)executeTask:(void(^)(void))task {
[self performSelector:@selector(performTask:)
onThread:self.thread
withObject:[task copy]
waitUntilDone:NO];
}
- (void)performTask:(void(^)(void))task {
task();
}
- (void)stop {
CFRunLoopStop([[NSThread currentThread] runLoop]);
self.thread = nil;
}
@end
要点:
-
必须加 source (Port) 或 Timer,否则
run立刻返回 -
while (YES)配合runMode:beforeDate:的方式比直接run更可控,能支持 stop -
注意线程生命周期,不要创建过多常驻线程
场景 2:卡顿监控
原理:监听 RunLoop 状态变化。如果 BeforeSources → BeforeWaiting 之间的间隔超过阈值(如 100ms),说明主线程一直在忙,即卡顿。
final class LagMonitor {
static let shared = LagMonitor()
private var lagStart: CFAbsoluteTime = 0
func start() {
let observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
[.beforeSources, .afterWaiting, .beforeWaiting].rawValue,
true, 0
) { [weak self] _, activity in
guard let self else { return }
switch activity {
case .beforeSources, .afterWaiting:
// 开始干活,记时间
self.lagStart = CFAbsoluteTimeGetCurrent()
case .beforeWaiting:
// 准备歇了,算耗时
let elapsed = (CFAbsoluteTimeGetCurrent() - self.lagStart) * 1000
if elapsed > 100 {
print("[LagMonitor] 卡顿 \(elapsed)ms — 该采集堆栈了")
// 这里可以用 BSBacktraceLogger 或 PLCrashReporter 采集堆栈
}
default:
break
}
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
}
}
为什么只监听到 BeforeWaiting:主线程在 BeforeSources 之后开始干活,要干到 BeforeWaiting 才准备歇。这段时间就是主线程的忙碌时间。如果超过阈值就是卡顿。
场景 3:NSTimer 滑动时保持触发
问题根因:Timer 加在 DefaultMode 下,滑动时 RunLoop 切到 TrackingMode,DefaultMode 下的 Timer 就不处理了。
// ❌ 滑动时不触发
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer *t) {
NSLog(@"fire");
}];
// ✅ 方案1:加入 CommonModes(在 DefaultMode 和 TrackingMode 下都触发)
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
// ✅ 方案2:用 dispatch_source,不依赖 RunLoop Mode
dispatch_source_t dt = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
dispatch_source_set_timer(dt, DISPATCH_TIME_NOW, 1.0 * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(dt, ^{ NSLog(@"fire"); });
dispatch_resume(dt);
场景 4:空闲任务调度
思路:在 RunLoop 即将休眠(BeforeWaiting)时执行低优先级任务,此时主线程闲下来了,正好处理。
static void addIdleTask(void (^task)(void)) {
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
kCFRunLoopBeforeWaiting | kCFRunLoopExit,
YES, 0,
^(CFRunLoopObserverRef obs, CFRunLoopActivity activity) {
task();
CFRunLoopObserverInvalidate(obs); // 执行一次就移除
CFRelease(obs);
});
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
这个技巧可以用来:延迟预加载、非关键 UI 刷新、埋点上报等——主线程不忙的时候偷偷干了。
六、面试速查表
| 问题 | 答案 |
|---|---|
| RunLoop 是什么 | 线程的事件泵,mach_msg 驱动,有事处理、没事休眠 |
| 一个线程几个 RunLoop | 一个,TLS 绑定,懒加载创建 |
| Mode 是什么 | RunLoop 的运行模式,同一时刻只能一个 Mode |
| CommonModes 是什么 | Mode 标签集合,不是真实 Mode |
| source0 vs source1 | source0 手动唤醒;source1 基于 mach_port 内核自动唤醒 |
| 主线程 RunLoop 怎么启动的 | UIApplicationMain 内部调 CFRunLoopRun |
| 子线程保活怎么做 | addPort + while(runMode:) |
| NSTimer 为什么滑动不触发 | Mode 切换,DefaultMode 的 Timer 在 TrackingMode 下不处理 |
| 怎么解决滑动时 Timer 失效 | 加到 CommonModes,或用 dispatch_source |
| performSelector:afterDelay: 原理 | 底层创建 Timer 加到 RunLoop |
| autoreleasepool 什么时候释放 | RunLoop 每个循环结束 pop |
| 卡顿监控原理 | Observer 监听状态切换,计算忙碌时长超过阈值 |
| 休眠省电原理 | mach_msg 让线程进入内核态,不占 CPU 时间片 |
| 主线程卡顿检测时,应该监听哪些 RunLoop 状态 | BeforeSources 到 BeforeWaiting 之间的间隔 |
七、学习路径
第一阶段:理解概念
-
能用自己的话解释 RunLoop 是什么
-
理解 RunLoop 与线程的关系
-
知道 Mode 的作用
-
能解释 Timer 在滑动时不触发的原因并写出修复代码
第二阶段:动手实践
-
手写 PermanentThread(子线程保活)
-
手写 LagMonitor(卡顿监控骨架)
-
验证 Timer 在 DefaultMode / CommonModes 下的行为差异
-
验证 performSelector:afterDelay: 在子线程不 run 时不触发
第三阶段:深入理解
-
理解 GCD main queue 如何与 RunLoop 协作
-
实现空闲任务调度器
-
分析 App 启动阶段 RunLoop 的状态变化
-
读 CFRunLoop 源码关键路径(
__CFRunLoopRun)