Tino又想吃肉了

RunLoop

Word count: 2.7kReading time: 9 min
2021/11/29

根据源码大概整理了一次RunLoop的底层原理和运行机制…(因为之前没找着源码)

Runloop是iOS系统中的事件循环,它保证了我们的程序不会在main函数执行完后就被退出,(线程保活),可以粗糙地理解成一个while(true)的循环,但它的实现并没有那么简单。实际上它是一个NSRunLoop的对象,在对象内部维护了一个事件循环,当没有事件要处理时,Runloop将线程控制器交给系统,即从用户态->内核态,当被唤醒时又从内核态->用户态,实现了在休眠时不占用CPU资源。

基础概念

正如前面的引言提到的,一般程序运行完毕后就会自动退出,比如当我们在Xcode中新建一个macOS的CommandLine项目,当main函数return后程序即运行完毕并退出。
然而,我们的APP显然不能这样,所以我们要让APP可以随时响应而不退出。这样的机制通常使用事件循环(Event Loop)来实现,在iOS中即为Runloop。
与Runtime不同的是,Runloop是一个可实际获取的对象,对应Foundation框架的NSRunloop类与Core Foundation框架的CFRunloop,NSRunloop是基于CFRunloop的上层封装。

Runloop核心

前面我们提到,Runloop可以简单地概括成一个while(true)的循环,但实际上这样的实现会使CPU进行大量无谓的空转。所以,Runloop机制的核心就是保证线程在有events需要处理时能唤醒,在没有events时能进行休眠。
而实现真正的休眠,是靠没有events时从用户态->内核态实现的,当有事件时,系统内核通过mach_msg()或者mach port方法将事件发送给对应的Runloop,Runloop收到事件后从休眠状态切换到唤醒状态,并从内核态->用户态

如何唤醒Runloop

Source

Source是Runloop中一个重要的概念,它代表了在上文中提到的events。
在Runloop中,Source分为两类

  • Source0:该类Source是App的内部事件,不具有独立唤醒Runloop的能力。一个Source0需要被处理时,他需要被CFRunLoopSourceSignal()函数标记为待处理,并调用CFRunLoopWakeUp函数来唤醒Runloop,CFRunLoopWakeUp函数内部通过一个_wakeUpPort成员变量来唤醒Runloop,推测该变量是一个mach port,Runloop只有通过mach port与mach_msg()才可以唤醒。唤醒后通过调用__CFRunLoopDoSources0函数来处理Source0事件,并在之后将该事件标记为已处理。
  • Source1:该类Source是由硬件事件生成的Source,如触摸、摇晃、旋转等。此类Source可唤醒Runloop。

Timer

使用NSTimer API注册执行的任务,就属于这一类

Observer

某个Observer可以监听runloop的状态变化,并作出反应

Runloop与线程的关系

  • Runloop与线程是一一对应的,且子线程的Runloop无法获取到其他子线程的Runloop,一一对应的关系以key-value存储在一个全局字典里。在CFRunloop中,只有CFRunloopGetMainCFRunloopGetCurrent两个函数可以获取到Runloop
  • 主线程会自动创建Runloop以响应事件,但子线程并不会自动创建Runloop。由于NSTimer对象需要加入到Runloop中的mode,所以在子线程中调用performSel afterdelay系列方法并不会被调用,因为这些方法都会注册一个NSTimer到Runloop中,而子线程默认情况下是没有Runloop的。
  • Runloop会在线程销毁时销毁

CFRunLoopMode

mode是管理着Runloop与source/timer/observer之间的桥梁,在一开始会注册五个mode

  • kCFRunLoopDefaultMode: App的默认 Mode,通常主线程是在这个 Mode 下运行的。
  • UITrackingRunLoopMode: 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。默认NSTimer是被加入到default mode中的,所以当滑动时Runloop切换到tracking mode,这时default mode中的Timer回调不会被调用,所以NSTimer的精度没有CADisplayLinker高。
  • UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
  • GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。
  • kCFRunLoopCommonModes: 这是一个占位的 Mode,没有实际作用。
  • 如果需要将事件加入到多个mode中,则将它注册到commonMode中,该mode实际上是多个mode的集合。
  • 出于将source/timer/observer分隔开的目的,RunLoop一次只能运行在一个mode下,当运行时在RunLoop的currentMode属性中会标记当前运行的mode。而当要切换mode时,RunLoop必须先退出,并选中一个mode重新进入,达到切换mode的目的。在切换mode时,被加入到commonModes中的事件会被拷贝一次到运行的mode中。

