尽管APM有相当多的采集指标,但假如只能监控一种数据,那么必然会选择crash。毫不客气的说,crash在APM中绝对可以占据80%甚至更多的地位。因此,如果一旦crash采集的数据发生了异常,对于APM相关的开发人员来说,绝对是一个噩耗。
crash采集
细分之后,crash可以被分为mach exception、signal以及NSException三种类型,每一种类型有着对应的捕获方式。
mach exception mach exception通过端口的方式传递,在异常抛出之后会依次投递到thread、task和host端口。系统提供了一部分的端口API,包括创建和注册对应的exception handler。但即便我们注册了对应的处理程序,也不会干扰原有的投递流程,最终mach exception会经过一系列的转换成为signal信号。
NSException NSException对异常信息进行了抽象封装,面向对象的设计使得我们可以使用try-catch来避免应用异常崩溃,这是其他两种crash不具备的。和mach exception一样,NSException同样能被转换成SIGABRT抛出。另外,假如我们使用NSSetUncaughtExceptionHandler去注册回调处理,那么对应的crash不会转换成signal
signal signal是一种强大的机制,异常处理仅仅是它一部分的功能。signal.h文件中声明了32种异常信号,一般crash需要捕获的为6-8种。对于每一种signal异常,都能在网上找到具体的异常信息,这里不再多说。由于mach exception和NSException最终都能被转换成signal,因此理论上我们只需要注册signal的处理就可以了
由于crash的捕获机制只允许我们注册一个回调函数,因此多个crash采集框架很可能会存在冲突。为了避免冲突导致回调流程失效,注册前都应该检测是否存在已注册的handler,保证多个handler可以像响应链一般连续的执行下去。以信号注册sigaction函数为例,正确的注册代码如下:
static struct sigaction registered_action; struct sigaction my_action; void signal_handler(int signal) { ...... } myAction.sa_handler = &signal_handler; sigemptyset(&my_action.sa_mask); sigaction(SIGABRT, &my_action, ®istered_action);
但是,即便我们对冲突做了防范处理,并不代表第三方框架在我们注册之后会同样善待我们的handler。某年某月,线上的crash上报数量堪称飞流直下三千尺。经过了一系列的排查,最终发现问题出在第三方注册了多个与我们相同的signal回调并且没有做冲突处理。
冲突解决方案
解决问题应当寻求代价最小的方案,并且要避免相同的问题今后发生之后也能有效的解决。另外,除了要保护我们自己的crash回调之外,由于回调的过程中存在二次crash以及人工abort的风险,我们应当保证我们的handler是第一个被回调的。首先除开load阶段发生crash的可能,为了减少遗漏,注册的时机应当是尽可能的提前。所以大部分框架选择注册crash的时机是在didLaunch方法里面。
周期性检测
利用已有或者新建一个周期回调,去检测我们注册的handler是否被替换,然后做对应的处理。
监听应用状态
基本上在应用进入active状态之前,crash相关的注册都已经完成,我们可以接收对应的状态通知来保护我们注册的handler
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... [[NSNotificationCenter defaultCenter] addObserver: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) name: UIApplicationDidBecomeActiveNotification object: nil]; ...... } static struct sigaction existActions[32]; static int fatal_signals[] = { SIGILL, SIGBUS, SIGABRT, SIGPIPE, }; - (void)checkRegisterCrashHandler { struct sigaction oldAction; for (int idx = 0; idx < sizeof(fatal_signals) / sizeof(int); idx++) { sigaction(fatal_signals[idx], NULL, &oldAction); if (oldAction.sa_handler != &signal_handler) { existActions[fatal_signals[idx]] = oldAction; struct sigaction myAction; myAction.sa_handler = &signal_handler; sigemptyset(&myAction.sa_mask); sigaction(SIGABRT, &myAction, NULL); } } }
定时器检测
创建定时器来控制检测周期,比起监听应用状态有更强的可控性。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... NSTimer *timer = [[NSTimer alloc] initWithFireDate: [NSDate date] interval: 30 target: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) userInfo: nil repeats: YES]; [[NSRunLoop currentRunLoop] addTimer: timer forMode: NSRunLoopCommonModes]; [timer fire]; ...... }
周期性检测方案存在的风险是,如果在两个周期之间发生了注册冲突,并且随后发生crash时,并不能保证我们的handler会被调起。周期性检测的方案优点在于简单,但是由于重复注册handler,可能导致回调时也被多次调用。
hook
虽然crash相关的捕获接口都是C语言编写的,但fishhook提供了对于C语言级别的hook方案。通过hook对应的捕获接口,检测注册的handler是否为我们的回调:
struct SignalHandler { void (*signal_handler)(int); struct SignalHandler *next; } struct SignalHandler *previousHandlers[32]; void append(struct SignalHandler *handlers, struct SignalHandler *node) { ...... } static int (*origin_sigaction)(int, const struct sigaction *__restrict, struct sigaction * __restrict) = NULL; int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) { if (new_action.sa_handler != signal_handler) { append(previousHandlers[signal], new_action); return origin_sigaction(signal, NULL, old_action); } else { return origin_sigaction(signal, new_action, old_action); } }
如果检测到即将注册的handler不是我们的注册函数,那么将注册的回调添加到回调链表中,在我们处理完成之后逐一唤起调用。这种hook机制存在的一个风险是假如我们先进行了注册,后续其他的注册如果做了冲突处理。那么由于其他handler会保留已注册的回调函数,就是我们的signal_handler,将会导致crash发生后,我们的signa_handler将会被调起多次:
解决方案之一是永远不告诉其他的注册者已经存在的回调函数,避免发生二次回调:
int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) { if (new_action.sa_handler != signal_handler) { append(previousHandlers[signal], new_action); return origin_sigaction(signal, NULL, NULL); } else { return origin_sigaction(signal, new_action, old_action); } } 又或者在我们的signal_handler中采用单例方式写法,保证回调处理只被执行一次: void signal_handler(int signal) { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ ...... }); }
hook的方式对注册的回调函数有了更高的控制力,并且保证回调的顺序总是我们的回调在前。但是,如果其他框架中同样hook了相同的函数,做了过滤处理,可能将导致所有的handler都被过滤掉,最终所有的crash捕获都失效。
最简单的方式
上述的方法都或多或少存在自己的不足,那么有没有不需要改动那么大,又可以避免发生采集冲突的做法呢?答案是不用冲突的SDK或者和对方协商提供一个不冲突的版本。
尾言
踩坑是件很有趣的事情。踩坑让我们支付了时间、精力、甚至KPI,但是踩过的坑越多,我们才会越强大。