背景
使用 Flutter 技术构建的应用,一直以高性能高流畅度著称。但是随着应用复杂度越来越高,Flutter 会出现一些页面流畅度明显低于 Native 的情况,甚至可能发生一些卡顿。而很多时候卡顿都发生在线上,即使获得了用户的操作路径,也难以重现。如果我们有一套卡顿监控系统,能够帮助我们捕获到卡顿时的堆栈,那么在发生卡顿的时候,我们就可以定位到具体是哪个函数引起的卡顿,从而解决这些问题。
既然想要设计一个卡顿监控系统,那么我们就需要先解决两个问题:
如何判断当前发生了卡顿。
如何在卡顿时获取堆栈。
如何判断卡顿
既然我们希望能够抓取 Flutter 的卡顿堆栈,那么首先我们得先有办法判断 Flutter App 是否发生卡顿。为此,我们先来简单回顾一下 Flutter 的渲染原理。Flutter 的 UI Task Runner 负责执行 Dart 代码,而 Flutter 的渲染管线也是在 UI Task Runner 中运行的。每次 Flutter App 的界面需要更新时,Framework 会通过 ui.window.scheduleFrame 通知 Engine。然后 Engine 会注册一个 Vsync 信号的回调,在下一个 VSync 信号到来之际,Engine 会通过 ui.window.onBeginFrame 和 ui.window.onDrawFrame 回调给 Framework 来驱动 Flutter 渲染管线,渲染管线中的 Build、Layout、Paint 一一被执行,生成了最新的 Layer Tree。最后 Layer Tree 通过 ui.window.render 发送到了 Engine 端,交给 GPU Task Runner 做光栅化与上屏。
我们可以定义一个卡顿阈值,在 ui.window.onBeginFrame 开始计时,在 ui.window.onDrawFrame 做好卡口,如果渲染管线的执行时间过长,大于卡顿阈值,那么我们就可以判断发生了卡顿。
如果等到我们判断出了当前发生了卡顿,再去采集堆栈,为时已晚。因此,我们需要另外起一个 Isolate,每隔一小段时间就去采集一次 root Isolate 的堆栈,那么当我们判断出现卡顿时,只要将从卡顿开始到卡顿结束的这段时间采集到的堆栈做一次聚合,就能知道是哪个方法引起了卡顿了。
举个例子,如果我们定义的卡顿阈值为 100ms,然后每隔 5ms 采集一次卡顿堆栈,假设 ui.window.onBeginFrame 开始到 ui.window.onDrawFrame 结束总共耗时 200ms,其中 foo 方法耗时 160ms,bar 方法耗时 30ms,其余方法耗时 10ms。那么在这段时间,我们一共能采集到 40 个堆栈,其中有 32 个堆栈的栈顶为 foo 方法,6 个堆栈的栈顶为 bar 方法。在堆栈聚合后,我们就能发现 foo 方法的耗时大约为 160ms,而 bar 方法的耗时大约为 30ms。
这个方案看上去比较简单,整体思路上也是借鉴了 Android 端的卡顿检测框架 BlockCanary,那么这个方案是否可行呢?我们需要解决的第一个问题,就是如何在另一个 Isolate 去采集 root Isolate 的堆栈。
堆栈采集方案一:修改 Dart SDK
在 Dart 中,我们可以通过调用 StackTrace.current 来获取当前 Isolate 的调用栈。那么它能否帮助我们获取卡顿时候的堆栈呢?非常可惜,它做不到这一点。
举个例子:假设我们有一个名叫 foo 的方法,耗时大概 300ms,在 root Isolate 中执行,很明显,这个方法会引起卡顿。而 StackTrace.current 并不能获取帮助我们定位到这个耗时的 foo 方法。我们需要的,是在另一个 Isolate 中采集到 root Isolate 堆栈的方法。
官方的相关 issue
并不是只有我们有这个诉求,google 的同学在 flutter 的 repo 下,提了 Issue:Add API to query main Isolate’s stack trace #37204。这个 Issue 的大致内容是说,希望 Dart 能够提供一个 API,用于在另一个 Isolate 去采集 main Isolate 堆栈,当前这个 Issue 还是 open 的状态。
Issue 提出到现在大概已经过去一年时间了,为什么这个 API 还是没有实现呢?其实,实现这个 API 本身并不困难,只是官方有一些自己的考量,其中之一就是这可能会引入安全性问题:Dart Isolate 之间本应该相互隔离,如果添加了这个 API,那么可能会有黑客通过多次调用该 API 来获取大量的堆栈信息,再通过比对这些堆栈的差异来对加密秘钥发起定时攻击等。看来官方短期之内是不会提供这个 API 了,那么我们是不是可以先试试通过修改 Dart SDK 来实现类似的功能。
通过修改 SDK 获取 API
我们先来看看 StackTrace.current 是如何获取堆栈的吧
我们可以看到,StackTrace.current 方法的修饰符中有一个 external,这代表了这是一个 external 函数,Dart 中的 external 函数意味着这个函数的声明和实现是分开的,这里只是声明,实现在另一个地方,其实现的地方如下:
从 StackTrace.current 的实现中有一个 native 关键字,native 关键字是 Dart 的 Native Extension 的关键字,意味着这个方法是 C/C++实现的。Native Extension 与 Java 中的 JNI 非常的相似。
我们终于找到了实现,CurrentStackTrace,通过观察发现,它的第一个参数是一个 thread。可见 CurrentStackTrace 方法获取的堆栈是基于 thread 的,那么是不是说,如果我们在另一个 Isolate 中,将 root Isolate 对应的 Thread 作为参数,传入到 CurrentStackTrace 方法里,就能获得 root Isolate 对应的堆栈了呢?
为了验证我们这个想法,我们新增了两个方法:StackTrace.prepare 和 StackTrace.root,我们在 root Isolate 中调用 StackTrace.prepare,将 root Isolate 的 thread 对象使用静态变量 rootIsolateThread 保存起来。StackTrace.prepare 对应的 C++实现如下
然后我们新开一个 Isolate,在这个新的 Isolate 中,我们调用 StackTrace.root 来获取 root Isolate 的堆栈,StackTrace.root 对应的 C++实现如下
经过验证发现,通过这个方案,的确能在另一个 Isolate 中获取 root Isolate 的堆栈。当然上面的修改主要还是为了验证可行性,如果真的要采用修改 Dart SDK 的方案,还有非常多的地方需要考虑。
修改 Dart SDK 的这个方案大大增加了后期的维护成本,有没有可能存在一种不修改 Dart SDK,还是能获取到堆栈的方案呢?
堆栈采集方案二:AOT 模式下采集堆栈(暂停线程)
在不修改 Dart SDK 的前提下获取堆栈,听上去感觉是一个不可能完成的任务。但是有时候我们遇到了问题,或许转变一下思路,就能找到答案。
AOT 模式与符号表
让我们一起来梳理一下我们的诉求,首先我们设计的是一个线上卡顿监控方案,这个场景下的 Dart 代码是基于 AOT 编译的,在 iOS 端其产物为 App.framework,在 Android 端则为 libapp.so。基于 AOT,也就意味着 Dart 代码(包括 SDK 和你自己的)会被编译成平台相关的机器码。
那么 Dart 语言 AOT 编译生成的可执行程序与 C 语言编译生成的可执行程序,是否有区别呢?从操作系统的角度来看,它们并没有什么本质区别。操作系统只关心这个可执行程序如何加载,如何执行,至于程序是从 C 语言还是 Dart 语言编译过来的,它并不关心。
我们先来把目光聚焦到 Dart 代码在 iOS 端 profile 模式下的产物 App.framework。从 iOS 的视角触发,这是一个 Embedded Framework。我们可以使用 nm 命令导出其符号表,以下是符号表的一部分:
我们惊喜地发现,这些符号与 Dart 函数几乎是一一对应。比如符号 PrecompiledElementupdate_260,很明显对应的 Dart 函数为 Element.update。
有了这份符号表,也就意味着,如果我们能采集到 root Isolate 对应线程的 native 的堆栈,我们就可以通过符号化来还原出当时 Dart 函数的调用栈。而且我们也不再需要去寻找从另一个 Isolate 获取 root Isolate 的 Dart 堆栈的方法了。与之对应的,我们只需要能够在另一个线程获取 root Isolate 对应的线程的 native 堆栈即可。
堆栈采集的方案
栈帧采集的方案整体思路如下:
获取当前进程中的所有线程,并找到 Flutter UI TaskRunner 对应的线程
暂停该线程,并获取该线程当前的寄存器的值,重点为 PC 和 FP
根据栈帧回溯算法,获取堆栈
让该线程继续运行
在当前进程或者远端做符号化
方案实现
接下来我们来看看如何实现这个方案,我们以 iOS 端为例子,来说明如何实现这个方案:
在 iOS 端,我们可以通过 API task_threads
来获取所有的线程,代码如下:
我们可以通过比对线程名字来定位到 UI Task Runner 对应的线程,如果是 Flutter 单 Engine 方案,那么 UI Task Runner 对应的 Thread 的名字应为"io.flutter.1.ui"。
在采集堆栈前,我们得先暂停这个线程。
暂停线程后,我们就可以通过 thread_get_state
去获取这个线程此时此刻的寄存器的值了,其中能够帮助我们做栈帧回溯的两个寄存器分别是 pc 和 fp,我们这里的代码是以 arm64 为例子的,在实际的产品中,还需要考虑到其他的架构:
获取 pc 和 fp 后,就可以进行栈帧回溯了。至于如何进行栈帧回溯,我们会在下一个小节单独说明。栈帧采集完之后,我们需要让线程继续运行:
以上就是 iOS 端堆栈采集方案的大体实现了。Android 端想实现这个方案,思路上大同小异,无论是找到所有的线程,定位到 UI Task Runner 对应的线程,还是线程的暂停和恢复,都能找到解决方案。唯一比较麻烦的地方在于如何获取另一个线程暂停时的寄存器的值,这部分可以使用 ptrace 来完成,不过这个需要起一个独立的进程。
栈帧回溯原理
上文说到,我们获得了 pc 和 fp 寄存器的值,该如何做栈帧回溯呢?
这里我们以ARM64栈帧布局为例子(也就是上图)。每次函数调用,都会在调用栈上,维护一个独立的栈帧,每个栈帧中都有一个FP(Frame Pointer),指向上一个栈帧的FP,而与FP相邻的LR(Link Register)中保存的是函数的返回地址。也就是我们可以根据FP找到上一个FP,而与FP相邻的LR对应的函数就是该栈帧对应的函数。回溯的算法如下
堆栈采集完毕后,我们只需要将采集到的堆栈进行符号化即可。
堆栈采集方案三:AOT 模式下采集堆栈(通过信号)
性能瓶颈
上面的这个方案可能会对性能造成一些影响,堆栈回溯本身并不耗时,真正的耗时在于线程的暂停和恢复。线程暂停后,线程就会进入阻塞状态,而去恢复线程时,线程并不会立即执行,而是会进入就绪状态,等待内核调度为其分配 CPU 时间片。所以在这个方案,每一次采集线程堆栈,都意味着这个线程的状态可能会从运行态到阻塞态再到就绪态。
那么有没有更为轻量级的采集堆栈的方案?
信号机制原理
信号(Signal)是事件发生时对进程的通知机制,有时候也称之为软件中断。一般发给进程的信号,通常是由内核产生的,比如访问了非法的内存地址,或者被 0 除等等,当然,一个进程可以向另一个进程或者自身发送信号。如果进程注册了信号处理器(Signal Handler),那么当收到信号后,就会中断当前程序,开始调用信号处理器程序,等信号处理器程序执行完成后,当前程序会在被中断的位置继续执行。
新方案的实现
我们先注册一个信号处理器,用于采集堆栈。接着,我们还是启动一个采集线程,每隔一段时间,向 UI Task Runner 发送一个信号。当收到信号后,UI Task Runner 对应的线程就会被中断,执行信号处理器程序来采集堆栈,堆栈采集完后,程序会从中断点回复执行。
我们来看看这个方案具体如何实现,这次我们以 Android 端为例子:
首先我们先注册一些 signal handler,用于在收到信号时采集堆栈
接着我们每隔一段时间,就向 UI Task Runner 对应的线程发送一个信号。
信号到达后,该线程就会中断当前执行的程序,然后调用 signal handler 采集堆栈,其中 signalHandler 的实现如下
实际上,FaceBook 的性能监控方案 profilo,以及 Dart VM 的 CPU Profiler,均使用了这个方案来采集堆栈。
堆栈采集方案对比
我们来对比一下上面提到的 3 个方案,它们的区别如下图所示:
我们可以看到,方案三无需修改 SDK,所以维护成本较低,并且在三个方案中它的性能损耗是最低的。最终我们决定采用方案三来作为我们堆栈采集的方案。
总结
本文主要介绍了我们在设计 Flutter 卡顿监控系统的一些思路,给出了如何判断卡顿跟如何获取堆栈的思考和探索,目前这个方案的产品化正在进行当中。Flutter 作为高性能的跨平台方案,其渲染性能从理论上来说,可以做到不弱于原生。同时 Flutter 在性能体验方向上,和原生相比,还有非常多值得探索的地方,让我们一起不忘初心,继续朝着这个方向前进。
本文转载自公众号闲鱼技术(ID:XYtech_Alibaba)。
原文链接:
https://mp.weixin.qq.com/s/-BTEkHYeh_tHqJY2UNI_xw
评论