《C#深入理解3册》async和await异步编程

15.2 思考异步编程

15.2.1 异步执行的基础

  实际上, C#编译器会对所有await都构建一个后续操作。这个理念表述起来非常简单,显然是为了可读性和开发者的健康。

  实际上基于任务的异步模式要稍有不同。它并不会将后续操作传递给异步操作,而是在异步操作开始时返回一个token,我们可以用这个token在稍后提供后续操作。它表示正在进行的操作,在返回调用代码前可能已经完成,也可能正在处理。token用于表达这样的想法:在这个操作完成之前,不能进行下一步处理。 token的形式通常为TaskTask<TResult>,但这并不是必须的。

在C# 5中,异步方法的执行流通常遵守下列流程。
(1) 执行某些操作。
(2) 开始异步操作,并记住返回的token
(3) 可能会执行其他操作。(在异步操作完成前,往往不能进行任何操作,此时忽略该步骤。)
(4) 等待异步操作完成(通过token)。
(5) 执行其他操作。
(6) 完成。

同步上下文

之前我提到过, UI代码的金科玉律之一是,除非在正确的线程中,否则不要更新用户界面。
在“检查页面长度”的示例中(代码清单15-1) ,我们需要确保await表达式之后的代码在UI线程上的执行。异步函数能够回到正确的线程中,是因为使用了SynchronizationContext类 。 该 类 早 在 .NET 2.0 中 就 已 存 在 , 用 以 供 BackgroundWorker 等 其 他 组 件 使 用 。

SynchronizationContext涵盖了“在适当的线程上”执行委托这一理念。其Post(异步)和Send(同步)消息的方法,与Windows Forms中的Control.BeginInvoke和Control.Invoke异曲同工。

不同的执行环境使用不同的上下文。例如,某个上下文可能会从线程池中取出一个线程并执行给定的行为。除了同步上下文以外还有很多上下文信息,但如果你想知道异步方法是如何在正确的位置上执行的,就要牢记同步上下文。

要了解更多关于SynchronizationContext的信息,请阅读Stephen Cleary在MSDN杂志上关于该话题的文章(http://mng.bz/5cDw) 。如果你是ASP.NET开发者的话,应尤其注意:
ASP.NET上下文会让看上去没问题的代码死锁,轻易地让粗心的开发者掉进陷阱。

15.2.2 异步方法

我们主要感兴趣的是异步方法本身,但也包含了其他方法,这样就能看到它们是如何交互的。
特别是,你一定要了解方法边界处的有效类型。

15.3 语法和语义

15.3.1 声明异步方法

异步方法的声明语法与其他方法完全一样,只是要包含async上下文关键字。async可以出现在返回类型之前的任何位置。以下这些都是有效的:

public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }

async上下文关键字有一个不为人知的秘密:对语言设计者来说,方法签名中有没有该关键字都无所谓。3

async修饰符在生成的代码中没有作用,这个事实是非常重要的。对调用方法来说,它只是一个可能会返回任务的普通方法。你可以将一个(具有适当签名的)已有方法改成使用async,反之亦然。对于源代码和二进制来说,这都是一个兼容的转换。

15.3.2 异步方法的返回类型

  调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:

  • void;
  • Task;
  • Task<TResult>(某些类型的TResult,其自身即可为类型参数)。

  1.在某种意义上,你可以认为Task就是Task<void>类型,如果这么写合法的话。

  2.对于一个异步方法,只有在作为事件订阅者时才应该返回void。在其他不需要特定返回值的情况下,最好将方法声明为返回Task。这样,调用者可以等待操作完成,以及探测失败情况等。

  3.还有一个关于异步方法签名的约束:所有参数都不能使用out或ref修饰符。因为这些修饰符是用于将通信信息返回给调用代码的;而且在控制返回给调用者时,某些异步方法可能还没有开始执行,因此引用参数可能还没有赋值。

15.3.3 可等待模式

  await表达式非常简单,只是在其他表达式前面加了一个await。当然,对于能等待的东西是有限制的。需要提醒的是,我们正在谈论图15-1的第二个边界,即异步方法如何与其他异步操作交互。一般来说,我们只能等待(await)一个异步操作。换句话说,是包含以下含义的操作:
 告知是否已经完成;
 如未完成可附加后续操作;
 获取结果,该结果可能为返回值,但至少可以指明成功或失败。

15.3.6 异常

1.在等待时拆包异常

