面试宝点

runloop的基本概念和实现原理

2022-02-23  本文已影响0人  anny_4243

摘自《iOS程序员面试笔试真题与解析》

什么时候需要用到runloop机制?一般情况下,一个线程在一段时间内,只能执行一个任务,执行完一个任务后该线程就退出了。假如需要一个机制让线程一直处于保活状态,即线程能够随时处理接收到的事件,在没有事件需要处理时,线程处于睡眠状态不占用CPU,从而提高CPU的利用率。可以使用while或者for循环来模拟这种机制,下面给出描述这种机制的伪代码。

function loop(){
    initialize();
    do{
        get(message);
        process_message(message);
        sleep();
    }while(message != quit);
}

上述伪代码所描述的机制就是iOS/macOS中常说的runloop机制,先来了解一下runloop的概念,顾名思义,把runloop翻译成中文意思是:运行循环或者跑圈,通常叫运行循环。runloop与window程序中的消息循环类似,因此,有时也被称作事件循环(Event Loop)。Apple对runloop的概念做了如下解释:

“Run loops are part of thefundamental infrastructure associated withthreads. A run loop is an event processing loopthat you use to schedule work and coordinatethe receipt of incoming events. The purpose ofa runloop is to keep your thread busy whenthere is work to do and put your thread tosleep when there is none.”

通过这句话能够很清晰地知道runloop是用来做什么的,它是一个与线程相关的底层机制,用来接收事件和调度任务。runloop目的是让线程在有工作的时候保持忙碌,在没有工作的时候睡眠。这句话中点名runloop是与线程相关的,它们的关系一一对应:一个线程只能对应一个runloop,即在某一时刻,一个线程只能运行在某一个runloop上。当运行一个应用程序的时候,系统会为应用程序的主线程创建一个runloop用来处理主线程上的事件,例如UI刷新和触屏事件。因此,开发者不需要为主线程显式地(Explicitly)创建和运行一个runloop,而一些辅助线程(Secondary Thread)或叫子线程需要显式地运行一个runloop,再将辅助线程放到runloop中运行,否则线程不会自动开启runloop,线程运行一次就结束了,无法一直处于忙碌状态。例如下面这段代码。

NSThread *t = [[NSThread alloc]initWithTarget:self selector:@selector(print) object:nil];
[t start];

这段代码的含义是,创建一个线程t,让这个线程执行print的方法。当通过start方法开启这个线程时,会在这个线程上执行一次print方法,执行完print方法后,线程就退出程序,无法再进行其他任务。如果继续在线程t上调用其他方法,例如下面这段代码:

NSThread *t = [[NSThread alloc]initWithTarget:self selector:@selector(print) object:nil];
[t start];
[self performSelector:@selector(print) onThread:t withObject:nil waitUntilDone:NO];

那么此时应用将会抛出异常,如下所示。

这个异常的意思是,线程已经退出,无法执行perform方法。这就和之前的讲解的一样,一般情况下,一个线程在执行完一个任务后就会退出程序。如果想要让线程t一直运行,使得线程t在有任务时执行任务,没有任务时睡眠,那么此时就需要为线程t开启runloop,让线程t保活,通常情况下可以这样做,在print方法中做如下实现。

- (void)print{
    NSLog(@"print");
    NSRunLoop *runloop = [NSRunLoop currentRunLoop];
    [runloop run]; //注意需要手动运行runloop
}

此时再次执行上面抛出异常的代码,则不会再出错误。这段代码的意思是获取一个runloop,并让这个runloop一直run,使得线程t一直保活。这里不展开对这段代码进行分析,下文会结合官方文档和源码深入分析runloop的原理,源码选择目前最新的CF-1151.16版本,下文中出现的源码都以此版本进行分析,不同版本之间的源码实现存在差异。读者需要根据自己下载的源码进行合理分析。

在学习runloop的原理前,有两个非常重要的概念需要事先了解:事件源(Sources)和模式(Modes)。事件源指的是触发事件的源,也可以理解为触发事件的方式,官方文档中将事件源分为两种:输入源(Input Sources)和定时器源(Timer Sources)。其中输入源表示触发异步事件的源,例如一个线程和另一个线程之间的消息;定时器源表示触发同步事件的源,例如预定时间内触发的事件或重复触发的事件。需要注意的是,事件源在官方文档和源码中的分类不同,官方文档中将事件源分为Input Sources和Timer Sources,而源码中将事件源分为Soruce0、Source1和Timer,后文在分析源码的时候会进行进一步的说明,这里暂不赘述。下图为runloop的结构图。

