iOS 开发进阶干货iOS成长之路iOS开发

iOS多线程--RunLoop

2017-10-18  本文已影响589人  Claire_wu

1 RunLoop简介

神秘的RunLoop。一个应用开始运行以后放在那里,如果不对它进行任何操作,这个应用就像静止了一样,不会自发的有任何动作发生,但是如果我们点击界面上的一个按钮,这个时候就会有对应的按钮响应事件发生。给我们的感觉就像应用一直处于随时待命的状态,在没人操作的时候它一直在休息,在让它干活的时候,它就能立刻响应。其实,这就是RunLoop的功劳。
RunLoop实际上是一个对象,这个对象在循环中用来处理程序运行过程中出现的各种事件(比如说触摸事件、UI刷新事件、定时器事件、Selector事件),从而保持程序的持续运行;而且在没有事件处理的时候,会进入睡眠模式,从而节省CPU资源,提高程序性能。

2 RunLoop和线程

说说线程,有些线程执行的任务是一条直线,起点到终点;而另一些线程要干的活则是一个圆,不断循环,直到通过某种方式将它终止。在iOS中,圆型的线程就是通过RunLoop不停的循环实现的。RunLoop和线程是紧密相连的,可以这样说RunLoop是为了线程而生,没有线程,它就没有存在的必要。

RunLoop的伪代码表现方式为如下:

int main(int argc, char * argv[]) {        
    BOOL running = YES;
    do {
        // 执行各种任务,处理各种事件
        // ......
    } while (running);
    return 0;
}

2.1 主线程的RunLoop对象默认创建及启动

iOS的应用程序里面,程序启动后会有一个如下的main() 函数:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

重点是UIApplicationMain() 函数,这个方法会为main thread 设置一个NSRunLoop 对象,这就解释了本文开始说的为什么我们的应用可以在无人操作的时候休息,需要让它干活的时候又能立马响应。

2.2 子线程的RunLoop对象需手动创建及启动

需要更多的线程交互则可以手动配置和启动,如果线程只是去执行一个长时间的已确定的任务则不需要。

//获取(创建)当前线程的RunLoop
NSRunLoop  *runloop = [NSRunLoop currentRunLoop];
//启动
[runloop run];

(有待后续例证,先把概念贴上)Cocoa中的NSRunLoop类并不是线程安全的,我们不能在一个线程中去操作另外一个线程的run loop对象,那很可能会造成意想不到的后果。不过幸运的是CoreFundation中的不透明类CFRunLoopRef是线程安全的,而且两种类型的run loop完全可以混合使用。Cocoa中的NSRunLoop类可以通过实例方法:- (CFRunLoopRef)getCFRunLoop;获取对应的CFRunLoopRef类,来达到线程安全的目的。

3 RunLoop相关类和方法

下面我们来了解一下Core Foundation框架下关于RunLoop的5个类,只有弄懂这几个类的含义,我们才能深入了解RunLoop运行机制。

CFRunLoopRef:代表RunLoop的对象
CFRunLoopModeRef:RunLoop的运行模式
CFRunLoopSourceRef:就是RunLoop模型图中提到的输入源/事件源
CFRunLoopTimerRef:就是RunLoop模型图中提到的定时源
CFRunLoopObserverRef:观察者,能够监听RunLoop的状态改变

RunLoop相关类关系图.png

一个RunLoop对象(CFRunLoopRef)中包含若干个运行模式(CFRunLoopModeRef),简称Mode。而每一个运行模式下又包含若干个输入源(CFRunLoopSourceRef)、定时源(CFRunLoopTimerRef)、观察者(CFRunLoopObserverRef),简称Mode Item。

3.1 CFRunLoopRef RunLoop对象

CFRunLoopRef就是Core Foundation框架下RunLoop对象类。我们可通过以下方式来获取RunLoop对象:

CFRunLoopGetCurrent(); // 获得当前线程的RunLoop对象
CFRunLoopGetMain(); // 获得主线程的RunLoop对象

在Foundation框架下获取RunLoop对象类的方法如下:

[NSRunLoop currentRunLoop]; // 获得当前线程的RunLoop对象
[NSRunLoop mainRunLoop]; // 获得主线程的RunLoop对象

由苹果源码(参看下图),这两个函数的内部实现看出线程和RunLoop是一一对应的,这种对应关系用一个字典保存起来,key是pthread,value是CFRunLoopRef。RunLoop在第一次获取时创建,然后在线程结束时销毁。所以,在子线程如果不手动获取RunLoop,它是一直都不会有的。


image.png image.png

3.2 CFRunLoopModeRef 运行模式

