RunLoop中级

当前环境: Xcode10.0 Swift4.2 iOS SDK 12.1

RunLoop 对外的接口

在 CoreFoundation 里面关于 RunLoop 有 5 个类:

  1. CFRunLoopRef:

    运行循环,主线程默认开启,子线程需手动开启。

  2. CFRunLoopModeRef:

    其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:

    一个RunLoop 是有 N 多个 Mode 的,只是图片大小原因。如果你想看苹果更多 Mode: http://iphonedevwiki.net/index.php/CFRunLoop

    一个 RunLoop 包含 若干个 Mode,每个 Mode 又包含 若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个 Mode 被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

  3. CFRunLoopSourceRef:

    CFRunLoopSourceRef 是事件产生的地方。Source 有两个版本:Source0 和 Source1。

    Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。

    Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

  4. CFRunLoopTimerRef:

    是基于时间的触发器,它和 NSTimer 是 toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行那个回调。

    另外: 平时我们在主线程执行的 Timer,直接就可以工作是因为系统封装了,将它加入到了主线程的 RunLoop,所以才会工作的。

    但是一旦我们在子线程重新创建了 RunLoop 要将 Timer 加入到运行中的 RunLoop 中。

  5. CFRunLoopObserverRef:

    是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

    上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,可以查看 RunLoop 实现源码,不进入循环。

RunLoop 深入了解 Mode

CFRunLoopMode 和 CFRunLoop 的结构大致如下:

这里有个概念叫 “CommonModes”:一个 Mode 可以将自己标记为”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有 Mode 里。

应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个 TableView 时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。

有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入到顶层的 RunLoop 的 “commonModeItems” 中。”commonModeItems” 被 RunLoop 自动更新到所有具有”Common”属性的 Mode 里去。

所以:

CFRunLoop 对外暴露的管理 Mode 接口只有下面 2 个:

Mode 暴露的管理 mode item 的接口有下面几个:

你只能通过 mode name 来操作内部的 mode,当你传入一个新的 mode name 但 RunLoop 内部没有对应 mode 时,RunLoop 会自动帮你创建对应的 CFRunLoopModeRef。对于一个 RunLoop 来说,其内部的 mode 只能增加不能删除。

苹果公开提供的 Mode 有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 和 UITrackingRunLoopMode,你可以用这两个 Mode Name 来操作其对应的 Mode。

同时苹果还提供了一个操作 Common 标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),你可以用这个字符串来操作 Common Items,或标记一个 Mode 为 “Common”。使用时注意区分这个字符串和其他 mode name。

RunLoop 的内部逻辑

根据苹果在文档里的说明,RunLoop 内部的逻辑大致如下:

其内部代码整理如下 (太长了不想看可以直接跳过去,后面会有说明):

可以看到,实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。

RunLoop 的底层实现

从上面代码可以看到,RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。为了解释这个逻辑,下面稍微介绍一下 OSX/iOS 的系统架构。

苹果官方将整个系统大致划分为上述 4 个层次: 应用层包括用户能接触到的图形应用,例如 Spotlight、Aqua、 SpringBoard 等。 应用框架层即开发人员接触到的 Cocoa 等框架。 核心框架层包括各种核心框架、OpenGL 等内容。 Darwin 即操作系统的核心,包括系统内核、驱动、Shell 等内容,这一层是开源的,其所有源码都可以在 opensource.apple.com 里找到。

我们在深入看一下 Darwin 这个核心的架构:

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。 XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。 BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。 IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

Mach 本身提供的 API 非常有限,而且苹果也不鼓励使用 Mach 的 API,但是这些 API 非常基础,如果没有这些 API 的话,其他任何工作都无法实施。在 Mach 中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为”对象”。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。”消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。

Mach 的消息定义是在 <mach/message.h> 头文件的,很简单:

一条 Mach 消息实际上就是一个二进制数据包 (BLOB),其头部定义了当前端口 local_port 和目标端口 remote_port, 发送和接受消息是通过同一个 API 进行的,其 option 标记了消息传递的方向:

为了实现消息的发送和接收,mach_msg() 函数实际上是调用了一个 Mach 陷阱 (trap),即函数 mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg() 函数会完成实际的工作,如下图:

这些概念可以参考维基百科: System_call、[Trap(computing)](https://en.wikipedia.org/wiki/Trap(computing))。

RunLoop 的核心就是一个 mach_msg() (见上面代码的第 7 步),RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

关于具体的如何利用 mach port 发送信息,可以看看 NSHipster 这一篇文章,或者这里的中文翻译

总结

  1. RunLoop 内部结构,一个 RunLoop 对象是由多个 Mode 组成的,每个 Mode 又有多个 Timer、多个Source、多个 Observer 组成。

  2. CommonMode 是一种特殊的 Mode,它不是真实的 Mode,如果我们把 Timer,Source,Timer 添加在CommonMode 下,当操作系统切换 App 的 RunLoop 的 Mode 时,CommonMode 会默认添加到切换到的 Mode 下面,前提是:切换到的 Mode 是标记为 Common 的 Mode。

  3. RunLoop 的实现执行步骤:

    1. 通知 Observer,准备进去 RunLoop 循环。

    2. 通知 Observer,准备处理 Timer。

    3. 通知 Observer,准备处理 Source0。

    4. 处理 Source0。

    5. 如果有 Source1 跳到第 9 步骤。

    6. 通知 Observer,线程即将休眠。

    7. 休眠等待唤醒。 (Source0, Timer, 外部手动唤醒)

    8. 通知 Observer,线程刚被唤醒。

    9. 处理唤醒收到的消息,之后跳会 2。

    10. 通知 Observer,即将退出 RunLoop。

  4. RunLoop 线程休眠等待外部唤醒,使用的是底层 Mach 框架实现。

本文参考: https://blog.ibireme.com/2015/05/18/runloop/ https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html

最后更新于

这有帮助吗?