RunLoop
我们知道NSTimer
是不精准的,如果NSTimer
当前所处的线程正在进行大数据处理(假设为一个大循环),NSTimer
本次执行会等到这个大数据处理完毕之后才会继续执行。这是因为我们创建的NSTimer
默认是被主线程添加到NSDefaultRunLoopMode模式下,一般情况,我们如果想要NSTimer
准确,可以将其添加到NSRunLoopCommonModes:
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
那么问题就来了,为什么能这么做呢?这就是我们今天的主角RunLoop了。
-
基本概念
-
RunLoop 与线程的关系
-
接口及其联系
-
调用流程
-
应用
1. 基本概念
RunLoop是与线程相关的基本基础设施的一部分。一个RunLoop是一个事件处理循环,用于调度工作并协调传入事件的接收。。目的是在有工作要做的时候保持线程的忙碌,闲置的情况下让线程休眠。
一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常的代码逻辑是这样的:
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}
这种模型通常被称作 Event Loop 。RunLoop就是采用的这种机制(通常所说的RunLoop指的是NSRunloop或者CFRunloopRef,CFRunloopRef是纯C的函数,而NSRunloop仅仅是CFRunloopRef的OC封装,并未提供额外的其他功能),安卓里面的Looper机制和此类似。可以理解为一种高级的循环,当有事件发生时, RunLoop 会去找对应的 Handler 处理事件;当没有事件时,RunLoop 会进入休眠状态。如下图,可以看出他的工作流程:从 input source 和 timer source 中接受到事件,然后在线程中去处理事件。
runloop.jpgInput Source 和 Timer Source
这两个都是 RunLoop 事件的来源,Input Source 传送来自其他应用或线程的异步事件/消息;Input Source 又可以分为三类(按照函数调用栈又可分为Source0和Source1,见CFRunLoopSourceRef)。
- Port-Based Sources,系统底层的 Port 事件,例如 CFSocketRef
- Custom Input Sources,用户手动创建的 Source
- Cocoa Perform Selector Sources, Cocoa 提供的 performSelector 系列方法,也是一种事件源
Timer Source 传送的是基于定时器的同步事件,可以定时或重复发送。
2. RunLoop 与线程的关系
苹果不允许直接创建 RunLoop,但是提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:
/// 全局的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());
}
结论:线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。
3. 接口及其联系
在 CoreFoundation/CFRunLoop中,定义了5个struct:
-
CFRunLoopMode
-
CFRunLoopRef
-
CFRunLoopSourceRef
-
CFRunLoopObserverRef
-
CFRunLoopTimerRef
线程和 RunLoop 是一一对应的,一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer,Source又按照事件分为Source0 和 Source1。
每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。
如下图:
2017-03-31-RunLoop结构.png RunLoop_0.png1. CFRunLoopMode:
系统默认注册了五中Mode:
- NSDefaultRunLoopMode:(kCFRunLoopDefaultMode (Core Foundation)) App的默认Mode,通常主线程实在这个模式下运行
- UITrackingRunLoopMode:界面跟踪Mode,用于界面控件(ScrollView,tableView等等)追踪触摸滑动,保证界面滑动时不受其他Mode影响
- UIInitializationRunLoopMode:在刚启动App是进入的第一个Mode,启动完成后就不再使用
- GSEventReceiveRunLoopMode:接收系统事件的内部Mode,通常用不到
- NSRunLoopCommonMode:这是一个占位的Mode,不是一种真正的Mode,(可以看成模式组,默认情况下包括了NSDefaultRunLoopMode,UITrackingRunLoopMode)两种模式.
iOS 中暴露出来的只有 NSDefaultRunLoopMode 和 NSRunLoopCommonModes。 NSRunLoopCommonModes 实际上是一个 Mode 的集合,默认包括 NSDefaultRunLoopMode 和 NSEventTrackingRunLoopMode。
2. CFRunLoopRef:
RunLoop对象,NSRunLoop是基于CFRunLoopRef的一层OC包装。
3. CFRunLoopSourceRef:
是事件产生的地方。Source又分为2种:Source0 和 Source1。
- Source0 是非基于 port 的事件,主要是 APP 内部事件,如点击事件,触摸事件等。只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
- Source1 是基于Port的,通过内核和其他线程通信,接收,分发系统事件。包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。
4. CFRunLoopObserverRef:
每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。
RunLoop状态可分为:
- kCFRunLoopEntry 即将进入runLoop
- kCFRunLoopBeforeTimers 即将处理Timer
- kCFRunLoopBeforeSources 即将处理source(事件源)
- kCFRunLoopBeforeWaiting 即将进入休眠
- kCFRunLoopAfterWaiting 被唤醒但是还没开始处理事件
- kCFRunLoopExit 即将退出runLoop
5. CFRunLoopTimerRef:
-
CFRunLoopTimerRef是基于时间的触发器,其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。
-
CFRunLoopTimerRef基本上说的就是NSTimer,它受RunLoop的Mode影响
4. 调用流程
RunLoop_1.png5. 应用
1. AutoreleasePool
当App启动之后,系统会启动主线程并创建RunLoop,在 main thread 中注册了两个 observer ,回调都是
_wrapRunLoopWithAutoreleasePoolHandler()
,分别会在进去Loop的kCFRunLoopEntry状态回调调用_objc_autoreleasePoolPush
方法创建AutoreleasePool;在kCFRunLoopBeforeWaiting进入休眠时调用_objc_autoreleasePoolPop()
和_objc_autoreleasePoolPush()
来释放旧的池并创建新的池;以及在kCFRunLoopExit退出时释放。
2. 事件响应
系统注册了一个 Source1 用于接收系统事件,其回调函数为
__IOHIDEventSystemClientQueueCallback()
。当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。
3. 手势识别
当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。
苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。
当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。
4. 界面更新
当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
这个函数内部的调用栈大概是这样的:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
QuartzCore:CA::Transaction::observer_callback:
CA::Transaction::commit();
CA::Context::commit_transaction();
CA::Layer::layout_and_display_if_needed();
CA::Layer::layout_if_needed();
[CALayer layoutSublayers];
[UIView layoutSubviews];
CA::Layer::display_if_needed();
[CALayer display];
[UIView drawRect];
5. 定时器
NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。
NSTimer的创建通常有两种方式,尽管都是类方法,一种是timerWithXXX,另一种scheduedTimerWithXXX。二者最大的区别就是后者除了创建一个定时器外会自动以NSDefaultRunLoopMode添加到当前线程RunLoop中,不添加到RunLoop中的NSTimer是无法正常工作的。
6. performSelecter
当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。
当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。
7. RunLoop 与 GCD
RunLoop 与 GCD 是互相协作的关系,RunLoop 的最开始部分使用了 GCD 的 timer 做超时的回调;通过 GCD 调用带有 RunLoop 的线程的 block,会通过
dispatch port CFRunLoopServiceMachPort
把事件发送到该线程的 RunLoop 里面。
比如:
dispatch_async(dispatch_get_main_queue(), ^{});
主线程存在 RunLoop,那么 GCD 会通过
dispatch port CFRunLoopServiceMachPort
,把事件发送给 RunLoop,RunLoop 接收到事件后,会执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。
6. 使用RunLoop
在次级线程运行 run loop 之前,必须向其添加至少一个 input source 或 timer,否则 run loop 会因没有可监控的 source 而在运行后立刻退出。
除了用 source 外,还可以用 run loop observer 观察 run loop 的各种运行阶段。做法是创建一个 CFRunLoopObserverRef
类型的对象并用 CFRunLoopAddObserver
函数将其添加到 run loop 中。注意的是只能用 Core Foundation 创建 run loop observer,Cocoa 框架无能为力。
下面的示例代码在线程入口函数中创建了 run loop observer 并将其添加到 run loop 中。observer 监听了 run loop 所有的活动,并省略了回调函数 myRunLoopObserver
的实现。
- (void)threadMain
{
// The application uses garbage collection, so no autorelease pool is needed.
NSRunLoop* myRunLoop = [NSRunLoop currentRunLoop];
// Create a run loop observer and attach it to the run loop.
CFRunLoopObserverContext context = {0, self, NULL, NULL, NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities, YES, 0, &myRunLoopObserver, &context);
if (observer)
{
CFRunLoopRef cfLoop = [myRunLoop getCFRunLoop];
CFRunLoopAddObserver(cfLoop, observer, kCFRunLoopDefaultMode);
}
// Create and schedule the timer.
[NSTimer scheduledTimerWithTimeInterval:0.1 target:self
selector:@selector(doFireTimer:) userInfo:nil repeats:YES];
NSInteger loopCount = 10;
do
{
// Run the run loop 10 times to let the timer fire.
[myRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
loopCount--;
}
while (loopCount);
}
为了不让 run loop 刚运行就立刻退出,上面的代码向 run loop 添加了一个 timer。因为 timer 一旦触发就无效了,依然会导致 run loop 退出,所以这里 repeats
参数传入 YES
。但这样会让 run loop 一直运行很久,并需要周期性触发 timer 来唤醒线程,这实际上是轮询的另一种形式罢了。相比之下,input source 等待事件发生后才唤醒线程,在这之前线程保持休眠。
参考文献