Flutter難點問題之GPU後臺Crash

語言: CN / TW / HK

背景介紹

眾所周知,在眾多跨平臺方案中,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。

官方的修復方案

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 的程式碼:

以下是上述方法的具體實現,我們可以看到邏輯比較簡單,主要就是在 SetSwitch Execude 時加鎖,然後根據 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 [1]

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

問題的進一步解決

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

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 [2]

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

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 [3]

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

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

Rasterizer::DrawToSurface場景的Crash

這是閒魚GPU後臺Crash的最後一個場景,也是三個場景中最為棘手的一個,其堆疊如下:

從堆疊分析,問題非常清晰。我們需要確保 Rasterizer::DrawToSurface 方法不要在後臺訪問GPU。但是這個場景和之前場景卻有著比較大的區別,之前的場景如果我們無法訪問GPU,那麼我們可以使用CPU來做兜底邏輯。但是在 Rasterizer::DrawToSurface 時無法訪問GPU,那麼應該怎麼處理呢。

正在我還在苦惱如何來解決這個問題時,官方突然提了一個Issue: Crash in Metal from MTLReleaseAssertionFailure [4] ,我仔細看了一下堆疊,發現他們遇到的竟然和我遇到的是同一個問題!這個Issue的優先順序是P2,還是非常緊急的,因為我決定盡我所能,和官方一起解決這個問題。

為了說清楚這個問題,我寫了一段具體的 分析過程 [5] ,闡述了這個問題和之前遇到的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()

總結

Flutter應用在後臺訪問GPU導致Crash的問題至此得到了圓滿解決,相信不久的將來大家就能在Flutter release版本體驗到。未來閒魚團隊會一如既往在Flutter上繼續深耕,解決Flutter在落地過程中遇到的各種問題,給大家帶來更好的使用者體驗。

References

[1]

#13908 Made a way to turn off the OpenGL operations on the IO thread for backgrounded apps:  https://github.com/flutter/engine/pull/13908

[2]

#28159 Prevent app from accessing the GPU in the background in MultiFrameCodec:  https://github.com/flutter/engine/pull/28159

[3]  #28369 Prevent app from accessing the GPU in the background in EncodeImage:  https://github.com/flutter/engine/pull/28369

[4]   Crash in Metal from MTLReleaseAssertionFailure  =>  https://github.com/flutter/flutter/issues/89171

[5]  分析過程:  https://github.com/flutter/flutter/issues/89171#issuecomment-908871405

[6]   #28383 Started providing the GPU sync switch to Rasterizer.DrawToSurface() =>  https://github.com/flutter/engine/pull/28383