Unity 中的非同步:Coroutine 與 async/await

本文將探討在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 ),使用 asyncawait 關鍵字來標記和等待非同步操作。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的行為幾乎完全相同,但有一些重要的區別:

  1. UniTask主要使用結構而非類別,從而減少了在堆上的記憶體分配。
  2. 它提供了更好的支援,例如UniTask.Forget,用於”即發即忘”的非同步操作。
  3. 為 Unity 提供有效的無GC async/await集成。
  4. 基於Struct UniTask<T> 的自定義 AsyncMethodBuilder,實現零GC,使所有Unity的異步操作和協程可以await
  5. 基於PlayerLoop的Task( UniTask.YieldUniTask.DelayUniTask.DelayFrame 等)這使得能夠替換所有協程操作
  6. MonoBehaviour 消息事件和 uGUI 事件為可使用Await/AsyncEnumerable
  7. 完全在 Unity 的 PlayerLoop 上運行,因此不使用線程,可在 WebGL、wasm 等平台上運行。
  8. 異步 LINQ,具有Channel和 AsyncReactiveProperty
  9. 防止內存泄漏的 TaskTracker (Task追蹤器)窗口
  10. 與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 將在另一篇文章中介紹。