在等待时拆包异常awaiterGetResult方法可获取返回值(如果存在的话) ;同样地,如果存在异常,它还负责将异常从异步操作传递回方法中。听上去简单做起来难,因为在异步世界里,单个Task可表示多个操作,并导致多个失败。尽管还存在其他的可等待模式实现,但有必要专门介绍Task,因为在大多数情况下,我们等待的都是这个类型。

Task有多种方式可以表示异常

  • 当异步操作失败时,任务的Status变为Faulted(并且IsFaulted返回true)。

  • Exception属性返回一个AggregateException,该AggregateException包含所有(可能有多个)造成任务失败的异常;如果任务没有错误,则返回null。

  • 如果任务的最终状态为错误,则Wait()方法将抛出一个AggregateException。

  • Task<T>Result属性(同样等待完成)也将抛出AggregateException。

取消操作

  此外,任务还支持取消操作,可通过CancellationTokenSourceCancellationToken来实现这一点。如果任务取消了,Wait()方法和Result属性都将抛出包含OperationCanceledException的AggregateException(实际上是一个TaskCanceledException,它继承自OperationCanceledException),但状态将变为Canceled,而不是Faulted

抛出第一个异常

  在等待任务时,任务出错或取消都将抛出异常,但并不是AggregateException。大多情况下为方便起见,抛出的是AggregateException中的第一个异常,往往这就是我们想要的。
  异步特性就是像编写同步代码那样编写异步代码,如下所示:

async Task<string> FetchFirstSuccessfulAsync(IEnumerable urls)
{
    // TODO:验证是否获取到了URL
    foreach (string url in urls)
    {
        try
        {
            using (var client = new HttpClient())
            {
               return await client.GetStringAsync(url);
            }
        }
        catch (WebException exception)
        {
             // TODO:记录日志、更新统计信息等
        }
    }
    throw new WebException("No URLs succeeded");
}

  但GetStringAsync()方法不能为服务器超时等错误抛出WebException,因为方法仅仅启动了操作。它只能返回一个包含WebException的任务 。 如 果 简 单 地 调 用 该 任 务 的 Wait() 方 法 , 将 会 抛 出 一 个 包 含 WebException 的AggregateException。任务awaiter的GetResult方法将抛出WebException,并被以上代码所捕获。

  当然,这样会丢失信息。如果错误的任务中包含多个异常,则GetResult只能抛出其中的一个异常(即第一个)。你可能需要重写以上代码,这样在发生错误时,调用者就可捕获AggregateException并检查所有失败的原因。重要的是,一些框架方法(如Task.WhenAll())也可以实现这一点。 WhenAll()方法可异步等待(方法调用中指定的)多个任务的完成。如果其中有失败的,则结果即为失败,并包含所有错误任务中的异常。但如果只是等待(await)WhenAll()返回的任务,则只能看到第一个异常。

  幸好,要解决这个问题并不需要太多的工作。我们可以使用可等待模式的知识,编写一个Task的扩展方法,从而创建一个可从任务中抛出原始AggregateException的特殊可等待模式成员。

public static partial class TaskExtensions
    {
        public static AggregatedExceptionAwaitable WithAggregatedExceptions(this Task task)
        {
            if (task == null)
            {
                throw new ArgumentNullException("task");
            }

            return new AggregatedExceptionAwaitable(task);
        }

        public struct AggregatedExceptionAwaitable
        {
            private readonly Task task;

            internal AggregatedExceptionAwaitable(Task task)
            {
                this.task = task;
            }

            public AggregatedExceptionAwaiter GetAwaiter()
            {
                return new AggregatedExceptionAwaiter(task);
            }
        }

        public struct AggregatedExceptionAwaiter : ICriticalNotifyCompletion
        {
            private readonly Task task;

            internal AggregatedExceptionAwaiter(Task task)
            {
                this.task = task;
            }

            // Delegate most members to the task's awaiter
            public bool IsCompleted { get { return task.GetAwaiter().IsCompleted; } }

            public void UnsafeOnCompleted(Action continuation)
            {
 	            task.GetAwaiter().UnsafeOnCompleted(continuation);		//❶ 委托给任务awaiter
            }

            public void OnCompleted(Action continuation)
            {
                task.GetAwaiter().OnCompleted(continuation);		//❶ 委托给任务awaiter
            }

            public void GetResult()
            {
                // This will throw AggregateException directly on failure,
                // unlike task.GetAwaiter().GetResult()
                task.Wait();		//❷ 发生错误时,直接抛出AggregateException
            }
        }
    }

    class AggregatedExceptions
    {
        static void Main()
        {
            MainAsync().Wait();
            Console.ReadKey();
        }

        private async static Task MainAsync()
        {
            Task task1 = Task.Run(() => { throw new Exception("Message 1"); });
            Task task2 = Task.Run(() => { throw new Exception("Message 2"); });

            try
            {
                await Task.WhenAll(task1, task2);
            }

            catch (Exception e)
            {
                Console.WriteLine("Caught {0}", e.Message);
            }

            try
            {
                await Task.WhenAll(task1, task2).WithAggregatedExceptions();
            }
            catch (AggregateException e)
            {
                Console.WriteLine("Caught {0} exceptions: {1}", e.InnerExceptions.Count,
                                  string.Join(", ", e.InnerExceptions.Select(x => x.Message)));
            }
        }
    }

