iOS Runloop 深度解析

iOS Runloop 面试深度解析

一句话定义:Runloop 是一个有事干就干、没事就睡觉的事件驱动循环,它让线程不退出,同时做到 CPU 零空转。


目录

  1. Runloop 是什么

  2. Runloop 与线程的关系

  3. 核心数据结构

  4. Mode 机制

  5. Source / Timer / Observer

  6. 完整运行流程

  7. 休眠原理(亮点)

  8. Runloop 与 AutoreleasePool

  9. Runloop 与 NSTimer

  10. Runloop 与 GCD

  11. Runloop 与事件响应

  12. 实战应用场景

  13. 高频面试题精解


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);
  1. 调用 mach_msg 系统调用,CPU 从用户态切换到内核态

  2. 内核将线程挂起,放入等待队列

  3. 当指定 port 收到消息(Timer 触发 / 系统事件 / 主动唤醒),内核将线程从等待队列取出

  4. 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 不精确的原因

  1. Timer 依赖 Runloop,Runloop 每次循环耗时不固定

  2. 当前 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
  • 本质也是 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 某次循环耗时过长(即在 BeforeSourcesAfterWaiting 状态停留太久),子线程定时采样 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 状态记录。

  1. 主线程注册 Observer 监听 BeforeSourcesAfterWaiting 状态,记录进入时间戳

  2. 子线程每隔 16ms(一帧)通过信号量检查主线程 Runloop 状态

  3. 若主线程在 BeforeSourcesAfterWaiting 状态停留超过阈值(如 50ms),判定卡顿

  4. 通过 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

相关推荐
╰つ栺尖篴夢ゞ7 天前
iOS经典面试题之深入解析一个触摸事件是如何识别并执行具体逻辑的
触摸事件·runloop·事件传递机制·响应者链·hit-testing
linweidong4 个月前
得物ios开发面试题及参考答案(下)
ios开发·appstore·runloop·自旋锁·ios版本·ios事件·app面试
RollingPin6 个月前
iOS八股文之 RunLoop
ios·多线程·卡顿·ios面试·runloop·ios保活·ios八股文
RollingPin6 个月前
iOS八股文之 内存管理
ios·内存管理·内存泄漏·ios面试·arc·runloop·引用计数
SchneeDuan2 年前
iOS--RunLoop原理
ios·timer·runloop
码农--xc2 年前
深入理解RunLoop
ios·线程·runloop
依旧风轻2 年前
RunLoop小白入门
ios·ios面试·runloop