查看原文
其他

iOS 性能监控:Runloop 卡顿监控的坑

酷酷的哀殿 老司机技术 2022-08-26

作者:酷酷的哀殿,百度 iOS 开发工程师

背景

前两天,一位朋友遇到一个问题,说自己无法使用 Runloop 监测到 -tableView:didSelectRowAtIndexPath: 场景的卡顿。

什么意思呢?就是监控不到下面这段代码的卡顿问题:


- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    int a = 8;
    NSLog(@"调试:大量计算");
    for (long i = 0; i < 999999999; i++) {
        a = a + 1;
    }
    NSLog(@"调试:大量计算结束");
}

当用户点击 cell 时,上述代码会触发一次大量计算,具体的调用栈和调试日志如下所示:

image-02214033496

从红框的 console 日志,我们可以发现上面卡顿监控代码,没有任何相关的卡顿提示

出于好奇心,我就对这个问题研究了一番,找到的原因,在这里做一次总结和分享。

常规卡顿方案和问题

首先,问题的主要原因在于目前网上的「Runloop 卡顿监控」技术方案并不是完善的,存在一些漏洞,会导致丢失这类场景的监控。

如果我们用 Runloop 卡顿 为关键字,搜索相关技术方案,基本上都是下面这种方案:

 // 注册
- (void)beginMonitor {
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    CFRunLoopObserverRef runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                              kCFRunLoopAllActivities,
                                              YES,
                                              0,
                                              &runLoopObserverCallBack,
                                              &context);
    //将观察者添加到主线程runloop的common模式下的观察中
    CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);

    //创建子线程监控
    dispatch_async(dispatch_get_global_queue(00), ^{
        int i=0;
        //子线程开启一个持续的loop用来进行监控
        while (YES) {
            long semaphoreWait = dispatch_semaphore_wait(self->dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 80 * NSEC_PER_MSEC));
            NSLog(@"while%@",@(i++));
            printAct(self->runLoopActivity);
            if (semaphoreWait != 0) {
                if (!self->runLoopObserver) {
                    self->timeoutCount = 0;
                    self->dispatchSemaphore = 0;
                    self->runLoopActivity = 0;
                    return;
                }
                //两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
                if (self->runLoopActivity == kCFRunLoopBeforeSources || self->runLoopActivity == kCFRunLoopAfterWaiting) {
                    //出现三次出结果
                    if (++self->timeoutCount < 3) {
                        continue;
                    }
                    NSLog(@"调试:监测到卡顿");
                } //end activity
            }// end semaphore wait
            self->timeoutCount = 0;
        }// end while
    });
}
// 记录状态
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    SMLagMonitor *lagMonitor = (__bridge SMLagMonitor*)info;
    lagMonitor->runLoopActivity = activity;

    printAct(activity);

    dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
    dispatch_semaphore_signal(semaphore);
}

简单的整理一下上述代码的思路:

  • 创建一个 CFRunLoopObserverRef,并提供 runLoopObserverCallBack 记录 RunloopCFRunLoopActivity 变化
  • main Runloop 添加该 observer
  • 开启异步线程,并以指定间隔持续监测 CFRunLoopActivity
  • 连续 3 次检测到 kCFRunLoopBeforeSources 或者 kCFRunLoopAfterWaiting 时,认为当前处于卡顿状态,触发 卡顿 的数据收集

卡顿监控失效分析

1、代码执行顺序

首先,我们先将监控代码与 Runloop 的执行顺序合并到一起进行分析:

  • Runloop 通知 卡顿检测代码 进入 kCFRunLoopBeforeWaiting 状态

  • Runloop 执行 UIKit点击事件 逻辑

  • Runloop 进入 休眠状态

    image-17000108309

值得重点关注的是上图两个回调的执行顺序:卡顿监控点击事件 更早接收到 kCFRunLoopBeforeWaiting 事件。

点击事件 执行时,异步线程会因为 卡顿监控先接到 kCFRunLoopBeforeWaiting状态,导致错误认为 Runloop 处于睡眠状态

所以,为了解决卡顿监控 代码无法检测 tableView:didSelectRowAtIndexPath: 的现象,我们需要将 kCFRunLoopBeforeWaiting_卡顿监控 调用时机进行调整

2、 __CFRunLoopDoObservers 函数的执行逻辑

为了调整回调的执行顺序,我们需要先了解 __CFRunLoopDoObservers 函数的执行逻辑。


