西瓜视频稳定性治理体系建设三:Sliver 原理及实践

采样型 trace 方案的核心就是抓栈,大家最熟知的抓栈方法,应该是 Java Thread 类的 getStackTrace 方法,下面根据这个函数分析下抓栈原理。

getStackTrace 内调用了 VMStack.getThreadStackTrace,传入抓栈的目标线程。

该函数是一个 native 函数,里面的工作分为三步:一是创建回调,内部会调用 native 层的 Thread::CreateInternalStackTrace 函数;二是调用 GetThreadStack 函数,传入 env,Java Thread 以及回调函数指针;三是将 GetThreadStack 的返回结果通过 Thread::InternalStackTraceToStackTraceElementArray 处理,得到 Java 层最终的返回值 StackTraceElement[]。

GetThreadStack 函数内,会先判断抓栈的目标线程和执行抓栈的当前线程是否是同一个线程,如果是的话,则可以直接运行传入的回调 fn;而如果要抓其他的函数栈,则要先将目标线程 Suspend,再执行 fn,抓栈完成后再将目标线程 Resume。因为按照虚拟机的 Java 函数栈的实现,抓栈是非线程安全的,在跨线程抓栈时,目标线程的函数栈发生变动,就会出现问题,所以跨线程抓栈时需要将目标线程挂起,保证安全。

fn 回调内执行了 Thread::CreateInternalStackTrace 函数,该函数创建了两个 StackVisitor:FetchStackTraceVisitor 和 BuildInternalStackTraceVisitor。StackVisitor 就是栈回溯器,方法内先通过 FetchStackTraceVisitor 回溯,最大深度为 256,因为正常程序运行栈深度超过 256 的情况很少,这样做能够节省创建数组的开销。通过 WalkStack 回溯获取结果后,如果栈的深度小于 256,则添加到 BuildInternalStackTraceVisitor 中,否则再通过 BuildInternalStackTraceVisitor 回溯一次。最终将记录的方法数组返回,完成回溯。

StackVisitor 的核心函数是 WalkStack,其内部主要是一个遍历函数栈的操作。每个线程都管理着各自的 ManagedStack,是一个链表结构,每一个 ManagedStack 节点又管理着 cur_shadow_frame_ 和 cur_quick_frame_,分别表示解释执行帧与机器码执行帧。

cur_shadow_frame_ 也是链表式结构,每个 ShadowFrame 节点对应一个 ArtMethod,通过遍历即可获取到对应的函数栈。

cur_quick_frame_ 则是 ArtMethod 指针数组,即 ArtMethod**,通过遍历计算偏移量 frame_size 来不断的得到对应的函数。

WalkStack 函数遍历 ManagedStack,每个 ManagedStack 节点只能存在一种 frame 类型,然后在循环里不断遍历 cur_shadow_frame_ 或 cur_quick_frame_,每次得到一层函数栈,然后通过 StackVisitor 的虚函数 VisitFrame 来判断是否需要继续回溯。

art 里有许多 StackVisitor 的实现,在实现的 VisitFrame 内获取函数栈然后进行各种操作。比如上面提到的 FetchStackTraceVisitor 就在实现的 VisitFrame 内进行 ArtMethod 的筛选和记录等操作。

综上分析,抓取 Java 函数栈的核心函数就是 StackVisitor::WalkStack,art 内抓取函数栈的最终实现都是该函数。其实通过阅读 Profilo 的源码也可以发现,其抓栈的核心函数就是通过指针+偏移量的方案实现了 WalkStack,写死了大量偏移,并做了很多适配工作。这样做虽然可以实现功能,但稳定性就会变差,维护成本也会增高。Profilo 的开源版本截至目前仅适配到了 Android 9,适配 Android 10、11 和 12 的 32&64 位架构又是极大的工作量,而且厂商定制 ROM 若更改了任何一处源码,导致偏移量发生变化,那么轻则功能不可用,重则发生 Crash。

快速抓栈

既然自己实现栈回溯十分复杂且风险较大,那就直接用系统的函数,通过 xDL 查找符号表拿到 WalkStack 的函数指针直接调用。

该函数只有一个 bool 型入参,但该函数是成员函数,编译后会增加一个入参即 this 指针,而且 StackVisitor 还包含虚函数 VisitFrame,所以需要构造出一个实现 VisitFrame 的 StackVisitor。最简单的方案就是利用内存布局的特性,直接复制 StackVisitor 的代码到工程内,使用时直接 new 即可。

