引用
Apple文档 -- Run Loops
深入理解RunLoop
iOS刨根问底-深入理解RunLoop
首先感谢ibireme及KenshinCui两位大神对Run Loops做出的探讨和总结. 本文将大量地直接或间接引用自以上三个出处的内容, 是对这三篇文章的一个理解和整合. 如需更加深入了解Run Loops, 还请单击上文引用链接.
目录
了解Run Loops
- 概念
- Run Loops与线程的关系
- CFRunLoop相关类
- Call out
- 运行流程
- 底层实现
- 什么时候使用
应用
- NSTimer
- AutoreleasePool
- UI更新
- NSURLConnection
了解Run Loops
一般来说, 我们开辟一个线程, 在任务执行完之后就会退出. 如果我们想反复执行线程里的这些任务, 可以使用 for / while / do while
等各种循环, 例如:
int loop()
{
initialize();
while (1) {
// do something
if (quit) break;
sleep(0.2);
}
return 0;
}
虽然这样简单方便, 但会有几个问题:
- 如何管理事件/消息
- 如何让线程在没有处理消息时休眠以避免资源占用
- 如何在有消息到来时立刻被唤醒
使用Run Loops能够解决这些问题.
概念
Run Loops (运行循环) 是与线程相关联的基础设施的一部分, 目的是在有工作要做时让线程忙, 而在没有工作时让线程进入睡眠状态.
iOS中的 Run Loops 指的分别是 Cocoa 中的 NSRunLoop
以及 CoreFoundation 中的 CFRunLoopRef
. 其中, CFRunloopRef是纯C的函数, 而NSRunloop仅仅是CFRunloopRef的OC封装, 并未提供额外的其他功能, 因此本文将直接讨论CF框架下的CFRunLoopRef.
NSRunLoop一般不被认为是线程安全的, 并且它的方法只应在当前线程的上下文中被调用. 您永远不要尝试在不同线程中调用同一个NSRunLoop对象的方法,因为这样做可能会导致意外结果。
Run Loops与线程关系
Run Loops是用来管理线程的, 包括线程的 循环运行 / 事件处理 / 唤醒 等等, 因此我们不能抛开线程来谈Run Loops.
- Run Loops与线程是一一对应的, 其关系是保存在一个全局的 Dictionary 里.
- 我们不能主动创建RunLoop, 只能通过CFRunLoopGetMain() 和 CFRunLoopGetCurrent()分别获取主线程和子线程的RunLoop.
- 主线程中, 系统会自动帮我们创建一个RunLoop; 而子线程中的RunLoop采用类似懒加载的机制, 即我们第一次去获取的时候才会创建, 如果不调用CFRunLoopGetCurrent()获取RunLoop, 那么就一直没有.
这两个函数内部的逻辑大概是下面这样 (CFRunloop开源):
/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
OSSpinLockLock(&loopsLock);
if (!loopsDic) {
// 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
loopsDic = CFDictionaryCreateMutable();
CFRunLoopRef mainLoop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
}
/// 直接从 Dictionary 里获取。
CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
if (!loop) {
/// 取不到时,创建一个
loop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, thread, loop);
/// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
_CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
}
OSSpinLockUnLock(&loopsLock);
return loop;
}
CFRunLoopRef CFRunLoopGetMain() {
return _CFRunLoopGet(pthread_main_thread_np());
}
CFRunLoopRef CFRunLoopGetCurrent() {
return _CFRunLoopGet(pthread_self());
}
CFRunLoop相关类
CFRunLoopRef
RunLoop的CF对象CFRunLoopModeRef
RunLoop的运行模式CFRunLoopSourceRef
各种输入源 (除计时器)CFRunLoopTimerRef
计时器源CFRunLoopObserverRef
观察者
CFRunLoopRef
即RunLoop的CF对象, 提供各种方法来管理RunLoop.
CFRunLoopModeRef
即CFRunLoopRef的运行模式. 每个CFRunLoopRef可以有多个CFRunLoopModeRef, 但在同一时刻有且仅有一种CFRunLoopModeRef.
几种常用的mode (或mode组合):
-
kCFRunLoopDefaultMode
默认mode -
UITrackingRunLoopMode
追踪ScrollView滚动时的状态 -
kCFRunLoopCommonModes
前两个mode的组合
ScrollView滚动时NSTimer不工作
主线程中注册了kCFRunLoopDefaultMode和UITrackingRunLoopMode这两个mode. 当有ScrollView发生滚动时, RunLoop从kCFRunLoopDefaultMode切换到UITrackingRunLoopMode, 此模式下计时器(NSTimer)将不工作.
CFRunLoopSourceRef
输入源Source将事件异步传递到线程, 事件的来源取决于输入来源的类型. 通常有两个类别: 基于端口的输入源
和自定义输入源
.
图中, 左边是线程循环, 右边是输入源. 输入源1~4分别为基于端口的源, 自定义源, 选择器源, 以及计时器源. 其中, 1和4都属于基于端口的源, 不同的是所有的计时器源都共用一个端口“Mode Timer Port”,而每个基于端口的源都有不同的对应端口. 2和3都属于自定义输入源, 可以理解为选择器源是官方为我们定义好的自定义源.
-
Port-Based Sources 基于端口的源
监视您的应用程序的Mach端口, 由内核自动发出信号. 关于Mach/内核/端口, 详见后文<底层实现>.
Cocoa和Core Foundation提供了内置支持, 用于创建基于端口的输入源.
例如
- 在Cocoa中,您根本不需要直接创建输入源, 而只需创建一个端口对象(NSPort), 然后使用端口对象的方法将该端口添加到运行循环中。端口对象为您处理所需输入源的创建和配置。
- 在Core Foundation中,您必须手动创建端口及其运行循环源。在这两种情况下,都使用与端口不透明类型(CFMachPortRef、CFMessagePortRef或CFSocketRef)关联的函数来创建适当的对象。
Custom Input Sources 自定义输入源
监视事件的定制源, 从另一个线程手动发出信号.
参见定义自定义输入源Cocoa Perform Selector Sources 选择器源 (属于自定义输入源)
该源可让您在任何线程上执行选择器. 与基于端口的源不同, 执行选择器源在执行选择器后将其自身从运行循环中删除.
注意: 在另一个线程上执行选择器时, 目标线程必须开启了RunLoop.
CFRunLoopTimerRef
计时器源, 属于端口输入源, 在将来的预设时间将事件同步传递到您的线程.
如果timer不支持当前RunLoop的模式, 则不会触发; 直到RunLoop切换到timer所支持的模式, 才会触发; 如果RunLoop没有运行, 则timer永远不会触发.
重复计时器会根据计划的触发时间(而不是实际的触发时间)自动重新计划自身. 例如,如果 timer 每5秒触发一次, 则即使实际触发时间被延迟, 计划的触发时间也将始终落在原始的5秒时间间隔上.如果触发时间延迟得太久, 以致错过了一个或多个计划的触发时间, 则 timer 将在错过的时间段内仅触发一次. 在错过了一段时间后触发后, 计时器将重新安排为下一个计划的触发时间.
CFRunLoopObserverRef
观察者 (监听器), 随时通知外部当前RunLoop的运行状态.
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
};
Call out
在开发过程中几乎所有的操作都是通过Call out进行回调的(无论是Observer的状态通知还是Timer、Source的处理),而系统在回调时通常使用如下几个函数进行回调(换句话说你的代码其实最终都是通过下面几个函数来负责调用的,即使你自己监听Observer也会先调用下面的函数然后间接通知你,所以在调用堆栈中经常看到这些函数):
static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();
例如在控制器的touchBegin中打入断点查看堆栈(由于UIEvent是Source0,所以可以看到一个Source0的Call out函数CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION调用)(图片来源):
运行流程
线程的运行循环都会处理事件源并为所有已注册的观察者生成通知. RunLoop 内部的逻辑大致如下 (图片来源):
Source0: 自定义输入源
Source1: 基于端口的输入源
可以看到, 实际上 RunLoop 内部是一个 do-while 循环. 当你调用 CFRunLoopRun() 时, 线程就会一直停留在这个循环里; 直到超时或被手动停止, 该循环才会退出.
底层实现
RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。为了解释这个逻辑,下面稍微介绍一下 OSX/iOS 的系统架构。图片来源
Darwin 即操作系统的核心, 包括系统内核、驱动、Shell 等内容. 我们再深入看一下 Darwin 这个核心的架构 (图片来源):
其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。
- XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。
- BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。
- IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。
在Mach中,进程、线程间的通信是以消息的方式来完成的,消息在两个Port之间进行传递(这也正是Source1之所以称之为Port-based Source的原因,因为它就是依靠系统发送消息到指定的Port来触发的)。消息的发送和接收使用
什么时候使用
我们只需在子线程创建的时候决定是否需要运行RunLoop (主线程中自动创建并运行). 当然, 我们无需在所有情况下都启动线程的运行循环. 例如, 如果使用线程执行一些很耗时的任务, 则可以避免启动RunLoop.
RunLoop用于需要与线程更多交互的情况. 例如:
- 使用端口或自定义输入源与其他线程进行通信
- 在线程上使用计时器
- 使用任何
performSelector
方法 - 保持线程执行定期任务
应用
NSTimer
NSTimer 是基于 RunLoop 运行的 (对应于CFRunloopTimerRef), 所以使用 NSTimer 之前必须注册到 RunLoop, 但是 RunLoop 为了节省资源并不会在非常准确的时间点调用定时器.
NSTimer 的创建通常有两种方式: 一种是timerWithXXX
, 另一种scheduedTimerWithXXX
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
二者最大的区别是, scheduedTimerWithXXX
除了创建一个定时器外会自动以NSDefaultRunLoopMode
添加到当前线程 RunLoop 中, 而不添加到 RunLoop 中的 NSTimer 是无法正常工作的.
AutoreleasePool
AutoreleasePool与RunLoop并没有直接的关系, 之所以将两个话题放到一起讨论最主要的原因是因为在iOS应用启动后会注册两个Observer管理和维护AutoreleasePool. 这两个Observer是和自动释放池相关的两个监听.
不妨在应用程序刚刚启动时打印currentRunLoop可以看到系统默认注册了很多个Observer,其中有两个Observer的callout如下:
{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = {type = mutable-small, count = 0, values = ()}}
{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = {type = mutable-small, count = 0, values = ()}}
第一个 Observer 监视的事件是 Entry (即将进入Loop), 它会回调objc_autoreleasePoolPush()创建自动释放池, 并向当前的AutoreleasePoolPage增加一个哨兵对象标志. 这个Observer的order是-2147483647, 优先级最高, 保证创建释放池发生在其他所有回调之前.
第二个 Observer 监视了两个事件: BeforeWaiting (准备进入休眠) 和 Exit (即将退出Loop) . BeforeWaiting时调用方法释放旧的池并创建新池;Exit时调用方法来释放自动释放池. 这个Observer的order是2147483647, 优先级最低, 保证其释放池子发生在其他所有回调之后。
在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。
UI更新
当在操作 UI 时, 比如改变了 Frame、更新了 UIView/CALayer 的层次时, 或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后, 这个 UIView/CALayer 就被标记为待处理, 并被提交到一个全局的容器去, 等待下一次RunLoop运行时更新UI. 但是如果当前正在执行大量的逻辑运算可能UI的更新就会比较卡.
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
NSURLConnection
NSURLConnection 启动以后就会不断调用delegate方法接收数据,这样一个连续的的动作正是基于RunLoop来运行。
一旦NSURLConnection设置了delegate会立即创建一个线程com.apple.NSURLConnectionLoader,同时内部启动RunLoop并在NSDefaultMode模式下添加4个Source0。其中CFHTTPCookieStorage用于处理cookie ;CFMultiplexerSource负责各种delegate回调并在回调中唤醒delegate内部的RunLoop(通常是主线程)来执行实际操作。
NSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程).