本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode        

        在 .NET 的初始版本中,我们只能依赖线程(线程可以直接创建或者使用 ThreadPool 类创建)。ThreadPool 类提供了一个托管抽象层,但是开发人员仍然需要依靠 Thread 类来进行更好的控制。而 Thread 类维护困难,且不可托管,给内存和 CPU 带来沉重负担。

        因此,我们需要一种方案,既能充分利用 Thread 类的优点,又规避它的困难。这就是任务 (Task)。

        (另:本章篇幅较大,将分为上种下三部分发表。)


1、任务(Task)的特性

        任务(Task)是 .NET 中的抽象,一个异步单位。从技术上讲,任务不过是对线程的包装,并且这个线程还是通过 ThreadPool 创建的。但是任务提供了诸如等待、取消和继续之类的特性,这些特性可以在任务完成后运行。

        任务具有以下重要特性:

  • 任务由 TaskScheduler (任务调度程序)执行,默认的调度仅在 ThreadPool 上运行。

  • 可以从任务中返回值。

  • 任务在完成时有通知(ThreadPool 和 Thread 都没有)。

  • 可以使用 ContinueWith() 构造连续执行的任务。

  • 可以通过调用 Task.Wait() 等待任务的执行,这将阻塞调用线程,直到任务完成为止。

  • 与传统线程或 ThreadPool 相比,任务可以使代码的可读性更高。他们还为在 C# 5.0 中引入异步编程构造铺平了道路。

  • 当一个任务从另一个任务启动时,可以建立它们之间的父子级关系。

  • 可以将子任务的异常传播到父任务。

  • 可以使用 CancellationToken 类取消任务。

2、创建和启动任务

        我们可以通过多种方式使用任务并行库(TPL)创建和运行任务。

2.1、使用 Task

        Task 类是作为 ThreadPool 线程异步执行工作的一种方式。它采用的是基于任务的异步模式( Task-Based Asynchronous Pattern,TAP)。非通用 Task 类不会返回结果,因此当需要从任务中返回值时,就需要使用通用版本的 Task<T> 。Task 需要调用 Start 方法来调度运行。

        具体的 Task 调用代码如下:

        /// <summary>
        /// 测试方法,打印10次,等待10秒
        /// </summary>
        public static void DebugAndWait()
        {
            int length = 10;
            for (int i = 0; i < length; i++)
            {
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
                Thread.Sleep(1000);
            }
        }
        
        //使用任务执行
        private void RunByNewTask()
        {
            //创建任务
            Task task = new Task(TestFunction.DebugAndWait);
            task.Start();//不调用 Start 则不会执行
        }

        最终结果也没有什么意外:

2.2、使用 Task.Factory.StartNew

        TaskFactory 类的 StartNew 方法也可以创建任务。这种方式创建的任务将安排在 ThreadPool 中执行,然后返回该任务的引用:

        private void RunByTaskFactory()
        {
            //使用 Task.Factory 创建任务,不需要调用 Start
            var task = Task.Factory.StartNew(TestFunction.DebugAndWait);
        }

        当然打印的结果和上述一样的。

2.3、使用 Task.Run

        这个原理和 Task.Factory.StartNew 一样:

        private void RunByTaskRun()
        {
            //使用 Task.Run 创建任务,不需要调用 Start
            var task = Task.Run(TestFunction.DebugAndWait);
        }

2.4、Task.Delay

        使用 Task.Delay 也可以创建一个任务,但是这个任务有点特别。它可以在指定时间间隔后完成,可以使用

        CacellationToken 类随时取消。与 Thread.Sleep 不同,Task.Delay 不需要利用 CPU 周期,且可以异步运行。

        为了体现两者的不同,我们直接写个例子:

        public static void DebugWithTaskDelay()
        {
            Debug.Log("TaskDelay Start");
            Task.Delay(2000);//等待2s        
            Debug.Log("TaskDelay End");
        }

        然后我们直接在程序中直接同步调用此方法:

        private void RunWithTaskDelay()
        {
            Debug.Log("开始测试 Task.Delay !");
            TestFunction.DebugWithTaskDelay();
            Debug.Log("结束测试 Task.Delay !");
        }

        结果如下:

         可以看到4条打印按照顺序一瞬间被打印出来了,根本没有任何等待。而如果我们把上述的 Task.Delay 替换成 Thread.Sleep,结果会如何呢?

         在运行此方法后,Unity直接卡住,然后2s后打印出4条信息。并且,显然线程等待生效了,但是是以阻塞主线程的方式生效的。

        让我们换回 Task.Delay ,并使用 Task.Run 来运行这个方法,打印结果如下:

         显然线程等待命令生效了,说明在子线程中的 Delay 是可以正常工作的。

