五.iOS RunLoop

21 阅读7分钟

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日常状态
TrackingModeUIScrollView 滑动中
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

要点

  1. 必须加 source (Port) 或 Timer,否则 run 立刻返回

  2. while (YES) 配合 runMode:beforeDate: 的方式比直接 run 更可控,能支持 stop

  3. 注意线程生命周期,不要创建过多常驻线程

场景 2:卡顿监控

原理:监听 RunLoop 状态变化。如果 BeforeSourcesBeforeWaiting 之间的间隔超过阈值(如 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 source1source0 手动唤醒;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 状态BeforeSourcesBeforeWaiting 之间的间隔

七、学习路径

第一阶段:理解概念

  • 能用自己的话解释 RunLoop 是什么

  • 理解 RunLoop 与线程的关系

  • 知道 Mode 的作用

  • 能解释 Timer 在滑动时不触发的原因并写出修复代码

第二阶段:动手实践

  • 手写 PermanentThread(子线程保活)

  • 手写 LagMonitor(卡顿监控骨架)

  • 验证 Timer 在 DefaultMode / CommonModes 下的行为差异

  • 验证 performSelector:afterDelay: 在子线程不 run 时不触发

第三阶段:深入理解

  • 理解 GCD main queue 如何与 RunLoop 协作

  • 实现空闲任务调度器

  • 分析 App 启动阶段 RunLoop 的状态变化

  • 读 CFRunLoop 源码关键路径(__CFRunLoopRun