深入浅出 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。

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

4.3 UI 刷新

在 iOS 和 macOS 中,UI 刷新主要由主线程上的 RunLoop 管理,而 UI 刷新的具体执行时机通常是在 RunLoop 的 beforeWaiting 阶段。这种设计的主要原因是为了确保 UI 更新的效率和响应性。

beforeWaiting 时机的优势

beforeWaiting 是 RunLoop 在即将进入休眠之前的最后一个阶段。这个时机特别适合 UI 刷新,原因包括:

  • 避免不必要的重复刷新:UI 刷新操作是昂贵的操作。如果 UI 刷新放在 RunLoop 的其他阶段,可能会在同一个 RunLoop Cycle 中多次触发刷新。而在 beforeWaiting 阶段,由于 RunLoop 即将进入休眠状态,这时触发 UI 刷新可以确保当前所有的 UI 更新操作都已经合并完成,不会浪费资源进行多次刷新。
  • 最大化响应性:当 RunLoop 即将休眠时,说明当前没有其他高优先级的任务需要立即处理。这时触发 UI 刷新,能最大限度地利用主线程的空闲时间来更新 UI,从而提升应用的响应性和用户体验。
  • 避免 UI 更新被阻塞:如果 UI 刷新放在其他阶段,例如定时器或输入源阶段,UI 刷新可能会被其他更高优先级的事件(如用户交互事件)阻塞。而放在 beforeWaiting 阶段,则是确保所有输入事件处理完毕后,再进行 UI 刷新,从而使 UI 刷新不被其他任务阻塞。

补充

是否可以自定义Model?

参考
可以自定义Model。
对于开发者而言经常用到的Mode还有一个kCFRunLoopCommonModes(NSRunLoopCommonModes),其实这个并不是某种具体的Mode,而是一种模式组合,在iOS系统中默认包含了NSDefaultRunLoopMode和 UITrackingRunLoopMode(注意:并不是说Runloop会运行在kCFRunLoopCommonModes这种模式下,而是相当于分别注册了 NSDefaultRunLoopMode和 UITrackingRunLoopMode。当然你也可以通过调用CFRunLoopAddCommonMode()方法将自定义Mode放到 kCFRunLoopCommonModes组合)。

我们常常还会碰到一些系统框架自定义Mode,例如Foundation中NSConnectionReplyMode。还有一些系统私有Mode,例如:GSEventReceiveRunLoopMode接受系统事件,UIInitializationRunLoopMode App启动过程中初始化Mode。

在代码中,可以通过名称识别 mode。Cocoa、Cocoa Touch、Core Foundation 均提供了 default mode 和常用 mode,也可以通过为 mode 名称指定自定义字符串来创建自定义 mode。虽然 mode 名称可以任意指定,但 mode 内容不是任意的,必须提供 input source、timer、observer 中的一个或多个。Mode 可用于过滤不需要的 source。大部分情况下,使用系统定义的 default mode 即可。UIScrollView滑动时主线程会进入UITrackingRunLoopMode。 创建 input source 时,可以将其分配给一种或多种 mode。mode 决定任一时刻监控哪些输入源。通常,在 default mode 运行 run loop,但也可以指定自定义 mode。如果 input source 不在当前监控的 mode,其产生的事件将被保留,等 run loop 在该 mode 运行时才传递。

source0和source1区别

参考

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