日常的开发工作中,我们几乎很少注意 RunLoop,因为我们基本上 "用不到"RunLoop。包括我在内应该有很多人都不了解这个东西,只是听说过。最近有空查了不少资料终于把 RunLoop 运行原理搞清楚了。
本文会对 RunLoop 的原理进行深入探讨,但是不涉及底层的实现。
我们平时开发中的很多东西都和 RunLoop 相关,比如:
RunLoop 机制贯穿整个 App 的生命周期的,这里提前剧透个彩蛋:
我们都知道:如果主线程的 RunLoop 挂掉了,App 也就挂掉了
BUT: 我们通过 RunLoop 机制可以让崩溃的 App 继续保持运行,非常英吹思婷!后面会有介绍。
计算机处理任务有进程和线程的概念,安卓中一个应用可以开启多个进程,而在 iOS 中一个 App 只能开启一个进程,但是线程可以开启多个。线程是用来处理事务的,多个线程处理事务是为了防止线程堵塞;一般来说一个线程一次只能执行一个任务,任务执行完成这个线程就会退出。
某些情况下我们需要这个线程一直运行着,不管有没有任务执行(比方说 App 的主线程),所以需要一种机制来维持线程的生命周期,iOS 中叫做 RunLoop,安卓里面的 Looper 机制和此类似。
为了让线程不退出随时候命处理事件而不退出,可以将逻辑简化为下面的代码
- do {
- var message = getNewmessages(); //接收来自外部的消息
- exec(message); //处理消息任务
- } while ( 0 == isQuit )
RunLoop 实际上也是一个对象,这个对象管理了线程内部需要处理的事件和消息,存在 RunLoop 的线程一直处于 "消息接收 -> 等待 -> 处理 " 的循环中,直到这个循环结束(RunLoop 被释放)。
这里举一个比较通俗易懂的例子:
当工厂接到商家的订单时,会将订单生产的消息(外界的 event 消息)发送给对应流水线上的主管(RunLoop),主管接收到消息之后启动这个流水线(唤醒线程)进行生产(线程处理事务)。如果这个流水线没有主管,流水线将会被工厂销毁。
需要注意的是,线程与 RunLoop 是一一对应的关系(对应关系保存在一个全局的 Dictionary 里),线程创建之后是没有 RunLoop 的(主线程除外),RunLoop 的创建是发生在第一次获取时。
苹果不允许直接创建 RunLoop,但是可以通过 [NSRunLoop currentRunLoop] 或者 CFRunLoopGetCurrent()来获取(如果没有就会自动创建一个)。
一般开发中使用的 RunLoop 就是 NSRunLoop 和 CFRunLoopRef,CFRunLoopRef 属于 Core Foundation 框架,提供的是 C 函数的 API,是线程安全的,NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,这些 API 不是线程安全的。
由于 NSRunLoop 是基于 CFRunLoop 封装的,下文关于 RunLoop 的原理讨论都会基于 CFRunLoop 来进行。NSRunLoop 和 CFRunLoop 所有类都是一一对应的关系。
CFRunLoop 对象可以检测某个 task 或者 dispatch 的输入事件,当检测到有输入源事件,CFRunLoop 将会将其加入到线程中进行处理。比方说用户输入事件、网络连接事件、周期性或者延时事件、异步的回调等。
RunLoop 可以检测的事件类型一共有 3 种,分别是 CFRunLoopSource、CFRunLoopTimer、CFRunLoopObserver。可以通过 CFRunLoopAddSource, CFRunLoopAddTimer 或者 CFRunLoopAddObserver 添加相应的事件类型。
要让一个 RunLoop 跑起来还需要 run loop modes,每一个 source, timer 和 observer 添加到 RunLoop 中时必须要与一个模式(CFRunLoopMode)相关联才可以运行。
上面是对于 CFRunLoop 官方文档的解释,大致说明了 RunLoop 的工作原理。
RunLoop 的主要组成部分如下:
RunLoop 共包含 5 个类,但公开的只有 Source、Timer、Observer 相关的三个类。
这 5 个类之间的关系关系:
下面对这几个部分作详细的讲解。
Run Loop Mode 就是流水线上能够生产的产品类型,流水线在一个时刻只能在一种模式下运行,生产某一类型的产品。消息事件就是订单。
CFRunLoopMode 和 CFRunLoop 的结构大致如下:
- struct __CFRunLoopMode {
- CFStringRef _name; // Mode Name, 例如 @"kCFRunLoopDefaultMode"
- CFMutableSetRef _sources0; // Set
- CFMutableSetRef _sources1; // Set
- CFMutableArrayRef _observers; // Array
- CFMutableArrayRef _timers; // Array
- ...
- };
- struct __CFRunLoop {
- CFMutableSetRef _commonModes; // Set
- CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
- CFRunLoopModeRef _currentMode; // Current Runloop Mode
- CFMutableSetRef _modes; // Set
- ...
- };
一个 RunLoop 包含了多个 Mode,每个 Mode 又包含了若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个 Mode 被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同 Mode 中的 Source/Timer/Observer,让其互不影响。下面是 5 种 Mode
其中 kCFDefaultRunLoopMode、UITrackingRunLoopMode 是苹果公开的,其余的 mode 都是无法添加的。既然没有 CommonModes 这个模式,那我们平时用的这行代码怎么解释呢?
- [[NSRunLoop mainRunLoop] addTimer: timer forMode: NSRunLoopCommonModes];
什么是 CommonModes?
一个 Mode 可以将自己标记为 "Common" 属性(通过将其 ModeName 添加到 RunLoop 的 "commonModes" 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 "Common" 标记的所有 Mode 里
主线程的 RunLoop 里有 kCFRunLoopDefaultMode 和 UITrackingRunLoopMode,这两个 Mode 都已经被标记为 "Common" 属性。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个 scrollView 时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。
如果想让 scrollView 滑动时 Timer 可以正常调用,一种办法就是手动将这个 Timer 分别加入这两个 Mode。另一种方法就是将 Timer 加入到 CommonMode 中。
怎么将事件加入到 CommonMode?
我们调用上面的代码将 Timer 加入到 CommonMode 时,但实际并没有 CommonMode,其实系统将这个 Timer 加入到顶层的 RunLoop 的 commonModeItems 中。commonModeItems 会被 RunLoop 自动更新到所有具有 "Common" 属性的 Mode 里去。
这一步其实是系统帮我们将 Timer 加到了 kCFRunLoopDefaultMode 和 UITrackingRunLoopMode 中。
CFRunLoopSourceRef 是事件源(输入源),比如外部的触摸,点击事件和系统内部进程间的通信等。
按照官方文档,Source 的分类:
Source 有两个版本:Source0 和 Source1(这么风骚的名字不知道是谁想出来的)。
Source0: 非基于 Port 的,只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
Source1: 基于 Port 的,包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。后面讲到的 AFNetwoeking 创建常驻线程就是在线程中添加一个 NSport 来实现的。
CFRunLoopTimerRef 是基于时间的触发器,基本上说的就是 NSTimer,它受 RunLoop 的 Mode 影响(GCD 的定时器不受 RunLoop 的 Mode 影响),当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行那个回调。如果线程阻塞或者不在这个 Mode 下,触发点将不会执行,一直等到下一个周期时间点触发。
CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个
- enum CFRunLoopActivity {
- kCFRunLoopEntry = (1 << 0), // 即将进入Loop
- kCFRunLoopBeforeTimers = (1 << 1), // 即将处理 Timer
- kCFRunLoopBeforeSources = (1 << 2), // 即将处理 Source
- kCFRunLoopBeforeWaiting = (1 << 5), // 即将进入休眠
- kCFRunLoopAfterWaiting = (1 << 6), // 刚从休眠中唤醒
- kCFRunLoopExit = (1 << 7), // 即将退出Loop
- kCFRunLoopAllActivities = 0x0FFFFFFFU // 包含上面所有状态
- };
- typedef enum CFRunLoopActivity CFRunLoopActivity;
这是我从别人博客上面摘录的一张图片,详细的描述了 RunLoop 运行机制
每次线程运行 RunLoop 都会自动处理之前未处理的消息,并且将消息发送给观察者,让事件得到执行。RunLoop 运行时首先根据 modeName 找到对应 mode,如果 mode 里没有 source/timer/observer,直接返回。
流程如下:
Step1 通知观察者 RunLoop 启动(之后调用内部函数,进入 Loop,下面的流程都在 Loop 内部 do-while 函数中执行)
Step2 通知观察者: RunLoop 即将触发 Timer 回调。(kCFRunLoopBeforeTimers)
Step3 通知观察者: RunLoop 即将触发 Source0 回调。(kCFRunLoopBeforeSources)
Step4 RunLoop 触发 Source0 回调。
Step5 如果有 Source1 处于等待状态,直接处理这个 Source1 然后跳转到第 9 步处理消息。
Step6 通知观察者:RunLoop 的线程即将进入休眠 (sleep)。(kCFRunLoopBeforeWaiting)
Step7 调用
等待接受
- mach_msg
的消息。线程将进入休眠, 直到被下面某一个事件唤醒
- mach_port
- 存在 Source0 被标记为待处理,系统调用 CFRunLoopWakeUp 唤醒线程处理事件
- 定时器时间到了
- RunLoop 自身的超时时间到了
- RunLoop 外部调用者唤醒
Step8 通知观察者线程已经被唤醒 (kCFRunLoopAfterWaiting)
Step9 处理事件
- 如果一个 Timer 到时间了,触发这个 Timer 的回调
- 如果有 dispatch 到 main_queue 的 block,执行 block
- 如果一个 Source1 发出事件了,处理这个事件
事件处理完成进行判断:
- 进入 loop 时传入参数指明处理完事件就返回(stopAfterHandle)
- 超出传入参数标记的超时时间(timeout)
- 被外部调用者强制停止__CFRunLoopIsStopped(runloop)
- source/timer/observer 全都空了__CFRunLoopModeIsEmpty(runloop, currentMode)
上面 4 个条件都不满足,即没超时、mode 里没空、loop 也没被停止,那继续 loop。此时跳转到步骤 2 继续循环。
Step10 系统通知观察者: RunLoop 即将退出。 满足步骤 9 事件处理完成判断 4 条中的任何一条,跳出 do-while 函数的内部,通知观察者 Loop 结束。
App 启动之后,系统启动主线程并创建了 RunLoop,在 main thread 中注册了两个 observer ,回调都是
- _wrapRunLoopWithAutoreleasePoolHandler()
监听了一个事件:
其回调会调用
创建一个栈自动释放池,这个优先级最高,保证创建释放池在其他操作之前。
- _objc_autoreleasePoolPush()
监听了两个事件:
此时调用
和
- _objc_autoreleasePoolPop()
来释放旧的池并创建新的池。
- _objc_autoreleasePoolPush()
此时调用
释放自动释放池。这个 observer 的优先级最低,确保池子释放在所有回调之后。
- _objc_autoreleasePoolPop()
在主线程中执行代码一般都是写在事件回调或 Timer 回调中的,这些回调都被加入了 main thread 的自动释放池中,所以在 ARC 模式下我们不用关心对象什么时候释放,也不用去创建和管理 pool。(如果事件不在主线程中要注意创建自动释放池,否则可能会出现内存泄漏)。
系统注册了一个 Source1 用来接收系统事件,其回调函数为
。当一个硬件事件 (触摸 / 锁屏 / 摇晃等) 发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收,
- __IOHIDEventSystemClientQueueCallback()
SpringBoard 只接收按键 (锁屏 / 静音等)、触摸、加速,传感器等几种事件
随后用 mach port 转发给需要的 App 进程。随后系统注册的那个 Source1 就会触发回调,并调用
进行应用内部的分发。
- _UIApplicationHandleEventQueue()
会把 IOHIDEvent 事件处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture / 处理屏幕旋转 / 发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。
- _UIApplicationHandleEventQueue()
这里说的定时器就是 NSTimer,我们使用频率最高的定时器,它的原型是 CFRunLoopTimerRef。一个 Timer 注册 RunLoop 之后,RunLoop 会为这个 Timer 的重复时间点注册好事件。
需要注意:
- 如果某个重复的时间点由于线程阻塞或者其他原因错过了,这个时间点会跳过去,直到下一个可以执行的时间点才会触发事件。举个栗子:假如公交车的发车间隔是 10 分钟,10:10 的公交车我们没赶上,只能等 10:20,如果由于我打电话没注意错过了 10:20 的车,只能等 10:30 的。
- 我们在哪个线程调用 NSTimer 就必须在哪个线程终止
NSTimer 有一个 tolerance ,官方文档给它的解释是 Timer 的计时并不是准确的,有一定的误差,这个误差就是 tolerance 默认为 0,我们可以手动设置这个误差。文档最后还强调了,为了防止时间点偏移,系统有权力给这个属性设置一个值无论你设置的值是多少,即使 RunLoop 模式正确,当前线程并不阻塞,系统依然可能会在 NSTimer 上加上很小的的容差。
我们在平时开发中一个很常见的现象:
在界面上有一个 UIscrollview 控件(tableview,collectionview 等),如果此时还有一个定时器在执行一个事件,你会发现当你滚动 scrollview 的时候,定时器会失效。
这是因为,为了更好的用户体验,在主线程中 UITrackingRunLoopMode 的优先级最高。在用户拖动控件时,主线程的 Run Loop 是运行在 UITrackingRunLoopMode 下,而创建的 Timer 是默认关联为 Default Mode,因此系统不会立即执行 Default Mode 下接收的事件。
解决方法 1:
将当前 Timer 加入到 UITrackingRunLoopMode 或 kCFRunLoopCommonModes 中
- NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(TimerFire:) userInfo:nil repeats:YES];
- [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
- // 或 [[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
- [timer fire];
解决方法 2: 因为 GCD 创建的定时器不受 RunLoop 的影响,可以使用 GCD 创建的定时器
- //dispatch_source_t必须是全局或static变量,否则timer不会触发
- static dispatch_source_t timer;
- //创建新的调度源(这里传入的是DISPATCH_SOURCE_TYPE_TIMER,创建的是Timer调度
- timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
- dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
- dispatch_source_set_event_handler(timer, ^{
- NSLog(@"%@", [NSThread currentThread]);
- });
- //启动或继续定时器
- dispatch_resume(timer);
在 Timer 使用中我们可以通过将其加入到不同的 mode 来解决 Timer 的跳票问题。不过有些情况下,例如:
用户滑动 scrollView 的过程中加载图片,由于 UI 的操作都是在主线程进行的,会造成滑动不流畅的问题,这个时候我们就需要在滑动的时候不加载图片,等滑动操作完成再进行加载图片的操作。
一般我们可以设置代理,当用户滑动结束的时候通知代理加载图片,这样比较麻烦太 low,基于 RunLoop 的原理我们只要一行代码即可搞定
- UIImage *downloadImage = ...
- [self.imageView performSelector:@selector(setImage:)
- withObject: downloadImage
- afterDelay:3.0
- inModes:@[NSDefaultRunLoopMode]];
通过将图片的设置
添加到 DefaultMode 里面,确保在 UITrackingRunLoopMode 下该操作不会被执行,保证了滑动的流畅性。
- setImage:
iOS 中的网络请求接口自下而上有这么几层
CFSocket 是最底层的接口,只负责 socket 通信。
CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作在这层。
NSURLConnection 是基于 CFNetwork 更高层的封装,提供了面向对象的接口,AFNetworking 工作在这一层。
NSURLSession 看似是和 NSURLConnection 并列的,实际上它也用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程)
开始网络传输时,NSURLConnection 创建了两个新线程:
和
- com.apple.NSURLConnectionLoader
。 其中 CFSocket 线程是处理底层 socket 连接的,NSURLConnectionLoader 这个线程的 RunLoop 创建了一个 Source1 事件源用来监听底层 socket 事件。当 CFSocket 处理好 socket 事件之后会通过 mach port 通知 NSURLConnectionLoader,然后 NSURLConnectionLoader 所在的线程再将消息通过 mach prot 转发给上层的 Delegate 所在的线程,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。
- com.apple.CFSocket.private
在 AFNetworking2.6.3 版本之前是有 AFURLConnectionOperation 这个类的, AFNetworking 3.0 版本开始已经移除了这个类,AFN 没有自己创建线程,而是采用的下面的这种方式
- [inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
- [outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
- SCNetworkReachabilityUnscheduleFromRunLoop(self.networkReachability, CFRunLoopGetMain(), kCFRunLoopCommonModes);
由于本文讨论的是 RunLoop,所以这里我们还是回到 2.6.3 版本 AFN 自己创建线程并添加 RunLoop 的这种方式讨论,在 AFURLConnectionOperation 类中可以找到下面的代码
- + (void)networkRequestThreadEntryPoint:(id)__unused object {
- @autoreleasepool {
- [[NSThread currentThread] setName:@"AFNetworking"];
- NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
- [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
- [runLoop run];
- }
- }
- + (NSThread *)networkRequestThread {
- static NSThread *_networkRequestThread = nil;
- static dispatch_once_t oncePredicate;
- dispatch_once(&oncePredicate, ^{
- _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
- [_networkRequestThread start];
- });
- return _networkRequestThread;
- }
从上面的代码可以看出,AFN 创建了一个新的线程命名为 AFNetworking ,然后在这个线程中创建了一个 RunLoop ,在上面 2.3 章节 RunLoop 运行机制中提到了,一个 RunLoop 中如果 source/timer/observer 都为空则会退出,并不进入循环。所以,AFN 在这里为 RunLoop 添加了一个 NSMachPort ,这个 port 开启相当于添加了一个 Source1 事件源,但是这个事件源并没有真正的监听什么东西,只是为了不让 RunLoop 退出。
- //开始请求
- - (void)start {
- [self.lock lock];
- if ([self isCancelled]) {
- [self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
- } else if ([self isReady]) {
- self.state = AFOperationExecutingState;
- [self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
- }
- [self.lock unlock];
- }
- //暂停请求
- - (void)pause {
- if ([self isPaused] || [self isFinished] || [self isCancelled]) {
- return;
- }
- [self.lock lock];
- if ([self isExecuting]) {
- [self performSelector:@selector(operationDidPause) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
- dispatch_async(dispatch_get_main_queue(), ^{
- NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
- [notificationCenter postNotificationName:AFNetworkingOperationDidFinishNotification object:self];
- });
- }
- self.state = AFOperationPausedState;
- [self.lock unlock];
- }
- //取消请求
- - (void)cancel {
- [self.lock lock];
- if (![self isFinished] && ![self isCancelled]) {
- [super cancel];
- if ([self isExecuting]) {
- [self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
- }
- }
- [self.lock unlock];
- }
可以看到,AFN 每次进行的网络操作,开始、暂停、取消操作时都将相应的执行任务扔进了自己创建的线程的 RunLoop 中进行处理,从而避免造成主线程的阻塞。
我们都知道,如果 App 运行遇到 Exception 就会直接崩溃并且退出,其实真正让应用退出的并不是产生的异常,而是当产生异常时,系统会结束掉当前主线程的 RunLoop ,RunLoop 退出主线程就退出了,所以应用才会退出。明白这个道理,去完成这个 "不可能的任务" 就很简单了。
接下来我们就去让应用在崩溃时依然可以正常运行,这个是非常有意义的。
应用遇到 BUG 崩溃时一般会给使用者造成非常不好的用户体验,如果当应用崩溃时我们让用户选择退出还是继续运行,那么用户会感觉我们的 App 跟别人的不一样,叼叼哒!
苹果提供了产生 Exception 的处理方法,我们可以在相应的方法中处理产生的异常,但是这个时间非常的短,之后应用就会退出,具体多长时间我们也不清楚,很被动。如果我们可以在应用崩溃时,有足够的时间收集并且上传到服务器,那么给我们的分析和解决 BUG 会带来相当大的便利。
下面直接上代码,非常简单:
- CFRunLoopRef runLoop = CFRunLoopGetCurrent();
- CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
- while (!isQuit) {
- for (NSString * mode in (__bridge NSArray * ) allModes) {
- CFRunLoopRunInMode((CFStringRef) mode, 0.001, false);
- }
- }
- CFRelease(allModes);
把上面的代码添加到 Exception 的 handle 方法中,此时创建了一个 RunLoop ,让这个 RunLoop 在所有的 Mode 下面一直不停的跑,保证主线程不会退出,我们的应用也就存活下来了。
参考:
developer.apple.com/reference/c…
iphil.cc/?p=279
blog.ibireme.com/2015/11/12/…
www.itdadao.com/article/251…
来源: https://juejin.im/post/5a3095435188250a5719b7b2