Flutter難點問題之GPU後臺Crash

作者:閒魚技術——皓黯

1. 背景介紹

衆所周知,在衆多跨平臺方案中,Flutter的渲染一致性一直是它的一大亮點,可謂是真正的實現了像素級別的控制。這主要歸功於Flutter的架構設計,它基於Skia來實現渲染,而後者則以OpenGLES、Metal或Vulkan作爲後端,這在最大程度上保證了不同平臺的渲染一致性。Flutter的這個架構設計非常先進,當然,同其他項目一樣,Flutter也不可避免的存在一些bug。今天我想和大家聊的,就是一個Flutter在iOS後臺時訪問GPU導致Crash的問題。本文將先對GPU後臺Crash發生的原因進行說明,再介紹官方對此問題的修復方案,最後分享閒魚在此基礎上如何在其他三個場景解決該問題。

閒魚App在使用Flutter開發項目的過程中,發現了一個與Flutter相關的iOS Crash,這個Crash的具體堆棧如下:
根據堆棧中的`_gpus_ReturnNotPermittedKillClient`可知,App是因爲在後臺訪問了GPU導致了Crash,或許有些同學不太明白,爲什麼App在後臺訪問GPU會導致Crash呢?這其實是和iOS系統的策略有關。iOS系統是禁止後臺的App訪問GPU的,主要是爲了保證前臺正在運行的App的性能體驗。因爲GPU在系統看來是非常寶貴且有限的資源,如果App退到後臺之後還繼續瘋狂使用GPU的話,那麼前臺App的性能可能就無法得到保障了。那麼就有同學問了,如果App並沒有遵循這個規範,在退到後臺之後,繼續使用Metal或OpenGLES訪問GPU,會發生什麼事情呢?答案很簡單,會直接Crash。

由於Flutter使用了Skia作爲渲染引擎,而後者在iOS則以Metal或OpenGLES作爲後端,因此免不了要和GPU打交道,而在LayerTree光柵化上屏或者圖片解碼上傳紋理時,都會使用到GPU,因此如果沒有做好相應的保護措施的話,App就有可能Crash。

2. 官方的修復方案

Flutter應用日益增多,開發者們慢慢發現了這個問題,並向官方提了相應的Issue。陸陸續續有開發者向Flutter官方反饋GPU後臺Crash的問題,這引起了官方的注意,官方決定跟蹤和解決這個問題。那麼這個問題該如何解決呢?解決這個問題的關鍵,就是在收到`UIApplicationDidEnterBackgroundNotification`這個通知後,不要再執行任何可能會訪問到GPU的操作。但是這個通知是在主線程收到的,而真正去訪問GPU的則是Raster線程或IO線程,那麼該如何通知它們呢?爲此,Google軟件工程師Aaron Clarke(github名爲gaaclarke)設計了一個新的同步機制: SyncSwitch。SyncSwitch簡單來說就是可以在一個線程去設置一個類型爲bool的value,另一個線程的代碼分爲兩個分支,根據value的值來確定具體走哪個分支。我們先來看看SyncSwitch是如何設計與實現的,以下是SyncSwitch的構造函數和兩個API:

當iOS的前後臺狀態發生改變時,可以通過SetSwitch來設置value來表示GPU是否可用。而邏輯需要根據iOS在前臺或者在後臺走不同分支時,則調用Execute方法來走對應的邏輯。

以下是作爲Execute方法參數的結構體Handlers的代碼:

以下是上述方法的具體實現,我們可以看到邏輯比較簡單,主要就是在SetSwitchExecude時加鎖,然後根據value值去調用true_handler或者false_handler

最終官方通過這個方案,成功修復了ImageDecoder::UploadRasterImage導致的GPU後臺Crash,具體代碼如下:

這是官方關於用於修復這個問題的PR:

#13908 Made a way to turn off the OpenGL operations on the IO thread for backgrounded apps

當然,這個過程也不是一帆風順的,在這個過程中,也遇到了一些問題,但是gaaclarke都順利解決了。

3. 問題的進一步解決

閒魚將Flutter引擎升級並將官方最新的修復Patch打上以後,發現依然存在GPU後臺Crash,這說明GPU後臺Crash的問題並沒有完全解決,難道是官方的解決方案還存在什麼疏漏嗎?我仔細分析了閒魚發生GPU後臺Crash的堆棧後,確認問題一共分佈在3個地方,MultipleFrameCodec、EncodeImage以及DrawToSurface,而之前大家反饋的ImageDecoder則並未出現。所以可以確定的是,官方的解決方案並沒有問題,只是並沒有覆蓋全面。而閒魚由於業務體量大,場景複雜,再加上大規模使用Flutter,所以這些問題都都被一一暴露了出來。既然問題原因已經確認,那麼讓我們來看下如何修復吧。

