Tenloy's Blog

iOS卡顿监控与堆栈获取

Word count: 8.6kReading time: 33 min
2021/07/30 Share

一、卡顿的难点

时不时会收到这样的卡顿反馈:“用户A 刚才碰到从后台切换前台卡了一下,最近偶尔会遇到几次”、“用户B 反馈点对话框卡了五六秒”、“现网有用户反馈切换 tab 很卡”。

这些反馈有几个特点,导致跟进困难:

  1. 不易重现。可能是特定用户的手机上才有问题,由于种种原因这个手机不能拿来调试;也有可能是特定的时机才会出问题,过后就不能重现了(例如线程抢锁)。
  2. 操作路径长,日志无法准确打点

对于这些界面卡顿反馈,通常我们拿用户日志作用不大,增加日志点也用处不大。只能不断重试希望能够重现出来,或者埋头代码逻辑中试图能找的蛛丝马迹。

二、原理

在开始之前,我们先思考一下,界面卡顿是由哪些原因导致的?

  • 死锁:主线程拿到锁 A,需要获得锁 B,而同时某个子线程拿了锁 B,需要锁 A,这样相互等待就死锁了。
  • 抢锁:主线程需要访问 DB,而此时某个子线程往 DB 插入大量数据。通常抢锁的体验是偶尔卡一阵子,过会就恢复了。
  • 主线程大量 IO:主线程为了方便直接写入大量数据,会导致界面卡顿。
  • 主线程大量计算:算法不合理,导致主线程某个函数占用大量 CPU。
  • 大量的 UI 绘制:复杂的 UI、图文混排等,带来大量的 UI 绘制。

针对这些原因,我们可以怎么定位问题呢?

  • 死锁一般会伴随 crash,可以通过 crash report 来分析。
  • 抢锁不好办,将锁等待时间打出来用处不大,我们还需要知道是谁占了锁
  • 大量 IO 可以在函数开始结束打点,将占用时间打到日志中。
  • 大量计算同理可以将耗时打到日志中。
  • 大量 UI 绘制一般是必现,还好办;如果是偶现的话,想加日志点都没地方,因为是慢在系统函数里面

如果可以将当时的线程堆栈捕捉下来,那么上述难题都迎刃而解。主线程在什么函数哪一行卡住、在等什么锁而这个锁又是被哪个子线程的哪个函数占用、是在进行I/O操作、或者是进行复杂计算,有了堆栈,我们都可以知道。自然也能知道是慢在UI绘制,还是慢在我们的代码。

所以,思路就是起一个子线程,监控主线程的活动情况,如果发现有卡顿,就将堆栈 dump 下来

流程图描述如下:

三、细节

原理一旦讲出来,好像也不复杂。魔鬼都是隐藏在细节中,效果好不好,完全由实现细节决定。具体到卡顿检测,有几个问题需要仔细处理:

  • 怎么知道主线程发生了卡顿?
  • 子线程以什么样的策略和频率来检测主线程?这个是要发布到现网的,如果处理不好,带来明显的性能损耗(尤其是电量),就不能接受了。
  • 堆栈上报了上来怎么分类?直接用 crash report 的分类不适合。
  • 卡顿 dump 下来的堆栈会有多频繁?数据量会有多大?
  • 全量上报还是抽样上报?怎么在问题跟进与节省流量之间平衡?

3.1 卡顿判断标准

怎么判断主线程是不是发生了卡顿?一般来说,用户感受得到的卡顿大概有三个特征:

  • FPS 降低
  • CPU 占用率很高
  • 主线程 Runloop 执行了很久

看起来 FPS 能够兼容后面两个特征,但是在实际操作过程中发现 FPS 并不适用,不好衡量:

  • 人眼结构上看,当一组动作在 1 秒内有 12 次变化(即 12FPS),我们会认为这组动作是连贯的;
  • 平时看到的大部分电影或视频 FPS 其实不高,一般只有 25FPS ~ 30FPS,而实际上我们也没有觉得卡顿;
  • 游戏玩家通常追求更流畅的游戏画面体验一般要达到 60FPS 以上
  • FPS 低并不意味着卡顿发生,而卡顿发生 FPS 一定不高。FPS 可以衡量一个界面的流畅性,但往往不能很直观的衡量卡顿的发生。

而对于抢锁或大量 IO 的情况,光有 CPU 是不行的。所以我们实际上用到的是下面两个准则:

  • 单核 CPU 的占用超过了 80%
  • 主线程 Runloop 执行了超过2秒

3.2 卡顿检测实现

在 iOS/macOS 平台应用中,主线程有一个 Runloop。Runloop 是一个 Event Loop 模型,让线程可以处于接收消息、处理事件、进入等待而不马上退出。在进入事件的前后,Runloop 会向注册的 Observer 通知相应的事件。

Runloop 的详细介绍可以参考:深入理解RunLoop,一个简易的 Runloop 流程如下所示:

Matrix 卡顿监控在 Runloop 的起始最开始和结束最末尾位置添加 Observer,从而获得主线程的开始和结束状态。卡顿监控起一个子线程定时检查主线程的状态,当主线程的状态运行超过一定阈值则认为主线程卡顿,从而标记为一个卡顿。

目前微信使用的卡顿监控,主程序 Runloop 超时的阈值是 2 秒,子线程的检查周期是 1 秒。每隔 1 秒,子线程检查主线程的运行状态;如果检查到主线程 Runloop 运行超过 2 秒则认为是卡顿,并获得当前的线程快照。

同时,我们也认为 CPU 过高也可能导致应用出现卡顿,所以在子线程检查主线程状态的同时,如果检测到 CPU 占用过高,会捕获当前的线程快照保存到文件中。目前微信应用中认为,单核 CPU 的占用超过了 80%,此时的 CPU 占用就过高了。

代码示例:SMLagMonitor.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//创建子线程监控
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 检测策略—退火算法

为了降低检测带来的性能损耗,我们仔细设计了检测线程的策略:

  • 内存 dump:每次子线程检查到主线程卡顿,会先获得主线程的堆栈并保存到内存中(不会直接去获得线程快照保存到文件中);
  • 文件 dump:将获得的主线程堆栈与上次卡顿获得的主线程堆栈进行比对:
    • 如果堆栈不同,则获得当前的线程快照并写入文件中;
    • 如果相同则会跳过,并按照斐波那契数列将检查时间递增(1,1,2,3,5,8…)直到没有遇到卡顿或者主线程卡顿堆栈不一样。

这样,可以避免同一个卡顿写入多个文件的情况;避免检测线程遇到主线程卡死的情况下,不断写线程快照文件。

3.4 卡顿时堆栈获取

3.4.1 直接调用系统函数

获取堆栈信息的一种方法是直接调用系统函数。这种方法的优点在于,性能消耗小。但是,它只能够获取简单的信息,也没有办法配合 dSYM 来获取具体是哪行代码出了问题,而且能够获取的信息类型也有限,且只能获取当前线程的调用栈。这种方法,因为性能比较好,所以适用于观察大盘统计卡顿情况,而不是想要找到卡顿原因的场景。

直接调用系统函数方法的主要思路是:用 signal 进行错误信息的获取。具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static int s_fatal_signals[] = {
SIGABRT,
SIGBUS,
SIGFPE,
SIGILL,
SIGSEGV,
SIGTRAP,
SIGTERM,
SIGKILL,
};

static int s_fatal_signal_num = sizeof(s_fatal_signals) / sizeof(s_fatal_signals[0]);

void UncaughtExceptionHandler(NSException *exception) {
NSArray *exceptionArray = [exception callStackSymbols]; //得到当前调用栈信息
NSString *exceptionReason = [exception reason]; //非常重要,就是崩溃的原因
NSString *exceptionName = [exception name]; //异常类型
}

void SignalHandler(int code){
NSLog(@"signal handler = %d",code);
}

void InitCrashReport(){
//系统错误信号捕获
for (int i = 0; i < s_fatal_signal_num; ++i) {
signal(s_fatal_signals[i], SignalHandler);
}
//oc未捕获异常的捕获
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}

int main(int argc, char * argv[]) {
@autoreleasepool {
InitCrashReport();
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

3.4.2 PLCrashReporter三方库

PLCrashReporter 是微软开源的第三方框架,用来做 crash 收集,可以直接用 PLCrashReporter 来获取堆栈信息。这种方法的特点是,能够定位到问题代码的具体位置,而且性能消耗也不大。所以,也是我推荐的获取堆栈信息的方法。

具体如何使用 PLCrashReporter 来获取堆栈信息,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 获取数据
NSData *lagData = [[[PLCrashReporter alloc] initWithConfiguration:
[[PLCrashReporterConfig alloc]
initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]]
generateLiveReport];
// 转换成 PLCrashReport 对象
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
// 进行字符串格式化处理
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
//将字符串上传服务器
NSLog(@"lag happen, detail below: \n %@",lagReportString);

堆栈采集相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// ▼ plcrash_write_report是核心,暂停线程,抓线程堆栈信息,写文件和恢复线程都在这个函数里。
// ▶ plcrash_log_writer_write: 具体抓线程信息和写文件的关键实现在plcrash_log_writer_write这个函数里,
// 这个函数内部会去读image_list,获取所有线程,暂停除了当前线程之外的所有线程,
plcrash_error_t plcrash_log_writer_write (...) {
thread_act_array_t threads;
mach_msg_type_number_t thread_count;
/* Get a list of all threads */
if (task_threads(mach_task_self(), &threads, &thread_count) != KERN_SUCCESS) {
PLCF_DEBUG("Fetching thread list failed");
thread_count = 0;
}

/* Suspend all but the current thread. */
for (mach_msg_type_number_t i = 0; i < thread_count; i++) {
if (threads[i] != pl_mach_thread_self())
thread_suspend(threads[i]);
}

/* Threads */
for (mach_msg_type_number_t i = 0; i < thread_count; i++) {
thread_t thread = threads[i];
...
// 上报线程堆栈信息
plcrash_writer_write_thread(file, writer, mach_task_self(), thread,
thread_number, thr_ctx, image_list, &findContext, crashed);
}
}

static size_t plcrash_writer_write_thread (...)
{
//...
/* Walk the stack, limiting the total number of frames that are output. */
uint32_t frame_count = 0;
// plframe_cursor_next: Fetch the next frame 获取下一帧。内部与BSBacktraceLogger原理类似,也是通过LR寄存器,一层层向上找。
while ((ferr = plframe_cursor_next(&cursor)) == PLFRAME_ESUCCESS && frame_count < MAX_THREAD_FRAMES) {
uint32_t frame_size;

/* On the first frame, dump registers for the crashed thread */
if (frame_count == 0 && crashed) {
rv += plcrash_writer_write_thread_registers(file, task, &cursor);
}

/* Fetch the PC value */
plcrash_greg_t pc = 0;
if ((ferr = plframe_cursor_get_reg(&cursor, PLCRASH_REG_IP, &pc)) != PLFRAME_ESUCCESS) {
PLCF_DEBUG("Could not retrieve frame PC register: %s", plframe_strerror(ferr));
break;
}

/* Determine the size */
frame_size = (uint32_t) plcrash_writer_write_thread_frame(NULL, writer, pc, image_list, findContext);

rv += plcrash_writer_pack(file, PLCRASH_PROTO_THREAD_FRAMES_ID, PLPROTOBUF_C_TYPE_MESSAGE, &frame_size);
rv += plcrash_writer_write_thread_frame(file, writer, pc, image_list, findContext);
frame_count++;
}
//...
return rv;
}

3.4.3 KSCrash

KSCrash 是 iOS 上一个知名的 crash 收集框架。包括腾讯开源的 APM 框架 Matrix,其中 crash 收集部分也是直接使用的 KSCrash。

KSCrash 可以处理以下类型的崩溃:

  • Mach kernel exceptions Mac内核异常
  • Fatal signals
  • C++ exceptions
  • Objective-C exceptions
  • Main thread deadlock (experimental) 主线程死锁
  • Custom crashes (e.g. from scripting languages) 自定义崩溃。

堆栈采集源码:(获取任意线程调用栈的那些事 _bestswifter BSBackTracelogger的参考资料中有KSCrash,貌似对KSCrash有参考,源码思路上有些相似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static bool advanceCursor(KSStackCursor *cursor) {
MachineContextCursor* context = (MachineContextCursor*)cursor->context;
uintptr_t nextAddress = 0;

if(cursor->state.currentDepth >= context->maxStackDepth) {
cursor->state.hasGivenUp = true;
return false;
}

if(context->instructionAddress == 0 && cursor->state.currentDepth == 0) {
context->instructionAddress = kscpu_instructionAddress(context->machineContext);
nextAddress = context->instructionAddress;
goto successfulExit;
}

if(context->linkRegister == 0 && !context->isPastFramePointer) {
// Link register, if available, is the second address in the trace.
/*
uintptr_t kscpu_linkRegister(const KSMachineContext* const context) {
return context->machineContext.__ss.__lr;
}
*/
context->linkRegister = kscpu_linkRegister(context->machineContext);
if(context->linkRegister != 0) {
nextAddress = context->linkRegister;
goto successfulExit;
}
}

if(context->currentFrame.previous == NULL) {
if(context->isPastFramePointer)
{
return false;
}
context->currentFrame.previous = (struct FrameEntry*)kscpu_framePointer(context->machineContext);
context->isPastFramePointer = true;
}

if(!ksmem_copySafely(context->currentFrame.previous, &context->currentFrame, sizeof(context->currentFrame))) {
return false;
}
if(context->currentFrame.previous == 0 || context->currentFrame.return_address == 0) {
return false;
}

nextAddress = context->currentFrame.return_address;

successfulExit:
cursor->stackEntry.address = kscpu_normaliseInstructionPointer(nextAddress);
cursor->state.currentDepth++;
return true;
}

3.4.4 WCCrashBlockMonitorPlugin

Matrix for iOS/macOS 是微信开源的一个工具,可以使用在 iOS、macOS 平台上。在日常开发中,微信iOS团队通过卡顿监控上报的堆栈,找到微信的代码不合理之处或者是一些性能瓶颈;通过卡顿监控的辅助,尽可能地提升微信的流畅性,给用户带来更加极致美好的体验。

工具监控范围包括:崩溃、卡顿和爆内存,包含以下两款插件:

  • WCCrashBlockMonitorPlugin:基于 KSCrash 框架开发,具有业界领先的卡顿堆栈捕获能力,同时兼备崩溃捕获能力。
  • WCMemoryStatPlugin:一款性能优化到极致的爆内存监控工具,能够全面捕获应用爆内存时的内存分配以及调用堆栈情况。

3.4.5 BSBackTracelogger

见下文

3.5 耗时堆栈提取

子线程检测到主线程 Runloop 时,会获得当前的线程快照当做卡顿文件。但是这个当前的主线程堆栈不一定是最耗时的堆栈,不一定是导致主线程超时的主要原因。

例如,主线程在绘制一个微信logo,过程如下:

子线程在检测到超出阈值时获得的线程快照,主线程的当前任务是“画小气泡”。但其实“画大气泡”才是耗时操作,导致主线程超时的主要原因。Matrix 卡顿监控通过主线程耗时堆栈提取来解决这个问题。

卡顿监控定时获取主线程堆栈,并将堆栈保存到内存的一个循环队列中。如下图,每间隔时间 t 获得一个堆栈,然后将堆栈保存到一个最大个数为 3 的循环队列中。有一个游标不断的指向最近的堆栈。

微信的策略是每隔 50 毫秒获取一次主线程堆栈,保存最近 20 个主线程堆栈。这个会增加 3% 的 CPU 占用,内存占用可以忽略不计。

当主线程检测到卡顿时,通过对保存到循坏队列中的堆栈进行回溯,获取最近最耗时堆栈。

如下图,检测到卡顿时,内存的循环队列中记录了最近的20个主线程堆栈,需要从中找出最近最耗时的堆栈。Matrix 卡顿监控用如下特征找出最近最耗时堆栈:

  • 以栈顶函数为特征,认为栈顶函数相同的即整个堆栈是相同的;
  • 取堆栈的间隔是相同的,堆栈的重复次数近似作为堆栈的调用耗时,重复越多,耗时越多;
  • 重复次数相同的堆栈可能很有多个,取最近的一个最耗时堆栈。

获得的最近最耗时堆栈会附带到卡顿文件中。

3.6 卡死卡顿

Matrix 中内置了应用被杀原因的检测机制。这个机制从 Facebook 的博文 中获得灵感,在其基础上增加了系统强杀的判定。Matrix 检测应用被杀原因的具体机制如下图所示:

Matrix 检测到应用卡死被强杀,会把应用上次存活时的最后一份卡顿日志标记为卡死卡顿。

3.7 性能损耗

Matrix 卡顿监控不打开耗时堆栈提取,性能损耗可以忽略不计。

打开耗时堆栈提取后,性能损耗和定时获取主线程堆栈的间隔有关。实测,每隔 50 毫秒不断获取主线程堆栈,会增加 3% 的 CPU 占用。

3.8 分类方法

直接用 crash report 的分类方法是不行的,这个很好理解:最终卡在 lock 函数的卡顿,外面可能是很多不同的业务,例如可能是读取消息,可能是读取联系人,等等。卡顿监控需要仔细定义自己的分类规则。可以是从调用堆栈的最外层开始归类,或者是取中间一部分归类,或者是取最里面一部分归类。各有优缺点:

  • 最外层归类:能够将同一入口的卡顿归类起来。缺点是层数不好定,可能外面十来层都是系统调用,也有可能第一层就是微信的函数了。
  • 中间层归类:能够根据事先划分好的“特征值”来归类。缺点是“特征值”不好定,如果要做到自动学习生成的话,对后台分析系统要求太高了。
  • 最内层归类:能够将同一原因的卡顿归类起来。缺点是同一分类可能包含不同的业务。

综合考虑并一一尝试之后,我们采用了最内层归类的优化版,亦即进行二级归类。

  • 第一级:按照 最内倒数2层 归类,这样能够将 同一原因 的卡顿集中起来;
    • 第二级分类是从第一级点击进来,然后按照 最内层倒数4层 进行归类,这样能够将同一原因,根据 不同业务(不同入口) 分散归类起来。

3.9 可运营

在正式发布之前,我们进行了灰度,以评估卡顿对用户的影响。收集到的结果是用户平均每天会产生30个 dump 文件,压缩上传大约要 300k 流量。预计正式发布的话会对后台有比较大的压力,对用户也有一定流量损耗。所以必须进行抽样上报。

  • 抽样上报:每天抽取不同的用户进行上报,抽样概率是5%。
  • 文件上传:被抽中的用户1天仅上传前20个堆栈文件,并且每次上报会进行多文件压缩上传。
  • 白名单:对于需要跟进问题的用户,可以在后台配置白名单,强制上报。

另外,为了减少对用户存储空间的影响,卡顿文件仅保存最近7天的记录,过期删除。

四、BSBackTracelogger堆栈获取原理

NSThread 有一个类方法 callstackSymbols 可以获取调用栈,但是它输出的是当前线程的调用栈。在利用 Runloop 检测卡顿时,子线程检测到了主线程发生卡顿,需要通过主线程的调用栈来分析具体是哪个方法导致了阻塞,这时系统提供的方法就无能为力了。

4.1 失败的方法

最初的想法很简单,既然 callstackSymbols 只能获取当前线程的调用栈,那在目标线程调用就可以了。比如 dispatch_async 到主队列,或者 performSelector 系列,更不用说还可以用 Block 或者代理等方法。

我们以 UIViewControllerviewDidLoad 方法为例,推测它底层都发生了什么。

首先主线程也是线程,就得按照线程基本法来办事。线程基本法说的是首先要把线程运行起来,然后(如果有必要,比如主线程)启动 runloop 进行保活。我们知道 runloop 的本质就是一个死循环,在循环中调用多个函数,分别判断 source0、source1、timer、dispatch_queue 等事件源有没有要处理的内容。

和 UI 相关的事件都是 source0,因此会执行 __CFRunLoopDoSources0,最终一步步走到 viewDidLoad。当事件处理完后 runloop 进入休眠状态。

假设我们使用 dispatch_async,它会唤醒 runloop 并处理事件,但此时 __CFRunLoopDoSources0 已经执行完毕,不可能获取到 viewDidLoad 的调用栈。

performSelector 系列方法的底层也依赖于 runloop,因此它只是像当前的 runloop 提交了一个任务,但是依然要等待现有任务完成以后才能执行,所以拿不到实时的调用栈。

总而言之,一切涉及到 runloop,或者需要等待 viewDidLoad 执行完的方案都不可能成功。

4.2 ARM64 函数调用栈

从汇编入手,看一下实际的Arm64的栈帧格式:案例来自BSBackTracelogger学习笔记,图解的非常清晰,感谢。

1
2
3
4
5
6
7
8
void b() {
int b = 1;
}

void a() {
int a = 1;
b();
}

汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
armtest`a:
0x102569f2c <+0>: sub sp, sp, #0x20 ; SP向低地址方向移动0x20(2*16=32)个字节

; 将x29、x30寄存器的值存储到当前栈顶SP+0x10的位置,分别保存的是函数c的栈底和a函数返回后的下一条指令
0x102569f30 <+4>: stp x29, x30, [sp, #0x10]

; 当前栈顶SP+0x10存入x29寄存器中,相当于FP指向了SP+0x10,此时FP指向函数a调用栈的栈底,其中存储的是调用方c函数的栈底
0x102569f34 <+8>: add x29, sp, #0x10
0x102569f38 <+12>: mov w8, #0x1 ; 简单的赋值操作,w8即x8寄存器的低8位存储常数1
0x102569f3c <+16>: stur w8, [x29, #-0x4] ; 将w8的值存储在FP往下偏移4个字节的位置
-> 0x102569f40 <+20>: bl 0x102569f18 ; b at ViewController.m:23 bl是带返回的跳转指令,返回地址保存到LR(X30)
0x102569f44 <+24>: ldp x29, x30, [sp, #0x10]
0x102569f48 <+28>: add sp, sp, #0x20 ; =0x20
0x102569f4c <+32>: ret


armtest`b:
0x102569f18 <+0>: sub sp, sp, #0x10 ; =0x10

; 可以发现在b函数的这一步,没看到保存a函数的FP(x29)和LR(x30)的指令,猜测是因为b函数已经处于整个调用链的最后,它没有调用其他的函数,因此不需要专门记录了,只需要在执行完毕之后返回到LR的指令就可以了

0x102569f1c <+4>: mov w8, #0x1

; 和a函数不同的是,a函数是存储在FP-0x4的位置,b函数存储在SP+0xc的位置(如上面所说,此时的FP并没有移动,仍指向的是a函数栈底)
-> 0x102569f20 <+8>: str w8, [sp, #0xc]
0x102569f24 <+12>: add sp, sp, #0x10 ; =0x10
0x102569f28 <+16>: ret
method-call-stack

c函数调用a函数是一个入栈出栈的过程,调用开始的时候入栈,同时需要保存c函数的FP(x29)和LR(x30)在a函数的FP和FP+8的位置,即当前函数a的FP位置保存的就是调用方的FP位置,a函数调用结束时返回到LR的位置继续执行下一条指令,而这条指令属于c函数,因此我们可以通过FP来建立整个调用链的关系,通过LR来确认调用方函数的符号。

尽管如此,有两种情况是获取不到调用堆栈的,一种是尾调用优化,一种是内联函数

4.3 BSBackTracelogger步骤

上文中我们谈到了函数调用栈,那么通过调用栈,只要我们可以拿到主线程的相关寄存器,就可以通过调用关系一步一步拿到主线程的调用堆栈,这也正是BSBackTracelogger的原理所在。

以下是获取调用栈原理(根据 arm64 情况)。

第一步 获取所有mach线程标识

Unix 系统提供的 thread_get_statetask_threads 等方法,操作的都是内核线程,每个内核线程由 thread_t 类型的 id 来唯一标识,pthread 的唯一标识是 pthread_t 类型。

mach 线程 — pthread — NSThread

获取主线程标识:

  • static mach_port_t main_thread_id = mach_thread_self(); 可以获取主线程的标识。
  • 上述方案要求我们在主线程中执行代码,一个很好的方案就是在 load 方法里。
  • 在使用的地方,判断目标 [NSThread isMainThread] 为真,则直接获取上面全局变量值。

获取子线程标识:

  • 通过 task_threads 函数获取当前进程中线程列表 thread_act_array_t,里面保存的是 mach 线程的标识,可以通过这个线程标识获取对应线程的 name、idle 状态等。

将子线程 NSThread 与 mach 线程对应起来(详见4.4.3小节):

  1. 如果不是主线程,那么可以给 NSThread 设置 name(比如设置为时间戳)。
  2. NSThread 的 name 和 pthread name 是一致的,所以可以遍历 thread_act_array_t,逐个通过 pthread_from_mach_thread_np 函数转为 pthread 获取 name 进行比对,匹配则标识找到了 NSThread 对应的 mach 线程标识。

关键函数、类型定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#define KERN_SUCCESS                0
typedef int kern_return_t;

//一步一步找下去,发现thread_act_array_t是个int *指针。act(active? 激活的)
typedef thread_act_t *thread_act_array_t;
typedef mach_port_t thread_act_t;
typedef __darwin_mach_port_t mach_port_t;
typedef __darwin_mach_port_name_t __darwin_mach_port_t;
typedef __darwin_natural_t __darwin_mach_port_name_t;
typedef unsigned int __darwin_natural_t;

// xnu源码task.c
/*
* 可以获取到所有的线程,注意这里的线程是最底层的 mach 线程
*/
kern_return_t task_threads(
task_t task,
thread_act_array_t *threads_out,
mach_msg_type_number_t *count)
{
return task_threads_internal(task, threads_out, count, THREAD_FLAVOR_CONTROL);
}

/*
* 将mach线程标识转为pthread
*/
struct _opaque_pthread_t {
long __sig;
struct __darwin_pthread_handler_rec *__cleanup_stack;
char __opaque[__PTHREAD_SIZE__];
};
typedef struct _opaque_pthread_t *__darwin_pthread_t;
typedef __darwin_pthread_t pthread_t;

pthread_t pthread_from_mach_thread_np(mach_port_t kernel_thread)
{
pthread_t p = NULL;

/* No need to wait as mach port is already known */
_pthread_lock_lock(&_pthread_list_lock);

TAILQ_FOREACH(p, &__pthread_head, tl_plist) {
if (_pthread_tsd_slot(p, MACH_THREAD_SELF) == kernel_thread) {
break;
}
}

_pthread_lock_unlock(&_pthread_list_lock);
return p;
}

第二步 根据线程标识获取调用栈

前面介绍了调用栈的结构:当前栈帧的 X29 指向上一个栈帧的 X29、X30,X30 中保存的就是 LR,LR 的上一条指令即为函数调用处。

所以根据线程标识获取栈帧结构体:

  • 最外侧的调用栈取 PC,通过回溯 FP 获取上层调用栈;
  • 其余的调用栈都取 LR ( LR 的上一条指令就是调用处),通过回溯 FP 获取上层调用栈;
  • 通过FP来建立整个调用链的关系,通过LR来确认调用方函数的符号。

所以构建一个递归结构体,分别指向自己和 LR,对应前面说的 X29、X30 结构,递归找到所有地址

根据线程标识可以通过 thread_get_state 获取到线程寄存器状态结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
typedef mach_port_t             thread_t;
typedef _STRUCT_ARM_THREAD_STATE64 arm_thread_state64_t;
#define _STRUCT_ARM_THREAD_STATE64 struct arm_thread_state64
_STRUCT_ARM_THREAD_STATE64
{
__uint64_t x[29]; /* General purpose registers x0-x28 通用寄存器*/
__uint64_t fp; /* Frame pointer x29 栈底指针*/
__uint64_t lr; /* Link register x30 子程序返回的下一行指令地址*/
__uint64_t sp; /* Stack pointer x31 栈顶指针*/
__uint64_t pc; /* Program counter 程序计数器,PC指针*/
__uint32_t cpsr; /* Current program status register */
__uint32_t flags; /* Flags describing structure format */
};
//之外,还有_STRUCT_ARM_THREAD_STATE
#define _STRUCT_ARM_THREAD_STATE struct arm_thread_state
_STRUCT_ARM_THREAD_STATE
{
__uint32_t r[13]; /* General purpose register r0-r12 */
__uint32_t sp; /* Stack pointer r13 */
__uint32_t lr; /* Link register r14 */
__uint32_t pc; /* Program counter r15 */
__uint32_t cpsr; /* Current program status register */
};
//如果不想使用的时候判断系统,可以使用_STRUCT_MCONTEXT这个结构体宏,这个宏内部做了系统判断,向上屏蔽了系统差异。然后从结构体中取出__ss字段,也就是_STRUCT_ARM_THREAD_STATE64或_STRUCT_ARM_THREAD_STATE。

/*
* 返回目标线程的执行状态,例如寄存器。
* 有个不太明白的点,第三个参数是个thread_state_t,一步步点击进入,发现最终是个unsigned int *,但是在XNU源码中使用的时候,发现都是传入的arm_thread_state_t/arm_thread_state64_t/x86_thread_state64_t结构体变量的地址,emm...这一点上类型的设计有些搞不清楚。或者是我源码看差了?

* 源码片段:
arm_thread_state64_t state = {};
thread_state_flavor_t flavor = ARM_THREAD_STATE64;
mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT;

kern_return_t ret = thread_get_state(thread, flavor, (thread_state_t)&state, &count);
if (ret != KERN_SUCCESS) return;
*/
kern_return_t thread_get_state(
thread_t thread,
int flavor,
thread_state_t state, /* pointer to OUT array */
mach_msg_type_number_t *state_count) /* IN/OUT */
{
return thread_get_state_internal(thread, flavor, state, state_count, FALSE);
}

BSBackTracelogger的源代码中使用的就是 _STRUCT_MCONTEXT 并从中取 __ss 字段,_STRUCT_MCONTEXT 判断了 32 位和 64 位,实质上是想取 _STRUCT_ARM_THREAD_STATExx

1
2
3
4
5
6
#pragma -mark HandleMachineContext
bool bs_fillThreadStateIntoMachineContext(thread_t thread, _STRUCT_MCONTEXT *machineContext) {
mach_msg_type_number_t state_count = BS_THREAD_STATE_COUNT;
kern_return_t kr = thread_get_state(thread, BS_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
return (kr == KERN_SUCCESS);
}

第三步 地址符号化

本地符号化可以使用 dladdr 函数,原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* Structure filled in by dladdr().
*/
typedef struct dl_info {
const char *dli_fname; /* Pathname of shared object 包含address的加载模块的文件名*/
void *dli_fbase; /* Base address of shared object 加载模块的句柄。该句柄可用作dlsym() 的第一个参数。*/
const char *dli_sname; /* Name of nearest symbol 与指定的address最接近的符号的名称。该符号要么带有相同的地址,要么是带有低位地址的最接近符号。两次调用dladdr()后,该内存位置的内容可能发生更改。*/
void *dli_saddr; /* Address of nearest symbol 最接近符号的实际地址。*/
} Dl_info;

// 获取某个地址的符号信息
int dladdr(const void* address, Dl_info* info);

BSBacktraceLogger 采用的方式是:

  1. 根据地址找到所属镜像(主二进制、系统库);
  2. 然后从所属镜像中找到符号表;
  3. 再找到与地址最接近的符号名称;

BSBackTracelogger 中并没有使用系统的 dladdr 函数,而是重写了一个。下面是个issue,以及作者的解答:

系统提供的dladdr方法是线程安全的,而代码中的fl_dladdr其实底层调用的也是系统提供的_dyld_get_image_header和_dyld_get_image_name等方法,而这些方法是线程不安全的,经过我本人的实验对比,fl_dladdr返回的symbolbuffer和dladdr返回的结果是一样的,不清楚作者是出于什么样的考虑自己重写了一个fl_dladdr方法。

回答:这段代码是从 PLC (PLCrashReporter) 里面抄出来的….

4.4 补充:NSThread、pthread、mach线程

4.4.1 pthread与mach内核线程

pthread 中的字母 p 是 POSIX 的简写,POSIX 表示 “可移植操作系统接口(Portable Operating System Interface)”。

每个操作系统都有自己的线程模型,不同操作系统提供的,操作线程的 API 也不一样,这就给跨平台的线程管理带来了问题,而 POSIX 的目的就是提供抽象的 pthread 以及相关 API,这些 API 在不同操作系统中有不同的实现,但是完成的功能一致。

iOS中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的:

RunLoop_0

Unix 系统提供的 thread_get_statetask_threads 等方法,操作的都是内核线程,每个内核线程由 thread_t 类型的 id 来唯一标识,pthread 的唯一标识是 pthread_t 类型。

内核线程和 pthread 的转换(也即是 thread_tpthread_t 互转)很容易,因为 pthread 诞生的目的就是为了抽象内核线程。

4.4.2 NSThread与pthread

NSThread 是苹果官方提供的,使用起来比 pthread 更加面向对象,简单易用,可以直接操作线程对象。

关于两者联系,可以查看 GNUStep-base 的源码,其中包含了 Foundation 库的源码,并不能确保 NSThread 完全采用这里的实现,但至少可以从 NSThread.m 类中挖掘出很多有用信息。

1
2
3
4
5
6
7
8
9
- (void)start {
pthread_attr_t attr;
pthread_t thr;
errno = 0;
pthread_attr_init(&attr);
if (pthread_create(&thr, &attr, nsthreadLauncher, self)) {
// Error Handling
}
}

NSThread 关于线程的实现,还是使用的pthread。但另一方面,NSThread 内部只有很少的地方用到了 pthread。从上面看到,NSThread 甚至都没有存储新建 pthread 的 pthread_t 标识。

另一处用到 pthread 的地方就是 NSThread 在退出时,调用了 pthread_exit()。除此以外就很少感受到 pthread 的存在感了。

1
2
3
4
5
6
7
8
9
10
11
12
13
+ (void)exit {
NSThread *t;
t = GSCurrentThread();
if (t->_active == YES) {
unregisterActiveThread(t);
if (t == defaultThread || defaultThread == nil) {
/* For the default thread, we exit the process. */
exit(0);
}else {
pthread_exit(NULL);
}
}
}

4.4.3 NSThread 转 内核thread

由于系统没有提供相应的转换方法,而且 NSThread 没有保留线程的 pthread_t,所以常规手段无法满足需求。

一种思路是利用 performSelector 方法在指定线程执行代码并记录 thread_t,执行代码的时机不能太晚,如果在打印调用栈时才执行就会破坏调用栈。最好的方法是在线程创建时执行,上文提到了在利用 pthread_create 方法创建线程时,会注册一个回调函数 nsthreadLauncher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Trampoline function called to launch the thread
*/
static NSNotificationCenter *nc = nil;
static void * nsthreadLauncher(void *thread) {
NSThread *t = (NSThread*)thread;
setThreadForCurrentThread(t);

/* Let observers know a new thread is starting. */
if (nc == nil) nc = RETAIN([NSNotificationCenter defaultCenter]);
[nc postNotificationName: NSThreadDidStartNotification object: t userInfo: nil];
[t _setName: [t name]];
[t main];
[NSThread exit];

// Not reached
return NULL;
}

很神奇的发现系统居然会发送一个通知,通知名不对外提供,但是可以通过监听所有通知名的方法得知它的名字: @"_NSThreadDidStartNotification",于是我们可以监听这个通知并调用 performSelector 方法。

一般 NSThread 使用 initWithTarget:Selector:object 方法创建。在 main 方法中 selector 会被执行,main 方法执行结束后线程就会退出。如果想做线程保活,需要在传入的 selector 中开启 runloop,详见这篇文章: 深入研究 Runloop 与线程保活

可见,这种方案并不现实,因为之前已经解释过,performSelector 依赖于 runloop 开启,而 runloop 直到 main 方法才有可能开启。

回顾问题发现,我们需要的是一个联系 NSThread 对象和内核 thread 的纽带,也就是说要找到 NSThread 对象的某个唯一值,而且内核 thread 也具有这个唯一值。

观察一下 NSThread,它的唯一值只有对象地址,对象序列号(Sequence Number) 和线程名称:

1
<NSThread: 0x144d095e0>{number = 1, name = main}

地址分配在堆上,没有使用意义,序列号的计算没有看懂,因此只剩下 name。幸运的是 pthread 也提供了一个方法 pthread_getname_np 来获取线程的名字,两者是一致的,感兴趣的读者可以自行阅读 setName 方法的实现,它调用的就是 pthread 提供的接口。

这里的 np 表示 not POSIX,也就是说它并不能跨平台使用。

于是解决方案就很简单了,对于 NSThread 参数,把它的名字改为某个随机数(我选择了时间戳),然后遍历 pthread 并检查有没有匹配的名字。查找完成后把参数的名字恢复即可。

4.4.4 主线程 转 内核thread

本来以为问题已经圆满解决,不料还有一个坑,主线程设置 name 后无法用 pthread_getname_np 读取到。

好在我们还可以迂回解决问题: 事先获得主线程的 thread_t,然后进行比对。

上述方案要求我们在主线程中执行代码从而获得 thread_t,显然最好的方案是在 load 方法里:

1
2
3
4
static mach_port_t main_thread_id;
+ (void)load {
main_thread_id = mach_thread_self();
}

五、参考链接

Author:Tenloy

原文链接:https://tenloy.github.io/2021/07/30/Lag-Monitor.html

发表日期:2021.07.30 , 7:28 PM

更新日期:2024.04.23 , 3:48 PM

版权声明:本文采用Crative Commons 4.0 许可协议进行许可

CATALOG
  1. 一、卡顿的难点
  2. 二、原理
  3. 三、细节
    1. 3.1 卡顿判断标准
    2. 3.2 卡顿检测实现
    3. 3.3 检测策略—退火算法
    4. 3.4 卡顿时堆栈获取
      1. 3.4.1 直接调用系统函数
      2. 3.4.2 PLCrashReporter三方库
      3. 3.4.3 KSCrash
      4. 3.4.4 WCCrashBlockMonitorPlugin
      5. 3.4.5 BSBackTracelogger
    5. 3.5 耗时堆栈提取
    6. 3.6 卡死卡顿
    7. 3.7 性能损耗
    8. 3.8 分类方法
    9. 3.9 可运营
  4. 四、BSBackTracelogger堆栈获取原理
    1. 4.1 失败的方法
    2. 4.2 ARM64 函数调用栈
    3. 4.3 BSBackTracelogger步骤
      1. 第一步 获取所有mach线程标识
      2. 第二步 根据线程标识获取调用栈
      3. 第三步 地址符号化
    4. 4.4 补充:NSThread、pthread、mach线程
      1. 4.4.1 pthread与mach内核线程
      2. 4.4.2 NSThread与pthread
      3. 4.4.3 NSThread 转 内核thread
      4. 4.4.4 主线程 转 内核thread
  5. 五、参考链接