深入浅出 RunLoop

前言

文章主要分为四个部分

  • 一、RunLoop 简介
  • 二、RunLoop 相关接口
  • 三、RunLoop 相关逻辑流程
  • 四、RunLoop 休眠实现原理
  • 五、RunLoop 实际应用

一、RunLoop 简介

1.1 RunLoop 基本概念

RunLoop 顾名思义 运行循环,在程序运行过程中循环做一些事情。比如定时器、GCD、事件响应、界面刷新、手势识别、AutoreleasePool 等都是基于 RunLoop 的基础之上,没有 RunLoop 任何事都无法做。
一个线程一次只能执行一个任务,执行完成后线程就会退出。RunLoop 机制能让线程随时处理事件但并不退出。这里说的随时是指:程序需要运行时就保持程序的持续运行,不需要的时候就进入休眠状态。下述 1.2 是一个典型的案列。

NSRunLoop 和 CFRunLoopRef 都是和RunLoop 机制相关的类。CFRunLoopRef 基于 CoreFoundation 框架内,是纯 C 函数的 API,所有这些 API 都是线程安全的。CFRunLoopRef 的代码是开源的。NSRunLoop 是基于 CFRunLoopRef ,提供了面向对象的 API,但是这些 API 不是线程安全的。

1.2 为什么 main 函数不会 return掉 ?

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

上面main函数同一般函数相比,启动程序后并不会立刻 return 掉。其中UIApplicationMain 函数内部默认开启了主线程的 RunLoop ,并执行了一段类似无限循环的代码。UIApplicationMain 函数一直没有返回,所以运行程序之后会 保持持续运行状态,节省CPU资源,提高程序性能,该做事时做事,该休息时休息

//无限循环代码模式
int main(int argc, char * argv[]) {        
    BOOL running = YES;
    do {
        // 执行各种任务,处理各种事件
        // ......
    } while (running);

    return 0;
}

1.3 RunLoop 和 线程的关系

iOS中有2套API来访问和使用RunLoop:

  • Foundation:NSRunLoop
  • Core Foundation:CFRunLoopRef

关于RunLoop 和线程之间的关系要知道以下几点:

  • 1、 线程和 RunLoop 是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程作为 key,RunLoop 作为 value
  • 2、线程刚创建时并没有RunLoop对象,RunLoop会在第一次获取它时创建。4个自动获取的函数:CFRunLoopGetMain()、[NSrunLoop mainRunLoop] 和 CFRunLoopGetCurrent()、[NSrunLoop currentRunLoop] 。主线程的RunLoop对象获取是在 UIApplicationMain 内部,而子线程的RunLoop对象需要我们自己去获取。
  • 3、销毁则是在线程结束的时候。
  • 4、只能在当前线程中操作当前线程的RunLoop,而不能去操作其他线程的RunLoop。

二、RunLoop 相关接口

2.1 RunLoop 的结构


从上图可以看出RunLoop 中包含thread,即 RunLoop 和 线程一一对应。

和 RunLoop 相关的主要涉及五个类:

  • CFRunLoopRef:RunLoop对象
  • CFRunLoopModeRef:运行模式
  • CFRunLoopSourceRef:输入源/事件源
  • CFRunLoopTimerRef:定时源
  • CFRunLoopObserverRef:观察者
RunLoop的结构
image.png

从上图可以看出,RunLoop 对象中可以包含多个 Mode,每个 Mode 又包含多个 Source、Timer、Observer,RunLoop 运行过程中实际上就是去处理当前 mode 中的 source、timer、observer。

2.2 RunLoop 中的 Mode

关于Mode首先要知道:

  • 一个RunLoop 对象中可能包含多个Mode。
  • 每次调用 RunLoop 的主函数时,只能指定其中一个 Mode(CurrentMode)。如RunLoop启动时只能选择其中一个Mode,作为currentMode。
  • 如果需要切换Mode,只能退出当前Loop,再重新选择一个Mode进入。主要是为了分隔开不同的 Source、Timer、Observer,让它们之间互不影响。参考
  • RunLoop启动时只能选择其中一个Mode,作为currentMode

