15

Flutter卡顿问题的监控与思考

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzU4MDUxOTI5NA%3D%3D&%3Bmid=2247485389&%3Bidx=1&%3Bsn=80dd8eff64f8dbf9bf75f2c4267823d2
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

使用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做光栅化与上屏。

YRjeYnf.png!web

我们可以定义一个卡顿阈值,在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是如何获取堆栈的吧

2IBjYjN.png!web

们可以看到,StackTrace.current方法的修饰符中有一个external,这代表了这是一个external函数,Dart中的external函数意味着这个函数的声明和实现是分开的,这里只是声明,实现在另一个地方,其实现的地方如下:

JJJJ32Q.png!web

从StackTrace.current的实现中有一个native关键字,native关键字是Dart的Native Extension的关键字,意味着这个方法是C/C++实现的。 Native Extension与Java中的JNI非常的相似。

JjaMVva.png!web

我们终于找到了实现,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++实现如下

vuMFBfq.png!web

然后我们新开一个Isolate,在这个新的Isolate中,我们调用StackTrace.root来获取root Isolate的堆栈,StackTrace.root对应的C++实现如下

Ermauqq.png!web

经过验证发现,通过这个方案,的确能在另一个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命令导出其符号表,以下是符号表的一部分:

Evqqe26.png!web

我们惊喜地发现,这些符号与Dart函数几乎是一一对应。比如符号Precompiled Element update_260,很明显对应的Dart函数为Element.update。

有了这份符号表,也就意味着,如果我们能采集到root Isolate对应线程的native的堆栈,我们就可以通过符号化来还原出当时Dart函数的调用栈。而且我们也不再需要去寻找从另一个Isolate获取root Isolate的Dart堆栈的方法了。与之对应的,我们只需要能够在另一个线程获取root Isolate对应的线程的native堆栈即可。

堆栈采集的方案

栈帧采集的方案整体思路如下:

  1. 获取当前进程中的所有线程,并找到Flutter UI TaskRunner对应的线程

  2. 暂停该线程,并获取该线程当前的寄存器的值,重点为PC和FP

  3. 根据栈帧回溯算法,获取堆栈

  4. 让该线程继续运行

  5. 在当前进程或者远端做符号化

方案实现

接下来我们来看看如何实现这个方案,我们以iOS端为例子,来说明如何实现这个方案:

在iOS端,我们可以通过API task_threads 来获取所有的线程,代码如下:

n6BJJfF.png!web

我们可以通过比对线程名字来定位到UI Task Runner对应的线程,如果是Flutter单Engine方案,那么UI Task Runner对应的Thread的名字应为"io.flutter.1.ui"。

q2eA3qA.png!web

在采集堆栈前,我们得先暂停这个线程。

暂停线程后,我们就可以通过 thread_get_state 去获取这个线程此时此刻的寄存器的值了,其中能够帮助我们做栈帧回溯的两个寄存器分别是pc和fp,我们这里的代码是以arm64为例子的,在实际的产品中,还需要考虑到其他的架构:

7JN77fJ.png!web

获取pc和fp后,就可以进行栈帧回溯了。 至于如何进行栈帧回溯,我们会在下一个小节单独说明。 栈帧采集完之后,我们需要让线程继续运行:

以上就是iOS端堆栈采集方案的大体实现了。 Android端想实现这个方案,思路上大同小异,无论是找到所有的线程,定位到UI Task Runner对应的线程,还是线程的暂停和恢复,都能找到解决方案。 唯一比较麻烦的地方在于如何获取另一个线程暂停时的寄存器的值,这部分可以使用ptrace来完成,不过这个需要起一个独立的进程。

栈帧回溯原理

上文说到,我们获得了pc和fp寄存器的值,该如何做栈帧回溯呢?

3MfABba.png!web

这里我们以ARM64栈帧布局为例子(也就是上图)。每次函数调用,都会在调用栈上,维护一个独立的栈帧,每个栈帧中都有一个FP(Frame Pointer),指向上一个栈帧的FP,而与FP相邻的LR(Link Register)中保存的是函数的返回地址。也就是我们可以根据FP找到上一个FP,而与FP相邻的LR对应的函数就是该栈帧对应的函数。回溯的算法如下

