简而言之:.NET中的Async / Await最佳实践

预期课程的开始,“ C#Developer”准备了一些有趣的材料的翻译。




异步/等待-简介


Async / Await语言构造自C#版本5.0(2012)开始存在,并迅速成为现代.NET编程的支柱之一-任何自重的C#开发人员都应使用它来提高应用程序性能,整体响应能力和代码可读性。

Async / Await使引入异步代码的过程变得异常简单,并且不需要程序员了解其处理细节,但是我们当中有多少人真正知道它的工作原理,这种方法的优点和缺点是什么?有很多有用的信息,但是它们是零散的,所以我决定写这篇文章。

好吧,让我们深入研究该主题。

状态机(IAsyncStateMachine)


您需要了解的第一件事是,每当您使用Async / Await获得方法或函数时,编译器实际上会将您的方法转换为生成的类,该类实现IAsyncStateMachine接口。此类负责在异步操作的生命周期内维护方法的状态-它以字段的形式封装方法的所有变量,并将您的代码分成在状态机在状态之间转换时执行的部分,以便线程可以离开方法以及何时离开方法。将返回,状态不会改变。

例如,这是一个非常简单的类定义,其中包含两个异步方法:

using System.Threading.Tasks;

using System.Diagnostics;

namespace AsyncAwait
{
    public class AsyncAwait
    {

        public async Task AsyncAwaitExample()
        {
            int myVariable = 0;

            await DummyAsyncMethod();
            Debug.WriteLine("Continuation - After First Await");
            myVariable = 1;

            await DummyAsyncMethod();
            Debug.WriteLine("Continuation - After Second Await");
            myVariable = 2;

        }

        public async Task DummyAsyncMethod()
        {
            // 
        }

    }
}

具有两个异步方法的类

如果我们看一下在汇编过程中生成的代码,我们将看到以下内容:



注意,我们为我们生成了2个新的内部类,每个异步方法一个。这些类包含每个异步方法的状态机。

此外,在研究了的反编译代码之后<AsyncAwaitExample> d__0,我们将注意到我们的内部变量«myVariable»现在是一个类字段:



我们还可以看到内部用于维护state的其他类字段IAsyncStateMachine。状态机使用以下方法来检查状态MoveNext(),实际上是一个很大的开关。请注意,在每个异步调用(带有上一个继续标签)之后,该方法如何在不同的部分继续进行。



这意味着异步/等待优雅是有代价的。使用异步/等待实际上会增加一些复杂性(您可能没有意识到)。在服务器端逻辑中,这可能并不重要,但是特别是在编写考虑每个CPU和KB内存周期的移动应用程序时,请牢记这一点,因为开销会迅速增加。在本文的后面,我们将讨论仅在必要时使用Async / Await的最佳实践。

有关状态机的说明性说明,请在YouTube上观看此视频

何时使用异步/等待


通常在两种情况下,“异步/等待”是正确的解决方案。

  • 与I / O相关的工作:您的代码将期望某些东西,例如来自数据库的数据,读取文件,调用Web服务。在这种情况下,您应该使用异步/等待,而不是任务并行库。
  • 与CPU相关的工作:您的代码将执行复杂的计算。在这种情况下,您应该使用Async / Await,但是您需要使用Task.Run在另一个线程中开始工作。您也可以考虑使用Task Parallel Library



一路异步


当您开始使用异步方法时,您会很快注意到代码的异步性质开始在您的调用层次结构中上下扩散-这意味着您还必须使调用代码异步,依此类推。
您可能很想通过使用Task.Result或Task.Wait阻止代码,转换应用程序的一小部分并将其包装在同步API中来“阻止”此操作,以便使应用程序的其余部分不受更改的影响。不幸的是,这是造成难以跟踪的死锁的秘诀。

解决此问题的最佳方法是允许异步代码在代码库中自然增长。如果遵循此决定,您将看到异步代码到其入口点的扩展,通常是事件处理程序或控制器操作。屈服于异步!

有关 MSDN 文章的更多信息

如果该方法声明为异步,请确保有一个等待!


正如我们所讨论的,当编译器找到异步方法时,它将把该方法转换为状态机。如果您的代码中没有等待代码,则编译器将生成警告,但仍会创建状态机,从而为永远无法完成的操作增加不必要的开销。

避免异步无效


异步无效是应该真正避免的事情。将使用异步任务而不是异步void设为规则。

public async void AsyncVoidMethod()
{
    //!
}

public async Task AsyncTaskMethod()
{
    //!
}

异步void和异步Task方法的

原因有很多,其中包括:

  • 异步void方法中引发的异常无法在此方法之外捕获
从异步Task或异步Task <T >方法引发异常时,将捕获此异常并将其放置在Task对象中。使用异步void方法时,不存在Task对象,因此,将从异步void方法引发的任何异常直接在启动异步void方法时处于活动状态的SynchronizationContext中调用。

考虑下面的示例。捕获块将永远不会到达。

