iOS底层学习——RunLoop实现原理

1.什么是RunLoop

RunLoop是一个运行循环,也是一个对象,并且提供了入口函数,进行do while循环,保证运行程序不退出。

我们知道一个程序运行结束的标志性语句是return,在iOS应用的入口main函数中,return并执行了一个UIApplicationMain函数,见下图:

image.png

既然已经return了,为什么应用依然可以接收消息,处理消息呢?程序不应该到此结束吗?我们在代理AppDelegateapplication:didFinishLaunchingWithOptions:方法中添加断点,并bt打印堆栈信息,探索到以下内容:

image.png

程序的执行流程,首先dyld进行应用程序加载,执行main函数,启动RunLoop,加载GCD………由此可见应用程序在启动过程中进行了一系列的初始化工作。同时可以确定,RunLoop来自CoreFoundation框架,CoreFoundation的部分源码是开源的,其中包括RunLoop。在其源码中,RunLoop Run的实现也确实是一个do while循环。见下图:

image.png

我们知道RunLoop线程有关系,并且提供了一套消息处理机制。在苹果官方的开发者文档Documentation Archive中搜索Thread内容,其中就包含了RunLoop的相关说明:

image.png

这也说明了RunLoop线程是息息相关的。

2.RunLoop的作用

总结一下RunLoop的作用:

  • 保持程序的持续运行
  • 处理APP中的各种事件(触摸、定时器、performSelector
  • 节省cpu资源、提高程序的性能:该做事就做事,该休息就休息
  1. 保持程序的持续运行

    这一点很好理解,在main函数创建UIApplicationMain时启动了RunLoop,如果没有启动RunLoop,程序就会直接退出。

  2. 处理APP中的各种事件

    在苹果的官方文档中,有这样一张图:

    image.png

    再结合官方的说明,我们可以知道,RunLoop是线程用于处理运行事件、处理响应事件的循环。这些事件包括port事件源、屏幕触摸事件、performSelectortimer等。

    我们在上层APP开发时貌似很少接触到RunLoop,个人觉得RunLoop的封装达到了极致。一些事件处理都运用到了RunLoop,下面通过案例来分析:

    • Timer

      处理Timer事件,对应__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__,见下图:

      image.png

    • performSelector

      处理performSelector事件,也对应的__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__,见下图:

      image.png

    • GCD

      队列中处理事件,对应__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__,见下图:

      image.png

    通过上面的探索,我们可以发现这些事件的处理方法均以__CFRUNLOOP_IS_为开头的方式命名,查看源码,其会根据不同的事件,提供不同的响应方法,见下图:

    image.png

    事件处理回调方法总结:

    • block应用:__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__
    • 调用timer__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
    • 响应source0__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
    • 响应source1__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
    • GCD主队列:__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
    • observer源:__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__
  3. 节省cpu资源、提高程序的性能

    RunLoop能节省cpu资源,提高程序的性能,这点体现在哪呢?见下图:

    image.png

    在上图中可以发现,应用程序启动后,保持运行状态,但是此时的cpu占用率一直是0%。我们知道,RunLoop实际上就是一个do while循环,我们如果开启一个循环会怎样呢?对比一下!

    image.png

    通过上图可以发现,cpu占用一直很高,所以可以得出结论,RunLoop所提供的循环是和普通的循环是有区别的,有事需要处理才会运行,没有事则会休息!从而达到了节省cpu资源,提高程序性能的作用。那么这种功能是如何实现的,下面会分析。

3.RunLoop与线程的关系

上面我们已经提到,RunLoop和线程是息息相关的,并且是一一对应的关系。那么他们的关系是如何建立的呢?

    // 获取main RunLoop
    NSLog(@"%@", CFRunLoopGetMain());
    // 获取当前 RunLoop
    NSLog(@"%@", CFRunLoopGetCurrent());
复制代码

我们通常会通过上面的两种方式打印输出main RunLoop以及当前RunLoop。在CFRunLoop源码中查看其实现:

image.png

解读上面的源码不难发现,获取RunLoop均是通过线程进行获取。那么线程和RunLoop的关系是如何建立的呢?这就需要解读_CFRunLoopGet0函数的实现源码:

image.png

解读_CFRunLoopGet0源码:

  1. 维护了一个CFMutableDistionaryRef字典__CFRunLoops,字典默认为NULL
  2. 如果CFRunLoops是空,则创建一个CFMutableDistionaryRef字典,并默认初始化主线程的RunLoop
  3. 将创建的RunLoop放入到CFMutableDistionaryRef字典中,也就是放入__CFRunLoops中,以线程为keyRunLoopvalue
  4. 在通过线程获取RunLoop时,以key-value方式从字典中获取对应的RunLoop
  5. 如果RunLoop为空,则创建一个newLoop,以线程为keyRunLoopvalue,存储到__CFRunLoops中;
  6. 返回线程对应的RunLoop

上面的源码我们可以得出一个结论:主线程的RunLoop会默认被创建,而子线程的RunLoop是懒加载的,需要时才会创建,RunLoop和线程是一对一的关系,存储在一个字典中。

  • 子线程RunLoop案例分析

        GFThread * gfThread = [[GFThread alloc] initWithBlock:^{
            NSLog(@"running....");
    
            [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
                NSLog(@"helloc timer...%@", [NSThread currentThread]);
            }];
        }];
    
        gfThread.name = @"Hello.thread";
        [gfThread start];
    复制代码

    GFThread是一个继承自NSThread的自定义线程,并重写了dealloc方法。案例中,在子线程中使用了一个NSTimer,运行这段代码会是什么结果呢?

    image.png

    线程生命周期结束,但是NSTimer的任务并没有执行。这是因为NSTimer需要依赖于RunLoop,主线程的RunLoop默认开启,而子线程的RunLoop是懒加载,需要手动开启。

    对上面的案例进行修改,启动子线程的RunLoop。见下图:

    image.png

    那么如何结束NSTimer呢?首先我们需要理理清楚一个关系:线程和RunLoop一一对应,而NSTimer又依赖于RunLoop。根据这个思路可以做如下修改:

    image.png

    通过外部变量可以控制线程,如果线程退出,对应的RunLoop也会停止运行,NSTimer又依赖于RunLoop,也自然不能运行。

