浅谈.Net异步编程的前世今生----异步函数篇(完结)

.NET
321
0
0
2023-01-07

前言

上一篇我们着重讲解了TPL任务并行库,可以看出TPL已经很符合现代API的特性:简洁易用。但它的不足之处在于,使用者难以理解程序的实际执行顺序。

为了解决这些问题,在C# 5.0中,引入了新的语言特性,被称为异步函数(asynchronous function)。对应的.Net版本为.Net Framework 4.5。

最后一个异步编程模型:异步函数

概述

由于异步函数为语言特性的实现,因此它的本质依然属于TPL模型,但提供了更高级别的抽象,真正简化了异步编程。抽象可以隐藏主要的实现细节,使得开发人员无需考虑许多重要的事情,从而达到简化的效果。

在本文中,我们主要会讲解异步函数的声明和使用方式,以及在多种场景下使用异步函数,处理异常等。

声明异步函数

声明异步函数的方法很简单,只需使用async关键字标注任意一个方法即可。需要注意的是,如果只使用了async标注方法,而方法内部未使用await,会导致编译警告,如图所示:

img

另一个重要的事实是,异步函数必须返回Task或Task<T>类型。也可使用async void,但不推荐,若使用async void方式, 异常处理及跟踪将不由TPL模型处理,而是会直接在SynchronizationContext上引发,这样会引起整个进程的崩溃。因此通常会在UI层处理事件时,才会使用async void方式。

改写后相关代码示例如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace asyncDemo
{
    public class Utils
    {
        public async Task<string> GetStringAsync()
        {
            await Task.Delay(TimeSpan.FromSeconds(2));
            return "Hello World!";
        }
    }
}

这里我们执行完await调用的代码行后,会立即返回,而不是阻塞两秒,如果是同步执行则结果相反。当执行完await操作后,TPL会立即将工作线程放回线程池,我们的程序会进行异步等待。直到2秒后,我们又一次从线程池中得到工作线程,并继续运行其中剩余的异步方法。这样就允许我们在等待2秒时,可以重用工作线程来做其他事,提升了应用程序的可伸缩性。

事实上,异步函数在编译器后台会被编译成复杂的程序结构,一般称之为迭代器。迭代器的内部是一种状态机,由于状态机的概念理解较为复杂,因此这里不再赘述。所以我们在日常编写代码时,并不需要将每一个方法都标记为async,尤其是并不需要使用异步的方法。通过上述概念可知,滥用async会导致编译器编译时生成大量的迭代器,会有显著的性能损失。

获取异步任务结果

既然我们已经了解了async-await本质上依然为TPL模型,那么在使用TPL和await操作符获取异步结果中有什么不同呢?此处我们可以通过实验来探究。

如图所示,我们分别使用Task和await执行:

img

二者都调用了同一个异步函数打印当前线程的Id和状态。

在第一个中启动了一个任务,运行2秒后返回关于工作线程的信息。我们还定义了一个后续操作,用于在异步操作完成后,打印出操作结果;另一个后续操作用于有错误发生时,打印异常信息。最终返回一个代表其中一个后续操作任务的任务,并在Main中等待其执行完成。

而在第二个中,我们直接使用await对任务进行操作,获取异步执行的结果,同时使用try-catch代码块来捕获可能发生的异常,这和我们编写同步方法的代码风格是一致的,简化了程序编写的复杂度。实际上在await之后编译器创建了一个任务及后续操作,并处理了可能发生的异常信息。

相关代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace asyncDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Task t = AsyncTPL();
            t.Wait();

            t = AsyncAwait();
            t.Wait();

            Console.Read();
        }

        static Task AsyncTPL()
        {
            Task<string> t = GetInfoAsync("任务1");
            Task t2 = t.ContinueWith(x => Console.WriteLine(t.Result), TaskContinuationOptions.NotOnFaulted);
            Task t3 = t.ContinueWith(x => Console.WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted);

            return Task.WhenAny(t2, t3);
        }

        async static Task AsyncAwait()
        {
            try
            {
                string result = await GetInfoAsync("任务2");
                Console.WriteLine(result);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }

        async static Task<string> GetInfoAsync(string name)
        {
            await Task.Delay(TimeSpan.FromSeconds(2));

            return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +
                $"{Thread.CurrentThread.IsThreadPoolThread}";
        }
    }
}

运行后,如图所示:

img

从结果中我们可以看出,两种操作的方式在概念上是等同的,但是第二种方式中编译器隐式处理了异步相关的代码,背后的逻辑更为复杂,我们在后续小节中会借助示例再详细说明这些内容。

多个连续的await

