参考
RunLoop的概念
RunLoop是一个机制,让线程能随时处理事件但并不退出。这种机制实现的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。
iOS提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。
-
CFRunLoopRef是在CoreFoundation框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。 -
NSRunLoop是基于CFRunLoopRef的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。
Runloop和线程之间的关系
线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。主线程的RunLoop是一直运行的,RunLoop在执行完任务后会进入休眠,等待下一次启动。
RunLoop的组成
-
Timer- 理解的
Timer
- 理解的
-
Source(RunLoop数据源的抽象类protocol)-
Source0:处理App内部时间,App自己负责触发(UIEvent、CFSocket) -
Source1:由RunLoop和mach内核管理,由mach-port驱动
-
-
Observer- 许多机制都由
Observer来触发- 例如
CAAnimation,在afterwaiting收集完所有animation后才执行动画
- 例如
- 许多机制都由
RunLoop的Mode
-
NSDefaultRunLoopMode(kCFRunLoopDefaultMode):App的默认 Mode,通常主线程是在这个 Mode 下运行的 -
UITrackingRunLoopMode:界面跟踪 Mode,用于ScrollView追踪触摸滑动,保证界面滑动时不受其他 Mode 影响 -
UIInitializationRunLoopMode:在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用(iOS不公开提供) -
NSRunLoopCommonModes(kCFRunLoopCommonModes):Mode集合(iOS不公开提供) -
GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到
RunLoop只能运行在一个mode下,如果要换mode,当前的loop也需要停下重启成新的。
例如:ScrollView滚动过程中NSDefaultRunLoopMode(kCFRunLoopDefaultMode)的mode会切换到UITrackingRunLoopMode来保证ScrollView的流畅滑动,如果我们把一个NSTimer对象以NSDefaultRunLoopMode(kCFRunLoopDefaultMode)添加到主运行循环中的时候, ScrollView滚动过程中会因为mode的切换,而导致NSTimer将不再被调度,解决方案是将timer添加到NSRunLoopCommonModes(kCFRunLoopCommonModes)中或者另起线程避免mode切换来解决。
RunLoop内部逻辑