public async void AsyncVoidMethodThrowsException()
{
    throw new Exception("Hmmm, something went wrong!");
}

public void ThisWillNotCatchTheException()
{
    try
    {
        AsyncVoidMethodThrowsException();
    }
    catch(Exception ex)
    {
        //     
        Debug.WriteLine(ex.Message);
    }
}

异步void方法中引发的异常无法在此方法之外捕获,

与此代码进行比较,其中有异步Task而不是异步void。在这种情况下,渔获量将达到。

public async Task AsyncTaskMethodThrowsException()
{
    throw new Exception("Hmmm, something went wrong!");
}

public async Task ThisWillCatchTheException()
{
    try
    {
        await AsyncTaskMethodThrowsException();
    }
    catch (Exception ex)
    {
        //    
        Debug.WriteLine(ex.Message);
    }
}

捕获异常并将其放置在Task对象中。

  • 如果调用方不希望异步方法无效,则异步void方法可能会导致不良的副作用:如果您的异步方法未返回任何内容,请使用异步Task(对于Task,不带“ <T >”)作为返回类型。
  • 异步void方法非常难以测试:由于错误处理和布局上的差异,编写调用异步void方法的单元测试很困难。异步MSTest测试仅适用于返回Task或Task <T的异步方法>

异步事件处理程序是这种做法的一个例外。但是即使在这种情况下,也建议尽量减少处理程序本身中编写的代码-期望包含逻辑的异步Task方法。

有关 MSDN 文章的更多信息

优先选择return Task而不是return等待


如前所述,每次将方法声明为异步方法时,编译器都会创建一个状态机类,该类实际上包装了方法的逻辑。这会增加某些可能累积的开销,尤其是对于我们的资源限制更为严格的移动设备。

有时,方法不必是异步的,但它返回Task <T >并允许另一端相应地对其进行处理。如果你的代码的最后一句是AWAIT回报,你应该考虑重构它,这样的方法的返回类型是任务<ŧ>(而不是异步T)。因此,您避免生成状态机,这会使您的代码更加灵活。我们真正要等待的唯一情况是当我们使用异步Task进行某些操作时导致方法的继续。

public async Task<string> AsyncTask()

{
   //  !
   //...  -  
   //await -   ,  await  

   return await GetData();

}

public Task<string> JustTask()

{
   //!
   //...  -  
   // Task

   return GetData();

}

优先选择return Task而不是return await

注意,如果没有等待,而是返回Task <T >,则返回立即发生,因此,如果代码在try / catch块中,则不会捕获异常。同样,如果代码在using块内,它将立即删除该对象。请参阅下一个提示。

不要将return Task包装在try..catch {}内或使用{}块


当在try..catch块(永远不会捕获由异步方法抛出的异常)或using块中使用返回任务时,可能会导致未定义的行为,因为该任务将立即返回。

如果需要将异步代码包装在try..catch或using块中,请改用return await。

public Task<string> ReturnTaskExceptionNotCaught()

{
   try
   {
       // ...

       return GetData();

   }
   catch (Exception ex)

   {
       //     

       Debug.WriteLine(ex.Message);
       throw;
   }

}

public Task<string> ReturnTaskUsingProblem()

{
   using (var resource = GetResource())
   {

       // ...  ,     , ,    

       return GetData(resource);
   }
}

不要将return任务包装在块try..catch{}或中using{}

有关堆栈溢出的线程中的更多信息

避免使用.Wait().Result-改为使用GetAwaiter().GetResult()


如果您需要阻止等待异步任务完成,请使用GetAwaiter().GetResult(). WaitResult抛出中的任何异常AggregateException,这会使错误处理变得复杂。好处GetAwaiter().GetResult()是它返回了通常的异常AggregateException

public void GetAwaiterGetResultExample()

{
   // ,    ,     AggregateException  

   string data = GetData().Result;

   // ,   ,      

   data = GetData().GetAwaiter().GetResult();
}

如果您需要阻止等待异步任务完成,请使用GetAwaiter().GetResult().

链接上更多信息

如果该方法是异步的,请在其名称后添加Async后缀


这是.NET中使用的约定,可以更轻松地区分同步方法和异步方法(事件处理程序或Web控制器方法除外,但您的代码仍不应显式调用它们)。

异步库方法应使用Task.ConfigureAwait(false)来提高性能



.NET Framework具有“同步上下文”的概念,这是“回到以前的状态”的一种方式。每当任务正在等待时,它都会在等待之前捕获当前的同步上下文。

Task完成后.Post(),将调用同步上下文方法,该方法从以前的位置恢复工作。这对于返回用户界面线程或返回相同的ASP.NET上下文等很有用。
在编写库代码时,您几乎不需要返回到之前的上下文。使用Task.ConfigureAwait(false)时,代码不再尝试从之前的位置恢复,而是,如果可能的话,代码将在完成任务的线程中退出,从而避免了上下文切换。这样可以稍微提高性能,并有助于避免死锁。

