深入理解Runloop总结篇

1,870 阅读11分钟

一、简介

一、什么是runloop

  runloop是一个通过管理内部循环来接收和处理App运行期间消息事件的一个对象

事件(比如说触摸事件、UI刷新事件、定时器事件、Selector事件)和消息,

二、三个重要的作用

1.保持程序的持续运行 保活 

2.处理App中的各种事件(比如触摸事件、定时器事件等) 

3.节省CPU资源,提高程序性能:该做事时做事,该休息时休息 

三、可以检测的三种事件类型

分别是CFRunLoopSource、CFRunLoopTimer、CFRunLoopObserver

要让一个RunLoop跑起来还需要run loop modes,每一个source, timer和observer添加到RunLoop中时必须要与一个模式(CFRunLoopMode)相关联才可以运行。

RunLoop主要处理以下6类事件 主线程所有函数 都是从6个函数之一 调起 

 1、RunLoopObserver 触发 

 2、消息通知、非延迟的perform、dispatch调用、block回调、KVO 

 3、MAIN_DISPATCH_QUEUE - 主调度队列 

 4、TIMER_CALLBACK - 延迟的perform, 延迟dispatch调用 

 5、Source0 - 处理App内部事件、App自己负责管理(触发),如UIEvent、CFSocket。普通函数调用,系统调用 

 6、Source1 - 由RunLoop和内核管理,Mach port驱动,如CFMachPort、CFMessagePort

下面具体介绍

CFRunLoopSourceRef

source是RunLoop的数据源(输入源)的抽象类(protocol),

Source有两个版本:Source0 和 Source1

    • source0处理App内部事件,App自己负责管理(触发runloop),如UIEvent,CFSocket;只包含了一个回调(函数指针),使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。                                  处理App内部事件,App自己负责管理(出发),如UIEvent(Touch事件等,GS发起到RunLoop运行再到事件回调到UI)、CFSocketRef。
    • Source1:由RunLoop和内核管理,由mach_port驱动(特指port-based事件),如CFMachPort(轻量级的进程间通信的方式,NSPort就是对它的封装,还有Runloop的睡眠和唤醒就是通过它来做的)、CFMessagePort、NSSocketPort。特别要注意一下Mach port的概念,它是一个轻量级的进程间通讯的方式,可以理解为它是一个通讯通道假如同时有几个进程都挂在这个通道上,那么其它进程向这个通道发送消息后,这些挂在这个通道上的进程都可以收到相应的消息。这个Port的概念非常重要,因为它是RunLoop休眠和被唤醒的关键,它是RunLoop与系统内核进行消息通讯的窗口。



CFRunLoopTimerRef

基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用(底层基于使用mk_timer实现)。它受RunLoop的Mode影响(GCD的定时器不受RunLoop的Mode影响),当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。如果线程阻塞或者不在这个Mode下,触发点将不会执行,一直等到下一个周期时间点触发。


CFRunLoopObserverRef

观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个

enum CFRunLoopActivity {
    kCFRunLoopEntry              = (1 << 0),    // 即将进入Loop   
    kCFRunLoopBeforeTimers      = (1 << 1),    // 即将处理 Timer        
    kCFRunLoopBeforeSources     = (1 << 2),    // 即将处理 Source  
    kCFRunLoopBeforeWaiting     = (1 << 5),    // 即将进入休眠     
    kCFRunLoopAfterWaiting      = (1 << 6),    // 刚从休眠中唤醒   
    kCFRunLoopExit               = (1 << 7),    // 即将退出Loop  
    kCFRunLoopAllActivities     = 0x0FFFFFFFU  // 包含上面所有状态  
};
typedef enum CFRunLoopActivity CFRunLoopActivity;

这里要提一句的是,timer和source1(也就是基于port的source)可以反复使用,比如timer设置为repeat,port可以持续接收消息,而source0在一次触发后就会被runloop移除。

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

RunLoop的Mode

CFRunLoopMode 和 CFRunLoop的结构大致如下:

struct __CFRunLoopMode {
    CFStringRef _name;            // Mode Name, 例如 @"kCFRunLoopDefaultMode"
    CFMutableSetRef _sources0;    // Set
    CFMutableSetRef _sources1;    // Set
    CFMutableArrayRef _observers; // Array
    CFMutableArrayRef _timers;    // Array
    ...
};

struct __CFRunLoop {
    CFMutableSetRef _commonModes;     // Set
    CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
    CFRunLoopModeRef _currentMode;    // Current Runloop Mode
    CFMutableSetRef _modes;           // Set
    ...
}; 
一个RunLoop包含了多个Mode,每个Mode又包含了若干个Source/Timer/Observer。

每次调用 RunLoop的主函数时,只能指定其中一个Mode,这个Mode被称作CurrentMode。

如果需要切换 Mode,只能退出Loop,再重新指定一个Mode进入。

这样做主要是为了分隔开不同Mode中的Source/Timer/Observer,让其互不影响。下面是5种Mode
  • kCFDefaultRunLoopMode 
    • App的默认Mode,通常主线程是在这个Mode下运行
  • UITrackingRunLoopMode 
    • 界面跟踪Mode,用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响
  • UIInitializationRunLoopMode 
    • 刚启动App时第进入的第一个Mode,启动完成后就不再使用
  • GSEventReceiveRunLoopMode 
    • 接受系统事件的内部Mode,通常用不到
  • kCFRunLoopCommonModes 
    • 这是一个占位用的Mode,不是一种真正的Mode


RunLoop运行机制



RunLoop的挂起和唤醒

Runloop的挂起,实际上是指定一个端口,给内核发消息,这里是一个等待消息,就是等待被唤醒。

1、指定用于被唤醒的mach port端口;
2、调用mach_msg监听唤醒端口,被唤醒前,系统内核将这个线程挂起,停留在mach_msg_trap状态;
3、由另一个线程(或者另一个进程中的某个线程)向内核发送这个端口的msg后trap状态被唤醒,RunLoop继续开始干活。


这接种情况下会被唤醒

  1. 存在Source0被标记为待处理,系统调用CFRunLoopWakeUp唤醒线程处理事件
  2. 定时器时间到了
  3. RunLoop自身的超时时间到了
  4. RunLoop外部调用者唤醒
RunLoop 的核心就是一个 mach_msg() ,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。


RunLoop和线程

线程的作用是用来执行特定的一个或多个任务,但是在默认情况下,线程执行完之后就会退出,就不能再执行任务了。RunLoop让线程能够处理任务,并不退出

1. iOS开发中会遇到两个线程对象: pthread_t和NSThread,pthread_t和NSThread 是一一对应的

 pthread_main_thread_np()或 [NSThread mainThread]来获取主线程;

pthread_self()或[NSThread currentThread]来获取当前线程。

CFRunLoop 是基于 pthread 来管理的

2. 线程与RunLoop是一一对应的关系(对应关系保存在一个全局的Dictionary里)

线程创建之后是没有RunLoop的(主线程除外),RunLoop的创建是发生在第一次获取时,销毁则是在线程结束的时候。只能在当前线程中操作当前线程的RunLoop,而不能去操作其他线程的RunLoop。



RunLoop实例

实例1:RunLoop角度,如何处理一次点击事件的

1. 苹果注册了一个source1 (基于mach port的)用来接收系统事件,其回调__IOHIDEventSystemClientQueueCallback()。
****为什么是source1 ,不是处理App事件的Source0?****
Source1 接收IOHIDEvent,之后再回调__IOHIDEventSystemClientQueueCallback()内触发的Source0,Source0再触发的 _UIApplicationHandleEventQueue()。

2. 当硬件事件(触摸/锁屏/摇晃等)发生后,先由IOKit.framework 生成 IOHIDEvent事件,并由springBoard接收。

3. 随后mach port 转发给需要的APP进程。苹果注册的source1会触发回调,Source0再触发_UIApplicationHandleEventQueue() 进行应用内部的分发,并处理成UIEvent,识别手势、屏幕旋转静音等 ,发送给UIwindow。

4. UIWindow调用- (UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event方法, 遍历每一个子视图,查找出事件产生的视图


实例2: GCD如何实现调回主线程执行的,GCD由 子线程 返回到 主线程

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,

 libDispatch 会向主线程的 RunLoop 发送消息,会触发 RunLoop 的 Source 1 事件,RunLoop会被唤醒,并从消息中取得这个 block,并在回调里执行这个 block。

 Runloop只处理主线程的block,dispatch 到其他线程仍然是由 libDispatch 处理的。


实例3:实现一个常驻线程

  • 为当前线程开启一个RunLoop
  • 向该RunLoop中添加一个Port/Source等维持RunLoop的事件循环
  • 启动该RunLoop

    AFN中常驻线程的最好示例


1. currentRunLoop方法是获得RunLoop,如果没有就会创建RunLoop

2. [thread start]为了让线程活下来,如果没有这一行代码,RunLoop并不会挂起,线程运行完就会退出;



实例4:TableView延迟加载图片的新思路:

将setImage放到NSDefaultRunLoopMode去做,也就是在滑动的时候并不会去调用这个方法,而是会等到滑动完毕切换到NSDefaultRunLoopMode下面才会调用。

UIImage *downLoadImage = ...;  
[self.avatarImageView performSelector:@selector(setImage:)  
                        withObject:downloadImage  
                        afterDelay:0  
                        inModes:@[NSDefaultRunLoopMode]];

这种优化方式,它的意思就是将设置图片的操作放在Default Mode的runloop中,
因为tableview在滑动时,所在的runloop的mode为UITrackingRunLoopMode,
这是两种完全不同的Mode,且水火不容。
滑动时,只会执行设置为UITrackingRunLoopMode的runloop,故而设置为NSDefaultRunLoopMode的runloop就不会执行,只有停止滑动的时候才会自动切换到NSDefaultRunLoopMode,这时就会去设置图片了。 

实例5:接收处理Crash

1、program received signal:SIGABRT SIGABRT一般是过度release或者发送unrecogized selector导致。
2、EXC_BAD_ACCESS是访问已被释放的内存导致,野指针错误。

由 SIGABRT 引起的Crash 是系统发这个signal给App,程序收到这个signal后,就会把主线程的RunLoop杀死,程序就Crash了 (该例只针对 SIGABRT引起的Crash有效。)

     CFRunLoopRef runloop = CFRunLoopGetCurrent();  
    //获取所有Mode,因为可能有很多Mode,每个Mode都需要跑,此处可以选择提交下崩溃信息之类的  
    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"程序崩溃了" message:@"崩溃信息" 
                delegate:nil cancelButtonTitle:@"取消" otherButtonTitles:nil];  
    [alertView show];  
    NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runloop));  
    while (1) {  
        //快速切换Mode  
        for (NSString *mode in allModes) {  
            CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);  
        }  
    }



