iOS RunLoop分析

804 阅读9分钟

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出。

写在最后

运行的主要函数CFRunLoopRun()有点长...留待下次分析

如有错漏,欢迎指出


Tino Wu
more at tinowu.top