通过阅读源码发现,StackVisitor 的结构包含了如图中“CodeInfo”和“BitTableRange”这类变量,若直接采用复制的方案,一是复杂,二是厂商修改可能性大。通过上面对 WalkStack 的原理分析,回溯中的核心变量是 cur_shadow_frame_ 与 cur_quick_frame_,至于行号在 trace 中是非必须的,解析行号只会徒增成本,所以作为调用方其实只关心前四个变量,后面的变量我们并不关心其值,只要保证运行时有足够的内存 get/set 即可。

这里采用一种取巧的方案,自己构造的 StackVisitor 中,只写出虚函数和前四个核心变量,而后面的内容,使用长度足够的 char 数组占位,这样在运行时就有了足够的内存读写其他的变量,而且只使用前四个变量可以降低厂商定制带来问题的概率。

然后再实现一个继承自 StackVisitor 并实现了 VisitFrame 的函数,在其内部通过 cur_shadow_frame_ 与 cur_quick_frame_ 获取到每一帧对应的 ArtMethod,这样即可完成 StackVisitor 的构造。

通过函数指针调用 WalkStack,即可完成一次栈回溯的操作。

在 Callback 内将每一层的 ArtMethod* 保存到数组中,完成堆栈的记录。

线程挂起

实现了抓栈能力后,还需要选择暂停目标线程的方式,有两种方案,一是通过发送信号来实现中断,二是通过虚拟机的 Suspend 函数来实现挂起。

信号机制无论是实现还是性能都是要略优于挂起机制的,但在实际开发过程中,发现如果在信号回调中调用系统的 WalkStack,运行一段时间后会出现各类 Crash。

比如这一类在 WalkStack 内部调用 GetOatQuickMethodHeader 时的崩溃,x0 寄存器即该函数的 this 指针,ArtMethod 的值为 0x8 导致的非法指针问题。

还有 Didn’t find oat method index for virtual method 的 abort 异常,以及各种各样的其他崩溃,堆栈不固定,但基本都是在解析机器码函数栈时发生了异常。

通过分析发现,这些崩溃在正常情况下是不应该出现的,经过测试,在非信号回调中频繁调用 WalkStack 确实没有发现任何异常。经过深入分析与阅读源码,推测是发生了重入问题。由于信号的机制,信号回调会在中断后调用,而目标线程被中断时,无法保证指令的位置,所以如果是中断在机器码函数栈记录过程中,而我们又在回调中执行 WalkStack,就会发生不可预期的崩溃。而机器码函数相关的代码十分复杂,暂未找到该问题的有效解决方案。既然信号机制的问题暂时无法规避,所以选择尝试线程挂起方案。

ThreadList 的 SuspendThreadByPeer 函数,可以通过传入目标线程的 Java Thread 对象的 jobject 完成对目标线程的挂起,刚好可以满足需求。但是该函数是成员函数,调用还需要拿到 ThreadList 指针,ThreadList 是全局唯一的,定义在 Runtime 中,翻遍源码也未找到安全可靠的获取方式,最终只能通过写死偏移的方式获取。

ThreadList 在 Runtime 中的位置比较靠后,单纯的写死偏移还是风险太高,所以决定采用偏移+校验的方式来保证读到的内存是 ThreadList。Android 10 的 Runtime 结构中,thread_list_ 再向后增加 4 个指针长度,即是 JavaVMExt,JavaVM 可以在 JNI_OnLoad 时获取到,所以只需要在初始化时对各个版本的偏移进行 JavaVM 的校验,就可大大降低出问题的概率。

获取到 ThreadList 和函数指针后,在栈回溯的前后调用 Suspend 和 Resume 即可完成一次跨线程栈回溯。在 Suspend 和 Resume 之间,仅执行 WalkStack 的操作,堆栈记录和其他操作则放在 Resume 后执行,保证线程被挂起的时间足够短,这样可以确保性能最优。

线程挂起机制经过实践验证可以满足现有需求,实际运行中也未发现无法攻克的问题。另外线程挂起相较于信号机制,可以将大部分操作放到 Resume 后执行,而信号机制记录堆栈等操作需要在回调内做,额外增加耗时,所以最后决定选用线程挂起方案。

