• 使用 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 

    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方法就能直接当做协程来用了.

      测试一下多个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, 项目中就可以有满足各种需求的多线程框架了.

       

  • 相关阅读:
    多角度分析平台即服务?PaaS的类型和用例
    2021.3.10 Android导出Excel表
    2021.3.9 个人作业阶段2
    2021.3.8 Android图像视图1
    2021.3.7 Android图像视图
    2021.3.6 Android页面刷新
    2021.3.5 个人作业1+家庭记账本(8)
    2021.3.4 家庭记账本(7)
    2021.3.3 Android项目打包+家庭记账本(6)
    2021.3.2 开课博客+家庭记账本(5)
  • 原文地址:https://www.cnblogs.com/tiancaiwrk/p/12420902.html
Copyright © 2020-2023  润新知