使用 async-await 簡化代碼的反省

  從API版本升級到4.6以後, Unity支持了async和await語法, 而且根據測試來看, 它運行在主線程裏, 跟通常的C#編譯不大同樣, 這就頗有操做空間了, 先來看看普通C# Console工程和Unity中運行的差異:多線程

  1. C# Console框架

using System;

namespace AsyncTest
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");

            Console.WriteLine("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);   // 1
            Test();

            Console.ReadLine();
        }

        async static void Test()
        {
            await System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(5));
            Console.WriteLine("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);   // 4
        }
    }
}

  運行結果能夠看到運行在不一樣的線程裏面 : 異步

 

  2. Unity async

using UnityEngine;

public class AsyncAwaitTest : MonoBehaviour
{
    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);  // 1
        Test();
    }
    async static void Test()
    {
        await System.Threading.Tasks.Task.Delay(System.TimeSpan.FromSeconds(5));
        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);  // 1
    }
}

  運行結果能夠看到運行在主線程裏面 : 函數

 

  這樣的好處是什麼呢? 第一個是它跟協程同樣了, 經過不一樣的await方法返回不一樣的對象實現協程的做用, 我發現它可使用 WaitForSeconds 這些Unity自帶的控制類型, 比較神奇, 看下面測試:性能

using UnityEngine;

public class AsyncAwaitTest : MonoBehaviour
{
    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
        Test();
    }
    async static void Test()
    {
        //await System.Threading.Tasks.Task.Delay(System.TimeSpan.FromSeconds(5));
        Time.timeScale = 2.0f;
        await new WaitForSeconds(2.0f);

        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time3 : " + Time.time);
        Debug.Log("Time4 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));       
    }
}

  運行結果以下:測試

  上面的運行在開始時調整了Time.timeScale, 而後等待的時間 WaitForSeconds(2.0) 運行結果也是正確的, 看到遊戲時間過了2秒, 實際時間過了1秒, 也就是說Unity中對await的返回進行了整合, 自帶的YieldInstruction也能被await正確返回. 這樣async方法就能直接當作協程來用了.spa

  測試一下多個async嵌套運行的狀況:線程

using UnityEngine;

public class AsyncAwaitTest : MonoBehaviour
{
    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
        Test();
    }
    async void Test()
    {
        Time.timeScale = 2.0f;
        await new WaitForSeconds(2.0f);

        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time3 : " + Time.time);
        Debug.Log("Time4 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));

        await Test2();
    }
    async System.Threading.Tasks.Task Test2()
    {
        await new WaitForSecondsRealtime(2.0f); // Time.timeScale = 2.0f;
        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time5 : " + Time.time);
        Debug.Log("Time6 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
    }
}

  運行結果 : 設計

  正確的結果, 由於在Test2中timeScale仍是2, 使用realtime的話就是4秒的遊戲時間. 

  都是在主線程中運行的, 這樣看來由於async是語言級別的支持, 可能之後就沒有協程什麼事了, 使用async在寫法上也比協程簡單了一點, 咱們試試用協程來寫:

    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));

        StartCoroutine(Test());
    }
    IEnumerator Test()
    {
        Time.timeScale = 2.0f;
        yield return new WaitForSeconds(2.0f);

        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time3 : " + Time.time);
        Debug.Log("Time4 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));

        yield return Test2();
    }
    IEnumerator Test2()
    {
        yield return new WaitForSecondsRealtime(2.0f); // Time.timeScale = 2.0f;
        Debug.Log("Async : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time5 : " + Time.time);
        Debug.Log("Time6 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
    }

  差異在StartCoroutine上, 反正我是常常忘了寫它, 而後運行不起來的. 由於沒有什麼好方法測試兩種方案的性能差異, 暫時先拋開性能吧.

  而後是 WaitForEndOfFrame 在async是否正確的測試 : 

using UnityEngine;

public class AsyncAwaitTest : MonoBehaviour
{
    bool update = false;
    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
        Test();
    }
    async void Test()
    {
        int i = 0;
        update = true;
        while(i < 10)
        {
            i++;
            Debug.Log("Async -- " + Time.frameCount);
            await new WaitForEndOfFrame();
        }
        update = false;
    }
    void Update()
    {
        if(update)
        {
            Debug.Log("Update -- " + Time.frameCount);
        }
    }
}

  能夠看到跟Update函數是交互進行的, 確實async能以YieldInstruction做爲等待邏輯 (更正, 能以Unity已經建立好的YieldInstruction做爲等待邏輯). 這些都驗證了async-await 可以替代協程, 再來測試一個await對異步操做自動返回的類型的:

    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));

        var loadPath = Application.streamingAssetsPath + "/mycube";
        Load<GameObject>(loadPath, "MyCube", (_prefab) =>
        {
            var go = GameObject.Instantiate(_prefab);
            go.name = "MyCube Loaded";
            Debug.Log("Time3 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));
        });
    }

    async void Load<T>(string loadPath, string assetName, System.Action<T> loaded) where T : UnityEngine.Object
    {
        AssetBundle assetBundle = await AssetBundle.LoadFromFileAsync(loadPath);
        UnityEngine.Object asset = await assetBundle.LoadAssetAsync<T>(assetName);
        loaded.Invoke(asset as T);
        assetBundle.Unload(false);
    }

  上面的代碼用來讀取一個AssetBundle中的GameObject, 在讀取步驟 await AssetBundle.LoadFromFileAsync(loadPath); 返回的直接就是assetBundle了, 而且在 await  assetBundle.LoadAssetAsync<T>(assetName); 直接返回的就是asset(Object)了, 這個可能也是Unity在編譯層面作的改動吧, 因此通過測試正常API都能經過await返回.

  這只是基本操做, 其實有更厲害的地方, 它能改變上下文達到跳轉線程的做用. Unity有它本身的同步上下文叫作UnitySynchronizationContext, .NET中叫SynchronizationContext, 由於Unity使用的是.NET標準庫, 因此繼承了Task的ConfigureAwait功能, 它是告訴這個Task能夠運行在其它線程上, 而若是上下文的線程進行了轉換, 若是沒有須要它就不會自動轉回主線程. 測試一下 : 

    public class EnterWorkThread
    {
        public ConfiguredTaskAwaitable.ConfiguredTaskAwaiter GetAwaiter()
        {
            return Task.Run(() => { }).ConfigureAwait(false).GetAwaiter();
        }
    }

    void Start()
    {
        Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log("Time1 : " + Time.time);
        Debug.Log("Time2 : " + System.DateTime.Now.ToString("HH:mm:ss fff"));

        Test();
    }
    async void Test()
    {
        Debug.Log("Async1 : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        await new EnterWorkThread();
        Debug.Log("Async2 : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        GameObject.CreatePrimitive(PrimitiveType.Cube);
    }

  

  能夠看到 await new EnterWorkThread(); 以後當前線程轉換爲了工做線程, 經過這個方式就把上下文轉換到了其它線程裏面. 後面運行的代碼也繼續在新線程中運行.

  await 只須要返回對象有GetAwaiter方法便可.

  那麼要回到主線程有什麼方法呢? 等待主線程的生命週期便可:

    async void Test()
    {
        Debug.Log("Async1 : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        await new EnterWorkThread();
        Debug.Log("Async2 : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        await new WaitForEndOfFrame();
        Debug.Log("Async3 : " + System.Threading.Thread.CurrentThread.ManagedThreadId);
        Debug.Log(GameObject.CreatePrimitive(PrimitiveType.Cube).name);
    }

  看到線程又回到了主線程, 而且調用API沒有問題. 之後寫多線程的代碼能夠很簡單了!!!

(2020.03.06)

 PS : 目前本身建立的對象只有繼承於CustomYieldInstruction類的才能做爲awaitable對象, 其它仍是須要按照正常的C#方式來, 而且在執行這個以後必定會回到主線程, 這應該是Unity底層作了強制轉換, 因此纔有了這個寫法的理論支持. 而後這個線程轉換, 在回到主線程的時候都是要等待下一幀的, 跟咱們本身寫的邏輯差很少 : 

 void OnGUI()
    {
        if(GUI.Button(new Rect(100, 100, 100, 50), "Test")) { FrameTest(); } } async void FrameTest() { Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId); Debug.Log("Frame : " + Time.frameCount); await new EnterWorkThread(); // 工做線程 Debug.Log("WorlThread : " + System.Threading.Thread.CurrentThread.ManagedThreadId); await new EnterMainThread(); Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId); Debug.Log("Frame : " + Time.frameCount); return; }

 

  PS2 : 在工做線程中進行等待操做, 也須要另外封裝才行, 若是使用Unity的會被強制回到主線程的, 但是即便本身封裝, 也會被強制轉換線程 : 

    public class WaitTimeWorkThread
    {
        private float _time = 0.0f;
        public WaitTimeWorkThread(float time)
        {
            _time = time;
        }
        public ConfiguredTaskAwaitable.ConfiguredTaskAwaiter GetAwaiter()
        {
            return Task.Delay(TimeSpan.FromSeconds(_time)).ConfigureAwait(false).GetAwaiter();
        }
    }

    async void FrameTest()
    {
     Debug.Log("Main : " + System.Threading.Thread.CurrentThread.ManagedThreadId);

      await new EnterWorkThread(); // 工做線程1
      Debug.Log("EnterWorkThread : " + System.Threading.Thread.CurrentThread.ManagedThreadId);

      await new WaitTimeWorkThread(1.0f); // 工做線程2
      Debug.Log("WaitTimeWorkThread : " + System.Threading.Thread.CurrentThread.ManagedThreadId);

    }

  結果很不理想, 在線程中仍是被轉換了線程 : 

  

  若是是多重嵌套的邏輯, 隨着上下文轉換的開銷增長, 很難說性能影響的大小, 而且全部調用都要注意線程問題, 有些邏輯自帶線程轉換的, 就比較麻煩了, 雖然跟普通多線程比較方便了不少, 但是跟Job系統比起來又弱爆了, 各有各的好吧.

 

  補充一下額外的相關信息, 一個普通協程它是能夠被中止的, 經過關閉運行這個協程的GameObject, 或者是調用StopCoroutine方法, 咱們使用async方法的話, 就很sucks了, 由於語言自己就沒有提供中止Task的方法, 測試了它提供的CancellationToken簡直就是個智障設計, 徹底沒有實際意義. 看看微軟本身提供的例子 : 

    static async Task Main()
    {
        var tokenSource2 = new CancellationTokenSource(); CancellationToken ct = tokenSource2.Token; var task = Task.Run(() => {  ct.ThrowIfCancellationRequested(); bool moreToDo = true; while (moreToDo) { if (ct.IsCancellationRequested) {  ct.ThrowIfCancellationRequested(); }
} }, tokenSource2.Token); // Pass same token to Task.Run. tokenSource2.Cancel(); try { await task; } catch (OperationCanceledException e) { Console.WriteLine($"{nameof(OperationCanceledException)} thrown with message: {e.Message}"); } finally { tokenSource2.Dispose(); } Console.ReadKey(); }

  除了一句MDZZ以外還能說什麼呢, 在全部代碼前添加異常拋出嗎? 在全部循環中本身添加嗎? 簡直弱爆了啊. 

  若是使用殺線程的方式不知道是否可行, 由於在這裏的async模式下, 咱們是能夠不斷轉換線程的, 主線程的話怎麼辦? 不能殺線程也不能中止. 還有它進入工做線程的時候怎樣記錄線程ID也是個問題......

 

   無論怎樣, 它提供了另一種協程或多線程的方式, 加上ECS on Job, 項目中就能夠有知足各類需求的多線程框架了.

相關文章
相關標籤/搜索