RunLoop

7 阅读7分钟

Runloop

一个 RunLoop 包含若干个 Mode,每个Mode都包含Source/Time/Observer,每次调用RunLoop只能指定一个mode,如果切换Mode,只能退出Loop,再重新指定一个mode进入。这样做主要是为分割开不同组的Source/Timer/Observer,让其互不影响。

Source 有2个版本,Source0和Source1

  1. SourceO只包含一个回调函数指针,它不能主动触发事件,使用时,你需要调用CFRunLoopSourceSignal(Source),将这个Source标记为待处理,然后手动调用CFRunLoopSourceWakeUp(Source)来唤醒RunLoop,让其处理这个事件。
  2. Source1包含了一个match_port和一个回调,被用于通过内核和其他线程相互发送消息,这种Source能主动唤醒RunLoop的线程

RunLoop 的核心是一个 do-while 循环,它通过内核底层的 mach_msg() 函数让线程在没有任务时进入休眠,在有任务时被唤醒。 以下是根据开源的 CFRunLoop 源码 整理的详细执行流程以及进入休眠的判断机制。 [1]

RunLoop 的完整执行流程

每一次 RunLoop 循环(跑圈)的内部逻辑如下:

  1. 通知 Observer:即将进入 RunLoop (kCFRunLoopEntry)。 [2]
  2. 通知 Observer:即将处理 Timer (kCFRunLoopBeforeTimers)。 [2]
  3. 通知 Observer:即将处理 Source0 (kCFRunLoopBeforeSources)。 [2]
  4. 处理 Source0:执行 Source0 事件(非系统内核事件,如 UIButton 点击)。
  5. 检查 Source1:如果有 Source1(基于 Mach Port 的内核事件)准备就绪,直接跳转到第 9 步处理消息,跳过休眠。
  6. 通知 Observer:线程即将进入休眠 (kCFRunLoopBeforeWaiting)。 [2, 3]
  7. 进入休眠(内核态):调用 mach_msg() 阻断当前线程,让出 CPU 资源,等待特定内核端口消息唤醒。 [1]
  8. 被唤醒(内核态):当收到以下条件之一的消息时,线程被唤醒,并通知 Observer 线程刚从休眠中唤醒 (kCFRunLoopAfterWaiting):
  • 基于 Mach Port 的 Source1 事件。
    • 定时器(Timer)到期。
    • 主线程专属的 GCD 异步派发任务 (dispatch_async(dispatch_get_main_queue(), ...) )。
    • RunLoop 自身被外部手动唤醒 (CFRunLoopWakeUp)。
  1. 处理唤醒事件:
  • 如果是被 Timer 唤醒,执行 Timer。
    • 如果是被 GCD 唤醒,执行主线程 GCD 任务。
    • 如果是被 Source1 唤醒,执行 Source1 事件。
  1. 判断是否退出:根据条件判断是否继续循环,满足以下任一条件则退出 RunLoop (kCFRunLoopExit):
  • RunLoop 的 Mode 变为 Empty(没有任何 Source/Timer/Observer)。
    • 外部手动调用了 CFRunLoopStop()。
    • 设定的超时时间(Timeout)已到。
  1. 循环进入下一圈:如果不退出,返回第 2 步重新开始循环。

如何判断并进入睡眠

RunLoop 判断并进入休眠的核心在于 “没有即时事件需要处理” 且 “不满足退出条件”。 [4]

// 伪代码简化:RunLoop 如何进入休眠do { // 1. 处理 Timer 和 Source0 // ...

// 2. 检查是否有 Source1 消息已经到达
if (__CFRunLoopServiceMachPort(..., &msg, 0)) { 
    goto handle_msg; // 如果有,直接去处理,不休眠
}

// 3. 准备进入休眠,通知观察者
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);

// 4. 调用 mach_msg 真正进入休眠
// 此时线程完全阻断,不会消耗 CPU 资源
mach_msg(&msg, MACH_RCV_MSG|MACH_RCV_TIMEOUT, ..., timeout, wakeUpPort, ...); 

// 5. 线程被唤醒,继续向下执行
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);

handle_msg: // 6. 处理消息 // ...

} while (retVal == 0);

1. 触发休眠的“前置判断”

在进入 do-while 循环内部后,RunLoop 会先按顺序处理掉当前的 Timer 和 Source0 任务。处理完毕后,它会发起一次不等待的检测:检查注册的 Mach Port 中有没有 Source1 消息。

  • 如果发现有消息:直接跳转到 handle_msg 逻辑,不进入休眠。
  • 如果没有任何消息:代表当前线程已经完全没有任务可做,RunLoop 决定让线程进入休眠。 [4]

2. 休眠的实现机制(内核态阻断)