4.RunLoop数据结构

RunLoop中涉及到5个重要的类:

  1. CFRunLoop - RunLoop对象
  2. CFRunLoopMode - 五种运行模式
  3. CFRunLoopSource - 输入源/事件源,包括Source0Source1
  4. CFRunLoopTimer - 定时源,也就是NSTimer
  5. CFRunLoopObserver - 观察者,用来监听RunLoop
  • CFRunLoop

    在底层RunLoop对象为CFRunLoopNSRunLoopOC层的封装。我们可以通过以下两种方式获取当前线程的RunLoop:

        // c/c++
        CFRunLoopRef lp     = CFRunLoopGetCurrent();
        // OC
        NSRunLoop *currentRunLoop = [NSRunLoop currentRunLoop];
    复制代码

    那么CFRunLoop在底层是如何定义的呢?

        struct __CFRunLoop {
            CFRuntimeBase _base;
            pthread_mutex_t _lock;            /* locked for accessing mode list */
            __CFPort _wakeUpPort;            // used for CFRunLoopWakeUp
            Boolean _unused;
            volatile _per_run_data *_perRunData;              // reset for runs of the run loop
            pthread_t _pthread;
            uint32_t _winthread;
            CFMutableSetRef _commonModes;
            CFMutableSetRef _commonModeItems;
            CFRunLoopModeRef _currentMode;
            CFMutableSetRef _modes;
            struct _block_item *_blocks_head;
            struct _block_item *_blocks_tail;
            CFAbsoluteTime _runTime;
            CFAbsoluteTime _sleepTime;
            CFTypeRef _counterpart;
        };
    复制代码

    CRRunLoop中包括锁_lock、用于处理source1的唤醒端口_wakeUpport、关联的线程_pthread、当前模式_currentMode等。同时维护了一个set集合_modes,所以通过数据结构我们可以得出结论: RunLoopmode是一对多的关系。同时包括_commonModes属性,commonMode是一个伪模式。

  • CFRunLoopMode

    可以通过以下代码获取当前线程RunLoopcurrentModemode列表:

        CFRunLoopRef lp     = CFRunLoopGetCurrent();
        CFRunLoopMode mode  = CFRunLoopCopyCurrentMode(lp);
        NSLog(@"mode == %@",mode);
    
        CFArrayRef modeArray= CFRunLoopCopyAllModes(lp);
        NSLog(@"modeArray == %@",modeArray);
    复制代码

    运行上面的代码,得到以下结果:

    image.png

    此时currentModekCFRunLoopDefaultMode,而当前线程的RunLoop包括了三种mode,分别是:UITrackingRunLoopModeGSEventReceiveRunLoopModekCFRunLoopDefaultMode

    • 案例了解mode的切换

      引入下面的案例,用于了解mode的切换过程,见下图:

      image.png

      RunLoop添加Timer时放在了DefaultMode,程序正常情况下也运行在DefaultMode,但在滚动视图时,切换到了UITrackingModetimer事件也不再触发。当停止滚动视图,又切回到了DefaultModetimer恢复运行。

      如何解决这个问题呢?我们可以将Timer添加到commonMode,为什么放在commonMode就不受视图滚动的影响了呢?下面会解答!

    那么mode在底层是如何定义的呢?

        typedef struct __CFRunLoopMode *CFRunLoopModeRef;
        struct __CFRunLoopMode {
            CFRuntimeBase _base;
            pthread_mutex_t _lock;    /* must have the run loop locked before locking this */
            CFStringRef _name;
            Boolean _stopped;
            char _padding[3];
            CFMutableSetRef _sources0;
            CFMutableSetRef _sources1;
            CFMutableArrayRef _observers;
            CFMutableArrayRef _timers;
            CFMutableDictionaryRef _portToV1SourceMap;
            __CFPortSet _portSet;
            CFIndex _observerMask;
        #if USE_DISPATCH_SOURCE_FOR_TIMERS
            dispatch_source_t _timerSource;
            dispatch_queue_t _queue;
            Boolean _timerFired; // set to true by the source when a timer has fired
            Boolean _dispatchTimerArmed;
        #endif
        #if USE_MK_TIMER_TOO
            mach_port_t _timerPort;
            Boolean _mkTimerArmed;
        #endif
        #if DEPLOYMENT_TARGET_WINDOWS
            DWORD _msgQMask;
            void (*_msgPump)(void);
        #endif
            uint64_t _timerSoftDeadline; /* TSR */
            uint64_t _timerHardDeadline; /* TSR */
        };
    复制代码

    __CFRunLoopMode源码定义中包括了4set集合_sources0_sources1_observers_timers,这四个集合也就是我们常说的事件(事务)。所以我们可以得出结论:CFRunLoopModesoursestimerobserver也是一对多的关系。

    Developer Document中搜索NSRunLoopMode可以找到,系统共维护了5mode见下图:

    image.png

    • kCFRunLoopDefaultMode 默认的运行模式,通常主线程是在这个Mode下运行
    • UITrackingRunLoopMode 界面跟踪Mode,用于ScrollView等视图,追踪触摸滑动,保证界面的滑动不受其他Mode的影响
    • UIInitializationRunLoopMode 在刚启动App时进入的第一个Mode,启动完成后就不在使用
    • GSEventReceiveRunLoopMode 接受系统时间的内部Mode,通常用不到
    • kCFRunLoopCommonModes 是一个伪模式,可以在标记为CommonModes的模式下运行,RunLoop会自动将_commonModeItems里的sourceobservetimer同步到具有标记的Mode里。