runloop的结构

上图简单地描述了runloop的运行过程:一个线程在循环运行并等待事件源的到来,当事件源到来时,处理对应的事件。右图为事件源,左图为线程在runloop上运行的过程,其中runUntilDate表示执行一段时间后结束,timerFired表示处理定时器事件,mySelector处理perform事件,handlePort处理Port事件,start表示创建并开启一个runloop,end表示runloop停止。右图中输入源有三种类型,第一种是通过端口(Port)触发,第二种是用户(Custom)自定义事件触发,第三种是通过performSelector:onThread触发;此外,定时器源只有一种。通过上图能够直观地认识到事件源的产生,为了进一步说明事件源的结构,到CoreFoundation的源码中找到Source的定义,如下所示。

//CFRunLoop.h
typedef struct __CFRunLoopSource *CFRunLoopSourceRef;
//CFRunLoop.c
struct __CFRunLoopSource{
    CFRuntimeBase _base;//
    uint32_t _bits;
    pthread_mutex_t lock;
    CFIndex _order;  /*immutable*/
    CFMutableBagRef _runLoops;
    union{
        CFRunLoopSourceContext version0; /*immutable,except invalidation*/
        CFRunLoopSourceContext1 version1; /*immutable,except invalidattion*/
    }_context;
};

从__CFRunLoopSource的定义中可以看到有一个联合体union,里面有version0和version1两个成员变量,这两个变量对应了上文中提到的Source0和Source1,表示事件源有两种类型。联合体的作用是共享存储空间,也就是说,version0和version1两个变量共享一段存储空间,一个__CFRunLoopSource结构体变量要么对应version0类型的事件源,要么对应version1类型的事件源。其中,version0和version1分别在源码中对应事件源Source0和Source1。Source0和Source1的区别如下所示。

1)Source0对应需要手动触发的事件,对应官方文档Input Source中的Custom和performSelector:onThread事件源。

2)Source1表示基于端口触发的事件,对应官方文档Input Source中Port的事件源。

在源码中对事件源的操作并不会直接使用__CFRunLoopSource对象,而是使用CFRunLoopSourceRef对象。

模式(Mode)指的是一个包括输入源(Inputsource)、定时器(Timer)、观察者(Observer)的模型对象。一个runloop在运行的时候,需要配置一个模式用来告诉runloop需要执行哪些事件。简单点来说,模式就是用来存储runloop需要响应的事件,这些事件包括许多输入源、定时器和观察者。下图是模式的结构图。

模式的结构图

再到源码CFRunLoop.c中看一下模式的定义,如下所示。


从上述__CFRunLoopMode定义中可得,集合_sources0和_sources1对应事件源,数组observers对应观察者,数组_timers对应定时器。源码中对模式的操作不直接使用__CFRunLoopMode对象,而是使用CFRunLoopModeRef对象。

当运行一个runlopp时,该运行循环会执行当前模式中的事件源、定时器和通知观察者。Apple提供了以下三种可用的模式。

1)NSDefaultRunLoopMode和kCFRunLoopDefaultMode这两个模式作用相同,都表示默认模式,区别是NSDefaultRunLoopMode属于Cocoa框架,而kCFRunLoopDefaultMode属于CoreFoundation框架,本质上NSDefaultRunLoopMode其实是对kCFRunLoopDefaultMode的封装,NSTimer默认注册到这个模式中。

2)在Cocoa框架中,UITrackingRunLoopMode表示UI跟踪模式。

3)NSRunLoopCommonMode和kCFRunLoopCommonMode这两个模式作用相同,都表示公共模式,区别是NSRunLoopCommonMode属于Cocoa框架,而kCFRunLoopCommonMode属于CoreFoundation框架。需要特别注意的是:公共模式并不是一个真正意义上存在的模式,官方将这种模式称作“伪模式”(Pseudo-Mode)。通过上文对模式的描述可以得知,一个模式必须要包括事件源、定时器、观察者这三个属性,而公共模式只是将某个输入源、定时器和观察者标记为Common,并将它们注册到每个标记Common的runloop中。下文在讲到runloop时会进一步分析,如何注册到标记Common的runloop中。

在__CFRunLoopMode的定义中提到了观察者和定时器。其中观察者为检测runloop状态的对象,当runloop状态发生变化时会通知观察者;不同于Source0和Source1,定时器作为一个属性,只有在执行NSTimer时才会触发timer事件。先来看一下runloop有哪些活动状态。