总共是有五种Mode:

  • kCFRunLoopDefaultMode:默认模式,主线程是在这个运行模式下运行
  • UITrackingRunLoopMode:跟踪用户交互事件(用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他Mode影响)
  • UIInitializationRunLoopMode:在刚启动App时第进入的第一个 Mode,启动完成后就不再使用
  • GSEventReceiveRunLoopMode:接受系统内部事件,通常用不到
  • kCFRunLoopCommonModes:伪模式,不是一种真正的运行模式,实际是kCFRunLoopDefaultModeUITrackingRunLoopMode的结合。

有这样一个场景,假设自己封装一个无限轮播视图,很有可能会出现这样一种情况:当你滑动轮播视图时,轮播视图的定时器不再起作用,不能通过定时器调整UIScrollView的偏移值。之所以会出项上述现象,是因为主线程的 RunLoop 里有两个 Mode:kCFRunLoopDefaultModeUITrackingRunLoopMode。默认情况下是defaultMode,但是当滑动UIScrollView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被执行。这样区分 mode 分隔开不同的 Source、Timer、Observer,让它们之间互不影响。这样做的好处是让不同模式下专心做自己的事情,可以更好的提高应用性能。当然如果想在滑动的时候不让定时器失效,可以使用CommonMode来解决。

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

参考
实际NSRunLoopCommonModes并不是一种模式,它只是一种标记,它能够让timer在放在NSRunLoopCommonModes中的所有模式下运行。而NSRunLoopCommonModes正好放了NSDefaultRunLoopMode和UITrackingRunLoopMode两种模式,所以timer就能在两种模式下运行了。

2.3 Mode 中的 CFRunLoopSourceRef

CFRunLoopSourceRef是事件源,主要有两种有Source0 和 Source1。

Source0 :非基于 Port。
  • 触摸事件处理
  • performSelector:onThread:
    只包含了一个回调(函数指针),不能主动触发事件。使用时,需先调用 CFRunLoopSourceSignal(source),将 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop)唤醒 RunLoop,让其处理这个事件。
Source1:基于Port。
  • 线程间通信。每个线程都有一个 port ,不同线程之前通过这个 port 进行通信。
  • 事件捕捉。如点击屏幕,Source1 先进行事件捕捉,会放在队列中,然后再一次包装为 Source0 触摸事件处理。

函数调用栈分类举例

如上图,创建一个按钮,添加点击事件,并在按钮回调事件添加断点,当执行到断点出左侧会出现相关栈调用信息。从上图可以看出:点击事件就是在Sources0中处理的。至于 Source1 主要是用来接收、分发系统事件,然后再分发到Sources0中处理。
Sources0:

2.4 Mode 中的 CFRunLoopTimerRef

NSTimerperformSelector:withObject:afterDelay: 都是通过CFRunLoopTimerRef处理的。CFRunLoopTimerRef 是定时源,你可以简单把它理解为NSTimer。其包含一个时间点和一个回调(函数指针)。当被加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间到时,RunLoop 会执行对应时间点的回调。

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

2.5 Mode 中的 CFRunLoopObserverRef

RunLoop 的状态UI界面的刷新(BeforeWaiting)autorelease pool都是通过 CFRunLoopObserverRef 处理的。

CFRunLoopObserverRef是观察者,主要用来监听RunLoop 的状态,主要有以下几种状态。

  • kCFRunLoopEntry : 即将进入RunLoop
  • kCFRunLoopBeforeTimers :即将处理Timer
  • kCFRunLoopBeforeSources:即将处理Source
  • kCFRunLoopBeforeWaiting :即将进入休眠
  • kCFRunLoopAfterWaiting:即将从休眠中唤醒
  • kCFRunLoopExit :即将从RunLoop中退出
  • kCFRunLoopAllActivities:监听全部状态改变

可以通过以下代码验证RunLoop的几种状态:

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

除了用于监听 RunLoop 的状态之外,UI刷新(BeforeWaiting)、Autorelease pool(BeforeWaiting)都与其有关系。

三、RunLoop 相关逻辑流程

RunLoop 逻辑流程

上图是笔者从网上找到的一张 RunLoop 运行的相关流程逻辑图。具体来说主要执行逻辑是这样的:

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

四、RunLoop 休眠实现原理

首先有一点肯定的是,RunLoop 的休眠不是一个类似 while(1).... 一直在循环的代码,因为这种属于线程阻塞,是需要消耗资源的。 一般休眠的实现和操作系统层面(内核层面)有关系。API 一般分为两种内核API和应用层面API。应用层面的 API 一般是提供给开发者直接使用,内核 API 开发者不能直接调用。RunLoop 休眠实现原理是用户态低调用 mach_msg, 继而转去调用内核态的 mach_msg,实现真正的休眠。当有消息需要处理时,内核态 mach_msg 会唤醒用户态,让线程去处理任务。


五、RunLoop 实际应用

4.1 线程保活

借助RunLoop可以实现线程保活的功能,关键是在于两行代码,具体请看如下代码。

- (void)viewDidLoad {
    [super viewDidLoad];
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(runOne) object:nil];
    [self.thread start];
}
- (void) runOne{
    NSLog(@"----任务1-----");
    // 下面两句代码可以实现线程保活
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    // 测试是否开启了RunLoop,如果开启RunLoop,则来不了这里,因为RunLoop开启了循环。
    NSLog(@"未开启RunLoop");
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 利用performSelector,在self.thread的线程中调用run2方法执行任务
    [self performSelector:@selector(runTwo) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void) runTwo{
    NSLog(@"----任务2------");
}

实现了上述代码之后,每次点击屏幕都会打印----任务2------,这说明子线程处于活跃状态。如果Mode里没有任何Source0/Source1/Timer/Observer,RunLoop会立马退出,上述代码中的 [NSPort port] 相当于往 RunLoop 中添加 Source1。[[NSRunLoop currentRunLoop] run] 相当于开启了一个无限循环,默认是 defaultMode,相应的线程永远也不会释放。即使调用CFRunLoopStop(CFRunLoopGetCurrent) 也只能停止其中的一次 [[NSRunLoop currentRunLoop] run],并不能持续有效。

在一些分析AFNetworking源码的文章中,也经常会出现如下这些代码。其核心也是为了实现线程后台常驻。

+ (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;
}

当后台线程执行任务时,通过 performSelector:onThread:..方法将任务放在后台线程的 RunLoop 中。正常来说,一个线程执行完任务后就退出了。开启runloop是为了防止线程退出。一方面避免每次请求都要创建新的线程;另一方面,因为connection 的请求是异步的,如果不开启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];
}

4.2 AutoreleasePool

应用程序一旦启动,主线程 RunLoop 里注册了两个 Observer。一个 Observer 监听即将进入 Loop 事件,回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池,并保证创建释放池发生在其他所有回调之前。另外一个 Observer 监视了两个事件 (RunLoop即将进入休眠和即将退出 RunLoop 事件) ,前者会调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;后者会调用 _objc_autoreleasePoolPop() 来释放自动释放池,并保证释放自动释放池事件发生在其它回调之后。

4.3 卡顿监测

所谓的卡顿一般是在主线程做了耗时操作,卡顿监测的主要原理是在主线程的 RunLoop 中添加一个 observer。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。检测是否一直处于 即将处理Source(kCFRunLoopBeforeSources) 状态(一直处于忙碌状态无法进入休眠状态)、 是否一直处于即将从休眠中唤醒 (kCFRunLoopAfterWaiting)状态(一直无法从休眠状态唤醒去处理事件) 。如果花费的时间大于某一个阙值,则认为卡顿,此时可以输出对应的堆栈调用信息。触发卡顿的时间阈值,我们可以根据 WatchDog 机制来设置。WatchDog 在不同状态下设置的不同时间,如下所示:

  • 启动(Launch):20s;
  • 恢复(Resume):10s;
  • 挂起(Suspend):10s;
  • 退出(Quit):6s;
  • 后台(Background):3min(在 iOS 7 之前,每次申请 10min; 之后改为每次申请 3min,可连续申请,最多申请到 10min)。

通过 WatchDog 设置的时间,我认为可以把启动的阈值设置为 10 秒,其他状态则都默认设置为 3 秒。其中的 3s 也可以理解为 3次 * 1s。

网上很多抓去卡顿堆栈或多或少存在问题,比较正确的解决方案可参考这篇文章
参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260