/* rl is locked, rlm is locked on entrance and exit */
static void __CFRunLoopDoObservers(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFRunLoopActivity activity) __attribute__((noinline));
static void __CFRunLoopDoObservers(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFRunLoopActivity activity) { /* DOES CALLOUT */
    
    cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_OBSERVERS | DBG_FUNC_START, rl, rlm, activity, 0);
    
    CHECK_FOR_FORK();
    // 获取 runLoopMode 的 observer 数量,如果小于1,则直接返回
    CFIndex cnt = rlm->_observers ? CFArrayGetCount(rlm->_observers) : 0;
    if (cnt < 1return;

    /* Fire the observers */
    STACK_BUFFER_DECL(CFRunLoopObserverRef, buffer, (cnt <= 1024) ? cnt : 1);
    CFRunLoopObserverRef *collectedObservers = (cnt <= 1024) ? buffer : (CFRunLoopObserverRef *)malloc(cnt * sizeof(CFRunLoopObserverRef));
    CFIndex obs_cnt = 0;
    // 1、顺序遍历 _observers,
    // 因为每个 observer 可以观察不同的 activity,所以,需要通过 & 操作符过滤需要触发的 observer
    // 并组成新的数组 collectedObservers
    for (CFIndex idx = 0; idx < cnt; idx++) {
        CFRunLoopObserverRef rlo = (CFRunLoopObserverRef)CFArrayGetValueAtIndex(rlm->_observers, idx);
        // 【避免递归】1、通过 __CFRunLoopObserverIsFiring 判断是否处于执行状态
        if (0 != (rlo->_activities & activity) && __CFIsValid(rlo) && !__CFRunLoopObserverIsFiring(rlo)) {
            collectedObservers[obs_cnt++] = (CFRunLoopObserverRef)CFRetain(rlo);
        }
    }
    __CFRunLoopModeUnlock(rlm);
    __CFRunLoopUnlock(rl);
    // 2、顺序遍历 collectedObservers
    for (CFIndex idx = 0; idx < obs_cnt; idx++) {
        CFRunLoopObserverRef rlo = collectedObservers[idx];
        __CFRunLoopObserverLock(rlo);
        if (__CFIsValid(rlo)) {
            // 【非重复 observer】1、记录是否属于非重复 observer
            Boolean doInvalidate = !__CFRunLoopObserverRepeats(rlo);
            // 【避免递归】2、回调前,通过 __CFRunLoopObserverSetFiring 记录执行的状态
            __CFRunLoopObserverSetFiring(rlo);
            __CFRunLoopObserverUnlock(rlo);
            CFRunLoopObserverCallBack callout = rlo->_callout;
            void *info = rlo->_context.info;
            cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_OBSERVER | DBG_FUNC_START, callout, rlo, activity, info);
            // 3、执行 observer 的回调
            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(callout, rlo, activity, info);
            cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_OBSERVER | DBG_FUNC_END, callout, rlo, activity, info);
            // 【非重复 observer】2、非重复 observer,在回调完毕后,直接销毁
            if (doInvalidate) {
                CFRunLoopObserverInvalidate(rlo);
            }
            // 【避免递归】3、回调后,通过 __CFRunLoopObserverUnsetFiring 恢复状态
            __CFRunLoopObserverUnsetFiring(rlo);
        } else {
            __CFRunLoopObserverUnlock(rlo);
        }
        CFRelease(rlo);
    }
    __CFRunLoopLock(rl);
    __CFRunLoopModeLock(rlm);

    if (collectedObservers != buffer)
        free(collectedObservers);
    
    cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_OBSERVERS | DBG_FUNC_END, rl, rlm, activity, 0);
}

值得注意的是,__CFRunLoopMode 持有一个数组类型的结构成员:_observers

image-17000157559
  • __CFRunLoopDoObservers 会先遍历 _observers ,并根据各种条件组成一个新的数组 collectedObservers
  • 新的数组生成后,会再次遍历 collectedObservers,并通过 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ 回调监控函数

所以,我们可以得到第一个重要的结论:通过控制 _observers 数组的排列顺序,能够改变调用时机

3、CFRunLoopAddObserver 函数的执行逻辑

为了控制 _observers 数组的排列顺序,我们还需要先看看 CFRunLoopAddObserver 函数的执行逻辑。

如下,创建 CFRunLoopObserverRef 时,开发者可以传入 CFIndex order 参数

