卡顿监控

在很早之前就有过实现一套自己的iOS监控体系,但首先是instrument足够的优秀,几乎所有监控相关的操作都有对应的工具。二来,也是笔者没(lan)时(de)间(zuo),项目大多也集成了第三方的统计SDK,所以迟迟没有去实现。这段时间,因为代码设计上存在的缺陷,导致项目在iphone5s以下的设备运行时会出现比较明显的卡顿现象。虽然instrument足够优秀,但笔者更希望在程序运行期间能及时获取卡顿信息,因此开始动手自己的卡顿检测方案。

获取栈上下文

任何监控体系在监控到目标事件发生时,获取线程的调用栈上下文是必须的,问题在于如何挂起当前线程并且获取线程信息。好在网上有大神分享了足够多的资料供笔者查阅,让笔者可以站在巨人的肩膀上来完成这部分业务。

demo中获取调用栈代码重写自BSBacktraceLogger,在使用之前建议能结合下方的参考资料和源代码一起阅览,知其然知其所以然。栈是一种后进先出(LIFO)的数据结构,对于一个线程来说,其调用栈的结构如下: 调用栈上每一个单位被称作栈帧(stack frame),每一个栈帧由函数参数返回地址以及栈帧中的变量组成,其中Frame Pointer指向内存存储了上一栈帧的地址信息。换句话说,只要能获取到栈顶的Frame Pointer就能递归遍历整个栈上的帧,遍历栈帧的核心代码如下:

define MAX_FRAME_NUMBER 30
define FAILED_UINT_PTR_ADDRESS 0

NSString * _lxd_backtraceOfThread(thread_t thread) {
    uintptr_t backtraceBuffer[MAX_FRAME_NUMBER];
    int idx = 0;
  
    ......

    LXDStackFrameEntry frame = { 0 };
    const uintptr_t framePtr = lxd_mach_framePointer(&machineContext);
    if (framePtr == FAILED_UINT_PTR_ADDRESS ||
        lxd_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) {
        return @"failed to get frame pointer";
    }
    for (; idx < MAX_FRAME_NUMBER; idx++) {
        backtraceBuffer[idx] = frame.return_address;
        if (backtraceBuffer[idx] == FAILED_UINT_PTR_ADDRESS ||
            frame.previous == NULL ||
            lxd_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
            break;
        }
    }
}

从栈帧中我们只能获取到调用函数的地址信息,为了输出上下文数据,我们还需要根据地址进行符号化,即找到地址所在的内存镜像,然后定位该镜像中的符号表,最后从符号表中匹配地址对应的符号输出。 符号化过程中包括不限于以下的数据结构:

typedef struct dl_info {
    const char   *dli_fname;
    void         *dli_fbase;
    const char   *dli_sname;
    void         *dli_saddr;
} Dl_info;

Dl_info存储了包括路径名、镜像起始地址、符号地址和符号名等信息

struct symtab_command {
    uint32_t    cmd;
    uint32_t    cmdsize;
    uint32_t    symoff;
    uint32_t    nsyms;
    uint32_t    stroff;
    uint32_t    strsize;
};

提供了符号表的偏移量,以及元素个数,还有字符串表的偏移和其长度。更多堆栈的资料可以参考文末最后三个链接学习。符号化的核心函数lxd_dladdr如下:

bool lxd_dladdr(const uintptr_t address, Dl_info * const info) {
    info->dli_fname = NULL;
    info->dli_fbase = NULL;
    info->dli_sname = NULL;
    info->dli_saddr = NULL;

    const uint32_t idx = lxd_imageIndexContainingAddress(address);
    if (idx == UINT_MAX) { return false; }

    const struct mach_header * header = _dyld_get_image_header(idx);
    const uintptr_t imageVMAddressSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
    const uintptr_t addressWithSlide = address - imageVMAddressSlide;
    const uintptr_t segmentBase = lxd_segmentBaseOfImageIndex(idx) + imageVMAddressSlide;
    if (segmentBase == FAILED_UINT_PTR_ADDRESS) { return false; }

    info->dli_fbase = (void *)header;
    info->dli_fname = _dyld_get_image_name(idx);

    const LXD_NLIST * bestMatch = NULL;
    uintptr_t bestDistance = ULONG_MAX;
    uintptr_t cmdPtr = lxd_firstCmdAfterHeader(header);
    if (cmdPtr == FAILED_UINT_PTR_ADDRESS) { return false; }

    for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
        const struct load_command * loadCmd = (struct load_command *)cmdPtr;
        if (loadCmd->cmd == LC_SYMTAB) {
            const struct symtab_command * symtabCmd = (struct symtab_command *)cmdPtr;
            const LXD_NLIST * symbolTable = (LXD_NLIST *)(segmentBase + symtabCmd->symoff);
            const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
        
            for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
                if (symbolTable[iSym].n_value == FAILED_UINT_PTR_ADDRESS) { continue; }
                uintptr_t symbolBase = symbolTable[iSym].n_value;
                uintptr_t currentDistance = addressWithSlide - symbolBase;
                if ( (addressWithSlide >= symbolBase && currentDistance <= bestDistance) ) {
                    bestMatch = symbolTable + iSym;
                    bestDistance = currentDistance;
                }
            }
            if (bestMatch != NULL) {
                info->dli_saddr = (void *)(bestMatch->n_value + imageVMAddressSlide);
                info->dli_sname = (char *)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
                if (*info->dli_sname == '_') {
                    info->dli_sname++;
                }
                if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
                    info->dli_sname = NULL;
                }
                break;
            }
        }
        cmdPtr += loadCmd->cmdsize;
    }
    return true;
}

整个符号化过程可以用下面的图表示ps:经过joy__说明,前面放上的图虽然在操作上类似,但是图示是fishhook的过程,因此删除旧图片

关于RunLoop

RunLoop是一个重复接收着端口信号和事件源的死循环,它不断的唤醒沉睡,主线程的RunLoop在应用跑起来的时候就自动启动,RunLoop的执行流程由下图表示: CFRunLoop.c中,可以看到RunLoop的执行代码大致如下:

{
    /// 1. 通知Observers,即将进入RunLoop
    /// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
    do {
 
        /// 2. 通知 Observers: 即将触发 Timer 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
        /// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources);
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
 
        /// 4. 触发 Source0 (非基于port的) 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
 
        /// 6. 通知Observers,即将进入休眠
        /// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);
 
        /// 7. sleep to wait msg.
        mach_msg() -> mach_msg_trap();
    
 
        /// 8. 通知Observers,线程被唤醒
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);
 
        /// 9. 如果是被Timer唤醒的,回调Timer
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);
 
        /// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
        __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);
 
        /// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);
 
 
    } while (...);
 
    /// 10. 通知Observers,即将退出RunLoop
    /// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
}

通过源码不难发现RunLoop处理事件的时间主要出在两个阶段:

  • kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting之间
  • kCFRunLoopAfterWaiting之后

监控RunLoop状态检测超时

通过RunLoop的源码我们已经知道了主线程处理事件的时间,那么如何检测应用是否发生了卡顿呢?为了找到合理的处理方案,笔者先监听RunLoop的状态并且输出:

static void lxdRunLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void * info) {
    SHAREDMONITOR.currentActivity = activity;
    dispatch_semaphore_signal(SHAREDMONITOR.semphore);
    switch (activity) {
        case kCFRunLoopEntry:
            NSLog(@"runloop entry");
            break;
        
        case kCFRunLoopExit:
            NSLog(@"runloop exit");
            break;
        
        case kCFRunLoopAfterWaiting:
            NSLog(@"runloop after waiting");
            break;
        
        case kCFRunLoopBeforeTimers:
            NSLog(@"runloop before timers");
            break;
        
        case kCFRunLoopBeforeSources:
            NSLog(@"runloop before sources");
            break;
        
        case kCFRunLoopBeforeWaiting:
            NSLog(@"runloop before waiting");
            break;
        
        default:
            break;
    }
};

运行之后输出的结果是滚动引发的Sources事件总是被快速的执行完成,然后进入到kCFRunLoopBeforeWaiting状态下。假如在滚动过程中发生了卡顿现象,那么RunLoop必然会保持kCFRunLoopAfterWaiting或者kCFRunLoopBeforeSources这两个状态之一。

为了实现卡顿的检测,首先需要注册RunLoop的监听回调,保存RunLoop状态;其次,通过创建子线程循环监听主线程RunLoop的状态来检测是否存在停留卡顿现象: 收到Sources相关的事件时,将超时阙值时间内分割成多个时间片段,重复去获取当前RunLoop的状态。如果多次处在处理事件的状态下,那么可以视作发生了卡顿现象

define SHAREDMONITOR [LXDAppFluecyMonitor sharedMonitor]

@interface LXDAppFluecyMonitor : NSObject

@property (nonatomic, assign) int timeOut;
@property (nonatomic, assign) BOOL isMonitoring;
@property (nonatomic, assign) CFRunLoopActivity currentActivity;

+ (instancetype)sharedMonitor;
- (void)startMonitoring;
- (void)stopMonitoring;

@end

- (void)startMonitoring {
    dispatch_async(lxd_fluecy_monitor_queue(), ^{
    while (SHAREDMONITOR.isMonitoring) {
        long waitTime = dispatch_semaphore_wait(self.semphore, dispatch_time(DISPATCH_TIME_NOW, lxd_wait_interval));
            if (waitTime != LXD_SEMPHORE_SUCCESS) {
                if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || 
                   SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) {
                    if (++SHAREDMONITOR.timeOut < 5) {
                        continue;
                    }
                    [LXDBacktraceLogger lxd_logMain];
                    [NSThread sleepForTimeInterval: lxd_restore_interval];
                }
            }
            SHAREDMONITOR.timeOut = 0;
        }
    });
}

标记位检测线程超时

与UI卡顿不同的事,事件处理往往是处在kCFRunLoopBeforeWaiting的状态下收到了Sources事件源,最开始笔者尝试同样以多个时间片段查询的方式处理。但是由于主线程的RunLoop在闲置时基本处于Before Waiting状态,这就导致了即便没有发生任何卡顿,这种检测方式也总能认定主线程处在卡顿状态。

就在这时候寒神(南栀倾寒)推荐给我一套Swift的卡顿检测第三方ANREye,这套卡顿监控方案大致思路为:创建一个子线程进行循环检测,每次检测时设置标记位为YES,然后派发任务到主线程中将标记位设置为NO。接着子线程沉睡超时阙值时长,判断标志位是否成功设置成NO。如果没有说明主线程发生了卡顿,无法处理派发任务:

事后发现在特定情况下,这种检测方式会出错:当主线程被async大量的执行任务时,每个任务执行时间小于卡顿时间阙值,即对操作无影响。这时候由于设置标志位的async任务位置过于靠后,导致子线程沉睡后未能成功设置,造成卡顿误报的现象。(ps:当然,实测结果是基本不可能发生这种现象)这套方案解决了上面监听RunLoop的缺陷。结合这套方案,当主线程处在Before Waiting状态的时候,通过派发任务到主线程来设置标记位的方式处理常态下的卡顿检测:

dispatch_async(lxd_event_monitor_queue(), ^{
    while (SHAREDMONITOR.isMonitoring) {
        if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) {
            __block BOOL timeOut = YES;
            dispatch_async(dispatch_get_main_queue(), ^{
                timeOut = NO;
                dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore);
            });
            [NSThread sleepForTimeInterval: lxd_time_out_interval];
            if (timeOut) {
                [LXDBacktraceLogger lxd_logMain];
            }
            dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER);
        }
    }
});

CADisplayLink监控

这几天看了iOS应用UI线程卡顿监控后,对卡顿有了更深的理解。从前文的描述来看卡顿就是主线程在某段时间内无法处理其他事件。但是从计算机的角度来说,假设屏幕在连续的屏幕刷新周期之内无法刷新屏幕内容,即是发生了卡顿。如下图第二个屏幕刷新周期出现了掉帧现象: 对于上述的两个方案。监听RunLoop无疑会污染主线程。死循环在线程间通信会造成大量的不必要损耗,即便GCD的性能已经很好了。因此,借鉴于MrPeak的文章,第三种方案采用CADisplayLink的方式来处理。思路是每个屏幕刷新周期派发标记位设置任务到主线程中,如果多次超出16.7ms的刷新阙值,即可看作是发生了卡顿。

define LXD_RESPONSE_THRESHOLD 10

dispatch_async(lxd_fluecy_monitor_queue(), ^{
    CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget: self selector: @selector(screenRenderCall)];
    [self.displayLink invalidate];
    self.displayLink = displayLink;
    
    [self.displayLink addToRunLoop: [NSRunLoop currentRunLoop] forMode: NSDefaultRunLoopMode];
    CFRunLoopRunInMode(kCFRunLoopDefaultMode, CGFLOAT_MAX, NO);
});

- (void)screenRenderCall {
    __block BOOL flag = YES;
    dispatch_async(dispatch_get_main_queue(), ^{
        flag = NO;
        dispatch_semaphore_signal(self.semphore);
    });
    dispatch_wait(self.semphore, 16.7 * NSEC_PER_MSEC);
    if (flag) {
        if (++self.timeOut < LXD_RESPONSE_THRESHOLD) { return; }
        [LXDBacktraceLogger lxd_logMain];
    }
    self.timeOut = 0;
}

尾言

虽然市场上存在着大量的监控体系轮子,但是笔者认为如果不去思考轮子是怎么做的,不去尝试造轮子,很多技术点难以融会贯通,使用起来。 多数开发者对于RunLoop可能并没有进行实际的应用开发过,或者说即便了解RunLoop也只是处在理论的认知上。当然,也包括调用堆栈追溯的技术。本文旨在通过自身实现的卡顿监控代码来让更多开发者去了解这些深层次的运用与实践。

此外,上面两种检测方案可以兼并使用,甚至只使用后者进行主线程的卡顿检测也是可以的,本文demo已经上传:LXDAppMonitor

参考资料

深入了解RunLoop
移动端监控体系之技术原理
趣探 Mach-O:FishHook 解析
iOS中线程Call Stack的捕获和解析1-2