前言
我们都知道,线程的消息事件是依赖于 NSRunLoop 的,所以从 NSRunLoop 入手,就可以知道主线程上都调用了哪些方法。我们通过监听 NSRunLoop 的状态,就能够发现调用方法是否执行时间过长,从而判断出是否会出现卡顿。
Runloop的运行原理。
首先了解一下Runloop的运行原理,如下图所示:
第一步:
通知Observers: Runloop要开始runloop了。紧接着进入runloop啦
// 通知 observers
if (currentMode->_observerMask & kCFRunLoopEntry )
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
// 进入 loop
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
第二步:
开启一个do while保活。通知Observers:Runloop会触发Timer回调、source0回调
、接着执行加入Block。
// 通知 Observers RunLoop 会触发 Timer 回调
if (currentMode->_observerMask & kCFRunLoopBeforeTimers)
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 通知 Observers RunLoop 会触发 Source0 回调
if (currentMode->_observerMask & kCFRunLoopBeforeSources)
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 执行 block
__CFRunLoopDoBlocks(runloop, currentMode);
接下来就是出发Source0回调,如果还有Source1是ready状态的话,就会跳到hanlde_msg处理消息
if (MACH_PORT_NULL != dispatchPort ) {
Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
if (hasMsg) goto handle_msg;
}
第三步:
回调触发后,通知Observes:Runloop的线程进入休眠状态
Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
if (!poll && (currentMode->_observerMask & kCFRunLoopBeforeWaiting)) {
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}
等四步:
进入休眠后,会等待math_port的消息,以再次唤醒,只有在下面四个事件出现时才会被再次唤醒:
- 基于port的source事件
- Timer时间到
- Runloop超时
- 被调用者唤醒
等待唤醒的代码如下:
do {
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
// 基于 port 的 Source 事件、调用者唤醒
if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
break;
}
// Timer 时间到、RunLoop 超时
if (currentMode->_timerFired) {
break;
}
} while (1);
第六步:
Runloop被唤醒后就要开始处理消息了
- 如果是Timer的时间的话,就处理timer的回调
- 如果是dispatch的话,就执行Block
- 如果是Source1时间的话,就处理这个事件
消息执行完之后,就执行到loop里的Block
handle_msg:
// 如果 Timer 时间到,就触发 Timer 回调
if (msg-is-timer) {
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
}
// 如果 dispatch 就执行 block
else if (msg_is_dispatch) {
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
}
// Source1 事件的话,就处理这个事件
else {
CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
if (sourceHandledThisLoop) {
mach_msg(reply, MACH_SEND_MSG, reply);
}
}
第七步:
根据当前的runloop状态来判断是否要走下一个loop。当外部强制停止或者loop超时,就不再继续下一个loop了,否则继续走下一个loop.
if (sourceHandledThisLoop && stopAfterHandle) {
// 事件已处理完
retVal = kCFRunLoopRunHandledSource;
} else if (timeout) {
// 超时
retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(runloop)) {
// 外部调用者强制停止
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
// mode 为空,RunLoop 结束
retVal = kCFRunLoopRunFinished;
}
如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。
所以,如果我们要利用 RunLoop 原理来监控卡顿的话,就是要关注这两个阶段。RunLoop 在进入睡眠之前和唤醒后的两个 loop 状态定义的值,分别是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,也就是要触发 Source0 回调和接收 mach_port 消息两个状态。
检查卡顿
要想监听 RunLoop,你就首先需要创建一个 CFRunLoopObserverContext
观察者,代码如下:
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
将创建好的观察者 runLoopObserver
添加到主线程 RunLoop 的 common
模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态
一旦发现进入睡眠前的 kCFRunLoopBeforeSources
状态,或者唤醒后的状态 kCFRunLoopAfterWaiting
,在设置的时间阈值内一直没有变化,即可判定为卡顿。
开启一个子线程监控的代码如下:
// 创建子线程监控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 子线程开启一个持续的 loop 用来进行监控
while (YES) {
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
if (semaphoreWait != 0) {
if (!runLoopObserver) {
timeoutCount = 0;
dispatchSemaphore = 0;
runLoopActivity = 0;
return;
}
//BeforeSources 和 AfterWaiting 这两个状态能够检测到是否卡顿
if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
// 将堆栈信息上报服务器的代码放到这里
} //end activity
}// end semaphore wait
timeoutCount = 0;
}// end while
});
我们把这个阈值设置成了 3 秒。那么,这个 3 秒的阈值是从何而来呢?这样设置合理吗?
其实,触发卡顿的时间阈值,我们可以根据 WatchDog 机制来设置。WatchDog 在不同状态下设置的不同时间,如下所示:
- 启动(Launch):20s;
- 恢复(Resume):10s;
- 挂起(Suspend):10s;
- 退出(Quit):6s;
- 后台(Background):3min(在 iOS 7 之前,每次申请 10min; 之后改为每次申请 3min,可连续申请,最多申请到 10min)。
通过 WatchDog 设置的时间,我认为可以把启动的阈值设置为 10 秒,其他状态则都默认设置为 3 秒。总的原则就是,要小于 WatchDog 的限制时间。当然了,这个阈值也不用小得太多,原则就是要优先解决用户感知最明显的体验问题。
获取卡顿的方法堆栈信息
子线程监控发现卡顿后,还需要记录当前出现卡顿的方法堆栈信息,并适时推送到服务端供开发者分析,从而解决卡顿问题。那么,在这个过程中,如何获取卡顿的方法堆栈信息呢?
获取堆栈信息的一种方法是直接调用系统函数。这种方法的优点在于,性能消耗小。但是,它只能够获取简单的信息,也没有办法配合 dSYM
来获取具体是哪行代码出了问题,而且能够获取的信息类型也有限。这种方法,因为性能比较好,所以适用于观察大盘统计卡顿情况,而不是想要找到卡顿原因的场景。
第一种:直接调用系统函数方法的主要思路是:用 signal 进行错误信息的获取。
第二种:直接用 PLCrashReporter 这个开源的第三方库来获取堆栈信息。这种方法的特点是,能够定位到问题代码的具体位置,而且性能消耗也不大。所以,也是我推荐的获取堆栈信息的方法。
搜集到卡顿的方法堆栈信息以后,就是由开发者来分析并解决卡顿问题了。
参考:监控卡顿完整代码