综上,可以得出以下关系图:

image.png

  • RunLoop线程一对一
  • RunLoopMode一对多
  • Modesourcetimerobserver也是一对多

5.RunLoop事件处理机制

在上面第二节,已经说明RunLoop处理APP中的各种事件(触摸、定时器、performSelector),也就是说blocktimersource0source1GCDobserver都需要依赖于RunLoop,那么这些事件是如何加入到RunLoop中的呢?底层又是如何去处理这些事件的呢?

1.添加事务

在源码中提供了一些事务(事件)添加方法,这些事务会添加到对应的mode中,见下图:

image.png

  • block事务添加

    当有block事务时,RunLoop会调用CFRunLoopPerformBlock方法,将block事务存储到对应的mode中,见下图:

    image.png

    在此过程中首先进行mode的判断处理,确定需要将事务放到哪个mode中,如果mode或者block为空,则释放;否则会创建一个block_item,该数据是一个链表结构,其存储了一个block和下一个节点的地址信息。

  • timer事务添加

    当有timer事务时,RunLoop会调用CFRunLoopAddTimer方法,将block事务存储到对应的mode中,见下图:

    image.png image.png 这里会对mode进行判断,判断是否为commonModes,如果是会初始化_commonModeItems集合,并将timer事务添加到集合中。否则找到对应的mode,然后调用__CFRepositionTimerInMode方法,将timer添加到_timers集合中。