7JfemmU.png!web

堆栈采集完毕后,我们只需要将采集到的堆栈进行符号化即可。

堆栈采集方案三:AOT模式下采集堆栈(通过信号)

性能瓶颈

上面的这个方案可能会对性能造成一些影响,堆栈回溯本身并不耗时,真正的耗时在于线程的暂停和恢复。线程暂停后,线程就会进入阻塞状态,而去恢复线程时,线程并不会立即执行,而是会进入就绪状态,等待内核调度为其分配CPU时间片。所以在这个方案,每一次采集线程堆栈,都意味着这个线程的状态可能会从运行态到阻塞态再到就绪态。

QramAvf.png!web

那么有没有更为轻量级的采集堆栈的方案?

信号机制原理

信号(Signal)是事件发生时对进程的通知机制,有时候也称之为软件中断。一般发给进程的信号,通常是由内核产生的,比如访问了非法的内存地址,或者被0除等等,当然,一个进程可以向另一个进程或者自身发送信号。如果进程注册了信号处理器(Signal Handler),那么当收到信号后,就会中断当前程序,开始调用信号处理器程序,等信号处理器程序执行完成后,当前程序会在被中断的位置继续执行。

qimEVjb.png!web

新方案的实现

我们先注册一个信号处理器,用于采集堆栈。接着,我们还是启动一个采集线程,每隔一段时间,向UI Task Runner发送一个信号。当收到信号后,UI Task Runner对应的线程就会被中断,执行信号处理器程序来采集堆栈,堆栈采集完后,程序会从中断点回复执行。

我们来看看这个方案具体如何实现,这次我们以Android端为例子:

首先我们先注册一些signal handler,用于在收到信号时采集堆栈

QzMvqmz.png!web

接着我们每隔一段时间,就向UI Task Runner对应的线程发送一个信号。

ymQZNb7.png!web

信号到达后,该线程就会中断当前执行的程序,然后调用signal handler采集堆栈,其中signalHandler的实现如下

3aaUnee.png!web

实际上,FaceBook的性能监控方案profilo,以及Dart VM的CPU Profiler,均使用了这个方案来采集堆栈。

堆栈采集方案对比

我们来对比一下上面提到的3个方案,它们的区别如下图所示:

aQVnmar.png!web

我们可以看到,方案三无需修改SDK,所以维护成本较低,并且在三个方案中它的性能损耗是最低的。最终我们决定采用方案三来作为我们堆栈采集的方案。

总结

本文主要介绍了我们在设计Flutter卡顿监控系统的一些思路,给出了如何判断卡顿跟如何获取堆栈的思考和探索,目前这个方案的产品化正在进行当中。Flutter作为高性能的跨平台方案,其渲染性能从理论上来说,可以做到不弱于原生。同时Flutter在性能体验方向上,和原生相比,还有非常多值得探索的地方,让我们一起不忘初心,继续朝着这个方向前进。

闲鱼技术团队不仅是阿里巴巴集团旗下闲置交易社区的创造者,更是移动与高并发大数据应用新技术的引导者与创新者。我们与 Google Flutter/Dart 小组密切合作,为社区贡献了多个高 star 的项目和大量 PR 。我们正在积极探索深度学习和视觉技术在互动、交易、社区场景的创新应用。闲鱼技术与集团中间件团队共同打造的 FaaS 平台每天支持数以千万级用户的高并发访问场景。  

就是现在! 客户端/服务端java/架构/前端/质量 工程师 面向社会+校园招聘,base杭州阿里巴巴西溪园区,一起做有创想空间的社区产品、做深度顶级的开源项目,一起拓展技术边界成就极致!

*投喂简历给小闲鱼→ [email protected]

6VrARnz.jpg!web

ZriEn26.png!web

开源项目、峰会直击、关键洞察、深度解读

请认准 闲鱼技术


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK