iOS Runloop 深度解析
iOS Runloop 面试深度解析
一句话定义:Runloop 是一个有事干就干、没事就睡觉的事件驱动循环,它让线程不退出,同时做到 CPU 零空转。
目录
Runloop 是什么
Runloop 与线程的关系
核心数据结构
Mode 机制
Source / Timer / Observer
完整运行流程
休眠原理(亮点)
Runloop 与 AutoreleasePool
Runloop 与 NSTimer
Runloop 与 GCD
Runloop 与事件响应
实战应用场景
高频面试题精解
1. Runloop 是什么
本质:有条件的 do-while
// 伪代码还原 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 与线程的关系
一一对应,懒加载
线程 ←——对应——→ Runloop(存储在全局 CFMutableDictionaryRef 中,key=线程,value=Runloop)主线程:App 启动时由系统自动创建并运行 Runloop(
UIApplicationMain内部调用)子线程:默认没有 Runloop,第一次调用
[NSRunLoop currentRunLoop]时才懒加载创建子线程 Runloop 不会自动运行:需要手动调用
run/runUntilDate:/runMode:beforeDate:
为什么主线程不会退出?
因为主线程的 Runloop 在UIApplicationMain里被启动,内部是一个永不退出的循环,App 的生命周期就等于这个循环的生命周期。
3. 核心数据结构
Runloop 相关结构全部定义在CoreFoundation框架(开源)中:
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)中。
// 错误: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 种状态:
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. 完整运行流程
进入 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:从用户态到内核态
// 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:
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 滚动时失效
// 原因: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 + TrackingCADisplayLink
本质也是
CFRunLoopTimer,但绑定屏幕刷新(60/120Hz)同样受 Mode 影响,滚动时需要加入 CommonModes
子线程 Timer
// 子线程使用 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 是独立的两套机制,但在主线程上有交集:
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 与事件响应
触摸事件完整链路
硬件触摸 → 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
// 延迟执行,依赖 Runloop(子线程中若无 Runloop 则不执行) [self performSelector:@selector(foo) withObject:nil afterDelay:1.0]; // 跨线程执行,Runloop 不存在时无效 [self performSelector:@selector(foo) onThread:thread withObject:nil waitUntilDone:NO];12. 实战应用场景
场景一:常驻线程(保活线程)
// 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 滚动时延迟加载图片
// 图片加载放到 Default Mode,滚动时不占资源 [imageView performSelector:@selector(setImage:) withObject:image afterDelay:0 inModes:@[NSDefaultRunLoopMode]];场景三:卡顿检测
通过 Observer 监控主线程 Runloop 的状态时长:
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler( kCFAllocatorDefault, kCFRunLoopBeforeSources | kCFRunLoopAfterWaiting, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { // 记录状态开始时间 // 在子线程定时检查:如果某状态持续超过阈值(如 16ms),判定为卡顿 } ); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);原理:卡顿 = 主线程 Runloop 某次循环耗时过长(即在BeforeSources或AfterWaiting状态停留太久),子线程定时采样 Runloop 状态,触发卡顿上报。
场景四:界面流畅优化
Core Animation 的渲染提交时机在kCFRunLoopBeforeWaitingObserver 回调中(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)。
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];Q4:如何实现一个常驻子线程?
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 注册的kCFRunLoopBeforeWaitingObserver 回调触发(CA::Transaction::commit)。这就是为什么同一次循环内的多次 UI 修改不会导致多次重绘。
Q10:如何用 Runloop 实现卡顿监控?
核心思路:子线程信号量定时采样 + 主线程 Runloop Observer 状态记录。
主线程注册 Observer 监听
BeforeSources和AfterWaiting状态,记录进入时间戳子线程每隔 16ms(一帧)通过信号量检查主线程 Runloop 状态
若主线程在
BeforeSources或AfterWaiting状态停留超过阈值(如 50ms),判定卡顿通过
PLCrashReporter等采集当前主线程调用栈上报
总结:一张图串联全部知识点
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