CFRunLoopAddObserverCFRunLoopAddSource流程类似,这里不详细说明。

2.RunLoop循环

在程序运行的入口处设置断点,我们可以发现系统会首先调用CFRunLoopRunSpecific方法,启动RunLoop。见下图:

image.png

下面跟踪RunLoop处理流程。在源码中查找CFRunLoopRunSpecific的方法实现,见下图:

image.png

在这里注册了两个Observer,第一个Observer监视的事件是Entry(即将进入Loop),第二个Observer监视Exit(即将退出Loop)。

进入__CFRunLoopRun方法,方法实现见下图:

iShot2021-09-29 18.18.53.png

循环状态是对retVal进行控制,在循环过程中,会对状态进行判断,如,是否TimedOut、是否Stopped、是否Finished,来确定RunLoop是否需要销毁。

此部分代码是RunLoop的核心流程,关键点做了标注,总结下来,可以得出下面这个流程图:

image.png

3.事务处理

上面两节已经摸清楚事务(事件)的添加流程、RunLoop循环处理流程,下面重点分析RunLoop在循环过程中是如何处理事务的。

在上面的do while循环中,有处理事务的入口:__CFRunLoopDoBlocks__CFRunLoopDoTimers__CFRunLoopDoSources0__CFRunLoopDoSource1

  • 处理block事务

    __CFRunLoopDoBlocks源码实现见下图:

    image.png

    在分析block事务添加过程时提到,block事务是以链表的形式存储的,这里进行处理事务时通过_next指针循环遍历所有的block事务。

    block执行逻辑:

    • 事务加入的mode和当前RunLoopmode相等
    • 当前modecommonModes
    • 通过调用回调函数__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__执行任务
      static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__() __attribute__((noinline));
      static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(void (^block)(void)) {
          if (block) {
              block();
          }
          asm __volatile__(""); // thwart tail-call optimization
      }
      复制代码
  • 处理timer事务

    __CFRunLoopDoTimers源码实现见下图:

    image.png

    此过程中,会从当前mode_timers中获取需要执行的timer事务,放入到数组timers中,然后在调用__CFRunLoopDoTimer方法执行timer__CFRunLoopDoTimer实现原理见下图:

    image.png

    在此流程中会对Timer的状态进行判断,并调用函数__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__完成事务的执行。

__CFRunLoopDoSources0的处理流程不再详细介绍,最终会调用__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__函数。

总结可以得出以下流程图:

image.png

6.RunLoop与AutoreleasePool

AutoreleasePool创建和释放

  • App启动后,苹果在主线程RunLoop里注册了两个Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()

  • 第一个Observer监视的事件是Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush()创建自动释放池。其order-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。

  • 第二个Observer监视了两个事件:BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop()_objc_autoreleasePoolPush(),释放旧的池并创建新池;Exit(即将退出Loop) 时调用_objc_autoreleasePoolPop()来释放自动释放池。这个Observerorder2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

  • 在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被RunLoop创建好的AutoreleasePool环绕着,所以不会出现内存泄漏,开发者也不必显示创建Pool了。

也就是说AutoreleasePool创建是在一个RunLoop事件开始之前(push),AutoreleasePool释放是在一个RunLoop事件即将结束之前(pop)。AutoreleasePool里的Autorelease对象的加入是在RunLoop事件中,AutoreleasePool里的Autorelease对象的释放是在AutoreleasePool释放时。

