背景

使用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函數幾乎是一一對應。比如符號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 來獲取所有的線程,代碼如下:

我們可以通過比對線程名字來定位到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在性能體驗方向上,和原生相比,還有非常多值得探索的地方,讓我們一起不忘初心,繼續朝着這個方向前進。

閒魚技術團隊不僅是阿里巴巴集團旗下閒置交易社區的創造者,更是移動與高併發大數據應用新技術的引導者與創新者。我們與 Google Flutter/Dart 小組密切合作,爲社區貢獻了多個高 star 的項目和大量 PR 。我們正在積極探索深度學習和視覺技術在互動、交易、社區場景的創新應用。閒魚技術與集團中間件團隊共同打造的 FaaS 平臺每天支持數以千萬級用戶的高併發訪問場景。  

就是現在! 客戶端/服務端java/架構/前端/質量 工程師 面向社會+校園招聘,base杭州阿里巴巴西溪園區,一起做有創想空間的社區產品、做深度頂級的開源項目,一起拓展技術邊界成就極致!

*投餵簡歷給小閒魚→ [email protected]

開源項目、峯會直擊、關鍵洞察、深度解讀

請認準 閒魚技術

相關文章