CF_EXPORT CFRunLoopObserverRef CFRunLoopObserverCreate(CFAllocatorRef allocator, CFOptionFlags activities, Boolean repeats, CFIndex order, CFRunLoopObserverCallBack callout, CFRunLoopObserverContext *context);
image-17000220812

CFRunLoopAddObserver 函数内部会根据 CFRunLoopObserverRef_order 逆序遍历 CFRunLoopRef_observers,并找到合适的位置进行插入

具体的源码如下所示:

// 添加 observer
void CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef rlo, CFStringRef modeName) {
    CHECK_FOR_FORK();
    CFRunLoopModeRef rlm;
    // 如果 runloop 处于销毁状态,直接返回
    if (__CFRunLoopIsDeallocating(rl)) return;
    // 如果主线程已经停止执行,则直接返回
    if (__CFMainThreadHasExited && rl == CFRunLoopGetMain()) {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            CFLog(kCFLogLevelError, CFSTR("Attempting to add observer to main runloop, but the main thread has exited. This message will only log once. Break on _CFRunLoopError_MainThreadHasExited to debug."));
        });
        _CFRunLoopError_MainThreadHasExited();
        return;
    }
    // 合规性校验 & 防止重入
    if (!__CFIsValid(rlo) || (NULL != rlo->_runLoop && rlo->_runLoop != rl)) return;

    __CFRunLoopLock(rl);
    // 1、如果监听 kCFRunLoopCommonModes,则遍历 _commonModes,并进行监听
    if (modeName == kCFRunLoopCommonModes) {
        CFSetRef set = rl->_commonModes ? CFSetCreateCopy(kCFAllocatorSystemDefault, rl->_commonModes) : NULL;
        if (NULL == rl->_commonModeItems) {
            rl->_commonModeItems = CFSetCreateMutable(kCFAllocatorSystemDefault, 0, &kCFTypeSetCallBacks);
        }
        CFSetAddValue(rl->_commonModeItems, rlo);
        if (NULL != set) {
            CFTypeRef context[2] = {rl, rlo};
            /* add new item to all common-modes */
            CFSetApplyFunction(set, (__CFRunLoopAddItemToCommonModes), (void *)context);
            CFRelease(set);
        }
    } else {
        rlm = __CFRunLoopFindMode(rl, modeName, true);
        if (NULL != rlm && NULL == rlm->_observers) {
            rlm->_observers = CFArrayCreateMutable(kCFAllocatorSystemDefault, 0, &kCFTypeArrayCallBacks);
        }
        if (NULL != rlm && !CFArrayContainsValue(rlm->_observers, CFRangeMake(0CFArrayGetCount(rlm->_observers)), rlo)) {
                Boolean inserted = false;
                // 2、逆序遍历 _observers,并找到合适的位置进行插入
                for (CFIndex idx = CFArrayGetCount(rlm->_observers); idx--; ) {
                    CFRunLoopObserverRef obs = (CFRunLoopObserverRef)CFArrayGetValueAtIndex(rlm->_observers, idx);
                    if (obs->_order <= rlo->_order) {
                        CFArrayInsertValueAtIndex(rlm->_observers, idx + 1, rlo);
                        inserted = true;
                        break;
                    }
                }
                if (!inserted) {
                CFArrayInsertValueAtIndex(rlm->_observers, 0, rlo);
                }
            rlm->_observerMask |= rlo->_activities;
            __CFRunLoopObserverSchedule(rlo, rl, rlm);
        }
        if (NULL != rlm) {
            __CFRunLoopModeUnlock(rlm);
        }
    }
    __CFRunLoopUnlock(rl);
}

为了方便读者理解上面的逻辑,我们通过一个具体的示例进行讲解。

如下,假设现有的 observersorder0812,新插入的 observers  的 order 分别是 010

则,两个 observers 会分别插入到 stub0stub1 位置。

所以,我们可以得到第二个重要结论:通过调整 CFRunLoopObserverCreateorder 参数,可以调整两个回调的执行顺序。

image-17000244565

高可用的 Runloop 卡顿监测方案

根据前面的两个结论,我们可以采用将 order 调整到 LONG_MAX 的方式改变调用顺序:

1、优化方案

runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                          kCFRunLoopAllActivities,
                                          YES,
                                          LONG_MAX,
                                          &runLoopObserverCallBack,
                                          &context);

重新编译&运行APP后,我们可以发现 console 的内容变成如下:

image-03013224495

2、高可用方案

相信聪明的读者很容易发现上面的优化方案仍然存在下面的badcase

kCFRunLoopAfterWaiting_其它阻塞事件 位置发生卡顿时,新方案因为执行顺序比较晚,卡顿监控代码仍然认为当前处于休眠状态,导致无法进行卡顿监控。

image-17000304903

针对上面的情况,我们可以使用的Observer 的方式处理:

  • 第一个 Observer 的  order 调整到 LONG_MIN
    • 进入 kCFRunLoopAfterWaiting 状态时,第一个被调用,用于监控 Runloop 处于 运行状态
  • 第二个 Observerorder  调整到 LONG_MAX
    • 进入  kCFRunLoopBeforeWaiting 状态时,最后一个被调用,用于判断 Runloop 处于 睡眠状态

如下图所示,通过 Observer ,我们可以更加准确的判断Runloop 的运行状态,从而对卡顿进行更加有效的监控。

image-17000342009
- (void)addRunLoopObserver
{
    NSRunLoop *curRunLoop = [NSRunLoop currentRunLoop];

    // 第一个监控,监控是否处于 **运行状态**
    CFRunLoopObserverContext context = {0, (__bridge void *) selfNULLNULLNULL};
    CFRunLoopObserverRef beginObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, LONG_MIN, &myRunLoopBeginCallback, &context);
    CFRetain(beginObserver);
    m_runLoopBeginObserver = beginObserver;

    //  第二个监控,监控是否处于 **睡眠状态**
    CFRunLoopObserverRef endObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, LONG_MAX, &myRunLoopEndCallback, &context);
    CFRetain(endObserver);
    m_runLoopEndObserver = endObserver;

    CFRunLoopRef runloop = [curRunLoop getCFRunLoop];
    CFRunLoopAddObserver(runloop, beginObserver, kCFRunLoopCommonModes);
    CFRunLoopAddObserver(runloop, endObserver, kCFRunLoopCommonModes);

}

// 第一个监控,监控是否处于 **运行状态**
void myRunLoopBeginCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    g_runLoopActivity = activity;
    g_runLoopMode = eRunloopDefaultMode;
    switch (activity) {
        case kCFRunLoopEntry:
            g_bRun = YES;
            break;
        case kCFRunLoopBeforeTimers:
            if (g_bRun == NO) {
                gettimeofday(&g_tvRun, NULL);
            }
            g_bRun = YES;
            break;
        case kCFRunLoopBeforeSources:
            if (g_bRun == NO) {
                gettimeofday(&g_tvRun, NULL);
            }
            g_bRun = YES;
            break;
        case kCFRunLoopAfterWaiting:
            if (g_bRun == NO) {
                gettimeofday(&g_tvRun, NULL);
            }
            g_bRun = YES;
            break;
        case kCFRunLoopAllActivities:
            break;
        default:
            break;
    }
}

//  第二个监控,监控是否处于 **睡眠状态**
void myRunLoopEndCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    g_runLoopActivity = activity;
    g_runLoopMode = eRunloopDefaultMode;
    switch (activity) {
        case kCFRunLoopBeforeWaiting:
            gettimeofday(&g_tvRun, NULL);
            g_bRun = NO;
            break;
        case kCFRunLoopExit:
            g_bRun = NO;
            break;
        case kCFRunLoopAllActivities:
            break;
        default:
            break;
    }
}

总结

本文通过分析 __CFRunLoopDoObservers 函数 和 CFRunLoopAddObserver 函数的内部逻辑,分析了网络上广泛流传的 Runloop 卡顿监测方案  存在低可用性问题的原因,并给出了一份高可用的 Runloop 卡顿监测方案

完整代码,可以访问 腾讯 的 matrix[1] 仓库获取

推荐阅读

✨ iOS 性能优化:优化 App 启动速度

✨ iOS 性能优化:使用 MetricKit 2.0 收集数据

✨ iOS 性能优化:用电池和性能 API 识别性能趋势

✨ iOS 性能优化:用 Xcode Organizer 诊断性能问题

✨ iOS 性能优化:优化 App 的持久化策略

关注我们

我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2020」,领取学习大礼包。

参考资料

[1]

matrix: https://github.com/Tencent/matrix/blob/master/matrix/matrix-iOS/Matrix/WCCrashBlockMonitor/CrashBlockPlugin/Main/BlockMonitor/WCBlockMonitorMgr.mm#L815-L844


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存