运用 Runloop 对主线程耗时的一次分析
(小编在迅雷公司码代码,最近公司各种职位热招,需要内推的可以私聊~)
一、Runloop 简述:
1、作用:
1.保持程序持续运行:例如程序一启动就会开一个主线程,主线程一开起来就会跑一个主线程对应的RunLoop,RunLoop 保证主线程不会被销毁,也就保证了程序的持续运行;
例如子线程的保活。
2.处理 App 中的各种事件
比如:触摸事件,定时器事件,Selector 事件等;
3.节省 CPU 资源,优化程序性能:程序运行起来时,当什么操作都没有做的时候,RunLoop 就通知系统,现在没有事情做,然后进行休息待命状态,这时系统就会将其资源释放出来去做其他的事情。当有事情做,也就是一有响应的时候 RunLoop 就会唤醒线程去做事情;
主要的作用是:不停跑 Runloop 处理各种事件,休眠 or 唤醒工作 。
2、大体的流程:
int32_t __CFRunLoopRun()
{
// 通知即将进入runloop
__CFRunLoopDoObservers(KCFRunLoopEntry);
do
{
// 通知将要处理timer和source
__CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
__CFRunLoopDoObservers(kCFRunLoopBeforeSources);
// 处理非延迟的主线程调用
__CFRunLoopDoBlocks();
// 处理Source0事件
__CFRunLoopDoSource0();
if (sourceHandledThisLoop) {
__CFRunLoopDoBlocks();
}
/// 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
if (__Source0DidDispatchPortLastTime) {
Boolean hasMsg = __CFRunLoopServiceMachPort();
if (hasMsg) goto handle_1msg;
}
/// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
if (!sourceHandledThisLoop) {
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}
// GCD dispatch main queue
CheckIfExistMessagesInMainDispatchQueue();
// 即将进入休眠
__CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
// 等待内核mach_msg事件
mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
// 等待。。。
// 从等待中醒来
__CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
// 处理因timer的唤醒
if (wakeUpPort == timerPort)
__CFRunLoopDoTimers();
// 处理异步方法唤醒,如dispatch_async
else if (wakeUpPort == mainDispatchQueuePort)
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
// 处理Source1
else
__CFRunLoopDoSource1();
// 再次确保是否有同步的方法需要调用
__CFRunLoopDoBlocks();
} while (!stop && !timeout);
// 通知即将退出runloop
__CFRunLoopDoObservers(CFRunLoopExit);
}
流程图示:
(左边的“source0 (port) ”应改为"source1 (port)",因为 source0 无法主动唤醒。)
【Runloop 流程介绍:https://blog.ibireme.com/2015/05/18/runloop/】
3、接受源
RunLoop 接收输入事件来自两种不同的来源:输入源(input source)和定时源(timer source)
输入源有两种 Source0 和 Source1:
• Source0:非基于端口 port,例如触摸,滚动,selector 选择器等用户触发的事件;(只包含了一个回调函数,它并不能主动唤醒触发事件,需要手动触发。)
• Source1:基于端口 port,一些系统事件; (包含了一个 mach_port 和一个回调函数,被用于通过内核和其他线程相互发送消息。能主动唤醒 RunLoop 的线程)
4、Runloop 的观察者 CFRunLoopObserverRef
/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
};
二、Runloop 参与的开发角色
1、NSTimer 和 CADisplayLink
NSTimer:
也即 CFRunLoopTimerRef:是基于时间的触发器。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行那个回调。
NSTimer 定时器不精准的原因:
A timer is not a real-time mechanism; it fires only when one of the run loop modes to which the timer has been added is running and able to check if the timer’s firing time has passed. If a timer’s firing time occurs while the run loop is in a mode that is not monitoring the timer or during a long callout, the timer does not fire until the next time the run loop checks the timer. Therefore, the actual time at which the timer fires potentially can be a significant period of time after the scheduled firing time.
计时器不是实时机制。仅当添加了计时器的运行循环模式之一正在运行并且能够检查计时器的触发时间是否经过时,它才会触发。如果在运行循环处于不监视计时器的模式下或长时间调用期间,计时器的触发时间发生,则直到下一次运行循环检查计时器时,计时器才会启动。因此,计时器可能实际触发的时间可能是在计划的触发时间之后的相当长的一段时间。
CADisplayLink:
帧数定时器 为什么能准确?
跟屏幕刷新同步的。和 NSTimer 并不一样,其内部实际是操作了一个 Source,通过 Source 1 mach_port 直接接受 VSync 信号驱动的。
因为是跟着屏幕帧数去定时的,且当屏幕正常绘制的情况下,理想状态下,60 fps 比较精确的。
如果对 NSRunloop 而言,想让 CADisplayLink 在正常的 fps 工作下,至少每秒循环60次,也就是至多17ms。
by:在 iOS 15 ProMotion 设备上,CADisplayLink 不再由 VSync信号驱动,而是由一个 UIKit 内部的 Source0 信号驱动。
CoreAnimation 的事务提交不再由完全由 RunLoop 驱动,而是涉及了多个信号源。
2、一次触摸屏幕 Runloop 的参与过程
source0 和 source 1 在触摸屏幕的场景中的角色:
我们触摸屏幕,先摸到硬件(屏幕),屏幕表面的事件会先包装成 Event,Event 先告诉 source1(mach_port), source1 唤醒 RunLoop。
应用程序把事件放入队列,也就是将事件 Event 分发给 source0, 然后由 source0 来处理。。UIApplication 对象是第一个对象接收到事件,然后决定怎样处理它。一个 touch event 通常都被分发到 main window对象,然后依次分发到发生触碰的 view,直到找到能接收这个事件的类。
也即是:首先是由 Source1接收 IOHIDEvent,之后在回调 __IOHIDEventSystemClientQueueCallback() 内触发的 Source0,Source0 再触发的 _UIApplicationHandleEventQueue()。所以 UIButton 事件看到是在 Source0 内的。)
一次触摸的整个过程的图示
三、Runloop 的运用
主题:运用以上 Runloop 内容,对 dispatch_async 队列在主线程中的一次分析过程。
dispatch_async(dispatch_get_main_queue(), block) 在 iOS 开发中常见的 GCD 函数。
主要作用:异步切换到主线程执行 block。
1. 一般会在什么情况下使用?
子线程切换到主线程渲染 UI。
2. 如果在主线程中使用,会发生什么?
打印不同函数:
NSLog(@"dispatch_async block 外 1"); dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"dispatch_async block 内"); }); NSLog(@"dispatch_async block 外 2"); **2022-08-31 17:31:20.376227+0800 iOSTest[64011:1594427] dispatch_async block 外 1****2022-08-31 17:31:20.376306+0800 iOSTest[64011:1594427] dispatch_async block 外 2****2022-08-31 17:31:20.382031+0800 iOSTest[64011:1594427] dispatch_async block 内
比较得出 :会有所延迟。
打印几次不同函数时间 -> 具体大概多少时间?
2022-08-31 17:32:48.462829+0800 iOSTest[64074:1596740] 相差时间(block 中):7.905006毫秒、
现象:一次主线程的异步 dispatch_async 只有几毫秒?
那么,如果加上其他操作例如:
[**self** addTableView]; **// 如果以上该操作中有复杂的算法,耗时会更明显** **2022-08-31 17:33:34.714659+0800 iOSTest[64105:1598089] 相差时间(block 中):13.237000毫秒**
也就是在比较复杂的业务场景中,DispatchQueue 大概可以延迟到 10几毫秒。
10几毫秒的概念:
在一些秒开的性能优化中,就是一个比较明显的指标消耗数据。
例如首开优化,抖音大概100-200毫秒。
小结:在主线程中执行 dispatch_async(dispatch_get_main_queue(), block),block 在开始执行时,会比非 dispatch 操作有一定的延迟。
(当遇到问题的时候,解决前提,最好是分析这个问题存在的原因。)
这里的问题是:在主线程的 dispatch_async 会出现<u>多余的耗时操作</u>。
3. 这个耗时操作存在的原因?
首先分析出现的原因:
这里注册了 Runloop 的观察者。
输出日志:
2022-08-31 17:43:12.063394+0800 iOSTest[64402:1605714] ------ 通知即将进入 runloop
2022-08-31 17:43:12.063475+0800 iOSTest[64402:1605714] ------- 通知将要处理 timer
2022-08-31 17:43:12.063519+0800 iOSTest[64402:1605714] ------- 通知将要处理 source**
2022-08-31 17:43:12.071835+0800 iOSTest[64402:1605714] 相差时间1:0.000954毫秒****
2022-08-31 17:43:12.072033+0800 iOSTest[64402:1605714] 相差时间2:0.182986毫秒**
2022-08-31 17:43:12.079609+0800 iOSTest[64402:1605714] ------- 通知将要处理 timer
2022-08-31 17:43:12.079693+0800 iOSTest[64402:1605714] ------- 通知将要处理 source**
2022-08-31 17:43:12.080144+0800 iOSTest[64402:1605714] 相差时间(block 中):8.311033毫秒**
2022-08-31 17:43:12.080369+0800 iOSTest[64402:1605714] ------- 通知将要处理 timer
2022-08-31 17:43:12.080558+0800 iOSTest[64402:1605714] ------- 通知将要处理 source
可以发现这里的延迟,处在两组 timer 和 source 的中间。
而在源码中,一次的 Runloop 中有两次调用 dispatch_async(dispatch_get_main_queue) 的机会。
// 1.休眠前**CheckIfExistMessagesInMainDispatchQueue()
// 2.被唤醒后
else if (wakeUpPort == mainDispatchQueuePort) {
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
}
小结:延迟的主要原因可能有两个:
• handle_msg 后,只处理了 Source1,然后等再下一次 Runloop,才执行:
CheckIfExistMessagesInMainDispatchQueue() // 最长是慢了一个 Runloop;
• 在一次 Runloop 已执行完,进入睡眠,才唤醒后触发 DispatchQueue;
4. 一次 Runloop 消耗的时间?
在保证帧数据画面,实时刷新,不卡顿。一个 runloop 16.7 毫秒这样。(CADisplayLink 在正常的 fps 工作下,就是一秒走 60 次,因为由 sourece 去驱动)
所以:一次在主线程的 dispatch_async,延迟最多 10几毫秒。
小结:(分析问题出现的原因和现象。)
在主线程,避免使用 dispatch_async,调用可导致延迟最多一个 runloop,大概几毫秒到10几毫秒的时间。
5、如何解决 DispatchQueue 在主线程的耗时?
1. 方案:
判断是否在线程,是的话,直接执行 block。
否则使用:
dispatch_async(dispatch_get_main_queue(), block);
2. 如何处理:
把方案写进代码:
if ([NSThread isMainThread]) {
block();
} else {
dispatch_async(dispatch_get_main_queue(), block);
}
关于主队列和主线程的问题:
1、主线程上的队列一定是主队列吗?
否
2、主队列一定在主线程执行吗?
是
3. 为什么一定要在主队列?
如果库(如MapKit / VektorKit)依赖于检查主队列上的执行,则从在主线程上执行的非主队列调用 API 将导致问题。
【以下填充文章部分。】
判断是否当前主队列:
#ifndef dispatch_main_async_safe (比较严谨的,防止重复定义 dispatch_main_async_safe)
#define dispatch_main_async_safe(block)\ if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {\
block();\
} else {\
dispatch_async(dispatch_get_main_queue(), block);\
} #endif
小结:
在主队列调度的任务肯定在主线程执行,而在主线程执行的任务不一定是由主队列调度的。
所以,做主线程判断,可以判断是否在主线程中。
那么,按以上结论,问题耗时的原因已分析,解决方案已提供。
疑问:在主线程中 DispatchQueue:
4. 一定会产生耗时操作?
2022-09-04 14:36:50.974062+0800 iOSTest[29279:3664874] ------- 线程即将进入休眠(sleep)
2022-09-04 14:36:51.034567+0800 iOSTest[29279:3664874] ------- 从等待中醒来****
2022-09-04 14:36:51.035105+0800 iOSTest[29279:3664874] ------- 通知将要处理 timer
2022-09-04 14:36:51.035225+0800 iOSTest[29279:3664874] ------- 通知将要处理 source
2022-09-04 14:36:51.036229+0800 iOSTest[29279:3664874] buttonClick start****2022-09-04 14:36:51.036720+0800 iOSTest[29279:3664874] 相差时间1:0.001073毫秒
2022-09-04 14:36:51.037145+0800 iOSTest[29279:3664874] 相差时间2:0.419974毫秒****
2022-09-04 14:36:51.037670+0800 iOSTest[29279:3664874] 相差时间(block 中):0.931025毫秒
2022-09-04 14:36:51.037862+0800 iOSTest[29279:3664874] ------- 通知将要处理 timer
2022-09-04 14:36:51.038030+0800 iOSTest[29279:3664874] ------- 通知将要处理 source
2022-09-04 14:36:51.038202+0800 iOSTest[29279:3664874] ------- 线程即将进入休眠(sleep)
参见 UI 更新,一次触摸,Runloop 参与的角色。
所以,这是唤醒过来的,是在同一 Runloop 中处理的,还是在原来的事件里面。
但是因为是异步的,会立即返回,会先执行下一行的代码,后面再执行 block。
但基本上述无过多的耗时操作。
归纳总结:
在主线程中执行 dispatch_async(dispatch_get_main_queue(), block),如果在非同一个事件(例如点击触摸事件)中,可能导致执行 block 在主线程 的 下一次 Runloop 中执行,耗时从几毫秒到十几毫秒不等。
规避耗时操作代码如下:
|
Objective-C #ifndef dispatch_main_async_safe #define dispatch_main_async_safe(block)\ if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())) {\ block();\ } else {\ dispatch_async(dispatch_get_main_queue(), block);\ } #endif
|
四、拓展
两点优化:
1、看一下项目里的代码,是否有在主线程做异步执行的切换?省个几毫秒。
2、过一遍 Runloop 源码,创建 Runloop 观察者,看不同时期调用。
留意一下平时开发哪些流程可以根据 Runloop 不同时期做优化和拆分。
一些思考:
1、source0 为什么无法主动唤醒?
2、dispatch_async 改为 dispatch_sync?
3、子线程的 Runloop 怎么运行?和主线程的 Runloop 有啥区别?
4、在低端机中使用多线程对优化收益?参考: GCD queue 对主线程的抢占
(转载请标明原文出处,谢谢支持 ~ - ~)
by:啊左~