上述几种状态的含义如下。

1)kCFRunLoopEntry表示刚进入runloop的时候。

2)kCFRunLoopBeforeTimers表示将要处理timer。

3)kCFRunLoopBeforeSources表示将要处理Source。

4)kCFRunLoopBeforeWaiting表示将要进入休眠状态。

5)kCFRunLoopAfterWaiting表示将要从休眠状态进入唤醒状态。

6)kCFRunLoopExit表示退出状态。

7)kCFRunLoopAllActivities表示所有1)~6)中的状态。

再来看一下观察者的在源码中的定义。

CFRunLoopObserverRef将要观察的CFRunLoopActivity活动注册到_activities中,_activities中不同的标识位表示不同的活动,因此,活动的注册其实是通过或操作某些位置实现的。当一个runloop的状态发生变化时,通过回调_callout通知所有监听这个状态的观察者,而_callout实际上是将一个函数指针指向一个名字很长的函数,CFRunLoopObserverCallBack的定义如下。

typedef void (*CFRunLoopObserverCallBack)(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info);

_callout指向的函数有以下几个。

这几个函数经常出现在Xcode的调用栈中,由于命名规范,从名字中就能得知每个函数的作用,这里不再做多余的解释。

再来看定时器在源码中的实现。

__CFRunLoopTimer是一个基于mk_timer实现的定时器,通过_callout回调实现定时执行任务。NSTimer其实是对CFRunLoopTimerRef的一个上层封装。

在学习完Source和Mode之后,再来进一步探究runloop的实现。runloop也是一个对象,在iOS/macOS中提供NSRunLoop和CFRunLoopRef这两个结构体来操作runloop对象。先来看一下runloop在源码中的定义。

__CFRunLoop定义非常简单,但需要解释一下commonModes和commonModeItems的作用。commonModes的作用是用来存放被注册为common model的模式,例如可以将UITrackingRunLoopMode和kCFRunLoopDefaultMode注册为commonmodel。commonModeItems中存放被注册为common model item的Source、Timer或者Observer。commonModeItems中的所有Source、Timer和Observer都会放入commonModes中的每个model中。举一个简单的例子说明,现在有一个runloop,这个runloop的commonModes中存放了kCFRunLoopDefaultMode和UITrackingRunLoopMode,commonModeItems中存放了一个timer,此时,不管runloop是运行在kCFRunLoopDefaultMode还是UITrackingRunLoopMode,这个timer都会运行,因为这个timer会注册到所有的commonmodel中。从这里可以得出runloop的本质其实是处理当前model中的Source、Timer和Observer。弄清楚了这个原理就可以很简单地解决NSTimer在UITrackingRunLoopMode中不起作用的问题。源码中给出了如何将一个model添加到commonModes中的方法,如下所示。

void CFRunLoopAddCommonMode(CFRunLoopRef rl, CFRunLoopMode mode);

还给出了如何添加或删除一个Source、Timer、Observer到commonModelItmes中,如下所示。

void CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFRunLoopMode mode);
void CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFRunLoopMode mode);
void CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFRunLoopMode mode);
void CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFRunLoopMode mode);
void CFRunLoopRemoveObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFRunLoopMode mode);
void CFRunLoopRemoveTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFRunLoopMode mode);

在认识了runloop的定义之后,再来看一下runloop的内部事件的运行。当一个线程开启runloop之后,这个runloop内部将会处理许多事件,官方文档将这些事件划分为10个不同的事件,事件之间关系的具体描述如图

runloop内部逻辑

所示。

上图非常清楚地描述了runloop的内部运行机制,但还是过于抽象,为了能够弄清楚runloop的原理,需要到源码中看runloop的具体实现。下面先给出runloop中最关键的几个函数。

void CFRunLoopRun(void) //runloop的运行入口
CFRunLoopRunResult CFRunLoopRunInMode(CFRunLoopMode mode, CFTimeInterval seconds, Boolean returnAfterSourceHandled) //指定特定的model运行runloop
static int32_t __CFRunLoopRun(...) //真正运行runloop的地方

弄清楚上述三个函数的关系及实现,就能弄清楚runloop的运行原理。先来看第一个函数的实现。