多线程

调查 ANR 和卡顿问题,大部分时候只对主线程采样抓 trace 即可达到目的,但还是有些问题需要结合子线程的 trace 分析。

Android 提供的采样 trace 最大的问题是每次都需要挂起所有线程,等待全部线程抓栈完成后,才恢复线程,而且这样造成了很多无意义的等待。

为了避免性能损耗问题,采用了分组的方式来设计多线程 trace:主线程单独由一个采样线程抓栈,无其它干扰,子线程分布到一定数量的采样线程中,比如设置五个采样线程,目标子线程会被添加到这些采样线程中,每个采样线程对一定数量的子线程进行采样抓栈,且每个目标子线程通过 SuspendThread 独立挂起,而不是 SuspendAll,互不影响。

锁信息

很多时候仅有 Method Trace 定位问题依然较为困难,一个函数的耗时可能有很多原因,知道耗时,还要知道为什么耗时,比如在等锁,进行 I/O,Binder 等情况。Java 层的 I/O 和 Binder 通信可以通过函数栈看出来,而锁信息就很难通过函数栈确定了。

在 ANR 日志中,经常可以看到类似的锁信息,能够非常清楚的看出主线程在等待子线程释放锁。如果能拿到这个锁信息并体现到 trace 中,就可以很轻易的知道当前主线程的阻塞原因。

ANR 日志是通过 DumpForSigQuit 输出的,获取 Java 函数栈是通过 Thread::DumpJavaStack 方法,该函数的核心代码是通过 StackDumpVisitor 进行栈回溯。

StackDumpVisitor 继承自 MonitorObjectsStackVisitor,最终也是继承自 StackVisitor,特点是实现了很多有关锁信息的函数。

在其实现的 VisitFrame 函数中,会通过 Monitor::FetchState 拿到当前线程的锁信息,还可以拿到当前线程的状态,添加到堆栈中。所以只要在自实现的 VisitFrame 中,通过函数指针直接调用 Monitor::FetchState 函数,就能拿到锁信息。

在栈回溯时调用一次 FetchState,如果当前线程是 block 状态就保存拿到的锁指针和持锁的线程 id,与堆栈一同记录。

数据结构

完成一次抓栈后,为了得到完整的 trace,需要将本次抓栈的结果与前一次抓栈的结果进行对比计算,得出函数出栈入栈的结果进行记录。方案为从栈底到栈顶遍历新旧两次堆栈,找到不相同的函数,理论上存在四种情况:

  • 若找到不相同的函数,且旧栈是新栈的子集,则表示有新方法入栈,记录新方法 Enter。

  • 若找到不相同的函数,且新栈是旧栈的子集,则表示有旧方法出栈,记录旧方法 Exit。

  • 若找到不相同的函数,且旧栈和新栈存在 diff,则先记录旧方法 Exit,再记录新方法 Enter。

  • 若新栈旧栈各层函数完全一致,则表示记录的函数均还在执行中,本次不用记录。

数据记录采用 ring buffer 的结构,每次入栈或者出栈事件用两个 uintptr_t 表示,记录时间戳与对应事件值。当入栈时,事件值为 ArtMethod 指针地址值;当出栈时,事件值为 0。

为了保证抓栈的高性能,在采样过程中,不进行堆栈的反解,即不进行 ArtMethod 转为 string 的过程,在 dump trace 时统一通过 ArtMethod 的 PrettyMethod 方法进行反解。

Sliver 使用了 Nanoscope 的文件格式记录 trace,易于解析,且能够清晰的表示栈结构。每一行开始的时间戳为纳秒级,表示事件发生的时间;时间戳后跟着对应时刻发生的事件,POP 表示出栈行为,其它表示对应函数的入栈行为。

整体结构

Sliver 在 Java 层提供 start 和 dump 方法,start 函数会传入目标线程,采样间隔等信息,在 native 层通过 pthread_create 创建采样线程 SamplingThread,采样线程循环执行 Suspend->WalkStack->Resume 对目标线程进行抓栈,抓栈完成后对比函数栈得到函数的出入栈信息,写入 RingBuffer 中。在 Java 层调用 dump 时,会读取 RingBuffer,通过 PrettyMethod 方法将 ArtMethod 转为函数名,输出到文件中。

拿到文件后可以通过脚本处理成 perfetto 的格式,在 perfetto trace viewer 上展示。