3.1 MultipleFrameCodec::getNextFrame場景的Crash

在閒魚遇到的3個GPU後臺Crash中,`MultipleFrameCodec::getNextFrame`引起的佔比是最高的,因此我決定先從這個問題下手。我們先來看一下問題的堆棧信息,來分析一下Crash具體是如何發生的。
根據堆棧可知,在發生Crash時,Flutter調用了`SkImage::MakeCrossContextFromPixmap`來生成一個基於texture的`SkImage`,該方法與問題相關的邏輯如下:
我們看到,在生成`SkImage`之前,會先調用了`GrGpu::prepareTextureForCrossContextUsage`來獲取一個`GrSemaphore`,那麼這個方法具體是什麼用的呢,我們先來看看官方的文檔註釋:
根據文檔註釋可以看到,這個方法主要是爲了讓保障texture能夠在多個context下安全使用。根據具體的後端實現,這個方法可能會返回一個`GrSemaphore`用於同步。接下來看看使用OpenGLES的情況下這個方法是如何實現的吧。
我們注意到,這個方法會創建一個`GrGLSync`,並且會調用一次`flush`來確保`GrGLSync`對象已經創建並且發送到了gpu。這個`flush`方法會去調用OpenGLES的API`glFlush`,如果此時應用正處於後臺,那麼調用`glFlush`會導致應用直接崩潰。

上面我們分析了OpenGLES的實現,那麼在Metal下是否也存在GPU後臺Crash呢?答案是肯定的,Metal也有這方面的限制,我們在flutter issue裏找到了一個與上面相似的堆棧。
既然已經找到問題的原因了,那麼我們來看看如何修復吧。先來看一下`MultipleFrameCodec::getNextFrame`方法與之相關的邏輯,邏輯還是比較清晰的,如果有`resourceContext`,則使用`SkImage::MakeCrossContextFromPixmap`來生成`SkImage`,否則則使用`SkImage::MakeFromBitmap`來生成。
那麼該如何修復這個問題呢,相信細心的讀者可能已經想到了解決方法,可以使用`gpu_disable_sync_switch`來確保只有在GPU可用時纔會調用`SkImage::MakeCrossContextFromPixmap`生成`SkImage`,而如果GPU不可用,則回退到調用`SkImage::MakeFromBitmap`生成`SkImage`。

有了這個方案後,那麼只需要稍加修改代碼,功能也就實現了。當然,爲了確保功能正確以及後續不會因爲其他改動而導致不可用,我們還需要寫一個單元測試。最終的PR如下:

#28159 Prevent app from accessing the GPU in the background in MultiFrameCodec

gaaclarke在review了這個PR之後給予了肯定,目前這個PR已經成功合入到了master。

3.2 EncodeImage場景的Crash

第二個發生Crash的場景是在EncodeImage的時候,具體堆棧如下

根據這個堆棧,我很快就定位到了場景,這是在image_encoding.cc中的EncodeImage方法未使用is_gpu_disabled_sync_switch導致的Crash,具體代碼如下:

有了上一次的經驗,我很快在這個基礎上加上is_gpu_disabled_sync_switch的邏輯,這部分代碼比較簡單,就不貼了。定位問題和修改問題可以說都很順利,但是如何去寫單元測試則讓我犯了難。我修改的ConvertToRasterUsingResourceContext是一個內部方法,寫單元測試時訪問不到,另外即使將這個方法暴露出來,我們也沒有辦法傳入一個flutter::SyncSwitch來用於測試,原因是flutter::SyncSwitch內部並沒有屬性來判斷它自己是否被訪問過。由於寫不出單元測試,所以我只好向flutter官方的同學求助。

gaaclarke非常熱心地給了我一個方案,讓我將`ConvertToRasterUsingResourceContext`放到頭文件,並改成模板,這樣單元測試裏不用傳入`flutter::SyncSwitch`,只需要傳入另一個Mock的其它類型的`SyncSwitch`就行。
我嘗試了這個他給的這個方案,覺得改動有點大,在當時的我看來,單元測試的作用是爲了保證自己的功能不被意外回滾。而我覺得這個PR被回滾的概率很小,因此我想着是不是可以和官方同學商量一下,不用寫測試。

