Unity3D 遊戲在 iOS 上因爲 trampolines 閃退的原因與解決辦法

Unity3D 遊戲在 iOS 上因爲 trampolines 閃退的原因與解決辦法

崩潰的情況

進入遊戲一會兒,神馬都不要做,雙手離開手機,盯着屏幕看吧,遊戲會定時從服務器那兒讀取一些數據,時間一長,閃退了。尼瑪問題是神馬呢?完全沒有頭緒,不過大體猜測是因爲網絡請求導致的,那麼好,先排查服務器返回結果是否有問題,最終確認每次客戶端崩潰的時候,服務器都成功的返回了格式正確的數據,沒有任何異常。那麼可以確定問題是出在客戶端部分了。 先檢查代碼,確認邏輯上沒有任何問題之後,也倍感無力啊,問題依然在重現。腫麼辦呢?

確定具體原因

那麼好吧,打一個測試版本再來看,然後再等着崩潰,查看崩潰日誌吧,最終看到的崩潰日誌中,崩潰線程輸出信息如下:

Thread 27 Crashed:

0 libsystem_kernel.dylib 0x38e671fc __pthread_kill + 8

1 libsystem_pthread.dylib 0x38ecea4e pthread_kill + 54

2 libsystem_c.dylib 0x38e18028 abort + 72

3 gowonline 0x0178a0c0 mono_handle_native_sigsegv + 312

4 gowonline 0x01779a30 mono_sigsegv_signal_handler + 256

5 libsystem_platform.dylib 0x38ec9720 _sigtramp + 40

6 gowonline 0x00114f48 m_RestSharp_Http_ExecuteCallback_RestSharp_HttpResponse_System_Action_1_RestSharp_HttpResponse + 52

7 gowonline 0x001142b4 m_RestSharp_Http_RequestStreamCallback_System_IAsyncResult_System_Action_1_RestSharp_HttpResponse + 900

8 gowonline 0x00329c60 m_2be7 + 48

9 gowonline 0x00a39d08 m_System_Net_WebAsyncResult_DoCallback + 76

10 gowonline 0x00a29628 m_System_Net_HttpWebRequest_SetWriteStream_System_Net_WebConnectionStream + 536

11 gowonline 0x00a46f84 m_System_Net_WebConnection_InitConnection_object + 708

12 gowonline 0x0101ffac m_wrapper_runtime_invoke_object_runtime_invoke_dynamic_intptr_intptr_intptr_intptr + 200

13 gowonline 0x017792d4 mono_jit_runtime_invoke + 2152

14 gowonline 0x0181b324 mono_runtime_invoke + 132

15 gowonline 0x01820118 mono_runtime_invoke_array + 1448

16 gowonline 0x01820510 mono_message_invoke + 444

17 gowonline 0x018444a8 mono_async_invoke + 124

18 gowonline 0x01844174 async_invoke_thread + 312

19 gowonline 0x0184c580 start_wrapper + 496

20 gowonline 0x018695b4 thread_start_routine + 284

21 gowonline 0x01885750 GC_start_routine + 92

22 libsystem_pthread.dylib 0x38ecdc5a _pthread_body + 138

23 libsystem_pthread.dylib 0x38ecdbca _pthread_start + 98

好的,那麼已經確定是在我們使用的一個第三方類庫 RestSharp 中出現的問題,問題是出現在一個 Action 回調的地方。那麼這種問題爲什麼會出現呢,那我們就得好好得來找找原因了。

關於如何查看 iOS 崩潰日誌,讓崩潰日誌更加友好,我們可以參考這篇文章,iOS 應用崩潰日誌揭祕,主要就是要確保你的設備上跑着的這個 App 的編譯和打包的二進制文件要在你用於查看日誌的 Mac 上,這樣的話,當我們查看崩潰日誌的時候,Xcode 會自動將那些無法閱讀的函數調用的堆棧信息轉化成可讀性較強的日誌信息,幫助還是很大的。

那麼這個時候我們可以通過將設備連接到 Mac 上,直接通過 Xcode 將程序編譯並運行,多嘗試着玩一段時間,當程序再次出現崩潰的時候,我們就能看到更清楚的函數調用關係了,同時也能看到更多的日誌提示。

最終能確定每次崩潰的函數就是這個 mono_convert_imt_slot_to_vtable_slot,這個看上去就是 Mono Runtime 在將接口聲明的方法指針指向實際實現這個接口的對方的方法,我們可以找到 mono_convert_imt_slot_to_vtable_slot 這個方法所在的文件查看一下,這個方法就在 Mono 項目的目錄 mono/mini/mini-trampolines.c 中可以找到。

在 Xcode 中崩潰時,會輸出類似” SIGABRT (ERROR:mini-trampolines.c:183:mono_convert_imt_slot_to_vtable_slot: code should not be reached) “ 的日誌,看着很像是原本是要執行某個方法,但是不知道因爲什麼原因這個方法就無法訪問到了,好奇葩啊。

解決方案

現在雖然已經知道了問題出現的地方,但是貌似完全看不明白的樣子,尼瑪 trampoline 都還是第一次聽說耶,那麼先請教一個我大 Google 吧,我們總是相信自己不是那第一個吃螃蟹的人,所以我們找到了一位大神的解決方案就在  這裏 ,大神的文章寫得非常言簡意賅,大體意思就是如果你在做 Unity3D 開發時,特別是在針對 iOS 和 Android 平臺的時候,你很有可能會碰到比較杯具的就是程序會莫名其妙地閃退哦,不過不要着急,這個通常就是因爲你的程序編譯的時候給 trampoline 分配的空間太小,而你的程序中又大量使用了泛型、泛型方法調用和接口實現導致的。然後給出了具體的解決方法,那就是在 Unity3D 的編譯選項 Player Setting 中有一個 AOT Compilation Options 條目,在這個選項條目中加上以下編譯參數就好了

nrgctx-trampolines=8096,nimt-trampolines=8096,ntrampolines=4048

然後再重新一下,多多測試吧,騷年。關於這三個參數的意思呢,大神也給出瞭解釋,分別如下:

  1. nrgctx-trampolines=8096 這是留給遞歸泛型使用的空間,默認是 1024
  2. nimt-trampolines=8096 這是留給接口使用的空間,默認是 128
  3. ntrampolines=4048 這是留給泛型方法調用使用的空間,默認是 1024

Mono Runtime AOT 機制剖析

雖然問題貌似已經得到解決了,而且我們貌似也搞清楚了具體原因就是因爲默認 Mono Runtime 在 AOT 編譯的時候給的 trampoline 配置太小,不適合我們這種設計優良,大量使用 interface,設計絕對遵照 OO 思想的稍大一些的項目呢。那麼我們以後是不是在做 Unity3D 開發的時候就儘量少用接口呢?是不是我們就儘量少用泛型和泛型方法呢?

既然這麼感興趣,想問個究竟,那麼我們就來好好看看這個 AOT 到底是個神馬東西吧,尼瑪爲什麼就這麼複雜,這麼隱蔽,這麼折騰人,《鐵血戰神》在 App Store 上線都 5 個月了有木有,尼瑪這個問題碰到也不是一次兩次了有木有,作爲程序猿的我們被玩家吐槽了很多次,我們的客服 XDJM 們爲我們背了多少黑鍋啊,我勒個去啊。

首先,還是先搞定這個 trampoline 吧,畢竟問題的根源是在它身上的,那麼我們就好好來看看這是個神馬東西。我們找到 Mono Runtime 的官方文檔中關於 trampoline 的描述來看看吧。

Trampolines are small, hand-written pieces of assembly code used to perform various tasks in the mono runtime. They are generated at runtime using the native code generation macros used by the JIT. They usually have a corresponding C function they can fall back to if they need to perform a more complicated task. They can be viewed as ways to pass control from JITted code back to the runtime.

翻譯一下吧:

Trampoline 是一些手寫的非常短小的用來在 mono 運行時中執行很多操作的組件代碼。主要是通過 JIT 使用到的本地代碼宏在運行時動態生成的。它們通常都有與之相對應的 C 方法,在某些較爲複雜的場景中,當 trampoline 無法勝任時,mono 運行時就會將這些複雜的操作交回給這些對應的 C 方法來執行。這也可以看作是將 JIT 代碼的執行權交回給 runtime 的一種方式。

好吧,貌似還沒有太明白,那麼這個 Trampoline 爲什麼會導致出現閃退的問題的,這看起來明顯是爲了提高 mono runtime 在執行 C#代碼時候的效率啊。

那麼我們再來看看官方文檔關於 JIT Trampolines 和 AOT Trampolines 的介紹吧,杯具的 IMT Trampolines 介紹還在//TODO 狀態中。

JIT Trampolines These trampolines are used to JIT compile a method the first time it is called. When the JIT compiles a call instruction, it doesn’t compile the called method right away. Instead, it creates a JIT trampoline, and emits a call instruction referencing the trampoline. When the trampoline is called, it calls mono_magic_trampoline () which compiles the target method, and returns the address of the compiled code to the trampoline which branches to it. This process is somewhat slow, so mono_magic_trampoline () tries to patch the calling JITted code so it calls the compiled code instead of the trampoline from now on. This is done by mono_arch_patch_callsite () in tramp-.c.

好吧,再翻譯一下吧。

JIT Trampolines 這些 Trampoline 主要是 JIT 在首次調用某個方法的時候編譯方法用的。當 JIT 在編譯一個方法調用指令時,它並不會立刻就編譯這個被調用到的方法。實際上,它會先創建一個 JIT Trampoline,同時創建一個指向這個 trampoline 的調用指令。當這個 JIT Trampoline 在調用到的時候,它會再調用 mono_magic_trampoline() 方法來編譯這個 trampoline 實際指向的目標方法,然後將編譯後的方法的指針地址返回給這個指向它的 trampoline。這個過程呢稍微有點慢,所以呢,mono_magic_trampoline() 方法會優化調用 JIT 代碼的過程,它會先嚐試調用已經通過 JIT 編譯過的方法而不是立即通過 trampoline 直接進行調用。這些都是通過在 tramp-.c 文件中的 mono_patch_callsiete() 方法來完成的。