我们已经得知了使用await的代码行将会异步执行,那么如果我们在同一个async方法中使用多个连续的await,它们会并行异步执行吗?我们不妨一试。

如图所示,我们依然定义TPL和Async函数进行对比:

img

我们在定义AsyncAwait方法时,依然使用同步代码的方式进行书写,唯一的不同之处是连续使用了两个await声明。

而在TPL方法中,则使用了一个容器任务,来处理所有相互依赖的任务。然后启动主任务,并为其添加一系列的后续操作。当该任务完成时,会打印出其结果,然后再启动第二个任务,并抛出一个异常,打印出异常信息。

相关代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace asyncDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Task t = AsyncTPL();
            t.Wait();

            t = AsyncAwait();
            t.Wait();

            Console.Read();
        }

        static Task AsyncTPL()
        {
            var continueTask = new Task(() =>
            {
                Task<string> t = GetInfoAsync("TPL1");
                t.ContinueWith(task =>
                {
                    Console.WriteLine(t.Result);
                    Task<string> t2 = GetInfoAsync("TPL2");
                    t2.ContinueWith(innerTask =>
                    Console.WriteLine(innerTask.Result),
                    TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);
                    t2.ContinueWith(innerTask =>
                    Console.WriteLine(innerTask.Exception.InnerException),
                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);
                },
                TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);
                t.ContinueWith(task =>
                Console.WriteLine(t.Exception.InnerException),
                TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);
            });

            continueTask.Start();
            return continueTask;
        }

        async static Task AsyncAwait()
        {
            try
            {
                string result = await GetInfoAsync("Async1");
                Console.WriteLine(result);
                result = await GetInfoAsync("Async2");
                Console.WriteLine(result);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }

        async static Task<string> GetInfoAsync(string name)
        {
            Console.WriteLine($"{name} 开始执行!");
            await Task.Delay(TimeSpan.FromSeconds(2));
            if (name == "TPL2")
            {
                throw new Exception("发生异常!");
            }
            return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +
                $"{Thread.CurrentThread.IsThreadPoolThread}";
        }
    }
}

运行后,执行结果如图所示:

img

我们从结果中可以看出,TPL的后续依赖任务会按照我们的书写顺序依次执行,让人讶异的是await,它并没有并行执行,而也是顺序执行的。Async2任务只有等Async1任务完成后才会开始执行,但它为什么是异步程序呢?

事实上,它并不总是异步的,当使用await时,如果一个任务已经完成,我们会异步地得到相应的任务结果。否则,在看到await声明时,通常的行为是方法执行到await代码行应立即返回,且剩下的代码会在一个后续操作任务中执行。因此等待操作结果时,并没有阻塞程序执行,这是一个异步调用。当AsyncAwait方法中的代码在执行时,除了可以在Main中执行t.Wait外,我们可以执行其他任何任务。但主线程必须等待直到所有异步操作完成,否则主线程完成后会停止所有异步操作的后台线程。

这两段代码中,如果要比较TPL和await,那么则是TPL方法的书写更容易阅读和理解,调用层次更为清晰,请记住一点,异步并不总是意味着并行执行。

并行执行的await

现在我们已经得知了,异步并不总是并行的,那么它能不能通过某种手段或方式进行并行操作呢?答案是可以的,我们一起看一下如何实现:

img

这里我们定义了2个不同的Task分别运行3秒和5秒,然后使用Task.WhenAll来创建另一个任务,该任务只有在所有底层任务完成后才会执行,之后我们等待所有任务的结果。

相关实现代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace asyncDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Task t = AsyncProcessing();
            t.Wait();

            Console.Read();
        }

        async static Task AsyncProcessing()
        {
            Task<string> t1 = GetInfoAsync("任务1", 3);
            Task<string> t2 = GetInfoAsync("任务2", 5);

            string[] results = await Task.WhenAll(t1, t2);
            foreach (string result in results)
            {
                Console.WriteLine(result);
            }

        }

        async static Task<string> GetInfoAsync(string name, int seconds)
        {
            await Task.Delay(TimeSpan.FromSeconds(seconds));
            //await Task.Run(() => Thread.Sleep(TimeSpan.FromSeconds(seconds))); 
            return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +
               $"{Thread.CurrentThread.IsThreadPoolThread}";
        }
    }
}

运行后,结果如图所示:

img

根据程序运行的结果我们可以看到,5秒之后,我们获取到了所有的结果,说明这些任务是同时运行的。这里还有一个有趣的现象是,两个任务是被同一个线程池中的工作线程执行的,为什么会这样呢?这时候我们可以注释掉Task.Delay这行代码,并取消对Task.Run的注释,再次运行后,结果如图所示:

img

此时我们会发现,两个任务会被不同的工作线程执行。

造成这种情况的原因是Task.Delay在幕后使用了一个计时器,它的执行过程如下:

1、从线程池中获取工作线程,它将等待Task.Delay返回结果;

2、Task.Delay方法启动计时器,并指定一块代码,该代码会在计时器到了Task.Delay中指定的时间后进行调用,之后立即将工作线程返回线程池中;

3、当计时器事件运行时(类似于Timer类),我们会再次从线程池中获取一个可用的工作线程并运行计时器给它的代码(可能会是我们之前使用过的工作线程)。

而Task.Run方法则不同,它的执行过程如下:

1、从线程池中获取工作线程,并将其阻塞几秒钟;

2、获取第二个工作线程,也将其阻塞几秒钟。

在此过程中,两个工作线程并无法做其他事,只能进行等待操作,因此在某种程度上,这两个工作线程是被浪费掉了。

所以我们在实际使用时,尽量使用Task.Delay的方式进行并行操作,而不是使用Task.Run。

处理异常

在异步函数中,处理异常可以像同步代码那样使用try-catch去处理,但是在不同的场景下,也有不同的使用方式,下面我们一起来看看有哪些常见的使用场景,如图所示:

img

我们分别定义了三种场景:单个异常、多个异常及多个异常的异常集合。相关实现代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace asyncDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Task t = AsyncProcessing();
            t.Wait();

            Console.Read();
        }

        async static Task AsyncProcessing()
        {
            Console.WriteLine("1、单个异常");
            try
            {
                string result = await GetInfoAsync("任务1", 2);
                Console.WriteLine(result);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"异常内容:{ex}");
            }

            Console.WriteLine("-----------------------------------------------------");
            Console.WriteLine("2、多个异常");

            Task<string> t1 = GetInfoAsync("任务1", 3);
            Task<string> t2 = GetInfoAsync("任务2", 2);
            try
            {
                string[] results = await Task.WhenAll(t1, t2);
                Console.WriteLine(results.Length);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"异常内容:{ex}");
            }
            Console.WriteLine("-----------------------------------------------------");
            Console.WriteLine("3、多个异常的异常集合");
            t1 = GetInfoAsync("任务1", 3);
            t2 = GetInfoAsync("任务2", 2);
            Task<string[]> t3 = Task.WhenAll(t1, t2);
            try
            {
                string[] results = await t3;
                Console.WriteLine(results.Length);
            }
            catch
            {
                var ae = t3.Exception.Flatten();
                var exceptions = ae.InnerExceptions;
                Console.WriteLine($"异常发生数量:{exceptions.Count}");
            }
        }

        async static Task<string> GetInfoAsync(string name, int seconds)
        {
            await Task.Delay(TimeSpan.FromSeconds(seconds));
            throw new Exception($"异常来自于:{name}");
        }
    }
}

执行后的结果如图所示:

img

从执行结果我们可以看出,如果在可能发生多个异常的场景下,仍直接使用try-catch的方式处理异常,那么只能从底层的AggregateException中获取到第一个异常。

为了得到所有的异常信息,我们需要使用await任务的Exception属性。在第三种场景中,我们使用了AggregateException的Flatten方法,将层级异常放入一个列表,从而达到获取所有异常的效果,在实际使用时应多加注意。

小结

至此为止,关于异步函数的特性及使用方式就已经介绍完毕。通过异步模型的发展历程我们可以看出,为了应对不同时期的需求,异步模型也经历了由复杂到简单的过程。最终我们使用的异步函数模式,可以使得程序在编写代码时,能用编写同步代码的方式来实现异步,大大降低了复杂度,也提升了代码可读性。由于该思想和语法相当简洁,在其他语言中也借鉴了类似的语法,如JavaScript在ES6标准中也引入了async-await的写法来实现异步,避免了多个回调嵌套的尴尬方式。

但关于async-await本身,C#编译器在背后通过及其复杂的原理为我们屏蔽了底层的细节,包括为何不能使用async void等等,这些原理还是建议大家有时间的话进行一些挖掘和探究,学习背后的设计思想,会对我们的程序设计思维大有裨益。

.Net异步编程系列的文章,到此也暂时告一段落了。我个人在后面的日子中也会将主要精力投入到架构设计和微服务等前沿技术中,同时会总结一些个人的心得与体会形成其他系列的分享,请大家拭目以待。也感谢所有阅读此系列文章的读者,感谢大家的反馈,陪伴我度过一段难忘的时光,我们下一期再会!

参考

  1. 1.避免 Async Void https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming