Flutter難點問題之GPU後臺Crash

語言: CN / TW / HK

作者:閒魚技術——皓黯

1. 背景介紹

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

​ 閒魚App在使用Flutter開發專案的過程中,發現了一個與Flutter相關的iOS Crash,這個Crash的具體堆疊如下:

image.png

​ 根據堆疊中的_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:

image.png

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

以下是作為Execute方法引數的結構體Handlers的程式碼:

image.png

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

image.png

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

image.png

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

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

當然,這個過程也不是一帆風順的,在這個過程中,也遇到了一些問題,但是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具體是如何發生的。

image.png

​ 根據堆疊可知,在發生Crash時,Flutter呼叫了SkImage::MakeCrossContextFromPixmap來生成一個基於texture的SkImage,該方法與問題相關的邏輯如下:

image.png

​ 我們看到,在生成SkImage之前,會先呼叫了GrGpu::prepareTextureForCrossContextUsage來獲取一個GrSemaphore,那麼這個方法具體是什麼用的呢,我們先來看看官方的文件註釋:

image.png

​ 根據文件註釋可以看到,這個方法主要是為了讓保障texture能夠在多個context下安全使用。根據具體的後端實現,這個方法可能會返回一個GrSemaphore用於同步。接下來看看使用OpenGLES的情況下這個方法是如何實現的吧。

image.png

​ 我們注意到,這個方法會建立一個GrGLSync,並且會呼叫一次flush來確保GrGLSync物件已經建立並且傳送到了gpu。這個flush方法會去呼叫OpenGLES的APIglFlush,如果此時應用正處於後臺,那麼呼叫glFlush會導致應用直接崩潰。

​ 上面我們分析了OpenGLES的實現,那麼在Metal下是否也存在GPU後臺Crash呢?答案是肯定的,Metal也有這方面的限制,我們在flutter issue裡找到了一個與上面相似的堆疊。

image.png

​ 既然已經找到問題的原因了,那麼我們來看看如何修復吧。先來看一下MultipleFrameCodec::getNextFrame方法與之相關的邏輯,邏輯還是比較清晰的,如果有resourceContext,則使用SkImage::MakeCrossContextFromPixmap來生成SkImage,否則則使用SkImage::MakeFromBitmap來生成。

image.png

​ 那麼該如何修復這個問題呢,相信細心的讀者可能已經想到了解決方法,可以使用gpu_disable_sync_switch來確保只有在GPU可用時才會呼叫SkImage::MakeCrossContextFromPixmap生成SkImage,而如果GPU不可用,則回退到呼叫SkImage::MakeFromBitmap生成SkImage

image.png

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

28159 Prevent app from accessing the GPU in the background in MultiFrameCodec[2]

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

image-20211018170934583

3.2 EncodeImage場景的Crash

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

image-20211018170934583

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

image-20211018170934583

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

​ gaaclarke非常熱心地給了我一個方案,讓我將ConvertToRasterUsingResourceContext放到標頭檔案,並改成模板,這樣單元測試裡不用傳入flutter::SyncSwitch,只需要傳入另一個Mock的其它型別的SyncSwitch就行。

image-20211018170934583

​ 我嘗試了這個他給的這個方案,覺得改動有點大,在當時的我看來,單元測試的作用是為了保證自己的功能不被意外回滾。而我覺得這個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來解決這個問題,這的確是個好主意。

image-20211018170934583

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

28369 Prevent app from accessing the GPU in the background in EncodeImage[3]

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

image-20211018170934583

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

3.3 Rasterizer::DrawToSurface場景的Crash

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

image-20211018170934583

​ 從堆疊分析,問題非常清晰。我們需要確保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層解決更為合適。

image-20211018170934583

​ 而在經過一陣子調研後,gaaclarke決定採納我的這個方案,最終進過幾輪的討論和改進,我和gaaclarke一起完成了這個PR,這個PR最終被合入了主幹。

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

image-20211018170934583

4. 總結

​ 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