iOS开发之进阶篇(8)—— Run Loops

引用

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将事件异步传递到线程, 事件的来源取决于输入来源的类型. 通常有两个类别: 基于端口的输入源自定义输入源.

Source.jpg

图中, 左边是线程循环, 右边是输入源. 输入源1~4分别为基于端口的源, 自定义源, 选择器源, 以及计时器源. 其中, 1和4都属于基于端口的源, 不同的是所有的计时器源都共用一个端口“Mode Timer Port”,而每个基于端口的源都有不同的对应端口. 2和3都属于自定义输入源, 可以理解为选择器源是官方为我们定义好的自定义源.

  1. Port-Based Sources 基于端口的源
    监视您的应用程序的Mach端口, 由内核自动发出信号. 关于Mach/内核/端口, 详见后文<底层实现>.
    Cocoa和Core Foundation提供了内置支持, 用于创建基于端口的输入源.

例如

  • 在Cocoa中,您根本不需要直接创建输入源, 而只需创建一个端口对象(NSPort), 然后使用端口对象的方法将该端口添加到运行循环中。端口对象为您处理所需输入源的创建和配置。
  • 在Core Foundation中,您必须手动创建端口及其运行循环源。在这两种情况下,都使用与端口不透明类型(CFMachPortRef、CFMessagePortRef或CFSocketRef)关联的函数来创建适当的对象。
  1. Custom Input Sources 自定义输入源
    监视事件的定制源, 从另一个线程手动发出信号.
    参见定义自定义输入源

  2. 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调用)(图片来源):

call out

运行流程

线程的运行循环都会处理事件源并为所有已注册的观察者生成通知. RunLoop 内部的逻辑大致如下 (图片来源):

RunLoop运行流程

Source0: 自定义输入源
Source1: 基于端口的输入源

可以看到, 实际上 RunLoop 内部是一个 do-while 循环. 当你调用 CFRunLoopRun() 时, 线程就会一直停留在这个循环里; 直到超时或被手动停止, 该循环才会退出.

底层实现

RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。为了解释这个逻辑,下面稍微介绍一下 OSX/iOS 的系统架构。图片来源

系统架构.png

Darwin 即操作系统的核心, 包括系统内核、驱动、Shell 等内容. 我们再深入看一下 Darwin 这个核心的架构 (图片来源):

Darwin架构.png

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。

  • XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。
  • BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。
  • IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

在Mach中,进程、线程间的通信是以消息的方式来完成的,消息在两个Port之间进行传递(这也正是Source1之所以称之为Port-based Source的原因,因为它就是依靠系统发送消息到指定的Port来触发的)。消息的发送和接收使用中的mach_msg()函数.

什么时候使用

我们只需在子线程创建的时候决定是否需要运行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 线程).

你可能感兴趣的:(iOS开发之进阶篇(8)—— Run Loops)