//output:
//Caught Message 1
//Caught 2 exceptions: Message 1, Message 2

  Task<T>也需要一个类似的方法,即在GetResult()中使用return task.Result,而不是调用Wait()。重点在于,我们把自己不想处理的部分委托给了任务的awaiter❶ ,而回避了GetResult()的常规行为,即对异常进行拆包。在调用GetResult时,我们知道该任务处于即将结束的状态,因此Wait()调用❷可立即返回,这并不妨碍我们要实现的异步性。

  WithAggregateException()返回自定义的可等待模式成员,而后者的GetAwaiter()又提供自定义的awaiter,并支持C#编译器所需要的操作来等待结果。注意,也可将可等待模式成员和awaiter合并,并没有要求二者必须是不同类型,但分开的话会感觉更清晰一些。

2.在抛出异常时进行包装

异步方法在调用时永远不会直接抛出异常。

异常方法会返回TaskTask<T>,方法内抛出的任何异常(包括从其他同步或异步操作中传播过来的异常)都将简单地传递给任务,就像前面介绍的那样。如果调用者直接等待①任务,则可得到一个包含真正异常的AggregateException;但如果调用者使用await,异常则会从任务中解包。返回void的异步方法可向原始的SynchronizationContext报告异常,如何处理将取决于上下文②。

//以熟悉的方式处理异步的异常

static async Task MainAsync()
{
    Task<string> task = ReadFileAsync("garbage file");   //❶ 开始异步读取
    try
    {
	    //任务中解包
        string text = await task;     //❷ 等待内容
            Console.WriteLine("File contents: {0}", text);
    }
    catch (IOException e)    //❸ 处理IO失败
    {
        Console.WriteLine("Caught IOException: {0}", e.Message);
    }
}

static async Task<string> ReadFileAsync(string filename)
{
    using (var reader = File.OpenText(filename))    //❹ 同步打开文件
    {
        return await reader.ReadToEndAsync();
    }
}

调用File.OpenText时可抛出一个IOException❹ (除非创建了一个名为“ garbage file”的文件), 但如果ReadToEndAsync返回的任务失败了,也会出现同样的执行路径。在MainAsync中, ReadFileAsync的调用❶ 发生在进入try块之前,但只有在等待任务时 ❷,调用者才能看到异常并在catch块中捕获 ❸,就像前面的WebException示例一样。同样,除异常发生的时机以外,其行为我们也非常熟悉。

来看AggregateException的情况:

//
		static void Main()
        {
            MainAsync().Wait();
        }

        static async Task MainAsync()
        {
            Task<string> task = ReadFileAsync("garbage file");
            try
            {
                // task.Wait() 或者 task.Result 
                string text =  task.Result;
                Console.WriteLine("File contents: {0}", text);
            }
            catch (AggregateException e)
            {
                 Console.WriteLine("Caught {0} exceptions: {1}", e.InnerExceptions.Count,
                                  string.Join(", ", e.InnerExceptions.Select(x => x.Message)));	//执行这里
            }
            catch (IOException e)
            {
                Console.WriteLine("Caught IOException: {0}", e.Message);
            }

        }

        static async Task<string> ReadFileAsync(string filename)
        {
            using (var reader = File.OpenText(filename))
            {
                return await reader.ReadToEndAsync();
            }
        }

//Caught 1 exceptions: 未能找到文件“E:\OtherChapters\Chapter15\bin\Debug\garbage file”。

  迭代器块类似,参数验证会有些麻烦。假设我们在验证完参数不含有空值后,想在异步方法里做一些处理。如果像在同步代码中那样验证参数,那么在等待任务之前,调用者不会得到任何错误提示。