源码验证

__CFRunLoop

RunLoop在Core Foundation中对应的类是CFRunLoopRef,其对应的结构是__CFRunLoop
-w795

可以看到结构体中包含了上文提到的mode,对应CFRunLoopModeRef,其结构如下图
-w778

在这里我们看到了上文提到的source,observer,timer,这是能唤醒RunLoop的三种类型,当然能独立唤醒RunLoop的只有sources1. Mode与source,observer,timer的关系如下图
-w455

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

_CFRunloopGet0()

在Core Foundation中,提供了两个接口来获取Runloop,分别是CFRunloopGetMainCFRunloopGetCurrent,先来看看他们的源码实现。
-w769

可以看到,两个函数实际上都是调用了_CFRunLoopGet0()方法,方法的参数是线程pthread_t。在CFRunLoopGetCurrent()中,如果当前线程的Runloop已存在,那么会在_CFGetTSD()函数中找到并返回。

接下来继续看看_CFRunLoopGet0()函数,显然这是获取RunLoop的关键函数。先看一下第一部分。
-w741

这里注意到__CFRunLoops变量,它是一个CFMutableDictionaryRef类型的字典,key为线程,value为CFRunLoopRef

-w499

在第一次进入时,_CFRunLoopGet0()函数先创建了CFMutableDictionaryRef类型的字典变量,显然这个就是全局RunLoop表了。这里也印证了前文提到的线程与RunLoop一一对应的结论。
接着我们可以看到,这里调用__CFRunLoopCreate()函数创建了主线程的RunLoop,所以RunLoop是直到被获取时才会被创建,如果不获取便不会被创建。RunLoop创建后以key为线程,value为CFRunLoopRef存储在全局RunLoop表中。

接着看看第二部分,如果不是第一次进入则是走到这个流程的代码。

-w803

首先定义了一个loop变量,从全局RunLoops表__CFRunLoops中查找线程对应的RunLoop,如果找到了则返回该CFRunLoopRef。
如果在__CFRunLoops中没有查找到该线程对应的RunLoop,则调用__CFRunLoopCreate()函数创建RunLoop,并添加到__CFRunLoops中。

__CFRunLoopSource

在mode中,Source对应__CFRunLoopSource结构体。在上文我们知道Source是分为Source0和Source1的,而他们其实都是__CFRunLoopSource,在结构体中,以CFRunLoopSourceContext来区分不同的Source,其中的version0version1分别对应source0source1

Source0与Source1

上图即为CFRunLoopSourceContextCFRunLoopSourceContext1的结构定义,可以看到CFRunLoopSourceContext1与CFRunLoopSourceContext0一个明显的区别就是CFRunLoopSourceContext1具有一个mach_port_t类型的变量。从这里就可以知道为什么Source0不可以独立唤醒RunLoop而Source1可以,在前文中我们提到只有mach portmach_msg()可以独立唤醒RunLoop。

__CFRunLoopCreate(_CFThreadRef t)

该函数被用来创建RunLoop,在_CFRunloopGet0()函数中若获取不到线程对应的RunLoop则调用该函数来创建一个新RunLoop。可以看到入参是一个_CFThreadRef类型的变量,代表着线程,因为线程与RunLoop是一一对应的。

__CFRunLoopCreate

在该函数中进行了对__CFRunLoop结构的分配内存与初始化,可以看到实际上是调用了_CFRuntimeCreateInstance()函数创建了CFRunLoopRef类型的实例,从该函数的名字以及代码可以看到,其实RunLoop的创建是利用Runtime的动态创建类的特性来创建的。

CFRunLoopWakeUp

CFRunLoopWakeUp

该函数在CFRunLoop中用来唤醒RunLoop,可以看到在TARGET_OS_MAC下,函数的关键调用是__CFSendTrivialMachMessage()函数,该函数使用了CFRunLoop中的_wakeUpPort属性。

__CFSendTrivialMachMessage

可以看到在__CFSendTrivialMachMessage函数内部,的确是使用了mach_msg的方式来给mach port发送信息,以达到唤醒RunLoop的目的。

Runloop应用之事件响应

从用户触摸屏幕,到我们的app响应这个触摸,中间其实需要经过多步的处理,并且涉及到的是硬件->软件的通信。之前关于Runloop的Source中提到Source1是一类由硬件生成的事件,那么以触摸事件为例子,看看Runloop是怎么处理事件响应的。

事件响应链

首先我们来梳理一下事件响应链。

  1. 用户触发事件
  2. 系统将事件转交到对应 APP 的事件队列
  3. APP 从消息队列头取出事件
  4. 交由 Main Window 进行消息分发
  5. 找到合适的 Responder 进行处理,如果没找到,则会沿着 Responder chain 返回到 APP 层,丢弃不响应该事件。

从上面的五步,我们可以看到其实1和2是独立于app外的,需要涉及到硬件,从第3步开始事件才被发送到app内进行处理。

用户触发事件

这一步始于用户点触屏幕,此时系统的IOKit.framework会生成一个IOHIDEvent事件,该事件会被Spring board接收。

事件转发到app

IOHIDEvent被接收后,将会通过mach port转发给对应的app进程。而这时,app中一个名为com.apple.uikit.eventfetch-thread的线程中已经注册了一个Source1,其回调是__IOHIDEventSystemClientQueueCallback()函数。

消息队列

IOHIDEvent被mach port转发到对应app进程后,就唤醒了com.apple.uikit.eventfetch-thread线程的Runloop,并调用了Source1对应的__IOHIDEventSystemClientQueueCallback()回调。该回调从消息队列中取出event,并将main thread__handleEventQueue对应的Source0设置为待处理状态,同时唤醒main thread的Runloop。

消息分发

此时就开始调用__eventQueueSourceCallback函数进行消息分发,_UIApplicationHandleEventQueue会把IOHIDEvent封装成UIEvent并分发出去,开始hitTest等函数的调用,并且TouchBegan/end/move/cancel等函数都是在该回调中调用的。

In short

用户触发事件, IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收,SpringBoard 会利用 mach port,产生 source1,来唤醒目标 APP 的 com.apple.uikit.eventfetch-thread 的 RunLoop。Eventfetch thread 会将 main runloop 中 __handleEventQueue 所对应的 source0 设置为 signalled = Yes 状态,同时唤醒 main RunLoop。mainRunLoop 则调用 __eventQueueSourceCallback 进行事件队列处理。

Runloop与autorelease pool

Runloop会注册几个监听自己状态变化的回调,当Runloop进入一次事件循环时,会调用AutoreleasePoolPage::push()方法创建一个新的自动释放池并传入哨兵对象,而在即将退出或者休眠时则会将自动释放池中的对象pop出。

CATALOG
  1. 1. 基础概念
  2. 2. Runloop核心
  3. 3. 如何唤醒Runloop
    1. 3.1. Source
    2. 3.2. Timer
    3. 3.3. Observer
  4. 4. Runloop与线程的关系
  5. 5. CFRunLoopMode
  6. 6. 源码验证
    1. 6.1. __CFRunLoop
    2. 6.2. _CFRunloopGet0()
    3. 6.3. __CFRunLoopSource
    4. 6.4. __CFRunLoopCreate(_CFThreadRef t)
    5. 6.5. CFRunLoopWakeUp
  7. 7. Runloop应用之事件响应
    1. 7.1. 事件响应链
    2. 7.2. 用户触发事件
    3. 7.3. 事件转发到app
    4. 7.4. 消息队列
    5. 7.5. 消息分发
    6. 7.6. In short
  8. 8. Runloop与autorelease pool