這就是 JIT Trampolines 的機制,接下來我們看看 AOT Trampolines 又是怎麼一回事呢。

AOT Trampolines

These are similar to the JIT trampolines but instead of receiving a MonoMethod to compile, they receive an image+token pair. If the method identified by this pair is also AOT compiled, the address of its compiled code can be obtained without loading the metadata for the method.

再翻譯一下。

AOT Trampolines AOT Trampolines 和 JIT Trampolines 非常相似,但是 AOT Trampolines 接受的編譯參數不是一個 Mono 方法而是一個 image+token 對。如果傳入的用於編譯的 image+token 對所指向的方法已經經過 AOT 編譯過了,那麼再次編譯這個 image+token 對時,就會直接返回這個已編譯方法的指針地址而不需要再次加載這個方法的元數據進行再次編譯了。

好吧,看了這麼多關於 Trampoline 相關的內容,貌似只是瞭解到了非常有限的內容,那就依然是 Trampolines 存在的價值就是爲了減少 C#代碼在 mono runtime 中運行時的性能損耗,提高 C#代碼的執行效率。

還有那個沒有出場的 IMT Trampolines 應該也就是用於優化接口調用效率的小『蹦牀』吧。

那麼我們在開發 Unity3D 遊戲的時候通常都會發布到 iOS 設備和 Android 設備上,而 Unity3D 在 iOS 和 Android 設備上的發佈都選擇了使用 AOT 編譯機制來實現。那麼顯然我們碰到的 Trampolines 問題都是跟 AOT Trampolines 有關,那麼 AOT 又是神馬呢?

AOT 就是區別於 JIT(Just In Time) 的另一個編譯機制,全稱是 Ahead Of Time,就是預先編譯好,而不是在代碼執行到了某個方法再進行編譯,這樣的話會有一些好處。

通過查看 Mono 官方 AOT 介紹文檔 ,使用 AOT 編譯的有點有以下優點: 1. 加快程序啓動速度 2. 更強的內存共享機制 3. 潛在的性能提升

當然也會有一些限制,例如支持平臺的有限,支持 AOT 的 Mono 版本有限等等,具體信息可以參考 Mono 官方 AOT 介紹文檔 。

那麼回到我們最開始的問題,爲什麼我們的遊戲就會出現崩潰呢?好吧,現在一點點回顧吧。

我們出現的問題是偶爾會出現閃退,根據崩潰日誌我們能定位到是 mono_convert_imt_slot_to_vtable_slot 這個方法導致的,然後我們再通過 Xcode 跟蹤到了是 trampoline 無法被訪問到的問題。

那麼這麼高端大氣上檔次的問題是腫麼出現的呢?貌似 Mono 還算是個不錯的產品啊,還是很活躍的啊,也有專門的公司 Xamarin 在支撐着,怎麼就會出現這種問提呢?

好吧,程序都是人寫的,有問題也是很正常的。上面的分析已經很清楚了,大體的原因就是因爲 Mono 在 iOS/Android 等移動設備上使用了 AOT 這種機制,爲什麼選擇這種機制?原因非常簡單,那就是可以針對特定平臺編譯成在平臺優化的字節碼,在資源比較緊缺的移動平臺上還是有着明顯優勢的。而使用 AOT 編譯就需要爲 Trampolines 這些小東西留足足夠的空間,當然這個肯定是硬編碼的某個常數啦,在整個程序加載成功運行之後,該常數就成爲了 Trampolines 運行時的配置。AOT 默認編譯時給 Trampolines 的參數有點低:

nrgctx-trampolines 默認爲 1024

nimt-trampolines 默認爲 128

ntrampolines 默認爲 1024

這對於小一些的項目可能是夠用的,因爲整體項目的結構不會太複雜,使用到的接口、泛型、遞歸相對也不會太多,但是對於一個稍大一些的項目來說,特別是採用了某些設計良好的第三方庫的項目來說,這就比較糾結了。

其實我們在項目中就使用了兩個第三方的庫,一個是 CodeTitan.JSon 庫,一個是 RestSharp,分別用於 JSON 解析和 HTTP 請求處理,可是這兩個庫實在是設計得太好了,各種使用接口,各種抽象,沒個兩三天我都沒法說完全理解了整個庫的結構。

就是因爲這些設計良好,完全遵循 OOP 原則,高度抽象的類庫將 Mono 默認的 Trampolines 的配置耗盡了,所以捏,我們就把這個編譯選項開大就好了,解決方案就是上面咱們提到的咯。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章