2.5、Task.Yield

        Task.Yiled 是创建 await 任务的另一种方法。使用此方法可以让方法强制变成异步的,并将控制权返回给操作系统。

        怎么理解呢?我们这里需要一个很耗时的函数:

        public static async void DebugWithTaskYield()
        {
            int length = 27;//这个方法不能执行很多次
            string str = "";

            for (int i = 0; i < length; i++)
            {
                //以下是耗时函数
                str += "1,1";
                var arr = str.Split(',');
                foreach (var item in arr)
                {
                    str += item;
                }

                await Task.Yield();
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
            }
        }

        这里我直接用简单的字符串拼接来实现了耗时函数。

        我们在主线程调用 Task.Run 来执行,Debug 的结果如下:

         可以看到随着字符串的增加,单次耗时越来越长。但是无论单次耗时时长有多少,都没有阻碍主线程!可能大家第一感觉和 Unity 的协程是一样的,但是 Unity 的协程使用是在主线程运行的,使用协程并不代表不会阻塞主线程。这里我们直接将这段代码用协程的逻辑实现:

        public static IEnumerator DebugWithCoroutine()
        {
            int length = 27;//这个方法不能执行很多次
            string str = "";

            for (int i = 0; i < length; i++)
            {
                //以下是耗时函数
                str += "1,1";
                var arr = str.Split(',');
                foreach (var item in arr)
                {
                    str += item;
                }

                yield return null;
                Debug.Log($"执行第:{i + 1}/{length} 次打印!");
            }
        }

        逻辑上没有任何区别,就是把 await Task.Yield(); 改成了 yield return null 。当然,日志打印上看起来差不多,但是对主线程而言有本质区别。当运行到后面时,每次迭代都会造成主线程的卡顿。这一点在 Profiler 上看起来非常明显:

 (可以看到协程调用的显然耗时)

2.6、Task.FromResult

        FromResult<T> 是在 .NET Framework 4.5 中才被引入的方法,这在 Unity 2022.2.5 f1c1 使用的 .NET Standard 2.1 是支持的。

        public static int FromResultTest()
        {
            int length = 100;
            int result = 0;
            for (int i = 0; i < length; i++)
                result += Random.Range(0, 100);
            Debug.Log($"FromResultTest 运算结果:{result} ");
            return result;
        }
        
        private void RunWithFromResult()
        {
            Debug.Log("RunWithFromResult Start !");
            Task<int> resultTask = Task.FromResult<int>(TestFunction.FromResultTest());
            Debug.Log("RunWithFromResult End ! Result : " + resultTask.Result);
        }

        如上述代码所示 RunWithFromResult 的结果如下:

         与一般的Task异步不同,这里是按照执行顺序依次打印的。如果这个函数是个耗时函数,会阻塞主线程吗?我把 2.5 里测试的耗时函数搬过来测试了一下(就不贴代码了):

         显然已经阻塞主线程了。

        也就是说这个 FromResult 将异步的方法拿到主线程中调度了(也可以理解为把子线程直接拿到父线程)。既然已经是 Unity 主线程了,那么 Task.Delay 就不会生效;而 Thread.Sleep 会生效,且会阻塞主线程。

        与前面的几个创建Task任务的方法不同,这个Task.FromResult 是可以调用带参函数的(Task.Run 只能运行无参函数)。但即便如此,因为其会阻塞父线程,也不建议在 Unity 主线程中使用。

2.7、Task.FromException 和 Task.FromException<T>

        这两个方法都可以抛出异步任务中的异常,在单元测试中很有用。

        (这里暂时不会用到,就先不讲了,在后面学单元测试的时候再详细学习这两个)