RunLoop 的休眠与单纯的 while(true); 死循环完全不同。死循环会占满 CPU。RunLoop 调用的是操作系统的底层 API mach_msg()。 [1, 5]

  • 当执行到 mach_msg() 时,程序从用户态切换到内核态。
  • 操作系统内核会将当前线程的状态改为等待状态(Waiting),并收回该线程对 CPU 的使用权。
  • 线程在内核中会一直卡在 mach_msg() 这一行,直到被特定的 Mach Port 消息刺激。 [1]

3. 退出休眠的四个“闹钟”

在休眠期间,内核会持续监控发往该线程的端口。只有当满足以下四个条件之一时,内核才会重新唤醒线程,让 mach_msg() 成功返回:

  • timerPort 触发:定时器时间到了。
  • mainQueuePort 触发:有 dispatch_async 到主线程的任务需要执行。
  • wakeUpPort 触发:其他线程手动调用了 CFRunLoopWakeUp()。
  • 普通的 Source1 端口触发:接收到了系统内核事件(如屏幕触摸)。 [2, 6]

如果想更深一步研究 RunLoop 的线程保活、自动释放池释放时机 或卡顿监控,可以告诉我,我为你提供对应的代码方案。 [7, 8]

什么是 Source0 和 Source1?

在 Core Foundation 中,RunLoop 的事件源(Input Sources)被分成了两大类:Source0 和 Source1。它们的核心区别在于是否基于 Mach Port(内核通信机制)。

1. Source0(非基于 Mach Port)

  • 特点:它不具备直接唤醒线程的能力。
  • 原理:它完全工作在用户态。如果一个 Source0 事件发生了(比如用户点击了一个按钮),它必须先被手动标记为待处理状态(CFRunLoopSourceSignal),然后手动唤醒 RunLoop(CFRunLoopWakeUp),RunLoop 才能在下一次循环中处理它。
  • 常见场景:
  • UI 事件(如屏幕点击 UIButton、手势识别)。
    • performSelector:onThread:withObject:。

2. Source1(基于 Mach Port)

  • 特点:它具备直接唤醒线程的能力。
  • 原理:它由底层内核(Mach Kernel)和 Mach Port 驱动。当其他进程或系统底层内核向该线程的 Mach Port 发送消息时,内核会自动把该线程从休眠状态拉起/唤醒,并直接交付给 RunLoop 去处理。
  • 常见场景:
  • 硬件事件(如屏幕触摸:硬件物理受压 -> 触发系统硬件驱动 -> 通过 Mach Port 发送给应用进程 -> 触发 Source1)。
    • 线程间通信(基于 NSMachPort 的底层跨线程消息传递)。

为什么在第 5 步要检测 Source1?

我们先回顾一下之前提到的第 4、5、6、7 步:

  • 第 4 步:处理 Source0(用户态事件)。
  • 第 5 步:检测 Source1。如果有,直接跳过休眠,去第 9 步处理消息。
  • 第 6 步:通知 Observer 准备休眠。
  • 第 7 步:进入休眠(调用 mach_msg 阻塞)。

为什么要在这里特意加一个“第 5 步”的检测?

为了防止“刚准备睡,其实任务已经来了”的尴尬情况,从而提高执行效率。 在第 4 步处理 Source0 的过程中,线程正在忙着执行代码。在这个极短的时间差里,系统底层可能刚好派发了一个紧急的 Source1 内核事件(比如另一个线程发来了一条紧急消息,或者屏幕刚好被按下了)。 由于 RunLoop 马上就要走到第 7 步去调用 mach_msg() 睡觉了,如果在第 5 步不做一个“不等待的快速检查(Non-blocking Check)”,RunLoop 就会直接进入休眠。 如果直接进入休眠,虽然这个 Source1 事件最终也会在第 7 步把线程唤醒,但这个过程会经历:准备休眠 -> 通知观察者要睡觉了 -> 调用内核函数 -> 内核发现有消息立马唤醒 -> 通知观察者醒了。 这一整套“躺下又立刻爬起来”的操作涉及到用户态与内核态的上下文切换,以及大量的 Observer 回调通知,非常消耗 CPU 性能。

总结第 5 步的逻辑:

第 5 步是一个顺手瞅一眼的操作:

“反正我马上要处理完手头的 Source0 了,在正式躺下睡觉之前,我先顺便看一眼内核端口(Source1)。如果发现里面已经有现成的信件(消息)到了,那我就别折腾去睡觉了,直接拿着信件去第 9 步处理,省去了高昂的休眠和唤醒开销。”


如果你想了解这些事件源在实际开发中的应用,我可以为你分享:

  • 如何通过 Source1 (Mach Port) 实现高精度的线程间保活和通信?
  • 怎么通过监控 RunLoop 状态来做 App 的卡顿检测(APM)?

[1] blog.ibireme.com [2] leylfl.github.io [3] www.cnblogs.com [4] juejin.cn [5] juejin.cn [6] blog.csdn.net [7] zhuanlan.zhihu.com [8] smallbei.com