综合评估


性能

对单次栈回溯的耗时进行埋点,设置采样间隔为 10ms,接入到西瓜视频,程序运行中取连续的 1w 次耗时数据,在高端机与低端机分别进行分布统计,横轴为单次回溯耗时区间,单位 us,纵轴表示落在对应区间的样本量。

在高端机上,可以看出,约 93%的栈回溯耗时是小于 40us 的,1w 次耗时的平均值为 23.6us。在 10ms 采样间隔的情况下,按单次栈回溯耗时 25us 计算,每秒耗时增加 2.5ms,性能损耗为 2.5‰。

在低端机上,性能会稍差一些,但约 92%的栈回溯耗时是小于 100us 的,1w 次的平均值为 43.2us,在 10ms 采样间隔的情况下,按单次栈回溯耗时 50us 计算,每秒耗时增加 5ms,性能损耗为 5‰。实际使用中,在低端机上可以适当放大采样间隔,比如设置为 20ms 来降低损耗,一定程度的放大采样间隔对调查问题能力并无影响。

当然这样计算出的是理论损耗,受到 Suspend 机制的影响,在 Resume 后线程不会立即运行,而是处在等待调度的状态,所以实际运行中损耗是要大于理论值的,但总体可控制在 1%以内。

稳定性

Sliver 在线上灰度验证时,出现了 ThreadSuspendTimeout 的崩溃,这类问题在线上经常发生在 GC 时的 SuspendAll 函数,而采样抓栈调用挂起太过频繁,增大了问题出现的概率。

在通过 SuspendThreadByPeer 挂起线程时,会在循环内不断的调用 ModifySuspendCount 尝试挂起目标线程,每次挂起失败会检查一下是否超时,如果超时则会抛出 FATAL 异常。观察这段代码,可以发现 FATAL 后的逻辑是根本不会走到的,因为程序在 ThreadSuspendByPeerWarning 时会 abort 终止掉,那么后面的代码设计的就没有意义,比较奇怪。

通过搜索代码,发现了另外一个函数 SuspendThreadById,该函数的代码与 SuspendThreadByPeer 基本相同,功能同样是挂起线程,只不过是通过线程的 id 来挂起。而有一点不同是当线程挂起超时, ThreadSuspendByIdWarning 的 log 等级由 FATAL 变成了 WARNING,也就是仅打出 log,并不会崩溃,这样后面的逻辑也可以执行到了。在发现了这点后,把支持 SuspendThreadById 的版本均替换成了该函数,这样在发生挂起超时,不会发生崩溃,仅会丢失采样数据。在不支持 SuspendThreadById 的版本上,依然使用 SuspendThreadByPeer,通过采样时 hook 将此处的 log 等级替换成 WARNING 来避免崩溃。

除了这个问题以外,还陆续解决了一些由于自身代码或兼容适配产生的问题,另外由于少部分能力还是使用了风险较高的写死偏移,所以这部分代码统一使用 signal handler + siglongjmp 进行保护,在出错时及时停止采样。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

总结:

各行各样都会淘汰一些能力差的,不仅仅是IT这个行业,所以,不要被程序猿是吃青春饭等等这类话题所吓倒,也不要觉得,找到一份工作,就享受安逸的生活,你在安逸的同时,别人正在奋力的向前跑,这样与别人的差距也就会越来越遥远,加油,希望,我们每一个人,成为更好的自己。

  • BAT大厂面试题、独家面试工具包,

  • 资料包括 数据结构、Kotlin、计算机网络、Framework源码、数据结构与算法、小程序、NDK、Flutter

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!

2.imgtp.com/2024/03/13/H4lCoPEF.jpg" />

总结:

各行各样都会淘汰一些能力差的,不仅仅是IT这个行业,所以,不要被程序猿是吃青春饭等等这类话题所吓倒,也不要觉得,找到一份工作,就享受安逸的生活,你在安逸的同时,别人正在奋力的向前跑,这样与别人的差距也就会越来越遥远,加油,希望,我们每一个人,成为更好的自己。

  • BAT大厂面试题、独家面试工具包,

  • 资料包括 数据结构、Kotlin、计算机网络、Framework源码、数据结构与算法、小程序、NDK、Flutter
    [外链图片转存中…(img-nLvsGIzq-1712050345508)]

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值