本文將探討在Unity專案中編寫非同步方法的兩種選擇:Unity的協程(Coroutine)和C#的async/await/Task。首先,我們將解釋什麼是非同步,然後介紹Coroutine和async/await,展示如何使用它們。接著,我們將比較這兩種方法的差異。然後,我們將介紹C#中的Task替代方案。最後,對本文進行總結。
一、什麼是”非同步”? 非同步的優點
非同步指的是開啟某個任務後,不需等待該任務執行完畢,就可以去執行接下來的任務。
– 非同步缺點
- 概念較複雜 – 開發人員必須熟悉回調和遞歸函數。
- 回調函數:
- 回調函數是一種特殊的函數,作為參數傳遞給其他函數。
- 當某些事件發生時,這個回調函數會被調用,以執行特定的操作。
- 遞歸函數:
- 遞歸函數是一個函數,它在其定義中直接或間接地調用自身。
- 遞歸是一種計算過程,其中每一步都依賴於前一步的結果。
二、Unity 中使用非同步 2 種方式:
– Coroutine
協程是一種可以在多幀中執行的函數,允許在特定條件下暫停執行並在稍後繼續。在 Unity 中,協程使用 StartCoroutine
方法啟動,並且使用 yield return
來暫停協程直到條件達成後接續執行協程。
– async/await/Task
C# 提供了內建的非同步程式碼支持(Unity 需引用 System.Threading.Tasks ),使用 async
和 await
關鍵字來標記和等待非同步操作。Task
類型用於表示異步操作的結果,可以在操作完成後繼續處理。
三、如何編寫非同步代碼
– 使用協程 Coroutine
using UnityEngine;
using System.Collections;
public class CoroutineTimerExample : MonoBehaviour
{
void Start()
{
StartCoroutine(ExampleCoroutine());
Debug.Log("Coroutine next!");
}
IEnumerator ExampleCoroutine()
{
Debug.Log("Coroutine started");
yield return new WaitForSeconds(3);
Debug.Log("Coroutine resumed after 3 seconds");
}
}
– 使用 async/await/Task
using UnityEngine;
using System.Threading.Tasks;
public class TaskTimerExample : MonoBehaviour
{
void Start()
{
StartTaskTimer();
Debug.Log("Task next!");
}
async void StartTaskTimer()
{
await Timer();
Debug.Log("Task end!");
}
async Task Timer()
{
Debug.Log("Task started");
await Task.Delay(3000); // 3 seconds delay
Debug.Log("Task end after 3 seconds");
}
}
四、差異比較
特性 | async/await | 協程 (Coroutine) |
性能 | 主要取決於使用情境,適合處理不依賴於 Unity 特定功能的背景操作。(運行快) | 協程在 Unity 內部優化,對於處理大量非同步操作時可能更高效。(啟動快) |
錯誤處理 | 可以直接使用 try-catch 處理異常。 | 需要額外的錯誤處理邏輯。 |
整合性 | 更適合處理不依賴於 Unity 特定功能的背景操作。 | 更適合與 Unity 的生命周期和更新循環集成。 |
五、Task替代方案 – UniTask
在遊戲開發中,特別是在使用具有自動記憶體管理功能(如C#)的語言時,我們需要特別關注記憶體的分配情況。我們通常有兩種方式來減少垃圾收集器(GC)分配記憶體的次數,從而使應用程式的記憶體使用效率更高。首先,我們可以盡量減少整個應用程式的記憶體使用量,雖然這對於大型資產佔用記憶體的遊戲來說僅具邊際收益。其次,更重要的是,我們可以降低GC收集垃圾的頻率,因為這些操作需要花費大量時間,並且經常會導致性能下降。
當涉及到協程和C#的Task時,創建新實例會強制GC在堆上分配記憶體,但並非所有協程和Task的使用都會如此。例如,等待下一幀的操作不會在堆上分配記憶體。相反,等待給定的時間確實會在堆上分配記憶體。在遊戲的生命週期中,協程和任務的分配成本可能會積累,導致垃圾收集頻繁導致性能下降。
值得注意的是,雖然ValueTask<T>可能會減少堆分配,但其收益可能微不足道。幸運的是,C#有一個替代方案UniTask,它旨在減少這種效能開銷。UniTask提供了對「即發即忘」非同步操作的更好支援,並且在記憶體分配方面表現更佳。
總的來說,在Unity中,協程和Task是編寫非同步程式碼的兩種主要解決方案。然而,UniTask作為對Task的替代方案,為開發者提供了更好的記憶體分配效率。
UniTask是一個針對Unity的高效非同步/等待整合函式庫,旨在減少記憶體分配。在使用C#的Task時,它被引入作為Task的替代品。UniTask使用結構,與Task的行為幾乎完全相同,但有一些重要的區別:
- UniTask主要使用結構而非類別,從而減少了在堆上的記憶體分配。
- 它提供了更好的支援,例如UniTask.Forget,用於”即發即忘”的非同步操作。
- 為 Unity 提供有效的無GC async/await集成。
- 基於Struct
UniTask<T>
的自定義 AsyncMethodBuilder,實現零GC,使所有Unity的異步操作和協程可以await - 基於PlayerLoop的Task(
UniTask.Yield
、UniTask.Delay
、UniTask.DelayFrame
等)這使得能夠替換所有協程操作 - MonoBehaviour 消息事件和 uGUI 事件為可使用Await/AsyncEnumerable
- 完全在 Unity 的 PlayerLoop 上運行,因此不使用線程,可在 WebGL、wasm 等平台上運行。
- 異步 LINQ,具有Channel和 AsyncReactiveProperty
- 防止內存泄漏的 TaskTracker (Task追蹤器)窗口
- 與Task/ValueTask/IValueTaskSource 的行為高度兼容
UniTask的主要缺點來自於需要自行安裝,它並未內建於Unity中,還有初期使用時需要花一些時間習慣,為了能更好的控制,相對來說使用上的複雜程度比協程高了一些。儘管如此,在我看來,為了在Unity中編寫非同步程式碼提供整體卓越的解決方案,這是值得付出的代價。如果你在Unity項目中需要高效的非同步操作,UniTask是一個值得考慮的選項。
使用UniTask須注意: UniTask官方支援的最低版本為
Unity 2018.4.13f1
。
總結
協程Coroutines不是線程threads. 非同步不是多線呈。多線呈是非同步方法的其中一種。
Coroutine 的限制:
- 只能在 MonoBehaviour 中啟動: 協程只能在繼承自 MonoBehaviour 的類中啟動。
- 這意味著只有 Unity 中的遊戲物體或場景中的腳本才能使用協程。
在協程中運行的同步操作,仍然在主執行緒上執行。如果想要減少主執行緒上花費的 CPU 時間,編寫時仍須避免阻塞操作。如果想在 Unity 中使用多執行緒程式碼,需要用到 C# Job System.
在 Unity 的 Play Mode 中,當遊戲停止運行時,所有的異步任務(如 Task)也會被取消。這是因為 Unity 在停止播放時會清理所有運行時的資源,包括任務。
為什麼需要UniTask(自訂類別任務物件)?因為UniTask實現了比Task更快、更低的分配的非同步操作,並且可以良好運行於Unity中。
關於如何使用 UniTask 將在另一篇文章中介紹。