使用 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, 项目中就能够有知足各类需求的多线程框架了.

相关文章
相关标签/搜索