//异步方法中失效的参数验证

static async Task MainAsync()
{
    Task<int> task = ComputeLengthAsync(null);  //故意传入错误的参数
    Console.WriteLine("Fetched the task");
    int length = await task;   //❶ 等待结果
    Console.WriteLine("Length: {0}", length);
}
static async Task<int> ComputeLengthAsync(string text)
{
	if (text == null)
    {
        throw new ArgumentNullException("text");   // ❷  立即抛出异常
    }
    await Task.Delay(500);  //模拟真实的异步工作
    return text.Length;
}

  实际上,在输出这条结果之前,异常就已经同步地抛出了,这是因为在验证语句之前并不存在await表达式 。但调用代码直到等待返回的任务时 ,才能看到这个异常。

在C# 5中,有两种方式可以迫使异常立即抛出。

//将参数验证从异步实现中分离出来

static Task<int> ComputeLengthAsync(string text)
{
    if (text == null)
    {
        throw new ArgumentNullException("text");
    }
    return ComputeLengthAsyncImpl(text);
}
static async Task<int> ComputeLengthAsyncImpl(string text)
{
    await Task.Delay(500); // 模拟真正的异步工作
    return text.Length;
}

3.取消处理

  任务并行库(TPL)利用CancellationTokenSourceCancellationToken两种类型向.NET 4中引入了一套统一的取消模型。该模型的理念是,创建一个CancellationTokenSource,然后向其请求一个CancellationToken,并传递给异步操作。可在source上只执行取消操作,但该操作会反映到token上。(这意味着你可以向多个操作传递相同的token,而不用担心它们之间会相互干扰。)取消token有很多种方式,最常用的是调用ThrowIfCancellationRequested,如果取消了token,并且没有其他操作,则会抛出OperationCanceledException。如果在同步调用(如Task.Wait)中执行了取消操作,则可抛出同样的异常。

//通过抛出OperationCanceledException来创建一个取消的任务

		static void Main()
        {
            Task task = ThrowCancellationException();
            Console.WriteLine(task.Status);
        }

        static async Task ThrowCancellationException()
        {
            throw new OperationCanceledException();
        }

//output:
//Canceled

  这段代码的输出为Canceld,而不是Faulted。如果在任务上执行Wait(),或请求其结果(针对Task<T>) ,则AggregateException内还是会抛出异常,所以没有必要在每次使用任务时都显式检查是否有取消操作。

  重要的是,等待一个取消了的操作,将抛出原始的OperationCanceledException。这意味着如果不采取一些直接的行动,从异步方法返回的任务同样会被取消,因为取消操作具有可传播性。

//通过一个取消的延迟操作来取消异步方法
		static void Main()
        {
            var source = new CancellationTokenSource();
            var task = DelayFor30Seconds(source.Token);
            source.CancelAfter(TimeSpan.FromSeconds(1));
            Console.WriteLine("Initial status: {0}", task.Status);
            try
            {
                task.Wait();
            }
            catch (AggregateException e)
            {
                Console.WriteLine("Caught {0}", e.InnerExceptions[0]);
            }
            Console.WriteLine("Final status: {0}", task.Status);
        }

        static async Task DelayFor30Seconds(CancellationToken token)
        {
            Console.WriteLine("Waiting for 30 seconds...");
            await Task.Delay(TimeSpan.FromSeconds(30), token);
        }

//Waiting for 30 seconds...
//Initial status: WaitingForActivation
//Caught System.Threading.Tasks.TaskCanceledException: 已取消一个任务。
//Final status: Canceled

  代码中启动了一个异步操作 ,该操作调用Task.Delay模拟真正的工作 ,并提供了一个CancellationToken。这一次,我们的确涉及了多个线程:到达await表达式时,控制返回到调用方法,这时要求CancellationToken在1秒后取消 。然后(同步地)等待任务完成 ,并期望在最终得到一个异常。最后展示任务的状态。

  可认为取消操作默认是可传递的:如果A操作等待B操作,而B操作被取消了,那么我们认为A操作也被取消了。

  当然,你不必这么做。你可以在DelayFor30Seconds方法中捕获OperationCanceledException,然后或继续做其他事情,或立即返回,或干脆抛出一个其他类型的异常。异步特性不会移除控制,它只是提供了一种有用的默认行为而已。

posted @ 2021-05-02 22:09  【唐】三三  阅读(207)  评论(0编辑  收藏  举报