系统默认定义了多种运行模式(CFRunLoopModeRef),如下:

  1. kCFRunLoopDefaultMode:App的默认运行模式,通常主线程是在这个运行模式下运行
  2. UITrackingRunLoopMode:跟踪用户交互事件(用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他Mode影响)
  3. UIInitializationRunLoopMode:在刚启动App时第进入的第一个 Mode,启动完成后就不再使用
  4. GSEventReceiveRunLoopMode:接受系统内部事件,通常用不到
  5. kCFRunLoopCommonModes:是一种常用的模式集合,包含 NSDefaultRunLoopMode 、UITrackingRunLoopMode。这个模式存在的好处是,如果现在异步线程有个timer启动,不需要再所有的 RunLoop Mode 中都去加一遍,只需要直接在 NSRunLoopCommonModes 加一次即可。

其中kCFRunLoopDefaultMode、UITrackingRunLoopMode、kCFRunLoopCommonModes是我们开发中需要用到的模式。

3.3 输入事件来源

RunLoop接收输入事件来自两种不同的来源:输入源(input source)和定时源(timer source),两种源都使用程序的某一特定的处理例程来处理到达的事件。下图是官方RunLoop模型图,显示了RunLoop的概念结构以及各种源。
RunLoop就是线程中的一个循环,RunLoop在循环中会不断检测,通过Input sources(输入源)和Timer sources(定时源)两种来源等待接受事件;然后对接受到的事件通知线程进行处理,并在没有事件的时候进行休息。


官方RunLoop模型图

在启动RunLoop之前,必须添加监听的输入源事件或者定时源事件,否则调用[runloop run]会直接返回,而不会进入循环让线程长驻。具体可参看基于端口的输入源和定时源的demo实例。

3.3.1 CFRunLoopSourceRef 输入源

输入源常见的有:基于端口的输入源、自定义输入源、Cocoa上的Selector源。

3.3.1.1 基于端口的输入源

Cocoa和Core Foundation内置支持使用端口相关的对象和函数来创建的基于端口的源。例如,在Cocoa里面你从来不需要直接创建输入源。你只要简单的创建端口对象,并使用NSPort的方法把该端口添加到RunLoop。端口对象会自己处理创建和配置输入源。

- (void)showDemo4
{
    // 创建线程,并调用run1方法执行任务
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    [self.thread start];
}

- (void) run1
{
    // 这里写任务
    NSLog(@"----run1-----%@",[NSThread currentThread]);
    //端口添加到RunLoop
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    //启动RunLoop(在启动之前一定是添加了输入源或者定时源的,[runloop run]会直接返回)
    [[NSRunLoop currentRunLoop] run];
    // 测试是否开启了RunLoop,如果开启RunLoop,则来不了这里,因为RunLoop开启了循环。
    NSLog(@"-------------");
}

#这段也可以只作为了解#在Core Foundation,你必须人工创建端口和它的RunLoop源。我们可以使用端口相关的函数(CFMachPortRef,CFMessagePortRef,CFSocketRef)来创建合适的对象。下面的例子展示了如何创建一个基于端口的输入源,将其添加到RunLoop并启动:

void createPortSource()
{
    CFMessagePortRef port = CFMessagePortCreateLocal(kCFAllocatorDefault, CFSTR("com.someport"),myCallbackFunc, NULL, NULL);
    CFRunLoopSourceRef source =  CFMessagePortCreateRunLoopSource(kCFAllocatorDefault, port, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
    while (pageStillLoading) {
        NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
        CFRunLoopRun();
        [pool release];
    }
    CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
    CFRelease(source);
}

3.3.1.2 自定义输入源(只作为了解即可,具体还没有实践)

在Core Foundation程序中,必须使用CFRunLoopSourceRef类型相关的函数来创建自定义输入源,接着使用回调函数来配置输入源。Core Fundation会在恰当的时候调用回调函数,处理输入事件以及清理源。常见的触摸、滚动事件等就是该类源,由系统内部实现。一般我们不会使用该种源,第三种情况已经满足我们的需求。

除了定义在事件到达时自定义输入源的行为,你也必须定义消息传递机制。源的这部分运行在单独的线程里面,并负责在数据等待处理的时候传递数据给源并通知它处理数据。消息传递机制的定义取决于你,但最好不要过于复杂。创建并启动自定义输入源的示例如下:

void createCustomSource()
{
    CFRunLoopSourceContext context = {0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL};
    CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
    while (pageStillLoading) {
        NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
        CFRunLoopRun();
        [pool release];
    }
    CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
    CFRelease(source);
}

用button的点击事件来举个例子,看点击时程序的调用栈,可以看到点击事件是由source0来处理的。


button点击是source0输入源.png

3.3.1.3 Cocoa上的Selector源

Cocoa允许你在任何线程执行selector方法。和基于端口的源一样,执行selector请求会在目标线程上序列化,减缓许多在线程上允许多个方法容易引起的同步问题。不像基于端口的源,一个selector执行完后会自动从RunLoop里面移除。

当在其他线程上面执行selector时,目标线程须有一个活动的RunLoop。对于你创建的线程,这意味着线程在你显式的启动RunLoop之前是不会执行selector方法的,而是一直处于休眠状态。
NSObject类提供了类似如下的selector方法:

/// 主线程
performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:

/// 指定线程
performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:

/// 针对当前线程
performSelector:withObject:afterDelay:         
performSelector:withObject:afterDelay:inModes:

/// 取消,在当前线程,和上面两个方法对应
cancelPreviousPerformRequestsWithTarget:
cancelPreviousPerformRequestsWithTarget:selector:object:

还要注意以下这些selector方法,它们是同步执行的,和线程无关,主线程子线程都可以用。不会添加到runloop,而是直接执行,相当于是[self xxx]这样调用,只不过是编译期、运行期处理的不同。

- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;

3.3.2 CFRunLoopTimerRef 定时源

定时源在预设的时间点同步方式传递消息,这些消息都会发生在特定时间或者重复的时间间隔。定时源则直接传递消息给处理例程,不会立即退出RunLoop。

需要注意的是,尽管定时器可以产生基于时间的通知,但它并不是实时机制。和输入源一样,定时器也和你的RunLoop的特定模式相关。如果定时器所在的模式当前未被RunLoop监视,那么定时器将不会开始直到RunLoop运行在相应的模式下。类似的,如果定时器在RunLoop处理某一事件期间开始,定时器会一直等待直到下次RunLoop开始相应的处理程序。如果RunLoop不再运行,那定时器也将永远不启动。

创建定时器源有两种方法,
方法一:

NSTimer *timer = [NSTimer timerWithTimeInterval:4.0
                                                   target:self
                                                   selector:@selector(backgroundThreadFire:) 
                                                   userInfo:nil
                                                   repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timerforMode:NSDefaultRunLoopMode];

方法二:

//scheduledTimer方式下,NSTimer会自动被加入到了RunLoop的NSDefaultRunLoopMode模式下
[NSTimer scheduledTimerWithTimeInterval:10
                                       target:self
                                       selector:@selector(backgroundThreadFire:)
                                       userInfo:nil
                                       repeats:YES];

具体使用举例如下:

/**
 * 用来展示CFRunLoopModeRef和CFRunLoopTimerRef的结合使用
 */
- (void)showDemo1
{
    // 定义一个定时器,约定两秒之后调用self的run方法
    NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
    
    // 将定时器添加到当前RunLoop的NSDefaultRunLoopMode下,一旦RunLoop进入其他模式,定时器timer就不工作了
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    
    // 将定时器添加到当前RunLoop的UITrackingRunLoopMode下,只在拖动情况下工作
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];

    // 将定时器添加到当前RunLoop的NSRunLoopCommonModes下,定时器就会跑在被标记为Common Modes的模式下
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    
    // 调用了scheduledTimer返回的定时器,已经自动被加入到了RunLoop的NSDefaultRunLoopMode模式下。
    [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
}

- (void)run
{
    NSLog(@"---run");
    
}

3.4 CFRunLoopSourceRef的数据结构source0、source1

虽然按3.3介绍的输入源分好几种,但数据结构只有两类(source0、source1)。

Source1和Timer都属于端口事件源,不同的是所有的Timer都共用一个端口(Timer Port),而每个Source1都有不同的对应端口。
Source0属于Input Source中的一部分,Input Source还包括custom自定义源,由其他线程手动发出。

3.5 CFRunLoopObserverRef 观察者

CFRunLoopObserverRef是观察者,用来监听RunLoop的状态改变,CFRunLoopObserverRef可以监听的状态改变有以下几种:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),               // 即将进入Loop:1
    kCFRunLoopBeforeTimers = (1UL << 1),        // 即将处理Timer:2    
    kCFRunLoopBeforeSources = (1UL << 2),       // 即将处理Source:4
    kCFRunLoopBeforeWaiting = (1UL << 5),       // 即将进入休眠:32
    kCFRunLoopAfterWaiting = (1UL << 6),        // 即将从休眠中唤醒:64
    kCFRunLoopExit = (1UL << 7),                // 即将从Loop中退出:128
    kCFRunLoopAllActivities = 0x0FFFFFFFU       // 监听全部状态改变  
};