public async Task ConfigureAwaitExample()

{
   //   ConfigureAwait(false)   .

   var data = await GetData().ConfigureAwait(false);
}

通常,对服务器进程和库代码使用ConfigureAwait(false)。
当多次调用该库方法时,这对于提高响应速度尤为重要。

通常,通常对服务器进程使用ConfigureAwait(false)。我们不在乎使用哪个线程继续执行,这与需要返回用户界面线程的应用程序不同。

现在...在ASP.NET Core中,Microsoft取消了SynchronizationContext,因此从理论上讲您不需要它。但是,如果编写的库代码有可能在其他应用程序(例如UI App,Legacy ASP.NET,Xamarin Forms)中重用,则这仍然是最佳做法

有关此概念的详细说明,请观看此视频

异步任务进度报告


异步方法的一个相当普遍的用例是在后台工作,释放用户界面线程以执行其他任务,并保持响应能力。在这种情况下,您可能需要将进度报告回用户界面,以便用户可以监视流程的进度并与操作进行交互。

为了解决此常见问题,.NET提供了IProgress <T 接口>,该接口提供了Report <T 方法>,该方法由异步任务调用,以将进度报告给调用方。该接口被接受为异步方法的参数-调用方必须提供一个实现此接口的对象。

.NET提供了Progress <T (实际上>是IProgress <T 的默认实现)>,因为它可以处理与保存和还原同步上下文相关的所有低级逻辑,因此,实际上建议使用它。进度<T >还提供了动作<T 事件和回调>-在任务报告进度时都将调用它们。

IProgress <T >和Progress <T一起>提供了一种将进度信息从后台任务传输到用户界面线程的简便方法。

请注意,<T>它可以是一个简单的值(例如int),也可以是提供上下文进度信息(例如完成百分比,当前操作的字符串描述,ETA等)的对象。
考虑一下您报告进度的频率。根据您正在执行的操作,您可能会发现代码报告每秒每秒执行几次进度,这可能导致用户界面的响应速度变慢。在这种情况下,建议以较大的时间间隔报告进度。本文在Microsoft .NET官方博客上的

更多信息

取消异步任务


后台任务的另一个常见用例是取消执行的能力。.NET提供了CancellationToken类。异步方法接收CancellationToken对象,然后由主叫方代码和异步方法共享该对象,从而提供了用于信号消除的机制。

在最常见的情况下,取消发生如下:

  1. 调用方创建一个CancellationTokenSource对象。
  2. 调用方调用已取消的异步API,并从CancellationTokenSource(CancellationTokenSource.Token)传递CancellationToken。
  3. 调用方使用CancellationTokenSource(CancellationTokenSource.Cancel())对象请求取消。
  4. 该任务通常使用CancellationToken.ThrowIfCancellationRequested方法确认取消并自行取消。

请注意,为使此机制正常工作,您需要编写代码以定期检查请求的取消操作(例如,每次代码迭代或逻辑中的自然断点)。理想情况下,在取消请求之后,应尽快取消异步任务。

您应该考虑对所有可能花费很长时间才能完成的方法使用撤消。本文在Microsoft .NET官方博客上的

更多信息

进度和取消报告-示例


using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
namespace TestAsyncAwait
{
   public partial class AsyncProgressCancelExampleForm : Form
   {
       public AsyncProgressCancelExampleForm()
       {
           InitializeComponent();
       }

       CancellationTokenSource _cts = new CancellationTokenSource();

       private async void btnRunAsync_Click(object sender, EventArgs e)

       {

           //   .

            <int>   ,          ,   ,    , ETA  . .

           var progressIndicator = new Progress<int>(ReportProgress);

           try

           {
               //   ,         

               await AsyncMethod(progressIndicator, _cts.Token);

           }

           catch (OperationCanceledException ex)

           {
               // 

               lblProgress.Text = "Cancelled";
           }
       }

       private void btnCancel_Click(object sender, EventArgs e)

       {
          // 
           _cts.Cancel();

       }

       private void ReportProgress(int value)

       {
           //    

           lblProgress.Text = value.ToString();

       }

       private async Task AsyncMethod(IProgress<int> progress, CancellationToken ct)

       {

           for (int i = 0; i < 100; i++)

           {
              //   ,     

               await Task.Delay(1000);

               //   

               if (ct != null)

               {

                   ct.ThrowIfCancellationRequested();

               }

               //   

               if (progress != null)

               {

                   progress.Report(i);
               }
           }
       }
   }
}

等待一段时间


如果您需要等待一段时间(例如,再次尝试检查资源的可用性),请确保使用Task.Delay-在这种情况下切勿使用Thread.Sleep。

等待几个异步任务完成


使用Task.WaitAny等待任何任务的完成。使用Task.WaitAll等待所有任务完成。

我是否必须急于切换到C#7或8?注册免费的网络研讨会来讨论此主题。

All Articles