CFRunLoopRun对应了NSRunLoop中的run方法。CFRunLoopRun中实现了一个while循环,这个while循环中调用了CFRunLoopRunSpecific这个函数,注意CFRunLoopRun没有返回值,线程会不断调用CFRunLoopRunSpecific,这点很关键,也是为什么当调用[NSRunLoop run]的时候,run以后的代码不会再执行的原因,除非runloop停止或者退出,下文会再进行详细分析。当kCFRunLoopRunStopped为1时,表示runloop停止;当kCFRunLoopRunFinished为1时,表示runloop运行结束时,kCFRunLoopRunStopped和kCFRunLoopRunFinished任意一个为1,就会终止这个while循环。

CFRunLoopRunInMode的内部实现和CFRunLoopRun差不多,如下所示。

CFRunLoopRunInMode对应了NSRunLoop中的runMode方法。CFRunLoopRunInMode中只是调用了一个CFRunLoopRunSpecific,并返回这个函数的返回值。在看CFRunLoopRunSpecific之前,注意CFRunLoopRunSpecific的第一个参数是通过CFRunLoopGetCurrent获得的,这个函数的作用是获取当前线程的runloop对象,如果当前线程中不存在runloop对象,那么就为这个线程创建一个runloop对象。这个函数对应NSRunLoop中的currentRunLoop,由于runloop并不能显示地创建,因此,只能通过这个获取函数创建。

最后,再来看一下CFRunLoopRunSpecific和__CFRunLoopRun这个两个函数的实现。由于源码中CFRunLoopRunSpecific和__CFRunLoopRun的实现较为复杂,不易学习,因此,通过去除源码中的冗余代码和复杂逻辑,将源码整理为如下伪代码。

伪代码中许多地方都出现了对Observer的调用,作用是通知每个观察者runloop的状态发生了变化,因此,可以通过分析runloop的状态变化来认识runloop的运行原理,这会使得runloop的运行原理更加容易理解。前文中提到过,每一个Observer都是通过回调一个名字很长的函数实现通知的。下面通过Observer的回调函数即调用栈,给出runloop在__CFRunLoopRun中的状态变化过程。

使用调用栈不仅能够简单地描述runloop的整个运行的过程,还能够得出AutoreleasePool的创建和释放时机。当一个运行循环开始的时候会通过_objc_autoreleasePoolPush创建一个AutoreleasePool,当一个线程睡眠或退出的时候会销毁AutoreleasePool中的对象。这就是为什么主线程中不需要手动创建AutoreleasePool的原因,runloop会根据主线程的状态创建AutoreleasePool并清理AutoreleasePool中的对象。

以上就是runloop运行的整个过程的分析,下面结合NSTimer、NSThread和perform的使用,看一下runloop的实际应用。在结合NSTimer分析之前,需要知道哪些方式创建的NSTimer会开启runloop,哪些方式创建的NSTimer不会开启runloop。下面根据NSTimer创建时是否开启runloop给出以下区分。

//第一、二种方式创建的NSTimer不会开启runloop
1、+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
2、+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
//第三、四种方式创建的NSTimer会开启runloop
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

以timerxxx开头创建的定时器不会将NSTimer对象放入到runloop中,因此定时器不会被执行;以scheduledxxx开头创建的定时器,会将NSTimer对象放入到当前线程的runloop中,因此定时器会执行。下面使用timerxxx创建一个NSTimer对象,并开启定时器,猜想一下会出现什么结果?

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(print) userInfo:nil repeats:YES];
[timer fire];

很显然,timer并不会被开启,也不会执行print方法。因为这种方式创建的定时器没有将NSTimer对象放入到runloop中,调用fire方法后也不会执行。上文中指出,一个timer必须要放入到一个runloop中才能实现定时器功能。如何才能让timer运行起来?方法很简单,只要将timer放入到当前的runloop中即可,做法如下。

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(print) userInfo:nil repeats:YES];
NSRunLoop *runloop = [NSRunLoop currentRunLoop]; //获得当前线程的runloop
//以NSDefaultRunLoopMode模式将timer添加到runloop中
[runloop addTimer:timer forMode:NSDefaultRunLoopMode];
[timer fire];

此时,timer就能够作为定时器正常运行了,但有这里经常会遇到一个问题,当开启定时器,然后滑动UIScrollView时,定时器会停止。这是因为,滑动UIScrollView时,主线程的模式切换到了UITrackingRunLoopMode,而定时器是运行在NSDefaultRunLoopMode中的,所以UITrackingRunLoopMode模式下无法运行定时器。解决方法也很简单,将timer添加到NSRunLoopCommonModes中即可,如下所示。

 NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(print) userInfo:nil repeats:YES];
 NSRunLoop *runloop = [NSRunLoop currentRunLoop]; //获得当前线程的runloop
