【C#】并行编程实战:任务并行性(中)

        本章继续介绍任务并行性,因篇幅所限,本章为中篇。


4、取消任务

        .NET Framework 提供了以下两个类来支持任务取消:

  • CancellationTokenSource :此类负责创建取消令牌,并将取消请求传递给通过源创建的所有令牌。

  • CancellationToken:侦听器使用该类来监视请求的当前状态。

        其实和 2.8 的内容有点类似,接下来按照教程步骤走一遍:

CancellationTokenSource source = new CancellationTokenSource();
CancellationToken token = source.Token;       

4.2、使用令牌创建任务

        创建任务的API就有很多了,可用 2.1 ~ 2.3 的任意一种:

var task = new Task(TestFunction.LoopFuntion, token);
var task = Task.Factory.StartNew(TestFunction.LoopFuntion, token);
var task = Task.Run(TestFunction.LoopFuntion, token);

        我设定的 LoopFuntion 是个无参函数,那么这个 token 有什么用呢?

        我一开始想的是,我运行了一个 Task,并传入了 token,他在后台长时间运行。当我需要取消任务时,则调用 CancellationTokenSource 的 Cancel 方法,我创建的 Task 就取消了 。但实际并不是这样。取消令牌只能在任务开始前取消任务,而任务一旦开始运行,则无法取消任务!

        其实和上一章说的 BackgroundWorker 类似,一旦开始执行就取消不了了,即便对 Task 进行 Dispose 也不行。需要程序员在函数中自行实现取消的方法,将方法修改如下:

        var task = Task.Run(() => TestFunction.LoopFuntion(token), token);
        
        public static async void LoopFuntion(CancellationToken token)
        {
            int index = 0;

            for (int i = 0; i < 10; i++)
            {
                await Task.Delay(1000);
                index++;
                Debug.Log($"LoopFuncion ,Number : {index}");

                if (token.IsCancellationRequested)
                {
                    Debug.Log("手动取消!");
                    return;//手动取消;
                }
            }
        }

4.3、注册请求取消的回调

        CancellationToken 中可以注册一个回调函数,在取消时触发。同样在上述的LoopFunction中,可以如下写代码:

//注册一个事件,在 token 设置为取消时触发中断循环
bool IsCancelled = false;
token.Register(() =>
{
    IsCancelled = true;
});

5、等待正在运行的任务

        TPL 中提供了多种用于等待一个或多个任务的 API,具体如下所示:

5.1、Task.Wait

        我们先写一个简单示例如下:

        private void RunWithTaskWait()
        {
            Debug.Log("RunWithTaskWait Start !");
            var task = Task.Run(TestFunction.DebugWithTaskDelay);
            Debug.Log("子线程已经开始运行 !");
            task.Wait();
            Debug.Log("RunWithTaskWait End!");
        }

        我在Unity主线程中调用,猜猜结果会如何?可能你会认为最后一条Log(“RunWithTaskWait End!”)会在 task 完全执行完成之后才会打印,然而其实并不会:

         这个效果和没有 Wait 的效果是一样的。

        这个和书上说的就不一样了,无语……我在想作者写书的时候是不是根本没有运行过啊……总之我们先不纠结这个,先看看怎么实现 Task 的等待:

        RunWithTaskWait 不变,然后 DebugWithTaskDelay 做如下修改:

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

        发现区别没有?我们把返回值从 void 直接改成 Task,没有编译错误,而且运行结果也正确了。(我这样写,在等待的时候会阻塞 Unity 主线程,大家可以在另一个 Task 调用 DebugWithTaskDelay)

         其实从C#的源代码上可以看到,Task.Run 有以下两个重载:

public static Task Run(Func<Task> function);//函数在 Task 调度中执行
public static Task Run(Action action);//函数当成普通任务执行

        调用下面这个 Run 是不会有效果的,类似于普通函数,只有上面那个 Run 才是正确的。所以我们需要返回一个 Task 才能正确执行任务里的逻辑。而 Task 又比较特殊,和 void 一样不用 return !这个确实是我没有想到的,真有你的 C# !不得不说,我对 Task 的理解又更进一步 ~

5.2、Task.WaitAll

        Task.WaitAll 用于等待多个任务,任务将作为数组的传递给方法,并且调用程序将被阻塞,直至所有任务都完成。Task.WaitAll 还支持超时和取消令牌。

        在 TestFunction 中添加一个传参的等待方法:

        /// <summary>
        /// 传参的等待方法;
        /// </summary>
        /// <param name="millisecondsDelay">毫秒</param>
        /// <returns></returns>
        public static async Task DebugWithTaskWaitByParameter(int millisecondsDelay)
        {
            Debug.Log($"开始等待:{millisecondsDelay} !");
            await Task.Delay(millisecondsDelay);
            Debug.Log($"结束等待:{millisecondsDelay} !");
        }

        之后我们尝试执行 Task.WallAll,这里我为了避免卡死主线程,所以开了一个新线程来执行:

        private void RunWithTaskWaitAll()
        {
            Thread thread = new Thread(() =>
                {
                    Debug.Log($"开始 RunWithTaskWaitAll!");
                    var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
                    var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
                    var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
                    Task.WaitAll(task1, task2, task3);
                    Debug.Log($"完成 RunWithTaskWaitAll!");
                });
            thread.Start();
        }

        效果如下:

        和预期效果一样。

5.3、Task.WaitAny

        Task.WaitAny 也可以等待多个任务,顾名思义,只要等待的任务中有任何一个执行完毕,调用线程就不会阻塞。 Task.Wait、Task.WaitAll 和 Task.WaitAny 都可以设置超时时间和取消令牌,这个就是纯 API 的调用,没有什么变化,就不再赘述了。

        Task.WaitAny 的测试代码如下:

        private void RunWithTaskWaitAny()
        {
            Thread thread = new Thread(() =>
            {
                Debug.Log($"开始 RunWithTaskWaitAny!");
                var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
                var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
                var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
                Task.WaitAny(task1, task2, task3);
                Debug.Log($"完成 RunWithTaskWaitAny!");
            });
            thread.Start();
        }

        结果如下:

5.4、Task.WhenAll

        Task.WhenAll 是 Task.WatiAll 方法的非阻塞变体,区别在于会返回一个 Task ,代表所有指定任务的等待。单看概念可能不好理解,直接上代码:

        private void Update()
        {
            if (RunningTask == null)
                return;

            switch (RunningTask.Status)
            {
                case TaskStatus.RanToCompletion:
                    Debug.Log("RunningTask 运行完成!");
                    RunningTask.Dispose();
                    RunningTask = null;
                    break;
            }
        }

        private Task RunningTask;

        private void RunWithTaskWhenAll()
        {
            Thread thread = new Thread(() =>
            {
                Debug.Log($"开始 RunWithTaskWhenAll!");
                var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
                var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
                var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
                RunningTask = Task.WhenAll(task1, task2, task3);
                Debug.Log($"完成 RunWithTaskWhenAll!");
            });
            thread.Start();
        }

        这里开了一个 Update 来轮询 Task.WhenAll 返回的 Task 的结果,当 RunningTask 完成时进行一次打印。其余部分都是把 5.2 的 Task.WaitAll 测试用例抄过来的。

        结果如下:

         结果很明显,开始执行3个等待任务的时候,并没有阻塞调用线程。而等到3个任务都完成之后,RunningTask 才会标记为完成。

5.5、Task.WhenAny

        这个很显然了,就是 WaitAny 的非阻塞变体。WaitAny 和 WhenAny 的区别就和 WaitAll 和 WhenAll 的区别一样,就是功能的排列组合,这里就不再赘述了。

6、处理任务异常

        所有优秀的程序员都擅长高效地处理异常,这也是并行编程最重要的方面之一。任务并行库(TPL)提供了一种高效的设计来处理异常:任务中发生的任何未处理异常都将被延迟,然后传播到使用 Join 方法加入的线程,后者负责观察任务中的异常。

        下面我们通过代码实例来学习:

6.1、处理来自单个任务的异常

        首先,我们需要写一个会出异常的程序:

        /// <summary>
        /// 一个“可能”错误的程序;
        /// 会抛出异常错误
        /// </summary>
        public async static Task ErrorFunction()
        {
            var random = new System.Random();
            int div = random.Next(-2, 2);
            float ret = 1;
            for (int i = 0; i < 10; i++)
            {
                if (div == 0)
                {
                    //这里我们只打印,但是并不中断运行;
                    Debug.LogError("开始除0了!");
                }

                //直接除法,抛出除0的移除
                ret += i / div;
                await Task.Yield();
                div = random.Next(-2, 2);
            }
            Debug.Log($"ErrorFunction 居然成功完成了!结果为:{ret} | {div}");
        }

        之后我们直接运行这段程序,就按照最简单的 Task.Run 来运行。结果很有意思啊:

         发现没有,已经出现除0的警告了,但是并没有跑错误出来,Unity 一点反应没有!这说明在子线程里的异常是不会直接抛给主线程的。

        下面我们换一个写法:

        private void RunWithErrorTask()
        {
            try
            {
                Debug.Log("RunWithErrorTask 开始!");
                var task=Task.Run(TestFunction.ErrorFunction);
                task.Wait();//不用 task.Wait() 则不会抛出异常
            }
            catch (System.Exception ex)
            {
                Debug.LogError(ex.Message);
                Debug.LogError(ex.StackTrace);
                Debug.LogError(ex.InnerException);
            }
        }

        我们调用 task.Wait(),用 try catch 语句进行包裹,结果如下:

         其实没啥好说的,就是因为 task.Wait 调回了主线程,所以能接收到异常。上面2张截图,其实就是为了说明 Exception 的 StackTrace 和 InnerException 的区别:可以看到 StackTrace 是没有行号的,但是 InnerException 是可以定位到具体的方法。

6.2、处理来自多个任务的异常

        类似于 5.3 那种,子任务有多个的情况,异常处理也类似。把 catch 的类型换成 AggregateException 就能拿到所有的异常了。

        这里就不贴代码了,只要一张贴图就能明白所有:

6.3、使用回调函数处理任务异常

        这里指的就是 AggregateException 运行使用回调来处理异常:

            .......
            catch (System.AggregateException ex)
            {
                ex.Handle(exception =>
                {
                    Debug.LogError(exception.InnerException);
                    return true;
                });
            }

        这里就是 Handle 提供一个方法,返回 true 表示此异常已经正确处理,返回 false 则系统会再次抛出此异常。

        这些都是通用的 C# 函数异常处理方法了,就不必要再多说了。

AggregateException.Handle(FuncInvokes a handler on each Exception contained by this AggregateException. icon-default.png?t=N5F7https://learn.microsoft.com/en-us/dotnet/api/system.aggregateexception.handle?view=netstandard-2.1


            本文介绍了取消任务、等待任务、任务异常收集的基本写法、语法介绍等。这些都是平时进行 TPL 编程时常用的 API ,我只是简单介绍一下。后续熟练使用还是要多多练习。

猜你喜欢

转载自blog.csdn.net/cyf649669121/article/details/131237683
今日推荐