官方同學給我的回覆讓我對單元測試有了新的認知。gaaclarke覺得一個不完美的測試也比沒有測試要好,而zanderso則給出了另一個理由,所有能被cherry-pick到beta或stable分支的功能都需要有單元測試,如果一個功能沒有單元測試,那麼即使有需要,它也不可能被cherry-pick到beta或stable分支。

他們的回覆讓我更加明白了單元測試的重要性,但是我當時覺得gaaclarke給的方案改動有點大,所以想了一個新方案,使用`FLUTTER_RELEASE`這個宏來做條件編譯,在非release模式下爲`SyncSwitch`增加邏輯使得其可以知道它是否被調用過,這樣可以儘量少改動具體實現來做單元測試。但是這個方案最終沒有被gaaclarke採納,他覺得條件編譯使得維護變得複雜,並不是一個好方案。

所以最終我還是按照gaaclarke的建議實現了最終版本的單元測試,同時也向gaaclarke表達了我自己的擔憂。這個方案將原本無需暴露的頭文件都暴露到了`image_encoding.h`中,gaaclarke給了我一個建議,可以增加一個`image_encoding_impl.h`來解決這個問題,這的確是個好主意。

在經過多輪的嘗試和探討後,這個PR終於成功合入官方。

#28369 Prevent app from accessing the GPU in the background in EncodeImage

整個過程和結果得到了gaaclarke的認可,他對此表示讚許以及感謝。

其實我覺得這個過程中,我從gaaclarke那邊學到了非常多的東西,包括編碼能力以及如何寫好單元測試等等。

3.3 Rasterizer::DrawToSurface場景的Crash

這是閒魚GPU後臺Crash的最後一個場景,也是三個場景中最爲棘手的一個,其堆棧如下:
從堆棧分析,問題非常清晰。我們需要確保`Rasterizer::DrawToSurface`方法不要在後臺訪問GPU。但是這個場景和之前場景卻有着比較大的區別,之前的場景如果我們無法訪問GPU,那麼我們可以使用CPU來做兜底邏輯。但是在`Rasterizer::DrawToSurface`時無法訪問GPU,那麼應該怎麼處理呢。

正在我還在苦惱如何來解決這個問題時,官方突然提了一個Issue:[Crash in Metal from MTLReleaseAssertionFailure](https://github.com/flutter/flutter/issues/89171),我仔細看了一下堆棧,發現他們遇到的竟然和我遇到的是同一個問題!這個Issue的優先級是P2,還是非常緊急的,因爲我決定盡我所能,和官方一起解決這個問題。 

爲了說清楚這個問題,我寫了一段具體的[分析過程](https://github.com/flutter/flutter/issues/89171#issuecomment-908871405),闡述了這個問題和之前遇到的GPU後臺Crash是一類問題,所以我們需要在`Rasterizer::DrawToSurface`時,也使用`is_gpu_disabled_sync_switch`。那麼如果當前無法訪問GPU,該怎麼做呢,我突然想到,`DrawToSurface`是爲了讓這一幀上屏,讓用戶能夠看見。那麼如果此時應用在後臺,用戶本來就看不見這一幀,那麼我們爲什麼不直接將這一幀丟棄掉呢?這一幀丟掉會有問題嗎,我仔細分析了一下,應該沒有問題,因爲當用戶從後臺回到前臺時,`Animator::Start`會被調用,然後會調用`RequestFrame`去確保最新的一幀上屏。

爲了能更快解決這個問題,我還提了一個PR,供官方作爲解決問題的一個選擇方案。gaaclarke在看了我的分析後,覺得有道理,不過他還是不太確定是不是應該在`Rasterizer::DrawToSurface`這麼頂層的地方使用`is_gpu_disabled_sync_switch`。他覺得或許這個問題應該從Skia層解決更爲合適。
而在經過一陣子調研後,gaaclarke決定採納我的這個方案,最終進過幾輪的討論和改進,我和gaaclarke一起完成了這個PR,這個PR最終被合入了主幹。

[#28383 Started providing the GPU sync switch to Rasterizer.DrawToSurface()](Started providing the GPU sync switch to Rasterizer.DrawToSurface())

4. 總結

Flutter應用在後臺訪問GPU導致Crash的問題至此得到了圓滿解決,相信不久的將來大家就能在Flutter release版本體驗到。未來閒魚團隊會一如既往在Flutter上繼續深耕,解決Flutter在落地過程中遇到的各種問題,給大家帶來更好的用戶體驗。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章