2.8、Task.FromCanceled 和 Task.FromCanceled<T>

                这个和 Task.FromException 的情况有点类似,都是看起来不知道有啥用其实很有用的方法。为了方便学习,这里还是展开讲讲。

        首先看下面一段代码,这个也是 Task.FromCanceled 的示例代码:

CancellationTokenSource source = new CancellationTokenSource();//构建取消令牌源
source.Cancel();//设置为取消

//返回标记为取消的任务。
//注意!使用此方法要确保 CancellationTokenSource 已经调用过 Cancel 方法 ,否则会出错!
Task.FromCanceled(source.Token);

        当我们把这个最后得到的Task状态(Task.Status)打印出来,其结果是便是 Created 。

        肯定就有人会问了,这个有啥用啊?我是创建了一个取消的任务?那我执行这段代码的意义是什么呢?

        单看这段代码,确实没什么意义,但是我们这里提出一个需求:

         逻辑很简单,但是问题就出在最后,要维护一个Task。我们假设预计执行的任务A是某个长期的异步函数,外部需要检测他的状态和结果。那我们在输入偶数的时候,该返回什么呢?首先肯定不能返回一个空的Task,这个返回就和正常的Task一样的了,外部监控的状态要么是 WaitingToRun, 要么就是 RanToCompletion,要么就是 Running 。我根本无法知道我是执行了 任务A 还是没有执行 任务A。

        这时候就发现 Task.FromCanceled 的作用了:

        private void RunWithFromCanceled()
        {
            var val = commonPanel.GetInt32Parameter();
            //这里测试输入双数就取消执行,单数就正常执行。
            CancellationTokenSource source = new CancellationTokenSource();
            if (val % 2 == 0)
                source.Cancel();
            var task = TestFunction.TestCanceledTask(source);
            Debug.Log($"Task State 1: {task.Status}");
        }
        
        /// <summary>
        /// 测试用于取消任务
        /// </summary>
        public static Task TestCanceledTask(CancellationTokenSource source)
        {
            if (source.IsCancellationRequested)
            {
                Debug.Log($"任务取消 !");
                var token = source.Token;       
                return Task.FromCanceled(token);
            }
            else
            {
                Debug.Log($"任务执行 !");
                return Task.Run(DebugWithTaskDelay);
            }
        }

        当输入偶数时,就会返回一个已取消的任务,而奇数则会正常执行。

        当我们对任务进行了封装,内部的判断逻辑会比较复杂,而外部也只需要知道任务执行情况而不需要知道其内部逻辑。此时使用 Task.FromCanceled 和 Task.FromException 就能返回给外部一个通用的“异常”Task。

3、从完成的任务中获取结果

        任务并行库(TPL)中提供的API有如下几个:

        /// <summary>
        /// 获取任务并行结果
        /// </summary>
        private void GetTaskResult()
        {
            int inputParam = commonPanel.GetInt32Parameter();
            Debug.Log($"get task result start ! paramter :  {inputParam}");

            //方法1 :new Task
            var task_1 = new Task<int>(()=>TestFunction.FromResultTest(inputParam));
            task_1.Start();
            Debug.Log($"task_1 result : {task_1.Result}");

            //方法2:Task.Factory
            var task_2 = Task.Factory.StartNew<int>(()=> TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_2 result : {task_2.Result}");

            //方法3:
            var task_3 = Task.Run<int>(()=>TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_3 result : {task_3.Result}");

            //方法4:
            var task_4 = Task.FromResult<int>(TestFunction.FromResultTest(inputParam));
            Debug.Log($"task_4 result : {task_4.Result}");
        }

        这次测试终于出现了一个熟悉的错误:

         Random.Range 只能在Unity主线程使用。

        这个以前就知道 UnityEngine 的类不能在子线程使用,这里遇到了。但是没关系,我们直接修改这个方法即可,用System的Random就行了。

        但是这能说明我们的程序确实在子线程运行了,但是实际上这4个方法都是会阻塞主线程的

         所有的运算流程都是和 2.6 的 FromResult 一样,已经将子线程调回主线程使用了。显然这几种方法都是提供一种同步的结果获取,而真正做到异步计算还不能直接这么使用。


        限于篇幅,任务并行性(上)到此为止。

        本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