RunLoop 内部是一个 do-while 循环。当你调用 CFRunLoopRun()时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。
RunLoop的底层实现
RunLoop 的核心是基于 mach port 的
iOS的内核是Mach,在 Mach 中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为"对象"。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。"消息"是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的IPC (进程间通信) 的核心。
为了实现消息的发送和接收,mach_msg() 函数实际上是调用了一个 Mach 陷阱 (trap),即函数mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg()函数会完成实际的工作。
RunLoop调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在mach_msg_trap()这个地方。
iOS利用RunLoop实现的功能
-
AutoreleasePool
App启动后,苹果在主线程
RunLoop里注册了两个Observer,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()。第一个
Observer监视的事件是Entry(即将进入Loop),其回调内会调用_objc_autoreleasePoolPush()创建自动释放池。其order是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。第二个
Observer监视了两个事件:BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()释放旧的池并创建新池;Exit(即将退出Loop) 时调用_objc_autoreleasePoolPop()来释放自动释放池。这个Observer的order是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。在主线程执行的代码,通常是写在诸如事件回调、
Timer回调内的。这些回调会被RunLoop创建好的AutoreleasePool环绕着,所以不会出现内存泄漏,开发者也不必显示创建Pool了。 -
事件响应
苹果注册了一个
Source1(基于mach port的) 用来接收系统事件,其回调函数为__IOHIDEventSystemClientQueueCallback()。当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由
IOKit.framework生成一个IOHIDEvent事件并由SpringBoard接收。这个过程的详细情况可以参考这里。SpringBoard只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种Event,随后用mach port转发给需要的App进程。随后苹果注册的那个Source1就会触发回调,并调用_UIApplicationHandleEventQueue()进行应用内部的分发。_UIApplicationHandleEventQueue()会把IOHIDEvent处理并包装成UIEvent进行处理或分发,其中包括识别UIGesture、处理屏幕旋转发送给UIWindow等。通常事件比如UIButton点击、touchesBegin/Move/End/Cancel事件都是在这个回调中完成的。 -
手势识别
当上面的
_UIApplicationHandleEventQueue()识别了一个手势时,其首先会调用Cancel将当前的touchesBegin/Move/End系列回调打断。随后系统将对应的UIGestureRecognizer标记为待处理。苹果注册了一个
Observer监测BeforeWaiting(Loop即将进入休眠) 事件,这个Observer的回调函数是_UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的GestureRecognizer,并执行GestureRecognizer的回调。当有
UIGestureRecognizer的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。 -
界面更新
当在操作 UI 时,比如改变了
Frame、更新了UIView/CALayer的层次时,或者手动调用了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法后,这个UIView/CALayer就被标记为待处理,并被提交到一个全局的容器去。苹果注册了一个
Observer监听BeforeWaiting(即将进入休眠) 和Exit(即将退出Loop) 事件,回调去执行一个很长的函数:_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的UIView/CAlayer以执行实际的绘制和调整,并更新 UI 界面。 -
定时器
NSTimer其实就是CFRunLoopTimerRef,他们之间是toll-free bridged的。一个NSTimer注册到RunLoop后,RunLoop会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer有个属性叫做Tolerance(宽容度),标示了当时间点到后,容许有多少最大误差。如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
CADisplayLink是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和NSTimer并不一样,其内部实际是操作了一个Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook开源的AsyncDisplayLink就是为了解决界面卡顿的问题,其内部也用到了RunLoop。 -
PerformSelecter
当调用
NSObject的performSelecter:afterDelay:后,实际上其内部会创建一个Timer并添加到当前线程的RunLoop中。所以如果当前线程没有RunLoop,则这个方法会失效。当调用
performSelector:onThread:时,实际上其会创建一个Timer加到对应的线程去,同样的,如果对应线程没有RunLoop该方法也会失效。
RunLoop常见应用
- 使用
RunLoop的Mode做tableview滑动优化- 通过不同的
mode的切换,实现滑动时暂停加载图片等,停止滑动时加载
- 通过不同的
-
NSTimer计时任务 -
autorelease pool- 由
RunLoop维护
- 由
- 卡顿检测
- 利用
Observer记录主线程RunLoop休眠的时间 - 利用
Observer记录主线程RunLoop唤醒的时间 - 计算这个(唤醒时间 - 休眠时间)的值,将其与正常的时间比较,判断当前是否会掉帧
- 利用
- 让
Crash的程序回光返照- 接收到
Crash的Signal后手动重启RunLoop
- 接收到
- 异步
Test Case-
sleep前验证
-
用到的框架
-
AFNetworking用于维护线程AFNetworking是基于NSURLConnection构建的,为了在后台也能接受回调,会创建一个线程,线程中添加一个RunLoop。由于没有调用RunLoop的停止方法,所以RunLoop不会退出。 -
AsyncDisplayKitASDK创建了一个名为ASDisplayNode的对象,并在内部封装了UIView/CALayer,它具有和UIView/CALayer相似的属性,例如frame、backgroundColor等。所有这些属性都可以在后台线程更改,开发者可以只通过Node来操作其内部的UIView/CALayer,这样就可以将排版和绘制放入了后台线程。
并在主线程的RunLoop中添加一个Observer,监听RunLoop进入休眠和退出的回调事件,收到回调后,遍历执行队列中的任务。 -
YYAsyncLayer- 实现原理如下:
- 正常情况下:假设一次
RunLoop需要处理50张图片 - 使用
YYAsyncLayer的情况:一次RunLoop处理1张图片,利用50个RunLoop去处理50张图片- 注意:在不计算休眠时间的情况下,50个
RunLoop处理时间 = 1次RunLoop处理50张图片的时间
- 注意:在不计算休眠时间的情况下,50个
- 正常情况下:假设一次
- 实现原理如下:
有关RunLoop的问题
-
RunLoop与线程的关系- 一对一,一个线程可以有一个
RunLoop,也可以没有 - 主线程的
RunLoop已经自动创建好了,子线程的RunLoop需要主动创建 -
RunLoop在第一次获取时创建,在线程结束时销毁
- 一对一,一个线程可以有一个
-
RunLoop只是个死循环吗?- 不是,
RunLoop是个有时间限制的循环
- 不是,
- 使用
while(true)和RunLoop哪个好?-
RunLoop,因为RunLoop可以在不需要使用的时候休眠,节省CPU资源,而while(true)则一直处于CPU活跃状态
-
- 为什么我们主线程需要有
RunLoop?- 保持线程存活,接受事件
- 为了管理
AutoreleasePool
-
[NSRunLoop currentRunLoop]实际上做了什么-
[NSRunLoop currentRunLoop]实则为一个懒加载的方法。它会遍历一张全局静态的数据表,该数据表以线程PID为Key,以与该线程绑定的RunLoop为Value。该表创建的时候会首先对当前线程(主线程)的PID放入一个RunLoop
-
-
RunLoop与autorelease pool的关系- 对于每一个
Runloop, 系统会隐式创建一个Autorelease pool,这样所有的release pool会构成一个象CallStack一样的一个栈式结构,对象会自动被放入栈顶的AutoreleasePool中,在每一个Runloop结束时,当前栈顶的Autorelease pool会被销毁,这样这个pool里的每个Object会被release。 - 两次
pop两次push,均利用Observer实现- 进入后
push - 睡眠前
pop - 睡眠后
push - 离开前
pop
- 进入后
- 对于每一个
-
GCD的dispatch_get_main()是如何实现的- 当调用
dispatch_async(dispatch_get_main_queue(), block)时,libDispatch会向主线程的RunLoop发送消息,RunLoop会被唤醒,并从消息中取得这个block,并在回调\__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__里执行这个block。但这个逻辑仅限于 利用GCD将block分发到主线程,分发到其他线程仍然是由libDispatch处理的。 -
GCD有自己的线程池,当需要使用到线程的时候随机找一个线程来跑,但是主线程是唯一的,使用RunLoop的主线程
- 当调用
- 如何切换
Mode?为什么要这样做?- 先离开,重新进入后切换
Mode - 这样是为了保证
Mode里面的Timer、Sources、Observer互不影响 - 延伸:在主线程
Mode切换的时候,RunLoop这一次离开与下一次进入之前有一段间隔,这段间隔会对我们的应用有影响吗(比如会丢事件吗)?- 不会有影响,因为我们会把在这期间收到的事件都放在一个队列中,等待下一次
RunLoop进入的时候,RunLoop根据该队列进行处理
- 不会有影响,因为我们会把在这期间收到的事件都放在一个队列中,等待下一次
- 先离开,重新进入后切换
- 使用
Timer要注意什么- 注意使用内存管理
[timer invalidate];及设nil - 使用
addCommonMode/addUITrackingMode保证精准度
- 注意使用内存管理
-
CommonModes本质是什么-
CommonModes是一个标识,CFRunLoopAddCommonMode等于给某个Mode打标识。 - 这里有个概念叫
CommonModes:一个Mode可以将自己标记为Common属性(通过将其ModeName添加到RunLoop的commonModes中)。每当RunLoop的内容发生变化时,RunLoop都会自动将_commonModeItems里的Source/Observer/Timer同步到具有Common标记的所有Mode里。
-
-
NSThread在没有RunLoop的情况下,执行完入口函数,会被立刻关闭吗?- 不会立刻关闭,会在执行完后,过段时间被清理
- 延伸:既然如此,为什么把主线程的
RunLoop关闭后,应用会崩溃?- 应用保证了主线程一定要有
RunLoop,没有RunLoop则崩,与上面问题没有关系
- 应用保证了主线程一定要有
