• [翻译]用一个用户场景来掌握它们


    翻译自一篇博文,原文:One user scenario to rule them all

    异步系列

    • 剖析C#中的异步方法
    • 扩展C#中的异步方法
    • C#中异步方法的性能特点。
    • 用一个用户场景来掌握它们

    c#中异步方法的几乎所有重要行为都可以基于一个用户场景进行解释:尽可能简单地将现有的同步代码迁移到异步。你应该能在方法的返回类型前面加上async关键字,在方法名最后加上Async后缀,在方法内部加上一些await关键字,就能得到一个功能完整的异步方法。

    ![](D:OneDrivelog postspicsOne_Scenario_Figure_11.png)

    这个“简单”场景以许多不同的方式极大地影响异步方法的行为:从调度任务的延续到异常处理。这个场景听起来似乎很合理,也很重要,但它使异步方法背后的简单性变得非常具有欺骗性。

    同步上下文(synchronization context)

    UI开发是上面提到的场景特别重要的领域之一。UI线程中的耗时较长的操作使应用程序无法响应,而异步编程一直被认为是一个很好的解决方法。

    private async void buttonOk_ClickAsync(object sender, EventArgs args)
    {
        textBox.Text = "Running.."; // 1 -- UI Thread
        var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread
        textBox.Text = "Result is: " + result; //3 -- Should be UI Thread
    }
    

    这段代码看起来十分简单,但我们现在有一个问题。大多数UI框架都有只有专门的UI线程可以改变UI元素的限制。这意味着如果第三行代码是在线程池上的线程被调度的任务延续,它将失败。幸运的是,这个问题相对较老,从.NET Framework 2.0开始,就引入了同步上下文的概念。

    每一个UI框架都为将代码在专用UI线程上执行提供了特殊的实用工具。Windows Forms依靠Control.Invoke,WPF依靠Dispatcher.Invoke,而其他UI框架可能依靠其他什么东西。这个概念在所有的情况下都是相似的,但是底层的细节是不同的。同步上下文把差异抽象掉,并提供一个API用于在“特殊”的上下文中执行代码,将细节留给派生类,如WindowsFormsSynchronizationContextDispatcherSynchronizationContext

    为了解决线程关联问题,C#语言作者决定在异步方法的开头捕获当前同步上下文,并将所有延续调度到所捕获的上下文中。现在,await语句之间的每个代码块都在UI线程中执行,这使得主场景成为可能。但解决方案也带来了一系列其他挑战。

    死锁

    让我们来审核一段相对较简单的代码。你能看出其中的问题吗?

    // UI code
    private void buttonOk_Click(object sender, EventArgs args)
    {
        textBox.Text = "Running..";
        var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
        textBox.Text = "Result is: " + result;
    }
     
    // StockPrices.dll
    public Task<decimal> GetStockPricesForAsync(string symbol)
    {
        await Task.Yield();
        return 42;
    }
    

    这段代码会造成死锁。UI线程调用了一个异步方法,并且同步地等待它的结果。但是那个异步方法却不能完成,因为它的第二行必须在UI线程下执行,从而造成死锁

    你可能会说,这个问题比较容易发现,我同意你的观点。在UI代码中,任何对Task.ResultTask.Wait的调用都应该被禁止。但是如果UI代码依赖的组件仍然同步地等待一个异步操作的结果,那么问题依然是可能存在的:

    // UI code
    private void buttonOk_Click(object sender, EventArgs args)
    {
        textBox.Text = "Running..";
        var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
        textBox.Text = "Result is: " + result;
    }
     
    // StockPrices.dll
    public Task<decimal> GetStockPricesForAsync(string symbol)
    {
        // We know that the initialization step is very fast,
        // and completes synchronously in most cases,
        // let's wait for the result synchronously for "performance reasons".
        InitializeIfNeededAsync().Wait();
        return Task.FromResult((decimal)42);
    }
     
    // StockPrices.dll
    private async Task InitializeIfNeededAsync() => await Task.Delay(1);
    

    这段代码也会导致死锁。现在,C#中两个“众所周知的”异步编程最佳实践应该让你更明白了:

    • 不要通过Task.Wait()Task.Result阻塞异步代码。
    • 在类库代码中使用ConfigureAwait(false)

    上述第一条建议已经明了,现在我们解释另一条。

    Configure "awaits"

    上一个例子中有两个造成死锁的原因:在GetStockPricesForAsync中Task.Wait()的调用是阻塞的,以及在InitializeIfNeededAsync中对任务延续的调度隐式地捕获了同步上下文。尽管C#作者不鼓励在异步方法中使用阻塞调用,但在很多情况下这种情况可能会发生。为了解决死锁问题,C#语言作者提出了解决方案:Task.ConfigureAwait(continueOnCapturedContext:false)

    public Task<decimal> GetStockPricesForAsync(string symbol)
    {
        InitializeIfNeededAsync().Wait();
        return Task.FromResult((decimal)42);
    }
     
    private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false);
    

    如此一来,Task.Delay(1)的任务延续(在这个例子中也就是空语句)是在一个线程池的线程中被调度的,而不是在UI线程中,于是解决了死锁问题。

    分离(detach)同步上下文

    我知道ConfigureAwait是解决这个问题的实际办法,但我发现它有一个很大的问题。这里有一个小例子:

    public Task<decimal> GetStockPricesForAsync(string symbol)
    {
        InitializeIfNeededAsync().Wait();
        return Task.FromResult((decimal)42);
    }
     
    private async Task InitializeIfNeededAsync()
    {
        // Initialize the cache field first
        await _cache.InitializeAsync().ConfigureAwait(false);
        // Do some work
        await Task.Delay(1);
    }
    

    你能看出其中的问题吗?我们已经使用了ConfigureAwait(false)所以一切都应该正常,但是并不一定。

    ConfigureAwait(false)返回一个叫ConfiguredTaskAwaitable的自定义awaiter,并且我们已经知道:awaiter只有在任务没有同步地完成的情况下才会被使用。也就是说如果_cache.InitializeAsync()是同步执行完毕的,那么我们依然可能面临死锁。

    为了解决死锁问题,每一个被await的task都应该被一个ConfigureAwait(false)调用所“装饰”。这是很繁琐并且很容易出错的。

    另一个解决方案是:在每一个public方法中都使用一个自定义awaiter来将同步上下文从异步方法中分离:

    private void buttonOk_Click(object sender, EventArgs args)
    {
        textBox.Text = "Running..";
        var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
        textBox.Text = "Result is: " + result;
    }
     
    // StockPrices.dll
    public async Task<decimal> GetStockPricesForAsync(string symbol)
    {
        // The rest of the method is guarantee won't have a current sync context.
        await Awaiters.DetachCurrentSyncContext();
     
        // We can wait synchronously here and we won't have a deadlock.
        InitializeIfNeededAsync().Wait();
        return 42;
    }
    

    Awaiters.DetachCurrentSyncContext返回下面的自定义awaiter:

    public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion
    {
        /// <summary>
        /// Returns true if a current synchronization context is null.
        /// It means that the continuation is called only when a current context
        /// is presented.
        /// </summary>
        public bool IsCompleted => SynchronizationContext.Current == null;
     
        public void OnCompleted(Action continuation)
        {
            ThreadPool.QueueUserWorkItem(state => continuation());
        }
     
        public void UnsafeOnCompleted(Action continuation)
        {
            ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null);
        }
     
        public void GetResult() { }
     
        public DetachSynchronizationContextAwaiter GetAwaiter() => this;
    }
     
    public static class Awaiters
    {
        public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext()
        {
            return new DetachSynchronizationContextAwaiter();
        }
    }
    

    DetachSynchronizationContextAwaiter做了以下几点:如果异步方法是在一个非null的同步上下文中被调用的,这个awaiter会探测到这一点并且将延续调度给一个线程池线程。但如果异步方法的调用没有任何同步上下文,那么IsCompleted属性返回true,并且任务延续将同步地执行。

    这意味着,如果异步方法是被线程池中的线程调用的,那么开销接近于0,如果是从UI线程中被调用的,那么你只需要付出这一次,就能从UI线程转移到线程池线程。

    这种方法的好处:

    • 更不容易出错。只有在所有被await的task被ConfigureAwait(false)所装饰时,ConfigureAwait(false)才有效。如果你不小心忘了一个,死锁就有可能发生。而用上述的自定义awaiter方法,你只需要记住一件事:所有你类库中的public方法的开头都应该先调用Awaiters.DetachCurrentSyncContext()。虽然仍有可能出错,但概率更低了。
    • 代码更具声明性,且更简洁。在我看来,一个有好几个ConfigureAwait调用的方法更难阅读,对于一个新人来说可理解性也更低。

    异常处理

    下面两种情况有什么不同:

    Task mayFail = Task.FromException(new ArgumentNullException());
     
    // Case 1
    try { await mayFail; }
    catch (ArgumentException e)
    {
        // Handle the error
    }
     
    // Case 2
    try { mayFail.Wait(); }
    catch (ArgumentException e)
    {
        // Handle the error
    }
    

    第一种情况完全符合你的预期——处理错误,但是第二种情况并不会。TPL是为异步和并行编程设计的,而Task/Task<T>可以代表多个操作的结果。这就是为什么Task.ResultTask.Wait()总是会抛出一个可能包含多个错误的AggregateException

    但是我们的主场景改变了一切:用户应该能够添加async/await而无需更改错误处理逻辑。这也就意味着await语句应该与Task.Result/Task.Wait()不同:它应该从AggregateException实例中“unwrap”一个异常出来,今天它选择了第一个。

    如果所有基于task的方法都是异步,并且这些task不是基于并行计算,那么一切就没问题。但是事实并非总是如此:

    try
    {
        Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
     
        Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
     
        // await will rethrow the first exception
        await Task.WhenAll(task1, task2);
    }
    catch (Exception e)
    {
        // ArgumentNullException. The second error is lost!
        Console.WriteLine(e.GetType());
    }
    

    Task.WhenAll返回一个代表了两个错误的失败任务,但是await语句只会抽取其中第一个错误,然后抛出。

    有两种方法解决这个问题:

    1. 如果你有访问这些任务的权限,可以手动观察它们。
    2. 强制TPL将异常报装进另一个AggregateException中。
    try
    {
        Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
     
        Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
     
        // t.Result forces TPL to wrap the exception into AggregateException
        await Task.WhenAll(task1, task2).ContinueWith(t => t.Result);
    }
    catch(Exception e)
    {
        // AggregateException
        Console.WriteLine(e.GetType());
    }
    

    async void方法

    基于任务的方法返回一个承诺(promise)——一个可以用于在将来处理结果的令牌(token)。如果这个任务对象丢失,用户的代码将就无法观察到该承诺。返回void的异步操作就使得用户代码不可能处理错误情况。这就使它们变得有点儿没什么用,而且危险(我们马上就会看到)。但我们的主场景却需要这么做:

    private async void buttonOk_ClickAsync(object sender, EventArgs args)
    {
        textBox.Text = "Running..";
        var result = await _stockPrices.GetStockPricesForAsync("MSFT");
        textBox.Text = "Result is: " + result;
    }
    

    如果GetStockPricesForAsync随着一个错误而失败了会发生什么?这个async void方法的未处理异常会进入当前的同步上下文,触发与同步代码相同的行为(详见AsyncMethodBuilder.cs的 ThrowAsync方法)。在Windows Forms中一个事件处理器的未处理异常会触发Application.ThreadException事件,WPF则是Application.DispatcherUnhandledException事件等等。

    但是如果一个async void方法没有一个捕获的同步上下文怎么办?在这种情况下,一个未处理异常将导致应用程序崩溃,而无法从中恢复。它不会触发可恢复的TaskScheduler.UnobservedTaskException事件,而会触发不可恢复的AppDomain.UnhandledException事件并关闭应用程序。这是有意为之的,也是应该的。(译注:我试了一下,即使一个async void方法没有一个捕获的同步上下文,还是会触发Application.ThreadException,但是如果用Thread就会触发AppDomain.UnhandledException,我想这应该和底层的task scheduler的实现有关。)

    现在你应该了解另一个著名的最佳实践:仅对UI事件处理器使用async-void方法。

    不幸的是,不小心且未察觉地引入一个async void方法是相对比较容易的:

    public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError)
    {
        // Calls 'provider' N times and calls 'onError' in case of an error.
    }
     
    public async Task<string> AccidentalAsyncVoid(string fileName)
    {
        return await ActionWithRetry(
            provider:
            () =>
            {
                return File.ReadAllTextAsync(fileName);
            },
            // Can you spot the issue?
            onError:
            async e =>
            {
                await File.WriteAllTextAsync(errorLogFile, e.ToString());
            });
    }
    

    仅通过查看lambda表达式是很难判断这个函数到底是返回task还是void,即使有彻底的代码审核,这个错误也很容易潜入代码库。

    结论

    有一个用户场景——对现有的UI应用程序从同步到异步代码的简单迁移——在很多方面影响了C#中的异步编程:

    • 异步方法的延续会被调度进一个捕获的同步上下文,可能会造成死锁。
    • 为了避免死锁,类库中所有的异步代码都应该加上ConfigureAwait(false)
    • await task;只会抛出第一个错误,这使得对并行编程的异常处理更加复杂。
    • async void方法被用于处理UI事件,但它们可能会被不慎使用,造成在发生未处理异常时应用程序的崩溃。

    天下没有免费的午餐。在一种情况下的易用性可能会使其他情况复杂化。了解C#异步编程的历史可以使奇怪的行为变得不那么奇怪,并且减少异步代码中出现错误的可能性。

  • 相关阅读:
    hdu 1164 Eddy's research I
    链式线性表的实现
    StreamReader和StreamWrite和FileStream区别和用法
    后台返回json数据,前台显示代码
    数字分页类
    在线编辑器总结
    C# Web开发打开下载对话框代码
    Jquery validate插件使用方法详解
    正则表达式
    漂亮的表格样式
  • 原文地址:https://www.cnblogs.com/raytheweak/p/9383273.html
Copyright © 2020-2023  润新知