runLoop 虽然平时很少用到,但是面试的时候问的多啊。那我也就来了解一下 runLoop 的原理。
概念
什么是RunLoop
以下是一个 iOS 程序的 main 函数:
1 | int main(int argc, char * argv[]) { |
main 函数是程序的入口,那么为什么程序执行完毕后没有退出呢?因为 RunLoop,使线程循环,能够随时处理事件但并不退出。这种方式在各个框架中都有实现,比如 Node.js 的事件处理,比如 Windows 程序的消息循环,关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。
上面代码中 UIApplicationMain()
方法在这里不仅完成了初始化我们的程序并设置程序 Delegate 的任务,而且随之开启了主线程的 RunLoop,开始接受处理事件。这样我们的应用就可以在无人操作的时候休息,需要让它干活的时候又能立马响应。流程图如下所示:
在 OS X/iOS 系统中,提供了两个这样的对象:
- CFRunLoopRef:是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
- NSRunLoop:是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。
先来一张 RunLoop 机制关系图总览:
RunLoop与线程的关系
苹果不允许直接创建 RunLoop,提供了两个获取函数,CFRunLoopRef 的获取方法为 CFRunLoopGetMain()
, CFRunLoopGetCurrent()
。NSRunLoop 的获取方法是 currentRunLoop
,mainRunLoop
。NSRunLoop 对象可以通过 getCFRunLoop
方法获得 CFRunLoopRef 对象。CFRunLoopRef 的内部逻辑如下:
1 | /// 全局的 Dictionary,key 是 pthread_t, value 是 CFRunLoopRef |
从上面的代码可以看出,线程和 RunLoop 之间是一一对应的(这也就解释了前面关系图中 CFRunLoop 和 Thread 连线中的两个1
的意义),其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有,所以一个子线程,你想要它有 RunLoop 就必须在该线程内调用 NSRunLoop *runLoop =[NSRunLoop currentRunLoop]
。如果你想启动这个 RunLoop,则要继续调用 [runLoop run]
。但是注意,一般不需要开启子线程的 runLoop,因为这会让子线程一直存在,不会回收。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。
RunLoop对外接口
在 CoreFoundation 里面关于 RunLoop 有5个类:
- CFRunLoopRef
- CFRunLoopModeRef
- CFRunLoopSourceRef
- CFRunLoopTimerRef
- CFRunLoopObserverRef
其中,CFRunLoopModeRef 类并没有对外暴露,不能直接得到其对象,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:
一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。
接口类型
RunLoop的Source
RunLoop 对象处理的事件源分为两种:Input sources 和 Timer sources(分别对应上面的 CFRunLoopSourceRef 和 CFRunLoopTimerRef,统称为事件源):
- Input sources:用分发异步事件,通常是用于其他线程或程序的消息,比如:
performSelector:onThread:
- Timer sources:用分发同步事件,通常这些事件发生在特定时间或者重复的时间间隔上,比如:
[NSTimer scheduledTimerWithTimeInterval:target:selector:]
下图是苹果官方的一张图,展示了 RunLoop 的概念结构及各种事件源
Input Source
Input Source 也就是 CFRunLoopSourceRef 有两个版本:Source0(Custom Input Sources)和 Source1(Port-Based Sources):
- Source0 只包含了一个回调(函数指针),非基于Port的,它并不能主动触发事件。主要处理触摸事件,performSelectors 等
- Source1 包含了一个 mach_port 和一个回调(函数指针),基于Port的,被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。
Time Source
基于时间的触发器,它和 NSTimer 是 Toll-Free Bridging 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行那个回调。
Foundation 中 NSTimer Class 提供了相关方法来设置 Timer sources。需要注意的是除了 scheduledTimerWithTimeInterval
开头的方法创建的 Timer 都需要手动添加到当前 RunLoop 中。(scheduledTimerWithTimeInterval
创建的 Timer 会自动以 Default Mode 加载到当前 RunLoop中。)
RunLoop的Mode
RunLoop Mode 是指要被监听的事件源(包括 Input sources 和 Timer sources)的集合 + 要被通知的 run-loop observers 的集合。每一次运行自己的 RunLoop 时,都需要显示或者隐示的指定其运行于哪一种 Mode。在设置 RunLoop Mode 后,你的 RunLoop 会自动过滤和其他 Mode 相关的事件源,而只监视和当前设置 Mode 相关的源(通知相关的观察者)。大多数时候,RunLoop 都是运行在系统定义的默认模式上。
系统默认定了一下几个 mode:
- kCFRunLoopDefaultMode:App:App的默认 Mode,通常主线程是在这个 Mode 下运行的
- UITrackingRunLoopMode:界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
- UIInitializationRunLoopMode:在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用(私有)
- GSEventReceiveRunLoopMode:接受系统事件的内部 Mode,通常用不到
- kCFRunLoopCommonModes:这是一个占位的 Mode,包含上面的 kCFRunLoopDefaultMode 以及 UITrackingRunLoopMode
应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入 CommonModes 中。
Mode 暴露的管理 mode item 的接口有下面几个:
1 | CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName); |
RunLoop的Observers
对比上面说的事件源,它们是在特定的同步事件或异步事件发生时被触发,RunLoop Observers 就不一样了,它是在 RunLoop 执行自己的代码到某一个指定位置时被触发。我们可以用 RunLoop Observers 来跟踪到这些事件:
- 进入 RunLoop 的时候
- RunLoop 将要处理一个 Timer source 的时候
- RunLoop 将要处理一个 Input source 的时候
- RunLoop 将要休眠的时候
- RunLoop 被唤醒,并准备处理唤醒它的事件的时候
- RunLoop 将要退出的时候
使用方式
Input Source
Timer Source
Observers
这几个方面看起来太麻烦了,而且真的用不到,所以不想看了。=。=55555。贴几个介绍如何使用的博客吧,如果真的要用到了再看。
RunLoop深度探究(五)
RunLoop
走进Run Loop的世界 (一):什么是Run Loop?
iOS多线程编程指南(三)Run Loop
相关使用 Demo 也放到 github 上了。
实现
处理过程
可以看到,实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。
RunLoop 的底层实现
从上面代码可以看到,RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()
。为了实现消息的发送和接收,mach_msg()
函数实际上是调用了一个 Mach 陷阱 (trap),即函数 mach_msg_trap()
,陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap()
时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg()
函数会完成实际的工作,如下图:
RunLoop 的核心就是一个 mach_msg()
,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap()
这个地方。
关于具体的如何利用 mach port 发送信息,哈哈,这个就不是我研究的东西了。
应用
苹果使用 RunLoop 实现了诸多功能
AutoreleasePool
Autorelease 对象什么时候释放?答案当然不是“当前作用域大括号结束时释放”。在没有手加 Autorelease Pool 的情况下,Autorelease 对象是在当前的 runloop 迭代结束时或者线程销毁时释放的,当有 runloop 的情况下,系统在每个 runloop 循环中都加入了自动释放池 Push 和 Pop。
什么情况下会自动创建 autoreleasepool?
- 手动调用了 autorelease 方法的对象,比如
__autorelease Person *p = [[Person alloc] init]
,会在当前线程插入创建一个AutoreleasePool
。- 调用非 alloc,new 开头方法的时候,比如
NSString *str = [NSString stringwithForamt:@"&ld", 123123123123123]
,会将对象加入AutoreleasePool
如果是 ARC 下编译器自动插入的
[person release]
,则会直接减少引用计数。而不会延迟释放
原理
ARC下,我们使用 @autoreleasepool{}
来使用一个 AutoreleasePool,随后编译器将其改写成下面的样子:
1 | void *context = objc_autoreleasePoolPush(); |
而这两个函数都是对 AutoreleasePoolPage
的简单封装,所以自动释放机制的核心就在于这个类。
AutoreleasePoolPage
是一个 C++ 实现的类
- AutoreleasePool 并没有单独的结构,而是由若干个 AutoreleasePoolPage 以双向链表的形式组合而成(分别对应结构中的 parent 指针和 child 指针)
- AutoreleasePool 是按线程一一对应的(结构中的 thread 指针指向当前线程)
- AutoreleasePoolPage 每个对象会开辟4096字节内存(也就是虚拟内存一页的大小),除了上面的实例变量所占空间,剩下的空间全部用来储存 autorelease 对象的地址
- 上面的
id *next
指针作为游标指向栈顶最新 add 进来的 autorelease 对象的下一个位置 - 一个 AutoreleasePoolPage 的空间被占满时,会新建一个 AutoreleasePoolPage 对象,连接链表,后来的 autorelease 对象在新的 page 加入
所以,若当前线程中只有一个 AutoreleasePoolPage 对象,并记录了很多 autorelease 对象地址时内存如下图:
图中的情况,这一页再加入一个 autorelease 对象就要满了(也就是 next 指针马上指向栈顶),这时就要执行上面说的操作,建立下一页 page 对象,与这一页链表连接完成后,新page的next指针被初始化在栈底(begin的位置),然后继续向栈顶添加新对象。所以,向一个对象发送 autorelease
消息,就是将这个对象加入到当前 AutoreleasePoolPage 的栈顶 next 指针指向的位置。
每当进行一次 objc_autoreleasePoolPush
调用时,runtime 向当前的 AutoreleasePoolPage 中 add 进一个哨兵对象,值为0(也就是个 nil),那么这一个 page 就变成了下面的样子:
objc_autoreleasePoolPush
的返回值正是这个哨兵对象的地址,被 objc_autoreleasePoolPop
(哨兵对象)作为入参,于是:
- 根据传入的哨兵对象地址找到哨兵对象所处的 page
- 在当前 page 中,将晚于哨兵对象插入的所有 autorelease 对象都发送一次
- release
消息,并向回移动 next 指针到正确位置 - 补充2:从最新加入的对象一直向前清理,可以向前跨越若干个 page,直到哨兵所在的 page
刚才的 objc_autoreleasePoolPop
执行后,最终变成了下面的样子:
事件响应
苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()
。
当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。 SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的 App 进程。随后苹果注册的那个 Source1 就会触发__IOHIDEventSystemClientQueueCallback()
回调,并调用 _UIApplicationHandleEventQueue()
进行应用内部的分发。
_UIApplicationHandleEventQueue()
会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。
手势识别
当上面的 _UIApplicationHandleEventQueue()
识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。
苹果注册了一个 Observer 监测 BeforeWaiting (Loop 即将进入休眠) 事件,这个 Observer 的回调函数是 _UIGestureRecognizerUpdateObserver()
,其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行 GestureRecognizer 的回调。
当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。
界面更新
当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay
方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
这个函数内部的调用栈大概是这样的:
1 | ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv() |
定时器
参见 iOS 定时器 这一篇。几乎所有定时器都要求 runloop 开启,且将自身加入到 runloop 中。
关于网络请求
iOS 中,关于网络请求的接口自下至上有如下几层:
- CFSocket
- CFNetwork ->ASIHttpRequest
- NSURLConnection ->AFNetworking
- NSURLSession ->AFNetworking2, Alamofire
NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。
AFNetworking
AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:
1 | + (void)networkRequestThreadEntryPoint:(id)__unused object { |
RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run]
之前先创建了一个新的 NSMachPort 添加进去了。通常情况下,调用者需要持有这个 NSMachPort (mach_port)
并在外部线程通过这个 port 发送消息到 loop 内;但此处添加 port 只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。
1 | - (void)start { |
当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..]
将这个任务扔到了后台线程的 RunLoop 中。
RN 中的线程保活
RN 会开启一个 jsThread,用于通过 performSelector:onThread:withObject:waitUntilDone:
方法将异步执行任务。
RN 中开启runloop的方式和 AF 中的不同:
1 | // in some func |
通过添加 source 给 runloop 的方式使线程保活。通过 CFRunLoopRunInMode()
方法开启 runloop 循环。runloop 开启后,之后的代码时不会执行的,所以只有当 runloop 停下来之后才会进行 while 判断。
RN 这种方式是可以取消 runloop 循环的,而 AF 的则不行,因为 RN 为 Runloop 设置了一个无穷大的过期时间,而 AF 的 runloop 则是会循环调用,取消一次下次还会开启。具体可见深入研究 Runloop 与线程保活
检测卡顿
卡顿的发生可以通过一次 runloop 从开始到结束的时间间隔来间接判断。
注册 observer 记录 runloop 开启的时间,并且在 runloop 结束的时候清空,然后创建一个子线程,每隔一定时间去检测当前时间和 runloop 开启时记录的时间是否大于某一个阈值。
当大于某个阈值的时候表明产生了卡顿,记录下卡顿时候的堆栈
问题
- runloop 有什么用?
- runloop 和 线程的关系是什么?runloop 是如何获取的?
- runloop 与其 mode 、Source/Timer/Observer 的关系?
- runloop 有哪几种source?
- runloop 的 input Source 有什么用?有哪两个版本?区别是什么?
- runloop 有哪几种 mode?区别是什么?
- runloop 的 observer 是干嘛的?
- runloop 的处理过程是什么?
- autoreleasepool 与 线程的关系?
- autoreleasepool 的实现原理?
- 手势识别、界面更新的基本过程是什么?