//以NSRunLoopCommonModes模式将timer添加到runloop中
[runloop addTimer:timer forMode:NSRunLoopCommonModes];
[timer fire];

上文中解释过NSRunLoopCommonModes的作用,它并不是一个真正意义上的模式,只是一个模式的占位符。使用NSRunLoopCommonModes只是将timer注册到runloop的_commonModeItems集合中,当runloop运行在被标记为common的模式时,会执行_commonModetems集合中所有的Soure、Timer、Observer。在主线程的runloop中,UITrackingRunLoopMode和NSDefaultRunLoopMode已经被标记为common,因此,无论主线程是运行在UITrackingRunLoopMode下还是NSDefaultRunLoopMode下,都会执行_commonModetems中的Soure、Timer、Observer。可以使用[NSRunLoop currentRunLoop]打印出主线程中的runloop对象来观察_commonModes和_commonModeItems,下面给出部分打印结果。


从打印结果中能得出,主线程的runloop默认运行在kCFRunLoopDefaultMode模式下,并且已经将UITrackingRunLoopMode和kCFRunLoopDefaultMode放入到commonmodes中。以上就是runloop在NSTimer中的运用。

Runloop在NSThread和perform中的运用在上文中就已经写过了,因此,直接将上文中的代码复制下来分析。

NSThread *t = [[NSThread alloc]initWithTarget:self selector:@selector(print) object:nil];
[self performSelector:@selector(print) onThread:t withObject:nil waitUntilDone:NO];
[t start];

- (void)print{
    NSLog(@"print");
    NSRunLoop *runloop = [NSRunLoop currentRunLoop];
    [runloop run]; //注意需要手动运行runloop
    //NSLog(@"here");
}

以上这段代码使得线程t能够一直运行的原因是调用了runloop的run方法让当前线程所在的runloop一直运行。原理在上文对源码的CFRunLoopRun函数分析中已经提示过,这里不再详述。这里还留下一个问题,如果在[runloop run]后再通过NSLog打印一句话,那么会出现什么样的结果?这个问题留给读者思考。

通过分析上述的示例代码对runloop在NSTimer、NSThread和perfrom中的使用做一个小结:NSTimer的运行依赖于runloop,需要手动将以timerxxx创建的timer放入到runloop中,以scheduledxxx创建的timer会被自动放入到当前线程的runloop中,并以NSDefaultRunLoopMode模式运行。NSThread通过initWithTarget创建的线程,如果不手动开启线程的runloop,那么该线程执行完一次任务后就会退出程序,无法再执行其他任务;如果想让线程一直运行,那么需要手动开启线程的runloop。perform方法是将任务放到某个线程中执行,执行过程中可以指定线程运行的mode。

简单点来说,runloop其实是一个while循环,用来接收事件并执行相应的工作。从应用程序的层面上来看,runloop是一个事件循环,用来响应用户的触摸事件、按钮点击事件和处理界面的刷新事件;从代码层面上来看,runloop是为了让线程处于一种忙则工作、闲则睡眠的状态,以提高CPU的利用率。然而runloop的实现并非这么简单,需要结合源码和经验才能有更深次的认识。如果能熟练地运用runloop机制,那么可以将程序设计得更加合理。

为什么把NSTimer对象以NSDefaultRunLoopMode添加到主运行循环以后,滑动scrollview的时候NSTimer却不动了?

答案:当scrollview滑动时,runloop会被切换到UITrackingRunLoopMode,而NSTimer在UITrackingRunLoopMode下无法继续执行,因此,在滑动scrollview时,NSTimer是不会继续计时的。此时只需要将NSTimer切换到NSRunLoopCommonModes即可。

NSTimer准吗?如果不准,那么如何实现一个精确的NSTimer?

答案:一般情况下可以使用NSTimer作为定时器使用。当使用NSTimer时有几个注意的地方:1)当NSTimer和UIScrollView一起使用时,需要将NSTimer所在的runloop模式切换到NSRunLoopCommonModes中;2)NSTimer中不要执行耗时任务,超过定时器的间隔则会跳过这个时间片段,导致计时错误。相对NSTimer来说,使用GCD作为定时器更加准确。

上一篇下一篇

猜你喜欢

热点阅读