7.RunLoop总结

RunLoop是通过系统内部维护的循环进行事件、消息管理的一个对象。RunLoop实际上就是一个do...while循环,有任务时开始,无任务时休眠。本质是通过mach_msg()函数接收、发送消息。

  • RunLoop与线程的关系:

    1. RunLoop的作用就是来管理线程,当线程的RunLoop开启后,线程就会在执行完任务后,处于休眠状态,随时等待接受新的任务,不会退出。
    2. 只有主线程的RunLoop是默认开启的,其他线程的RunLoop需要手动开启。所以当程序开启后,主线程一直运行,不会退出。
  • RunLoop中涉及到5个重要的类:

    1. CFRunLoop - RunLoop对象
    2. CFRunLoopMode - 五种运行模式
    3. CFRunLoopSource - 输入源/事件源,包括Source0Source1
    4. CFRunLoopTimer - 定时源,也就是NSTimer
    5. CFRunLoopObserve - 观察者,用来监听RunLoop
  • CFRunLoopMode - 五种运行模式

    • kCFRunLoopDefaultMode 默认的运行模式,通常主线程是在这个Mode下运行
    • UITrackingRunLoopMode 界面跟踪Mode,用于ScrollView等视图,追踪触摸滑动,保证界面的滑动不受其他Mode的影响
    • UIInitializationRunLoopMode 在刚启动App时进入的第一个Mode,启动完成后就不在使用
    • GSEventReceiveRunLoopMode 接受系统时间的内部Mode,通常用不到
    • kCFRunLoopCommonModes 是一个伪模式,可以在标记为CommonModes的模式下运行,RunLoop会自动将_commonModeItems里的sourceobservetimer同步到具有标记的Mode里。
  • CFRunLoopSource - 事件源

    • Source1:基于mach_port和回调函数指针,也就是端口通讯,处理来自系统内核或其他进程的事件,比如点击手机屏幕
    • Source0:非基于Port的处理事件,也就是应用层事件(内部事件、APP负责管理的事件,UIEvent),包含一个回调函数指针,需要手动标记为待处理或者手动唤醒RunLoop,如performSelectorblock
    • 例如:一个APP在前台静止,用户点击APP界面,屏幕表面的时事件会先包装成Event告诉source1(基于mach_port),source1唤醒RunLoop将事件Event分发给source0,由source0来处理。
  • CFRunLooTimer - 定时源

    就是NSTimer,在预设的时间点唤醒RunLoop执行回调。因为它是基于RunLoop的,因此它不是实时的(Timer是不准确的,因为RunLoop只负责分发源消息。如果线程当前正在处理繁重的任务,就有可能导致Timer本次延时,或者少执行一次)。

  • CFRunLoopObserver - 观察者

    用来监听时间点事件CFRunLoopActivity

    • KCFRunLoopEntery RunLoop准备启动
    • kCFRunLoopBeforeTimers RunLoop将要处理一些Timer相关的事件
    • kCFRunLoopBeforeSources RunLoop将要处理一些Source事件
    • kCFRunLoopBeforeWaiting RunLoop将要进行休眠状态,即将由用户状态切换内核态
    • kCFRunLoopAfterWaiting RunLoop被唤醒,即从内核态切换到用户态
    • kCFRunLoopExit RunLoop退出
    • kCfRunLoopAllActivitires 监听所有状态
  • 各数据结构之间的联系

    • RunLoop线程是一对一的关系
    • RunLoopRunLoopMode是一对多的关系
    • RunLoopModeRunLoopSource是一对多的关系
    • RunLoopModeRunLoopTimer是一对多的关系
    • RunLoopModeRunLoopObserver是一对多的关系
  • 为什么main函数能够保持一直存在且不退出?

    main函数内容会调用UIApplication函数,而在UIAPPlicationMain内部会启动主线程的RunLoop,可以做到有消息处理,能够迅速从内核态到用户态的切换,立刻唤醒处理,而没有消息处理时,通过用户态到内核态的切换进入等待状态,避免资源的占用。因此main函数能够一直存在并且不退出。

猜你喜欢

转载自juejin.im/post/7013559485461430286