实例6:NSRunloop 卡顿监控

1. 背景

先说屏幕,苹果移动设备屏幕,即显示器的刷新频率是60HZ,这是硬件设备决定。

显示器显示的内容是由显卡渲染的,显卡渲染一帧并显示到显示器上的时间点,程序可以通过CADisplayLink捕获。

由于iOS设备都开启了垂直同步,显卡总是等到显示器发出垂直同步信号后再开始渲染下一帧。如果两次垂直同步信号之间,即16.7ms内,渲染数据没有准备好,那么这一帧数据就会丢失,显示器刷新的仍然是上一帧的数据,造成掉帧卡顿。

2. 导致丢帧原因

渲染数据从哪来。

2.1. App主线程在CPU中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。
   随后CPU会将计算好的内容提交到GPU去

2.2. GPU进行变换、合成、渲染。把渲染结果提交到帧缓冲区去。

CPU和GPU不论哪个阻碍了显示流程,都会造成掉帧现象。


3. 该怎么做

  3.1 在程序中能做的只有监控CPU了,GPU无能为力,而且通过观察instruments,会发现除了离屏渲染,其他情况下GPU并不是瓶颈,平时开发中尽量避免即可。

   3.2 主线程上的CPU工作都是在RunLoop中进行的,从下面的伪代码可以看到主要计算工作都在kCFRunLoopAfterWaiting和下一次kCFRunLoopBeforeWaiting之间。(刚从休眠中唤醒 到 准备进入休眠)

 3.3 所以可以将监控kCFRunLoopAfterWaiting开始到下一次kCFRunLoopBeforeWaiting结束的运行时间。(这段时间 不是一次完整的runloop,但是这是autoreleasepool一次重载时机)

综上,为主线程的 RunLoop 添加一个 Observer ,来检测 RunLoop 的运行情况。

CFRunLoopObserverContext context = {0, NULL, NULL, NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                    kCFRunLoopAllActivities,
                                                    YES,
                                                    0,
                                                    &runLoopObserverCallBack,
                                                    &context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);


在回调中,使用mach_absolute_time()记录kCFRunLoopAfterWaiting的时间点,在下一次kCFRunLoopBeforeWaiting时计算RunLoop的运行时间,如果超时,可以根据需求处理,比如dump函数堆栈,并上传监控服务器等。示例中使用的是断言处理。

static const NSTimeInterval kRunLoopThreshold = 0.3;
//粗略计算一下,以运行时低于40帧为卡,则会掉20帧,卡住的时间约为320ms。
//可以假设如果runloop执行超过了0.3,主线程无法将计算好的内容提交给 GPU,会造成卡顿。
static uint64_t kStartTime = 0;
static void runLoopObserverCallBack(CFRunLoopObserverRef observer,
 CFRunLoopActivity activity, void *info)
{
  switch (activity) {
    case kCFRunLoopAfterWaiting:
        kStartTime = mach_absolute_time();
        break;
    case kCFRunLoopBeforeWaiting:
        if (kStartTime != 0 ) {
            uint64_t elapsed = mach_absolute_time() - kStartTime;
            mach_timebase_info_data_t timebase;
            mach_timebase_info(&timebase);
            NSTimeInterval duration = elapsed * timebase.numer / timebase.denom / 1e9;
            if (duration > kRunLoopThreshold) { 
                assert(0);
            }
        }
        break;
    default:
        break;
    }
}

上述计算中,在kCFRunLoopBeforeWaiting时每次都需要将mach_absolute_time()的时间转换成秒,会比较浪费,可以通过context参数传进来。