iOS 初识RunLoop
iOS 初识RunLoop
文章目录
- iOS 初识RunLoop
- RunLoop的概念
- RunLoop的功能
- RunLoop和线程的关系
- RunLoop的结构
- Mode
- Observer
- Timer 和 source
- 小结
- RunLoop的核心
- RunLoop的流程
- RunLoop的应用
- AutoreleasePool
- 响应触控事件
- 刷新界面
- 常驻线程
- 网络请求
- NSTimer 和 CADisplayLink
- NSTimer
- GCDTimer
- CADisplayLink
RunLoop的概念
一般来说,一个线程一次只能执行一个任务,执行完成后线程就退出,如果我们需要一个机制,让线程能随时处理事件而不退出.
可以将代码理解成下图:
do {//获取消息//处理消息
} while (消息 != 退出)
这种模型通常被称作Event Loop
很多系统和框架都有实现在iOS和macOS中就被称作RunLoop.
简单来说RunLoop就是一种循环,通过这种循环得以让应用程序持续运行,让线程可以随时处理事件,并且让线程需要进行事件处理的时候忙起来,在不需要事件处理的时候闲下来.
- RunLoop是通过内部维护的
事件循环
来实现对于事件/消息
进行管理的一个对象 - 没有消息处理时,休眠以避免资源占用,有消息需要处理的,立刻被唤醒
从代码上来看RunLoop是一个对象:
struct __CFRunLoop {CFRuntimeBase _base;pthread_mutex_t _lock; /* locked for accessing mode list */__CFPort _wakeUpPort; // used for CFRunLoopWakeUp 内核向该端口发送消息可以唤醒runloopBoolean _unused;volatile _per_run_data *_perRunData; // reset for runs of the run looppthread_t _pthread; //RunLoop对应的线程uint32_t _winthread;CFMutableSetRef _commonModes; //存储的是字符串,记录所有标记为common的modeCFMutableSetRef _commonModeItems;//存储所有commonMode的item(source、timer、observer)CFRunLoopModeRef _currentMode; //当前运行的modeCFMutableSetRef _modes; //存储的是CFRunLoopModeRefstruct _block_item *_blocks_head;//doblocks的时候用到struct _block_item *_blocks_tail;CFTypeRef _counterpart;
};
下面这个图片是一个RunLoop结构的一个概览:
RunLoop的功能
- 保持程序的持续运行
- 处理app中的各种事件
- 节省cpu资源,提高程序性能:在需要工作的时候才去工作,在不需要工作的时候就进入休眠状态,在休眠状态是不占用CPU的
RunLoop和线程的关系
RunLoop和线程是一一对应的,app启动之后,程序进入了主线程,apple帮我们在主线程启动了一个RunLoop.如果是我们开辟的子线程,那么需要我们手动开启RunLoop,而且如果你不主动去获取RunLoop的话,那么子线程的RunLoop是不会开启的,他是采用了一个懒加载的形式.
- 注意:苹果不允许直接创建 RunLoop,只能通过
CFRunLoopGetMain()
和CFRunLoopGetCurrent()
去获取,其内部会创建一个 RunLoop 并返回给你(子线程),而它的销毁是在线程结束时。
RunLoop在线程中的作用主要就是从 input source 和 timer source这里面接受事件,然后在线程中处理事件
输入源 (input source) 异步传递的事件,通常是从其他线程,app发送的消息
计时器源 (Timer source) 同步传递事件,这些事件在计划的时间,间隔触发.
input source 传递异步事件到对应的一个处理程序,并且退出
runUntilDate
方法.Timer source
传递事件到对应处理程序,但是不会退出runLoop.
runUntilDate:方法在指定时间到达前会保持RunLoop 处于运行状态,RunLoop运行时会处理 input source 的各种事件。
这里我们就可以方便理解下面这段代码的意思了
- 为什么
main
函数不会退出
int main(int argc, char * argv[]) {NSString * appDelegateClassName;@autoreleasepool {// Setup code that might create autoreleased objects goes here.appDelegateClassName = NSStringFromClass([AppDelegate class]);}return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
UIApplicationMain
内部默认开启了主线程的RunLoop
并执行了一段无限循环的代码,UIApplicationMain
函数一直没有返回,还在不断的接受处理消息以及等待休眠,所以运行程序之后,会保持持续运行状态.
RunLoop的结构
在 CoreFoundation 里面关于 RunLoop 有5个类:
- CFRunLoopRef
- CFRunLoopModeRef
- CFRunLoopSourceRef
- CFRunLoopTimerRef
- CFRunLoopObserverRef
他们之间的关系如下图:
一个RunLoop中有多个Mode,每一个mode有属于自己的Observer,Timer,Source.
Mode
Mode也就是模式,一个RunLoop当前只能处于一种Mode中,就好比当前只能是白天或者当前是夜晚.图中可以看到不同Mode之间是互不干扰的,处于各自的世界里面,AMode做了什么和BMode都是没有关系的,这也就是apple为什么会设置成两个状态来管理我们的滑动和默认状态.
apple给我提供了一下几种Mode:
kCFRunLoopDefaultMode
app默认的Mode,通常主线程是在这个Mode下运行的UITrackingRunLoopMode
界面追踪Mode,让UISrcollView处于滑动的时候就处于这个ModeUIInitializationRunLoopMode
:刚启动app就进入的第一个Mode.启动后就不在使用GSEventReceiveRunLoopMode
接受系统事件的内部Mode.通常情况下是用不到的kCFRunLoopCommonModes
:不会是一个真正意义上的一个Mode,但是如果你把事件丢到这里来,那么不管你处于什么mode,都会触发你想要执行的事件
当我们的程序运行起来的时候,我们就不用去动他了,它就处于一个kCFRunLoopDefaultMode
的状态,当你滚动他力,他就会处于UITrackingRunLoopMode
的一个状态,如果你想要在这两个状态都可以响应同一个事件的话,那么就要把事情都方法到kCFRunLoopCommonModes
中去执行
大部分情况我们只用使用下面的几种
kCFRunLoopDefaultMode
UITrackingRunLoopMode
kCFRunLoopCommonModes
Observer
Observer,这个就相当于观察你上班的状态的人,这里用一个枚举来表示当前RunLoop的状态:
/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {kCFRunLoopEntry = (1UL << 0), // 即将进入 LoopkCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 TimerkCFRunLoopBeforeSources = (1UL << 2), // 即将处理 SourcekCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒kCFRunLoopExit = (1UL << 7), // 即将退出 LoopkCFRunLoopAllActivities = 0x0FFFFFFFU // 所有的状态
};
Timer 和 source
从结构的那张图可以看到,Mode 中有一个 Timer 的数组,一个 Mode 中可以有多个 Timer。Timer其实就是计时器,工作原理其实是确定一个计时器,然后给一个任务设置开始事件,和多久执行一次的一个Timer,把它注册到RunLoop中去,然后RunLoop就会根据我们注册的一个时间点来实现对应的内容:
注意,RunLoop中的Timer也不一定是准确的,因为RunLoop的执行事件是有一个顺序的,所以我们要处理玩一个事件才可以处理下一个事件,所以按照逻辑上来讲,尽管事件到了我们还是要把上一个事件执行玩才可以执行这个事件,也就是说我们尽管设置了一个是Timer,也要把上一个事件执行完才可以执行下一个任务,所以按道理来说Timer也不一定准确.
Source
Source 是另外一种 RunLoop 要干的活,看源码的话,Source 其实是 RunLoop 的数据源抽象类.
这里apple给我定义了两种Version的source:
- source0 : 处理 App 内部事件,App 自己负责管理(触发),如
UIEvent
、CFSocket
。 - source1:由 RunLoop 内核管理,Mach port 驱动,如
CFMackPort
、CFMessagePort
。
Soucre1可以理解成一种线程之间通信的一种方式,在这ARunLoop中,我想让B线程传递东西给我们,那我们就要通过Port来进行一个传递,然后系统将传输的东西包装成 Source1,在线程 A 中监听 port 是否有东西传输过来,接收到后,唤醒 RunLoop 进行处理
小结
上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。(所以在下文中我们实现一个常住线程也是给他添加一个item,保证RunLoop不会被退出)
RunLoop的核心
前面我们一直重复的内容是RunLoop的是一个do - while循环,但是按道理来说do-while循环也是会占用cpu的,我们是怎么实现RunLoop的休眠功能呢?
就是它如何在没有消息处理时休眠,在有消息时又能唤醒。这样可以提高CPU资源使用效率 当然RunLoop它不是简单的while循环,不是用sleep来休眠,毕竟sleep这方法也是会占用cpu资源的。那它是如何实现真正的休眠的呢?
那就是:没有消息需要处理时,就会从用户态切换到内核态,用户态进入内核态后,把当前线程控制器交给内核态,这样的休眠线程是被挂起的,不会再占用cpu资源。
这里要注意用户态和内核态 这两个概念,还有mach_msg()方法。 内核态 这个机制是依靠系统内核来完成的。
RunLoop是通过通过内部维护的时间循环来对事件/消息进行管理的一个对象
- 没有消息需要处理时,休眠避免掉资源占用
- 用户态 -> 内核态
- 有消息时候,立刻被唤醒
- 内核态 -> 用户态
RunLoop的流程
具体流程:
- 通知Observer已经进入RunLoop
- 通知 Observer 即将处理 Timer
- 通知Observer即将处理source0
- 处理sourece0
- 如果有soucre1就通知跳转第九步
- 通知Observer即将休眠
- 线程休眠状态,除非遇到下面的情况:
- 有Source0
- Timer到时间执行
- 外部手动唤醒
- 为RunLoop设定时间超时
- 通知Observer线程刚被唤醒
- 处理等待处理事件
- 如果是 Timer 事件,处理 Timer 并重新启动循环,跳到 2
- 如果是source1触发,就处理source
- RunLoop如果手动被触发但尚未超时,重新启动循环
- 通知Observer即将退出
实际上 RunLoop 内部就是一个 do-while
循环。当你调用 CFRunLoopRun()
时,线程就会一直停留在这个循环里,直到超时或手动停止,该函数才会返回。
这里不用担心因为一直处于一个while中的时候,我们的线程就一直在运行,会浪费资源,但是实际上并非如此,我们的RunLoop进入休眠调用的函数mach_msg
这个函数是内核记别的指令,可以让这个线程进入一个等待的装入,然后需要的时候再唤醒,所以不用担心这个线程的一个占用问题.
RunLoop的应用
AutoreleasePool
首先我们之前讲过主线程创建的时候,会自动生成RunLoop,而主线程注册了两个Observer,来是实现这里的AutoreleasePool的管理:
- 第一个Observer,这里的Observer是用来监听一个事件
Entry
这个事件,即将进入 Loop 的时候,创建一个自动释放池,并且给了一个最高的优先级,保证自动释放池的创建发生在其他回调之前,这是为了保证能管理所有的引用计数。 - 第二个Observer: 监听两个事件,一个
BeforeWaiting
,一个Exit
,BeforeWaiting
的时候,干两件事,一个释放旧的池,然后创建一个新的池,所以这个时候,自动释放池就会有一次释放的操作,是在 RunLoop 即将进入休眠的时候。Exit
的时候,也释放自动释放池,这里也有一次释放的操作。
BeforeWaiting
这个含义其实就是指我们当前的一个RunLoop即将休眠了的状态,我们这里就会释放一个旧池子,然后创建一个新的池子,等到RunLoop退出的时候再次销毁池子.
- 进入RunLoop的时候会创建一个池子
- 休眠的时候会销毁该池子,在创建一个新池子
- 退出的时候再次销毁池子
响应触控事件
苹果提前在 App 内注册了一个 Source1 来监听系统事件。
比如,当一个 触摸/锁屏/摇晃 之类的系统事情产生,系统会先包装,包装好了,通过 mach port 传输给需要的 App 进程,传输后,提前注册的 Source1 就会触发回调,然后由 App 内部再进行分发。
- 注册一个Souce1来接受事件
- 硬件事件的发生
- IOKit.framework 生成 IOHIDEvent 事件并由 SpringBoard 接收
- SpringBoard 用 mach port 转发给需要的 App
- Soucre1触发回调
- 回调中 IOHIDEvent 包装成 UIEvent 进行处理或分发
刷新界面
改变Ui的参数他不会立刻更新,他也是通过RunLoop来进行一个更新的
当 UI 需要更新,先标记一个 dirty,然后提交到一个全局容器中去。然后,在 BeforeWaiting
时,会遍历这个容器,执行实际的绘制和调整,并更新 UI 界面。(在休眠和退出)
常驻线程
我们每一次网络请求的时候都会创建一个新的线程,这里为了减小开销,我们其实可以让一个线程常驻,保证线程不被销毁,这样可以让我们的开销减小.
我们在上面说过,一个RunLoop中如果没有Observer/Timer/Source等items,Runloop会自动退出,因此我们创建一个空的port发送消息给Runloop,以至于Runloop不会退出而是一直常驻
这里笔者给出一个案例:
- (void)viewDidLoad {[super viewDidLoad];self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(runThread) object:nil];[self.thread start];self.view.backgroundColor = UIColor.redColor;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {[self performSelector:@selector(runTest) onThread:self.thread withObject:nil waitUntilDone:NO];
}
- (void)runTest {NSLog(@"%@", [NSThread currentThread]);
}
- (void)runThread {NSLog(@"开启子线程 %@", self.thread);[[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];[[NSRunLoop currentRunLoop] run];
}
然后我们点击空白处
网络请求
网络请求在RunLoop中有以下几个层级:
CFSocket
CFNetwork ->ASIHttpRequest
NSURLConnection ->AFNetworking
NSURLSession ->AFNetworking2, Alamofire
- CFSocket:是最底层的接口,只负责socket通信
- CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作于这一层。
- 这一次是一个更高级的封装,可以说是新的面向接口的封装
- NSSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程),AFNetworking2 和 Alamofire 工作于这一层。
下面来了解一下有关于NSURLConnection
的工作流程
使用NSConnection的时候,会传入一个Delegte,当调用了[connection start]
后,delegate会不断收到回调.实际上,start 这个函数的内部会会获取 CurrentRunLoop
,然后在其中的 DefaultMode 添加了4个 Source0 (即需要手动触发的Source)。CFMultiplexerSource 是负责各种 Delegate 回调的,CFHTTPCookieStorage 是处理各种 Cookie 的
当开始网络传输时,我们可以看到 NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoader 和 com.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0 通知到上层的 Delegate。
NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。
NSTimer 和 CADisplayLink
NSTimer
前面一直有提到Timer source事件源,从上层来看其实他就相当于我们的一个NSTimer
一个NSTimer注册到RunLoop后,RunLoop会为其重复的时间点注册好事件,比方说10 : 00
者几个时间点.RunLoop为了节省资源,不会在非常准确的时间点进行一个回调Timer.Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。由于 NSTimer 的这种机制,因此 NSTimer 的执行必须依赖于 RunLoop,如果没有 RunLoop,NSTimer 是不会执行的。
GCDTimer
使用GCDTimer就不会有这个问题,NSTimer十因为依赖与RunLoop的内容,如果RunLoop的任务繁重,很有可能导致时间不够准确,而采用GCD的计时器就不会有这个问题,因为这里的GCDTimer是依赖于我们的操作系统内核的,所以会更加准确.
CADisplayLink
CADisplayLink是一个执行频率(fps)和屏幕刷新相同(可以修改preferredFramesPerSecond改变刷新频率)的定时器,它也需要加入到RunLoop才能执行。与NSTimer类似,CADisplayLink同样是基于CFRunloopTimerRef实现,底层使用mk_timer(可以比较加入到RunLoop前后RunLoop中timer的变化)。和NSTimer相比它精度更高(尽管NSTimer也可以修改精度),不过和NStimer类似的是如果遇到大任务它仍然存在丢帧现象。通常情况下CADisaplayLink用于构建帧动画,看起来相对更加流畅,而NSTimer则有更广泛的用处。