具体使用举例如下:

/**
 * 用来展示CFRunLoopObserverRef使用
 */
- (void)showDemo2
{
    // 创建观察者
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"监听到RunLoop发生改变---%zd",activity);
    });
    
    // 添加观察者到当前RunLoop中
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
    
    // 释放observer
    CFRelease(observer);
}

4 RunLoop事件队列原理

RunLoop运行逻辑图

在每次运行开启RunLoop的时候,所在线程的RunLoop会自动处理之前未处理的事件,并且通知相关的观察者。

具体的顺序如下:

  1. 通知观察者RunLoop已经启动
  2. 通知观察者即将要开始的定时器
  3. 通知观察者任何即将启动的非基于端口的源
  4. 启动任何准备好的非基于端口的源
  5. 如果基于端口的源准备好并处于等待状态,立即启动;并进入步骤9
  6. 通知观察者线程进入休眠状态
  7. 将线程置于休眠直到任一下面的事件发生:
    • 某一事件到达基于端口的源
    • 定时器启动
    • RunLoop设置的时间已经超时
    • RunLoop被显式唤醒
  8. 通知观察者线程将被唤醒
  9. 处理未处理的事件
    • 如果用户定义的定时器启动,处理定时器事件并重启RunLoop。进入步骤2
    • 如果输入源启动,传递相应的消息
    • 如果RunLoop被显示唤醒而且时间还没超时,重启RunLoop。进入步骤2

10.通知观察者RunLoop结束。

5 什么时候使用RunLoop

仅当在为你的程序创建辅助线程的时候,你才需要显式运行一个RunLoop。RunLoop是程序主线程基础设施的关键部分。所以程序提供了代码运行主程序的循环并自动启动RunLoop。iOS程序中UIApplication的run方法(或Mac OS X中的NSApplication)作为程序启动步骤的一部分,它在程序正常启动的时候就会启动程序的主循环。

对于辅助线程,你需要判断一个RunLoop是否是必须的。如果是必须的,那么你要自己配置并启动它。你不需要在任何情况下都去启动一个线程的RunLoop。比如,你使用线程来处理一个预先定义的长时间运行的任务时,你应该避免启动RunLoop。RunLoop在你要和线程有更多的交互时才需要,比如以下情况:

如果你决定在程序中使用RunLoop,那么它的配置和启动都很简单。和所有线程编程一样,你需要计划好在辅助线程退出线程的情形。让线程自然退出往往比强制关闭它更好。

5.1 实战--Timer使用

实际应用开发中,会发现滑动事件会导致Timer暂停不执行,可以采用RunLoop的kCFRunLoopCommonModes模式解决此问题,具体可参看3.3.2节。

5.2 实战--ImageView延迟显示

实际应用开发中,会遇到滑动时加上图片加载会导致页面卡顿的情况,因此需要在滑动时延迟加载图片,具体代码示例如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [self showDemo3]; // 用来展示UIImageView的延迟显示
}

/**
 * 用来展示UIImageView的延迟显示
 */
- (void)showDemo3
{
    NSLog(@"showDemo3 begin");
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];
}
图片延迟展示

5.3 实战--后台常驻线程

- (void)showDemo4
{
    // 创建线程,并调用run1方法执行任务
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    [self.thread start];
}

- (void) run1
{
    // 这里写任务
    NSLog(@"----run1-----%@",[NSThread currentThread]);
    
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    
    // 测试是否开启了RunLoop,如果开启RunLoop,则来不了这里,因为RunLoop开启了循环。
    NSLog(@"-------------");
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    // 利用performSelector调用常驻线程self.thread的run2方法
    [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO]; // 用来展示常驻内存的方式
}

- (void) run2
{
    NSLog(@"----run2------");
}

经过运行测试,除了之前打印的----run1-----,每当我们点击屏幕,都能调用----run2------。

5.4 PerformSelecter...

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

5.5 GCD

RunLoop 底层会用到 GCD 的东西,GCD 的某些 API 也用到了 RunLoop。如当调用了 dispatch_async(dispatch_get_main_queue(), block)时,主队列会把该 block 放到对应的线程(恰好是主线程)中,主线程的 RunLoop 会被唤醒,从消息中取得这个 block,回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 来执行这个 block:

GCD.png

6 参考资料

iOS多线程--彻底学会多线程之『RunLoop』
关于Runloop的原理探究及基本使用
iOS开发-Runloop详解(简书)

上一篇下一篇

猜你喜欢

热点阅读